Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
f2ced44
WIP: tokens routed through backend
d10r Dec 9, 2025
7945dc7
add to host and governance contract
d10r Dec 10, 2025
5c28327
added tests for measuring gas cost
d10r Dec 10, 2025
1d3e9be
simplication
d10r Dec 11, 2025
d19348c
added method for withdrawing surplus
d10r Dec 11, 2025
04a4e1c
natspec
d10r Dec 11, 2025
19624e6
made it work for SETH
d10r Dec 12, 2025
7db2940
use helper lib to reduce boilerplate code around delegatecall
d10r Dec 12, 2025
0e4c436
remove duplicate submodule entry
d10r Dec 12, 2025
8b9979c
added missing storage slot test, interface doc, updated solhint
d10r Dec 18, 2025
806dba1
added missing storage slot test, interface doc, updated solhint
d10r Dec 18, 2025
a75947d
advance hardhat and truffle config to cancun
d10r Dec 19, 2025
3621b96
immutable surplus receiver
d10r Dec 19, 2025
f217e28
added test for rounding error
d10r Dec 19, 2025
82bc629
consider non-deposited underlying when calculating surplus
d10r Dec 19, 2025
1987a65
added spark yield backend
d10r Dec 19, 2025
2002d94
added missing newline
d10r Jan 7, 2026
955a94f
removed unnecessary methods
d10r Jan 7, 2026
47e8081
fix indentation
d10r Jan 7, 2026
882ec21
add delegateCallChecked to CallUtils
d10r Jan 7, 2026
5a615d8
updated CHANGELOG
d10r Jan 7, 2026
8ab52e1
renamed SparkYieldBackend to ERC4626YieldBackend
d10r Jan 7, 2026
fd0b070
split out AaveETHYieldBackend
d10r Jan 7, 2026
5f695a2
more comments
d10r Jan 8, 2026
4bbbd2e
added test for grifting case
d10r Jan 8, 2026
061a491
unit tests
d10r Jan 8, 2026
3500962
remove console logs
d10r Jan 8, 2026
fd3aa4f
add re-done integration test for AaveYieldBackend
d10r Jan 12, 2026
e459e00
add randomized sequence
d10r Jan 12, 2026
121df80
remove prev test code
d10r Jan 12, 2026
17da953
Merge branch 'dev' into 2025-12-yield
d10r Jan 12, 2026
ce01fa7
appease linter
d10r Jan 12, 2026
61af65b
adjust solhint rules for all packages
d10r Jan 12, 2026
4272afd
change hot-fuzz evm target
d10r Jan 12, 2026
a9bc707
fixed solc requirement
d10r Jan 12, 2026
d77a933
use hardhat node for deployment test
d10r Jan 12, 2026
f2883eb
update dep solidity-coverage
d10r Jan 12, 2026
886554a
update evm version in automation packages foundry config
d10r Jan 12, 2026
1e49964
solve open TODOs
d10r Jan 12, 2026
d8ca70d
added integration test for AaveETHBackend and related logic fix
d10r Jan 12, 2026
5fdb16e
Merge branch 'dev' into 2025-12-yield
d10r Jan 12, 2026
c044943
added tests for delegateCallChecked
d10r Jan 13, 2026
d69e4b0
Update README.md codecov link
hellwolf Jan 13, 2026
1b04366
removed all uses of ganache
d10r Jan 13, 2026
fb61d8b
Merge branch 'dev' into 2025-12-yield
d10r Jan 13, 2026
92112ab
remove TODOs
d10r Jan 13, 2026
ac0b856
corrected address of native token wrapper for Polygon
d10r Jan 19, 2026
2a9428b
propagate error with delegateCallChecked
d10r Jan 19, 2026
8f8ff4e
added integration test for ERC4626
d10r Jan 21, 2026
4d24a83
added access control tests for new SuperToken methods
d10r Jan 23, 2026
101b154
added negative test for withdrawSurplus
d10r Jan 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,9 @@
path = lib/openzeppelin-contracts
url = https://github.com/OpenZeppelin/openzeppelin-contracts.git
branch = release-v5.4
[submodule "aave-v3-origin"]
path = aave-v3-origin
url = https://github.com/aave-dao/aave-v3-origin
[submodule "lib/aave-v3"]
path = lib/aave-v3
url = https://github.com/aave-dao/aave-v3-origin
1 change: 1 addition & 0 deletions lib/aave-v3
Submodule aave-v3 added at 1ce897
Original file line number Diff line number Diff line change
Expand Up @@ -63,13 +63,16 @@ interface ISuperToken is ISuperfluidToken, IERC20Metadata, IERC777, IERC20Permit
*/
function changeAdmin(address newAdmin) external;


