Skip to content
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
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 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(
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 amount,
Comment thread
QGarchery marked this conversation as resolved.
Outdated
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, amount, 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 amount,
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);
}
159 changes: 159 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,159 @@
// 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 {
AaveMigrationBundlesV1 internal bundles;
AaveV3PoolMock internal pool;
TokenMock internal asset;
ATokenMock internal aToken;
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 = 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();
}

/// HELPERS ///

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

vm.startPrank(user);
aToken.approve(address(bundles), amount);
bundles.aaveMigrationBundlesV1WithdrawAndDepositInVaultV2(
address(pool),
address(aToken),
amount,
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(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(
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), 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;
}
}