Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,6 @@
path = lib/openzeppelin-contracts
url = https://github.com/OpenZeppelin/openzeppelin-contracts.git
branch = release-v5.4
[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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@
},
"scripts": {
"prepare": "husky && npm run git-submodule:init",
"postinstall": "ln -fs ../lib/openzeppelin-contracts node_modules/@openzeppelin-v5",
"postinstall": "ln -fs ../lib/openzeppelin-contracts node_modules/@openzeppelin-v5 && ln -fs ../lib/aave-v3 node_modules/aave-v3",
"lint": "run-s -l lint:*",
"lint:syncpack": "syncpack lint",
"lint:shellcheck": "tasks/shellcheck-all-tasks.sh",
Expand Down
4 changes: 4 additions & 0 deletions packages/ethereum-contracts/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm

## [UNRELEASED]

### Added

- `SuperToken`: the contract admin can enable/disable a _Yield Backend_ in order to generate a yield on the underlying asset.

### Changed

- EVM target changed from _shanghai_ to _cancun_.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,13 +63,43 @@ 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
* The admin account has the exclusive privilege of
* - updating the contract (change implementation)
* - enabling/disabling a yield backend
* - setting another admin
* If no admin is set (zero address), this privileges are delegated to the host contract.
*/
function getAdmin() external view returns (address admin);

/**
* @dev Returns the address of the yield backend contract (see `IYieldBackend`).
* The yield backend contract is responsible for managing the yield of the SuperToken.
*/
function getYieldBackend() external view returns (address yieldBackend);

/**
* @dev Yield backend enabled event
* @param yieldBackend The address of the yield backend that was enabled
* @param depositAmount The amount deposited to the yield backend
*/
event YieldBackendEnabled(
address indexed yieldBackend,
uint256 depositAmount
);

/**
* @dev Yield backend disabled event
* @param yieldBackend The address of the yield backend that was disabled
*/
event YieldBackendDisabled(
address indexed yieldBackend
);

/**************************************************************************
* Immutable variables
*************************************************************************/
Expand Down Expand Up @@ -614,5 +644,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,43 @@
// SPDX-License-Identifier: MIT
pragma solidity >= 0.8.11;

/**
* 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.
/// 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 withdrawable amount of the underlying asset from the yield backend.
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;
}
15 changes: 15 additions & 0 deletions packages/ethereum-contracts/contracts/libs/CallUtils.sol
Original file line number Diff line number Diff line change
@@ -1,6 +1,21 @@
// SPDX-License-Identifier: AGPLv3
pragma solidity ^0.8.23;


/**
* @dev Helper method to delegatecall, reverts if not successful.
* @param target The address to delegatecall to
* @param callData The data to delegatecall with
* Does not return anything!
*/
function delegateCallChecked(address target, bytes memory callData) {
// solhint-disable-next-line avoid-low-level-calls
(bool success, bytes memory returnedData) = target.delegatecall(callData);
if (!success) {
CallUtils.revertFromReturnedData(returnedData);
}
}

/**
* @title Call utilities library that is absent from the OpenZeppelin
* @author Superfluid
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,14 @@ 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:= _nonces.slot offset := _nonces.offset }
require (slot == 22 && offset == 0, "_nonces changed location");

assembly { slot:= _yieldBackend.slot offset := _yieldBackend.offset }
require (slot == 23 && offset == 0, "_yieldBackend 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,104 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.23;

import { AaveYieldBackend } from "./AaveYieldBackend.sol";
import { IERC20 } from "../interfaces/superfluid/ISuperfluid.sol";
import { IPool } from "aave-v3/src/contracts/interfaces/IPool.sol";
import { IWETH } from "aave-v3/src/contracts/helpers/interfaces/IWETH.sol";

/**
* @title a SuperToken yield backend for the Aave protocol for ETH/native tokens.
* This contract extends AaveYieldBackend to support native ETH by wrapping it to WETH.
* WETH addresses are hardcoded by chain id.
*
* NOTE: "WETH" is to be interpreted in a technical sense: the native token wrapper.
* On chains with ETH not being the native token, the ERC20 token with symbol "WETH" may be an ordinary ERC20
* while the ERC20 wrapper of the native token may have a different symbol. We mean the latter!
*
* NOTE: Surplus WETH will NOT be unwrapped by `withdrawSurplus` (which is inherited from the Base contract)
* before transferring it to the configured SURPLUS_RECEIVER.
*/
contract AaveETHYieldBackend is AaveYieldBackend {
AaveETHYieldBackend internal immutable _SELF;

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

/**
* @param aavePool the Aave pool
* @param surplusReceiver the address to receive the surplus asset when withdrawing the surplus
*/
constructor(IPool aavePool, address surplusReceiver)
AaveYieldBackend(IERC20(getWETHAddress()), aavePool, surplusReceiver)
{
_SELF = this;
}

/// get the canonical native token ERC20 wrapper contract address based on the chain id and Aave deployment.
/// Implemented for chains with official deployments of Aave and Superfluid.
function getWETHAddress() internal view returns (address) {
if (block.chainid == 1) { // Ethereum
return 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2;
}
if (block.chainid == 10 || block.chainid == 8453) {
return 0x4200000000000000000000000000000000000006;
}
if (block.chainid == 137) { // Polygon
// Note this token has the symbol WPOL, wrapping the native token POL
return 0x0d500B1d8E8eF31E21C99d1Db9A6444d3ADf1270;
}
if (block.chainid == 42161) { // Arbitrum
return 0x82aF49447D8a07e3bd95BD0d56f35241523fBab1;
}
if (block.chainid == 100) { // Gnosis Chain
// Note this token has the symbol WXDAI, wrapping the native token xDAI
return 0xe91D153E0b41518A2Ce8Dd3D7944Fa863463a97d;
}
if (block.chainid == 43114) { // Avalanche C-Chain
// Note this token has the symbol WAVAX, wrapping the native token AVAX
return 0xB31f66AA3C1e785363F0875A1B74E27b85FD66c7;
}
if (block.chainid == 56) { // BNB
// Note this token has the symbol WBNB, wrapping the native token BNB
return 0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c;
}
if (block.chainid == 534352) { // Scroll
return 0x5300000000000000000000000000000000000004;
}
// Celo: WCELO does not implement IWETH

revert("chain not supported");
}

function deposit(uint256 amount) public override {
// wrap ETH to WETH
IWETH(address(ASSET_TOKEN)).deposit{ value: amount }();
// Deposit asset and get back aTokens
super.deposit(amount);
}

function withdraw(uint256 amount) public override {
// withdraw WETH by redeeming the corresponding aTokens amount.
// the receiver is set to the address of the implementation contract in order to not trigger the
// fallback function of the SuperToken contract.
uint256 withdrawnAmount = AAVE_POOL.withdraw(address(ASSET_TOKEN), amount, address(_SELF));
// unwrap to ETH and transfer it to the calling SuperToken contract
_SELF.unwrapWETHAndForwardETH(withdrawnAmount, address(this));
}

// ============ functions operating on this contract itself (NOT in delegatecall context) ============

// allow unwrapping from WETH to this contract
receive() external payable { }

// To be invoked by `withdraw` which is executed via delegatecall in a SuperToken context.
// WETH deposited or withdrawn by the SuperToken never stays in this contract beyond the lifetime of the tx.
// Thus it is not necessary to restrict msg.sender.
// We accept that an alien caller may withdraw WETH deposited to this contract (for whatever reason).
function unwrapWETHAndForwardETH(uint256 amount, address recipient) external {
IWETH(address(ASSET_TOKEN)).withdraw(amount);
(bool success,) = recipient.call{ value: amount }("");
require(success, "call failed");
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// 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/src/contracts/interfaces/IPool.sol";

/**
* @title a SuperToken yield backend for the Aave protocol.
* 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.
*
* In order to learn about the limitations and constraints of this implementation, see
* https://github.com/superfluid-org/protocol-monorepo/wiki/Yield-Backend
*/
contract AaveYieldBackend is IYieldBackend {
IERC20 public immutable ASSET_TOKEN;
IPool public immutable AAVE_POOL;
IERC20 public immutable A_TOKEN;
address public immutable SURPLUS_RECEIVER;

// 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. Must be a valid ERC20 token address.
* @param aavePool the Aave pool
* @param surplusReceiver the address to receive the surplus asset when withdrawing the surplus
*/
constructor(IERC20 assetToken, IPool aavePool, address surplusReceiver) {
require(address(assetToken) != address(0), "assetToken cannot be address(0)");
ASSET_TOKEN = assetToken;
AAVE_POOL = IPool(aavePool);
SURPLUS_RECEIVER = surplusReceiver;
A_TOKEN = IERC20(aavePool.getReserveAToken(address(ASSET_TOKEN)));
}

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) public virtual {
// 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 withdraw(uint256 amount) public virtual {
// withdraw amount asset by redeeming the corresponding aTokens amount
AAVE_POOL.withdraw(address(ASSET_TOKEN), amount, address(this));
}

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

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 100 in order to give ample of margin for offsetting Aave's potential rounding error
// If there's no surplus, this will simply revert due to arithmetic underflow.
uint256 surplusAmount = A_TOKEN.balanceOf(address(this)) + ASSET_TOKEN.balanceOf(address(this))
- normalizedTotalSupply - 100;
AAVE_POOL.withdraw(address(ASSET_TOKEN), surplusAmount, SURPLUS_RECEIVER);
}
}
Loading
Loading