event AdminChanged(address indexed oldAdmin, address indexed newAdmin);

/**
* @dev Returns the admin address for the SuperToken
*/
function getAdmin() external view returns (address admin);

function getYieldBackend() external view returns (address yieldBackend);

/**************************************************************************
* Immutable variables
*************************************************************************/
Expand Down Expand Up @@ -614,5 +617,4 @@ interface ISuperToken is ISuperfluidToken, IERC20Metadata, IERC777, IERC20Permit

/// @dev The msg.sender must be the contract itself
//modifier onlySelf() virtual

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.23;

/**
* A yield backend acts as interface between an ERC20 wrapper SuperToken and a yield generating protocol.
* The underlying token can be deposited on upgrade and withdrawn on downgrade.
*
* It is possible to transition from no/one yield backend to another/no yield backend.
* one -> another could be seen as a composition of one -> no -> another
*
* one -> no means withdraw not in the context of a downgrade.
*
* Contracts implementing this act as a kind of hot-pluggable library,
* using delegatecall to execute its logic on the SuperToken contract.
* This means that underlying tokens are transferred directly between the SuperToken contract and the yield protocol,
* as are yield protocol tokens representing positions in that protocol.
* If an implementation requires to hold state, it shall do so using a namespaced storage layout (EIP-7201).
*/
interface IYieldBackend {
/// Invoked by `SuperToken` as delegatecall.
/// Sets up the SuperToken as needed, e.g. by giving required approvals.
function enable() external;

/// Invoked by `SuperToken` as delegatecall.
/// Restores the prior state, e.g. by revoking given approvals
function disable() external;

/// Invoked by `SuperToken` as delegatecall.
/// Deposits the given amount of the underlying asset into the yield backend.
function deposit(uint256 amount) external;
/// Invoked by `SuperToken` as delegatecall.
/// Deposits the maximum amount of the underlying asset into the yield backend.
/// Maximum is defined by the underlying asset balance of the SuperToken and the yield backend capacity.
function depositMax() external;

/// Invoked by `SuperToken` as delegatecall.
/// Withdraws the given amount of the underlying asset from the yield backend.
function withdraw(uint256 amount) external;

/// Invoked by `SuperToken` as delegatecall.
/// Withdraws the maximum amount of the underlying asset from the yield backend.
/// Maximum is defined by how much can be withdrawn from the yield backend at that point in time.
function withdrawMax() external;

/// Invoked by `SuperToken` as delegatecall.
/// tranfers the deposited asset exceeding totalSupply of the SuperToken to the preset receiver account
function withdrawSurplus(uint256 totalSupply) external;
}
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,8 @@ contract SuperTokenStorageLayoutTester is SuperToken {
require (slot == 18 && offset == 0, "_operators changed location");
// uses 4 slots

assembly { slot:= _reserve23.slot offset := _reserve23.offset }
require (slot == 23 && offset == 0, "_reserve23 changed location");
assembly { slot:= _reserve24.slot offset := _reserve24.offset }
require (slot == 24 && offset == 0, "_reserve24 changed location");

assembly { slot:= _reserve31.slot offset := _reserve31.offset }
require (slot == 31 && offset == 0, "_reserve31 changed location");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.23;

import { IYieldBackend } from "../interfaces/superfluid/IYieldBackend.sol";
import { IERC20, ISuperToken } from "../interfaces/superfluid/ISuperfluid.sol";
import { IPool } from "aave-v3/interfaces/IPool.sol";


/**
* Aave supports a simple deposit/withdraw workflow nicely matching the IYieldBackend interface.
* Deposits are represented by transferrable aTokens.
*
* This contract is conceptually a hot-pluggable library.
* All methods are supposed to be invoked as delegatecall.
*/
contract AaveYieldBackend is IYieldBackend {
IERC20 public immutable ASSET_TOKEN;
IPool public immutable AAVE_POOL;
IERC20 public immutable A_TOKEN;
// TODO: make an immutable
address constant SURPLUS_RECEIVER = 0xac808840f02c47C05507f48165d2222FF28EF4e1; // dao.superfluid.eth

// THIS CONTRACT CANNOT HAVE STATE VARIABLES!
// IF STATE IS NEEDED, USE NAMESPACED STORAGE LAYOUT (EIP-7201)

/**
* @param assetToken the asset (Aave terminology) supplied to Aave for yield. Typically, this will be
* the underlyingToken of a SuperToken.
* @param aavePool the Aave pool
*/
constructor(IERC20 assetToken, IPool aavePool) {
// TODO: any checks to be done?
ASSET_TOKEN = assetToken;
AAVE_POOL = IPool(aavePool);
A_TOKEN = IERC20(aavePool.getReserveAToken(address(assetToken)));
}

function enable() external {
// approve Aave pool to fetch asset
ASSET_TOKEN.approve(address(AAVE_POOL), type(uint256).max);
}

function disable() external {
// Revoke approval
ASSET_TOKEN.approve(address(AAVE_POOL), 0);
}

function deposit(uint256 amount) external {
// TODO: can this constraint break anything?
require(amount > 0, "amount must be greater than 0");
// Deposit asset and get back aTokens
AAVE_POOL.supply(address(ASSET_TOKEN), amount, address(this), 0);
}

function depositMax() external {
uint256 amount = ASSET_TOKEN.balanceOf(address(this));
if (amount > 0) {
AAVE_POOL.supply(address(ASSET_TOKEN), amount, address(this), 0);
}
}

function withdraw(uint256 amount) external {
// withdraw amount asset by redeeming the corresponding aTokens amount
AAVE_POOL.withdraw(address(ASSET_TOKEN), amount, address(this));
}

function withdrawMax() external {
// We can delegate the max calculation to the Aave pool by setting amount to type(uint256).max
AAVE_POOL.withdraw(address(ASSET_TOKEN), type(uint256).max, address(this));
}

function withdrawSurplus(uint256 totalSupply) external {
// totalSupply is always 18 decimals while assetToken and aToken may not
(uint256 normalizedTotalSupply,) = ISuperToken(address(this)).toUnderlyingAmount(totalSupply);
// decrement by 1 in order to offset Aave's rounding up
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

find a reference to make it more precise.

uint256 surplusAmount = A_TOKEN.balanceOf(address(this)) - normalizedTotalSupply - 1;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Take into account the stray balances of the underlying, forwhatever the reason it came about.

AAVE_POOL.withdraw(address(ASSET_TOKEN), surplusAmount, SURPLUS_RECEIVER);
}
}
66 changes: 63 additions & 3 deletions packages/ethereum-contracts/contracts/superfluid/SuperToken.sol
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
IERC20,
IPoolAdminNFT
} from "../interfaces/superfluid/ISuperfluid.sol";
import { IYieldBackend } from "../interfaces/superfluid/IYieldBackend.sol";
import { SuperfluidToken } from "./SuperfluidToken.sol";
import { ERC777Helper } from "../libs/ERC777Helper.sol";
import { SafeERC20 } from "@openzeppelin-v5/contracts/token/ERC20/utils/SafeERC20.sol";
Expand Down Expand Up @@ -82,15 +83,17 @@ contract SuperToken is
/// @dev ERC20 Nonces for EIP-2612 (permit)
mapping(address account => uint256) internal _nonces;

