From 1c0946d078dfb411876d31ba41b6a03a97f43b99 Mon Sep 17 00:00:00 2001 From: Quentin Garchery Date: Mon, 29 Jun 2026 20:16:50 +0200 Subject: [PATCH 01/11] migration bundles --- src/aave-migration/AaveMigrationBundlesV1.sol | 42 +++ src/aave-migration/IAToken.sol | 7 + .../IAaveMigrationBundlesV1.sol | 22 ++ src/aave-migration/IAaveV3.sol | 7 + test/AaveMigrationBundlesTest.sol | 243 ++++++++++++++++++ 5 files changed, 321 insertions(+) create mode 100644 src/aave-migration/AaveMigrationBundlesV1.sol create mode 100644 src/aave-migration/IAToken.sol create mode 100644 src/aave-migration/IAaveMigrationBundlesV1.sol create mode 100644 src/aave-migration/IAaveV3.sol create mode 100644 test/AaveMigrationBundlesTest.sol diff --git a/src/aave-migration/AaveMigrationBundlesV1.sol b/src/aave-migration/AaveMigrationBundlesV1.sol new file mode 100644 index 0000000..5b1f96b --- /dev/null +++ b/src/aave-migration/AaveMigrationBundlesV1.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (c) 2026 Morpho Association +pragma solidity 0.8.34; + +import {IAaveMigrationBundlesV1} from "./IAaveMigrationBundlesV1.sol"; +import {IAaveV3} from "./IAaveV3.sol"; +import {IAToken} from "./IAToken.sol"; +import {IVaultV2} from "../../lib/vault-v2/src/interfaces/IVaultV2.sol"; +import {TokenLib, TokenPermit} from "../libraries/TokenLib.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 { + /// EXTERNAL /// + + /// @dev Migration is permissionless on Vault V2, so no authorization of msg.sender over onBehalf is required. + /// @dev Pulls `amount` 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 The underlying withdrawn from Aave is `vaultV2`'s asset. + function aaveMigrationBundlesV1WithdrawAndDepositInVaultV2( + address aaveV3Pool, + address aToken, + uint256 amount, + address vaultV2, + 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, amount, aTokenPermit); + uint256 withdrawn = IAaveV3(aaveV3Pool).withdraw(asset, type(uint256).max, address(this)); + + TokenLib.forceApproveMax(asset, vaultV2); + IVaultV2(vaultV2).deposit(withdrawn, onBehalf); + } +} diff --git a/src/aave-migration/IAToken.sol b/src/aave-migration/IAToken.sol new file mode 100644 index 0000000..fa1463d --- /dev/null +++ b/src/aave-migration/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/IAaveMigrationBundlesV1.sol b/src/aave-migration/IAaveMigrationBundlesV1.sol new file mode 100644 index 0000000..be51c1f --- /dev/null +++ b/src/aave-migration/IAaveMigrationBundlesV1.sol @@ -0,0 +1,22 @@ +// 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 DeadlinePassed(); + + /// FUNCTIONS /// + function aaveMigrationBundlesV1WithdrawAndDepositInVaultV2( + address aaveV3Pool, + address aToken, + uint256 amount, + address vaultV2, + address onBehalf, + TokenPermit memory aTokenPermit, + uint256 deadline + ) external; +} diff --git a/src/aave-migration/IAaveV3.sol b/src/aave-migration/IAaveV3.sol new file mode 100644 index 0000000..5c08848 --- /dev/null +++ b/src/aave-migration/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/AaveMigrationBundlesTest.sol b/test/AaveMigrationBundlesTest.sol new file mode 100644 index 0000000..cabd378 --- /dev/null +++ b/test/AaveMigrationBundlesTest.sol @@ -0,0 +1,243 @@ +// 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 {ERC20Permit} from "../lib/midnight/test/erc20s/ERC20Permit.sol"; +import {Permit2 as VendorPermit2} from "../lib/midnight/test/vendor/Permit2.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/IAaveMigrationBundlesV1.sol"; +import {TokenPermit, PermitKind} from "../src/libraries/TokenLib.sol"; + +contract AaveMigrationBundlesTest is Test { + address internal constant PERMIT2 = 0x000000000022D473030F116dDEE9F6B43aC78BA3; + + AaveMigrationBundlesV1 internal bundles; + AaveV3PoolMock internal pool; + TokenMock internal asset; + ATokenMock internal aToken; + IVaultV2Factory internal vaultFactory; + IVaultV2 internal vault; + + address internal owner; + address internal user; + address internal receiver; + mapping(address => uint256) internal privateKey; + + function setUp() public { + owner = makeAddr("owner"); + receiver = makeAddr("receiver"); + uint256 userPk; + (user, userPk) = makeAddrAndKey("user"); + privateKey[user] = userPk; + + asset = new TokenMock("asset", "asset"); + aToken = new ATokenMock("aToken", "aToken", address(asset)); + pool = new AaveV3PoolMock(); + pool.setAToken(address(asset), aToken); + + vaultFactory = IVaultV2Factory(deployCode("VaultV2Factory.sol:VaultV2Factory")); + vault = IVaultV2(vaultFactory.createVaultV2(owner, address(asset), bytes32(0))); + + bundles = new AaveMigrationBundlesV1(); + deployCodeTo("Permit2", PERMIT2); + } + + /// HELPERS /// + + function _noPermit() internal pure returns (TokenPermit memory) {} + + /// @dev Mints `amount` of aTokens to `onBehalf` and funds the pool with the matching underlying liquidity. + function _openAavePosition(address onBehalf, uint256 amount) internal { + aToken.mint(onBehalf, amount); + asset.mint(address(pool), amount); + } + + function _permit(address token, address holder, uint256 amount, uint256 nonce, uint256 deadline) + internal + view + returns (TokenPermit memory) + { + bytes32 structHash = keccak256( + abi.encode(ERC20Permit(token).PERMIT_TYPEHASH(), holder, address(bundles), amount, nonce, deadline) + ); + bytes32 digest = keccak256(abi.encodePacked("\x19\x01", ERC20Permit(token).DOMAIN_SEPARATOR(), structHash)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey[holder], digest); + return TokenPermit({kind: PermitKind.ERC2612, data: abi.encode(deadline, v, r, s)}); + } + + function _permit2(address token, address holder, uint256 amount, uint256 nonce, uint256 deadline) + internal + view + returns (TokenPermit memory) + { + bytes32 tokenPermissionsHash = + keccak256(abi.encode(keccak256("TokenPermissions(address token,uint256 amount)"), token, amount)); + bytes32 permitHash = keccak256( + abi.encode( + keccak256( + "PermitTransferFrom(TokenPermissions permitted,address spender,uint256 nonce,uint256 deadline)TokenPermissions(address token,uint256 amount)" + ), + tokenPermissionsHash, + address(bundles), + nonce, + deadline + ) + ); + bytes32 digest = keccak256(abi.encodePacked("\x19\x01", VendorPermit2(PERMIT2).DOMAIN_SEPARATOR(), permitHash)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey[holder], digest); + return TokenPermit({kind: PermitKind.Permit2, data: abi.encode(nonce, deadline, abi.encodePacked(r, s, v))}); + } + + /// @dev Asserts the full pulled position landed in the vault for `onBehalf` and that nothing is left behind. + function _assertMigrated(address payer, address onBehalf, uint256 expectedShares, uint256 amount) internal view { + assertEq(aToken.balanceOf(payer), 0, "payer aToken balance"); + assertEq(vault.balanceOf(onBehalf), expectedShares, "onBehalf vault shares"); + assertEq(asset.balanceOf(address(vault)), amount, "vault assets"); + assertEq(asset.balanceOf(address(bundles)), 0, "bundler asset residual"); + assertEq(aToken.balanceOf(address(bundles)), 0, "bundler aToken residual"); + } + + /// WITHDRAW AND DEPOSIT IN VAULT V2 /// + + function testWithdrawAndDepositInVaultV2(uint256 amount) public { + amount = bound(amount, 1, 1e30); + _openAavePosition(user, amount); + uint256 expectedShares = vault.previewDeposit(amount); + + vm.startPrank(user); + aToken.approve(address(bundles), amount); + bundles.aaveMigrationBundlesV1WithdrawAndDepositInVaultV2( + address(pool), address(aToken), amount, address(vault), user, _noPermit(), block.timestamp + ); + vm.stopPrank(); + + _assertMigrated(user, user, expectedShares, amount); + } + + /// @dev Vault V2 deposit is permissionless: a third-party payer can migrate their Aave position into onBehalf's + /// vault account. The aTokens are pulled from the caller, the vault shares accrue to onBehalf. + function testWithdrawAndDepositIsPermissionless(uint256 amount) public { + amount = bound(amount, 1, 1e30); + address payer = makeAddr("payer"); + _openAavePosition(payer, amount); + uint256 expectedShares = vault.previewDeposit(amount); + + vm.startPrank(payer); + aToken.approve(address(bundles), amount); + bundles.aaveMigrationBundlesV1WithdrawAndDepositInVaultV2( + address(pool), address(aToken), amount, address(vault), user, _noPermit(), block.timestamp + ); + vm.stopPrank(); + + _assertMigrated(payer, user, expectedShares, amount); + } + + function testWithdrawAndDepositInVaultV2Permit() public { + uint256 amount = 100e18; + _openAavePosition(user, amount); + uint256 expectedShares = vault.previewDeposit(amount); + + TokenPermit memory permit = _permit(address(aToken), user, amount, 0, vm.getBlockTimestamp() + 1); + vm.prank(user); + bundles.aaveMigrationBundlesV1WithdrawAndDepositInVaultV2( + address(pool), address(aToken), amount, address(vault), user, permit, block.timestamp + ); + + assertEq(aToken.allowance(user, address(bundles)), 0, "permit allowance consumed"); + _assertMigrated(user, user, expectedShares, amount); + } + + function testWithdrawAndDepositInVaultV2Permit2() public { + uint256 amount = 100e18; + _openAavePosition(user, amount); + uint256 expectedShares = vault.previewDeposit(amount); + + vm.prank(user); + aToken.approve(PERMIT2, amount); + + TokenPermit memory permit = _permit2(address(aToken), user, amount, 0, vm.getBlockTimestamp() + 1); + vm.prank(user); + bundles.aaveMigrationBundlesV1WithdrawAndDepositInVaultV2( + address(pool), address(aToken), amount, address(vault), user, permit, block.timestamp + ); + + assertEq(aToken.allowance(user, PERMIT2), 0, "permit2 allowance consumed"); + _assertMigrated(user, user, expectedShares, amount); + } + + /// @dev The vault's asset must match the aToken's underlying, otherwise the wrong token would be withdrawn. + 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( + address(pool), address(otherAToken), 1, address(vault), user, _noPermit(), block.timestamp + ); + } + + function testDeadlinePassed() public { + uint256 past = block.timestamp - 1; + + vm.prank(user); + vm.expectRevert(IAaveMigrationBundlesV1.DeadlinePassed.selector); + bundles.aaveMigrationBundlesV1WithdrawAndDepositInVaultV2( + address(pool), address(aToken), 1, address(vault), user, _noPermit(), past + ); + } +} + +/// @dev Minimal mintable/burnable ERC-2612 token used for both the underlying and the aToken. +contract TokenMock is ERC20Permit { + constructor(string memory name_, string memory symbol_) ERC20Permit(name_, symbol_) {} + + 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; + } +} + +/// @dev 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; + + constructor(string memory name_, string memory symbol_, address underlying) TokenMock(name_, symbol_) { + UNDERLYING = underlying; + } + + // forge-lint: disable-next-line(mixed-case-function) + function UNDERLYING_ASSET_ADDRESS() external view returns (address) { + return UNDERLYING; + } +} + +/// @dev Aave V3 pool: burns the caller's aTokens 1:1 and pays out the underlying it holds. +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); + ERC20(underlying).transfer(to, amount); + return amount; + } +} From 955fcc30e403dbff81735b01e8b3c89783b39a77 Mon Sep 17 00:00:00 2001 From: Quentin Garchery Date: Mon, 29 Jun 2026 20:23:06 +0200 Subject: [PATCH 02/11] cleaning --- src/aave-migration/AaveMigrationBundlesV1.sol | 9 +- test/AaveMigrationBundlesTest.sol | 108 +----------------- 2 files changed, 8 insertions(+), 109 deletions(-) diff --git a/src/aave-migration/AaveMigrationBundlesV1.sol b/src/aave-migration/AaveMigrationBundlesV1.sol index 5b1f96b..7923c65 100644 --- a/src/aave-migration/AaveMigrationBundlesV1.sol +++ b/src/aave-migration/AaveMigrationBundlesV1.sol @@ -14,11 +14,7 @@ import {TokenLib, TokenPermit} from "../libraries/TokenLib.sol"; /// @dev Zero checks are not systematically performed. contract AaveMigrationBundlesV1 is IAaveMigrationBundlesV1 { /// EXTERNAL /// - - /// @dev Migration is permissionless on Vault V2, so no authorization of msg.sender over onBehalf is required. - /// @dev Pulls `amount` 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 The underlying withdrawn from Aave is `vaultV2`'s asset. + /// @dev Pulls amount 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. function aaveMigrationBundlesV1WithdrawAndDepositInVaultV2( address aaveV3Pool, address aToken, @@ -28,9 +24,8 @@ contract AaveMigrationBundlesV1 is IAaveMigrationBundlesV1 { TokenPermit memory aTokenPermit, uint256 deadline ) external { - require(block.timestamp <= deadline, DeadlinePassed()); - address asset = IVaultV2(vaultV2).asset(); + require(block.timestamp <= deadline, DeadlinePassed()); require(asset == IAToken(aToken).UNDERLYING_ASSET_ADDRESS(), InconsistentTokens()); TokenLib.pullToken(aToken, msg.sender, amount, aTokenPermit); diff --git a/test/AaveMigrationBundlesTest.sol b/test/AaveMigrationBundlesTest.sol index cabd378..9fd7f5b 100644 --- a/test/AaveMigrationBundlesTest.sol +++ b/test/AaveMigrationBundlesTest.sol @@ -4,17 +4,13 @@ pragma solidity ^0.8.0; import {Test} from "../lib/forge-std/src/Test.sol"; import {ERC20} from "../lib/midnight/test/erc20s/ERC20.sol"; -import {ERC20Permit} from "../lib/midnight/test/erc20s/ERC20Permit.sol"; -import {Permit2 as VendorPermit2} from "../lib/midnight/test/vendor/Permit2.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/IAaveMigrationBundlesV1.sol"; -import {TokenPermit, PermitKind} from "../src/libraries/TokenLib.sol"; +import {TokenPermit} from "../src/libraries/TokenLib.sol"; contract AaveMigrationBundlesTest is Test { - address internal constant PERMIT2 = 0x000000000022D473030F116dDEE9F6B43aC78BA3; - AaveMigrationBundlesV1 internal bundles; AaveV3PoolMock internal pool; TokenMock internal asset; @@ -24,15 +20,10 @@ contract AaveMigrationBundlesTest is Test { address internal owner; address internal user; - address internal receiver; - mapping(address => uint256) internal privateKey; function setUp() public { owner = makeAddr("owner"); - receiver = makeAddr("receiver"); - uint256 userPk; - (user, userPk) = makeAddrAndKey("user"); - privateKey[user] = userPk; + user = makeAddr("user"); asset = new TokenMock("asset", "asset"); aToken = new ATokenMock("aToken", "aToken", address(asset)); @@ -43,7 +34,6 @@ contract AaveMigrationBundlesTest is Test { vault = IVaultV2(vaultFactory.createVaultV2(owner, address(asset), bytes32(0))); bundles = new AaveMigrationBundlesV1(); - deployCodeTo("Permit2", PERMIT2); } /// HELPERS /// @@ -56,42 +46,6 @@ contract AaveMigrationBundlesTest is Test { asset.mint(address(pool), amount); } - function _permit(address token, address holder, uint256 amount, uint256 nonce, uint256 deadline) - internal - view - returns (TokenPermit memory) - { - bytes32 structHash = keccak256( - abi.encode(ERC20Permit(token).PERMIT_TYPEHASH(), holder, address(bundles), amount, nonce, deadline) - ); - bytes32 digest = keccak256(abi.encodePacked("\x19\x01", ERC20Permit(token).DOMAIN_SEPARATOR(), structHash)); - (uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey[holder], digest); - return TokenPermit({kind: PermitKind.ERC2612, data: abi.encode(deadline, v, r, s)}); - } - - function _permit2(address token, address holder, uint256 amount, uint256 nonce, uint256 deadline) - internal - view - returns (TokenPermit memory) - { - bytes32 tokenPermissionsHash = - keccak256(abi.encode(keccak256("TokenPermissions(address token,uint256 amount)"), token, amount)); - bytes32 permitHash = keccak256( - abi.encode( - keccak256( - "PermitTransferFrom(TokenPermissions permitted,address spender,uint256 nonce,uint256 deadline)TokenPermissions(address token,uint256 amount)" - ), - tokenPermissionsHash, - address(bundles), - nonce, - deadline - ) - ); - bytes32 digest = keccak256(abi.encodePacked("\x19\x01", VendorPermit2(PERMIT2).DOMAIN_SEPARATOR(), permitHash)); - (uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey[holder], digest); - return TokenPermit({kind: PermitKind.Permit2, data: abi.encode(nonce, deadline, abi.encodePacked(r, s, v))}); - } - /// @dev Asserts the full pulled position landed in the vault for `onBehalf` and that nothing is left behind. function _assertMigrated(address payer, address onBehalf, uint256 expectedShares, uint256 amount) internal view { assertEq(aToken.balanceOf(payer), 0, "payer aToken balance"); @@ -118,57 +72,6 @@ contract AaveMigrationBundlesTest is Test { _assertMigrated(user, user, expectedShares, amount); } - /// @dev Vault V2 deposit is permissionless: a third-party payer can migrate their Aave position into onBehalf's - /// vault account. The aTokens are pulled from the caller, the vault shares accrue to onBehalf. - function testWithdrawAndDepositIsPermissionless(uint256 amount) public { - amount = bound(amount, 1, 1e30); - address payer = makeAddr("payer"); - _openAavePosition(payer, amount); - uint256 expectedShares = vault.previewDeposit(amount); - - vm.startPrank(payer); - aToken.approve(address(bundles), amount); - bundles.aaveMigrationBundlesV1WithdrawAndDepositInVaultV2( - address(pool), address(aToken), amount, address(vault), user, _noPermit(), block.timestamp - ); - vm.stopPrank(); - - _assertMigrated(payer, user, expectedShares, amount); - } - - function testWithdrawAndDepositInVaultV2Permit() public { - uint256 amount = 100e18; - _openAavePosition(user, amount); - uint256 expectedShares = vault.previewDeposit(amount); - - TokenPermit memory permit = _permit(address(aToken), user, amount, 0, vm.getBlockTimestamp() + 1); - vm.prank(user); - bundles.aaveMigrationBundlesV1WithdrawAndDepositInVaultV2( - address(pool), address(aToken), amount, address(vault), user, permit, block.timestamp - ); - - assertEq(aToken.allowance(user, address(bundles)), 0, "permit allowance consumed"); - _assertMigrated(user, user, expectedShares, amount); - } - - function testWithdrawAndDepositInVaultV2Permit2() public { - uint256 amount = 100e18; - _openAavePosition(user, amount); - uint256 expectedShares = vault.previewDeposit(amount); - - vm.prank(user); - aToken.approve(PERMIT2, amount); - - TokenPermit memory permit = _permit2(address(aToken), user, amount, 0, vm.getBlockTimestamp() + 1); - vm.prank(user); - bundles.aaveMigrationBundlesV1WithdrawAndDepositInVaultV2( - address(pool), address(aToken), amount, address(vault), user, permit, block.timestamp - ); - - assertEq(aToken.allowance(user, PERMIT2), 0, "permit2 allowance consumed"); - _assertMigrated(user, user, expectedShares, amount); - } - /// @dev The vault's asset must match the aToken's underlying, otherwise the wrong token would be withdrawn. function testInconsistentTokens() public { TokenMock otherAsset = new TokenMock("other", "other"); @@ -192,9 +95,9 @@ contract AaveMigrationBundlesTest is Test { } } -/// @dev Minimal mintable/burnable ERC-2612 token used for both the underlying and the aToken. -contract TokenMock is ERC20Permit { - constructor(string memory name_, string memory symbol_) ERC20Permit(name_, symbol_) {} +/// @dev 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_) {} function decimals() external pure returns (uint8) { return 18; @@ -237,6 +140,7 @@ contract AaveV3PoolMock { ATokenMock _aToken = aToken[underlying]; if (amount == type(uint256).max) amount = _aToken.balanceOf(msg.sender); _aToken.burn(msg.sender, amount); + // forge-lint: disable-next-line(erc20-unchecked-transfer) ERC20(underlying).transfer(to, amount); return amount; } From bc006b200512258b42d7fd4fccf1536777296dc9 Mon Sep 17 00:00:00 2001 From: Quentin Garchery Date: Tue, 30 Jun 2026 10:02:24 +0200 Subject: [PATCH 03/11] small tweaks --- src/aave-migration/AaveMigrationBundlesV1.sol | 2 +- test/AaveMigrationBundlesTest.sol | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/aave-migration/AaveMigrationBundlesV1.sol b/src/aave-migration/AaveMigrationBundlesV1.sol index 7923c65..9e0bff3 100644 --- a/src/aave-migration/AaveMigrationBundlesV1.sol +++ b/src/aave-migration/AaveMigrationBundlesV1.sol @@ -24,8 +24,8 @@ contract AaveMigrationBundlesV1 is IAaveMigrationBundlesV1 { TokenPermit memory aTokenPermit, uint256 deadline ) external { - address asset = IVaultV2(vaultV2).asset(); require(block.timestamp <= deadline, DeadlinePassed()); + address asset = IVaultV2(vaultV2).asset(); require(asset == IAToken(aToken).UNDERLYING_ASSET_ADDRESS(), InconsistentTokens()); TokenLib.pullToken(aToken, msg.sender, amount, aTokenPermit); diff --git a/test/AaveMigrationBundlesTest.sol b/test/AaveMigrationBundlesTest.sol index 9fd7f5b..043fc38 100644 --- a/test/AaveMigrationBundlesTest.sol +++ b/test/AaveMigrationBundlesTest.sol @@ -140,8 +140,8 @@ contract AaveV3PoolMock { ATokenMock _aToken = aToken[underlying]; if (amount == type(uint256).max) amount = _aToken.balanceOf(msg.sender); _aToken.burn(msg.sender, amount); - // forge-lint: disable-next-line(erc20-unchecked-transfer) - ERC20(underlying).transfer(to, amount); + bool success = ERC20(underlying).transfer(to, amount); + require(success, "transfer failed"); return amount; } } From fdd4bc680200cb5a4051db8b4b60edcf9aa42e68 Mon Sep 17 00:00:00 2001 From: Quentin Garchery Date: Tue, 30 Jun 2026 10:14:43 +0200 Subject: [PATCH 04/11] simplify tests --- test/AaveMigrationBundlesTest.sol | 43 ++++++++++--------------------- 1 file changed, 13 insertions(+), 30 deletions(-) diff --git a/test/AaveMigrationBundlesTest.sol b/test/AaveMigrationBundlesTest.sol index 043fc38..f9aa61f 100644 --- a/test/AaveMigrationBundlesTest.sol +++ b/test/AaveMigrationBundlesTest.sol @@ -40,26 +40,10 @@ contract AaveMigrationBundlesTest is Test { function _noPermit() internal pure returns (TokenPermit memory) {} - /// @dev Mints `amount` of aTokens to `onBehalf` and funds the pool with the matching underlying liquidity. - function _openAavePosition(address onBehalf, uint256 amount) internal { - aToken.mint(onBehalf, amount); - asset.mint(address(pool), amount); - } - - /// @dev Asserts the full pulled position landed in the vault for `onBehalf` and that nothing is left behind. - function _assertMigrated(address payer, address onBehalf, uint256 expectedShares, uint256 amount) internal view { - assertEq(aToken.balanceOf(payer), 0, "payer aToken balance"); - assertEq(vault.balanceOf(onBehalf), expectedShares, "onBehalf vault shares"); - assertEq(asset.balanceOf(address(vault)), amount, "vault assets"); - assertEq(asset.balanceOf(address(bundles)), 0, "bundler asset residual"); - assertEq(aToken.balanceOf(address(bundles)), 0, "bundler aToken residual"); - } - - /// WITHDRAW AND DEPOSIT IN VAULT V2 /// - function testWithdrawAndDepositInVaultV2(uint256 amount) public { amount = bound(amount, 1, 1e30); - _openAavePosition(user, amount); + aToken.mint(user, amount); + asset.mint(address(pool), amount); uint256 expectedShares = vault.previewDeposit(amount); vm.startPrank(user); @@ -69,10 +53,13 @@ contract AaveMigrationBundlesTest is Test { ); vm.stopPrank(); - _assertMigrated(user, user, expectedShares, amount); + assertEq(aToken.balanceOf(user), 0, "user aToken balance"); + assertEq(vault.balanceOf(user), expectedShares, "user vault shares"); + assertEq(asset.balanceOf(address(vault)), amount, "vault assets"); + assertEq(asset.balanceOf(address(bundles)), 0, "bundler asset residual"); + assertEq(aToken.balanceOf(address(bundles)), 0, "bundler aToken residual"); } - /// @dev The vault's asset must match the aToken's underlying, otherwise the wrong token would be withdrawn. function testInconsistentTokens() public { TokenMock otherAsset = new TokenMock("other", "other"); ATokenMock otherAToken = new ATokenMock("otherA", "otherA", address(otherAsset)); @@ -95,10 +82,11 @@ contract AaveMigrationBundlesTest is Test { } } -/// @dev Minimal mintable/burnable token used for both the underlying and the aToken. +// 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; } @@ -114,21 +102,16 @@ contract TokenMock is ERC20 { } } -/// @dev Aave V3 aToken: tracks its underlying so the bundler can cross-check it against the vault's asset. +// 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; + address public immutable UNDERLYING_ASSET_ADDRESS; constructor(string memory name_, string memory symbol_, address underlying) TokenMock(name_, symbol_) { - UNDERLYING = underlying; - } - - // forge-lint: disable-next-line(mixed-case-function) - function UNDERLYING_ASSET_ADDRESS() external view returns (address) { - return UNDERLYING; + UNDERLYING_ASSET_ADDRESS = underlying; } } -/// @dev Aave V3 pool: burns the caller's aTokens 1:1 and pays out the underlying it holds. +// Aave V3 pool that burns the caller's aTokens and sends the underlying in equal proportions. contract AaveV3PoolMock { mapping(address => ATokenMock) public aToken; From 323c2e7a043942bb1de5dd0c3cea856fc7827fff Mon Sep 17 00:00:00 2001 From: "prd-carapulse[bot]" <264278285+prd-carapulse[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 14:31:58 +0000 Subject: [PATCH 05/11] refactor: move aave migration interfaces into subfolders --- src/aave-migration/AaveMigrationBundlesV1.sol | 6 +++--- src/aave-migration/{ => interfaces}/IAToken.sol | 0 .../{ => interfaces}/IAaveMigrationBundlesV1.sol | 2 +- src/aave-migration/{ => interfaces}/IAaveV3.sol | 0 test/AaveMigrationBundlesTest.sol | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) rename src/aave-migration/{ => interfaces}/IAToken.sol (100%) rename src/aave-migration/{ => interfaces}/IAaveMigrationBundlesV1.sol (90%) rename src/aave-migration/{ => interfaces}/IAaveV3.sol (100%) diff --git a/src/aave-migration/AaveMigrationBundlesV1.sol b/src/aave-migration/AaveMigrationBundlesV1.sol index 9e0bff3..98c3b39 100644 --- a/src/aave-migration/AaveMigrationBundlesV1.sol +++ b/src/aave-migration/AaveMigrationBundlesV1.sol @@ -2,9 +2,9 @@ // Copyright (c) 2026 Morpho Association pragma solidity 0.8.34; -import {IAaveMigrationBundlesV1} from "./IAaveMigrationBundlesV1.sol"; -import {IAaveV3} from "./IAaveV3.sol"; -import {IAToken} from "./IAToken.sol"; +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"; diff --git a/src/aave-migration/IAToken.sol b/src/aave-migration/interfaces/IAToken.sol similarity index 100% rename from src/aave-migration/IAToken.sol rename to src/aave-migration/interfaces/IAToken.sol diff --git a/src/aave-migration/IAaveMigrationBundlesV1.sol b/src/aave-migration/interfaces/IAaveMigrationBundlesV1.sol similarity index 90% rename from src/aave-migration/IAaveMigrationBundlesV1.sol rename to src/aave-migration/interfaces/IAaveMigrationBundlesV1.sol index be51c1f..70468c7 100644 --- a/src/aave-migration/IAaveMigrationBundlesV1.sol +++ b/src/aave-migration/interfaces/IAaveMigrationBundlesV1.sol @@ -2,7 +2,7 @@ // Copyright (c) 2026 Morpho Association pragma solidity >=0.8.0; -import {TokenPermit} from "../libraries/TokenLib.sol"; +import {TokenPermit} from "../../libraries/TokenLib.sol"; interface IAaveMigrationBundlesV1 { /// ERRORS /// diff --git a/src/aave-migration/IAaveV3.sol b/src/aave-migration/interfaces/IAaveV3.sol similarity index 100% rename from src/aave-migration/IAaveV3.sol rename to src/aave-migration/interfaces/IAaveV3.sol diff --git a/test/AaveMigrationBundlesTest.sol b/test/AaveMigrationBundlesTest.sol index f9aa61f..0fc52e6 100644 --- a/test/AaveMigrationBundlesTest.sol +++ b/test/AaveMigrationBundlesTest.sol @@ -7,7 +7,7 @@ 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/IAaveMigrationBundlesV1.sol"; +import {IAaveMigrationBundlesV1} from "../src/aave-migration/interfaces/IAaveMigrationBundlesV1.sol"; import {TokenPermit} from "../src/libraries/TokenLib.sol"; contract AaveMigrationBundlesTest is Test { From c588aa8f57a06ea503dc261bb287933050d5ff78 Mon Sep 17 00:00:00 2001 From: Quentin Garchery Date: Thu, 9 Jul 2026 17:07:07 +0200 Subject: [PATCH 06/11] add slippage check --- src/aave-migration/AaveMigrationBundlesV1.sol | 8 ++++++- .../interfaces/IAaveMigrationBundlesV1.sol | 2 ++ test/AaveMigrationBundlesTest.sol | 21 ++++++++++++++++--- 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/src/aave-migration/AaveMigrationBundlesV1.sol b/src/aave-migration/AaveMigrationBundlesV1.sol index 98c3b39..0371f9f 100644 --- a/src/aave-migration/AaveMigrationBundlesV1.sol +++ b/src/aave-migration/AaveMigrationBundlesV1.sol @@ -7,19 +7,24 @@ 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 amount 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 amount, address vaultV2, + uint256 maxSharePriceE27, address onBehalf, TokenPermit memory aTokenPermit, uint256 deadline @@ -32,6 +37,7 @@ contract AaveMigrationBundlesV1 is IAaveMigrationBundlesV1 { uint256 withdrawn = IAaveV3(aaveV3Pool).withdraw(asset, type(uint256).max, address(this)); TokenLib.forceApproveMax(asset, vaultV2); - IVaultV2(vaultV2).deposit(withdrawn, onBehalf); + uint256 shares = IVaultV2(vaultV2).deposit(withdrawn, onBehalf); + require(withdrawn.mulDivUp(1e27, shares) <= maxSharePriceE27, SlippageExceeded()); } } diff --git a/src/aave-migration/interfaces/IAaveMigrationBundlesV1.sol b/src/aave-migration/interfaces/IAaveMigrationBundlesV1.sol index 70468c7..559b72e 100644 --- a/src/aave-migration/interfaces/IAaveMigrationBundlesV1.sol +++ b/src/aave-migration/interfaces/IAaveMigrationBundlesV1.sol @@ -7,6 +7,7 @@ import {TokenPermit} from "../../libraries/TokenLib.sol"; interface IAaveMigrationBundlesV1 { /// ERRORS /// error InconsistentTokens(); + error SlippageExceeded(); error DeadlinePassed(); /// FUNCTIONS /// @@ -15,6 +16,7 @@ interface IAaveMigrationBundlesV1 { address aToken, uint256 amount, address vaultV2, + uint256 maxSharePriceE27, address onBehalf, TokenPermit memory aTokenPermit, uint256 deadline diff --git a/test/AaveMigrationBundlesTest.sol b/test/AaveMigrationBundlesTest.sol index 0fc52e6..3363c4d 100644 --- a/test/AaveMigrationBundlesTest.sol +++ b/test/AaveMigrationBundlesTest.sol @@ -49,7 +49,7 @@ contract AaveMigrationBundlesTest is Test { vm.startPrank(user); aToken.approve(address(bundles), amount); bundles.aaveMigrationBundlesV1WithdrawAndDepositInVaultV2( - address(pool), address(aToken), amount, address(vault), user, _noPermit(), block.timestamp + address(pool), address(aToken), amount, address(vault), type(uint256).max, user, _noPermit(), block.timestamp ); vm.stopPrank(); @@ -67,17 +67,32 @@ contract AaveMigrationBundlesTest is Test { vm.prank(user); vm.expectRevert(IAaveMigrationBundlesV1.InconsistentTokens.selector); bundles.aaveMigrationBundlesV1WithdrawAndDepositInVaultV2( - address(pool), address(otherAToken), 1, address(vault), user, _noPermit(), block.timestamp + address(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(address(pool), amount); + + vm.startPrank(user); + aToken.approve(address(bundles), amount); + vm.expectRevert(IAaveMigrationBundlesV1.SlippageExceeded.selector); + bundles.aaveMigrationBundlesV1WithdrawAndDepositInVaultV2( + address(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( - address(pool), address(aToken), 1, address(vault), user, _noPermit(), past + address(pool), address(aToken), 1, address(vault), type(uint256).max, user, _noPermit(), past ); } } From 8f11abe61b1a6e0ef2fd3d85b899bbb8e9b28b9c Mon Sep 17 00:00:00 2001 From: Quentin Garchery Date: Thu, 9 Jul 2026 17:23:20 +0200 Subject: [PATCH 07/11] fmt --- test/AaveMigrationBundlesTest.sol | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/test/AaveMigrationBundlesTest.sol b/test/AaveMigrationBundlesTest.sol index 3363c4d..6e17b79 100644 --- a/test/AaveMigrationBundlesTest.sol +++ b/test/AaveMigrationBundlesTest.sol @@ -49,7 +49,14 @@ contract AaveMigrationBundlesTest is Test { vm.startPrank(user); aToken.approve(address(bundles), amount); bundles.aaveMigrationBundlesV1WithdrawAndDepositInVaultV2( - address(pool), address(aToken), amount, address(vault), type(uint256).max, user, _noPermit(), block.timestamp + address(pool), + address(aToken), + amount, + address(vault), + type(uint256).max, + user, + _noPermit(), + block.timestamp ); vm.stopPrank(); @@ -67,7 +74,14 @@ contract AaveMigrationBundlesTest is Test { vm.prank(user); vm.expectRevert(IAaveMigrationBundlesV1.InconsistentTokens.selector); bundles.aaveMigrationBundlesV1WithdrawAndDepositInVaultV2( - address(pool), address(otherAToken), 1, address(vault), type(uint256).max, user, _noPermit(), block.timestamp + address(pool), + address(otherAToken), + 1, + address(vault), + type(uint256).max, + user, + _noPermit(), + block.timestamp ); } From 67be53da3c8c7dbe954be4c5891f10f5a89833a6 Mon Sep 17 00:00:00 2001 From: Quentin Garchery Date: Fri, 10 Jul 2026 11:32:54 +0200 Subject: [PATCH 08/11] README --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) 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: From 12a0a05f9b79a64dd44528f513b65f3a6c1847a3 Mon Sep 17 00:00:00 2001 From: Quentin Garchery Date: Fri, 10 Jul 2026 14:32:37 +0200 Subject: [PATCH 09/11] aTokenAmount --- src/aave-migration/AaveMigrationBundlesV1.sol | 6 +++--- src/aave-migration/interfaces/IAaveMigrationBundlesV1.sol | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/aave-migration/AaveMigrationBundlesV1.sol b/src/aave-migration/AaveMigrationBundlesV1.sol index 0371f9f..48fe0f2 100644 --- a/src/aave-migration/AaveMigrationBundlesV1.sol +++ b/src/aave-migration/AaveMigrationBundlesV1.sol @@ -17,12 +17,12 @@ contract AaveMigrationBundlesV1 is IAaveMigrationBundlesV1 { using UtilsLib for uint256; /// EXTERNAL /// - /// @dev Pulls amount 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 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 amount, + uint256 aTokenAmount, address vaultV2, uint256 maxSharePriceE27, address onBehalf, @@ -33,7 +33,7 @@ contract AaveMigrationBundlesV1 is IAaveMigrationBundlesV1 { address asset = IVaultV2(vaultV2).asset(); require(asset == IAToken(aToken).UNDERLYING_ASSET_ADDRESS(), InconsistentTokens()); - TokenLib.pullToken(aToken, msg.sender, amount, aTokenPermit); + TokenLib.pullToken(aToken, msg.sender, aTokenAmount, aTokenPermit); uint256 withdrawn = IAaveV3(aaveV3Pool).withdraw(asset, type(uint256).max, address(this)); TokenLib.forceApproveMax(asset, vaultV2); diff --git a/src/aave-migration/interfaces/IAaveMigrationBundlesV1.sol b/src/aave-migration/interfaces/IAaveMigrationBundlesV1.sol index 559b72e..d1978de 100644 --- a/src/aave-migration/interfaces/IAaveMigrationBundlesV1.sol +++ b/src/aave-migration/interfaces/IAaveMigrationBundlesV1.sol @@ -14,7 +14,7 @@ interface IAaveMigrationBundlesV1 { function aaveMigrationBundlesV1WithdrawAndDepositInVaultV2( address aaveV3Pool, address aToken, - uint256 amount, + uint256 aTokenAmount, address vaultV2, uint256 maxSharePriceE27, address onBehalf, From 83c62e70ea91ce62b0b6cf4d83a7ff6cb5e96c5b Mon Sep 17 00:00:00 2001 From: Quentin Garchery Date: Fri, 10 Jul 2026 16:45:44 +0200 Subject: [PATCH 10/11] fork test --- foundry.toml | 3 + test/AaveMigrationBundlesForkTest.sol | 79 +++++++++++++++++++++++++++ test/AaveMigrationBundlesTest.sol | 47 ++++++---------- 3 files changed, 99 insertions(+), 30 deletions(-) create mode 100644 test/AaveMigrationBundlesForkTest.sol 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/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 index 6e17b79..6ec1612 100644 --- a/test/AaveMigrationBundlesTest.sol +++ b/test/AaveMigrationBundlesTest.sol @@ -11,10 +11,11 @@ import {IAaveMigrationBundlesV1} from "../src/aave-migration/interfaces/IAaveMig import {TokenPermit} from "../src/libraries/TokenLib.sol"; contract AaveMigrationBundlesTest is Test { - AaveMigrationBundlesV1 internal bundles; - AaveV3PoolMock internal pool; + address internal pool; TokenMock internal asset; ATokenMock internal aToken; + + AaveMigrationBundlesV1 internal bundles; IVaultV2Factory internal vaultFactory; IVaultV2 internal vault; @@ -27,8 +28,8 @@ contract AaveMigrationBundlesTest is Test { asset = new TokenMock("asset", "asset"); aToken = new ATokenMock("aToken", "aToken", address(asset)); - pool = new AaveV3PoolMock(); - pool.setAToken(address(asset), aToken); + 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))); @@ -40,29 +41,22 @@ contract AaveMigrationBundlesTest is Test { function _noPermit() internal pure returns (TokenPermit memory) {} - function testWithdrawAndDepositInVaultV2(uint256 amount) public { - amount = bound(amount, 1, 1e30); - aToken.mint(user, amount); - asset.mint(address(pool), amount); - uint256 expectedShares = vault.previewDeposit(amount); + 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), amount); + aToken.approve(address(bundles), aTokenAmount); bundles.aaveMigrationBundlesV1WithdrawAndDepositInVaultV2( - address(pool), - address(aToken), - amount, - address(vault), - type(uint256).max, - user, - _noPermit(), - block.timestamp + 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)), amount, "vault assets"); + 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"); } @@ -74,14 +68,7 @@ contract AaveMigrationBundlesTest is Test { vm.prank(user); vm.expectRevert(IAaveMigrationBundlesV1.InconsistentTokens.selector); bundles.aaveMigrationBundlesV1WithdrawAndDepositInVaultV2( - address(pool), - address(otherAToken), - 1, - address(vault), - type(uint256).max, - user, - _noPermit(), - block.timestamp + pool, address(otherAToken), 1, address(vault), type(uint256).max, user, _noPermit(), block.timestamp ); } @@ -89,13 +76,13 @@ contract AaveMigrationBundlesTest is Test { function testWithdrawAndDepositSlippageExceeded(uint256 amount) public { amount = bound(amount, 1, 1e30); aToken.mint(user, amount); - asset.mint(address(pool), amount); + asset.mint(pool, amount); vm.startPrank(user); aToken.approve(address(bundles), amount); vm.expectRevert(IAaveMigrationBundlesV1.SlippageExceeded.selector); bundles.aaveMigrationBundlesV1WithdrawAndDepositInVaultV2( - address(pool), address(aToken), amount, address(vault), 1, user, _noPermit(), block.timestamp + pool, address(aToken), amount, address(vault), 1, user, _noPermit(), block.timestamp ); vm.stopPrank(); } @@ -106,7 +93,7 @@ contract AaveMigrationBundlesTest is Test { vm.prank(user); vm.expectRevert(IAaveMigrationBundlesV1.DeadlinePassed.selector); bundles.aaveMigrationBundlesV1WithdrawAndDepositInVaultV2( - address(pool), address(aToken), 1, address(vault), type(uint256).max, user, _noPermit(), past + pool, address(aToken), 1, address(vault), type(uint256).max, user, _noPermit(), past ); } } From cb8ead0f7684023e8c6dfe41720785226bd8f2f8 Mon Sep 17 00:00:00 2001 From: Quentin Garchery Date: Fri, 10 Jul 2026 17:34:24 +0200 Subject: [PATCH 11/11] add key --- .github/workflows/forge-old.yml | 2 ++ .github/workflows/forge.yml | 2 ++ 2 files changed, 4 insertions(+) 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 }}