-
Notifications
You must be signed in to change notification settings - Fork 1
[MigrationBundles] Aave v3 migration bundles #30
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
1c0946d
955fcc3
bc006b2
fdd4bc6
323c2e7
a381f57
c588aa8
8f11abe
31744b4
67be53d
12a0a05
83c62e7
cb8ead0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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( | ||
|
QGarchery marked this conversation as resolved.
QGarchery marked this conversation as resolved.
|
||
| address aaveV3Pool, | ||
| address aToken, | ||
| uint256 aTokenAmount, | ||
| address vaultV2, | ||
| uint256 maxSharePriceE27, | ||
| address onBehalf, | ||
| TokenPermit memory aTokenPermit, | ||
| uint256 deadline | ||
| ) external { | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Didn't add a prior repay step (which is an action that exists in
|
||
| 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)); | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not doing slippage check for this withdraw, because withdrawing 1 aTokens give out about 1 underlying:
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. you mean "rebasing"?
QGarchery marked this conversation as resolved.
|
||
|
|
||
| TokenLib.forceApproveMax(asset, vaultV2); | ||
| uint256 shares = IVaultV2(vaultV2).deposit(withdrawn, onBehalf); | ||
| require(withdrawn.mulDivUp(1e27, shares) <= maxSharePriceE27, SlippageExceeded()); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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"); | ||
| } | ||
| } |
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. worth a fork integration test? |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.