diff --git a/.gitmodules b/.gitmodules index e1e7d6c2c5..d4eca99945 100644 --- a/.gitmodules +++ b/.gitmodules @@ -5,3 +5,6 @@ path = lib/openzeppelin-contracts url = https://github.com/OpenZeppelin/openzeppelin-contracts.git branch = release-v5.4 +[submodule "lib/aave-v3"] + path = lib/aave-v3 + url = https://github.com/aave-dao/aave-v3-origin 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": { 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/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/CHANGELOG.md b/packages/ethereum-contracts/CHANGELOG.md index 0d73f219ca..9e7928fb33 100644 --- a/packages/ethereum-contracts/CHANGELOG.md +++ b/packages/ethereum-contracts/CHANGELOG.md @@ -5,6 +5,10 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [UNRELEASED] +### Added + +- `SuperToken`: the contract admin can enable/disable a _Yield Backend_ in order to generate a yield on the underlying asset. + ### Changed - EVM target changed from _shanghai_ to _cancun_. diff --git a/packages/ethereum-contracts/contracts/interfaces/superfluid/ISuperToken.sol b/packages/ethereum-contracts/contracts/interfaces/superfluid/ISuperToken.sol index 267aec9a8e..7d82ec0f68 100644 --- a/packages/ethereum-contracts/contracts/interfaces/superfluid/ISuperToken.sol +++ b/packages/ethereum-contracts/contracts/interfaces/superfluid/ISuperToken.sol @@ -63,13 +63,43 @@ interface ISuperToken is ISuperfluidToken, IERC20Metadata, IERC777, IERC20Permit */ function changeAdmin(address newAdmin) external; + event AdminChanged(address indexed oldAdmin, address indexed newAdmin); /** * @dev Returns the admin address for the SuperToken + * The admin account has the exclusive privilege of + * - updating the contract (change implementation) + * - enabling/disabling a yield backend + * - setting another admin + * If no admin is set (zero address), this privileges are delegated to the host contract. */ function getAdmin() external view returns (address admin); + /** + * @dev Returns the address of the yield backend contract (see `IYieldBackend`). + * The yield backend contract is responsible for managing the yield of the SuperToken. + */ + function getYieldBackend() external view returns (address yieldBackend); + + /** + * @dev Yield backend enabled event + * @param yieldBackend The address of the yield backend that was enabled + * @param depositAmount The amount deposited to the yield backend + */ + event YieldBackendEnabled( + address indexed yieldBackend, + uint256 depositAmount + ); + + /** + * @dev Yield backend disabled event + * @param yieldBackend The address of the yield backend that was disabled + */ + event YieldBackendDisabled( + address indexed yieldBackend + ); + /************************************************************************** * Immutable variables *************************************************************************/ @@ -614,5 +644,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..5bc71eb60a --- /dev/null +++ b/packages/ethereum-contracts/contracts/interfaces/superfluid/IYieldBackend.sol @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: MIT +pragma solidity >= 0.8.11; + +/** + * A yield backend acts as interface between an ERC20 wrapper SuperToken and a yield generating protocol. + * The underlying token can be deposited on upgrade and withdrawn on downgrade. + * + * It is possible to transition from no/one yield backend to another/no yield backend. + * one -> another could be seen as a composition of one -> no -> another + * + * one -> no means withdraw not in the context of a downgrade. + * + * Contracts implementing this act as a kind of hot-pluggable library, + * using delegatecall to execute its logic on the SuperToken contract. + * This means that underlying tokens are transferred directly between the SuperToken contract and the yield protocol, + * as are yield protocol tokens representing positions in that protocol. + * If an implementation requires to hold state, it shall do so using a namespaced storage layout (EIP-7201). + */ +interface IYieldBackend { + /// Invoked by `SuperToken` as delegatecall. + /// Sets up the SuperToken as needed, e.g. by giving required approvals. + function enable() external; + + /// Invoked by `SuperToken` as delegatecall. + /// Restores the prior state, e.g. by revoking given approvals + function disable() external; + + /// Invoked by `SuperToken` as delegatecall. + /// Deposits the given amount of the underlying asset into the yield backend. + function deposit(uint256 amount) external; + + /// Invoked by `SuperToken` as delegatecall. + /// Withdraws the given amount of the underlying asset from the yield backend. + function withdraw(uint256 amount) external; + + /// Invoked by `SuperToken` as delegatecall. + /// Withdraws the maximum withdrawable amount of the underlying asset from the yield backend. + function withdrawMax() external; + + /// Invoked by `SuperToken` as delegatecall. + /// tranfers the deposited asset exceeding totalSupply of the SuperToken to the preset receiver account + function withdrawSurplus(uint256 totalSupply) external; +} 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/mocks/SuperTokenMock.t.sol b/packages/ethereum-contracts/contracts/mocks/SuperTokenMock.t.sol index 166929543f..ef14ef157d 100644 --- a/packages/ethereum-contracts/contracts/mocks/SuperTokenMock.t.sol +++ b/packages/ethereum-contracts/contracts/mocks/SuperTokenMock.t.sol @@ -64,8 +64,14 @@ contract SuperTokenStorageLayoutTester is SuperToken { require (slot == 18 && offset == 0, "_operators changed location"); // uses 4 slots - assembly { slot:= _reserve23.slot offset := _reserve23.offset } - require (slot == 23 && offset == 0, "_reserve23 changed location"); + assembly { slot:= _nonces.slot offset := _nonces.offset } + require (slot == 22 && offset == 0, "_nonces changed location"); + + assembly { slot:= _yieldBackend.slot offset := _yieldBackend.offset } + require (slot == 23 && offset == 0, "_yieldBackend changed location"); + + assembly { slot:= _reserve24.slot offset := _reserve24.offset } + require (slot == 24 && offset == 0, "_reserve24 changed location"); assembly { slot:= _reserve31.slot offset := _reserve31.offset } require (slot == 31 && offset == 0, "_reserve31 changed location"); diff --git a/packages/ethereum-contracts/contracts/superfluid/AaveETHYieldBackend.sol b/packages/ethereum-contracts/contracts/superfluid/AaveETHYieldBackend.sol new file mode 100644 index 0000000000..664833801c --- /dev/null +++ b/packages/ethereum-contracts/contracts/superfluid/AaveETHYieldBackend.sol @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +import { AaveYieldBackend } from "./AaveYieldBackend.sol"; +import { IERC20 } from "../interfaces/superfluid/ISuperfluid.sol"; +import { IPool } from "aave-v3/src/contracts/interfaces/IPool.sol"; +import { IWETH } from "aave-v3/src/contracts/helpers/interfaces/IWETH.sol"; + +/** + * @title a SuperToken yield backend for the Aave protocol for ETH/native tokens. + * This contract extends AaveYieldBackend to support native ETH by wrapping it to WETH. + * WETH addresses are hardcoded by chain id. + * + * NOTE: "WETH" is to be interpreted in a technical sense: the native token wrapper. + * On chains with ETH not being the native token, the ERC20 token with symbol "WETH" may be an ordinary ERC20 + * while the ERC20 wrapper of the native token may have a different symbol. We mean the latter! + * + * NOTE: Surplus WETH will NOT be unwrapped by `withdrawSurplus` (which is inherited from the Base contract) + * before transferring it to the configured SURPLUS_RECEIVER. + */ +contract AaveETHYieldBackend is AaveYieldBackend { + AaveETHYieldBackend internal immutable _SELF; + + // THIS CONTRACT CANNOT HAVE STATE VARIABLES! + // IF STATE IS NEEDED, USE NAMESPACED STORAGE LAYOUT (EIP-7201) + + /** + * @param aavePool the Aave pool + * @param surplusReceiver the address to receive the surplus asset when withdrawing the surplus + */ + constructor(IPool aavePool, address surplusReceiver) + AaveYieldBackend(IERC20(getWETHAddress()), aavePool, surplusReceiver) + { + _SELF = this; + } + + /// get the canonical native token ERC20 wrapper contract address based on the chain id and Aave deployment. + /// Implemented for chains with official deployments of Aave and Superfluid. + function getWETHAddress() internal view returns (address) { + if (block.chainid == 1) { // Ethereum + return 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; + } + if (block.chainid == 10 || block.chainid == 8453) { + return 0x4200000000000000000000000000000000000006; + } + if (block.chainid == 137) { // Polygon + 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, address(this)); + } + + // ============ functions operating on this contract itself (NOT in delegatecall context) ============ + + // allow unwrapping from WETH to this contract + receive() external payable { } + + // To be invoked by `withdraw` which is executed via delegatecall in a SuperToken context. + // WETH deposited or withdrawn by the SuperToken never stays in this contract beyond the lifetime of the tx. + // Thus it is not necessary to restrict msg.sender. + // We accept that an alien caller may withdraw WETH deposited to this contract (for whatever reason). + function unwrapWETHAndForwardETH(uint256 amount, address recipient) external { + IWETH(address(ASSET_TOKEN)).withdraw(amount); + (bool success,) = recipient.call{ value: amount }(""); + require(success, "call failed"); + } +} + diff --git a/packages/ethereum-contracts/contracts/superfluid/AaveYieldBackend.sol b/packages/ethereum-contracts/contracts/superfluid/AaveYieldBackend.sol new file mode 100644 index 0000000000..0b876edc58 --- /dev/null +++ b/packages/ethereum-contracts/contracts/superfluid/AaveYieldBackend.sol @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +import { IYieldBackend } from "../interfaces/superfluid/IYieldBackend.sol"; +import { IERC20, ISuperToken } from "../interfaces/superfluid/ISuperfluid.sol"; +import { IPool } from "aave-v3/src/contracts/interfaces/IPool.sol"; + +/** + * @title a SuperToken yield backend for the Aave protocol. + * Aave supports a simple deposit/withdraw workflow nicely matching the IYieldBackend interface. + * Deposits are represented by transferrable aTokens. + * + * This contract is conceptually a hot-pluggable library. + * All methods are supposed to be invoked as delegatecall. + */ +contract AaveYieldBackend is IYieldBackend { + IERC20 public immutable ASSET_TOKEN; + IPool public immutable AAVE_POOL; + IERC20 public immutable A_TOKEN; + address public immutable SURPLUS_RECEIVER; + + // THIS CONTRACT CANNOT HAVE STATE VARIABLES! + // IF STATE IS NEEDED, USE NAMESPACED STORAGE LAYOUT (EIP-7201) + + /** + * @param assetToken the asset (Aave terminology) supplied to Aave for yield. Typically, this will be + * the underlyingToken of a SuperToken. Must be a valid ERC20 token address. + * @param aavePool the Aave pool + * @param surplusReceiver the address to receive the surplus asset when withdrawing the surplus + */ + constructor(IERC20 assetToken, IPool aavePool, address surplusReceiver) { + require(address(assetToken) != address(0), "assetToken cannot be address(0)"); + ASSET_TOKEN = assetToken; + AAVE_POOL = IPool(aavePool); + SURPLUS_RECEIVER = surplusReceiver; + A_TOKEN = IERC20(aavePool.getReserveAToken(address(ASSET_TOKEN))); + } + + function enable() external { + // approve Aave pool to fetch asset + ASSET_TOKEN.approve(address(AAVE_POOL), type(uint256).max); + } + + function disable() external { + // Revoke approval + ASSET_TOKEN.approve(address(AAVE_POOL), 0); + } + + function deposit(uint256 amount) public virtual { + // TODO: can this constraint break anything? + require(amount > 0, "amount must be greater than 0"); + // Deposit asset and get back aTokens + AAVE_POOL.supply(address(ASSET_TOKEN), amount, address(this), 0); + } + + function withdraw(uint256 amount) public virtual { + // withdraw amount asset by redeeming the corresponding aTokens amount + AAVE_POOL.withdraw(address(ASSET_TOKEN), amount, address(this)); + } + + function withdrawMax() external virtual { + // We can delegate the max calculation to the Aave pool by setting amount to type(uint256).max + withdraw(type(uint256).max); + } + + function withdrawSurplus(uint256 totalSupply) external { + // totalSupply is always 18 decimals while assetToken and aToken may not + (uint256 normalizedTotalSupply,) = ISuperToken(address(this)).toUnderlyingAmount(totalSupply); + // decrement by 100 in order to give ample of margin for offsetting Aave's potential rounding error + // If there's no surplus, this will simply revert due to arithmetic underflow. + uint256 surplusAmount = A_TOKEN.balanceOf(address(this)) + ASSET_TOKEN.balanceOf(address(this)) + - normalizedTotalSupply - 100; + AAVE_POOL.withdraw(address(ASSET_TOKEN), surplusAmount, SURPLUS_RECEIVER); + } +} diff --git a/packages/ethereum-contracts/contracts/superfluid/ERC4626YieldBackend.sol b/packages/ethereum-contracts/contracts/superfluid/ERC4626YieldBackend.sol new file mode 100644 index 0000000000..0619726943 --- /dev/null +++ b/packages/ethereum-contracts/contracts/superfluid/ERC4626YieldBackend.sol @@ -0,0 +1,56 @@ +// 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"; + + +/// @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; + + 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 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)); + } +} diff --git a/packages/ethereum-contracts/contracts/superfluid/SuperToken.sol b/packages/ethereum-contracts/contracts/superfluid/SuperToken.sol index 5f7b7d0c0f..7785fa1bc6 100644 --- a/packages/ethereum-contracts/contracts/superfluid/SuperToken.sol +++ b/packages/ethereum-contracts/contracts/superfluid/SuperToken.sol @@ -11,6 +11,8 @@ import { IERC20, IPoolAdminNFT } from "../interfaces/superfluid/ISuperfluid.sol"; +import { IYieldBackend } from "../interfaces/superfluid/IYieldBackend.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"; @@ -82,15 +84,17 @@ contract SuperToken is /// @dev ERC20 Nonces for EIP-2612 (permit) mapping(address account => uint256) internal _nonces; + /// @dev optional contract using the underlying asset to generate yield + IYieldBackend internal _yieldBackend; + // NOTE: for future compatibility, these are reserved solidity slots - // The sub-class of SuperToken solidity slot will start after _reserve22 + // The sub-class of SuperToken solidity slot will start after _reserve24 // NOTE: Whenever modifying the storage layout here it is important to update the validateStorageLayout // function in its respective mock contract to ensure that it doesn't break anything or lead to unexpected // behaviors/layout when upgrading - uint256 internal _reserve23; - uint256 private _reserve24; + uint256 internal _reserve24; uint256 private _reserve25; uint256 private _reserve26; uint256 private _reserve27; @@ -102,6 +106,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 @@ -195,6 +202,42 @@ contract SuperToken is } } + function enableYieldBackend(IYieldBackend newYieldBackend) external onlyAdmin { + require(address(_yieldBackend) == address(0), "yield backend already set"); + _yieldBackend = newYieldBackend; + 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)); + delegateCallChecked(address(_yieldBackend), abi.encodeCall(IYieldBackend.deposit, (depositAmount))); + 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); + + // 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); + } + + function getYieldBackend() external view returns (address) { + return address(_yieldBackend); + } + + function withdrawSurplusFromYieldBackend() external onlyAdmin { + require(address(_yieldBackend) != address(0), "yield backend not set"); + delegateCallChecked(address(_yieldBackend), abi.encodeCall(IYieldBackend.withdrawSurplus, (_totalSupply))); + } /************************************************************************** * ERC20 Token Info @@ -359,7 +402,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); } @@ -728,8 +770,14 @@ 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)) { + delegateCallChecked(address(_yieldBackend), abi.encodeCall(IYieldBackend.deposit, (amount))); + } + + _mint(msg.sender, account, amount, userData.length != 0 /* invokeHook */, + userData.length != 0 /* requireReceptionAck */, userData, new bytes(0)); + } } function selfBurn( @@ -741,6 +789,12 @@ contract SuperToken is onlySelf { _burn(msg.sender, account, amount, userData.length != 0 /* invokeHook */, userData, new bytes(0)); + + if (address(_yieldBackend) != address(0)) { + _skipSelfMint = true; + delegateCallChecked(address(_yieldBackend), abi.encodeCall(IYieldBackend.withdraw, (amount))); + _skipSelfMint = false; + } } function selfApproveFor( @@ -839,6 +893,10 @@ contract SuperToken is uint256 actualUpgradedAmount = amountAfter - amountBefore; if (underlyingAmount != actualUpgradedAmount) revert SUPER_TOKEN_INFLATIONARY_DEFLATIONARY_NOT_SUPPORTED(); + if (address(_yieldBackend) != address(0)) { + delegateCallChecked(address(_yieldBackend), abi.encodeCall(IYieldBackend.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 +919,10 @@ 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)) { + delegateCallChecked(address(_yieldBackend), abi.encodeCall(IYieldBackend.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 350cd10e1c..949d96e7e1 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/', '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 46d41394b0..72ee7bf950 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, 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 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/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 new file mode 100644 index 0000000000..148cee9ee6 --- /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 pure 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 pure override returns (address) { + return AAVE_POOL; + } +} + 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..2c6e7b5b9f --- /dev/null +++ b/packages/ethereum-contracts/test/foundry/superfluid/yieldbackend/AaveYieldBackendIntegration.t.sol @@ -0,0 +1,471 @@ +// 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 { ISuperToken } from "../../../../contracts/interfaces/superfluid/ISuperToken.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 { + 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(); + } + + /// @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); + vm.expectEmit(true, false, false, true); + emit ISuperToken.YieldBackendDisabled(address(aaveBackend)); + 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); + } + + /*////////////////////////////////////////////////////////////////////////// + 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); + } +} + 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..6eae788a30 --- /dev/null +++ b/packages/ethereum-contracts/test/foundry/superfluid/yieldbackend/YieldBackendUnitTestBase.sol @@ -0,0 +1,267 @@ +// 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 { + (bool success, ) = address(backend).delegatecall( + abi.encodeWithSelector(IYieldBackend.withdraw.selector, amount) + ); + 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(); + + _withdraw(amount); + + uint256 balanceAfter = _getAssetBalance(); + + 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); + } +} +