// TODO: use a randomly located storage slot instead?
IYieldBackend internal _yieldBackend;

// NOTE: for future compatibility, these are reserved solidity slots
// The sub-class of SuperToken solidity slot will start after _reserve22
// The sub-class of SuperToken solidity slot will start after _reserve24

// NOTE: Whenever modifying the storage layout here it is important to update the validateStorageLayout
// function in its respective mock contract to ensure that it doesn't break anything or lead to unexpected
// behaviors/layout when upgrading

uint256 internal _reserve23;
uint256 private _reserve24;
uint256 internal _reserve24;
uint256 private _reserve25;
uint256 private _reserve26;
uint256 private _reserve27;
Expand Down Expand Up @@ -195,6 +198,47 @@ contract SuperToken is
}
}

function enableYieldBackend(IYieldBackend newYieldBackend) external onlyAdmin {
require(address(_yieldBackend) == address(0), "yield backend already set");
_yieldBackend = newYieldBackend;
(bool success, ) = address(_yieldBackend).delegatecall(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

consider an internal library boilerplate to hide the cruft away from the main super token logic.

abi.encodeCall(IYieldBackend.enable, ())
);
require(success, "delegatecall failed");
(success, ) = address(_yieldBackend).delegatecall(
abi.encodeCall(IYieldBackend.depositMax, ())
);
require(success, "delegatecall failed");
// TODO: emit event
}

