Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/forge-old.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/forge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions foundry.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
43 changes: 43 additions & 0 deletions src/aave-migration/AaveMigrationBundlesV1.sol
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(
Comment thread
QGarchery marked this conversation as resolved.
Comment thread
QGarchery marked this conversation as resolved.
Comment thread
QGarchery marked this conversation as resolved.
address aaveV3Pool,
address aToken,
uint256 aTokenAmount,
address vaultV2,
uint256 maxSharePriceE27,
address onBehalf,
TokenPermit memory aTokenPermit,
uint256 deadline
) external {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The 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 bundler3), which would allow to repay and withdraw the collateral to deposit on a vault v2. It doesn't seem to make a lot of sense to add it:

  • it would be most useful if we integrate a swap mechanism, but it's not planned atm
  • it would enable to deposit a collateral into a vault v2, which doesn't seem like a big use case

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));

@QGarchery QGarchery Jul 9, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The 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:

  • token is rebalancing to account for interest
  • bad debt can be realized, but it's under Umbrella system which only burns it own aToken (so no "price" repercussion to users)

@MathisGD MathisGD Jul 10, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you mean "rebasing"?

Comment thread
QGarchery marked this conversation as resolved.

TokenLib.forceApproveMax(asset, vaultV2);
uint256 shares = IVaultV2(vaultV2).deposit(withdrawn, onBehalf);
require(withdrawn.mulDivUp(1e27, shares) <= maxSharePriceE27, SlippageExceeded());
}
}
7 changes: 7 additions & 0 deletions src/aave-migration/interfaces/IAToken.sol
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);
}
24 changes: 24 additions & 0 deletions src/aave-migration/interfaces/IAaveMigrationBundlesV1.sol
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;
}
7 changes: 7 additions & 0 deletions src/aave-migration/interfaces/IAaveV3.sol
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);
}
79 changes: 79 additions & 0 deletions test/AaveMigrationBundlesForkTest.sol
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");
}
}
146 changes: 146 additions & 0 deletions test/AaveMigrationBundlesTest.sol

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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;
}
}