diff --git a/certora/confs/NoResidue.conf b/certora/confs/NoResidue.conf index c1eac7a..25d7aad 100644 --- a/certora/confs/NoResidue.conf +++ b/certora/confs/NoResidue.conf @@ -7,6 +7,7 @@ "solc_via_ir": true, "solc_evm_version": "osaka", "optimistic_loop": true, + "optimistic_fallback": true, "loop_iter": 2, "msg": "BlueBundles: no token residue" } diff --git a/certora/specs/NoResidue.spec b/certora/specs/NoResidue.spec index 4860963..91a79e7 100644 --- a/certora/specs/NoResidue.spec +++ b/certora/specs/NoResidue.spec @@ -2,24 +2,18 @@ // No token residue: every entry point preserves the bundler's balance of every token (delta 0). // Scope: all entry points, excluding migrate borrow position. -// Two assumptions shared with that suite: +// Assumptions shared with that suite: // - no bundler donations: the receiver and the recipient are different from the bundler. // - well-behaved ERC20 (no fee-on-transfer/rebasing): matching the token restriction in BlueBundles' header. - -definition WAD() returns mathint = 10 ^ 18; - -// The bundler's balance of every token, updated on every transfer that touches it. -persistent ghost mapping(address => mathint) bundlerBalance; +// - the bundler is not the wrapped-native token. methods { // ERC20: the bundler's own transfers move bundlerBalance. - function _.transferFrom(address from, address to, uint256 amt) external => cvlTransferFrom(calledContract, from, to, amt) expect(bool); function _.transfer(address to, uint256 amt) external with(env e) => cvlTransferFrom(calledContract, e.msg.sender, to, amt) expect(bool); // Morpho: pull on supply/repay/supplyCollateral, send on borrow/withdraw/withdrawCollateral. // Also assumes that the Morpho Blue address is different from the bundler's. - function _.supply(BlueBundlesV1.MarketParams marketParams, uint256 assets, uint256 shares, address onBehalf, bytes data) external => summarySupply(marketParams.loanToken, assets, shares) expect(uint256, uint256); function _.repay(BlueBundlesV1.MarketParams marketParams, uint256 assets, uint256 shares, address onBehalf, bytes data) external => summaryRepay(marketParams.loanToken) expect(uint256, uint256); function _.supplyCollateral(BlueBundlesV1.MarketParams marketParams, uint256 assets, address onBehalf, bytes data) external => summarySupplyCollateral(marketParams.collateralToken, assets) expect void; @@ -27,9 +21,27 @@ methods { function _.withdraw(BlueBundlesV1.MarketParams marketParams, uint256 assets, uint256 shares, address onBehalf, address receiver) external => summaryWithdraw(marketParams.loanToken, receiver) expect(uint256, uint256); function _.withdrawCollateral(BlueBundlesV1.MarketParams marketParams, uint256 assets, address onBehalf, address receiver) external => summaryWithdrawCollateral(marketParams.collateralToken, assets, receiver) expect void; + // Model the WNative contract's deposit and withdraw behavior: mints on deposit and burns on withdraw. + function _.deposit() external with(env e) => summaryWrapNative(calledContract, e.msg.value) expect void; + function _.withdraw(uint256 amount) external => summaryUnwrapNative(calledContract, amount) expect void; + + // Summarized without state changes, as the HAVOC_ECF allows the callee to credit native tokens to the caller. + function _.setAuthorizationWithSig(BlueBundlesV1.Authorization authorization, BlueBundlesV1.Signature signature) external => NONDET; + function TokenLib.safeApprove(address token, address spender, uint256 value) internal => NONDET; + // Since calls are not summarized as havoc all by default, it is assumed that other calls don't change the bundler's balance of any token. } +definition WAD() returns mathint = 10 ^ 18; + +// The bundler's balance of every token, updated on every transfer that touches it. +persistent ghost mapping(address => mathint) bundlerBalance; + +// Native sent to the bundler by the wrapped-native contract on withdraw: summaries cannot write nativeBalances, so it is tracked separately and added to the bundler's native balance in the rules. +persistent ghost mathint unwrappedNative; + +definition bundlerNativeBalance() returns mathint = nativeBalances[currentContract] + unwrappedNative; + // well-behaved ERC20: transfers move balances by the amount. function cvlTransferFrom(address token, address from, address to, uint256 amount) returns bool { if (from == currentContract) bundlerBalance[token] = bundlerBalance[token] - amount; @@ -73,14 +85,26 @@ function summaryWithdrawCollateral(address token, uint256 assets, address receiv if (receiver == currentContract) bundlerBalance[token] = bundlerBalance[token] + assets; } +function summaryWrapNative(address token, uint256 value) { + bundlerBalance[token] = bundlerBalance[token] + value; +} + +function summaryUnwrapNative(address token, uint256 amount) { + bundlerBalance[token] = bundlerBalance[token] - amount; + unwrappedNative = unwrappedNative + amount; +} + rule supplyPreservesBalance(env e, BlueBundlesV1.MarketParams marketParams, uint256 assets, uint256 maxSharePriceE27, TokenLib.TokenPermit permit, uint256 feePct, address recipient, address token, uint256 deadline) { require permit.kind == TokenLib.PermitKind.None, "simplification for prover performance"; require e.msg.sender != currentContract, "bundler is never its own caller"; require recipient != currentContract, "no bundler donations of the fee"; + require marketParams.loanToken != currentContract, "the bundler is not the wrapped-native token"; mathint before = bundlerBalance[token]; + mathint nativeBefore = bundlerNativeBalance(); blueBundlesV1Supply(e, marketParams, assets, maxSharePriceE27, permit, feePct, recipient, deadline); assert bundlerBalance[token] == before; + assert bundlerNativeBalance() == nativeBefore; } rule withdrawPreservesBalance(env e, BlueBundlesV1.MarketParams marketParams, uint256 assets, uint256 shares, BlueBundlesV1.SignedAuthorization signedAuthorization, uint256 feePct, address recipient, address token, uint256 deadline) { @@ -88,26 +112,34 @@ rule withdrawPreservesBalance(env e, BlueBundlesV1.MarketParams marketParams, ui require recipient != currentContract, "no bundler donations of the fee"; mathint before = bundlerBalance[token]; + mathint nativeBefore = bundlerNativeBalance(); blueBundlesV1Withdraw(e, marketParams, assets, shares, signedAuthorization, feePct, recipient, deadline); assert bundlerBalance[token] == before; + assert bundlerNativeBalance() == nativeBefore; } rule supplyCollateralAndBorrowPreservesBalance(env e, BlueBundlesV1.MarketParams marketParams, uint256 collateralAmount, uint256 borrowAssets, uint256 minSharePriceE27, uint256 maxLtv, TokenLib.TokenPermit permit, BlueBundlesV1.SignedAuthorization signedAuthorization, uint256 feePct, address recipient, address token, uint256 deadline) { require permit.kind == TokenLib.PermitKind.None, "simplification for prover performance"; require e.msg.sender != currentContract, "bundler is never its own caller"; require recipient != currentContract, "no bundler donations of the fee"; + require marketParams.collateralToken != currentContract, "the bundler is not the wrapped-native token"; mathint before = bundlerBalance[token]; + mathint nativeBefore = bundlerNativeBalance(); blueBundlesV1SupplyCollateralAndBorrow(e, marketParams, collateralAmount, borrowAssets, minSharePriceE27, maxLtv, permit, signedAuthorization, feePct, recipient, deadline); assert bundlerBalance[token] == before; + assert bundlerNativeBalance() == nativeBefore; } rule repayAndWithdrawCollateralPreservesBalance(env e, BlueBundlesV1.MarketParams marketParams, uint256 assets, uint256 shares, uint256 maxRepayAssets, uint256 maxSharePriceE27, uint256 withdrawCollateralAssets, uint256 maxLtv, TokenLib.TokenPermit permit, BlueBundlesV1.SignedAuthorization signedAuthorization, uint256 feePct, address recipient, address token, uint256 deadline) { require permit.kind == TokenLib.PermitKind.None, "simplification for prover performance"; require e.msg.sender != currentContract, "bundler is never its own caller"; require recipient != currentContract, "no bundler donations of the fee"; + require marketParams.loanToken != currentContract, "the bundler is not the wrapped-native token"; mathint before = bundlerBalance[token]; + mathint nativeBefore = bundlerNativeBalance(); blueBundlesV1RepayAndWithdrawCollateral(e, marketParams, assets, shares, maxRepayAssets, maxSharePriceE27, withdrawCollateralAssets, maxLtv, permit, signedAuthorization, feePct, recipient, deadline); assert bundlerBalance[token] == before; + assert bundlerNativeBalance() == nativeBefore; } diff --git a/src/blue/BlueBundlesV1.sol b/src/blue/BlueBundlesV1.sol index 5c8b397..6b58786 100644 --- a/src/blue/BlueBundlesV1.sol +++ b/src/blue/BlueBundlesV1.sol @@ -4,6 +4,7 @@ pragma solidity 0.8.34; import {IBlueBundlesV1, SignedAuthorization} from "./interfaces/IBlueBundlesV1.sol"; import {TokenLib, TokenPermit} from "../libraries/TokenLib.sol"; +import {IWNative} from "../libraries/interfaces/IWNative.sol"; import { IMorpho, MarketParams, @@ -36,9 +37,13 @@ contract BlueBundlesV1 is IBlueBundlesV1, IMorphoRepayCallback { BLUE = _blue; } + /// @dev Receives the native tokens unwrapped from the wrapped-native token when reimbursing a native repay. + receive() external payable {} + /// EXTERNAL /// /// @dev Pulls collateralAssets of marketParams.collateralToken from msg.sender (optionally via ERC-2612 or Permit2), supplies it as collateral on Blue for msg.sender, then borrows borrowAssets of the loan token on behalf of msg.sender. + /// @dev When native tokens are sent, they are wrapped into marketParams.collateralToken (which must be the wrapped-native token) instead of pulling. /// @dev The msg.sender must have authorized this contract on Blue, beforehand or via signedAuthorization. /// @dev referralFeeAssets = borrowAssets * referralFeePct / WAD; net = borrowAssets - referralFeeAssets. /// @dev To receive an amount W, pass borrowAssets = floor(W * WAD / (WAD - referralFeePct)). @@ -55,12 +60,12 @@ contract BlueBundlesV1 is IBlueBundlesV1, IMorphoRepayCallback { uint256 referralFeePct, address referralFeeRecipient, uint256 deadline - ) external { + ) external payable { require(block.timestamp <= deadline, DeadlinePassed()); require(referralFeePct < WAD, PctExceeded()); setAuthorizationWithSig(signedAuthorization); - TokenLib.pullToken(marketParams.collateralToken, msg.sender, collateralAssets, collateralPermit); + TokenLib.pullOrWrapNative(marketParams.collateralToken, msg.sender, collateralAssets, collateralPermit); if (collateralAssets > 0) { TokenLib.forceApproveMax(marketParams.collateralToken, BLUE); IMorpho(BLUE).supplyCollateral(marketParams, collateralAssets, msg.sender, ""); @@ -78,7 +83,9 @@ contract BlueBundlesV1 is IBlueBundlesV1, IMorphoRepayCallback { SafeTransferLib.safeTransfer(marketParams.loanToken, msg.sender, borrowAssets - referralFeeAssets); } - /// @dev Pulls maxRepayAssets from msg.sender, repays msg.sender's debt, reimburses the unused remainder at the end of the call, and withdraws collateral if collateralAssets > 0. + /// @dev Pulls maxRepayAssets from msg.sender, repays msg.sender's debt, reimburses the unused remainder (if any) at the end of the call, and withdraws collateral if collateralAssets > 0. + /// @dev When native tokens are sent, they are wrapped into marketParams.loanToken (which must be the wrapped-native token) instead of pulling, and the reimbursed remainder is unwrapped back to native. + /// @dev Reimbursing native tokens requires msg.sender to be able to receive native tokens, or else it will revert. /// @dev The msg.sender must have authorized this contract on Blue, beforehand or via signedAuthorization, if some collateral is withdrawn. /// @dev Exactly one of repayAssets and repayShares should be non-zero: the debt is repaid by assets, or by shares. To close the full debt, pass msg.sender's full borrow shares as repayShares. /// @dev The fee is repaidAmount * referralFeePct / (WAD - referralFeePct). @@ -97,12 +104,12 @@ contract BlueBundlesV1 is IBlueBundlesV1, IMorphoRepayCallback { uint256 referralFeePct, address referralFeeRecipient, uint256 deadline - ) external { + ) external payable { require(block.timestamp <= deadline, DeadlinePassed()); require(referralFeePct < WAD, PctExceeded()); setAuthorizationWithSig(signedAuthorization); - TokenLib.pullToken(marketParams.loanToken, msg.sender, maxRepayAssets, loanTokenPermit); + TokenLib.pullOrWrapNative(marketParams.loanToken, msg.sender, maxRepayAssets, loanTokenPermit); TokenLib.forceApproveMax(marketParams.loanToken, BLUE); (repayAssets, repayShares) = IMorpho(BLUE).repay(marketParams, repayAssets, repayShares, msg.sender, ""); @@ -117,12 +124,20 @@ contract BlueBundlesV1 is IBlueBundlesV1, IMorphoRepayCallback { if (referralFeeAssets > 0) { SafeTransferLib.safeTransfer(marketParams.loanToken, referralFeeRecipient, referralFeeAssets); } - SafeTransferLib.safeTransfer( - marketParams.loanToken, msg.sender, maxRepayAssets - repayAssets - referralFeeAssets - ); + uint256 remainder = maxRepayAssets - repayAssets - referralFeeAssets; + if (remainder > 0) { + if (msg.value > 0) { + IWNative(marketParams.loanToken).withdraw(remainder); + (bool success,) = msg.sender.call{value: remainder}(""); + require(success, NativeTransferFailed()); + } else { + SafeTransferLib.safeTransfer(marketParams.loanToken, msg.sender, remainder); + } + } } /// @dev Pulls assets from msg.sender (optionally via ERC-2612 or Permit2) and supplies them to the market for msg.sender. + /// @dev When native tokens are sent, they are wrapped into marketParams.loanToken (which must be the wrapped-native token) instead of pulling. /// @dev The referral fee is deducted from assets; the remainder is supplied to the market for msg.sender. /// @dev Fee = assets * referralFeePct / WAD; supplied = assets - fee. /// @dev maxSharePriceE27 upper-bounds the realized supply share price (supplied assets per share, scaled by 1e27). @@ -134,14 +149,14 @@ contract BlueBundlesV1 is IBlueBundlesV1, IMorphoRepayCallback { uint256 referralFeePct, address referralFeeRecipient, uint256 deadline - ) external { + ) external payable { require(block.timestamp <= deadline, DeadlinePassed()); require(referralFeePct < WAD, PctExceeded()); uint256 referralFeeAssets = assets.mulDivDown(referralFeePct, WAD); uint256 toSupply = assets - referralFeeAssets; - TokenLib.pullToken(marketParams.loanToken, msg.sender, assets, loanTokenPermit); + TokenLib.pullOrWrapNative(marketParams.loanToken, msg.sender, assets, loanTokenPermit); TokenLib.forceApproveMax(marketParams.loanToken, BLUE); (, uint256 suppliedShares) = IMorpho(BLUE).supply(marketParams, toSupply, 0, msg.sender, ""); diff --git a/src/blue/interfaces/IBlueBundlesV1.sol b/src/blue/interfaces/IBlueBundlesV1.sol index e41e53a..12951d6 100644 --- a/src/blue/interfaces/IBlueBundlesV1.sol +++ b/src/blue/interfaces/IBlueBundlesV1.sol @@ -18,6 +18,7 @@ interface IBlueBundlesV1 { error DeadlinePassed(); error InconsistentTokens(); error LtvExceeded(); + error NativeTransferFailed(); error PctExceeded(); error SlippageExceeded(); error UnauthorizedCallback(); @@ -37,7 +38,7 @@ interface IBlueBundlesV1 { uint256 referralFeePct, address referralFeeRecipient, uint256 deadline - ) external; + ) external payable; function blueBundlesV1RepayAndWithdrawCollateral( MarketParams memory marketParams, @@ -52,7 +53,7 @@ interface IBlueBundlesV1 { uint256 referralFeePct, address referralFeeRecipient, uint256 deadline - ) external; + ) external payable; function blueBundlesV1Supply( MarketParams memory marketParams, @@ -62,7 +63,7 @@ interface IBlueBundlesV1 { uint256 referralFeePct, address referralFeeRecipient, uint256 deadline - ) external; + ) external payable; function blueBundlesV1Withdraw( MarketParams memory marketParams, diff --git a/src/libraries/TokenLib.sol b/src/libraries/TokenLib.sol index c7fd737..abc91f1 100644 --- a/src/libraries/TokenLib.sol +++ b/src/libraries/TokenLib.sol @@ -4,6 +4,7 @@ pragma solidity >=0.8.0; import {SafeTransferLib} from "../../lib/midnight/src/libraries/SafeTransferLib.sol"; import {IERC20Permit} from "./interfaces/IERC20Permit.sol"; +import {IWNative} from "./interfaces/IWNative.sol"; import {IPermit2, ISignatureTransfer} from "../../lib/permit2/src/interfaces/IPermit2.sol"; enum PermitKind { @@ -30,6 +31,8 @@ struct Permit { library TokenLib { error ApproveReturnedFalse(); + error BothNativeAndToken(); + error InconsistentAmountAndNative(); /// @dev Canonical Permit2 singleton. address constant PERMIT2 = 0x000000000022D473030F116dDEE9F6B43aC78BA3; @@ -78,6 +81,17 @@ library TokenLib { } } + /// @dev Same as pullToken, but when native tokens are sent with the call, instead wraps msg.value into token. + function pullOrWrapNative(address token, address from, uint256 amount, TokenPermit memory permit) internal { + if (msg.value > 0) { + require(permit.kind == PermitKind.None, BothNativeAndToken()); + require(amount == msg.value, InconsistentAmountAndNative()); + IWNative(token).deposit{value: msg.value}(); + } else { + pullToken(token, from, amount, permit); + } + } + /// @dev The parameters signed by the user should be the same as the inputs of this function. /// @dev Skipped when the permit is empty (v, r and s all zero; which doesn't correspond to a valid signature), useful when the tokens are already permitted. /// @dev Skipped on an already consumed nonce (e.g. a front-run submission): the permit is not submitted in that case. diff --git a/src/libraries/interfaces/IWNative.sol b/src/libraries/interfaces/IWNative.sol new file mode 100644 index 0000000..a4e569e --- /dev/null +++ b/src/libraries/interfaces/IWNative.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (c) 2026 Morpho Association +pragma solidity >=0.8.0; + +interface IWNative { + function deposit() external payable; + function withdraw(uint256 amount) external; +} diff --git a/src/vault/VaultBundlesV1.sol b/src/vault/VaultBundlesV1.sol index e7574d1..0871512 100644 --- a/src/vault/VaultBundlesV1.sol +++ b/src/vault/VaultBundlesV1.sol @@ -25,6 +25,7 @@ contract VaultBundlesV1 is IVaultBundlesV1 { /// EXTERNAL /// /// @dev Pulls assets of the vault asset from msg.sender (optionally via ERC-2612 or Permit2) and deposits them into vault. + /// @dev When native tokens are sent, they are wrapped into the vault asset (which must be the wrapped-native token) instead of pulling. /// @dev The referral fee is deducted from assets; the remainder is deposited into vault for msg.sender. /// @dev Fee = assets * referralFeePct / WAD; deposited = assets - fee. /// @dev maxSharePriceE27 upper-bounds the realized deposit share price (deposited assets per share, scaled by 1e27). @@ -36,7 +37,7 @@ contract VaultBundlesV1 is IVaultBundlesV1 { uint256 referralFeePct, address referralFeeRecipient, uint256 deadline - ) external { + ) external payable { require(initiator == address(0), AlreadyInitiated()); initiator = msg.sender; require(block.timestamp <= deadline, DeadlinePassed()); @@ -46,7 +47,7 @@ contract VaultBundlesV1 is IVaultBundlesV1 { uint256 toDeposit = assets - referralFeeAssets; address asset = IERC4626(vault).asset(); - TokenLib.pullToken(asset, msg.sender, assets, assetPermit); + TokenLib.pullOrWrapNative(asset, msg.sender, assets, assetPermit); TokenLib.forceApproveMax(asset, vault); uint256 shares = IERC4626(vault).deposit(toDeposit, msg.sender); diff --git a/src/vault/interfaces/IVaultBundlesV1.sol b/src/vault/interfaces/IVaultBundlesV1.sol index b681edd..aa6da7c 100644 --- a/src/vault/interfaces/IVaultBundlesV1.sol +++ b/src/vault/interfaces/IVaultBundlesV1.sol @@ -25,7 +25,7 @@ interface IVaultBundlesV1 { uint256 referralFeePct, address referralFeeRecipient, uint256 deadline - ) external; + ) external payable; function vaultBundlesV1Withdraw( address vault, diff --git a/test/BlueBundlesTest.sol b/test/BlueBundlesTest.sol index 024ae40..b18abf0 100644 --- a/test/BlueBundlesTest.sol +++ b/test/BlueBundlesTest.sol @@ -12,9 +12,10 @@ import {ORACLE_PRICE_SCALE, AUTHORIZATION_TYPEHASH} from "../lib/morpho-blue/src import {OracleMock} from "../lib/morpho-blue/src/mocks/OracleMock.sol"; import {WAD} from "../lib/midnight/src/libraries/ConstantsLib.sol"; import {ERC20Permit} from "../lib/midnight/test/erc20s/ERC20Permit.sol"; +import {ERC20} from "../lib/midnight/test/erc20s/ERC20.sol"; import {BlueBundlesV1} from "../src/blue/BlueBundlesV1.sol"; import {IBlueBundlesV1, SignedAuthorization} from "../src/blue/interfaces/IBlueBundlesV1.sol"; -import {TokenPermit} from "../src/libraries/TokenLib.sol"; +import {TokenLib, TokenPermit, PermitKind} from "../src/libraries/TokenLib.sol"; contract BlueBundlesTest is Test { using MarketParamsLib for MarketParams; @@ -139,12 +140,35 @@ contract BlueBundlesTest is Test { /// @dev Opens a borrow position for `onBehalf` directly on Morpho (used to set up repay/withdraw tests). function _openBorrow(address onBehalf, uint256 borrowAssets) internal { + _openBorrow(marketParams, onBehalf, borrowAssets); + } + + function _openBorrow(MarketParams memory marketParams_, address onBehalf, uint256 borrowAssets) internal { uint256 collateral = _collateralFor(borrowAssets); deal(address(collateralToken), onBehalf, collateral); vm.startPrank(onBehalf); collateralToken.approve(address(morpho), type(uint256).max); - morpho.supplyCollateral(marketParams, collateral, onBehalf, ""); - morpho.borrow(marketParams, borrowAssets, 0, onBehalf, onBehalf); + morpho.supplyCollateral(marketParams_, collateral, onBehalf, ""); + morpho.borrow(marketParams_, borrowAssets, 0, onBehalf, onBehalf); + vm.stopPrank(); + } + + function _createWrappedNativeMarket() internal returns (WETHMock weth, MarketParams memory wethMarketParams) { + weth = new WETHMock(); + wethMarketParams = MarketParams({ + loanToken: address(weth), + collateralToken: address(collateralToken), + oracle: address(oracle), + irm: address(0), + lltv: LLTV + }); + morpho.createMarket(wethMarketParams); + + vm.deal(supplier, LIQUIDITY); + vm.startPrank(supplier); + weth.deposit{value: LIQUIDITY}(); + weth.approve(address(morpho), type(uint256).max); + morpho.supply(wethMarketParams, LIQUIDITY, 0, supplier, ""); vm.stopPrank(); } @@ -474,6 +498,77 @@ contract BlueBundlesTest is Test { assertEq(loanToken.balanceOf(address(blueBundles)), 0, "bundler residual"); } + /// @dev Sending native tokens (msg.value) wraps them into the collateral token and supplies them as collateral. + function testSupplyCollateralAndBorrowWrapNative(uint256 borrowAssets) public { + borrowAssets = bound(borrowAssets, 1, 1e30); + uint256 collateral = _collateralFor(borrowAssets); + + // Market whose collateral token is the wrapped-native token. + WETHMock weth = new WETHMock(); + MarketParams memory wethMarketParams = MarketParams({ + loanToken: address(loanToken), + collateralToken: address(weth), + oracle: address(oracle), + irm: address(0), + lltv: LLTV + }); + morpho.createMarket(wethMarketParams); + Id wethId = wethMarketParams.id(); + + // Seed loan-side liquidity so the borrow can be served. + deal(address(loanToken), supplier, LIQUIDITY); + vm.prank(supplier); + morpho.supply(wethMarketParams, LIQUIDITY, 0, supplier, ""); + + vm.deal(user, collateral); + // When native tokens are sent, collateralAssets must equal msg.value and no collateralPermit may be set. + vm.prank(user); + blueBundles.blueBundlesV1SupplyCollateralAndBorrow{value: collateral}( + wethMarketParams, + collateral, + borrowAssets, + 0, + WAD, + _noPermit(), + _noAuthSig(), + 0, + address(0), + block.timestamp + ); + + assertEq(morpho.collateral(wethId, user), collateral, "collateral"); + assertEq(morpho.expectedBorrowAssets(wethMarketParams, user), borrowAssets, "debt"); + assertEq(loanToken.balanceOf(user), borrowAssets, "user"); + assertEq(user.balance, 0, "user native residual"); + assertEq(address(blueBundles).balance, 0, "bundler native residual"); + assertEq(weth.balanceOf(address(blueBundles)), 0, "bundler wrapped residual"); + } + + /// @dev Sending native tokens together with a collateral permit reverts. + function testSupplyCollateralAndBorrowBothNativeAndToken() public { + uint256 collateral = _collateralFor(1e18); + vm.deal(user, collateral); + + TokenPermit memory permit = TokenPermit({kind: PermitKind.ERC2612, data: ""}); + vm.prank(user); + vm.expectRevert(TokenLib.BothNativeAndToken.selector); + blueBundles.blueBundlesV1SupplyCollateralAndBorrow{value: collateral}( + marketParams, collateral, 1e18, 0, WAD, permit, _noAuthSig(), 0, address(0), block.timestamp + ); + } + + /// @dev Sending native tokens with a collateral amount that does not match msg.value reverts. + function testSupplyCollateralAndBorrowInconsistentAmountAndNative() public { + uint256 collateral = _collateralFor(1e18); + vm.deal(user, collateral); + + vm.prank(user); + vm.expectRevert(TokenLib.InconsistentAmountAndNative.selector); + blueBundles.blueBundlesV1SupplyCollateralAndBorrow{value: collateral}( + marketParams, collateral + 1, 1e18, 0, WAD, _noPermit(), _noAuthSig(), 0, address(0), block.timestamp + ); + } + function testSupplyCollateralAndBorrowWithReferralFee(uint256 borrowAssets, uint256 referralFeePct) public { borrowAssets = bound(borrowAssets, 1, 1e30); referralFeePct = bound(referralFeePct, 0, WAD - 1); @@ -630,6 +725,111 @@ contract BlueBundlesTest is Test { assertEq(loanToken.balanceOf(address(blueBundles)), 0, "bundler residual"); } + /// @dev Sending native tokens (msg.value) wraps them into the loan token and repays msg.sender's debt. + function testRepayAndWithdrawCollateralWrapNative() public { + (WETHMock weth, MarketParams memory wethMarketParams) = _createWrappedNativeMarket(); + Id wethId = wethMarketParams.id(); + + uint256 borrowAssets = 100e18; + uint256 collateral = _collateralFor(borrowAssets); + + _openBorrow(wethMarketParams, user, borrowAssets); + + // Discard the borrowed wrapped tokens; the user repays with native and the refund must come back as native. + deal(address(weth), user, 0); + + uint256 repayAssets = 40e18; + uint256 maxRepayAssets = 50e18; + // After repaying repayAssets, remaining debt is 60e18, needing 75e18 collateral (60/0.8) at 1:1 price. + uint256 withdrawCollateral = collateral - 75e18; + + vm.deal(user, maxRepayAssets); + // When native tokens are sent, maxRepayAssets must equal msg.value and no loanTokenPermit may be set. + vm.prank(user); + blueBundles.blueBundlesV1RepayAndWithdrawCollateral{value: maxRepayAssets}( + wethMarketParams, + repayAssets, + 0, + maxRepayAssets, + type(uint256).max, + withdrawCollateral, + WAD, + _noPermit(), + _noAuthSig(), + 0, + address(0), + block.timestamp + ); + + assertEq(morpho.expectedBorrowAssets(wethMarketParams, user), borrowAssets - repayAssets, "debt"); + assertEq(morpho.collateral(wethId, user), collateral - withdrawCollateral, "remaining collateral"); + assertEq(collateralToken.balanceOf(user), withdrawCollateral, "collateral to user"); + // The unused remainder is unwrapped and returned as native, not as the wrapped token. + assertEq(user.balance, maxRepayAssets - repayAssets, "native refund"); + assertEq(weth.balanceOf(user), 0, "no wrapped refund"); + assertEq(address(blueBundles).balance, 0, "bundler native residual"); + assertEq(weth.balanceOf(address(blueBundles)), 0, "bundler wrapped residual"); + } + + function testRepayAndWithdrawCollateralNativeRefundToContractWithReceive() public { + (WETHMock weth, MarketParams memory wethMarketParams) = _createWrappedNativeMarket(); + address caller = address(new NativeReceiver()); + + uint256 borrowAssets = 100e18; + uint256 repayAssets = 40e18; + uint256 maxRepayAssets = 50e18; + _openBorrow(wethMarketParams, caller, borrowAssets); + deal(address(weth), caller, 0); + + vm.deal(caller, maxRepayAssets); + vm.prank(caller); + blueBundles.blueBundlesV1RepayAndWithdrawCollateral{value: maxRepayAssets}( + wethMarketParams, + repayAssets, + 0, + maxRepayAssets, + type(uint256).max, + 0, + WAD, + _noPermit(), + _noAuthSig(), + 0, + address(0), + block.timestamp + ); + + assertEq(caller.balance, maxRepayAssets - repayAssets, "native refund"); + } + + function testRepayAndWithdrawCollateralNativeRefundToContractWithoutReceive() public { + (WETHMock weth, MarketParams memory wethMarketParams) = _createWrappedNativeMarket(); + address caller = address(new NonNativeReceiver()); + + uint256 borrowAssets = 100e18; + uint256 repayAssets = 40e18; + uint256 maxRepayAssets = 50e18; + _openBorrow(wethMarketParams, caller, borrowAssets); + deal(address(weth), caller, 0); + + vm.deal(caller, maxRepayAssets); + vm.prank(caller); + vm.expectRevert(IBlueBundlesV1.NativeTransferFailed.selector); + blueBundles.blueBundlesV1RepayAndWithdrawCollateral{value: maxRepayAssets}( + wethMarketParams, + repayAssets, + 0, + maxRepayAssets, + type(uint256).max, + 0, + WAD, + _noPermit(), + _noAuthSig(), + 0, + address(0), + block.timestamp + ); + } + /// @dev maxLtv caps the resulting LTV after a withdrawal: repaying 30e18 and withdrawing 100e18 leaves 70e18 /// debt against 100e18 collateral (LTV 0.7) — within the 0.8 LLTV Blue allows, but above a 0.6 maxLtv. function testRepayAndWithdrawCollateralLtvExceeded() public { @@ -915,6 +1115,34 @@ contract BlueBundlesTest is Test { assertEq(loanToken.balanceOf(address(blueBundles)), 0, "bundler residual"); } + /// @dev Sending native tokens (msg.value) wraps them into the loan token and supplies them to the market. + function testSupplyWrapNative(uint256 assets) public { + assets = bound(assets, 1, 1e30); + + // Market whose loan token is the wrapped-native token. + WETHMock weth = new WETHMock(); + MarketParams memory wethMarketParams = MarketParams({ + loanToken: address(weth), + collateralToken: address(collateralToken), + oracle: address(oracle), + irm: address(0), + lltv: LLTV + }); + morpho.createMarket(wethMarketParams); + + vm.deal(user, assets); + // When native tokens are sent, assets must equal msg.value and no loanTokenPermit may be set. + vm.prank(user); + blueBundles.blueBundlesV1Supply{value: assets}( + wethMarketParams, assets, type(uint256).max, _noPermit(), 0, address(0), block.timestamp + ); + + assertEq(morpho.expectedSupplyAssets(wethMarketParams, user), assets, "supply position"); + assertEq(user.balance, 0, "user native residual"); + assertEq(address(blueBundles).balance, 0, "bundler native residual"); + assertEq(weth.balanceOf(address(blueBundles)), 0, "bundler wrapped residual"); + } + /// WITHDRAW /// function testWithdraw(uint256 supplyAssets, uint256 withdrawAssets) public { @@ -1331,3 +1559,25 @@ contract BlueBundlesTest is Test { ); } } + +/// @dev Minimal wrapped-native token: deposit() mints 1:1 for the native tokens sent. +contract WETHMock is ERC20 { + constructor() ERC20("Wrapped Ether", "WETH") {} + + function deposit() external payable { + balanceOf[msg.sender] += msg.value; + totalSupply += msg.value; + } + + function withdraw(uint256 amount) external { + balanceOf[msg.sender] -= amount; + totalSupply -= amount; + payable(msg.sender).transfer(amount); + } +} + +contract NativeReceiver { + receive() external payable {} +} + +contract NonNativeReceiver {} diff --git a/test/VaultBundlesTest.sol b/test/VaultBundlesTest.sol index b1c5ac5..1c6a7f4 100644 --- a/test/VaultBundlesTest.sol +++ b/test/VaultBundlesTest.sol @@ -229,6 +229,26 @@ contract VaultBundlesTest is Test { bundles.vaultBundlesV1Deposit(address(vaultV1), 1e18, RAY, noPermit, WAD, referralFeeRecipient, block.timestamp); } + /// @dev Sending native tokens (msg.value) wraps them into the vault asset and deposits them. + function testDepositWrapNative(uint256 assets) public { + assets = bound(assets, MIN_ASSETS, MAX_ASSETS); + + // Vault whose asset is the wrapped-native token. + WETHMock weth = new WETHMock(); + IERC4626 wethVault = _deployVaultV2(weth, bytes32(uint256(4))); + + vm.deal(user, assets); + // When native tokens are sent, assets must equal msg.value and no assetPermit may be set. + bundles.vaultBundlesV1Deposit{value: assets}( + address(wethVault), assets, RAY, noPermit, 0, address(0), block.timestamp + ); + + assertEq(user.balance, 0, "user native residual"); + assertEq(address(bundles).balance, 0, "bundler native residual"); + assertEq(weth.balanceOf(address(bundles)), 0, "bundler wrapped residual"); + assertApproxEqAbs(wethVault.convertToAssets(wethVault.balanceOf(user)), assets, 1, "user position"); + } + /// WITHDRAW /// function testWithdrawV1(uint256 assets) public { @@ -791,3 +811,12 @@ contract VaultBundlesTest is Test { ); } } + +/// @dev Minimal wrapped-native token: deposit() mints 1:1 for the native tokens sent. +contract WETHMock is ERC20Mock { + constructor() ERC20Mock(18) {} + + function deposit() external payable { + _mint(msg.sender, msg.value); + } +}