diff --git a/.github/workflows/forge-old.yml b/.github/workflows/forge-old.yml index e979283..8e3dfae 100644 --- a/.github/workflows/forge-old.yml +++ b/.github/workflows/forge-old.yml @@ -48,6 +48,8 @@ jobs: [ -s compare/old.tsv ] || { echo "old manifest is empty"; exit 1; } - name: Run Forge tests run: forge test + env: + ALCHEMY_KEY: ${{ secrets.ALCHEMY_KEY }} - name: Install Foundry uses: foundry-rs/foundry-toolchain@8789b3e21e6c11b2697f5eb56eddae542f746c10 # v1 - name: Build with Foundry diff --git a/.github/workflows/forge.yml b/.github/workflows/forge.yml index b037041..2d10dff 100644 --- a/.github/workflows/forge.yml +++ b/.github/workflows/forge.yml @@ -59,3 +59,5 @@ jobs: uses: foundry-rs/foundry-toolchain@8789b3e21e6c11b2697f5eb56eddae542f746c10 # v1 - name: Run Forge tests run: forge test --deny warnings + env: + ALCHEMY_KEY: ${{ secrets.ALCHEMY_KEY }} diff --git a/README.md b/README.md index db3847d..d5663b4 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,12 @@ Each bundle exposes a small set of high-level entry points that chain several pr - `blueBundlesV1Withdraw` — withdraw supplied loan assets (optionally the full position by shares) to a receiver. - `blueBundlesV1MigrateBorrowPosition` — move a full borrow position (collateral and debt) from one market to another. +## Aave migration bundles + +[AaveMigrationBundlesV1](src/aave-migration/AaveMigrationBundlesV1.sol) contains: + +- `aaveMigrationBundlesV1WithdrawAndDepositInVaultV2` — pull an aToken, withdraw its underlying from Aave V3, then deposit the underlying into a Morpho Vault V2. + ## Vault force withdraw bundles [VaultForceWithdrawBundlesV1](src/vault-force-withdraw/VaultForceWithdrawBundlesV1.sol) contains: diff --git a/foundry.toml b/foundry.toml index 9401916..ca6e5da 100644 --- a/foundry.toml +++ b/foundry.toml @@ -12,6 +12,9 @@ bytecode_hash = "none" [lint] exclude_lints = ["block-timestamp"] +[rpc_endpoints] +1 = "https://eth-mainnet.g.alchemy.com/v2/${ALCHEMY_KEY}" + [[profile.default.additional_compiler_profiles]] name = "paris-999999" evm_version = "paris" diff --git a/src/aave-migration/AaveMigrationBundlesV1.sol b/src/aave-migration/AaveMigrationBundlesV1.sol new file mode 100644 index 0000000..48fe0f2 --- /dev/null +++ b/src/aave-migration/AaveMigrationBundlesV1.sol @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (c) 2026 Morpho Association +pragma solidity 0.8.34; + +import {IAaveMigrationBundlesV1} from "./interfaces/IAaveMigrationBundlesV1.sol"; +import {IAaveV3} from "./interfaces/IAaveV3.sol"; +import {IAToken} from "./interfaces/IAToken.sol"; +import {IVaultV2} from "../../lib/vault-v2/src/interfaces/IVaultV2.sol"; +import {TokenLib, TokenPermit} from "../libraries/TokenLib.sol"; +import {UtilsLib} from "../../lib/midnight/src/libraries/UtilsLib.sol"; + +/// @dev Inherits the token safety requirements of Aave V3 and Vault V2. +/// @dev Unusable with tokens that revert on such a sequence: approve(..., 0); approve(..., type(uint256).max). +/// @dev No-ops are allowed. +/// @dev Zero checks are not systematically performed. +contract AaveMigrationBundlesV1 is IAaveMigrationBundlesV1 { + using UtilsLib for uint256; + + /// EXTERNAL /// + /// @dev Pulls aTokenAmount of aToken from msg.sender (optionally via ERC-2612 or Permit2), withdraws the whole pulled balance from aaveV3Pool into this contract, then deposits the underlying into vaultV2 for onBehalf. + /// @dev maxSharePriceE27 upper-bounds the realized deposit share price (deposited assets per share, scaled by 1e27). + function aaveMigrationBundlesV1WithdrawAndDepositInVaultV2( + address aaveV3Pool, + address aToken, + uint256 aTokenAmount, + address vaultV2, + uint256 maxSharePriceE27, + address onBehalf, + TokenPermit memory aTokenPermit, + uint256 deadline + ) external { + require(block.timestamp <= deadline, DeadlinePassed()); + address asset = IVaultV2(vaultV2).asset(); + require(asset == IAToken(aToken).UNDERLYING_ASSET_ADDRESS(), InconsistentTokens()); + + TokenLib.pullToken(aToken, msg.sender, aTokenAmount, aTokenPermit); + uint256 withdrawn = IAaveV3(aaveV3Pool).withdraw(asset, type(uint256).max, address(this)); + + TokenLib.forceApproveMax(asset, vaultV2); + uint256 shares = IVaultV2(vaultV2).deposit(withdrawn, onBehalf); + require(withdrawn.mulDivUp(1e27, shares) <= maxSharePriceE27, SlippageExceeded()); + } +} diff --git a/src/aave-migration/interfaces/IAToken.sol b/src/aave-migration/interfaces/IAToken.sol new file mode 100644 index 0000000..fa1463d --- /dev/null +++ b/src/aave-migration/interfaces/IAToken.sol @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (c) 2026 Morpho Association +pragma solidity >=0.8.0; + +interface IAToken { + function UNDERLYING_ASSET_ADDRESS() external view returns (address); +} diff --git a/src/aave-migration/interfaces/IAaveMigrationBundlesV1.sol b/src/aave-migration/interfaces/IAaveMigrationBundlesV1.sol new file mode 100644 index 0000000..d1978de --- /dev/null +++ b/src/aave-migration/interfaces/IAaveMigrationBundlesV1.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (c) 2026 Morpho Association +pragma solidity >=0.8.0; + +import {TokenPermit} from "../../libraries/TokenLib.sol"; + +interface IAaveMigrationBundlesV1 { + /// ERRORS /// + error InconsistentTokens(); + error SlippageExceeded(); + error DeadlinePassed(); + + /// FUNCTIONS /// + function aaveMigrationBundlesV1WithdrawAndDepositInVaultV2( + address aaveV3Pool, + address aToken, + uint256 aTokenAmount, + address vaultV2, + uint256 maxSharePriceE27, + address onBehalf, + TokenPermit memory aTokenPermit, + uint256 deadline + ) external; +} diff --git a/src/aave-migration/interfaces/IAaveV3.sol b/src/aave-migration/interfaces/IAaveV3.sol new file mode 100644 index 0000000..5c08848 --- /dev/null +++ b/src/aave-migration/interfaces/IAaveV3.sol @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (c) 2026 Morpho Association +pragma solidity >=0.8.0; + +interface IAaveV3 { + function withdraw(address asset, uint256 amount, address to) external returns (uint256); +} diff --git a/test/AaveMigrationBundlesForkTest.sol b/test/AaveMigrationBundlesForkTest.sol new file mode 100644 index 0000000..7c4e0c3 --- /dev/null +++ b/test/AaveMigrationBundlesForkTest.sol @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (c) 2026 Morpho Association +pragma solidity ^0.8.0; + +import {Test} from "../lib/forge-std/src/Test.sol"; +import {IVaultV2Factory} from "../lib/vault-v2/src/interfaces/IVaultV2Factory.sol"; +import {IVaultV2} from "../lib/vault-v2/src/interfaces/IVaultV2.sol"; +import {AaveMigrationBundlesV1} from "../src/aave-migration/AaveMigrationBundlesV1.sol"; +import {IAaveMigrationBundlesV1} from "../src/aave-migration/interfaces/IAaveMigrationBundlesV1.sol"; +import {TokenPermit} from "../src/libraries/TokenLib.sol"; + +interface IERC20 { + function balanceOf(address account) external view returns (uint256); + function approve(address spender, uint256 amount) external returns (bool); +} + +interface IAavePool { + function supply(address asset, uint256 amount, address onBehalfOf, uint16 referralCode) external; +} + +contract AaveMigrationBundlesForkTest is Test { + address internal constant AAVE_V3_POOL = 0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2; + address internal constant A_USDC = 0x98C23E9d8f34FEFb1B7BD6a91B7FF122F4e16F5c; + address internal constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; + uint256 internal constant FORK_BLOCK = 25_400_000; + + AaveMigrationBundlesV1 internal bundles; + IVaultV2Factory internal vaultFactory; + IVaultV2 internal vault; + + address internal owner; + address internal user; + + function setUp() public { + // Create a fork of Ethereum at the given block, requiring to use Alchemy RPC. + vm.createSelectFork(vm.toString(uint256(1)), FORK_BLOCK); + + owner = makeAddr("owner"); + user = makeAddr("user"); + + vaultFactory = IVaultV2Factory(deployCode("VaultV2Factory.sol:VaultV2Factory")); + vault = IVaultV2(vaultFactory.createVaultV2(owner, USDC, bytes32(0))); + + bundles = new AaveMigrationBundlesV1(); + } + + /// HELPERS /// + + function _noPermit() internal pure returns (TokenPermit memory) {} + + function testWithdrawAndDepositInVaultV2(uint256 usdcAmount, uint256 aTokenAmount) public { + usdcAmount = bound(usdcAmount, 1e6, 1_000_000e6); + deal(USDC, user, usdcAmount); + vm.startPrank(user); + IERC20(USDC).approve(AAVE_V3_POOL, usdcAmount); + IAavePool(AAVE_V3_POOL).supply(USDC, usdcAmount, user, 0); + vm.stopPrank(); + + uint256 aTokenBalance = IERC20(A_USDC).balanceOf(user); + // Kept above dust so Aave's rebasing rounding (a few wei) stays negligible relative to the amount. + aTokenAmount = bound(aTokenAmount, 1e5, aTokenBalance); + + // The underlying withdrawn from Aave matches aTokenAmount up to rebasing rounding. + uint256 expectedShares = vault.previewDeposit(aTokenAmount); + + vm.startPrank(user); + IERC20(A_USDC).approve(address(bundles), aTokenAmount); + bundles.aaveMigrationBundlesV1WithdrawAndDepositInVaultV2( + AAVE_V3_POOL, A_USDC, aTokenAmount, address(vault), type(uint256).max, user, _noPermit(), block.timestamp + ); + vm.stopPrank(); + + assertApproxEqAbs(IERC20(A_USDC).balanceOf(user), aTokenBalance - aTokenAmount, 2, "user aToken balance"); + assertApproxEqRel(vault.balanceOf(user), expectedShares, 0.0001e18, "user vault shares"); + assertApproxEqRel(IERC20(USDC).balanceOf(address(vault)), aTokenAmount, 0.0001e18, "vault assets"); + assertEq(IERC20(USDC).balanceOf(address(bundles)), 0, "bundler asset residual"); + assertEq(IERC20(A_USDC).balanceOf(address(bundles)), 0, "bundler aToken residual"); + } +} diff --git a/test/AaveMigrationBundlesTest.sol b/test/AaveMigrationBundlesTest.sol new file mode 100644 index 0000000..6ec1612 --- /dev/null +++ b/test/AaveMigrationBundlesTest.sol @@ -0,0 +1,146 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (c) 2026 Morpho Association +pragma solidity ^0.8.0; + +import {Test} from "../lib/forge-std/src/Test.sol"; +import {ERC20} from "../lib/midnight/test/erc20s/ERC20.sol"; +import {IVaultV2Factory} from "../lib/vault-v2/src/interfaces/IVaultV2Factory.sol"; +import {IVaultV2} from "../lib/vault-v2/src/interfaces/IVaultV2.sol"; +import {AaveMigrationBundlesV1} from "../src/aave-migration/AaveMigrationBundlesV1.sol"; +import {IAaveMigrationBundlesV1} from "../src/aave-migration/interfaces/IAaveMigrationBundlesV1.sol"; +import {TokenPermit} from "../src/libraries/TokenLib.sol"; + +contract AaveMigrationBundlesTest is Test { + address internal pool; + TokenMock internal asset; + ATokenMock internal aToken; + + AaveMigrationBundlesV1 internal bundles; + IVaultV2Factory internal vaultFactory; + IVaultV2 internal vault; + + address internal owner; + address internal user; + + function setUp() public { + owner = makeAddr("owner"); + user = makeAddr("user"); + + asset = new TokenMock("asset", "asset"); + aToken = new ATokenMock("aToken", "aToken", address(asset)); + pool = address(new AaveV3PoolMock()); + AaveV3PoolMock(pool).setAToken(address(asset), aToken); + + vaultFactory = IVaultV2Factory(deployCode("VaultV2Factory.sol:VaultV2Factory")); + vault = IVaultV2(vaultFactory.createVaultV2(owner, address(asset), bytes32(0))); + + bundles = new AaveMigrationBundlesV1(); + } + + /// HELPERS /// + + function _noPermit() internal pure returns (TokenPermit memory) {} + + function testWithdrawAndDepositInVaultV2(uint256 aTokenAmount) public { + aTokenAmount = bound(aTokenAmount, 1, 1e30); + aToken.mint(user, aTokenAmount); + asset.mint(pool, aTokenAmount); + uint256 expectedShares = vault.previewDeposit(aTokenAmount); + + vm.startPrank(user); + aToken.approve(address(bundles), aTokenAmount); + bundles.aaveMigrationBundlesV1WithdrawAndDepositInVaultV2( + pool, address(aToken), aTokenAmount, address(vault), type(uint256).max, user, _noPermit(), block.timestamp + ); + vm.stopPrank(); + + assertEq(aToken.balanceOf(user), 0, "user aToken balance"); + assertEq(vault.balanceOf(user), expectedShares, "user vault shares"); + assertEq(asset.balanceOf(address(vault)), aTokenAmount, "vault assets"); + assertEq(asset.balanceOf(address(bundles)), 0, "bundler asset residual"); + assertEq(aToken.balanceOf(address(bundles)), 0, "bundler aToken residual"); + } + + function testInconsistentTokens() public { + TokenMock otherAsset = new TokenMock("other", "other"); + ATokenMock otherAToken = new ATokenMock("otherA", "otherA", address(otherAsset)); + + vm.prank(user); + vm.expectRevert(IAaveMigrationBundlesV1.InconsistentTokens.selector); + bundles.aaveMigrationBundlesV1WithdrawAndDepositInVaultV2( + pool, address(otherAToken), 1, address(vault), type(uint256).max, user, _noPermit(), block.timestamp + ); + } + + /// @dev A maxSharePriceE27 below the realized deposit share price reverts. + function testWithdrawAndDepositSlippageExceeded(uint256 amount) public { + amount = bound(amount, 1, 1e30); + aToken.mint(user, amount); + asset.mint(pool, amount); + + vm.startPrank(user); + aToken.approve(address(bundles), amount); + vm.expectRevert(IAaveMigrationBundlesV1.SlippageExceeded.selector); + bundles.aaveMigrationBundlesV1WithdrawAndDepositInVaultV2( + pool, address(aToken), amount, address(vault), 1, user, _noPermit(), block.timestamp + ); + vm.stopPrank(); + } + + function testDeadlinePassed() public { + uint256 past = block.timestamp - 1; + + vm.prank(user); + vm.expectRevert(IAaveMigrationBundlesV1.DeadlinePassed.selector); + bundles.aaveMigrationBundlesV1WithdrawAndDepositInVaultV2( + pool, address(aToken), 1, address(vault), type(uint256).max, user, _noPermit(), past + ); + } +} + +// Minimal mintable/burnable token used for both the underlying and the aToken. +contract TokenMock is ERC20 { + constructor(string memory name_, string memory symbol_) ERC20(name_, symbol_) {} + + // The vault constructor calls decimals(). + function decimals() external pure returns (uint8) { + return 18; + } + + function mint(address to, uint256 amount) external { + balanceOf[to] += amount; + totalSupply += amount; + } + + function burn(address from, uint256 amount) external { + balanceOf[from] -= amount; + totalSupply -= amount; + } +} + +// Aave V3 aToken: tracks its underlying so the bundler can cross-check it against the vault's asset. +contract ATokenMock is TokenMock { + address public immutable UNDERLYING_ASSET_ADDRESS; + + constructor(string memory name_, string memory symbol_, address underlying) TokenMock(name_, symbol_) { + UNDERLYING_ASSET_ADDRESS = underlying; + } +} + +// Aave V3 pool that burns the caller's aTokens and sends the underlying in equal proportions. +contract AaveV3PoolMock { + mapping(address => ATokenMock) public aToken; + + function setAToken(address underlying, ATokenMock _aToken) external { + aToken[underlying] = _aToken; + } + + function withdraw(address underlying, uint256 amount, address to) external returns (uint256) { + ATokenMock _aToken = aToken[underlying]; + if (amount == type(uint256).max) amount = _aToken.balanceOf(msg.sender); + _aToken.burn(msg.sender, amount); + bool success = ERC20(underlying).transfer(to, amount); + require(success, "transfer failed"); + return amount; + } +}