Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 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
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
"nyc": "^17.0.0",
"prettier": "^3.3.3",
"prettier-eslint": "^16.3.0",
"solhint": "^5.0.3",
"solhint": "^6.0.2",
"syncpack": "^13.0.0",
"truffle": "^5.11.5",
"ts-node": "^10.9.2",
Expand All @@ -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
12 changes: 11 additions & 1 deletion packages/ethereum-contracts/.solhint.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,16 @@
"constructor-syntax": "error",
"func-visibility": ["error", { "ignoreConstructors": true }],
"quotes": ["error", "double"],
"max-line-length": ["error", 120]
"max-line-length": ["error", 120],
"use-natspec": "off",
"import-path-check": "off",
"gas-indexed-events": "off",
"gas-struct-packing": "off",
"gas-small-strings": "off",
"gas-increment-by-one": "off",
"gas-strict-inequalities": "off",
"gas-calldata-parameters": "off",
"function-max-lines": "off",
"contract-name-capwords": "off"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import {
ISuperApp,
SuperAppDefinitions
} from "../interfaces/superfluid/ISuperfluid.sol";
import { ISuperfluidToken } from "../interfaces/superfluid/ISuperfluidToken.sol";

import { SafeCast } from "@openzeppelin-v5/contracts/utils/math/SafeCast.sol";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,13 +63,25 @@ 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);

/**************************************************************************
* Immutable variables
*************************************************************************/
Expand Down Expand Up @@ -614,5 +626,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,54 @@
// 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;

/// Invoked by `SuperToken` as delegatecall.
/// Returns the amount of the underlying asset currently managed by the yield backend.
/// This shall reflect the amount which could currently be withdrawn from the yield backend,
/// including the generated yield.
function getManagedAmount() external view returns (uint256);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// SPDX-License-Identifier: AGPLv3
pragma solidity ^0.8.23;

import { IYieldBackend } from "../interfaces/superfluid/IYieldBackend.sol";


/**
* @dev Helper to delegatecall yield backend methods.
* Reverts if the call fails.
* Does NOT return anything!
*/
library YieldBackendHelperLib {
function dCall(IYieldBackend yieldBackend, bytes memory callData) internal {
// solhint-disable-next-line avoid-low-level-calls
(bool success,) = address(yieldBackend).delegatecall(callData);
require(success, "yield backend delegatecall failed");
}
}
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,124 @@
// 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";
import { IWETH } from "aave-v3/src/contracts/helpers/interfaces/IWETH.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;
bool public immutable USING_WETH;
address public immutable SURPLUS_RECEIVER;
AaveYieldBackend internal immutable _SELF;

// 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
* @param surplusReceiver the address to receive the surplus asset when withdrawing the surplus
*/
constructor(IERC20 assetToken, IPool aavePool, address surplusReceiver) {
// TODO: any checks to be done?
if (address(assetToken) == address(0)) {
// native token, need to wrap to WETH
USING_WETH = true;
// This implementation currently only supports Base
if (block.chainid == 10 || block.chainid == 8453) {
// base, optimism
ASSET_TOKEN = IERC20(0x4200000000000000000000000000000000000006);
} else {
revert("not supported");
}
} else {
ASSET_TOKEN = assetToken;
}
AAVE_POOL = IPool(aavePool);
SURPLUS_RECEIVER = surplusReceiver;
A_TOKEN = IERC20(aavePool.getReserveAToken(address(ASSET_TOKEN)));

_SELF = this;
}

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 {
// TODO: can this constraint break anything?
require(amount > 0, "amount must be greater than 0");
if (USING_WETH) {
// wrap ETH to WETH
IWETH(address(ASSET_TOKEN)).deposit{ value: amount }();
}
// Deposit asset and get back aTokens
AAVE_POOL.supply(address(ASSET_TOKEN), amount, address(this), 0);
}

function depositMax() external {
uint256 amount = USING_WETH ? address(this).balance : ASSET_TOKEN.balanceOf(address(this));
if (amount > 0) {
deposit(amount);
}
}

function withdraw(uint256 amount) external {
// withdraw amount asset by redeeming the corresponding aTokens amount
if (USING_WETH) {
AAVE_POOL.withdraw(address(ASSET_TOKEN), amount, address(_SELF));
_SELF.unwrapAndForwardWETH(amount);
} else {
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 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);
}

function getManagedAmount() external view returns (uint256) {
return A_TOKEN.balanceOf(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` executed via delegatecall in a SuperToken context.
// Since WETH never stays in this contract, no validation of msg.sender is necessary.
function unwrapAndForwardWETH(uint256 amount) external {
IWETH(address(ASSET_TOKEN)).withdraw(amount);
(bool success,) = address(msg.sender).call{ value: amount }("");
require(success, "call failed");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// 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 { IERC4626 } from "@openzeppelin-v5/contracts/interfaces/IERC4626.sol";

// Note: Spark Vaults on Base/Mainnet are ERC4626 compliant.

contract SparkYieldBackend is IYieldBackend {
IERC20 public immutable ASSET_TOKEN;
IERC4626 public immutable VAULT;
address public immutable SURPLUS_RECEIVER;

constructor(IERC4626 vault, address surplusReceiver) {
VAULT = vault;
ASSET_TOKEN = IERC20(vault.asset());
SURPLUS_RECEIVER = surplusReceiver;
}

function enable() external {
ASSET_TOKEN.approve(address(VAULT), type(uint256).max);
}

function disable() external {
ASSET_TOKEN.approve(address(VAULT), 0);
}

function deposit(uint256 amount) external {
require(amount > 0, "amount must be greater than 0");
VAULT.deposit(amount, address(this));
}

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

function withdraw(uint256 amount) external {
VAULT.withdraw(amount, address(this), address(this));
}

function withdrawMax() external {
uint256 balance = VAULT.maxWithdraw(address(this));
if (balance > 0) {
VAULT.withdraw(balance, address(this), address(this));
}
}

function withdrawSurplus(uint256 totalSupply) external {
(uint256 normalizedTotalSupply, ) = ISuperToken(address(this))
.toUnderlyingAmount(totalSupply);

uint256 vaultAssets = VAULT.convertToAssets(
VAULT.balanceOf(address(this))
);

uint256 surplusAmount = vaultAssets + ASSET_TOKEN.balanceOf(address(this)) - normalizedTotalSupply;
VAULT.withdraw(surplusAmount, SURPLUS_RECEIVER, address(this));
}

function getManagedAmount() external view returns (uint256) {
return VAULT.convertToAssets(VAULT.balanceOf(address(this)));
}
}
Loading
Loading