// withdraws everything and removes allowances
function disableYieldBackend() external onlyAdmin {
require(address(_yieldBackend) != address(0), "yield backend not set");
(bool success, ) = address(_yieldBackend).delegatecall(
abi.encodeCall(IYieldBackend.withdrawMax, ())
);
require(success, "delegatecall failed");
(success, ) = address(_yieldBackend).delegatecall(
abi.encodeCall(IYieldBackend.disable, ())
);
// TODO: should this be allowed to fail?
require(success, "delegatecall failed");
_yieldBackend = IYieldBackend(address(0));
// TODO: emit event
}

function getYieldBackend() external view returns (address) {
return address(_yieldBackend);
}

function withdrawSurplusFromYieldBackend() external onlyAdmin {
require(address(_yieldBackend) != address(0), "yield backend not set");
(bool success, ) = address(_yieldBackend).delegatecall(
abi.encodeCall(IYieldBackend.withdrawSurplus, (_totalSupply))
);
require(success, "delegatecall failed");
}

/**************************************************************************
* ERC20 Token Info
Expand Down Expand Up @@ -839,6 +883,14 @@ contract SuperToken is
uint256 actualUpgradedAmount = amountAfter - amountBefore;
if (underlyingAmount != actualUpgradedAmount) revert SUPER_TOKEN_INFLATIONARY_DEFLATIONARY_NOT_SUPPORTED();

if (address(_yieldBackend) != address(0)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

move to _burn and _mint, so that selfBurn/selfMint could work too (for SETH).

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 reasons against that:

  • would need to add an underlyingAmount argument (or duplicate the calculation)
  • in the selfBurn path we want to set a flag which avoids the withdrawn ETH to be wrapped to ETHx

// TODO: shall we deposit all, or just the upgradeAmount?
(bool success, ) = address(_yieldBackend).delegatecall(
abi.encodeCall(IYieldBackend.deposit, (actualUpgradedAmount))
);
require(success, "delegatecall failed");
}

_mint(operator, to, adjustedAmount,
// if `userData.length` is greater than 0, we set invokeHook and requireReceptionAck true
userData.length != 0, userData.length != 0, userData, operatorData);
Expand All @@ -861,6 +913,14 @@ contract SuperToken is
// _burn will check the (actual) amount availability again
_burn(operator, account, adjustedAmount, userData.length != 0, userData, operatorData);

if (address(_yieldBackend) != address(0)) {
// TODO: we may want to skip if enough underlying already in the contract
(bool success, ) = address(_yieldBackend).delegatecall(
abi.encodeCall(IYieldBackend.withdraw, (underlyingAmount))
);
require(success, "delegatecall failed");
}

uint256 amountBefore = _underlyingToken.balanceOf(address(this));
_underlyingToken.safeTransfer(to, underlyingAmount);
uint256 amountAfter = _underlyingToken.balanceOf(address(this));
Expand Down
1 change: 1 addition & 0 deletions packages/ethereum-contracts/foundry.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ remappings = [
'@superfluid-finance/ethereum-contracts/contracts/=packages/ethereum-contracts/contracts/',
'@superfluid-finance/solidity-semantic-money/src/=packages/solidity-semantic-money/src/',
'@openzeppelin-v5/=lib/openzeppelin-contracts/',
'aave-v3=lib/aave-v3/src/contracts/',
'ds-test/=lib/forge-std/lib/ds-test/src/',
'forge-std/=lib/forge-std/src/']
out = 'packages/ethereum-contracts/build/foundry/default'
Expand Down
Loading
Loading