From f2ced443575e3c10525ad3611578bf474f060655 Mon Sep 17 00:00:00 2001 From: didi Date: Tue, 9 Dec 2025 15:16:02 +0100 Subject: [PATCH 01/44] WIP: tokens routed through backend --- .gitmodules | 6 + lib/aave-v3 | 1 + .../interfaces/superfluid/ISuperToken.sol | 1 - .../interfaces/superfluid/IYieldBackend.sol | 27 +++ .../contracts/mocks/SuperTokenMock.t.sol | 4 +- .../contracts/superfluid/AaveYieldBackend.sol | 103 ++++++++++ .../contracts/superfluid/SuperToken.sol | 42 +++- packages/ethereum-contracts/foundry.toml | 1 + .../foundry/superfluid/SuperTokenYield.t.sol | 194 ++++++++++++++++++ 9 files changed, 373 insertions(+), 6 deletions(-) create mode 160000 lib/aave-v3 create mode 100644 packages/ethereum-contracts/contracts/interfaces/superfluid/IYieldBackend.sol create mode 100644 packages/ethereum-contracts/contracts/superfluid/AaveYieldBackend.sol create mode 100644 packages/ethereum-contracts/test/foundry/superfluid/SuperTokenYield.t.sol diff --git a/.gitmodules b/.gitmodules index e1e7d6c2c5..426149dfa5 100644 --- a/.gitmodules +++ b/.gitmodules @@ -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 diff --git a/lib/aave-v3 b/lib/aave-v3 new file mode 160000 index 0000000000..1ce897ba99 --- /dev/null +++ b/lib/aave-v3 @@ -0,0 +1 @@ +Subproject commit 1ce897ba99d5a9ab659861b591576cd4278e9e27 diff --git a/packages/ethereum-contracts/contracts/interfaces/superfluid/ISuperToken.sol b/packages/ethereum-contracts/contracts/interfaces/superfluid/ISuperToken.sol index 267aec9a8e..c858183b4f 100644 --- a/packages/ethereum-contracts/contracts/interfaces/superfluid/ISuperToken.sol +++ b/packages/ethereum-contracts/contracts/interfaces/superfluid/ISuperToken.sol @@ -614,5 +614,4 @@ interface ISuperToken is ISuperfluidToken, IERC20Metadata, IERC777, IERC20Permit /// @dev The msg.sender must be the contract itself //modifier onlySelf() virtual - } diff --git a/packages/ethereum-contracts/contracts/interfaces/superfluid/IYieldBackend.sol b/packages/ethereum-contracts/contracts/interfaces/superfluid/IYieldBackend.sol new file mode 100644 index 0000000000..ee4ced2fae --- /dev/null +++ b/packages/ethereum-contracts/contracts/interfaces/superfluid/IYieldBackend.sol @@ -0,0 +1,27 @@ +// 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 updrade 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. + */ +interface IYieldBackend { + // returns the config to be provided to init() and deinit() + function getConfig() external returns (bytes memory config); + + // to be invoked as delegatecall + function init(bytes memory config) external; + // to be invoked as delegatecall + function deinit(bytes memory config) external; + + function deposit(uint256 amount) external; + function depositMax() external; + + function withdraw(uint256 amount) external; + function withdrawMax() external; +} diff --git a/packages/ethereum-contracts/contracts/mocks/SuperTokenMock.t.sol b/packages/ethereum-contracts/contracts/mocks/SuperTokenMock.t.sol index 166929543f..5b0703da9e 100644 --- a/packages/ethereum-contracts/contracts/mocks/SuperTokenMock.t.sol +++ b/packages/ethereum-contracts/contracts/mocks/SuperTokenMock.t.sol @@ -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"); diff --git a/packages/ethereum-contracts/contracts/superfluid/AaveYieldBackend.sol b/packages/ethereum-contracts/contracts/superfluid/AaveYieldBackend.sol new file mode 100644 index 0000000000..eef5aef052 --- /dev/null +++ b/packages/ethereum-contracts/contracts/superfluid/AaveYieldBackend.sol @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +import { IYieldBackend } from "../interfaces/superfluid/IYieldBackend.sol"; +import { DataTypes } from "aave-v3/protocol/libraries/types/DataTypes.sol"; +import { IPool } from "aave-v3/interfaces/IPool.sol"; +import { Ownable } from "@openzeppelin-v5/contracts/access/Ownable.sol"; +import { IERC20 } from "../interfaces/superfluid/ISuperfluid.sol"; + + +struct Config { + address assetTokenAddr; + address aTokenAddr; + address spender; +} + +contract AaveYieldBackend is Ownable, IYieldBackend { + IERC20 public immutable ASSET_TOKEN; + IPool public immutable AAVE_POOL; + IERC20 public immutable A_TOKEN; + + // TODO: what preconditions shall be checked? + /** + * @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 owner the account allowed to deposit and withdraw via this contract. To be set to a SuperToken. + */ + constructor(IERC20 assetToken, IPool aavePool, address owner) + Ownable(owner) + { + ASSET_TOKEN = assetToken; + AAVE_POOL = IPool(aavePool); + + // Grant unlimited approval to Aave pool + // (safe pattern: immutable approval reduces gas & friction) + assetToken.approve(address(aavePool), type(uint256).max); + + A_TOKEN = IERC20(aavePool.getReserveAToken(address(assetToken))); + } + + // returns the config to be provided to delegate init() and deinit() calls + function getConfig() external view returns (bytes memory config) { + return abi.encode(Config({ + aTokenAddr: address(A_TOKEN), spender: address(this), assetTokenAddr: address(ASSET_TOKEN) + })); + } + + // to be invoked as delegatecall + // CANNOT ACCESS STATE OF THIS CONTRACT! + // TODO: how can we single this out such that it can't access state? + function init(bytes memory config) external { + Config memory c = abi.decode(config, (Config)); + IERC20(c.assetTokenAddr).approve(c.spender, type(uint256).max); + IERC20(c.aTokenAddr).approve(c.spender, type(uint256).max); + } + + // to be invoked as delegatecall + // CANNOT ACCESS STATE OF THIS CONTRACT! + function deinit(bytes memory config) external { + Config memory c = abi.decode(config, (Config)); + IERC20(c.assetTokenAddr).approve(c.spender, 0); + IERC20(c.aTokenAddr).approve(c.spender, 0); + } + + /// @notice Caller deposits tokens into Aave V3 + function deposit(uint256 amount) public onlyOwner { + require(amount > 0, "amount must be greater than 0"); + // TODO: how to handle 0 amount? + + // Pull tokens from caller + require(ASSET_TOKEN.transferFrom(msg.sender, address(this), amount), "transferFrom failed"); + + // Deposit into Aave on behalf of this contract + AAVE_POOL.supply(address(ASSET_TOKEN), amount, owner(), 0); + } + + function depositMax() external onlyOwner { + // determine max amount: all of the underlying + // TODO: take into account the max supported by the pool + + uint256 amount = ASSET_TOKEN.balanceOf(owner()); + deposit(amount); + } + + /// @notice Caller withdraws tokens from Aave V3 + function withdraw(uint256 amount) public onlyOwner { + // TODO: how to handle 0 amount? + + A_TOKEN.transferFrom(owner(), address(this), A_TOKEN.balanceOf(owner())); + + // Withdraw from Aave to this contract + uint256 withdrawnAmount = AAVE_POOL.withdraw(address(ASSET_TOKEN), amount, address(this)); + + // Transfer to caller + require(ASSET_TOKEN.transfer(msg.sender, withdrawnAmount), "transfer failed"); + } + + function withdrawMax() external onlyOwner { + // we can delegate the calculation to the pool by setting amount to type(uint256).max + withdraw(type(uint256).max); + } +} diff --git a/packages/ethereum-contracts/contracts/superfluid/SuperToken.sol b/packages/ethereum-contracts/contracts/superfluid/SuperToken.sol index 5f7b7d0c0f..b0fd029fb9 100644 --- a/packages/ethereum-contracts/contracts/superfluid/SuperToken.sol +++ b/packages/ethereum-contracts/contracts/superfluid/SuperToken.sol @@ -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"; @@ -82,15 +83,16 @@ contract SuperToken is /// @dev ERC20 Nonces for EIP-2612 (permit) mapping(address account => uint256) internal _nonces; + IYieldBackend public 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; @@ -137,6 +139,30 @@ contract SuperToken is _initialize(underlyingToken, underlyingDecimals, n, s, address(0)); } + function enableYieldBackend(IYieldBackend newYieldBackend) external onlyAdmin { + require(address(yieldBackend) == address(0)); + yieldBackend = newYieldBackend; + (bool success, ) = address(yieldBackend).delegatecall( + //abi.encodeWithSignature("init(bytes)", yieldBackend.getConfig()) + abi.encodeCall(IYieldBackend.init, (yieldBackend.getConfig())) + ); + require(success, "delegatecall failed"); + yieldBackend.depositMax(); + // TODO: emit event + } + + // withdraws everything and removes allowances + function disableYieldBackend() external onlyAdmin { + yieldBackend.withdrawMax(); + (bool success, ) = address(yieldBackend).delegatecall( + abi.encodeWithSignature("deinit(bytes)", yieldBackend.getConfig()) + ); + // TODO: should this be allowed to fail? + require(success, "delegatecall failed"); + yieldBackend = IYieldBackend(address(0)); + // TODO: emit event + } + /// @dev Initialize the Super Token proxy with an admin function initializeWithAdmin( IERC20 underlyingToken, @@ -839,6 +865,11 @@ contract SuperToken is uint256 actualUpgradedAmount = amountAfter - amountBefore; if (underlyingAmount != actualUpgradedAmount) revert SUPER_TOKEN_INFLATIONARY_DEFLATIONARY_NOT_SUPPORTED(); + if (address(yieldBackend) != address(0)) { + // TODO: shall we deposit all, or just the upgradeAmount? + yieldBackend.deposit(actualUpgradedAmount); + } + _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); @@ -861,6 +892,11 @@ 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 + yieldBackend.withdraw(underlyingAmount); + } + uint256 amountBefore = _underlyingToken.balanceOf(address(this)); _underlyingToken.safeTransfer(to, underlyingAmount); uint256 amountAfter = _underlyingToken.balanceOf(address(this)); diff --git a/packages/ethereum-contracts/foundry.toml b/packages/ethereum-contracts/foundry.toml index d0e6a3f967..7fe628a2f7 100644 --- a/packages/ethereum-contracts/foundry.toml +++ b/packages/ethereum-contracts/foundry.toml @@ -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' diff --git a/packages/ethereum-contracts/test/foundry/superfluid/SuperTokenYield.t.sol b/packages/ethereum-contracts/test/foundry/superfluid/SuperTokenYield.t.sol new file mode 100644 index 0000000000..b02084a2c0 --- /dev/null +++ b/packages/ethereum-contracts/test/foundry/superfluid/SuperTokenYield.t.sol @@ -0,0 +1,194 @@ +// SPDX-License-Identifier: AGPLv3 +pragma solidity ^0.8.23; + +import { Test } from "forge-std/Test.sol"; +import { console } from "forge-std/console.sol"; +import { AaveYieldBackend, Config } from "../../../contracts/superfluid/AaveYieldBackend.sol"; +import { IERC20, ISuperfluid } from "../../../contracts/interfaces/superfluid/ISuperfluid.sol"; +import { SuperToken } from "../../../contracts/superfluid/SuperToken.sol"; +import { IPool } from "aave-v3/interfaces/IPool.sol"; + +/** + * @title SuperTokenYieldForkTest + * @notice Fork test for testing yield-related features with AaveYieldBackend + * @author Superfluid + */ +contract SuperTokenYieldForkTest is Test { + address constant ALICE = address(0x420); + + // Base network constants + uint256 internal constant CHAIN_ID = 8453; + string internal constant RPC_URL = "https://mainnet.base.org"; + + // Aave V3 Pool on Base (verified address) + address internal constant AAVE_POOL = 0xA238Dd80C259a72e81d7e4664a9801593F98d1c5; + + // Common tokens on Base + address internal constant USDCx = 0xD04383398dD2426297da660F9CCA3d439AF9ce1b; // USDCx on Base + address internal constant USDC = 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913; // USDC on Base + address internal constant WETH = 0x4200000000000000000000000000000000000006; // WETH on Base + address internal constant aUSDC = 0x4e65fE4DbA92790696d040ac24Aa414708F5c0AB; // aUSDC on Base + + SuperToken public superToken; + /// @notice AaveYieldBackend contract instance + AaveYieldBackend public aaveBackend; + /// @notice Underlying token (USDC) + IERC20 public underlyingToken; + /// @notice Aave V3 Pool contract + IPool public aavePool; + + /// @notice Admin address (this contract) + address public admin; + /// @notice Test user address + address public user; + + /// @notice Set up the test environment by forking Base and deploying AaveYieldBackend + function setUp() public { + // Fork Base using public RPC + vm.createSelectFork(RPC_URL); + + // Verify we're on Base + assertEq(block.chainid, CHAIN_ID, "Chainid mismatch"); + + // Initialize test accounts + admin = address(this); + user = address(0x1234); + + // Get Aave Pool + aavePool = IPool(AAVE_POOL); + + // Use USDC as the underlying token for testing + underlyingToken = IERC20(USDC); + + superToken = SuperToken(USDCx); + + // Deploy AaveBackend + // Note: In a real scenario, the owner would be the SuperToken contract + // For testing, we use this contract as owner + aaveBackend = new AaveYieldBackend(IERC20(USDC), IPool(AAVE_POOL), USDCx); + + // Verify AaveBackend was deployed correctly + assertEq(address(aaveBackend.ASSET_TOKEN()), USDC, "Asset token mismatch"); + assertEq(address(aaveBackend.AAVE_POOL()), AAVE_POOL, "Aave pool mismatch"); + assertEq(aaveBackend.owner(), USDCx, "Owner mismatch"); + + // upgrade SuperToken to new logic + SuperToken newSuperTokenLogic = new SuperToken(ISuperfluid(superToken.getHost()), superToken.POOL_ADMIN_NFT()); + vm.startPrank(address(superToken.getHost())); + superToken.updateCode(address(newSuperTokenLogic)); + vm.stopPrank(); + + console.log("aaveBackend address", address(aaveBackend)); + } + + function _enableYieldBackend() public { + vm.startPrank(address(superToken.getHost())); + superToken.enableYieldBackend(aaveBackend); + vm.stopPrank(); + } + + /// @notice Test that we're forking the correct Base network + function testForkBaseNetwork() public view { + assertEq(block.chainid, CHAIN_ID, "Chainid mismatch"); + assertTrue(AAVE_POOL.code.length > 0, "Aave Pool should exist"); + assertTrue(USDC.code.length > 0, "USDC should exist"); + } + + /// @notice Test AaveBackend deployment and initialization + function testAaveBackendDeployment() public view { + assertEq(address(aaveBackend.ASSET_TOKEN()), USDC, "Asset token should be USDC"); + assertEq(address(aaveBackend.AAVE_POOL()), AAVE_POOL, "Aave pool address should match"); + assertTrue(address(aaveBackend.A_TOKEN()) != address(0), "aToken should be set"); + } + + /// @notice Test AaveBackend getConfig function + function testAaveBackendGetConfig() public { + bytes memory config = aaveBackend.getConfig(); + assertTrue(config.length > 0, "Config should not be empty"); + Config memory c = abi.decode(config, (Config)); + assertEq(c.assetTokenAddr, USDC, "Asset token mismatch"); + assertEq(c.aTokenAddr, aUSDC, "Aave token mismatch"); + assertEq(c.spender, address(aaveBackend), "Spender mismatch"); + } + + function testEnableYieldBackend() public { + // log USDC balance of SuperToken + console.log("USDC balance of SuperToken", IERC20(USDC).balanceOf(address(superToken))); + + _enableYieldBackend(); + + assertEq(address(superToken.yieldBackend()), address(aaveBackend), "Yield backend mismatch"); + + // Check if SuperToken has approved AaveBackend to spend USDC + uint256 usdcAllowance = IERC20(USDC).allowance(address(superToken), address(aaveBackend)); + console.log("USDC allowance from SuperToken to AaveBackend", usdcAllowance); +// assertEq(usdcAllowance, type(uint256).max, "SuperToken should have approved AaveBackend to spend USDC"); + + // Check if SuperToken has approved AaveBackend to spend aUSDC + uint256 ausdcAllowance = IERC20(aUSDC).allowance(address(superToken), address(aaveBackend)); + console.log("aUSDC allowance from SuperToken to AaveBackend", ausdcAllowance); + assertEq(ausdcAllowance, type(uint256).max, "SuperToken should have approved AaveBackend to spend aUSDC"); + + // the SuperToken should now have a zero USDC balance and a non-zero aUSDC balance + assertEq(IERC20(USDC).balanceOf(address(superToken)), 0, "USDC balance should be zero"); + assertGt(IERC20(aUSDC).balanceOf(address(superToken)), 0, "aUSDC balance should be non-zero"); + + // log aUSDC balance of SuperToken + console.log("aUSDC balance of SuperToken", IERC20(aUSDC).balanceOf(address(superToken))); + // TODO: We'd want asset balance to equal aToken balance. But that's not exactly the case. + // what else shall be require? + } + + function testDisableYieldBackend() public { + // store underlying balance before enabling yield backend + uint256 underlyingBalanceBefore = IERC20(USDC).balanceOf(address(superToken)); + + _enableYieldBackend(); + + vm.startPrank(address(superToken.getHost())); + superToken.disableYieldBackend(); + vm.stopPrank(); + assertEq(address(superToken.yieldBackend()), address(0), "Yield backend mismatch"); + + // the SuperToken should now have a non-zero USDC balance and a zero aUSDC balance + assertGt(IERC20(USDC).balanceOf(address(superToken)), 0, "USDC balance should be non-zero"); + assertEq(IERC20(aUSDC).balanceOf(address(superToken)), 0, "aUSDC balance should be zero"); + + // get underlying balance after disabling yield backend + uint256 underlyingBalanceAfter = IERC20(USDC).balanceOf(address(superToken)); + //assertEq(underlyingBalanceAfter, underlyingBalanceBefore, "Underlying balance should be the same"); + } + + // TODO: bool fuzz arg for disabled/enabled backend + function testUpgradeDowngrade() public { + _enableYieldBackend(); + + deal(USDC, ALICE, 1000 ether); + + uint256 aTokenBalanceBefore = IERC20(aUSDC).balanceOf(address(superToken)); + vm.startPrank(ALICE); + IERC20(USDC).approve(address(superToken), type(uint256).max); + superToken.upgrade(1 ether); + vm.stopPrank(); + + uint256 aTokenBalanceAfter = IERC20(aUSDC).balanceOf(address(superToken)); + + // log superToken amount of ALICE + console.log("superToken amount of ALICE", superToken.balanceOf(ALICE)); + + // log aToken balance of superToken contract + console.log("aToken balance of superToken contract", IERC20(aUSDC).balanceOf(address(superToken))); + + // log diff + console.log("aToken balance diff", aTokenBalanceAfter - aTokenBalanceBefore); + + // downgrade + vm.startPrank(ALICE); + // there's a flaw in the API here: upgrade may have down-rounded the amount, but doesn't tell as (via return value). In that case a consecutive downgrade (of the un-adjusted amount) would revert. + superToken.downgrade(1 ether); + vm.stopPrank(); + + uint256 aTokenBalanceAfterDowngrade = IERC20(aUSDC).balanceOf(address(superToken)); + } +} + From 7945dc73f679e19c81986b2ecfb8a3e0e968efe9 Mon Sep 17 00:00:00 2001 From: didi Date: Wed, 10 Dec 2025 16:05:44 +0100 Subject: [PATCH 02/44] add to host and governance contract --- .../gov/SuperfluidGovernanceBase.sol | 7 ++ .../interfaces/superfluid/ISuperToken.sol | 10 +++ .../interfaces/superfluid/IYieldBackend.sol | 6 +- .../contracts/superfluid/AaveYieldBackend.sol | 6 +- .../contracts/superfluid/SuperToken.sol | 70 +++++++++++-------- .../contracts/superfluid/Superfluid.sol | 5 ++ .../foundry/superfluid/SuperTokenYield.t.sol | 10 +-- .../test/foundry/superfluid/Superfluid.t.sol | 13 ++++ 8 files changed, 88 insertions(+), 39 deletions(-) diff --git a/packages/ethereum-contracts/contracts/gov/SuperfluidGovernanceBase.sol b/packages/ethereum-contracts/contracts/gov/SuperfluidGovernanceBase.sol index 02d8beb2fd..bad1b3c59c 100644 --- a/packages/ethereum-contracts/contracts/gov/SuperfluidGovernanceBase.sol +++ b/packages/ethereum-contracts/contracts/gov/SuperfluidGovernanceBase.sol @@ -141,6 +141,13 @@ abstract contract SuperfluidGovernanceBase is ISuperfluidGovernance host.changeSuperTokenAdmin(token, newAdmin); } + function setSuperTokenYieldBackend(ISuperfluid host, ISuperToken token, address yieldBackend) + external + onlyAuthorized(host) + { + token.setYieldBackend(yieldBackend); + } + function batchChangeSuperTokenAdmin(ISuperfluid host, ISuperToken[] calldata token, address[] calldata newAdmins) external onlyAuthorized(host) diff --git a/packages/ethereum-contracts/contracts/interfaces/superfluid/ISuperToken.sol b/packages/ethereum-contracts/contracts/interfaces/superfluid/ISuperToken.sol index c858183b4f..970846388d 100644 --- a/packages/ethereum-contracts/contracts/interfaces/superfluid/ISuperToken.sol +++ b/packages/ethereum-contracts/contracts/interfaces/superfluid/ISuperToken.sol @@ -63,6 +63,7 @@ interface ISuperToken is ISuperfluidToken, IERC20Metadata, IERC777, IERC20Permit */ function changeAdmin(address newAdmin) external; + event AdminChanged(address indexed oldAdmin, address indexed newAdmin); /** @@ -70,6 +71,15 @@ interface ISuperToken is ISuperfluidToken, IERC20Metadata, IERC777, IERC20Permit */ function getAdmin() external view returns (address admin); + /** + * @notice Sets the yield backend for the SuperToken + * @dev Only the admin can call this function + * @param yieldBackend Address of the yield backend contract, or address(0) to disable the yield backend + */ + function setYieldBackend(address yieldBackend) external; + + function getYieldBackend() external view returns (address yieldBackend); + /************************************************************************** * Immutable variables *************************************************************************/ diff --git a/packages/ethereum-contracts/contracts/interfaces/superfluid/IYieldBackend.sol b/packages/ethereum-contracts/contracts/interfaces/superfluid/IYieldBackend.sol index ee4ced2fae..f98a03ff28 100644 --- a/packages/ethereum-contracts/contracts/interfaces/superfluid/IYieldBackend.sol +++ b/packages/ethereum-contracts/contracts/interfaces/superfluid/IYieldBackend.sol @@ -11,13 +11,13 @@ pragma solidity ^0.8.23; * one -> no means withdraw not in the context of a downgrade. */ interface IYieldBackend { - // returns the config to be provided to init() and deinit() + // returns the config to be provided to delegateInitSuperToken() and deinit() function getConfig() external returns (bytes memory config); // to be invoked as delegatecall - function init(bytes memory config) external; + function delegateInitSuperToken(bytes memory config) external; // to be invoked as delegatecall - function deinit(bytes memory config) external; + function delegateDeinitSuperToken(bytes memory config) external; function deposit(uint256 amount) external; function depositMax() external; diff --git a/packages/ethereum-contracts/contracts/superfluid/AaveYieldBackend.sol b/packages/ethereum-contracts/contracts/superfluid/AaveYieldBackend.sol index eef5aef052..069ab4d062 100644 --- a/packages/ethereum-contracts/contracts/superfluid/AaveYieldBackend.sol +++ b/packages/ethereum-contracts/contracts/superfluid/AaveYieldBackend.sol @@ -36,6 +36,8 @@ contract AaveYieldBackend is Ownable, IYieldBackend { // (safe pattern: immutable approval reduces gas & friction) assetToken.approve(address(aavePool), type(uint256).max); + // TODO: aavePool seems to have implicit allowance to aTokens. + A_TOKEN = IERC20(aavePool.getReserveAToken(address(assetToken))); } @@ -49,7 +51,7 @@ contract AaveYieldBackend is Ownable, IYieldBackend { // to be invoked as delegatecall // CANNOT ACCESS STATE OF THIS CONTRACT! // TODO: how can we single this out such that it can't access state? - function init(bytes memory config) external { + function delegateInitSuperToken(bytes memory config) external { Config memory c = abi.decode(config, (Config)); IERC20(c.assetTokenAddr).approve(c.spender, type(uint256).max); IERC20(c.aTokenAddr).approve(c.spender, type(uint256).max); @@ -57,7 +59,7 @@ contract AaveYieldBackend is Ownable, IYieldBackend { // to be invoked as delegatecall // CANNOT ACCESS STATE OF THIS CONTRACT! - function deinit(bytes memory config) external { + function delegateDeinitSuperToken(bytes memory config) external { Config memory c = abi.decode(config, (Config)); IERC20(c.assetTokenAddr).approve(c.spender, 0); IERC20(c.aTokenAddr).approve(c.spender, 0); diff --git a/packages/ethereum-contracts/contracts/superfluid/SuperToken.sol b/packages/ethereum-contracts/contracts/superfluid/SuperToken.sol index b0fd029fb9..173a3976ad 100644 --- a/packages/ethereum-contracts/contracts/superfluid/SuperToken.sol +++ b/packages/ethereum-contracts/contracts/superfluid/SuperToken.sol @@ -83,7 +83,8 @@ contract SuperToken is /// @dev ERC20 Nonces for EIP-2612 (permit) mapping(address account => uint256) internal _nonces; - IYieldBackend public yieldBackend; + // 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 _reserve24 @@ -139,30 +140,6 @@ contract SuperToken is _initialize(underlyingToken, underlyingDecimals, n, s, address(0)); } - function enableYieldBackend(IYieldBackend newYieldBackend) external onlyAdmin { - require(address(yieldBackend) == address(0)); - yieldBackend = newYieldBackend; - (bool success, ) = address(yieldBackend).delegatecall( - //abi.encodeWithSignature("init(bytes)", yieldBackend.getConfig()) - abi.encodeCall(IYieldBackend.init, (yieldBackend.getConfig())) - ); - require(success, "delegatecall failed"); - yieldBackend.depositMax(); - // TODO: emit event - } - - // withdraws everything and removes allowances - function disableYieldBackend() external onlyAdmin { - yieldBackend.withdrawMax(); - (bool success, ) = address(yieldBackend).delegatecall( - abi.encodeWithSignature("deinit(bytes)", yieldBackend.getConfig()) - ); - // TODO: should this be allowed to fail? - require(success, "delegatecall failed"); - yieldBackend = IYieldBackend(address(0)); - // TODO: emit event - } - /// @dev Initialize the Super Token proxy with an admin function initializeWithAdmin( IERC20 underlyingToken, @@ -221,6 +198,41 @@ contract SuperToken is } } + function setYieldBackend(address newYieldBackend) external onlyAdmin { + if (address(_yieldBackend) != address(0)) { + _disableYieldBackend(); + } + if (address(newYieldBackend) != address(0)) { + _enableYieldBackend(IYieldBackend(newYieldBackend)); + } + } + + function _enableYieldBackend(IYieldBackend newYieldBackend) internal { + require(address(_yieldBackend) == address(0)); + _yieldBackend = newYieldBackend; + (bool success, ) = address(_yieldBackend).delegatecall( + abi.encodeCall(IYieldBackend.delegateInitSuperToken, (_yieldBackend.getConfig())) + ); + require(success, "delegatecall failed"); + _yieldBackend.depositMax(); + // TODO: emit event + } + + // withdraws everything and removes allowances + function _disableYieldBackend() internal { + _yieldBackend.withdrawMax(); + (bool success, ) = address(_yieldBackend).delegatecall( + abi.encodeCall(IYieldBackend.delegateDeinitSuperToken, (_yieldBackend.getConfig())) + ); + // 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); + } /************************************************************************** * ERC20 Token Info @@ -865,9 +877,9 @@ contract SuperToken is uint256 actualUpgradedAmount = amountAfter - amountBefore; if (underlyingAmount != actualUpgradedAmount) revert SUPER_TOKEN_INFLATIONARY_DEFLATIONARY_NOT_SUPPORTED(); - if (address(yieldBackend) != address(0)) { + if (address(_yieldBackend) != address(0)) { // TODO: shall we deposit all, or just the upgradeAmount? - yieldBackend.deposit(actualUpgradedAmount); + _yieldBackend.deposit(actualUpgradedAmount); } _mint(operator, to, adjustedAmount, @@ -892,9 +904,9 @@ 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)) { + if (address(_yieldBackend) != address(0)) { // TODO: we may want to skip if enough underlying already in the contract - yieldBackend.withdraw(underlyingAmount); + _yieldBackend.withdraw(underlyingAmount); } uint256 amountBefore = _underlyingToken.balanceOf(address(this)); diff --git a/packages/ethereum-contracts/contracts/superfluid/Superfluid.sol b/packages/ethereum-contracts/contracts/superfluid/Superfluid.sol index cbd88198b6..b1f88b3655 100644 --- a/packages/ethereum-contracts/contracts/superfluid/Superfluid.sol +++ b/packages/ethereum-contracts/contracts/superfluid/Superfluid.sol @@ -19,6 +19,7 @@ import { ISuperTokenFactory, IAccessControl } from "../interfaces/superfluid/ISuperfluid.sol"; +import { IYieldBackend } from "../interfaces/superfluid/IYieldBackend.sol"; import { GeneralDistributionAgreementV1 } from "../agreements/gdav1/GeneralDistributionAgreementV1.sol"; import { SuperfluidUpgradeableBeacon } from "../upgradability/SuperfluidUpgradeableBeacon.sol"; import { CallUtils } from "../libs/CallUtils.sol"; @@ -332,6 +333,10 @@ contract Superfluid is token.changeAdmin(newAdmin); } + function setSuperTokenYieldBackend(ISuperToken token, address yieldBackend) external onlyGovernance { + token.setYieldBackend(yieldBackend); + } + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // Superfluid Upgradeable Beacon //////////////////////////////////////////////////////////////////////////////////////////////////////////////////// diff --git a/packages/ethereum-contracts/test/foundry/superfluid/SuperTokenYield.t.sol b/packages/ethereum-contracts/test/foundry/superfluid/SuperTokenYield.t.sol index b02084a2c0..e9a4a629a9 100644 --- a/packages/ethereum-contracts/test/foundry/superfluid/SuperTokenYield.t.sol +++ b/packages/ethereum-contracts/test/foundry/superfluid/SuperTokenYield.t.sol @@ -83,7 +83,7 @@ contract SuperTokenYieldForkTest is Test { function _enableYieldBackend() public { vm.startPrank(address(superToken.getHost())); - superToken.enableYieldBackend(aaveBackend); + superToken.setYieldBackend(address(aaveBackend)); vm.stopPrank(); } @@ -102,7 +102,7 @@ contract SuperTokenYieldForkTest is Test { } /// @notice Test AaveBackend getConfig function - function testAaveBackendGetConfig() public { + function testAaveBackendGetConfig() public view { bytes memory config = aaveBackend.getConfig(); assertTrue(config.length > 0, "Config should not be empty"); Config memory c = abi.decode(config, (Config)); @@ -117,7 +117,7 @@ contract SuperTokenYieldForkTest is Test { _enableYieldBackend(); - assertEq(address(superToken.yieldBackend()), address(aaveBackend), "Yield backend mismatch"); + assertEq(address(superToken.getYieldBackend()), address(aaveBackend), "Yield backend mismatch"); // Check if SuperToken has approved AaveBackend to spend USDC uint256 usdcAllowance = IERC20(USDC).allowance(address(superToken), address(aaveBackend)); @@ -146,9 +146,9 @@ contract SuperTokenYieldForkTest is Test { _enableYieldBackend(); vm.startPrank(address(superToken.getHost())); - superToken.disableYieldBackend(); + superToken.setYieldBackend(address(0)); vm.stopPrank(); - assertEq(address(superToken.yieldBackend()), address(0), "Yield backend mismatch"); + assertEq(address(superToken.getYieldBackend()), address(0), "Yield backend mismatch"); // the SuperToken should now have a non-zero USDC balance and a zero aUSDC balance assertGt(IERC20(USDC).balanceOf(address(superToken)), 0, "USDC balance should be non-zero"); diff --git a/packages/ethereum-contracts/test/foundry/superfluid/Superfluid.t.sol b/packages/ethereum-contracts/test/foundry/superfluid/Superfluid.t.sol index 9721897ca6..a272952ace 100644 --- a/packages/ethereum-contracts/test/foundry/superfluid/Superfluid.t.sol +++ b/packages/ethereum-contracts/test/foundry/superfluid/Superfluid.t.sol @@ -89,6 +89,19 @@ contract SuperfluidIntegrationTest is FoundrySuperfluidTester { vm.stopPrank(); } + function testSetSuperTokenYieldBackend(address yieldBackend) public { + // revert if not called by governance + vm.expectRevert(ISuperfluid.HOST_ONLY_GOVERNANCE.selector); + sf.host.setSuperTokenYieldBackend(superToken, yieldBackend); + + // succeed if called by governance + vm.startPrank(address(sf.governance)); + sf.host.setSuperTokenYieldBackend(superToken, yieldBackend); + vm.stopPrank(); + + assertEq(address(superToken.getYieldBackend()), yieldBackend, "Superfluid.t: super token yield backend not set"); + } + function testSuperAppRegistrationViaSimpleACL() public { SimpleACL simpleAcl = new SimpleACL(); Superfluid hostWithSimpleACL = new Superfluid( From 5c28327f3ee0ed1f5fb642f0ac39db8079852fb2 Mon Sep 17 00:00:00 2001 From: didi Date: Wed, 10 Dec 2025 16:40:05 +0100 Subject: [PATCH 03/44] added tests for measuring gas cost --- .../foundry/superfluid/SuperTokenYield.t.sol | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/packages/ethereum-contracts/test/foundry/superfluid/SuperTokenYield.t.sol b/packages/ethereum-contracts/test/foundry/superfluid/SuperTokenYield.t.sol index e9a4a629a9..e86c153924 100644 --- a/packages/ethereum-contracts/test/foundry/superfluid/SuperTokenYield.t.sol +++ b/packages/ethereum-contracts/test/foundry/superfluid/SuperTokenYield.t.sol @@ -190,5 +190,127 @@ contract SuperTokenYieldForkTest is Test { uint256 aTokenBalanceAfterDowngrade = IERC20(aUSDC).balanceOf(address(superToken)); } + + // ============ Gas Benchmarking Tests ============ + + /// @notice Test gas cost of upgrade WITHOUT yield backend + /// @dev Separate test function to avoid cold/warm storage slot interference + function testGasUpgrade_WithoutYieldBackend() public { + // Ensure yield backend is NOT set + vm.startPrank(address(superToken.getHost())); + superToken.setYieldBackend(address(0)); + vm.stopPrank(); + assertEq(address(superToken.getYieldBackend()), address(0), "Yield backend should not be set"); + + // Prepare test state + // 1000 USDC = 1000 * 1e6 (USDC has 6 decimals) + // In SuperToken units (18 decimals), this is 1000 * 1e18 + uint256 upgradeAmount = 1000 * 1e18; + deal(USDC, ALICE, 1000 * 1e6); + vm.startPrank(ALICE); + IERC20(USDC).approve(address(superToken), type(uint256).max); + + // Measure gas for upgrade + uint256 gasBefore = gasleft(); + superToken.upgrade(upgradeAmount); + uint256 gasUsed = gasBefore - gasleft(); + vm.stopPrank(); + + console.log("=== Gas: Upgrade WITHOUT Yield Backend ==="); + console.log("Gas used", gasUsed); + console.log("Amount upgraded", upgradeAmount); + } + + /// @notice Test gas cost of upgrade WITH yield backend + /// @dev Separate test function to avoid cold/warm storage slot interference + function testGasUpgrade_WithYieldBackend() public { + // Enable yield backend + _enableYieldBackend(); + assertEq(address(superToken.getYieldBackend()), address(aaveBackend), "Yield backend should be set"); + + // Prepare test state + // 1000 USDC = 1000 * 1e6 (USDC has 6 decimals) + // In SuperToken units (18 decimals), this is 1000 * 1e18 + uint256 upgradeAmount = 1000 * 1e18; + deal(USDC, ALICE, 1000 * 1e6); + vm.startPrank(ALICE); + IERC20(USDC).approve(address(superToken), type(uint256).max); + + // Measure gas for upgrade + uint256 gasBefore = gasleft(); + superToken.upgrade(upgradeAmount); + uint256 gasUsed = gasBefore - gasleft(); + vm.stopPrank(); + + console.log("=== Gas: Upgrade WITH Yield Backend ==="); + console.log("Gas used", gasUsed); + console.log("Amount upgraded", upgradeAmount); + } + + /// @notice Test gas cost of downgrade WITHOUT yield backend + /// @dev Separate test function to avoid cold/warm storage slot interference + function testGasDowngrade_WithoutYieldBackend() public { + // Ensure yield backend is NOT set + vm.startPrank(address(superToken.getHost())); + superToken.setYieldBackend(address(0)); + vm.stopPrank(); + + // First, upgrade some tokens for ALICE to downgrade later + // 1000 USDC = 1000 * 1e6 (USDC has 6 decimals) + // In SuperToken units (18 decimals), this is 1000 * 1e18 + uint256 initialUpgradeAmount = 1000 * 1e18; + deal(USDC, ALICE, 1000 * 1e6); + vm.startPrank(ALICE); + IERC20(USDC).approve(address(superToken), type(uint256).max); + superToken.upgrade(initialUpgradeAmount); + vm.stopPrank(); + + uint256 aliceBalance = superToken.balanceOf(ALICE); + assertGt(aliceBalance, 0, "ALICE should have super tokens"); + + // Now measure gas for downgrade + vm.startPrank(ALICE); + uint256 amountToDowngrade = aliceBalance / 2; // Downgrade half + uint256 gasBefore = gasleft(); + superToken.downgrade(amountToDowngrade); + uint256 gasUsed = gasBefore - gasleft(); + vm.stopPrank(); + + console.log("=== Gas: Downgrade WITHOUT Yield Backend ==="); + console.log("Gas used", gasUsed); + console.log("Amount downgraded", amountToDowngrade); + } + + /// @notice Test gas cost of downgrade WITH yield backend + /// @dev Separate test function to avoid cold/warm storage slot interference + function testGasDowngrade_WithYieldBackend() public { + // Enable yield backend + _enableYieldBackend(); + + // First, upgrade some tokens for ALICE to downgrade later + // 1000 USDC = 1000 * 1e6 (USDC has 6 decimals) + // In SuperToken units (18 decimals), this is 1000 * 1e18 + uint256 initialUpgradeAmount = 1000 * 1e18; + deal(USDC, ALICE, 1000 * 1e6); + vm.startPrank(ALICE); + IERC20(USDC).approve(address(superToken), type(uint256).max); + superToken.upgrade(initialUpgradeAmount); + vm.stopPrank(); + + uint256 aliceBalance = superToken.balanceOf(ALICE); + assertGt(aliceBalance, 0, "ALICE should have super tokens"); + + // Now measure gas for downgrade + vm.startPrank(ALICE); + uint256 amountToDowngrade = aliceBalance / 2; // Downgrade half + uint256 gasBefore = gasleft(); + superToken.downgrade(amountToDowngrade); + uint256 gasUsed = gasBefore - gasleft(); + vm.stopPrank(); + + console.log("=== Gas: Downgrade WITH Yield Backend ==="); + console.log("Gas used", gasUsed); + console.log("Amount downgraded", amountToDowngrade); + } } From 1d3e9be12bdc8ec52ce1680f9ebfad3d551e4646 Mon Sep 17 00:00:00 2001 From: didi Date: Thu, 11 Dec 2025 09:18:19 +0100 Subject: [PATCH 04/44] simplication --- .../interfaces/superfluid/IYieldBackend.sol | 12 +- .../contracts/superfluid/AaveYieldBackend.sol | 104 ++++++------------ .../contracts/superfluid/SuperToken.sol | 26 +++-- .../foundry/superfluid/SuperTokenYield.t.sol | 25 +---- 4 files changed, 58 insertions(+), 109 deletions(-) diff --git a/packages/ethereum-contracts/contracts/interfaces/superfluid/IYieldBackend.sol b/packages/ethereum-contracts/contracts/interfaces/superfluid/IYieldBackend.sol index f98a03ff28..be666e3c3e 100644 --- a/packages/ethereum-contracts/contracts/interfaces/superfluid/IYieldBackend.sol +++ b/packages/ethereum-contracts/contracts/interfaces/superfluid/IYieldBackend.sol @@ -3,7 +3,7 @@ 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 updrade and withdrawn on downgrade. + * 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 @@ -11,17 +11,11 @@ pragma solidity ^0.8.23; * one -> no means withdraw not in the context of a downgrade. */ interface IYieldBackend { - // returns the config to be provided to delegateInitSuperToken() and deinit() - function getConfig() external returns (bytes memory config); - - // to be invoked as delegatecall - function delegateInitSuperToken(bytes memory config) external; - // to be invoked as delegatecall - function delegateDeinitSuperToken(bytes memory config) external; + function init() external; + function deinit() external; function deposit(uint256 amount) external; function depositMax() external; - function withdraw(uint256 amount) external; function withdrawMax() external; } diff --git a/packages/ethereum-contracts/contracts/superfluid/AaveYieldBackend.sol b/packages/ethereum-contracts/contracts/superfluid/AaveYieldBackend.sol index 069ab4d062..23e2fd3386 100644 --- a/packages/ethereum-contracts/contracts/superfluid/AaveYieldBackend.sol +++ b/packages/ethereum-contracts/contracts/superfluid/AaveYieldBackend.sol @@ -2,104 +2,68 @@ pragma solidity ^0.8.23; import { IYieldBackend } from "../interfaces/superfluid/IYieldBackend.sol"; -import { DataTypes } from "aave-v3/protocol/libraries/types/DataTypes.sol"; import { IPool } from "aave-v3/interfaces/IPool.sol"; -import { Ownable } from "@openzeppelin-v5/contracts/access/Ownable.sol"; import { IERC20 } from "../interfaces/superfluid/ISuperfluid.sol"; -struct Config { - address assetTokenAddr; - address aTokenAddr; - address spender; -} - -contract AaveYieldBackend is Ownable, IYieldBackend { +/** + * 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: what preconditions shall be checked? + // 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 owner the account allowed to deposit and withdraw via this contract. To be set to a SuperToken. */ - constructor(IERC20 assetToken, IPool aavePool, address owner) - Ownable(owner) - { + constructor(IERC20 assetToken, IPool aavePool) { + // TODO: any checks to be done? ASSET_TOKEN = assetToken; AAVE_POOL = IPool(aavePool); - - // Grant unlimited approval to Aave pool - // (safe pattern: immutable approval reduces gas & friction) - assetToken.approve(address(aavePool), type(uint256).max); - - // TODO: aavePool seems to have implicit allowance to aTokens. - A_TOKEN = IERC20(aavePool.getReserveAToken(address(assetToken))); } - // returns the config to be provided to delegate init() and deinit() calls - function getConfig() external view returns (bytes memory config) { - return abi.encode(Config({ - aTokenAddr: address(A_TOKEN), spender: address(this), assetTokenAddr: address(ASSET_TOKEN) - })); + function init() external { + // approve Aave pool to fetch asset + ASSET_TOKEN.approve(address(AAVE_POOL), type(uint256).max); } - // to be invoked as delegatecall - // CANNOT ACCESS STATE OF THIS CONTRACT! - // TODO: how can we single this out such that it can't access state? - function delegateInitSuperToken(bytes memory config) external { - Config memory c = abi.decode(config, (Config)); - IERC20(c.assetTokenAddr).approve(c.spender, type(uint256).max); - IERC20(c.aTokenAddr).approve(c.spender, type(uint256).max); + function deinit() external { + // Revoke approval + ASSET_TOKEN.approve(address(AAVE_POOL), 0); } - // to be invoked as delegatecall - // CANNOT ACCESS STATE OF THIS CONTRACT! - function delegateDeinitSuperToken(bytes memory config) external { - Config memory c = abi.decode(config, (Config)); - IERC20(c.assetTokenAddr).approve(c.spender, 0); - IERC20(c.aTokenAddr).approve(c.spender, 0); - } - - /// @notice Caller deposits tokens into Aave V3 - function deposit(uint256 amount) public onlyOwner { + function deposit(uint256 amount) external { + // TODO: can this constraint break anything? require(amount > 0, "amount must be greater than 0"); - // TODO: how to handle 0 amount? - - // Pull tokens from caller - require(ASSET_TOKEN.transferFrom(msg.sender, address(this), amount), "transferFrom failed"); - - // Deposit into Aave on behalf of this contract - AAVE_POOL.supply(address(ASSET_TOKEN), amount, owner(), 0); + // Deposit asset and get back aTokens + AAVE_POOL.supply(address(ASSET_TOKEN), amount, address(this), 0); } - function depositMax() external onlyOwner { - // determine max amount: all of the underlying - // TODO: take into account the max supported by the pool - - uint256 amount = ASSET_TOKEN.balanceOf(owner()); - deposit(amount); + function depositMax() external { + uint256 amount = ASSET_TOKEN.balanceOf(address(this)); + if (amount > 0) { + AAVE_POOL.supply(address(ASSET_TOKEN), amount, address(this), 0); + } } - /// @notice Caller withdraws tokens from Aave V3 - function withdraw(uint256 amount) public onlyOwner { - // TODO: how to handle 0 amount? - - A_TOKEN.transferFrom(owner(), address(this), A_TOKEN.balanceOf(owner())); - - // Withdraw from Aave to this contract - uint256 withdrawnAmount = AAVE_POOL.withdraw(address(ASSET_TOKEN), amount, address(this)); - - // Transfer to caller - require(ASSET_TOKEN.transfer(msg.sender, withdrawnAmount), "transfer failed"); + 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 onlyOwner { - // we can delegate the calculation to the pool by setting amount to type(uint256).max - withdraw(type(uint256).max); + 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)); } } diff --git a/packages/ethereum-contracts/contracts/superfluid/SuperToken.sol b/packages/ethereum-contracts/contracts/superfluid/SuperToken.sol index 173a3976ad..ddb8788b09 100644 --- a/packages/ethereum-contracts/contracts/superfluid/SuperToken.sol +++ b/packages/ethereum-contracts/contracts/superfluid/SuperToken.sol @@ -208,21 +208,27 @@ contract SuperToken is } function _enableYieldBackend(IYieldBackend newYieldBackend) internal { - require(address(_yieldBackend) == address(0)); + require(address(_yieldBackend) == address(0), "yield backend already set"); _yieldBackend = newYieldBackend; (bool success, ) = address(_yieldBackend).delegatecall( - abi.encodeCall(IYieldBackend.delegateInitSuperToken, (_yieldBackend.getConfig())) + abi.encodeCall(IYieldBackend.initSuperToken, ()) + ); + require(success, "delegatecall failed"); + (success, ) = address(_yieldBackend).delegatecall( + abi.encodeCall(IYieldBackend.depositMax, ()) ); require(success, "delegatecall failed"); - _yieldBackend.depositMax(); // TODO: emit event } // withdraws everything and removes allowances function _disableYieldBackend() internal { - _yieldBackend.withdrawMax(); (bool success, ) = address(_yieldBackend).delegatecall( - abi.encodeCall(IYieldBackend.delegateDeinitSuperToken, (_yieldBackend.getConfig())) + abi.encodeCall(IYieldBackend.withdrawMax, ()) + ); + require(success, "delegatecall failed"); + (success, ) = address(_yieldBackend).delegatecall( + abi.encodeCall(IYieldBackend.deinitSuperToken, ()) ); // TODO: should this be allowed to fail? require(success, "delegatecall failed"); @@ -879,7 +885,10 @@ contract SuperToken is if (address(_yieldBackend) != address(0)) { // TODO: shall we deposit all, or just the upgradeAmount? - _yieldBackend.deposit(actualUpgradedAmount); + (bool success, ) = address(_yieldBackend).delegatecall( + abi.encodeCall(IYieldBackend.deposit, (actualUpgradedAmount)) + ); + require(success, "delegatecall failed"); } _mint(operator, to, adjustedAmount, @@ -906,7 +915,10 @@ contract SuperToken is if (address(_yieldBackend) != address(0)) { // TODO: we may want to skip if enough underlying already in the contract - _yieldBackend.withdraw(underlyingAmount); + (bool success, ) = address(_yieldBackend).delegatecall( + abi.encodeCall(IYieldBackend.withdraw, (underlyingAmount)) + ); + require(success, "delegatecall failed"); } uint256 amountBefore = _underlyingToken.balanceOf(address(this)); diff --git a/packages/ethereum-contracts/test/foundry/superfluid/SuperTokenYield.t.sol b/packages/ethereum-contracts/test/foundry/superfluid/SuperTokenYield.t.sol index e86c153924..5a6a3f2766 100644 --- a/packages/ethereum-contracts/test/foundry/superfluid/SuperTokenYield.t.sol +++ b/packages/ethereum-contracts/test/foundry/superfluid/SuperTokenYield.t.sol @@ -3,7 +3,7 @@ pragma solidity ^0.8.23; import { Test } from "forge-std/Test.sol"; import { console } from "forge-std/console.sol"; -import { AaveYieldBackend, Config } from "../../../contracts/superfluid/AaveYieldBackend.sol"; +import { AaveYieldBackend } from "../../../contracts/superfluid/AaveYieldBackend.sol"; import { IERC20, ISuperfluid } from "../../../contracts/interfaces/superfluid/ISuperfluid.sol"; import { SuperToken } from "../../../contracts/superfluid/SuperToken.sol"; import { IPool } from "aave-v3/interfaces/IPool.sol"; @@ -65,12 +65,11 @@ contract SuperTokenYieldForkTest is Test { // Deploy AaveBackend // Note: In a real scenario, the owner would be the SuperToken contract // For testing, we use this contract as owner - aaveBackend = new AaveYieldBackend(IERC20(USDC), IPool(AAVE_POOL), USDCx); + aaveBackend = new AaveYieldBackend(IERC20(USDC), IPool(AAVE_POOL)); // Verify AaveBackend was deployed correctly assertEq(address(aaveBackend.ASSET_TOKEN()), USDC, "Asset token mismatch"); assertEq(address(aaveBackend.AAVE_POOL()), AAVE_POOL, "Aave pool mismatch"); - assertEq(aaveBackend.owner(), USDCx, "Owner mismatch"); // upgrade SuperToken to new logic SuperToken newSuperTokenLogic = new SuperToken(ISuperfluid(superToken.getHost()), superToken.POOL_ADMIN_NFT()); @@ -101,16 +100,6 @@ contract SuperTokenYieldForkTest is Test { assertTrue(address(aaveBackend.A_TOKEN()) != address(0), "aToken should be set"); } - /// @notice Test AaveBackend getConfig function - function testAaveBackendGetConfig() public view { - bytes memory config = aaveBackend.getConfig(); - assertTrue(config.length > 0, "Config should not be empty"); - Config memory c = abi.decode(config, (Config)); - assertEq(c.assetTokenAddr, USDC, "Asset token mismatch"); - assertEq(c.aTokenAddr, aUSDC, "Aave token mismatch"); - assertEq(c.spender, address(aaveBackend), "Spender mismatch"); - } - function testEnableYieldBackend() public { // log USDC balance of SuperToken console.log("USDC balance of SuperToken", IERC20(USDC).balanceOf(address(superToken))); @@ -119,16 +108,6 @@ contract SuperTokenYieldForkTest is Test { assertEq(address(superToken.getYieldBackend()), address(aaveBackend), "Yield backend mismatch"); - // Check if SuperToken has approved AaveBackend to spend USDC - uint256 usdcAllowance = IERC20(USDC).allowance(address(superToken), address(aaveBackend)); - console.log("USDC allowance from SuperToken to AaveBackend", usdcAllowance); -// assertEq(usdcAllowance, type(uint256).max, "SuperToken should have approved AaveBackend to spend USDC"); - - // Check if SuperToken has approved AaveBackend to spend aUSDC - uint256 ausdcAllowance = IERC20(aUSDC).allowance(address(superToken), address(aaveBackend)); - console.log("aUSDC allowance from SuperToken to AaveBackend", ausdcAllowance); - assertEq(ausdcAllowance, type(uint256).max, "SuperToken should have approved AaveBackend to spend aUSDC"); - // the SuperToken should now have a zero USDC balance and a non-zero aUSDC balance assertEq(IERC20(USDC).balanceOf(address(superToken)), 0, "USDC balance should be zero"); assertGt(IERC20(aUSDC).balanceOf(address(superToken)), 0, "aUSDC balance should be non-zero"); From d19348c6a075c85479ec4a652745092da4516f30 Mon Sep 17 00:00:00 2001 From: didi Date: Thu, 11 Dec 2025 11:18:03 +0100 Subject: [PATCH 05/44] added method for withdrawing surplus --- .../gov/SuperfluidGovernanceBase.sol | 7 -- .../interfaces/superfluid/ISuperToken.sol | 7 -- .../interfaces/superfluid/IYieldBackend.sol | 12 +- .../contracts/superfluid/AaveYieldBackend.sol | 16 ++- .../contracts/superfluid/SuperToken.sol | 26 ++--- .../contracts/superfluid/Superfluid.sol | 5 - .../foundry/superfluid/SuperTokenYield.t.sol | 107 +++++++++++------- .../test/foundry/superfluid/Superfluid.t.sol | 13 --- 8 files changed, 104 insertions(+), 89 deletions(-) diff --git a/packages/ethereum-contracts/contracts/gov/SuperfluidGovernanceBase.sol b/packages/ethereum-contracts/contracts/gov/SuperfluidGovernanceBase.sol index bad1b3c59c..02d8beb2fd 100644 --- a/packages/ethereum-contracts/contracts/gov/SuperfluidGovernanceBase.sol +++ b/packages/ethereum-contracts/contracts/gov/SuperfluidGovernanceBase.sol @@ -141,13 +141,6 @@ abstract contract SuperfluidGovernanceBase is ISuperfluidGovernance host.changeSuperTokenAdmin(token, newAdmin); } - function setSuperTokenYieldBackend(ISuperfluid host, ISuperToken token, address yieldBackend) - external - onlyAuthorized(host) - { - token.setYieldBackend(yieldBackend); - } - function batchChangeSuperTokenAdmin(ISuperfluid host, ISuperToken[] calldata token, address[] calldata newAdmins) external onlyAuthorized(host) diff --git a/packages/ethereum-contracts/contracts/interfaces/superfluid/ISuperToken.sol b/packages/ethereum-contracts/contracts/interfaces/superfluid/ISuperToken.sol index 970846388d..19cff9ee64 100644 --- a/packages/ethereum-contracts/contracts/interfaces/superfluid/ISuperToken.sol +++ b/packages/ethereum-contracts/contracts/interfaces/superfluid/ISuperToken.sol @@ -71,13 +71,6 @@ interface ISuperToken is ISuperfluidToken, IERC20Metadata, IERC777, IERC20Permit */ function getAdmin() external view returns (address admin); - /** - * @notice Sets the yield backend for the SuperToken - * @dev Only the admin can call this function - * @param yieldBackend Address of the yield backend contract, or address(0) to disable the yield backend - */ - function setYieldBackend(address yieldBackend) external; - function getYieldBackend() external view returns (address yieldBackend); /************************************************************************** diff --git a/packages/ethereum-contracts/contracts/interfaces/superfluid/IYieldBackend.sol b/packages/ethereum-contracts/contracts/interfaces/superfluid/IYieldBackend.sol index be666e3c3e..385b6a099f 100644 --- a/packages/ethereum-contracts/contracts/interfaces/superfluid/IYieldBackend.sol +++ b/packages/ethereum-contracts/contracts/interfaces/superfluid/IYieldBackend.sol @@ -11,11 +11,19 @@ pragma solidity ^0.8.23; * one -> no means withdraw not in the context of a downgrade. */ interface IYieldBackend { - function init() external; - function deinit() external; + /// Invoked by `SuperToken.enableYieldBackend()` as delegatecall. + /// Sets up the SuperToken as needed, e.g. by giving required approvals. + function enable() external; + + /// Invoked by `SuperToken.disableYieldBackend()` as delegatecall. + /// Restores the prior state, e.g. by revoking given approvals + function disable() external; function deposit(uint256 amount) external; function depositMax() external; function withdraw(uint256 amount) external; function withdrawMax() external; + + /// tranfers the deposited asset exceeding the required underlying to the preset treasury account + function withdrawSurplus(uint256 totalSupply) external; } diff --git a/packages/ethereum-contracts/contracts/superfluid/AaveYieldBackend.sol b/packages/ethereum-contracts/contracts/superfluid/AaveYieldBackend.sol index 23e2fd3386..ee027575bc 100644 --- a/packages/ethereum-contracts/contracts/superfluid/AaveYieldBackend.sol +++ b/packages/ethereum-contracts/contracts/superfluid/AaveYieldBackend.sol @@ -2,8 +2,8 @@ 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"; -import { IERC20 } from "../interfaces/superfluid/ISuperfluid.sol"; /** @@ -17,6 +17,8 @@ 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) @@ -33,12 +35,12 @@ contract AaveYieldBackend is IYieldBackend { A_TOKEN = IERC20(aavePool.getReserveAToken(address(assetToken))); } - function init() external { + function enable() external { // approve Aave pool to fetch asset ASSET_TOKEN.approve(address(AAVE_POOL), type(uint256).max); } - function deinit() external { + function disable() external { // Revoke approval ASSET_TOKEN.approve(address(AAVE_POOL), 0); } @@ -66,4 +68,12 @@ contract AaveYieldBackend is IYieldBackend { // 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 + uint256 surplusAmount = A_TOKEN.balanceOf(address(this)) - normalizedTotalSupply - 1; + AAVE_POOL.withdraw(address(ASSET_TOKEN), surplusAmount, SURPLUS_RECEIVER); + } } diff --git a/packages/ethereum-contracts/contracts/superfluid/SuperToken.sol b/packages/ethereum-contracts/contracts/superfluid/SuperToken.sol index ddb8788b09..1a70f84ad8 100644 --- a/packages/ethereum-contracts/contracts/superfluid/SuperToken.sol +++ b/packages/ethereum-contracts/contracts/superfluid/SuperToken.sol @@ -198,20 +198,11 @@ contract SuperToken is } } - function setYieldBackend(address newYieldBackend) external onlyAdmin { - if (address(_yieldBackend) != address(0)) { - _disableYieldBackend(); - } - if (address(newYieldBackend) != address(0)) { - _enableYieldBackend(IYieldBackend(newYieldBackend)); - } - } - - function _enableYieldBackend(IYieldBackend newYieldBackend) internal { + function enableYieldBackend(IYieldBackend newYieldBackend) external onlyAdmin { require(address(_yieldBackend) == address(0), "yield backend already set"); _yieldBackend = newYieldBackend; (bool success, ) = address(_yieldBackend).delegatecall( - abi.encodeCall(IYieldBackend.initSuperToken, ()) + abi.encodeCall(IYieldBackend.enable, ()) ); require(success, "delegatecall failed"); (success, ) = address(_yieldBackend).delegatecall( @@ -222,13 +213,14 @@ contract SuperToken is } // withdraws everything and removes allowances - function _disableYieldBackend() internal { + 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.deinitSuperToken, ()) + abi.encodeCall(IYieldBackend.disable, ()) ); // TODO: should this be allowed to fail? require(success, "delegatecall failed"); @@ -240,6 +232,14 @@ contract SuperToken is 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 *************************************************************************/ diff --git a/packages/ethereum-contracts/contracts/superfluid/Superfluid.sol b/packages/ethereum-contracts/contracts/superfluid/Superfluid.sol index b1f88b3655..cbd88198b6 100644 --- a/packages/ethereum-contracts/contracts/superfluid/Superfluid.sol +++ b/packages/ethereum-contracts/contracts/superfluid/Superfluid.sol @@ -19,7 +19,6 @@ import { ISuperTokenFactory, IAccessControl } from "../interfaces/superfluid/ISuperfluid.sol"; -import { IYieldBackend } from "../interfaces/superfluid/IYieldBackend.sol"; import { GeneralDistributionAgreementV1 } from "../agreements/gdav1/GeneralDistributionAgreementV1.sol"; import { SuperfluidUpgradeableBeacon } from "../upgradability/SuperfluidUpgradeableBeacon.sol"; import { CallUtils } from "../libs/CallUtils.sol"; @@ -333,10 +332,6 @@ contract Superfluid is token.changeAdmin(newAdmin); } - function setSuperTokenYieldBackend(ISuperToken token, address yieldBackend) external onlyGovernance { - token.setYieldBackend(yieldBackend); - } - //////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // Superfluid Upgradeable Beacon //////////////////////////////////////////////////////////////////////////////////////////////////////////////////// diff --git a/packages/ethereum-contracts/test/foundry/superfluid/SuperTokenYield.t.sol b/packages/ethereum-contracts/test/foundry/superfluid/SuperTokenYield.t.sol index 5a6a3f2766..f733eb5697 100644 --- a/packages/ethereum-contracts/test/foundry/superfluid/SuperTokenYield.t.sol +++ b/packages/ethereum-contracts/test/foundry/superfluid/SuperTokenYield.t.sol @@ -15,6 +15,7 @@ import { IPool } from "aave-v3/interfaces/IPool.sol"; */ contract SuperTokenYieldForkTest is Test { address constant ALICE = address(0x420); + address constant ADMIN = address(0xAAA); // Base network constants uint256 internal constant CHAIN_ID = 8453; @@ -37,11 +38,6 @@ contract SuperTokenYieldForkTest is Test { /// @notice Aave V3 Pool contract IPool public aavePool; - /// @notice Admin address (this contract) - address public admin; - /// @notice Test user address - address public user; - /// @notice Set up the test environment by forking Base and deploying AaveYieldBackend function setUp() public { // Fork Base using public RPC @@ -50,10 +46,6 @@ contract SuperTokenYieldForkTest is Test { // Verify we're on Base assertEq(block.chainid, CHAIN_ID, "Chainid mismatch"); - // Initialize test accounts - admin = address(this); - user = address(0x1234); - // Get Aave Pool aavePool = IPool(AAVE_POOL); @@ -77,14 +69,33 @@ contract SuperTokenYieldForkTest is Test { superToken.updateCode(address(newSuperTokenLogic)); vm.stopPrank(); + // designate an admin for the SuperToken + vm.startPrank(address(superToken.getHost())); + superToken.changeAdmin(ADMIN); + vm.stopPrank(); + + // provide ALICE with underlying and let her approve for upgrade + deal(USDC, ALICE, type(uint128).max); + vm.startPrank(ALICE); + IERC20(USDC).approve(address(superToken), type(uint256).max); + console.log("aaveBackend address", address(aaveBackend)); } function _enableYieldBackend() public { - vm.startPrank(address(superToken.getHost())); - superToken.setYieldBackend(address(aaveBackend)); + vm.startPrank(ADMIN); + superToken.enableYieldBackend(aaveBackend); vm.stopPrank(); } + + function _verifyInvariants() internal view { + // underlyingBalance + aTokenBalance >= superToken.supply() + uint256 underlyingBalance = IERC20(USDC).balanceOf(address(superToken)); + uint256 aTokenBalance = IERC20(aUSDC).balanceOf(address(superToken)); + (uint256 superTokenNormalizedSupply,) = superToken.toUnderlyingAmount(superToken.totalSupply()); + + assertGe(underlyingBalance + aTokenBalance, superTokenNormalizedSupply, "invariant failed: underlyingBalance + aTokenBalance insufficient"); + } /// @notice Test that we're forking the correct Base network function testForkBaseNetwork() public view { @@ -116,16 +127,14 @@ contract SuperTokenYieldForkTest is Test { console.log("aUSDC balance of SuperToken", IERC20(aUSDC).balanceOf(address(superToken))); // TODO: We'd want asset balance to equal aToken balance. But that's not exactly the case. // what else shall be require? + _verifyInvariants(); } function testDisableYieldBackend() public { - // store underlying balance before enabling yield backend - uint256 underlyingBalanceBefore = IERC20(USDC).balanceOf(address(superToken)); - _enableYieldBackend(); - vm.startPrank(address(superToken.getHost())); - superToken.setYieldBackend(address(0)); + vm.startPrank(ADMIN); + superToken.disableYieldBackend(); vm.stopPrank(); assertEq(address(superToken.getYieldBackend()), address(0), "Yield backend mismatch"); @@ -133,20 +142,15 @@ contract SuperTokenYieldForkTest is Test { assertGt(IERC20(USDC).balanceOf(address(superToken)), 0, "USDC balance should be non-zero"); assertEq(IERC20(aUSDC).balanceOf(address(superToken)), 0, "aUSDC balance should be zero"); - // get underlying balance after disabling yield backend - uint256 underlyingBalanceAfter = IERC20(USDC).balanceOf(address(superToken)); - //assertEq(underlyingBalanceAfter, underlyingBalanceBefore, "Underlying balance should be the same"); + _verifyInvariants(); } // TODO: bool fuzz arg for disabled/enabled backend function testUpgradeDowngrade() public { _enableYieldBackend(); - deal(USDC, ALICE, 1000 ether); - uint256 aTokenBalanceBefore = IERC20(aUSDC).balanceOf(address(superToken)); vm.startPrank(ALICE); - IERC20(USDC).approve(address(superToken), type(uint256).max); superToken.upgrade(1 ether); vm.stopPrank(); @@ -167,7 +171,7 @@ contract SuperTokenYieldForkTest is Test { superToken.downgrade(1 ether); vm.stopPrank(); - uint256 aTokenBalanceAfterDowngrade = IERC20(aUSDC).balanceOf(address(superToken)); + _verifyInvariants(); } // ============ Gas Benchmarking Tests ============ @@ -176,19 +180,13 @@ contract SuperTokenYieldForkTest is Test { /// @dev Separate test function to avoid cold/warm storage slot interference function testGasUpgrade_WithoutYieldBackend() public { // Ensure yield backend is NOT set - vm.startPrank(address(superToken.getHost())); - superToken.setYieldBackend(address(0)); - vm.stopPrank(); assertEq(address(superToken.getYieldBackend()), address(0), "Yield backend should not be set"); // Prepare test state // 1000 USDC = 1000 * 1e6 (USDC has 6 decimals) // In SuperToken units (18 decimals), this is 1000 * 1e18 uint256 upgradeAmount = 1000 * 1e18; - deal(USDC, ALICE, 1000 * 1e6); vm.startPrank(ALICE); - IERC20(USDC).approve(address(superToken), type(uint256).max); - // Measure gas for upgrade uint256 gasBefore = gasleft(); superToken.upgrade(upgradeAmount); @@ -211,10 +209,7 @@ contract SuperTokenYieldForkTest is Test { // 1000 USDC = 1000 * 1e6 (USDC has 6 decimals) // In SuperToken units (18 decimals), this is 1000 * 1e18 uint256 upgradeAmount = 1000 * 1e18; - deal(USDC, ALICE, 1000 * 1e6); vm.startPrank(ALICE); - IERC20(USDC).approve(address(superToken), type(uint256).max); - // Measure gas for upgrade uint256 gasBefore = gasleft(); superToken.upgrade(upgradeAmount); @@ -230,17 +225,13 @@ contract SuperTokenYieldForkTest is Test { /// @dev Separate test function to avoid cold/warm storage slot interference function testGasDowngrade_WithoutYieldBackend() public { // Ensure yield backend is NOT set - vm.startPrank(address(superToken.getHost())); - superToken.setYieldBackend(address(0)); - vm.stopPrank(); + assertEq(address(superToken.getYieldBackend()), address(0), "Yield backend should not be set"); // First, upgrade some tokens for ALICE to downgrade later // 1000 USDC = 1000 * 1e6 (USDC has 6 decimals) // In SuperToken units (18 decimals), this is 1000 * 1e18 uint256 initialUpgradeAmount = 1000 * 1e18; - deal(USDC, ALICE, 1000 * 1e6); vm.startPrank(ALICE); - IERC20(USDC).approve(address(superToken), type(uint256).max); superToken.upgrade(initialUpgradeAmount); vm.stopPrank(); @@ -270,9 +261,7 @@ contract SuperTokenYieldForkTest is Test { // 1000 USDC = 1000 * 1e6 (USDC has 6 decimals) // In SuperToken units (18 decimals), this is 1000 * 1e18 uint256 initialUpgradeAmount = 1000 * 1e18; - deal(USDC, ALICE, 1000 * 1e6); vm.startPrank(ALICE); - IERC20(USDC).approve(address(superToken), type(uint256).max); superToken.upgrade(initialUpgradeAmount); vm.stopPrank(); @@ -291,5 +280,45 @@ contract SuperTokenYieldForkTest is Test { console.log("Gas used", gasUsed); console.log("Amount downgraded", amountToDowngrade); } + + function testWithdrawSurplusFromYieldBackend() public { + address SURPLUS_RECEIVER = 0xac808840f02c47C05507f48165d2222FF28EF4e1; + + // Simulate yield accumulation by transferring extra underlying to SuperToken + uint256 surplusAmount = 100 * 1e6; // 100 USDC + deal(USDC, address(this), surplusAmount); + + _enableYieldBackend(); + + // Upgrade tokens to create supply + uint256 upgradeAmount = 1000 * 1e18; + vm.startPrank(ALICE); + superToken.upgrade(upgradeAmount); + vm.stopPrank(); + + uint256 receiverBalanceBefore = IERC20(USDC).balanceOf(SURPLUS_RECEIVER); + uint256 aTokenBalanceBefore = IERC20(aUSDC).balanceOf(address(superToken)); + + // log USDC and aUSDC balances of SuperToken + console.log("USDC balance of SuperToken", IERC20(USDC).balanceOf(address(superToken))); + console.log("aUSDC balance of SuperToken", IERC20(aUSDC).balanceOf(address(superToken))); + // log normalized total supply + (uint256 normalizedTotalSupply, uint256 adjustedAmount) = superToken.toUnderlyingAmount(superToken.totalSupply()); + console.log("normalized total supply", normalizedTotalSupply); + console.log("adjusted amount", adjustedAmount); + + vm.startPrank(ADMIN); + superToken.withdrawSurplusFromYieldBackend(); + vm.stopPrank(); + + uint256 receiverBalanceAfter = IERC20(USDC).balanceOf(SURPLUS_RECEIVER); + uint256 aTokenBalanceAfter = IERC20(aUSDC).balanceOf(address(superToken)); + console.log("aToken balance after", aTokenBalanceAfter); + console.log("aToken balance diff", aTokenBalanceBefore - aTokenBalanceAfter); + + assertGt(receiverBalanceAfter, receiverBalanceBefore, "Surplus should be withdrawn to receiver"); + assertLt(aTokenBalanceAfter, aTokenBalanceBefore, "aToken balance should decrease"); + _verifyInvariants(); + } } diff --git a/packages/ethereum-contracts/test/foundry/superfluid/Superfluid.t.sol b/packages/ethereum-contracts/test/foundry/superfluid/Superfluid.t.sol index a272952ace..9721897ca6 100644 --- a/packages/ethereum-contracts/test/foundry/superfluid/Superfluid.t.sol +++ b/packages/ethereum-contracts/test/foundry/superfluid/Superfluid.t.sol @@ -89,19 +89,6 @@ contract SuperfluidIntegrationTest is FoundrySuperfluidTester { vm.stopPrank(); } - function testSetSuperTokenYieldBackend(address yieldBackend) public { - // revert if not called by governance - vm.expectRevert(ISuperfluid.HOST_ONLY_GOVERNANCE.selector); - sf.host.setSuperTokenYieldBackend(superToken, yieldBackend); - - // succeed if called by governance - vm.startPrank(address(sf.governance)); - sf.host.setSuperTokenYieldBackend(superToken, yieldBackend); - vm.stopPrank(); - - assertEq(address(superToken.getYieldBackend()), yieldBackend, "Superfluid.t: super token yield backend not set"); - } - function testSuperAppRegistrationViaSimpleACL() public { SimpleACL simpleAcl = new SimpleACL(); Superfluid hostWithSimpleACL = new Superfluid( From 04a4e1c7f7df73ee857a223cd71bec4e55d94e85 Mon Sep 17 00:00:00 2001 From: didi Date: Thu, 11 Dec 2025 11:30:20 +0100 Subject: [PATCH 06/44] natspec --- .../interfaces/superfluid/IYieldBackend.sol | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/packages/ethereum-contracts/contracts/interfaces/superfluid/IYieldBackend.sol b/packages/ethereum-contracts/contracts/interfaces/superfluid/IYieldBackend.sol index 385b6a099f..34843d6577 100644 --- a/packages/ethereum-contracts/contracts/interfaces/superfluid/IYieldBackend.sol +++ b/packages/ethereum-contracts/contracts/interfaces/superfluid/IYieldBackend.sol @@ -9,21 +9,40 @@ pragma solidity ^0.8.23; * 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.enableYieldBackend()` as delegatecall. + /// Invoked by `SuperToken` as delegatecall. /// Sets up the SuperToken as needed, e.g. by giving required approvals. function enable() external; - /// Invoked by `SuperToken.disableYieldBackend()` as delegatecall. + /// 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; - /// tranfers the deposited asset exceeding the required underlying to the preset treasury account + /// Invoked by `SuperToken` as delegatecall. + /// tranfers the deposited asset exceeding totalSupply of the SuperToken to the preset receiver account function withdrawSurplus(uint256 totalSupply) external; } From 19624e6d9c5389f22ff35bd1ad0e4fd491e1dc7d Mon Sep 17 00:00:00 2001 From: didi Date: Fri, 12 Dec 2025 10:40:48 +0100 Subject: [PATCH 07/44] made it work for SETH --- .../contracts/superfluid/AaveYieldBackend.sol | 54 +++++++++++++++-- .../contracts/superfluid/SuperToken.sol | 27 ++++++++- packages/ethereum-contracts/foundry.toml | 2 +- .../foundry/superfluid/SuperTokenYield.t.sol | 60 +++++++++++++++++++ 4 files changed, 134 insertions(+), 9 deletions(-) diff --git a/packages/ethereum-contracts/contracts/superfluid/AaveYieldBackend.sol b/packages/ethereum-contracts/contracts/superfluid/AaveYieldBackend.sol index ee027575bc..74c97ffda2 100644 --- a/packages/ethereum-contracts/contracts/superfluid/AaveYieldBackend.sol +++ b/packages/ethereum-contracts/contracts/superfluid/AaveYieldBackend.sol @@ -4,6 +4,7 @@ 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"; +import { IWETH } from "aave-v3//helpers/interfaces/IWETH.sol"; /** @@ -17,9 +18,12 @@ contract AaveYieldBackend is IYieldBackend { IERC20 public immutable ASSET_TOKEN; IPool public immutable AAVE_POOL; IERC20 public immutable A_TOKEN; + bool public immutable USING_WETH; // TODO: make an immutable address constant SURPLUS_RECEIVER = 0xac808840f02c47C05507f48165d2222FF28EF4e1; // dao.superfluid.eth + AaveYieldBackend internal immutable _SELF; + // THIS CONTRACT CANNOT HAVE STATE VARIABLES! // IF STATE IS NEEDED, USE NAMESPACED STORAGE LAYOUT (EIP-7201) @@ -30,9 +34,23 @@ contract AaveYieldBackend is IYieldBackend { */ constructor(IERC20 assetToken, IPool aavePool) { // TODO: any checks to be done? - ASSET_TOKEN = assetToken; + 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); - A_TOKEN = IERC20(aavePool.getReserveAToken(address(assetToken))); + A_TOKEN = IERC20(aavePool.getReserveAToken(address(ASSET_TOKEN))); + + _SELF = this; } function enable() external { @@ -45,23 +63,34 @@ contract AaveYieldBackend is IYieldBackend { ASSET_TOKEN.approve(address(AAVE_POOL), 0); } - function deposit(uint256 amount) external { + 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 = ASSET_TOKEN.balanceOf(address(this)); + uint256 amount = USING_WETH ? + address(this).balance : + ASSET_TOKEN.balanceOf(address(this)); if (amount > 0) { - AAVE_POOL.supply(address(ASSET_TOKEN), amount, address(this), 0); + deposit(amount); } } function withdraw(uint256 amount) external { // withdraw amount asset by redeeming the corresponding aTokens amount - AAVE_POOL.withdraw(address(ASSET_TOKEN), amount, address(this)); + 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 { @@ -76,4 +105,17 @@ contract AaveYieldBackend is IYieldBackend { uint256 surplusAmount = A_TOKEN.balanceOf(address(this)) - normalizedTotalSupply - 1; AAVE_POOL.withdraw(address(ASSET_TOKEN), surplusAmount, SURPLUS_RECEIVER); } + + // ============ 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"); + } } diff --git a/packages/ethereum-contracts/contracts/superfluid/SuperToken.sol b/packages/ethereum-contracts/contracts/superfluid/SuperToken.sol index 1a70f84ad8..e23ef4071d 100644 --- a/packages/ethereum-contracts/contracts/superfluid/SuperToken.sol +++ b/packages/ethereum-contracts/contracts/superfluid/SuperToken.sol @@ -105,6 +105,9 @@ contract SuperToken is // NOTE: You cannot add more storage here. Refer to CustomSuperTokenBase.sol // to see the hard-coded storage padding used by SETH and PureSuperToken + // set when withdrawing ETH from yield backend in order to avoid a burn/mint loop + bool transient internal _skipSelfMint; + constructor( ISuperfluid host, IPoolAdminNFT poolAdminNFT @@ -772,8 +775,18 @@ contract SuperToken is external virtual override onlySelf { - _mint(msg.sender, account, amount, userData.length != 0 /* invokeHook */, - userData.length != 0 /* requireReceptionAck */, userData, new bytes(0)); + if (!_skipSelfMint) { + if (address(_yieldBackend) != address(0)) { + // TODO: shall we deposit all, or just the upgradeAmount? + (bool success, ) = address(_yieldBackend).delegatecall( + abi.encodeCall(IYieldBackend.deposit, (amount)) + ); + require(success, "delegatecall failed"); + } + + _mint(msg.sender, account, amount, userData.length != 0 /* invokeHook */, + userData.length != 0 /* requireReceptionAck */, userData, new bytes(0)); + } } function selfBurn( @@ -785,6 +798,16 @@ contract SuperToken is onlySelf { _burn(msg.sender, account, amount, userData.length != 0 /* invokeHook */, userData, new bytes(0)); + + if (address(_yieldBackend) != address(0)) { + _skipSelfMint = true; + // TODO: we may want to skip if enough underlying already in the contract + (bool success, ) = address(_yieldBackend).delegatecall( + abi.encodeCall(IYieldBackend.withdraw, (amount)) + ); + require(success, "delegatecall failed"); + _skipSelfMint = false; + } } function selfApproveFor( diff --git a/packages/ethereum-contracts/foundry.toml b/packages/ethereum-contracts/foundry.toml index 7fe628a2f7..b9c04d51e8 100644 --- a/packages/ethereum-contracts/foundry.toml +++ b/packages/ethereum-contracts/foundry.toml @@ -8,7 +8,7 @@ ignored_error_codes = [ 1699 # assembly { selfdestruct } in contracts/mocks/SuperfluidDestructorMock.sol ] # keep in sync with truffle-config.js -evm_version = 'shanghai' +evm_version = 'cancun' optimizer = true optimizer_runs = 200 remappings = [ diff --git a/packages/ethereum-contracts/test/foundry/superfluid/SuperTokenYield.t.sol b/packages/ethereum-contracts/test/foundry/superfluid/SuperTokenYield.t.sol index f733eb5697..ec4ef640bd 100644 --- a/packages/ethereum-contracts/test/foundry/superfluid/SuperTokenYield.t.sol +++ b/packages/ethereum-contracts/test/foundry/superfluid/SuperTokenYield.t.sol @@ -7,6 +7,7 @@ import { AaveYieldBackend } from "../../../contracts/superfluid/AaveYieldBackend import { IERC20, ISuperfluid } from "../../../contracts/interfaces/superfluid/ISuperfluid.sol"; import { SuperToken } from "../../../contracts/superfluid/SuperToken.sol"; import { IPool } from "aave-v3/interfaces/IPool.sol"; +import { ISETH } from "../../../contracts/interfaces/tokens/ISETH.sol"; /** * @title SuperTokenYieldForkTest @@ -29,6 +30,8 @@ contract SuperTokenYieldForkTest is Test { address internal constant USDC = 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913; // USDC on Base address internal constant WETH = 0x4200000000000000000000000000000000000006; // WETH on Base address internal constant aUSDC = 0x4e65fE4DbA92790696d040ac24Aa414708F5c0AB; // aUSDC on Base + + address internal constant ETHx = 0x46fd5cfB4c12D87acD3a13e92BAa53240C661D93; // ETHx on Base SuperToken public superToken; /// @notice AaveYieldBackend contract instance @@ -80,6 +83,7 @@ contract SuperTokenYieldForkTest is Test { IERC20(USDC).approve(address(superToken), type(uint256).max); console.log("aaveBackend address", address(aaveBackend)); + console.log("aUSDC address", address(aUSDC)); } function _enableYieldBackend() public { @@ -320,5 +324,61 @@ contract SuperTokenYieldForkTest is Test { assertLt(aTokenBalanceAfter, aTokenBalanceBefore, "aToken balance should decrease"); _verifyInvariants(); } + + function testUpgadeDowngradeETH() public { + // Get aWETH address from Aave pool + address aWETH = aavePool.getReserveAToken(WETH); + + // Set up ETHx + SuperToken ethxToken = SuperToken(ETHx); + + // Upgrade ETHx to new logic + SuperToken newSuperTokenLogic = new SuperToken(ISuperfluid(ethxToken.getHost()), ethxToken.POOL_ADMIN_NFT()); + vm.startPrank(address(ethxToken.getHost())); + ethxToken.updateCode(address(newSuperTokenLogic)); + vm.stopPrank(); + + // Designate admin for ETHx + vm.startPrank(address(ethxToken.getHost())); + ethxToken.changeAdmin(ADMIN); + vm.stopPrank(); + + // Deploy AaveBackend for native ETH (address(0)) + AaveYieldBackend ethxBackend = new AaveYieldBackend(IERC20(address(0)), IPool(AAVE_POOL)); + + // assert that USING_WETH is set + assertEq(ethxBackend.USING_WETH(), true); + + // Enable yield backend + vm.startPrank(ADMIN); + ethxToken.enableYieldBackend(ethxBackend); + vm.stopPrank(); + + // Give ALICE some ETH + vm.deal(ALICE, 10 ether); + + // Upgrade ETH using upgradeByETH + uint256 upgradeAmount = 1 ether; + vm.startPrank(ALICE); + ISETH(address(ethxToken)).upgradeByETH{value: upgradeAmount}(); + vm.stopPrank(); + + uint256 aliceBalance = ethxToken.balanceOf(ALICE); + assertGt(aliceBalance, 0, "ALICE should have ETHx tokens"); + + // Verify aWETH balance increased + uint256 aWETHBalance = IERC20(aWETH).balanceOf(address(ethxToken)); + assertGt(aWETHBalance, 0, "ETHx should have aWETH balance"); + + // Downgrade using downgradeToETH + uint256 aliceETHBefore = ALICE.balance; + vm.startPrank(ALICE); + ISETH(address(ethxToken)).downgradeToETH(aliceBalance); + vm.stopPrank(); + + uint256 aliceETHAfter = ALICE.balance; + assertGt(aliceETHAfter, aliceETHBefore, "ALICE should receive ETH back"); + assertEq(ethxToken.balanceOf(ALICE), 0, "ALICE should have no ETHx tokens"); + } } From 7db294020ba2e3f516f75f14e0efcf18281da6f7 Mon Sep 17 00:00:00 2001 From: didi Date: Fri, 12 Dec 2025 11:00:21 +0100 Subject: [PATCH 08/44] use helper lib to reduce boilerplate code around delegatecall --- .../contracts/libs/YieldBackendHelperLib.sol | 17 +++++++ .../contracts/superfluid/AaveYieldBackend.sol | 2 +- .../contracts/superfluid/SuperToken.sol | 48 +++++-------------- 3 files changed, 29 insertions(+), 38 deletions(-) create mode 100644 packages/ethereum-contracts/contracts/libs/YieldBackendHelperLib.sol diff --git a/packages/ethereum-contracts/contracts/libs/YieldBackendHelperLib.sol b/packages/ethereum-contracts/contracts/libs/YieldBackendHelperLib.sol new file mode 100644 index 0000000000..eb91bd000c --- /dev/null +++ b/packages/ethereum-contracts/contracts/libs/YieldBackendHelperLib.sol @@ -0,0 +1,17 @@ +// 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 { + (bool success,) = address(yieldBackend).delegatecall(callData); + require(success, "yield backend delegatecall failed"); + } +} \ No newline at end of file diff --git a/packages/ethereum-contracts/contracts/superfluid/AaveYieldBackend.sol b/packages/ethereum-contracts/contracts/superfluid/AaveYieldBackend.sol index 74c97ffda2..b4a51ea90e 100644 --- a/packages/ethereum-contracts/contracts/superfluid/AaveYieldBackend.sol +++ b/packages/ethereum-contracts/contracts/superfluid/AaveYieldBackend.sol @@ -4,7 +4,7 @@ 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"; -import { IWETH } from "aave-v3//helpers/interfaces/IWETH.sol"; +import { IWETH } from "aave-v3/helpers/interfaces/IWETH.sol"; /** diff --git a/packages/ethereum-contracts/contracts/superfluid/SuperToken.sol b/packages/ethereum-contracts/contracts/superfluid/SuperToken.sol index e23ef4071d..27971c4f6e 100644 --- a/packages/ethereum-contracts/contracts/superfluid/SuperToken.sol +++ b/packages/ethereum-contracts/contracts/superfluid/SuperToken.sol @@ -12,6 +12,7 @@ import { IPoolAdminNFT } from "../interfaces/superfluid/ISuperfluid.sol"; import { IYieldBackend } from "../interfaces/superfluid/IYieldBackend.sol"; +import { YieldBackendHelperLib } from "../libs/YieldBackendHelperLib.sol"; import { SuperfluidToken } from "./SuperfluidToken.sol"; import { ERC777Helper } from "../libs/ERC777Helper.sol"; import { SafeERC20 } from "@openzeppelin-v5/contracts/token/ERC20/utils/SafeERC20.sol"; @@ -38,6 +39,7 @@ contract SuperToken is using SafeCast for uint256; using ERC777Helper for ERC777Helper.Operators; using SafeERC20 for IERC20; + using YieldBackendHelperLib for IYieldBackend; // See: https://eips.ethereum.org/EIPS/eip-1967#admin-address bytes32 constant private _ADMIN_SLOT = 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103; @@ -204,29 +206,16 @@ 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( - abi.encodeCall(IYieldBackend.enable, ()) - ); - require(success, "delegatecall failed"); - (success, ) = address(_yieldBackend).delegatecall( - abi.encodeCall(IYieldBackend.depositMax, ()) - ); - require(success, "delegatecall failed"); + _yieldBackend.dCall(abi.encodeCall(IYieldBackend.enable, ())); + _yieldBackend.dCall(abi.encodeCall(IYieldBackend.depositMax, ())); // 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.dCall(abi.encodeCall(IYieldBackend.withdrawMax, ())); + _yieldBackend.dCall(abi.encodeCall(IYieldBackend.disable, ())); _yieldBackend = IYieldBackend(address(0)); // TODO: emit event } @@ -237,10 +226,7 @@ contract SuperToken is 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"); + _yieldBackend.dCall(abi.encodeCall(IYieldBackend.withdrawSurplus, (_totalSupply))); } /************************************************************************** @@ -778,10 +764,7 @@ contract SuperToken is if (!_skipSelfMint) { if (address(_yieldBackend) != address(0)) { // TODO: shall we deposit all, or just the upgradeAmount? - (bool success, ) = address(_yieldBackend).delegatecall( - abi.encodeCall(IYieldBackend.deposit, (amount)) - ); - require(success, "delegatecall failed"); + _yieldBackend.dCall(abi.encodeCall(IYieldBackend.deposit, (amount))); } _mint(msg.sender, account, amount, userData.length != 0 /* invokeHook */, @@ -802,10 +785,7 @@ contract SuperToken is if (address(_yieldBackend) != address(0)) { _skipSelfMint = true; // TODO: we may want to skip if enough underlying already in the contract - (bool success, ) = address(_yieldBackend).delegatecall( - abi.encodeCall(IYieldBackend.withdraw, (amount)) - ); - require(success, "delegatecall failed"); + _yieldBackend.dCall(abi.encodeCall(IYieldBackend.withdraw, (amount))); _skipSelfMint = false; } } @@ -908,10 +888,7 @@ contract SuperToken is if (address(_yieldBackend) != address(0)) { // TODO: shall we deposit all, or just the upgradeAmount? - (bool success, ) = address(_yieldBackend).delegatecall( - abi.encodeCall(IYieldBackend.deposit, (actualUpgradedAmount)) - ); - require(success, "delegatecall failed"); + _yieldBackend.dCall(abi.encodeCall(IYieldBackend.deposit, (actualUpgradedAmount))); } _mint(operator, to, adjustedAmount, @@ -938,10 +915,7 @@ contract SuperToken is 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"); + _yieldBackend.dCall(abi.encodeCall(IYieldBackend.withdraw, (underlyingAmount))); } uint256 amountBefore = _underlyingToken.balanceOf(address(this)); From 0e4c436dfbbc5eb1bceee66dbd1dee2418fc3be1 Mon Sep 17 00:00:00 2001 From: didi Date: Fri, 12 Dec 2025 11:01:07 +0100 Subject: [PATCH 09/44] remove duplicate submodule entry --- .gitmodules | 3 --- 1 file changed, 3 deletions(-) diff --git a/.gitmodules b/.gitmodules index 426149dfa5..d4eca99945 100644 --- a/.gitmodules +++ b/.gitmodules @@ -5,9 +5,6 @@ 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 From 8b9979c18409309fca2fedcc2a48bdba9beecff3 Mon Sep 17 00:00:00 2001 From: didi Date: Thu, 18 Dec 2025 20:41:34 +0100 Subject: [PATCH 10/44] added missing storage slot test, interface doc, updated solhint --- packages/ethereum-contracts/.solhint.json | 12 +++++++++++- .../contracts/agreements/AgreementLibrary.sol | 1 - .../contracts/interfaces/superfluid/ISuperToken.sol | 9 +++++++++ .../contracts/libs/YieldBackendHelperLib.sol | 1 + .../contracts/mocks/SuperTokenMock.t.sol | 6 ++++++ 5 files changed, 27 insertions(+), 2 deletions(-) diff --git a/packages/ethereum-contracts/.solhint.json b/packages/ethereum-contracts/.solhint.json index 86c3f4d7b6..0f6ea63b82 100644 --- a/packages/ethereum-contracts/.solhint.json +++ b/packages/ethereum-contracts/.solhint.json @@ -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" } } diff --git a/packages/ethereum-contracts/contracts/agreements/AgreementLibrary.sol b/packages/ethereum-contracts/contracts/agreements/AgreementLibrary.sol index 22d4e66ddb..90c257e615 100644 --- a/packages/ethereum-contracts/contracts/agreements/AgreementLibrary.sol +++ b/packages/ethereum-contracts/contracts/agreements/AgreementLibrary.sol @@ -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"; diff --git a/packages/ethereum-contracts/contracts/interfaces/superfluid/ISuperToken.sol b/packages/ethereum-contracts/contracts/interfaces/superfluid/ISuperToken.sol index 19cff9ee64..fc1520089d 100644 --- a/packages/ethereum-contracts/contracts/interfaces/superfluid/ISuperToken.sol +++ b/packages/ethereum-contracts/contracts/interfaces/superfluid/ISuperToken.sol @@ -68,9 +68,18 @@ interface ISuperToken is ISuperfluidToken, IERC20Metadata, IERC777, IERC20Permit /** * @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); /************************************************************************** diff --git a/packages/ethereum-contracts/contracts/libs/YieldBackendHelperLib.sol b/packages/ethereum-contracts/contracts/libs/YieldBackendHelperLib.sol index eb91bd000c..66cb49c938 100644 --- a/packages/ethereum-contracts/contracts/libs/YieldBackendHelperLib.sol +++ b/packages/ethereum-contracts/contracts/libs/YieldBackendHelperLib.sol @@ -11,6 +11,7 @@ import { IYieldBackend } from "../interfaces/superfluid/IYieldBackend.sol"; */ 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"); } diff --git a/packages/ethereum-contracts/contracts/mocks/SuperTokenMock.t.sol b/packages/ethereum-contracts/contracts/mocks/SuperTokenMock.t.sol index 5b0703da9e..ef14ef157d 100644 --- a/packages/ethereum-contracts/contracts/mocks/SuperTokenMock.t.sol +++ b/packages/ethereum-contracts/contracts/mocks/SuperTokenMock.t.sol @@ -64,6 +64,12 @@ contract SuperTokenStorageLayoutTester is SuperToken { require (slot == 18 && offset == 0, "_operators changed location"); // uses 4 slots + 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"); From 806dba1026960ed7abfb49d9e8a07640c0d621c9 Mon Sep 17 00:00:00 2001 From: didi Date: Thu, 18 Dec 2025 20:41:34 +0100 Subject: [PATCH 11/44] added missing storage slot test, interface doc, updated solhint --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9cdea83ffc..7967a812d9 100644 --- a/package.json +++ b/package.json @@ -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", From a75947d273532a6216d8674461eef17ef9aff706 Mon Sep 17 00:00:00 2001 From: didi Date: Fri, 19 Dec 2025 06:32:39 +0100 Subject: [PATCH 12/44] advance hardhat and truffle config to cancun --- package.json | 2 +- packages/ethereum-contracts/foundry.toml | 2 +- packages/ethereum-contracts/hardhat.config.ts | 8 +++- packages/ethereum-contracts/truffle-config.js | 2 +- yarn.lock | 43 +++++++++++++++---- 5 files changed, 45 insertions(+), 12 deletions(-) diff --git a/package.json b/package.json index 7967a812d9..b31238b508 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/ethereum-contracts/foundry.toml b/packages/ethereum-contracts/foundry.toml index b9c04d51e8..949d96e7e1 100644 --- a/packages/ethereum-contracts/foundry.toml +++ b/packages/ethereum-contracts/foundry.toml @@ -15,7 +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/', + 'aave-v3=lib/aave-v3/', 'ds-test/=lib/forge-std/lib/ds-test/src/', 'forge-std/=lib/forge-std/src/'] out = 'packages/ethereum-contracts/build/foundry/default' diff --git a/packages/ethereum-contracts/hardhat.config.ts b/packages/ethereum-contracts/hardhat.config.ts index e8ecc1933f..4d2e1b2cca 100644 --- a/packages/ethereum-contracts/hardhat.config.ts +++ b/packages/ethereum-contracts/hardhat.config.ts @@ -59,6 +59,12 @@ subtask(TASK_COMPILE_SOLIDITY_GET_SOURCE_PATHS).setAction( } ); +/* +Note: for hardhat to find libs in node_modules, they need to look like an npm package, that is they need to have +a package.json at the directory level looking like the package. +E.g. if the first path component is prefixed with @, it will expect a package.json in the nested directory. +*/ + const chainIds = { "eth-mainnet": 1, "eth-sepolia": 11155111, @@ -105,7 +111,7 @@ const config: HardhatUserConfig = { enabled: true, runs: 200, }, - evmVersion: "shanghai", + evmVersion: "cancun", }, }, paths: { diff --git a/packages/ethereum-contracts/truffle-config.js b/packages/ethereum-contracts/truffle-config.js index cc53fc2335..96cd8150b2 100644 --- a/packages/ethereum-contracts/truffle-config.js +++ b/packages/ethereum-contracts/truffle-config.js @@ -389,7 +389,7 @@ const E = (module.exports = { runs: 200, }, // see https://docs.soliditylang.org/en/latest/using-the-compiler.html#target-options - evmVersion: "shanghai", + evmVersion: "cancun", }, }, }, diff --git a/yarn.lock b/yarn.lock index b0396cbb47..c6d5b20400 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1874,6 +1874,11 @@ resolved "https://registry.yarnpkg.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz#af5b2691a22b44be847b0ca81641c5fb6ad0172c" integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA== +"@humanwhocodes/momoa@^2.0.4": + version "2.0.4" + resolved "https://registry.yarnpkg.com/@humanwhocodes/momoa/-/momoa-2.0.4.tgz#8b9e7a629651d15009c3587d07a222deeb829385" + integrity sha512-RE815I4arJFtt+FVeU1Tgp9/Xvecacji8w/V6XtXsWWH/wz/eNkNbhb+ny/+PlVZjV0rxQpRSQKNKE3lcktHEA== + "@humanwhocodes/object-schema@^2.0.2": version "2.0.3" resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz#4a2868d75d6d6963e423bcf90b7fd1be343409d3" @@ -3578,6 +3583,11 @@ resolved "https://registry.yarnpkg.com/@solidity-parser/parser/-/parser-0.18.0.tgz#8e77a02a09ecce957255a2f48c9a7178ec191908" integrity sha512-yfORGUIPgLck41qyN7nbwJRAx17/jAIXCTanHOJZhB6PJ1iAk/84b/xlsVKFSyNyLXIj0dhppoE0+CRws7wlzA== +"@solidity-parser/parser@^0.20.2": + version "0.20.2" + resolved "https://registry.yarnpkg.com/@solidity-parser/parser/-/parser-0.20.2.tgz#e07053488ed60dae1b54f6fe37bb6d2c5fe146a7" + integrity sha512-rbu0bzwNvMcwAjH86hiEAcOeRI2EeK8zCkHDrFykh/Al8mvJeFmjy3UrE7GYQjNwOgbGUUtCn5/k8CB8zIu7QA== + "@standard-schema/spec@^1.0.0": version "1.0.0" resolved "https://registry.yarnpkg.com/@standard-schema/spec/-/spec-1.0.0.tgz#f193b73dc316c4170f2e82a881da0f550d551b9c" @@ -4982,6 +4992,11 @@ aggregate-error@^3.0.0: clean-stack "^2.0.0" indent-string "^4.0.0" +ajv-errors@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/ajv-errors/-/ajv-errors-1.0.1.tgz#f35986aceb91afadec4102fbd85014950cefa64d" + integrity sha512-DCRfO/4nQ+89p/RK43i8Ezd41EqdGIU4ld7nGF8OQ14oc/we5rEntLCUa7+jrn3nn83BosfwZA0wb4pon2o8iQ== + ajv-formats@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/ajv-formats/-/ajv-formats-2.1.1.tgz#6e669400659eb74973bbf2e33327180a0996b520" @@ -5715,6 +5730,17 @@ better-ajv-errors@^0.8.2: jsonpointer "^5.0.0" leven "^3.1.0" +better-ajv-errors@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/better-ajv-errors/-/better-ajv-errors-2.0.2.tgz#868e7b9ea091077de0fca41770995868baa30ed6" + integrity sha512-1cLrJXEq46n0hjV8dDYwg9LKYjDb3KbeW7nZTv4kvfoDD9c2DXHIE31nxM+Y/cIfXMggLUfmxbm6h/JoM/yotA== + dependencies: + "@babel/code-frame" "^7.27.1" + "@humanwhocodes/momoa" "^2.0.4" + chalk "^4.1.2" + jsonpointer "^5.0.1" + leven "^3.1.0 < 4" + big-integer@1.6.36: version "1.6.36" resolved "https://registry.yarnpkg.com/big-integer/-/big-integer-1.6.36.tgz#78631076265d4ae3555c04f85e7d9d2f3a071a36" @@ -11795,7 +11821,7 @@ jsonparse@^1.2.0, jsonparse@^1.3.1: resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280" integrity sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg== -jsonpointer@^5.0.0: +jsonpointer@^5.0.0, jsonpointer@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/jsonpointer/-/jsonpointer-5.0.1.tgz#2110e0af0900fd37467b5907ecd13a7884a1b559" integrity sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ== @@ -12196,7 +12222,7 @@ levelup@^1.2.1: semver "~5.4.1" xtend "~4.0.0" -leven@^3.1.0: +leven@^3.1.0, "leven@^3.1.0 < 4": version "3.1.0" resolved "https://registry.yarnpkg.com/leven/-/leven-3.1.0.tgz#77891de834064cccba82ae7842bb6b14a13ed7f2" integrity sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A== @@ -16374,15 +16400,17 @@ solc@^0.4.20: semver "^5.3.0" yargs "^4.7.1" -solhint@^5.0.3: - version "5.0.3" - resolved "https://registry.yarnpkg.com/solhint/-/solhint-5.0.3.tgz#b57f6d2534fe09a60f9db1b92e834363edd1cbde" - integrity sha512-OLCH6qm/mZTCpplTXzXTJGId1zrtNuDYP5c2e6snIv/hdRVxPfBBz/bAlL91bY/Accavkayp2Zp2BaDSrLVXTQ== +solhint@^6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/solhint/-/solhint-6.0.2.tgz#9c78abf3a9d3be10d9886ee058845956dea83915" + integrity sha512-RInN0tz9FVR4eYlyLS0Pk8iJP3WdfVmmMJR9FIUxe9bKHgAPE8OYUXcMd5PGi5fO5BnZw32e0qMYcJZgH9MiBg== dependencies: - "@solidity-parser/parser" "^0.18.0" + "@solidity-parser/parser" "^0.20.2" ajv "^6.12.6" + ajv-errors "^1.0.1" antlr4 "^4.13.1-patch-1" ast-parents "^0.0.1" + better-ajv-errors "^2.0.2" chalk "^4.1.2" commander "^10.0.0" cosmiconfig "^8.0.0" @@ -16394,7 +16422,6 @@ solhint@^5.0.3: lodash "^4.17.21" pluralize "^8.0.0" semver "^7.5.2" - strip-ansi "^6.0.1" table "^6.8.1" text-table "^0.2.0" optionalDependencies: From 3621b96920c6a20fc21eec98df2b8e77a4e8516d Mon Sep 17 00:00:00 2001 From: didi Date: Fri, 19 Dec 2025 12:59:41 +0100 Subject: [PATCH 13/44] immutable surplus receiver --- .../interfaces/superfluid/IYieldBackend.sol | 6 ++++ .../contracts/superfluid/AaveYieldBackend.sol | 34 ++++++++++--------- 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/packages/ethereum-contracts/contracts/interfaces/superfluid/IYieldBackend.sol b/packages/ethereum-contracts/contracts/interfaces/superfluid/IYieldBackend.sol index 34843d6577..24a8551da3 100644 --- a/packages/ethereum-contracts/contracts/interfaces/superfluid/IYieldBackend.sol +++ b/packages/ethereum-contracts/contracts/interfaces/superfluid/IYieldBackend.sol @@ -45,4 +45,10 @@ interface IYieldBackend { /// 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); } diff --git a/packages/ethereum-contracts/contracts/superfluid/AaveYieldBackend.sol b/packages/ethereum-contracts/contracts/superfluid/AaveYieldBackend.sol index b4a51ea90e..22daf5029f 100644 --- a/packages/ethereum-contracts/contracts/superfluid/AaveYieldBackend.sol +++ b/packages/ethereum-contracts/contracts/superfluid/AaveYieldBackend.sol @@ -3,9 +3,8 @@ 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"; -import { IWETH } from "aave-v3/helpers/interfaces/IWETH.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. @@ -19,20 +18,19 @@ contract AaveYieldBackend is IYieldBackend { IPool public immutable AAVE_POOL; IERC20 public immutable A_TOKEN; bool public immutable USING_WETH; - // TODO: make an immutable - address constant SURPLUS_RECEIVER = 0xac808840f02c47C05507f48165d2222FF28EF4e1; // dao.superfluid.eth - + 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 + * @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) { + 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 @@ -48,6 +46,7 @@ contract AaveYieldBackend is IYieldBackend { ASSET_TOKEN = assetToken; } AAVE_POOL = IPool(aavePool); + SURPLUS_RECEIVER = surplusReceiver; A_TOKEN = IERC20(aavePool.getReserveAToken(address(ASSET_TOKEN))); _SELF = this; @@ -68,16 +67,14 @@ contract AaveYieldBackend is IYieldBackend { require(amount > 0, "amount must be greater than 0"); if (USING_WETH) { // wrap ETH to WETH - IWETH(address(ASSET_TOKEN)).deposit{value: amount}(); + 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)); + uint256 amount = USING_WETH ? address(this).balance : ASSET_TOKEN.balanceOf(address(this)); if (amount > 0) { deposit(amount); } @@ -101,21 +98,26 @@ contract AaveYieldBackend is IYieldBackend { 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 - uint256 surplusAmount = A_TOKEN.balanceOf(address(this)) - normalizedTotalSupply - 1; + // 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)) - 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 {} + 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}(""); + (bool success,) = address(msg.sender).call{ value: amount }(""); require(success, "call failed"); } } From f217e283d66bf38fe31812cb190bd80765776264 Mon Sep 17 00:00:00 2001 From: didi Date: Fri, 19 Dec 2025 13:05:50 +0100 Subject: [PATCH 14/44] added test for rounding error --- .../superfluid/AaveYieldBackendForkTest.sol | 181 ++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 packages/ethereum-contracts/test/foundry/superfluid/AaveYieldBackendForkTest.sol diff --git a/packages/ethereum-contracts/test/foundry/superfluid/AaveYieldBackendForkTest.sol b/packages/ethereum-contracts/test/foundry/superfluid/AaveYieldBackendForkTest.sol new file mode 100644 index 0000000000..a0aac9c83b --- /dev/null +++ b/packages/ethereum-contracts/test/foundry/superfluid/AaveYieldBackendForkTest.sol @@ -0,0 +1,181 @@ +// SPDX-License-Identifier: AGPLv3 +pragma solidity ^0.8.23; + +import { Test } from "forge-std/Test.sol"; +import { console } from "forge-std/console.sol"; +import { Math } from "@openzeppelin-v5/contracts/utils/math/Math.sol"; +import { AaveYieldBackend } from "../../../contracts/superfluid/AaveYieldBackend.sol"; +import { IERC20 } from "../../../contracts/interfaces/superfluid/ISuperfluid.sol"; +import { IPool } from "aave-v3/src/contracts/interfaces/IPool.sol"; + +/** + * @title AaveYieldBackendForkTest + * @notice Fork test for testing AaveYieldBackend + * @notice The test contract itself takes the role of SuperToken for delegatecall operations + * @author Superfluid + */ +contract AaveYieldBackendForkTest is Test { + uint256 internal constant CHAIN_ID = 8453; + string internal constant RPC_URL = "https://mainnet.base.org"; + + address internal constant AAVE_POOL = 0xA238Dd80C259a72e81d7e4664a9801593F98d1c5; + + address internal constant USDC = 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913; + address internal constant aUSDC = 0x4e65fE4DbA92790696d040ac24Aa414708F5c0AB; + address internal constant SURPLUS_RECEIVER = 0xac808840f02c47C05507f48165d2222FF28EF4e1; + + AaveYieldBackend internal aaveBackend; + IERC20 internal assetToken; + IERC20 internal aToken; + IPool internal aavePool; + + /// @notice Set up the test environment by forking Base and deploying AaveYieldBackend + function setUp() public { + vm.createSelectFork(RPC_URL); + + assertEq(block.chainid, CHAIN_ID, "Chainid mismatch"); + + aavePool = IPool(AAVE_POOL); + + assetToken = IERC20(USDC); + aToken = IERC20(aUSDC); + + aaveBackend = new AaveYieldBackend(assetToken, aavePool, SURPLUS_RECEIVER); + + // Enable the backend (approves Aave pool) + (bool success,) = address(aaveBackend).delegatecall(abi.encodeWithSelector(AaveYieldBackend.enable.selector)); + require(success, "enable failed"); + + deal(USDC, address(this), 200_000_000 * 1e6); // 200M USDC + } + + /// @notice Mock of toUnderlyingAmount, hardcoded to 18 to 6 decimals conversion + function toUnderlyingAmount(uint256 amount) + external + pure + returns (uint256 underlyingAmount, uint256 adjustedAmount) + { + uint256 factor = 10 ** (18 - 6); + underlyingAmount = amount / factor; + adjustedAmount = underlyingAmount * factor; + } + + /// Generates a random number between 1 and 1e14 using an exponential distribution. + function _getRandomWithExpDistribution() internal view returns (uint256) { + uint256 MAX_VAL = 1e14; + + // 1. Determine max magnitude. 1e14 is approx 2^46.5 + uint256 maxMagnitude = Math.log2(MAX_VAL); + + // 2. Pick a random magnitude (bit-length) uniformly. + // This ensures 1 digit numbers are as likely as 14 digit numbers to be "the range". + uint256 magnitude = bound(vm.randomUint(), 0, maxMagnitude); + + // 3. Set the high bit (2^magnitude) + uint256 base = 1 << magnitude; + + // 4. Fill the lower bits with random noise to get specific numbers like 14,532 + // We mod by 'base' so we don't spill into the next magnitude + uint256 noise = vm.randomUint() % base; + + uint256 result = base + noise; + + // 5. Cap at strict MAX_VAL (handle slight overflow at top magnitude) + return result > MAX_VAL ? MAX_VAL : result; + } + + function _deposit(uint256 amount) internal { + (bool success,) = + address(aaveBackend).delegatecall(abi.encodeWithSelector(AaveYieldBackend.deposit.selector, amount)); + require(success, "deposit failed"); + } + + /// @notice Helper function to log amount in fixed point format (integer.fractional) + function _logFixedPoint(string memory label, uint256 amount) internal pure { + console.log(string.concat(label, " ", vm.toString(amount / 1e6), ".", vm.toString(amount % 1e6))); + } + + /// @notice Helper function to perform a withdraw and return results + function _withdrawAndGetResults(uint256 requestedAmount) + internal + returns ( + uint256 assetAmountReceived, + uint256 aTokenAmountDecrease, + int256 diffAssetRequestedReceived, + int256 diffAtokenExpectedDecreased + ) + { + uint256 aTokenBalanceBefore = aToken.balanceOf(address(this)); + uint256 assetBalanceBefore = assetToken.balanceOf(address(this)); + + (bool success,) = address(aaveBackend).delegatecall( + abi.encodeWithSelector(AaveYieldBackend.withdraw.selector, requestedAmount) + ); + require(success, "withdraw failed"); + + uint256 aTokenBalanceAfter = aToken.balanceOf(address(this)); + uint256 assetBalanceAfter = assetToken.balanceOf(address(this)); + + assetAmountReceived = assetBalanceAfter - assetBalanceBefore; + aTokenAmountDecrease = aTokenBalanceBefore - aTokenBalanceAfter; + + diffAssetRequestedReceived = int256(assetAmountReceived) - int256(requestedAmount); + diffAtokenExpectedDecreased = int256(assetAmountReceived) - int256(aTokenAmountDecrease); + } + + /// @notice Test deposit/withdraw loop with random amounts + /// This is to verify that the rounding error of the aToken decrease is narronwly bounded. + function testDepositWithdrawLoop() public { + // Do an initial deposit of 1 USDC that is not withdrawn + // This provides a buffer against small rounding discrepancies which cause the + // aToken amount to not precisely match the asset amount. + uint256 initialDeposit = 1 * 1e6; + _deposit(initialDeposit); + + uint256 iterations = 1000; + + for (uint256 i = 0; i < iterations; ++i) { + uint256 randomAmount = _getRandomWithExpDistribution(); + if (randomAmount == 1) { + // getting a revert with InvalidAmount() for 1 + randomAmount = 2; + } + + _deposit(randomAmount); + + ( + uint256 assetAmountReceived, + uint256 aTokenAmountDecrease, + int256 diffAssetRequestedReceived, + int256 diffAtokenExpectedDecreased + ) = _withdrawAndGetResults(randomAmount); + + console.log("=== Iteration", i + 1, "==="); + console.log( + string.concat( + "assetAmount requested: ", vm.toString(randomAmount / 1e6), ".", vm.toString(randomAmount % 1e6) + ) + ); + console.log( + string.concat( + "assetAmount received: ", + vm.toString(assetAmountReceived / 1e6), + ".", + vm.toString(assetAmountReceived % 1e6) + ) + ); + console.log( + string.concat( + "aTokenAmount decrease: ", + vm.toString(aTokenAmountDecrease / 1e6), + ".", + vm.toString(aTokenAmountDecrease % 1e6) + ) + ); + console.log("diff (aToken decrease expected / actual):", vm.toString(diffAtokenExpectedDecreased)); + + assertEq(diffAssetRequestedReceived, 0, "diffAssetRequestedReceived is not 0"); + assertGe(diffAtokenExpectedDecreased, -2, "diffAtokenExpectedDecreased is < -2"); + } + } +} From 82bc62969bee59393224f1f92d204f7a63be1ebb Mon Sep 17 00:00:00 2001 From: didi Date: Fri, 19 Dec 2025 13:33:39 +0100 Subject: [PATCH 15/44] consider non-deposited underlying when calculating surplus --- .../contracts/superfluid/AaveYieldBackend.sol | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/ethereum-contracts/contracts/superfluid/AaveYieldBackend.sol b/packages/ethereum-contracts/contracts/superfluid/AaveYieldBackend.sol index 22daf5029f..fc44e2dbe1 100644 --- a/packages/ethereum-contracts/contracts/superfluid/AaveYieldBackend.sol +++ b/packages/ethereum-contracts/contracts/superfluid/AaveYieldBackend.sol @@ -100,7 +100,8 @@ contract AaveYieldBackend is IYieldBackend { (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)) - normalizedTotalSupply - 100; + uint256 surplusAmount = A_TOKEN.balanceOf(address(this)) + ASSET_TOKEN.balanceOf(address(this)) + - normalizedTotalSupply - 100; AAVE_POOL.withdraw(address(ASSET_TOKEN), surplusAmount, SURPLUS_RECEIVER); } From 1987a654917292ac82ff29fcc33cbe4eabc15bab Mon Sep 17 00:00:00 2001 From: didi Date: Fri, 19 Dec 2025 13:34:49 +0100 Subject: [PATCH 16/44] added spark yield backend --- .../superfluid/SparkYieldBackend.sol | 67 +++++ .../superfluid/SparkYieldBackend.t.sol | 248 ++++++++++++++++++ 2 files changed, 315 insertions(+) create mode 100644 packages/ethereum-contracts/contracts/superfluid/SparkYieldBackend.sol create mode 100644 packages/ethereum-contracts/test/foundry/superfluid/SparkYieldBackend.t.sol diff --git a/packages/ethereum-contracts/contracts/superfluid/SparkYieldBackend.sol b/packages/ethereum-contracts/contracts/superfluid/SparkYieldBackend.sol new file mode 100644 index 0000000000..ffcd963ce6 --- /dev/null +++ b/packages/ethereum-contracts/contracts/superfluid/SparkYieldBackend.sol @@ -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))); + } +} diff --git a/packages/ethereum-contracts/test/foundry/superfluid/SparkYieldBackend.t.sol b/packages/ethereum-contracts/test/foundry/superfluid/SparkYieldBackend.t.sol new file mode 100644 index 0000000000..0bd9df606e --- /dev/null +++ b/packages/ethereum-contracts/test/foundry/superfluid/SparkYieldBackend.t.sol @@ -0,0 +1,248 @@ +// SPDX-License-Identifier: AGPLv3 +pragma solidity ^0.8.23; + +import { Test } from "forge-std/Test.sol"; +import { console } from "forge-std/console.sol"; +import { + SparkYieldBackend +} from "../../../contracts/superfluid/SparkYieldBackend.sol"; +import { + IERC20, + ISuperfluid +} from "../../../contracts/interfaces/superfluid/ISuperfluid.sol"; +import { SuperToken } from "../../../contracts/superfluid/SuperToken.sol"; +import { IERC4626 } from "@openzeppelin-v5/contracts/interfaces/IERC4626.sol"; + +/** + * @title SparkYieldBackendForkTest + * @notice Fork test for testing yield-related features with SparkYieldBackend on Base + * @author Superfluid + */ +contract SparkYieldBackendForkTest is Test { + address internal constant ALICE = address(0x420); + address internal constant ADMIN = address(0xAAA); + address internal constant SURPLUS_RECEIVER = 0xac808840f02c47C05507f48165d2222FF28EF4e1; // dao.superfluid.eth + + // Base network constants + uint256 internal constant CHAIN_ID = 8453; + string internal constant RPC_URL = "https://mainnet.base.org"; + + // Spark USDC Vault on Base (sUSDC) + address internal constant SPARK_VAULT = 0x3128a0F7f0ea68E7B7c9B00AFa7E41045828e858; + + // Common tokens on Base + address internal constant USDCx = 0xD04383398dD2426297da660F9CCA3d439AF9ce1b; + address internal constant USDC = 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913; + + + SuperToken internal superToken; + SparkYieldBackend internal sparkBackend; + IERC20 internal underlyingToken; + IERC4626 internal vault; + + function setUp() public { + vm.createSelectFork(RPC_URL); + assertEq(block.chainid, CHAIN_ID, "Chainid mismatch"); + + vault = IERC4626(SPARK_VAULT); + underlyingToken = IERC20(USDC); + + superToken = SuperToken(USDCx); + + sparkBackend = new SparkYieldBackend(vault, SURPLUS_RECEIVER); + + assertEq( + address(sparkBackend.ASSET_TOKEN()), + USDC, + "Asset token mismatch" + ); + assertEq(address(sparkBackend.VAULT()), SPARK_VAULT, "Vault mismatch"); + + // upgrade SuperToken to new logic (mocking upgrade to enable features if needed, + // essentially ensuring we have a fresh state or compatible logic) + // Note: SuperToken on Base might already be up to date, but we re-deploy logic for safety in test + SuperToken newSuperTokenLogic = new SuperToken( + ISuperfluid(superToken.getHost()), + superToken.POOL_ADMIN_NFT() + ); + vm.startPrank(address(superToken.getHost())); + superToken.updateCode(address(newSuperTokenLogic)); + vm.stopPrank(); + + // designate an admin for the SuperToken + vm.startPrank(address(superToken.getHost())); + superToken.changeAdmin(ADMIN); + vm.stopPrank(); + + // provide ALICE with underlying and let her approve for upgrade + deal(USDC, ALICE, type(uint128).max); + vm.startPrank(ALICE); + IERC20(USDC).approve(address(superToken), type(uint256).max); + } + + function _enableYieldBackend() public { + vm.startPrank(ADMIN); + superToken.enableYieldBackend(sparkBackend); + vm.stopPrank(); + } + + function _verifyInvariants() internal view { + // underlyingBalance + vaultAssets >= superToken.supply() + uint256 underlyingBalance = IERC20(USDC).balanceOf(address(superToken)); + // vault balance is in shares, need to convert to assets + uint256 vaultAssets = vault.convertToAssets( + vault.balanceOf(address(superToken)) + ); + + (uint256 superTokenNormalizedSupply, ) = superToken.toUnderlyingAmount( + superToken.totalSupply() + ); + + // We use approx because of potential rounding/yield accruing differently per block + // But assets should be >= supply + assertGe( + underlyingBalance + vaultAssets, + superTokenNormalizedSupply, + "invariant failed: underlying + vaultAssets insufficient" + ); + } + + function testSparkBackendDeployment() public view { + assertEq( + address(sparkBackend.ASSET_TOKEN()), + USDC, + "Asset token should be USDC" + ); + assertEq( + address(sparkBackend.VAULT()), + SPARK_VAULT, + "Vault address should match" + ); + } + + function testEnableYieldBackend() public { + _enableYieldBackend(); + + assertEq( + address(superToken.getYieldBackend()), + address(sparkBackend), + "Yield backend mismatch" + ); + + // For new deposits, we need to upgrade + uint256 amount = 100 * 1e18; + vm.startPrank(ALICE); + superToken.upgrade(amount); + vm.stopPrank(); + + // the SuperToken should now have a zero USDC balance (all deposited) + assertEq( + IERC20(USDC).balanceOf(address(superToken)), + 0, + "USDC balance should be zero" + ); + + // And non-zero vault balance + assertGt( + vault.balanceOf(address(superToken)), + 0, + "Vault share balance should be non-zero" + ); + + _verifyInvariants(); + } + + function testDisableYieldBackend() public { + _enableYieldBackend(); + + // Deposit some funds first so we have something to withdraw + vm.startPrank(ALICE); + superToken.upgrade(100 * 1e18); + vm.stopPrank(); + + vm.startPrank(ADMIN); + superToken.disableYieldBackend(); + vm.stopPrank(); + assertEq( + address(superToken.getYieldBackend()), + address(0), + "Yield backend mismatch" + ); + + // the SuperToken should now have a non-zero USDC balance and a zero vault balance + assertGt( + IERC20(USDC).balanceOf(address(superToken)), + 0, + "USDC balance should be non-zero" + ); + assertEq( + vault.balanceOf(address(superToken)), + 0, + "Vault balance should be zero" + ); + + _verifyInvariants(); + } + + function testUpgradeDowngrade() public { + _enableYieldBackend(); + + uint256 vaultSharesBefore = vault.balanceOf(address(superToken)); + uint256 amount = 100 * 1e18; // 100 USDCx + + vm.startPrank(ALICE); + superToken.upgrade(amount); + vm.stopPrank(); + + uint256 vaultSharesAfter = vault.balanceOf(address(superToken)); + + assertGt( + vaultSharesAfter, + vaultSharesBefore, + "Vault shares should increase" + ); + + // downgrade + vm.startPrank(ALICE); + superToken.downgrade(amount); + vm.stopPrank(); + + uint256 vaultSharesFinal = vault.balanceOf(address(superToken)); + assertLt( + vaultSharesFinal, + vaultSharesAfter, + "Vault shares should decrease" + ); + + _verifyInvariants(); + } + + function testWithdrawSurplusFromYieldBackend() public { + // simulate the SuperToken having a surplus of underlying from the start + uint256 surplusAmount = 100 * 1e6; // 100 USDC + deal(USDC, address(superToken), surplusAmount); + + _enableYieldBackend(); + + uint256 receiverBalanceBefore = IERC20(USDC).balanceOf( + SURPLUS_RECEIVER + ); + + vm.startPrank(ADMIN); + superToken.withdrawSurplusFromYieldBackend(); + vm.stopPrank(); + + uint256 receiverBalanceAfter = IERC20(USDC).balanceOf(SURPLUS_RECEIVER); + + console.log("Receiver balance before", receiverBalanceBefore); + console.log("Receiver balance after", receiverBalanceAfter); + console.log("Diff", receiverBalanceAfter - receiverBalanceBefore); + + assertGt( + receiverBalanceAfter, + receiverBalanceBefore, + "Surplus should be withdrawn to receiver" + ); + _verifyInvariants(); + } +} From 2002d9452c163aa586108f62c9a79495c961f0d9 Mon Sep 17 00:00:00 2001 From: didi Date: Wed, 7 Jan 2026 12:52:28 +0100 Subject: [PATCH 17/44] added missing newline --- .../contracts/interfaces/superfluid/IYieldBackend.sol | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ethereum-contracts/contracts/interfaces/superfluid/IYieldBackend.sol b/packages/ethereum-contracts/contracts/interfaces/superfluid/IYieldBackend.sol index 24a8551da3..b6c25c991e 100644 --- a/packages/ethereum-contracts/contracts/interfaces/superfluid/IYieldBackend.sol +++ b/packages/ethereum-contracts/contracts/interfaces/superfluid/IYieldBackend.sol @@ -28,6 +28,7 @@ interface IYieldBackend { /// 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. From 955a94f91336102252520349e75aef3e4c3db440 Mon Sep 17 00:00:00 2001 From: didi Date: Wed, 7 Jan 2026 13:16:33 +0100 Subject: [PATCH 18/44] removed unnecessary methods --- .../interfaces/superfluid/IYieldBackend.sol | 14 +------------- .../contracts/superfluid/AaveYieldBackend.sol | 11 ----------- .../contracts/superfluid/SparkYieldBackend.sol | 11 ----------- .../contracts/superfluid/SuperToken.sol | 7 ++++++- 4 files changed, 7 insertions(+), 36 deletions(-) diff --git a/packages/ethereum-contracts/contracts/interfaces/superfluid/IYieldBackend.sol b/packages/ethereum-contracts/contracts/interfaces/superfluid/IYieldBackend.sol index b6c25c991e..7dae6844ad 100644 --- a/packages/ethereum-contracts/contracts/interfaces/superfluid/IYieldBackend.sol +++ b/packages/ethereum-contracts/contracts/interfaces/superfluid/IYieldBackend.sol @@ -29,27 +29,15 @@ interface IYieldBackend { /// 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. + /// 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; - - /// 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); } diff --git a/packages/ethereum-contracts/contracts/superfluid/AaveYieldBackend.sol b/packages/ethereum-contracts/contracts/superfluid/AaveYieldBackend.sol index fc44e2dbe1..93037bfc1d 100644 --- a/packages/ethereum-contracts/contracts/superfluid/AaveYieldBackend.sol +++ b/packages/ethereum-contracts/contracts/superfluid/AaveYieldBackend.sol @@ -73,13 +73,6 @@ contract AaveYieldBackend is IYieldBackend { 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) { @@ -105,10 +98,6 @@ contract AaveYieldBackend is IYieldBackend { 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 diff --git a/packages/ethereum-contracts/contracts/superfluid/SparkYieldBackend.sol b/packages/ethereum-contracts/contracts/superfluid/SparkYieldBackend.sol index ffcd963ce6..e615a8c401 100644 --- a/packages/ethereum-contracts/contracts/superfluid/SparkYieldBackend.sol +++ b/packages/ethereum-contracts/contracts/superfluid/SparkYieldBackend.sol @@ -31,13 +31,6 @@ contract SparkYieldBackend is IYieldBackend { 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)); } @@ -60,8 +53,4 @@ contract SparkYieldBackend is IYieldBackend { 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))); - } } diff --git a/packages/ethereum-contracts/contracts/superfluid/SuperToken.sol b/packages/ethereum-contracts/contracts/superfluid/SuperToken.sol index 27971c4f6e..13c7e2943b 100644 --- a/packages/ethereum-contracts/contracts/superfluid/SuperToken.sol +++ b/packages/ethereum-contracts/contracts/superfluid/SuperToken.sol @@ -207,7 +207,12 @@ contract SuperToken is require(address(_yieldBackend) == address(0), "yield backend already set"); _yieldBackend = newYieldBackend; _yieldBackend.dCall(abi.encodeCall(IYieldBackend.enable, ())); - _yieldBackend.dCall(abi.encodeCall(IYieldBackend.depositMax, ())); + // Assumption: if no underlying token is set, it's the native token wrapper (SETH). + // This doesn't hold for pure SuperTokens, but those can't have a yield backend. + uint256 depositAmount = address(_underlyingToken) == address(0) + ? address(this).balance + : _underlyingToken.balanceOf(address(this)); + _yieldBackend.dCall(abi.encodeCall(IYieldBackend.deposit, (depositAmount))); // TODO: emit event } From 47e80819f0f5aef2bc071a23301c256e2deb542a Mon Sep 17 00:00:00 2001 From: didi Date: Wed, 7 Jan 2026 13:20:04 +0100 Subject: [PATCH 19/44] fix indentation --- packages/ethereum-contracts/.solhint.json | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/ethereum-contracts/.solhint.json b/packages/ethereum-contracts/.solhint.json index 0f6ea63b82..e24930910b 100644 --- a/packages/ethereum-contracts/.solhint.json +++ b/packages/ethereum-contracts/.solhint.json @@ -17,15 +17,15 @@ "func-visibility": ["error", { "ignoreConstructors": true }], "quotes": ["error", "double"], "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" + "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" } } From 882ec217c999cfd5ba0977c6dbfab2ff4af04ef9 Mon Sep 17 00:00:00 2001 From: didi Date: Wed, 7 Jan 2026 16:24:34 +0100 Subject: [PATCH 20/44] add delegateCallChecked to CallUtils --- .../contracts/libs/CallUtils.sol | 13 ++++++++++++ .../contracts/libs/YieldBackendHelperLib.sol | 18 ---------------- .../contracts/superfluid/SuperToken.sol | 21 +++++++++---------- 3 files changed, 23 insertions(+), 29 deletions(-) delete mode 100644 packages/ethereum-contracts/contracts/libs/YieldBackendHelperLib.sol diff --git a/packages/ethereum-contracts/contracts/libs/CallUtils.sol b/packages/ethereum-contracts/contracts/libs/CallUtils.sol index 91392b0038..6415c33a36 100644 --- a/packages/ethereum-contracts/contracts/libs/CallUtils.sol +++ b/packages/ethereum-contracts/contracts/libs/CallUtils.sol @@ -1,6 +1,19 @@ // 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,) = target.delegatecall(callData); + require(success, "CallUtils: delegatecall failed"); +} + /** * @title Call utilities library that is absent from the OpenZeppelin * @author Superfluid diff --git a/packages/ethereum-contracts/contracts/libs/YieldBackendHelperLib.sol b/packages/ethereum-contracts/contracts/libs/YieldBackendHelperLib.sol deleted file mode 100644 index 66cb49c938..0000000000 --- a/packages/ethereum-contracts/contracts/libs/YieldBackendHelperLib.sol +++ /dev/null @@ -1,18 +0,0 @@ -// 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"); - } -} \ No newline at end of file diff --git a/packages/ethereum-contracts/contracts/superfluid/SuperToken.sol b/packages/ethereum-contracts/contracts/superfluid/SuperToken.sol index 13c7e2943b..8585dcb99c 100644 --- a/packages/ethereum-contracts/contracts/superfluid/SuperToken.sol +++ b/packages/ethereum-contracts/contracts/superfluid/SuperToken.sol @@ -12,7 +12,7 @@ import { IPoolAdminNFT } from "../interfaces/superfluid/ISuperfluid.sol"; import { IYieldBackend } from "../interfaces/superfluid/IYieldBackend.sol"; -import { YieldBackendHelperLib } from "../libs/YieldBackendHelperLib.sol"; +import { delegateCallChecked } from "../libs/CallUtils.sol"; import { SuperfluidToken } from "./SuperfluidToken.sol"; import { ERC777Helper } from "../libs/ERC777Helper.sol"; import { SafeERC20 } from "@openzeppelin-v5/contracts/token/ERC20/utils/SafeERC20.sol"; @@ -39,7 +39,6 @@ contract SuperToken is using SafeCast for uint256; using ERC777Helper for ERC777Helper.Operators; using SafeERC20 for IERC20; - using YieldBackendHelperLib for IYieldBackend; // See: https://eips.ethereum.org/EIPS/eip-1967#admin-address bytes32 constant private _ADMIN_SLOT = 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103; @@ -206,21 +205,21 @@ contract SuperToken is function enableYieldBackend(IYieldBackend newYieldBackend) external onlyAdmin { require(address(_yieldBackend) == address(0), "yield backend already set"); _yieldBackend = newYieldBackend; - _yieldBackend.dCall(abi.encodeCall(IYieldBackend.enable, ())); + delegateCallChecked(address(_yieldBackend), abi.encodeCall(IYieldBackend.enable, ())); // Assumption: if no underlying token is set, it's the native token wrapper (SETH). // This doesn't hold for pure SuperTokens, but those can't have a yield backend. uint256 depositAmount = address(_underlyingToken) == address(0) ? address(this).balance : _underlyingToken.balanceOf(address(this)); - _yieldBackend.dCall(abi.encodeCall(IYieldBackend.deposit, (depositAmount))); + delegateCallChecked(address(_yieldBackend), abi.encodeCall(IYieldBackend.deposit, (depositAmount))); // TODO: emit event } // withdraws everything and removes allowances function disableYieldBackend() external onlyAdmin { require(address(_yieldBackend) != address(0), "yield backend not set"); - _yieldBackend.dCall(abi.encodeCall(IYieldBackend.withdrawMax, ())); - _yieldBackend.dCall(abi.encodeCall(IYieldBackend.disable, ())); + delegateCallChecked(address(_yieldBackend), abi.encodeCall(IYieldBackend.withdrawMax, ())); + delegateCallChecked(address(_yieldBackend), abi.encodeCall(IYieldBackend.disable, ())); _yieldBackend = IYieldBackend(address(0)); // TODO: emit event } @@ -231,7 +230,7 @@ contract SuperToken is function withdrawSurplusFromYieldBackend() external onlyAdmin { require(address(_yieldBackend) != address(0), "yield backend not set"); - _yieldBackend.dCall(abi.encodeCall(IYieldBackend.withdrawSurplus, (_totalSupply))); + delegateCallChecked(address(_yieldBackend), abi.encodeCall(IYieldBackend.withdrawSurplus, (_totalSupply))); } /************************************************************************** @@ -769,7 +768,7 @@ contract SuperToken is if (!_skipSelfMint) { if (address(_yieldBackend) != address(0)) { // TODO: shall we deposit all, or just the upgradeAmount? - _yieldBackend.dCall(abi.encodeCall(IYieldBackend.deposit, (amount))); + delegateCallChecked(address(_yieldBackend), abi.encodeCall(IYieldBackend.deposit, (amount))); } _mint(msg.sender, account, amount, userData.length != 0 /* invokeHook */, @@ -790,7 +789,7 @@ contract SuperToken is if (address(_yieldBackend) != address(0)) { _skipSelfMint = true; // TODO: we may want to skip if enough underlying already in the contract - _yieldBackend.dCall(abi.encodeCall(IYieldBackend.withdraw, (amount))); + delegateCallChecked(address(_yieldBackend), abi.encodeCall(IYieldBackend.withdraw, (amount))); _skipSelfMint = false; } } @@ -893,7 +892,7 @@ contract SuperToken is if (address(_yieldBackend) != address(0)) { // TODO: shall we deposit all, or just the upgradeAmount? - _yieldBackend.dCall(abi.encodeCall(IYieldBackend.deposit, (actualUpgradedAmount))); + delegateCallChecked(address(_yieldBackend), abi.encodeCall(IYieldBackend.deposit, (actualUpgradedAmount))); } _mint(operator, to, adjustedAmount, @@ -920,7 +919,7 @@ contract SuperToken is if (address(_yieldBackend) != address(0)) { // TODO: we may want to skip if enough underlying already in the contract - _yieldBackend.dCall(abi.encodeCall(IYieldBackend.withdraw, (underlyingAmount))); + delegateCallChecked(address(_yieldBackend), abi.encodeCall(IYieldBackend.withdraw, (underlyingAmount))); } uint256 amountBefore = _underlyingToken.balanceOf(address(this)); From 5a615d8ac5572a0100de3496db63496a8cbe781e Mon Sep 17 00:00:00 2001 From: didi Date: Wed, 7 Jan 2026 16:27:45 +0100 Subject: [PATCH 21/44] updated CHANGELOG --- packages/ethereum-contracts/CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/ethereum-contracts/CHANGELOG.md b/packages/ethereum-contracts/CHANGELOG.md index 71058b745b..0b2bbef6e3 100644 --- a/packages/ethereum-contracts/CHANGELOG.md +++ b/packages/ethereum-contracts/CHANGELOG.md @@ -3,6 +3,16 @@ All notable changes to the ethereum-contracts will be documented in this file. This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [UNRELEASED] + +### Added + +- `SuperToken`: the contract admin can enable/disable a _Yield Backend_ in order to get a yield on the underlying asset. + +### Changed + +- EVM target changed from _shanghai_ to _cancun_. + ## [v1.14.1] ### Added From 8ab52e171b3fedfecaf9adafdf1d6b59686d8e25 Mon Sep 17 00:00:00 2001 From: didi Date: Wed, 7 Jan 2026 17:23:27 +0100 Subject: [PATCH 22/44] renamed SparkYieldBackend to ERC4626YieldBackend --- .../contracts/superfluid/AaveYieldBackend.sol | 1 + ...eldBackend.sol => ERC4626YieldBackend.sol} | 4 +- .../superfluid/SparkYieldBackend.t.sol | 103 ++++-------------- 3 files changed, 23 insertions(+), 85 deletions(-) rename packages/ethereum-contracts/contracts/superfluid/{SparkYieldBackend.sol => ERC4626YieldBackend.sol} (93%) diff --git a/packages/ethereum-contracts/contracts/superfluid/AaveYieldBackend.sol b/packages/ethereum-contracts/contracts/superfluid/AaveYieldBackend.sol index 93037bfc1d..43fe878cb7 100644 --- a/packages/ethereum-contracts/contracts/superfluid/AaveYieldBackend.sol +++ b/packages/ethereum-contracts/contracts/superfluid/AaveYieldBackend.sol @@ -7,6 +7,7 @@ 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. * Aave supports a simple deposit/withdraw workflow nicely matching the IYieldBackend interface. * Deposits are represented by transferrable aTokens. * diff --git a/packages/ethereum-contracts/contracts/superfluid/SparkYieldBackend.sol b/packages/ethereum-contracts/contracts/superfluid/ERC4626YieldBackend.sol similarity index 93% rename from packages/ethereum-contracts/contracts/superfluid/SparkYieldBackend.sol rename to packages/ethereum-contracts/contracts/superfluid/ERC4626YieldBackend.sol index e615a8c401..0619726943 100644 --- a/packages/ethereum-contracts/contracts/superfluid/SparkYieldBackend.sol +++ b/packages/ethereum-contracts/contracts/superfluid/ERC4626YieldBackend.sol @@ -5,9 +5,9 @@ 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 { +/// @title A SuperToken yield backend for ERC4626 compliant vaults. +contract ERC4626YieldBackend is IYieldBackend { IERC20 public immutable ASSET_TOKEN; IERC4626 public immutable VAULT; address public immutable SURPLUS_RECEIVER; diff --git a/packages/ethereum-contracts/test/foundry/superfluid/SparkYieldBackend.t.sol b/packages/ethereum-contracts/test/foundry/superfluid/SparkYieldBackend.t.sol index 0bd9df606e..c6f3986dad 100644 --- a/packages/ethereum-contracts/test/foundry/superfluid/SparkYieldBackend.t.sol +++ b/packages/ethereum-contracts/test/foundry/superfluid/SparkYieldBackend.t.sol @@ -3,13 +3,8 @@ pragma solidity ^0.8.23; import { Test } from "forge-std/Test.sol"; import { console } from "forge-std/console.sol"; -import { - SparkYieldBackend -} from "../../../contracts/superfluid/SparkYieldBackend.sol"; -import { - IERC20, - ISuperfluid -} from "../../../contracts/interfaces/superfluid/ISuperfluid.sol"; +import { ERC4626YieldBackend } from "../../../contracts/superfluid/ERC4626YieldBackend.sol"; +import { IERC20, ISuperfluid } from "../../../contracts/interfaces/superfluid/ISuperfluid.sol"; import { SuperToken } from "../../../contracts/superfluid/SuperToken.sol"; import { IERC4626 } from "@openzeppelin-v5/contracts/interfaces/IERC4626.sol"; @@ -34,9 +29,8 @@ contract SparkYieldBackendForkTest is Test { address internal constant USDCx = 0xD04383398dD2426297da660F9CCA3d439AF9ce1b; address internal constant USDC = 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913; - SuperToken internal superToken; - SparkYieldBackend internal sparkBackend; + ERC4626YieldBackend internal sparkBackend; IERC20 internal underlyingToken; IERC4626 internal vault; @@ -49,22 +43,15 @@ contract SparkYieldBackendForkTest is Test { superToken = SuperToken(USDCx); - sparkBackend = new SparkYieldBackend(vault, SURPLUS_RECEIVER); + sparkBackend = new ERC4626YieldBackend(vault, SURPLUS_RECEIVER); - assertEq( - address(sparkBackend.ASSET_TOKEN()), - USDC, - "Asset token mismatch" - ); + assertEq(address(sparkBackend.ASSET_TOKEN()), USDC, "Asset token mismatch"); assertEq(address(sparkBackend.VAULT()), SPARK_VAULT, "Vault mismatch"); // upgrade SuperToken to new logic (mocking upgrade to enable features if needed, // essentially ensuring we have a fresh state or compatible logic) // Note: SuperToken on Base might already be up to date, but we re-deploy logic for safety in test - SuperToken newSuperTokenLogic = new SuperToken( - ISuperfluid(superToken.getHost()), - superToken.POOL_ADMIN_NFT() - ); + SuperToken newSuperTokenLogic = new SuperToken(ISuperfluid(superToken.getHost()), superToken.POOL_ADMIN_NFT()); vm.startPrank(address(superToken.getHost())); superToken.updateCode(address(newSuperTokenLogic)); vm.stopPrank(); @@ -90,13 +77,9 @@ contract SparkYieldBackendForkTest is Test { // underlyingBalance + vaultAssets >= superToken.supply() uint256 underlyingBalance = IERC20(USDC).balanceOf(address(superToken)); // vault balance is in shares, need to convert to assets - uint256 vaultAssets = vault.convertToAssets( - vault.balanceOf(address(superToken)) - ); + uint256 vaultAssets = vault.convertToAssets(vault.balanceOf(address(superToken))); - (uint256 superTokenNormalizedSupply, ) = superToken.toUnderlyingAmount( - superToken.totalSupply() - ); + (uint256 superTokenNormalizedSupply,) = superToken.toUnderlyingAmount(superToken.totalSupply()); // We use approx because of potential rounding/yield accruing differently per block // But assets should be >= supply @@ -108,26 +91,14 @@ contract SparkYieldBackendForkTest is Test { } function testSparkBackendDeployment() public view { - assertEq( - address(sparkBackend.ASSET_TOKEN()), - USDC, - "Asset token should be USDC" - ); - assertEq( - address(sparkBackend.VAULT()), - SPARK_VAULT, - "Vault address should match" - ); + assertEq(address(sparkBackend.ASSET_TOKEN()), USDC, "Asset token should be USDC"); + assertEq(address(sparkBackend.VAULT()), SPARK_VAULT, "Vault address should match"); } function testEnableYieldBackend() public { _enableYieldBackend(); - assertEq( - address(superToken.getYieldBackend()), - address(sparkBackend), - "Yield backend mismatch" - ); + assertEq(address(superToken.getYieldBackend()), address(sparkBackend), "Yield backend mismatch"); // For new deposits, we need to upgrade uint256 amount = 100 * 1e18; @@ -136,18 +107,10 @@ contract SparkYieldBackendForkTest is Test { vm.stopPrank(); // the SuperToken should now have a zero USDC balance (all deposited) - assertEq( - IERC20(USDC).balanceOf(address(superToken)), - 0, - "USDC balance should be zero" - ); + assertEq(IERC20(USDC).balanceOf(address(superToken)), 0, "USDC balance should be zero"); // And non-zero vault balance - assertGt( - vault.balanceOf(address(superToken)), - 0, - "Vault share balance should be non-zero" - ); + assertGt(vault.balanceOf(address(superToken)), 0, "Vault share balance should be non-zero"); _verifyInvariants(); } @@ -163,23 +126,11 @@ contract SparkYieldBackendForkTest is Test { vm.startPrank(ADMIN); superToken.disableYieldBackend(); vm.stopPrank(); - assertEq( - address(superToken.getYieldBackend()), - address(0), - "Yield backend mismatch" - ); + assertEq(address(superToken.getYieldBackend()), address(0), "Yield backend mismatch"); // the SuperToken should now have a non-zero USDC balance and a zero vault balance - assertGt( - IERC20(USDC).balanceOf(address(superToken)), - 0, - "USDC balance should be non-zero" - ); - assertEq( - vault.balanceOf(address(superToken)), - 0, - "Vault balance should be zero" - ); + assertGt(IERC20(USDC).balanceOf(address(superToken)), 0, "USDC balance should be non-zero"); + assertEq(vault.balanceOf(address(superToken)), 0, "Vault balance should be zero"); _verifyInvariants(); } @@ -196,11 +147,7 @@ contract SparkYieldBackendForkTest is Test { uint256 vaultSharesAfter = vault.balanceOf(address(superToken)); - assertGt( - vaultSharesAfter, - vaultSharesBefore, - "Vault shares should increase" - ); + assertGt(vaultSharesAfter, vaultSharesBefore, "Vault shares should increase"); // downgrade vm.startPrank(ALICE); @@ -208,11 +155,7 @@ contract SparkYieldBackendForkTest is Test { vm.stopPrank(); uint256 vaultSharesFinal = vault.balanceOf(address(superToken)); - assertLt( - vaultSharesFinal, - vaultSharesAfter, - "Vault shares should decrease" - ); + assertLt(vaultSharesFinal, vaultSharesAfter, "Vault shares should decrease"); _verifyInvariants(); } @@ -224,9 +167,7 @@ contract SparkYieldBackendForkTest is Test { _enableYieldBackend(); - uint256 receiverBalanceBefore = IERC20(USDC).balanceOf( - SURPLUS_RECEIVER - ); + uint256 receiverBalanceBefore = IERC20(USDC).balanceOf(SURPLUS_RECEIVER); vm.startPrank(ADMIN); superToken.withdrawSurplusFromYieldBackend(); @@ -238,11 +179,7 @@ contract SparkYieldBackendForkTest is Test { console.log("Receiver balance after", receiverBalanceAfter); console.log("Diff", receiverBalanceAfter - receiverBalanceBefore); - assertGt( - receiverBalanceAfter, - receiverBalanceBefore, - "Surplus should be withdrawn to receiver" - ); + assertGt(receiverBalanceAfter, receiverBalanceBefore, "Surplus should be withdrawn to receiver"); _verifyInvariants(); } } From fd0b070d825b05aa74346de60abba6be45698ab0 Mon Sep 17 00:00:00 2001 From: didi Date: Wed, 7 Jan 2026 19:22:28 +0100 Subject: [PATCH 23/44] split out AaveETHYieldBackend --- .../superfluid/AaveETHYieldBackend.sol | 102 ++++++++++++++++++ .../contracts/superfluid/AaveYieldBackend.sol | 55 ++-------- 2 files changed, 110 insertions(+), 47 deletions(-) create mode 100644 packages/ethereum-contracts/contracts/superfluid/AaveETHYieldBackend.sol diff --git a/packages/ethereum-contracts/contracts/superfluid/AaveETHYieldBackend.sol b/packages/ethereum-contracts/contracts/superfluid/AaveETHYieldBackend.sol new file mode 100644 index 0000000000..4f2e9a35ab --- /dev/null +++ b/packages/ethereum-contracts/contracts/superfluid/AaveETHYieldBackend.sol @@ -0,0 +1,102 @@ +// 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: 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 WETH contract address based on the chain id and Aave deployment. + /// Implemented for chains with official deployments of Aave and Superfluid. + /// 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! + 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 + return 0x7ceB23fD6bC0adD59E62ac25578270cFf1b9f619; + } + 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); + } + + // ============ 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 transaction. + // Thus it is not necessary to check msg.sender. + // We accept that an alien caller may withdraw WETH deposited to this contract (for whatever reason). + function unwrapWETHAndForwardETH(uint256 amount) external { + IWETH(address(ASSET_TOKEN)).withdraw(amount); + (bool success,) = address(msg.sender).call{ value: amount }(""); + require(success, "call failed"); + } +} + diff --git a/packages/ethereum-contracts/contracts/superfluid/AaveYieldBackend.sol b/packages/ethereum-contracts/contracts/superfluid/AaveYieldBackend.sol index 43fe878cb7..0b876edc58 100644 --- a/packages/ethereum-contracts/contracts/superfluid/AaveYieldBackend.sol +++ b/packages/ethereum-contracts/contracts/superfluid/AaveYieldBackend.sol @@ -4,7 +4,6 @@ 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"; /** * @title a SuperToken yield backend for the Aave protocol. @@ -18,39 +17,23 @@ 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. + * 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) { - // 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; - } + 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))); - - _SELF = this; } function enable() external { @@ -63,30 +46,21 @@ contract AaveYieldBackend is IYieldBackend { ASSET_TOKEN.approve(address(AAVE_POOL), 0); } - function deposit(uint256 amount) public { + function deposit(uint256 amount) public virtual { // 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 withdraw(uint256 amount) external { + function withdraw(uint256 amount) public virtual { // 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)); - } + AAVE_POOL.withdraw(address(ASSET_TOKEN), amount, address(this)); } - function withdrawMax() external { + function withdrawMax() external virtual { // 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)); + withdraw(type(uint256).max); } function withdrawSurplus(uint256 totalSupply) external { @@ -98,17 +72,4 @@ contract AaveYieldBackend is IYieldBackend { - normalizedTotalSupply - 100; AAVE_POOL.withdraw(address(ASSET_TOKEN), surplusAmount, SURPLUS_RECEIVER); } - - // ============ 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"); - } } From 5f695a2502b8918906b318a5101886be6a4da845 Mon Sep 17 00:00:00 2001 From: didi Date: Thu, 8 Jan 2026 06:51:55 +0100 Subject: [PATCH 24/44] more comments --- .../contracts/superfluid/AaveETHYieldBackend.sol | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/ethereum-contracts/contracts/superfluid/AaveETHYieldBackend.sol b/packages/ethereum-contracts/contracts/superfluid/AaveETHYieldBackend.sol index 4f2e9a35ab..6c15972d08 100644 --- a/packages/ethereum-contracts/contracts/superfluid/AaveETHYieldBackend.sol +++ b/packages/ethereum-contracts/contracts/superfluid/AaveETHYieldBackend.sol @@ -10,6 +10,10 @@ 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. @@ -30,11 +34,8 @@ contract AaveETHYieldBackend is AaveYieldBackend { _SELF = this; } - /// get the canonical WETH contract address based on the chain id and Aave deployment. + /// 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. - /// 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! function getWETHAddress() internal view returns (address) { if (block.chainid == 1) { // Ethereum return 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; From 4bbbd2e06a3c6f5d2c3fc69fd092fe647562ead7 Mon Sep 17 00:00:00 2001 From: didi Date: Thu, 8 Jan 2026 06:52:53 +0100 Subject: [PATCH 25/44] added test for grifting case --- .../foundry/superfluid/GrifterContract.sol | 91 ++++ .../foundry/superfluid/SuperTokenYield.t.sol | 389 +++++++++++++++--- 2 files changed, 418 insertions(+), 62 deletions(-) create mode 100644 packages/ethereum-contracts/test/foundry/superfluid/GrifterContract.sol diff --git a/packages/ethereum-contracts/test/foundry/superfluid/GrifterContract.sol b/packages/ethereum-contracts/test/foundry/superfluid/GrifterContract.sol new file mode 100644 index 0000000000..eeadca5ae9 --- /dev/null +++ b/packages/ethereum-contracts/test/foundry/superfluid/GrifterContract.sol @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +import { + ISuperToken +} from "../../../contracts/interfaces/superfluid/ISuperfluid.sol"; +import { + IERC20 +} from "../../../contracts/interfaces/superfluid/ISuperfluid.sol"; + +import { ISETH } from "../../../contracts/interfaces/tokens/ISETH.sol"; + +import "forge-std/console.sol"; + +contract GrifterContract { + ISuperToken public immutable superToken; + IERC20 public immutable underlyingToken; + IERC20 public immutable aToken; + uint256 public immutable amount; + + constructor(ISuperToken _superToken, IERC20 _aToken, uint256 _amount) { + superToken = _superToken; + aToken = _aToken; + underlyingToken = IERC20(superToken.getUnderlyingToken()); + amount = _amount; + + if (address(underlyingToken) != address(0)) { + // Approve SuperToken to move underlying + underlyingToken.approve(address(superToken), type(uint256).max); + } + } + + receive() external payable {} + + function grift(uint256 iterations) external { + if (address(underlyingToken) == address(0)) { + // Native ETH path + for (uint256 i = 0; i < iterations; ++i) { + uint256 bal0 = aToken.balanceOf(address(superToken)); + + // Upgrade ETH + ISETH(address(superToken)).upgradeByETH{ value: amount }(); + + uint256 bal1 = aToken.balanceOf(address(superToken)); + + // Downgrade ETH + ISETH(address(superToken)).downgradeToETH(amount); + + uint256 bal2 = aToken.balanceOf(address(superToken)); + + // Analyze Downgrade + if (bal2 < bal1) { + uint256 burned = bal1 - bal2; + if (burned > amount) { + uint256 diff = burned - amount; + console.log("ETHx Downgrade Excess FOUND:"); + console.log(" Req: %s", amount); + console.log(" Burn: %s", burned); + console.log(" Diff: %s", diff); + revert("FOUND EXCESS BURN"); + } + } + } + } else { + // ERC20 Path + for (uint256 i = 0; i < iterations; ++i) { + uint256 bal0 = aToken.balanceOf(address(superToken)); + + superToken.upgrade(amount); + + uint256 bal1 = aToken.balanceOf(address(superToken)); + + superToken.downgrade(amount); + uint256 bal2 = aToken.balanceOf(address(superToken)); + + // Analyze Downgrade + if (bal2 < bal1) { + uint256 burned = bal1 - bal2; + if (burned > amount) { + uint256 diff = burned - amount; + console.log("ERC20 Downgrade Excess FOUND:"); + console.log(" Req: %s", amount); + console.log(" Burn: %s", burned); + console.log(" Diff: %s", diff); + revert("FOUND EXCESS BURN"); + } + } + } + } + } +} diff --git a/packages/ethereum-contracts/test/foundry/superfluid/SuperTokenYield.t.sol b/packages/ethereum-contracts/test/foundry/superfluid/SuperTokenYield.t.sol index ec4ef640bd..ed4d4f7032 100644 --- a/packages/ethereum-contracts/test/foundry/superfluid/SuperTokenYield.t.sol +++ b/packages/ethereum-contracts/test/foundry/superfluid/SuperTokenYield.t.sol @@ -4,10 +4,11 @@ pragma solidity ^0.8.23; import { Test } from "forge-std/Test.sol"; import { console } from "forge-std/console.sol"; import { AaveYieldBackend } from "../../../contracts/superfluid/AaveYieldBackend.sol"; -import { IERC20, ISuperfluid } from "../../../contracts/interfaces/superfluid/ISuperfluid.sol"; +import { IERC20, ISuperfluid, ISuperToken } from "../../../contracts/interfaces/superfluid/ISuperfluid.sol"; import { SuperToken } from "../../../contracts/superfluid/SuperToken.sol"; -import { IPool } from "aave-v3/interfaces/IPool.sol"; +import { IPool } from "aave-v3/src/contracts/interfaces/IPool.sol"; import { ISETH } from "../../../contracts/interfaces/tokens/ISETH.sol"; +import { GrifterContract } from "./GrifterContract.sol"; /** * @title SuperTokenYieldForkTest @@ -21,50 +22,62 @@ contract SuperTokenYieldForkTest is Test { // Base network constants uint256 internal constant CHAIN_ID = 8453; string internal constant RPC_URL = "https://mainnet.base.org"; - + // Aave V3 Pool on Base (verified address) address internal constant AAVE_POOL = 0xA238Dd80C259a72e81d7e4664a9801593F98d1c5; - + // Common tokens on Base address internal constant USDCx = 0xD04383398dD2426297da660F9CCA3d439AF9ce1b; // USDCx on Base address internal constant USDC = 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913; // USDC on Base address internal constant WETH = 0x4200000000000000000000000000000000000006; // WETH on Base + address internal constant aWETH = 0xD4a0e0b9149BCee3C920d2E00b5dE09138fd8bb7; address internal constant aUSDC = 0x4e65fE4DbA92790696d040ac24Aa414708F5c0AB; // aUSDC on Base - address internal constant ETHx = 0x46fd5cfB4c12D87acD3a13e92BAa53240C661D93; // ETHx on Base - + address internal constant SURPLUS_RECEIVER = 0xac808840f02c47C05507f48165d2222FF28EF4e1; // dao.superfluid.eth + SuperToken public superToken; /// @notice AaveYieldBackend contract instance AaveYieldBackend public aaveBackend; - /// @notice Underlying token (USDC) + /// @notice Underlying token (USDC or address(0) for ETH) IERC20 public underlyingToken; /// @notice Aave V3 Pool contract IPool public aavePool; - + /// @notice Set up the test environment by forking Base and deploying AaveYieldBackend function setUp() public { // Fork Base using public RPC vm.createSelectFork(RPC_URL); - + // Verify we're on Base assertEq(block.chainid, CHAIN_ID, "Chainid mismatch"); - + // Get Aave Pool aavePool = IPool(AAVE_POOL); - - // Use USDC as the underlying token for testing - underlyingToken = IERC20(USDC); - superToken = SuperToken(USDCx); - + // Default to USDC for existing tests + _setUpToken(USDC, USDCx); + + // provide ALICE with underlying and let her approve for upgrade + deal(USDC, ALICE, type(uint128).max); + vm.startPrank(ALICE); + IERC20(USDC).approve(address(superToken), type(uint256).max); + + console.log("aaveBackend address", address(aaveBackend)); + console.log("aUSDC address", address(aUSDC)); + } + + function _setUpToken(address _underlying, address _superToken) internal { + if (_underlying != address(0)) { + underlyingToken = IERC20(_underlying); + } else { + // For ETH, underlying is address(0) + underlyingToken = IERC20(address(0)); + } + + superToken = SuperToken(_superToken); + // Deploy AaveBackend - // Note: In a real scenario, the owner would be the SuperToken contract - // For testing, we use this contract as owner - aaveBackend = new AaveYieldBackend(IERC20(USDC), IPool(AAVE_POOL)); - - // Verify AaveBackend was deployed correctly - assertEq(address(aaveBackend.ASSET_TOKEN()), USDC, "Asset token mismatch"); - assertEq(address(aaveBackend.AAVE_POOL()), AAVE_POOL, "Aave pool mismatch"); + aaveBackend = new AaveYieldBackend(IERC20(_underlying), IPool(AAVE_POOL), SURPLUS_RECEIVER); // upgrade SuperToken to new logic SuperToken newSuperTokenLogic = new SuperToken(ISuperfluid(superToken.getHost()), superToken.POOL_ADMIN_NFT()); @@ -76,14 +89,6 @@ contract SuperTokenYieldForkTest is Test { vm.startPrank(address(superToken.getHost())); superToken.changeAdmin(ADMIN); vm.stopPrank(); - - // provide ALICE with underlying and let her approve for upgrade - deal(USDC, ALICE, type(uint128).max); - vm.startPrank(ALICE); - IERC20(USDC).approve(address(superToken), type(uint256).max); - - console.log("aaveBackend address", address(aaveBackend)); - console.log("aUSDC address", address(aUSDC)); } function _enableYieldBackend() public { @@ -98,23 +103,33 @@ contract SuperTokenYieldForkTest is Test { uint256 aTokenBalance = IERC20(aUSDC).balanceOf(address(superToken)); (uint256 superTokenNormalizedSupply,) = superToken.toUnderlyingAmount(superToken.totalSupply()); - assertGe(underlyingBalance + aTokenBalance, superTokenNormalizedSupply, "invariant failed: underlyingBalance + aTokenBalance insufficient"); + assertGe( + underlyingBalance + aTokenBalance, + superTokenNormalizedSupply, + "invariant failed: underlyingBalance + aTokenBalance insufficient" + ); + + assertEq( + underlyingBalance + aTokenBalance, + superTokenNormalizedSupply, + "invariant failed: underlyingBalance + aTokenBalance insufficient" + ); } - + /// @notice Test that we're forking the correct Base network function testForkBaseNetwork() public view { assertEq(block.chainid, CHAIN_ID, "Chainid mismatch"); assertTrue(AAVE_POOL.code.length > 0, "Aave Pool should exist"); assertTrue(USDC.code.length > 0, "USDC should exist"); } - + /// @notice Test AaveBackend deployment and initialization function testAaveBackendDeployment() public view { assertEq(address(aaveBackend.ASSET_TOKEN()), USDC, "Asset token should be USDC"); assertEq(address(aaveBackend.AAVE_POOL()), AAVE_POOL, "Aave pool address should match"); assertTrue(address(aaveBackend.A_TOKEN()) != address(0), "aToken should be set"); } - + function testEnableYieldBackend() public { // log USDC balance of SuperToken console.log("USDC balance of SuperToken", IERC20(USDC).balanceOf(address(superToken))); @@ -122,7 +137,7 @@ contract SuperTokenYieldForkTest is Test { _enableYieldBackend(); assertEq(address(superToken.getYieldBackend()), address(aaveBackend), "Yield backend mismatch"); - + // the SuperToken should now have a zero USDC balance and a non-zero aUSDC balance assertEq(IERC20(USDC).balanceOf(address(superToken)), 0, "USDC balance should be zero"); assertGt(IERC20(aUSDC).balanceOf(address(superToken)), 0, "aUSDC balance should be non-zero"); @@ -135,6 +150,16 @@ contract SuperTokenYieldForkTest is Test { } function testDisableYieldBackend() public { + // verify: underlying matches totalSupply + uint256 underlyingBalanceBefore = IERC20(USDC).balanceOf(address(superToken)); + uint256 superTokenBalanceBefore = superToken.totalSupply(); + (uint256 normalizedTotalSupply,) = superToken.toUnderlyingAmount(superTokenBalanceBefore); + assertEq( + underlyingBalanceBefore, + normalizedTotalSupply, + "precondition failed: underlyingBalanceBefore != normalizedTotalSupply" + ); + _enableYieldBackend(); vm.startPrank(ADMIN); @@ -171,7 +196,8 @@ contract SuperTokenYieldForkTest is Test { // downgrade vm.startPrank(ALICE); - // there's a flaw in the API here: upgrade may have down-rounded the amount, but doesn't tell as (via return value). In that case a consecutive downgrade (of the un-adjusted amount) would revert. + // there's a flaw in the API here: upgrade may have down-rounded the amount, but doesn't tell us (via return + // value). In that case a consecutive downgrade (of the un-adjusted amount) would revert. superToken.downgrade(1 ether); vm.stopPrank(); @@ -286,14 +312,12 @@ contract SuperTokenYieldForkTest is Test { } function testWithdrawSurplusFromYieldBackend() public { - address SURPLUS_RECEIVER = 0xac808840f02c47C05507f48165d2222FF28EF4e1; - // Simulate yield accumulation by transferring extra underlying to SuperToken - uint256 surplusAmount = 100 * 1e6; // 100 USDC - deal(USDC, address(this), surplusAmount); + // uint256 surplusAmount = 100 * 1e6; // 100 USDC + // deal(USDC, address(this), surplusAmount); _enableYieldBackend(); - + // Upgrade tokens to create supply uint256 upgradeAmount = 1000 * 1e18; vm.startPrank(ALICE); @@ -307,78 +331,319 @@ contract SuperTokenYieldForkTest is Test { console.log("USDC balance of SuperToken", IERC20(USDC).balanceOf(address(superToken))); console.log("aUSDC balance of SuperToken", IERC20(aUSDC).balanceOf(address(superToken))); // log normalized total supply - (uint256 normalizedTotalSupply, uint256 adjustedAmount) = superToken.toUnderlyingAmount(superToken.totalSupply()); + (uint256 normalizedTotalSupply, uint256 adjustedAmount) = + superToken.toUnderlyingAmount(superToken.totalSupply()); console.log("normalized total supply", normalizedTotalSupply); console.log("adjusted amount", adjustedAmount); - + vm.startPrank(ADMIN); superToken.withdrawSurplusFromYieldBackend(); vm.stopPrank(); - + uint256 receiverBalanceAfter = IERC20(USDC).balanceOf(SURPLUS_RECEIVER); uint256 aTokenBalanceAfter = IERC20(aUSDC).balanceOf(address(superToken)); console.log("aToken balance after", aTokenBalanceAfter); console.log("aToken balance diff", aTokenBalanceBefore - aTokenBalanceAfter); - + assertGt(receiverBalanceAfter, receiverBalanceBefore, "Surplus should be withdrawn to receiver"); assertLt(aTokenBalanceAfter, aTokenBalanceBefore, "aToken balance should decrease"); _verifyInvariants(); } function testUpgadeDowngradeETH() public { - // Get aWETH address from Aave pool - address aWETH = aavePool.getReserveAToken(WETH); - // Set up ETHx SuperToken ethxToken = SuperToken(ETHx); - + // Upgrade ETHx to new logic SuperToken newSuperTokenLogic = new SuperToken(ISuperfluid(ethxToken.getHost()), ethxToken.POOL_ADMIN_NFT()); vm.startPrank(address(ethxToken.getHost())); ethxToken.updateCode(address(newSuperTokenLogic)); vm.stopPrank(); - + // Designate admin for ETHx vm.startPrank(address(ethxToken.getHost())); ethxToken.changeAdmin(ADMIN); vm.stopPrank(); - + // Deploy AaveBackend for native ETH (address(0)) - AaveYieldBackend ethxBackend = new AaveYieldBackend(IERC20(address(0)), IPool(AAVE_POOL)); - + AaveYieldBackend ethxBackend = new AaveYieldBackend(IERC20(address(0)), IPool(AAVE_POOL), SURPLUS_RECEIVER); + // assert that USING_WETH is set - assertEq(ethxBackend.USING_WETH(), true); +// assertEq(ethxBackend.USING_WETH(), true); // Enable yield backend vm.startPrank(ADMIN); ethxToken.enableYieldBackend(ethxBackend); vm.stopPrank(); - + // Give ALICE some ETH vm.deal(ALICE, 10 ether); - + // Upgrade ETH using upgradeByETH uint256 upgradeAmount = 1 ether; vm.startPrank(ALICE); - ISETH(address(ethxToken)).upgradeByETH{value: upgradeAmount}(); + ISETH(address(ethxToken)).upgradeByETH{ value: upgradeAmount }(); vm.stopPrank(); - + uint256 aliceBalance = ethxToken.balanceOf(ALICE); assertGt(aliceBalance, 0, "ALICE should have ETHx tokens"); - + // Verify aWETH balance increased uint256 aWETHBalance = IERC20(aWETH).balanceOf(address(ethxToken)); assertGt(aWETHBalance, 0, "ETHx should have aWETH balance"); - + // Downgrade using downgradeToETH uint256 aliceETHBefore = ALICE.balance; vm.startPrank(ALICE); ISETH(address(ethxToken)).downgradeToETH(aliceBalance); vm.stopPrank(); - + uint256 aliceETHAfter = ALICE.balance; assertGt(aliceETHAfter, aliceETHBefore, "ALICE should receive ETH back"); assertEq(ethxToken.balanceOf(ALICE), 0, "ALICE should have no ETHx tokens"); } -} + function testGrifting() public { + _enableYieldBackend(); + + // 1. Setup Grifter + // Use an amount that is likely to cause rounding reduction. + // User mentioned "small inconvenience" but let's test with a standard amount. + // Aave rounding often happens due to Ray math on index scaling. + // 100 USDC is a good round number. + uint256 amountUnderlying = 100 * 1e6; // 100 USDC + uint256 amountSuper = 100 * 1e18; // 100 USDC in 18 decimals + console.log("Creating Grifter..."); + GrifterContract grifter = new GrifterContract(ISuperToken(address(superToken)), IERC20(aUSDC), amountSuper); + console.log("Grifter created."); + + // Fund grifter + deal(USDC, address(grifter), amountUnderlying); + + // Fund a victim to provide a buffer for rounding errors + address victim = address(0xBEEE); + uint256 victimAmountUnderlying = 1000 * 1e6; + uint256 victimAmountSuper = 1000 * 1e18; + deal(USDC, victim, victimAmountUnderlying); + console.log("Victim funded. Balance:", IERC20(USDC).balanceOf(victim)); + + vm.startPrank(victim); + IERC20(USDC).approve(address(superToken), victimAmountUnderlying); + console.log("Victim approved SuperToken."); + + console.log("Victim upgrading..."); + superToken.upgrade(victimAmountSuper); + console.log("Victim upgrade done."); + vm.stopPrank(); + + // 2. Measure state before + uint256 aTokenBalanceBefore = IERC20(aUSDC).balanceOf(address(superToken)); + + // 3. Execute Grift + // Run enough iterations to see meaningful damage but keep gas reasonable for test. + // 50 iterations + uint256 iterations = 50; + + // 3. Execute Grift + vm.startPrank(address(0x1337)); + uint256 gasBefore = gasleft(); + grifter.grift(iterations); + uint256 gasUsed = gasBefore - gasleft(); + vm.stopPrank(); + + // 4. Measure state after + uint256 aTokenBalanceAfter = IERC20(aUSDC).balanceOf(address(superToken)); + uint256 damage = aTokenBalanceBefore - aTokenBalanceAfter; + + // 5. Analysis + console.log("=== Grifting Analysis ==="); + console.log("Iterations:", iterations); + console.log("Gas Used:", gasUsed); + console.log("Damage (aToken dust lost):", damage); + console.log("Damage per 1 Million Gas:", (damage * 1000000) / gasUsed); + + // Expectation: damage should be roughly equal to iterations (1 wei per it) or 0 depending on rounding + // direction/conditions + // The user claims "x+1" consumption often happens. + + // Calculate Damage per 1 USD spent for tx fees + // Assumptions: + // - Base L2 gas price: 0.1 gwei (average low usage) + // - ETH Price: $2000 + // - L1 data cost is amortized/negligible for high volume batching or not included in this simple calc + // Cost in ETH = Gas Used * Gas Price + // Cost in USD = Cost in ETH * ETH Price + // Damage per 1 USD = Damage / Cost in USD + + // Using 18 decimals for precision in calculation + uint256 gasPrice = 0.1 gwei; + uint256 ethPrice = 2000; + + uint256 costInEthEx18 = gasUsed * gasPrice; + // costInUSD = costInEth * ethPrice + // But we want Damage / CostInUSD + // CostInUSD = (gasUsed * gasPrice * ethPrice) / 1e18 + // DamagePerUSD = Damage / ((gasUsed * gasPrice * ethPrice) / 1e18) + // DamagePerUSD = (Damage * 1e18) / (gasUsed * gasPrice * ethPrice) + + uint256 damagePerUSD = (damage * 1e18) / (gasUsed * gasPrice * ethPrice); + console.log("Damage per 1 USD (Base assumptions):", damagePerUSD); + console.log("Damage in USDC decimals per 1 USD:", damagePerUSD); // Since damage is in wei of USDC (6 decimals) + + // A damage of 1e6 would mean 1 USD of damage per 1 USD spent (break even). + } + + function testRiskOfWithdraw() public { + _enableYieldBackend(); + + // Amount from user report: 59999 USDC wei + // 59999 = 0.059999 USDC. + uint256 amount = 59999 * 1e12; // 18 decimals + + // OR if the user meant 59999 underlying units... + // The log said "requested amount 59999". + // And "aToken balance diff 60000". + // If aToken is 6 decimals (aUSDC), then it's USDC. + // If aToken is 18 decimals, then ? + // aUSDC is 6 decimals. + // So amount is 59999 wei USDC. + + // Let's test with the Grifter using this specific amount. + // Let's test with the Grifter using this specific amount. + GrifterContract grifter = new GrifterContract(ISuperToken(address(superToken)), IERC20(aUSDC), amount); + + // Fund Grifter with enough Underlying (USDC) + // amount (18 decimals) -> underlying (6 decimals) + // 59999 * 1e12 / 1e12 = 59999. + uint256 underlyingAmount = 59999; + deal(USDC, address(grifter), underlyingAmount); + + grifter.grift(1); + } + + function testFuzzGrifting(uint256 amount) public { + _enableYieldBackend(); + + // Fuzz amount between 1 wei USDC (1e12 SuperToken wei) and 100M USDC + // 1e12 is the smallest amount that represents 1 wei of USDC. + // Cap at 1 billion USDC. + amount = bound(amount, 1e12, 1_000_000_000 * 1e18); + + // Setup Grifter with fuzzed amount + // Grifter setup logic repeated here (could refactor, but kept inline for now) + GrifterContract grifter = new GrifterContract(ISuperToken(address(superToken)), IERC20(aUSDC), amount); + + // Calculate needed underlying amount (6 decimals) + // Roughly amount / 1e12 + uint256 amountUnderlying = amount / 1e12; + + // Fund grifter + deal(USDC, address(grifter), amountUnderlying); + + // Fund a victim to provide a buffer for rounding errors + address victim = address(0xBEEE); + uint256 victimAmountUnderlying = 1000 * 1e6; + uint256 victimAmountSuper = 1000 * 1e18; + deal(USDC, victim, victimAmountUnderlying); + vm.startPrank(victim); + IERC20(USDC).approve(address(superToken), victimAmountUnderlying); + try superToken.upgrade(victimAmountSuper) { + // success + } + catch { + console.log("Victim upgrade failed (likely Supply Cap), skipping"); + vm.stopPrank(); + return; + } + vm.stopPrank(); + + // Measure state before + uint256 aTokenBalanceBefore = IERC20(aUSDC).balanceOf(address(superToken)); + + // Execute grift cycle (multiple iterations to provoke rounding errors) + // Single iteration often yields 0 loss on fresh state. + try grifter.grift(20) { + // console.log("Grift success for amount %s", amount); + } + catch { + console.log("Grift failed (likely Supply Cap), skipping"); + return; + } + // Measure state after + uint256 aTokenBalanceAfter = IERC20(aUSDC).balanceOf(address(superToken)); + + if (aTokenBalanceBefore > aTokenBalanceAfter) { + uint256 loss = aTokenBalanceBefore - aTokenBalanceAfter; + if (loss > 0) { + console.log("Loss detected: %s wei for amount %s", loss, amount); + } + // Assert max loss per operation is bounded + // Based on manual testing, we saw up to 2 wei per operation. + // With 20 iterations, expected max loss is 40 wei. + // Let's set a conservative bound of 45 wei. + if (loss > 45) { + console.log("CRITICAL: Loss exceeded expected bound!"); + console.log("Amount:", amount); + console.log("Loss:", loss); + revert("Max loss exceeded expectation"); + } + } else { + // console.log("No loss for amount %s", amount); + } + } + + function testFuzzGriftingETHx(uint256 amount) public { + _setUpToken(address(0), ETHx); + _enableYieldBackend(); + vm.warp(block.timestamp + 1 hours); + + // Fuzz amount between 1 wei and 100M ETH + // 1e18 is 1 ETH. + // Cap at 100M ETH. + amount = bound(amount, 1e15, 100_000_000 * 1e18); + + // Setup Grifter with fuzzed amount + GrifterContract grifter = new GrifterContract(ISuperToken(address(superToken)), IERC20(aWETH), amount); + + // Fund grifter with ETH + vm.deal(address(grifter), amount); + + // Fund a victim buffer + address victim = address(0xBEEE); + uint256 victimAmount = 100 * 1e18; + vm.deal(victim, victimAmount); + vm.startPrank(victim); + ISETH(address(superToken)).upgradeByETH{ value: victimAmount }(); + vm.stopPrank(); + + // Measure state before + uint256 aTokenBalanceBefore = IERC20(aWETH).balanceOf(address(superToken)); + + // Execute grift cycle (20 iterations) + try grifter.grift(20) { + // success + } + catch { + console.log("Grift failed (likely Supply Cap or min/max), skipping"); + return; + } + // Measure state after + uint256 aTokenBalanceAfter = IERC20(aWETH).balanceOf(address(superToken)); + + if (aTokenBalanceBefore > aTokenBalanceAfter) { + uint256 loss = aTokenBalanceBefore - aTokenBalanceAfter; + if (loss > 0) { + console.log("Loss detected: %s wei for amount %s", loss, amount); + } + // Assert max loss per operation is bounded + // Expectation: 2 wei per op * 20 ops = 40 wei max. + // Buffer to 45 wei. + if (loss > 45) { + console.log("CRITICAL: Loss exceeded expected bound!"); + console.log("Amount:", amount); + console.log("Loss:", loss); + revert("Max loss exceeded expectation"); + } + } + } +} From 061a491dfd2e48e17e528ac1759e26b649e6a689 Mon Sep 17 00:00:00 2001 From: didi Date: Thu, 8 Jan 2026 17:05:24 +0100 Subject: [PATCH 26/44] unit tests --- .../yieldbackend/AaveETHYieldBackend.t.sol | 101 ++++++ .../yieldbackend/AaveYieldBackend.t.sol | 70 +++++ .../yieldbackend/ERC4626YieldBackend.t.sol | 57 ++++ .../yieldbackend/YieldBackendUnitTestBase.sol | 291 ++++++++++++++++++ 4 files changed, 519 insertions(+) create mode 100644 packages/ethereum-contracts/test/foundry/superfluid/yieldbackend/AaveETHYieldBackend.t.sol create mode 100644 packages/ethereum-contracts/test/foundry/superfluid/yieldbackend/AaveYieldBackend.t.sol create mode 100644 packages/ethereum-contracts/test/foundry/superfluid/yieldbackend/ERC4626YieldBackend.t.sol create mode 100644 packages/ethereum-contracts/test/foundry/superfluid/yieldbackend/YieldBackendUnitTestBase.sol diff --git a/packages/ethereum-contracts/test/foundry/superfluid/yieldbackend/AaveETHYieldBackend.t.sol b/packages/ethereum-contracts/test/foundry/superfluid/yieldbackend/AaveETHYieldBackend.t.sol new file mode 100644 index 0000000000..60dce92e3f --- /dev/null +++ b/packages/ethereum-contracts/test/foundry/superfluid/yieldbackend/AaveETHYieldBackend.t.sol @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: AGPLv3 +pragma solidity ^0.8.23; + +import { YieldBackendUnitTestBase } from "./YieldBackendUnitTestBase.sol"; +import { AaveETHYieldBackend } from "../../../../contracts/superfluid/AaveETHYieldBackend.sol"; +import { IYieldBackend } from "../../../../contracts/interfaces/superfluid/IYieldBackend.sol"; +import { IERC20 } from "../../../../contracts/interfaces/superfluid/ISuperfluid.sol"; +import { IPool } from "aave-v3/src/contracts/interfaces/IPool.sol"; + +/** + * @title tests for AaveETHYieldBackend with ETH/WETH on Base + * Tests the backend in isolation using delegatecall + */ +contract AaveETHYieldBackendUnitTest is YieldBackendUnitTestBase { + uint256 internal constant CHAIN_ID = 8453; + string internal constant RPC_URL = "https://mainnet.base.org"; + + address internal constant AAVE_POOL = 0xA238Dd80C259a72e81d7e4664a9801593F98d1c5; + address internal constant WETH = 0x4200000000000000000000000000000000000006; + + AaveETHYieldBackend internal aaveETHBackend; + + function getRpcUrl() internal pure override returns (string memory) { + return RPC_URL; + } + + function getChainId() internal pure override returns (uint256) { + return CHAIN_ID; + } + + /// @notice toUnderlyingAmount for ETH (18 decimals) - uses base implementation + /// @dev No override needed, ETH has 18 decimals like SuperToken + + function createBackend() internal override returns (IYieldBackend) { + aaveETHBackend = new AaveETHYieldBackend( + IPool(AAVE_POOL), + SURPLUS_RECEIVER + ); + return IYieldBackend(address(aaveETHBackend)); + } + + function getAssetToken() internal pure override returns (IERC20) { + // For AaveETHYieldBackend, the asset token is WETH + return IERC20(WETH); + } + + function fundTestContract() internal override { + // Fund with ETH (will be wrapped to WETH on deposit) + vm.deal(address(this), 10_000 ether); + } + + function getAssetDecimals() internal pure override returns (uint8) { + return 18; // ETH/WETH has 18 decimals + } + + function _getProtocolAddress() internal pure override returns (address) { + return AAVE_POOL; + } + + /// @notice Override _boundAmount to ensure minimum viable amounts for ETH + /// @dev Aave may have minimum deposit requirements, so we use a higher minimum + function _boundAmount(uint256 amount) internal pure override returns (uint256) { + // Minimum: 0.001 ETH (1e15 wei) to avoid issues with very small amounts + // Maximum: 1000 ETH + uint256 minAmount = 1e15; // 0.001 ETH + uint256 maxAmount = 1000 * 1e18; // 1000 ETH + return bound(amount, minAmount, maxAmount); + } + + /// @notice Override _fundAsset to handle ETH (native token) + function _fundAsset(uint256 amount) internal override { + // Ensure we have at least the amount in ETH + uint256 currentBalance = address(this).balance; + if (currentBalance < amount) { + vm.deal(address(this), amount); + } + } + + /// @notice Override _getAssetBalance to handle ETH balance + /// @dev For ETH backend, we track ETH balance (not WETH) + /// @dev Before deposit: ETH balance + /// @dev After deposit: ETH is wrapped and deposited, so ETH balance decreases + /// @dev After withdraw: ETH is unwrapped back, so ETH balance increases + function _getAssetBalance() internal view override returns (uint256) { + // Track ETH balance directly (the native token) + // WETH is just an intermediate step in the deposit/withdraw process + return address(this).balance; + } + + /// @notice Override _getSurplusReceiverBalance for WETH + function _getSurplusReceiverBalance() internal view override returns (uint256) { + // Note: Surplus is paid in WETH, not ETH + // Use assetToken (IERC20) which has balanceOf + return assetToken.balanceOf(SURPLUS_RECEIVER); + } + + /// @notice Allow the test contract to receive ETH + /// @dev Required for unwrapWETHAndForwardETH to send ETH back to the test contract + receive() external payable { } +} + diff --git a/packages/ethereum-contracts/test/foundry/superfluid/yieldbackend/AaveYieldBackend.t.sol b/packages/ethereum-contracts/test/foundry/superfluid/yieldbackend/AaveYieldBackend.t.sol new file mode 100644 index 0000000000..516a0a4b79 --- /dev/null +++ b/packages/ethereum-contracts/test/foundry/superfluid/yieldbackend/AaveYieldBackend.t.sol @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: AGPLv3 +pragma solidity ^0.8.23; + +import { YieldBackendUnitTestBase } from "./YieldBackendUnitTestBase.sol"; +import { AaveYieldBackend } from "../../../../contracts/superfluid/AaveYieldBackend.sol"; +import { IYieldBackend } from "../../../../contracts/interfaces/superfluid/IYieldBackend.sol"; +import { IERC20 } from "../../../../contracts/interfaces/superfluid/ISuperfluid.sol"; +import { IPool } from "aave-v3/src/contracts/interfaces/IPool.sol"; + +/** + * @title Unit tests for AaveYieldBackend with USDC on Base + * Tests the backend in isolation using delegatecall + */ +contract AaveYieldBackendUnitTest is YieldBackendUnitTestBase { + uint256 internal constant CHAIN_ID = 8453; + string internal constant RPC_URL = "https://mainnet.base.org"; + + address internal constant AAVE_POOL = 0xA238Dd80C259a72e81d7e4664a9801593F98d1c5; + address internal constant USDC = 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913; + + AaveYieldBackend internal aaveBackend; + + function getRpcUrl() internal pure override returns (string memory) { + return RPC_URL; + } + + function getChainId() internal pure override returns (uint256) { + return CHAIN_ID; + } + + /// @notice Override toUnderlyingAmount for USDC (6 decimals) + function toUnderlyingAmount(uint256 amount) + external + pure + override + returns (uint256 underlyingAmount, uint256 adjustedAmount) + { + // USDC has 6 decimals, SuperToken has 18 decimals + uint256 factor = 10 ** (18 - 6); + underlyingAmount = amount / factor; + adjustedAmount = underlyingAmount * factor; + } + + function createBackend() internal override returns (IYieldBackend) { + aaveBackend = new AaveYieldBackend( + IERC20(USDC), + IPool(AAVE_POOL), + SURPLUS_RECEIVER + ); + return IYieldBackend(address(aaveBackend)); + } + + function getAssetToken() internal override returns (IERC20) { + return IERC20(USDC); + } + + function fundTestContract() internal override { + // Fund with 200M USDC (6 decimals) + deal(USDC, address(this), 200_000_000 * 1e6); + } + + function getAssetDecimals() internal pure override returns (uint8) { + return 6; + } + + function _getProtocolAddress() internal view override returns (address) { + return AAVE_POOL; + } +} + diff --git a/packages/ethereum-contracts/test/foundry/superfluid/yieldbackend/ERC4626YieldBackend.t.sol b/packages/ethereum-contracts/test/foundry/superfluid/yieldbackend/ERC4626YieldBackend.t.sol new file mode 100644 index 0000000000..548c5fcdbc --- /dev/null +++ b/packages/ethereum-contracts/test/foundry/superfluid/yieldbackend/ERC4626YieldBackend.t.sol @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: AGPLv3 +pragma solidity ^0.8.23; + +import { YieldBackendUnitTestBase } from "./YieldBackendUnitTestBase.sol"; +import { ERC4626YieldBackend } from "../../../../contracts/superfluid/ERC4626YieldBackend.sol"; +import { IYieldBackend } from "../../../../contracts/interfaces/superfluid/IYieldBackend.sol"; +import { IERC20 } from "../../../../contracts/interfaces/superfluid/ISuperfluid.sol"; +import { IERC4626 } from "@openzeppelin-v5/contracts/interfaces/IERC4626.sol"; + +/** + * @title Unit tests for ERC4626YieldBackend with Spark USDS on Ethereum + * Tests the backend in isolation using delegatecall + */ +contract ERC4626YieldBackendUnitTest is YieldBackendUnitTestBase { + uint256 internal constant CHAIN_ID = 1; + string internal constant RPC_URL = "https://eth.drpc.org"; + address internal constant VAULT = 0xa3931d71877C0E7a3148CB7Eb4463524FEc27fbD; // sUSDS on Ethereum + + ERC4626YieldBackend internal erc4626Backend; + + function getRpcUrl() internal pure override returns (string memory) { + return RPC_URL; + } + + function getChainId() internal pure override returns (uint256) { + return CHAIN_ID; + } + + /// @notice toUnderlyingAmount for USDS (18 decimals) - uses base implementation + /// @dev No override needed, USDS has 18 decimals like SuperToken + + function createBackend() internal override returns (IYieldBackend) { + erc4626Backend = new ERC4626YieldBackend( + IERC4626(VAULT), + SURPLUS_RECEIVER + ); + return IYieldBackend(address(erc4626Backend)); + } + + function getAssetToken() internal view override returns (IERC20) { + return IERC20((IERC4626(VAULT)).asset()); + } + + function fundTestContract() internal override { + // Fund with 10M USDS (18 decimals) + deal(address(getAssetToken()), address(this), 10_000_000 * 1e18); + } + + function getAssetDecimals() internal pure override returns (uint8) { + return 18; // USDS has 18 decimals + } + + function _getProtocolAddress() internal pure override returns (address) { + return VAULT; + } +} + diff --git a/packages/ethereum-contracts/test/foundry/superfluid/yieldbackend/YieldBackendUnitTestBase.sol b/packages/ethereum-contracts/test/foundry/superfluid/yieldbackend/YieldBackendUnitTestBase.sol new file mode 100644 index 0000000000..ef94952e3e --- /dev/null +++ b/packages/ethereum-contracts/test/foundry/superfluid/yieldbackend/YieldBackendUnitTestBase.sol @@ -0,0 +1,291 @@ +// SPDX-License-Identifier: AGPLv3 +pragma solidity ^0.8.23; + +import { Test } from "forge-std/Test.sol"; +import { console } from "forge-std/console.sol"; +import { IYieldBackend } from "../../../../contracts/interfaces/superfluid/IYieldBackend.sol"; +import { IERC20 } from "../../../../contracts/interfaces/superfluid/ISuperfluid.sol"; + +/** + * @title YieldBackendUnitTestBase + * @notice Abstract base contract for unit testing yield backends in isolation. + * @notice The test contract itself takes the role of SuperToken for delegatecall operations. + * @notice Concrete implementations must provide backend-specific setup and configuration. + */ +abstract contract YieldBackendUnitTestBase is Test { + // Constants (defined by concrete implementations) + address internal constant SURPLUS_RECEIVER = 0xac808840f02c47C05507f48165d2222FF28EF4e1; // dao.superfluid.eth + + // State variables (set by concrete implementations) + IYieldBackend internal backend; + IERC20 internal assetToken; + + // ============ Abstract functions for network configuration ============ + + /// @notice Get the RPC URL for forking + function getRpcUrl() internal pure virtual returns (string memory); + + /// @notice Get the expected chain ID + function getChainId() internal pure virtual returns (uint256); + + /// @notice Set up the test environment - must be implemented by concrete tests + function setUp() public virtual { + vm.createSelectFork(getRpcUrl()); + assertEq(block.chainid, getChainId(), "Chainid mismatch"); + + // Deploy and configure backend (implemented by concrete tests) + backend = createBackend(); + assetToken = getAssetToken(); + + // Fund the test contract with underlying asset + fundTestContract(); + + // Enable the backend (usually sets ERC20 approvals) + (bool success,) = address(backend).delegatecall( + abi.encodeWithSelector(IYieldBackend.enable.selector) + ); + require(success, "enable failed"); + } + + /// @notice Mock of toUnderlyingAmount - assumes 18 decimals by default + /// @dev Can be overridden in concrete tests for different decimal configurations + function toUnderlyingAmount(uint256 amount) + external + pure + virtual + returns (uint256 underlyingAmount, uint256 adjustedAmount) + { + // Default: assume same decimals (18) + underlyingAmount = amount; + adjustedAmount = amount; + } + + // ============ Abstract functions to be implemented by concrete tests ============ + + /// @notice Get the yield backend instance + function createBackend() internal virtual returns (IYieldBackend); + + /// @notice Get the underlying asset token + function getAssetToken() internal virtual returns (IERC20); + + /// @notice Fund the test contract with underlying asset + function fundTestContract() internal virtual; + + /// @notice Get the asset token decimals (for proper amount handling) + function getAssetDecimals() internal pure virtual returns (uint8); + + // ============ Helper functions ============ + + /// @notice Execute deposit via delegatecall + function _deposit(uint256 amount) internal { + (bool success,) = address(backend).delegatecall( + abi.encodeWithSelector(IYieldBackend.deposit.selector, amount) + ); + require(success, "deposit failed"); + } + + /// @notice Execute withdraw via delegatecall + function _withdraw(uint256 amount) internal { + console.log("_withdraw: calling backend at", address(backend)); + console.log("_withdraw: amount", amount); + console.log("_withdraw: address(this) before", address(this)); + console.log("_withdraw: address(this).balance before", address(this).balance); + + (bool success, bytes memory returnData) = address(backend).delegatecall( + abi.encodeWithSelector(IYieldBackend.withdraw.selector, amount) + ); + + console.log("_withdraw: delegatecall success", success); + if (!success) { + console.log("_withdraw: returnData length", returnData.length); + if (returnData.length > 0) { + console.logBytes(returnData); + } + } + console.log("_withdraw: address(this).balance after", address(this).balance); + + require(success, "withdraw failed"); + } + + /// @notice Execute withdrawMax via delegatecall + function _withdrawMax() internal { + (bool success,) = address(backend).delegatecall( + abi.encodeWithSelector(IYieldBackend.withdrawMax.selector) + ); + require(success, "withdrawMax failed"); + } + + /// @notice Execute withdrawSurplus via delegatecall + function _withdrawSurplus(uint256 totalSupply) internal { + (bool success,) = address(backend).delegatecall( + abi.encodeWithSelector(IYieldBackend.withdrawSurplus.selector, totalSupply) + ); + require(success, "withdrawSurplus failed"); + } + + /// @notice Execute enable via delegatecall + function _enable() internal { + (bool success,) = address(backend).delegatecall( + abi.encodeWithSelector(IYieldBackend.enable.selector) + ); + require(success, "enable failed"); + } + + /// @notice Execute disable via delegatecall + function _disable() internal { + (bool success,) = address(backend).delegatecall( + abi.encodeWithSelector(IYieldBackend.disable.selector) + ); + require(success, "disable failed"); + } + + // ============ Test functions ============ + + /// @notice Test enable() - assert: approval set + function testEnable() public view { + // already enabled in setUp + // Verify approval is set to max + uint256 allowanceAfter = assetToken.allowance(address(this), _getProtocolAddress()); + assertEq(allowanceAfter, type(uint256).max, "approval should be max after enable"); + } + + /// @notice Test disable() - assert: approval revoked + function testDisable() public { + // Disable backend + _disable(); + + // Check approval is revoked + uint256 allowance = assetToken.allowance(address(this), _getProtocolAddress()); + assertEq(allowance, 0, "approval should be revoked"); + } + + /// @notice Test deposit() - assert: underlying asset balance decreased by amount + function testDeposit(uint256 amount) public { + amount = _boundAmount(amount); + + // Fund contract with more than amount + _fundAsset(amount * 2); + + uint256 balanceBefore = _getAssetBalance(); + _deposit(amount); + uint256 balanceAfter = _getAssetBalance(); + + assertEq(balanceBefore - balanceAfter, amount, "balance should decrease by amount"); + } + + /// @notice Test withdraw() - assert: underlying asset balance increased by amount + function testWithdraw(uint256 amount) public { + amount = _boundAmount(amount); + + // Fund, deposit, then withdraw + _fundAsset(amount * 2); + _deposit(amount * 2); + + uint256 balanceBefore = _getAssetBalance(); + console.log("testWithdraw: amount requested", amount); + console.log("testWithdraw: balance before", balanceBefore); + console.log("testWithdraw: msg.sender", msg.sender); + console.log("testWithdraw: address(this)", address(this)); + + _withdraw(amount); + + uint256 balanceAfter = _getAssetBalance(); + uint256 balanceIncrease = balanceAfter - balanceBefore; + + console.log("testWithdraw: balance after", balanceAfter); + console.log("testWithdraw: balance increase", balanceIncrease); + console.log("testWithdraw: difference (increase - amount)", int256(balanceIncrease) - int256(amount)); + + assertEq(balanceAfter - balanceBefore, amount, "balance should increase by amount"); + } + + /// @notice Test withdrawMax() - prep: deposit random amount, fast forward random time + /// @notice assert: balance after is > balance before (accrued yield) + function testWithdrawMax(uint256 depositAmount, uint256 timeForward) public { + depositAmount = _boundAmount(depositAmount); + // Fast forward between 1 hour and 365 days + timeForward = bound(timeForward, 1 hours, 365 days); + + // Fund and deposit + _fundAsset(depositAmount); + _deposit(depositAmount); + + // Fast forward time to accrue yield + vm.warp(block.timestamp + timeForward); + + // Record balance before withdrawMax + uint256 balanceBefore = _getAssetBalance(); + + // Withdraw max + _withdrawMax(); + + // Record balance after + uint256 balanceAfter = _getAssetBalance(); + + // Balance after should be greater than before (yield accrued) + assertGt(balanceAfter, balanceBefore, "balance after should be greater (yield accrued)"); + } + + /// @notice Test withdrawSurplus() - prep: deposit random amount, fast forward random time + /// @notice assert: surplus receiver balance increased + /// @dev Note: For ETH backends, surplus is paid in WETH, not ETH + function testWithdrawSurplus(uint256 depositAmount, uint256 timeForward) public { + depositAmount = _boundAmount(depositAmount); + // Fast forward between 1 hour and 365 days + timeForward = bound(timeForward, 1 hours, 365 days); + + // Fund and deposit + _fundAsset(depositAmount); + _deposit(depositAmount); + + // Fast forward time to accrue yield + vm.warp(block.timestamp + timeForward); + + // Calculate total supply (in 18 decimals) + uint256 totalSupply = depositAmount; + // Note: normalizedTotalSupply not used directly, but toUnderlyingAmount is called for consistency + this.toUnderlyingAmount(totalSupply); + + // Record surplus receiver balance before + uint256 receiverBalanceBefore = _getSurplusReceiverBalance(); + + // Withdraw surplus + _withdrawSurplus(totalSupply); + + // Record surplus receiver balance after + uint256 receiverBalanceAfter = _getSurplusReceiverBalance(); + + // Receiver balance should increase + assertGt(receiverBalanceAfter, receiverBalanceBefore, "surplus receiver balance should increase"); + } + + // ============ Internal helper functions ============ + + /// @notice Bound amount to reasonable range based on asset decimals + /// @dev Can be overridden in concrete tests for custom bounds + function _boundAmount(uint256 amount) internal view virtual returns (uint256) { + uint8 decimals = getAssetDecimals(); + uint256 minAmount = 10 ** decimals; // 1 token + uint256 maxAmount = 1_000_000 * 10 ** decimals; // 1M tokens + return bound(amount, minAmount, maxAmount); + } + + /// @notice Fund the test contract with underlying asset + function _fundAsset(uint256 amount) internal virtual { + deal(address(assetToken), address(this), amount); + } + + /// @notice Get the underlying asset balance of this contract + function _getAssetBalance() internal view virtual returns (uint256) { + return assetToken.balanceOf(address(this)); + } + + /// @notice Get the protocol address that needs approval (e.g., Aave Pool, ERC4626 Vault) + function _getProtocolAddress() internal view virtual returns (address); + + /// @notice Get the surplus receiver balance + function _getSurplusReceiverBalance() internal view virtual returns (uint256) { + return assetToken.balanceOf(SURPLUS_RECEIVER); + } +} + From 3500962666dc0624d2aa007289b719977d2e4a27 Mon Sep 17 00:00:00 2001 From: didi Date: Thu, 8 Jan 2026 18:38:20 +0100 Subject: [PATCH 27/44] remove console logs --- .../yieldbackend/YieldBackendUnitTestBase.sol | 23 ------------------- 1 file changed, 23 deletions(-) diff --git a/packages/ethereum-contracts/test/foundry/superfluid/yieldbackend/YieldBackendUnitTestBase.sol b/packages/ethereum-contracts/test/foundry/superfluid/yieldbackend/YieldBackendUnitTestBase.sol index ef94952e3e..f444737e15 100644 --- a/packages/ethereum-contracts/test/foundry/superfluid/yieldbackend/YieldBackendUnitTestBase.sol +++ b/packages/ethereum-contracts/test/foundry/superfluid/yieldbackend/YieldBackendUnitTestBase.sol @@ -86,24 +86,9 @@ abstract contract YieldBackendUnitTestBase is Test { /// @notice Execute withdraw via delegatecall function _withdraw(uint256 amount) internal { - console.log("_withdraw: calling backend at", address(backend)); - console.log("_withdraw: amount", amount); - console.log("_withdraw: address(this) before", address(this)); - console.log("_withdraw: address(this).balance before", address(this).balance); - (bool success, bytes memory returnData) = address(backend).delegatecall( abi.encodeWithSelector(IYieldBackend.withdraw.selector, amount) ); - - console.log("_withdraw: delegatecall success", success); - if (!success) { - console.log("_withdraw: returnData length", returnData.length); - if (returnData.length > 0) { - console.logBytes(returnData); - } - } - console.log("_withdraw: address(this).balance after", address(this).balance); - require(success, "withdraw failed"); } @@ -182,20 +167,12 @@ abstract contract YieldBackendUnitTestBase is Test { _deposit(amount * 2); uint256 balanceBefore = _getAssetBalance(); - console.log("testWithdraw: amount requested", amount); - console.log("testWithdraw: balance before", balanceBefore); - console.log("testWithdraw: msg.sender", msg.sender); - console.log("testWithdraw: address(this)", address(this)); _withdraw(amount); uint256 balanceAfter = _getAssetBalance(); uint256 balanceIncrease = balanceAfter - balanceBefore; - console.log("testWithdraw: balance after", balanceAfter); - console.log("testWithdraw: balance increase", balanceIncrease); - console.log("testWithdraw: difference (increase - amount)", int256(balanceIncrease) - int256(amount)); - assertEq(balanceAfter - balanceBefore, amount, "balance should increase by amount"); } From fd3aa4fd8020d247ead79c869e9bc6324665c226 Mon Sep 17 00:00:00 2001 From: didi Date: Mon, 12 Jan 2026 11:46:22 +0100 Subject: [PATCH 28/44] add re-done integration test for AaveYieldBackend --- .../AaveYieldBackendIntegration.t.sol | 329 ++++++++++++++++++ 1 file changed, 329 insertions(+) create mode 100644 packages/ethereum-contracts/test/foundry/superfluid/yieldbackend/AaveYieldBackendIntegration.t.sol diff --git a/packages/ethereum-contracts/test/foundry/superfluid/yieldbackend/AaveYieldBackendIntegration.t.sol b/packages/ethereum-contracts/test/foundry/superfluid/yieldbackend/AaveYieldBackendIntegration.t.sol new file mode 100644 index 0000000000..19d3c7e572 --- /dev/null +++ b/packages/ethereum-contracts/test/foundry/superfluid/yieldbackend/AaveYieldBackendIntegration.t.sol @@ -0,0 +1,329 @@ +// SPDX-License-Identifier: AGPLv3 +pragma solidity ^0.8.23; + +import { Test } from "forge-std/Test.sol"; +import { AaveYieldBackend } from "../../../../contracts/superfluid/AaveYieldBackend.sol"; +import { IERC20, ISuperfluid } from "../../../../contracts/interfaces/superfluid/ISuperfluid.sol"; +import { SuperToken } from "../../../../contracts/superfluid/SuperToken.sol"; +import { IPool } from "aave-v3/src/contracts/interfaces/IPool.sol"; + +/** + * @title AaveYieldBackendIntegrationTest + * @notice Integration tests for AaveYieldBackend with USDC on Base + * @author Superfluid + */ +contract AaveYieldBackendIntegrationTest is Test { + address internal constant ALICE = address(0x420); + address internal constant ADMIN = address(0xAAA); + + // Base network constants + uint256 internal constant CHAIN_ID = 8453; + string internal constant RPC_URL = "https://mainnet.base.org"; + + // Aave V3 Pool on Base (verified address) + address internal constant AAVE_POOL = 0xA238Dd80C259a72e81d7e4664a9801593F98d1c5; + + // Common tokens on Base + address internal constant USDCX = 0xD04383398dD2426297da660F9CCA3d439AF9ce1b; // USDCx on Base + address internal constant USDC = 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913; // USDC on Base + address internal constant A_USDC = 0x4e65fE4DbA92790696d040ac24Aa414708F5c0AB; // aUSDC on Base + address internal constant SURPLUS_RECEIVER = 0xac808840f02c47C05507f48165d2222FF28EF4e1; // dao.superfluid.eth + + /// Rounding tolerance for Aave deposit/withdraw operations (in wei) + uint256 internal constant AAVE_ROUNDING_TOLERANCE = 2; + + SuperToken public superToken; + AaveYieldBackend public aaveBackend; + IERC20 public underlyingToken; + IPool public aavePool; + /// Initial excess underlying balance (underlyingBalance - normalizedTotalSupply) + uint256 public initialExcessUnderlying; + + /// @notice Set up the test environment by forking the chain and deploying AaveYieldBackend + function setUp() public { + vm.createSelectFork(RPC_URL); + + // Verify chain id + assertEq(block.chainid, CHAIN_ID, "Chainid mismatch"); + + // Get Aave Pool + aavePool = IPool(AAVE_POOL); + + // Set up USDC + underlyingToken = IERC20(USDC); + superToken = SuperToken(USDCX); + + // Deploy AaveBackend + aaveBackend = new AaveYieldBackend(IERC20(USDC), IPool(AAVE_POOL), SURPLUS_RECEIVER); + + // upgrade SuperToken to new logic (including the yield backend related code) + SuperToken newSuperTokenLogic = new SuperToken(ISuperfluid(superToken.getHost()), superToken.POOL_ADMIN_NFT()); + vm.startPrank(address(superToken.getHost())); + superToken.updateCode(address(newSuperTokenLogic)); + vm.stopPrank(); + + // designate an admin for the SuperToken + vm.startPrank(address(superToken.getHost())); + superToken.changeAdmin(ADMIN); + vm.stopPrank(); + + // provide ALICE with underlying and let her approve for upgrade + deal(USDC, ALICE, type(uint128).max); + vm.startPrank(ALICE); + IERC20(USDC).approve(address(superToken), type(uint256).max); + vm.stopPrank(); + + // Calculate and store initial excess underlying balance + // The underlying balance may be greater than totalSupply due to rounding or initial state + uint256 underlyingBalance = IERC20(USDC).balanceOf(address(superToken)); + (uint256 normalizedTotalSupply,) = superToken.toUnderlyingAmount(superToken.totalSupply()); + + // assert that the underlying balance is equal or greater than total supply (aka the SuperToken is solvent) + assertGe( + underlyingBalance, + normalizedTotalSupply, + "underlyingBalance should be >= normalizedTotalSupply" + ); + initialExcessUnderlying = underlyingBalance - normalizedTotalSupply; + } + + function _enableYieldBackend() public { + vm.startPrank(ADMIN); + superToken.enableYieldBackend(aaveBackend); + vm.stopPrank(); + } + + /// @notice Verify invariants for the SuperToken yield backend system + /// @param preserveInitialExcess If true, expect initial excess to be preserved (not withdrawn as surplus) + /// @param numAaveOperations Number of Aave deposit/withdraw operations that have occurred + function _verifyInvariants(bool preserveInitialExcess, uint256 numAaveOperations) internal view { + // underlyingBalance + aTokenBalance >= superToken.supply() [+ initialExcessUnderlying if preserved] + // Allow for Aave rounding tolerance (may lose up to 2 wei per operation) + uint256 underlyingBalance = IERC20(USDC).balanceOf(address(superToken)); + uint256 aTokenBalance = IERC20(A_USDC).balanceOf(address(superToken)); + (uint256 superTokenNormalizedSupply,) = superToken.toUnderlyingAmount(superToken.totalSupply()); + + uint256 expectedMinTotalAssets = preserveInitialExcess + ? superTokenNormalizedSupply + initialExcessUnderlying + : superTokenNormalizedSupply; + uint256 totalAssets = underlyingBalance + aTokenBalance; + + // Calculate total tolerance based on number of operations + uint256 totalTolerance = numAaveOperations * AAVE_ROUNDING_TOLERANCE; + + // Add tolerance to actual to avoid underflow, equivalent to: actual >= expected - tolerance + assertGe( + totalAssets + totalTolerance, + expectedMinTotalAssets, + preserveInitialExcess + ? "invariant failed: total assets should be >= supply + initial excess (accounting for rounding)" + : "invariant failed: total assets should be >= supply (accounting for rounding)" + ); + } + + /// @notice Test enabling yield backend + function testEnableYieldBackend() public { + // Record state before enabling + uint256 underlyingBalanceBefore = IERC20(USDC).balanceOf(address(superToken)); + (uint256 normalizedTotalSupplyBefore,) = superToken.toUnderlyingAmount(superToken.totalSupply()); + uint256 expectedUnderlyingBefore = normalizedTotalSupplyBefore + initialExcessUnderlying; + + // Verify initial state + assertGe( + underlyingBalanceBefore, + expectedUnderlyingBefore, + "initial underlying should be >= supply + initial excess" + ); + + _enableYieldBackend(); + + assertEq(address(superToken.getYieldBackend()), address(aaveBackend), "Yield backend mismatch"); + + // the SuperToken should now have a zero USDC balance (all deposited) + assertEq(IERC20(USDC).balanceOf(address(superToken)), 0, "USDC balance should be zero"); + + uint256 aTokenBalanceAfter = IERC20(A_USDC).balanceOf(address(superToken)); + assertGe( + aTokenBalanceAfter, + underlyingBalanceBefore - AAVE_ROUNDING_TOLERANCE, + "aUSDC balance should match previous underlying balance" + ); + + // The aToken balance should approximately match what was deposited + // Account for initial excess and potential rounding in Aave + assertGe( + aTokenBalanceAfter, + expectedUnderlyingBefore - 1000, // Allow some rounding tolerance + "aToken balance should approximately match deposited amount" + ); + + // 1 operation: enable deposits all existing underlying + _verifyInvariants(true, 1); + } + + /// @notice Test disabling yield backend + function testDisableYieldBackend() public { + // verify: underlying >= totalSupply (with initial excess accounted for) + uint256 underlyingBalanceBefore = IERC20(USDC).balanceOf(address(superToken)); + uint256 superTokenBalanceBefore = superToken.totalSupply(); + (uint256 normalizedTotalSupply,) = superToken.toUnderlyingAmount(superTokenBalanceBefore); + uint256 expectedUnderlying = normalizedTotalSupply + initialExcessUnderlying; + assertGe( + underlyingBalanceBefore, + expectedUnderlying, + "precondition failed: underlyingBalanceBefore should be >= supply + initial excess" + ); + + _enableYieldBackend(); + + vm.startPrank(ADMIN); + superToken.disableYieldBackend(); + vm.stopPrank(); + assertEq(address(superToken.getYieldBackend()), address(0), "Yield backend mismatch"); + + // the SuperToken should now have a non-zero USDC balance and a zero aUSDC balance + uint256 underlyingBalanceAfter = IERC20(USDC).balanceOf(address(superToken)); + assertGt(underlyingBalanceAfter, 0, "USDC balance should be non-zero"); + assertEq(IERC20(A_USDC).balanceOf(address(superToken)), 0, "aUSDC balance should be zero"); + + // After disabling, underlying balance should be at least the amount we had in aTokens + initial excess + // (the aTokens were converted back to underlying) + // Allow for Aave rounding tolerance (may lose up to 2 wei) + // Add tolerance to actual to avoid underflow + assertGe( + underlyingBalanceAfter + AAVE_ROUNDING_TOLERANCE, + expectedUnderlying, + "underlying balance after disable should be >= original underlying + initial excess" + ); + + // 2 operations: enable deposits + disable withdraws + _verifyInvariants(true, 2); + } + + /// @notice Test upgrade and downgrade with fuzzed amount + function testUpgradeDowngrade(uint256 amount) public { + // Bound amount to reasonable range + // USDC has 6 decimals, SuperToken has 18 decimals + // Minimum: 1 USDC (1e6) = 1e18 SuperToken units + // Maximum: 1M USDC (1e6 * 1e6) = 1e24 SuperToken units + amount = bound(amount, 1e18, 1_000_000 * 1e18); + + _enableYieldBackend(); + + vm.startPrank(ALICE); + superToken.upgrade(amount); + vm.stopPrank(); + + // Downgrade + vm.startPrank(ALICE); + // Note: upgrade may have down-rounded the amount, but doesn't tell us (via return value). + // In that case a consecutive downgrade (of the un-adjusted amount) might revert. + // For fuzzing, we downgrade the actual balance ALICE received + uint256 aliceBalance = superToken.balanceOf(ALICE); + superToken.downgrade(aliceBalance); + vm.stopPrank(); + + // 3 operations: enable deposits + upgrade deposits + downgrade withdraws + _verifyInvariants(true, 3); + } + + /// @notice Test withdrawing surplus due to excess underlying balance + function testWithdrawSurplusFromYieldBackendExcessUnderlying() public { + _enableYieldBackend(); + + // Upgrade tokens to create supply + uint256 upgradeAmount = 1000 * 1e18; + vm.startPrank(ALICE); + superToken.upgrade(upgradeAmount); + vm.stopPrank(); + + // Manually add excess underlying to SuperToken to simulate surplus + // This could happen if someone accidentally sends tokens to the SuperToken + uint256 surplusAmount = 100 * 1e6; // 100 USDC + deal(USDC, address(superToken), surplusAmount); + + uint256 receiverBalanceBefore = IERC20(USDC).balanceOf(SURPLUS_RECEIVER); + uint256 underlyingBalanceBefore = IERC20(USDC).balanceOf(address(superToken)); + uint256 aTokenBalanceBefore = IERC20(A_USDC).balanceOf(address(superToken)); + + // Verify there is excess underlying + (uint256 normalizedTotalSupply,) = superToken.toUnderlyingAmount(superToken.totalSupply()); + uint256 totalAssetsBefore = underlyingBalanceBefore + aTokenBalanceBefore; + assertGt( + totalAssetsBefore, + normalizedTotalSupply + 100, // withdrawSurplus uses -100 margin + "Precondition: excess underlying should exist" + ); + + vm.startPrank(ADMIN); + superToken.withdrawSurplusFromYieldBackend(); + vm.stopPrank(); + + uint256 receiverBalanceAfter = IERC20(USDC).balanceOf(SURPLUS_RECEIVER); + + // Surplus should be withdrawn to receiver + uint256 surplusWithdrawn = receiverBalanceAfter - receiverBalanceBefore; + assertGt(surplusWithdrawn, 0, "Surplus should be withdrawn to receiver"); + // The surplus withdrawn should be approximately the excess (minus 100 wei margin) + assertGe( + surplusWithdrawn, + surplusAmount - 200, + "Surplus withdrawn should be approximately the excess" + ); + + // After withdrawing surplus, initial excess is also withdrawn, so don't expect it to be preserved + // 3 operations: enable deposits + upgrade deposits + withdraw surplus + _verifyInvariants(false, 3); + } + + /// @notice Test withdrawing surplus generated by yield protocol (fast forward time) + function testWithdrawSurplusFromYieldBackendYieldAccrued(uint256 timeForward) public { + // Bound time forward between 1 hour and 365 days + timeForward = bound(timeForward, 1 hours, 365 days); + + _enableYieldBackend(); + + // Record initial state before yield accrual + (uint256 normalizedTotalSupplyInitial,) = superToken.toUnderlyingAmount(superToken.totalSupply()); + + // Fast forward time to accrue yield in Aave + vm.warp(block.timestamp + timeForward); + + // Calculate total supply after time forward (should be unchanged) + (uint256 normalizedTotalSupply,) = + superToken.toUnderlyingAmount(superToken.totalSupply()); + assertEq( + normalizedTotalSupply, + normalizedTotalSupplyInitial, + "Total supply should not change from time forward" + ); + + uint256 receiverBalanceBefore = IERC20(USDC).balanceOf(SURPLUS_RECEIVER); + uint256 underlyingBalanceBefore = IERC20(USDC).balanceOf(address(superToken)); + uint256 aTokenBalanceBefore = IERC20(A_USDC).balanceOf(address(superToken)); + + // Total assets should be greater than supply due to yield accrual + // Note: Aave yield accrues by increasing the underlying value of aTokens over time + uint256 totalAssetsBefore = underlyingBalanceBefore + aTokenBalanceBefore; + + // Check if there's actually surplus to withdraw (after the 100 wei margin used in withdrawSurplus) + bool hasSurplus = totalAssetsBefore > normalizedTotalSupply + 100; + assertTrue(hasSurplus, "no surplus, may need to review the lower bound for timeForward"); + + vm.startPrank(ADMIN); + superToken.withdrawSurplusFromYieldBackend(); + vm.stopPrank(); + + uint256 receiverBalanceAfter = IERC20(USDC).balanceOf(SURPLUS_RECEIVER); + uint256 aTokenBalanceAfter = IERC20(A_USDC).balanceOf(address(superToken)); + + // Surplus should be withdrawn to receiver + assertGt(receiverBalanceAfter, receiverBalanceBefore, "Surplus should be withdrawn to receiver"); + assertLt(aTokenBalanceAfter, aTokenBalanceBefore, "aToken balance should decrease"); + + // After withdrawing surplus, initial excess is also withdrawn, so don't expect it to be preserved + // 2 operations: enable deposits + withdraw surplus + _verifyInvariants(false, 2); + } +} + From e459e00056519d344830cf111225ebec5bcc3bab Mon Sep 17 00:00:00 2001 From: didi Date: Mon, 12 Jan 2026 12:12:13 +0100 Subject: [PATCH 29/44] add randomized sequence --- .../AaveYieldBackendIntegration.t.sol | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) diff --git a/packages/ethereum-contracts/test/foundry/superfluid/yieldbackend/AaveYieldBackendIntegration.t.sol b/packages/ethereum-contracts/test/foundry/superfluid/yieldbackend/AaveYieldBackendIntegration.t.sol index 19d3c7e572..233147ad28 100644 --- a/packages/ethereum-contracts/test/foundry/superfluid/yieldbackend/AaveYieldBackendIntegration.t.sol +++ b/packages/ethereum-contracts/test/foundry/superfluid/yieldbackend/AaveYieldBackendIntegration.t.sol @@ -325,5 +325,140 @@ contract AaveYieldBackendIntegrationTest is Test { // 2 operations: enable deposits + withdraw surplus _verifyInvariants(false, 2); } + + /*////////////////////////////////////////////////////////////////////////// + Random Sequence Fuzz Tests + //////////////////////////////////////////////////////////////////////////*/ + + struct YieldBackendStep { + uint8 a; // action type: 0 enable, 1 disable, 2 switch, 3 upgrade, 4 downgrade, 5 withdraw surplus + uint32 v; // action param (amount for upgrade/downgrade, unused for others) + uint16 dt; // time delta (for yield accrual simulation) + } + + /// @notice Test random sequence of yield backend operations + /// @dev Simulates real-world usage patterns with appropriate frequency distribution + function testRandomYieldBackendSequence(YieldBackendStep[20] memory steps) external { + // Track state + bool backendEnabled = false; + bool initialExcessPreserved = true; // Track if initial excess has been withdrawn via surplus + uint256 numAaveOperations = 0; + AaveYieldBackend currentBackend = aaveBackend; + + for (uint256 i = 0; i < steps.length; ++i) { + YieldBackendStep memory s = steps[i]; + uint256 action = s.a % 20; // Use modulo 20 for frequency distribution + + // Action frequency distribution: + // 0: Enable (5%) + // 1: Disable (5%) + // 2: Switch (5%) + // 3-16: Upgrade/Downgrade (70%, split evenly: 3-9 upgrade, 10-16 downgrade) + // 17-19: Withdraw surplus (15%) + + if (action == 0) { + // Enable yield backend (5% frequency) + if (!backendEnabled) { + vm.startPrank(ADMIN); + superToken.enableYieldBackend(currentBackend); + vm.stopPrank(); + backendEnabled = true; + numAaveOperations += 1; // enable deposits all existing underlying + } + } else if (action == 1) { + // Disable yield backend (5% frequency) + if (backendEnabled) { + vm.startPrank(ADMIN); + superToken.disableYieldBackend(); + vm.stopPrank(); + backendEnabled = false; + numAaveOperations += 1; // disable withdraws max + } + } else if (action == 2) { + // Switch yield backend: disable current, enable new (5% frequency) + if (backendEnabled) { + // Disable current + vm.startPrank(ADMIN); + superToken.disableYieldBackend(); + vm.stopPrank(); + numAaveOperations += 1; // disable withdraws + + // Deploy and enable new backend + AaveYieldBackend newBackend = new AaveYieldBackend( + IERC20(USDC), + IPool(AAVE_POOL), + SURPLUS_RECEIVER + ); + vm.startPrank(ADMIN); + superToken.enableYieldBackend(newBackend); + vm.stopPrank(); + currentBackend = newBackend; + numAaveOperations += 1; // enable deposits + } + } else if (action >= 3 && action <= 9) { + // Upgrade (35% frequency) + if (backendEnabled) { + // Bound upgrade amount to reasonable range + uint256 upgradeAmount = bound(uint256(s.v), 1e18, 1_000_000 * 1e18); + vm.startPrank(ALICE); + superToken.upgrade(upgradeAmount); + vm.stopPrank(); + numAaveOperations += 1; // upgrade deposits + } + } else if (action >= 10 && action <= 16) { + // Downgrade (35% frequency) + if (backendEnabled) { + uint256 aliceBalance = superToken.balanceOf(ALICE); + if (aliceBalance > 0) { + // Bound downgrade amount to available balance + uint256 downgradeAmount = bound(uint256(s.v), 1e18, aliceBalance); + // Don't downgrade more than available + if (downgradeAmount > aliceBalance) { + downgradeAmount = aliceBalance; + } + vm.startPrank(ALICE); + superToken.downgrade(downgradeAmount); + vm.stopPrank(); + numAaveOperations += 1; // downgrade withdraws + } + } + } else if (action >= 17 && action <= 19) { + // Withdraw surplus (15% frequency) + if (backendEnabled) { + // Check if there's surplus to withdraw + uint256 underlyingBalance = IERC20(USDC).balanceOf(address(superToken)); + uint256 aTokenBalance = IERC20(A_USDC).balanceOf(address(superToken)); + (uint256 normalizedTotalSupply,) = superToken.toUnderlyingAmount(superToken.totalSupply()); + uint256 totalAssets = underlyingBalance + aTokenBalance; + + // Only withdraw if there's actual surplus (after 100 wei margin) + if (totalAssets > normalizedTotalSupply + 100) { + vm.startPrank(ADMIN); + superToken.withdrawSurplusFromYieldBackend(); + vm.stopPrank(); + numAaveOperations += 1; // withdraw surplus + // After withdrawing surplus, initial excess is also withdrawn + initialExcessPreserved = false; + } + } + } + + // Warp time to simulate yield accrual (if dt > 0) + if (s.dt > 0) { + // Bound time warp to reasonable range (1 hour to 30 days) + uint256 timeWarp = bound(uint256(s.dt), 1 hours, 30 days); + vm.warp(block.timestamp + timeWarp); + } + + // Verify invariants after each step + // Initial excess should be preserved only if backend is enabled AND surplus hasn't been withdrawn + bool preserveInitialExcess = backendEnabled && initialExcessPreserved; + _verifyInvariants(preserveInitialExcess, numAaveOperations); + } + + // Final invariant check + bool finalPreserveInitialExcess = backendEnabled && initialExcessPreserved; + _verifyInvariants(finalPreserveInitialExcess, numAaveOperations); + } } From 121df80ceb8f9184e25e79982c52b80f00597444 Mon Sep 17 00:00:00 2001 From: didi Date: Mon, 12 Jan 2026 12:14:14 +0100 Subject: [PATCH 30/44] remove prev test code --- .../superfluid/AaveYieldBackendForkTest.sol | 181 ----- .../foundry/superfluid/GrifterContract.sol | 91 --- .../superfluid/SparkYieldBackend.t.sol | 185 ----- .../foundry/superfluid/SuperTokenYield.t.sol | 649 ------------------ 4 files changed, 1106 deletions(-) delete mode 100644 packages/ethereum-contracts/test/foundry/superfluid/AaveYieldBackendForkTest.sol delete mode 100644 packages/ethereum-contracts/test/foundry/superfluid/GrifterContract.sol delete mode 100644 packages/ethereum-contracts/test/foundry/superfluid/SparkYieldBackend.t.sol delete mode 100644 packages/ethereum-contracts/test/foundry/superfluid/SuperTokenYield.t.sol diff --git a/packages/ethereum-contracts/test/foundry/superfluid/AaveYieldBackendForkTest.sol b/packages/ethereum-contracts/test/foundry/superfluid/AaveYieldBackendForkTest.sol deleted file mode 100644 index a0aac9c83b..0000000000 --- a/packages/ethereum-contracts/test/foundry/superfluid/AaveYieldBackendForkTest.sol +++ /dev/null @@ -1,181 +0,0 @@ -// SPDX-License-Identifier: AGPLv3 -pragma solidity ^0.8.23; - -import { Test } from "forge-std/Test.sol"; -import { console } from "forge-std/console.sol"; -import { Math } from "@openzeppelin-v5/contracts/utils/math/Math.sol"; -import { AaveYieldBackend } from "../../../contracts/superfluid/AaveYieldBackend.sol"; -import { IERC20 } from "../../../contracts/interfaces/superfluid/ISuperfluid.sol"; -import { IPool } from "aave-v3/src/contracts/interfaces/IPool.sol"; - -/** - * @title AaveYieldBackendForkTest - * @notice Fork test for testing AaveYieldBackend - * @notice The test contract itself takes the role of SuperToken for delegatecall operations - * @author Superfluid - */ -contract AaveYieldBackendForkTest is Test { - uint256 internal constant CHAIN_ID = 8453; - string internal constant RPC_URL = "https://mainnet.base.org"; - - address internal constant AAVE_POOL = 0xA238Dd80C259a72e81d7e4664a9801593F98d1c5; - - address internal constant USDC = 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913; - address internal constant aUSDC = 0x4e65fE4DbA92790696d040ac24Aa414708F5c0AB; - address internal constant SURPLUS_RECEIVER = 0xac808840f02c47C05507f48165d2222FF28EF4e1; - - AaveYieldBackend internal aaveBackend; - IERC20 internal assetToken; - IERC20 internal aToken; - IPool internal aavePool; - - /// @notice Set up the test environment by forking Base and deploying AaveYieldBackend - function setUp() public { - vm.createSelectFork(RPC_URL); - - assertEq(block.chainid, CHAIN_ID, "Chainid mismatch"); - - aavePool = IPool(AAVE_POOL); - - assetToken = IERC20(USDC); - aToken = IERC20(aUSDC); - - aaveBackend = new AaveYieldBackend(assetToken, aavePool, SURPLUS_RECEIVER); - - // Enable the backend (approves Aave pool) - (bool success,) = address(aaveBackend).delegatecall(abi.encodeWithSelector(AaveYieldBackend.enable.selector)); - require(success, "enable failed"); - - deal(USDC, address(this), 200_000_000 * 1e6); // 200M USDC - } - - /// @notice Mock of toUnderlyingAmount, hardcoded to 18 to 6 decimals conversion - function toUnderlyingAmount(uint256 amount) - external - pure - returns (uint256 underlyingAmount, uint256 adjustedAmount) - { - uint256 factor = 10 ** (18 - 6); - underlyingAmount = amount / factor; - adjustedAmount = underlyingAmount * factor; - } - - /// Generates a random number between 1 and 1e14 using an exponential distribution. - function _getRandomWithExpDistribution() internal view returns (uint256) { - uint256 MAX_VAL = 1e14; - - // 1. Determine max magnitude. 1e14 is approx 2^46.5 - uint256 maxMagnitude = Math.log2(MAX_VAL); - - // 2. Pick a random magnitude (bit-length) uniformly. - // This ensures 1 digit numbers are as likely as 14 digit numbers to be "the range". - uint256 magnitude = bound(vm.randomUint(), 0, maxMagnitude); - - // 3. Set the high bit (2^magnitude) - uint256 base = 1 << magnitude; - - // 4. Fill the lower bits with random noise to get specific numbers like 14,532 - // We mod by 'base' so we don't spill into the next magnitude - uint256 noise = vm.randomUint() % base; - - uint256 result = base + noise; - - // 5. Cap at strict MAX_VAL (handle slight overflow at top magnitude) - return result > MAX_VAL ? MAX_VAL : result; - } - - function _deposit(uint256 amount) internal { - (bool success,) = - address(aaveBackend).delegatecall(abi.encodeWithSelector(AaveYieldBackend.deposit.selector, amount)); - require(success, "deposit failed"); - } - - /// @notice Helper function to log amount in fixed point format (integer.fractional) - function _logFixedPoint(string memory label, uint256 amount) internal pure { - console.log(string.concat(label, " ", vm.toString(amount / 1e6), ".", vm.toString(amount % 1e6))); - } - - /// @notice Helper function to perform a withdraw and return results - function _withdrawAndGetResults(uint256 requestedAmount) - internal - returns ( - uint256 assetAmountReceived, - uint256 aTokenAmountDecrease, - int256 diffAssetRequestedReceived, - int256 diffAtokenExpectedDecreased - ) - { - uint256 aTokenBalanceBefore = aToken.balanceOf(address(this)); - uint256 assetBalanceBefore = assetToken.balanceOf(address(this)); - - (bool success,) = address(aaveBackend).delegatecall( - abi.encodeWithSelector(AaveYieldBackend.withdraw.selector, requestedAmount) - ); - require(success, "withdraw failed"); - - uint256 aTokenBalanceAfter = aToken.balanceOf(address(this)); - uint256 assetBalanceAfter = assetToken.balanceOf(address(this)); - - assetAmountReceived = assetBalanceAfter - assetBalanceBefore; - aTokenAmountDecrease = aTokenBalanceBefore - aTokenBalanceAfter; - - diffAssetRequestedReceived = int256(assetAmountReceived) - int256(requestedAmount); - diffAtokenExpectedDecreased = int256(assetAmountReceived) - int256(aTokenAmountDecrease); - } - - /// @notice Test deposit/withdraw loop with random amounts - /// This is to verify that the rounding error of the aToken decrease is narronwly bounded. - function testDepositWithdrawLoop() public { - // Do an initial deposit of 1 USDC that is not withdrawn - // This provides a buffer against small rounding discrepancies which cause the - // aToken amount to not precisely match the asset amount. - uint256 initialDeposit = 1 * 1e6; - _deposit(initialDeposit); - - uint256 iterations = 1000; - - for (uint256 i = 0; i < iterations; ++i) { - uint256 randomAmount = _getRandomWithExpDistribution(); - if (randomAmount == 1) { - // getting a revert with InvalidAmount() for 1 - randomAmount = 2; - } - - _deposit(randomAmount); - - ( - uint256 assetAmountReceived, - uint256 aTokenAmountDecrease, - int256 diffAssetRequestedReceived, - int256 diffAtokenExpectedDecreased - ) = _withdrawAndGetResults(randomAmount); - - console.log("=== Iteration", i + 1, "==="); - console.log( - string.concat( - "assetAmount requested: ", vm.toString(randomAmount / 1e6), ".", vm.toString(randomAmount % 1e6) - ) - ); - console.log( - string.concat( - "assetAmount received: ", - vm.toString(assetAmountReceived / 1e6), - ".", - vm.toString(assetAmountReceived % 1e6) - ) - ); - console.log( - string.concat( - "aTokenAmount decrease: ", - vm.toString(aTokenAmountDecrease / 1e6), - ".", - vm.toString(aTokenAmountDecrease % 1e6) - ) - ); - console.log("diff (aToken decrease expected / actual):", vm.toString(diffAtokenExpectedDecreased)); - - assertEq(diffAssetRequestedReceived, 0, "diffAssetRequestedReceived is not 0"); - assertGe(diffAtokenExpectedDecreased, -2, "diffAtokenExpectedDecreased is < -2"); - } - } -} diff --git a/packages/ethereum-contracts/test/foundry/superfluid/GrifterContract.sol b/packages/ethereum-contracts/test/foundry/superfluid/GrifterContract.sol deleted file mode 100644 index eeadca5ae9..0000000000 --- a/packages/ethereum-contracts/test/foundry/superfluid/GrifterContract.sol +++ /dev/null @@ -1,91 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.23; - -import { - ISuperToken -} from "../../../contracts/interfaces/superfluid/ISuperfluid.sol"; -import { - IERC20 -} from "../../../contracts/interfaces/superfluid/ISuperfluid.sol"; - -import { ISETH } from "../../../contracts/interfaces/tokens/ISETH.sol"; - -import "forge-std/console.sol"; - -contract GrifterContract { - ISuperToken public immutable superToken; - IERC20 public immutable underlyingToken; - IERC20 public immutable aToken; - uint256 public immutable amount; - - constructor(ISuperToken _superToken, IERC20 _aToken, uint256 _amount) { - superToken = _superToken; - aToken = _aToken; - underlyingToken = IERC20(superToken.getUnderlyingToken()); - amount = _amount; - - if (address(underlyingToken) != address(0)) { - // Approve SuperToken to move underlying - underlyingToken.approve(address(superToken), type(uint256).max); - } - } - - receive() external payable {} - - function grift(uint256 iterations) external { - if (address(underlyingToken) == address(0)) { - // Native ETH path - for (uint256 i = 0; i < iterations; ++i) { - uint256 bal0 = aToken.balanceOf(address(superToken)); - - // Upgrade ETH - ISETH(address(superToken)).upgradeByETH{ value: amount }(); - - uint256 bal1 = aToken.balanceOf(address(superToken)); - - // Downgrade ETH - ISETH(address(superToken)).downgradeToETH(amount); - - uint256 bal2 = aToken.balanceOf(address(superToken)); - - // Analyze Downgrade - if (bal2 < bal1) { - uint256 burned = bal1 - bal2; - if (burned > amount) { - uint256 diff = burned - amount; - console.log("ETHx Downgrade Excess FOUND:"); - console.log(" Req: %s", amount); - console.log(" Burn: %s", burned); - console.log(" Diff: %s", diff); - revert("FOUND EXCESS BURN"); - } - } - } - } else { - // ERC20 Path - for (uint256 i = 0; i < iterations; ++i) { - uint256 bal0 = aToken.balanceOf(address(superToken)); - - superToken.upgrade(amount); - - uint256 bal1 = aToken.balanceOf(address(superToken)); - - superToken.downgrade(amount); - uint256 bal2 = aToken.balanceOf(address(superToken)); - - // Analyze Downgrade - if (bal2 < bal1) { - uint256 burned = bal1 - bal2; - if (burned > amount) { - uint256 diff = burned - amount; - console.log("ERC20 Downgrade Excess FOUND:"); - console.log(" Req: %s", amount); - console.log(" Burn: %s", burned); - console.log(" Diff: %s", diff); - revert("FOUND EXCESS BURN"); - } - } - } - } - } -} diff --git a/packages/ethereum-contracts/test/foundry/superfluid/SparkYieldBackend.t.sol b/packages/ethereum-contracts/test/foundry/superfluid/SparkYieldBackend.t.sol deleted file mode 100644 index c6f3986dad..0000000000 --- a/packages/ethereum-contracts/test/foundry/superfluid/SparkYieldBackend.t.sol +++ /dev/null @@ -1,185 +0,0 @@ -// SPDX-License-Identifier: AGPLv3 -pragma solidity ^0.8.23; - -import { Test } from "forge-std/Test.sol"; -import { console } from "forge-std/console.sol"; -import { ERC4626YieldBackend } from "../../../contracts/superfluid/ERC4626YieldBackend.sol"; -import { IERC20, ISuperfluid } from "../../../contracts/interfaces/superfluid/ISuperfluid.sol"; -import { SuperToken } from "../../../contracts/superfluid/SuperToken.sol"; -import { IERC4626 } from "@openzeppelin-v5/contracts/interfaces/IERC4626.sol"; - -/** - * @title SparkYieldBackendForkTest - * @notice Fork test for testing yield-related features with SparkYieldBackend on Base - * @author Superfluid - */ -contract SparkYieldBackendForkTest is Test { - address internal constant ALICE = address(0x420); - address internal constant ADMIN = address(0xAAA); - address internal constant SURPLUS_RECEIVER = 0xac808840f02c47C05507f48165d2222FF28EF4e1; // dao.superfluid.eth - - // Base network constants - uint256 internal constant CHAIN_ID = 8453; - string internal constant RPC_URL = "https://mainnet.base.org"; - - // Spark USDC Vault on Base (sUSDC) - address internal constant SPARK_VAULT = 0x3128a0F7f0ea68E7B7c9B00AFa7E41045828e858; - - // Common tokens on Base - address internal constant USDCx = 0xD04383398dD2426297da660F9CCA3d439AF9ce1b; - address internal constant USDC = 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913; - - SuperToken internal superToken; - ERC4626YieldBackend internal sparkBackend; - IERC20 internal underlyingToken; - IERC4626 internal vault; - - function setUp() public { - vm.createSelectFork(RPC_URL); - assertEq(block.chainid, CHAIN_ID, "Chainid mismatch"); - - vault = IERC4626(SPARK_VAULT); - underlyingToken = IERC20(USDC); - - superToken = SuperToken(USDCx); - - sparkBackend = new ERC4626YieldBackend(vault, SURPLUS_RECEIVER); - - assertEq(address(sparkBackend.ASSET_TOKEN()), USDC, "Asset token mismatch"); - assertEq(address(sparkBackend.VAULT()), SPARK_VAULT, "Vault mismatch"); - - // upgrade SuperToken to new logic (mocking upgrade to enable features if needed, - // essentially ensuring we have a fresh state or compatible logic) - // Note: SuperToken on Base might already be up to date, but we re-deploy logic for safety in test - SuperToken newSuperTokenLogic = new SuperToken(ISuperfluid(superToken.getHost()), superToken.POOL_ADMIN_NFT()); - vm.startPrank(address(superToken.getHost())); - superToken.updateCode(address(newSuperTokenLogic)); - vm.stopPrank(); - - // designate an admin for the SuperToken - vm.startPrank(address(superToken.getHost())); - superToken.changeAdmin(ADMIN); - vm.stopPrank(); - - // provide ALICE with underlying and let her approve for upgrade - deal(USDC, ALICE, type(uint128).max); - vm.startPrank(ALICE); - IERC20(USDC).approve(address(superToken), type(uint256).max); - } - - function _enableYieldBackend() public { - vm.startPrank(ADMIN); - superToken.enableYieldBackend(sparkBackend); - vm.stopPrank(); - } - - function _verifyInvariants() internal view { - // underlyingBalance + vaultAssets >= superToken.supply() - uint256 underlyingBalance = IERC20(USDC).balanceOf(address(superToken)); - // vault balance is in shares, need to convert to assets - uint256 vaultAssets = vault.convertToAssets(vault.balanceOf(address(superToken))); - - (uint256 superTokenNormalizedSupply,) = superToken.toUnderlyingAmount(superToken.totalSupply()); - - // We use approx because of potential rounding/yield accruing differently per block - // But assets should be >= supply - assertGe( - underlyingBalance + vaultAssets, - superTokenNormalizedSupply, - "invariant failed: underlying + vaultAssets insufficient" - ); - } - - function testSparkBackendDeployment() public view { - assertEq(address(sparkBackend.ASSET_TOKEN()), USDC, "Asset token should be USDC"); - assertEq(address(sparkBackend.VAULT()), SPARK_VAULT, "Vault address should match"); - } - - function testEnableYieldBackend() public { - _enableYieldBackend(); - - assertEq(address(superToken.getYieldBackend()), address(sparkBackend), "Yield backend mismatch"); - - // For new deposits, we need to upgrade - uint256 amount = 100 * 1e18; - vm.startPrank(ALICE); - superToken.upgrade(amount); - vm.stopPrank(); - - // the SuperToken should now have a zero USDC balance (all deposited) - assertEq(IERC20(USDC).balanceOf(address(superToken)), 0, "USDC balance should be zero"); - - // And non-zero vault balance - assertGt(vault.balanceOf(address(superToken)), 0, "Vault share balance should be non-zero"); - - _verifyInvariants(); - } - - function testDisableYieldBackend() public { - _enableYieldBackend(); - - // Deposit some funds first so we have something to withdraw - vm.startPrank(ALICE); - superToken.upgrade(100 * 1e18); - vm.stopPrank(); - - vm.startPrank(ADMIN); - superToken.disableYieldBackend(); - vm.stopPrank(); - assertEq(address(superToken.getYieldBackend()), address(0), "Yield backend mismatch"); - - // the SuperToken should now have a non-zero USDC balance and a zero vault balance - assertGt(IERC20(USDC).balanceOf(address(superToken)), 0, "USDC balance should be non-zero"); - assertEq(vault.balanceOf(address(superToken)), 0, "Vault balance should be zero"); - - _verifyInvariants(); - } - - function testUpgradeDowngrade() public { - _enableYieldBackend(); - - uint256 vaultSharesBefore = vault.balanceOf(address(superToken)); - uint256 amount = 100 * 1e18; // 100 USDCx - - vm.startPrank(ALICE); - superToken.upgrade(amount); - vm.stopPrank(); - - uint256 vaultSharesAfter = vault.balanceOf(address(superToken)); - - assertGt(vaultSharesAfter, vaultSharesBefore, "Vault shares should increase"); - - // downgrade - vm.startPrank(ALICE); - superToken.downgrade(amount); - vm.stopPrank(); - - uint256 vaultSharesFinal = vault.balanceOf(address(superToken)); - assertLt(vaultSharesFinal, vaultSharesAfter, "Vault shares should decrease"); - - _verifyInvariants(); - } - - function testWithdrawSurplusFromYieldBackend() public { - // simulate the SuperToken having a surplus of underlying from the start - uint256 surplusAmount = 100 * 1e6; // 100 USDC - deal(USDC, address(superToken), surplusAmount); - - _enableYieldBackend(); - - uint256 receiverBalanceBefore = IERC20(USDC).balanceOf(SURPLUS_RECEIVER); - - vm.startPrank(ADMIN); - superToken.withdrawSurplusFromYieldBackend(); - vm.stopPrank(); - - uint256 receiverBalanceAfter = IERC20(USDC).balanceOf(SURPLUS_RECEIVER); - - console.log("Receiver balance before", receiverBalanceBefore); - console.log("Receiver balance after", receiverBalanceAfter); - console.log("Diff", receiverBalanceAfter - receiverBalanceBefore); - - assertGt(receiverBalanceAfter, receiverBalanceBefore, "Surplus should be withdrawn to receiver"); - _verifyInvariants(); - } -} diff --git a/packages/ethereum-contracts/test/foundry/superfluid/SuperTokenYield.t.sol b/packages/ethereum-contracts/test/foundry/superfluid/SuperTokenYield.t.sol deleted file mode 100644 index ed4d4f7032..0000000000 --- a/packages/ethereum-contracts/test/foundry/superfluid/SuperTokenYield.t.sol +++ /dev/null @@ -1,649 +0,0 @@ -// SPDX-License-Identifier: AGPLv3 -pragma solidity ^0.8.23; - -import { Test } from "forge-std/Test.sol"; -import { console } from "forge-std/console.sol"; -import { AaveYieldBackend } from "../../../contracts/superfluid/AaveYieldBackend.sol"; -import { IERC20, ISuperfluid, ISuperToken } from "../../../contracts/interfaces/superfluid/ISuperfluid.sol"; -import { SuperToken } from "../../../contracts/superfluid/SuperToken.sol"; -import { IPool } from "aave-v3/src/contracts/interfaces/IPool.sol"; -import { ISETH } from "../../../contracts/interfaces/tokens/ISETH.sol"; -import { GrifterContract } from "./GrifterContract.sol"; - -/** - * @title SuperTokenYieldForkTest - * @notice Fork test for testing yield-related features with AaveYieldBackend - * @author Superfluid - */ -contract SuperTokenYieldForkTest is Test { - address constant ALICE = address(0x420); - address constant ADMIN = address(0xAAA); - - // Base network constants - uint256 internal constant CHAIN_ID = 8453; - string internal constant RPC_URL = "https://mainnet.base.org"; - - // Aave V3 Pool on Base (verified address) - address internal constant AAVE_POOL = 0xA238Dd80C259a72e81d7e4664a9801593F98d1c5; - - // Common tokens on Base - address internal constant USDCx = 0xD04383398dD2426297da660F9CCA3d439AF9ce1b; // USDCx on Base - address internal constant USDC = 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913; // USDC on Base - address internal constant WETH = 0x4200000000000000000000000000000000000006; // WETH on Base - address internal constant aWETH = 0xD4a0e0b9149BCee3C920d2E00b5dE09138fd8bb7; - address internal constant aUSDC = 0x4e65fE4DbA92790696d040ac24Aa414708F5c0AB; // aUSDC on Base - address internal constant ETHx = 0x46fd5cfB4c12D87acD3a13e92BAa53240C661D93; // ETHx on Base - address internal constant SURPLUS_RECEIVER = 0xac808840f02c47C05507f48165d2222FF28EF4e1; // dao.superfluid.eth - - SuperToken public superToken; - /// @notice AaveYieldBackend contract instance - AaveYieldBackend public aaveBackend; - /// @notice Underlying token (USDC or address(0) for ETH) - IERC20 public underlyingToken; - /// @notice Aave V3 Pool contract - IPool public aavePool; - - /// @notice Set up the test environment by forking Base and deploying AaveYieldBackend - function setUp() public { - // Fork Base using public RPC - vm.createSelectFork(RPC_URL); - - // Verify we're on Base - assertEq(block.chainid, CHAIN_ID, "Chainid mismatch"); - - // Get Aave Pool - aavePool = IPool(AAVE_POOL); - - // Default to USDC for existing tests - _setUpToken(USDC, USDCx); - - // provide ALICE with underlying and let her approve for upgrade - deal(USDC, ALICE, type(uint128).max); - vm.startPrank(ALICE); - IERC20(USDC).approve(address(superToken), type(uint256).max); - - console.log("aaveBackend address", address(aaveBackend)); - console.log("aUSDC address", address(aUSDC)); - } - - function _setUpToken(address _underlying, address _superToken) internal { - if (_underlying != address(0)) { - underlyingToken = IERC20(_underlying); - } else { - // For ETH, underlying is address(0) - underlyingToken = IERC20(address(0)); - } - - superToken = SuperToken(_superToken); - - // Deploy AaveBackend - aaveBackend = new AaveYieldBackend(IERC20(_underlying), IPool(AAVE_POOL), SURPLUS_RECEIVER); - - // upgrade SuperToken to new logic - SuperToken newSuperTokenLogic = new SuperToken(ISuperfluid(superToken.getHost()), superToken.POOL_ADMIN_NFT()); - vm.startPrank(address(superToken.getHost())); - superToken.updateCode(address(newSuperTokenLogic)); - vm.stopPrank(); - - // designate an admin for the SuperToken - vm.startPrank(address(superToken.getHost())); - superToken.changeAdmin(ADMIN); - vm.stopPrank(); - } - - function _enableYieldBackend() public { - vm.startPrank(ADMIN); - superToken.enableYieldBackend(aaveBackend); - vm.stopPrank(); - } - - function _verifyInvariants() internal view { - // underlyingBalance + aTokenBalance >= superToken.supply() - uint256 underlyingBalance = IERC20(USDC).balanceOf(address(superToken)); - uint256 aTokenBalance = IERC20(aUSDC).balanceOf(address(superToken)); - (uint256 superTokenNormalizedSupply,) = superToken.toUnderlyingAmount(superToken.totalSupply()); - - assertGe( - underlyingBalance + aTokenBalance, - superTokenNormalizedSupply, - "invariant failed: underlyingBalance + aTokenBalance insufficient" - ); - - assertEq( - underlyingBalance + aTokenBalance, - superTokenNormalizedSupply, - "invariant failed: underlyingBalance + aTokenBalance insufficient" - ); - } - - /// @notice Test that we're forking the correct Base network - function testForkBaseNetwork() public view { - assertEq(block.chainid, CHAIN_ID, "Chainid mismatch"); - assertTrue(AAVE_POOL.code.length > 0, "Aave Pool should exist"); - assertTrue(USDC.code.length > 0, "USDC should exist"); - } - - /// @notice Test AaveBackend deployment and initialization - function testAaveBackendDeployment() public view { - assertEq(address(aaveBackend.ASSET_TOKEN()), USDC, "Asset token should be USDC"); - assertEq(address(aaveBackend.AAVE_POOL()), AAVE_POOL, "Aave pool address should match"); - assertTrue(address(aaveBackend.A_TOKEN()) != address(0), "aToken should be set"); - } - - function testEnableYieldBackend() public { - // log USDC balance of SuperToken - console.log("USDC balance of SuperToken", IERC20(USDC).balanceOf(address(superToken))); - - _enableYieldBackend(); - - assertEq(address(superToken.getYieldBackend()), address(aaveBackend), "Yield backend mismatch"); - - // the SuperToken should now have a zero USDC balance and a non-zero aUSDC balance - assertEq(IERC20(USDC).balanceOf(address(superToken)), 0, "USDC balance should be zero"); - assertGt(IERC20(aUSDC).balanceOf(address(superToken)), 0, "aUSDC balance should be non-zero"); - - // log aUSDC balance of SuperToken - console.log("aUSDC balance of SuperToken", IERC20(aUSDC).balanceOf(address(superToken))); - // TODO: We'd want asset balance to equal aToken balance. But that's not exactly the case. - // what else shall be require? - _verifyInvariants(); - } - - function testDisableYieldBackend() public { - // verify: underlying matches totalSupply - uint256 underlyingBalanceBefore = IERC20(USDC).balanceOf(address(superToken)); - uint256 superTokenBalanceBefore = superToken.totalSupply(); - (uint256 normalizedTotalSupply,) = superToken.toUnderlyingAmount(superTokenBalanceBefore); - assertEq( - underlyingBalanceBefore, - normalizedTotalSupply, - "precondition failed: underlyingBalanceBefore != normalizedTotalSupply" - ); - - _enableYieldBackend(); - - vm.startPrank(ADMIN); - superToken.disableYieldBackend(); - vm.stopPrank(); - assertEq(address(superToken.getYieldBackend()), address(0), "Yield backend mismatch"); - - // the SuperToken should now have a non-zero USDC balance and a zero aUSDC balance - assertGt(IERC20(USDC).balanceOf(address(superToken)), 0, "USDC balance should be non-zero"); - assertEq(IERC20(aUSDC).balanceOf(address(superToken)), 0, "aUSDC balance should be zero"); - - _verifyInvariants(); - } - - // TODO: bool fuzz arg for disabled/enabled backend - function testUpgradeDowngrade() public { - _enableYieldBackend(); - - uint256 aTokenBalanceBefore = IERC20(aUSDC).balanceOf(address(superToken)); - vm.startPrank(ALICE); - superToken.upgrade(1 ether); - vm.stopPrank(); - - uint256 aTokenBalanceAfter = IERC20(aUSDC).balanceOf(address(superToken)); - - // log superToken amount of ALICE - console.log("superToken amount of ALICE", superToken.balanceOf(ALICE)); - - // log aToken balance of superToken contract - console.log("aToken balance of superToken contract", IERC20(aUSDC).balanceOf(address(superToken))); - - // log diff - console.log("aToken balance diff", aTokenBalanceAfter - aTokenBalanceBefore); - - // downgrade - vm.startPrank(ALICE); - // there's a flaw in the API here: upgrade may have down-rounded the amount, but doesn't tell us (via return - // value). In that case a consecutive downgrade (of the un-adjusted amount) would revert. - superToken.downgrade(1 ether); - vm.stopPrank(); - - _verifyInvariants(); - } - - // ============ Gas Benchmarking Tests ============ - - /// @notice Test gas cost of upgrade WITHOUT yield backend - /// @dev Separate test function to avoid cold/warm storage slot interference - function testGasUpgrade_WithoutYieldBackend() public { - // Ensure yield backend is NOT set - assertEq(address(superToken.getYieldBackend()), address(0), "Yield backend should not be set"); - - // Prepare test state - // 1000 USDC = 1000 * 1e6 (USDC has 6 decimals) - // In SuperToken units (18 decimals), this is 1000 * 1e18 - uint256 upgradeAmount = 1000 * 1e18; - vm.startPrank(ALICE); - // Measure gas for upgrade - uint256 gasBefore = gasleft(); - superToken.upgrade(upgradeAmount); - uint256 gasUsed = gasBefore - gasleft(); - vm.stopPrank(); - - console.log("=== Gas: Upgrade WITHOUT Yield Backend ==="); - console.log("Gas used", gasUsed); - console.log("Amount upgraded", upgradeAmount); - } - - /// @notice Test gas cost of upgrade WITH yield backend - /// @dev Separate test function to avoid cold/warm storage slot interference - function testGasUpgrade_WithYieldBackend() public { - // Enable yield backend - _enableYieldBackend(); - assertEq(address(superToken.getYieldBackend()), address(aaveBackend), "Yield backend should be set"); - - // Prepare test state - // 1000 USDC = 1000 * 1e6 (USDC has 6 decimals) - // In SuperToken units (18 decimals), this is 1000 * 1e18 - uint256 upgradeAmount = 1000 * 1e18; - vm.startPrank(ALICE); - // Measure gas for upgrade - uint256 gasBefore = gasleft(); - superToken.upgrade(upgradeAmount); - uint256 gasUsed = gasBefore - gasleft(); - vm.stopPrank(); - - console.log("=== Gas: Upgrade WITH Yield Backend ==="); - console.log("Gas used", gasUsed); - console.log("Amount upgraded", upgradeAmount); - } - - /// @notice Test gas cost of downgrade WITHOUT yield backend - /// @dev Separate test function to avoid cold/warm storage slot interference - function testGasDowngrade_WithoutYieldBackend() public { - // Ensure yield backend is NOT set - assertEq(address(superToken.getYieldBackend()), address(0), "Yield backend should not be set"); - - // First, upgrade some tokens for ALICE to downgrade later - // 1000 USDC = 1000 * 1e6 (USDC has 6 decimals) - // In SuperToken units (18 decimals), this is 1000 * 1e18 - uint256 initialUpgradeAmount = 1000 * 1e18; - vm.startPrank(ALICE); - superToken.upgrade(initialUpgradeAmount); - vm.stopPrank(); - - uint256 aliceBalance = superToken.balanceOf(ALICE); - assertGt(aliceBalance, 0, "ALICE should have super tokens"); - - // Now measure gas for downgrade - vm.startPrank(ALICE); - uint256 amountToDowngrade = aliceBalance / 2; // Downgrade half - uint256 gasBefore = gasleft(); - superToken.downgrade(amountToDowngrade); - uint256 gasUsed = gasBefore - gasleft(); - vm.stopPrank(); - - console.log("=== Gas: Downgrade WITHOUT Yield Backend ==="); - console.log("Gas used", gasUsed); - console.log("Amount downgraded", amountToDowngrade); - } - - /// @notice Test gas cost of downgrade WITH yield backend - /// @dev Separate test function to avoid cold/warm storage slot interference - function testGasDowngrade_WithYieldBackend() public { - // Enable yield backend - _enableYieldBackend(); - - // First, upgrade some tokens for ALICE to downgrade later - // 1000 USDC = 1000 * 1e6 (USDC has 6 decimals) - // In SuperToken units (18 decimals), this is 1000 * 1e18 - uint256 initialUpgradeAmount = 1000 * 1e18; - vm.startPrank(ALICE); - superToken.upgrade(initialUpgradeAmount); - vm.stopPrank(); - - uint256 aliceBalance = superToken.balanceOf(ALICE); - assertGt(aliceBalance, 0, "ALICE should have super tokens"); - - // Now measure gas for downgrade - vm.startPrank(ALICE); - uint256 amountToDowngrade = aliceBalance / 2; // Downgrade half - uint256 gasBefore = gasleft(); - superToken.downgrade(amountToDowngrade); - uint256 gasUsed = gasBefore - gasleft(); - vm.stopPrank(); - - console.log("=== Gas: Downgrade WITH Yield Backend ==="); - console.log("Gas used", gasUsed); - console.log("Amount downgraded", amountToDowngrade); - } - - function testWithdrawSurplusFromYieldBackend() public { - // Simulate yield accumulation by transferring extra underlying to SuperToken - // uint256 surplusAmount = 100 * 1e6; // 100 USDC - // deal(USDC, address(this), surplusAmount); - - _enableYieldBackend(); - - // Upgrade tokens to create supply - uint256 upgradeAmount = 1000 * 1e18; - vm.startPrank(ALICE); - superToken.upgrade(upgradeAmount); - vm.stopPrank(); - - uint256 receiverBalanceBefore = IERC20(USDC).balanceOf(SURPLUS_RECEIVER); - uint256 aTokenBalanceBefore = IERC20(aUSDC).balanceOf(address(superToken)); - - // log USDC and aUSDC balances of SuperToken - console.log("USDC balance of SuperToken", IERC20(USDC).balanceOf(address(superToken))); - console.log("aUSDC balance of SuperToken", IERC20(aUSDC).balanceOf(address(superToken))); - // log normalized total supply - (uint256 normalizedTotalSupply, uint256 adjustedAmount) = - superToken.toUnderlyingAmount(superToken.totalSupply()); - console.log("normalized total supply", normalizedTotalSupply); - console.log("adjusted amount", adjustedAmount); - - vm.startPrank(ADMIN); - superToken.withdrawSurplusFromYieldBackend(); - vm.stopPrank(); - - uint256 receiverBalanceAfter = IERC20(USDC).balanceOf(SURPLUS_RECEIVER); - uint256 aTokenBalanceAfter = IERC20(aUSDC).balanceOf(address(superToken)); - console.log("aToken balance after", aTokenBalanceAfter); - console.log("aToken balance diff", aTokenBalanceBefore - aTokenBalanceAfter); - - assertGt(receiverBalanceAfter, receiverBalanceBefore, "Surplus should be withdrawn to receiver"); - assertLt(aTokenBalanceAfter, aTokenBalanceBefore, "aToken balance should decrease"); - _verifyInvariants(); - } - - function testUpgadeDowngradeETH() public { - // Set up ETHx - SuperToken ethxToken = SuperToken(ETHx); - - // Upgrade ETHx to new logic - SuperToken newSuperTokenLogic = new SuperToken(ISuperfluid(ethxToken.getHost()), ethxToken.POOL_ADMIN_NFT()); - vm.startPrank(address(ethxToken.getHost())); - ethxToken.updateCode(address(newSuperTokenLogic)); - vm.stopPrank(); - - // Designate admin for ETHx - vm.startPrank(address(ethxToken.getHost())); - ethxToken.changeAdmin(ADMIN); - vm.stopPrank(); - - // Deploy AaveBackend for native ETH (address(0)) - AaveYieldBackend ethxBackend = new AaveYieldBackend(IERC20(address(0)), IPool(AAVE_POOL), SURPLUS_RECEIVER); - - // assert that USING_WETH is set -// assertEq(ethxBackend.USING_WETH(), true); - - // Enable yield backend - vm.startPrank(ADMIN); - ethxToken.enableYieldBackend(ethxBackend); - vm.stopPrank(); - - // Give ALICE some ETH - vm.deal(ALICE, 10 ether); - - // Upgrade ETH using upgradeByETH - uint256 upgradeAmount = 1 ether; - vm.startPrank(ALICE); - ISETH(address(ethxToken)).upgradeByETH{ value: upgradeAmount }(); - vm.stopPrank(); - - uint256 aliceBalance = ethxToken.balanceOf(ALICE); - assertGt(aliceBalance, 0, "ALICE should have ETHx tokens"); - - // Verify aWETH balance increased - uint256 aWETHBalance = IERC20(aWETH).balanceOf(address(ethxToken)); - assertGt(aWETHBalance, 0, "ETHx should have aWETH balance"); - - // Downgrade using downgradeToETH - uint256 aliceETHBefore = ALICE.balance; - vm.startPrank(ALICE); - ISETH(address(ethxToken)).downgradeToETH(aliceBalance); - vm.stopPrank(); - - uint256 aliceETHAfter = ALICE.balance; - assertGt(aliceETHAfter, aliceETHBefore, "ALICE should receive ETH back"); - assertEq(ethxToken.balanceOf(ALICE), 0, "ALICE should have no ETHx tokens"); - } - - function testGrifting() public { - _enableYieldBackend(); - - // 1. Setup Grifter - // Use an amount that is likely to cause rounding reduction. - // User mentioned "small inconvenience" but let's test with a standard amount. - // Aave rounding often happens due to Ray math on index scaling. - // 100 USDC is a good round number. - uint256 amountUnderlying = 100 * 1e6; // 100 USDC - uint256 amountSuper = 100 * 1e18; // 100 USDC in 18 decimals - console.log("Creating Grifter..."); - GrifterContract grifter = new GrifterContract(ISuperToken(address(superToken)), IERC20(aUSDC), amountSuper); - console.log("Grifter created."); - - // Fund grifter - deal(USDC, address(grifter), amountUnderlying); - - // Fund a victim to provide a buffer for rounding errors - address victim = address(0xBEEE); - uint256 victimAmountUnderlying = 1000 * 1e6; - uint256 victimAmountSuper = 1000 * 1e18; - deal(USDC, victim, victimAmountUnderlying); - console.log("Victim funded. Balance:", IERC20(USDC).balanceOf(victim)); - - vm.startPrank(victim); - IERC20(USDC).approve(address(superToken), victimAmountUnderlying); - console.log("Victim approved SuperToken."); - - console.log("Victim upgrading..."); - superToken.upgrade(victimAmountSuper); - console.log("Victim upgrade done."); - vm.stopPrank(); - - // 2. Measure state before - uint256 aTokenBalanceBefore = IERC20(aUSDC).balanceOf(address(superToken)); - - // 3. Execute Grift - // Run enough iterations to see meaningful damage but keep gas reasonable for test. - // 50 iterations - uint256 iterations = 50; - - // 3. Execute Grift - vm.startPrank(address(0x1337)); - uint256 gasBefore = gasleft(); - grifter.grift(iterations); - uint256 gasUsed = gasBefore - gasleft(); - vm.stopPrank(); - - // 4. Measure state after - uint256 aTokenBalanceAfter = IERC20(aUSDC).balanceOf(address(superToken)); - uint256 damage = aTokenBalanceBefore - aTokenBalanceAfter; - - // 5. Analysis - console.log("=== Grifting Analysis ==="); - console.log("Iterations:", iterations); - console.log("Gas Used:", gasUsed); - console.log("Damage (aToken dust lost):", damage); - console.log("Damage per 1 Million Gas:", (damage * 1000000) / gasUsed); - - // Expectation: damage should be roughly equal to iterations (1 wei per it) or 0 depending on rounding - // direction/conditions - // The user claims "x+1" consumption often happens. - - // Calculate Damage per 1 USD spent for tx fees - // Assumptions: - // - Base L2 gas price: 0.1 gwei (average low usage) - // - ETH Price: $2000 - // - L1 data cost is amortized/negligible for high volume batching or not included in this simple calc - // Cost in ETH = Gas Used * Gas Price - // Cost in USD = Cost in ETH * ETH Price - // Damage per 1 USD = Damage / Cost in USD - - // Using 18 decimals for precision in calculation - uint256 gasPrice = 0.1 gwei; - uint256 ethPrice = 2000; - - uint256 costInEthEx18 = gasUsed * gasPrice; - // costInUSD = costInEth * ethPrice - // But we want Damage / CostInUSD - // CostInUSD = (gasUsed * gasPrice * ethPrice) / 1e18 - // DamagePerUSD = Damage / ((gasUsed * gasPrice * ethPrice) / 1e18) - // DamagePerUSD = (Damage * 1e18) / (gasUsed * gasPrice * ethPrice) - - uint256 damagePerUSD = (damage * 1e18) / (gasUsed * gasPrice * ethPrice); - console.log("Damage per 1 USD (Base assumptions):", damagePerUSD); - console.log("Damage in USDC decimals per 1 USD:", damagePerUSD); // Since damage is in wei of USDC (6 decimals) - - // A damage of 1e6 would mean 1 USD of damage per 1 USD spent (break even). - } - - function testRiskOfWithdraw() public { - _enableYieldBackend(); - - // Amount from user report: 59999 USDC wei - // 59999 = 0.059999 USDC. - uint256 amount = 59999 * 1e12; // 18 decimals - - // OR if the user meant 59999 underlying units... - // The log said "requested amount 59999". - // And "aToken balance diff 60000". - // If aToken is 6 decimals (aUSDC), then it's USDC. - // If aToken is 18 decimals, then ? - // aUSDC is 6 decimals. - // So amount is 59999 wei USDC. - - // Let's test with the Grifter using this specific amount. - // Let's test with the Grifter using this specific amount. - GrifterContract grifter = new GrifterContract(ISuperToken(address(superToken)), IERC20(aUSDC), amount); - - // Fund Grifter with enough Underlying (USDC) - // amount (18 decimals) -> underlying (6 decimals) - // 59999 * 1e12 / 1e12 = 59999. - uint256 underlyingAmount = 59999; - deal(USDC, address(grifter), underlyingAmount); - - grifter.grift(1); - } - - function testFuzzGrifting(uint256 amount) public { - _enableYieldBackend(); - - // Fuzz amount between 1 wei USDC (1e12 SuperToken wei) and 100M USDC - // 1e12 is the smallest amount that represents 1 wei of USDC. - // Cap at 1 billion USDC. - amount = bound(amount, 1e12, 1_000_000_000 * 1e18); - - // Setup Grifter with fuzzed amount - // Grifter setup logic repeated here (could refactor, but kept inline for now) - GrifterContract grifter = new GrifterContract(ISuperToken(address(superToken)), IERC20(aUSDC), amount); - - // Calculate needed underlying amount (6 decimals) - // Roughly amount / 1e12 - uint256 amountUnderlying = amount / 1e12; - - // Fund grifter - deal(USDC, address(grifter), amountUnderlying); - - // Fund a victim to provide a buffer for rounding errors - address victim = address(0xBEEE); - uint256 victimAmountUnderlying = 1000 * 1e6; - uint256 victimAmountSuper = 1000 * 1e18; - deal(USDC, victim, victimAmountUnderlying); - vm.startPrank(victim); - IERC20(USDC).approve(address(superToken), victimAmountUnderlying); - try superToken.upgrade(victimAmountSuper) { - // success - } - catch { - console.log("Victim upgrade failed (likely Supply Cap), skipping"); - vm.stopPrank(); - return; - } - vm.stopPrank(); - - // Measure state before - uint256 aTokenBalanceBefore = IERC20(aUSDC).balanceOf(address(superToken)); - - // Execute grift cycle (multiple iterations to provoke rounding errors) - // Single iteration often yields 0 loss on fresh state. - try grifter.grift(20) { - // console.log("Grift success for amount %s", amount); - } - catch { - console.log("Grift failed (likely Supply Cap), skipping"); - return; - } - // Measure state after - uint256 aTokenBalanceAfter = IERC20(aUSDC).balanceOf(address(superToken)); - - if (aTokenBalanceBefore > aTokenBalanceAfter) { - uint256 loss = aTokenBalanceBefore - aTokenBalanceAfter; - if (loss > 0) { - console.log("Loss detected: %s wei for amount %s", loss, amount); - } - // Assert max loss per operation is bounded - // Based on manual testing, we saw up to 2 wei per operation. - // With 20 iterations, expected max loss is 40 wei. - // Let's set a conservative bound of 45 wei. - if (loss > 45) { - console.log("CRITICAL: Loss exceeded expected bound!"); - console.log("Amount:", amount); - console.log("Loss:", loss); - revert("Max loss exceeded expectation"); - } - } else { - // console.log("No loss for amount %s", amount); - } - } - - function testFuzzGriftingETHx(uint256 amount) public { - _setUpToken(address(0), ETHx); - _enableYieldBackend(); - vm.warp(block.timestamp + 1 hours); - - // Fuzz amount between 1 wei and 100M ETH - // 1e18 is 1 ETH. - // Cap at 100M ETH. - amount = bound(amount, 1e15, 100_000_000 * 1e18); - - // Setup Grifter with fuzzed amount - GrifterContract grifter = new GrifterContract(ISuperToken(address(superToken)), IERC20(aWETH), amount); - - // Fund grifter with ETH - vm.deal(address(grifter), amount); - - // Fund a victim buffer - address victim = address(0xBEEE); - uint256 victimAmount = 100 * 1e18; - vm.deal(victim, victimAmount); - vm.startPrank(victim); - ISETH(address(superToken)).upgradeByETH{ value: victimAmount }(); - vm.stopPrank(); - - // Measure state before - uint256 aTokenBalanceBefore = IERC20(aWETH).balanceOf(address(superToken)); - - // Execute grift cycle (20 iterations) - try grifter.grift(20) { - // success - } - catch { - console.log("Grift failed (likely Supply Cap or min/max), skipping"); - return; - } - // Measure state after - uint256 aTokenBalanceAfter = IERC20(aWETH).balanceOf(address(superToken)); - - if (aTokenBalanceBefore > aTokenBalanceAfter) { - uint256 loss = aTokenBalanceBefore - aTokenBalanceAfter; - if (loss > 0) { - console.log("Loss detected: %s wei for amount %s", loss, amount); - } - // Assert max loss per operation is bounded - // Expectation: 2 wei per op * 20 ops = 40 wei max. - // Buffer to 45 wei. - if (loss > 45) { - console.log("CRITICAL: Loss exceeded expected bound!"); - console.log("Amount:", amount); - console.log("Loss:", loss); - revert("Max loss exceeded expectation"); - } - } - } -} From ce01fa76a25310772d044a53671535ea8c18ac0e Mon Sep 17 00:00:00 2001 From: didi Date: Mon, 12 Jan 2026 12:18:54 +0100 Subject: [PATCH 31/44] appease linter --- .../contracts/superfluid/AaveETHYieldBackend.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ethereum-contracts/contracts/superfluid/AaveETHYieldBackend.sol b/packages/ethereum-contracts/contracts/superfluid/AaveETHYieldBackend.sol index 6c15972d08..18309a9dc1 100644 --- a/packages/ethereum-contracts/contracts/superfluid/AaveETHYieldBackend.sol +++ b/packages/ethereum-contracts/contracts/superfluid/AaveETHYieldBackend.sol @@ -91,7 +91,7 @@ contract AaveETHYieldBackend is AaveYieldBackend { 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 transaction. + // WETH deposited or withdrawn by the SuperToken never stays in this contract beyond the lifetime of the tx. // Thus it is not necessary to check msg.sender. // We accept that an alien caller may withdraw WETH deposited to this contract (for whatever reason). function unwrapWETHAndForwardETH(uint256 amount) external { From 61af65b6bc27926cde1424d1098adde9c20ec956 Mon Sep 17 00:00:00 2001 From: didi Date: Mon, 12 Jan 2026 13:10:24 +0100 Subject: [PATCH 32/44] adjust solhint rules for all packages --- .gitignore | 3 +++ .../automation-contracts/autowrap/.solhint.json | 12 +++++++++++- .../automation-contracts/scheduler/.solhint.json | 12 +++++++++++- packages/ethereum-contracts/.gitignore | 2 ++ packages/hot-fuzz/.solhint.json | 13 +++++++++++-- packages/solidity-semantic-money/.solhint.json | 12 +++++++++++- 6 files changed, 49 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 1a6a88ce3b..7624fc61f5 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,6 @@ package-lock.json # echidna litters crytic-export corpus + +/broadcast +/.direnv diff --git a/packages/automation-contracts/autowrap/.solhint.json b/packages/automation-contracts/autowrap/.solhint.json index 80a876d2e9..1e31b8ca30 100644 --- a/packages/automation-contracts/autowrap/.solhint.json +++ b/packages/automation-contracts/autowrap/.solhint.json @@ -14,6 +14,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" } } diff --git a/packages/automation-contracts/scheduler/.solhint.json b/packages/automation-contracts/scheduler/.solhint.json index 80a876d2e9..1e31b8ca30 100644 --- a/packages/automation-contracts/scheduler/.solhint.json +++ b/packages/automation-contracts/scheduler/.solhint.json @@ -14,6 +14,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" } } diff --git a/packages/ethereum-contracts/.gitignore b/packages/ethereum-contracts/.gitignore index d5791612ca..1a08887d6c 100644 --- a/packages/ethereum-contracts/.gitignore +++ b/packages/ethereum-contracts/.gitignore @@ -13,3 +13,5 @@ /packages /testing-benchmark.json /broadcast + +/addrs diff --git a/packages/hot-fuzz/.solhint.json b/packages/hot-fuzz/.solhint.json index f127ecd0bb..2887187566 100644 --- a/packages/hot-fuzz/.solhint.json +++ b/packages/hot-fuzz/.solhint.json @@ -8,7 +8,6 @@ "no-unused-import": "off", "max-states-count": "off", "no-inline-assembly": "off", - "mark-callable-contracts": "off", "gas-custom-errors": "off", "one-contract-per-file": "off", "max-line-length": ["error", 120], @@ -17,6 +16,16 @@ "private-vars-leading-underscore": "off", "reason-string": ["error", { "maxLength": 64 } ], "compiler-version": ["off"], - "func-visibility" : ["error", { "ignoreConstructors": true }] + "func-visibility" : ["error", { "ignoreConstructors": true }], + "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" } } diff --git a/packages/solidity-semantic-money/.solhint.json b/packages/solidity-semantic-money/.solhint.json index f4923a5b6f..3c6c582d2a 100644 --- a/packages/solidity-semantic-money/.solhint.json +++ b/packages/solidity-semantic-money/.solhint.json @@ -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" } } From 4272afd27b38f0e12eaed8c9622ff4e9bbe0d2ae Mon Sep 17 00:00:00 2001 From: didi Date: Mon, 12 Jan 2026 13:36:51 +0100 Subject: [PATCH 33/44] change hot-fuzz evm target --- flake.nix | 2 +- packages/hot-fuzz/foundry.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/flake.nix b/flake.nix index 4e6f328d99..bc7fd21e11 100644 --- a/flake.nix +++ b/flake.nix @@ -92,7 +92,7 @@ gnupg ]; - # minimem development shell + # minimum development shell minimumDevInputs = commonDevInputs ++ ethDevInputs ++ defaultNodeDevInputs; # additional tooling for whitehat hackers diff --git a/packages/hot-fuzz/foundry.toml b/packages/hot-fuzz/foundry.toml index 2d630772b8..c2769d9c2b 100644 --- a/packages/hot-fuzz/foundry.toml +++ b/packages/hot-fuzz/foundry.toml @@ -2,7 +2,7 @@ root = '../..' src = 'packages/hot-fuzz/contracts' solc_version = "0.8.30" -evm_version = 'shanghai' +evm_version = 'cancun' optimizer = true optimizer_runs = 200 remappings = [ From a9bc707c01950896a01a1f961ded4904eb527b1a Mon Sep 17 00:00:00 2001 From: didi Date: Mon, 12 Jan 2026 13:43:40 +0100 Subject: [PATCH 34/44] fixed solc requirement --- .../contracts/interfaces/superfluid/IYieldBackend.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ethereum-contracts/contracts/interfaces/superfluid/IYieldBackend.sol b/packages/ethereum-contracts/contracts/interfaces/superfluid/IYieldBackend.sol index 7dae6844ad..5bc71eb60a 100644 --- a/packages/ethereum-contracts/contracts/interfaces/superfluid/IYieldBackend.sol +++ b/packages/ethereum-contracts/contracts/interfaces/superfluid/IYieldBackend.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.23; +pragma solidity >= 0.8.11; /** * A yield backend acts as interface between an ERC20 wrapper SuperToken and a yield generating protocol. From d77a933beef3136c5b1820ee51de3b995e682ceb Mon Sep 17 00:00:00 2001 From: didi Date: Mon, 12 Jan 2026 15:40:21 +0100 Subject: [PATCH 35/44] use hardhat node for deployment test --- packages/ethereum-contracts/hardhat.config.ts | 4 ++++ .../ops-scripts/libs/getConfig.js | 4 ++-- .../test/test-solc-compatibility.sh | 2 +- packages/ethereum-contracts/test/testenv-ctl.sh | 16 +++++++--------- packages/ethereum-contracts/truffle-config.js | 2 +- 5 files changed, 15 insertions(+), 13 deletions(-) diff --git a/packages/ethereum-contracts/hardhat.config.ts b/packages/ethereum-contracts/hardhat.config.ts index 4d2e1b2cca..72ee7bf950 100644 --- a/packages/ethereum-contracts/hardhat.config.ts +++ b/packages/ethereum-contracts/hardhat.config.ts @@ -165,6 +165,10 @@ const config: HardhatUserConfig = { hardhat: { // We defer the contract size limit test to foundry. allowUnlimitedContractSize: true, + // Expected by testenv-ctl.sh + accounts: { + mnemonic: "candy maple cake sugar pudding cream honey rich smooth crumble sweet treat", + }, }, }, mocha: { diff --git a/packages/ethereum-contracts/ops-scripts/libs/getConfig.js b/packages/ethereum-contracts/ops-scripts/libs/getConfig.js index d1985387a2..edf9df3969 100644 --- a/packages/ethereum-contracts/ops-scripts/libs/getConfig.js +++ b/packages/ethereum-contracts/ops-scripts/libs/getConfig.js @@ -6,8 +6,8 @@ module.exports = function getConfig(chainId) { // here go the trusted forwarders which aren't part of the framework contracts // Local Testing - 4447: { - // for local testing (truffle internal ganache and TestEnvironment) + 31337: { + // for local testing (hardhat node default chainId) // this is a fake forwarder address, it is to test the deployment script trustedForwarders: ["0x3075b4dc7085C48A14A5A39BBa68F58B19545971"], }, diff --git a/packages/ethereum-contracts/test/test-solc-compatibility.sh b/packages/ethereum-contracts/test/test-solc-compatibility.sh index c4a397d019..11917a6766 100755 --- a/packages/ethereum-contracts/test/test-solc-compatibility.sh +++ b/packages/ethereum-contracts/test/test-solc-compatibility.sh @@ -31,7 +31,7 @@ fi # from here - don't forget to add 0x to our generated sha256 # workaround to make solc to find OZ library -ln -s ../../lib/openzeppelin-contracts @openzeppelin-v5 +ln -sf ../../lib/openzeppelin-contracts @openzeppelin-v5 # verify they are compatible with the minimum version of the SOLC we support find contracts/{interfaces/,apps/} -name '*.sol' | while read i;do diff --git a/packages/ethereum-contracts/test/testenv-ctl.sh b/packages/ethereum-contracts/test/testenv-ctl.sh index cd81f6a2cf..147acf6422 100755 --- a/packages/ethereum-contracts/test/testenv-ctl.sh +++ b/packages/ethereum-contracts/test/testenv-ctl.sh @@ -3,21 +3,19 @@ # make sure that if any step fails, the script fails set -xe -TESTENV_MNEMONIC="candy maple cake sugar pudding cream honey rich smooth crumble sweet treat" - CMD=$1 -start_ganache() { - ganache-cli --networkId 4447 --port 47545 --mnemonic "$TESTENV_MNEMONIC" +start_hardhat_node() { + npx hardhat node --port 47545 } -kill_ganache() { - pkill -f "ganache-cli --networkId 4447" || true +kill_hardhat_node() { + pkill -f "hardhat node --port 47545" || true } if [ "$CMD" == "start" ];then - kill_ganache - start_ganache + kill_hardhat_node + start_hardhat_node elif [ "$CMD" == "stop" ];then - kill_ganache + kill_hardhat_node fi diff --git a/packages/ethereum-contracts/truffle-config.js b/packages/ethereum-contracts/truffle-config.js index 96cd8150b2..026faf9e57 100644 --- a/packages/ethereum-contracts/truffle-config.js +++ b/packages/ethereum-contracts/truffle-config.js @@ -312,7 +312,7 @@ const E = (module.exports = { development: { host: "127.0.0.1", port: 47545, - network_id: "4447", + network_id: "31337", // hardhat default chainId // workaround to improve testing speed // see https://github.com/trufflesuite/truffle/issues/3522 From f2883eb0858a7867059a42ebe80b6b5b11af7998 Mon Sep 17 00:00:00 2001 From: didi Date: Mon, 12 Jan 2026 15:44:27 +0100 Subject: [PATCH 36/44] update dep solidity-coverage --- packages/ethereum-contracts/package.json | 2 +- yarn.lock | 17 ++++++----------- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/packages/ethereum-contracts/package.json b/packages/ethereum-contracts/package.json index e0c0581858..2da1bc89d6 100644 --- a/packages/ethereum-contracts/package.json +++ b/packages/ethereum-contracts/package.json @@ -25,7 +25,7 @@ "ganache-time-traveler": "^1.0.16", "mochawesome": "^7.1.3", "readline": "^1.3.0", - "solidity-coverage": "^0.8.12", + "solidity-coverage": "^0.8.17", "solidity-docgen": "^0.6.0-beta.36", "stack-trace": "0.0.10", "truffle-flattener": "^1.6.0" diff --git a/yarn.lock b/yarn.lock index 21b9028696..02dc9baf38 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3672,12 +3672,7 @@ dependencies: antlr4ts "^0.5.0-alpha.4" -"@solidity-parser/parser@^0.18.0": - version "0.18.0" - resolved "https://registry.yarnpkg.com/@solidity-parser/parser/-/parser-0.18.0.tgz#8e77a02a09ecce957255a2f48c9a7178ec191908" - integrity sha512-yfORGUIPgLck41qyN7nbwJRAx17/jAIXCTanHOJZhB6PJ1iAk/84b/xlsVKFSyNyLXIj0dhppoE0+CRws7wlzA== - -"@solidity-parser/parser@^0.20.2": +"@solidity-parser/parser@^0.20.1", "@solidity-parser/parser@^0.20.2": version "0.20.2" resolved "https://registry.yarnpkg.com/@solidity-parser/parser/-/parser-0.20.2.tgz#e07053488ed60dae1b54f6fe37bb6d2c5fe146a7" integrity sha512-rbu0bzwNvMcwAjH86hiEAcOeRI2EeK8zCkHDrFykh/Al8mvJeFmjy3UrE7GYQjNwOgbGUUtCn5/k8CB8zIu7QA== @@ -16581,13 +16576,13 @@ solidity-comments-extractor@^0.0.7: resolved "https://registry.yarnpkg.com/solidity-comments-extractor/-/solidity-comments-extractor-0.0.7.tgz#99d8f1361438f84019795d928b931f4e5c39ca19" integrity sha512-wciNMLg/Irp8OKGrh3S2tfvZiZ0NEyILfcRCXCD4mp7SgK/i9gzLfhY2hY7VMCQJ3kH9UB9BzNdibIVMchzyYw== -solidity-coverage@^0.8.12: - version "0.8.13" - resolved "https://registry.yarnpkg.com/solidity-coverage/-/solidity-coverage-0.8.13.tgz#8eeada2e82ae19d25568368aa782a2baad0e0ce7" - integrity sha512-RiBoI+kF94V3Rv0+iwOj3HQVSqNzA9qm/qDP1ZDXK5IX0Cvho1qiz8hAXTsAo6KOIUeP73jfscq0KlLqVxzGWA== +solidity-coverage@^0.8.17: + version "0.8.17" + resolved "https://registry.yarnpkg.com/solidity-coverage/-/solidity-coverage-0.8.17.tgz#e71df844ccf46a49b03c5ce92333a3b27597c4ae" + integrity sha512-5P8vnB6qVX9tt1MfuONtCTEaEGO/O4WuEidPHIAJjx4sktHHKhO3rFvnE0q8L30nWJPTrcqGQMT7jpE29B2qow== dependencies: "@ethersproject/abi" "^5.0.9" - "@solidity-parser/parser" "^0.18.0" + "@solidity-parser/parser" "^0.20.1" chalk "^2.4.2" death "^1.1.0" difflib "^0.2.4" From 886554a79fda4532771e317ddbd6ca30134e260c Mon Sep 17 00:00:00 2001 From: didi Date: Mon, 12 Jan 2026 15:46:03 +0100 Subject: [PATCH 37/44] update evm version in automation packages foundry config --- packages/automation-contracts/autowrap/foundry.toml | 2 +- packages/automation-contracts/scheduler/foundry.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/automation-contracts/autowrap/foundry.toml b/packages/automation-contracts/autowrap/foundry.toml index a724979827..61775d11b7 100644 --- a/packages/automation-contracts/autowrap/foundry.toml +++ b/packages/automation-contracts/autowrap/foundry.toml @@ -3,7 +3,7 @@ root = '../../../' libs = ['lib'] src = 'packages/automation-contracts/autowrap' solc_version = "0.8.30" -evm_version = 'shanghai' +evm_version = 'cancun' optimizer = true optimizer_runs = 200 remappings = [ diff --git a/packages/automation-contracts/scheduler/foundry.toml b/packages/automation-contracts/scheduler/foundry.toml index d430bfbe9f..aa61497fdf 100644 --- a/packages/automation-contracts/scheduler/foundry.toml +++ b/packages/automation-contracts/scheduler/foundry.toml @@ -3,7 +3,7 @@ root = '../../../' libs = ['lib'] src = 'packages/automation-contracts/scheduler' solc_version = "0.8.30" -evm_version = 'shanghai' +evm_version = 'cancun' optimizer = true optimizer_runs = 200 remappings = [ From 1e499648e32efe7be0eb3d1dbd16b80e8813bd84 Mon Sep 17 00:00:00 2001 From: didi Date: Mon, 12 Jan 2026 18:01:33 +0100 Subject: [PATCH 38/44] solve open TODOs --- .../interfaces/superfluid/ISuperToken.sol | 18 ++++++++++++++++++ .../contracts/superfluid/SuperToken.sol | 10 ++++------ .../AaveYieldBackendIntegration.t.sol | 7 +++++++ 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/packages/ethereum-contracts/contracts/interfaces/superfluid/ISuperToken.sol b/packages/ethereum-contracts/contracts/interfaces/superfluid/ISuperToken.sol index fc1520089d..7d82ec0f68 100644 --- a/packages/ethereum-contracts/contracts/interfaces/superfluid/ISuperToken.sol +++ b/packages/ethereum-contracts/contracts/interfaces/superfluid/ISuperToken.sol @@ -82,6 +82,24 @@ interface ISuperToken is ISuperfluidToken, IERC20Metadata, IERC777, IERC20Permit */ 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 *************************************************************************/ diff --git a/packages/ethereum-contracts/contracts/superfluid/SuperToken.sol b/packages/ethereum-contracts/contracts/superfluid/SuperToken.sol index 8585dcb99c..db693dc6b1 100644 --- a/packages/ethereum-contracts/contracts/superfluid/SuperToken.sol +++ b/packages/ethereum-contracts/contracts/superfluid/SuperToken.sol @@ -84,7 +84,7 @@ contract SuperToken is /// @dev ERC20 Nonces for EIP-2612 (permit) mapping(address account => uint256) internal _nonces; - // TODO: use a randomly located storage slot instead? + /// @dev optional contract using the underlying asset to generate yield IYieldBackend internal _yieldBackend; // NOTE: for future compatibility, these are reserved solidity slots @@ -212,16 +212,17 @@ contract SuperToken is ? address(this).balance : _underlyingToken.balanceOf(address(this)); delegateCallChecked(address(_yieldBackend), abi.encodeCall(IYieldBackend.deposit, (depositAmount))); - // TODO: emit event + emit YieldBackendEnabled(address(_yieldBackend), depositAmount); } // withdraws everything and removes allowances function disableYieldBackend() external onlyAdmin { require(address(_yieldBackend) != address(0), "yield backend not set"); + address oldYieldBackend = address(_yieldBackend); delegateCallChecked(address(_yieldBackend), abi.encodeCall(IYieldBackend.withdrawMax, ())); delegateCallChecked(address(_yieldBackend), abi.encodeCall(IYieldBackend.disable, ())); _yieldBackend = IYieldBackend(address(0)); - // TODO: emit event + emit YieldBackendDisabled(oldYieldBackend); } function getYieldBackend() external view returns (address) { @@ -396,7 +397,6 @@ contract SuperToken is if (spender != holder) { require(amount <= _allowances[holder][spender], "SuperToken: transfer amount exceeds allowance"); - // TODO: this triggers an `Approval` event, which shouldn't happen for transfers. _approve(holder, spender, _allowances[holder][spender] - amount, false); } @@ -767,7 +767,6 @@ contract SuperToken is { if (!_skipSelfMint) { if (address(_yieldBackend) != address(0)) { - // TODO: shall we deposit all, or just the upgradeAmount? delegateCallChecked(address(_yieldBackend), abi.encodeCall(IYieldBackend.deposit, (amount))); } @@ -788,7 +787,6 @@ contract SuperToken is if (address(_yieldBackend) != address(0)) { _skipSelfMint = true; - // TODO: we may want to skip if enough underlying already in the contract delegateCallChecked(address(_yieldBackend), abi.encodeCall(IYieldBackend.withdraw, (amount))); _skipSelfMint = false; } diff --git a/packages/ethereum-contracts/test/foundry/superfluid/yieldbackend/AaveYieldBackendIntegration.t.sol b/packages/ethereum-contracts/test/foundry/superfluid/yieldbackend/AaveYieldBackendIntegration.t.sol index 233147ad28..2c6e7b5b9f 100644 --- a/packages/ethereum-contracts/test/foundry/superfluid/yieldbackend/AaveYieldBackendIntegration.t.sol +++ b/packages/ethereum-contracts/test/foundry/superfluid/yieldbackend/AaveYieldBackendIntegration.t.sol @@ -4,6 +4,7 @@ pragma solidity ^0.8.23; import { Test } from "forge-std/Test.sol"; import { AaveYieldBackend } from "../../../../contracts/superfluid/AaveYieldBackend.sol"; import { IERC20, ISuperfluid } from "../../../../contracts/interfaces/superfluid/ISuperfluid.sol"; +import { ISuperToken } from "../../../../contracts/interfaces/superfluid/ISuperToken.sol"; import { SuperToken } from "../../../../contracts/superfluid/SuperToken.sol"; import { IPool } from "aave-v3/src/contracts/interfaces/IPool.sol"; @@ -88,7 +89,11 @@ contract AaveYieldBackendIntegrationTest is Test { } function _enableYieldBackend() public { + uint256 underlyingBalanceBefore = IERC20(USDC).balanceOf(address(superToken)); + vm.startPrank(ADMIN); + vm.expectEmit(true, false, false, true); + emit ISuperToken.YieldBackendEnabled(address(aaveBackend), underlyingBalanceBefore); superToken.enableYieldBackend(aaveBackend); vm.stopPrank(); } @@ -177,6 +182,8 @@ contract AaveYieldBackendIntegrationTest is Test { _enableYieldBackend(); vm.startPrank(ADMIN); + vm.expectEmit(true, false, false, true); + emit ISuperToken.YieldBackendDisabled(address(aaveBackend)); superToken.disableYieldBackend(); vm.stopPrank(); assertEq(address(superToken.getYieldBackend()), address(0), "Yield backend mismatch"); From d8ca70d965723501fed70adf2c7a33e2b3d84e9c Mon Sep 17 00:00:00 2001 From: didi Date: Mon, 12 Jan 2026 19:16:40 +0100 Subject: [PATCH 39/44] added integration test for AaveETHBackend and related logic fix --- .../superfluid/AaveETHYieldBackend.sol | 8 +- .../contracts/superfluid/SuperToken.sol | 5 + .../AaveETHYieldBackendIntegration.t.sol | 428 ++++++++++++++++++ .../yieldbackend/AaveYieldBackend.t.sol | 4 +- .../yieldbackend/YieldBackendUnitTestBase.sol | 3 +- 5 files changed, 440 insertions(+), 8 deletions(-) create mode 100644 packages/ethereum-contracts/test/foundry/superfluid/yieldbackend/AaveETHYieldBackendIntegration.t.sol diff --git a/packages/ethereum-contracts/contracts/superfluid/AaveETHYieldBackend.sol b/packages/ethereum-contracts/contracts/superfluid/AaveETHYieldBackend.sol index 18309a9dc1..664833801c 100644 --- a/packages/ethereum-contracts/contracts/superfluid/AaveETHYieldBackend.sol +++ b/packages/ethereum-contracts/contracts/superfluid/AaveETHYieldBackend.sol @@ -82,7 +82,7 @@ contract AaveETHYieldBackend is AaveYieldBackend { // 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); + _SELF.unwrapWETHAndForwardETH(withdrawnAmount, address(this)); } // ============ functions operating on this contract itself (NOT in delegatecall context) ============ @@ -92,11 +92,11 @@ contract AaveETHYieldBackend is AaveYieldBackend { // 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 check msg.sender. + // 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) external { + function unwrapWETHAndForwardETH(uint256 amount, address recipient) external { IWETH(address(ASSET_TOKEN)).withdraw(amount); - (bool success,) = address(msg.sender).call{ value: amount }(""); + (bool success,) = recipient.call{ value: amount }(""); require(success, "call failed"); } } diff --git a/packages/ethereum-contracts/contracts/superfluid/SuperToken.sol b/packages/ethereum-contracts/contracts/superfluid/SuperToken.sol index db693dc6b1..15b7e3c9ef 100644 --- a/packages/ethereum-contracts/contracts/superfluid/SuperToken.sol +++ b/packages/ethereum-contracts/contracts/superfluid/SuperToken.sol @@ -219,7 +219,12 @@ contract SuperToken is function disableYieldBackend() external onlyAdmin { require(address(_yieldBackend) != address(0), "yield backend not set"); address oldYieldBackend = address(_yieldBackend); + + // This guard is needed for the native token wrapper + _skipSelfMint = true; delegateCallChecked(address(_yieldBackend), abi.encodeCall(IYieldBackend.withdrawMax, ())); + _skipSelfMint = false; + delegateCallChecked(address(_yieldBackend), abi.encodeCall(IYieldBackend.disable, ())); _yieldBackend = IYieldBackend(address(0)); emit YieldBackendDisabled(oldYieldBackend); diff --git a/packages/ethereum-contracts/test/foundry/superfluid/yieldbackend/AaveETHYieldBackendIntegration.t.sol b/packages/ethereum-contracts/test/foundry/superfluid/yieldbackend/AaveETHYieldBackendIntegration.t.sol new file mode 100644 index 0000000000..0b7f14d883 --- /dev/null +++ b/packages/ethereum-contracts/test/foundry/superfluid/yieldbackend/AaveETHYieldBackendIntegration.t.sol @@ -0,0 +1,428 @@ +// SPDX-License-Identifier: AGPLv3 +pragma solidity ^0.8.23; + +import { Test } from "forge-std/Test.sol"; +import { AaveETHYieldBackend } from "../../../../contracts/superfluid/AaveETHYieldBackend.sol"; +import { IERC20, ISuperfluid } from "../../../../contracts/interfaces/superfluid/ISuperfluid.sol"; +import { ISuperToken } from "../../../../contracts/interfaces/superfluid/ISuperToken.sol"; +import { ISETH } from "../../../../contracts/interfaces/tokens/ISETH.sol"; +import { SuperToken } from "../../../../contracts/superfluid/SuperToken.sol"; +import { IPool } from "aave-v3/src/contracts/interfaces/IPool.sol"; + +/** + * @title AaveETHYieldBackendIntegrationTest + * @notice Integration tests for AaveETHYieldBackend with ETHx on Base + * @author Superfluid + */ +contract AaveETHYieldBackendIntegrationTest is Test { + address internal constant ALICE = address(0x420); + address internal constant ADMIN = address(0xAAA); + + // Base network constants + uint256 internal constant CHAIN_ID = 8453; + string internal constant RPC_URL = "https://mainnet.base.org"; + + // Aave V3 Pool on Base (verified address) + address internal constant AAVE_POOL = 0xA238Dd80C259a72e81d7e4664a9801593F98d1c5; + + // Common tokens on Base + address internal constant ETHX = 0x46fd5cfB4c12D87acD3a13e92BAa53240C661D93; // ETHx on Base + address internal constant WETH = 0x4200000000000000000000000000000000000006; // WETH on Base + address internal SURPLUS_RECEIVER = 0xac808840f02c47C05507f48165d2222FF28EF4e1; // dao.superfluid.eth + + /// Rounding tolerance for Aave deposit/withdraw operations (in wei) + uint256 internal constant AAVE_ROUNDING_TOLERANCE = 2; + + SuperToken public superToken; + ISETH public superTokenETH; // For ETH-specific functions + AaveETHYieldBackend public aaveETHBackend; + IPool public aavePool; + address public aWETH; // aWETH address (retrieved from Aave Pool) + IERC20 public wethToken; + /// Initial excess underlying balance (underlyingBalance - normalizedTotalSupply) + uint256 public initialExcessUnderlying; + + /// @notice Set up the test environment by forking the chain and deploying AaveETHYieldBackend + function setUp() public { + vm.createSelectFork(RPC_URL); + + // Verify chain id + assertEq(block.chainid, CHAIN_ID, "Chainid mismatch"); + + // Get Aave Pool + aavePool = IPool(AAVE_POOL); + + // Get aWETH address from Aave Pool + aWETH = aavePool.getReserveAToken(WETH); + require(aWETH != address(0), "aWETH address not found"); + + // Set up ETHx + superToken = SuperToken(ETHX); + superTokenETH = ISETH(ETHX); + wethToken = IERC20(WETH); + + // Deploy AaveETHBackend + aaveETHBackend = new AaveETHYieldBackend(IPool(AAVE_POOL), SURPLUS_RECEIVER); + + // upgrade SuperToken to new logic (including the yield backend related code) + SuperToken newSuperTokenLogic = new SuperToken(ISuperfluid(superToken.getHost()), superToken.POOL_ADMIN_NFT()); + vm.startPrank(address(superToken.getHost())); + superToken.updateCode(address(newSuperTokenLogic)); + vm.stopPrank(); + + // designate an admin for the SuperToken + vm.startPrank(address(superToken.getHost())); + superToken.changeAdmin(ADMIN); + vm.stopPrank(); + + // provide ALICE with ETH (will be used for upgradeByETH) + vm.deal(ALICE, type(uint128).max); + + // Calculate and store initial excess underlying balance + // The underlying balance may be greater than totalSupply due to rounding or initial state + uint256 underlyingBalance = address(superToken).balance; + (uint256 normalizedTotalSupply,) = superToken.toUnderlyingAmount(superToken.totalSupply()); + + // assert that the underlying balance is equal or greater than total supply (aka the SuperToken is solvent) + assertGe( + underlyingBalance, + normalizedTotalSupply, + "underlyingBalance should be >= normalizedTotalSupply" + ); + initialExcessUnderlying = underlyingBalance - normalizedTotalSupply; + } + + function _enableYieldBackend() public { + uint256 underlyingBalanceBefore = address(superToken).balance; + + vm.startPrank(ADMIN); + vm.expectEmit(true, false, false, true); + emit ISuperToken.YieldBackendEnabled(address(aaveETHBackend), underlyingBalanceBefore); + superToken.enableYieldBackend(aaveETHBackend); + vm.stopPrank(); + } + + /// @notice Verify invariants for the SuperToken yield backend system + /// @param preserveInitialExcess If true, expect initial excess to be preserved (not withdrawn as surplus) + /// @param numAaveOperations Number of Aave deposit/withdraw operations that have occurred + function _verifyInvariants(bool preserveInitialExcess, uint256 numAaveOperations) internal view { + // underlyingBalance (ETH) + aTokenBalance (aWETH) >= superToken.supply() [+ initialExcessUnderlying if preserved] + // Allow for Aave rounding tolerance (may lose up to 2 wei per operation) + uint256 underlyingBalance = address(superToken).balance; + uint256 aTokenBalance = IERC20(aWETH).balanceOf(address(superToken)); + (uint256 superTokenNormalizedSupply,) = superToken.toUnderlyingAmount(superToken.totalSupply()); + + uint256 expectedMinTotalAssets = preserveInitialExcess + ? superTokenNormalizedSupply + initialExcessUnderlying + : superTokenNormalizedSupply; + uint256 totalAssets = underlyingBalance + aTokenBalance; + + // Calculate total tolerance based on number of operations + uint256 totalTolerance = numAaveOperations * AAVE_ROUNDING_TOLERANCE; + + // Add tolerance to actual to avoid underflow, equivalent to: actual >= expected - tolerance + assertGe( + totalAssets + totalTolerance, + expectedMinTotalAssets, + preserveInitialExcess + ? "invariant failed: total assets should be >= supply + initial excess (accounting for rounding)" + : "invariant failed: total assets should be >= supply (accounting for rounding)" + ); + } + + /// @notice Test enabling yield backend + function testEnableYieldBackend() public { + // Record state before enabling + uint256 underlyingBalanceBefore = address(superToken).balance; + (uint256 normalizedTotalSupplyBefore,) = superToken.toUnderlyingAmount(superToken.totalSupply()); + uint256 expectedUnderlyingBefore = normalizedTotalSupplyBefore + initialExcessUnderlying; + + // Verify initial state + assertGe( + underlyingBalanceBefore, + expectedUnderlyingBefore, + "initial underlying should be >= supply + initial excess" + ); + + _enableYieldBackend(); + + assertEq(address(superToken.getYieldBackend()), address(aaveETHBackend), "Yield backend mismatch"); + + // the SuperToken should now have a zero ETH balance (all deposited and wrapped to WETH) + assertEq(address(superToken).balance, 0, "ETH balance should be zero"); + + uint256 aTokenBalanceAfter = IERC20(aWETH).balanceOf(address(superToken)); + assertGe( + aTokenBalanceAfter, + underlyingBalanceBefore - AAVE_ROUNDING_TOLERANCE, + "aWETH balance should match previous ETH balance" + ); + + // The aToken balance should approximately match what was deposited + // Account for initial excess and potential rounding in Aave + assertGe( + aTokenBalanceAfter, + expectedUnderlyingBefore - 1000, // Allow some rounding tolerance + "aToken balance should approximately match deposited amount" + ); + + // 1 operation: enable deposits all existing underlying + _verifyInvariants(true, 1); + } + + /// @notice Test disabling yield backend + function testDisableYieldBackend() public { + // verify: underlying >= totalSupply (with initial excess accounted for) + uint256 underlyingBalanceBefore = address(superToken).balance; + uint256 superTokenBalanceBefore = superToken.totalSupply(); + (uint256 normalizedTotalSupply,) = superToken.toUnderlyingAmount(superTokenBalanceBefore); + uint256 expectedUnderlying = normalizedTotalSupply + initialExcessUnderlying; + assertGe( + underlyingBalanceBefore, + expectedUnderlying, + "precondition failed: underlyingBalanceBefore should be >= supply + initial excess" + ); + + _enableYieldBackend(); + + vm.startPrank(ADMIN); + vm.expectEmit(true, false, false, true); + emit ISuperToken.YieldBackendDisabled(address(aaveETHBackend)); + superToken.disableYieldBackend(); + vm.stopPrank(); + assertEq(address(superToken.getYieldBackend()), address(0), "Yield backend mismatch"); + + // the SuperToken should now have a non-zero ETH balance and a zero aWETH balance + uint256 underlyingBalanceAfter = address(superToken).balance; + assertGt(underlyingBalanceAfter, 0, "ETH balance should be non-zero"); + assertEq(IERC20(aWETH).balanceOf(address(superToken)), 0, "aWETH balance should be zero"); + + // After disabling, underlying balance should be at least the amount we had in aTokens + initial excess + // (the aTokens were converted back to underlying and unwrapped from WETH to ETH) + // Allow for Aave rounding tolerance (may lose up to 2 wei) + // Add tolerance to actual to avoid underflow + assertGe( + underlyingBalanceAfter + AAVE_ROUNDING_TOLERANCE, + expectedUnderlying, + "underlying balance after disable should be >= original underlying + initial excess" + ); + + // 2 operations: enable deposits + disable withdraws + _verifyInvariants(true, 2); + } + + /// @notice Test upgrade and downgrade with fuzzed amount + function testUpgradeDowngrade(uint256 amount) public { + // Bound amount to reasonable range + // ETH has 18 decimals, SuperToken has 18 decimals (same) + // Minimum: 0.001 ETH (1e15 wei) = 1e15 SuperToken units + // Maximum: 1000 ETH (1000 * 1e18) = 1000 * 1e18 SuperToken units + amount = bound(amount, 1e15, 1000 * 1e18); + + _enableYieldBackend(); + + vm.startPrank(ALICE); + superTokenETH.upgradeByETH{value: amount}(); + vm.stopPrank(); + + // Downgrade + vm.startPrank(ALICE); + // Note: upgrade may have down-rounded the amount, but doesn't tell us (via return value). + // In that case a consecutive downgrade (of the un-adjusted amount) might revert. + // For fuzzing, we downgrade the actual balance ALICE received + uint256 aliceBalance = superToken.balanceOf(ALICE); + superTokenETH.downgradeToETH(aliceBalance); + vm.stopPrank(); + + // 3 operations: enable deposits + upgrade deposits + downgrade withdraws + _verifyInvariants(true, 3); + } + + + // testWithdrawSurplusFromYieldBackendExcessUnderlying not possible for SETH + // because there's no way to deposit ETH without also increasing totalSupply + + /// @notice Test withdrawing surplus generated by yield protocol (fast forward time) + function testWithdrawSurplusFromYieldBackendYieldAccrued(uint256 timeForward) public { + // Bound time forward between 1 hour and 365 days + timeForward = bound(timeForward, 1 hours, 365 days); + + _enableYieldBackend(); + + // Record initial state before yield accrual + (uint256 normalizedTotalSupplyInitial,) = superToken.toUnderlyingAmount(superToken.totalSupply()); + + // Fast forward time to accrue yield in Aave + vm.warp(block.timestamp + timeForward); + + // Calculate total supply after time forward (should be unchanged) + (uint256 normalizedTotalSupply,) = + superToken.toUnderlyingAmount(superToken.totalSupply()); + assertEq( + normalizedTotalSupply, + normalizedTotalSupplyInitial, + "Total supply should not change from time forward" + ); + + uint256 receiverBalanceBefore = wethToken.balanceOf(SURPLUS_RECEIVER); + uint256 underlyingBalanceBefore = address(superToken).balance; + uint256 aTokenBalanceBefore = IERC20(aWETH).balanceOf(address(superToken)); + + // Total assets should be greater than supply due to yield accrual + // Note: Aave yield accrues by increasing the underlying value of aTokens over time + uint256 totalAssetsBefore = underlyingBalanceBefore + aTokenBalanceBefore; + + // Check if there's actually surplus to withdraw (after the 100 wei margin used in withdrawSurplus) + bool hasSurplus = totalAssetsBefore > normalizedTotalSupply + 100; + assertTrue(hasSurplus, "no surplus, may need to review the lower bound for timeForward"); + + vm.startPrank(ADMIN); + superToken.withdrawSurplusFromYieldBackend(); + vm.stopPrank(); + + uint256 receiverBalanceAfter = wethToken.balanceOf(SURPLUS_RECEIVER); + uint256 aTokenBalanceAfter = IERC20(aWETH).balanceOf(address(superToken)); + + // Surplus should be withdrawn to receiver (as WETH) + assertGt(receiverBalanceAfter, receiverBalanceBefore, "Surplus should be withdrawn to receiver"); + assertLt(aTokenBalanceAfter, aTokenBalanceBefore, "aToken balance should decrease"); + + // After withdrawing surplus, initial excess is also withdrawn, so don't expect it to be preserved + // 2 operations: enable deposits + withdraw surplus + _verifyInvariants(false, 2); + } + + /*////////////////////////////////////////////////////////////////////////// + Random Sequence Fuzz Tests + //////////////////////////////////////////////////////////////////////////*/ + + struct YieldBackendStep { + uint8 a; // action type: 0 enable, 1 disable, 2 switch, 3 upgrade, 4 downgrade, 5 withdraw surplus + uint32 v; // action param (amount for upgrade/downgrade, unused for others) + uint16 dt; // time delta (for yield accrual simulation) + } + + /// @notice Test random sequence of yield backend operations + /// @dev Simulates real-world usage patterns with appropriate frequency distribution + function testRandomYieldBackendSequence(YieldBackendStep[20] memory steps) external { + // Track state + bool backendEnabled = false; + bool initialExcessPreserved = true; // Track if initial excess has been withdrawn via surplus + uint256 numAaveOperations = 0; + AaveETHYieldBackend currentBackend = aaveETHBackend; + + for (uint256 i = 0; i < steps.length; ++i) { + YieldBackendStep memory s = steps[i]; + uint256 action = s.a % 20; // Use modulo 20 for frequency distribution + + // Action frequency distribution: + // 0: Enable (5%) + // 1: Disable (5%) + // 2: Switch (5%) + // 3-16: Upgrade/Downgrade (70%, split evenly: 3-9 upgrade, 10-16 downgrade) + // 17-19: Withdraw surplus (15%) + + if (action == 0) { + // Enable yield backend (5% frequency) + if (!backendEnabled) { + vm.startPrank(ADMIN); + superToken.enableYieldBackend(currentBackend); + vm.stopPrank(); + backendEnabled = true; + numAaveOperations += 1; // enable deposits all existing underlying + } + } else if (action == 1) { + // Disable yield backend (5% frequency) + if (backendEnabled) { + vm.startPrank(ADMIN); + superToken.disableYieldBackend(); + vm.stopPrank(); + backendEnabled = false; + numAaveOperations += 1; // disable withdraws max + } + } else if (action == 2) { + // Switch yield backend: disable current, enable new (5% frequency) + if (backendEnabled) { + // Disable current + vm.startPrank(ADMIN); + superToken.disableYieldBackend(); + vm.stopPrank(); + numAaveOperations += 1; // disable withdraws + + // Deploy and enable new backend + AaveETHYieldBackend newBackend = new AaveETHYieldBackend( + IPool(AAVE_POOL), + SURPLUS_RECEIVER + ); + vm.startPrank(ADMIN); + superToken.enableYieldBackend(newBackend); + vm.stopPrank(); + currentBackend = newBackend; + numAaveOperations += 1; // enable deposits + } + } else if (action >= 3 && action <= 9) { + // Upgrade (35% frequency) + if (backendEnabled) { + // Bound upgrade amount to reasonable range + uint256 upgradeAmount = bound(uint256(s.v), 1e15, 1000 * 1e18); + vm.startPrank(ALICE); + superTokenETH.upgradeByETH{value: upgradeAmount}(); + vm.stopPrank(); + numAaveOperations += 1; // upgrade deposits + } + } else if (action >= 10 && action <= 16) { + // Downgrade (35% frequency) + if (backendEnabled) { + uint256 aliceBalance = superToken.balanceOf(ALICE); + if (aliceBalance >= 1e15) { + // Bound downgrade amount to available balance + uint256 downgradeAmount = bound(uint256(s.v), 1e15, aliceBalance); + // Don't downgrade more than available + if (downgradeAmount > aliceBalance) { + downgradeAmount = aliceBalance; + } + vm.startPrank(ALICE); + superTokenETH.downgradeToETH(downgradeAmount); + vm.stopPrank(); + numAaveOperations += 1; // downgrade withdraws + } + } + } else if (action >= 17 && action <= 19) { + // Withdraw surplus (15% frequency) + if (backendEnabled) { + // Check if there's surplus to withdraw + uint256 underlyingBalance = address(superToken).balance; + uint256 aTokenBalance = IERC20(aWETH).balanceOf(address(superToken)); + (uint256 normalizedTotalSupply,) = superToken.toUnderlyingAmount(superToken.totalSupply()); + uint256 totalAssets = underlyingBalance + aTokenBalance; + + // Only withdraw if there's actual surplus (after 100 wei margin) + if (totalAssets > normalizedTotalSupply + 100) { + vm.startPrank(ADMIN); + superToken.withdrawSurplusFromYieldBackend(); + vm.stopPrank(); + numAaveOperations += 1; // withdraw surplus + // After withdrawing surplus, initial excess is also withdrawn + initialExcessPreserved = false; + } + } + } + + // Warp time to simulate yield accrual (if dt > 0) + if (s.dt > 0) { + // Bound time warp to reasonable range (1 hour to 30 days) + uint256 timeWarp = bound(uint256(s.dt), 1 hours, 30 days); + vm.warp(block.timestamp + timeWarp); + } + + // Verify invariants after each step + // Initial excess should be preserved only if backend is enabled AND surplus hasn't been withdrawn + bool preserveInitialExcess = backendEnabled && initialExcessPreserved; + _verifyInvariants(preserveInitialExcess, numAaveOperations); + } + + // Final invariant check + bool finalPreserveInitialExcess = backendEnabled && initialExcessPreserved; + _verifyInvariants(finalPreserveInitialExcess, numAaveOperations); + } +} diff --git a/packages/ethereum-contracts/test/foundry/superfluid/yieldbackend/AaveYieldBackend.t.sol b/packages/ethereum-contracts/test/foundry/superfluid/yieldbackend/AaveYieldBackend.t.sol index 516a0a4b79..148cee9ee6 100644 --- a/packages/ethereum-contracts/test/foundry/superfluid/yieldbackend/AaveYieldBackend.t.sol +++ b/packages/ethereum-contracts/test/foundry/superfluid/yieldbackend/AaveYieldBackend.t.sol @@ -50,7 +50,7 @@ contract AaveYieldBackendUnitTest is YieldBackendUnitTestBase { return IYieldBackend(address(aaveBackend)); } - function getAssetToken() internal override returns (IERC20) { + function getAssetToken() internal pure override returns (IERC20) { return IERC20(USDC); } @@ -63,7 +63,7 @@ contract AaveYieldBackendUnitTest is YieldBackendUnitTestBase { return 6; } - function _getProtocolAddress() internal view override returns (address) { + function _getProtocolAddress() internal pure override returns (address) { return AAVE_POOL; } } diff --git a/packages/ethereum-contracts/test/foundry/superfluid/yieldbackend/YieldBackendUnitTestBase.sol b/packages/ethereum-contracts/test/foundry/superfluid/yieldbackend/YieldBackendUnitTestBase.sol index f444737e15..6eae788a30 100644 --- a/packages/ethereum-contracts/test/foundry/superfluid/yieldbackend/YieldBackendUnitTestBase.sol +++ b/packages/ethereum-contracts/test/foundry/superfluid/yieldbackend/YieldBackendUnitTestBase.sol @@ -86,7 +86,7 @@ abstract contract YieldBackendUnitTestBase is Test { /// @notice Execute withdraw via delegatecall function _withdraw(uint256 amount) internal { - (bool success, bytes memory returnData) = address(backend).delegatecall( + (bool success, ) = address(backend).delegatecall( abi.encodeWithSelector(IYieldBackend.withdraw.selector, amount) ); require(success, "withdraw failed"); @@ -171,7 +171,6 @@ abstract contract YieldBackendUnitTestBase is Test { _withdraw(amount); uint256 balanceAfter = _getAssetBalance(); - uint256 balanceIncrease = balanceAfter - balanceBefore; assertEq(balanceAfter - balanceBefore, amount, "balance should increase by amount"); } From c044943cc369e790034ce0764907d53f99a106c6 Mon Sep 17 00:00:00 2001 From: didi Date: Tue, 13 Jan 2026 09:33:38 +0100 Subject: [PATCH 40/44] added tests for delegateCallChecked --- .../test/foundry/libs/CallUtils.t.sol | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/packages/ethereum-contracts/test/foundry/libs/CallUtils.t.sol b/packages/ethereum-contracts/test/foundry/libs/CallUtils.t.sol index 9a68c67c43..376c8c921f 100644 --- a/packages/ethereum-contracts/test/foundry/libs/CallUtils.t.sol +++ b/packages/ethereum-contracts/test/foundry/libs/CallUtils.t.sol @@ -4,6 +4,33 @@ pragma solidity ^0.8.23; import "forge-std/Test.sol"; import { CallUtils } from "../../../contracts/libs/CallUtils.sol"; +import { delegateCallChecked } from "../../../contracts/libs/CallUtils.sol"; + +// Helper contract to test delegateCallChecked +contract DelegateCallTarget { + uint256 public value; + + function setValue(uint256 _value) external { + value = _value; + } + + function revertAlways() external pure { + revert("Target revert"); + } +} + +// Contract that uses delegateCallChecked +contract DelegateCallChecker { + uint256 public value; + + function delegateCallSetValue(address target, uint256 _value) external { + delegateCallChecked(target, abi.encodeWithSelector(DelegateCallTarget.setValue.selector, _value)); + } + + function delegateCallRevert(address target) external { + delegateCallChecked(target, abi.encodeWithSelector(DelegateCallTarget.revertAlways.selector)); + } +} contract CallUtilsAnvil is Test { function testPadLength32(uint256 len) public pure { @@ -16,6 +43,26 @@ contract CallUtilsAnvil is Test { assertTrue(CallUtils.isValidAbiEncodedBytes(abi.encode(data))); } + function testDelegateCallChecked_Success() public { + DelegateCallTarget target = new DelegateCallTarget(); + DelegateCallChecker checker = new DelegateCallChecker(); + + uint256 testValue = 42; + checker.delegateCallSetValue(address(target), testValue); + + // The value should be set in the checker contract (not the target) due to delegatecall + assertEq(checker.value(), testValue); + assertEq(target.value(), 0); + } + + function testDelegateCallChecked_Revert() public { + DelegateCallTarget target = new DelegateCallTarget(); + DelegateCallChecker checker = new DelegateCallChecker(); + + vm.expectRevert("CallUtils: delegatecall failed"); + checker.delegateCallRevert(address(target)); + } + // TODO this is a hard fuzzing case, because we need to know if there is a case that: // 1. CallUtils.isValidAbiEncodedBytes returns true // 2. and abi.decode reverts From d69e4b0394c38d2760b9b54f173a797b630c9ed1 Mon Sep 17 00:00:00 2001 From: Miao ZhiCheng Date: Tue, 13 Jan 2026 11:23:23 +0200 Subject: [PATCH 41/44] Update README.md codecov link --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7373672bea..ec3b0ab29e 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ For technical document, references and tutorials, etc, refer to the Version - + From 1b0436658062efd54515ae64c2310e766bd9a64f Mon Sep 17 00:00:00 2001 From: didi Date: Tue, 13 Jan 2026 11:44:30 +0100 Subject: [PATCH 42/44] removed all uses of ganache --- .github/workflows/call.deploy-dry-run.yml | 10 ++++---- packages/ethereum-contracts/README.md | 2 +- .../ops-scripts/libs/getConfig.js | 5 ---- packages/ethereum-contracts/package.json | 1 - .../test/TestEnvironment.ts | 23 ++++++++++++++----- packages/ethereum-contracts/truffle-config.js | 4 ++-- 6 files changed, 25 insertions(+), 20 deletions(-) diff --git a/.github/workflows/call.deploy-dry-run.yml b/.github/workflows/call.deploy-dry-run.yml index f09cf98d1f..b0c4f33f41 100644 --- a/.github/workflows/call.deploy-dry-run.yml +++ b/.github/workflows/call.deploy-dry-run.yml @@ -6,9 +6,6 @@ on: network: required: true type: string - network-id: - required: true - type: string provider-url: required: true type: string @@ -28,8 +25,11 @@ jobs: yarn install --frozen-lockfile yarn build-for-contracts-dev - - name: Start ganache - run: npx ganache --port 47545 --mnemonic --fork.url ${{ github.event.inputs.provider-url }} --network-id ${{ github.event.inputs.network-id }} --chain.chainId ${{ github.event.inputs.network-id }} + - name: Start hardhat node + run: | + cd ${{ env.ethereum-contracts-working-directory }} + npx hardhat node --port 47545 --fork ${{ github.event.inputs.provider-url }} & + sleep 5 - name: Deploy framework run: | diff --git a/packages/ethereum-contracts/README.md b/packages/ethereum-contracts/README.md index 9330f7d6b1..5d1d27d658 100644 --- a/packages/ethereum-contracts/README.md +++ b/packages/ethereum-contracts/README.md @@ -355,7 +355,7 @@ Run the test suite for core contracts: yarn run-hardhat test testsuites/superfluid-core.js ``` -The `pretest` script starts a ganache instance with deterministic accounts in the background, the `posttest` script stops it. +The `pretest` script starts a local dev chain with deterministic accounts in the background, the `posttest` script stops it. When running tests with `yarn test`, those get executed automatically (see [npm docs](https://docs.npmjs.com/cli/v7/using-npm/scripts#pre--post-scripts)). > NOTE: You don't need to run the `pretest` and `posttest` scripts when running hardhat tests, but you do when running tests with truffle. diff --git a/packages/ethereum-contracts/ops-scripts/libs/getConfig.js b/packages/ethereum-contracts/ops-scripts/libs/getConfig.js index edf9df3969..099a574865 100644 --- a/packages/ethereum-contracts/ops-scripts/libs/getConfig.js +++ b/packages/ethereum-contracts/ops-scripts/libs/getConfig.js @@ -11,11 +11,6 @@ module.exports = function getConfig(chainId) { // this is a fake forwarder address, it is to test the deployment script trustedForwarders: ["0x3075b4dc7085C48A14A5A39BBa68F58B19545971"], }, - 5777: { - // for local testing (external ganache) - // this is a fake forwarder address, it is to test the deployment script - trustedForwarders: ["0x3075b4dc7085C48A14A5A39BBa68F58B19545971"], - }, 6777: { // for coverage testing // this is a fake forwarder address, it is to test the deployment script diff --git a/packages/ethereum-contracts/package.json b/packages/ethereum-contracts/package.json index 2da1bc89d6..cca0215676 100644 --- a/packages/ethereum-contracts/package.json +++ b/packages/ethereum-contracts/package.json @@ -22,7 +22,6 @@ "async": "^3.2.6", "csv-writer": "^1.6.0", "ethers": "^5.7.2", - "ganache-time-traveler": "^1.0.16", "mochawesome": "^7.1.3", "readline": "^1.3.0", "solidity-coverage": "^0.8.17", diff --git a/packages/ethereum-contracts/test/TestEnvironment.ts b/packages/ethereum-contracts/test/TestEnvironment.ts index 560e63ffb8..b43a2558be 100644 --- a/packages/ethereum-contracts/test/TestEnvironment.ts +++ b/packages/ethereum-contracts/test/TestEnvironment.ts @@ -32,7 +32,6 @@ import { const {web3tx, wad4human} = require("@decentral.ee/web3-helpers"); const SuperfluidSDK = require("@superfluid-finance/js-sdk"); -const traveler = require("ganache-time-traveler"); const deployFramework = require("../ops-scripts/deploy-framework"); const deploySuperToken = require("../ops-scripts/deploy-super-token"); @@ -161,6 +160,22 @@ export default class TestEnvironment { console.debug("popEvmSnapshot", JSON.stringify(this._evmSnapshots)); } + /** + * Advance time by specified seconds and mine a block (replaces ganache-time-traveler.advanceTimeAndBlock) + */ + async _advanceTimeAndBlock(seconds: number) { + await network.provider.send("evm_increaseTime", [seconds]); + await network.provider.send("evm_mine", []); + } + + /** + * Set the next block's timestamp and mine a block (replaces ganache-time-traveler.advanceBlockAndSetTime) + * Uses evm_mine with timestamp parameter, matching ganache-time-traveler behavior + */ + async _advanceBlockAndSetTime(timestamp: number) { + await network.provider.send("evm_mine", [timestamp]); + } + async useLastEvmSnapshot() { let oldEvmSnapshotId = ""; const popped = this._evmSnapshots.pop(); @@ -171,10 +186,6 @@ export default class TestEnvironment { } = popped); } await this._revertToEvmSnapShot(oldEvmSnapshotId); - // move the time to now - await traveler.advanceBlockAndSetTime( - parseInt((Date.now() / 1000).toString()) - ); const newEvmSnapshotId = await this._takeEvmSnapshot(); this._evmSnapshots.push({ id: newEvmSnapshotId, @@ -192,7 +203,7 @@ export default class TestEnvironment { const block1 = await ethers.provider.getBlock("latest"); console.log("current block time", block1.timestamp); console.log(`time traveler going to the future +${jsNumTime}...`); - await traveler.advanceTimeAndBlock(jsNumTime); + await this._advanceTimeAndBlock(jsNumTime); const block2 = await ethers.provider.getBlock("latest"); console.log("new block time", block2.timestamp); } diff --git a/packages/ethereum-contracts/truffle-config.js b/packages/ethereum-contracts/truffle-config.js index 026faf9e57..9dd7ba3ad2 100644 --- a/packages/ethereum-contracts/truffle-config.js +++ b/packages/ethereum-contracts/truffle-config.js @@ -171,8 +171,8 @@ const E = (module.exports = { networks: { // Useful for testing. The `development` name is special - truffle uses it by default // if it's defined here and no other network is specified at the command line. - // You should run a client (like ganache-cli, geth or parity) in a separate terminal - // tab if you use this network and you must also set the `host`, `port` and `network_id` + // You should run a client in a separate terminal tab + // if you use this network and you must also set the `host`, `port` and `network_id` // options below to some value. // From 92112ab9e39c8fec0be121a46afabd084de17c36 Mon Sep 17 00:00:00 2001 From: didi Date: Tue, 13 Jan 2026 16:06:40 +0100 Subject: [PATCH 43/44] remove TODOs --- packages/ethereum-contracts/contracts/superfluid/SuperToken.sol | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/ethereum-contracts/contracts/superfluid/SuperToken.sol b/packages/ethereum-contracts/contracts/superfluid/SuperToken.sol index 15b7e3c9ef..7785fa1bc6 100644 --- a/packages/ethereum-contracts/contracts/superfluid/SuperToken.sol +++ b/packages/ethereum-contracts/contracts/superfluid/SuperToken.sol @@ -894,7 +894,6 @@ contract SuperToken is if (underlyingAmount != actualUpgradedAmount) revert SUPER_TOKEN_INFLATIONARY_DEFLATIONARY_NOT_SUPPORTED(); if (address(_yieldBackend) != address(0)) { - // TODO: shall we deposit all, or just the upgradeAmount? delegateCallChecked(address(_yieldBackend), abi.encodeCall(IYieldBackend.deposit, (actualUpgradedAmount))); } @@ -921,7 +920,6 @@ contract SuperToken is _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 delegateCallChecked(address(_yieldBackend), abi.encodeCall(IYieldBackend.withdraw, (underlyingAmount))); } From 6b95d904453aafc9ea514d61dc96b8027a2a06e9 Mon Sep 17 00:00:00 2001 From: didi Date: Wed, 14 Jan 2026 15:18:31 +0100 Subject: [PATCH 44/44] updated foundry --- flake.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flake.lock b/flake.lock index 053374114d..620b2ada9a 100644 --- a/flake.lock +++ b/flake.lock @@ -28,11 +28,11 @@ ] }, "locked": { - "lastModified": 1752867797, - "narHash": "sha256-oT129SDSr7SI9ThTd6ZbpmShh5f2tzUH3S4hl6c5/7w=", + "lastModified": 1766221822, + "narHash": "sha256-7e41xdHPr0gDhtLd07VFyPpW2DrxZzaGiBczW37V2wI=", "owner": "shazow", "repo": "foundry.nix", - "rev": "d4445852933ab5bc61ca532cb6c5d3276d89c478", + "rev": "f69896cb54bdd49674b453fb80ff98aa452c4c1d", "type": "github" }, "original": {