From e78cd9b97b09517b38cddc4e6e627bad2e9d26ab Mon Sep 17 00:00:00 2001 From: Kevin Cheng Date: Mon, 26 Aug 2024 10:18:33 -0700 Subject: [PATCH 01/13] Recurring Swap script (#63) `RecurringSwap.sol` is a Quark script that allows a Quark wallet to swap for an asset at a regular cadence. For example, one could submit an order to "Buy 1000 USDC worth of ETH every week". The current implementation is built with the following features: - User can set an amount of asset A to buy using an asset B - User can set a fixed interval for buying the asset - User can cancel the recurring buy - The recurring swap has oracle price checks to protect against MEV --- src/RecurringSwap.sol | 282 ++++++++++ test/RecurringSwap.t.sol | 868 ++++++++++++++++++++++++++++++ test/ReplayableTransactions.t.sol | 419 -------------- test/lib/RecurringPurchase.sol | 120 ----- 4 files changed, 1150 insertions(+), 539 deletions(-) create mode 100644 src/RecurringSwap.sol create mode 100644 test/RecurringSwap.t.sol delete mode 100644 test/ReplayableTransactions.t.sol delete mode 100644 test/lib/RecurringPurchase.sol diff --git a/src/RecurringSwap.sol b/src/RecurringSwap.sol new file mode 100644 index 00000000..81a93c83 --- /dev/null +++ b/src/RecurringSwap.sol @@ -0,0 +1,282 @@ +// SPDX-License-Identifier: BSD-3-Clause +pragma solidity 0.8.23; + +import {IERC20} from "openzeppelin/token/ERC20/IERC20.sol"; +import {SafeERC20} from "openzeppelin/token/ERC20/utils/SafeERC20.sol"; +import {IERC20Metadata} from "openzeppelin/token/ERC20/extensions/IERC20Metadata.sol"; + +import {AggregatorV3Interface} from "src/vendor/chainlink/AggregatorV3Interface.sol"; +import {ISwapRouter} from "v3-periphery/interfaces/ISwapRouter.sol"; + +import {QuarkWallet} from "quark-core/src/QuarkWallet.sol"; +import {QuarkScript} from "quark-core/src/QuarkScript.sol"; + +/** + * @title Recurring Swap Script + * @notice Quark script that performs a swap on a regular interval. + * @author Legend Labs, Inc. + */ +contract RecurringSwap is QuarkScript { + using SafeERC20 for IERC20; + + error BadPrice(); + error InvalidInput(); + error SwapWindowNotOpen(uint256 nextSwapTime, uint256 currentTime); + + /// @notice Emitted when a swap is executed + event SwapExecuted( + address indexed sender, + address indexed recipient, + address tokenIn, + address tokenOut, + uint256 amountIn, + uint256 amountOut, + bytes path + ); + + /// @notice The base slippage factor where `1e18` represents 100% slippage tolerance + uint256 public constant BASE_SLIPPAGE_FACTOR = 1e18; + + /// @notice The factor to scale up intermediate values by to preserve precision during multiplication and division + uint256 public constant PRECISION_FACTOR = 1e18; + + /** + * @dev Note: This script uses the following storage layout in the QuarkStateManager: + * mapping(bytes32 hashedSwapConfig => uint256 nextSwapTime) + * where hashedSwapConfig = keccak256(SwapConfig) + */ + + /// @notice Parameters for a recurring swap order + struct SwapConfig { + uint256 startTime; + /// @dev In seconds + uint256 interval; + SwapParams swapParams; + SlippageParams slippageParams; + } + + /// @notice Parameters for performing a swap + struct SwapParams { + address uniswapRouter; + address recipient; + address tokenIn; + address tokenOut; + /// @dev The amount for tokenIn if exact in; the amount for tokenOut if exact out + uint256 amount; + /// @dev False for exact in; true for exact out + bool isExactOut; + uint256 deadline; + bytes path; + } + + /// @notice Parameters for controlling slippage in a swap operation + struct SlippageParams { + /// @dev Maximum acceptable slippage, expressed as a percentage where 100% = 1e18 + uint256 maxSlippage; + /// @dev Price feed addresses for determining market exchange rates between token pairs + /// Example: For SUSHI -> SNX swap, use [SUSHI/ETH feed, SNX/ETH feed] + address[] priceFeeds; + /// @dev Flags indicating whether each corresponding price feed should be inverted + /// Example: For USDC -> ETH swap, use [true] with [ETH/USD feed] to get ETH per USDC + bool[] shouldInvert; + } + + /// @notice Cancel the recurring swap for the current nonce + function cancel() external { + // Not explicitly clearing the nonce just cancels the replayable txn + } + + /** + * @notice Execute a swap given a configuration for a recurring swap + * @param config The configuration for a recurring swap order + */ + function swap(SwapConfig calldata config) public { + allowReplay(); + + if (config.slippageParams.priceFeeds.length == 0) { + revert InvalidInput(); + } + if (config.slippageParams.priceFeeds.length != config.slippageParams.shouldInvert.length) { + revert InvalidInput(); + } + + bytes32 hashedConfig = _hashConfig(config); + uint256 nextSwapTime; + if (read(hashedConfig) == 0) { + nextSwapTime = config.startTime; + } else { + nextSwapTime = uint256(read(hashedConfig)); + } + + // Check conditions + if (block.timestamp < nextSwapTime) { + revert SwapWindowNotOpen(nextSwapTime, block.timestamp); + } + + // Update nextSwapTime + write(hashedConfig, bytes32(nextSwapTime + config.interval)); + + (uint256 amountIn, uint256 amountOut) = _calculateSwapAmounts(config); + (uint256 actualAmountIn, uint256 actualAmountOut) = + _executeSwap({swapParams: config.swapParams, amountIn: amountIn, amountOut: amountOut}); + + // Emit the swap event + emit SwapExecuted( + msg.sender, + config.swapParams.recipient, + config.swapParams.tokenIn, + config.swapParams.tokenOut, + actualAmountIn, + actualAmountOut, + config.swapParams.path + ); + } + + /** + * @notice Calculates the amounts of tokens required for a swap based on the given configuration + * @param config The configuration for the swap including swap parameters and slippage parameters + * @return amountIn The amount of `tokenIn` required for the swap + * @return amountOut The amount of `tokenOut` expected from the swap + * @dev This function handles both "exact in" and "exact out" scenarios. It adjusts amounts based on price feeds and decimals. + * For "exact out", it calculates the required `amountIn` to achieve the desired `amountOut`. + * For "exact in", it calculates the expected `amountOut` for the provided `amountIn`. + * The function also applies slippage tolerance to the calculated amounts. + */ + function _calculateSwapAmounts(SwapConfig calldata config) + internal + view + returns (uint256 amountIn, uint256 amountOut) + { + SwapParams memory swapParams = config.swapParams; + // We multiply intermediate values by 1e18 to preserve precision during multiplication and division + amountIn = swapParams.amount * PRECISION_FACTOR; + amountOut = swapParams.amount * PRECISION_FACTOR; + + for (uint256 i = 0; i < config.slippageParams.priceFeeds.length; ++i) { + // Get price from oracle + AggregatorV3Interface priceFeed = AggregatorV3Interface(config.slippageParams.priceFeeds[i]); + (, int256 rawPrice,,,) = priceFeed.latestRoundData(); + if (rawPrice <= 0) { + revert BadPrice(); + } + uint256 price = uint256(rawPrice); + uint256 priceScale = 10 ** uint256(priceFeed.decimals()); + + if (swapParams.isExactOut) { + // For exact out, we need to adjust amountIn by going backwards through the price feeds + amountIn = config.slippageParams.shouldInvert[i] + ? amountIn * price / priceScale + : amountIn * priceScale / price; + } else { + // For exact in, we need to adjust amountOut by going forwards through price feeds + amountOut = config.slippageParams.shouldInvert[i] + ? amountOut * priceScale / price + : amountOut * price / priceScale; + } + } + + uint256 tokenInDecimals = IERC20Metadata(swapParams.tokenIn).decimals(); + uint256 tokenOutDecimals = IERC20Metadata(swapParams.tokenOut).decimals(); + + // Scale amountIn to the correct amount of decimals and apply a slippage tolerance to it + if (swapParams.isExactOut) { + amountIn = _rescale({amount: amountIn, fromDecimals: tokenOutDecimals, toDecimals: tokenInDecimals}); + amountIn = (amountIn * (BASE_SLIPPAGE_FACTOR + config.slippageParams.maxSlippage)) / BASE_SLIPPAGE_FACTOR + / PRECISION_FACTOR; + amountOut /= PRECISION_FACTOR; + } else { + amountOut = _rescale({amount: amountOut, fromDecimals: tokenInDecimals, toDecimals: tokenOutDecimals}); + amountOut = (amountOut * (BASE_SLIPPAGE_FACTOR - config.slippageParams.maxSlippage)) / BASE_SLIPPAGE_FACTOR + / PRECISION_FACTOR; + amountIn /= PRECISION_FACTOR; + } + } + + /** + * @notice Executes the swap based on the provided parameters + * @param swapParams The parameters for the swap including router address, token addresses, and amounts + * @param amountIn The amount of `tokenIn` to be used in the swap + * @param amountOut The amount of `tokenOut` to be received from the swap + * @return actualAmountIn The actual amount of input tokens used in the swap + * @return actualAmountOut The actual amount of output tokens received from the swap + * @dev This function performs the swap using either the exact input or exact output method, depending on the configuration. + * It also handles the approval of tokens for the swap router and resets the approval after the swap. + */ + function _executeSwap(SwapParams memory swapParams, uint256 amountIn, uint256 amountOut) + internal + returns (uint256 actualAmountIn, uint256 actualAmountOut) + { + IERC20(swapParams.tokenIn).forceApprove(swapParams.uniswapRouter, amountIn); + + if (swapParams.isExactOut) { + // Exact out swap + actualAmountIn = ISwapRouter(swapParams.uniswapRouter).exactOutput( + ISwapRouter.ExactOutputParams({ + path: swapParams.path, + recipient: swapParams.recipient, + deadline: swapParams.deadline, + amountOut: amountOut, + amountInMaximum: amountIn + }) + ); + actualAmountOut = amountOut; + } else { + // Exact in swap + actualAmountOut = ISwapRouter(swapParams.uniswapRouter).exactInput( + ISwapRouter.ExactInputParams({ + path: swapParams.path, + recipient: swapParams.recipient, + deadline: swapParams.deadline, + amountIn: amountIn, + amountOutMinimum: amountOut + }) + ); + actualAmountIn = amountIn; + } + + // Approvals to external contracts should always be reset to 0 + IERC20(swapParams.tokenIn).forceApprove(swapParams.uniswapRouter, 0); + } + + /** + * @notice Scales an amount from one decimal precision to another decision precision + * @param amount The amount to be scaled + * @param fromDecimals The number of decimals in the source precision + * @param toDecimals The number of decimals in the target precision + * @return The scaled amount adjusted to the target precision + */ + function _rescale(uint256 amount, uint256 fromDecimals, uint256 toDecimals) internal pure returns (uint256) { + if (fromDecimals < toDecimals) { + return amount * (10 ** (toDecimals - fromDecimals)); + } else if (fromDecimals > toDecimals) { + return amount / (10 ** (fromDecimals - toDecimals)); + } + + return amount; + } + + /// @notice Deterministically hash the swap configuration + function _hashConfig(SwapConfig calldata config) internal pure returns (bytes32) { + return keccak256( + abi.encodePacked( + config.startTime, + config.interval, + abi.encodePacked( + config.swapParams.uniswapRouter, + config.swapParams.recipient, + config.swapParams.tokenIn, + config.swapParams.tokenOut, + config.swapParams.amount, + config.swapParams.isExactOut, + config.swapParams.deadline, + config.swapParams.path + ), + abi.encodePacked( + config.slippageParams.maxSlippage, + keccak256(abi.encodePacked(config.slippageParams.priceFeeds)), + keccak256(abi.encodePacked(config.slippageParams.shouldInvert)) + ) + ) + ); + } +} diff --git a/test/RecurringSwap.t.sol b/test/RecurringSwap.t.sol new file mode 100644 index 00000000..f7359bc6 --- /dev/null +++ b/test/RecurringSwap.t.sol @@ -0,0 +1,868 @@ +// SPDX-License-Identifier: BSD-3-Clause +pragma solidity 0.8.23; + +import "forge-std/Test.sol"; +import "forge-std/console.sol"; + +import {CodeJar} from "codejar/src/CodeJar.sol"; + +import {QuarkStateManager} from "quark-core/src/QuarkStateManager.sol"; +import {QuarkWallet} from "quark-core/src/QuarkWallet.sol"; + +import {QuarkMinimalProxy} from "quark-proxy/src/QuarkMinimalProxy.sol"; + +import {RecurringSwap} from "src/RecurringSwap.sol"; + +import {YulHelper} from "./lib/YulHelper.sol"; +import {SignatureHelper} from "./lib/SignatureHelper.sol"; +import {QuarkOperationHelper, ScriptType} from "./lib/QuarkOperationHelper.sol"; + +import {IERC20} from "openzeppelin/token/ERC20/IERC20.sol"; + +contract RecurringSwapTest is Test { + event SwapExecuted( + address indexed sender, + address indexed recipient, + address tokenIn, + address tokenOut, + uint256 amountIn, + uint256 amountOut, + bytes path + ); + + CodeJar public codeJar; + QuarkStateManager public stateManager; + QuarkWallet public walletImplementation; + + uint256 alicePrivateKey = 0x8675309; + address aliceAccount = vm.addr(alicePrivateKey); + QuarkWallet aliceWallet; // see constructor() + + bytes recurringSwap = new YulHelper().getCode("RecurringSwap.sol/RecurringSwap.json"); + + // Contracts address on mainnet + address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; + address constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; + address constant COMP = 0xc00e94Cb662C3520282E6f5717214004A7f26888; + // Uniswap router info on mainnet + address constant UNISWAP_ROUTER = 0xE592427A0AEce92De3Edee1F18E0157C05861564; + // Price feeds + address constant ETH_USD_PRICE_FEED = 0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419; // Price is $1790.45 + address constant USDC_USD_PRICE_FEED = 0x8fFfFfd4AfB6115b954Bd326cbe7B4BA576818f6; + + constructor() { + // Fork setup + vm.createSelectFork( + string.concat( + "https://node-provider.compound.finance/ethereum-mainnet/", vm.envString("NODE_PROVIDER_BYPASS_KEY") + ), + 18429607 // 2023-10-25 13:24:00 PST + ); + + codeJar = new CodeJar(); + console.log("CodeJar deployed to: %s", address(codeJar)); + + stateManager = new QuarkStateManager(); + console.log("QuarkStateManager deployed to: %s", address(stateManager)); + + walletImplementation = new QuarkWallet(codeJar, stateManager); + console.log("QuarkWallet implementation: %s", address(walletImplementation)); + + aliceWallet = + QuarkWallet(payable(new QuarkMinimalProxy(address(walletImplementation), aliceAccount, address(0)))); + console.log("Alice signer: %s", aliceAccount); + console.log("Alice wallet at: %s", address(aliceWallet)); + } + + /* ===== recurring swap tests ===== */ + + function testRecurringSwapExactInSwap() public { + // gas: disable gas metering except while executing operations + vm.pauseGasMetering(); + + uint256 startingUSDC = 100_000e6; + deal(USDC, address(aliceWallet), startingUSDC); + uint40 swapInterval = 86_400; // 1 day interval + uint256 amountToSell = 3_000e6; + // Price of ETH is $1,790.45 at the current block + uint256 expectedAmountOutMinimum = 1.65 ether; + RecurringSwap.SwapConfig memory swapConfig = _createSwapConfig({ + startTime: block.timestamp, + swapInterval: swapInterval, + amount: amountToSell, + isExactOut: false + }); + + QuarkWallet.QuarkOperation memory op = new QuarkOperationHelper().newBasicOpWithCalldata( + aliceWallet, + recurringSwap, + abi.encodeWithSelector(RecurringSwap.swap.selector, swapConfig), + ScriptType.ScriptAddress + ); + op.expiry = swapConfig.swapParams.deadline; + (uint8 v1, bytes32 r1, bytes32 s1) = new SignatureHelper().signOp(alicePrivateKey, aliceWallet, op); + + assertEq(IERC20(WETH).balanceOf(address(aliceWallet)), 0 ether); + assertEq(IERC20(USDC).balanceOf(address(aliceWallet)), startingUSDC); + + // gas: meter execute + vm.resumeGasMetering(); + vm.expectEmit(true, true, true, true); + emit SwapExecuted( + address(aliceWallet), + address(aliceWallet), + USDC, + WETH, + 3_000e6, + 1_674_115_383_192_806_353, // 1.674 WETH + abi.encodePacked(USDC, uint24(500), WETH) + ); + aliceWallet.executeQuarkOperation(op, v1, r1, s1); + + assertGt(IERC20(WETH).balanceOf(address(aliceWallet)), expectedAmountOutMinimum); + assertEq(IERC20(USDC).balanceOf(address(aliceWallet)), startingUSDC - amountToSell); + } + + function testRecurringSwapExactOutSwap() public { + // gas: disable gas metering except while executing operations + vm.pauseGasMetering(); + + uint256 startingUSDC = 100_000e6; + deal(USDC, address(aliceWallet), startingUSDC); + uint40 swapInterval = 86_400; // 1 day interval + uint256 amountToSwap = 10 ether; + // Price of ETH is $1,790.45 at the current block + uint256 expectedAmountInMaximum = 1_800e6 * 10; + RecurringSwap.SwapConfig memory swapConfig = _createSwapConfig({ + startTime: block.timestamp, + swapInterval: swapInterval, + amount: amountToSwap, + isExactOut: true + }); + + QuarkWallet.QuarkOperation memory op = new QuarkOperationHelper().newBasicOpWithCalldata( + aliceWallet, + recurringSwap, + abi.encodeWithSelector(RecurringSwap.swap.selector, swapConfig), + ScriptType.ScriptAddress + ); + op.expiry = swapConfig.swapParams.deadline; + (uint8 v1, bytes32 r1, bytes32 s1) = new SignatureHelper().signOp(alicePrivateKey, aliceWallet, op); + + assertEq(IERC20(WETH).balanceOf(address(aliceWallet)), 0 ether); + assertEq(IERC20(USDC).balanceOf(address(aliceWallet)), startingUSDC); + + // gas: meter execute + vm.resumeGasMetering(); + vm.expectEmit(true, true, true, true); + emit SwapExecuted( + address(aliceWallet), + address(aliceWallet), + USDC, + WETH, + 17_920_004_306, // 17,920 USDC + 10 ether, + abi.encodePacked(WETH, uint24(500), USDC) + ); + aliceWallet.executeQuarkOperation(op, v1, r1, s1); + + assertEq(IERC20(WETH).balanceOf(address(aliceWallet)), amountToSwap); + assertLt(IERC20(USDC).balanceOf(address(aliceWallet)), startingUSDC); + assertGt(IERC20(USDC).balanceOf(address(aliceWallet)), startingUSDC - expectedAmountInMaximum); + } + + // Note: We test a WETH -> USDC swap here instead of the usual USDC -> WETH swap + function testRecurringSwapExactInAlternateSwap() public { + // gas: disable gas metering except while executing operations + vm.pauseGasMetering(); + + uint256 startingWETH = 100 ether; + deal(WETH, address(aliceWallet), startingWETH); + uint40 swapInterval = 86_400; // 1 day interval + uint256 amountToSell = 10 ether; + // Price of ETH is $1,790.45 at the current block + uint256 expectedAmountOutMinimum = 17_800e6; + RecurringSwap.SwapParams memory swapParams = RecurringSwap.SwapParams({ + uniswapRouter: UNISWAP_ROUTER, + recipient: address(aliceWallet), + tokenIn: WETH, + tokenOut: USDC, + amount: amountToSell, + isExactOut: false, + deadline: type(uint256).max, + path: abi.encodePacked(WETH, uint24(500), USDC) + }); + RecurringSwap.SlippageParams memory slippageParams = RecurringSwap.SlippageParams({ + maxSlippage: 1e17, // 1% + priceFeeds: _array1(ETH_USD_PRICE_FEED), + shouldInvert: _array1(false) + }); + RecurringSwap.SwapConfig memory swapConfig = RecurringSwap.SwapConfig({ + startTime: block.timestamp, + interval: swapInterval, + swapParams: swapParams, + slippageParams: slippageParams + }); + + QuarkWallet.QuarkOperation memory op = new QuarkOperationHelper().newBasicOpWithCalldata( + aliceWallet, + recurringSwap, + abi.encodeWithSelector(RecurringSwap.swap.selector, swapConfig), + ScriptType.ScriptAddress + ); + op.expiry = swapConfig.swapParams.deadline; + (uint8 v1, bytes32 r1, bytes32 s1) = new SignatureHelper().signOp(alicePrivateKey, aliceWallet, op); + + assertEq(IERC20(WETH).balanceOf(address(aliceWallet)), startingWETH); + assertEq(IERC20(USDC).balanceOf(address(aliceWallet)), 0e6); + + // gas: meter execute + vm.resumeGasMetering(); + vm.expectEmit(true, true, true, true); + emit SwapExecuted( + address(aliceWallet), + address(aliceWallet), + WETH, + USDC, + amountToSell, + 17_901_866_835, // 17,901.86 USDC + abi.encodePacked(WETH, uint24(500), USDC) + ); + aliceWallet.executeQuarkOperation(op, v1, r1, s1); + + assertEq(IERC20(WETH).balanceOf(address(aliceWallet)), startingWETH - amountToSell); + assertGt(IERC20(USDC).balanceOf(address(aliceWallet)), expectedAmountOutMinimum); + } + + // Note: We test a WETH -> USDC swap here instead of the usual USDC -> WETH swap + function testRecurringSwapExactOutAlternateSwap() public { + // gas: disable gas metering except while executing operations + vm.pauseGasMetering(); + + uint256 startingWETH = 100 ether; + deal(WETH, address(aliceWallet), startingWETH); + uint40 swapInterval = 86_400; // 1 day interval + uint256 amountToSwap = 1_800e6; + // Price of ETH is $1,790.45 at the current block + uint256 expectedAmountInMaximum = 1.1 ether; + RecurringSwap.SwapParams memory swapParams = RecurringSwap.SwapParams({ + uniswapRouter: UNISWAP_ROUTER, + recipient: address(aliceWallet), + tokenIn: WETH, + tokenOut: USDC, + amount: amountToSwap, + isExactOut: true, + deadline: type(uint256).max, + path: abi.encodePacked(USDC, uint24(500), WETH) + }); + RecurringSwap.SlippageParams memory slippageParams = RecurringSwap.SlippageParams({ + maxSlippage: 1e17, // 1% + priceFeeds: _array1(ETH_USD_PRICE_FEED), + shouldInvert: _array1(false) + }); + RecurringSwap.SwapConfig memory swapConfig = RecurringSwap.SwapConfig({ + startTime: block.timestamp, + interval: swapInterval, + swapParams: swapParams, + slippageParams: slippageParams + }); + + QuarkWallet.QuarkOperation memory op = new QuarkOperationHelper().newBasicOpWithCalldata( + aliceWallet, + recurringSwap, + abi.encodeWithSelector(RecurringSwap.swap.selector, swapConfig), + ScriptType.ScriptAddress + ); + op.expiry = swapConfig.swapParams.deadline; + (uint8 v1, bytes32 r1, bytes32 s1) = new SignatureHelper().signOp(alicePrivateKey, aliceWallet, op); + + assertEq(IERC20(WETH).balanceOf(address(aliceWallet)), startingWETH); + assertEq(IERC20(USDC).balanceOf(address(aliceWallet)), 0e6); + + // gas: meter execute + vm.resumeGasMetering(); + vm.expectEmit(true, true, true, true); + emit SwapExecuted( + address(aliceWallet), + address(aliceWallet), + WETH, + USDC, + 1_005_476_123_256_214_692, // 1.005 WETH + amountToSwap, + abi.encodePacked(USDC, uint24(500), WETH) + ); + aliceWallet.executeQuarkOperation(op, v1, r1, s1); + + assertLt(IERC20(WETH).balanceOf(address(aliceWallet)), startingWETH); + assertGt(IERC20(WETH).balanceOf(address(aliceWallet)), startingWETH - expectedAmountInMaximum); + assertEq(IERC20(USDC).balanceOf(address(aliceWallet)), amountToSwap); + } + + function testRecurringSwapCanSwapMultipleTimes() public { + // gas: disable gas metering except while executing operations + vm.pauseGasMetering(); + + deal(USDC, address(aliceWallet), 100_000e6); + uint40 swapInterval = 86_400; // 1 day interval + uint256 amountToSwap = 10 ether; + RecurringSwap.SwapConfig memory swapConfig = _createSwapConfig({ + startTime: block.timestamp, + swapInterval: swapInterval, + amount: amountToSwap, + isExactOut: true + }); + QuarkWallet.QuarkOperation memory op = new QuarkOperationHelper().newBasicOpWithCalldata( + aliceWallet, + recurringSwap, + abi.encodeWithSelector(RecurringSwap.swap.selector, swapConfig), + ScriptType.ScriptAddress + ); + op.expiry = swapConfig.swapParams.deadline; + (uint8 v1, bytes32 r1, bytes32 s1) = new SignatureHelper().signOp(alicePrivateKey, aliceWallet, op); + + assertEq(IERC20(WETH).balanceOf(address(aliceWallet)), 0 ether); + + // gas: meter execute + vm.resumeGasMetering(); + // 1. Execute recurring swap for the first time + aliceWallet.executeQuarkOperation(op, v1, r1, s1); + + assertEq(IERC20(WETH).balanceOf(address(aliceWallet)), amountToSwap); + + // 2a. Cannot buy again unless time interval has passed + vm.expectRevert( + abi.encodeWithSelector( + RecurringSwap.SwapWindowNotOpen.selector, block.timestamp + swapInterval, block.timestamp + ) + ); + aliceWallet.executeQuarkOperation(op, v1, r1, s1); + + // 2b. Execute recurring swap a second time after warping 1 day + vm.warp(block.timestamp + swapInterval); + aliceWallet.executeQuarkOperation(op, v1, r1, s1); + + assertEq(IERC20(WETH).balanceOf(address(aliceWallet)), 2 * amountToSwap); + } + + function testCancelRecurringSwap() public { + // gas: disable gas metering except while executing operations + vm.pauseGasMetering(); + + deal(USDC, address(aliceWallet), 100_000e6); + uint40 swapInterval = 86_400; // 1 day interval + uint256 amountToSwap = 10 ether; + RecurringSwap.SwapConfig memory swapConfig = _createSwapConfig({ + startTime: block.timestamp, + swapInterval: swapInterval, + amount: amountToSwap, + isExactOut: true + }); + QuarkWallet.QuarkOperation memory op = new QuarkOperationHelper().newBasicOpWithCalldata( + aliceWallet, + recurringSwap, + abi.encodeWithSelector(RecurringSwap.swap.selector, swapConfig), + ScriptType.ScriptAddress + ); + op.expiry = swapConfig.swapParams.deadline; + (uint8 v1, bytes32 r1, bytes32 s1) = new SignatureHelper().signOp(alicePrivateKey, aliceWallet, op); + + QuarkWallet.QuarkOperation memory cancelOp = new QuarkOperationHelper().newBasicOpWithCalldata( + aliceWallet, recurringSwap, abi.encodeWithSelector(RecurringSwap.cancel.selector), ScriptType.ScriptAddress + ); + (uint8 v2, bytes32 r2, bytes32 s2) = new SignatureHelper().signOp(alicePrivateKey, aliceWallet, cancelOp); + + assertEq(IERC20(WETH).balanceOf(address(aliceWallet)), 0 ether); + + // gas: meter execute + vm.resumeGasMetering(); + // 1. Execute recurring swap for the first time + aliceWallet.executeQuarkOperation(op, v1, r1, s1); + + assertEq(IERC20(WETH).balanceOf(address(aliceWallet)), amountToSwap); + + // 2. Cancel replayable transaction + aliceWallet.executeQuarkOperation(cancelOp, v2, r2, s2); + + // 3. Replayable transaction can no longer be executed + vm.warp(block.timestamp + swapInterval); + vm.expectRevert(QuarkStateManager.NonceAlreadySet.selector); + aliceWallet.executeQuarkOperation(op, v1, r1, s1); + + assertEq(IERC20(WETH).balanceOf(address(aliceWallet)), amountToSwap); + } + + function testRecurringSwapWithMultiplePriceFeeds() public { + // gas: disable gas metering except while executing operations + vm.pauseGasMetering(); + + uint256 startingUSDC = 100_000e6; + deal(USDC, address(aliceWallet), startingUSDC); + uint40 swapInterval = 86_400; // 1 day interval + uint256 amountToSell = 3_000e6; + uint256 expectedAmountOutMinimum = 1.65 ether; + // We are swapping from USDC -> WETH, so the order of the price feeds should be: + // USDC/USD -> USD/WETH (convert from USDC to USD to WETH) + RecurringSwap.SlippageParams memory slippageParams = RecurringSwap.SlippageParams({ + maxSlippage: 1e17, // 1% accepted slippage + priceFeeds: _array2(USDC_USD_PRICE_FEED, ETH_USD_PRICE_FEED), + shouldInvert: _array2(false, true) + }); + RecurringSwap.SwapConfig memory swapConfig = _createSwapConfig({ + startTime: block.timestamp, + swapInterval: swapInterval, + amount: amountToSell, + isExactOut: false, + slippageParams: slippageParams + }); + QuarkWallet.QuarkOperation memory op = new QuarkOperationHelper().newBasicOpWithCalldata( + aliceWallet, + recurringSwap, + abi.encodeWithSelector(RecurringSwap.swap.selector, swapConfig), + ScriptType.ScriptAddress + ); + op.expiry = swapConfig.swapParams.deadline; + (uint8 v1, bytes32 r1, bytes32 s1) = new SignatureHelper().signOp(alicePrivateKey, aliceWallet, op); + + assertEq(IERC20(WETH).balanceOf(address(aliceWallet)), 0 ether); + assertEq(IERC20(USDC).balanceOf(address(aliceWallet)), startingUSDC); + + // gas: meter execute + vm.resumeGasMetering(); + aliceWallet.executeQuarkOperation(op, v1, r1, s1); + + assertGt(IERC20(WETH).balanceOf(address(aliceWallet)), expectedAmountOutMinimum); + assertEq(IERC20(USDC).balanceOf(address(aliceWallet)), startingUSDC - amountToSell); + } + + function testRecurringSwapWithDifferentCalldata() public { + // gas: disable gas metering except while executing operations + vm.pauseGasMetering(); + + deal(USDC, address(aliceWallet), 100_000e6); + uint40 swapInterval = 86_400; // 1 day interval + uint256 amountToSwap1 = 10 ether; + uint256 amountToSwap2 = 5 ether; + QuarkWallet.QuarkOperation memory op1; + QuarkWallet.QuarkOperation memory op2; + QuarkWallet.QuarkOperation memory cancelOp; + // Local scope to avoid stack too deep + { + // Two swap configs using the same nonce: one to swap 10 ETH and the other to swap 5 ETH + RecurringSwap.SwapConfig memory swapConfig1 = _createSwapConfig({ + startTime: block.timestamp, + swapInterval: swapInterval, + amount: amountToSwap1, + isExactOut: true + }); + op1 = new QuarkOperationHelper().newBasicOpWithCalldata( + aliceWallet, + recurringSwap, + abi.encodeWithSelector(RecurringSwap.swap.selector, swapConfig1), + ScriptType.ScriptAddress + ); + op1.expiry = swapConfig1.swapParams.deadline; + RecurringSwap.SwapConfig memory swapConfig2 = _createSwapConfig({ + startTime: block.timestamp, + swapInterval: swapInterval, + amount: amountToSwap2, + isExactOut: true + }); + op2 = new QuarkOperationHelper().newBasicOpWithCalldata( + aliceWallet, + recurringSwap, + abi.encodeWithSelector(RecurringSwap.swap.selector, swapConfig2), + ScriptType.ScriptAddress + ); + op2.expiry = swapConfig2.swapParams.deadline; + cancelOp = new QuarkOperationHelper().newBasicOpWithCalldata( + aliceWallet, + recurringSwap, + abi.encodeWithSelector(RecurringSwap.cancel.selector), + ScriptType.ScriptAddress + ); + cancelOp.expiry = op2.expiry; + } + (uint8 v1, bytes32 r1, bytes32 s1) = new SignatureHelper().signOp(alicePrivateKey, aliceWallet, op1); + (uint8 v2, bytes32 r2, bytes32 s2) = new SignatureHelper().signOp(alicePrivateKey, aliceWallet, op2); + (uint8 v3, bytes32 r3, bytes32 s3) = new SignatureHelper().signOp(alicePrivateKey, aliceWallet, cancelOp); + + assertEq(IERC20(WETH).balanceOf(address(aliceWallet)), 0 ether); + + // gas: meter execute + vm.resumeGasMetering(); + // 1a. Execute recurring swap order #1 + aliceWallet.executeQuarkOperation(op1, v1, r1, s1); + + assertEq(IERC20(WETH).balanceOf(address(aliceWallet)), amountToSwap1); + + // 1b. Execute recurring swap order #2 + aliceWallet.executeQuarkOperation(op2, v2, r2, s2); + + assertEq(IERC20(WETH).balanceOf(address(aliceWallet)), amountToSwap1 + amountToSwap2); + + // 2. Warp until next swap period + vm.warp(block.timestamp + swapInterval); + + // 3a. Execute recurring swap order #1 + aliceWallet.executeQuarkOperation(op1, v1, r1, s1); + + assertEq(IERC20(WETH).balanceOf(address(aliceWallet)), 2 * amountToSwap1 + amountToSwap2); + + // 3b. Execute recurring swap order #2 + aliceWallet.executeQuarkOperation(op2, v2, r2, s2); + + assertEq(IERC20(WETH).balanceOf(address(aliceWallet)), 2 * amountToSwap1 + 2 * amountToSwap2); + + // 4. Cancel replayable transaction + aliceWallet.executeQuarkOperation(cancelOp, v3, r3, s3); + + // 5. Warp until next swap period + vm.warp(block.timestamp + swapInterval); + + // 6. Both recurring swap orders can no longer be executed + vm.expectRevert(QuarkStateManager.NonceAlreadySet.selector); + aliceWallet.executeQuarkOperation(op1, v1, r1, s1); + vm.expectRevert(QuarkStateManager.NonceAlreadySet.selector); + aliceWallet.executeQuarkOperation(op2, v2, r2, s2); + + assertEq(IERC20(WETH).balanceOf(address(aliceWallet)), 2 * amountToSwap1 + 2 * amountToSwap2); + } + + function testRevertsForInvalidInput() public { + // gas: disable gas metering except while executing operations + vm.pauseGasMetering(); + + uint40 swapInterval = 86_400; // 1 day interval + uint256 amountToSwap = 10 ether; + RecurringSwap.SlippageParams memory invalidSlippageParams1 = RecurringSwap.SlippageParams({ + maxSlippage: 1e17, // 1% + priceFeeds: new address[](0), + shouldInvert: new bool[](0) + }); + RecurringSwap.SlippageParams memory invalidSlippageParams2 = RecurringSwap.SlippageParams({ + maxSlippage: 1e17, // 1% + priceFeeds: new address[](0), + shouldInvert: new bool[](1) + }); + RecurringSwap.SwapConfig memory invalidSwapConfig1 = _createSwapConfig({ + startTime: block.timestamp, + swapInterval: swapInterval, + amount: amountToSwap, + isExactOut: true, + slippageParams: invalidSlippageParams1 + }); + RecurringSwap.SwapConfig memory invalidSwapConfig2 = _createSwapConfig({ + startTime: block.timestamp, + swapInterval: swapInterval, + amount: amountToSwap, + isExactOut: true, + slippageParams: invalidSlippageParams2 + }); + QuarkWallet.QuarkOperation memory op1 = new QuarkOperationHelper().newBasicOpWithCalldata( + aliceWallet, + recurringSwap, + abi.encodeWithSelector(RecurringSwap.swap.selector, invalidSwapConfig1), + ScriptType.ScriptAddress + ); + QuarkWallet.QuarkOperation memory op2 = new QuarkOperationHelper().newBasicOpWithCalldata( + aliceWallet, + recurringSwap, + abi.encodeWithSelector(RecurringSwap.swap.selector, invalidSwapConfig2), + ScriptType.ScriptAddress + ); + op1.expiry = invalidSwapConfig1.swapParams.deadline; + op2.expiry = invalidSwapConfig2.swapParams.deadline; + (uint8 v1, bytes32 r1, bytes32 s1) = new SignatureHelper().signOp(alicePrivateKey, aliceWallet, op1); + (uint8 v2, bytes32 r2, bytes32 s2) = new SignatureHelper().signOp(alicePrivateKey, aliceWallet, op2); + + // gas: meter execute + vm.resumeGasMetering(); + vm.expectRevert(RecurringSwap.InvalidInput.selector); + aliceWallet.executeQuarkOperation(op1, v1, r1, s1); + + vm.expectRevert(RecurringSwap.InvalidInput.selector); + aliceWallet.executeQuarkOperation(op2, v2, r2, s2); + } + + function testRevertsForSwapBeforeNextSwapWindow() public { + // gas: disable gas metering except while executing operations + vm.pauseGasMetering(); + + deal(USDC, address(aliceWallet), 100_000e6); + uint40 swapInterval = 86_400; // 1 day interval + uint256 amountToSwap = 10 ether; + RecurringSwap.SwapConfig memory swapConfig = _createSwapConfig({ + startTime: block.timestamp, + swapInterval: swapInterval, + amount: amountToSwap, + isExactOut: true + }); + QuarkWallet.QuarkOperation memory op = new QuarkOperationHelper().newBasicOpWithCalldata( + aliceWallet, + recurringSwap, + abi.encodeWithSelector(RecurringSwap.swap.selector, swapConfig), + ScriptType.ScriptAddress + ); + op.expiry = swapConfig.swapParams.deadline; + (uint8 v1, bytes32 r1, bytes32 s1) = new SignatureHelper().signOp(alicePrivateKey, aliceWallet, op); + + assertEq(IERC20(WETH).balanceOf(address(aliceWallet)), 0 ether); + + // gas: meter execute + vm.resumeGasMetering(); + // 1. Execute recurring swap for the first time + aliceWallet.executeQuarkOperation(op, v1, r1, s1); + + assertEq(IERC20(WETH).balanceOf(address(aliceWallet)), amountToSwap); + + // 2. Cannot buy again unless time interval has passed + vm.expectRevert( + abi.encodeWithSelector( + RecurringSwap.SwapWindowNotOpen.selector, block.timestamp + swapInterval, block.timestamp + ) + ); + aliceWallet.executeQuarkOperation(op, v1, r1, s1); + + assertEq(IERC20(WETH).balanceOf(address(aliceWallet)), amountToSwap); + } + + function testRevertsForSwapBeforeStartTime() public { + // gas: disable gas metering except while executing operations + vm.pauseGasMetering(); + + deal(USDC, address(aliceWallet), 100_000e6); + uint40 swapInterval = 86_400; // 1 day interval + uint256 amountToSwap = 10 ether; + RecurringSwap.SwapConfig memory swapConfig = _createSwapConfig({ + startTime: block.timestamp + 100, + swapInterval: swapInterval, + amount: amountToSwap, + isExactOut: true + }); + QuarkWallet.QuarkOperation memory op = new QuarkOperationHelper().newBasicOpWithCalldata( + aliceWallet, + recurringSwap, + abi.encodeWithSelector(RecurringSwap.swap.selector, swapConfig), + ScriptType.ScriptAddress + ); + op.expiry = swapConfig.swapParams.deadline; + (uint8 v1, bytes32 r1, bytes32 s1) = new SignatureHelper().signOp(alicePrivateKey, aliceWallet, op); + + assertEq(IERC20(WETH).balanceOf(address(aliceWallet)), 0 ether); + + // gas: meter execute + vm.expectRevert( + abi.encodeWithSelector(RecurringSwap.SwapWindowNotOpen.selector, block.timestamp + 100, block.timestamp) + ); + aliceWallet.executeQuarkOperation(op, v1, r1, s1); + } + + function testRevertsForExpiredQuarkOperation() public { + // gas: disable gas metering except while executing operations + vm.pauseGasMetering(); + + deal(USDC, address(aliceWallet), 100_000e6); + uint40 swapInterval = 86_400; // 1 day interval + uint256 amountToSwap = 10 ether; + RecurringSwap.SwapConfig memory swapConfig = _createSwapConfig({ + startTime: block.timestamp, + swapInterval: swapInterval, + amount: amountToSwap, + isExactOut: true + }); + QuarkWallet.QuarkOperation memory op = new QuarkOperationHelper().newBasicOpWithCalldata( + aliceWallet, + recurringSwap, + abi.encodeWithSelector(RecurringSwap.swap.selector, swapConfig), + ScriptType.ScriptAddress + ); + op.expiry = block.timestamp - 1; // Set Quark operation expiry to always expire + (uint8 v1, bytes32 r1, bytes32 s1) = new SignatureHelper().signOp(alicePrivateKey, aliceWallet, op); + + // gas: meter execute + vm.resumeGasMetering(); + vm.expectRevert(QuarkWallet.SignatureExpired.selector); + aliceWallet.executeQuarkOperation(op, v1, r1, s1); + } + + function testRevertsForExpiredUniswapParams() public { + // gas: disable gas metering except while executing operations + vm.pauseGasMetering(); + + deal(USDC, address(aliceWallet), 100_000e6); + uint40 swapInterval = 86_400; // 1 day interval + uint256 amountToSwap = 10 ether; + RecurringSwap.SwapConfig memory swapConfig = _createSwapConfig({ + startTime: block.timestamp, + swapInterval: swapInterval, + amount: amountToSwap, + isExactOut: true + }); + swapConfig.swapParams.deadline = block.timestamp - 1; // Set Uniswap deadline to always expire + QuarkWallet.QuarkOperation memory op = new QuarkOperationHelper().newBasicOpWithCalldata( + aliceWallet, + recurringSwap, + abi.encodeWithSelector(RecurringSwap.swap.selector, swapConfig), + ScriptType.ScriptAddress + ); + (uint8 v1, bytes32 r1, bytes32 s1) = new SignatureHelper().signOp(alicePrivateKey, aliceWallet, op); + + // gas: meter execute + vm.resumeGasMetering(); + vm.expectRevert(bytes("Transaction too old")); + aliceWallet.executeQuarkOperation(op, v1, r1, s1); + } + + function testRevertsWhenSlippageTooHigh() public { + // gas: disable gas metering except while executing operations + vm.pauseGasMetering(); + + deal(USDC, address(aliceWallet), 100_000e6); + uint40 swapInterval = 86_400; // 1 day interval + uint256 amountToSell = 3_000e6; + RecurringSwap.SlippageParams memory slippageParams = RecurringSwap.SlippageParams({ + maxSlippage: 0e18, // 0% accepted slippage + priceFeeds: _array1(ETH_USD_PRICE_FEED), + shouldInvert: _array1(true) + }); + RecurringSwap.SwapConfig memory swapConfig = _createSwapConfig({ + startTime: block.timestamp, + swapInterval: swapInterval, + amount: amountToSell, + isExactOut: false, + slippageParams: slippageParams + }); + QuarkWallet.QuarkOperation memory op = new QuarkOperationHelper().newBasicOpWithCalldata( + aliceWallet, + recurringSwap, + abi.encodeWithSelector(RecurringSwap.swap.selector, swapConfig), + ScriptType.ScriptAddress + ); + op.expiry = swapConfig.swapParams.deadline; + (uint8 v1, bytes32 r1, bytes32 s1) = new SignatureHelper().signOp(alicePrivateKey, aliceWallet, op); + + assertEq(IERC20(WETH).balanceOf(address(aliceWallet)), 0 ether); + + // gas: meter execute + vm.resumeGasMetering(); + vm.expectRevert(bytes("Too little received")); + aliceWallet.executeQuarkOperation(op, v1, r1, s1); + } + + function testRevertsWhenSlippageParamsConfiguredWrong() public { + // gas: disable gas metering except while executing operations + vm.pauseGasMetering(); + + deal(USDC, address(aliceWallet), 100_000e6); + uint40 swapInterval = 86_400; // 1 day interval + uint256 amountToSell = 3_000e6; + RecurringSwap.SlippageParams memory slippageParams = RecurringSwap.SlippageParams({ + maxSlippage: 5e17, // 5% accepted slippage + priceFeeds: _array1(ETH_USD_PRICE_FEED), + shouldInvert: _array1(false) // Should be true because this is a USDC -> ETH swap + }); + RecurringSwap.SwapConfig memory swapConfig = _createSwapConfig({ + startTime: block.timestamp, + swapInterval: swapInterval, + amount: amountToSell, + isExactOut: false, + slippageParams: slippageParams + }); + QuarkWallet.QuarkOperation memory op = new QuarkOperationHelper().newBasicOpWithCalldata( + aliceWallet, + recurringSwap, + abi.encodeWithSelector(RecurringSwap.swap.selector, swapConfig), + ScriptType.ScriptAddress + ); + op.expiry = swapConfig.swapParams.deadline; + (uint8 v1, bytes32 r1, bytes32 s1) = new SignatureHelper().signOp(alicePrivateKey, aliceWallet, op); + + assertEq(IERC20(WETH).balanceOf(address(aliceWallet)), 0 ether); + + // gas: meter execute + vm.resumeGasMetering(); + vm.expectRevert(bytes("Too little received")); + aliceWallet.executeQuarkOperation(op, v1, r1, s1); + } + + /* ===== helper functions ===== */ + + function _array1(address address0) internal pure returns (address[] memory) { + address[] memory arr = new address[](1); + arr[0] = address0; + return arr; + } + + function _array1(bool bool0) internal pure returns (bool[] memory) { + bool[] memory arr = new bool[](1); + arr[0] = bool0; + return arr; + } + + function _array2(address address0, address address1) internal pure returns (address[] memory) { + address[] memory arr = new address[](2); + arr[0] = address0; + arr[1] = address1; + return arr; + } + + function _array2(bool bool0, bool bool1) internal pure returns (bool[] memory) { + bool[] memory arr = new bool[](2); + arr[0] = bool0; + arr[1] = bool1; + return arr; + } + + function _createSwapConfig(uint256 startTime, uint256 swapInterval, uint256 amount, bool isExactOut) + internal + view + returns (RecurringSwap.SwapConfig memory) + { + RecurringSwap.SlippageParams memory slippageParams = RecurringSwap.SlippageParams({ + maxSlippage: 1e17, // 1% + priceFeeds: _array1(ETH_USD_PRICE_FEED), + shouldInvert: _array1(true) + }); + return _createSwapConfig({ + startTime: startTime, + swapInterval: swapInterval, + amount: amount, + isExactOut: isExactOut, + slippageParams: slippageParams + }); + } + + function _createSwapConfig( + uint256 startTime, + uint256 swapInterval, + uint256 amount, + bool isExactOut, + RecurringSwap.SlippageParams memory slippageParams + ) internal view returns (RecurringSwap.SwapConfig memory) { + bytes memory swapPath; + if (isExactOut) { + // Exact out swap + swapPath = abi.encodePacked(WETH, uint24(500), USDC); + } else { + // Exact in swap + swapPath = abi.encodePacked(USDC, uint24(500), WETH); + } + RecurringSwap.SwapParams memory swapParams = RecurringSwap.SwapParams({ + uniswapRouter: UNISWAP_ROUTER, + recipient: address(aliceWallet), + tokenIn: USDC, + tokenOut: WETH, + amount: amount, + isExactOut: isExactOut, + deadline: type(uint256).max, + path: swapPath + }); + RecurringSwap.SwapConfig memory swapConfig = RecurringSwap.SwapConfig({ + startTime: startTime, + interval: swapInterval, + swapParams: swapParams, + slippageParams: slippageParams + }); + return swapConfig; + } +} diff --git a/test/ReplayableTransactions.t.sol b/test/ReplayableTransactions.t.sol deleted file mode 100644 index b7da0380..00000000 --- a/test/ReplayableTransactions.t.sol +++ /dev/null @@ -1,419 +0,0 @@ -// SPDX-License-Identifier: BSD-3-Clause -pragma solidity 0.8.23; - -import "forge-std/Test.sol"; -import "forge-std/console.sol"; - -import {CodeJar} from "codejar/src/CodeJar.sol"; - -import {QuarkStateManager} from "quark-core/src/QuarkStateManager.sol"; -import {QuarkWallet} from "quark-core/src/QuarkWallet.sol"; - -import {QuarkMinimalProxy} from "quark-proxy/src/QuarkMinimalProxy.sol"; - -import {RecurringPurchase} from "./lib/RecurringPurchase.sol"; - -import {YulHelper} from "./lib/YulHelper.sol"; -import {SignatureHelper} from "./lib/SignatureHelper.sol"; -import {QuarkOperationHelper, ScriptType} from "./lib/QuarkOperationHelper.sol"; - -import {IERC20} from "openzeppelin/token/ERC20/IERC20.sol"; - -// TODO: Limit orders -// TODO: Liquidation protection -contract ReplayableTransactionsTest is Test { - event Ping(uint256); - event ClearNonce(address indexed wallet, uint96 nonce); - - CodeJar public codeJar; - QuarkStateManager public stateManager; - QuarkWallet public walletImplementation; - - uint256 alicePrivateKey = 0x8675309; - address aliceAccount = vm.addr(alicePrivateKey); - QuarkWallet aliceWallet; // see constructor() - - bytes recurringPurchase = new YulHelper().getCode("RecurringPurchase.sol/RecurringPurchase.json"); - - // Contracts address on mainnet - address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; - address constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; - address constant COMP = 0xc00e94Cb662C3520282E6f5717214004A7f26888; - // Uniswap router info on mainnet - address constant uniswapRouter = 0xE592427A0AEce92De3Edee1F18E0157C05861564; - - constructor() { - // Fork setup - vm.createSelectFork( - string.concat( - "https://node-provider.compound.finance/ethereum-mainnet/", vm.envString("NODE_PROVIDER_BYPASS_KEY") - ), - 18429607 // 2023-10-25 13:24:00 PST - ); - - codeJar = new CodeJar(); - console.log("CodeJar deployed to: %s", address(codeJar)); - - stateManager = new QuarkStateManager(); - console.log("QuarkStateManager deployed to: %s", address(stateManager)); - - walletImplementation = new QuarkWallet(codeJar, stateManager); - console.log("QuarkWallet implementation: %s", address(walletImplementation)); - - aliceWallet = - QuarkWallet(payable(new QuarkMinimalProxy(address(walletImplementation), aliceAccount, address(0)))); - console.log("Alice signer: %s", aliceAccount); - console.log("Alice wallet at: %s", address(aliceWallet)); - } - - /* ===== recurring purchase tests ===== */ - - function createPurchaseConfig(uint40 purchaseInterval, uint256 timesToPurchase, uint216 totalAmountToPurchase) - internal - view - returns (RecurringPurchase.PurchaseConfig memory) - { - uint256 deadline = block.timestamp + purchaseInterval * (timesToPurchase - 1) + 1; - RecurringPurchase.SwapParamsExactOut memory swapParams = RecurringPurchase.SwapParamsExactOut({ - uniswapRouter: uniswapRouter, - recipient: address(aliceWallet), - tokenFrom: USDC, - amount: uint256(totalAmountToPurchase) / timesToPurchase, - amountInMaximum: 30_000e6, - deadline: deadline, - path: abi.encodePacked(WETH, uint24(500), USDC) // Path: WETH - 0.05% -> USDC - }); - RecurringPurchase.PurchaseConfig memory purchaseConfig = RecurringPurchase.PurchaseConfig({ - interval: purchaseInterval, - totalAmountToPurchase: totalAmountToPurchase, - swapParams: swapParams - }); - return purchaseConfig; - } - - // Executes the script once for gas measurement purchases - function testRecurringPurchaseHappyPath() public { - // gas: disable gas metering except while executing operations - vm.pauseGasMetering(); - - deal(USDC, address(aliceWallet), 100_000e6); - uint40 purchaseInterval = 86_400; // 1 day interval - uint256 timesToPurchase = 1; - uint216 totalAmountToPurchase = 10 ether; - RecurringPurchase.PurchaseConfig memory purchaseConfig = - createPurchaseConfig(purchaseInterval, timesToPurchase, totalAmountToPurchase); - QuarkWallet.QuarkOperation memory op = new QuarkOperationHelper().newBasicOpWithCalldata( - aliceWallet, - recurringPurchase, - abi.encodeWithSelector(RecurringPurchase.purchase.selector, purchaseConfig), - ScriptType.ScriptAddress - ); - op.expiry = purchaseConfig.swapParams.deadline; - (uint8 v1, bytes32 r1, bytes32 s1) = new SignatureHelper().signOp(alicePrivateKey, aliceWallet, op); - - assertEq(IERC20(WETH).balanceOf(address(aliceWallet)), 0 ether); - - // gas: meter execute - vm.resumeGasMetering(); - aliceWallet.executeQuarkOperation(op, v1, r1, s1); - - assertEq(IERC20(WETH).balanceOf(address(aliceWallet)), totalAmountToPurchase); - } - - function testRecurringPurchaseMultiplePurchases() public { - // gas: disable gas metering except while executing operations - vm.pauseGasMetering(); - - deal(USDC, address(aliceWallet), 100_000e6); - uint40 purchaseInterval = 86_400; // 1 day interval - uint256 timesToPurchase = 2; - uint216 totalAmountToPurchase = 20 ether; - RecurringPurchase.PurchaseConfig memory purchaseConfig = - createPurchaseConfig(purchaseInterval, timesToPurchase, totalAmountToPurchase); - QuarkWallet.QuarkOperation memory op = new QuarkOperationHelper().newBasicOpWithCalldata( - aliceWallet, - recurringPurchase, - abi.encodeWithSelector(RecurringPurchase.purchase.selector, purchaseConfig), - ScriptType.ScriptAddress - ); - op.expiry = purchaseConfig.swapParams.deadline; - (uint8 v1, bytes32 r1, bytes32 s1) = new SignatureHelper().signOp(alicePrivateKey, aliceWallet, op); - - assertEq(IERC20(WETH).balanceOf(address(aliceWallet)), 0 ether); - - // gas: meter execute - vm.resumeGasMetering(); - // 1. Execute recurring purchase for the first time - aliceWallet.executeQuarkOperation(op, v1, r1, s1); - - assertEq(IERC20(WETH).balanceOf(address(aliceWallet)), 10 ether); - - // 2a. Cannot buy again unless time interval has passed - vm.expectRevert(RecurringPurchase.PurchaseConditionNotMet.selector); - aliceWallet.executeQuarkOperation(op, v1, r1, s1); - - // 2b. Execute recurring purchase a second time after warping 1 day - vm.warp(block.timestamp + purchaseInterval); - aliceWallet.executeQuarkOperation(op, v1, r1, s1); - - assertEq(IERC20(WETH).balanceOf(address(aliceWallet)), 20 ether); - } - - function testCancelRecurringPurchase() public { - // gas: disable gas metering except while executing operations - vm.pauseGasMetering(); - - deal(USDC, address(aliceWallet), 100_000e6); - uint40 purchaseInterval = 86_400; // 1 day interval - uint256 timesToPurchase = 2; - uint216 totalAmountToPurchase = 20 ether; - RecurringPurchase.PurchaseConfig memory purchaseConfig = - createPurchaseConfig(purchaseInterval, timesToPurchase, totalAmountToPurchase); - QuarkWallet.QuarkOperation memory op = new QuarkOperationHelper().newBasicOpWithCalldata( - aliceWallet, - recurringPurchase, - abi.encodeWithSelector(RecurringPurchase.purchase.selector, purchaseConfig), - ScriptType.ScriptAddress - ); - op.expiry = purchaseConfig.swapParams.deadline; - (uint8 v1, bytes32 r1, bytes32 s1) = new SignatureHelper().signOp(alicePrivateKey, aliceWallet, op); - - QuarkWallet.QuarkOperation memory cancelOp = new QuarkOperationHelper().newBasicOpWithCalldata( - aliceWallet, - recurringPurchase, - abi.encodeWithSelector(RecurringPurchase.cancel.selector), - ScriptType.ScriptAddress - ); - (uint8 v2, bytes32 r2, bytes32 s2) = new SignatureHelper().signOp(alicePrivateKey, aliceWallet, cancelOp); - - assertEq(IERC20(WETH).balanceOf(address(aliceWallet)), 0 ether); - - // gas: meter execute - vm.resumeGasMetering(); - // 1. Execute recurring purchase for the first time - aliceWallet.executeQuarkOperation(op, v1, r1, s1); - - assertEq(IERC20(WETH).balanceOf(address(aliceWallet)), 10 ether); - - // 2. Cancel replayable transaction - aliceWallet.executeQuarkOperation(cancelOp, v2, r2, s2); - - // 3. Replayable transaction can no longer be executed - vm.warp(block.timestamp + purchaseInterval); - vm.expectRevert(QuarkStateManager.NonceAlreadySet.selector); - aliceWallet.executeQuarkOperation(op, v1, r1, s1); - - assertEq(IERC20(WETH).balanceOf(address(aliceWallet)), 10 ether); - } - - function testRecurringPurchaseWithDifferentCalldata() public { - // gas: disable gas metering except while executing operations - vm.pauseGasMetering(); - - deal(USDC, address(aliceWallet), 100_000e6); - uint40 purchaseInterval = 86_400; // 1 day interval - QuarkWallet.QuarkOperation memory op1; - QuarkWallet.QuarkOperation memory op2; - QuarkWallet.QuarkOperation memory cancelOp; - // Local scope to avoid stack too deep - { - uint256 timesToPurchase = 3; - uint216 totalAmountToPurchase1 = 30 ether; // 10 ETH / day - uint216 totalAmountToPurchase2 = 15 ether; // 5 ETH / day - // Two purchase configs using the same nonce: one to purchase 10 ETH and the other to purchase 5 ETH - RecurringPurchase.PurchaseConfig memory purchaseConfig1 = - createPurchaseConfig(purchaseInterval, timesToPurchase, totalAmountToPurchase1); - op1 = new QuarkOperationHelper().newBasicOpWithCalldata( - aliceWallet, - recurringPurchase, - abi.encodeWithSelector(RecurringPurchase.purchase.selector, purchaseConfig1), - ScriptType.ScriptAddress - ); - op1.expiry = purchaseConfig1.swapParams.deadline; - RecurringPurchase.PurchaseConfig memory purchaseConfig2 = - createPurchaseConfig(purchaseInterval, timesToPurchase, totalAmountToPurchase2); - op2 = new QuarkOperationHelper().newBasicOpWithCalldata( - aliceWallet, - recurringPurchase, - abi.encodeWithSelector(RecurringPurchase.purchase.selector, purchaseConfig2), - ScriptType.ScriptAddress - ); - op2.expiry = purchaseConfig2.swapParams.deadline; - cancelOp = new QuarkOperationHelper().newBasicOpWithCalldata( - aliceWallet, - recurringPurchase, - abi.encodeWithSelector(RecurringPurchase.cancel.selector), - ScriptType.ScriptAddress - ); - cancelOp.expiry = op2.expiry; - } - (uint8 v1, bytes32 r1, bytes32 s1) = new SignatureHelper().signOp(alicePrivateKey, aliceWallet, op1); - (uint8 v2, bytes32 r2, bytes32 s2) = new SignatureHelper().signOp(alicePrivateKey, aliceWallet, op2); - (uint8 v3, bytes32 r3, bytes32 s3) = new SignatureHelper().signOp(alicePrivateKey, aliceWallet, cancelOp); - - assertEq(IERC20(WETH).balanceOf(address(aliceWallet)), 0 ether); - - // gas: meter execute - vm.resumeGasMetering(); - // 1a. Execute recurring purchase order #1 - aliceWallet.executeQuarkOperation(op1, v1, r1, s1); - - assertEq(IERC20(WETH).balanceOf(address(aliceWallet)), 10 ether); - - // 1b. Execute recurring purchase order #2 - aliceWallet.executeQuarkOperation(op2, v2, r2, s2); - - assertEq(IERC20(WETH).balanceOf(address(aliceWallet)), 15 ether); - - // 2. Warp until next purchase period - vm.warp(block.timestamp + purchaseInterval); - - // 3a. Execute recurring purchase order #1 - aliceWallet.executeQuarkOperation(op1, v1, r1, s1); - - assertEq(IERC20(WETH).balanceOf(address(aliceWallet)), 25 ether); - - // 3b. Execute recurring purchase order #2 - aliceWallet.executeQuarkOperation(op2, v2, r2, s2); - - assertEq(IERC20(WETH).balanceOf(address(aliceWallet)), 30 ether); - - // 4. Cancel replayable transaction - aliceWallet.executeQuarkOperation(cancelOp, v3, r3, s3); - - // 5. Warp until next purchase period - vm.warp(block.timestamp + purchaseInterval); - - // 6. Both recurring purchase orders can no longer be executed - vm.expectRevert(QuarkStateManager.NonceAlreadySet.selector); - aliceWallet.executeQuarkOperation(op1, v1, r1, s1); - vm.expectRevert(QuarkStateManager.NonceAlreadySet.selector); - aliceWallet.executeQuarkOperation(op2, v2, r2, s2); - - assertEq(IERC20(WETH).balanceOf(address(aliceWallet)), 30 ether); - } - - function testRevertsForPurchaseBeforeNextPurchasePeriod() public { - // gas: disable gas metering except while executing operations - vm.pauseGasMetering(); - - deal(USDC, address(aliceWallet), 100_000e6); - uint40 purchaseInterval = 86_400; // 1 day interval - uint256 timesToPurchase = 2; - uint216 totalAmountToPurchase = 20 ether; - RecurringPurchase.PurchaseConfig memory purchaseConfig = - createPurchaseConfig(purchaseInterval, timesToPurchase, totalAmountToPurchase); - QuarkWallet.QuarkOperation memory op = new QuarkOperationHelper().newBasicOpWithCalldata( - aliceWallet, - recurringPurchase, - abi.encodeWithSelector(RecurringPurchase.purchase.selector, purchaseConfig), - ScriptType.ScriptAddress - ); - op.expiry = purchaseConfig.swapParams.deadline; - (uint8 v1, bytes32 r1, bytes32 s1) = new SignatureHelper().signOp(alicePrivateKey, aliceWallet, op); - - assertEq(IERC20(WETH).balanceOf(address(aliceWallet)), 0 ether); - - // gas: meter execute - vm.resumeGasMetering(); - // 1. Execute recurring purchase for the first time - aliceWallet.executeQuarkOperation(op, v1, r1, s1); - - assertEq(IERC20(WETH).balanceOf(address(aliceWallet)), 10 ether); - - // 2. Cannot buy again unless time interval has passed - vm.expectRevert(RecurringPurchase.PurchaseConditionNotMet.selector); - aliceWallet.executeQuarkOperation(op, v1, r1, s1); - - assertEq(IERC20(WETH).balanceOf(address(aliceWallet)), 10 ether); - } - - function testRevertsForExpiredQuarkOperation() public { - // gas: disable gas metering except while executing operations - vm.pauseGasMetering(); - - deal(USDC, address(aliceWallet), 100_000e6); - uint40 purchaseInterval = 86_400; // 1 day interval - uint256 timesToPurchase = 1; - uint216 totalAmountToPurchase = 10 ether; - RecurringPurchase.PurchaseConfig memory purchaseConfig = - createPurchaseConfig(purchaseInterval, timesToPurchase, totalAmountToPurchase); - QuarkWallet.QuarkOperation memory op = new QuarkOperationHelper().newBasicOpWithCalldata( - aliceWallet, - recurringPurchase, - abi.encodeWithSelector(RecurringPurchase.purchase.selector, purchaseConfig), - ScriptType.ScriptAddress - ); - op.expiry = block.timestamp - 1; // Set Quark operation expiry to always expire - (uint8 v1, bytes32 r1, bytes32 s1) = new SignatureHelper().signOp(alicePrivateKey, aliceWallet, op); - - // gas: meter execute - vm.resumeGasMetering(); - vm.expectRevert(QuarkWallet.SignatureExpired.selector); - aliceWallet.executeQuarkOperation(op, v1, r1, s1); - } - - function testRevertsForExpiredUniswapParams() public { - // gas: disable gas metering except while executing operations - vm.pauseGasMetering(); - - deal(USDC, address(aliceWallet), 100_000e6); - uint40 purchaseInterval = 86_400; // 1 day interval - uint256 timesToPurchase = 1; - uint216 totalAmountToPurchase = 10 ether; - RecurringPurchase.PurchaseConfig memory purchaseConfig = - createPurchaseConfig(purchaseInterval, timesToPurchase, totalAmountToPurchase); - purchaseConfig.swapParams.deadline = block.timestamp - 1; // Set Uniswap deadline to always expire - QuarkWallet.QuarkOperation memory op = new QuarkOperationHelper().newBasicOpWithCalldata( - aliceWallet, - recurringPurchase, - abi.encodeWithSelector(RecurringPurchase.purchase.selector, purchaseConfig), - ScriptType.ScriptAddress - ); - (uint8 v1, bytes32 r1, bytes32 s1) = new SignatureHelper().signOp(alicePrivateKey, aliceWallet, op); - - // gas: meter execute - vm.resumeGasMetering(); - vm.expectRevert(bytes("Transaction too old")); - aliceWallet.executeQuarkOperation(op, v1, r1, s1); - } - - function testRevertsForPurchasingOverTheLimit() public { - // gas: disable gas metering except while executing operations - vm.pauseGasMetering(); - - deal(USDC, address(aliceWallet), 100_000e6); - uint40 purchaseInterval = 86_400; // 1 day interval - uint256 timesToPurchase = 2; - uint216 totalAmountToPurchase = 20 ether; // 10 ETH / day - RecurringPurchase.PurchaseConfig memory purchaseConfig = - createPurchaseConfig(purchaseInterval, timesToPurchase, totalAmountToPurchase); - purchaseConfig.totalAmountToPurchase = 10 ether; // Will only be able to purchase once - QuarkWallet.QuarkOperation memory op = new QuarkOperationHelper().newBasicOpWithCalldata( - aliceWallet, - recurringPurchase, - abi.encodeWithSelector(RecurringPurchase.purchase.selector, purchaseConfig), - ScriptType.ScriptAddress - ); - op.expiry = purchaseConfig.swapParams.deadline; - (uint8 v1, bytes32 r1, bytes32 s1) = new SignatureHelper().signOp(alicePrivateKey, aliceWallet, op); - - assertEq(IERC20(WETH).balanceOf(address(aliceWallet)), 0 ether); - - // gas: meter execute - vm.resumeGasMetering(); - // 1. Execute recurring purchase - aliceWallet.executeQuarkOperation(op, v1, r1, s1); - - assertEq(IERC20(WETH).balanceOf(address(aliceWallet)), 10 ether); - - // 2. Warp until next purchase period - vm.warp(block.timestamp + purchaseInterval); - - // 3. Purchasing again will go over the `totalAmountToPurchase` cap - vm.expectRevert(RecurringPurchase.PurchaseConditionNotMet.selector); - aliceWallet.executeQuarkOperation(op, v1, r1, s1); - - assertEq(IERC20(WETH).balanceOf(address(aliceWallet)), 10 ether); - } -} diff --git a/test/lib/RecurringPurchase.sol b/test/lib/RecurringPurchase.sol deleted file mode 100644 index 9b7fc77f..00000000 --- a/test/lib/RecurringPurchase.sol +++ /dev/null @@ -1,120 +0,0 @@ -// SPDX-License-Identifier: BSD-3-Clause -pragma solidity 0.8.23; - -import {IERC20} from "openzeppelin/token/ERC20/IERC20.sol"; -import {SafeERC20} from "openzeppelin/token/ERC20/utils/SafeERC20.sol"; - -import {ISwapRouter} from "v3-periphery/interfaces/ISwapRouter.sol"; - -import {QuarkWallet} from "quark-core/src/QuarkWallet.sol"; -import {QuarkScript} from "quark-core/src/QuarkScript.sol"; - -contract RecurringPurchase is QuarkScript { - using SafeERC20 for IERC20; - - error PurchaseConditionNotMet(); - - /** - * @dev Note: This script uses the following storage layout: - * mapping(bytes32 hashedPurchaseConfig => PurchaseState purchaseState) - * where hashedPurchaseConfig = keccak256(PurchaseConfig) - */ - - // TODO: Support exact input swaps - struct SwapParamsExactOut { - address uniswapRouter; - address recipient; - address tokenFrom; - uint256 amount; - // Maximum amount of input token to spend (revert if input amount is greater than this) - uint256 amountInMaximum; - uint256 deadline; - // Path of the swap - bytes path; - } - - // TODO: Consider adding a purchaseWindow - struct PurchaseConfig { - uint40 interval; - uint216 totalAmountToPurchase; - SwapParamsExactOut swapParams; - } - - struct PurchaseState { - uint216 totalPurchased; - uint40 nextPurchaseTime; - } - - function purchase(PurchaseConfig calldata config) public { - allowReplay(); - - bytes32 hashedConfig = hashConfig(config); - PurchaseState memory purchaseState; - if (read(hashedConfig) == 0) { - purchaseState = PurchaseState({totalPurchased: 0, nextPurchaseTime: uint40(block.timestamp)}); - } else { - bytes memory prevState = abi.encode(read(hashedConfig)); - uint216 totalPurchased; - uint40 nextPurchaseTime; - // We need assembly to decode packed structs - assembly { - totalPurchased := mload(add(prevState, 27)) - nextPurchaseTime := mload(add(prevState, 32)) - } - purchaseState = PurchaseState({totalPurchased: totalPurchased, nextPurchaseTime: nextPurchaseTime}); - } - - // Check conditions - if (block.timestamp < purchaseState.nextPurchaseTime) { - revert PurchaseConditionNotMet(); - } - if (purchaseState.totalPurchased + config.swapParams.amount > config.totalAmountToPurchase) { - revert PurchaseConditionNotMet(); - } - - SwapParamsExactOut memory swapParams = config.swapParams; - IERC20(swapParams.tokenFrom).forceApprove(swapParams.uniswapRouter, swapParams.amountInMaximum); - ISwapRouter(swapParams.uniswapRouter).exactOutput( - ISwapRouter.ExactOutputParams({ - path: swapParams.path, - recipient: swapParams.recipient, - deadline: swapParams.deadline, - amountOut: swapParams.amount, - amountInMaximum: swapParams.amountInMaximum - }) - ); - - PurchaseState memory newPurchaseState = PurchaseState({ - totalPurchased: purchaseState.totalPurchased + uint216(config.swapParams.amount), - // TODO: or should it be purchaseState.nextPurchaseTime + config.interval? - nextPurchaseTime: purchaseState.nextPurchaseTime + config.interval - }); - - // Write new PurchaseState to storage - write( - hashedConfig, bytes32(abi.encodePacked(newPurchaseState.totalPurchased, newPurchaseState.nextPurchaseTime)) - ); - } - - function cancel() external { - // Not explicitly clearing the nonce just cancels the replayable txn - } - - function hashConfig(PurchaseConfig calldata config) internal pure returns (bytes32) { - return keccak256( - abi.encodePacked( - config.interval, - config.totalAmountToPurchase, - abi.encodePacked( - config.swapParams.uniswapRouter, - config.swapParams.recipient, - config.swapParams.tokenFrom, - config.swapParams.amount, - config.swapParams.amountInMaximum, - config.swapParams.deadline, - config.swapParams.path - ) - ) - ); - } -} From 2659a118f0f03a495ffaf56d47133a568acf9a1f Mon Sep 17 00:00:00 2001 From: Kevin Cheng Date: Thu, 29 Aug 2024 14:53:47 -0700 Subject: [PATCH 02/13] Support recurring swaps in QuarkBuilder (#65) This implements the QuarkBuilder logic to construct Quark Operations for recurring swaps. We introduce a few new libraries that hardcode some addresses: `PriceFeeds.sol` and `UniswapRouter.sol`. --- README.md | 4 +- src/builder/Actions.sol | 132 +++++- src/builder/PriceFeeds.sol | 284 ++++++++++++ src/builder/QuarkBuilder.sol | 173 +++++++- src/builder/UniswapRouter.sol | 43 ++ test/builder/PriceFeeds.t.sol | 79 ++++ test/builder/QuarkBuilderRecurringSwap.t.sol | 432 +++++++++++++++++++ 7 files changed, 1134 insertions(+), 13 deletions(-) create mode 100644 src/builder/PriceFeeds.sol create mode 100644 src/builder/UniswapRouter.sol create mode 100644 test/builder/PriceFeeds.t.sol create mode 100644 test/builder/QuarkBuilderRecurringSwap.t.sol diff --git a/README.md b/README.md index df06aded..c59ff5fa 100644 --- a/README.md +++ b/README.md @@ -10,13 +10,13 @@ Quark is an Ethereum smart contract wallet system, designed to run custom code Replayable scripts are Quark scripts that can re-executed multiple times using the same signature of a _Quark operation_. More specifically, replayable scripts explicitly clear the nonce used by the transaction (can be done via the `allowReplay` helper function in [`QuarkScript.sol`](./lib/quark/src/quark-core/src/QuarkScript.sol)) to allow for the same nonce to be re-used with the same script address. -An example use-case for replayable scripts is recurring purchases. If a user wanted to buy X WETH using 1,000 USDC every Wednesday until 10,000 USDC is spent, they can achieve this by signing a single _Quark operation_ of a replayable script ([example](./test/lib/RecurringPurchase.sol)). A submitter can then submit this same signed _Quark operation_ every Wednesday to execute the recurring purchase. The replayable script should have checks to ensure conditions are met before purchasing the WETH. +An example use-case for replayable scripts is recurring purchases. If a user wanted to buy X WETH using 1,000 USDC every Wednesday until 10,000 USDC is spent, they can achieve this by signing a single _Quark operation_ of a replayable script ([example](./src/RecurringSwap.sol)). A submitter can then submit this same signed _Quark operation_ every Wednesday to execute the recurring purchase. The replayable script should have checks to ensure conditions are met before purchasing the WETH. #### Same script address, but different calldata For replayable transactions where the nonce is cleared, _Quark State Manager_ requires future transactions using that nonce to use the same script. This is to ensure that the same nonce is not accidentally used by two different scripts. However, it does not require the `calldata` passed to that script to be the same. This means that a cleared nonce can be executed with the same script but different calldata. -Allowing the calldata to change greatly increases the flexibility of replayable scripts. One can think of a replayable script like a sub-module of a wallet that supports different functionality. In the [example script](./test/lib/RecurringPurchase.sol) for recurring purchases, there is a separate `cancel` function that the user can sign to cancel the nonce, and therefore, cancel all the recurring purchases that use this nonce. The user can also also sign multiple `purchase` calls, each with different purchase configurations. This means that multiple variations of recurring purchases can exist on the same nonce and can all be cancelled together. +Allowing the calldata to change greatly increases the flexibility of replayable scripts. One can think of a replayable script like a sub-module of a wallet that supports different functionality. In the [example script](./src/RecurringSwap.sol) for recurring purchases, there is a separate `cancel` function that the user can sign to cancel the nonce, and therefore, cancel all the recurring purchases that use this nonce. The user can also also sign multiple `purchase` calls, each with different purchase configurations. This means that multiple variations of recurring purchases can exist on the same nonce and can all be cancelled together. One danger of flexible `calldata` in replayable scripts is that previously signed `calldata` can always be re-executed. The Quark system does not disallow previously used calldata when a new calldata is executed. This means that scripts may need to implement their own method of invalidating previously-used `calldata`. diff --git a/src/builder/Actions.sol b/src/builder/Actions.sol index c6a28334..aec46409 100644 --- a/src/builder/Actions.sol +++ b/src/builder/Actions.sol @@ -1,10 +1,12 @@ // SPDX-License-Identifier: BSD-3-Clause pragma solidity ^0.8.23; -import {BridgeRoutes, CCTP} from "./BridgeRoutes.sol"; -import {Strings} from "./Strings.sol"; import {Accounts} from "./Accounts.sol"; +import {BridgeRoutes, CCTP} from "./BridgeRoutes.sol"; import {CodeJarHelper} from "./CodeJarHelper.sol"; +import {PriceFeeds} from "./PriceFeeds.sol"; +import {Strings} from "./Strings.sol"; +import {UniswapRouter} from "./UniswapRouter.sol"; import { ApproveAndSwap, @@ -14,6 +16,7 @@ import { CometWithdrawActions, TransferActions } from "../DeFiScripts.sol"; +import {RecurringSwap} from "../RecurringSwap.sol"; import {WrapperActions} from "../WrapperScripts.sol"; import {IQuarkWallet} from "quark-core/src/interfaces/IQuarkWallet.sol"; @@ -27,6 +30,7 @@ library Actions { string constant ACTION_TYPE_BRIDGE = "BRIDGE"; string constant ACTION_TYPE_CLAIM_REWARDS = "CLAIM_REWARDS"; string constant ACTION_TYPE_DRIP_TOKENS = "DRIP_TOKENS"; + string constant ACTION_TYPE_RECURRING_SWAP = "RECURRING_SWAP"; string constant ACTION_TYPE_REPAY = "REPAY"; string constant ACTION_TYPE_SUPPLY = "SUPPLY"; string constant ACTION_TYPE_SWAP = "SWAP"; @@ -45,6 +49,8 @@ library Actions { uint256 constant SWAP_EXPIRY_BUFFER = 3 days; uint256 constant TRANSFER_EXPIRY_BUFFER = 7 days; + uint256 constant RECURRING_SWAP_MAX_SLIPPAGE = 1e17; // 1% + /* ===== Custom Errors ===== */ error BridgingUnsupportedForAsset(); @@ -112,6 +118,22 @@ library Actions { uint256 blockTimestamp; } + struct RecurringSwapParams { + Accounts.ChainAccounts[] chainAccountsList; + address sellToken; + string sellAssetSymbol; + uint256 sellAmount; + address buyToken; + string buyAssetSymbol; + uint256 buyAmount; + bool isExactOut; + bytes path; + uint256 interval; + uint256 chainId; + address sender; + uint256 blockTimestamp; + } + struct TransferAsset { Accounts.ChainAccounts[] chainAccountsList; string assetSymbol; @@ -225,6 +247,20 @@ library Actions { uint256 outputTokenPrice; } + struct RecurringSwapActionContext { + uint256 chainId; + uint256 inputAmount; + string inputAssetSymbol; + address inputToken; + uint256 inputTokenPrice; + uint256 outputAmount; + string outputAssetSymbol; + address outputToken; + uint256 outputTokenPrice; + bool isExactOut; + uint256 interval; + } + struct TransferActionContext { uint256 amount; string assetSymbol; @@ -946,6 +982,93 @@ library Actions { return (quarkOperation, action); } + function recurringSwap(RecurringSwapParams memory swap, PaymentInfo.Payment memory payment, bool useQuotecall) + internal + pure + returns (IQuarkWallet.QuarkOperation memory, Action memory) + { + bytes[] memory scriptSources = new bytes[](1); + scriptSources[0] = type(RecurringSwap).creationCode; + + Accounts.ChainAccounts memory accounts = Accounts.findChainAccounts(swap.chainId, swap.chainAccountsList); + + Accounts.AssetPositions memory sellTokenAssetPositions = + Accounts.findAssetPositions(swap.sellAssetSymbol, accounts.assetPositionsList); + + Accounts.AssetPositions memory buyTokenAssetPositions = + Accounts.findAssetPositions(swap.buyAssetSymbol, accounts.assetPositionsList); + + Accounts.QuarkState memory accountState = Accounts.findQuarkState(swap.sender, accounts.quarkStates); + + RecurringSwap.SwapParams memory swapParams = RecurringSwap.SwapParams({ + uniswapRouter: UniswapRouter.knownRouter(swap.chainId), + recipient: swap.sender, + tokenIn: swap.buyToken, + tokenOut: swap.sellToken, + amount: swap.isExactOut ? swap.buyAmount : swap.sellAmount, + isExactOut: swap.isExactOut, + // The swap never expires and needs to be cancelled explicity + deadline: type(uint256).max, + path: swap.path + }); + (address[] memory priceFeeds, bool[] memory shouldInvert) = PriceFeeds.findPriceFeedPath({ + inputAssetSymbol: PriceFeeds.convertToPriceFeedSymbol(swap.sellAssetSymbol), + outputAssetSymbol: PriceFeeds.convertToPriceFeedSymbol(swap.buyAssetSymbol), + chainId: swap.chainId + }); + RecurringSwap.SlippageParams memory slippageParams = RecurringSwap.SlippageParams({ + maxSlippage: RECURRING_SWAP_MAX_SLIPPAGE, + priceFeeds: priceFeeds, + shouldInvert: shouldInvert + }); + RecurringSwap.SwapConfig memory swapConfig = RecurringSwap.SwapConfig({ + startTime: swap.blockTimestamp, + interval: swap.interval, + swapParams: swapParams, + slippageParams: slippageParams + }); + // TODO: Handle wrapping ETH? Do we need to? + bytes memory scriptCalldata = abi.encodeWithSelector(RecurringSwap.swap.selector, swapConfig); + + // Construct QuarkOperation + IQuarkWallet.QuarkOperation memory quarkOperation = IQuarkWallet.QuarkOperation({ + nonce: accountState.quarkNextNonce, + scriptAddress: CodeJarHelper.getCodeAddress(type(RecurringSwap).creationCode), + scriptCalldata: scriptCalldata, + scriptSources: scriptSources, + expiry: type(uint256).max + }); + + // Construct Action + RecurringSwapActionContext memory recurringSwapActionContext = RecurringSwapActionContext({ + chainId: swap.chainId, + inputAmount: swap.sellAmount, + inputAssetSymbol: swap.sellAssetSymbol, + inputToken: swap.sellToken, + inputTokenPrice: sellTokenAssetPositions.usdPrice, + outputAmount: swap.buyAmount, + outputAssetSymbol: swap.buyAssetSymbol, + outputToken: swap.buyToken, + outputTokenPrice: buyTokenAssetPositions.usdPrice, + isExactOut: swap.isExactOut, + interval: swap.interval + }); + + Action memory action = Actions.Action({ + chainId: swap.chainId, + quarkAccount: swap.sender, + actionType: ACTION_TYPE_RECURRING_SWAP, + actionContext: abi.encode(recurringSwapActionContext), + paymentMethod: PaymentInfo.paymentMethodForPayment(payment, useQuotecall), + // Null address for OFFCHAIN payment. + paymentToken: payment.isToken ? PaymentInfo.knownToken(payment.currency, swap.chainId).token : address(0), + paymentTokenSymbol: payment.currency, + paymentMaxCost: payment.isToken ? PaymentInfo.findMaxCost(payment, swap.chainId) : 0 + }); + + return (quarkOperation, action); + } + function findActionsOfType(Action[] memory actions, string memory actionType) internal pure @@ -1021,6 +1144,11 @@ library Actions { return ds[0]; } + function emptyRecurringSwapActionContext() external pure returns (RecurringSwapActionContext memory) { + RecurringSwapActionContext[] memory rs = new RecurringSwapActionContext[](1); + return rs[0]; + } + function emptyRepayActionContext() external pure returns (RepayActionContext memory) { RepayActionContext[] memory rs = new RepayActionContext[](1); return rs[0]; diff --git a/src/builder/PriceFeeds.sol b/src/builder/PriceFeeds.sol new file mode 100644 index 00000000..34f6a9cf --- /dev/null +++ b/src/builder/PriceFeeds.sol @@ -0,0 +1,284 @@ +// SPDX-License-Identifier: BSD-3-Clause +pragma solidity ^0.8.23; + +import {Strings} from "./Strings.sol"; + +library PriceFeeds { + error NoPriceFeedPathFound(string inputAssetSymbol, string outputAssetSymbol); + + struct PriceFeed { + uint256 chainId; + /// @dev The asset symbol for the base currency e.g. "ETH" in ETH/USD + string baseSymbol; + /// @dev The asset symbol for the quote currency e.g. "USD" in ETH/USD + string quoteSymbol; + address priceFeed; + } + + /// @dev Addresses fetched from: https://docs.chain.link/data-feeds/price-feeds/addresses + function knownPriceFeeds() internal pure returns (PriceFeed[] memory) { + PriceFeed[] memory mainnetFeeds = knownPriceFeeds_1(); + PriceFeed[] memory baseFeeds = knownPriceFeeds_8453(); + PriceFeed[] memory sepoliaFeeds = knownPriceFeeds_11155111(); + PriceFeed[] memory baseSepoliaFeeds = knownPriceFeeds_84532(); + + uint256 totalLength = mainnetFeeds.length + baseFeeds.length + sepoliaFeeds.length + baseSepoliaFeeds.length; + PriceFeed[] memory allFeeds = new PriceFeed[](totalLength); + + uint256 currentIndex = 0; + for (uint256 i = 0; i < mainnetFeeds.length; ++i) { + allFeeds[currentIndex] = mainnetFeeds[i]; + currentIndex++; + } + for (uint256 i = 0; i < baseFeeds.length; ++i) { + allFeeds[currentIndex] = baseFeeds[i]; + currentIndex++; + } + for (uint256 i = 0; i < sepoliaFeeds.length; ++i) { + allFeeds[currentIndex] = sepoliaFeeds[i]; + currentIndex++; + } + for (uint256 i = 0; i < baseSepoliaFeeds.length; ++i) { + allFeeds[currentIndex] = baseSepoliaFeeds[i]; + currentIndex++; + } + + return allFeeds; + } + + // Mainnet + function knownPriceFeeds_1() internal pure returns (PriceFeed[] memory) { + PriceFeed[] memory priceFeeds = new PriceFeed[](4); + priceFeeds[0] = PriceFeed({ + chainId: 1, + baseSymbol: "USDC", + quoteSymbol: "ETH", + priceFeed: 0x986b5E1e1755e3C2440e960477f25201B0a8bbD4 + }); + priceFeeds[1] = PriceFeed({ + chainId: 1, + baseSymbol: "ETH", + quoteSymbol: "USD", + priceFeed: 0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419 + }); + priceFeeds[2] = PriceFeed({ + chainId: 1, + baseSymbol: "LINK", + quoteSymbol: "USD", + priceFeed: 0x2c1d072e956AFFC0D435Cb7AC38EF18d24d9127c + }); + priceFeeds[3] = PriceFeed({ + chainId: 1, + baseSymbol: "LINK", + quoteSymbol: "ETH", + priceFeed: 0xDC530D9457755926550b59e8ECcdaE7624181557 + }); + return priceFeeds; + } + + // Base + function knownPriceFeeds_8453() internal pure returns (PriceFeed[] memory) { + PriceFeed[] memory priceFeeds = new PriceFeed[](3); + priceFeeds[0] = PriceFeed({ + chainId: 8453, + baseSymbol: "ETH", + quoteSymbol: "USD", + priceFeed: 0x71041dddad3595F9CEd3DcCFBe3D1F4b0a16Bb70 + }); + priceFeeds[1] = PriceFeed({ + chainId: 8453, + baseSymbol: "LINK", + quoteSymbol: "USD", + priceFeed: 0x17CAb8FE31E32f08326e5E27412894e49B0f9D65 + }); + priceFeeds[2] = PriceFeed({ + chainId: 8453, + baseSymbol: "LINK", + quoteSymbol: "ETH", + priceFeed: 0xc5E65227fe3385B88468F9A01600017cDC9F3A12 + }); + return priceFeeds; + } + + // Sepolia + function knownPriceFeeds_11155111() internal pure returns (PriceFeed[] memory) { + PriceFeed[] memory priceFeeds = new PriceFeed[](3); + priceFeeds[0] = PriceFeed({ + chainId: 11155111, + baseSymbol: "ETH", + quoteSymbol: "USD", + priceFeed: 0x694AA1769357215DE4FAC081bf1f309aDC325306 + }); + priceFeeds[1] = PriceFeed({ + chainId: 11155111, + baseSymbol: "LINK", + quoteSymbol: "USD", + priceFeed: 0xc59E3633BAAC79493d908e63626716e204A45EdF + }); + priceFeeds[2] = PriceFeed({ + chainId: 11155111, + baseSymbol: "LINK", + quoteSymbol: "ETH", + priceFeed: 0x42585eD362B3f1BCa95c640FdFf35Ef899212734 + }); + return priceFeeds; + } + + // Base Sepolia + function knownPriceFeeds_84532() internal pure returns (PriceFeed[] memory) { + PriceFeed[] memory priceFeeds = new PriceFeed[](3); + priceFeeds[0] = PriceFeed({ + chainId: 84532, + baseSymbol: "ETH", + quoteSymbol: "USD", + priceFeed: 0x4aDC67696bA383F43DD60A9e78F2C97Fbbfc7cb1 + }); + priceFeeds[1] = PriceFeed({ + chainId: 84532, + baseSymbol: "LINK", + quoteSymbol: "USD", + priceFeed: 0xb113F5A928BCfF189C998ab20d753a47F9dE5A61 + }); + priceFeeds[2] = PriceFeed({ + chainId: 84532, + baseSymbol: "LINK", + quoteSymbol: "ETH", + priceFeed: 0x56a43EB56Da12C0dc1D972ACb089c06a5dEF8e69 + }); + return priceFeeds; + } + + /// @dev Finds the price feed path that can convert the input asset to the output asset. We use a heuristics-based approach + /// to find an appropriate path: + /// Given an input asset IN and an output asset OUT + /// 1. First, we check if IN/OUT or OUT/IN exists + /// 2. Then, we check if there is a mutual asset that can be used to link IN and OUT, e.g. IN/ABC and ABC/OUT + /// 3. If not, then we assume no price feed path exists + function findPriceFeedPath(string memory inputAssetSymbol, string memory outputAssetSymbol, uint256 chainId) + internal + pure + returns (address[] memory, bool[] memory) + { + PriceFeed[] memory inputAssetPriceFeeds = findPriceFeeds(inputAssetSymbol, chainId); + PriceFeed[] memory outputAssetPriceFeeds = findPriceFeeds(outputAssetSymbol, chainId); + + for (uint256 i = 0; i < inputAssetPriceFeeds.length; ++i) { + if ( + Strings.stringEqIgnoreCase(inputAssetSymbol, inputAssetPriceFeeds[i].baseSymbol) + && Strings.stringEqIgnoreCase(outputAssetSymbol, inputAssetPriceFeeds[i].quoteSymbol) + ) { + address[] memory path = new address[](1); + bool[] memory reverse = new bool[](1); + path[0] = inputAssetPriceFeeds[i].priceFeed; + reverse[0] = false; + return (path, reverse); + } else if ( + Strings.stringEqIgnoreCase(inputAssetSymbol, inputAssetPriceFeeds[i].quoteSymbol) + && Strings.stringEqIgnoreCase(outputAssetSymbol, inputAssetPriceFeeds[i].baseSymbol) + ) { + address[] memory path = new address[](1); + bool[] memory reverse = new bool[](1); + path[0] = inputAssetPriceFeeds[i].priceFeed; + reverse[0] = true; + return (path, reverse); + } + } + + // Check if there is an indirect price feed path between the input and output asset + // We only check for one-hop paths e.g. ETH/USD, USD/USDC + // We only get here if no single price feed paths were found + for (uint256 i = 0; i < inputAssetPriceFeeds.length; ++i) { + for (uint256 j = 0; j < outputAssetPriceFeeds.length; ++j) { + // e.g. ABC/IN and ABC/OUT -> We want IN/ABC and ABC/OUT, which equates to reverse=[true, false] + if (Strings.stringEqIgnoreCase(inputAssetPriceFeeds[i].baseSymbol, outputAssetPriceFeeds[j].baseSymbol)) + { + address[] memory path = new address[](2); + bool[] memory reverse = new bool[](2); + path[0] = inputAssetPriceFeeds[i].priceFeed; + reverse[0] = true; + path[1] = outputAssetPriceFeeds[j].priceFeed; + reverse[1] = false; + return (path, reverse); + } else if ( + Strings + // e.g. IN/ABC and ABC/OUT -> We want IN/ABC and ABC/OUT, which equates to reverse=[false, false] + .stringEqIgnoreCase(inputAssetPriceFeeds[i].quoteSymbol, outputAssetPriceFeeds[j].baseSymbol) + ) { + address[] memory path = new address[](2); + bool[] memory reverse = new bool[](2); + path[0] = inputAssetPriceFeeds[i].priceFeed; + reverse[0] = false; + path[1] = outputAssetPriceFeeds[j].priceFeed; + reverse[1] = false; + return (path, reverse); + } else if ( + Strings + // e.g. ABC/IN and OUT/ABC -> We want IN/ABC and ABC/OUT, which equates to reverse=[true, true] + .stringEqIgnoreCase(inputAssetPriceFeeds[i].baseSymbol, outputAssetPriceFeeds[j].quoteSymbol) + ) { + address[] memory path = new address[](2); + bool[] memory reverse = new bool[](2); + path[0] = inputAssetPriceFeeds[i].priceFeed; + reverse[0] = true; + path[1] = outputAssetPriceFeeds[j].priceFeed; + reverse[1] = true; + return (path, reverse); + } else if ( + Strings + // e.g. IN/ABC and OUT/ABC -> We want IN/ABC and ABC/OUT, which equates to reverse=[false, true] + .stringEqIgnoreCase(inputAssetPriceFeeds[i].quoteSymbol, outputAssetPriceFeeds[j].quoteSymbol) + ) { + address[] memory path = new address[](2); + bool[] memory reverse = new bool[](2); + path[0] = inputAssetPriceFeeds[i].priceFeed; + reverse[0] = false; + path[1] = outputAssetPriceFeeds[j].priceFeed; + reverse[1] = true; + return (path, reverse); + } + } + } + + revert NoPriceFeedPathFound(inputAssetSymbol, outputAssetSymbol); + } + + function findPriceFeeds(string memory assetSymbol, uint256 chainId) internal pure returns (PriceFeed[] memory) { + PriceFeed[] memory allPriceFeeds = knownPriceFeeds(); + uint256 count = 0; + for (uint256 i = 0; i < allPriceFeeds.length; ++i) { + if (allPriceFeeds[i].chainId == chainId) { + if ( + Strings.stringEqIgnoreCase(assetSymbol, allPriceFeeds[i].baseSymbol) + || Strings.stringEqIgnoreCase(assetSymbol, allPriceFeeds[i].quoteSymbol) + ) { + count++; + } + } + } + + PriceFeed[] memory result = new PriceFeed[](count); + count = 0; + for (uint256 i = 0; i < allPriceFeeds.length; ++i) { + if (allPriceFeeds[i].chainId == chainId) { + if ( + Strings.stringEqIgnoreCase(assetSymbol, allPriceFeeds[i].baseSymbol) + || Strings.stringEqIgnoreCase(assetSymbol, allPriceFeeds[i].quoteSymbol) + ) { + result[count++] = allPriceFeeds[i]; + } + } + } + + return result; + } + + function convertToPriceFeedSymbol(string memory assetSymbol) internal pure returns (string memory) { + if (Strings.stringEqIgnoreCase(assetSymbol, "WETH")) { + return "ETH"; + } else if (Strings.stringEqIgnoreCase(assetSymbol, "USDC")) { + return "USD"; + } else { + return assetSymbol; + } + } +} diff --git a/src/builder/QuarkBuilder.sol b/src/builder/QuarkBuilder.sol index d59bbed3..c26fb582 100644 --- a/src/builder/QuarkBuilder.sol +++ b/src/builder/QuarkBuilder.sol @@ -52,6 +52,7 @@ contract QuarkBuilder { /* ===== Helper Functions ===== */ /* ===== Main Implementation ===== */ + struct CometRepayIntent { uint256 amount; string assetSymbol; @@ -982,6 +983,132 @@ contract QuarkBuilder { }); } + struct RecurringSwapIntent { + uint256 chainId; + address sellToken; + // For exact out swaps, this will be an estimate of the expected input token amount for the first swap + uint256 sellAmount; + address buyToken; + uint256 buyAmount; + bool isExactOut; + bytes path; + uint256 interval; + address sender; + uint256 blockTimestamp; + } + + // Note: We don't currently bridge the input token or the payment token for recurring swaps. Recurring swaps + // are actions tied to assets on a single chain. + function recurringSwap( + RecurringSwapIntent memory swapIntent, + Accounts.ChainAccounts[] memory chainAccountsList, + PaymentInfo.Payment memory payment + ) external pure returns (BuilderResult memory) { + // If the action is paid for with tokens, filter out any chain accounts that do not have corresponding payment information + if (payment.isToken) { + chainAccountsList = Accounts.findChainAccountsWithPaymentInfo(chainAccountsList, payment); + } + + string memory sellAssetSymbol = + Accounts.findAssetPositions(swapIntent.sellToken, swapIntent.chainId, chainAccountsList).symbol; + string memory buyAssetSymbol = + Accounts.findAssetPositions(swapIntent.buyToken, swapIntent.chainId, chainAccountsList).symbol; + + // Check there are enough of the input token on the target chain + if (needsBridgedFunds(sellAssetSymbol, swapIntent.sellAmount, swapIntent.chainId, chainAccountsList, payment)) { + uint256 balanceOnChain = getBalanceOnChain(sellAssetSymbol, swapIntent.chainId, chainAccountsList, payment); + uint256 amountNeededOnChain = + getAmountNeededOnChain(sellAssetSymbol, swapIntent.sellAmount, swapIntent.chainId, payment); + uint256 maxCostOnChain = payment.isToken ? PaymentInfo.findMaxCost(payment, swapIntent.chainId) : 0; + uint256 availableAssetBalance = balanceOnChain >= maxCostOnChain ? balanceOnChain - maxCostOnChain : 0; + revert FundsUnavailable(sellAssetSymbol, amountNeededOnChain, availableAssetBalance); + } + + // Check there are enough of the payment token on the target chain + if (payment.isToken) { + uint256 maxCostOnChain = PaymentInfo.findMaxCost(payment, swapIntent.chainId); + if (needsBridgedFunds(payment.currency, maxCostOnChain, swapIntent.chainId, chainAccountsList, payment)) { + uint256 balanceOnChain = + getBalanceOnChain(payment.currency, swapIntent.chainId, chainAccountsList, payment); + revert FundsUnavailable(payment.currency, maxCostOnChain, balanceOnChain); + } + } + + // We don't support max swap for recurring swaps, so quote call is never used + bool useQuotecall = false; + List.DynamicArray memory actions = List.newList(); + List.DynamicArray memory quarkOperations = List.newList(); + + // TODO: Handle wrapping/unwrapping once the new Quark is out. That will allow us to construct a replayable + // Multicall transaction that contains 1) the wrapping/unwrapping action and 2) the recurring swap. The wrapping + // action will need to be smart: for exact in, it will check that balance < sellAmount before wrapping. For exact out, + // it will always wrap all. + // // Auto-wrap/unwrap + // checkAndInsertWrapOrUnwrapAction( + // actions, + // quarkOperations, + // chainAccountsList, + // payment, + // sellAssetSymbol, + // // TODO: We will need to set this to type(uint256).max if isExactOut is true + // swapIntent.sellAmount, + // swapIntent.chainId, + // swapIntent.sender, + // swapIntent.blockTimestamp, + // useQuotecall + // ); + + // Then, set up the recurring swap operation + (IQuarkWallet.QuarkOperation memory operation, Actions.Action memory action) = Actions.recurringSwap( + Actions.RecurringSwapParams({ + chainAccountsList: chainAccountsList, + sellToken: swapIntent.sellToken, + sellAssetSymbol: sellAssetSymbol, + sellAmount: swapIntent.sellAmount, + buyToken: swapIntent.buyToken, + buyAssetSymbol: buyAssetSymbol, + buyAmount: swapIntent.buyAmount, + isExactOut: swapIntent.isExactOut, + path: swapIntent.path, + interval: swapIntent.interval, + chainId: swapIntent.chainId, + sender: swapIntent.sender, + blockTimestamp: swapIntent.blockTimestamp + }), + payment, + useQuotecall + ); + List.addAction(actions, action); + List.addQuarkOperation(quarkOperations, operation); + + // Convert actions and quark operations to arrays + Actions.Action[] memory actionsArray = List.toActionArray(actions); + IQuarkWallet.QuarkOperation[] memory quarkOperationsArray = List.toQuarkOperationArray(quarkOperations); + // Validate generated actions for affordability + if (payment.isToken) { + assertSufficientPaymentTokenBalances(actionsArray, chainAccountsList, swapIntent.chainId, swapIntent.sender); + } + + // Merge operations that are from the same chain into one Multicall operation + (quarkOperationsArray, actionsArray) = + QuarkOperationHelper.mergeSameChainOperations(quarkOperationsArray, actionsArray); + + // Wrap operations around Paycall/Quotecall if payment is with token + if (payment.isToken) { + quarkOperationsArray = QuarkOperationHelper.wrapOperationsWithTokenPayment( + quarkOperationsArray, actionsArray, payment, useQuotecall + ); + } + + return BuilderResult({ + version: VERSION, + actions: actionsArray, + quarkOperations: quarkOperationsArray, + paymentCurrency: payment.currency, + eip712Data: EIP712Helper.eip712DataForQuarkOperations(quarkOperationsArray, actionsArray) + }); + } + // For some reason, funds that may otherwise be bridgeable or held by the user cannot // be made available to fulfill the transaction. // Funds cannot be bridged, e.g. no bridge exists @@ -1061,19 +1188,13 @@ contract QuarkBuilder { revert MissingWrapperCounterpart(); } - function needsBridgedFunds( + function getBalanceOnChain( string memory assetSymbol, - uint256 amount, uint256 chainId, Accounts.ChainAccounts[] memory chainAccountsList, PaymentInfo.Payment memory payment - ) internal pure returns (bool) { + ) internal pure returns (uint256) { uint256 balanceOnChain = Accounts.getBalanceOnChain(assetSymbol, chainId, chainAccountsList); - // If action is paid for with tokens and the payment token is the transfer token, then add the payment max cost for the target chain to the amount needed - uint256 amountNeededOnDstChain = amount; - if (payment.isToken && Strings.stringEqIgnoreCase(payment.currency, assetSymbol)) { - amountNeededOnDstChain += PaymentInfo.findMaxCost(payment, chainId); - } // If there exists a counterpart token, try to wrap/unwrap first before attempting to bridge if (TokenWrapper.hasWrapperContract(chainId, assetSymbol)) { @@ -1092,7 +1213,35 @@ contract QuarkBuilder { balanceOnChain += counterpartBalance; } - return balanceOnChain < amountNeededOnDstChain; + return balanceOnChain; + } + + function getAmountNeededOnChain( + string memory assetSymbol, + uint256 amount, + uint256 chainId, + PaymentInfo.Payment memory payment + ) internal pure returns (uint256) { + // If action is paid for with tokens and the payment token is the transfer token, then add the payment max cost for the target chain to the amount needed + uint256 amountNeededOnChain = amount; + if (payment.isToken && Strings.stringEqIgnoreCase(payment.currency, assetSymbol)) { + amountNeededOnChain += PaymentInfo.findMaxCost(payment, chainId); + } + + return amountNeededOnChain; + } + + function needsBridgedFunds( + string memory assetSymbol, + uint256 amount, + uint256 chainId, + Accounts.ChainAccounts[] memory chainAccountsList, + PaymentInfo.Payment memory payment + ) internal pure returns (bool) { + uint256 balanceOnChain = getBalanceOnChain(assetSymbol, chainId, chainAccountsList, payment); + uint256 amountNeededOnChain = getAmountNeededOnChain(assetSymbol, amount, chainId, payment); + + return balanceOnChain < amountNeededOnChain; } /** @@ -1233,6 +1382,12 @@ contract QuarkBuilder { if (Strings.stringEqIgnoreCase(swapActionContext.inputAssetSymbol, paymentTokenSymbol)) { paymentTokenCost += swapActionContext.inputAmount; } + } else if (Strings.stringEqIgnoreCase(nonBridgeAction.actionType, Actions.ACTION_TYPE_RECURRING_SWAP)) { + Actions.RecurringSwapActionContext memory recurringSwapActionContext = + abi.decode(nonBridgeAction.actionContext, (Actions.RecurringSwapActionContext)); + if (Strings.stringEqIgnoreCase(recurringSwapActionContext.inputAssetSymbol, paymentTokenSymbol)) { + paymentTokenCost += recurringSwapActionContext.inputAmount; + } } else if (Strings.stringEqIgnoreCase(nonBridgeAction.actionType, Actions.ACTION_TYPE_TRANSFER)) { Actions.TransferActionContext memory transferActionContext = abi.decode(nonBridgeAction.actionContext, (Actions.TransferActionContext)); diff --git a/src/builder/UniswapRouter.sol b/src/builder/UniswapRouter.sol new file mode 100644 index 00000000..140f5b96 --- /dev/null +++ b/src/builder/UniswapRouter.sol @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: BSD-3-Clause +pragma solidity ^0.8.23; + +library UniswapRouter { + error NoKnownRouter(string routerType, uint256 chainId); + + struct RouterChain { + uint256 chainId; + address router; + } + + /// @dev Addresses fetched from: https://docs.uniswap.org/contracts/v3/reference/deployments/ + function knownChains() internal pure returns (RouterChain[] memory) { + RouterChain[] memory chains = new RouterChain[](4); + // Mainnet + chains[0] = RouterChain({chainId: 1, router: 0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45}); + // Base + chains[1] = RouterChain({chainId: 8453, router: 0x2626664c2603336E57B271c5C0b26F421741e481}); + // Sepolia + chains[2] = RouterChain({chainId: 11155111, router: 0x3bFA4769FB09eefC5a80d6E87c3B9C650f7Ae48E}); + // Base Sepolia + chains[3] = RouterChain({chainId: 84532, router: 0x94cC0AaC535CCDB3C01d6787D6413C739ae12bc4}); + return chains; + } + + function knownChain(uint256 chainId) internal pure returns (RouterChain memory found) { + RouterChain[] memory routerChains = knownChains(); + for (uint256 i = 0; i < routerChains.length; ++i) { + if (routerChains[i].chainId == chainId) { + return found = routerChains[i]; + } + } + } + + function knownRouter(uint256 chainId) internal pure returns (address) { + RouterChain memory chain = knownChain(chainId); + if (chain.router != address(0)) { + return chain.router; + } else { + revert NoKnownRouter("Uniswap", chainId); + } + } +} diff --git a/test/builder/PriceFeeds.t.sol b/test/builder/PriceFeeds.t.sol new file mode 100644 index 00000000..5cf318e7 --- /dev/null +++ b/test/builder/PriceFeeds.t.sol @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: BSD-3-Clause +pragma solidity ^0.8.13; + +import "forge-std/Test.sol"; + +import {PriceFeeds} from "src/builder/PriceFeeds.sol"; +import {Strings} from "src/builder/Strings.sol"; + +contract PriceFeedsTest is Test { + address public constant USDC_ETH_PRICE_FEED = 0x986b5E1e1755e3C2440e960477f25201B0a8bbD4; + address public constant LINK_ETH_PRICE_FEED = 0xDC530D9457755926550b59e8ECcdaE7624181557; + + function testFindPriceFeeds() public { + string memory assetSymbol = "ETH"; + uint256 chainId = 1; + PriceFeeds.PriceFeed[] memory priceFeeds = PriceFeeds.findPriceFeeds(assetSymbol, chainId); + + assertEq(priceFeeds.length, 3); + for (uint256 i = 0; i < priceFeeds.length; ++i) { + // Check that ETH is either the baseSymbol or the quoteSymbol in each price feed + bool isBaseOrQuoteSymbol = Strings.stringEq(priceFeeds[i].baseSymbol, assetSymbol) + || Strings.stringEq(priceFeeds[i].quoteSymbol, assetSymbol); + + assertEq(priceFeeds[i].chainId, 1); + assertTrue(isBaseOrQuoteSymbol); + } + } + + function testFindPriceFeedPathDirectMatch() public { + string memory inputAssetSymbol = "USDC"; + string memory outputAssetSymbol = "ETH"; + uint256 chainId = 1; + (address[] memory path, bool[] memory reverse) = + PriceFeeds.findPriceFeedPath(inputAssetSymbol, outputAssetSymbol, chainId); + + // Assert + assertEq(path.length, 1); + assertEq(path[0], USDC_ETH_PRICE_FEED); + assertEq(reverse[0], false); + } + + function testFindPriceFeedPathDirectMatchWithReverse() public { + string memory inputAssetSymbol = "ETH"; + string memory outputAssetSymbol = "USDC"; + uint256 chainId = 1; + (address[] memory path, bool[] memory reverse) = + PriceFeeds.findPriceFeedPath(inputAssetSymbol, outputAssetSymbol, chainId); + + // Assert + assertEq(path.length, 1); + assertEq(path[0], USDC_ETH_PRICE_FEED); + assertEq(reverse[0], true); + } + + function testFindPriceFeedPathOneHopMatch() public { + string memory inputAssetSymbol = "LINK"; + string memory outputAssetSymbol = "USDC"; + uint256 chainId = 1; + (address[] memory path, bool[] memory reverse) = + PriceFeeds.findPriceFeedPath(inputAssetSymbol, outputAssetSymbol, chainId); + + assertEq(path.length, 2); + assertEq(path[0], LINK_ETH_PRICE_FEED); + assertEq(reverse[0], false); + assertEq(path[1], USDC_ETH_PRICE_FEED); + assertEq(reverse[1], true); + } + + function testFindPriceFeedPathNoMatch() public { + string memory inputAssetSymbol = "BTC"; + string memory outputAssetSymbol = "USDT"; + uint256 chainId = 1; + + vm.expectRevert( + abi.encodeWithSelector(PriceFeeds.NoPriceFeedPathFound.selector, inputAssetSymbol, outputAssetSymbol) + ); + PriceFeeds.findPriceFeedPath(inputAssetSymbol, outputAssetSymbol, chainId); + } +} diff --git a/test/builder/QuarkBuilderRecurringSwap.t.sol b/test/builder/QuarkBuilderRecurringSwap.t.sol new file mode 100644 index 00000000..d60c00a4 --- /dev/null +++ b/test/builder/QuarkBuilderRecurringSwap.t.sol @@ -0,0 +1,432 @@ +// SPDX-License-Identifier: BSD-3-Clause +pragma solidity ^0.8.23; + +import "forge-std/Test.sol"; +import "forge-std/console.sol"; + +import {QuarkBuilderTest} from "test/builder/lib/QuarkBuilderTest.sol"; + +import {RecurringSwap} from "src/RecurringSwap.sol"; +import {CCTPBridgeActions} from "src/BridgeScripts.sol"; + +import {Actions} from "src/builder/Actions.sol"; +import {Accounts} from "src/builder/Accounts.sol"; +import {CodeJarHelper} from "src/builder/CodeJarHelper.sol"; +import {QuarkBuilder} from "src/builder/QuarkBuilder.sol"; +import {Paycall} from "src/Paycall.sol"; +import {Quotecall} from "src/Quotecall.sol"; +import {Multicall} from "src/Multicall.sol"; +import {WrapperActions} from "src/WrapperScripts.sol"; +import {PaycallWrapper} from "src/builder/PaycallWrapper.sol"; +import {PaymentInfo} from "src/builder/PaymentInfo.sol"; +import {PriceFeeds} from "src/builder/PriceFeeds.sol"; +import {UniswapRouter} from "src/builder/UniswapRouter.sol"; + +contract QuarkBuilderRecurringSwapTest is Test, QuarkBuilderTest { + function buyWeth_( + uint256 chainId, + address sellToken, + uint256 sellAmount, + uint256 buyAmount, + bool isExactOut, + uint256 interval, + address sender, + uint256 blockTimestamp + ) internal pure returns (QuarkBuilder.RecurringSwapIntent memory) { + address weth = weth_(chainId); + return recurringSwap_({ + chainId: chainId, + sellToken: sellToken, + sellAmount: sellAmount, + buyToken: weth, + buyAmount: buyAmount, + isExactOut: isExactOut, + path: abi.encodePacked(address(0), uint24(500), address(1)), + interval: interval, + sender: sender, + blockTimestamp: blockTimestamp + }); + } + + function recurringSwap_( + uint256 chainId, + address sellToken, + uint256 sellAmount, + address buyToken, + uint256 buyAmount, + bool isExactOut, + bytes memory path, + uint256 interval, + address sender, + uint256 blockTimestamp + ) internal pure returns (QuarkBuilder.RecurringSwapIntent memory) { + return QuarkBuilder.RecurringSwapIntent({ + chainId: chainId, + sellToken: sellToken, + sellAmount: sellAmount, + buyToken: buyToken, + buyAmount: buyAmount, + isExactOut: isExactOut, + path: path, + interval: interval, + sender: sender, + blockTimestamp: blockTimestamp + }); + } + + function constructSwapConfig_(QuarkBuilder.RecurringSwapIntent memory swap) + internal + pure + returns (RecurringSwap.SwapConfig memory) + { + RecurringSwap.SwapParams memory swapParams = RecurringSwap.SwapParams({ + uniswapRouter: UniswapRouter.knownRouter(swap.chainId), + recipient: swap.sender, + tokenIn: swap.buyToken, + tokenOut: swap.sellToken, + amount: swap.isExactOut ? swap.buyAmount : swap.sellAmount, + isExactOut: swap.isExactOut, + deadline: type(uint256).max, + path: swap.path + }); + (address[] memory priceFeeds, bool[] memory shouldInvert) = + PriceFeeds.findPriceFeedPath("USD", "ETH", swap.chainId); + RecurringSwap.SlippageParams memory slippageParams = RecurringSwap.SlippageParams({ + maxSlippage: 1e17, // 1% + priceFeeds: priceFeeds, + shouldInvert: shouldInvert + }); + return RecurringSwap.SwapConfig({ + startTime: swap.blockTimestamp, + interval: swap.interval, + swapParams: swapParams, + slippageParams: slippageParams + }); + } + + function testInsufficientFunds() public { + QuarkBuilder builder = new QuarkBuilder(); + vm.expectRevert(abi.encodeWithSelector(QuarkBuilder.FundsUnavailable.selector, "USDC", 3000e6, 0e6)); + builder.recurringSwap( + buyWeth_({ + chainId: 1, + sellToken: usdc_(1), + sellAmount: 3000e6, + buyAmount: 1e18, + isExactOut: true, + interval: 86_400, + sender: address(0xfe11a), + blockTimestamp: BLOCK_TIMESTAMP + }), // swap 3000 USDC on chain 1 to 1 WETH + chainAccountsList_(0e6), // but we are holding 0 USDC in total across 1, 8453 + paymentUsd_() + ); + } + + function testMaxCostTooHigh() public { + QuarkBuilder builder = new QuarkBuilder(); + // Max cost is too high, so total available funds is 0 + vm.expectRevert(abi.encodeWithSelector(QuarkBuilder.FundsUnavailable.selector, "USDC", 1_030e6, 0e6)); + builder.recurringSwap( + buyWeth_({ + chainId: 1, + sellToken: usdc_(1), + sellAmount: 30e6, + buyAmount: 0.01e18, + isExactOut: true, + interval: 86_400, + sender: address(0xfe11a), + blockTimestamp: BLOCK_TIMESTAMP + }), // swap 30 USDC on chain 1 to 0.01 WETH + chainAccountsList_(60e6), // holding 60 USDC in total across chains 1, 8453 + paymentUsdc_(maxCosts_(1, 1_000e6)) // but costs 1,000 USDC + ); + } + + function testNotEnoughFundsOnTargetChain() public { + QuarkBuilder builder = new QuarkBuilder(); + vm.expectRevert(abi.encodeWithSelector(QuarkBuilder.FundsUnavailable.selector, "USDC", 80e6, 30e6)); + builder.recurringSwap( + buyWeth_({ + chainId: 1, + sellToken: usdc_(1), + sellAmount: 80e6, + buyAmount: 1e18, + isExactOut: true, + interval: 86_400, + sender: address(0xfe11a), + blockTimestamp: BLOCK_TIMESTAMP + }), // swap 80 USDC on chain 1 to 1 WETH + chainAccountsList_(60e6), // holding 60 USDC in total across chains 1, 8453 + paymentUsd_() + ); + } + + function testFundsUnavailableErrorGivesSuggestionForAvailableFunds() public { + QuarkBuilder builder = new QuarkBuilder(); + // The 27e6 is the suggested amount (total available funds) to swap + vm.expectRevert(abi.encodeWithSelector(QuarkBuilder.FundsUnavailable.selector, "USDC", 33e6, 27e6)); + builder.recurringSwap( + buyWeth_({ + chainId: 1, + sellToken: usdc_(1), + sellAmount: 30e6, + buyAmount: 0.01e18, + isExactOut: true, + interval: 86_400, + sender: address(0xfe11a), + blockTimestamp: BLOCK_TIMESTAMP + }), // swap 30 USDC on chain 1 to 0.01 WETH + chainAccountsList_(60e6), // holding 60 USDC in total across 1, 8453 + paymentUsdc_(maxCosts_(1, 3e6)) // but costs 3 USDC + ); + } + + function testRecurringExactInSwapSucceeds() public { + QuarkBuilder builder = new QuarkBuilder(); + QuarkBuilder.RecurringSwapIntent memory buyWethIntent = buyWeth_({ + chainId: 1, + sellToken: usdc_(1), + sellAmount: 3000e6, + buyAmount: 1e18, + isExactOut: false, + interval: 86_400, + sender: address(0xfe11a), + blockTimestamp: BLOCK_TIMESTAMP + }); + QuarkBuilder.BuilderResult memory result = builder.recurringSwap( + buyWethIntent, // swap 3000 USDC on chain 1 to 1 WETH + chainAccountsList_(6000e6), // holding 6000 USDC in total across chains 1, 8453 + paymentUsd_() + ); + + assertEq(result.paymentCurrency, "usd", "usd currency"); + + // Check the quark operations + assertEq(result.quarkOperations.length, 1, "one operation"); + assertEq( + result.quarkOperations[0].scriptAddress, + address( + uint160( + uint256( + keccak256( + abi.encodePacked( + bytes1(0xff), + /* codeJar address */ + address(CodeJarHelper.CODE_JAR_ADDRESS), + uint256(0), + /* script bytecode */ + keccak256(type(RecurringSwap).creationCode) + ) + ) + ) + ) + ), + "script address is correct given the code jar address on mainnet" + ); + assertEq( + result.quarkOperations[0].scriptCalldata, + abi.encodeCall(RecurringSwap.swap, (constructSwapConfig_(buyWethIntent))), + "calldata is RecurringSwap.swap(SwapConfig(...));" + ); + assertEq(result.quarkOperations[0].expiry, type(uint256).max, "expiry is type(uint256).max"); + + // check the actions + assertEq(result.actions.length, 1, "one action"); + assertEq(result.actions[0].chainId, 1, "operation is on chainid 1"); + assertEq(result.actions[0].quarkAccount, address(0xfe11a), "0xfe11a does the swap"); + assertEq(result.actions[0].actionType, "RECURRING_SWAP", "action type is 'RECURRING_SWAP'"); + assertEq(result.actions[0].paymentMethod, "OFFCHAIN", "payment method is 'OFFCHAIN'"); + assertEq(result.actions[0].paymentToken, address(0), "payment token is null"); + assertEq(result.actions[0].paymentMaxCost, 0, "payment has no max cost, since 'OFFCHAIN'"); + assertEq( + result.actions[0].actionContext, + abi.encode( + Actions.RecurringSwapActionContext({ + chainId: 1, + inputToken: USDC_1, + inputTokenPrice: USDC_PRICE, + inputAssetSymbol: "USDC", + inputAmount: 3000e6, + outputToken: WETH_1, + outputTokenPrice: WETH_PRICE, + outputAssetSymbol: "WETH", + outputAmount: 1e18, + isExactOut: false, + interval: 86_400 + }) + ), + "action context encoded from RecurringSwapActionContext" + ); + + // TODO: Check the contents of the EIP712 data + assertNotEq(result.eip712Data.digest, hex"", "non-empty digest"); + assertNotEq(result.eip712Data.domainSeparator, hex"", "non-empty domain separator"); + assertNotEq(result.eip712Data.hashStruct, hex"", "non-empty hashStruct"); + } + + function testRecurringExactOutSwapSucceeds() public { + QuarkBuilder builder = new QuarkBuilder(); + QuarkBuilder.RecurringSwapIntent memory buyWethIntent = buyWeth_({ + chainId: 1, + sellToken: usdc_(1), + sellAmount: 3000e6, + buyAmount: 1e18, + isExactOut: true, + interval: 86_400, + sender: address(0xfe11a), + blockTimestamp: BLOCK_TIMESTAMP + }); + QuarkBuilder.BuilderResult memory result = builder.recurringSwap( + buyWethIntent, // swap 3000 USDC on chain 1 to 1 WETH + chainAccountsList_(6000e6), // holding 6000 USDC in total across chains 1, 8453 + paymentUsd_() + ); + + assertEq(result.paymentCurrency, "usd", "usd currency"); + + // Check the quark operations + assertEq(result.quarkOperations.length, 1, "one operation"); + assertEq( + result.quarkOperations[0].scriptAddress, + address( + uint160( + uint256( + keccak256( + abi.encodePacked( + bytes1(0xff), + /* codeJar address */ + address(CodeJarHelper.CODE_JAR_ADDRESS), + uint256(0), + /* script bytecode */ + keccak256(type(RecurringSwap).creationCode) + ) + ) + ) + ) + ), + "script address is correct given the code jar address on mainnet" + ); + assertEq( + result.quarkOperations[0].scriptCalldata, + abi.encodeCall(RecurringSwap.swap, (constructSwapConfig_(buyWethIntent))), + "calldata is RecurringSwap.swap(SwapConfig(...));" + ); + assertEq(result.quarkOperations[0].expiry, type(uint256).max, "expiry is type(uint256).max"); + + // check the actions + assertEq(result.actions.length, 1, "one action"); + assertEq(result.actions[0].chainId, 1, "operation is on chainid 1"); + assertEq(result.actions[0].quarkAccount, address(0xfe11a), "0xfe11a does the swap"); + assertEq(result.actions[0].actionType, "RECURRING_SWAP", "action type is 'RECURRING_SWAP'"); + assertEq(result.actions[0].paymentMethod, "OFFCHAIN", "payment method is 'OFFCHAIN'"); + assertEq(result.actions[0].paymentToken, address(0), "payment token is null"); + assertEq(result.actions[0].paymentMaxCost, 0, "payment has no max cost, since 'OFFCHAIN'"); + assertEq( + result.actions[0].actionContext, + abi.encode( + Actions.RecurringSwapActionContext({ + chainId: 1, + inputToken: USDC_1, + inputTokenPrice: USDC_PRICE, + inputAssetSymbol: "USDC", + inputAmount: 3000e6, + outputToken: WETH_1, + outputTokenPrice: WETH_PRICE, + outputAssetSymbol: "WETH", + outputAmount: 1e18, + isExactOut: true, + interval: 86_400 + }) + ), + "action context encoded from RecurringSwapActionContext" + ); + + // TODO: Check the contents of the EIP712 data + assertNotEq(result.eip712Data.digest, hex"", "non-empty digest"); + assertNotEq(result.eip712Data.domainSeparator, hex"", "non-empty domain separator"); + assertNotEq(result.eip712Data.hashStruct, hex"", "non-empty hashStruct"); + } + + function testRecurringSwapWithPaycallSucceeds() public { + QuarkBuilder builder = new QuarkBuilder(); + QuarkBuilder.RecurringSwapIntent memory buyWethIntent = buyWeth_({ + chainId: 1, + sellToken: usdc_(1), + sellAmount: 3000e6, + buyAmount: 1e18, + isExactOut: false, + interval: 86_400, + sender: address(0xfe11a), + blockTimestamp: BLOCK_TIMESTAMP + }); + PaymentInfo.PaymentMaxCost[] memory maxCosts = new PaymentInfo.PaymentMaxCost[](1); + maxCosts[0] = PaymentInfo.PaymentMaxCost({chainId: 1, amount: 5e6}); + QuarkBuilder.BuilderResult memory result = builder.recurringSwap( + buyWethIntent, // swap 3000 USDC on chain 1 to 1 WETH + chainAccountsList_(6010e6), // holding 6010 USDC in total across chains 1, 8453 + paymentUsdc_(maxCosts) + ); + + address recurringSwapAddress = CodeJarHelper.getCodeAddress(type(RecurringSwap).creationCode); + address paycallAddress = CodeJarHelper.getCodeAddress( + abi.encodePacked(type(Paycall).creationCode, abi.encode(ETH_USD_PRICE_FEED_1, USDC_1)) + ); + + assertEq(result.paymentCurrency, "usdc", "usdc currency"); + + // Check the quark operations + assertEq(result.quarkOperations.length, 1, "one operation"); + assertEq( + result.quarkOperations[0].scriptAddress, + paycallAddress, + "script address is correct given the code jar address on mainnet" + ); + assertEq( + result.quarkOperations[0].scriptCalldata, + abi.encodeWithSelector( + Paycall.run.selector, + recurringSwapAddress, + abi.encodeWithSelector(RecurringSwap.swap.selector, constructSwapConfig_(buyWethIntent)), + 5e6 + ), + "calldata is Paycall.run(RecurringSwap.swap(SwapConfig(...)), 5e6);" + ); + assertEq(result.quarkOperations[0].expiry, type(uint256).max, "expiry is type(uint256).max"); + + // check the actions + assertEq(result.actions.length, 1, "one action"); + assertEq(result.actions[0].chainId, 1, "operation is on chainid 1"); + assertEq(result.actions[0].quarkAccount, address(0xfe11a), "0xfe11a does the swap"); + assertEq(result.actions[0].actionType, "RECURRING_SWAP", "action type is 'RECURRING_SWAP'"); + assertEq(result.actions[0].paymentMethod, "PAY_CALL", "payment method is 'PAY_CALL'"); + assertEq(result.actions[0].paymentToken, USDC_1, "payment token is USDC"); + assertEq(result.actions[0].paymentMaxCost, 5e6, "payment max is set to 5e6 in this test case"); + assertEq( + result.actions[0].actionContext, + abi.encode( + Actions.RecurringSwapActionContext({ + chainId: 1, + inputToken: USDC_1, + inputTokenPrice: USDC_PRICE, + inputAssetSymbol: "USDC", + inputAmount: 3000e6, + outputToken: WETH_1, + outputTokenPrice: WETH_PRICE, + outputAssetSymbol: "WETH", + outputAmount: 1e18, + isExactOut: false, + interval: 86_400 + }) + ), + "action context encoded from SwapActionContext" + ); + + // TODO: Check the contents of the EIP712 data + assertNotEq(result.eip712Data.digest, hex"", "non-empty digest"); + assertNotEq(result.eip712Data.domainSeparator, hex"", "non-empty domain separator"); + assertNotEq(result.eip712Data.hashStruct, hex"", "non-empty hashStruct"); + } + + // TODO: Test wrapping/unwrapping when new Quark is out +} From d646473e55c48b7aa82611f0f6710d20002acfc2 Mon Sep 17 00:00:00 2001 From: Kevin Cheng Date: Tue, 3 Sep 2024 10:38:48 -0700 Subject: [PATCH 03/13] Various QuarkBuilder fixes (#69) Bundling a few different fixes in this PR: - Give a bit more leniency to the `startTime` for recurring swaps by subtracting `AVERAGE_BLOCK_TIME` from the block timestamp - Fix bug where buyToken and sellToken were in the wrong places when constructing recurring swap operations - Move duplicated errors into an `Errors.sol` library - Add USDC on Sepolia as a payment token - Use `SwapRouter` addresses instead of `SwapRouter02` addresses --- src/builder/Actions.sol | 7 ++++--- src/builder/EIP712Helper.sol | 7 ++----- src/builder/Errors.sol | 7 +++++++ src/builder/PaymentInfo.sol | 16 +++++++++++++++- src/builder/QuarkOperationHelper.sol | 7 ++----- src/builder/UniswapRouter.sol | 10 ++++++---- test/builder/QuarkBuilderRecurringSwap.t.sol | 6 +++--- 7 files changed, 39 insertions(+), 21 deletions(-) create mode 100644 src/builder/Errors.sol diff --git a/src/builder/Actions.sol b/src/builder/Actions.sol index aec46409..bee97425 100644 --- a/src/builder/Actions.sol +++ b/src/builder/Actions.sol @@ -49,6 +49,7 @@ library Actions { uint256 constant SWAP_EXPIRY_BUFFER = 3 days; uint256 constant TRANSFER_EXPIRY_BUFFER = 7 days; + uint256 constant AVERAGE_BLOCK_TIME = 12 seconds; uint256 constant RECURRING_SWAP_MAX_SLIPPAGE = 1e17; // 1% /* ===== Custom Errors ===== */ @@ -1003,8 +1004,8 @@ library Actions { RecurringSwap.SwapParams memory swapParams = RecurringSwap.SwapParams({ uniswapRouter: UniswapRouter.knownRouter(swap.chainId), recipient: swap.sender, - tokenIn: swap.buyToken, - tokenOut: swap.sellToken, + tokenIn: swap.sellToken, + tokenOut: swap.buyToken, amount: swap.isExactOut ? swap.buyAmount : swap.sellAmount, isExactOut: swap.isExactOut, // The swap never expires and needs to be cancelled explicity @@ -1022,7 +1023,7 @@ library Actions { shouldInvert: shouldInvert }); RecurringSwap.SwapConfig memory swapConfig = RecurringSwap.SwapConfig({ - startTime: swap.blockTimestamp, + startTime: swap.blockTimestamp - AVERAGE_BLOCK_TIME, interval: swap.interval, swapParams: swapParams, slippageParams: slippageParams diff --git a/src/builder/EIP712Helper.sol b/src/builder/EIP712Helper.sol index 502a5cf1..b865a3e2 100644 --- a/src/builder/EIP712Helper.sol +++ b/src/builder/EIP712Helper.sol @@ -5,6 +5,7 @@ import {IQuarkWallet} from "quark-core/src/interfaces/IQuarkWallet.sol"; import {QuarkWalletMetadata} from "quark-core/src/QuarkWallet.sol"; import {Actions} from "./Actions.sol"; +import {Errors} from "./Errors.sol"; library EIP712Helper { /* ===== Constants ===== */ @@ -42,10 +43,6 @@ library EIP712Helper { ) ); - /* ===== Custom Errors ===== */ - - error BadData(); - /* ===== Output Types ===== */ /// @notice The structure containing EIP-712 data for a QuarkOperation or MultiQuarkOperation @@ -128,7 +125,7 @@ library EIP712Helper { Actions.Action[] memory actions ) internal pure returns (bytes32) { if (ops.length != actions.length) { - revert BadData(); + revert Errors.BadData(); } bytes32[] memory opDigests = new bytes32[](ops.length); diff --git a/src/builder/Errors.sol b/src/builder/Errors.sol new file mode 100644 index 00000000..ddad82ba --- /dev/null +++ b/src/builder/Errors.sol @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: BSD-3-Clause +pragma solidity 0.8.23; + +/// Library of shared errors used across Quark Builder files +library Errors { + error BadData(); +} diff --git a/src/builder/PaymentInfo.sol b/src/builder/PaymentInfo.sol index 36b24125..f12f80bd 100644 --- a/src/builder/PaymentInfo.sol +++ b/src/builder/PaymentInfo.sol @@ -32,7 +32,7 @@ library PaymentInfo { } function knownTokens() internal pure returns (PaymentToken[] memory) { - PaymentToken[] memory paymentTokens = new PaymentToken[](2); + PaymentToken[] memory paymentTokens = new PaymentToken[](4); paymentTokens[0] = PaymentToken({ chainId: 1, symbol: "USDC", @@ -49,6 +49,20 @@ library PaymentInfo { token: 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913, priceFeed: 0x71041dddad3595F9CEd3DcCFBe3D1F4b0a16Bb70 }); + + // Testnet + paymentTokens[2] = PaymentToken({ + chainId: 11155111, + symbol: "USDC", + token: 0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238, + priceFeed: 0x694AA1769357215DE4FAC081bf1f309aDC325306 + }); + paymentTokens[3] = PaymentToken({ + chainId: 84532, + symbol: "USDC", + token: 0x036CbD53842c5426634e7929541eC2318f3dCF7e, + priceFeed: 0x4aDC67696bA383F43DD60A9e78F2C97Fbbfc7cb1 + }); return paymentTokens; } diff --git a/src/builder/QuarkOperationHelper.sol b/src/builder/QuarkOperationHelper.sol index 5d8b57a3..a4c8d17b 100644 --- a/src/builder/QuarkOperationHelper.sol +++ b/src/builder/QuarkOperationHelper.sol @@ -7,6 +7,7 @@ import {Multicall} from "../Multicall.sol"; import {Actions} from "./Actions.sol"; import {CodeJarHelper} from "./CodeJarHelper.sol"; +import {Errors} from "./Errors.sol"; import {PaycallWrapper} from "./PaycallWrapper.sol"; import {PaymentInfo} from "./PaymentInfo.sol"; import {QuotecallWrapper} from "./QuotecallWrapper.sol"; @@ -15,17 +16,13 @@ import {HashMap} from "./HashMap.sol"; // Helper library to for transforming Quark Operations library QuarkOperationHelper { - /* ===== Custom Errors ===== */ - - error BadData(); - /* ===== Main Implementation ===== */ function mergeSameChainOperations( IQuarkWallet.QuarkOperation[] memory quarkOperations, Actions.Action[] memory actions ) internal pure returns (IQuarkWallet.QuarkOperation[] memory, Actions.Action[] memory) { - if (quarkOperations.length != actions.length) revert BadData(); + if (quarkOperations.length != actions.length) revert Errors.BadData(); // Group operations and actions by chain id HashMap.Map memory groupedQuarkOperations = HashMap.newMap(); diff --git a/src/builder/UniswapRouter.sol b/src/builder/UniswapRouter.sol index 140f5b96..733d4c54 100644 --- a/src/builder/UniswapRouter.sol +++ b/src/builder/UniswapRouter.sol @@ -10,16 +10,18 @@ library UniswapRouter { } /// @dev Addresses fetched from: https://docs.uniswap.org/contracts/v3/reference/deployments/ + /// Note: We use the addresses for SwapRouter, instead of SwapRouter02, which has a slightly different interface function knownChains() internal pure returns (RouterChain[] memory) { RouterChain[] memory chains = new RouterChain[](4); // Mainnet - chains[0] = RouterChain({chainId: 1, router: 0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45}); + chains[0] = RouterChain({chainId: 1, router: 0xE592427A0AEce92De3Edee1F18E0157C05861564}); + // TODO: These chains don't have SwapRouter, so we will add them back once we move to SwapRouter02 // Base - chains[1] = RouterChain({chainId: 8453, router: 0x2626664c2603336E57B271c5C0b26F421741e481}); + // chains[1] = RouterChain({chainId: 8453, router: 0x2626664c2603336E57B271c5C0b26F421741e481}); // Sepolia - chains[2] = RouterChain({chainId: 11155111, router: 0x3bFA4769FB09eefC5a80d6E87c3B9C650f7Ae48E}); + // chains[2] = RouterChain({chainId: 11155111, router: 0x3bFA4769FB09eefC5a80d6E87c3B9C650f7Ae48E}); // Base Sepolia - chains[3] = RouterChain({chainId: 84532, router: 0x94cC0AaC535CCDB3C01d6787D6413C739ae12bc4}); + // chains[3] = RouterChain({chainId: 84532, router: 0x94cC0AaC535CCDB3C01d6787D6413C739ae12bc4}); return chains; } diff --git a/test/builder/QuarkBuilderRecurringSwap.t.sol b/test/builder/QuarkBuilderRecurringSwap.t.sol index d60c00a4..7e4a7ea4 100644 --- a/test/builder/QuarkBuilderRecurringSwap.t.sol +++ b/test/builder/QuarkBuilderRecurringSwap.t.sol @@ -82,8 +82,8 @@ contract QuarkBuilderRecurringSwapTest is Test, QuarkBuilderTest { RecurringSwap.SwapParams memory swapParams = RecurringSwap.SwapParams({ uniswapRouter: UniswapRouter.knownRouter(swap.chainId), recipient: swap.sender, - tokenIn: swap.buyToken, - tokenOut: swap.sellToken, + tokenIn: swap.sellToken, + tokenOut: swap.buyToken, amount: swap.isExactOut ? swap.buyAmount : swap.sellAmount, isExactOut: swap.isExactOut, deadline: type(uint256).max, @@ -97,7 +97,7 @@ contract QuarkBuilderRecurringSwapTest is Test, QuarkBuilderTest { shouldInvert: shouldInvert }); return RecurringSwap.SwapConfig({ - startTime: swap.blockTimestamp, + startTime: swap.blockTimestamp - Actions.AVERAGE_BLOCK_TIME, interval: swap.interval, swapParams: swapParams, slippageParams: slippageParams From 3cdea8b5e2b9e6dc3792b90d3b52b62be98469c7 Mon Sep 17 00:00:00 2001 From: Kevin Cheng Date: Fri, 6 Sep 2024 09:17:14 -0700 Subject: [PATCH 04/13] Move off of Compound node provider proxy (#71) Similar change to https://github.com/compound-finance/quark/pull/206 --- .github/workflows/gas-snapshot.yml | 2 +- .github/workflows/test.yml | 2 +- README.md | 17 ++++++----------- test/ApproveAndSwap.t.sol | 4 +--- test/CCTPBridgeActions.t.sol | 4 +--- test/CometClaimRewards.t.sol | 4 +--- test/CometRepayAndWithdrawMultipleAssets.t.sol | 4 +--- test/CometSupplyActions.t.sol | 4 +--- test/CometSupplyMultipleAssetsAndBorrow.t.sol | 4 +--- test/CometWithdrawActions.t.sol | 4 +--- test/ConditionalMulticall.t.sol | 4 +--- test/Ethcall.t.sol | 4 +--- test/Multicall.t.sol | 4 +--- test/Paycall.t.sol | 4 +--- test/Quotecall.t.sol | 4 +--- test/RecurringSwap.t.sol | 4 +--- test/TransferActions.t.sol | 4 +--- test/UniswapFlashLoan.t.sol | 4 +--- test/UniswapFlashSwapExactOut.t.sol | 4 +--- test/UniswapSwapActions.t.sol | 4 +--- test/WrapperScripts.t.sol | 4 +--- 21 files changed, 26 insertions(+), 67 deletions(-) diff --git a/.github/workflows/gas-snapshot.yml b/.github/workflows/gas-snapshot.yml index a148fc20..9e573a33 100644 --- a/.github/workflows/gas-snapshot.yml +++ b/.github/workflows/gas-snapshot.yml @@ -33,7 +33,7 @@ jobs: | grep -E '^test' \ | tee .gas-snapshot.new env: - NODE_PROVIDER_BYPASS_KEY: ${{ secrets.NODE_PROVIDER_BYPASS_KEY }} + MAINNET_RPC_URL: ${{ secrets.MAINNET_RPC_URL }} - name: Check diff tolerance run: | diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6dd5d024..93efced0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -37,4 +37,4 @@ jobs: forge test -vvv id: test env: - NODE_PROVIDER_BYPASS_KEY: ${{ secrets.NODE_PROVIDER_BYPASS_KEY }} + MAINNET_RPC_URL: ${{ secrets.MAINNET_RPC_URL }} diff --git a/README.md b/README.md index c59ff5fa..18c7da40 100644 --- a/README.md +++ b/README.md @@ -32,23 +32,18 @@ Callbacks need to be explicitly turned on by Quark scripts. Specifically, this i [Quark Builder Helper](./src/builder/QuarkBuilderHelper.sol) is a contract with functions outside of constructing _Quark operations_ that might still be helpful for those using the QuarkBuilder. For example, there is a helper function to determine the bridgeability of assets on different chains. -## Fork tests and NODE_PROVIDER_BYPASS_KEY +## Fork tests and MAINNET_RPC_URL Some tests require forking mainnet, e.g. to exercise use-cases like supplying and borrowing in a comet market. -For a "fork url" we use our rate-limited node provider endpoint at -`https://node-provider.compound.finance/ethereum-mainnet`. Setting up a -fork quickly exceeds the rate limits, so we use a bypass key to allow fork -tests to exceed the rate limits. +The "fork url" is specified using the environment variable `MAINNET_RPC_URL`. +It can be any node provider for Ethereum mainnet, such as Infura or Alchemy. -A bypass key for Quark development can be found in 1Password as a -credential named "Quark Dev node-provider Bypass Key". The key can then be -set during tests via the environment variable `NODE_PROVIDER_BYPASS_KEY`, -like so: +The environment variable can be set when running tests, like so: ``` -$ NODE_PROVIDER_BYPASS_KEY=... forge test +$ MAINNET_RPC_URL=... forge test ``` ## Updating gas snapshots @@ -61,7 +56,7 @@ You can accept the diff and update the baseline if the increased gas usage is intentional. Just run the following command: ```sh -$ NODE_PROVIDER_BYPASS_KEY=... ./script/update-snapshot.sh +$ MAINNET_RPC_URL=... ./script/update-snapshot.sh ``` Then commit the updated snapshot file: diff --git a/test/ApproveAndSwap.t.sol b/test/ApproveAndSwap.t.sol index 0b06e912..d56b1659 100644 --- a/test/ApproveAndSwap.t.sol +++ b/test/ApproveAndSwap.t.sol @@ -29,9 +29,7 @@ contract ApproveAndSwapTest is Test { function setUp() public { // Fork setup vm.createSelectFork( - string.concat( - "https://node-provider.compound.finance/ethereum-mainnet/", vm.envString("NODE_PROVIDER_BYPASS_KEY") - ), + vm.envString("MAINNET_RPC_URL"), // Warp to the block where the quote is valid 19121945 ); diff --git a/test/CCTPBridgeActions.t.sol b/test/CCTPBridgeActions.t.sol index 933d528c..5cae3955 100644 --- a/test/CCTPBridgeActions.t.sol +++ b/test/CCTPBridgeActions.t.sol @@ -37,9 +37,7 @@ contract CCTPBridge is Test { function setUp() public { // Fork setup vm.createSelectFork( - string.concat( - "https://node-provider.compound.finance/ethereum-mainnet/", vm.envString("NODE_PROVIDER_BYPASS_KEY") - ), + vm.envString("MAINNET_RPC_URL"), 18429607 // 2023-10-25 13:24:00 PST ); factory = new QuarkWalletProxyFactory(address(new QuarkWallet(new CodeJar(), new QuarkStateManager()))); diff --git a/test/CometClaimRewards.t.sol b/test/CometClaimRewards.t.sol index 4c0dd018..e7010064 100644 --- a/test/CometClaimRewards.t.sol +++ b/test/CometClaimRewards.t.sol @@ -36,9 +36,7 @@ contract CometClaimRewardsTest is Test { function setUp() public { // Fork setup vm.createSelectFork( - string.concat( - "https://node-provider.compound.finance/ethereum-mainnet/", vm.envString("NODE_PROVIDER_BYPASS_KEY") - ), + vm.envString("MAINNET_RPC_URL"), 18429607 // 2023-10-25 13:24:00 PST ); factory = new QuarkWalletProxyFactory(address(new QuarkWallet(new CodeJar(), new QuarkStateManager()))); diff --git a/test/CometRepayAndWithdrawMultipleAssets.t.sol b/test/CometRepayAndWithdrawMultipleAssets.t.sol index 88e2da18..fea0d7c0 100644 --- a/test/CometRepayAndWithdrawMultipleAssets.t.sol +++ b/test/CometRepayAndWithdrawMultipleAssets.t.sol @@ -37,9 +37,7 @@ contract CometRepayAndWithdrawMultipleAssetsTest is Test { function setUp() public { // Fork setup vm.createSelectFork( - string.concat( - "https://node-provider.compound.finance/ethereum-mainnet/", vm.envString("NODE_PROVIDER_BYPASS_KEY") - ), + vm.envString("MAINNET_RPC_URL"), 18429607 // 2023-10-25 13:24:00 PST ); factory = new QuarkWalletProxyFactory(address(new QuarkWallet(new CodeJar(), new QuarkStateManager()))); diff --git a/test/CometSupplyActions.t.sol b/test/CometSupplyActions.t.sol index e4125549..f31dfd1d 100644 --- a/test/CometSupplyActions.t.sol +++ b/test/CometSupplyActions.t.sol @@ -39,9 +39,7 @@ contract SupplyActionsTest is Test { function setUp() public { // Fork setup vm.createSelectFork( - string.concat( - "https://node-provider.compound.finance/ethereum-mainnet/", vm.envString("NODE_PROVIDER_BYPASS_KEY") - ), + vm.envString("MAINNET_RPC_URL"), 18429607 // 2023-10-25 13:24:00 PST ); factory = new QuarkWalletProxyFactory(address(new QuarkWallet(new CodeJar(), new QuarkStateManager()))); diff --git a/test/CometSupplyMultipleAssetsAndBorrow.t.sol b/test/CometSupplyMultipleAssetsAndBorrow.t.sol index afdc7afb..45eabe2b 100644 --- a/test/CometSupplyMultipleAssetsAndBorrow.t.sol +++ b/test/CometSupplyMultipleAssetsAndBorrow.t.sol @@ -37,9 +37,7 @@ contract CometSupplyMultipleAssetsAndBorrowTest is Test { function setUp() public { // Fork setup vm.createSelectFork( - string.concat( - "https://node-provider.compound.finance/ethereum-mainnet/", vm.envString("NODE_PROVIDER_BYPASS_KEY") - ), + vm.envString("MAINNET_RPC_URL"), 18429607 // 2023-10-25 13:24:00 PST ); factory = new QuarkWalletProxyFactory(address(new QuarkWallet(new CodeJar(), new QuarkStateManager()))); diff --git a/test/CometWithdrawActions.t.sol b/test/CometWithdrawActions.t.sol index 2d4d3196..624d8e7e 100644 --- a/test/CometWithdrawActions.t.sol +++ b/test/CometWithdrawActions.t.sol @@ -38,9 +38,7 @@ contract WithdrawActionsTest is Test { function setUp() public { // Fork setup vm.createSelectFork( - string.concat( - "https://node-provider.compound.finance/ethereum-mainnet/", vm.envString("NODE_PROVIDER_BYPASS_KEY") - ), + vm.envString("MAINNET_RPC_URL"), 18429607 // 2023-10-25 13:24:00 PST ); factory = new QuarkWalletProxyFactory(address(new QuarkWallet(new CodeJar(), new QuarkStateManager()))); diff --git a/test/ConditionalMulticall.t.sol b/test/ConditionalMulticall.t.sol index 6e080264..420e0379 100644 --- a/test/ConditionalMulticall.t.sol +++ b/test/ConditionalMulticall.t.sol @@ -43,9 +43,7 @@ contract ConditionalMulticallTest is Test { function setUp() public { vm.createSelectFork( - string.concat( - "https://node-provider.compound.finance/ethereum-mainnet/", vm.envString("NODE_PROVIDER_BYPASS_KEY") - ), + vm.envString("MAINNET_RPC_URL"), 18429607 // 2023-10-25 13:24:00 PST ); factory = new QuarkWalletProxyFactory(address(new QuarkWallet(new CodeJar(), new QuarkStateManager()))); diff --git a/test/Ethcall.t.sol b/test/Ethcall.t.sol index c8eb7251..feee4bc7 100644 --- a/test/Ethcall.t.sol +++ b/test/Ethcall.t.sol @@ -41,9 +41,7 @@ contract EthcallTest is Test { function setUp() public { // Fork setup vm.createSelectFork( - string.concat( - "https://node-provider.compound.finance/ethereum-mainnet/", vm.envString("NODE_PROVIDER_BYPASS_KEY") - ), + vm.envString("MAINNET_RPC_URL"), 18429607 // 2023-10-25 13:24:00 PST ); diff --git a/test/Multicall.t.sol b/test/Multicall.t.sol index 9e85fd19..7bb53245 100644 --- a/test/Multicall.t.sol +++ b/test/Multicall.t.sol @@ -53,9 +53,7 @@ contract MulticallTest is Test { function setUp() public { vm.createSelectFork( - string.concat( - "https://node-provider.compound.finance/ethereum-mainnet/", vm.envString("NODE_PROVIDER_BYPASS_KEY") - ), + vm.envString("MAINNET_RPC_URL"), 18429607 // 2023-10-25 13:24:00 PST ); factory = new QuarkWalletProxyFactory(address(new QuarkWallet(new CodeJar(), new QuarkStateManager()))); diff --git a/test/Paycall.t.sol b/test/Paycall.t.sol index 27f92f0a..e941bfd6 100644 --- a/test/Paycall.t.sol +++ b/test/Paycall.t.sol @@ -76,9 +76,7 @@ contract PaycallTest is Test { function setUp() public { vm.createSelectFork( - string.concat( - "https://node-provider.compound.finance/ethereum-mainnet/", vm.envString("NODE_PROVIDER_BYPASS_KEY") - ), + vm.envString("MAINNET_RPC_URL"), 18429607 // 2023-10-25 13:24:00 PST ); factory = new QuarkWalletProxyFactory(address(new QuarkWallet(new CodeJar(), new QuarkStateManager()))); diff --git a/test/Quotecall.t.sol b/test/Quotecall.t.sol index 19bf73ba..4c080f02 100644 --- a/test/Quotecall.t.sol +++ b/test/Quotecall.t.sol @@ -77,9 +77,7 @@ contract QuotecallTest is Test { function setUp() public { vm.createSelectFork( - string.concat( - "https://node-provider.compound.finance/ethereum-mainnet/", vm.envString("NODE_PROVIDER_BYPASS_KEY") - ), + vm.envString("MAINNET_RPC_URL"), 18429607 // 2023-10-25 13:24:00 PST ); factory = new QuarkWalletProxyFactory(address(new QuarkWallet(new CodeJar(), new QuarkStateManager()))); diff --git a/test/RecurringSwap.t.sol b/test/RecurringSwap.t.sol index f7359bc6..87805fa1 100644 --- a/test/RecurringSwap.t.sol +++ b/test/RecurringSwap.t.sol @@ -53,9 +53,7 @@ contract RecurringSwapTest is Test { constructor() { // Fork setup vm.createSelectFork( - string.concat( - "https://node-provider.compound.finance/ethereum-mainnet/", vm.envString("NODE_PROVIDER_BYPASS_KEY") - ), + vm.envString("MAINNET_RPC_URL"), 18429607 // 2023-10-25 13:24:00 PST ); diff --git a/test/TransferActions.t.sol b/test/TransferActions.t.sol index dad251f3..014f867c 100644 --- a/test/TransferActions.t.sol +++ b/test/TransferActions.t.sol @@ -50,9 +50,7 @@ contract TransferActionsTest is Test { function setUp() public { // Fork setup vm.createSelectFork( - string.concat( - "https://node-provider.compound.finance/ethereum-mainnet/", vm.envString("NODE_PROVIDER_BYPASS_KEY") - ), + vm.envString("MAINNET_RPC_URL"), 18429607 // 2023-10-25 13:24:00 PST ); factory = new QuarkWalletProxyFactory(address(new QuarkWallet(new CodeJar(), new QuarkStateManager()))); diff --git a/test/UniswapFlashLoan.t.sol b/test/UniswapFlashLoan.t.sol index 52772542..5fce2c62 100644 --- a/test/UniswapFlashLoan.t.sol +++ b/test/UniswapFlashLoan.t.sol @@ -51,9 +51,7 @@ contract UniswapFlashLoanTest is Test { function setUp() public { vm.createSelectFork( - string.concat( - "https://node-provider.compound.finance/ethereum-mainnet/", vm.envString("NODE_PROVIDER_BYPASS_KEY") - ), + vm.envString("MAINNET_RPC_URL"), 18429607 // 2023-10-25 13:24:00 PST ); factory = new QuarkWalletProxyFactory(address(new QuarkWallet(new CodeJar(), new QuarkStateManager()))); diff --git a/test/UniswapFlashSwapExactOut.t.sol b/test/UniswapFlashSwapExactOut.t.sol index 9a1f762c..5511c794 100644 --- a/test/UniswapFlashSwapExactOut.t.sol +++ b/test/UniswapFlashSwapExactOut.t.sol @@ -47,9 +47,7 @@ contract UniswapFlashSwapExactOutTest is Test { function setUp() public { vm.createSelectFork( - string.concat( - "https://node-provider.compound.finance/ethereum-mainnet/", vm.envString("NODE_PROVIDER_BYPASS_KEY") - ), + vm.envString("MAINNET_RPC_URL"), 18429607 // 2023-10-25 13:24:00 PST ); factory = new QuarkWalletProxyFactory(address(new QuarkWallet(new CodeJar(), new QuarkStateManager()))); diff --git a/test/UniswapSwapActions.t.sol b/test/UniswapSwapActions.t.sol index 00bd16ef..38a1f0b7 100644 --- a/test/UniswapSwapActions.t.sol +++ b/test/UniswapSwapActions.t.sol @@ -42,9 +42,7 @@ contract UniswapSwapActionsTest is Test { function setUp() public { // Fork setup vm.createSelectFork( - string.concat( - "https://node-provider.compound.finance/ethereum-mainnet/", vm.envString("NODE_PROVIDER_BYPASS_KEY") - ), + vm.envString("MAINNET_RPC_URL"), 18429607 // 2023-10-25 13:24:00 PST ); factory = new QuarkWalletProxyFactory(address(new QuarkWallet(new CodeJar(), new QuarkStateManager()))); diff --git a/test/WrapperScripts.t.sol b/test/WrapperScripts.t.sol index e67b0fb7..947117a4 100644 --- a/test/WrapperScripts.t.sol +++ b/test/WrapperScripts.t.sol @@ -37,9 +37,7 @@ contract WrapperScriptsTest is Test { function setUp() public { // Fork setup vm.createSelectFork( - string.concat( - "https://node-provider.compound.finance/ethereum-mainnet/", vm.envString("NODE_PROVIDER_BYPASS_KEY") - ), + vm.envString("MAINNET_RPC_URL"), 18429607 // 2023-10-25 13:24:00 PST ); factory = new QuarkWalletProxyFactory(address(new QuarkWallet(new CodeJar(), new QuarkStateManager()))); From 19ebb31d1fc9c885ad065ed54f995c6b6f8f7f41 Mon Sep 17 00:00:00 2001 From: Hans Wang <2709448+cwang25@users.noreply.github.com> Date: Fri, 6 Sep 2024 10:28:51 -0700 Subject: [PATCH 05/13] Morpho integration (p1) - Defi script (#66) - Basic interfaces to interact with Morpho Blue (morpho), MetaMorpho (Morpho vault), Morpho reward contracts --- src/MorphoScripts.sol | 182 ++++++++++++++++++ src/interfaces/IMetaMorpho.sol | 13 ++ src/interfaces/IMorpho.sol | 54 ++++++ .../IMorphoUniversalRewardsDistributor.sol | 10 + src/vendor/manifest.json | 20 ++ src/vendor/morpho_blue_periphery/MathLib.sol | 45 +++++ .../morpho_blue_periphery/SharesMathLib.sol | 45 +++++ test/MorphoActions.t.sol | 165 ++++++++++++++++ test/MorphoRewardsActions.t.sol | 136 +++++++++++++ test/MorphoVaultActions.t.sol | 96 +++++++++ 10 files changed, 766 insertions(+) create mode 100644 src/MorphoScripts.sol create mode 100644 src/interfaces/IMetaMorpho.sol create mode 100644 src/interfaces/IMorpho.sol create mode 100644 src/interfaces/IMorphoUniversalRewardsDistributor.sol create mode 100644 src/vendor/morpho_blue_periphery/MathLib.sol create mode 100644 src/vendor/morpho_blue_periphery/SharesMathLib.sol create mode 100644 test/MorphoActions.t.sol create mode 100644 test/MorphoRewardsActions.t.sol create mode 100644 test/MorphoVaultActions.t.sol diff --git a/src/MorphoScripts.sol b/src/MorphoScripts.sol new file mode 100644 index 00000000..017a4eda --- /dev/null +++ b/src/MorphoScripts.sol @@ -0,0 +1,182 @@ +// SPDX-License-Identifier: BSD-3-Clause +pragma solidity 0.8.23; + +import {IERC20} from "openzeppelin/token/ERC20/IERC20.sol"; +import {SafeERC20} from "openzeppelin/token/ERC20/utils/SafeERC20.sol"; +import {IMorpho, MarketParams, Position} from "src/interfaces/IMorpho.sol"; +import {IMetaMorpho} from "src/interfaces/IMetaMorpho.sol"; +import {IMorphoUniversalRewardsDistributor} from "src/interfaces/IMorphoUniversalRewardsDistributor.sol"; +import {DeFiScriptErrors} from "src/lib/DeFiScriptErrors.sol"; + +contract MorphoVaultActions { + // To handle non-standard ERC20 tokens (i.e. USDT) + using SafeERC20 for IERC20; + + /** + * @notice Deposit assets into a MetaMorpho vault + * @param vault The address of the MetaMorpho vault + * @param asset The address of the asset to deposit + * @param amount The amount of the asset to deposit + */ + function deposit(address vault, address asset, uint256 amount) external { + IERC20(asset).forceApprove(vault, amount); + IMetaMorpho(vault).deposit({assets: amount, receiver: address(this)}); + } + + /** + * @notice Withdraw assets from a MetaMorpho vault + * As suggested from MetaMorpho.sol doc, it is recommended to not use their + * redeemMax function to retrieve max shares to redeem due to cost. + * Instead will just use balanceOf(vault) to optimistically redeem all shares when amount is `type(uint256).max`. + * @param vault The address of the MetaMorpho vault + * @param amount The amount of assets to withdraw, if it is `type(uint256).max`, it will withdraw max + */ + function withdraw(address vault, uint256 amount) external { + if (amount == type(uint256).max) { + // Withdraw max + IMetaMorpho(vault).redeem({ + shares: IMetaMorpho(vault).balanceOf(address(this)), + receiver: address(this), + owner: address(this) + }); + } else { + IMetaMorpho(vault).withdraw({assets: amount, receiver: address(this), owner: address(this)}); + } + } +} + +contract MorphoActions { + // To handle non-standard ERC20 tokens (i.e. USDT) + using SafeERC20 for IERC20; + + /** + * @notice Repay assets and withdraw collateral from a Morpho blue market on behalf of `onBehalf` and send collateral to `receiver` + * @param morpho The address of the top level Morpho contract + * @param marketParams The market parameters of the individual morpho blue market + * @param repayAmount The amount of assets to repay, pass in `type(uint256).max` to repay max + * @param withdrawAmount The amount of assets to withdraw as collateral + */ + function repayAndWithdrawCollateral( + address morpho, + MarketParams memory marketParams, + uint256 repayAmount, + uint256 withdrawAmount + ) external { + if (repayAmount > 0) { + if (repayAmount == type(uint256).max) { + // Repay max + IERC20(marketParams.loanToken).forceApprove(morpho, type(uint256).max); + IMorpho(morpho).repay({ + marketParams: marketParams, + assets: 0, + shares: IMorpho(morpho).position(marketId(marketParams), address(this)).borrowShares, + onBehalf: address(this), + data: new bytes(0) + }); + IERC20(marketParams.loanToken).forceApprove(morpho, 0); + } else { + IERC20(marketParams.loanToken).forceApprove(morpho, repayAmount); + IMorpho(morpho).repay({ + marketParams: marketParams, + assets: repayAmount, + shares: 0, + onBehalf: address(this), + data: new bytes(0) + }); + } + } + + if (withdrawAmount > 0) { + IMorpho(morpho).withdrawCollateral({ + marketParams: marketParams, + assets: withdrawAmount, + onBehalf: address(this), + receiver: address(this) + }); + } + } + + /** + * @notice Supply collateral and borrow assets from a Morpho blue market on behalf of `onBehalf` and send borrowed assets to `receiver` + * @param morpho The address of the top level Morpho contract + * @param marketParams The market parameters of the individual morpho blue market + * @param supplyAssetAmount The amount of assets to supply as collateral + * @param borrowAssetAmount The amount of assets to borrow + */ + function supplyCollateralAndBorrow( + address morpho, + MarketParams memory marketParams, + uint256 supplyAssetAmount, + uint256 borrowAssetAmount + ) external { + if (supplyAssetAmount > 0) { + IERC20(marketParams.collateralToken).forceApprove(morpho, supplyAssetAmount); + IMorpho(morpho).supplyCollateral({ + marketParams: marketParams, + assets: supplyAssetAmount, + onBehalf: address(this), + data: new bytes(0) + }); + } + if (borrowAssetAmount > 0) { + IMorpho(morpho).borrow({ + marketParams: marketParams, + assets: borrowAssetAmount, + shares: 0, + onBehalf: address(this), + receiver: address(this) + }); + } + } + + // Helper function to convert MarketParams to bytes32 Id + // Reference: https://github.com/morpho-org/morpho-blue/blob/731e3f7ed97cf15f8fe00b86e4be5365eb3802ac/src/libraries/MarketParamsLib.sol + function marketId(MarketParams memory params) public pure returns (bytes32 marketParamsId) { + assembly ("memory-safe") { + marketParamsId := keccak256(params, 160) + } + } +} + +contract MorphoRewardsActions { + /** + * @notice Claim rewards from a Morpho Universal Rewards Distributor + * @param distributor The address of the Morpho Universal Rewards Distributor + * @param account The address of the account to claim rewards for + * @param reward The address of the reward token to claim + * @param claimable The amount of rewards to claim + * @param proofs The proofs to claim the rewards (reference: https://docs.morpho.org/rewards/tutorials/claim-rewards/) + */ + function claim(address distributor, address account, address reward, uint256 claimable, bytes32[] calldata proofs) + external + { + IMorphoUniversalRewardsDistributor(distributor).claim(account, reward, claimable, proofs); + } + + /** + * @notice Claim rewards from multiple Morpho Universal Rewards Distributors in one transaction + * @param distributors The addresses of the Morpho Universal Rewards Distributors + * @param accounts The addresses of the accounts to claim rewards for + * @param rewards The addresses of the reward tokens to claim + * @param claimables The amounts of rewards to claim + * @param proofs The batch of proofs to claim the rewards (reference: https://docs.morpho.org/rewards/tutorials/claim-rewards/) + */ + function claimAll( + address[] calldata distributors, + address[] calldata accounts, + address[] calldata rewards, + uint256[] calldata claimables, + bytes32[][] calldata proofs + ) external { + if ( + distributors.length != accounts.length || distributors.length != rewards.length + || distributors.length != claimables.length || distributors.length != proofs.length + ) { + revert DeFiScriptErrors.InvalidInput(); + } + + for (uint256 i = 0; i < distributors.length; ++i) { + IMorphoUniversalRewardsDistributor(distributors[i]).claim(accounts[i], rewards[i], claimables[i], proofs[i]); + } + } +} diff --git a/src/interfaces/IMetaMorpho.sol b/src/interfaces/IMetaMorpho.sol new file mode 100644 index 00000000..6ed71770 --- /dev/null +++ b/src/interfaces/IMetaMorpho.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: BSD-3-Clause +pragma solidity 0.8.23; + +import {IERC4626} from "openzeppelin/interfaces/IERC4626.sol"; + +/// @dev Interface for MetaMorpho (vault) for earn +/// Reference: https://github.com/morpho-org/metamorpho/blob/main/src/MetaMorpho.sol +interface IMetaMorpho is IERC4626 { + function deposit(uint256 assets, address receiver) external returns (uint256 shares); + function mint(uint256 shares, address receiver) external returns (uint256 assets); + function withdraw(uint256 assets, address receiver, address owner) external returns (uint256 shares); + function redeem(uint256 shares, address receiver, address owner) external returns (uint256 assets); +} diff --git a/src/interfaces/IMorpho.sol b/src/interfaces/IMorpho.sol new file mode 100644 index 00000000..06b2a197 --- /dev/null +++ b/src/interfaces/IMorpho.sol @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: BSD-3-Clause +pragma solidity 0.8.23; + +/// @dev Interface for Morpho blue markets +/// Reference: https://github.com/morpho-org/morpho-blue/blob/main/src/Morpho.sol +interface IMorpho { + function borrow( + MarketParams memory marketParams, + uint256 assets, + uint256 shares, + address onBehalf, + address receiver + ) external returns (uint256 assetsBorrowed, uint256 sharesBorrowed); + + function repay( + MarketParams memory marketParams, + uint256 assets, + uint256 shares, + address onBehalf, + bytes memory data + ) external returns (uint256 assetsRepaid, uint256 sharesRepaid); + + function supplyCollateral(MarketParams memory marketParams, uint256 assets, address onBehalf, bytes memory data) + external; + + function withdrawCollateral(MarketParams memory marketParams, uint256 assets, address onBehalf, address receiver) + external; + function position(bytes32 id, address account) external view returns (Position memory); + function market(bytes32 id) + external + view + returns ( + uint128 totalSupplyAssets, + uint128 totalSupplyShares, + uint128 totalBorrowAssets, + uint128 totalBorrowShares, + uint128 lastUpdate, + uint128 fee + ); +} + +struct MarketParams { + address loanToken; + address collateralToken; + address oracle; + address irm; + uint256 lltv; +} + +struct Position { + uint256 supplyShares; + uint128 borrowShares; + uint128 collateral; +} diff --git a/src/interfaces/IMorphoUniversalRewardsDistributor.sol b/src/interfaces/IMorphoUniversalRewardsDistributor.sol new file mode 100644 index 00000000..8d9f8300 --- /dev/null +++ b/src/interfaces/IMorphoUniversalRewardsDistributor.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: BSD-3-Clause +pragma solidity 0.8.23; + +/// @dev Interface for Morpho Universal Rewards Distributor +/// Reference: https://github.com/morpho-org/universal-rewards-distributor/blob/main/src/UniversalRewardsDistributor.sol +interface IMorphoUniversalRewardsDistributor { + function claim(address account, address reward, uint256 claimable, bytes32[] calldata proof) + external + returns (uint256 amount); +} diff --git a/src/vendor/manifest.json b/src/vendor/manifest.json index 40cc65e2..f4aac3ca 100644 --- a/src/vendor/manifest.json +++ b/src/vendor/manifest.json @@ -131,6 +131,26 @@ ] } ] + }, + "morpho_blue_periphery/MathLib.sol":{ + "source": { + "git": { + "repo": "git@github.com:morpho-org/morpho-blue.git", + "commit": "0448402af51b8293ed36653de43cbee8d4d2bfda", + "path": "src/libraries/MathLib.sol" + } + }, + "patches": [] + }, + "morpho_blue_periphery/SharesMathLib.sol":{ + "source": { + "git": { + "repo": "git@github.com:morpho-org/morpho-blue.git", + "commit": "0448402af51b8293ed36653de43cbee8d4d2bfda", + "path": "src/libraries/SharesMathLib.sol" + } + }, + "patches": [] } } } \ No newline at end of file diff --git a/src/vendor/morpho_blue_periphery/MathLib.sol b/src/vendor/morpho_blue_periphery/MathLib.sol new file mode 100644 index 00000000..653db4f8 --- /dev/null +++ b/src/vendor/morpho_blue_periphery/MathLib.sol @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.0; + +uint256 constant WAD = 1e18; + +/// @title MathLib +/// @author Morpho Labs +/// @custom:contact security@morpho.org +/// @notice Library to manage fixed-point arithmetic. +library MathLib { + /// @dev Returns (`x` * `y`) / `WAD` rounded down. + function wMulDown(uint256 x, uint256 y) internal pure returns (uint256) { + return mulDivDown(x, y, WAD); + } + + /// @dev Returns (`x` * `WAD`) / `y` rounded down. + function wDivDown(uint256 x, uint256 y) internal pure returns (uint256) { + return mulDivDown(x, WAD, y); + } + + /// @dev Returns (`x` * `WAD`) / `y` rounded up. + function wDivUp(uint256 x, uint256 y) internal pure returns (uint256) { + return mulDivUp(x, WAD, y); + } + + /// @dev Returns (`x` * `y`) / `d` rounded down. + function mulDivDown(uint256 x, uint256 y, uint256 d) internal pure returns (uint256) { + return (x * y) / d; + } + + /// @dev Returns (`x` * `y`) / `d` rounded up. + function mulDivUp(uint256 x, uint256 y, uint256 d) internal pure returns (uint256) { + return (x * y + (d - 1)) / d; + } + + /// @dev Returns the sum of the first three non-zero terms of a Taylor expansion of e^(nx) - 1, to approximate a + /// continuous compound interest rate. + function wTaylorCompounded(uint256 x, uint256 n) internal pure returns (uint256) { + uint256 firstTerm = x * n; + uint256 secondTerm = mulDivDown(firstTerm, firstTerm, 2 * WAD); + uint256 thirdTerm = mulDivDown(secondTerm, firstTerm, 3 * WAD); + + return firstTerm + secondTerm + thirdTerm; + } +} diff --git a/src/vendor/morpho_blue_periphery/SharesMathLib.sol b/src/vendor/morpho_blue_periphery/SharesMathLib.sol new file mode 100644 index 00000000..3ed7115b --- /dev/null +++ b/src/vendor/morpho_blue_periphery/SharesMathLib.sol @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.0; + +import {MathLib} from "./MathLib.sol"; + +/// @title SharesMathLib +/// @author Morpho Labs +/// @custom:contact security@morpho.org +/// @notice Shares management library. +/// @dev This implementation mitigates share price manipulations, using OpenZeppelin's method of virtual shares: +/// https://docs.openzeppelin.com/contracts/4.x/erc4626#inflation-attack. +library SharesMathLib { + using MathLib for uint256; + + /// @dev The number of virtual shares has been chosen low enough to prevent overflows, and high enough to ensure + /// high precision computations. + /// @dev Virtual shares can never be redeemed for the assets they are entitled to, but it is assumed the share price + /// stays low enough not to inflate these assets to a significant value. + /// @dev Warning: The assets to which virtual borrow shares are entitled behave like unrealizable bad debt. + uint256 internal constant VIRTUAL_SHARES = 1e6; + + /// @dev A number of virtual assets of 1 enforces a conversion rate between shares and assets when a market is + /// empty. + uint256 internal constant VIRTUAL_ASSETS = 1; + + /// @dev Calculates the value of `assets` quoted in shares, rounding down. + function toSharesDown(uint256 assets, uint256 totalAssets, uint256 totalShares) internal pure returns (uint256) { + return assets.mulDivDown(totalShares + VIRTUAL_SHARES, totalAssets + VIRTUAL_ASSETS); + } + + /// @dev Calculates the value of `shares` quoted in assets, rounding down. + function toAssetsDown(uint256 shares, uint256 totalAssets, uint256 totalShares) internal pure returns (uint256) { + return shares.mulDivDown(totalAssets + VIRTUAL_ASSETS, totalShares + VIRTUAL_SHARES); + } + + /// @dev Calculates the value of `assets` quoted in shares, rounding up. + function toSharesUp(uint256 assets, uint256 totalAssets, uint256 totalShares) internal pure returns (uint256) { + return assets.mulDivUp(totalShares + VIRTUAL_SHARES, totalAssets + VIRTUAL_ASSETS); + } + + /// @dev Calculates the value of `shares` quoted in assets, rounding up. + function toAssetsUp(uint256 shares, uint256 totalAssets, uint256 totalShares) internal pure returns (uint256) { + return shares.mulDivUp(totalAssets + VIRTUAL_ASSETS, totalShares + VIRTUAL_SHARES); + } +} diff --git a/test/MorphoActions.t.sol b/test/MorphoActions.t.sol new file mode 100644 index 00000000..6600a436 --- /dev/null +++ b/test/MorphoActions.t.sol @@ -0,0 +1,165 @@ +// SPDX-License-Identifier: BSD-3-Clause +pragma solidity 0.8.23; + +import "forge-std/Test.sol"; +import "forge-std/console.sol"; +import "forge-std/StdUtils.sol"; +import "forge-std/StdMath.sol"; + +import {CodeJar} from "codejar/src/CodeJar.sol"; +import {IERC4626} from "openzeppelin/interfaces/IERC4626.sol"; +import {IERC20} from "openzeppelin/interfaces/IERC20.sol"; +import {IMorpho, MarketParams, Position} from "src/interfaces/IMorpho.sol"; +import {QuarkWallet} from "quark-core/src/QuarkWallet.sol"; +import {QuarkStateManager} from "quark-core/src/QuarkStateManager.sol"; + +import {QuarkWalletProxyFactory} from "quark-proxy/src/QuarkWalletProxyFactory.sol"; + +import {YulHelper} from "./lib/YulHelper.sol"; +import {SignatureHelper} from "./lib/SignatureHelper.sol"; +import {QuarkOperationHelper, ScriptType} from "./lib/QuarkOperationHelper.sol"; +import {SharesMathLib} from "src/vendor/morpho_blue_periphery/SharesMathLib.sol"; +import "src/MorphoScripts.sol"; + +/** + * Tests for Morpho Blue market + */ +contract MorphoActionsTest is Test { + QuarkWalletProxyFactory public factory; + uint256 alicePrivateKey = 0xa11ce; + address alice = vm.addr(alicePrivateKey); + + // Contracts address on mainnet + address constant morpho = 0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb; + address constant adaptiveCurveIrm = 0x870aC11D48B15DB9a138Cf899d20F13F79Ba00BC; + address constant morphoOracle = 0x48F7E36EB6B826B2dF4B2E630B62Cd25e89E40e2; + address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; + address constant wstETH = 0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0; + MarketParams marketParams = MarketParams(USDC, wstETH, morphoOracle, adaptiveCurveIrm, 0.86e18); + bytes MorphoActionsScripts = new YulHelper().getCode("MorphoScripts.sol/MorphoActions.json"); + + function setUp() public { + // Fork setup + vm.createSelectFork( + string.concat( + "https://node-provider.compound.finance/ethereum-mainnet/", vm.envString("NODE_PROVIDER_BYPASS_KEY") + ), + 20564787 // 2024-08-19 12:34:00 PST + ); + factory = new QuarkWalletProxyFactory(address(new QuarkWallet(new CodeJar(), new QuarkStateManager()))); + } + + function testRepayAndWithdrawCollateral() public { + vm.pauseGasMetering(); + QuarkWallet wallet = QuarkWallet(factory.create(alice, address(0))); + + deal(wstETH, address(wallet), 10e18); + vm.startPrank(address(wallet)); + IERC20(wstETH).approve(morpho, 10e18); + IMorpho(morpho).supplyCollateral(marketParams, 10e18, address(wallet), new bytes(0)); + IMorpho(morpho).borrow(marketParams, 1000e6, 0, address(wallet), address(wallet)); + vm.stopPrank(); + + // Repay 800 USDC and withdraw 5 wstETH collateral + QuarkWallet.QuarkOperation memory op = new QuarkOperationHelper().newBasicOpWithCalldata( + wallet, + MorphoActionsScripts, + abi.encodeWithSelector(MorphoActions.repayAndWithdrawCollateral.selector, morpho, marketParams, 800e6, 5e18), + ScriptType.ScriptSource + ); + (uint8 v, bytes32 r, bytes32 s) = new SignatureHelper().signOp(alicePrivateKey, wallet, op); + assertEq(IERC20(USDC).balanceOf(address(wallet)), 1000e6); + assertEq(IERC20(wstETH).balanceOf(address(wallet)), 0); + (,, uint128 totalBorrowAssets, uint128 totalBorrowShares,,) = IMorpho(morpho).market(marketId(marketParams)); + assertEq( + IMorpho(morpho).position(marketId(marketParams), address(wallet)).borrowShares, + SharesMathLib.toSharesUp(1000e6, totalBorrowAssets, totalBorrowShares) + ); + + vm.resumeGasMetering(); + wallet.executeQuarkOperation(op, v, r, s); + + assertEq(IERC20(USDC).balanceOf(address(wallet)), 200e6); + assertEq(IERC20(wstETH).balanceOf(address(wallet)), 5e18); + assertEq(IMorpho(morpho).position(marketId(marketParams), address(wallet)).collateral, 5e18); + assertApproxEqAbs( + IMorpho(morpho).position(marketId(marketParams), address(wallet)).borrowShares, + SharesMathLib.toSharesUp(200e6, totalBorrowAssets, totalBorrowShares), + 1 + ); + } + + function testSupplyCollateralAndBorrow() public { + vm.pauseGasMetering(); + QuarkWallet wallet = QuarkWallet(factory.create(alice, address(0))); + + deal(wstETH, address(wallet), 10e18); + + // Supply 10 wstETH as collateral and borrow 1000 USDC + QuarkWallet.QuarkOperation memory op = new QuarkOperationHelper().newBasicOpWithCalldata( + wallet, + MorphoActionsScripts, + abi.encodeWithSelector( + MorphoActions.supplyCollateralAndBorrow.selector, morpho, marketParams, 10e18, 1000e6 + ), + ScriptType.ScriptSource + ); + (uint8 v, bytes32 r, bytes32 s) = new SignatureHelper().signOp(alicePrivateKey, wallet, op); + assertEq(IERC20(wstETH).balanceOf(address(wallet)), 10e18); + assertEq(IMorpho(morpho).position(marketId(marketParams), address(wallet)).collateral, 0); + + vm.resumeGasMetering(); + wallet.executeQuarkOperation(op, v, r, s); + + assertEq(IERC20(wstETH).balanceOf(address(wallet)), 0); + assertEq(IMorpho(morpho).position(marketId(marketParams), address(wallet)).collateral, 10e18); + assertEq(IERC20(USDC).balanceOf(address(wallet)), 1000e6); + } + + function testRepayMaxAndWithdrawCollateral() public { + vm.pauseGasMetering(); + QuarkWallet wallet = QuarkWallet(factory.create(alice, address(0))); + + deal(wstETH, address(wallet), 10e18); + deal(USDC, address(wallet), 100e6); + + vm.startPrank(address(wallet)); + IERC20(wstETH).approve(morpho, 10e18); + IMorpho(morpho).supplyCollateral(marketParams, 10e18, address(wallet), new bytes(0)); + IMorpho(morpho).borrow(marketParams, 1000e6, 0, address(wallet), address(wallet)); + vm.stopPrank(); + + // Repay max USDC and withdraw all 10 wstETH collateral + QuarkWallet.QuarkOperation memory op = new QuarkOperationHelper().newBasicOpWithCalldata( + wallet, + MorphoActionsScripts, + abi.encodeWithSelector( + MorphoActions.repayAndWithdrawCollateral.selector, morpho, marketParams, type(uint256).max, 10e18 + ), + ScriptType.ScriptSource + ); + (uint8 v, bytes32 r, bytes32 s) = new SignatureHelper().signOp(alicePrivateKey, wallet, op); + assertEq(IERC20(USDC).balanceOf(address(wallet)), 1100e6); + assertEq(IERC20(wstETH).balanceOf(address(wallet)), 0); + (,, uint128 totalBorrowAssets, uint128 totalBorrowShares,,) = IMorpho(morpho).market(marketId(marketParams)); + assertEq( + IMorpho(morpho).position(marketId(marketParams), address(wallet)).borrowShares, + SharesMathLib.toSharesUp(1000e6, totalBorrowAssets, totalBorrowShares) + ); + + vm.resumeGasMetering(); + wallet.executeQuarkOperation(op, v, r, s); + + assertApproxEqAbs(IERC20(USDC).balanceOf(address(wallet)), 100e6, 0.01e6); + assertEq(IMorpho(morpho).position(marketId(marketParams), address(wallet)).borrowShares, 0); + assertEq(IERC20(wstETH).balanceOf(address(wallet)), 10e18); + } + + // Helper function to convert MarketParams to bytes32 Id + // Reference: https://github.com/morpho-org/morpho-blue/blob/731e3f7ed97cf15f8fe00b86e4be5365eb3802ac/src/libraries/MarketParamsLib.sol + function marketId(MarketParams memory params) public pure returns (bytes32 marketParamsId) { + assembly ("memory-safe") { + marketParamsId := keccak256(params, 160) + } + } +} diff --git a/test/MorphoRewardsActions.t.sol b/test/MorphoRewardsActions.t.sol new file mode 100644 index 00000000..c4c76ec2 --- /dev/null +++ b/test/MorphoRewardsActions.t.sol @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: BSD-3-Clause +pragma solidity 0.8.23; + +import "forge-std/Test.sol"; +import "forge-std/console.sol"; +import "forge-std/StdUtils.sol"; +import "forge-std/StdMath.sol"; + +import {CodeJar} from "codejar/src/CodeJar.sol"; +import {IERC4626} from "openzeppelin/interfaces/IERC4626.sol"; +import {IERC20} from "openzeppelin/interfaces/IERC20.sol"; +import {IMorpho, MarketParams, Position} from "src/interfaces/IMorpho.sol"; +import {QuarkWallet} from "quark-core/src/QuarkWallet.sol"; +import {QuarkStateManager} from "quark-core/src/QuarkStateManager.sol"; + +import {QuarkWalletProxyFactory} from "quark-proxy/src/QuarkWalletProxyFactory.sol"; + +import {YulHelper} from "./lib/YulHelper.sol"; +import {SignatureHelper} from "./lib/SignatureHelper.sol"; +import {QuarkOperationHelper, ScriptType} from "./lib/QuarkOperationHelper.sol"; + +import {DeFiScriptErrors} from "src/lib/DeFiScriptErrors.sol"; + +import "src/DeFiScripts.sol"; +import "src/MorphoScripts.sol"; + +/** + * Tests for Morpho Rewards Claim + */ +contract MorphoRewardsActionsTest is Test { + QuarkWalletProxyFactory public factory; + uint256 alicePrivateKey = 0xa11ce; + address alice = vm.addr(alicePrivateKey); + + // Contracts address on mainnet + address constant morphoBlue = 0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb; + address constant adaptiveCurveIrm = 0x870aC11D48B15DB9a138Cf899d20F13F79Ba00BC; + address constant morphoOracle = 0x48F7E36EB6B826B2dF4B2E630B62Cd25e89E40e2; + address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; + address constant wstETH = 0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0; + MarketParams marketParams = MarketParams(USDC, wstETH, morphoOracle, adaptiveCurveIrm, 0.86e18); + bytes morphoRewardsActionsScripts = new YulHelper().getCode("MorphoScripts.sol/MorphoRewardsActions.json"); + bytes MorphoActionsScripts = new YulHelper().getCode("MorphoScripts.sol/MorphoActions.json"); + + // Just a list of data from Morpho rewards api for ease of testing on sample account + address sampleAccount = 0x87E0b41CB4d65d788f08c8D82589eA7923D73BA5; + address[] distributors = [0x330eefa8a787552DC5cAd3C3cA644844B1E61Ddb, 0x330eefa8a787552DC5cAd3C3cA644844B1E61Ddb]; + address[] accounts = [sampleAccount, sampleAccount]; + address[] rewards = [0xc55126051B22eBb829D00368f4B12Bde432de5Da, 0xdAC17F958D2ee523a2206206994597C13D831ec7]; + uint256[] claimables = [547387349612, 116]; + bytes32[][] proofs = [ + [ + bytes32(0xce63a4c1fabb68437d0e5edc21b732c5a215f1c5a9ed6a52902f0415e148cc0a), + bytes32(0x23b2ad869c44ff4946d49f0e048edd1303f0cef3679d3e21143c4cfdcde97f20), + bytes32(0x937a82a4d574f809052269e6d4a5613fa4ce333064d012e96e9cc3c04fee7a9c), + bytes32(0xf93fea78509a3b4fe28d963d965ab8819bbf6c08f5789bddde16127e98e6f696), + bytes32(0xbb53cefdee57ab5a04a7be61a15c1ea00beacd0a4adb132dd2e046582eafbec8), + bytes32(0x3dcb507af99e19c829fc2f5a8f57418258230818d4db8dc3080e5cafff5bfd3c), + bytes32(0xca3e0c0cc07c55a02cbc21313bbd9a4d27dae6a28580fbd7dfad74216d4edac3), + bytes32(0x59bdab6ff3d8cd5c682ff241da1d56e9bba6f5c0a739c28629c10ffab8bb9c95), + bytes32(0x56a6fd126541d4a6b4902b78125db2c92b3b9cfb3249bbe3681cc2ccf9a6aa2c), + bytes32(0xfcfad3b73969b50e0369e94db6fcd9301b5e776784620a09c0b52a5cf3326f2b), + bytes32(0x7ee3c650dc15c36a6a0284c40b61391f7ac07f57d50802d92d2ccb7a19ff9dbb) + ], + [ + bytes32(0x7ac5a364f8e3d902a778e6f22d9800304bce9a24108a6b375e9d7afffa586648), + bytes32(0xd0e2f9d70a7c8ddfe74cf2e922067421f06af4c16da32c13d13e6226aff54772), + bytes32(0x8417ffe0c1e153c75ad3bf85f8d52b22ebc5370deda637231cb7fef3238d60b7), + bytes32(0x99baa8011e519a6650c7f8887edde764c9198973be390dfad9a43e8af4603326), + bytes32(0x7db554929334c43f06c93b0917a22765ba0b27684eb3bdbb09eefaad665cf51f), + bytes32(0xd35638edfe77f64712acd397cfddd12da5ba480d05d77b52fa5f9f930b8c4a11), + bytes32(0xee0010ba447e3edda1a034acc142e66ce5c772dc9cbbdf86044e5ee760d4159f), + bytes32(0xedca6a5e9ba49d334eebdc4167e1730fcce5c7e4bbc17638c1cb6b4c42e85e9b), + bytes32(0xfd8786de55c7c2e69c4ede4fe80b5d696875621b7aea7f29736451d3ea667427), + bytes32(0xff695c9c3721e77a593d67cf0cbea7d495d0120ed51e31ab1428a7251665ce37), + bytes32(0x487b38c91a22d77f124819ab4d40eea67b11683459c458933cae385630c90816) + ] + ]; + + function setUp() public { + // Fork setup + vm.createSelectFork( + string.concat( + "https://node-provider.compound.finance/ethereum-mainnet/", vm.envString("NODE_PROVIDER_BYPASS_KEY") + ), + 20568177 // 2024-08-19 23:54:00 PST + ); + factory = new QuarkWalletProxyFactory(address(new QuarkWallet(new CodeJar(), new QuarkStateManager()))); + } + + function testClaim() public { + vm.pauseGasMetering(); + QuarkWallet wallet = QuarkWallet(factory.create(alice, address(0))); + // Morpho claim rewards is depends on the account input, so even if the wallet is not the one + // with rewards, wallet can still claim it, but just rewards still goes to the original owner + QuarkWallet.QuarkOperation memory op = new QuarkOperationHelper().newBasicOpWithCalldata( + wallet, + morphoRewardsActionsScripts, + abi.encodeWithSelector( + MorphoRewardsActions.claim.selector, distributors[0], accounts[0], rewards[0], claimables[0], proofs[0] + ), + ScriptType.ScriptSource + ); + (uint8 v, bytes32 r, bytes32 s) = new SignatureHelper().signOp(alicePrivateKey, wallet, op); + assertEq(IERC20(0xc55126051B22eBb829D00368f4B12Bde432de5Da).balanceOf(sampleAccount), 0); + + vm.resumeGasMetering(); + wallet.executeQuarkOperation(op, v, r, s); + + assertEq(IERC20(0xc55126051B22eBb829D00368f4B12Bde432de5Da).balanceOf(sampleAccount), 547387349612); + } + + function testClaimAll() public { + vm.pauseGasMetering(); + QuarkWallet wallet = QuarkWallet(factory.create(alice, address(0))); + // Morpho claim rewards is depends on the account input, so even if the wallet is not the one + // with rewards, wallet can still claim it, but just rewards still goes to the original owner + QuarkWallet.QuarkOperation memory op = new QuarkOperationHelper().newBasicOpWithCalldata( + wallet, + morphoRewardsActionsScripts, + abi.encodeWithSelector( + MorphoRewardsActions.claimAll.selector, distributors, accounts, rewards, claimables, proofs + ), + ScriptType.ScriptSource + ); + (uint8 v, bytes32 r, bytes32 s) = new SignatureHelper().signOp(alicePrivateKey, wallet, op); + assertEq(IERC20(0xc55126051B22eBb829D00368f4B12Bde432de5Da).balanceOf(sampleAccount), 0); + assertEq(IERC20(0xdAC17F958D2ee523a2206206994597C13D831ec7).balanceOf(sampleAccount), 0); + + vm.resumeGasMetering(); + wallet.executeQuarkOperation(op, v, r, s); + + assertEq(IERC20(0xc55126051B22eBb829D00368f4B12Bde432de5Da).balanceOf(sampleAccount), 547387349612); + assertEq(IERC20(0xdAC17F958D2ee523a2206206994597C13D831ec7).balanceOf(sampleAccount), 116); + } +} diff --git a/test/MorphoVaultActions.t.sol b/test/MorphoVaultActions.t.sol new file mode 100644 index 00000000..6d001ff4 --- /dev/null +++ b/test/MorphoVaultActions.t.sol @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: BSD-3-Clause +pragma solidity 0.8.23; + +import "forge-std/Test.sol"; +import "forge-std/console.sol"; +import "forge-std/StdUtils.sol"; +import "forge-std/StdMath.sol"; + +import {CodeJar} from "codejar/src/CodeJar.sol"; +import {IERC4626} from "openzeppelin/interfaces/IERC4626.sol"; +import {QuarkWallet} from "quark-core/src/QuarkWallet.sol"; +import {QuarkStateManager} from "quark-core/src/QuarkStateManager.sol"; + +import {QuarkWalletProxyFactory} from "quark-proxy/src/QuarkWalletProxyFactory.sol"; + +import {YulHelper} from "./lib/YulHelper.sol"; +import {SignatureHelper} from "./lib/SignatureHelper.sol"; +import {QuarkOperationHelper, ScriptType} from "./lib/QuarkOperationHelper.sol"; + +import {DeFiScriptErrors} from "src/lib/DeFiScriptErrors.sol"; + +import "src/DeFiScripts.sol"; +import "src/MorphoScripts.sol"; + +/** + * Tests for supplying assets to Morpho Vault + */ +contract MorphoVaultActionsTest is Test { + QuarkWalletProxyFactory public factory; + uint256 alicePrivateKey = 0xa11ce; + address alice = vm.addr(alicePrivateKey); + + // Contracts address on mainnet + address constant morphoVault = 0x8eB67A509616cd6A7c1B3c8C21D48FF57df3d458; + address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; + bytes morphoVaultActionsScripts = new YulHelper().getCode("MorphoScripts.sol/MorphoVaultActions.json"); + + function setUp() public { + // Fork setup + vm.createSelectFork( + string.concat( + "https://node-provider.compound.finance/ethereum-mainnet/", vm.envString("NODE_PROVIDER_BYPASS_KEY") + ), + 20564787 // 2024-08-19 12:34:00 PST + ); + factory = new QuarkWalletProxyFactory(address(new QuarkWallet(new CodeJar(), new QuarkStateManager()))); + } + + function testDeposit() public { + vm.pauseGasMetering(); + QuarkWallet wallet = QuarkWallet(factory.create(alice, address(0))); + + deal(USDC, address(wallet), 10_000e6); + + QuarkWallet.QuarkOperation memory op = new QuarkOperationHelper().newBasicOpWithCalldata( + wallet, + morphoVaultActionsScripts, + abi.encodeWithSelector(MorphoVaultActions.deposit.selector, morphoVault, USDC, 10_000e6), + ScriptType.ScriptSource + ); + (uint8 v, bytes32 r, bytes32 s) = new SignatureHelper().signOp(alicePrivateKey, wallet, op); + assertEq(IERC20(USDC).balanceOf(address(wallet)), 10_000e6); + assertEq(IERC4626(morphoVault).balanceOf(address(wallet)), 0); + + vm.resumeGasMetering(); + wallet.executeQuarkOperation(op, v, r, s); + + assertEq(IERC20(USDC).balanceOf(address(wallet)), 0); + assertApproxEqAbs( + IERC4626(morphoVault).convertToAssets(IERC4626(morphoVault).balanceOf(address(wallet))), 10_000e6, 0.01e6 + ); + } + + function testWithdraw() public { + vm.pauseGasMetering(); + QuarkWallet wallet = QuarkWallet(factory.create(alice, address(0))); + + // Deal vault shares to wallet, ERC4262 is ERC20 compatible + deal(morphoVault, address(wallet), 10_000e18); + + QuarkWallet.QuarkOperation memory op = new QuarkOperationHelper().newBasicOpWithCalldata( + wallet, + morphoVaultActionsScripts, + abi.encodeWithSelector(MorphoVaultActions.withdraw.selector, morphoVault, 10_000e6), + ScriptType.ScriptSource + ); + (uint8 v, bytes32 r, bytes32 s) = new SignatureHelper().signOp(alicePrivateKey, wallet, op); + assertEq(IERC20(USDC).balanceOf(address(wallet)), 0e6); + assertEq(IERC4626(morphoVault).balanceOf(address(wallet)), 10_000e18); + + vm.resumeGasMetering(); + wallet.executeQuarkOperation(op, v, r, s); + + assertEq(IERC20(USDC).balanceOf(address(wallet)), 10_000e6); + } +} From 09d67e9e964625b9bc968c88b0c577a9b1aa543a Mon Sep 17 00:00:00 2001 From: Coburn Berry <32463466+coburncoburn@users.noreply.github.com> Date: Fri, 6 Sep 2024 14:28:11 -0400 Subject: [PATCH 06/13] grep for errors in the src and lib (#72) adding a simple script to grep for all the error declarations. --- script/list-errors.sh | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100755 script/list-errors.sh diff --git a/script/list-errors.sh b/script/list-errors.sh new file mode 100755 index 00000000..a4296b72 --- /dev/null +++ b/script/list-errors.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +# This script searches for custom Solidity error declarations in the ./lib and ./src directories, +# excluding any files with 'test' or 'mock' in their names. + +# Run the grep command to find error declarations +grep -r "error [A-Z]" ./lib ./src --include \*.sol --exclude '*test*' --exclude '*mock*' From 4d433bd7845c3d668d3febb8defb9270d83e3e74 Mon Sep 17 00:00:00 2001 From: Kevin Cheng Date: Mon, 9 Sep 2024 14:23:13 -0700 Subject: [PATCH 07/13] Switch to Uniswap `SwapRouter02` from `SwapRouter` (#70) We switch to Uniswap's `SwapRouter02` from `SwapRouter` because `SwapRouter` isn't supported on chains like Base. The routers are mostly the same, with `SwapRouter02` being newer and having support for Uniswap v2. However, their interfaces do differ slightly as the swap params for `SwapRouter02` no longer take a `deadline`. This is fine since a) recurring swaps don't need a deadline anyways and b) we can use the QuarkOperation's `expiry` to set the deadline for one-time swaps. We have to manually import parts of `ISwapRouter02` because one of its dependencies [locks the compiler version](https://github.com/Uniswap/swap-router-contracts/issues/72) to `=0.7.6`, so the library cannot be directly used for higher Solidity versions without modifications. --- .gas-snapshot | 174 +++++++++++++++--- .gitmodules | 3 + foundry.toml | 3 + lib/swap-router-contracts | 1 + remappings.txt | 1 + src/DeFiScripts.sol | 14 +- src/RecurringSwap.sol | 14 +- src/UniswapFlashLoan.sol | 2 +- src/UniswapFlashSwapExactOut.sol | 2 +- src/builder/Actions.sol | 2 - src/builder/UniswapRouter.sol | 9 +- src/vendor/manifest.json | 78 +++++++- .../IApproveAndCall.sol | 63 +++++++ .../ISwapRouter02.sol | 16 ++ .../PoolAddress.sol | 0 test/MorphoActions.t.sol | 4 +- test/MorphoRewardsActions.t.sol | 4 +- test/MorphoVaultActions.t.sol | 4 +- test/Multicall.t.sol | 5 +- test/RecurringSwap.t.sol | 56 +----- test/UniswapFlashLoan.t.sol | 2 +- test/UniswapFlashSwapExactOut.t.sol | 2 +- test/UniswapSwapActions.t.sol | 13 +- test/builder/QuarkBuilderRecurringSwap.t.sol | 1 - 24 files changed, 335 insertions(+), 138 deletions(-) create mode 160000 lib/swap-router-contracts create mode 100644 src/vendor/uniswap-swap-router-contracts/IApproveAndCall.sol create mode 100644 src/vendor/uniswap-swap-router-contracts/ISwapRouter02.sol rename src/vendor/{uniswap_v3_periphery => uniswap-v3-periphery}/PoolAddress.sol (100%) diff --git a/.gas-snapshot b/.gas-snapshot index 8d99b218..d728a0f3 100644 --- a/.gas-snapshot +++ b/.gas-snapshot @@ -1,11 +1,11 @@ -ApproveAndSwapTest:testSwap() (gas: 285500) -ApproveAndSwapTest:testSwapFailsIfWeExpectedTooMuch() (gas: 364666) -ApproveAndSwapTest:testSwapFailsWithNoApproval() (gas: 122231) +ApproveAndSwapTest:testSwap() (gas: 285492) +ApproveAndSwapTest:testSwapFailsIfWeExpectedTooMuch() (gas: 364765) +ApproveAndSwapTest:testSwapFailsWithNoApproval() (gas: 122254) CCTPBridge:testBridgeToBase() (gas: 146874) CometClaimRewardsTest:testClaimComp() (gas: 131265) -CometRepayAndWithdrawMultipleAssetsTest:testInvalidInput() (gas: 68171) +CometRepayAndWithdrawMultipleAssetsTest:testInvalidInput() (gas: 68180) CometRepayAndWithdrawMultipleAssetsTest:testRepayAndWithdrawMultipleAssets() (gas: 153860) -CometSupplyMultipleAssetsAndBorrowTest:testInvalidInput() (gas: 68114) +CometSupplyMultipleAssetsAndBorrowTest:testInvalidInput() (gas: 68123) CometSupplyMultipleAssetsAndBorrowTest:testSupplyMultipleAssetsAndBorrow() (gas: 295260) ConditionalMulticallTest:testConditionalRunEmptyInputIsValid() (gas: 51386) ConditionalMulticallTest:testConditionalRunInvalidInput() (gas: 68840) @@ -19,8 +19,15 @@ EthcallTest:testEthcallShouldReturnCallResult() (gas: 52545) EthcallTest:testEthcallSupplyUSDCToComet() (gas: 171965) EthcallTest:testEthcallWithdrawUSDCFromComet() (gas: 296115) GetDripTest:testDrip() (gas: 120591) +MorphoActionsTest:testRepayAndWithdrawCollateral() (gas: 130575) +MorphoActionsTest:testRepayMaxAndWithdrawCollateral() (gas: 118293) +MorphoActionsTest:testSupplyCollateralAndBorrow() (gas: 250037) +MorphoRewardsActionsTest:testClaim() (gas: 112124) +MorphoRewardsActionsTest:testClaimAll() (gas: 186069) +MorphoVaultActionsTest:testDeposit() (gas: 762206) +MorphoVaultActionsTest:testWithdraw() (gas: 570979) MulticallTest:testCallcodeToMulticallSucceedsWhenUninitialized() (gas: 83316) -MulticallTest:testCreateSubWalletAndExecute() (gas: 608534) +MulticallTest:testCreateSubWalletAndExecute() (gas: 619226) MulticallTest:testEmptyInputIsValid() (gas: 50892) MulticallTest:testExecutorCanMulticallAcrossSubwallets() (gas: 303309) MulticallTest:testInvalidInput() (gas: 68358) @@ -29,23 +36,126 @@ MulticallTest:testMulticallError() (gas: 309667) MulticallTest:testMulticallShouldReturnCallResults() (gas: 86409) MulticallTest:testRevertsForInvalidCallContext() (gas: 11574) MulticallTest:testSupplyWETHWithdrawUSDCOnComet() (gas: 259628) -PaycallTest:testInitializeProperlyFromConstructor() (gas: 6425) -PaycallTest:testPaycallForPayWithUSDT() (gas: 121389) -PaycallTest:testPaycallForPayWithWBTC() (gas: 115864) -PaycallTest:testReturnCallResult() (gas: 89981) -PaycallTest:testRevertsForInvalidCallContext() (gas: 16521) -PaycallTest:testRevertsWhenCostIsMoreThanMaxPaymentCost() (gas: 119258) -PaycallTest:testSimpleCounterAndPayWithUSDC() (gas: 143929) -PaycallTest:testSimpleTransferTokenAndPayWithUSDC() (gas: 139569) -PaycallTest:testSupplyWETHWithdrawUSDCOnCometAndPayWithUSDC() (gas: 300142) -ReplayableTransactionsTest:testCancelRecurringPurchase() (gas: 267995) -ReplayableTransactionsTest:testRecurringPurchaseHappyPath() (gas: 210285) -ReplayableTransactionsTest:testRecurringPurchaseMultiplePurchases() (gas: 365698) -ReplayableTransactionsTest:testRecurringPurchaseWithDifferentCalldata() (gas: 590229) -ReplayableTransactionsTest:testRevertsForExpiredQuarkOperation() (gas: 11893) -ReplayableTransactionsTest:testRevertsForExpiredUniswapParams() (gas: 118215) -ReplayableTransactionsTest:testRevertsForPurchaseBeforeNextPurchasePeriod() (gas: 283863) -ReplayableTransactionsTest:testRevertsForPurchasingOverTheLimit() (gas: 284305) +PaycallTest:testInitializeProperlyFromConstructor() (gas: 6430) +PaycallTest:testPaycallForPayWithUSDT() (gas: 125307) +PaycallTest:testPaycallForPayWithWBTC() (gas: 119793) +PaycallTest:testPaycallRevertsWhenCallReverts() (gas: 72350) +PaycallTest:testReturnCallResult() (gas: 92209) +PaycallTest:testRevertWhenCostIsMoreThanMaxPaymentCost() (gas: 119383) +PaycallTest:testRevertsForInvalidCallContext() (gas: 16562) +PaycallTest:testSimpleCounterAndPayWithUSDC() (gas: 148804) +PaycallTest:testSimpleTransferTokenAndPayWithUSDC() (gas: 144444) +PaycallTest:testSupplyWETHWithdrawUSDCOnCometAndPayWithUSDC() (gas: 305017) +PaycallWrapperTest:testSimpleTransferAndWrapForPaycall() (gas: 1342091) +PriceFeedsTest:testFindPriceFeedPathDirectMatch() (gas: 210653) +PriceFeedsTest:testFindPriceFeedPathDirectMatchWithReverse() (gas: 215730) +PriceFeedsTest:testFindPriceFeedPathNoMatch() (gas: 221293) +PriceFeedsTest:testFindPriceFeedPathOneHopMatch() (gas: 271926) +PriceFeedsTest:testFindPriceFeeds() (gas: 100690) +QuarkBuilderCometBorrowTest:testBorrow() (gas: 19750324) +QuarkBuilderCometBorrowTest:testBorrowFundsUnavailable() (gas: 18786452) +QuarkBuilderCometBorrowTest:testBorrowInvalidInput() (gas: 18586022) +QuarkBuilderCometBorrowTest:testBorrowPayFromBorrow() (gas: 20781060) +QuarkBuilderCometBorrowTest:testBorrowWithAutoWrapper() (gas: 21124862) +QuarkBuilderCometBorrowTest:testBorrowWithBridgedPaymentToken() (gas: 22260790) +QuarkBuilderCometBorrowTest:testBorrowWithBridgedcollateralAsset() (gas: 22323602) +QuarkBuilderCometBorrowTest:testBorrowWithPaycall() (gas: 20721101) +QuarkBuilderCometRepayTest:testCometRepay() (gas: 19692940) +QuarkBuilderCometRepayTest:testCometRepayFundsUnavailable() (gas: 18723824) +QuarkBuilderCometRepayTest:testCometRepayInvalidInput() (gas: 18586338) +QuarkBuilderCometRepayTest:testCometRepayMax() (gas: 20498265) +QuarkBuilderCometRepayTest:testCometRepayMaxCostTooHigh() (gas: 19142820) +QuarkBuilderCometRepayTest:testCometRepayMaxWithBridge() (gas: 22229297) +QuarkBuilderCometRepayTest:testCometRepayPayFromWithdraw() (gas: 20970376) +QuarkBuilderCometRepayTest:testCometRepayWithAutoWrapper() (gas: 21113470) +QuarkBuilderCometRepayTest:testCometRepayWithBridge() (gas: 22309913) +QuarkBuilderCometRepayTest:testCometRepayWithPaycall() (gas: 20612154) +QuarkBuilderCometSupplyTest:testCometSupplyMaxWithBridge() (gas: 19806359) +QuarkBuilderCometSupplyTest:testCometSupplyMaxWithBridgeAndQuotecall() (gas: 20092356) +QuarkBuilderCometSupplyTest:testCometSupplyWithBridge() (gas: 19609730) +QuarkBuilderCometSupplyTest:testCometSupplyWithBridgeAndPaycall() (gas: 19933817) +QuarkBuilderCometSupplyTest:testCometSupplyWithPaycall() (gas: 19156407) +QuarkBuilderCometSupplyTest:testFundsUnavailable() (gas: 18703862) +QuarkBuilderCometSupplyTest:testInsufficientFunds() (gas: 18722355) +QuarkBuilderCometSupplyTest:testMaxCostTooHigh() (gas: 18702511) +QuarkBuilderCometSupplyTest:testSimpleCometSupply() (gas: 18985338) +QuarkBuilderCometSupplyTest:testSimpleCometSupplyMax() (gas: 19006508) +QuarkBuilderCometSupplyTest:testSimpleCometSupplyWithAutoWrapper() (gas: 19613872) +QuarkBuilderCometWithdrawTest:testCometWithdraw() (gas: 18847756) +QuarkBuilderCometWithdrawTest:testCometWithdrawMax() (gas: 19160292) +QuarkBuilderCometWithdrawTest:testCometWithdrawMaxRevertsMaxCostTooHigh() (gas: 18854170) +QuarkBuilderCometWithdrawTest:testCometWithdrawPayFromWithdraw() (gas: 19164988) +QuarkBuilderCometWithdrawTest:testCometWithdrawWithBridge() (gas: 19696713) +QuarkBuilderCometWithdrawTest:testCometWithdrawWithPaycall() (gas: 19125617) +QuarkBuilderCometWithdrawTest:testWithdrawNotEnoughFundsToBridge() (gas: 18771151) +QuarkBuilderHelperTest:testAddBufferToPaymentCost() (gas: 413026) +QuarkBuilderHelperTest:testCanBridgeUSDCOnSupportedChains() (gas: 423046) +QuarkBuilderHelperTest:testCannotBridgeUSDCOnUnsupportedChains() (gas: 420966) +QuarkBuilderHelperTest:testCannotBridgeUnsupportedAssets() (gas: 419619) +QuarkBuilderRecurringSwapTest:testFundsUnavailableErrorGivesSuggestionForAvailableFunds() (gas: 18706546) +QuarkBuilderRecurringSwapTest:testInsufficientFunds() (gas: 18692219) +QuarkBuilderRecurringSwapTest:testMaxCostTooHigh() (gas: 18706365) +QuarkBuilderRecurringSwapTest:testNotEnoughFundsOnTargetChain() (gas: 18691754) +QuarkBuilderRecurringSwapTest:testRecurringExactInSwapSucceeds() (gas: 19757953) +QuarkBuilderRecurringSwapTest:testRecurringExactOutSwapSucceeds() (gas: 19757841) +QuarkBuilderRecurringSwapTest:testRecurringSwapWithPaycallSucceeds() (gas: 20044818) +QuarkBuilderSwapTest:testBridgeSwapBridgesPaymentToken() (gas: 20023763) +QuarkBuilderSwapTest:testBridgeSwapMaxWithQuotecallSucceeds() (gas: 20263384) +QuarkBuilderSwapTest:testBridgeSwapSucceeds() (gas: 19771808) +QuarkBuilderSwapTest:testBridgeSwapWithPaycallSucceeds() (gas: 20104014) +QuarkBuilderSwapTest:testFundsOnUnbridgeableChains() (gas: 18710246) +QuarkBuilderSwapTest:testFundsUnavailableErrorGivesSuggestionForAvailableFunds() (gas: 18708049) +QuarkBuilderSwapTest:testIgnoresChainIfMaxCostIsNotSpecified() (gas: 18707930) +QuarkBuilderSwapTest:testInsufficientFunds() (gas: 18727769) +QuarkBuilderSwapTest:testLocalSwapSucceeds() (gas: 19146905) +QuarkBuilderSwapTest:testLocalSwapWithAutoWrapperSucceeds() (gas: 19775323) +QuarkBuilderSwapTest:testLocalSwapWithPaycallSucceeds() (gas: 19325351) +QuarkBuilderSwapTest:testMaxCostTooHigh() (gas: 18707872) +QuarkBuilderSwapTest:testRevertsIfNotEnoughFundsToBridge() (gas: 18907157) +QuarkBuilderSwapTest:testSwapMaxSucceeds() (gas: 19393623) +QuarkBuilderTransferTest:testBridgeTransferBridgesPaymentToken() (gas: 19846281) +QuarkBuilderTransferTest:testBridgeTransferMaxFundUnavailableError() (gas: 18920988) +QuarkBuilderTransferTest:testFundsOnUnbridgeableChains() (gas: 18704432) +QuarkBuilderTransferTest:testFundsUnavailableErrorGivesSuggestionForAvailableFunds() (gas: 18703025) +QuarkBuilderTransferTest:testIgnoresChainIfMaxCostIsNotSpecified() (gas: 18702967) +QuarkBuilderTransferTest:testInsufficientFunds() (gas: 18722742) +QuarkBuilderTransferTest:testMaxCostTooHigh() (gas: 18702895) +QuarkBuilderTransferTest:testRevertsIfNotEnoughFundsToBridge() (gas: 18901147) +QuarkBuilderTransferTest:testSimpleBridgeTransferMax() (gas: 20060891) +QuarkBuilderTransferTest:testSimpleBridgeTransferSucceeds() (gas: 19562564) +QuarkBuilderTransferTest:testSimpleBridgeTransferWithPaycallSucceeds() (gas: 19925119) +QuarkBuilderTransferTest:testSimpleLocalTransferMax() (gas: 19184362) +QuarkBuilderTransferTest:testSimpleLocalTransferSucceeds() (gas: 18945497) +QuarkBuilderTransferTest:testSimpleLocalTransferWithPaycallSucceeds() (gas: 19156154) +QuarkBuilderTransferTest:testTransferMaxWithAutoUnwrapping() (gas: 20222055) +QuarkBuilderTransferTest:testTransferWithAutoUnwrapping() (gas: 19547064) +QuarkBuilderTransferTest:testTransferWithAutoUnwrappingWithPaycallSucceeds() (gas: 20048567) +QuarkBuilderTransferTest:testTransferWithAutoWrapping() (gas: 19607562) +QuotecallTest:testInitializeProperlyFromConstructor() (gas: 7036) +QuotecallTest:testQuotecallForPayWithUSDT() (gas: 125408) +QuotecallTest:testQuotecallForPayWithWBTC() (gas: 119962) +QuotecallTest:testQuotecallRevertsWhenCallReverts() (gas: 108579) +QuotecallTest:testReturnCallResult() (gas: 112319) +QuotecallTest:testRevertsForInvalidCallContext() (gas: 16537) +QuotecallTest:testRevertsWhenQuoteTooHigh() (gas: 155229) +QuotecallTest:testRevertsWhenQuoteTooLow() (gas: 155071) +QuotecallTest:testSimpleCounterAndPayWithUSDC() (gas: 148952) +QuotecallTest:testSimpleTransferTokenAndPayWithUSDC() (gas: 144596) +QuotecallWrapperTest:testSimpleTransferAndWrapForQuotecall() (gas: 1407584) +RecurringSwapTest:testCancelRecurringSwap() (gas: 293567) +RecurringSwapTest:testRecurringSwapCanSwapMultipleTimes() (gas: 425136) +RecurringSwapTest:testRecurringSwapExactInAlternateSwap() (gas: 229791) +RecurringSwapTest:testRecurringSwapExactInSwap() (gas: 237508) +RecurringSwapTest:testRecurringSwapExactOutAlternateSwap() (gas: 233113) +RecurringSwapTest:testRecurringSwapExactOutSwap() (gas: 239457) +RecurringSwapTest:testRecurringSwapWithDifferentCalldata() (gas: 724141) +RecurringSwapTest:testRecurringSwapWithMultiplePriceFeeds() (gas: 254663) +RecurringSwapTest:testRevertsForExpiredQuarkOperation() (gas: 12817) +RecurringSwapTest:testRevertsForInvalidInput() (gas: 143492) +RecurringSwapTest:testRevertsForSwapBeforeNextSwapWindow() (gas: 307929) +RecurringSwapTest:testRevertsForSwapBeforeStartTime() (gas: 9223372036854754743) +RecurringSwapTest:testRevertsWhenSlippageParamsConfiguredWrong() (gas: 260596) +RecurringSwapTest:testRevertsWhenSlippageTooHigh() (gas: 260796) SupplyActionsTest:testInvalidInput() (gas: 67616) SupplyActionsTest:testRepayBorrow() (gas: 89628) SupplyActionsTest:testSupply() (gas: 131927) @@ -57,7 +167,7 @@ TransferActionsTest:testRevertsForTransferReentrancyAttackWithReentrancyGuard() TransferActionsTest:testRevertsForTransferReentrancyAttackWithoutCallbackEnabled() (gas: 99351) TransferActionsTest:testRevertsForTransferReentrantAttackWithStolenSignature() (gas: 110234) TransferActionsTest:testTransferERC20TokenToEOA() (gas: 70280) -TransferActionsTest:testTransferERC20TokenToQuarkWallet() (gas: 71600) +TransferActionsTest:testTransferERC20TokenToQuarkWallet() (gas: 71604) TransferActionsTest:testTransferERC777SuccessWithEvilReceiverWithoutAttackAttempt() (gas: 106630) TransferActionsTest:testTransferERC777TokenReentrancyAttackSuccessWithCallbackEnabled() (gas: 144402) TransferActionsTest:testTransferNativeTokenToEOA() (gas: 79394) @@ -73,14 +183,18 @@ UniswapFlashSwapExactOutTest:testInvalidCallerFlashSwap() (gas: 70209) UniswapFlashSwapExactOutTest:testNotEnoughToPayFlashSwap() (gas: 293339) UniswapFlashSwapExactOutTest:testRevertsIfCalledDirectly() (gas: 10724) UniswapFlashSwapExactOutTest:testUniswapFlashSwapExactOutLeverageComet() (gas: 353254) -UniswapSwapActionsTest:testApprovalRefund() (gas: 163571) -UniswapSwapActionsTest:testBuyAssetOneStop() (gas: 251129) -UniswapSwapActionsTest:testBuyAssetTwoStops() (gas: 357689) -UniswapSwapActionsTest:testSellAssetOneStop() (gas: 248467) -UniswapSwapActionsTest:testSellAssetTwoStops() (gas: 361614) +UniswapSwapActionsTest:testApprovalRefund() (gas: 163410) +UniswapSwapActionsTest:testBuyAssetOneStop() (gas: 250917) +UniswapSwapActionsTest:testBuyAssetTwoStops() (gas: 357575) +UniswapSwapActionsTest:testSellAssetOneStop() (gas: 248255) +UniswapSwapActionsTest:testSellAssetTwoStops() (gas: 361500) WithdrawActionsTest:testBorrow() (gas: 153735) WithdrawActionsTest:testInvalidInput() (gas: 67488) WithdrawActionsTest:testWithdraw() (gas: 83594) WithdrawActionsTest:testWithdrawFrom() (gas: 83108) WithdrawActionsTest:testWithdrawMultipleAssets() (gas: 159195) -WithdrawActionsTest:testWithdrawTo() (gas: 83573) \ No newline at end of file +WithdrawActionsTest:testWithdrawTo() (gas: 83573) +WrapperScriptsTest:testUnwrapWETH() (gas: 57751) +WrapperScriptsTest:testUnwrapWstETH() (gas: 100520) +WrapperScriptsTest:testWrapETH() (gas: 76834) +WrapperScriptsTest:testWrapStETH() (gas: 124640) \ No newline at end of file diff --git a/.gitmodules b/.gitmodules index 8e9e25ac..9bdf4cdf 100644 --- a/.gitmodules +++ b/.gitmodules @@ -13,3 +13,6 @@ [submodule "lib/quark"] path = lib/quark url = git@github.com:compound-finance/quark +[submodule "lib/swap-router-contracts"] + path = lib/swap-router-contracts + url = https://github.com/Uniswap/swap-router-contracts diff --git a/foundry.toml b/foundry.toml index 752bd8ad..9e691a14 100644 --- a/foundry.toml +++ b/foundry.toml @@ -12,5 +12,8 @@ via_ir = true optimizer = true optimizer_runs = 100000000 +[fmt] +ignore = ["src/vendor/**/*"] + bytecode_hash = "none" cbor_metadata = false diff --git a/lib/swap-router-contracts b/lib/swap-router-contracts new file mode 160000 index 00000000..c696aada --- /dev/null +++ b/lib/swap-router-contracts @@ -0,0 +1 @@ +Subproject commit c696aada49b33c8e764e6f0bd0a0a56bd8aa455f diff --git a/remappings.txt b/remappings.txt index 72006390..72f616b2 100644 --- a/remappings.txt +++ b/remappings.txt @@ -7,6 +7,7 @@ openzeppelin-contracts/=lib/openzeppelin-contracts/ openzeppelin/=lib/openzeppelin-contracts/contracts/ v3-core/=lib/v3-core/ v3-periphery/=lib/v3-periphery/contracts/ +swap-router-contracts/=lib/swap-router-contracts/contracts/ codejar/=lib/quark/src/codejar/ quark-core/=lib/quark/src/quark-core/ quark-factory/=lib/quark/src/quark-factory/ diff --git a/src/DeFiScripts.sol b/src/DeFiScripts.sol index 020177ca..35ade4ab 100644 --- a/src/DeFiScripts.sol +++ b/src/DeFiScripts.sol @@ -3,7 +3,7 @@ pragma solidity 0.8.23; import {IERC20} from "openzeppelin/token/ERC20/IERC20.sol"; import {SafeERC20} from "openzeppelin/token/ERC20/utils/SafeERC20.sol"; -import {ISwapRouter} from "v3-periphery/interfaces/ISwapRouter.sol"; +import {ISwapRouter02, IV3SwapRouter} from "src/vendor/uniswap-swap-router-contracts/ISwapRouter02.sol"; import {QuarkScript} from "quark-core/src/QuarkScript.sol"; @@ -136,7 +136,6 @@ contract UniswapSwapActions { uint256 amount; // Minimum amount of target token to receive (revert if return amount is less than this) uint256 amountOutMinimum; - uint256 deadline; // Path of the swap bytes path; } @@ -148,7 +147,6 @@ contract UniswapSwapActions { uint256 amount; // Maximum amount of input token to spend (revert if input amount is greater than this) uint256 amountInMaximum; - uint256 deadline; // Path of the swap bytes path; } @@ -159,11 +157,10 @@ contract UniswapSwapActions { */ function swapAssetExactIn(SwapParamsExactIn calldata params) external { IERC20(params.tokenFrom).forceApprove(params.uniswapRouter, params.amount); - ISwapRouter(params.uniswapRouter).exactInput( - ISwapRouter.ExactInputParams({ + ISwapRouter02(params.uniswapRouter).exactInput( + IV3SwapRouter.ExactInputParams({ path: params.path, recipient: params.recipient, - deadline: params.deadline, amountIn: params.amount, amountOutMinimum: params.amountOutMinimum }) @@ -176,11 +173,10 @@ contract UniswapSwapActions { */ function swapAssetExactOut(SwapParamsExactOut calldata params) external { IERC20(params.tokenFrom).forceApprove(params.uniswapRouter, params.amountInMaximum); - uint256 amountIn = ISwapRouter(params.uniswapRouter).exactOutput( - ISwapRouter.ExactOutputParams({ + uint256 amountIn = ISwapRouter02(params.uniswapRouter).exactOutput( + IV3SwapRouter.ExactOutputParams({ path: params.path, recipient: params.recipient, - deadline: params.deadline, amountOut: params.amount, amountInMaximum: params.amountInMaximum }) diff --git a/src/RecurringSwap.sol b/src/RecurringSwap.sol index 81a93c83..f67a3e11 100644 --- a/src/RecurringSwap.sol +++ b/src/RecurringSwap.sol @@ -6,7 +6,7 @@ import {SafeERC20} from "openzeppelin/token/ERC20/utils/SafeERC20.sol"; import {IERC20Metadata} from "openzeppelin/token/ERC20/extensions/IERC20Metadata.sol"; import {AggregatorV3Interface} from "src/vendor/chainlink/AggregatorV3Interface.sol"; -import {ISwapRouter} from "v3-periphery/interfaces/ISwapRouter.sol"; +import {ISwapRouter02, IV3SwapRouter} from "src/vendor/uniswap-swap-router-contracts/ISwapRouter02.sol"; import {QuarkWallet} from "quark-core/src/QuarkWallet.sol"; import {QuarkScript} from "quark-core/src/QuarkScript.sol"; @@ -65,7 +65,6 @@ contract RecurringSwap is QuarkScript { uint256 amount; /// @dev False for exact in; true for exact out bool isExactOut; - uint256 deadline; bytes path; } @@ -210,11 +209,10 @@ contract RecurringSwap is QuarkScript { if (swapParams.isExactOut) { // Exact out swap - actualAmountIn = ISwapRouter(swapParams.uniswapRouter).exactOutput( - ISwapRouter.ExactOutputParams({ + actualAmountIn = ISwapRouter02(swapParams.uniswapRouter).exactOutput( + IV3SwapRouter.ExactOutputParams({ path: swapParams.path, recipient: swapParams.recipient, - deadline: swapParams.deadline, amountOut: amountOut, amountInMaximum: amountIn }) @@ -222,11 +220,10 @@ contract RecurringSwap is QuarkScript { actualAmountOut = amountOut; } else { // Exact in swap - actualAmountOut = ISwapRouter(swapParams.uniswapRouter).exactInput( - ISwapRouter.ExactInputParams({ + actualAmountOut = ISwapRouter02(swapParams.uniswapRouter).exactInput( + IV3SwapRouter.ExactInputParams({ path: swapParams.path, recipient: swapParams.recipient, - deadline: swapParams.deadline, amountIn: amountIn, amountOutMinimum: amountOut }) @@ -268,7 +265,6 @@ contract RecurringSwap is QuarkScript { config.swapParams.tokenOut, config.swapParams.amount, config.swapParams.isExactOut, - config.swapParams.deadline, config.swapParams.path ), abi.encodePacked( diff --git a/src/UniswapFlashLoan.sol b/src/UniswapFlashLoan.sol index 3659333d..016652ae 100644 --- a/src/UniswapFlashLoan.sol +++ b/src/UniswapFlashLoan.sol @@ -7,7 +7,7 @@ import "v3-core/contracts/interfaces/callback/IUniswapV3FlashCallback.sol"; import "quark-core/src/QuarkScript.sol"; -import "./vendor/uniswap_v3_periphery/PoolAddress.sol"; +import "./vendor/uniswap-v3-periphery/PoolAddress.sol"; import "./lib/UniswapFactoryAddress.sol"; contract UniswapFlashLoan is IUniswapV3FlashCallback, QuarkScript { diff --git a/src/UniswapFlashSwapExactOut.sol b/src/UniswapFlashSwapExactOut.sol index 8109dbc6..266ac349 100644 --- a/src/UniswapFlashSwapExactOut.sol +++ b/src/UniswapFlashSwapExactOut.sol @@ -8,7 +8,7 @@ import "v3-core/contracts/libraries/SafeCast.sol"; import "quark-core/src/QuarkScript.sol"; -import "./vendor/uniswap_v3_periphery/PoolAddress.sol"; +import "./vendor/uniswap-v3-periphery/PoolAddress.sol"; import "./lib/UniswapFactoryAddress.sol"; contract UniswapFlashSwapExactOut is IUniswapV3SwapCallback, QuarkScript { diff --git a/src/builder/Actions.sol b/src/builder/Actions.sol index bee97425..4f5644b2 100644 --- a/src/builder/Actions.sol +++ b/src/builder/Actions.sol @@ -1008,8 +1008,6 @@ library Actions { tokenOut: swap.buyToken, amount: swap.isExactOut ? swap.buyAmount : swap.sellAmount, isExactOut: swap.isExactOut, - // The swap never expires and needs to be cancelled explicity - deadline: type(uint256).max, path: swap.path }); (address[] memory priceFeeds, bool[] memory shouldInvert) = PriceFeeds.findPriceFeedPath({ diff --git a/src/builder/UniswapRouter.sol b/src/builder/UniswapRouter.sol index 733d4c54..5ff189fa 100644 --- a/src/builder/UniswapRouter.sol +++ b/src/builder/UniswapRouter.sol @@ -10,18 +10,17 @@ library UniswapRouter { } /// @dev Addresses fetched from: https://docs.uniswap.org/contracts/v3/reference/deployments/ - /// Note: We use the addresses for SwapRouter, instead of SwapRouter02, which has a slightly different interface + /// Note: Make sure that these are the addresses for SwapRouter02, not SwapRouter. function knownChains() internal pure returns (RouterChain[] memory) { RouterChain[] memory chains = new RouterChain[](4); // Mainnet chains[0] = RouterChain({chainId: 1, router: 0xE592427A0AEce92De3Edee1F18E0157C05861564}); - // TODO: These chains don't have SwapRouter, so we will add them back once we move to SwapRouter02 // Base - // chains[1] = RouterChain({chainId: 8453, router: 0x2626664c2603336E57B271c5C0b26F421741e481}); + chains[1] = RouterChain({chainId: 8453, router: 0x2626664c2603336E57B271c5C0b26F421741e481}); // Sepolia - // chains[2] = RouterChain({chainId: 11155111, router: 0x3bFA4769FB09eefC5a80d6E87c3B9C650f7Ae48E}); + chains[2] = RouterChain({chainId: 11155111, router: 0x3bFA4769FB09eefC5a80d6E87c3B9C650f7Ae48E}); // Base Sepolia - // chains[3] = RouterChain({chainId: 84532, router: 0x94cC0AaC535CCDB3C01d6787D6413C739ae12bc4}); + chains[3] = RouterChain({chainId: 84532, router: 0x94cC0AaC535CCDB3C01d6787D6413C739ae12bc4}); return chains; } diff --git a/src/vendor/manifest.json b/src/vendor/manifest.json index f4aac3ca..8f59c732 100644 --- a/src/vendor/manifest.json +++ b/src/vendor/manifest.json @@ -1,6 +1,6 @@ { "files": { - "uniswap_v3_periphery/PoolAddress.sol": { + "uniswap-v3-periphery/PoolAddress.sol": { "source": { "git": { "repo": "git@github.com:Uniswap/v3-periphery.git", @@ -90,7 +90,7 @@ "commit": "dd2c5ef1a71d821d97f199573b04df71dcab6172", "path": "contracts/src/v0.8/shared/interfaces/AggregatorV3Interface.sol" } - }, + }, "patches": [ { "oldStart": 1, @@ -131,7 +131,73 @@ ] } ] - }, + }, + "uniswap-swap-router-contracts/IApproveAndCall.sol": { + "source": { + "git": { + "repo": "git@github.com:Uniswap/swap-router-contracts.git", + "commit": "c696aada49b33c8e764e6f0bd0a0a56bd8aa455f", + "path": "contracts/interfaces/IApproveAndCall.sol" + } + }, + "patches": [ + { + "oldStart": 1, + "oldLines": 6, + "newStart": 1, + "newLines": 6, + "lines": [ + " // SPDX-License-Identifier: GPL-2.0-or-later", + "-pragma solidity >=0.7.6;", + "+pragma solidity =0.7.6;", + " pragma abicoder v2;", + " ", + " interface IApproveAndCall {", + " enum ApprovalType {NOT_REQUIRED, MAX, MAX_MINUS_ONE, ZERO_THEN_MAX, ZERO_THEN_MAX_MINUS_ONE}" + ] + } + ] + }, + "uniswap-swap-router-contracts/ISwapRouter02.sol": { + "source": { + "git": { + "repo": "git@github.com:Uniswap/swap-router-contracts.git", + "commit": "c696aada49b33c8e764e6f0bd0a0a56bd8aa455f", + "path": "contracts/interfaces/ISwapRouter02.sol" + } + }, + "patches": [ + { + "oldStart": 1, + "oldLines": 16, + "newStart": 1, + "newLines": 15, + "lines": [ + " // SPDX-License-Identifier: GPL-2.0-or-later", + " pragma solidity >=0.7.5;", + " pragma abicoder v2;", + " ", + "-import \"@uniswap/v3-periphery/contracts/interfaces/ISelfPermit.sol\";", + "+import '@uniswap/v3-periphery/contracts/interfaces/ISelfPermit.sol';", + " ", + "-import \"@uniswap/swap-router-contracts/contracts/interfaces/IV2SwapRouter.sol\";", + "-import \"@uniswap/swap-router-contracts/contracts/interfaces/IV3SwapRouter.sol\";", + "-import \"@uniswap/swap-router-contracts/contracts/interfaces/IMulticallExtended.sol\";", + "+import './IV2SwapRouter.sol';", + "+import './IV3SwapRouter.sol';", + "+import './IApproveAndCall.sol';", + "+import './IMulticallExtended.sol';", + " ", + "-import \"./IApproveAndCall.sol\";", + "-", + " /// @title Router token swapping functionality", + " interface ISwapRouter02 is IV2SwapRouter, IV3SwapRouter, IApproveAndCall, IMulticallExtended, ISelfPermit {", + " ", + " }" + ] + } + ] + }, "morpho_blue_periphery/MathLib.sol":{ "source": { "git": { @@ -139,9 +205,9 @@ "commit": "0448402af51b8293ed36653de43cbee8d4d2bfda", "path": "src/libraries/MathLib.sol" } - }, + }, "patches": [] - }, + }, "morpho_blue_periphery/SharesMathLib.sol":{ "source": { "git": { @@ -149,7 +215,7 @@ "commit": "0448402af51b8293ed36653de43cbee8d4d2bfda", "path": "src/libraries/SharesMathLib.sol" } - }, + }, "patches": [] } } diff --git a/src/vendor/uniswap-swap-router-contracts/IApproveAndCall.sol b/src/vendor/uniswap-swap-router-contracts/IApproveAndCall.sol new file mode 100644 index 00000000..fedf2dde --- /dev/null +++ b/src/vendor/uniswap-swap-router-contracts/IApproveAndCall.sol @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.7.6; +pragma abicoder v2; + +interface IApproveAndCall { + enum ApprovalType {NOT_REQUIRED, MAX, MAX_MINUS_ONE, ZERO_THEN_MAX, ZERO_THEN_MAX_MINUS_ONE} + + /// @dev Lens to be called off-chain to determine which (if any) of the relevant approval functions should be called + /// @param token The token to approve + /// @param amount The amount to approve + /// @return The required approval type + function getApprovalType(address token, uint256 amount) external returns (ApprovalType); + + /// @notice Approves a token for the maximum possible amount + /// @param token The token to approve + function approveMax(address token) external payable; + + /// @notice Approves a token for the maximum possible amount minus one + /// @param token The token to approve + function approveMaxMinusOne(address token) external payable; + + /// @notice Approves a token for zero, then the maximum possible amount + /// @param token The token to approve + function approveZeroThenMax(address token) external payable; + + /// @notice Approves a token for zero, then the maximum possible amount minus one + /// @param token The token to approve + function approveZeroThenMaxMinusOne(address token) external payable; + + /// @notice Calls the position manager with arbitrary calldata + /// @param data Calldata to pass along to the position manager + /// @return result The result from the call + function callPositionManager(bytes memory data) external payable returns (bytes memory result); + + struct MintParams { + address token0; + address token1; + uint24 fee; + int24 tickLower; + int24 tickUpper; + uint256 amount0Min; + uint256 amount1Min; + address recipient; + } + + /// @notice Calls the position manager's mint function + /// @param params Calldata to pass along to the position manager + /// @return result The result from the call + function mint(MintParams calldata params) external payable returns (bytes memory result); + + struct IncreaseLiquidityParams { + address token0; + address token1; + uint256 tokenId; + uint256 amount0Min; + uint256 amount1Min; + } + + /// @notice Calls the position manager's increaseLiquidity function + /// @param params Calldata to pass along to the position manager + /// @return result The result from the call + function increaseLiquidity(IncreaseLiquidityParams calldata params) external payable returns (bytes memory result); +} diff --git a/src/vendor/uniswap-swap-router-contracts/ISwapRouter02.sol b/src/vendor/uniswap-swap-router-contracts/ISwapRouter02.sol new file mode 100644 index 00000000..fb0efe08 --- /dev/null +++ b/src/vendor/uniswap-swap-router-contracts/ISwapRouter02.sol @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.7.5; +pragma abicoder v2; + +import "@uniswap/v3-periphery/contracts/interfaces/ISelfPermit.sol"; + +import "@uniswap/swap-router-contracts/contracts/interfaces/IV2SwapRouter.sol"; +import "@uniswap/swap-router-contracts/contracts/interfaces/IV3SwapRouter.sol"; +import "@uniswap/swap-router-contracts/contracts/interfaces/IMulticallExtended.sol"; + +import "./IApproveAndCall.sol"; + +/// @title Router token swapping functionality +interface ISwapRouter02 is IV2SwapRouter, IV3SwapRouter, IApproveAndCall, IMulticallExtended, ISelfPermit { + +} diff --git a/src/vendor/uniswap_v3_periphery/PoolAddress.sol b/src/vendor/uniswap-v3-periphery/PoolAddress.sol similarity index 100% rename from src/vendor/uniswap_v3_periphery/PoolAddress.sol rename to src/vendor/uniswap-v3-periphery/PoolAddress.sol diff --git a/test/MorphoActions.t.sol b/test/MorphoActions.t.sol index 6600a436..e73198ca 100644 --- a/test/MorphoActions.t.sol +++ b/test/MorphoActions.t.sol @@ -41,9 +41,7 @@ contract MorphoActionsTest is Test { function setUp() public { // Fork setup vm.createSelectFork( - string.concat( - "https://node-provider.compound.finance/ethereum-mainnet/", vm.envString("NODE_PROVIDER_BYPASS_KEY") - ), + vm.envString("MAINNET_RPC_URL"), 20564787 // 2024-08-19 12:34:00 PST ); factory = new QuarkWalletProxyFactory(address(new QuarkWallet(new CodeJar(), new QuarkStateManager()))); diff --git a/test/MorphoRewardsActions.t.sol b/test/MorphoRewardsActions.t.sol index c4c76ec2..69d00a2e 100644 --- a/test/MorphoRewardsActions.t.sol +++ b/test/MorphoRewardsActions.t.sol @@ -80,9 +80,7 @@ contract MorphoRewardsActionsTest is Test { function setUp() public { // Fork setup vm.createSelectFork( - string.concat( - "https://node-provider.compound.finance/ethereum-mainnet/", vm.envString("NODE_PROVIDER_BYPASS_KEY") - ), + vm.envString("MAINNET_RPC_URL"), 20568177 // 2024-08-19 23:54:00 PST ); factory = new QuarkWalletProxyFactory(address(new QuarkWallet(new CodeJar(), new QuarkStateManager()))); diff --git a/test/MorphoVaultActions.t.sol b/test/MorphoVaultActions.t.sol index 6d001ff4..d2093e06 100644 --- a/test/MorphoVaultActions.t.sol +++ b/test/MorphoVaultActions.t.sol @@ -38,9 +38,7 @@ contract MorphoVaultActionsTest is Test { function setUp() public { // Fork setup vm.createSelectFork( - string.concat( - "https://node-provider.compound.finance/ethereum-mainnet/", vm.envString("NODE_PROVIDER_BYPASS_KEY") - ), + vm.envString("MAINNET_RPC_URL"), 20564787 // 2024-08-19 12:34:00 PST ); factory = new QuarkWalletProxyFactory(address(new QuarkWallet(new CodeJar(), new QuarkStateManager()))); diff --git a/test/Multicall.t.sol b/test/Multicall.t.sol index 7bb53245..692e4bf9 100644 --- a/test/Multicall.t.sol +++ b/test/Multicall.t.sol @@ -34,8 +34,8 @@ contract MulticallTest is Test { address constant cWETHv3 = 0xA17581A9E3356d9A858b789D68B4d866e593aE94; address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; address constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; - // Uniswap router info on mainnet - address constant uniswapRouter = 0xE592427A0AEce92De3Edee1F18E0157C05861564; + // Uniswap SwapRouter02 info on mainnet + address constant uniswapRouter = 0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45; bytes ethcall = new YulHelper().getCode("Ethcall.sol/Ethcall.json"); bytes multicall; @@ -528,7 +528,6 @@ contract MulticallTest is Test { tokenFrom: USDC, amount: 5000e6, amountOutMinimum: 2 ether, - deadline: block.timestamp + 1000, path: abi.encodePacked(USDC, uint24(500), WETH) // Path: USDC - 0.05% -> WETH }) ) diff --git a/test/RecurringSwap.t.sol b/test/RecurringSwap.t.sol index 87805fa1..418ebf39 100644 --- a/test/RecurringSwap.t.sol +++ b/test/RecurringSwap.t.sol @@ -44,8 +44,8 @@ contract RecurringSwapTest is Test { address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; address constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; address constant COMP = 0xc00e94Cb662C3520282E6f5717214004A7f26888; - // Uniswap router info on mainnet - address constant UNISWAP_ROUTER = 0xE592427A0AEce92De3Edee1F18E0157C05861564; + // Uniswap SwapRouter02 info on mainnet + address constant UNISWAP_ROUTER = 0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45; // Price feeds address constant ETH_USD_PRICE_FEED = 0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419; // Price is $1790.45 address constant USDC_USD_PRICE_FEED = 0x8fFfFfd4AfB6115b954Bd326cbe7B4BA576818f6; @@ -97,7 +97,6 @@ contract RecurringSwapTest is Test { abi.encodeWithSelector(RecurringSwap.swap.selector, swapConfig), ScriptType.ScriptAddress ); - op.expiry = swapConfig.swapParams.deadline; (uint8 v1, bytes32 r1, bytes32 s1) = new SignatureHelper().signOp(alicePrivateKey, aliceWallet, op); assertEq(IERC20(WETH).balanceOf(address(aliceWallet)), 0 ether); @@ -144,7 +143,6 @@ contract RecurringSwapTest is Test { abi.encodeWithSelector(RecurringSwap.swap.selector, swapConfig), ScriptType.ScriptAddress ); - op.expiry = swapConfig.swapParams.deadline; (uint8 v1, bytes32 r1, bytes32 s1) = new SignatureHelper().signOp(alicePrivateKey, aliceWallet, op); assertEq(IERC20(WETH).balanceOf(address(aliceWallet)), 0 ether); @@ -187,7 +185,6 @@ contract RecurringSwapTest is Test { tokenOut: USDC, amount: amountToSell, isExactOut: false, - deadline: type(uint256).max, path: abi.encodePacked(WETH, uint24(500), USDC) }); RecurringSwap.SlippageParams memory slippageParams = RecurringSwap.SlippageParams({ @@ -208,7 +205,6 @@ contract RecurringSwapTest is Test { abi.encodeWithSelector(RecurringSwap.swap.selector, swapConfig), ScriptType.ScriptAddress ); - op.expiry = swapConfig.swapParams.deadline; (uint8 v1, bytes32 r1, bytes32 s1) = new SignatureHelper().signOp(alicePrivateKey, aliceWallet, op); assertEq(IERC20(WETH).balanceOf(address(aliceWallet)), startingWETH); @@ -250,7 +246,6 @@ contract RecurringSwapTest is Test { tokenOut: USDC, amount: amountToSwap, isExactOut: true, - deadline: type(uint256).max, path: abi.encodePacked(USDC, uint24(500), WETH) }); RecurringSwap.SlippageParams memory slippageParams = RecurringSwap.SlippageParams({ @@ -271,7 +266,6 @@ contract RecurringSwapTest is Test { abi.encodeWithSelector(RecurringSwap.swap.selector, swapConfig), ScriptType.ScriptAddress ); - op.expiry = swapConfig.swapParams.deadline; (uint8 v1, bytes32 r1, bytes32 s1) = new SignatureHelper().signOp(alicePrivateKey, aliceWallet, op); assertEq(IERC20(WETH).balanceOf(address(aliceWallet)), startingWETH); @@ -315,7 +309,7 @@ contract RecurringSwapTest is Test { abi.encodeWithSelector(RecurringSwap.swap.selector, swapConfig), ScriptType.ScriptAddress ); - op.expiry = swapConfig.swapParams.deadline; + op.expiry = type(uint256).max; (uint8 v1, bytes32 r1, bytes32 s1) = new SignatureHelper().signOp(alicePrivateKey, aliceWallet, op); assertEq(IERC20(WETH).balanceOf(address(aliceWallet)), 0 ether); @@ -361,7 +355,7 @@ contract RecurringSwapTest is Test { abi.encodeWithSelector(RecurringSwap.swap.selector, swapConfig), ScriptType.ScriptAddress ); - op.expiry = swapConfig.swapParams.deadline; + op.expiry = type(uint256).max; (uint8 v1, bytes32 r1, bytes32 s1) = new SignatureHelper().signOp(alicePrivateKey, aliceWallet, op); QuarkWallet.QuarkOperation memory cancelOp = new QuarkOperationHelper().newBasicOpWithCalldata( @@ -418,7 +412,6 @@ contract RecurringSwapTest is Test { abi.encodeWithSelector(RecurringSwap.swap.selector, swapConfig), ScriptType.ScriptAddress ); - op.expiry = swapConfig.swapParams.deadline; (uint8 v1, bytes32 r1, bytes32 s1) = new SignatureHelper().signOp(alicePrivateKey, aliceWallet, op); assertEq(IERC20(WETH).balanceOf(address(aliceWallet)), 0 ether); @@ -458,7 +451,7 @@ contract RecurringSwapTest is Test { abi.encodeWithSelector(RecurringSwap.swap.selector, swapConfig1), ScriptType.ScriptAddress ); - op1.expiry = swapConfig1.swapParams.deadline; + op1.expiry = type(uint256).max; RecurringSwap.SwapConfig memory swapConfig2 = _createSwapConfig({ startTime: block.timestamp, swapInterval: swapInterval, @@ -471,14 +464,14 @@ contract RecurringSwapTest is Test { abi.encodeWithSelector(RecurringSwap.swap.selector, swapConfig2), ScriptType.ScriptAddress ); - op2.expiry = swapConfig2.swapParams.deadline; + op2.expiry = type(uint256).max; cancelOp = new QuarkOperationHelper().newBasicOpWithCalldata( aliceWallet, recurringSwap, abi.encodeWithSelector(RecurringSwap.cancel.selector), ScriptType.ScriptAddress ); - cancelOp.expiry = op2.expiry; + cancelOp.expiry = type(uint256).max; } (uint8 v1, bytes32 r1, bytes32 s1) = new SignatureHelper().signOp(alicePrivateKey, aliceWallet, op1); (uint8 v2, bytes32 r2, bytes32 s2) = new SignatureHelper().signOp(alicePrivateKey, aliceWallet, op2); @@ -568,8 +561,6 @@ contract RecurringSwapTest is Test { abi.encodeWithSelector(RecurringSwap.swap.selector, invalidSwapConfig2), ScriptType.ScriptAddress ); - op1.expiry = invalidSwapConfig1.swapParams.deadline; - op2.expiry = invalidSwapConfig2.swapParams.deadline; (uint8 v1, bytes32 r1, bytes32 s1) = new SignatureHelper().signOp(alicePrivateKey, aliceWallet, op1); (uint8 v2, bytes32 r2, bytes32 s2) = new SignatureHelper().signOp(alicePrivateKey, aliceWallet, op2); @@ -601,7 +592,6 @@ contract RecurringSwapTest is Test { abi.encodeWithSelector(RecurringSwap.swap.selector, swapConfig), ScriptType.ScriptAddress ); - op.expiry = swapConfig.swapParams.deadline; (uint8 v1, bytes32 r1, bytes32 s1) = new SignatureHelper().signOp(alicePrivateKey, aliceWallet, op); assertEq(IERC20(WETH).balanceOf(address(aliceWallet)), 0 ether); @@ -643,7 +633,6 @@ contract RecurringSwapTest is Test { abi.encodeWithSelector(RecurringSwap.swap.selector, swapConfig), ScriptType.ScriptAddress ); - op.expiry = swapConfig.swapParams.deadline; (uint8 v1, bytes32 r1, bytes32 s1) = new SignatureHelper().signOp(alicePrivateKey, aliceWallet, op); assertEq(IERC20(WETH).balanceOf(address(aliceWallet)), 0 ether); @@ -683,34 +672,6 @@ contract RecurringSwapTest is Test { aliceWallet.executeQuarkOperation(op, v1, r1, s1); } - function testRevertsForExpiredUniswapParams() public { - // gas: disable gas metering except while executing operations - vm.pauseGasMetering(); - - deal(USDC, address(aliceWallet), 100_000e6); - uint40 swapInterval = 86_400; // 1 day interval - uint256 amountToSwap = 10 ether; - RecurringSwap.SwapConfig memory swapConfig = _createSwapConfig({ - startTime: block.timestamp, - swapInterval: swapInterval, - amount: amountToSwap, - isExactOut: true - }); - swapConfig.swapParams.deadline = block.timestamp - 1; // Set Uniswap deadline to always expire - QuarkWallet.QuarkOperation memory op = new QuarkOperationHelper().newBasicOpWithCalldata( - aliceWallet, - recurringSwap, - abi.encodeWithSelector(RecurringSwap.swap.selector, swapConfig), - ScriptType.ScriptAddress - ); - (uint8 v1, bytes32 r1, bytes32 s1) = new SignatureHelper().signOp(alicePrivateKey, aliceWallet, op); - - // gas: meter execute - vm.resumeGasMetering(); - vm.expectRevert(bytes("Transaction too old")); - aliceWallet.executeQuarkOperation(op, v1, r1, s1); - } - function testRevertsWhenSlippageTooHigh() public { // gas: disable gas metering except while executing operations vm.pauseGasMetering(); @@ -736,7 +697,6 @@ contract RecurringSwapTest is Test { abi.encodeWithSelector(RecurringSwap.swap.selector, swapConfig), ScriptType.ScriptAddress ); - op.expiry = swapConfig.swapParams.deadline; (uint8 v1, bytes32 r1, bytes32 s1) = new SignatureHelper().signOp(alicePrivateKey, aliceWallet, op); assertEq(IERC20(WETH).balanceOf(address(aliceWallet)), 0 ether); @@ -772,7 +732,6 @@ contract RecurringSwapTest is Test { abi.encodeWithSelector(RecurringSwap.swap.selector, swapConfig), ScriptType.ScriptAddress ); - op.expiry = swapConfig.swapParams.deadline; (uint8 v1, bytes32 r1, bytes32 s1) = new SignatureHelper().signOp(alicePrivateKey, aliceWallet, op); assertEq(IERC20(WETH).balanceOf(address(aliceWallet)), 0 ether); @@ -852,7 +811,6 @@ contract RecurringSwapTest is Test { tokenOut: WETH, amount: amount, isExactOut: isExactOut, - deadline: type(uint256).max, path: swapPath }); RecurringSwap.SwapConfig memory swapConfig = RecurringSwap.SwapConfig({ diff --git a/test/UniswapFlashLoan.t.sol b/test/UniswapFlashLoan.t.sol index 5fce2c62..8745b49d 100644 --- a/test/UniswapFlashLoan.t.sol +++ b/test/UniswapFlashLoan.t.sol @@ -16,7 +16,7 @@ import {QuarkWalletProxyFactory} from "quark-proxy/src/QuarkWalletProxyFactory.s import {Ethcall} from "src/Ethcall.sol"; import {Multicall} from "src/Multicall.sol"; -import {PoolAddress} from "src/vendor/uniswap_v3_periphery/PoolAddress.sol"; +import {PoolAddress} from "src/vendor/uniswap-v3-periphery/PoolAddress.sol"; import {UniswapFlashLoan} from "src/UniswapFlashLoan.sol"; import {Counter} from "./lib/Counter.sol"; diff --git a/test/UniswapFlashSwapExactOut.t.sol b/test/UniswapFlashSwapExactOut.t.sol index 5511c794..3639c1f7 100644 --- a/test/UniswapFlashSwapExactOut.t.sol +++ b/test/UniswapFlashSwapExactOut.t.sol @@ -16,7 +16,7 @@ import {QuarkWalletProxyFactory} from "quark-proxy/src/QuarkWalletProxyFactory.s import {Ethcall} from "src/Ethcall.sol"; import {Multicall} from "src/Multicall.sol"; -import {PoolAddress} from "src/vendor/uniswap_v3_periphery/PoolAddress.sol"; +import {PoolAddress} from "src/vendor/uniswap-v3-periphery/PoolAddress.sol"; import {UniswapFlashSwapExactOut} from "src/UniswapFlashSwapExactOut.sol"; import {Counter} from "./lib/Counter.sol"; diff --git a/test/UniswapSwapActions.t.sol b/test/UniswapSwapActions.t.sol index 38a1f0b7..bd70b2ac 100644 --- a/test/UniswapSwapActions.t.sol +++ b/test/UniswapSwapActions.t.sol @@ -35,8 +35,8 @@ contract UniswapSwapActionsTest is Test { address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; address constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; address constant COMP = 0xc00e94Cb662C3520282E6f5717214004A7f26888; - // Uniswap router info on mainnet - address constant uniswapRouter = 0xE592427A0AEce92De3Edee1F18E0157C05861564; + // Uniswap SwapRouter02 info on mainnet + address constant uniswapRouter = 0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45; bytes swapScript = new YulHelper().getCode("DeFiScripts.sol/UniswapSwapActions.json"); function setUp() public { @@ -68,7 +68,6 @@ contract UniswapSwapActionsTest is Test { tokenFrom: USDC, amount: 2000e6, amountOutMinimum: 1 ether, - deadline: block.timestamp + 1000, path: abi.encodePacked(USDC, uint24(500), WETH) // Path: USDC - 0.05% -> WETH }) ), @@ -97,7 +96,6 @@ contract UniswapSwapActionsTest is Test { tokenFrom: USDC, amount: 1 ether, amountInMaximum: 2000e6, - deadline: block.timestamp + 1000, path: abi.encodePacked(WETH, uint24(500), USDC) // Path: WETH - 0.05% -> USDC }) ), @@ -130,7 +128,6 @@ contract UniswapSwapActionsTest is Test { tokenFrom: USDC, amount: 2000e6, amountOutMinimum: 40e18, - deadline: block.timestamp + 1000, path: abi.encodePacked(USDC, uint24(500), WETH, uint24(3000), COMP) // Path: USDC - 0.05% -> WETH - 0.3% -> COMP }) ), @@ -157,7 +154,6 @@ contract UniswapSwapActionsTest is Test { tokenFrom: USDC, amount: 40e18, amountInMaximum: 2000e6, - deadline: block.timestamp + 1000, path: abi.encodePacked(COMP, uint24(3000), WETH, uint24(500), USDC) // Path: COMP - 0.05% -> WETH - 0.3% -> USDC }) ), @@ -189,7 +185,6 @@ contract UniswapSwapActionsTest is Test { tokenFrom: WETH, amount: 1 ether, amountOutMinimum: 1000e6, - deadline: block.timestamp + 1000, path: abi.encodePacked(WETH, uint24(500), USDC) // Path: WETH - 0.05% -> USDC }) ), @@ -215,7 +210,6 @@ contract UniswapSwapActionsTest is Test { tokenFrom: WETH, amount: 1600e6, amountInMaximum: 1 ether, - deadline: block.timestamp + 1000, path: abi.encodePacked(USDC, uint24(500), WETH) // Path: USDC - 0.05% -> WETH }) ), @@ -247,7 +241,6 @@ contract UniswapSwapActionsTest is Test { tokenFrom: COMP, amount: 50e18, amountOutMinimum: 1800e6, - deadline: block.timestamp + 1000, path: abi.encodePacked(COMP, uint24(3000), WETH, uint24(500), USDC) // Path: COMP - 0.05% -> WETH - 0.3% -> USDC }) ), @@ -273,7 +266,6 @@ contract UniswapSwapActionsTest is Test { tokenFrom: COMP, amount: 1500e6, amountInMaximum: 50e18, - deadline: block.timestamp + 1000, path: abi.encodePacked(USDC, uint24(500), WETH, uint24(3000), COMP) // Path: USDC - 0.05% -> WETH - 0.3% -> COMP }) ), @@ -307,7 +299,6 @@ contract UniswapSwapActionsTest is Test { tokenFrom: USDC, amount: 1 ether, amountInMaximum: 10000e6, // Give it a high amount to trigger approval refund - deadline: block.timestamp + 1000, path: abi.encodePacked(WETH, uint24(500), USDC) // Path: WETH - 0.05% -> USDC }) ), diff --git a/test/builder/QuarkBuilderRecurringSwap.t.sol b/test/builder/QuarkBuilderRecurringSwap.t.sol index 7e4a7ea4..b918efd1 100644 --- a/test/builder/QuarkBuilderRecurringSwap.t.sol +++ b/test/builder/QuarkBuilderRecurringSwap.t.sol @@ -86,7 +86,6 @@ contract QuarkBuilderRecurringSwapTest is Test, QuarkBuilderTest { tokenOut: swap.buyToken, amount: swap.isExactOut ? swap.buyAmount : swap.sellAmount, isExactOut: swap.isExactOut, - deadline: type(uint256).max, path: swap.path }); (address[] memory priceFeeds, bool[] memory shouldInvert) = From 0e734df8ebc77be488089aa70164023de6366cc8 Mon Sep 17 00:00:00 2001 From: Banky Adebajo Date: Tue, 10 Sep 2024 17:48:54 -0400 Subject: [PATCH 08/13] Add isExactOut on one-time swaps (#75) The one-time swap intent is used to generate the recurring intent, so we need to add `isExactOut` to the one-time swap so it can be propagated over --- src/builder/Actions.sol | 5 ++++- src/builder/QuarkBuilder.sol | 2 ++ test/builder/QuarkBuilderSwap.t.sol | 25 +++++++++++++++++-------- 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/src/builder/Actions.sol b/src/builder/Actions.sol index 4f5644b2..251b539b 100644 --- a/src/builder/Actions.sol +++ b/src/builder/Actions.sol @@ -116,6 +116,7 @@ library Actions { uint256 feeAmount; uint256 chainId; address sender; + bool isExactOut; uint256 blockTimestamp; } @@ -246,6 +247,7 @@ library Actions { string outputAssetSymbol; address outputToken; uint256 outputTokenPrice; + bool isExactOut; } struct RecurringSwapActionContext { @@ -965,7 +967,8 @@ library Actions { outputAmount: swap.buyAmount, outputAssetSymbol: swap.buyAssetSymbol, outputToken: swap.buyToken, - outputTokenPrice: buyTokenAssetPositions.usdPrice + outputTokenPrice: buyTokenAssetPositions.usdPrice, + isExactOut: swap.isExactOut }); Action memory action = Actions.Action({ diff --git a/src/builder/QuarkBuilder.sol b/src/builder/QuarkBuilder.sol index c26fb582..4a0d8e6d 100644 --- a/src/builder/QuarkBuilder.sol +++ b/src/builder/QuarkBuilder.sol @@ -826,6 +826,7 @@ contract QuarkBuilder { address feeToken; uint256 feeAmount; address sender; + bool isExactOut; uint256 blockTimestamp; } @@ -947,6 +948,7 @@ contract QuarkBuilder { feeAmount: swapIntent.feeAmount, chainId: swapIntent.chainId, sender: swapIntent.sender, + isExactOut: swapIntent.isExactOut, blockTimestamp: swapIntent.blockTimestamp }), payment, diff --git a/test/builder/QuarkBuilderSwap.t.sol b/test/builder/QuarkBuilderSwap.t.sol index b6d198ca..2685f14f 100644 --- a/test/builder/QuarkBuilderSwap.t.sol +++ b/test/builder/QuarkBuilderSwap.t.sol @@ -90,6 +90,7 @@ contract QuarkBuilderSwapTest is Test, QuarkBuilderTest { feeToken: buyToken, feeAmount: 10, sender: sender, + isExactOut: false, blockTimestamp: blockTimestamp }); } @@ -205,7 +206,8 @@ contract QuarkBuilderSwapTest is Test, QuarkBuilderTest { outputToken: WETH_1, outputTokenPrice: WETH_PRICE, outputAssetSymbol: "WETH", - outputAmount: 1e18 + outputAmount: 1e18, + isExactOut: false }) ), "action context encoded from SwapActionContext" @@ -309,7 +311,8 @@ contract QuarkBuilderSwapTest is Test, QuarkBuilderTest { outputToken: USDC_1, outputTokenPrice: USDC_PRICE, outputAssetSymbol: "USDC", - outputAmount: 3000e6 + outputAmount: 3000e6, + isExactOut: false }) ), "action context encoded from SwapActionContext" @@ -384,7 +387,8 @@ contract QuarkBuilderSwapTest is Test, QuarkBuilderTest { outputToken: WETH_1, outputTokenPrice: WETH_PRICE, outputAssetSymbol: "WETH", - outputAmount: 1e18 + outputAmount: 1e18, + isExactOut: false }) ), "action context encoded from SwapActionContext" @@ -479,7 +483,8 @@ contract QuarkBuilderSwapTest is Test, QuarkBuilderTest { outputToken: WETH_1, outputTokenPrice: WETH_PRICE, outputAssetSymbol: "WETH", - outputAmount: 3e18 + outputAmount: 3e18, + isExactOut: false }) ), "action context encoded from SwapActionContext" @@ -617,7 +622,8 @@ contract QuarkBuilderSwapTest is Test, QuarkBuilderTest { outputToken: WETH_8453, outputTokenPrice: WETH_PRICE, outputAssetSymbol: "WETH", - outputAmount: 1e18 + outputAmount: 1e18, + isExactOut: false }) ), "action context encoded from SwapActionContext" @@ -752,7 +758,8 @@ contract QuarkBuilderSwapTest is Test, QuarkBuilderTest { outputToken: WETH_8453, outputTokenPrice: WETH_PRICE, outputAssetSymbol: "WETH", - outputAmount: 1e18 + outputAmount: 1e18, + isExactOut: false }) ), "action context encoded from SwapActionContext" @@ -886,7 +893,8 @@ contract QuarkBuilderSwapTest is Test, QuarkBuilderTest { outputToken: WETH_8453, outputTokenPrice: WETH_PRICE, outputAssetSymbol: "WETH", - outputAmount: 2e18 + outputAmount: 2e18, + isExactOut: false }) ), "action context encoded from SwapActionContext" @@ -1021,7 +1029,8 @@ contract QuarkBuilderSwapTest is Test, QuarkBuilderTest { outputToken: WETH_8453, outputTokenPrice: WETH_PRICE, outputAssetSymbol: "WETH", - outputAmount: 1e18 + outputAmount: 1e18, + isExactOut: false }) ), "action context encoded from SwapActionContext" From 08c9afd73d63a31788384952b7acdec5b5eb331f Mon Sep 17 00:00:00 2001 From: Hans Wang <2709448+cwang25@users.noreply.github.com> Date: Tue, 10 Sep 2024 16:25:18 -0700 Subject: [PATCH 09/13] Small bug fix on potential underflow error in constructBridgeOperations (#74) - Noticed in one clause it does direct subtraction, which will cause underflow error if the chain has less than intended amount to bridge. --- .gas-snapshot | 151 ++++++++++--------- src/builder/Actions.sol | 6 +- test/builder/QuarkBuilderCometWithdraw.t.sol | 15 ++ 3 files changed, 95 insertions(+), 77 deletions(-) diff --git a/.gas-snapshot b/.gas-snapshot index d728a0f3..b66293b4 100644 --- a/.gas-snapshot +++ b/.gas-snapshot @@ -52,85 +52,86 @@ PriceFeedsTest:testFindPriceFeedPathDirectMatchWithReverse() (gas: 215730) PriceFeedsTest:testFindPriceFeedPathNoMatch() (gas: 221293) PriceFeedsTest:testFindPriceFeedPathOneHopMatch() (gas: 271926) PriceFeedsTest:testFindPriceFeeds() (gas: 100690) -QuarkBuilderCometBorrowTest:testBorrow() (gas: 19750324) -QuarkBuilderCometBorrowTest:testBorrowFundsUnavailable() (gas: 18786452) -QuarkBuilderCometBorrowTest:testBorrowInvalidInput() (gas: 18586022) -QuarkBuilderCometBorrowTest:testBorrowPayFromBorrow() (gas: 20781060) -QuarkBuilderCometBorrowTest:testBorrowWithAutoWrapper() (gas: 21124862) -QuarkBuilderCometBorrowTest:testBorrowWithBridgedPaymentToken() (gas: 22260790) -QuarkBuilderCometBorrowTest:testBorrowWithBridgedcollateralAsset() (gas: 22323602) -QuarkBuilderCometBorrowTest:testBorrowWithPaycall() (gas: 20721101) -QuarkBuilderCometRepayTest:testCometRepay() (gas: 19692940) -QuarkBuilderCometRepayTest:testCometRepayFundsUnavailable() (gas: 18723824) -QuarkBuilderCometRepayTest:testCometRepayInvalidInput() (gas: 18586338) -QuarkBuilderCometRepayTest:testCometRepayMax() (gas: 20498265) -QuarkBuilderCometRepayTest:testCometRepayMaxCostTooHigh() (gas: 19142820) -QuarkBuilderCometRepayTest:testCometRepayMaxWithBridge() (gas: 22229297) -QuarkBuilderCometRepayTest:testCometRepayPayFromWithdraw() (gas: 20970376) -QuarkBuilderCometRepayTest:testCometRepayWithAutoWrapper() (gas: 21113470) -QuarkBuilderCometRepayTest:testCometRepayWithBridge() (gas: 22309913) -QuarkBuilderCometRepayTest:testCometRepayWithPaycall() (gas: 20612154) -QuarkBuilderCometSupplyTest:testCometSupplyMaxWithBridge() (gas: 19806359) -QuarkBuilderCometSupplyTest:testCometSupplyMaxWithBridgeAndQuotecall() (gas: 20092356) -QuarkBuilderCometSupplyTest:testCometSupplyWithBridge() (gas: 19609730) -QuarkBuilderCometSupplyTest:testCometSupplyWithBridgeAndPaycall() (gas: 19933817) -QuarkBuilderCometSupplyTest:testCometSupplyWithPaycall() (gas: 19156407) -QuarkBuilderCometSupplyTest:testFundsUnavailable() (gas: 18703862) -QuarkBuilderCometSupplyTest:testInsufficientFunds() (gas: 18722355) -QuarkBuilderCometSupplyTest:testMaxCostTooHigh() (gas: 18702511) -QuarkBuilderCometSupplyTest:testSimpleCometSupply() (gas: 18985338) -QuarkBuilderCometSupplyTest:testSimpleCometSupplyMax() (gas: 19006508) -QuarkBuilderCometSupplyTest:testSimpleCometSupplyWithAutoWrapper() (gas: 19613872) -QuarkBuilderCometWithdrawTest:testCometWithdraw() (gas: 18847756) -QuarkBuilderCometWithdrawTest:testCometWithdrawMax() (gas: 19160292) -QuarkBuilderCometWithdrawTest:testCometWithdrawMaxRevertsMaxCostTooHigh() (gas: 18854170) -QuarkBuilderCometWithdrawTest:testCometWithdrawPayFromWithdraw() (gas: 19164988) -QuarkBuilderCometWithdrawTest:testCometWithdrawWithBridge() (gas: 19696713) -QuarkBuilderCometWithdrawTest:testCometWithdrawWithPaycall() (gas: 19125617) -QuarkBuilderCometWithdrawTest:testWithdrawNotEnoughFundsToBridge() (gas: 18771151) +QuarkBuilderCometBorrowTest:testBorrow() (gas: 19755759) +QuarkBuilderCometBorrowTest:testBorrowFundsUnavailable() (gas: 18791887) +QuarkBuilderCometBorrowTest:testBorrowInvalidInput() (gas: 18591456) +QuarkBuilderCometBorrowTest:testBorrowPayFromBorrow() (gas: 20786494) +QuarkBuilderCometBorrowTest:testBorrowWithAutoWrapper() (gas: 21130296) +QuarkBuilderCometBorrowTest:testBorrowWithBridgedPaymentToken() (gas: 22266221) +QuarkBuilderCometBorrowTest:testBorrowWithBridgedcollateralAsset() (gas: 22329033) +QuarkBuilderCometBorrowTest:testBorrowWithPaycall() (gas: 20726535) +QuarkBuilderCometRepayTest:testCometRepay() (gas: 19698375) +QuarkBuilderCometRepayTest:testCometRepayFundsUnavailable() (gas: 18729258) +QuarkBuilderCometRepayTest:testCometRepayInvalidInput() (gas: 18591772) +QuarkBuilderCometRepayTest:testCometRepayMax() (gas: 20503700) +QuarkBuilderCometRepayTest:testCometRepayMaxCostTooHigh() (gas: 19148254) +QuarkBuilderCometRepayTest:testCometRepayMaxWithBridge() (gas: 22234728) +QuarkBuilderCometRepayTest:testCometRepayPayFromWithdraw() (gas: 20975810) +QuarkBuilderCometRepayTest:testCometRepayWithAutoWrapper() (gas: 21118904) +QuarkBuilderCometRepayTest:testCometRepayWithBridge() (gas: 22315344) +QuarkBuilderCometRepayTest:testCometRepayWithPaycall() (gas: 20617588) +QuarkBuilderCometSupplyTest:testCometSupplyMaxWithBridge() (gas: 19811787) +QuarkBuilderCometSupplyTest:testCometSupplyMaxWithBridgeAndQuotecall() (gas: 20097787) +QuarkBuilderCometSupplyTest:testCometSupplyWithBridge() (gas: 19615158) +QuarkBuilderCometSupplyTest:testCometSupplyWithBridgeAndPaycall() (gas: 19939248) +QuarkBuilderCometSupplyTest:testCometSupplyWithPaycall() (gas: 19161841) +QuarkBuilderCometSupplyTest:testFundsUnavailable() (gas: 18709296) +QuarkBuilderCometSupplyTest:testInsufficientFunds() (gas: 18727789) +QuarkBuilderCometSupplyTest:testMaxCostTooHigh() (gas: 18707945) +QuarkBuilderCometSupplyTest:testSimpleCometSupply() (gas: 18990772) +QuarkBuilderCometSupplyTest:testSimpleCometSupplyMax() (gas: 19011942) +QuarkBuilderCometSupplyTest:testSimpleCometSupplyWithAutoWrapper() (gas: 19619306) +QuarkBuilderCometWithdrawTest:testCometWithdraw() (gas: 18853159) +QuarkBuilderCometWithdrawTest:testCometWithdrawCostTooHigh() (gas: 18776531) +QuarkBuilderCometWithdrawTest:testCometWithdrawMax() (gas: 19165737) +QuarkBuilderCometWithdrawTest:testCometWithdrawMaxRevertsMaxCostTooHigh() (gas: 18859615) +QuarkBuilderCometWithdrawTest:testCometWithdrawPayFromWithdraw() (gas: 19170480) +QuarkBuilderCometWithdrawTest:testCometWithdrawWithBridge() (gas: 19702164) +QuarkBuilderCometWithdrawTest:testCometWithdrawWithPaycall() (gas: 19131062) +QuarkBuilderCometWithdrawTest:testWithdrawNotEnoughFundsToBridge() (gas: 18776650) QuarkBuilderHelperTest:testAddBufferToPaymentCost() (gas: 413026) QuarkBuilderHelperTest:testCanBridgeUSDCOnSupportedChains() (gas: 423046) QuarkBuilderHelperTest:testCannotBridgeUSDCOnUnsupportedChains() (gas: 420966) QuarkBuilderHelperTest:testCannotBridgeUnsupportedAssets() (gas: 419619) -QuarkBuilderRecurringSwapTest:testFundsUnavailableErrorGivesSuggestionForAvailableFunds() (gas: 18706546) -QuarkBuilderRecurringSwapTest:testInsufficientFunds() (gas: 18692219) -QuarkBuilderRecurringSwapTest:testMaxCostTooHigh() (gas: 18706365) -QuarkBuilderRecurringSwapTest:testNotEnoughFundsOnTargetChain() (gas: 18691754) -QuarkBuilderRecurringSwapTest:testRecurringExactInSwapSucceeds() (gas: 19757953) -QuarkBuilderRecurringSwapTest:testRecurringExactOutSwapSucceeds() (gas: 19757841) -QuarkBuilderRecurringSwapTest:testRecurringSwapWithPaycallSucceeds() (gas: 20044818) -QuarkBuilderSwapTest:testBridgeSwapBridgesPaymentToken() (gas: 20023763) -QuarkBuilderSwapTest:testBridgeSwapMaxWithQuotecallSucceeds() (gas: 20263384) -QuarkBuilderSwapTest:testBridgeSwapSucceeds() (gas: 19771808) -QuarkBuilderSwapTest:testBridgeSwapWithPaycallSucceeds() (gas: 20104014) -QuarkBuilderSwapTest:testFundsOnUnbridgeableChains() (gas: 18710246) -QuarkBuilderSwapTest:testFundsUnavailableErrorGivesSuggestionForAvailableFunds() (gas: 18708049) -QuarkBuilderSwapTest:testIgnoresChainIfMaxCostIsNotSpecified() (gas: 18707930) -QuarkBuilderSwapTest:testInsufficientFunds() (gas: 18727769) -QuarkBuilderSwapTest:testLocalSwapSucceeds() (gas: 19146905) -QuarkBuilderSwapTest:testLocalSwapWithAutoWrapperSucceeds() (gas: 19775323) -QuarkBuilderSwapTest:testLocalSwapWithPaycallSucceeds() (gas: 19325351) -QuarkBuilderSwapTest:testMaxCostTooHigh() (gas: 18707872) -QuarkBuilderSwapTest:testRevertsIfNotEnoughFundsToBridge() (gas: 18907157) -QuarkBuilderSwapTest:testSwapMaxSucceeds() (gas: 19393623) -QuarkBuilderTransferTest:testBridgeTransferBridgesPaymentToken() (gas: 19846281) -QuarkBuilderTransferTest:testBridgeTransferMaxFundUnavailableError() (gas: 18920988) -QuarkBuilderTransferTest:testFundsOnUnbridgeableChains() (gas: 18704432) -QuarkBuilderTransferTest:testFundsUnavailableErrorGivesSuggestionForAvailableFunds() (gas: 18703025) -QuarkBuilderTransferTest:testIgnoresChainIfMaxCostIsNotSpecified() (gas: 18702967) -QuarkBuilderTransferTest:testInsufficientFunds() (gas: 18722742) -QuarkBuilderTransferTest:testMaxCostTooHigh() (gas: 18702895) -QuarkBuilderTransferTest:testRevertsIfNotEnoughFundsToBridge() (gas: 18901147) -QuarkBuilderTransferTest:testSimpleBridgeTransferMax() (gas: 20060891) -QuarkBuilderTransferTest:testSimpleBridgeTransferSucceeds() (gas: 19562564) -QuarkBuilderTransferTest:testSimpleBridgeTransferWithPaycallSucceeds() (gas: 19925119) -QuarkBuilderTransferTest:testSimpleLocalTransferMax() (gas: 19184362) -QuarkBuilderTransferTest:testSimpleLocalTransferSucceeds() (gas: 18945497) -QuarkBuilderTransferTest:testSimpleLocalTransferWithPaycallSucceeds() (gas: 19156154) -QuarkBuilderTransferTest:testTransferMaxWithAutoUnwrapping() (gas: 20222055) -QuarkBuilderTransferTest:testTransferWithAutoUnwrapping() (gas: 19547064) -QuarkBuilderTransferTest:testTransferWithAutoUnwrappingWithPaycallSucceeds() (gas: 20048567) -QuarkBuilderTransferTest:testTransferWithAutoWrapping() (gas: 19607562) +QuarkBuilderRecurringSwapTest:testFundsUnavailableErrorGivesSuggestionForAvailableFunds() (gas: 18711980) +QuarkBuilderRecurringSwapTest:testInsufficientFunds() (gas: 18697653) +QuarkBuilderRecurringSwapTest:testMaxCostTooHigh() (gas: 18711799) +QuarkBuilderRecurringSwapTest:testNotEnoughFundsOnTargetChain() (gas: 18697188) +QuarkBuilderRecurringSwapTest:testRecurringExactInSwapSucceeds() (gas: 19763387) +QuarkBuilderRecurringSwapTest:testRecurringExactOutSwapSucceeds() (gas: 19763275) +QuarkBuilderRecurringSwapTest:testRecurringSwapWithPaycallSucceeds() (gas: 20050252) +QuarkBuilderSwapTest:testBridgeSwapBridgesPaymentToken() (gas: 20029194) +QuarkBuilderSwapTest:testBridgeSwapMaxWithQuotecallSucceeds() (gas: 20268815) +QuarkBuilderSwapTest:testBridgeSwapSucceeds() (gas: 19777236) +QuarkBuilderSwapTest:testBridgeSwapWithPaycallSucceeds() (gas: 20109445) +QuarkBuilderSwapTest:testFundsOnUnbridgeableChains() (gas: 18715680) +QuarkBuilderSwapTest:testFundsUnavailableErrorGivesSuggestionForAvailableFunds() (gas: 18713483) +QuarkBuilderSwapTest:testIgnoresChainIfMaxCostIsNotSpecified() (gas: 18713364) +QuarkBuilderSwapTest:testInsufficientFunds() (gas: 18733203) +QuarkBuilderSwapTest:testLocalSwapSucceeds() (gas: 19152339) +QuarkBuilderSwapTest:testLocalSwapWithAutoWrapperSucceeds() (gas: 19780757) +QuarkBuilderSwapTest:testLocalSwapWithPaycallSucceeds() (gas: 19330785) +QuarkBuilderSwapTest:testMaxCostTooHigh() (gas: 18713306) +QuarkBuilderSwapTest:testRevertsIfNotEnoughFundsToBridge() (gas: 18912575) +QuarkBuilderSwapTest:testSwapMaxSucceeds() (gas: 19399057) +QuarkBuilderTransferTest:testBridgeTransferBridgesPaymentToken() (gas: 19851712) +QuarkBuilderTransferTest:testBridgeTransferMaxFundUnavailableError() (gas: 18926422) +QuarkBuilderTransferTest:testFundsOnUnbridgeableChains() (gas: 18709866) +QuarkBuilderTransferTest:testFundsUnavailableErrorGivesSuggestionForAvailableFunds() (gas: 18708459) +QuarkBuilderTransferTest:testIgnoresChainIfMaxCostIsNotSpecified() (gas: 18708401) +QuarkBuilderTransferTest:testInsufficientFunds() (gas: 18728176) +QuarkBuilderTransferTest:testMaxCostTooHigh() (gas: 18708329) +QuarkBuilderTransferTest:testRevertsIfNotEnoughFundsToBridge() (gas: 18906611) +QuarkBuilderTransferTest:testSimpleBridgeTransferMax() (gas: 20066322) +QuarkBuilderTransferTest:testSimpleBridgeTransferSucceeds() (gas: 19567992) +QuarkBuilderTransferTest:testSimpleBridgeTransferWithPaycallSucceeds() (gas: 19930550) +QuarkBuilderTransferTest:testSimpleLocalTransferMax() (gas: 19189796) +QuarkBuilderTransferTest:testSimpleLocalTransferSucceeds() (gas: 18950931) +QuarkBuilderTransferTest:testSimpleLocalTransferWithPaycallSucceeds() (gas: 19161588) +QuarkBuilderTransferTest:testTransferMaxWithAutoUnwrapping() (gas: 20227489) +QuarkBuilderTransferTest:testTransferWithAutoUnwrapping() (gas: 19552498) +QuarkBuilderTransferTest:testTransferWithAutoUnwrappingWithPaycallSucceeds() (gas: 20054001) +QuarkBuilderTransferTest:testTransferWithAutoWrapping() (gas: 19612996) QuotecallTest:testInitializeProperlyFromConstructor() (gas: 7036) QuotecallTest:testQuotecallForPayWithUSDT() (gas: 125408) QuotecallTest:testQuotecallForPayWithWBTC() (gas: 119962) diff --git a/src/builder/Actions.sol b/src/builder/Actions.sol index 251b539b..ed7f0d91 100644 --- a/src/builder/Actions.sol +++ b/src/builder/Actions.sol @@ -4,6 +4,7 @@ pragma solidity ^0.8.23; import {Accounts} from "./Accounts.sol"; import {BridgeRoutes, CCTP} from "./BridgeRoutes.sol"; import {CodeJarHelper} from "./CodeJarHelper.sol"; +import {Math} from "src/lib/Math.sol"; import {PriceFeeds} from "./PriceFeeds.sol"; import {Strings} from "./Strings.sol"; import {UniswapRouter} from "./UniswapRouter.sol"; @@ -393,8 +394,9 @@ library Actions { } else { // NOTE: This logic only works when the user has only a single account on each chain. If there are multiple, // then we need to re-adjust this. - amountToBridge = - srcAccountBalances[j].balance - PaymentInfo.findMaxCost(payment, srcChainAccounts.chainId); + amountToBridge = Math.subtractFlooredAtZero( + srcAccountBalances[j].balance, PaymentInfo.findMaxCost(payment, srcChainAccounts.chainId) + ); } } else { if (srcAccountBalances[j].balance >= amountLeftToBridge) { diff --git a/test/builder/QuarkBuilderCometWithdraw.t.sol b/test/builder/QuarkBuilderCometWithdraw.t.sol index 39434404..d56dbd3d 100644 --- a/test/builder/QuarkBuilderCometWithdraw.t.sol +++ b/test/builder/QuarkBuilderCometWithdraw.t.sol @@ -496,4 +496,19 @@ contract QuarkBuilderCometWithdrawTest is Test, QuarkBuilderTest { paymentUsdc_(maxCosts) // user will pay for transaction with withdrawn funds, but it is not enough ); } + + function testCometWithdrawCostTooHigh() public { + PaymentInfo.PaymentMaxCost[] memory maxCosts = new PaymentInfo.PaymentMaxCost[](2); + maxCosts[0] = PaymentInfo.PaymentMaxCost({chainId: 1, amount: 5e6}); + maxCosts[1] = PaymentInfo.PaymentMaxCost({chainId: 8453, amount: 5e6}); + QuarkBuilder builder = new QuarkBuilder(); + + vm.expectRevert(abi.encodeWithSelector(Actions.NotEnoughFundsToBridge.selector, "usdc", 4e6, 4e6)); + + builder.cometWithdraw( + cometWithdraw_(1, cometUsdc_(1), "USDC", 1e6), + chainAccountsList_(0e6), + paymentUsdc_(maxCosts) // user will pay for transaction with withdrawn funds, but it is not enough + ); + } } From deeda0ea4cd5ac0e23b07e9af99bb6b4d72e3143 Mon Sep 17 00:00:00 2001 From: Kevin Cheng Date: Tue, 10 Sep 2024 22:19:14 -0700 Subject: [PATCH 10/13] Ignore QuarkBuilder for gas snapshots (#77) --- .gas-snapshot | 85 ------------------------------ .github/workflows/gas-snapshot.yml | 2 +- script/update-snapshot.sh | 2 +- 3 files changed, 2 insertions(+), 87 deletions(-) diff --git a/.gas-snapshot b/.gas-snapshot index b66293b4..4545dad2 100644 --- a/.gas-snapshot +++ b/.gas-snapshot @@ -47,91 +47,6 @@ PaycallTest:testSimpleCounterAndPayWithUSDC() (gas: 148804) PaycallTest:testSimpleTransferTokenAndPayWithUSDC() (gas: 144444) PaycallTest:testSupplyWETHWithdrawUSDCOnCometAndPayWithUSDC() (gas: 305017) PaycallWrapperTest:testSimpleTransferAndWrapForPaycall() (gas: 1342091) -PriceFeedsTest:testFindPriceFeedPathDirectMatch() (gas: 210653) -PriceFeedsTest:testFindPriceFeedPathDirectMatchWithReverse() (gas: 215730) -PriceFeedsTest:testFindPriceFeedPathNoMatch() (gas: 221293) -PriceFeedsTest:testFindPriceFeedPathOneHopMatch() (gas: 271926) -PriceFeedsTest:testFindPriceFeeds() (gas: 100690) -QuarkBuilderCometBorrowTest:testBorrow() (gas: 19755759) -QuarkBuilderCometBorrowTest:testBorrowFundsUnavailable() (gas: 18791887) -QuarkBuilderCometBorrowTest:testBorrowInvalidInput() (gas: 18591456) -QuarkBuilderCometBorrowTest:testBorrowPayFromBorrow() (gas: 20786494) -QuarkBuilderCometBorrowTest:testBorrowWithAutoWrapper() (gas: 21130296) -QuarkBuilderCometBorrowTest:testBorrowWithBridgedPaymentToken() (gas: 22266221) -QuarkBuilderCometBorrowTest:testBorrowWithBridgedcollateralAsset() (gas: 22329033) -QuarkBuilderCometBorrowTest:testBorrowWithPaycall() (gas: 20726535) -QuarkBuilderCometRepayTest:testCometRepay() (gas: 19698375) -QuarkBuilderCometRepayTest:testCometRepayFundsUnavailable() (gas: 18729258) -QuarkBuilderCometRepayTest:testCometRepayInvalidInput() (gas: 18591772) -QuarkBuilderCometRepayTest:testCometRepayMax() (gas: 20503700) -QuarkBuilderCometRepayTest:testCometRepayMaxCostTooHigh() (gas: 19148254) -QuarkBuilderCometRepayTest:testCometRepayMaxWithBridge() (gas: 22234728) -QuarkBuilderCometRepayTest:testCometRepayPayFromWithdraw() (gas: 20975810) -QuarkBuilderCometRepayTest:testCometRepayWithAutoWrapper() (gas: 21118904) -QuarkBuilderCometRepayTest:testCometRepayWithBridge() (gas: 22315344) -QuarkBuilderCometRepayTest:testCometRepayWithPaycall() (gas: 20617588) -QuarkBuilderCometSupplyTest:testCometSupplyMaxWithBridge() (gas: 19811787) -QuarkBuilderCometSupplyTest:testCometSupplyMaxWithBridgeAndQuotecall() (gas: 20097787) -QuarkBuilderCometSupplyTest:testCometSupplyWithBridge() (gas: 19615158) -QuarkBuilderCometSupplyTest:testCometSupplyWithBridgeAndPaycall() (gas: 19939248) -QuarkBuilderCometSupplyTest:testCometSupplyWithPaycall() (gas: 19161841) -QuarkBuilderCometSupplyTest:testFundsUnavailable() (gas: 18709296) -QuarkBuilderCometSupplyTest:testInsufficientFunds() (gas: 18727789) -QuarkBuilderCometSupplyTest:testMaxCostTooHigh() (gas: 18707945) -QuarkBuilderCometSupplyTest:testSimpleCometSupply() (gas: 18990772) -QuarkBuilderCometSupplyTest:testSimpleCometSupplyMax() (gas: 19011942) -QuarkBuilderCometSupplyTest:testSimpleCometSupplyWithAutoWrapper() (gas: 19619306) -QuarkBuilderCometWithdrawTest:testCometWithdraw() (gas: 18853159) -QuarkBuilderCometWithdrawTest:testCometWithdrawCostTooHigh() (gas: 18776531) -QuarkBuilderCometWithdrawTest:testCometWithdrawMax() (gas: 19165737) -QuarkBuilderCometWithdrawTest:testCometWithdrawMaxRevertsMaxCostTooHigh() (gas: 18859615) -QuarkBuilderCometWithdrawTest:testCometWithdrawPayFromWithdraw() (gas: 19170480) -QuarkBuilderCometWithdrawTest:testCometWithdrawWithBridge() (gas: 19702164) -QuarkBuilderCometWithdrawTest:testCometWithdrawWithPaycall() (gas: 19131062) -QuarkBuilderCometWithdrawTest:testWithdrawNotEnoughFundsToBridge() (gas: 18776650) -QuarkBuilderHelperTest:testAddBufferToPaymentCost() (gas: 413026) -QuarkBuilderHelperTest:testCanBridgeUSDCOnSupportedChains() (gas: 423046) -QuarkBuilderHelperTest:testCannotBridgeUSDCOnUnsupportedChains() (gas: 420966) -QuarkBuilderHelperTest:testCannotBridgeUnsupportedAssets() (gas: 419619) -QuarkBuilderRecurringSwapTest:testFundsUnavailableErrorGivesSuggestionForAvailableFunds() (gas: 18711980) -QuarkBuilderRecurringSwapTest:testInsufficientFunds() (gas: 18697653) -QuarkBuilderRecurringSwapTest:testMaxCostTooHigh() (gas: 18711799) -QuarkBuilderRecurringSwapTest:testNotEnoughFundsOnTargetChain() (gas: 18697188) -QuarkBuilderRecurringSwapTest:testRecurringExactInSwapSucceeds() (gas: 19763387) -QuarkBuilderRecurringSwapTest:testRecurringExactOutSwapSucceeds() (gas: 19763275) -QuarkBuilderRecurringSwapTest:testRecurringSwapWithPaycallSucceeds() (gas: 20050252) -QuarkBuilderSwapTest:testBridgeSwapBridgesPaymentToken() (gas: 20029194) -QuarkBuilderSwapTest:testBridgeSwapMaxWithQuotecallSucceeds() (gas: 20268815) -QuarkBuilderSwapTest:testBridgeSwapSucceeds() (gas: 19777236) -QuarkBuilderSwapTest:testBridgeSwapWithPaycallSucceeds() (gas: 20109445) -QuarkBuilderSwapTest:testFundsOnUnbridgeableChains() (gas: 18715680) -QuarkBuilderSwapTest:testFundsUnavailableErrorGivesSuggestionForAvailableFunds() (gas: 18713483) -QuarkBuilderSwapTest:testIgnoresChainIfMaxCostIsNotSpecified() (gas: 18713364) -QuarkBuilderSwapTest:testInsufficientFunds() (gas: 18733203) -QuarkBuilderSwapTest:testLocalSwapSucceeds() (gas: 19152339) -QuarkBuilderSwapTest:testLocalSwapWithAutoWrapperSucceeds() (gas: 19780757) -QuarkBuilderSwapTest:testLocalSwapWithPaycallSucceeds() (gas: 19330785) -QuarkBuilderSwapTest:testMaxCostTooHigh() (gas: 18713306) -QuarkBuilderSwapTest:testRevertsIfNotEnoughFundsToBridge() (gas: 18912575) -QuarkBuilderSwapTest:testSwapMaxSucceeds() (gas: 19399057) -QuarkBuilderTransferTest:testBridgeTransferBridgesPaymentToken() (gas: 19851712) -QuarkBuilderTransferTest:testBridgeTransferMaxFundUnavailableError() (gas: 18926422) -QuarkBuilderTransferTest:testFundsOnUnbridgeableChains() (gas: 18709866) -QuarkBuilderTransferTest:testFundsUnavailableErrorGivesSuggestionForAvailableFunds() (gas: 18708459) -QuarkBuilderTransferTest:testIgnoresChainIfMaxCostIsNotSpecified() (gas: 18708401) -QuarkBuilderTransferTest:testInsufficientFunds() (gas: 18728176) -QuarkBuilderTransferTest:testMaxCostTooHigh() (gas: 18708329) -QuarkBuilderTransferTest:testRevertsIfNotEnoughFundsToBridge() (gas: 18906611) -QuarkBuilderTransferTest:testSimpleBridgeTransferMax() (gas: 20066322) -QuarkBuilderTransferTest:testSimpleBridgeTransferSucceeds() (gas: 19567992) -QuarkBuilderTransferTest:testSimpleBridgeTransferWithPaycallSucceeds() (gas: 19930550) -QuarkBuilderTransferTest:testSimpleLocalTransferMax() (gas: 19189796) -QuarkBuilderTransferTest:testSimpleLocalTransferSucceeds() (gas: 18950931) -QuarkBuilderTransferTest:testSimpleLocalTransferWithPaycallSucceeds() (gas: 19161588) -QuarkBuilderTransferTest:testTransferMaxWithAutoUnwrapping() (gas: 20227489) -QuarkBuilderTransferTest:testTransferWithAutoUnwrapping() (gas: 19552498) -QuarkBuilderTransferTest:testTransferWithAutoUnwrappingWithPaycallSucceeds() (gas: 20054001) -QuarkBuilderTransferTest:testTransferWithAutoWrapping() (gas: 19612996) QuotecallTest:testInitializeProperlyFromConstructor() (gas: 7036) QuotecallTest:testQuotecallForPayWithUSDT() (gas: 125408) QuotecallTest:testQuotecallForPayWithWBTC() (gas: 119962) diff --git a/.github/workflows/gas-snapshot.yml b/.github/workflows/gas-snapshot.yml index 9e573a33..8a2dad84 100644 --- a/.github/workflows/gas-snapshot.yml +++ b/.github/workflows/gas-snapshot.yml @@ -29,7 +29,7 @@ jobs: run: | set -euo pipefail # grep -E '^test' -- skip over test results, just get diffs - forge snapshot --diff \ + FOUNDRY_PROFILE=ir forge snapshot --no-match-path test/builder/**/*.t.sol --diff \ | grep -E '^test' \ | tee .gas-snapshot.new env: diff --git a/script/update-snapshot.sh b/script/update-snapshot.sh index 795d273f..6270c0b6 100755 --- a/script/update-snapshot.sh +++ b/script/update-snapshot.sh @@ -1,3 +1,3 @@ #!/bin/bash -FOUNDRY_PROFILE=ir forge snapshot \ No newline at end of file +FOUNDRY_PROFILE=ir forge snapshot --no-match-path test/builder/**/*.t.sol \ No newline at end of file From c6c524e218954cbd47aabe332d30c00b677603ed Mon Sep 17 00:00:00 2001 From: Kevin Cheng Date: Wed, 11 Sep 2024 00:15:14 -0700 Subject: [PATCH 11/13] Hardcode more price feeds to QuarkBuilder (#76) --- src/builder/PriceFeeds.sol | 52 +++++++++++++++++++++++++++++++++-- test/builder/PriceFeeds.t.sol | 2 +- 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/src/builder/PriceFeeds.sol b/src/builder/PriceFeeds.sol index 34f6a9cf..6efc8d89 100644 --- a/src/builder/PriceFeeds.sol +++ b/src/builder/PriceFeeds.sol @@ -48,7 +48,7 @@ library PriceFeeds { // Mainnet function knownPriceFeeds_1() internal pure returns (PriceFeed[] memory) { - PriceFeed[] memory priceFeeds = new PriceFeed[](4); + PriceFeed[] memory priceFeeds = new PriceFeed[](10); priceFeeds[0] = PriceFeed({ chainId: 1, baseSymbol: "USDC", @@ -73,12 +73,48 @@ library PriceFeeds { quoteSymbol: "ETH", priceFeed: 0xDC530D9457755926550b59e8ECcdaE7624181557 }); + priceFeeds[4] = PriceFeed({ + chainId: 1, + baseSymbol: "wstETH", + quoteSymbol: "USD", + priceFeed: 0x164b276057258d81941e97B0a900D4C7B358bCe0 + }); + priceFeeds[5] = PriceFeed({ + chainId: 1, + baseSymbol: "stETH", + quoteSymbol: "ETH", + priceFeed: 0x86392dC19c0b719886221c78AB11eb8Cf5c52812 + }); + priceFeeds[6] = PriceFeed({ + chainId: 1, + baseSymbol: "rETH", + quoteSymbol: "ETH", + priceFeed: 0x536218f9E9Eb48863970252233c8F271f554C2d0 + }); + priceFeeds[7] = PriceFeed({ + chainId: 1, + baseSymbol: "WBTC", + quoteSymbol: "BTC", + priceFeed: 0xfdFD9C85aD200c506Cf9e21F1FD8dd01932FBB23 + }); + priceFeeds[8] = PriceFeed({ + chainId: 1, + baseSymbol: "BTC", + quoteSymbol: "USD", + priceFeed: 0xF4030086522a5bEEa4988F8cA5B36dbC97BeE88c + }); + priceFeeds[9] = PriceFeed({ + chainId: 1, + baseSymbol: "BTC", + quoteSymbol: "ETH", + priceFeed: 0xdeb288F737066589598e9214E782fa5A8eD689e8 + }); return priceFeeds; } // Base function knownPriceFeeds_8453() internal pure returns (PriceFeed[] memory) { - PriceFeed[] memory priceFeeds = new PriceFeed[](3); + PriceFeed[] memory priceFeeds = new PriceFeed[](5); priceFeeds[0] = PriceFeed({ chainId: 8453, baseSymbol: "ETH", @@ -97,6 +133,18 @@ library PriceFeeds { quoteSymbol: "ETH", priceFeed: 0xc5E65227fe3385B88468F9A01600017cDC9F3A12 }); + priceFeeds[3] = PriceFeed({ + chainId: 8453, + baseSymbol: "cbETH", + quoteSymbol: "USD", + priceFeed: 0xd7818272B9e248357d13057AAb0B417aF31E817d + }); + priceFeeds[4] = PriceFeed({ + chainId: 8453, + baseSymbol: "cbETH", + quoteSymbol: "ETH", + priceFeed: 0x806b4Ac04501c29769051e42783cF04dCE41440b + }); return priceFeeds; } diff --git a/test/builder/PriceFeeds.t.sol b/test/builder/PriceFeeds.t.sol index 5cf318e7..2280e9d9 100644 --- a/test/builder/PriceFeeds.t.sol +++ b/test/builder/PriceFeeds.t.sol @@ -15,7 +15,7 @@ contract PriceFeedsTest is Test { uint256 chainId = 1; PriceFeeds.PriceFeed[] memory priceFeeds = PriceFeeds.findPriceFeeds(assetSymbol, chainId); - assertEq(priceFeeds.length, 3); + assertEq(priceFeeds.length, 6); for (uint256 i = 0; i < priceFeeds.length; ++i) { // Check that ETH is either the baseSymbol or the quoteSymbol in each price feed bool isBaseOrQuoteSymbol = Strings.stringEq(priceFeeds[i].baseSymbol, assetSymbol) From 9e6cf930b2956d86a865c8e671843b014dc21293 Mon Sep 17 00:00:00 2001 From: Hans Wang <2709448+cwang25@users.noreply.github.com> Date: Thu, 12 Sep 2024 12:03:26 -0700 Subject: [PATCH 12/13] (p2) PR again, mistakenly merged the previous approved P2 into wrong branch (#78) - Morpho actions --- .github/workflows/gas-snapshot.yml | 3 + .github/workflows/test.yml | 3 + src/builder/Accounts.sol | 53 ++ src/builder/Actions.sol | 291 +++++- src/builder/MorphoInfo.sol | 427 +++++++++ src/builder/PaymentInfo.sol | 1 + src/builder/QuarkBuilder.sol | 372 +++++++- src/vendor/manifest.json | 6 +- test/builder/QuarkBuilderCometBorrow.t.sol | 36 +- test/builder/QuarkBuilderCometRepay.t.sol | 42 +- test/builder/QuarkBuilderCometSupply.t.sol | 6 +- test/builder/QuarkBuilderCometWithdraw.t.sol | 12 +- test/builder/QuarkBuilderMorphoBorrow.t.sol | 644 +++++++++++++ test/builder/QuarkBuilderMorphoRepay.t.sol | 852 ++++++++++++++++++ test/builder/QuarkBuilderSwap.t.sol | 12 +- test/builder/QuarkBuilderTransfer.t.sol | 30 +- test/builder/lib/QuarkBuilderTest.sol | 94 +- .../MorphoInfo.t.sol | 107 +++ 18 files changed, 2892 insertions(+), 99 deletions(-) create mode 100644 src/builder/MorphoInfo.sol create mode 100644 test/builder/QuarkBuilderMorphoBorrow.t.sol create mode 100644 test/builder/QuarkBuilderMorphoRepay.t.sol create mode 100644 test/on-chain-info-verification/MorphoInfo.t.sol diff --git a/.github/workflows/gas-snapshot.yml b/.github/workflows/gas-snapshot.yml index 8a2dad84..d27946d1 100644 --- a/.github/workflows/gas-snapshot.yml +++ b/.github/workflows/gas-snapshot.yml @@ -34,6 +34,9 @@ jobs: | tee .gas-snapshot.new env: MAINNET_RPC_URL: ${{ secrets.MAINNET_RPC_URL }} + BASE_MAINNET_RPC_URL: ${{ secrets.BASE_MAINNET_RPC_URL }} + SEPOLIA_RPC_URL: ${{ secrets.SEPOLIA_RPC_URL }} + BASE_SEPOLIA_RPC_URL: ${{ secrets.BASE_SEPOLIA_RPC_URL }} - name: Check diff tolerance run: | diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 93efced0..d57dffa4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -38,3 +38,6 @@ jobs: id: test env: MAINNET_RPC_URL: ${{ secrets.MAINNET_RPC_URL }} + BASE_MAINNET_RPC_URL: ${{ secrets.BASE_MAINNET_RPC_URL }} + SEPOLIA_RPC_URL: ${{ secrets.SEPOLIA_RPC_URL }} + BASE_SEPOLIA_RPC_URL: ${{ secrets.BASE_SEPOLIA_RPC_URL }} diff --git a/src/builder/Accounts.sol b/src/builder/Accounts.sol index ee72e70e..56307da2 100644 --- a/src/builder/Accounts.sol +++ b/src/builder/Accounts.sol @@ -13,6 +13,7 @@ library Accounts { QuarkState[] quarkStates; AssetPositions[] assetPositionsList; CometPositions[] cometPositions; + MorphoPositions[] morphoPositions; } // We map this to the Portfolio data structure that the client will already have. @@ -57,6 +58,25 @@ library Accounts { uint256[] balances; } + struct MorphoPositions { + bytes32 marketId; + address morpho; + address loanToken; + address collateralToken; + MorphoBorrowPosition borrowPosition; + MorphoCollateralPosition collateralPosition; + } + + struct MorphoBorrowPosition { + address[] accounts; + uint256[] borrowed; + } + + struct MorphoCollateralPosition { + address[] accounts; + uint256[] balances; + } + function findChainAccounts(uint256 chainId, ChainAccounts[] memory chainAccountsList) internal pure @@ -82,6 +102,23 @@ library Accounts { } } + function findMorphoPositions( + uint256 chainId, + address loanToken, + address collateralToken, + ChainAccounts[] memory chainAccountsList + ) internal pure returns (MorphoPositions memory found) { + ChainAccounts memory chainAccounts = findChainAccounts(chainId, chainAccountsList); + for (uint256 i = 0; i < chainAccounts.morphoPositions.length; ++i) { + if ( + chainAccounts.morphoPositions[i].loanToken == loanToken + && chainAccounts.morphoPositions[i].collateralToken == collateralToken + ) { + return found = chainAccounts.morphoPositions[i]; + } + } + } + function findAssetPositions(string memory assetSymbol, AssetPositions[] memory assetPositionsList) internal pure @@ -253,4 +290,20 @@ library Accounts { } } } + + function totalMorphoBorrowForAccount( + Accounts.ChainAccounts[] memory chainAccountsList, + uint256 chainId, + address loanToken, + address collateralToken, + address account + ) internal pure returns (uint256 totalBorrow) { + Accounts.MorphoPositions memory morphoPositions = + findMorphoPositions(chainId, loanToken, collateralToken, chainAccountsList); + for (uint256 i = 0; i < morphoPositions.borrowPosition.accounts.length; ++i) { + if (morphoPositions.borrowPosition.accounts[i] == account) { + totalBorrow = morphoPositions.borrowPosition.borrowed[i]; + } + } + } } diff --git a/src/builder/Actions.sol b/src/builder/Actions.sol index ed7f0d91..b55da2e9 100644 --- a/src/builder/Actions.sol +++ b/src/builder/Actions.sol @@ -17,22 +17,28 @@ import { CometWithdrawActions, TransferActions } from "../DeFiScripts.sol"; +import {MorphoActions, MorphoRewardsActions, MorphoVaultActions} from "../MorphoScripts.sol"; import {RecurringSwap} from "../RecurringSwap.sol"; import {WrapperActions} from "../WrapperScripts.sol"; - import {IQuarkWallet} from "quark-core/src/interfaces/IQuarkWallet.sol"; +import {IMorpho, Position} from "../interfaces/IMorpho.sol"; import {PaymentInfo} from "./PaymentInfo.sol"; import {TokenWrapper} from "./TokenWrapper.sol"; +import {MorphoInfo} from "./MorphoInfo.sol"; import {List} from "./List.sol"; library Actions { /* ===== Constants ===== */ + // TODO: (LHT-86) Rename ACTION_TYPE_BORROW to ACTION_TYPE_COMET_BORROW, as now we have more than one borrow market string constant ACTION_TYPE_BORROW = "BORROW"; + string constant ACTION_TYPE_MORPHO_BORROW = "MORPHO_BORROW"; string constant ACTION_TYPE_BRIDGE = "BRIDGE"; string constant ACTION_TYPE_CLAIM_REWARDS = "CLAIM_REWARDS"; string constant ACTION_TYPE_DRIP_TOKENS = "DRIP_TOKENS"; string constant ACTION_TYPE_RECURRING_SWAP = "RECURRING_SWAP"; + // TODO: (LHT-86) Rename ACTION_TYPE_REPAY to ACTION_TYPE_COMET_REPAY, as now we have more than one borrow market string constant ACTION_TYPE_REPAY = "REPAY"; + string constant ACTION_TYPE_MORPHO_REPAY = "MORPHO_REPAY"; string constant ACTION_TYPE_SUPPLY = "SUPPLY"; string constant ACTION_TYPE_SWAP = "SWAP"; string constant ACTION_TYPE_TRANSFER = "TRANSFER"; @@ -147,6 +153,52 @@ library Actions { uint256 blockTimestamp; } + struct CometBorrowInput { + Accounts.ChainAccounts[] chainAccountsList; + uint256 amount; + string assetSymbol; + uint256 blockTimestamp; + address borrower; + uint256 chainId; + uint256[] collateralAmounts; + string[] collateralAssetSymbols; + address comet; + } + + struct CometRepayInput { + Accounts.ChainAccounts[] chainAccountsList; + uint256 amount; + string assetSymbol; + uint256 blockTimestamp; + uint256 chainId; + uint256[] collateralAmounts; + string[] collateralAssetSymbols; + address comet; + address repayer; + } + + struct MorphoBorrow { + uint256 amount; + string assetSymbol; + uint256 blockTimestamp; + address borrower; + Accounts.ChainAccounts[] chainAccountsList; + uint256 chainId; + uint256 collateralAmount; + string collateralAssetSymbol; + } + + struct MorphoRepay { + uint256 amount; + string assetSymbol; + uint256 blockTimestamp; + uint256 chainId; + Accounts.ChainAccounts[] chainAccountsList; + uint256 collateralAmount; + string collateralAssetSymbol; + address repayer; + } + // Note: Mainly to avoid stack too deep errors struct BridgeOperationInfo { string assetSymbol; @@ -303,6 +355,34 @@ library Actions { string toAssetSymbol; } + struct MorphoRepayActionContext { + uint256 amount; + string assetSymbol; + uint256 chainId; + uint256 collateralAmount; + string collateralAssetSymbol; + uint256 collateralTokenPrice; + address collateralToken; + address morpho; + bytes32 morphoMarketId; + uint256 price; + address token; + } + + struct MorphoBorrowActionContext { + uint256 amount; + string assetSymbol; + uint256 chainId; + uint256 collateralAmount; + string collateralAssetSymbol; + uint256 collateralTokenPrice; + address collateralToken; + address morpho; + bytes32 morphoMarketId; + uint256 price; + address token; + } + function constructBridgeOperations( BridgeOperationInfo memory bridgeInfo, Accounts.ChainAccounts[] memory chainAccountsList, @@ -502,8 +582,9 @@ library Actions { actionType: ACTION_TYPE_BRIDGE, actionContext: abi.encode(bridgeActionContext), paymentMethod: PaymentInfo.paymentMethodForPayment(payment, useQuotecall), - // Null address for OFFCHAIN payment. - paymentToken: payment.isToken ? PaymentInfo.knownToken(payment.currency, bridge.srcChainId).token : address(0), + paymentToken: payment.isToken + ? PaymentInfo.knownToken(payment.currency, bridge.srcChainId).token + : PaymentInfo.NON_TOKEN_PAYMENT, paymentTokenSymbol: payment.currency, paymentMaxCost: payment.isToken ? PaymentInfo.findMaxCost(payment, bridge.srcChainId) : 0 }); @@ -511,18 +592,6 @@ library Actions { return (quarkOperation, action); } - struct CometBorrowInput { - Accounts.ChainAccounts[] chainAccountsList; - uint256 amount; - string assetSymbol; - uint256 blockTimestamp; - address borrower; - uint256 chainId; - uint256[] collateralAmounts; - string[] collateralAssetSymbols; - address comet; - } - function cometBorrow(CometBorrowInput memory borrowInput, PaymentInfo.Payment memory payment) internal pure @@ -569,7 +638,7 @@ library Actions { }); // Construct Action - BorrowActionContext memory repayActionContext = BorrowActionContext({ + BorrowActionContext memory borrowActionContext = BorrowActionContext({ assetSymbol: borrowInput.assetSymbol, amount: borrowInput.amount, chainId: borrowInput.chainId, @@ -585,10 +654,11 @@ library Actions { chainId: borrowInput.chainId, quarkAccount: borrowInput.borrower, actionType: ACTION_TYPE_BORROW, - actionContext: abi.encode(repayActionContext), + actionContext: abi.encode(borrowActionContext), paymentMethod: PaymentInfo.paymentMethodForPayment(payment, false), - // Null address for OFFCHAIN payment. - paymentToken: payment.isToken ? PaymentInfo.knownToken(payment.currency, borrowInput.chainId).token : address(0), + paymentToken: payment.isToken + ? PaymentInfo.knownToken(payment.currency, borrowInput.chainId).token + : PaymentInfo.NON_TOKEN_PAYMENT, paymentTokenSymbol: payment.currency, paymentMaxCost: payment.isToken ? PaymentInfo.findMaxCost(payment, borrowInput.chainId) : 0 }); @@ -596,18 +666,6 @@ library Actions { return (quarkOperation, action); } - struct CometRepayInput { - Accounts.ChainAccounts[] chainAccountsList; - uint256 amount; - string assetSymbol; - uint256 blockTimestamp; - uint256 chainId; - uint256[] collateralAmounts; - string[] collateralAssetSymbols; - address comet; - address repayer; - } - function cometRepay(CometRepayInput memory repayInput, PaymentInfo.Payment memory payment) internal pure @@ -672,8 +730,9 @@ library Actions { actionType: ACTION_TYPE_REPAY, actionContext: abi.encode(repayActionContext), paymentMethod: PaymentInfo.paymentMethodForPayment(payment, false), - // Null address for OFFCHAIN payment. - paymentToken: payment.isToken ? PaymentInfo.knownToken(payment.currency, repayInput.chainId).token : address(0), + paymentToken: payment.isToken + ? PaymentInfo.knownToken(payment.currency, repayInput.chainId).token + : PaymentInfo.NON_TOKEN_PAYMENT, paymentTokenSymbol: payment.currency, paymentMaxCost: payment.isToken ? PaymentInfo.findMaxCost(payment, repayInput.chainId) : 0 }); @@ -729,8 +788,9 @@ library Actions { actionType: ACTION_TYPE_SUPPLY, actionContext: abi.encode(cometSupplyActionContext), paymentMethod: PaymentInfo.paymentMethodForPayment(payment, false), - // Null address for OFFCHAIN payment. - paymentToken: payment.isToken ? PaymentInfo.knownToken(payment.currency, cometSupply.chainId).token : address(0), + paymentToken: payment.isToken + ? PaymentInfo.knownToken(payment.currency, cometSupply.chainId).token + : PaymentInfo.NON_TOKEN_PAYMENT, paymentTokenSymbol: payment.currency, paymentMaxCost: payment.isToken ? PaymentInfo.findMaxCost(payment, cometSupply.chainId) : 0 }); @@ -787,10 +847,9 @@ library Actions { actionType: ACTION_TYPE_WITHDRAW, actionContext: abi.encode(cometWithdrawActionContext), paymentMethod: PaymentInfo.paymentMethodForPayment(payment, false), - // Null address for OFFCHAIN payment. paymentToken: payment.isToken ? PaymentInfo.knownToken(payment.currency, cometWithdraw.chainId).token - : address(0), + : PaymentInfo.NON_TOKEN_PAYMENT, paymentTokenSymbol: payment.currency, paymentMaxCost: payment.isToken ? PaymentInfo.findMaxCost(payment, cometWithdraw.chainId) : 0 }); @@ -851,8 +910,9 @@ library Actions { actionType: ACTION_TYPE_TRANSFER, actionContext: abi.encode(transferActionContext), paymentMethod: PaymentInfo.paymentMethodForPayment(payment, useQuotecall), - // Null address for OFFCHAIN payment. - paymentToken: payment.isToken ? PaymentInfo.knownToken(payment.currency, transfer.chainId).token : address(0), + paymentToken: payment.isToken + ? PaymentInfo.knownToken(payment.currency, transfer.chainId).token + : PaymentInfo.NON_TOKEN_PAYMENT, paymentTokenSymbol: payment.currency, paymentMaxCost: payment.isToken ? PaymentInfo.findMaxCost(payment, transfer.chainId) : 0 }); @@ -860,6 +920,141 @@ library Actions { return (quarkOperation, action); } + function morphoBorrow(MorphoBorrow memory borrowInput, PaymentInfo.Payment memory payment) + internal + pure + returns (IQuarkWallet.QuarkOperation memory, Action memory) + { + bytes[] memory scriptSources = new bytes[](1); + scriptSources[0] = type(MorphoActions).creationCode; + + Accounts.ChainAccounts memory accounts = + Accounts.findChainAccounts(borrowInput.chainId, borrowInput.chainAccountsList); + + Accounts.QuarkState memory accountState = Accounts.findQuarkState(borrowInput.borrower, accounts.quarkStates); + + Accounts.AssetPositions memory borrowAssetPositions = + Accounts.findAssetPositions(borrowInput.assetSymbol, accounts.assetPositionsList); + + Accounts.AssetPositions memory collateralAssetPositions = + Accounts.findAssetPositions(borrowInput.collateralAssetSymbol, accounts.assetPositionsList); + + bytes memory scriptCalldata = abi.encodeWithSelector( + MorphoActions.supplyCollateralAndBorrow.selector, + MorphoInfo.getMorphoAddress(borrowInput.chainId), + MorphoInfo.getMarketParams(borrowInput.chainId, borrowInput.collateralAssetSymbol, borrowInput.assetSymbol), + borrowInput.collateralAmount, + borrowInput.amount + ); + + // Construct QuarkOperation + IQuarkWallet.QuarkOperation memory quarkOperation = IQuarkWallet.QuarkOperation({ + nonce: accountState.quarkNextNonce, + scriptAddress: CodeJarHelper.getCodeAddress(type(MorphoActions).creationCode), + scriptCalldata: scriptCalldata, + scriptSources: scriptSources, + expiry: borrowInput.blockTimestamp + STANDARD_EXPIRY_BUFFER + }); + + MorphoBorrowActionContext memory borrowActionContext = MorphoBorrowActionContext({ + assetSymbol: borrowInput.assetSymbol, + amount: borrowInput.amount, + chainId: borrowInput.chainId, + collateralAmount: borrowInput.collateralAmount, + collateralTokenPrice: collateralAssetPositions.usdPrice, + collateralToken: collateralAssetPositions.asset, + collateralAssetSymbol: borrowInput.collateralAssetSymbol, + price: borrowAssetPositions.usdPrice, + token: borrowAssetPositions.asset, + morpho: MorphoInfo.getMorphoAddress(borrowInput.chainId), + morphoMarketId: MorphoInfo.marketId( + MorphoInfo.getMarketParams(borrowInput.chainId, borrowInput.collateralAssetSymbol, borrowInput.assetSymbol) + ) + }); + Action memory action = Actions.Action({ + chainId: borrowInput.chainId, + quarkAccount: borrowInput.borrower, + actionType: ACTION_TYPE_MORPHO_BORROW, + actionContext: abi.encode(borrowActionContext), + paymentMethod: PaymentInfo.paymentMethodForPayment(payment, false), + paymentToken: payment.isToken + ? PaymentInfo.knownToken(payment.currency, borrowInput.chainId).token + : PaymentInfo.NON_TOKEN_PAYMENT, + paymentTokenSymbol: payment.currency, + paymentMaxCost: payment.isToken ? PaymentInfo.findMaxCost(payment, borrowInput.chainId) : 0 + }); + + return (quarkOperation, action); + } + + function morphoRepay(MorphoRepay memory repayInput, PaymentInfo.Payment memory payment) + internal + pure + returns (IQuarkWallet.QuarkOperation memory, Action memory) + { + bytes[] memory scriptSources = new bytes[](1); + scriptSources[0] = type(MorphoActions).creationCode; + + Accounts.ChainAccounts memory accounts = + Accounts.findChainAccounts(repayInput.chainId, repayInput.chainAccountsList); + + Accounts.QuarkState memory accountState = Accounts.findQuarkState(repayInput.repayer, accounts.quarkStates); + + Accounts.AssetPositions memory repayAssetPositions = + Accounts.findAssetPositions(repayInput.assetSymbol, accounts.assetPositionsList); + + Accounts.AssetPositions memory collateralAssetPositions = + Accounts.findAssetPositions(repayInput.collateralAssetSymbol, accounts.assetPositionsList); + + bytes memory scriptCalldata = abi.encodeWithSelector( + MorphoActions.repayAndWithdrawCollateral.selector, + MorphoInfo.getMorphoAddress(repayInput.chainId), + MorphoInfo.getMarketParams(repayInput.chainId, repayInput.collateralAssetSymbol, repayInput.assetSymbol), + repayInput.amount, + repayInput.collateralAmount + ); + + // Construct QuarkOperation + IQuarkWallet.QuarkOperation memory quarkOperation = IQuarkWallet.QuarkOperation({ + nonce: accountState.quarkNextNonce, + scriptAddress: CodeJarHelper.getCodeAddress(type(MorphoActions).creationCode), + scriptCalldata: scriptCalldata, + scriptSources: scriptSources, + expiry: repayInput.blockTimestamp + STANDARD_EXPIRY_BUFFER + }); + + MorphoRepayActionContext memory morphoRepayActionContext = MorphoRepayActionContext({ + amount: repayInput.amount, + assetSymbol: repayInput.assetSymbol, + chainId: repayInput.chainId, + collateralAmount: repayInput.collateralAmount, + collateralAssetSymbol: repayInput.collateralAssetSymbol, + collateralTokenPrice: collateralAssetPositions.usdPrice, + collateralToken: collateralAssetPositions.asset, + price: repayAssetPositions.usdPrice, + token: repayAssetPositions.asset, + morpho: MorphoInfo.getMorphoAddress(repayInput.chainId), + morphoMarketId: MorphoInfo.marketId( + MorphoInfo.getMarketParams(repayInput.chainId, repayInput.collateralAssetSymbol, repayInput.assetSymbol) + ) + }); + + Action memory action = Actions.Action({ + chainId: repayInput.chainId, + quarkAccount: repayInput.repayer, + actionType: ACTION_TYPE_MORPHO_REPAY, + actionContext: abi.encode(morphoRepayActionContext), + paymentMethod: PaymentInfo.paymentMethodForPayment(payment, false), + paymentToken: payment.isToken + ? PaymentInfo.knownToken(payment.currency, repayInput.chainId).token + : PaymentInfo.NON_TOKEN_PAYMENT, + paymentTokenSymbol: payment.currency, + paymentMaxCost: payment.isToken ? PaymentInfo.findMaxCost(payment, repayInput.chainId) : 0 + }); + + return (quarkOperation, action); + } + function wrapOrUnwrapAsset( WrapOrUnwrapAsset memory wrapOrUnwrap, PaymentInfo.Payment memory payment, @@ -903,10 +1098,9 @@ library Actions { : ACTION_TYPE_WRAP, actionContext: abi.encode(wrapOrUnwrapActionContext), paymentMethod: PaymentInfo.paymentMethodForPayment(payment, useQuotecall), - // Null address for OFFCHAIN payment. paymentToken: payment.isToken ? PaymentInfo.knownToken(payment.currency, wrapOrUnwrap.chainId).token - : address(0), + : PaymentInfo.NON_TOKEN_PAYMENT, paymentTokenSymbol: payment.currency, paymentMaxCost: payment.isToken ? PaymentInfo.findMaxCost(payment, wrapOrUnwrap.chainId) : 0 }); @@ -979,8 +1173,9 @@ library Actions { actionType: ACTION_TYPE_SWAP, actionContext: abi.encode(swapActionContext), paymentMethod: PaymentInfo.paymentMethodForPayment(payment, useQuotecall), - // Null address for OFFCHAIN payment. - paymentToken: payment.isToken ? PaymentInfo.knownToken(payment.currency, swap.chainId).token : address(0), + paymentToken: payment.isToken + ? PaymentInfo.knownToken(payment.currency, swap.chainId).token + : PaymentInfo.NON_TOKEN_PAYMENT, paymentTokenSymbol: payment.currency, paymentMaxCost: payment.isToken ? PaymentInfo.findMaxCost(payment, swap.chainId) : 0 }); @@ -1133,6 +1328,11 @@ library Actions { return bs[0]; } + function emptyMorphoBorrowActionContext() external pure returns (MorphoBorrowActionContext memory) { + MorphoBorrowActionContext[] memory mb = new MorphoBorrowActionContext[](1); + return mb[0]; + } + function emptyBridgeActionContext() external pure returns (BridgeActionContext memory) { BridgeActionContext[] memory bs = new BridgeActionContext[](1); return bs[0]; @@ -1158,6 +1358,11 @@ library Actions { return rs[0]; } + function emptyMorphoRepayActionContext() external pure returns (MorphoRepayActionContext memory) { + MorphoRepayActionContext[] memory mr = new MorphoRepayActionContext[](1); + return mr[0]; + } + function emptySupplyActionContext() external pure returns (SupplyActionContext memory) { SupplyActionContext[] memory ss = new SupplyActionContext[](1); return ss[0]; diff --git a/src/builder/MorphoInfo.sol b/src/builder/MorphoInfo.sol new file mode 100644 index 00000000..9932e21c --- /dev/null +++ b/src/builder/MorphoInfo.sol @@ -0,0 +1,427 @@ +// SPDX-License-Identifier: BSD-3-Clause +pragma solidity ^0.8.23; + +import {MarketParams} from "src/interfaces/IMorpho.sol"; +import {HashMap} from "./HashMap.sol"; + +library MorphoInfo { + error UnsupportedChainId(); + error MorphoMarketNotFound(); + error MorphoVaultNotFound(); + + // Note: Current Morpho has same address across mainnet and base + function getMorphoAddress(uint256 chainId) internal pure returns (address) { + if (chainId == 1 || chainId == 8453 || chainId == 11155111 || chainId == 84532) { + return 0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb; + } else { + revert UnsupportedChainId(); + } + } + + // Morpho blue markets + // Note: This is a simple key, as one market per chain per borrow asset per collateral asset + struct MorphoMarketKey { + uint256 chainId; + string borrowAssetSymbol; + string collateralAssetSymbol; + } + + function getKnownMorphoMarketsParams() internal pure returns (HashMap.Map memory) { + HashMap.Map memory knownMarkets = HashMap.newMap(); + // === Mainnet morpho markets === + // WBTC collateral markets + addMarketParams( + knownMarkets, + MorphoMarketKey({chainId: 1, borrowAssetSymbol: "USDC", collateralAssetSymbol: "WBTC"}), + MarketParams({ + loanToken: 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48, + collateralToken: 0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599, + oracle: 0xDddd770BADd886dF3864029e4B377B5F6a2B6b83, + irm: 0x870aC11D48B15DB9a138Cf899d20F13F79Ba00BC, + lltv: 0.86e18 + }) + ); + addMarketParams( + knownMarkets, + MorphoMarketKey({chainId: 1, borrowAssetSymbol: "USDT", collateralAssetSymbol: "WBTC"}), + MarketParams({ + loanToken: 0xdAC17F958D2ee523a2206206994597C13D831ec7, + collateralToken: 0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599, + oracle: 0x008bF4B1cDA0cc9f0e882E0697f036667652E1ef, + irm: 0x870aC11D48B15DB9a138Cf899d20F13F79Ba00BC, + lltv: 0.86e18 + }) + ); + addMarketParams( + knownMarkets, + MorphoMarketKey({chainId: 1, borrowAssetSymbol: "WETH", collateralAssetSymbol: "WBTC"}), + MarketParams({ + loanToken: 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2, + collateralToken: 0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599, + oracle: 0xc29B3Bc033640baE31ca53F8a0Eb892AdF68e663, + irm: 0x870aC11D48B15DB9a138Cf899d20F13F79Ba00BC, + lltv: 0.915e18 + }) + ); + addMarketParams( + knownMarkets, + MorphoMarketKey({chainId: 1, borrowAssetSymbol: "PYUSD", collateralAssetSymbol: "WBTC"}), + MarketParams({ + loanToken: 0x6c3ea9036406852006290770BEdFcAbA0e23A0e8, + collateralToken: 0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599, + oracle: 0xc53c90d6E9A5B69E4ABf3d5Ae4c79225C7FeF3d2, + irm: 0x870aC11D48B15DB9a138Cf899d20F13F79Ba00BC, + lltv: 0.86e18 + }) + ); + addMarketParams( + knownMarkets, + MorphoMarketKey({chainId: 1, borrowAssetSymbol: "eUSD", collateralAssetSymbol: "WBTC"}), + MarketParams({ + loanToken: 0xA0d69E286B938e21CBf7E51D71F6A4c8918f482F, + collateralToken: 0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599, + oracle: 0x032F1C64899b2C89835E51aCeD9434b0aDEaA69d, + irm: 0x870aC11D48B15DB9a138Cf899d20F13F79Ba00BC, + lltv: 0.86e18 + }) + ); + addMarketParams( + knownMarkets, + MorphoMarketKey({chainId: 1, borrowAssetSymbol: "USDA", collateralAssetSymbol: "WBTC"}), + MarketParams({ + loanToken: 0x0000206329b97DB379d5E1Bf586BbDB969C63274, + collateralToken: 0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599, + oracle: 0x032F1C64899b2C89835E51aCeD9434b0aDEaA69d, + irm: 0x870aC11D48B15DB9a138Cf899d20F13F79Ba00BC, + lltv: 0.86e18 + }) + ); + // wstETH collateral markets + addMarketParams( + knownMarkets, + MorphoMarketKey({chainId: 1, borrowAssetSymbol: "WETH", collateralAssetSymbol: "wstETH"}), + MarketParams({ + loanToken: 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2, + collateralToken: 0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0, + oracle: 0xbD60A6770b27E084E8617335ddE769241B0e71D8, + irm: 0x870aC11D48B15DB9a138Cf899d20F13F79Ba00BC, + lltv: 0.945e18 + }) + ); + addMarketParams( + knownMarkets, + MorphoMarketKey({chainId: 1, borrowAssetSymbol: "USDC", collateralAssetSymbol: "wstETH"}), + MarketParams({ + loanToken: 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48, + collateralToken: 0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0, + oracle: 0x48F7E36EB6B826B2dF4B2E630B62Cd25e89E40e2, + irm: 0x870aC11D48B15DB9a138Cf899d20F13F79Ba00BC, + lltv: 0.86e18 + }) + ); + addMarketParams( + knownMarkets, + MorphoMarketKey({chainId: 1, borrowAssetSymbol: "USDC", collateralAssetSymbol: "wstETH"}), + MarketParams({ + loanToken: 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48, + collateralToken: 0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0, + oracle: 0x48F7E36EB6B826B2dF4B2E630B62Cd25e89E40e2, + irm: 0x870aC11D48B15DB9a138Cf899d20F13F79Ba00BC, + lltv: 0.86e18 + }) + ); + addMarketParams( + knownMarkets, + MorphoMarketKey({chainId: 1, borrowAssetSymbol: "USDT", collateralAssetSymbol: "wstETH"}), + MarketParams({ + loanToken: 0xdAC17F958D2ee523a2206206994597C13D831ec7, + collateralToken: 0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0, + oracle: 0x95DB30fAb9A3754e42423000DF27732CB2396992, + irm: 0x870aC11D48B15DB9a138Cf899d20F13F79Ba00BC, + lltv: 0.86e18 + }) + ); + addMarketParams( + knownMarkets, + MorphoMarketKey({chainId: 1, borrowAssetSymbol: "eUSD", collateralAssetSymbol: "wstETH"}), + MarketParams({ + loanToken: 0xA0d69E286B938e21CBf7E51D71F6A4c8918f482F, + collateralToken: 0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0, + oracle: 0xBC693693fDBB177Ad05ff38633110016BC043AC5, + irm: 0x870aC11D48B15DB9a138Cf899d20F13F79Ba00BC, + lltv: 0.86e18 + }) + ); + addMarketParams( + knownMarkets, + MorphoMarketKey({chainId: 1, borrowAssetSymbol: "PYUSD", collateralAssetSymbol: "wstETH"}), + MarketParams({ + loanToken: 0x6c3ea9036406852006290770BEdFcAbA0e23A0e8, + collateralToken: 0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0, + oracle: 0x27679a17b7419fB10Bd9D143f21407760fdA5C53, + irm: 0x870aC11D48B15DB9a138Cf899d20F13F79Ba00BC, + lltv: 0.86e18 + }) + ); + // weETH collateral markets + addMarketParams( + knownMarkets, + MorphoMarketKey({chainId: 1, borrowAssetSymbol: "WETH", collateralAssetSymbol: "weETH"}), + MarketParams({ + loanToken: 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2, + collateralToken: 0xCd5fE23C85820F7B72D0926FC9b05b43E359b7ee, + oracle: 0x3fa58b74e9a8eA8768eb33c8453e9C2Ed089A40a, + irm: 0x870aC11D48B15DB9a138Cf899d20F13F79Ba00BC, + lltv: 0.86e18 + }) + ); + // MKR collateral markets + addMarketParams( + knownMarkets, + MorphoMarketKey({chainId: 1, borrowAssetSymbol: "USDC", collateralAssetSymbol: "MKR"}), + MarketParams({ + loanToken: 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48, + collateralToken: 0x9f8F72aA9304c8B593d555F12eF6589cC3A579A2, + oracle: 0x6686788B4315A4F93d822c1Bf73910556FCe2d5a, + irm: 0x870aC11D48B15DB9a138Cf899d20F13F79Ba00BC, + lltv: 0.77e18 + }) + ); + // USDe collateral markets + addMarketParams( + knownMarkets, + MorphoMarketKey({chainId: 1, borrowAssetSymbol: "DAI", collateralAssetSymbol: "USDe"}), + MarketParams({ + loanToken: 0x6B175474E89094C44Da98b954EedeAC495271d0F, + collateralToken: 0x4c9EDD5852cd905f086C759E8383e09bff1E68B3, + oracle: 0xaE4750d0813B5E37A51f7629beedd72AF1f9cA35, + irm: 0x870aC11D48B15DB9a138Cf899d20F13F79Ba00BC, + lltv: 0.86e18 + }) + ); + // sUSDe collateral markets + addMarketParams( + knownMarkets, + MorphoMarketKey({chainId: 1, borrowAssetSymbol: "DAI", collateralAssetSymbol: "sUSDe"}), + MarketParams({ + loanToken: 0x6B175474E89094C44Da98b954EedeAC495271d0F, + collateralToken: 0x9D39A5DE30e57443BfF2A8307A4256c8797A3497, + oracle: 0x5D916980D5Ae1737a8330Bf24dF812b2911Aae25, + irm: 0x870aC11D48B15DB9a138Cf899d20F13F79Ba00BC, + lltv: 0.86e18 + }) + ); + // === Base morpho markets === + // WETH collateral markets + addMarketParams( + knownMarkets, + MorphoMarketKey({chainId: 8453, borrowAssetSymbol: "USDC", collateralAssetSymbol: "WETH"}), + MarketParams({ + loanToken: 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913, + collateralToken: 0x4200000000000000000000000000000000000006, + oracle: 0xFEa2D58cEfCb9fcb597723c6bAE66fFE4193aFE4, + irm: 0x46415998764C29aB2a25CbeA6254146D50D22687, + lltv: 0.86e18 + }) + ); + // wstETH collateral markets + addMarketParams( + knownMarkets, + MorphoMarketKey({chainId: 8453, borrowAssetSymbol: "WETH", collateralAssetSymbol: "wstETH"}), + MarketParams({ + loanToken: 0x4200000000000000000000000000000000000006, + collateralToken: 0xc1CBa3fCea344f92D9239c08C0568f6F2F0ee452, + oracle: 0x4A11590e5326138B514E08A9B52202D42077Ca65, + irm: 0x46415998764C29aB2a25CbeA6254146D50D22687, + lltv: 0.945e18 + }) + ); + // cbETH collateral markets + addMarketParams( + knownMarkets, + MorphoMarketKey({chainId: 8453, borrowAssetSymbol: "USDC", collateralAssetSymbol: "cbETH"}), + MarketParams({ + loanToken: 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913, + collateralToken: 0x2Ae3F1Ec7F1F5012CFEab0185bfc7aa3cf0DEc22, + oracle: 0xb40d93F44411D8C09aD17d7F88195eF9b05cCD96, + irm: 0x46415998764C29aB2a25CbeA6254146D50D22687, + lltv: 0.86e18 + }) + ); + addMarketParams( + knownMarkets, + MorphoMarketKey({chainId: 8453, borrowAssetSymbol: "WETH", collateralAssetSymbol: "cbETH"}), + MarketParams({ + loanToken: 0x4200000000000000000000000000000000000006, + collateralToken: 0x2Ae3F1Ec7F1F5012CFEab0185bfc7aa3cf0DEc22, + oracle: 0xB03855Ad5AFD6B8db8091DD5551CAC4ed621d9E6, + irm: 0x46415998764C29aB2a25CbeA6254146D50D22687, + lltv: 0.945e18 + }) + ); + addMarketParams( + knownMarkets, + MorphoMarketKey({chainId: 8453, borrowAssetSymbol: "eUSD", collateralAssetSymbol: "cbETH"}), + MarketParams({ + loanToken: 0xCfA3Ef56d303AE4fAabA0592388F19d7C3399FB4, + collateralToken: 0x2Ae3F1Ec7F1F5012CFEab0185bfc7aa3cf0DEc22, + oracle: 0xc3Fa71D77d80f671F366DAA6812C8bD6C7749cEc, + irm: 0x46415998764C29aB2a25CbeA6254146D50D22687, + lltv: 0.86e18 + }) + ); + // ezETH collateral markets + addMarketParams( + knownMarkets, + MorphoMarketKey({chainId: 8453, borrowAssetSymbol: "WETH", collateralAssetSymbol: "ezETH"}), + MarketParams({ + loanToken: 0x4200000000000000000000000000000000000006, + collateralToken: 0x2416092f143378750bb29b79eD961ab195CcEea5, + oracle: 0xcca88a97dE6700Bb5DAdf4082Cf35A55F383AF05, + irm: 0x46415998764C29aB2a25CbeA6254146D50D22687, + lltv: 0.915e18 + }) + ); + + // === Sepolia testnet morpho markets === + // None + + // === Base Sepolia testnet morpho markets === + addMarketParams( + knownMarkets, + MorphoMarketKey({chainId: 84532, borrowAssetSymbol: "USDC", collateralAssetSymbol: "WETH"}), + MarketParams({ + loanToken: 0x036CbD53842c5426634e7929541eC2318f3dCF7e, + collateralToken: 0x4200000000000000000000000000000000000006, + oracle: 0x1631366C38d49ba58793A5F219050923fbF24C81, + irm: 0x46415998764C29aB2a25CbeA6254146D50D22687, + lltv: 0.915e18 + }) + ); + + return knownMarkets; + } + + function getMarketParams(uint256 chainId, string memory collateralAssetSymbol, string memory borrowAssetSymbol) + internal + pure + returns (MarketParams memory) + { + HashMap.Map memory knownMarkets = getKnownMorphoMarketsParams(); + return getMarketParams( + knownMarkets, + MorphoMarketKey({ + chainId: chainId, + borrowAssetSymbol: borrowAssetSymbol, + collateralAssetSymbol: collateralAssetSymbol + }) + ); + } + + // Morpho vaults + // Note: Potentially can add other key (i.e. curator) for supporting multiple vaults with same assets + struct MorphoVaultKey { + uint256 chainId; + string supplyAssetSymbol; + } + + function getKnownMorphoVaultsAddresses() internal pure returns (HashMap.Map memory) { + HashMap.Map memory knownVaults = HashMap.newMap(); + // === Mainnet morpho vaults === + // USDC (Gauntlet USDC Core) + addMorphoVaultAddress( + knownVaults, + MorphoVaultKey({chainId: 1, supplyAssetSymbol: "USDC"}), + 0x8eB67A509616cd6A7c1B3c8C21D48FF57df3d458 + ); + // USDT (Gaunlet USDT Prime) + addMorphoVaultAddress( + knownVaults, + MorphoVaultKey({chainId: 1, supplyAssetSymbol: "USDT"}), + 0x8CB3649114051cA5119141a34C200D65dc0Faa73 + ); + // WETH (Gauntlet WETH Core) + addMorphoVaultAddress( + knownVaults, + MorphoVaultKey({chainId: 1, supplyAssetSymbol: "WETH"}), + 0x4881Ef0BF6d2365D3dd6499ccd7532bcdBCE0658 + ); + // WBTC (Guantlet WBTC Core) + addMorphoVaultAddress( + knownVaults, + MorphoVaultKey({chainId: 1, supplyAssetSymbol: "WBTC"}), + 0x443df5eEE3196e9b2Dd77CaBd3eA76C3dee8f9b2 + ); + + // === Base morpho vaults === + // USDC (Moonwell Flaghship USDC) + addMorphoVaultAddress( + knownVaults, + MorphoVaultKey({chainId: 8453, supplyAssetSymbol: "USDC"}), + 0xc1256Ae5FF1cf2719D4937adb3bbCCab2E00A2Ca + ); + // WETH (Moonwell Flaghship ETH) + addMorphoVaultAddress( + knownVaults, + MorphoVaultKey({chainId: 8453, supplyAssetSymbol: "WETH"}), + 0xa0E430870c4604CcfC7B38Ca7845B1FF653D0ff1 + ); + + // === Sepolia testnet morpho vaults === + // None + + // === Base Sepolia testnet morpho vaults === + // None + + return knownVaults; + } + + function getMorphoVaultAddress(uint256 chainId, string memory supplyAssetSymbol) internal pure returns (address) { + HashMap.Map memory knownVaults = getKnownMorphoVaultsAddresses(); + return + getMorphoVaultAddress(knownVaults, MorphoVaultKey({chainId: chainId, supplyAssetSymbol: supplyAssetSymbol})); + } + + // Helpers for map + function addMorphoVaultAddress(HashMap.Map memory knownVaults, MorphoVaultKey memory key, address addr) + internal + pure + { + HashMap.put(knownVaults, abi.encode(key), abi.encode(addr)); + } + + function getMorphoVaultAddress(HashMap.Map memory knownVaults, MorphoVaultKey memory key) + internal + pure + returns (address) + { + if (!HashMap.contains(knownVaults, abi.encode(key))) { + revert MorphoVaultNotFound(); + } + return abi.decode(HashMap.get(knownVaults, abi.encode(key)), (address)); + } + + function addMarketParams(HashMap.Map memory knownMarkets, MorphoMarketKey memory key, MarketParams memory params) + internal + pure + { + HashMap.put(knownMarkets, abi.encode(key), abi.encode(params)); + } + + function getMarketParams(HashMap.Map memory knownMarkets, MorphoMarketKey memory key) + internal + pure + returns (MarketParams memory) + { + if (!HashMap.contains(knownMarkets, abi.encode(key))) { + revert MorphoMarketNotFound(); + } + return abi.decode(HashMap.get(knownMarkets, abi.encode(key)), (MarketParams)); + } + + // Helper function to convert MarketParams to bytes32 Id + // Reference: https://github.com/morpho-org/morpho-blue/blob/731e3f7ed97cf15f8fe00b86e4be5365eb3802ac/src/libraries/MarketParamsLib.sol + function marketId(MarketParams memory params) public pure returns (bytes32) { + return keccak256(abi.encode(params)); + } +} diff --git a/src/builder/PaymentInfo.sol b/src/builder/PaymentInfo.sol index f12f80bd..93bf1f10 100644 --- a/src/builder/PaymentInfo.sol +++ b/src/builder/PaymentInfo.sol @@ -7,6 +7,7 @@ library PaymentInfo { string constant PAYMENT_METHOD_OFFCHAIN = "OFFCHAIN"; string constant PAYMENT_METHOD_PAYCALL = "PAY_CALL"; string constant PAYMENT_METHOD_QUOTECALL = "QUOTE_CALL"; + address constant NON_TOKEN_PAYMENT = address(0); error NoKnownPaymentToken(uint256 chainId); error MaxCostMissingForChain(uint256 chainId); diff --git a/src/builder/QuarkBuilder.sol b/src/builder/QuarkBuilder.sol index 4a0d8e6d..1cde7d6e 100644 --- a/src/builder/QuarkBuilder.sol +++ b/src/builder/QuarkBuilder.sol @@ -8,6 +8,7 @@ import {Accounts} from "./Accounts.sol"; import {BridgeRoutes} from "./BridgeRoutes.sol"; import {EIP712Helper} from "./EIP712Helper.sol"; import {Math} from "src/lib/Math.sol"; +import {MorphoInfo} from "./MorphoInfo.sol"; import {Strings} from "./Strings.sol"; import {PaycallWrapper} from "./PaycallWrapper.sol"; import {QuotecallWrapper} from "./QuotecallWrapper.sol"; @@ -30,6 +31,7 @@ contract QuarkBuilder { error InvalidInput(); error MaxCostTooHigh(); error MissingWrapperCounterpart(); + error InvalidRepayActionContext(); /* ===== Input Types ===== */ @@ -75,6 +77,19 @@ contract QuarkBuilder { return totalBorrowForAccount + buffer; } + function morphoRepayMaxAmount( + Accounts.ChainAccounts[] memory chainAccountsList, + uint256 chainId, + address loanToken, + address collateralToken, + address repayer + ) internal pure returns (uint256) { + uint256 totalBorrowForAccount = + Accounts.totalMorphoBorrowForAccount(chainAccountsList, chainId, loanToken, collateralToken, repayer); + uint256 buffer = totalBorrowForAccount / 1000; // 0.1% + return totalBorrowForAccount + buffer; + } + function cometRepay( CometRepayIntent memory repayIntent, Accounts.ChainAccounts[] memory chainAccountsList, @@ -1111,6 +1126,344 @@ contract QuarkBuilder { }); } + struct MorphoBorrowIntent { + uint256 amount; + string assetSymbol; + uint256 blockTimestamp; + address borrower; + uint256 chainId; + uint256 collateralAmount; + string collateralAssetSymbol; + } + + function morphoBorrow( + MorphoBorrowIntent memory borrowIntent, + Accounts.ChainAccounts[] memory chainAccountsList, + PaymentInfo.Payment memory payment + ) external pure returns (BuilderResult memory) { + List.DynamicArray memory actions = List.newList(); + List.DynamicArray memory quarkOperations = List.newList(); + + bool useQuotecall = false; // never use Quotecall + bool paymentTokenIsCollateralAsset = + Strings.stringEqIgnoreCase(borrowIntent.collateralAssetSymbol, payment.currency); + + assertFundsAvailable( + borrowIntent.chainId, + borrowIntent.collateralAssetSymbol, + borrowIntent.collateralAmount, + chainAccountsList, + payment + ); + + if ( + needsBridgedFunds( + borrowIntent.collateralAssetSymbol, + borrowIntent.collateralAmount, + borrowIntent.chainId, + chainAccountsList, + payment + ) + ) { + uint256 amountNeededOnDst = borrowIntent.collateralAmount; + if (payment.isToken && Strings.stringEqIgnoreCase(payment.currency, borrowIntent.collateralAssetSymbol)) { + amountNeededOnDst += PaymentInfo.findMaxCost(payment, borrowIntent.chainId); + } + (IQuarkWallet.QuarkOperation[] memory bridgeQuarkOperations, Actions.Action[] memory bridgeActions) = + Actions.constructBridgeOperations( + Actions.BridgeOperationInfo({ + assetSymbol: borrowIntent.collateralAssetSymbol, + amountNeededOnDst: amountNeededOnDst, + dstChainId: borrowIntent.chainId, + recipient: borrowIntent.borrower, + blockTimestamp: borrowIntent.blockTimestamp, + useQuotecall: useQuotecall + }), + chainAccountsList, + payment + ); + + for (uint256 j = 0; j < bridgeQuarkOperations.length; ++j) { + List.addQuarkOperation(quarkOperations, bridgeQuarkOperations[j]); + List.addAction(actions, bridgeActions[j]); + } + } + + // when paying with tokens, you may need to bridge the payment token to cover the cost + if (payment.isToken && !paymentTokenIsCollateralAsset) { + uint256 maxCostOnDstChain = PaymentInfo.findMaxCost(payment, borrowIntent.chainId); + // but if you're borrowing the payment token, you can use the + // borrowed amount to cover the cost + + if (Strings.stringEqIgnoreCase(payment.currency, borrowIntent.assetSymbol)) { + maxCostOnDstChain = Math.subtractFlooredAtZero(maxCostOnDstChain, borrowIntent.amount); + } + + if ( + needsBridgedFunds(payment.currency, maxCostOnDstChain, borrowIntent.chainId, chainAccountsList, payment) + ) { + (IQuarkWallet.QuarkOperation[] memory bridgeQuarkOperations, Actions.Action[] memory bridgeActions) = + Actions.constructBridgeOperations( + Actions.BridgeOperationInfo({ + assetSymbol: payment.currency, + amountNeededOnDst: maxCostOnDstChain, + dstChainId: borrowIntent.chainId, + recipient: borrowIntent.borrower, + blockTimestamp: borrowIntent.blockTimestamp, + useQuotecall: useQuotecall + }), + chainAccountsList, + payment + ); + + for (uint256 i = 0; i < bridgeQuarkOperations.length; ++i) { + List.addQuarkOperation(quarkOperations, bridgeQuarkOperations[i]); + List.addAction(actions, bridgeActions[i]); + } + } + } + + // Auto-wrap on collateral supply + checkAndInsertWrapOrUnwrapAction( + actions, + quarkOperations, + chainAccountsList, + payment, + borrowIntent.collateralAssetSymbol, + borrowIntent.collateralAmount, + borrowIntent.chainId, + borrowIntent.borrower, + borrowIntent.blockTimestamp, + useQuotecall + ); + + (IQuarkWallet.QuarkOperation memory borrowQuarkOperation, Actions.Action memory borrowAction) = Actions + .morphoBorrow( + Actions.MorphoBorrow({ + chainAccountsList: chainAccountsList, + assetSymbol: borrowIntent.assetSymbol, + amount: borrowIntent.amount, + chainId: borrowIntent.chainId, + borrower: borrowIntent.borrower, + blockTimestamp: borrowIntent.blockTimestamp, + collateralAmount: borrowIntent.collateralAmount, + collateralAssetSymbol: borrowIntent.collateralAssetSymbol + }), + payment + ); + + List.addQuarkOperation(quarkOperations, borrowQuarkOperation); + List.addAction(actions, borrowAction); + + // Convert actions and quark operations to arrays + Actions.Action[] memory actionsArray = List.toActionArray(actions); + IQuarkWallet.QuarkOperation[] memory quarkOperationsArray = List.toQuarkOperationArray(quarkOperations); + + // Validate generated actions for affordability + if (payment.isToken) { + uint256 supplementalPaymentTokenBalance = 0; + if (Strings.stringEqIgnoreCase(payment.currency, borrowIntent.assetSymbol)) { + supplementalPaymentTokenBalance += borrowIntent.amount; + } + + assertSufficientPaymentTokenBalances( + actionsArray, + chainAccountsList, + borrowIntent.chainId, + borrowIntent.borrower, + supplementalPaymentTokenBalance + ); + } + + // Merge operations that are from the same chain into one Multicall operation + (quarkOperationsArray, actionsArray) = + QuarkOperationHelper.mergeSameChainOperations(quarkOperationsArray, actionsArray); + + // Wrap operations around Paycall/Quotecall if payment is with token + if (payment.isToken) { + quarkOperationsArray = QuarkOperationHelper.wrapOperationsWithTokenPayment( + quarkOperationsArray, actionsArray, payment, useQuotecall + ); + } + + return BuilderResult({ + version: VERSION, + actions: actionsArray, + quarkOperations: quarkOperationsArray, + paymentCurrency: payment.currency, + eip712Data: EIP712Helper.eip712DataForQuarkOperations(quarkOperationsArray, actionsArray) + }); + } + + struct MorphoRepayIntent { + uint256 amount; + string assetSymbol; + uint256 blockTimestamp; + address repayer; + uint256 chainId; + uint256 collateralAmount; + string collateralAssetSymbol; + } + + function morphoRepay( + MorphoRepayIntent memory repayIntent, + Accounts.ChainAccounts[] memory chainAccountsList, + PaymentInfo.Payment memory payment + ) external pure returns (BuilderResult memory) { + bool isMaxRepay = repayIntent.amount == type(uint256).max; + bool useQuotecall = false; // never use Quotecall + bool paymentTokenIsRepaymentlAsset = Strings.stringEqIgnoreCase(repayIntent.assetSymbol, payment.currency); + // Only use repayAmount for purpose of bridging, will still use uint256 max for MorphoScript + uint256 repayAmount = repayIntent.amount; + if (isMaxRepay) { + repayAmount = morphoRepayMaxAmount( + chainAccountsList, + repayIntent.chainId, + Accounts.findAssetPositions(repayIntent.assetSymbol, repayIntent.chainId, chainAccountsList).asset, + Accounts.findAssetPositions(repayIntent.collateralAssetSymbol, repayIntent.chainId, chainAccountsList) + .asset, + repayIntent.repayer + ); + } + + assertFundsAvailable(repayIntent.chainId, repayIntent.assetSymbol, repayAmount, chainAccountsList, payment); + + List.DynamicArray memory actions = List.newList(); + List.DynamicArray memory quarkOperations = List.newList(); + + if (needsBridgedFunds(repayIntent.assetSymbol, repayAmount, repayIntent.chainId, chainAccountsList, payment)) { + // Note: Assumes that the asset uses the same # of decimals on each chain + uint256 amountNeededOnDst = repayAmount; + // If action is paid for with tokens and the payment token is the + // repay token, we need to add the max cost to the + // amountNeededOnDst for target chain + if (payment.isToken && Strings.stringEqIgnoreCase(payment.currency, repayIntent.assetSymbol)) { + amountNeededOnDst += PaymentInfo.findMaxCost(payment, repayIntent.chainId); + } + (IQuarkWallet.QuarkOperation[] memory bridgeQuarkOperations, Actions.Action[] memory bridgeActions) = + Actions.constructBridgeOperations( + Actions.BridgeOperationInfo({ + assetSymbol: repayIntent.assetSymbol, + amountNeededOnDst: amountNeededOnDst, + dstChainId: repayIntent.chainId, + recipient: repayIntent.repayer, + blockTimestamp: repayIntent.blockTimestamp, + useQuotecall: useQuotecall + }), + chainAccountsList, + payment + ); + + for (uint256 i = 0; i < bridgeQuarkOperations.length; ++i) { + List.addAction(actions, bridgeActions[i]); + List.addQuarkOperation(quarkOperations, bridgeQuarkOperations[i]); + } + } + + // Only bridge payment token if it is not the repayment asset (which the above briding action didn't cover) + if (payment.isToken && !paymentTokenIsRepaymentlAsset) { + uint256 maxCostOnDstChain = PaymentInfo.findMaxCost(payment, repayIntent.chainId); + if (Strings.stringEqIgnoreCase(payment.currency, repayIntent.assetSymbol)) { + maxCostOnDstChain = Math.subtractFlooredAtZero(maxCostOnDstChain, repayIntent.amount); + } + + if (needsBridgedFunds(payment.currency, maxCostOnDstChain, repayIntent.chainId, chainAccountsList, payment)) + { + (IQuarkWallet.QuarkOperation[] memory bridgeQuarkOperations, Actions.Action[] memory bridgeActions) = + Actions.constructBridgeOperations( + Actions.BridgeOperationInfo({ + assetSymbol: payment.currency, + amountNeededOnDst: maxCostOnDstChain, + dstChainId: repayIntent.chainId, + recipient: repayIntent.repayer, + blockTimestamp: repayIntent.blockTimestamp, + useQuotecall: useQuotecall + }), + chainAccountsList, + payment + ); + + for (uint256 i = 0; i < bridgeQuarkOperations.length; ++i) { + List.addQuarkOperation(quarkOperations, bridgeQuarkOperations[i]); + List.addAction(actions, bridgeActions[i]); + } + } + } + + // Auto-wrap + checkAndInsertWrapOrUnwrapAction( + actions, + quarkOperations, + chainAccountsList, + payment, + repayIntent.assetSymbol, + repayAmount, + repayIntent.chainId, + repayIntent.repayer, + repayIntent.blockTimestamp, + useQuotecall + ); + + (IQuarkWallet.QuarkOperation memory repayQuarkOperations, Actions.Action memory repayActions) = Actions + .morphoRepay( + Actions.MorphoRepay({ + chainAccountsList: chainAccountsList, + assetSymbol: repayIntent.assetSymbol, + amount: repayIntent.amount, + chainId: repayIntent.chainId, + repayer: repayIntent.repayer, + blockTimestamp: repayIntent.blockTimestamp, + collateralAmount: repayIntent.collateralAmount, + collateralAssetSymbol: repayIntent.collateralAssetSymbol + }), + payment + ); + + List.addAction(actions, repayActions); + List.addQuarkOperation(quarkOperations, repayQuarkOperations); + + // Convert actions and quark operations to arrays + Actions.Action[] memory actionsArray = List.toActionArray(actions); + IQuarkWallet.QuarkOperation[] memory quarkOperationsArray = List.toQuarkOperationArray(quarkOperations); + + // Validate generated actions for affordability + if (payment.isToken) { + // if you are withdrawing the payment token, you can pay with the + // withdrawn funds + uint256 supplementalPaymentTokenBalance = 0; + if (Strings.stringEqIgnoreCase(payment.currency, repayIntent.collateralAssetSymbol)) { + supplementalPaymentTokenBalance += repayIntent.collateralAmount; + } + + assertSufficientPaymentTokenBalances( + actionsArray, + chainAccountsList, + repayIntent.chainId, + repayIntent.repayer, + supplementalPaymentTokenBalance + ); + } + + // Merge operations that are from the same chain into one Multicall operation + (quarkOperationsArray, actionsArray) = + QuarkOperationHelper.mergeSameChainOperations(quarkOperationsArray, actionsArray); + + // Wrap operations around Paycall/Quotecall if payment is with token + if (payment.isToken) { + quarkOperationsArray = QuarkOperationHelper.wrapOperationsWithTokenPayment( + quarkOperationsArray, actionsArray, payment, useQuotecall + ); + } + + return BuilderResult({ + version: VERSION, + actions: actionsArray, + quarkOperations: quarkOperationsArray, + paymentCurrency: payment.currency, + eip712Data: EIP712Helper.eip712DataForQuarkOperations(quarkOperationsArray, actionsArray) + }); + } + // For some reason, funds that may otherwise be bridgeable or held by the user cannot // be made available to fulfill the transaction. // Funds cannot be bridged, e.g. no bridge exists @@ -1359,15 +1712,30 @@ contract QuarkBuilder { if (Strings.stringEqIgnoreCase(nonBridgeAction.actionType, Actions.ACTION_TYPE_BORROW)) { continue; + } else if (Strings.stringEqIgnoreCase(nonBridgeAction.actionType, Actions.ACTION_TYPE_MORPHO_BORROW)) { + continue; + } else if (Strings.stringEqIgnoreCase(nonBridgeAction.actionType, Actions.ACTION_TYPE_MORPHO_REPAY)) { + Actions.MorphoRepayActionContext memory morphoRepayActionContext = + abi.decode(nonBridgeAction.actionContext, (Actions.MorphoRepayActionContext)); + if (morphoRepayActionContext.amount == type(uint256).max) { + paymentTokenCost += morphoRepayMaxAmount( + chainAccountsList, + morphoRepayActionContext.chainId, + morphoRepayActionContext.token, + morphoRepayActionContext.collateralToken, + account + ); + } else { + paymentTokenCost += morphoRepayActionContext.amount; + } } else if (Strings.stringEqIgnoreCase(nonBridgeAction.actionType, Actions.ACTION_TYPE_REPAY)) { Actions.RepayActionContext memory cometRepayActionContext = abi.decode(nonBridgeAction.actionContext, (Actions.RepayActionContext)); if (Strings.stringEqIgnoreCase(cometRepayActionContext.assetSymbol, paymentTokenSymbol)) { if (cometRepayActionContext.amount == type(uint256).max) { - uint256 repayAmount = cometRepayMaxAmount( + paymentTokenCost += cometRepayMaxAmount( chainAccountsList, cometRepayActionContext.chainId, cometRepayActionContext.comet, account ); - paymentTokenCost += repayAmount; } else { paymentTokenCost += cometRepayActionContext.amount; } diff --git a/src/vendor/manifest.json b/src/vendor/manifest.json index 8f59c732..9140a980 100644 --- a/src/vendor/manifest.json +++ b/src/vendor/manifest.json @@ -131,7 +131,7 @@ ] } ] - }, + }, "uniswap-swap-router-contracts/IApproveAndCall.sol": { "source": { "git": { @@ -205,9 +205,9 @@ "commit": "0448402af51b8293ed36653de43cbee8d4d2bfda", "path": "src/libraries/MathLib.sol" } - }, + }, "patches": [] - }, + }, "morpho_blue_periphery/SharesMathLib.sol":{ "source": { "git": { diff --git a/test/builder/QuarkBuilderCometBorrow.t.sol b/test/builder/QuarkBuilderCometBorrow.t.sol index 981e2172..476c5cd4 100644 --- a/test/builder/QuarkBuilderCometBorrow.t.sol +++ b/test/builder/QuarkBuilderCometBorrow.t.sol @@ -87,7 +87,8 @@ contract QuarkBuilderCometBorrowTest is Test, QuarkBuilderTest { nextNonce: 12, assetSymbols: Arrays.stringArray("USDC", "USDT", "LINK", "WETH"), assetBalances: Arrays.uintArray(0, 0, 10e18, 0), // user has 10 LINK - cometPortfolios: emptyCometPortfolios_() + cometPortfolios: emptyCometPortfolios_(), + morphoPortfolios: emptyMorphoPortfolios_() }); chainPortfolios[1] = ChainPortfolio({ chainId: 8453, @@ -95,7 +96,8 @@ contract QuarkBuilderCometBorrowTest is Test, QuarkBuilderTest { nextNonce: 2, assetSymbols: Arrays.stringArray("USDC", "USDT", "LINK", "WETH"), assetBalances: Arrays.uintArray(0, 0, 0, 0), - cometPortfolios: emptyCometPortfolios_() + cometPortfolios: emptyCometPortfolios_(), + morphoPortfolios: emptyMorphoPortfolios_() }); QuarkBuilder builder = new QuarkBuilder(); @@ -208,7 +210,8 @@ contract QuarkBuilderCometBorrowTest is Test, QuarkBuilderTest { nextNonce: 12, assetSymbols: Arrays.stringArray("USDC", "ETH", "LINK", "WETH"), assetBalances: Arrays.uintArray(0, 10e18, 0, 0), // user has 10 ETH - cometPortfolios: emptyCometPortfolios_() + cometPortfolios: emptyCometPortfolios_(), + morphoPortfolios: emptyMorphoPortfolios_() }); chainPortfolios[1] = ChainPortfolio({ chainId: 8453, @@ -216,7 +219,8 @@ contract QuarkBuilderCometBorrowTest is Test, QuarkBuilderTest { nextNonce: 2, assetSymbols: Arrays.stringArray("USDC", "ETH", "LINK", "WETH"), assetBalances: Arrays.uintArray(0, 0, 0, 0), - cometPortfolios: emptyCometPortfolios_() + cometPortfolios: emptyCometPortfolios_(), + morphoPortfolios: emptyMorphoPortfolios_() }); QuarkBuilder builder = new QuarkBuilder(); @@ -315,7 +319,8 @@ contract QuarkBuilderCometBorrowTest is Test, QuarkBuilderTest { nextNonce: 12, assetSymbols: Arrays.stringArray("USDC", "USDT", "LINK", "WETH"), assetBalances: Arrays.uintArray(1e6, 0, 10e18, 0), // user has 1 USDC, 10 LINK - cometPortfolios: emptyCometPortfolios_() + cometPortfolios: emptyCometPortfolios_(), + morphoPortfolios: emptyMorphoPortfolios_() }); chainPortfolios[1] = ChainPortfolio({ chainId: 8453, @@ -323,7 +328,8 @@ contract QuarkBuilderCometBorrowTest is Test, QuarkBuilderTest { nextNonce: 2, assetSymbols: Arrays.stringArray("USDC", "USDT", "LINK", "WETH"), assetBalances: Arrays.uintArray(0, 0, 0, 0), - cometPortfolios: emptyCometPortfolios_() + cometPortfolios: emptyCometPortfolios_(), + morphoPortfolios: emptyMorphoPortfolios_() }); PaymentInfo.PaymentMaxCost[] memory maxCosts = new PaymentInfo.PaymentMaxCost[](1); @@ -441,7 +447,8 @@ contract QuarkBuilderCometBorrowTest is Test, QuarkBuilderTest { nextNonce: 12, assetSymbols: Arrays.stringArray("USDC", "USDT", "LINK", "WETH"), assetBalances: Arrays.uintArray(0, 0, 10e18, 0), // user has 10 LINK and 0 USDC - cometPortfolios: emptyCometPortfolios_() + cometPortfolios: emptyCometPortfolios_(), + morphoPortfolios: emptyMorphoPortfolios_() }); chainPortfolios[1] = ChainPortfolio({ chainId: 8453, @@ -449,7 +456,8 @@ contract QuarkBuilderCometBorrowTest is Test, QuarkBuilderTest { nextNonce: 2, assetSymbols: Arrays.stringArray("USDC", "USDT", "LINK", "WETH"), assetBalances: Arrays.uintArray(0, 0, 0, 0), - cometPortfolios: emptyCometPortfolios_() + cometPortfolios: emptyCometPortfolios_(), + morphoPortfolios: emptyMorphoPortfolios_() }); QuarkBuilder.BuilderResult memory result = builder.cometBorrow( @@ -564,7 +572,8 @@ contract QuarkBuilderCometBorrowTest is Test, QuarkBuilderTest { nextNonce: 12, assetSymbols: Arrays.stringArray("USDC", "USDT", "LINK", "WETH"), assetBalances: Arrays.uintArray(3e6, 0, 0, 0), // 3 USDC on mainnet - cometPortfolios: emptyCometPortfolios_() + cometPortfolios: emptyCometPortfolios_(), + morphoPortfolios: emptyMorphoPortfolios_() }); chainPortfolios[1] = ChainPortfolio({ chainId: 8453, @@ -572,7 +581,8 @@ contract QuarkBuilderCometBorrowTest is Test, QuarkBuilderTest { nextNonce: 2, assetSymbols: Arrays.stringArray("USDC", "USDT", "LINK", "WETH"), assetBalances: Arrays.uintArray(0, 0, 5e18, 0), - cometPortfolios: emptyCometPortfolios_() + cometPortfolios: emptyCometPortfolios_(), + morphoPortfolios: emptyMorphoPortfolios_() }); QuarkBuilder.BuilderResult memory result = builder.cometBorrow( @@ -748,7 +758,8 @@ contract QuarkBuilderCometBorrowTest is Test, QuarkBuilderTest { nextNonce: 12, assetSymbols: Arrays.stringArray("USDC", "USDT", "LINK", "WETH"), assetBalances: Arrays.uintArray(4e6, 0, 0, 0), // 4 USDC on mainnet - cometPortfolios: emptyCometPortfolios_() + cometPortfolios: emptyCometPortfolios_(), + morphoPortfolios: emptyMorphoPortfolios_() }); chainPortfolios[1] = ChainPortfolio({ chainId: 8453, @@ -756,7 +767,8 @@ contract QuarkBuilderCometBorrowTest is Test, QuarkBuilderTest { nextNonce: 2, assetSymbols: Arrays.stringArray("USDC", "USDT", "LINK", "WETH"), assetBalances: Arrays.uintArray(0, 0, 0, 0), // no assets on base - cometPortfolios: emptyCometPortfolios_() + cometPortfolios: emptyCometPortfolios_(), + morphoPortfolios: emptyMorphoPortfolios_() }); QuarkBuilder.BuilderResult memory result = builder.cometBorrow( diff --git a/test/builder/QuarkBuilderCometRepay.t.sol b/test/builder/QuarkBuilderCometRepay.t.sol index 47740d48..873df7cf 100644 --- a/test/builder/QuarkBuilderCometRepay.t.sol +++ b/test/builder/QuarkBuilderCometRepay.t.sol @@ -86,7 +86,8 @@ contract QuarkBuilderCometRepayTest is Test, QuarkBuilderTest { nextNonce: 12, assetSymbols: Arrays.stringArray("USDC", "USDT", "LINK", "WETH"), assetBalances: Arrays.uintArray(0.4e6, 0, 0, 1e18), // user does not have enough USDC - cometPortfolios: emptyCometPortfolios_() + cometPortfolios: emptyCometPortfolios_(), + morphoPortfolios: emptyMorphoPortfolios_() }); vm.expectRevert(QuarkBuilder.MaxCostTooHigh.selector); @@ -112,7 +113,8 @@ contract QuarkBuilderCometRepayTest is Test, QuarkBuilderTest { nextNonce: 12, assetSymbols: Arrays.stringArray("USDC", "USDT", "LINK", "WETH"), assetBalances: Arrays.uintArray(1e6, 0, 0, 0), // has 1 USDC - cometPortfolios: emptyCometPortfolios_() + cometPortfolios: emptyCometPortfolios_(), + morphoPortfolios: emptyMorphoPortfolios_() }); chainPortfolios[1] = ChainPortfolio({ chainId: 8453, @@ -120,7 +122,8 @@ contract QuarkBuilderCometRepayTest is Test, QuarkBuilderTest { nextNonce: 2, assetSymbols: Arrays.stringArray("USDC", "USDT", "LINK", "WETH"), assetBalances: Arrays.uintArray(0, 0, 0, 0), - cometPortfolios: emptyCometPortfolios_() + cometPortfolios: emptyCometPortfolios_(), + morphoPortfolios: emptyMorphoPortfolios_() }); QuarkBuilder builder = new QuarkBuilder(); @@ -234,7 +237,8 @@ contract QuarkBuilderCometRepayTest is Test, QuarkBuilderTest { nextNonce: 12, assetSymbols: Arrays.stringArray("USDC", "ETH", "LINK", "WETH"), assetBalances: Arrays.uintArray(0, 1e18, 0, 0), // has 1 ETH - cometPortfolios: emptyCometPortfolios_() + cometPortfolios: emptyCometPortfolios_(), + morphoPortfolios: emptyMorphoPortfolios_() }); chainPortfolios[1] = ChainPortfolio({ chainId: 8453, @@ -242,7 +246,8 @@ contract QuarkBuilderCometRepayTest is Test, QuarkBuilderTest { nextNonce: 2, assetSymbols: Arrays.stringArray("USDC", "ETH", "LINK", "WETH"), assetBalances: Arrays.uintArray(0, 0, 0, 0), - cometPortfolios: emptyCometPortfolios_() + cometPortfolios: emptyCometPortfolios_(), + morphoPortfolios: emptyMorphoPortfolios_() }); QuarkBuilder builder = new QuarkBuilder(); @@ -343,7 +348,8 @@ contract QuarkBuilderCometRepayTest is Test, QuarkBuilderTest { nextNonce: 12, assetSymbols: Arrays.stringArray("USDC", "USDT", "LINK", "WETH"), assetBalances: Arrays.uintArray(2e6, 0, 0, 0), - cometPortfolios: emptyCometPortfolios_() + cometPortfolios: emptyCometPortfolios_(), + morphoPortfolios: emptyMorphoPortfolios_() }); chainPortfolios[1] = ChainPortfolio({ chainId: 8453, @@ -351,7 +357,8 @@ contract QuarkBuilderCometRepayTest is Test, QuarkBuilderTest { nextNonce: 2, assetSymbols: Arrays.stringArray("USDC", "USDT", "LINK", "WETH"), assetBalances: Arrays.uintArray(0, 0, 0, 0), - cometPortfolios: emptyCometPortfolios_() + cometPortfolios: emptyCometPortfolios_(), + morphoPortfolios: emptyMorphoPortfolios_() }); PaymentInfo.PaymentMaxCost[] memory maxCosts = new PaymentInfo.PaymentMaxCost[](1); @@ -471,7 +478,8 @@ contract QuarkBuilderCometRepayTest is Test, QuarkBuilderTest { nextNonce: 12, assetSymbols: Arrays.stringArray("USDC", "USDT", "LINK", "WETH"), assetBalances: Arrays.uintArray(0, 0, 0, 1e18), - cometPortfolios: emptyCometPortfolios_() + cometPortfolios: emptyCometPortfolios_(), + morphoPortfolios: emptyMorphoPortfolios_() }); chainPortfolios[1] = ChainPortfolio({ chainId: 8453, @@ -479,7 +487,8 @@ contract QuarkBuilderCometRepayTest is Test, QuarkBuilderTest { nextNonce: 2, assetSymbols: Arrays.stringArray("USDC", "USDT", "LINK", "WETH"), assetBalances: Arrays.uintArray(0, 0, 0, 0), - cometPortfolios: emptyCometPortfolios_() + cometPortfolios: emptyCometPortfolios_(), + morphoPortfolios: emptyMorphoPortfolios_() }); QuarkBuilder.BuilderResult memory result = builder.cometRepay( @@ -596,7 +605,8 @@ contract QuarkBuilderCometRepayTest is Test, QuarkBuilderTest { nextNonce: 12, assetSymbols: Arrays.stringArray("USDC", "USDT", "LINK", "WETH"), assetBalances: Arrays.uintArray(4e6, 0, 0, 0), // 4 USDC on mainnet - cometPortfolios: emptyCometPortfolios_() + cometPortfolios: emptyCometPortfolios_(), + morphoPortfolios: emptyMorphoPortfolios_() }); chainPortfolios[1] = ChainPortfolio({ chainId: 8453, @@ -604,7 +614,8 @@ contract QuarkBuilderCometRepayTest is Test, QuarkBuilderTest { nextNonce: 2, assetSymbols: Arrays.stringArray("USDC", "USDT", "LINK", "WETH"), assetBalances: Arrays.uintArray(0, 0, 0, 0), // no assets on base - cometPortfolios: emptyCometPortfolios_() + cometPortfolios: emptyCometPortfolios_(), + morphoPortfolios: emptyMorphoPortfolios_() }); QuarkBuilder.BuilderResult memory result = builder.cometRepay( @@ -785,7 +796,8 @@ contract QuarkBuilderCometRepayTest is Test, QuarkBuilderTest { nextNonce: 12, assetSymbols: Arrays.stringArray("USDC", "USDT", "LINK", "WETH"), assetBalances: Arrays.uintArray(50e6, 0, 0, 0), // has 50 USDC - cometPortfolios: cometPortfolios + cometPortfolios: cometPortfolios, + morphoPortfolios: emptyMorphoPortfolios_() }); QuarkBuilder builder = new QuarkBuilder(); @@ -907,7 +919,8 @@ contract QuarkBuilderCometRepayTest is Test, QuarkBuilderTest { nextNonce: 12, assetSymbols: Arrays.stringArray("USDC", "USDT", "LINK", "WETH"), assetBalances: Arrays.uintArray(50e6, 0, 0, 0), // has 50 USDC on mainnet - cometPortfolios: emptyCometPortfolios_() + cometPortfolios: emptyCometPortfolios_(), + morphoPortfolios: emptyMorphoPortfolios_() }); chainPortfolios[1] = ChainPortfolio({ chainId: 8453, @@ -915,7 +928,8 @@ contract QuarkBuilderCometRepayTest is Test, QuarkBuilderTest { nextNonce: 12, assetSymbols: Arrays.stringArray("USDC", "USDT", "LINK", "WETH"), assetBalances: Arrays.uintArray(0, 0, 0, 0), // has 0 USDC on base - cometPortfolios: cometPortfolios + cometPortfolios: cometPortfolios, + morphoPortfolios: emptyMorphoPortfolios_() }); QuarkBuilder builder = new QuarkBuilder(); diff --git a/test/builder/QuarkBuilderCometSupply.t.sol b/test/builder/QuarkBuilderCometSupply.t.sol index 51fc298a..872ddb5d 100644 --- a/test/builder/QuarkBuilderCometSupply.t.sol +++ b/test/builder/QuarkBuilderCometSupply.t.sol @@ -158,7 +158,8 @@ contract QuarkBuilderCometSupplyTest is Test, QuarkBuilderTest { chainId: 1, quarkStates: quarkStates_(address(0xa11ce), 12), assetPositionsList: assetPositionsList_(1, address(0xa11ce), uint256(3e6)), - cometPositions: emptyCometPositions_() + cometPositions: emptyCometPositions_(), + morphoPositions: emptyMorphoPositions_() }); QuarkBuilder.BuilderResult memory result = builder.cometSupply( @@ -260,7 +261,8 @@ contract QuarkBuilderCometSupplyTest is Test, QuarkBuilderTest { chainId: 1, quarkStates: quarkStates_(address(0xa11ce), 12), assetPositionsList: assetPositionsList, - cometPositions: emptyCometPositions_() + cometPositions: emptyCometPositions_(), + morphoPositions: emptyMorphoPositions_() }); QuarkBuilder.BuilderResult memory result = diff --git a/test/builder/QuarkBuilderCometWithdraw.t.sol b/test/builder/QuarkBuilderCometWithdraw.t.sol index d56dbd3d..716a5f0c 100644 --- a/test/builder/QuarkBuilderCometWithdraw.t.sol +++ b/test/builder/QuarkBuilderCometWithdraw.t.sol @@ -255,13 +255,15 @@ contract QuarkBuilderCometWithdrawTest is Test, QuarkBuilderTest { chainId: 1, quarkStates: quarkStates_(address(0xa11ce), 12), assetPositionsList: assetPositionsList_(1, address(0xa11ce), 3e6), // 3 USDC on mainnet - cometPositions: emptyCometPositions_() + cometPositions: emptyCometPositions_(), + morphoPositions: emptyMorphoPositions_() }); chainAccountsList[1] = Accounts.ChainAccounts({ chainId: 8453, quarkStates: quarkStates_(address(0xb0b), 2), assetPositionsList: assetPositionsList_(8453, address(0xb0b), 0), // 0 USDC on base - cometPositions: emptyCometPositions_() + cometPositions: emptyCometPositions_(), + morphoPositions: emptyMorphoPositions_() }); QuarkBuilder.BuilderResult memory result = builder.cometWithdraw( @@ -396,7 +398,8 @@ contract QuarkBuilderCometWithdrawTest is Test, QuarkBuilderTest { nextNonce: 12, assetSymbols: Arrays.stringArray("USDC", "USDT", "LINK", "WETH"), assetBalances: Arrays.uintArray(0, 0, 0, 0), - cometPortfolios: cometPortfolios + cometPortfolios: cometPortfolios, + morphoPortfolios: emptyMorphoPortfolios_() }); QuarkBuilder builder = new QuarkBuilder(); @@ -483,7 +486,8 @@ contract QuarkBuilderCometWithdrawTest is Test, QuarkBuilderTest { nextNonce: 12, assetSymbols: Arrays.stringArray("USDC", "USDT", "LINK", "WETH"), assetBalances: Arrays.uintArray(0, 0, 0, 0), - cometPortfolios: cometPortfolios + cometPortfolios: cometPortfolios, + morphoPortfolios: emptyMorphoPortfolios_() }); QuarkBuilder builder = new QuarkBuilder(); diff --git a/test/builder/QuarkBuilderMorphoBorrow.t.sol b/test/builder/QuarkBuilderMorphoBorrow.t.sol new file mode 100644 index 00000000..d07c60eb --- /dev/null +++ b/test/builder/QuarkBuilderMorphoBorrow.t.sol @@ -0,0 +1,644 @@ +// SPDX-License-Identifier: BSD-3-Clause +pragma solidity ^0.8.23; + +import "forge-std/Test.sol"; +import "forge-std/console.sol"; + +import {Arrays} from "test/builder/lib/Arrays.sol"; +import {QuarkBuilderTest, Accounts, PaymentInfo, QuarkBuilder} from "test/builder/lib/QuarkBuilderTest.sol"; + +import {Actions} from "src/builder/Actions.sol"; +import {CCTPBridgeActions} from "src/BridgeScripts.sol"; +import {CodeJarHelper} from "src/builder/CodeJarHelper.sol"; +import {MorphoActions} from "src/MorphoScripts.sol"; +import {Paycall} from "src/Paycall.sol"; +import {Strings} from "src/builder/Strings.sol"; +import {Multicall} from "src/Multicall.sol"; +import {WrapperActions} from "src/WrapperScripts.sol"; +import {MorphoInfo} from "src/builder/MorphoInfo.sol"; +import {TokenWrapper} from "src/builder/TokenWrapper.sol"; + +contract QuarkBuilderMorphoBorrowTest is Test, QuarkBuilderTest { + function borrowIntent_( + uint256 chainId, + string memory assetSymbol, + uint256 amount, + string memory collateralAssetSymbol, + uint256 collateralAmount + ) internal pure returns (QuarkBuilder.MorphoBorrowIntent memory) { + return QuarkBuilder.MorphoBorrowIntent({ + amount: amount, + assetSymbol: assetSymbol, + blockTimestamp: BLOCK_TIMESTAMP, + borrower: address(0xa11ce), + chainId: chainId, + collateralAmount: collateralAmount, + collateralAssetSymbol: collateralAssetSymbol + }); + } + + function testBorrowInvalidMarketParams() public { + QuarkBuilder builder = new QuarkBuilder(); + ChainPortfolio[] memory chainPortfolios = new ChainPortfolio[](2); + chainPortfolios[0] = ChainPortfolio({ + chainId: 1, + account: address(0xa11ce), + nextNonce: 12, + assetSymbols: Arrays.stringArray("USDC", "USDT", "WBTC", "WETH"), + assetBalances: Arrays.uintArray(0, 0, 1e8, 1e18), + cometPortfolios: emptyCometPortfolios_(), + morphoPortfolios: emptyMorphoPortfolios_() + }); + chainPortfolios[1] = ChainPortfolio({ + chainId: 8453, + account: address(0xb0b), + nextNonce: 2, + assetSymbols: Arrays.stringArray("USDC", "USDT", "LINK", "WETH"), + assetBalances: Arrays.uintArray(0, 0, 0, 0), + cometPortfolios: emptyCometPortfolios_(), + morphoPortfolios: emptyMorphoPortfolios_() + }); + + // Pair not exist in known Morpho markets + vm.expectRevert(MorphoInfo.MorphoMarketNotFound.selector); + builder.morphoBorrow( + borrowIntent_(1, "USDC", 1e6, "WETH", 1e18), + chainAccountsFromChainPortfolios(chainPortfolios), + paymentUsd_() + ); + } + + function testBorrowFundsUnavailable() public { + QuarkBuilder builder = new QuarkBuilder(); + vm.expectRevert(abi.encodeWithSelector(QuarkBuilder.FundsUnavailable.selector, "WBTC", 1e8, 0)); + builder.morphoBorrow( + borrowIntent_(1, "USDC", 1e6, "WBTC", 1e8), + chainAccountsList_(3e6), // holding 3 USDC in total across chains 1, 8453 + paymentUsd_() + ); + } + + function testBorrowSuccess() public { + ChainPortfolio[] memory chainPortfolios = new ChainPortfolio[](2); + chainPortfolios[0] = ChainPortfolio({ + chainId: 1, + account: address(0xa11ce), + nextNonce: 12, + assetSymbols: Arrays.stringArray("USDC", "USDT", "WBTC", "WETH"), + assetBalances: Arrays.uintArray(0, 0, 1e8, 0), // user has 1 WBTC + cometPortfolios: emptyCometPortfolios_(), + morphoPortfolios: emptyMorphoPortfolios_() + }); + chainPortfolios[1] = ChainPortfolio({ + chainId: 8453, + account: address(0xb0b), + nextNonce: 2, + assetSymbols: Arrays.stringArray("USDC", "USDT", "LINK", "WETH"), + assetBalances: Arrays.uintArray(0, 0, 0, 0), + cometPortfolios: emptyCometPortfolios_(), + morphoPortfolios: emptyMorphoPortfolios_() + }); + + QuarkBuilder builder = new QuarkBuilder(); + QuarkBuilder.BuilderResult memory result = builder.morphoBorrow( + borrowIntent_(1, "USDC", 1e6, "WBTC", 1e8), chainAccountsFromChainPortfolios(chainPortfolios), paymentUsd_() + ); + address MorphoActionsAddress = CodeJarHelper.getCodeAddress(type(MorphoActions).creationCode); + // Check the quark operations + assertEq(result.paymentCurrency, "usd", "usd currency"); + assertEq(result.quarkOperations.length, 1, "one operation"); + assertEq( + result.quarkOperations[0].scriptAddress, + MorphoActionsAddress, + "script address is correct given the code jar address on mainnet" + ); + assertEq( + result.quarkOperations[0].scriptCalldata, + abi.encodeCall( + MorphoActions.supplyCollateralAndBorrow, + (MorphoInfo.getMorphoAddress(1), MorphoInfo.getMarketParams(1, "WBTC", "USDC"), 1e8, 1e6) + ), + "calldata is MorphoActions.supplyCollateralAndBorrow(MorphoInfo.getMorphoAddress(1), MorphoInfo.getMarketParams(1, WBTC, USDC), 1e8, 1e6, address(0xal1ce), address(0xal1ce));" + ); + assertEq(result.quarkOperations[0].scriptSources.length, 1); + assertEq(result.quarkOperations[0].scriptSources[0], type(MorphoActions).creationCode); + assertEq( + result.quarkOperations[0].expiry, BLOCK_TIMESTAMP + 7 days, "expiry is current blockTimestamp + 7 days" + ); + + // check the actions + assertEq(result.actions.length, 1, "one action"); + assertEq(result.actions[0].chainId, 1, "operation is on chainid 1"); + assertEq(result.actions[0].quarkAccount, address(0xa11ce), "0xa11ce sends the funds"); + assertEq(result.actions[0].actionType, "MORPHO_BORROW", "action type is 'MORPHO_BORROW'"); + assertEq(result.actions[0].paymentMethod, "OFFCHAIN", "payment method is 'OFFCHAIN'"); + assertEq(result.actions[0].paymentToken, address(0), "payment token is null"); + assertEq(result.actions[0].paymentMaxCost, 0, "payment has no max cost, since 'OFFCHAIN'"); + + assertEq( + result.actions[0].actionContext, + abi.encode( + Actions.MorphoBorrowActionContext({ + amount: 1e6, + assetSymbol: "USDC", + chainId: 1, + collateralAmount: 1e8, + collateralTokenPrice: WBTC_PRICE, + collateralToken: wbtc_(1), + collateralAssetSymbol: "WBTC", + price: USDC_PRICE, + token: usdc_(1), + morpho: MorphoInfo.getMorphoAddress(1), + morphoMarketId: MorphoInfo.marketId(MorphoInfo.getMarketParams(1, "WBTC", "USDC")) + }) + ), + "action context encoded from MorphoBorrowActionContext" + ); + assertNotEq(result.eip712Data.digest, hex"", "non-empty digest"); + assertNotEq(result.eip712Data.domainSeparator, hex"", "non-empty domain separator"); + assertNotEq(result.eip712Data.hashStruct, hex"", "non-empty hashStruct"); + } + + function testBorrowWithAutoWrapper() public { + ChainPortfolio[] memory chainPortfolios = new ChainPortfolio[](2); + chainPortfolios[0] = ChainPortfolio({ + chainId: 1, + account: address(0xb0b), + nextNonce: 2, + assetSymbols: Arrays.stringArray("USDC", "ETH", "LINK", "WETH"), + assetBalances: Arrays.uintArray(0, 0, 0, 0), + cometPortfolios: emptyCometPortfolios_(), + morphoPortfolios: emptyMorphoPortfolios_() + }); + chainPortfolios[1] = ChainPortfolio({ + chainId: 8453, + account: address(0xa11ce), + nextNonce: 2, + assetSymbols: Arrays.stringArray("USDC", "ETH", "LINK", "WETH"), + assetBalances: Arrays.uintArray(0, 10e18, 0, 0), + cometPortfolios: emptyCometPortfolios_(), + morphoPortfolios: emptyMorphoPortfolios_() + }); + + QuarkBuilder builder = new QuarkBuilder(); + QuarkBuilder.BuilderResult memory result = builder.morphoBorrow( + borrowIntent_(8453, "USDC", 1e6, "WETH", 1e18), + chainAccountsFromChainPortfolios(chainPortfolios), + paymentUsd_() + ); + + assertEq(result.paymentCurrency, "usd", "usd currency"); + + address multicallAddress = CodeJarHelper.getCodeAddress(type(Multicall).creationCode); + address wrapperActionsAddress = CodeJarHelper.getCodeAddress(type(WrapperActions).creationCode); + address MorphoActionsAddress = CodeJarHelper.getCodeAddress(type(MorphoActions).creationCode); + // Check the quark operations + assertEq(result.quarkOperations.length, 1, "one merged operation"); + assertEq( + result.quarkOperations[0].scriptAddress, + multicallAddress, + "script address is correct given the code jar address on mainnet" + ); + address[] memory callContracts = new address[](2); + callContracts[0] = wrapperActionsAddress; + callContracts[1] = MorphoActionsAddress; + bytes[] memory callDatas = new bytes[](2); + callDatas[0] = abi.encodeWithSelector( + WrapperActions.wrapETH.selector, TokenWrapper.getKnownWrapperTokenPair(8453, "WETH").wrapper, 1e18 + ); + callDatas[1] = abi.encodeCall( + MorphoActions.supplyCollateralAndBorrow, + (MorphoInfo.getMorphoAddress(8453), MorphoInfo.getMarketParams(8453, "WETH", "USDC"), 1e18, 1e6) + ); + + assertEq( + result.quarkOperations[0].scriptCalldata, + abi.encodeWithSelector(Multicall.run.selector, callContracts, callDatas), + "calldata is Multicall.run([wrapperActionsAddress, MorphoActionsAddress], [WrapperActions.wrapWETH(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2, 10e18), MorphoActions.supplyCollateralAndBorrow(MorphoInfo.getMorphoAddress(8453), MorphoInfo.getMarketParams(8453, WETH, USDC), 1e18, 1e6, address(0xa11ce), address(0xa11ce))" + ); + assertEq(result.quarkOperations[0].scriptSources.length, 3); + assertEq(result.quarkOperations[0].scriptSources[0], type(WrapperActions).creationCode); + assertEq(result.quarkOperations[0].scriptSources[1], type(MorphoActions).creationCode); + assertEq(result.quarkOperations[0].scriptSources[2], type(Multicall).creationCode); + assertEq( + result.quarkOperations[0].expiry, BLOCK_TIMESTAMP + 7 days, "expiry is current blockTimestamp + 7 days" + ); + + // check the actions + assertEq(result.actions.length, 1, "one action"); + assertEq(result.actions[0].chainId, 8453, "operation is on chainid 8453"); + assertEq(result.actions[0].quarkAccount, address(0xa11ce), "0xa11ce sends the funds"); + assertEq(result.actions[0].actionType, "MORPHO_BORROW", "action type is 'MORPHO_BORROW'"); + assertEq(result.actions[0].paymentMethod, "OFFCHAIN", "payment method is 'OFFCHAIN'"); + assertEq(result.actions[0].paymentToken, address(0), "payment token is null"); + assertEq(result.actions[0].paymentMaxCost, 0, "payment has no max cost, since 'OFFCHAIN'"); + + assertEq( + result.actions[0].actionContext, + abi.encode( + Actions.MorphoBorrowActionContext({ + amount: 1e6, + assetSymbol: "USDC", + chainId: 8453, + collateralAmount: 1e18, + collateralTokenPrice: WETH_PRICE, + collateralToken: weth_(8453), + collateralAssetSymbol: "WETH", + price: USDC_PRICE, + token: usdc_(8453), + morpho: MorphoInfo.getMorphoAddress(8453), + morphoMarketId: MorphoInfo.marketId(MorphoInfo.getMarketParams(8453, "WETH", "USDC")) + }) + ), + "action context encoded from MorphoBorrowActionContext" + ); + assertNotEq(result.eip712Data.digest, hex"", "non-empty digest"); + assertNotEq(result.eip712Data.domainSeparator, hex"", "non-empty domain separator"); + assertNotEq(result.eip712Data.hashStruct, hex"", "non-empty hashStruct"); + } + + function testBorrowWithPaycall() public { + ChainPortfolio[] memory chainPortfolios = new ChainPortfolio[](2); + chainPortfolios[0] = ChainPortfolio({ + chainId: 1, + account: address(0xa11ce), + nextNonce: 12, + assetSymbols: Arrays.stringArray("USDC", "USDT", "WBTC", "WETH"), + assetBalances: Arrays.uintArray(1e6, 0, 1e8, 0), // user has 1 WBTC and 1USDC for payment + cometPortfolios: emptyCometPortfolios_(), + morphoPortfolios: emptyMorphoPortfolios_() + }); + chainPortfolios[1] = ChainPortfolio({ + chainId: 8453, + account: address(0xb0b), + nextNonce: 2, + assetSymbols: Arrays.stringArray("USDC", "USDT", "LINK", "WETH"), + assetBalances: Arrays.uintArray(0, 0, 0, 0), + cometPortfolios: emptyCometPortfolios_(), + morphoPortfolios: emptyMorphoPortfolios_() + }); + + PaymentInfo.PaymentMaxCost[] memory maxCosts = new PaymentInfo.PaymentMaxCost[](1); + maxCosts[0] = PaymentInfo.PaymentMaxCost({chainId: 1, amount: 0.1e6}); + + QuarkBuilder builder = new QuarkBuilder(); + QuarkBuilder.BuilderResult memory result = builder.morphoBorrow( + borrowIntent_(1, "USDC", 1e6, "WBTC", 1e8), + chainAccountsFromChainPortfolios(chainPortfolios), + paymentUsdc_(maxCosts) + ); + + address MorphoActionsAddress = CodeJarHelper.getCodeAddress(type(MorphoActions).creationCode); + address paycallAddress = paycallUsdc_(1); + + assertEq(result.paymentCurrency, "usdc", "usdc currency"); + + // Check the quark operations + assertEq(result.quarkOperations.length, 1, "one operation"); + assertEq( + result.quarkOperations[0].scriptAddress, + paycallAddress, + "script address is correct given the code jar address on mainnet" + ); + + assertEq( + result.quarkOperations[0].scriptCalldata, + abi.encodeWithSelector( + Paycall.run.selector, + MorphoActionsAddress, + abi.encodeCall( + MorphoActions.supplyCollateralAndBorrow, + (MorphoInfo.getMorphoAddress(1), MorphoInfo.getMarketParams(1, "WBTC", "USDC"), 1e8, 1e6) + ), + 0.1e6 + ), + "calldata is Paycall.run(MorphoActions.supplyCollateralAndBorrow(MorphoInfo.getMorphoAddress(1), MorphoInfo.getMarketParams(1, WBTC, USDC), 1e8, 1e6, address(0xa11ce), address(0xa11ce));" + ); + assertEq(result.quarkOperations[0].scriptSources.length, 2); + assertEq(result.quarkOperations[0].scriptSources[0], type(MorphoActions).creationCode); + assertEq( + result.quarkOperations[0].scriptSources[1], + abi.encodePacked(type(Paycall).creationCode, abi.encode(ETH_USD_PRICE_FEED_1, USDC_1)) + ); + assertEq( + result.quarkOperations[0].expiry, BLOCK_TIMESTAMP + 7 days, "expiry is current blockTimestamp + 7 days" + ); + + // check the actions + assertEq(result.actions.length, 1, "one action"); + assertEq(result.actions[0].chainId, 1, "operation is on chainid 1"); + assertEq(result.actions[0].quarkAccount, address(0xa11ce), "0xa11ce sends the funds"); + assertEq(result.actions[0].actionType, "MORPHO_BORROW", "action type is 'MORPHO_BORROW'"); + assertEq(result.actions[0].paymentMethod, "PAY_CALL", "payment method is 'PAY_CALL'"); + assertEq(result.actions[0].paymentToken, USDC_1, "payment token is USDC"); + assertEq(result.actions[0].paymentMaxCost, 0.1e6, "payment max is set to .1e6 in this test case"); + + assertEq( + result.actions[0].actionContext, + abi.encode( + Actions.MorphoBorrowActionContext({ + amount: 1e6, + assetSymbol: "USDC", + chainId: 1, + collateralAmount: 1e8, + collateralTokenPrice: WBTC_PRICE, + collateralToken: wbtc_(1), + collateralAssetSymbol: "WBTC", + price: USDC_PRICE, + token: usdc_(1), + morpho: MorphoInfo.getMorphoAddress(1), + morphoMarketId: MorphoInfo.marketId(MorphoInfo.getMarketParams(1, "WBTC", "USDC")) + }) + ), + "action context encoded from MorphoBorrowActionContext" + ); + assertNotEq(result.eip712Data.digest, hex"", "non-empty digest"); + assertNotEq(result.eip712Data.domainSeparator, hex"", "non-empty domain separator"); + assertNotEq(result.eip712Data.hashStruct, hex"", "non-empty hashStruct"); + } + + function testBorrowPayFromBorrow() public { + ChainPortfolio[] memory chainPortfolios = new ChainPortfolio[](2); + chainPortfolios[0] = ChainPortfolio({ + chainId: 1, + account: address(0xa11ce), + nextNonce: 12, + assetSymbols: Arrays.stringArray("USDC", "USDT", "WBTC", "WETH"), + assetBalances: Arrays.uintArray(0, 0, 1e8, 0), // user has 1 WBTC but with 0 USDC + cometPortfolios: emptyCometPortfolios_(), + morphoPortfolios: emptyMorphoPortfolios_() + }); + chainPortfolios[1] = ChainPortfolio({ + chainId: 8453, + account: address(0xb0b), + nextNonce: 2, + assetSymbols: Arrays.stringArray("USDC", "USDT", "LINK", "WETH"), + assetBalances: Arrays.uintArray(0, 0, 0, 0), + cometPortfolios: emptyCometPortfolios_(), + morphoPortfolios: emptyMorphoPortfolios_() + }); + + PaymentInfo.PaymentMaxCost[] memory maxCosts = new PaymentInfo.PaymentMaxCost[](1); + maxCosts[0] = PaymentInfo.PaymentMaxCost({chainId: 1, amount: 0.1e6}); + + QuarkBuilder builder = new QuarkBuilder(); + QuarkBuilder.BuilderResult memory result = builder.morphoBorrow( + borrowIntent_(1, "USDC", 1e6, "WBTC", 1e8), + chainAccountsFromChainPortfolios(chainPortfolios), + paymentUsdc_(maxCosts) + ); + + address MorphoActionsAddress = CodeJarHelper.getCodeAddress(type(MorphoActions).creationCode); + address paycallAddress = paycallUsdc_(1); + + assertEq(result.paymentCurrency, "usdc", "usdc currency"); + + // Check the quark operations + assertEq(result.quarkOperations.length, 1, "one operation"); + assertEq( + result.quarkOperations[0].scriptAddress, + paycallAddress, + "script address is correct given the code jar address on mainnet" + ); + + assertEq( + result.quarkOperations[0].scriptCalldata, + abi.encodeWithSelector( + Paycall.run.selector, + MorphoActionsAddress, + abi.encodeCall( + MorphoActions.supplyCollateralAndBorrow, + (MorphoInfo.getMorphoAddress(1), MorphoInfo.getMarketParams(1, "WBTC", "USDC"), 1e8, 1e6) + ), + 0.1e6 + ), + "calldata is Paycall.run(MorphoActions.supplyCollateralAndBorrow(MorphoInfo.getMorphoAddress(), MorphoInfo.getMarketParams(1, WBTC, USDC), 1e8, 1e6, address(0xa11ce), address(0xa11ce));" + ); + assertEq(result.quarkOperations[0].scriptSources.length, 2); + assertEq(result.quarkOperations[0].scriptSources[0], type(MorphoActions).creationCode); + assertEq( + result.quarkOperations[0].scriptSources[1], + abi.encodePacked(type(Paycall).creationCode, abi.encode(ETH_USD_PRICE_FEED_1, USDC_1)) + ); + assertEq( + result.quarkOperations[0].expiry, BLOCK_TIMESTAMP + 7 days, "expiry is current blockTimestamp + 7 days" + ); + + // check the actions + assertEq(result.actions.length, 1, "one action"); + assertEq(result.actions[0].chainId, 1, "operation is on chainid 1"); + assertEq(result.actions[0].quarkAccount, address(0xa11ce), "0xa11ce sends the funds"); + assertEq(result.actions[0].actionType, "MORPHO_BORROW", "action type is 'MORPHO_MORPHO_BORROW'"); + assertEq(result.actions[0].paymentMethod, "PAY_CALL", "payment method is 'PAY_CALL'"); + assertEq(result.actions[0].paymentToken, USDC_1, "payment token is USDC"); + assertEq(result.actions[0].paymentMaxCost, 0.1e6, "payment max is set to .1e6 in this test case"); + assertEq( + result.actions[0].actionContext, + abi.encode( + Actions.MorphoBorrowActionContext({ + amount: 1e6, + assetSymbol: "USDC", + chainId: 1, + collateralAmount: 1e8, + collateralTokenPrice: WBTC_PRICE, + collateralToken: wbtc_(1), + collateralAssetSymbol: "WBTC", + price: USDC_PRICE, + token: usdc_(1), + morpho: MorphoInfo.getMorphoAddress(1), + morphoMarketId: MorphoInfo.marketId(MorphoInfo.getMarketParams(1, "WBTC", "USDC")) + }) + ), + "action context encoded from MorphoBorrowActionContext" + ); + assertNotEq(result.eip712Data.digest, hex"", "non-empty digest"); + assertNotEq(result.eip712Data.domainSeparator, hex"", "non-empty domain separator"); + assertNotEq(result.eip712Data.hashStruct, hex"", "non-empty hashStruct"); + } + + function testBorrowWithBridgedPaymentToken() public { + ChainPortfolio[] memory chainPortfolios = new ChainPortfolio[](2); + chainPortfolios[0] = ChainPortfolio({ + chainId: 1, + account: address(0xa11ce), + nextNonce: 2, + assetSymbols: Arrays.stringArray("USDC", "USDT", "cbETH", "WETH"), + assetBalances: Arrays.uintArray(5e6, 0, 0, 0), + cometPortfolios: emptyCometPortfolios_(), + morphoPortfolios: emptyMorphoPortfolios_() + }); + chainPortfolios[1] = ChainPortfolio({ + chainId: 8453, + account: address(0xb0b), + nextNonce: 2, + assetSymbols: Arrays.stringArray("USDC", "USDT", "cbETH", "WETH"), + assetBalances: Arrays.uintArray(0, 0, 1e18, 0), + cometPortfolios: emptyCometPortfolios_(), + morphoPortfolios: emptyMorphoPortfolios_() + }); + + PaymentInfo.PaymentMaxCost[] memory maxCosts = new PaymentInfo.PaymentMaxCost[](2); + maxCosts[0] = PaymentInfo.PaymentMaxCost({chainId: 1, amount: 0.1e6}); + maxCosts[1] = PaymentInfo.PaymentMaxCost({chainId: 8453, amount: 1e6}); // max cost on base is 1 USDC + + QuarkBuilder builder = new QuarkBuilder(); + QuarkBuilder.BuilderResult memory result = builder.morphoBorrow( + borrowIntent_(8453, "WETH", 0.2e18, "cbETH", 1e18), + chainAccountsFromChainPortfolios(chainPortfolios), + paymentUsdc_(maxCosts) + ); + + address paycallAddress = paycallUsdc_(1); + address paycallAddressBase = paycallUsdc_(8453); + address cctpBridgeActionsAddress = CodeJarHelper.getCodeAddress(type(CCTPBridgeActions).creationCode); + address MorphoActionsAddress = CodeJarHelper.getCodeAddress(type(MorphoActions).creationCode); + + assertEq(result.paymentCurrency, "usdc", "usdc currency"); + + // Check the quark operations + // first operation + assertEq(result.quarkOperations.length, 2, "two operations"); + assertEq( + result.quarkOperations[0].scriptAddress, + paycallAddress, + "script address is correct given the code jar address on base" + ); + assertEq( + result.quarkOperations[0].scriptCalldata, + abi.encodeWithSelector( + Paycall.run.selector, + cctpBridgeActionsAddress, + abi.encodeWithSelector( + CCTPBridgeActions.bridgeUSDC.selector, + address(0xBd3fa81B58Ba92a82136038B25aDec7066af3155), + 1e6, + 6, + bytes32(uint256(uint160(0xa11ce))), + usdc_(1) + ), + 0.1e6 + ), + "calldata is Paycall.run(CCTPBridgeActions.bridgeUSDC(0xBd3fa81B58Ba92a82136038B25aDec7066af3155, 1e6, 6, 0xa11ce, USDC_1)), 0.1e6);" + ); + assertEq(result.quarkOperations[0].scriptSources.length, 2); + assertEq(result.quarkOperations[0].scriptSources[0], type(CCTPBridgeActions).creationCode); + assertEq( + result.quarkOperations[0].scriptSources[1], + abi.encodePacked(type(Paycall).creationCode, abi.encode(ETH_USD_PRICE_FEED_1, USDC_1)) + ); + assertEq( + result.quarkOperations[0].expiry, BLOCK_TIMESTAMP + 7 days, "expiry is current blockTimestamp + 7 days" + ); + + // second operation + assertEq( + result.quarkOperations[1].scriptAddress, + paycallAddressBase, + "script address[1] has been wrapped with paycall address" + ); + + assertEq( + result.quarkOperations[1].scriptCalldata, + abi.encodeWithSelector( + Paycall.run.selector, + MorphoActionsAddress, + abi.encodeCall( + MorphoActions.supplyCollateralAndBorrow, + (MorphoInfo.getMorphoAddress(8453), MorphoInfo.getMarketParams(8453, "cbETH", "WETH"), 1e18, 0.2e18) + ), + 1e6 + ), + "calldata is Paycall.run(MorphoActions.supplyCollateralAndBorrow(MorphoInfo.getMorphoAddress(8453), MorphoInfo.getMarketParams(8453, cbETH, WETH), 1e18, 0.2e18, address(0xa11ce), address(0xa11ce));" + ); + assertEq(result.quarkOperations[1].scriptSources.length, 2); + assertEq(result.quarkOperations[1].scriptSources[0], type(MorphoActions).creationCode); + assertEq( + result.quarkOperations[1].scriptSources[1], + abi.encodePacked(type(Paycall).creationCode, abi.encode(ETH_USD_PRICE_FEED_8453, USDC_8453)) + ); + assertEq( + result.quarkOperations[1].expiry, BLOCK_TIMESTAMP + 7 days, "expiry is current blockTimestamp + 7 days" + ); + + // Check the actions + assertEq(result.actions.length, 2, "two actions"); + // first action + assertEq(result.actions[0].chainId, 1, "operation is on chainid 1"); + assertEq(result.actions[0].quarkAccount, address(0xa11ce), "0xa11ce sends the funds"); + assertEq(result.actions[0].actionType, "BRIDGE", "action type is 'BRIDGE'"); + assertEq(result.actions[0].paymentMethod, "PAY_CALL", "payment method is 'PAY_CALL'"); + assertEq(result.actions[0].paymentToken, USDC_1, "payment token is USDC on mainnet"); + assertEq(result.actions[0].paymentMaxCost, 0.1e6, "payment should have max cost of 0.1e6"); + assertEq( + result.actions[0].actionContext, + abi.encode( + Actions.BridgeActionContext({ + amount: 1e6, + price: USDC_PRICE, + token: USDC_1, + assetSymbol: "USDC", + chainId: 1, + recipient: address(0xa11ce), + destinationChainId: 8453, + bridgeType: Actions.BRIDGE_TYPE_CCTP + }) + ), + "action context encoded from BridgeActionContext" + ); + // second action + assertEq(result.actions[1].chainId, 8453, "operation is on chainid 8453"); + assertEq(result.actions[1].quarkAccount, address(0xa11ce), "0xa11ce sends the funds"); + assertEq(result.actions[1].actionType, "MORPHO_BORROW", "action type is 'MORPHO_BORROW'"); + assertEq(result.actions[1].paymentMethod, "PAY_CALL", "payment method is 'PAY_CALL'"); + assertEq(result.actions[1].paymentToken, USDC_8453, "payment token is USDC on Base"); + assertEq(result.actions[1].paymentMaxCost, 1e6, "payment should have max cost of 1e6"); + + assertEq( + result.actions[1].actionContext, + abi.encode( + Actions.MorphoBorrowActionContext({ + amount: 0.2e18, + assetSymbol: "WETH", + chainId: 8453, + collateralAmount: 1e18, + collateralTokenPrice: CBETH_PRICE, + collateralToken: cbEth_(8453), + collateralAssetSymbol: "cbETH", + price: WETH_PRICE, + token: weth_(8453), + morpho: MorphoInfo.getMorphoAddress(8453), + morphoMarketId: MorphoInfo.marketId(MorphoInfo.getMarketParams(8453, "cbETH", "WETH")) + }) + ), + "action context encoded from MorphoBorrowActionContext" + ); + assertNotEq(result.eip712Data.digest, hex"", "non-empty digest"); + assertNotEq(result.eip712Data.domainSeparator, hex"", "non-empty domain separator"); + assertNotEq(result.eip712Data.hashStruct, hex"", "non-empty hashStruct"); + } + + function testMorphoBorrowMaxCostTooHighForBridgePaymentToken() public { + QuarkBuilder builder = new QuarkBuilder(); + PaymentInfo.PaymentMaxCost[] memory maxCosts = new PaymentInfo.PaymentMaxCost[](1); + maxCosts[0] = PaymentInfo.PaymentMaxCost({chainId: 1, amount: 0.5e6}); // action costs .5 USDC + + ChainPortfolio[] memory chainPortfolios = new ChainPortfolio[](1); + chainPortfolios[0] = ChainPortfolio({ + chainId: 1, + account: address(0xa11ce), + nextNonce: 12, + assetSymbols: Arrays.stringArray("USDC", "USDT", "WBTC", "WETH"), + assetBalances: Arrays.uintArray(0.4e6, 0, 2e8, 1e18), // user does not have enough USDC + cometPortfolios: emptyCometPortfolios_(), + morphoPortfolios: emptyMorphoPortfolios_() + }); + + vm.expectRevert(abi.encodeWithSelector(Actions.NotEnoughFundsToBridge.selector, "usdc", 0.1e6, 0.1e6)); + + builder.morphoBorrow( + borrowIntent_(1, "WETH", 1e18, "WBTC", 0), + chainAccountsFromChainPortfolios(chainPortfolios), + paymentUsdc_(maxCosts) + ); + } +} diff --git a/test/builder/QuarkBuilderMorphoRepay.t.sol b/test/builder/QuarkBuilderMorphoRepay.t.sol new file mode 100644 index 00000000..c83fe700 --- /dev/null +++ b/test/builder/QuarkBuilderMorphoRepay.t.sol @@ -0,0 +1,852 @@ +// SPDX-License-Identifier: BSD-3-Clause +pragma solidity ^0.8.23; + +import "forge-std/Test.sol"; +import "forge-std/console.sol"; + +import {Arrays} from "test/builder/lib/Arrays.sol"; +import {QuarkBuilderTest, Accounts, PaymentInfo} from "test/builder/lib/QuarkBuilderTest.sol"; +import {Actions} from "src/builder/Actions.sol"; +import {CCTPBridgeActions} from "src/BridgeScripts.sol"; +import {CodeJarHelper} from "src/builder/CodeJarHelper.sol"; +import {MorphoActions} from "src/MorphoScripts.sol"; +import {Paycall} from "src/Paycall.sol"; +import {Strings} from "src/builder/Strings.sol"; +import {Multicall} from "src/Multicall.sol"; +import {WrapperActions} from "src/WrapperScripts.sol"; +import {MorphoInfo} from "src/builder/MorphoInfo.sol"; +import {QuarkBuilder} from "src/builder/QuarkBuilder.sol"; +import {TokenWrapper} from "src/builder/TokenWrapper.sol"; + +contract QuarkBuilderMorphoRepayTest is Test, QuarkBuilderTest { + function repayIntent_( + uint256 chainId, + string memory assetSymbol, + uint256 amount, + string memory collateralAssetSymbol, + uint256 collateralAmount + ) internal pure returns (QuarkBuilder.MorphoRepayIntent memory) { + return QuarkBuilder.MorphoRepayIntent({ + amount: amount, + assetSymbol: assetSymbol, + blockTimestamp: BLOCK_TIMESTAMP, + repayer: address(0xa11ce), + chainId: chainId, + collateralAmount: collateralAmount, + collateralAssetSymbol: collateralAssetSymbol + }); + } + + function testMorphoRepayFundsUnavailable() public { + QuarkBuilder builder = new QuarkBuilder(); + + vm.expectRevert(abi.encodeWithSelector(QuarkBuilder.FundsUnavailable.selector, "USDC", 1e6, 0)); + + builder.morphoRepay( + repayIntent_(1, "USDC", 1e6, "WBTC", 1e8), + chainAccountsList_(0e6), // but user has 0 USDC + paymentUsd_() + ); + } + + function testMorphoRepayMaxCostTooHigh() public { + QuarkBuilder builder = new QuarkBuilder(); + PaymentInfo.PaymentMaxCost[] memory maxCosts = new PaymentInfo.PaymentMaxCost[](1); + maxCosts[0] = PaymentInfo.PaymentMaxCost({chainId: 8453, amount: 0.5e6}); // action costs .5 USDC + + ChainPortfolio[] memory chainPortfolios = new ChainPortfolio[](1); + chainPortfolios[0] = ChainPortfolio({ + chainId: 8453, + account: address(0xa11ce), + nextNonce: 12, + assetSymbols: Arrays.stringArray("USDC", "USDT", "LINK", "WETH"), + assetBalances: Arrays.uintArray(0.4e6, 0, 0, 1e18), // user does not have enough USDC + cometPortfolios: emptyCometPortfolios_(), + morphoPortfolios: emptyMorphoPortfolios_() + }); + + vm.expectRevert(abi.encodeWithSelector(Actions.NotEnoughFundsToBridge.selector, "usdc", 0.1e6, 0.1e6)); + + builder.morphoRepay( + repayIntent_(8453, "WETH", 1e18, "cbETH", 1e18), + chainAccountsFromChainPortfolios(chainPortfolios), + paymentUsdc_(maxCosts) + ); + } + + function testMorphoRepay() public { + ChainPortfolio[] memory chainPortfolios = new ChainPortfolio[](2); + chainPortfolios[0] = ChainPortfolio({ + chainId: 1, + account: address(0xa11ce), + nextNonce: 12, + assetSymbols: Arrays.stringArray("USDC", "USDT", "WBTC", "WETH"), + assetBalances: Arrays.uintArray(1e6, 0, 0, 0), // has 1 USDC + cometPortfolios: emptyCometPortfolios_(), + morphoPortfolios: emptyMorphoPortfolios_() + }); + chainPortfolios[1] = ChainPortfolio({ + chainId: 8453, + account: address(0xb0b), + nextNonce: 2, + assetSymbols: Arrays.stringArray("USDC", "USDT", "WBTC", "WETH"), + assetBalances: Arrays.uintArray(0, 0, 0, 0), + cometPortfolios: emptyCometPortfolios_(), + morphoPortfolios: emptyMorphoPortfolios_() + }); + + QuarkBuilder builder = new QuarkBuilder(); + QuarkBuilder.BuilderResult memory result = builder.morphoRepay( + repayIntent_( + 1, + "USDC", + 1e6, // repaying 1 USDC + "WBTC", + 1e8 // withdraw WBTC + ), + chainAccountsFromChainPortfolios(chainPortfolios), + paymentUsd_() + ); + + assertEq(result.paymentCurrency, "usd", "usd currency"); + address MorphoActionsAddress = CodeJarHelper.getCodeAddress(type(MorphoActions).creationCode); + // Check the quark operations + assertEq(result.quarkOperations.length, 1, "one operation"); + assertEq( + result.quarkOperations[0].scriptAddress, + MorphoActionsAddress, + "script address is correct given the code jar address on mainnet" + ); + assertEq( + result.quarkOperations[0].scriptCalldata, + abi.encodeCall( + MorphoActions.repayAndWithdrawCollateral, + (MorphoInfo.getMorphoAddress(1), MorphoInfo.getMarketParams(1, "WBTC", "USDC"), 1e6, 1e8) + ), + "calldata is MorphoActions.repayAndWithdrawCollateral(MorphoInfo.getMorphoAddress(1), MorphoInfo.getMarketParams(1, WBTC, USDC), 1e6, 1e8, address(0xa11ce), address(0xa11ce));" + ); + assertEq(result.quarkOperations[0].scriptSources.length, 1); + assertEq(result.quarkOperations[0].scriptSources[0], type(MorphoActions).creationCode); + assertEq( + result.quarkOperations[0].expiry, BLOCK_TIMESTAMP + 7 days, "expiry is current blockTimestamp + 7 days" + ); + + // check the actions + assertEq(result.actions.length, 1, "one action"); + assertEq(result.actions[0].chainId, 1, "operation is on chainid 1"); + assertEq(result.actions[0].quarkAccount, address(0xa11ce), "0xa11ce sends the funds"); + assertEq(result.actions[0].actionType, "MORPHO_REPAY", "action type is 'MORPHO_REPAY'"); + assertEq(result.actions[0].paymentMethod, "OFFCHAIN", "payment method is 'OFFCHAIN'"); + assertEq(result.actions[0].paymentToken, address(0), "payment token is null"); + assertEq(result.actions[0].paymentMaxCost, 0, "payment has no max cost, since 'OFFCHAIN'"); + + assertEq( + result.actions[0].actionContext, + abi.encode( + Actions.MorphoRepayActionContext({ + amount: 1e6, + assetSymbol: "USDC", + chainId: 1, + collateralAmount: 1e8, + collateralAssetSymbol: "WBTC", + collateralTokenPrice: WBTC_PRICE, + collateralToken: wbtc_(1), + price: USDC_PRICE, + token: usdc_(1), + morpho: MorphoInfo.getMorphoAddress(1), + morphoMarketId: MorphoInfo.marketId(MorphoInfo.getMarketParams(1, "WBTC", "USDC")) + }) + ), + "action context encoded from MorphoRepayActionContext" + ); + assertNotEq(result.eip712Data.digest, hex"", "non-empty digest"); + assertNotEq(result.eip712Data.domainSeparator, hex"", "non-empty domain separator"); + assertNotEq(result.eip712Data.hashStruct, hex"", "non-empty hashStruct"); + } + + function testMorphoRepayWithAutoWrapper() public { + ChainPortfolio[] memory chainPortfolios = new ChainPortfolio[](2); + chainPortfolios[0] = ChainPortfolio({ + chainId: 1, + account: address(0xb0b), + nextNonce: 2, + assetSymbols: Arrays.stringArray("USDC", "ETH", "cbETH", "WETH"), + assetBalances: Arrays.uintArray(0, 0, 0, 0), + cometPortfolios: emptyCometPortfolios_(), + morphoPortfolios: emptyMorphoPortfolios_() + }); + chainPortfolios[1] = ChainPortfolio({ + chainId: 8453, + account: address(0xa11ce), + nextNonce: 2, + assetSymbols: Arrays.stringArray("USDC", "ETH", "cbETH", "WETH"), + assetBalances: Arrays.uintArray(0, 1e18, 0, 0), + cometPortfolios: emptyCometPortfolios_(), + morphoPortfolios: emptyMorphoPortfolios_() + }); + + QuarkBuilder builder = new QuarkBuilder(); + QuarkBuilder.BuilderResult memory result = builder.morphoRepay( + repayIntent_( + 8453, + "WETH", + 1e18, // repaying 1 WETH + "cbETH", + 0e18 + ), + chainAccountsFromChainPortfolios(chainPortfolios), + paymentUsd_() + ); + + assertEq(result.paymentCurrency, "usd", "usd currency"); + + address multicallAddress = CodeJarHelper.getCodeAddress(type(Multicall).creationCode); + address wrapperActionsAddress = CodeJarHelper.getCodeAddress(type(WrapperActions).creationCode); + address MorphoActionsAddress = CodeJarHelper.getCodeAddress(type(MorphoActions).creationCode); + + // Check the quark operations + assertEq(result.quarkOperations.length, 1, "one merged operation"); + assertEq( + result.quarkOperations[0].scriptAddress, + multicallAddress, + "script address is correct given the code jar address on mainnet" + ); + address[] memory callContracts = new address[](2); + callContracts[0] = wrapperActionsAddress; + callContracts[1] = MorphoActionsAddress; + bytes[] memory callDatas = new bytes[](2); + callDatas[0] = abi.encodeWithSelector( + WrapperActions.wrapETH.selector, TokenWrapper.getKnownWrapperTokenPair(8453, "WETH").wrapper, 1e18 + ); + callDatas[1] = abi.encodeCall( + MorphoActions.repayAndWithdrawCollateral, + (MorphoInfo.getMorphoAddress(8453), MorphoInfo.getMarketParams(8453, "cbETH", "WETH"), 1e18, 0e18) + ); + + assertEq( + result.quarkOperations[0].scriptCalldata, + abi.encodeWithSelector(Multicall.run.selector, callContracts, callDatas), + "calldata is Multicall.run([wrapperActionsAddress, MorphoActionsAddress], [WrapperActions.wrapWETH(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2, 1e18), MorphoActions.repayAndWithdrawCollateral(MorphoInfo.getMorphoAddress(8453), MorphoInfo.getMarketParams(8453, WETH, USDC), 1e18, 0, 0e18, address(0xa11ce), address(0xa11ce))" + ); + assertEq(result.quarkOperations[0].scriptSources.length, 3); + assertEq(result.quarkOperations[0].scriptSources[0], type(WrapperActions).creationCode); + assertEq(result.quarkOperations[0].scriptSources[1], type(MorphoActions).creationCode); + assertEq(result.quarkOperations[0].scriptSources[2], type(Multicall).creationCode); + assertEq( + result.quarkOperations[0].expiry, BLOCK_TIMESTAMP + 7 days, "expiry is current blockTimestamp + 7 days" + ); + + // check the actions + assertEq(result.actions.length, 1, "one action"); + assertEq(result.actions[0].chainId, 8453, "operation is on chainid 8453"); + assertEq(result.actions[0].quarkAccount, address(0xa11ce), "0xa11ce sends the funds"); + assertEq(result.actions[0].actionType, "MORPHO_REPAY", "action type is 'MORPHO_REPAY'"); + assertEq(result.actions[0].paymentMethod, "OFFCHAIN", "payment method is 'OFFCHAIN'"); + assertEq(result.actions[0].paymentToken, address(0), "payment token is null"); + assertEq(result.actions[0].paymentMaxCost, 0, "payment has no max cost, since 'OFFCHAIN'"); + + assertEq( + result.actions[0].actionContext, + abi.encode( + Actions.MorphoRepayActionContext({ + amount: 1e18, + assetSymbol: "WETH", + chainId: 8453, + collateralAmount: 0, + collateralAssetSymbol: "cbETH", + collateralTokenPrice: CBETH_PRICE, + collateralToken: cbEth_(8453), + price: WETH_PRICE, + token: weth_(8453), + morpho: MorphoInfo.getMorphoAddress(8453), + morphoMarketId: MorphoInfo.marketId(MorphoInfo.getMarketParams(8453, "cbETH", "WETH")) + }) + ), + "action context encoded from MorphoRepayActionContext" + ); + assertNotEq(result.eip712Data.digest, hex"", "non-empty digest"); + assertNotEq(result.eip712Data.domainSeparator, hex"", "non-empty domain separator"); + assertNotEq(result.eip712Data.hashStruct, hex"", "non-empty hashStruct"); + } + + function testMorphoRepayWithPaycall() public { + ChainPortfolio[] memory chainPortfolios = new ChainPortfolio[](2); + chainPortfolios[0] = ChainPortfolio({ + chainId: 1, + account: address(0xa11ce), + nextNonce: 12, + assetSymbols: Arrays.stringArray("USDC", "USDT", "WBTC", "WETH"), + assetBalances: Arrays.uintArray(2e6, 0, 0, 0), + cometPortfolios: emptyCometPortfolios_(), + morphoPortfolios: emptyMorphoPortfolios_() + }); + chainPortfolios[1] = ChainPortfolio({ + chainId: 8453, + account: address(0xb0b), + nextNonce: 2, + assetSymbols: Arrays.stringArray("USDC", "USDT", "WBTC", "WETH"), + assetBalances: Arrays.uintArray(0, 0, 0, 0), + cometPortfolios: emptyCometPortfolios_(), + morphoPortfolios: emptyMorphoPortfolios_() + }); + + PaymentInfo.PaymentMaxCost[] memory maxCosts = new PaymentInfo.PaymentMaxCost[](1); + maxCosts[0] = PaymentInfo.PaymentMaxCost({chainId: 1, amount: 0.1e6}); + + QuarkBuilder builder = new QuarkBuilder(); + QuarkBuilder.BuilderResult memory result = builder.morphoRepay( + repayIntent_( + 1, + "USDC", + 1e6, // repaying 1 USDC + "WBTC", + 0e8 + ), + chainAccountsFromChainPortfolios(chainPortfolios), + paymentUsdc_(maxCosts) // and paying with USDC + ); + + address MorphoActionsAddress = CodeJarHelper.getCodeAddress(type(MorphoActions).creationCode); + address paycallAddress = paycallUsdc_(1); + + assertEq(result.paymentCurrency, "usdc", "usdc currency"); + + // Check the quark operations + assertEq(result.quarkOperations.length, 1, "one operation"); + assertEq( + result.quarkOperations[0].scriptAddress, + paycallAddress, + "script address is correct given the code jar address on mainnet" + ); + assertEq( + result.quarkOperations[0].scriptCalldata, + abi.encodeWithSelector( + Paycall.run.selector, + MorphoActionsAddress, + abi.encodeCall( + MorphoActions.repayAndWithdrawCollateral, + (MorphoInfo.getMorphoAddress(1), MorphoInfo.getMarketParams(1, "WBTC", "USDC"), 1e6, 0e8) + ), + 0.1e6 + ), + "calldata is Paycall.run(MorphoActions.repayAndWithdrawCollateral(MorphoInfo.getMorphoAddress(1), MorphoInfo.getMarketParams(1, WBTC, USDC), 1e6, 0);" + ); + assertEq(result.quarkOperations[0].scriptSources.length, 2); + assertEq(result.quarkOperations[0].scriptSources[0], type(MorphoActions).creationCode); + assertEq( + result.quarkOperations[0].scriptSources[1], + abi.encodePacked(type(Paycall).creationCode, abi.encode(ETH_USD_PRICE_FEED_1, USDC_1)) + ); + assertEq( + result.quarkOperations[0].expiry, BLOCK_TIMESTAMP + 7 days, "expiry is current blockTimestamp + 7 days" + ); + + // check the actions + assertEq(result.actions.length, 1, "one action"); + assertEq(result.actions[0].chainId, 1, "operation is on chainid 1"); + assertEq(result.actions[0].quarkAccount, address(0xa11ce), "0xa11ce sends the funds"); + assertEq(result.actions[0].actionType, "MORPHO_REPAY", "action type is 'MORPHO_REPAY'"); + assertEq(result.actions[0].paymentMethod, "PAY_CALL", "payment method is 'PAY_CALL'"); + assertEq(result.actions[0].paymentToken, USDC_1, "payment token is USDC"); + assertEq(result.actions[0].paymentMaxCost, 0.1e6, "payment max is set to .1e6 in this test case"); + + uint256[] memory collateralAmounts = new uint256[](1); + collateralAmounts[0] = 0e18; + uint256[] memory collateralTokenPrices = new uint256[](1); + collateralTokenPrices[0] = WBTC_PRICE; + address[] memory collateralTokens = new address[](1); + collateralTokens[0] = wbtc_(1); + string[] memory collateralAssetSymbols = new string[](1); + collateralAssetSymbols[0] = "WBTC"; + + assertEq( + result.actions[0].actionContext, + abi.encode( + Actions.MorphoRepayActionContext({ + amount: 1e6, + assetSymbol: "USDC", + chainId: 1, + collateralAmount: 0, + collateralAssetSymbol: "WBTC", + collateralTokenPrice: WBTC_PRICE, + collateralToken: wbtc_(1), + price: USDC_PRICE, + token: usdc_(1), + morpho: MorphoInfo.getMorphoAddress(1), + morphoMarketId: MorphoInfo.marketId(MorphoInfo.getMarketParams(1, "WBTC", "USDC")) + }) + ), + "action context encoded from MorphoRepayActionContext" + ); + assertNotEq(result.eip712Data.digest, hex"", "non-empty digest"); + assertNotEq(result.eip712Data.domainSeparator, hex"", "non-empty domain separator"); + assertNotEq(result.eip712Data.hashStruct, hex"", "non-empty hashStruct"); + } + + function testCometRepayWithBridge() public { + QuarkBuilder builder = new QuarkBuilder(); + + PaymentInfo.PaymentMaxCost[] memory maxCosts = new PaymentInfo.PaymentMaxCost[](2); + maxCosts[0] = PaymentInfo.PaymentMaxCost({chainId: 1, amount: 0.1e6}); + maxCosts[1] = PaymentInfo.PaymentMaxCost({chainId: 8453, amount: 0.2e6}); + + ChainPortfolio[] memory chainPortfolios = new ChainPortfolio[](2); + chainPortfolios[0] = ChainPortfolio({ + chainId: 1, + account: address(0xa11ce), + nextNonce: 12, + assetSymbols: Arrays.stringArray("USDC", "USDT", "WBTC", "WETH"), + assetBalances: Arrays.uintArray(4e6, 0, 0, 0), // 4 USDC on mainnet + cometPortfolios: emptyCometPortfolios_(), + morphoPortfolios: emptyMorphoPortfolios_() + }); + chainPortfolios[1] = ChainPortfolio({ + chainId: 8453, + account: address(0xb0b), + nextNonce: 2, + assetSymbols: Arrays.stringArray("USDC", "USDT", "WBTC", "WETH"), + assetBalances: Arrays.uintArray(0, 0, 0, 0), // no assets on base + cometPortfolios: emptyCometPortfolios_(), + morphoPortfolios: emptyMorphoPortfolios_() + }); + + QuarkBuilder.BuilderResult memory result = builder.morphoRepay( + repayIntent_( + 8453, + "USDC", // repaying 2 USDC, bridged from mainnet to base + 2e6, + "WETH", + 0e18 + ), + chainAccountsFromChainPortfolios(chainPortfolios), + paymentUsdc_(maxCosts) + ); + + address paycallAddress = paycallUsdc_(1); + address paycallAddressBase = paycallUsdc_(8453); + address cctpBridgeActionsAddress = CodeJarHelper.getCodeAddress(type(CCTPBridgeActions).creationCode); + address MorphoActionsAddress = CodeJarHelper.getCodeAddress(type(MorphoActions).creationCode); + + assertEq(result.paymentCurrency, "usdc", "usdc currency"); + + // Check the quark operations + // first operation + assertEq(result.quarkOperations.length, 2, "two operations"); + assertEq( + result.quarkOperations[0].scriptAddress, + paycallAddress, + "script address is correct given the code jar address on base" + ); + assertEq( + result.quarkOperations[0].scriptCalldata, + abi.encodeWithSelector( + Paycall.run.selector, + cctpBridgeActionsAddress, + abi.encodeWithSelector( + CCTPBridgeActions.bridgeUSDC.selector, + address(0xBd3fa81B58Ba92a82136038B25aDec7066af3155), + 2.2e6, // 2e6 repaid + 0.2e6 max cost on Base + 6, + bytes32(uint256(uint160(0xa11ce))), + usdc_(1) + ), + 0.1e6 + ), + "calldata is Paycall.run(CCTPBridgeActions.bridgeUSDC(0xBd3fa81B58Ba92a82136038B25aDec7066af3155, 2.2e6, 6, 0xa11ce, USDC_1)), 0.1e6);" + ); + assertEq(result.quarkOperations[0].scriptSources.length, 2); + assertEq(result.quarkOperations[0].scriptSources[0], type(CCTPBridgeActions).creationCode); + assertEq( + result.quarkOperations[0].scriptSources[1], + abi.encodePacked(type(Paycall).creationCode, abi.encode(ETH_USD_PRICE_FEED_1, USDC_1)) + ); + assertEq( + result.quarkOperations[0].expiry, BLOCK_TIMESTAMP + 7 days, "expiry is current blockTimestamp + 7 days" + ); + + // second operation + assertEq( + result.quarkOperations[1].scriptAddress, + paycallAddressBase, + "script address[1] has been wrapped with paycall address" + ); + + assertEq( + result.quarkOperations[1].scriptCalldata, + abi.encodeWithSelector( + Paycall.run.selector, + MorphoActionsAddress, + abi.encodeCall( + MorphoActions.repayAndWithdrawCollateral, + (MorphoInfo.getMorphoAddress(8453), MorphoInfo.getMarketParams(8453, "WETH", "USDC"), 2e6, 0e18) + ), + 0.2e6 + ), + "calldata is Paycall.run(MorphoActions.repayAndWithdrawCollateral(MorphoInfo.getMorphoAddress(8453), MorphoInfo.getMarketParams(8453, WETH, USDC), 2e6, 0, 0);" + ); + assertEq(result.quarkOperations[1].scriptSources.length, 2); + assertEq(result.quarkOperations[1].scriptSources[0], type(MorphoActions).creationCode); + assertEq( + result.quarkOperations[1].scriptSources[1], + abi.encodePacked(type(Paycall).creationCode, abi.encode(ETH_USD_PRICE_FEED_8453, USDC_8453)) + ); + assertEq( + result.quarkOperations[1].expiry, BLOCK_TIMESTAMP + 7 days, "expiry is current blockTimestamp + 7 days" + ); + + // Check the actions + assertEq(result.actions.length, 2, "two actions"); + // first action + assertEq(result.actions[0].chainId, 1, "operation is on chainid 1"); + assertEq(result.actions[0].quarkAccount, address(0xa11ce), "0xa11ce sends the funds"); + assertEq(result.actions[0].actionType, "BRIDGE", "action type is 'BRIDGE'"); + assertEq(result.actions[0].paymentMethod, "PAY_CALL", "payment method is 'PAY_CALL'"); + assertEq(result.actions[0].paymentToken, USDC_1, "payment token is USDC on mainnet"); + assertEq(result.actions[0].paymentMaxCost, 0.1e6, "payment should have max cost of 0.1e6"); + assertEq( + result.actions[0].actionContext, + abi.encode( + Actions.BridgeActionContext({ + amount: 2.2e6, + assetSymbol: "USDC", + bridgeType: Actions.BRIDGE_TYPE_CCTP, + chainId: 1, + destinationChainId: 8453, + price: USDC_PRICE, + recipient: address(0xa11ce), + token: usdc_(1) + }) + ), + "action context encoded from BridgeActionContext" + ); + // second action + assertEq(result.actions[1].chainId, 8453, "operation is on chainid 8453"); + assertEq(result.actions[1].quarkAccount, address(0xa11ce), "0xa11ce sends the funds"); + assertEq(result.actions[1].actionType, "MORPHO_REPAY", "action type is 'MORPHO_REPAY'"); + assertEq(result.actions[1].paymentMethod, "PAY_CALL", "payment method is 'PAY_CALL'"); + assertEq(result.actions[1].paymentToken, USDC_8453, "payment token is USDC on Base"); + assertEq(result.actions[1].paymentMaxCost, 0.2e6, "payment should have max cost of 0.2e6"); + + assertEq( + result.actions[1].actionContext, + abi.encode( + Actions.MorphoRepayActionContext({ + amount: 2e6, + assetSymbol: "USDC", + chainId: 8453, + collateralAmount: 0, + collateralAssetSymbol: "WETH", + collateralTokenPrice: WETH_PRICE, + collateralToken: weth_(8453), + price: USDC_PRICE, + token: usdc_(8453), + morpho: MorphoInfo.getMorphoAddress(8453), + morphoMarketId: MorphoInfo.marketId(MorphoInfo.getMarketParams(8453, "WETH", "USDC")) + }) + ), + "action context encoded from MorphoRepayActionContext" + ); + assertNotEq(result.eip712Data.digest, hex"", "non-empty digest"); + assertNotEq(result.eip712Data.domainSeparator, hex"", "non-empty domain separator"); + assertNotEq(result.eip712Data.hashStruct, hex"", "non-empty hashStruct"); + } + + function testCometRepayMax() public { + PaymentInfo.PaymentMaxCost[] memory maxCosts = new PaymentInfo.PaymentMaxCost[](1); + maxCosts[0] = PaymentInfo.PaymentMaxCost({chainId: 1, amount: 0.1e6}); + + MorphoPortfolio[] memory morphoPortfolios = new MorphoPortfolio[](1); + morphoPortfolios[0] = MorphoPortfolio({ + marketId: MorphoInfo.marketId(MorphoInfo.getMarketParams(1, "WBTC", "USDC")), + loanToken: "USDC", + collateralToken: "WBTC", + borrowedBalance: 10e6, + collateralBalance: 1e8 + }); + + ChainPortfolio[] memory chainPortfolios = new ChainPortfolio[](1); + chainPortfolios[0] = ChainPortfolio({ + chainId: 1, + account: address(0xa11ce), + nextNonce: 12, + assetSymbols: Arrays.stringArray("USDC", "USDT", "WBTC", "WETH"), + assetBalances: Arrays.uintArray(20e6, 0, 0, 0), // has 20 USDC + cometPortfolios: emptyCometPortfolios_(), + morphoPortfolios: morphoPortfolios + }); + + QuarkBuilder builder = new QuarkBuilder(); + QuarkBuilder.BuilderResult memory result = builder.morphoRepay( + repayIntent_( + 1, + "USDC", + type(uint256).max, // repaying max (all 10 USDC) + "WBTC", + 0e8 // no collateral withdrawal + ), + chainAccountsFromChainPortfolios(chainPortfolios), + paymentUsdc_(maxCosts) + ); + + assertEq(result.paymentCurrency, "usdc", "usdc currency"); + + address paycallAddress = CodeJarHelper.getCodeAddress( + abi.encodePacked(type(Paycall).creationCode, abi.encode(ETH_USD_PRICE_FEED_1, USDC_1)) + ); + address MorphoActionsAddress = CodeJarHelper.getCodeAddress(type(MorphoActions).creationCode); + + // Check the quark operations + assertEq(result.quarkOperations.length, 1, "one operation"); + assertEq( + result.quarkOperations[0].scriptAddress, + paycallAddress, + "script address is correct given the code jar address on mainnet" + ); + + assertEq( + result.quarkOperations[0].scriptCalldata, + abi.encodeWithSelector( + Paycall.run.selector, + MorphoActionsAddress, + abi.encodeCall( + MorphoActions.repayAndWithdrawCollateral, + ( + MorphoInfo.getMorphoAddress(1), + MorphoInfo.getMarketParams(1, "WBTC", "USDC"), + type(uint256).max, + 0 + ) + ), + 0.1e6 + ), + "calldata is Paycall.run(MorphoActions.repayAndWithdrawCollateral(MorphoInfo.getMorphoAddress(1), MorphoInfo.getMarketParams(1, WBTC, USDC), type(uint256).max, 0);" + ); + + assertEq(result.quarkOperations[0].scriptSources.length, 2); + assertEq(result.quarkOperations[0].scriptSources[0], type(MorphoActions).creationCode); + assertEq( + result.quarkOperations[0].scriptSources[1], + abi.encodePacked(type(Paycall).creationCode, abi.encode(ETH_USD_PRICE_FEED_1, USDC_1)) + ); + assertEq( + result.quarkOperations[0].expiry, BLOCK_TIMESTAMP + 7 days, "expiry is current blockTimestamp + 7 days" + ); + + // check the actions + assertEq(result.actions.length, 1, "one action"); + assertEq(result.actions[0].chainId, 1, "operation is on chainid 1"); + assertEq(result.actions[0].quarkAccount, address(0xa11ce), "0xa11ce sends the funds"); + assertEq(result.actions[0].actionType, "MORPHO_REPAY", "action type is 'MORPHO_REPAY'"); + assertEq(result.actions[0].paymentMethod, "PAY_CALL", "payment method is 'PAY_CALL'"); + assertEq(result.actions[0].paymentToken, USDC_1, "payment token is USDC on mainnet"); + assertEq(result.actions[0].paymentMaxCost, 0.1e6, "payment should have max cost of 0.1e6"); + + assertEq( + result.actions[0].actionContext, + abi.encode( + Actions.MorphoRepayActionContext({ + amount: type(uint256).max, + assetSymbol: "USDC", + chainId: 1, + collateralAmount: 0, + collateralAssetSymbol: "WBTC", + collateralTokenPrice: WBTC_PRICE, + collateralToken: wbtc_(1), + price: USDC_PRICE, + token: usdc_(1), + morpho: MorphoInfo.getMorphoAddress(1), + morphoMarketId: MorphoInfo.marketId(MorphoInfo.getMarketParams(1, "WBTC", "USDC")) + }) + ), + "action context encoded from MorphoRepayActionContext" + ); + assertNotEq(result.eip712Data.digest, hex"", "non-empty digest"); + assertNotEq(result.eip712Data.domainSeparator, hex"", "non-empty domain separator"); + assertNotEq(result.eip712Data.hashStruct, hex"", "non-empty hashStruct"); + } + + function testCometRepayMaxWithBridge() public { + PaymentInfo.PaymentMaxCost[] memory maxCosts = new PaymentInfo.PaymentMaxCost[](2); + maxCosts[0] = PaymentInfo.PaymentMaxCost({chainId: 1, amount: 0.1e6}); + maxCosts[1] = PaymentInfo.PaymentMaxCost({chainId: 8453, amount: 0.1e6}); + + MorphoPortfolio[] memory morphoPortfolios = new MorphoPortfolio[](1); + morphoPortfolios[0] = MorphoPortfolio({ + marketId: MorphoInfo.marketId(MorphoInfo.getMarketParams(8453, "WETH", "USDC")), + loanToken: "USDC", + collateralToken: "WETH", + borrowedBalance: 10e6, + collateralBalance: 1e8 + }); + + ChainPortfolio[] memory chainPortfolios = new ChainPortfolio[](2); + chainPortfolios[0] = ChainPortfolio({ + chainId: 1, + account: address(0xa11ce), + nextNonce: 12, + assetSymbols: Arrays.stringArray("USDC", "USDT", "WBTC", "WETH"), + assetBalances: Arrays.uintArray(50e6, 0, 0, 0), // has 50 USDC + cometPortfolios: emptyCometPortfolios_(), + morphoPortfolios: emptyMorphoPortfolios_() + }); + chainPortfolios[1] = ChainPortfolio({ + chainId: 8453, + account: address(0xa11ce), + nextNonce: 12, + assetSymbols: Arrays.stringArray("USDC", "USDT", "WBTC", "WETH"), + assetBalances: Arrays.uintArray(0, 0, 0, 0), // has 0 USDC on base + cometPortfolios: emptyCometPortfolios_(), + morphoPortfolios: morphoPortfolios + }); + + QuarkBuilder builder = new QuarkBuilder(); + QuarkBuilder.BuilderResult memory result = builder.morphoRepay( + repayIntent_( + 8453, + "USDC", + type(uint256).max, // repaying max (all 10 USDC) + "WETH", + 0 + ), + chainAccountsFromChainPortfolios(chainPortfolios), + paymentUsdc_(maxCosts) + ); + + assertEq(result.paymentCurrency, "usdc", "usdc currency"); + + address cctpBridgeActionsAddress = CodeJarHelper.getCodeAddress(type(CCTPBridgeActions).creationCode); + address MorphoActionsAddress = CodeJarHelper.getCodeAddress(type(MorphoActions).creationCode); + address paycallAddress = CodeJarHelper.getCodeAddress( + abi.encodePacked(type(Paycall).creationCode, abi.encode(ETH_USD_PRICE_FEED_1, USDC_1)) + ); + address paycallAddressBase = CodeJarHelper.getCodeAddress( + abi.encodePacked(type(Paycall).creationCode, abi.encode(ETH_USD_PRICE_FEED_8453, USDC_8453)) + ); + + // Check the quark operations + // first operation + assertEq(result.quarkOperations.length, 2, "two operations"); + assertEq( + result.quarkOperations[0].scriptAddress, + paycallAddress, + "script address is correct given the code jar address on base" + ); + assertEq( + result.quarkOperations[0].scriptCalldata, + abi.encodeWithSelector( + Paycall.run.selector, + cctpBridgeActionsAddress, + abi.encodeWithSelector( + CCTPBridgeActions.bridgeUSDC.selector, + address(0xBd3fa81B58Ba92a82136038B25aDec7066af3155), + 10.11e6, // 10e6 repaid + .1% buffer + 0.1e6 max cost on Base + 6, + bytes32(uint256(uint160(0xa11ce))), + usdc_(1) + ), + 0.1e6 + ), + "calldata is Paycall.run(CCTPBridgeActions.bridgeUSDC(0xBd3fa81B58Ba92a82136038B25aDec7066af3155, 10.11e6, 6, 0xa11ce, USDC_1)), 0.1e6);" + ); + assertEq(result.quarkOperations[0].scriptSources.length, 2); + assertEq(result.quarkOperations[0].scriptSources[0], type(CCTPBridgeActions).creationCode); + assertEq( + result.quarkOperations[0].scriptSources[1], + abi.encodePacked(type(Paycall).creationCode, abi.encode(ETH_USD_PRICE_FEED_1, USDC_1)) + ); + assertEq( + result.quarkOperations[0].expiry, BLOCK_TIMESTAMP + 7 days, "expiry is current blockTimestamp + 7 days" + ); + + // second operation + assertEq( + result.quarkOperations[1].scriptAddress, + paycallAddressBase, + "script address[1] has been wrapped with paycall address" + ); + assertEq( + result.quarkOperations[1].scriptCalldata, + abi.encodeWithSelector( + Paycall.run.selector, + MorphoActionsAddress, + abi.encodeCall( + MorphoActions.repayAndWithdrawCollateral, + ( + MorphoInfo.getMorphoAddress(8453), + MorphoInfo.getMarketParams(8453, "WETH", "USDC"), + type(uint256).max, + 0 + ) // Repaying in shares + ), + 0.1e6 + ), + "calldata is Paycall.run(MorphoActions.repayAndWithdrawCollateral(MorphoInfo.getMorphoAddress(8453), MorphoInfo.getMarketParams(8453, WETH, USDC), type(uint256).max, 0);" + ); + + assertEq(result.quarkOperations[1].scriptSources.length, 2); + assertEq(result.quarkOperations[1].scriptSources[0], type(MorphoActions).creationCode); + assertEq( + result.quarkOperations[1].scriptSources[1], + abi.encodePacked(type(Paycall).creationCode, abi.encode(ETH_USD_PRICE_FEED_8453, USDC_8453)) + ); + assertEq( + result.quarkOperations[1].expiry, BLOCK_TIMESTAMP + 7 days, "expiry is current blockTimestamp + 7 days" + ); + + // check the actions + // first action + assertEq(result.actions[0].chainId, 1, "operation is on chainid 1"); + assertEq(result.actions[0].quarkAccount, address(0xa11ce), "0xa11ce sends the funds"); + assertEq(result.actions[0].actionType, "BRIDGE", "action type is 'BRIDGE'"); + assertEq(result.actions[0].paymentMethod, "PAY_CALL", "payment method is 'PAY_CALL'"); + assertEq(result.actions[0].paymentToken, USDC_1, "payment token is USDC on mainnet"); + assertEq(result.actions[0].paymentMaxCost, 0.1e6, "payment should have max cost of 0.1e6"); + assertEq( + result.actions[0].actionContext, + abi.encode( + Actions.BridgeActionContext({ + amount: 10.11e6, + assetSymbol: "USDC", + bridgeType: Actions.BRIDGE_TYPE_CCTP, + chainId: 1, + destinationChainId: 8453, + price: USDC_PRICE, + recipient: address(0xa11ce), + token: USDC_1 + }) + ), + "action context encoded from BridgeActionContext" + ); + + // second action + assertEq(result.actions[1].chainId, 8453, "operation is on chainid 8453"); + assertEq(result.actions[1].quarkAccount, address(0xa11ce), "0xa11ce sends the funds"); + assertEq(result.actions[1].actionType, "MORPHO_REPAY", "action type is 'MORPHO_REPAY'"); + assertEq(result.actions[1].paymentMethod, "PAY_CALL", "payment method is 'PAY_CALL'"); + assertEq(result.actions[1].paymentToken, USDC_8453, "payment token is USDC on Base"); + assertEq(result.actions[1].paymentMaxCost, 0.1e6, "payment should have max cost of 0.1e6"); + + assertEq( + result.actions[1].actionContext, + abi.encode( + Actions.MorphoRepayActionContext({ + amount: type(uint256).max, + assetSymbol: "USDC", + chainId: 8453, + collateralAmount: 0, + collateralAssetSymbol: "WETH", + collateralTokenPrice: WETH_PRICE, + collateralToken: weth_(8453), + price: USDC_PRICE, + token: usdc_(8453), + morpho: MorphoInfo.getMorphoAddress(8453), + morphoMarketId: MorphoInfo.marketId(MorphoInfo.getMarketParams(8453, "WETH", "USDC")) + }) + ), + "action context encoded from MorphoRepayActionContext" + ); + assertNotEq(result.eip712Data.digest, hex"", "non-empty digest"); + assertNotEq(result.eip712Data.domainSeparator, hex"", "non-empty domain separator"); + assertNotEq(result.eip712Data.hashStruct, hex"", "non-empty hashStruct"); + } +} diff --git a/test/builder/QuarkBuilderSwap.t.sol b/test/builder/QuarkBuilderSwap.t.sol index 2685f14f..6721b373 100644 --- a/test/builder/QuarkBuilderSwap.t.sol +++ b/test/builder/QuarkBuilderSwap.t.sol @@ -250,7 +250,8 @@ contract QuarkBuilderSwapTest is Test, QuarkBuilderTest { chainId: 1, quarkStates: quarkStates_(address(0xa11ce), 12), assetPositionsList: assetPositionsList, - cometPositions: emptyCometPositions_() + cometPositions: emptyCometPositions_(), + morphoPositions: emptyMorphoPositions_() }); QuarkBuilder.BuilderResult memory result = builder.swap( buyUsdc_(1, weth_(1), 1e18, 3000e6, address(0xa11ce), BLOCK_TIMESTAMP), // swap 1 ETH on chain 1 to 3000 USDC @@ -409,19 +410,22 @@ contract QuarkBuilderSwapTest is Test, QuarkBuilderTest { chainId: 1, quarkStates: quarkStates_(address(0xa11ce), 12), assetPositionsList: assetPositionsList_(1, address(0xa11ce), uint256(9005e6)), - cometPositions: emptyCometPositions_() + cometPositions: emptyCometPositions_(), + morphoPositions: emptyMorphoPositions_() }); chainAccountsList[1] = Accounts.ChainAccounts({ chainId: 8453, quarkStates: quarkStates_(address(0xb0b), 2), assetPositionsList: assetPositionsList_(8453, address(0xb0b), uint256(0)), - cometPositions: emptyCometPositions_() + cometPositions: emptyCometPositions_(), + morphoPositions: emptyMorphoPositions_() }); chainAccountsList[2] = Accounts.ChainAccounts({ chainId: 7777, quarkStates: quarkStates_(address(0xc0b), 5), assetPositionsList: assetPositionsList_(7777, address(0xc0b), uint256(0)), - cometPositions: emptyCometPositions_() + cometPositions: emptyCometPositions_(), + morphoPositions: emptyMorphoPositions_() }); QuarkBuilder.BuilderResult memory result = builder.swap( diff --git a/test/builder/QuarkBuilderTransfer.t.sol b/test/builder/QuarkBuilderTransfer.t.sol index 8f0f241f..368e409a 100644 --- a/test/builder/QuarkBuilderTransfer.t.sol +++ b/test/builder/QuarkBuilderTransfer.t.sol @@ -609,7 +609,8 @@ contract QuarkBuilderTransferTest is Test, QuarkBuilderTest { chainId: 1, quarkStates: quarkStates_(address(0xa11ce), 12), assetPositionsList: assetPositionsList_(1, address(0xa11ce), uint256(10e6)), - cometPositions: emptyCometPositions_() + cometPositions: emptyCometPositions_(), + morphoPositions: emptyMorphoPositions_() }); QuarkBuilder.BuilderResult memory result = builder.transfer( @@ -685,13 +686,15 @@ contract QuarkBuilderTransferTest is Test, QuarkBuilderTest { chainId: 1, quarkStates: quarkStates_(address(0xa11ce), 12), assetPositionsList: assetPositionsList_(1, address(0xa11ce), 8e6), - cometPositions: emptyCometPositions_() + cometPositions: emptyCometPositions_(), + morphoPositions: emptyMorphoPositions_() }); chainAccountsList[1] = Accounts.ChainAccounts({ chainId: 8453, quarkStates: quarkStates_(address(0xb0b), 2), assetPositionsList: assetPositionsList_(8453, address(0xb0b), 4e6), - cometPositions: emptyCometPositions_() + cometPositions: emptyCometPositions_(), + morphoPositions: emptyMorphoPositions_() }); QuarkBuilder.BuilderResult memory result = builder.transfer( @@ -820,19 +823,22 @@ contract QuarkBuilderTransferTest is Test, QuarkBuilderTest { chainId: 1, quarkStates: quarkStates_(address(0xa11ce), 12), assetPositionsList: assetPositionsList_(1, address(0xa11ce), 8e6), - cometPositions: emptyCometPositions_() + cometPositions: emptyCometPositions_(), + morphoPositions: emptyMorphoPositions_() }); chainAccountsList[1] = Accounts.ChainAccounts({ chainId: 8453, quarkStates: quarkStates_(address(0xb0b), 2), assetPositionsList: assetPositionsList_(8453, address(0xb0b), 4e6), - cometPositions: emptyCometPositions_() + cometPositions: emptyCometPositions_(), + morphoPositions: emptyMorphoPositions_() }); chainAccountsList[2] = Accounts.ChainAccounts({ chainId: 7777, quarkStates: quarkStates_(address(0xfe11a), 2), assetPositionsList: assetPositionsList_(7777, address(0xfe11a), 5e6), - cometPositions: emptyCometPositions_() + cometPositions: emptyCometPositions_(), + morphoPositions: emptyMorphoPositions_() }); // User has total holding of 17 USDC, but only 12 USDC is available for transfer/bridge to 8453, and missing 5 USDC stuck in random L2 so will revert with FundsUnavailable error @@ -919,7 +925,8 @@ contract QuarkBuilderTransferTest is Test, QuarkBuilderTest { chainId: 1, quarkStates: quarkStates_(address(0xa11ce), 12), assetPositionsList: assetPositionsList, - cometPositions: emptyCometPositions_() + cometPositions: emptyCometPositions_(), + morphoPositions: emptyMorphoPositions_() }); // Transfer 1.5ETH to 0xceecee on chain 1 @@ -1020,7 +1027,8 @@ contract QuarkBuilderTransferTest is Test, QuarkBuilderTest { chainId: 1, quarkStates: quarkStates_(address(0xa11ce), 12), assetPositionsList: assetPositionsList, - cometPositions: emptyCometPositions_() + cometPositions: emptyCometPositions_(), + morphoPositions: emptyMorphoPositions_() }); // Transfer 1.5ETH to 0xceecee on chain 1 @@ -1129,7 +1137,8 @@ contract QuarkBuilderTransferTest is Test, QuarkBuilderTest { chainId: 1, quarkStates: quarkStates_(address(0xa11ce), 12), assetPositionsList: assetPositionsList, - cometPositions: emptyCometPositions_() + cometPositions: emptyCometPositions_(), + morphoPositions: emptyMorphoPositions_() }); // Transfer max ETH to 0xceecee on chain 1 @@ -1239,7 +1248,8 @@ contract QuarkBuilderTransferTest is Test, QuarkBuilderTest { chainId: 1, quarkStates: quarkStates_(address(0xa11ce), 12), assetPositionsList: assetPositionsList, - cometPositions: emptyCometPositions_() + cometPositions: emptyCometPositions_(), + morphoPositions: emptyMorphoPositions_() }); // Transfer 1.5ETH to 0xceecee on chain 1 diff --git a/test/builder/lib/QuarkBuilderTest.sol b/test/builder/lib/QuarkBuilderTest.sol index 9c2e2398..235af9de 100644 --- a/test/builder/lib/QuarkBuilderTest.sol +++ b/test/builder/lib/QuarkBuilderTest.sol @@ -11,7 +11,7 @@ import {Quotecall} from "src/Quotecall.sol"; import {PaymentInfo} from "src/builder/PaymentInfo.sol"; import {QuarkBuilder} from "src/builder/QuarkBuilder.sol"; import {Strings} from "src/builder/Strings.sol"; - +import {MorphoInfo} from "src/builder/MorphoInfo.sol"; import {Arrays} from "test/builder/lib/Arrays.sol"; contract QuarkBuilderTest { @@ -42,6 +42,16 @@ contract QuarkBuilderTest { address constant WETH_8453 = 0x4200000000000000000000000000000000000006; uint256 constant WETH_PRICE = 3000e8; + address constant WBTC_1 = 0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599; + address constant WBTC_7777 = address(0xDEADBEEF); + address constant WBTC_8453 = address(0xDEADBEEF); + uint256 constant WBTC_PRICE = 66000e8; + + address constant CBETH_1 = 0xBe9895146f7AF43049ca1c1AE358B0541Ea49704; + address constant CBETH_7777 = address(0xDEADBEEF); + address constant CBETH_8453 = 0x2Ae3F1Ec7F1F5012CFEab0185bfc7aa3cf0DEc22; + uint256 constant CBETH_PRICE = 3300e8; + address constant ETH_USD_PRICE_FEED_1 = 0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419; address constant ETH_USD_PRICE_FEED_8453 = 0x71041dddad3595F9CEd3DcCFBe3D1F4b0a16Bb70; @@ -84,19 +94,22 @@ contract QuarkBuilderTest { chainId: 1, quarkStates: quarkStates_(address(0xa11ce), 12), assetPositionsList: assetPositionsList_(1, address(0xa11ce), uint256(amount / 2)), - cometPositions: emptyCometPositions_() + cometPositions: emptyCometPositions_(), + morphoPositions: emptyMorphoPositions_() }); chainAccountsList[1] = Accounts.ChainAccounts({ chainId: 8453, quarkStates: quarkStates_(address(0xb0b), 2), assetPositionsList: assetPositionsList_(8453, address(0xb0b), uint256(amount / 2)), - cometPositions: emptyCometPositions_() + cometPositions: emptyCometPositions_(), + morphoPositions: emptyMorphoPositions_() }); chainAccountsList[2] = Accounts.ChainAccounts({ chainId: 7777, quarkStates: quarkStates_(address(0xc0b), 5), assetPositionsList: assetPositionsList_(7777, address(0xc0b), uint256(0)), - cometPositions: emptyCometPositions_() + cometPositions: emptyCometPositions_(), + morphoPositions: emptyMorphoPositions_() }); return chainAccountsList; } @@ -106,6 +119,11 @@ contract QuarkBuilderTest { return emptyCometPositions; } + function emptyMorphoPositions_() internal pure returns (Accounts.MorphoPositions[] memory) { + Accounts.MorphoPositions[] memory emptyMorphoPositions = new Accounts.MorphoPositions[](0); + return emptyMorphoPositions; + } + function quarkStates_() internal pure returns (Accounts.QuarkState[] memory) { Accounts.QuarkState[] memory quarkStates = new Accounts.QuarkState[](1); quarkStates[0] = quarkState_(); @@ -165,6 +183,20 @@ contract QuarkBuilderTest { return accountBalances; } + function wbtc_(uint256 chainId) internal pure returns (address) { + if (chainId == 1) return WBTC_1; + if (chainId == 7777) return WBTC_7777; + if (chainId == 8453) return WBTC_8453; + revert("no mock WBTC for chain id"); + } + + function cbEth_(uint256 chainId) internal pure returns (address) { + if (chainId == 1) return CBETH_1; + if (chainId == 7777) return CBETH_7777; + if (chainId == 8453) return CBETH_8453; + revert("no mock cbETH for chain id"); + } + function link_(uint256 chainId) internal pure returns (address) { if (chainId == 1) return LINK_1; if (chainId == 7777) return LINK_7777; // Mock with random chain's LINK @@ -266,6 +298,7 @@ contract QuarkBuilderTest { string[] assetSymbols; uint256[] assetBalances; CometPortfolio[] cometPortfolios; + MorphoPortfolio[] morphoPortfolios; } struct CometPortfolio { @@ -276,11 +309,24 @@ contract QuarkBuilderTest { uint256[] collateralAssetBalances; } + struct MorphoPortfolio { + bytes32 marketId; + string loanToken; + string collateralToken; + uint256 borrowedBalance; + uint256 collateralBalance; + } + function emptyCometPortfolios_() internal pure returns (CometPortfolio[] memory) { CometPortfolio[] memory emptyCometPortfolios = new CometPortfolio[](0); return emptyCometPortfolios; } + function emptyMorphoPortfolios_() internal pure returns (MorphoPortfolio[] memory) { + MorphoPortfolio[] memory emptyMorphoPortfolios = new MorphoPortfolio[](0); + return emptyMorphoPortfolios; + } + function chainAccountsFromChainPortfolios(ChainPortfolio[] memory chainPortfolios) internal pure @@ -300,6 +346,9 @@ contract QuarkBuilderTest { // cometPositions: cometPositionsFor cometPositions: cometPositionsForCometPorfolios( chainPortfolios[i].chainId, chainPortfolios[i].account, chainPortfolios[i].cometPortfolios + ), + morphoPositions: morphoPositionsForMorphoPortfolios( + chainPortfolios[i].chainId, chainPortfolios[i].account, chainPortfolios[i].morphoPortfolios ) }); } @@ -343,6 +392,37 @@ contract QuarkBuilderTest { return cometPositions; } + function morphoPositionsForMorphoPortfolios( + uint256 chainId, + address account, + MorphoPortfolio[] memory morphoPortfolios + ) internal pure returns (Accounts.MorphoPositions[] memory) { + Accounts.MorphoPositions[] memory morphoPositions = new Accounts.MorphoPositions[](morphoPortfolios.length); + + for (uint256 i = 0; i < morphoPortfolios.length; ++i) { + MorphoPortfolio memory morphoPortfolio = morphoPortfolios[i]; + (address loanAsset,,) = assetInfo(morphoPortfolio.loanToken, chainId); + (address collateralAsset,,) = assetInfo(morphoPortfolio.collateralToken, chainId); + + morphoPositions[i] = Accounts.MorphoPositions({ + marketId: morphoPortfolio.marketId, + morpho: MorphoInfo.getMorphoAddress(chainId), + loanToken: loanAsset, + collateralToken: collateralAsset, + borrowPosition: Accounts.MorphoBorrowPosition({ + accounts: Arrays.addressArray(account), + borrowed: Arrays.uintArray(morphoPortfolio.borrowedBalance) + }), + collateralPosition: Accounts.MorphoCollateralPosition({ + accounts: Arrays.addressArray(account), + balances: Arrays.uintArray(morphoPortfolio.collateralBalance) + }) + }); + } + + return morphoPositions; + } + function baseAssetForComet(uint256 chainId, address comet) internal pure returns (address) { if (comet == COMET_1_USDC || comet == COMET_8453_USDC) { return usdc_(chainId); @@ -386,8 +466,12 @@ contract QuarkBuilderTest { return (eth_(), 18, WETH_PRICE); } else if (Strings.stringEq(assetSymbol, "LINK")) { return (link_(chainId), 18, LINK_PRICE); + } else if (Strings.stringEq(assetSymbol, "WBTC")) { + return (wbtc_(chainId), 8, WBTC_PRICE); + } else if (Strings.stringEq(assetSymbol, "cbETH")) { + return (cbEth_(chainId), 18, CBETH_PRICE); } else { - revert("unknown assetSymbol"); + revert("[Testlib QuarkBuilderTest]: unknown assetSymbol"); } } } diff --git a/test/on-chain-info-verification/MorphoInfo.t.sol b/test/on-chain-info-verification/MorphoInfo.t.sol new file mode 100644 index 00000000..ded392ba --- /dev/null +++ b/test/on-chain-info-verification/MorphoInfo.t.sol @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: BSD-3-Clause +pragma solidity 0.8.23; + +import "forge-std/Test.sol"; +import "forge-std/console.sol"; +import "forge-std/StdUtils.sol"; +import "forge-std/StdMath.sol"; + +import {HashMap} from "src/builder/HashMap.sol"; +import {IMorpho, MarketParams} from "src/interfaces/IMorpho.sol"; +import {IMetaMorpho} from "src/interfaces/IMetaMorpho.sol"; +import {MorphoInfo} from "src/builder/MorphoInfo.sol"; +import {IERC4626} from "openzeppelin/interfaces/IERC4626.sol"; + +/** + * Verify the MorphoInfo info is correct on-chain + */ +contract MorphoInfoTest is Test { + function testEthMainnet() public { + // Fork setup to get on-chain on eth mainnet + vm.createSelectFork( + vm.envString("MAINNET_RPC_URL"), + 20580267 // 2024-08-21 16:27:00 PST + ); + + verifyKnownMarketsParams(1); + verifyKnownVaults(1); + } + + function testBaseMainnet() public { + // Fork setup to get on-chain on base mainnet + vm.createSelectFork( + vm.envString("BASE_MAINNET_RPC_URL"), + 18746757 // 2024-08-21 16:27:00 PST + ); + + verifyKnownMarketsParams(8453); + verifyKnownVaults(8453); + } + + function testEthSepolia() public { + // Fork setup to get on-chain on base sepolia + vm.createSelectFork( + vm.envString("SEPOLIA_RPC_URL"), + 6546096 // 2024-08-21 16:27:00 PST + ); + + verifyKnownMarketsParams(11155111); + verifyKnownVaults(11155111); + } + + function testBaseSepolia() public { + // Fork setup to get on-chain on base sepolia + vm.createSelectFork( + vm.envString("BASE_SEPOLIA_RPC_URL"), + 14257289 // 2024-08-21 16:27:00 PST + ); + + verifyKnownMarketsParams(84532); + verifyKnownVaults(84532); + } + + function verifyKnownMarketsParams(uint256 chainId) internal { + HashMap.Map memory markets = MorphoInfo.getKnownMorphoMarketsParams(); + bytes[] memory keys = HashMap.keys(markets); + MorphoInfo.MorphoMarketKey[] memory marketKeys = new MorphoInfo.MorphoMarketKey[](keys.length); + for (uint256 i = 0; i < keys.length; ++i) { + marketKeys[i] = abi.decode(keys[i], (MorphoInfo.MorphoMarketKey)); + } + + // Filter and verify + for (uint256 i = 0; i < marketKeys.length; ++i) { + if (marketKeys[i].chainId == chainId) { + MarketParams memory marketParams = MorphoInfo.getMarketParams(markets, marketKeys[i]); + (uint128 totalSupplyAssets,,,, uint128 lastUpdate,) = + IMorpho(MorphoInfo.getMorphoAddress(chainId)).market(MorphoInfo.marketId(marketParams)); + assertGt( + totalSupplyAssets, + 0, + "MorphoInfo has markets with NO liquidity, something is wrong and may impact user expereince" + ); + assertGt(lastUpdate, 0, "MorphoInfo has markets with NO lastUpdate, the market is never used"); + } + } + } + + function verifyKnownVaults(uint256 chainId) internal { + HashMap.Map memory vaults = MorphoInfo.getKnownMorphoVaultsAddresses(); + bytes[] memory keys = HashMap.keys(vaults); + MorphoInfo.MorphoVaultKey[] memory vaultKeys = new MorphoInfo.MorphoVaultKey[](keys.length); + for (uint256 i = 0; i < keys.length; ++i) { + vaultKeys[i] = abi.decode(keys[i], (MorphoInfo.MorphoVaultKey)); + } + + // Filter and verify + for (uint256 i = 0; i < vaultKeys.length; ++i) { + if (vaultKeys[i].chainId == chainId) { + address vault = MorphoInfo.getMorphoVaultAddress(vaults, vaultKeys[i]); + assertGt( + IERC4626(vault).totalAssets(), + 0, + "MorphoInfo has vaults with NO assets, empty vault may impact user expereince" + ); + } + } + } +} From 528477d8145dabbedfd1831d77e2e56166590485 Mon Sep 17 00:00:00 2001 From: Hans Wang <2709448+cwang25@users.noreply.github.com> Date: Fri, 13 Sep 2024 11:39:54 -0700 Subject: [PATCH 13/13] Morpho Vault integration (p3) (#68) - Morpho Vault integration (earn/withdraw flow) --- src/builder/Accounts.sol | 21 + src/builder/Actions.sol | 154 ++++ src/builder/QuarkBuilder.sol | 267 +++++- test/builder/QuarkBuilderCometBorrow.t.sol | 36 +- test/builder/QuarkBuilderCometRepay.t.sol | 42 +- test/builder/QuarkBuilderCometSupply.t.sol | 6 +- test/builder/QuarkBuilderCometWithdraw.t.sol | 12 +- test/builder/QuarkBuilderMorphoBorrow.t.sol | 39 +- test/builder/QuarkBuilderMorphoRepay.t.sol | 36 +- .../QuarkBuilderMorphoVaultSupply.t.sol | 865 ++++++++++++++++++ .../QuarkBuilderMorphoVaultWithdraw.t.sol | 489 ++++++++++ test/builder/QuarkBuilderSwap.t.sol | 12 +- test/builder/QuarkBuilderTransfer.t.sol | 30 +- test/builder/lib/QuarkBuilderTest.sol | 50 +- 14 files changed, 1984 insertions(+), 75 deletions(-) create mode 100644 test/builder/QuarkBuilderMorphoVaultSupply.t.sol create mode 100644 test/builder/QuarkBuilderMorphoVaultWithdraw.t.sol diff --git a/src/builder/Accounts.sol b/src/builder/Accounts.sol index 56307da2..02396656 100644 --- a/src/builder/Accounts.sol +++ b/src/builder/Accounts.sol @@ -14,6 +14,7 @@ library Accounts { AssetPositions[] assetPositionsList; CometPositions[] cometPositions; MorphoPositions[] morphoPositions; + MorphoVaultPositions[] morphoVaultPositions; } // We map this to the Portfolio data structure that the client will already have. @@ -77,6 +78,13 @@ library Accounts { uint256[] balances; } + struct MorphoVaultPositions { + address asset; + address[] accounts; + uint256[] balances; + address vault; + } + function findChainAccounts(uint256 chainId, ChainAccounts[] memory chainAccountsList) internal pure @@ -119,6 +127,19 @@ library Accounts { } } + function findMorphoVaultPositions(uint256 chainId, address asset, ChainAccounts[] memory chainAccountsList) + internal + pure + returns (MorphoVaultPositions memory found) + { + ChainAccounts memory chainAccounts = findChainAccounts(chainId, chainAccountsList); + for (uint256 i = 0; i < chainAccounts.morphoVaultPositions.length; ++i) { + if (chainAccounts.morphoVaultPositions[i].asset == asset) { + return found = chainAccounts.morphoVaultPositions[i]; + } + } + } + function findAssetPositions(string memory assetSymbol, AssetPositions[] memory assetPositionsList) internal pure diff --git a/src/builder/Actions.sol b/src/builder/Actions.sol index b55da2e9..1d13f0b5 100644 --- a/src/builder/Actions.sol +++ b/src/builder/Actions.sol @@ -40,9 +40,11 @@ library Actions { string constant ACTION_TYPE_REPAY = "REPAY"; string constant ACTION_TYPE_MORPHO_REPAY = "MORPHO_REPAY"; string constant ACTION_TYPE_SUPPLY = "SUPPLY"; + string constant ACTION_TYPE_MORPHO_VAULT_SUPPLY = "MORPHO_VAULT_SUPPLY"; string constant ACTION_TYPE_SWAP = "SWAP"; string constant ACTION_TYPE_TRANSFER = "TRANSFER"; string constant ACTION_TYPE_WITHDRAW = "WITHDRAW"; + string constant ACTION_TYPE_MORPHO_VAULT_WITHDRAW = "MORPHO_VAULT_WITHDRAW"; string constant ACTION_TYPE_WITHDRAW_AND_BORROW = "WITHDRAW_AND_BORROW"; string constant ACTION_TYPE_WRAP = "WRAP"; string constant ACTION_TYPE_UNWRAP = "UNWRAP"; @@ -199,6 +201,24 @@ library Actions { address repayer; } + struct MorphoVaultSupply { + Accounts.ChainAccounts[] chainAccountsList; + string assetSymbol; + uint256 amount; + uint256 blockTimestamp; + uint256 chainId; + address sender; + } + + struct MorphoVaultWithdraw { + Accounts.ChainAccounts[] chainAccountsList; + string assetSymbol; + uint256 amount; + uint256 blockTimestamp; + uint256 chainId; + address withdrawer; + } + // Note: Mainly to avoid stack too deep errors struct BridgeOperationInfo { string assetSymbol; @@ -285,6 +305,15 @@ library Actions { address token; } + struct MorphoVaultSupplyActionContext { + uint256 amount; + string assetSymbol; + uint256 chainId; + address morphoVault; + uint256 price; + address token; + } + struct SwapActionContext { uint256 chainId; uint256 feeAmount; @@ -335,6 +364,15 @@ library Actions { address token; } + struct MorphoVaultWithdrawActionContext { + uint256 amount; + string assetSymbol; + uint256 chainId; + address morphoVault; + uint256 price; + address token; + } + struct WithdrawAndBorrowActionContext { uint256 borrowAmount; uint256 chainId; @@ -1055,6 +1093,122 @@ library Actions { return (quarkOperation, action); } + function morphoVaultSupply( + MorphoVaultSupply memory vaultSupply, + PaymentInfo.Payment memory payment, + bool useQuotecall + ) internal pure returns (IQuarkWallet.QuarkOperation memory, Action memory) { + bytes[] memory scriptSources = new bytes[](1); + scriptSources[0] = type(MorphoVaultActions).creationCode; + + Accounts.ChainAccounts memory accounts = + Accounts.findChainAccounts(vaultSupply.chainId, vaultSupply.chainAccountsList); + + Accounts.AssetPositions memory assetPositions = + Accounts.findAssetPositions(vaultSupply.assetSymbol, accounts.assetPositionsList); + + Accounts.QuarkState memory accountState = Accounts.findQuarkState(vaultSupply.sender, accounts.quarkStates); + + bytes memory scriptCalldata = abi.encodeWithSelector( + MorphoVaultActions.deposit.selector, + MorphoInfo.getMorphoVaultAddress(vaultSupply.chainId, vaultSupply.assetSymbol), + assetPositions.asset, + vaultSupply.amount + ); + + // Construct QuarkOperation + IQuarkWallet.QuarkOperation memory quarkOperation = IQuarkWallet.QuarkOperation({ + nonce: accountState.quarkNextNonce, + scriptAddress: CodeJarHelper.getCodeAddress(type(MorphoVaultActions).creationCode), + scriptCalldata: scriptCalldata, + scriptSources: scriptSources, + expiry: vaultSupply.blockTimestamp + STANDARD_EXPIRY_BUFFER + }); + + // Construct Action + MorphoVaultSupplyActionContext memory vaultSupplyActionContext = MorphoVaultSupplyActionContext({ + amount: vaultSupply.amount, + assetSymbol: assetPositions.symbol, + chainId: vaultSupply.chainId, + morphoVault: MorphoInfo.getMorphoVaultAddress(vaultSupply.chainId, vaultSupply.assetSymbol), + price: assetPositions.usdPrice, + token: assetPositions.asset + }); + + Action memory action = Actions.Action({ + chainId: vaultSupply.chainId, + quarkAccount: vaultSupply.sender, + actionType: ACTION_TYPE_MORPHO_VAULT_SUPPLY, + actionContext: abi.encode(vaultSupplyActionContext), + paymentMethod: PaymentInfo.paymentMethodForPayment(payment, useQuotecall), + // Null address for OFFCHAIN payment. + paymentToken: payment.isToken ? PaymentInfo.knownToken(payment.currency, vaultSupply.chainId).token : address(0), + paymentTokenSymbol: payment.currency, + paymentMaxCost: payment.isToken ? PaymentInfo.findMaxCost(payment, vaultSupply.chainId) : 0 + }); + + return (quarkOperation, action); + } + + function morphoVaultWithdraw(MorphoVaultWithdraw memory vaultWithdraw, PaymentInfo.Payment memory payment) + internal + pure + returns (IQuarkWallet.QuarkOperation memory, Action memory) + { + bytes[] memory scriptSources = new bytes[](1); + scriptSources[0] = type(MorphoVaultActions).creationCode; + + Accounts.ChainAccounts memory accounts = + Accounts.findChainAccounts(vaultWithdraw.chainId, vaultWithdraw.chainAccountsList); + + Accounts.AssetPositions memory assetPositions = + Accounts.findAssetPositions(vaultWithdraw.assetSymbol, accounts.assetPositionsList); + + Accounts.QuarkState memory accountState = + Accounts.findQuarkState(vaultWithdraw.withdrawer, accounts.quarkStates); + + bytes memory scriptCalldata = abi.encodeWithSelector( + MorphoVaultActions.withdraw.selector, + MorphoInfo.getMorphoVaultAddress(vaultWithdraw.chainId, vaultWithdraw.assetSymbol), + vaultWithdraw.amount + ); + + // Construct QuarkOperation + IQuarkWallet.QuarkOperation memory quarkOperation = IQuarkWallet.QuarkOperation({ + nonce: accountState.quarkNextNonce, + scriptAddress: CodeJarHelper.getCodeAddress(type(MorphoVaultActions).creationCode), + scriptCalldata: scriptCalldata, + scriptSources: scriptSources, + expiry: vaultWithdraw.blockTimestamp + STANDARD_EXPIRY_BUFFER + }); + + // Construct Action + MorphoVaultWithdrawActionContext memory vaultWithdrawActionContext = MorphoVaultWithdrawActionContext({ + amount: vaultWithdraw.amount, + assetSymbol: assetPositions.symbol, + chainId: vaultWithdraw.chainId, + morphoVault: MorphoInfo.getMorphoVaultAddress(vaultWithdraw.chainId, vaultWithdraw.assetSymbol), + price: assetPositions.usdPrice, + token: assetPositions.asset + }); + + Action memory action = Actions.Action({ + chainId: vaultWithdraw.chainId, + quarkAccount: vaultWithdraw.withdrawer, + actionType: ACTION_TYPE_MORPHO_VAULT_WITHDRAW, + actionContext: abi.encode(vaultWithdrawActionContext), + paymentMethod: PaymentInfo.paymentMethodForPayment(payment, false), + // Null address for OFFCHAIN payment. + paymentToken: payment.isToken + ? PaymentInfo.knownToken(payment.currency, vaultWithdraw.chainId).token + : address(0), + paymentTokenSymbol: payment.currency, + paymentMaxCost: payment.isToken ? PaymentInfo.findMaxCost(payment, vaultWithdraw.chainId) : 0 + }); + + return (quarkOperation, action); + } + function wrapOrUnwrapAsset( WrapOrUnwrapAsset memory wrapOrUnwrap, PaymentInfo.Payment memory payment, diff --git a/src/builder/QuarkBuilder.sol b/src/builder/QuarkBuilder.sol index 1cde7d6e..960184ac 100644 --- a/src/builder/QuarkBuilder.sol +++ b/src/builder/QuarkBuilder.sol @@ -564,7 +564,6 @@ contract QuarkBuilder { uint256 maxCostOnDstChain = PaymentInfo.findMaxCost(payment, cometWithdrawIntent.chainId); // if you're withdrawing the payment token, you can use the withdrawn amount to cover the cost if (Strings.stringEqIgnoreCase(payment.currency, cometWithdrawIntent.assetSymbol)) { - // XXX in the withdrawMax case, use the Comet balance maxCostOnDstChain = Math.subtractFlooredAtZero(maxCostOnDstChain, cometWithdrawIntent.amount); } @@ -1464,6 +1463,261 @@ contract QuarkBuilder { }); } + struct MorphoVaultSupplyIntent { + uint256 amount; + string assetSymbol; + uint256 blockTimestamp; + address sender; + uint256 chainId; + } + + function morphoVaultSupply( + MorphoVaultSupplyIntent memory supplyIntent, + Accounts.ChainAccounts[] memory chainAccountsList, + PaymentInfo.Payment memory payment + ) external pure returns (BuilderResult memory) { + // If the action is paid for with tokens, filter out any chain accounts that do not have corresponding payment information + if (payment.isToken) { + chainAccountsList = Accounts.findChainAccountsWithPaymentInfo(chainAccountsList, payment); + } + + // Initialize supply max flag + bool isMaxSupply = supplyIntent.amount == type(uint256).max; + // Convert supplyIntent to user aggregated balance + if (isMaxSupply) { + supplyIntent.amount = Accounts.totalAvailableAsset(supplyIntent.assetSymbol, chainAccountsList, payment); + } + + assertFundsAvailable( + supplyIntent.chainId, supplyIntent.assetSymbol, supplyIntent.amount, chainAccountsList, payment + ); + + bool useQuotecall = isMaxSupply; + List.DynamicArray memory actions = List.newList(); + List.DynamicArray memory quarkOperations = List.newList(); + + if ( + needsBridgedFunds( + supplyIntent.assetSymbol, supplyIntent.amount, supplyIntent.chainId, chainAccountsList, payment + ) + ) { + // Note: Assumes that the asset uses the same # of decimals on each chain + uint256 amountNeededOnDst = supplyIntent.amount; + // If action is paid for with tokens and the payment token is the + // transfer token, we need to add the max cost to the + // amountNeededOnDst for target chain + if (payment.isToken && Strings.stringEqIgnoreCase(payment.currency, supplyIntent.assetSymbol)) { + amountNeededOnDst += PaymentInfo.findMaxCost(payment, supplyIntent.chainId); + } + (IQuarkWallet.QuarkOperation[] memory bridgeQuarkOperations, Actions.Action[] memory bridgeActions) = + Actions.constructBridgeOperations( + Actions.BridgeOperationInfo({ + assetSymbol: supplyIntent.assetSymbol, + amountNeededOnDst: amountNeededOnDst, + dstChainId: supplyIntent.chainId, + recipient: supplyIntent.sender, + blockTimestamp: supplyIntent.blockTimestamp, + useQuotecall: useQuotecall + }), + chainAccountsList, + payment + ); + + for (uint256 i = 0; i < bridgeQuarkOperations.length; ++i) { + List.addAction(actions, bridgeActions[i]); + List.addQuarkOperation(quarkOperations, bridgeQuarkOperations[i]); + } + } + + // Auto-wrap + checkAndInsertWrapOrUnwrapAction( + actions, + quarkOperations, + chainAccountsList, + payment, + supplyIntent.assetSymbol, + supplyIntent.amount, + supplyIntent.chainId, + supplyIntent.sender, + supplyIntent.blockTimestamp, + useQuotecall + ); + + (IQuarkWallet.QuarkOperation memory supplyQuarkOperation, Actions.Action memory supplyAction) = Actions + .morphoVaultSupply( + Actions.MorphoVaultSupply({ + chainAccountsList: chainAccountsList, + assetSymbol: supplyIntent.assetSymbol, + amount: supplyIntent.amount, + blockTimestamp: supplyIntent.blockTimestamp, + chainId: supplyIntent.chainId, + sender: supplyIntent.sender + }), + payment, + useQuotecall + ); + + List.addQuarkOperation(quarkOperations, supplyQuarkOperation); + List.addAction(actions, supplyAction); + + // Convert actions and quark operations to array + Actions.Action[] memory actionsArray = List.toActionArray(actions); + IQuarkWallet.QuarkOperation[] memory quarkOperationsArray = List.toQuarkOperationArray(quarkOperations); + + // Validate generated actions for affordability + if (payment.isToken) { + assertSufficientPaymentTokenBalances( + actionsArray, chainAccountsList, supplyIntent.chainId, supplyIntent.sender + ); + } + + // Merge operations that are from the same chain into one Multicall operation + (quarkOperationsArray, actionsArray) = + QuarkOperationHelper.mergeSameChainOperations(quarkOperationsArray, actionsArray); + + // Wrap operations around Paycall/Quotecall if payment is with token + if (payment.isToken) { + quarkOperationsArray = QuarkOperationHelper.wrapOperationsWithTokenPayment( + quarkOperationsArray, actionsArray, payment, useQuotecall + ); + } + + return BuilderResult({ + version: VERSION, + actions: actionsArray, + quarkOperations: quarkOperationsArray, + paymentCurrency: payment.currency, + eip712Data: EIP712Helper.eip712DataForQuarkOperations(quarkOperationsArray, actionsArray) + }); + } + + struct MorphoVaultWithdrawIntent { + uint256 amount; + string assetSymbol; + uint256 blockTimestamp; + uint256 chainId; + address withdrawer; + } + + function morphoVaultWithdraw( + MorphoVaultWithdrawIntent memory withdrawIntent, + Accounts.ChainAccounts[] memory chainAccountsList, + PaymentInfo.Payment memory payment + ) external pure returns (BuilderResult memory) { + // XXX confirm that you actually have the amount to withdraw + + bool isMaxWithdraw = withdrawIntent.amount == type(uint256).max; + bool useQuotecall = false; // never use Quotecall + List.DynamicArray memory actions = List.newList(); + List.DynamicArray memory quarkOperations = List.newList(); + + // when paying with tokens, you may need to bridge the payment token to cover the cost + if (payment.isToken) { + uint256 maxCostOnDstChain = PaymentInfo.findMaxCost(payment, withdrawIntent.chainId); + // if you're withdrawing the payment token, you can use the withdrawn amount to cover the cost + if (Strings.stringEqIgnoreCase(payment.currency, withdrawIntent.assetSymbol)) { + maxCostOnDstChain = Math.subtractFlooredAtZero(maxCostOnDstChain, withdrawIntent.amount); + } + + if ( + needsBridgedFunds( + payment.currency, maxCostOnDstChain, withdrawIntent.chainId, chainAccountsList, payment + ) + ) { + (IQuarkWallet.QuarkOperation[] memory bridgeQuarkOperations, Actions.Action[] memory bridgeActions) = + Actions.constructBridgeOperations( + Actions.BridgeOperationInfo({ + assetSymbol: payment.currency, + amountNeededOnDst: maxCostOnDstChain, + dstChainId: withdrawIntent.chainId, + recipient: withdrawIntent.withdrawer, + blockTimestamp: withdrawIntent.blockTimestamp, + useQuotecall: useQuotecall + }), + chainAccountsList, + payment + ); + + for (uint256 i = 0; i < bridgeQuarkOperations.length; ++i) { + List.addQuarkOperation(quarkOperations, bridgeQuarkOperations[i]); + List.addAction(actions, bridgeActions[i]); + } + } + } + + (IQuarkWallet.QuarkOperation memory cometWithdrawQuarkOperation, Actions.Action memory cometWithdrawAction) = + Actions.morphoVaultWithdraw( + Actions.MorphoVaultWithdraw({ + chainAccountsList: chainAccountsList, + assetSymbol: withdrawIntent.assetSymbol, + amount: withdrawIntent.amount, + blockTimestamp: withdrawIntent.blockTimestamp, + chainId: withdrawIntent.chainId, + withdrawer: withdrawIntent.withdrawer + }), + payment + ); + List.addAction(actions, cometWithdrawAction); + List.addQuarkOperation(quarkOperations, cometWithdrawQuarkOperation); + + // Convert actions and quark operations to arrays + Actions.Action[] memory actionsArray = List.toActionArray(actions); + IQuarkWallet.QuarkOperation[] memory quarkOperationsArray = List.toQuarkOperationArray(quarkOperations); + + // Validate generated actions for affordability + if (payment.isToken) { + uint256 supplementalPaymentTokenBalance = 0; + if (Strings.stringEqIgnoreCase(payment.currency, withdrawIntent.assetSymbol)) { + if (isMaxWithdraw) { + // when doing a maxWithdraw of the payment token, add the account's supplied balance + // as supplemental payment token balance + Accounts.MorphoVaultPositions memory morphoVaultPositions = Accounts.findMorphoVaultPositions( + withdrawIntent.chainId, + Accounts.findAssetPositions( + withdrawIntent.assetSymbol, withdrawIntent.chainId, chainAccountsList + ).asset, + chainAccountsList + ); + + for (uint256 i = 0; i < morphoVaultPositions.accounts.length; ++i) { + if (morphoVaultPositions.accounts[i] == withdrawIntent.withdrawer) { + supplementalPaymentTokenBalance += morphoVaultPositions.balances[i]; + } + } + } else { + supplementalPaymentTokenBalance += withdrawIntent.amount; + } + } + + assertSufficientPaymentTokenBalances( + actionsArray, + chainAccountsList, + withdrawIntent.chainId, + withdrawIntent.withdrawer, + supplementalPaymentTokenBalance + ); + } + + // Merge operations that are from the same chain into one Multicall operation + (quarkOperationsArray, actionsArray) = + QuarkOperationHelper.mergeSameChainOperations(quarkOperationsArray, actionsArray); + + // Wrap operations around Paycall/Quotecall if payment is with token + if (payment.isToken) { + quarkOperationsArray = QuarkOperationHelper.wrapOperationsWithTokenPayment( + quarkOperationsArray, actionsArray, payment, useQuotecall + ); + } + + return BuilderResult({ + version: VERSION, + actions: actionsArray, + quarkOperations: quarkOperationsArray, + paymentCurrency: payment.currency, + eip712Data: EIP712Helper.eip712DataForQuarkOperations(quarkOperationsArray, actionsArray) + }); + } + // For some reason, funds that may otherwise be bridgeable or held by the user cannot // be made available to fulfill the transaction. // Funds cannot be bridged, e.g. no bridge exists @@ -1746,6 +2000,13 @@ contract QuarkBuilder { if (Strings.stringEqIgnoreCase(cometSupplyActionContext.assetSymbol, paymentTokenSymbol)) { paymentTokenCost += cometSupplyActionContext.amount; } + } else if (Strings.stringEqIgnoreCase(nonBridgeAction.actionType, Actions.ACTION_TYPE_MORPHO_VAULT_SUPPLY)) + { + Actions.MorphoVaultSupplyActionContext memory morphoVaultSupplyActionContext = + abi.decode(nonBridgeAction.actionContext, (Actions.MorphoVaultSupplyActionContext)); + if (Strings.stringEqIgnoreCase(morphoVaultSupplyActionContext.assetSymbol, paymentTokenSymbol)) { + paymentTokenCost += morphoVaultSupplyActionContext.amount; + } } else if (Strings.stringEqIgnoreCase(nonBridgeAction.actionType, Actions.ACTION_TYPE_SWAP)) { Actions.SwapActionContext memory swapActionContext = abi.decode(nonBridgeAction.actionContext, (Actions.SwapActionContext)); @@ -1776,6 +2037,10 @@ contract QuarkBuilder { } } else if (Strings.stringEqIgnoreCase(nonBridgeAction.actionType, Actions.ACTION_TYPE_WITHDRAW)) { continue; + } else if ( + Strings.stringEqIgnoreCase(nonBridgeAction.actionType, Actions.ACTION_TYPE_MORPHO_VAULT_WITHDRAW) + ) { + continue; } else { revert InvalidActionType(); } diff --git a/test/builder/QuarkBuilderCometBorrow.t.sol b/test/builder/QuarkBuilderCometBorrow.t.sol index 476c5cd4..5eb529dc 100644 --- a/test/builder/QuarkBuilderCometBorrow.t.sol +++ b/test/builder/QuarkBuilderCometBorrow.t.sol @@ -88,7 +88,8 @@ contract QuarkBuilderCometBorrowTest is Test, QuarkBuilderTest { assetSymbols: Arrays.stringArray("USDC", "USDT", "LINK", "WETH"), assetBalances: Arrays.uintArray(0, 0, 10e18, 0), // user has 10 LINK cometPortfolios: emptyCometPortfolios_(), - morphoPortfolios: emptyMorphoPortfolios_() + morphoPortfolios: emptyMorphoPortfolios_(), + morphoVaultPortfolios: emptyMorphoVaultPortfolios_() }); chainPortfolios[1] = ChainPortfolio({ chainId: 8453, @@ -97,7 +98,8 @@ contract QuarkBuilderCometBorrowTest is Test, QuarkBuilderTest { assetSymbols: Arrays.stringArray("USDC", "USDT", "LINK", "WETH"), assetBalances: Arrays.uintArray(0, 0, 0, 0), cometPortfolios: emptyCometPortfolios_(), - morphoPortfolios: emptyMorphoPortfolios_() + morphoPortfolios: emptyMorphoPortfolios_(), + morphoVaultPortfolios: emptyMorphoVaultPortfolios_() }); QuarkBuilder builder = new QuarkBuilder(); @@ -211,7 +213,8 @@ contract QuarkBuilderCometBorrowTest is Test, QuarkBuilderTest { assetSymbols: Arrays.stringArray("USDC", "ETH", "LINK", "WETH"), assetBalances: Arrays.uintArray(0, 10e18, 0, 0), // user has 10 ETH cometPortfolios: emptyCometPortfolios_(), - morphoPortfolios: emptyMorphoPortfolios_() + morphoPortfolios: emptyMorphoPortfolios_(), + morphoVaultPortfolios: emptyMorphoVaultPortfolios_() }); chainPortfolios[1] = ChainPortfolio({ chainId: 8453, @@ -220,7 +223,8 @@ contract QuarkBuilderCometBorrowTest is Test, QuarkBuilderTest { assetSymbols: Arrays.stringArray("USDC", "ETH", "LINK", "WETH"), assetBalances: Arrays.uintArray(0, 0, 0, 0), cometPortfolios: emptyCometPortfolios_(), - morphoPortfolios: emptyMorphoPortfolios_() + morphoPortfolios: emptyMorphoPortfolios_(), + morphoVaultPortfolios: emptyMorphoVaultPortfolios_() }); QuarkBuilder builder = new QuarkBuilder(); @@ -320,7 +324,8 @@ contract QuarkBuilderCometBorrowTest is Test, QuarkBuilderTest { assetSymbols: Arrays.stringArray("USDC", "USDT", "LINK", "WETH"), assetBalances: Arrays.uintArray(1e6, 0, 10e18, 0), // user has 1 USDC, 10 LINK cometPortfolios: emptyCometPortfolios_(), - morphoPortfolios: emptyMorphoPortfolios_() + morphoPortfolios: emptyMorphoPortfolios_(), + morphoVaultPortfolios: emptyMorphoVaultPortfolios_() }); chainPortfolios[1] = ChainPortfolio({ chainId: 8453, @@ -329,7 +334,8 @@ contract QuarkBuilderCometBorrowTest is Test, QuarkBuilderTest { assetSymbols: Arrays.stringArray("USDC", "USDT", "LINK", "WETH"), assetBalances: Arrays.uintArray(0, 0, 0, 0), cometPortfolios: emptyCometPortfolios_(), - morphoPortfolios: emptyMorphoPortfolios_() + morphoPortfolios: emptyMorphoPortfolios_(), + morphoVaultPortfolios: emptyMorphoVaultPortfolios_() }); PaymentInfo.PaymentMaxCost[] memory maxCosts = new PaymentInfo.PaymentMaxCost[](1); @@ -448,7 +454,8 @@ contract QuarkBuilderCometBorrowTest is Test, QuarkBuilderTest { assetSymbols: Arrays.stringArray("USDC", "USDT", "LINK", "WETH"), assetBalances: Arrays.uintArray(0, 0, 10e18, 0), // user has 10 LINK and 0 USDC cometPortfolios: emptyCometPortfolios_(), - morphoPortfolios: emptyMorphoPortfolios_() + morphoPortfolios: emptyMorphoPortfolios_(), + morphoVaultPortfolios: emptyMorphoVaultPortfolios_() }); chainPortfolios[1] = ChainPortfolio({ chainId: 8453, @@ -457,7 +464,8 @@ contract QuarkBuilderCometBorrowTest is Test, QuarkBuilderTest { assetSymbols: Arrays.stringArray("USDC", "USDT", "LINK", "WETH"), assetBalances: Arrays.uintArray(0, 0, 0, 0), cometPortfolios: emptyCometPortfolios_(), - morphoPortfolios: emptyMorphoPortfolios_() + morphoPortfolios: emptyMorphoPortfolios_(), + morphoVaultPortfolios: emptyMorphoVaultPortfolios_() }); QuarkBuilder.BuilderResult memory result = builder.cometBorrow( @@ -573,7 +581,8 @@ contract QuarkBuilderCometBorrowTest is Test, QuarkBuilderTest { assetSymbols: Arrays.stringArray("USDC", "USDT", "LINK", "WETH"), assetBalances: Arrays.uintArray(3e6, 0, 0, 0), // 3 USDC on mainnet cometPortfolios: emptyCometPortfolios_(), - morphoPortfolios: emptyMorphoPortfolios_() + morphoPortfolios: emptyMorphoPortfolios_(), + morphoVaultPortfolios: emptyMorphoVaultPortfolios_() }); chainPortfolios[1] = ChainPortfolio({ chainId: 8453, @@ -582,7 +591,8 @@ contract QuarkBuilderCometBorrowTest is Test, QuarkBuilderTest { assetSymbols: Arrays.stringArray("USDC", "USDT", "LINK", "WETH"), assetBalances: Arrays.uintArray(0, 0, 5e18, 0), cometPortfolios: emptyCometPortfolios_(), - morphoPortfolios: emptyMorphoPortfolios_() + morphoPortfolios: emptyMorphoPortfolios_(), + morphoVaultPortfolios: emptyMorphoVaultPortfolios_() }); QuarkBuilder.BuilderResult memory result = builder.cometBorrow( @@ -759,7 +769,8 @@ contract QuarkBuilderCometBorrowTest is Test, QuarkBuilderTest { assetSymbols: Arrays.stringArray("USDC", "USDT", "LINK", "WETH"), assetBalances: Arrays.uintArray(4e6, 0, 0, 0), // 4 USDC on mainnet cometPortfolios: emptyCometPortfolios_(), - morphoPortfolios: emptyMorphoPortfolios_() + morphoPortfolios: emptyMorphoPortfolios_(), + morphoVaultPortfolios: emptyMorphoVaultPortfolios_() }); chainPortfolios[1] = ChainPortfolio({ chainId: 8453, @@ -768,7 +779,8 @@ contract QuarkBuilderCometBorrowTest is Test, QuarkBuilderTest { assetSymbols: Arrays.stringArray("USDC", "USDT", "LINK", "WETH"), assetBalances: Arrays.uintArray(0, 0, 0, 0), // no assets on base cometPortfolios: emptyCometPortfolios_(), - morphoPortfolios: emptyMorphoPortfolios_() + morphoPortfolios: emptyMorphoPortfolios_(), + morphoVaultPortfolios: emptyMorphoVaultPortfolios_() }); QuarkBuilder.BuilderResult memory result = builder.cometBorrow( diff --git a/test/builder/QuarkBuilderCometRepay.t.sol b/test/builder/QuarkBuilderCometRepay.t.sol index 873df7cf..a153b320 100644 --- a/test/builder/QuarkBuilderCometRepay.t.sol +++ b/test/builder/QuarkBuilderCometRepay.t.sol @@ -87,7 +87,8 @@ contract QuarkBuilderCometRepayTest is Test, QuarkBuilderTest { assetSymbols: Arrays.stringArray("USDC", "USDT", "LINK", "WETH"), assetBalances: Arrays.uintArray(0.4e6, 0, 0, 1e18), // user does not have enough USDC cometPortfolios: emptyCometPortfolios_(), - morphoPortfolios: emptyMorphoPortfolios_() + morphoPortfolios: emptyMorphoPortfolios_(), + morphoVaultPortfolios: emptyMorphoVaultPortfolios_() }); vm.expectRevert(QuarkBuilder.MaxCostTooHigh.selector); @@ -114,7 +115,8 @@ contract QuarkBuilderCometRepayTest is Test, QuarkBuilderTest { assetSymbols: Arrays.stringArray("USDC", "USDT", "LINK", "WETH"), assetBalances: Arrays.uintArray(1e6, 0, 0, 0), // has 1 USDC cometPortfolios: emptyCometPortfolios_(), - morphoPortfolios: emptyMorphoPortfolios_() + morphoPortfolios: emptyMorphoPortfolios_(), + morphoVaultPortfolios: emptyMorphoVaultPortfolios_() }); chainPortfolios[1] = ChainPortfolio({ chainId: 8453, @@ -123,7 +125,8 @@ contract QuarkBuilderCometRepayTest is Test, QuarkBuilderTest { assetSymbols: Arrays.stringArray("USDC", "USDT", "LINK", "WETH"), assetBalances: Arrays.uintArray(0, 0, 0, 0), cometPortfolios: emptyCometPortfolios_(), - morphoPortfolios: emptyMorphoPortfolios_() + morphoPortfolios: emptyMorphoPortfolios_(), + morphoVaultPortfolios: emptyMorphoVaultPortfolios_() }); QuarkBuilder builder = new QuarkBuilder(); @@ -238,7 +241,8 @@ contract QuarkBuilderCometRepayTest is Test, QuarkBuilderTest { assetSymbols: Arrays.stringArray("USDC", "ETH", "LINK", "WETH"), assetBalances: Arrays.uintArray(0, 1e18, 0, 0), // has 1 ETH cometPortfolios: emptyCometPortfolios_(), - morphoPortfolios: emptyMorphoPortfolios_() + morphoPortfolios: emptyMorphoPortfolios_(), + morphoVaultPortfolios: emptyMorphoVaultPortfolios_() }); chainPortfolios[1] = ChainPortfolio({ chainId: 8453, @@ -247,7 +251,8 @@ contract QuarkBuilderCometRepayTest is Test, QuarkBuilderTest { assetSymbols: Arrays.stringArray("USDC", "ETH", "LINK", "WETH"), assetBalances: Arrays.uintArray(0, 0, 0, 0), cometPortfolios: emptyCometPortfolios_(), - morphoPortfolios: emptyMorphoPortfolios_() + morphoPortfolios: emptyMorphoPortfolios_(), + morphoVaultPortfolios: emptyMorphoVaultPortfolios_() }); QuarkBuilder builder = new QuarkBuilder(); @@ -349,7 +354,8 @@ contract QuarkBuilderCometRepayTest is Test, QuarkBuilderTest { assetSymbols: Arrays.stringArray("USDC", "USDT", "LINK", "WETH"), assetBalances: Arrays.uintArray(2e6, 0, 0, 0), cometPortfolios: emptyCometPortfolios_(), - morphoPortfolios: emptyMorphoPortfolios_() + morphoPortfolios: emptyMorphoPortfolios_(), + morphoVaultPortfolios: emptyMorphoVaultPortfolios_() }); chainPortfolios[1] = ChainPortfolio({ chainId: 8453, @@ -358,7 +364,8 @@ contract QuarkBuilderCometRepayTest is Test, QuarkBuilderTest { assetSymbols: Arrays.stringArray("USDC", "USDT", "LINK", "WETH"), assetBalances: Arrays.uintArray(0, 0, 0, 0), cometPortfolios: emptyCometPortfolios_(), - morphoPortfolios: emptyMorphoPortfolios_() + morphoPortfolios: emptyMorphoPortfolios_(), + morphoVaultPortfolios: emptyMorphoVaultPortfolios_() }); PaymentInfo.PaymentMaxCost[] memory maxCosts = new PaymentInfo.PaymentMaxCost[](1); @@ -479,7 +486,8 @@ contract QuarkBuilderCometRepayTest is Test, QuarkBuilderTest { assetSymbols: Arrays.stringArray("USDC", "USDT", "LINK", "WETH"), assetBalances: Arrays.uintArray(0, 0, 0, 1e18), cometPortfolios: emptyCometPortfolios_(), - morphoPortfolios: emptyMorphoPortfolios_() + morphoPortfolios: emptyMorphoPortfolios_(), + morphoVaultPortfolios: emptyMorphoVaultPortfolios_() }); chainPortfolios[1] = ChainPortfolio({ chainId: 8453, @@ -488,7 +496,8 @@ contract QuarkBuilderCometRepayTest is Test, QuarkBuilderTest { assetSymbols: Arrays.stringArray("USDC", "USDT", "LINK", "WETH"), assetBalances: Arrays.uintArray(0, 0, 0, 0), cometPortfolios: emptyCometPortfolios_(), - morphoPortfolios: emptyMorphoPortfolios_() + morphoPortfolios: emptyMorphoPortfolios_(), + morphoVaultPortfolios: emptyMorphoVaultPortfolios_() }); QuarkBuilder.BuilderResult memory result = builder.cometRepay( @@ -606,7 +615,8 @@ contract QuarkBuilderCometRepayTest is Test, QuarkBuilderTest { assetSymbols: Arrays.stringArray("USDC", "USDT", "LINK", "WETH"), assetBalances: Arrays.uintArray(4e6, 0, 0, 0), // 4 USDC on mainnet cometPortfolios: emptyCometPortfolios_(), - morphoPortfolios: emptyMorphoPortfolios_() + morphoPortfolios: emptyMorphoPortfolios_(), + morphoVaultPortfolios: emptyMorphoVaultPortfolios_() }); chainPortfolios[1] = ChainPortfolio({ chainId: 8453, @@ -615,7 +625,8 @@ contract QuarkBuilderCometRepayTest is Test, QuarkBuilderTest { assetSymbols: Arrays.stringArray("USDC", "USDT", "LINK", "WETH"), assetBalances: Arrays.uintArray(0, 0, 0, 0), // no assets on base cometPortfolios: emptyCometPortfolios_(), - morphoPortfolios: emptyMorphoPortfolios_() + morphoPortfolios: emptyMorphoPortfolios_(), + morphoVaultPortfolios: emptyMorphoVaultPortfolios_() }); QuarkBuilder.BuilderResult memory result = builder.cometRepay( @@ -797,7 +808,8 @@ contract QuarkBuilderCometRepayTest is Test, QuarkBuilderTest { assetSymbols: Arrays.stringArray("USDC", "USDT", "LINK", "WETH"), assetBalances: Arrays.uintArray(50e6, 0, 0, 0), // has 50 USDC cometPortfolios: cometPortfolios, - morphoPortfolios: emptyMorphoPortfolios_() + morphoPortfolios: emptyMorphoPortfolios_(), + morphoVaultPortfolios: emptyMorphoVaultPortfolios_() }); QuarkBuilder builder = new QuarkBuilder(); @@ -920,7 +932,8 @@ contract QuarkBuilderCometRepayTest is Test, QuarkBuilderTest { assetSymbols: Arrays.stringArray("USDC", "USDT", "LINK", "WETH"), assetBalances: Arrays.uintArray(50e6, 0, 0, 0), // has 50 USDC on mainnet cometPortfolios: emptyCometPortfolios_(), - morphoPortfolios: emptyMorphoPortfolios_() + morphoPortfolios: emptyMorphoPortfolios_(), + morphoVaultPortfolios: emptyMorphoVaultPortfolios_() }); chainPortfolios[1] = ChainPortfolio({ chainId: 8453, @@ -929,7 +942,8 @@ contract QuarkBuilderCometRepayTest is Test, QuarkBuilderTest { assetSymbols: Arrays.stringArray("USDC", "USDT", "LINK", "WETH"), assetBalances: Arrays.uintArray(0, 0, 0, 0), // has 0 USDC on base cometPortfolios: cometPortfolios, - morphoPortfolios: emptyMorphoPortfolios_() + morphoPortfolios: emptyMorphoPortfolios_(), + morphoVaultPortfolios: emptyMorphoVaultPortfolios_() }); QuarkBuilder builder = new QuarkBuilder(); diff --git a/test/builder/QuarkBuilderCometSupply.t.sol b/test/builder/QuarkBuilderCometSupply.t.sol index 872ddb5d..21050653 100644 --- a/test/builder/QuarkBuilderCometSupply.t.sol +++ b/test/builder/QuarkBuilderCometSupply.t.sol @@ -159,7 +159,8 @@ contract QuarkBuilderCometSupplyTest is Test, QuarkBuilderTest { quarkStates: quarkStates_(address(0xa11ce), 12), assetPositionsList: assetPositionsList_(1, address(0xa11ce), uint256(3e6)), cometPositions: emptyCometPositions_(), - morphoPositions: emptyMorphoPositions_() + morphoPositions: emptyMorphoPositions_(), + morphoVaultPositions: emptyMorphoVaultPositions_() }); QuarkBuilder.BuilderResult memory result = builder.cometSupply( @@ -262,7 +263,8 @@ contract QuarkBuilderCometSupplyTest is Test, QuarkBuilderTest { quarkStates: quarkStates_(address(0xa11ce), 12), assetPositionsList: assetPositionsList, cometPositions: emptyCometPositions_(), - morphoPositions: emptyMorphoPositions_() + morphoPositions: emptyMorphoPositions_(), + morphoVaultPositions: emptyMorphoVaultPositions_() }); QuarkBuilder.BuilderResult memory result = diff --git a/test/builder/QuarkBuilderCometWithdraw.t.sol b/test/builder/QuarkBuilderCometWithdraw.t.sol index 716a5f0c..259f258a 100644 --- a/test/builder/QuarkBuilderCometWithdraw.t.sol +++ b/test/builder/QuarkBuilderCometWithdraw.t.sol @@ -256,14 +256,16 @@ contract QuarkBuilderCometWithdrawTest is Test, QuarkBuilderTest { quarkStates: quarkStates_(address(0xa11ce), 12), assetPositionsList: assetPositionsList_(1, address(0xa11ce), 3e6), // 3 USDC on mainnet cometPositions: emptyCometPositions_(), - morphoPositions: emptyMorphoPositions_() + morphoPositions: emptyMorphoPositions_(), + morphoVaultPositions: emptyMorphoVaultPositions_() }); chainAccountsList[1] = Accounts.ChainAccounts({ chainId: 8453, quarkStates: quarkStates_(address(0xb0b), 2), assetPositionsList: assetPositionsList_(8453, address(0xb0b), 0), // 0 USDC on base cometPositions: emptyCometPositions_(), - morphoPositions: emptyMorphoPositions_() + morphoPositions: emptyMorphoPositions_(), + morphoVaultPositions: emptyMorphoVaultPositions_() }); QuarkBuilder.BuilderResult memory result = builder.cometWithdraw( @@ -399,7 +401,8 @@ contract QuarkBuilderCometWithdrawTest is Test, QuarkBuilderTest { assetSymbols: Arrays.stringArray("USDC", "USDT", "LINK", "WETH"), assetBalances: Arrays.uintArray(0, 0, 0, 0), cometPortfolios: cometPortfolios, - morphoPortfolios: emptyMorphoPortfolios_() + morphoPortfolios: emptyMorphoPortfolios_(), + morphoVaultPortfolios: emptyMorphoVaultPortfolios_() }); QuarkBuilder builder = new QuarkBuilder(); @@ -487,7 +490,8 @@ contract QuarkBuilderCometWithdrawTest is Test, QuarkBuilderTest { assetSymbols: Arrays.stringArray("USDC", "USDT", "LINK", "WETH"), assetBalances: Arrays.uintArray(0, 0, 0, 0), cometPortfolios: cometPortfolios, - morphoPortfolios: emptyMorphoPortfolios_() + morphoPortfolios: emptyMorphoPortfolios_(), + morphoVaultPortfolios: emptyMorphoVaultPortfolios_() }); QuarkBuilder builder = new QuarkBuilder(); diff --git a/test/builder/QuarkBuilderMorphoBorrow.t.sol b/test/builder/QuarkBuilderMorphoBorrow.t.sol index d07c60eb..2308452b 100644 --- a/test/builder/QuarkBuilderMorphoBorrow.t.sol +++ b/test/builder/QuarkBuilderMorphoBorrow.t.sol @@ -47,7 +47,8 @@ contract QuarkBuilderMorphoBorrowTest is Test, QuarkBuilderTest { assetSymbols: Arrays.stringArray("USDC", "USDT", "WBTC", "WETH"), assetBalances: Arrays.uintArray(0, 0, 1e8, 1e18), cometPortfolios: emptyCometPortfolios_(), - morphoPortfolios: emptyMorphoPortfolios_() + morphoPortfolios: emptyMorphoPortfolios_(), + morphoVaultPortfolios: emptyMorphoVaultPortfolios_() }); chainPortfolios[1] = ChainPortfolio({ chainId: 8453, @@ -56,7 +57,8 @@ contract QuarkBuilderMorphoBorrowTest is Test, QuarkBuilderTest { assetSymbols: Arrays.stringArray("USDC", "USDT", "LINK", "WETH"), assetBalances: Arrays.uintArray(0, 0, 0, 0), cometPortfolios: emptyCometPortfolios_(), - morphoPortfolios: emptyMorphoPortfolios_() + morphoPortfolios: emptyMorphoPortfolios_(), + morphoVaultPortfolios: emptyMorphoVaultPortfolios_() }); // Pair not exist in known Morpho markets @@ -87,7 +89,8 @@ contract QuarkBuilderMorphoBorrowTest is Test, QuarkBuilderTest { assetSymbols: Arrays.stringArray("USDC", "USDT", "WBTC", "WETH"), assetBalances: Arrays.uintArray(0, 0, 1e8, 0), // user has 1 WBTC cometPortfolios: emptyCometPortfolios_(), - morphoPortfolios: emptyMorphoPortfolios_() + morphoPortfolios: emptyMorphoPortfolios_(), + morphoVaultPortfolios: emptyMorphoVaultPortfolios_() }); chainPortfolios[1] = ChainPortfolio({ chainId: 8453, @@ -96,7 +99,8 @@ contract QuarkBuilderMorphoBorrowTest is Test, QuarkBuilderTest { assetSymbols: Arrays.stringArray("USDC", "USDT", "LINK", "WETH"), assetBalances: Arrays.uintArray(0, 0, 0, 0), cometPortfolios: emptyCometPortfolios_(), - morphoPortfolios: emptyMorphoPortfolios_() + morphoPortfolios: emptyMorphoPortfolios_(), + morphoVaultPortfolios: emptyMorphoVaultPortfolios_() }); QuarkBuilder builder = new QuarkBuilder(); @@ -168,7 +172,8 @@ contract QuarkBuilderMorphoBorrowTest is Test, QuarkBuilderTest { assetSymbols: Arrays.stringArray("USDC", "ETH", "LINK", "WETH"), assetBalances: Arrays.uintArray(0, 0, 0, 0), cometPortfolios: emptyCometPortfolios_(), - morphoPortfolios: emptyMorphoPortfolios_() + morphoPortfolios: emptyMorphoPortfolios_(), + morphoVaultPortfolios: emptyMorphoVaultPortfolios_() }); chainPortfolios[1] = ChainPortfolio({ chainId: 8453, @@ -177,7 +182,8 @@ contract QuarkBuilderMorphoBorrowTest is Test, QuarkBuilderTest { assetSymbols: Arrays.stringArray("USDC", "ETH", "LINK", "WETH"), assetBalances: Arrays.uintArray(0, 10e18, 0, 0), cometPortfolios: emptyCometPortfolios_(), - morphoPortfolios: emptyMorphoPortfolios_() + morphoPortfolios: emptyMorphoPortfolios_(), + morphoVaultPortfolios: emptyMorphoVaultPortfolios_() }); QuarkBuilder builder = new QuarkBuilder(); @@ -266,7 +272,8 @@ contract QuarkBuilderMorphoBorrowTest is Test, QuarkBuilderTest { assetSymbols: Arrays.stringArray("USDC", "USDT", "WBTC", "WETH"), assetBalances: Arrays.uintArray(1e6, 0, 1e8, 0), // user has 1 WBTC and 1USDC for payment cometPortfolios: emptyCometPortfolios_(), - morphoPortfolios: emptyMorphoPortfolios_() + morphoPortfolios: emptyMorphoPortfolios_(), + morphoVaultPortfolios: emptyMorphoVaultPortfolios_() }); chainPortfolios[1] = ChainPortfolio({ chainId: 8453, @@ -275,7 +282,8 @@ contract QuarkBuilderMorphoBorrowTest is Test, QuarkBuilderTest { assetSymbols: Arrays.stringArray("USDC", "USDT", "LINK", "WETH"), assetBalances: Arrays.uintArray(0, 0, 0, 0), cometPortfolios: emptyCometPortfolios_(), - morphoPortfolios: emptyMorphoPortfolios_() + morphoPortfolios: emptyMorphoPortfolios_(), + morphoVaultPortfolios: emptyMorphoVaultPortfolios_() }); PaymentInfo.PaymentMaxCost[] memory maxCosts = new PaymentInfo.PaymentMaxCost[](1); @@ -366,7 +374,8 @@ contract QuarkBuilderMorphoBorrowTest is Test, QuarkBuilderTest { assetSymbols: Arrays.stringArray("USDC", "USDT", "WBTC", "WETH"), assetBalances: Arrays.uintArray(0, 0, 1e8, 0), // user has 1 WBTC but with 0 USDC cometPortfolios: emptyCometPortfolios_(), - morphoPortfolios: emptyMorphoPortfolios_() + morphoPortfolios: emptyMorphoPortfolios_(), + morphoVaultPortfolios: emptyMorphoVaultPortfolios_() }); chainPortfolios[1] = ChainPortfolio({ chainId: 8453, @@ -375,7 +384,8 @@ contract QuarkBuilderMorphoBorrowTest is Test, QuarkBuilderTest { assetSymbols: Arrays.stringArray("USDC", "USDT", "LINK", "WETH"), assetBalances: Arrays.uintArray(0, 0, 0, 0), cometPortfolios: emptyCometPortfolios_(), - morphoPortfolios: emptyMorphoPortfolios_() + morphoPortfolios: emptyMorphoPortfolios_(), + morphoVaultPortfolios: emptyMorphoVaultPortfolios_() }); PaymentInfo.PaymentMaxCost[] memory maxCosts = new PaymentInfo.PaymentMaxCost[](1); @@ -465,7 +475,8 @@ contract QuarkBuilderMorphoBorrowTest is Test, QuarkBuilderTest { assetSymbols: Arrays.stringArray("USDC", "USDT", "cbETH", "WETH"), assetBalances: Arrays.uintArray(5e6, 0, 0, 0), cometPortfolios: emptyCometPortfolios_(), - morphoPortfolios: emptyMorphoPortfolios_() + morphoPortfolios: emptyMorphoPortfolios_(), + morphoVaultPortfolios: emptyMorphoVaultPortfolios_() }); chainPortfolios[1] = ChainPortfolio({ chainId: 8453, @@ -474,7 +485,8 @@ contract QuarkBuilderMorphoBorrowTest is Test, QuarkBuilderTest { assetSymbols: Arrays.stringArray("USDC", "USDT", "cbETH", "WETH"), assetBalances: Arrays.uintArray(0, 0, 1e18, 0), cometPortfolios: emptyCometPortfolios_(), - morphoPortfolios: emptyMorphoPortfolios_() + morphoPortfolios: emptyMorphoPortfolios_(), + morphoVaultPortfolios: emptyMorphoVaultPortfolios_() }); PaymentInfo.PaymentMaxCost[] memory maxCosts = new PaymentInfo.PaymentMaxCost[](2); @@ -630,7 +642,8 @@ contract QuarkBuilderMorphoBorrowTest is Test, QuarkBuilderTest { assetSymbols: Arrays.stringArray("USDC", "USDT", "WBTC", "WETH"), assetBalances: Arrays.uintArray(0.4e6, 0, 2e8, 1e18), // user does not have enough USDC cometPortfolios: emptyCometPortfolios_(), - morphoPortfolios: emptyMorphoPortfolios_() + morphoPortfolios: emptyMorphoPortfolios_(), + morphoVaultPortfolios: emptyMorphoVaultPortfolios_() }); vm.expectRevert(abi.encodeWithSelector(Actions.NotEnoughFundsToBridge.selector, "usdc", 0.1e6, 0.1e6)); diff --git a/test/builder/QuarkBuilderMorphoRepay.t.sol b/test/builder/QuarkBuilderMorphoRepay.t.sol index c83fe700..43762d00 100644 --- a/test/builder/QuarkBuilderMorphoRepay.t.sol +++ b/test/builder/QuarkBuilderMorphoRepay.t.sol @@ -62,7 +62,8 @@ contract QuarkBuilderMorphoRepayTest is Test, QuarkBuilderTest { assetSymbols: Arrays.stringArray("USDC", "USDT", "LINK", "WETH"), assetBalances: Arrays.uintArray(0.4e6, 0, 0, 1e18), // user does not have enough USDC cometPortfolios: emptyCometPortfolios_(), - morphoPortfolios: emptyMorphoPortfolios_() + morphoPortfolios: emptyMorphoPortfolios_(), + morphoVaultPortfolios: emptyMorphoVaultPortfolios_() }); vm.expectRevert(abi.encodeWithSelector(Actions.NotEnoughFundsToBridge.selector, "usdc", 0.1e6, 0.1e6)); @@ -83,7 +84,8 @@ contract QuarkBuilderMorphoRepayTest is Test, QuarkBuilderTest { assetSymbols: Arrays.stringArray("USDC", "USDT", "WBTC", "WETH"), assetBalances: Arrays.uintArray(1e6, 0, 0, 0), // has 1 USDC cometPortfolios: emptyCometPortfolios_(), - morphoPortfolios: emptyMorphoPortfolios_() + morphoPortfolios: emptyMorphoPortfolios_(), + morphoVaultPortfolios: emptyMorphoVaultPortfolios_() }); chainPortfolios[1] = ChainPortfolio({ chainId: 8453, @@ -92,7 +94,8 @@ contract QuarkBuilderMorphoRepayTest is Test, QuarkBuilderTest { assetSymbols: Arrays.stringArray("USDC", "USDT", "WBTC", "WETH"), assetBalances: Arrays.uintArray(0, 0, 0, 0), cometPortfolios: emptyCometPortfolios_(), - morphoPortfolios: emptyMorphoPortfolios_() + morphoPortfolios: emptyMorphoPortfolios_(), + morphoVaultPortfolios: emptyMorphoVaultPortfolios_() }); QuarkBuilder builder = new QuarkBuilder(); @@ -173,7 +176,8 @@ contract QuarkBuilderMorphoRepayTest is Test, QuarkBuilderTest { assetSymbols: Arrays.stringArray("USDC", "ETH", "cbETH", "WETH"), assetBalances: Arrays.uintArray(0, 0, 0, 0), cometPortfolios: emptyCometPortfolios_(), - morphoPortfolios: emptyMorphoPortfolios_() + morphoPortfolios: emptyMorphoPortfolios_(), + morphoVaultPortfolios: emptyMorphoVaultPortfolios_() }); chainPortfolios[1] = ChainPortfolio({ chainId: 8453, @@ -182,7 +186,8 @@ contract QuarkBuilderMorphoRepayTest is Test, QuarkBuilderTest { assetSymbols: Arrays.stringArray("USDC", "ETH", "cbETH", "WETH"), assetBalances: Arrays.uintArray(0, 1e18, 0, 0), cometPortfolios: emptyCometPortfolios_(), - morphoPortfolios: emptyMorphoPortfolios_() + morphoPortfolios: emptyMorphoPortfolios_(), + morphoVaultPortfolios: emptyMorphoVaultPortfolios_() }); QuarkBuilder builder = new QuarkBuilder(); @@ -278,7 +283,8 @@ contract QuarkBuilderMorphoRepayTest is Test, QuarkBuilderTest { assetSymbols: Arrays.stringArray("USDC", "USDT", "WBTC", "WETH"), assetBalances: Arrays.uintArray(2e6, 0, 0, 0), cometPortfolios: emptyCometPortfolios_(), - morphoPortfolios: emptyMorphoPortfolios_() + morphoPortfolios: emptyMorphoPortfolios_(), + morphoVaultPortfolios: emptyMorphoVaultPortfolios_() }); chainPortfolios[1] = ChainPortfolio({ chainId: 8453, @@ -287,7 +293,8 @@ contract QuarkBuilderMorphoRepayTest is Test, QuarkBuilderTest { assetSymbols: Arrays.stringArray("USDC", "USDT", "WBTC", "WETH"), assetBalances: Arrays.uintArray(0, 0, 0, 0), cometPortfolios: emptyCometPortfolios_(), - morphoPortfolios: emptyMorphoPortfolios_() + morphoPortfolios: emptyMorphoPortfolios_(), + morphoVaultPortfolios: emptyMorphoVaultPortfolios_() }); PaymentInfo.PaymentMaxCost[] memory maxCosts = new PaymentInfo.PaymentMaxCost[](1); @@ -398,7 +405,8 @@ contract QuarkBuilderMorphoRepayTest is Test, QuarkBuilderTest { assetSymbols: Arrays.stringArray("USDC", "USDT", "WBTC", "WETH"), assetBalances: Arrays.uintArray(4e6, 0, 0, 0), // 4 USDC on mainnet cometPortfolios: emptyCometPortfolios_(), - morphoPortfolios: emptyMorphoPortfolios_() + morphoPortfolios: emptyMorphoPortfolios_(), + morphoVaultPortfolios: emptyMorphoVaultPortfolios_() }); chainPortfolios[1] = ChainPortfolio({ chainId: 8453, @@ -407,7 +415,8 @@ contract QuarkBuilderMorphoRepayTest is Test, QuarkBuilderTest { assetSymbols: Arrays.stringArray("USDC", "USDT", "WBTC", "WETH"), assetBalances: Arrays.uintArray(0, 0, 0, 0), // no assets on base cometPortfolios: emptyCometPortfolios_(), - morphoPortfolios: emptyMorphoPortfolios_() + morphoPortfolios: emptyMorphoPortfolios_(), + morphoVaultPortfolios: emptyMorphoVaultPortfolios_() }); QuarkBuilder.BuilderResult memory result = builder.morphoRepay( @@ -572,7 +581,8 @@ contract QuarkBuilderMorphoRepayTest is Test, QuarkBuilderTest { assetSymbols: Arrays.stringArray("USDC", "USDT", "WBTC", "WETH"), assetBalances: Arrays.uintArray(20e6, 0, 0, 0), // has 20 USDC cometPortfolios: emptyCometPortfolios_(), - morphoPortfolios: morphoPortfolios + morphoPortfolios: morphoPortfolios, + morphoVaultPortfolios: emptyMorphoVaultPortfolios_() }); QuarkBuilder builder = new QuarkBuilder(); @@ -687,7 +697,8 @@ contract QuarkBuilderMorphoRepayTest is Test, QuarkBuilderTest { assetSymbols: Arrays.stringArray("USDC", "USDT", "WBTC", "WETH"), assetBalances: Arrays.uintArray(50e6, 0, 0, 0), // has 50 USDC cometPortfolios: emptyCometPortfolios_(), - morphoPortfolios: emptyMorphoPortfolios_() + morphoPortfolios: emptyMorphoPortfolios_(), + morphoVaultPortfolios: emptyMorphoVaultPortfolios_() }); chainPortfolios[1] = ChainPortfolio({ chainId: 8453, @@ -696,7 +707,8 @@ contract QuarkBuilderMorphoRepayTest is Test, QuarkBuilderTest { assetSymbols: Arrays.stringArray("USDC", "USDT", "WBTC", "WETH"), assetBalances: Arrays.uintArray(0, 0, 0, 0), // has 0 USDC on base cometPortfolios: emptyCometPortfolios_(), - morphoPortfolios: morphoPortfolios + morphoPortfolios: morphoPortfolios, + morphoVaultPortfolios: emptyMorphoVaultPortfolios_() }); QuarkBuilder builder = new QuarkBuilder(); diff --git a/test/builder/QuarkBuilderMorphoVaultSupply.t.sol b/test/builder/QuarkBuilderMorphoVaultSupply.t.sol new file mode 100644 index 00000000..befdd1c6 --- /dev/null +++ b/test/builder/QuarkBuilderMorphoVaultSupply.t.sol @@ -0,0 +1,865 @@ +// SPDX-License-Identifier: BSD-3-Clause +pragma solidity ^0.8.23; + +import "forge-std/Test.sol"; +import "forge-std/console.sol"; + +import {QuarkBuilderTest, Accounts, PaymentInfo} from "test/builder/lib/QuarkBuilderTest.sol"; + +import {Actions} from "src/builder/Actions.sol"; +import {CCTPBridgeActions} from "src/BridgeScripts.sol"; +import {CodeJarHelper} from "src/builder/CodeJarHelper.sol"; +import {CometSupplyActions, TransferActions} from "src/DeFiScripts.sol"; +import {Paycall} from "src/Paycall.sol"; +import {MorphoInfo} from "src/builder/MorphoInfo.sol"; +import {MorphoVaultActions} from "src/MorphoScripts.sol"; +import {Multicall} from "src/Multicall.sol"; +import {Quotecall} from "src/Quotecall.sol"; +import {QuarkBuilder} from "src/builder/QuarkBuilder.sol"; +import {WrapperActions} from "src/WrapperScripts.sol"; + +contract QuarkBuilderMorphoVaultTest is Test, QuarkBuilderTest { + function morphoSupplyIntent_(uint256 chainId, uint256 amount, string memory assetSymbol) + internal + pure + returns (QuarkBuilder.MorphoVaultSupplyIntent memory) + { + return QuarkBuilder.MorphoVaultSupplyIntent({ + amount: amount, + assetSymbol: assetSymbol, + blockTimestamp: BLOCK_TIMESTAMP, + chainId: chainId, + sender: address(0xa11ce) + }); + } + + function testInsufficientFunds() public { + QuarkBuilder builder = new QuarkBuilder(); + vm.expectRevert(abi.encodeWithSelector(QuarkBuilder.FundsUnavailable.selector, "USDC", 2e6, 0e6)); + builder.morphoVaultSupply( + QuarkBuilder.MorphoVaultSupplyIntent({ + amount: 2e6, + assetSymbol: "USDC", + blockTimestamp: BLOCK_TIMESTAMP, + sender: address(0xa11ce), + chainId: 1 + }), + chainAccountsList_(0e6), // but we are holding 0 USDC in total across 1, 8453 + paymentUsd_() + ); + } + + function testMaxCostTooHigh() public { + QuarkBuilder builder = new QuarkBuilder(); + // Max cost is too high, so total available funds is 0 + vm.expectRevert(abi.encodeWithSelector(QuarkBuilder.FundsUnavailable.selector, "USDC", 1e6, 0e6)); + builder.morphoVaultSupply( + QuarkBuilder.MorphoVaultSupplyIntent({ + amount: 1e6, + assetSymbol: "USDC", + blockTimestamp: BLOCK_TIMESTAMP, + sender: address(0xa11ce), + chainId: 1 + }), + chainAccountsList_(2e6), // holding 2 USDC in total across 1, 8453 + paymentUsdc_(maxCosts_(1, 1_000e6)) // but costs 1,000 USDC + ); + } + + function testFundsUnavailable() public { + QuarkBuilder builder = new QuarkBuilder(); + Accounts.ChainAccounts[] memory chainAccountsList = new Accounts.ChainAccounts[](3); + chainAccountsList[0] = Accounts.ChainAccounts({ + chainId: 1, + quarkStates: quarkStates_(address(0xa11ce), 12), + assetPositionsList: assetPositionsList_(1, address(0xa11ce), 0e6), + cometPositions: emptyCometPositions_(), + morphoPositions: emptyMorphoPositions_(), + morphoVaultPositions: emptyMorphoVaultPositions_() + }); + chainAccountsList[1] = Accounts.ChainAccounts({ + chainId: 8453, + quarkStates: quarkStates_(address(0xb0b), 2), + assetPositionsList: assetPositionsList_(8453, address(0xb0b), 0e6), + cometPositions: emptyCometPositions_(), + morphoPositions: emptyMorphoPositions_(), + morphoVaultPositions: emptyMorphoVaultPositions_() + }); + chainAccountsList[2] = Accounts.ChainAccounts({ + chainId: 7777, + quarkStates: quarkStates_(address(0xc0b), 5), + assetPositionsList: assetPositionsList_(7777, address(0xc0b), 100e6), + cometPositions: emptyCometPositions_(), + morphoPositions: emptyMorphoPositions_(), + morphoVaultPositions: emptyMorphoVaultPositions_() + }); + vm.expectRevert(abi.encodeWithSelector(QuarkBuilder.FundsUnavailable.selector, "USDC", 2e6, 0)); + builder.morphoVaultSupply( + // there is no bridge to brige from 7777, so we cannot get to our funds + QuarkBuilder.MorphoVaultSupplyIntent({ + amount: 2e6, + assetSymbol: "USDC", + blockTimestamp: BLOCK_TIMESTAMP, + sender: address(0xa11ce), + chainId: 1 + }), + chainAccountsList, + paymentUsd_() + ); + } + + function testSimpleMorphoVaultSupply() public { + QuarkBuilder builder = new QuarkBuilder(); + QuarkBuilder.BuilderResult memory result = builder.morphoVaultSupply( + QuarkBuilder.MorphoVaultSupplyIntent({ + amount: 1e6, + assetSymbol: "USDC", + blockTimestamp: BLOCK_TIMESTAMP, + sender: address(0xa11ce), + chainId: 1 + }), + chainAccountsList_(3e6), // holding 3 USDC in total across chains 1, 8453 + paymentUsd_() + ); + + assertEq(result.paymentCurrency, "usd", "usd currency"); + + // Check the quark operations + assertEq(result.quarkOperations.length, 1, "one operation"); + assertEq( + result.quarkOperations[0].scriptAddress, + CodeJarHelper.getCodeAddress(type(MorphoVaultActions).creationCode), + "script address is correct given the code jar address on mainnet" + ); + assertEq( + result.quarkOperations[0].scriptCalldata, + abi.encodeCall(MorphoVaultActions.deposit, (MorphoInfo.getMorphoVaultAddress(1, "USDC"), usdc_(1), 1e6)), + "calldata is MorphoVaultActions.deposit(MorphoInfo.getMorphoVaultAddress(1, USDC), usdc_(1), 1e6);" + ); + assertEq( + result.quarkOperations[0].expiry, BLOCK_TIMESTAMP + 7 days, "expiry is current blockTimestamp + 7 days" + ); + + // check the actions + assertEq(result.actions.length, 1, "one action"); + assertEq(result.actions[0].chainId, 1, "operation is on chainid 1"); + assertEq(result.actions[0].quarkAccount, address(0xa11ce), "0xa11ce sends the funds"); + assertEq(result.actions[0].actionType, "MORPHO_VAULT_SUPPLY", "action type is 'MORPHO_VAULT_SUPPLY'"); + assertEq(result.actions[0].paymentMethod, "OFFCHAIN", "payment method is 'OFFCHAIN'"); + assertEq(result.actions[0].paymentToken, address(0), "payment token is null"); + assertEq(result.actions[0].paymentMaxCost, 0, "payment has no max cost, since 'OFFCHAIN'"); + assertEq( + result.actions[0].actionContext, + abi.encode( + Actions.MorphoVaultSupplyActionContext({ + amount: 1e6, + assetSymbol: "USDC", + chainId: 1, + morphoVault: MorphoInfo.getMorphoVaultAddress(1, "USDC"), + price: USDC_PRICE, + token: USDC_1 + }) + ), + "action context encoded from SupplyActionContext" + ); + + // // TODO: Check the contents of the EIP712 data + assertNotEq(result.eip712Data.digest, hex"", "non-empty digest"); + assertNotEq(result.eip712Data.domainSeparator, hex"", "non-empty domain separator"); + assertNotEq(result.eip712Data.hashStruct, hex"", "non-empty hashStruct"); + } + + function testSimpleMorphoVaultSupplyMax() public { + QuarkBuilder builder = new QuarkBuilder(); + Accounts.ChainAccounts[] memory chainAccountsList = new Accounts.ChainAccounts[](1); + chainAccountsList[0] = Accounts.ChainAccounts({ + chainId: 1, + quarkStates: quarkStates_(address(0xa11ce), 12), + assetPositionsList: assetPositionsList_(1, address(0xa11ce), uint256(3e6)), + cometPositions: emptyCometPositions_(), + morphoPositions: emptyMorphoPositions_(), + morphoVaultPositions: emptyMorphoVaultPositions_() + }); + + QuarkBuilder.BuilderResult memory result = builder.morphoVaultSupply( + QuarkBuilder.MorphoVaultSupplyIntent({ + amount: type(uint256).max, + assetSymbol: "USDC", + blockTimestamp: BLOCK_TIMESTAMP, + sender: address(0xa11ce), + chainId: 1 + }), + chainAccountsList, // holding 3 USDC in total across chains 1, 8453 + paymentUsd_() + ); + + assertEq(result.paymentCurrency, "usd", "usd currency"); + + // Check the quark operations + assertEq(result.quarkOperations.length, 1, "one operation"); + assertEq( + result.quarkOperations[0].scriptAddress, + CodeJarHelper.getCodeAddress(type(MorphoVaultActions).creationCode), + "script address is correct given the code jar address on mainnet" + ); + assertEq( + result.quarkOperations[0].scriptCalldata, + abi.encodeCall(MorphoVaultActions.deposit, (MorphoInfo.getMorphoVaultAddress(1, "USDC"), usdc_(1), 3e6)), + "calldata is MorphoVaultActions.deposit(MorphoInfo.getMorphoVaultAddress(1, USDC), usdc_(1), 3e6);" + ); + assertEq( + result.quarkOperations[0].expiry, BLOCK_TIMESTAMP + 7 days, "expiry is current blockTimestamp + 7 days" + ); + + // check the actions + assertEq(result.actions.length, 1, "one action"); + assertEq(result.actions[0].chainId, 1, "operation is on chainid 1"); + assertEq(result.actions[0].quarkAccount, address(0xa11ce), "0xa11ce sends the funds"); + assertEq(result.actions[0].actionType, "MORPHO_VAULT_SUPPLY", "action type is 'MORPHO_VAULT_SUPPLY'"); + assertEq(result.actions[0].paymentMethod, "OFFCHAIN", "payment method is 'OFFCHAIN'"); + assertEq(result.actions[0].paymentToken, address(0), "payment token is null"); + assertEq(result.actions[0].paymentMaxCost, 0, "payment has no max cost, since 'OFFCHAIN'"); + assertEq( + result.actions[0].actionContext, + abi.encode( + Actions.MorphoVaultSupplyActionContext({ + amount: 3e6, + assetSymbol: "USDC", + chainId: 1, + morphoVault: MorphoInfo.getMorphoVaultAddress(1, "USDC"), + price: USDC_PRICE, + token: USDC_1 + }) + ), + "action context encoded from SupplyActionContext" + ); + + // // TODO: Check the contents of the EIP712 data + assertNotEq(result.eip712Data.digest, hex"", "non-empty digest"); + assertNotEq(result.eip712Data.domainSeparator, hex"", "non-empty domain separator"); + assertNotEq(result.eip712Data.hashStruct, hex"", "non-empty hashStruct"); + } + + function testSimpleMorphoVaultSupplyWithAutoWrapper() public { + QuarkBuilder builder = new QuarkBuilder(); + address account = address(0xa11ce); + Accounts.ChainAccounts[] memory chainAccountsList = new Accounts.ChainAccounts[](1); + Accounts.AssetPositions[] memory assetPositionsList = new Accounts.AssetPositions[](3); + assetPositionsList[0] = Accounts.AssetPositions({ + asset: eth_(), + symbol: "ETH", + decimals: 18, + usdPrice: WETH_PRICE, + accountBalances: accountBalances_(account, 1e18) + }); + assetPositionsList[1] = Accounts.AssetPositions({ + asset: weth_(1), + symbol: "WETH", + decimals: 18, + usdPrice: WETH_PRICE, + accountBalances: accountBalances_(account, 0) + }); + assetPositionsList[2] = Accounts.AssetPositions({ + asset: usdc_(1), + symbol: "USDC", + decimals: 6, + usdPrice: USDC_PRICE, + accountBalances: accountBalances_(account, 0e6) + }); + chainAccountsList[0] = Accounts.ChainAccounts({ + chainId: 1, + quarkStates: quarkStates_(address(0xa11ce), 12), + assetPositionsList: assetPositionsList, + cometPositions: emptyCometPositions_(), + morphoPositions: emptyMorphoPositions_(), + morphoVaultPositions: emptyMorphoVaultPositions_() + }); + + QuarkBuilder.BuilderResult memory result = + builder.morphoVaultSupply(morphoSupplyIntent_(1, 1e18, "WETH"), chainAccountsList, paymentUsd_()); + + assertEq(result.paymentCurrency, "usd", "usd currency"); + + address multicallAddress = CodeJarHelper.getCodeAddress(type(Multicall).creationCode); + address wrapperActionsAddress = CodeJarHelper.getCodeAddress(type(WrapperActions).creationCode); + address morphoVaultActionsAddress = CodeJarHelper.getCodeAddress(type(MorphoVaultActions).creationCode); + // Check the quark operations + assertEq(result.quarkOperations.length, 1, "one merged operation"); + assertEq( + result.quarkOperations[0].scriptAddress, + multicallAddress, + "script address is correct given the code jar address on mainnet" + ); + address[] memory callContracts = new address[](2); + callContracts[0] = wrapperActionsAddress; + callContracts[1] = morphoVaultActionsAddress; + bytes[] memory callDatas = new bytes[](2); + callDatas[0] = + abi.encodeWithSelector(WrapperActions.wrapETH.selector, 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2, 1e18); + callDatas[1] = + abi.encodeCall(MorphoVaultActions.deposit, (MorphoInfo.getMorphoVaultAddress(1, "WETH"), weth_(1), 1e18)); + assertEq( + result.quarkOperations[0].scriptCalldata, + abi.encodeWithSelector(Multicall.run.selector, callContracts, callDatas), + "calldata is Multicall.run([wrapperActionsAddress, morphoVaultActionsAddress], [WrapperActions.wrapWETH(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2, 1e18), MorphoVaultActions.deposit(MorphoInfo.getMorphoVaultAddress(1, WETH), weth_(1), 1e18)" + ); + assertEq( + result.quarkOperations[0].expiry, BLOCK_TIMESTAMP + 7 days, "expiry is current blockTimestamp + 3 days" + ); + + // check the actions + assertEq(result.actions.length, 1, "one action"); + assertEq(result.actions[0].chainId, 1, "operation is on chainid 1"); + assertEq(result.actions[0].quarkAccount, address(0xa11ce), "0xa11ce sends the funds"); + assertEq(result.actions[0].actionType, "MORPHO_VAULT_SUPPLY", "action type is 'MORPHO_VAULT_SUPPLY'"); + assertEq(result.actions[0].paymentMethod, "OFFCHAIN", "payment method is 'OFFCHAIN'"); + assertEq(result.actions[0].paymentToken, address(0), "payment token is null"); + assertEq(result.actions[0].paymentMaxCost, 0, "payment has no max cost, since 'OFFCHAIN'"); + assertEq( + result.actions[0].actionContext, + abi.encode( + Actions.MorphoVaultSupplyActionContext({ + amount: 1e18, + assetSymbol: "WETH", + chainId: 1, + morphoVault: MorphoInfo.getMorphoVaultAddress(1, "WETH"), + price: WETH_PRICE, + token: WETH_1 + }) + ), + "action context encoded from SupplyActionContext" + ); + + // // TODO: Check the contents of the EIP712 data + assertNotEq(result.eip712Data.digest, hex"", "non-empty digest"); + assertNotEq(result.eip712Data.domainSeparator, hex"", "non-empty domain separator"); + assertNotEq(result.eip712Data.hashStruct, hex"", "non-empty hashStruct"); + } + + function testMorphoVaultSupplyWithPaycall() public { + QuarkBuilder builder = new QuarkBuilder(); + PaymentInfo.PaymentMaxCost[] memory maxCosts = new PaymentInfo.PaymentMaxCost[](1); + maxCosts[0] = PaymentInfo.PaymentMaxCost({chainId: 1, amount: 0.1e6}); + QuarkBuilder.BuilderResult memory result = builder.morphoVaultSupply( + morphoSupplyIntent_(1, 1e6, "USDC"), + chainAccountsList_(3e6), // holding 3 USDC in total across chains 1, 8453 + paymentUsdc_(maxCosts) + ); + + address morphoVaultActionsAddress = CodeJarHelper.getCodeAddress(type(MorphoVaultActions).creationCode); + address paycallAddress = paycallUsdc_(1); + + assertEq(result.paymentCurrency, "usdc", "usdc currency"); + + // Check the quark operations + assertEq(result.quarkOperations.length, 1, "one operation"); + assertEq( + result.quarkOperations[0].scriptAddress, + paycallAddress, + "script address is correct given the code jar address on mainnet" + ); + assertEq( + result.quarkOperations[0].scriptCalldata, + abi.encodeWithSelector( + Paycall.run.selector, + morphoVaultActionsAddress, + abi.encodeWithSelector( + MorphoVaultActions.deposit.selector, MorphoInfo.getMorphoVaultAddress(1, "USDC"), usdc_(1), 1e6 + ), + 0.1e6 + ), + "calldata is Paycall.run(MorphoVaultActions.deposit(MorphoInfo.getMorphoVaultAddress(1, USDC), usdc_(1), 1e6), 0.1e6);" + ); + assertEq( + result.quarkOperations[0].expiry, BLOCK_TIMESTAMP + 7 days, "expiry is current blockTimestamp + 7 days" + ); + + // check the actions + assertEq(result.actions.length, 1, "one action"); + assertEq(result.actions[0].chainId, 1, "operation is on chainid 1"); + assertEq(result.actions[0].quarkAccount, address(0xa11ce), "0xa11ce sends the funds"); + assertEq(result.actions[0].actionType, "MORPHO_VAULT_SUPPLY", "action type is 'MORPHO_VAULT_SUPPLY'"); + assertEq(result.actions[0].paymentMethod, "PAY_CALL", "payment method is 'PAY_CALL'"); + assertEq(result.actions[0].paymentToken, USDC_1, "payment token is USDC"); + assertEq(result.actions[0].paymentMaxCost, 0.1e6, "payment max is set to .1e6 in this test case"); + assertEq( + result.actions[0].actionContext, + abi.encode( + Actions.MorphoVaultSupplyActionContext({ + amount: 1e6, + assetSymbol: "USDC", + chainId: 1, + morphoVault: MorphoInfo.getMorphoVaultAddress(1, "USDC"), + price: USDC_PRICE, + token: USDC_1 + }) + ), + "action context encoded from SupplyActionContext" + ); + + // TODO: Check the contents of the EIP712 data + assertNotEq(result.eip712Data.digest, hex"", "non-empty digest"); + assertNotEq(result.eip712Data.domainSeparator, hex"", "non-empty domain separator"); + assertNotEq(result.eip712Data.hashStruct, hex"", "non-empty hashStruct"); + } + + function testMorphoVaultSupplyWithBridge() public { + QuarkBuilder builder = new QuarkBuilder(); + QuarkBuilder.BuilderResult memory result = builder.morphoVaultSupply( + morphoSupplyIntent_(8453, 5e6, "USDC"), + chainAccountsList_(6e6), // holding 3 USDC in total across chains 1, 8453 + paymentUsd_() + ); + + assertEq(result.paymentCurrency, "usd", "usd currency"); + + // Check the quark operations + // first operation + assertEq(result.quarkOperations.length, 2, "two operations"); + assertEq( + result.quarkOperations[0].scriptAddress, + CodeJarHelper.getCodeAddress(type(CCTPBridgeActions).creationCode), + "script address is correct given the code jar address on mainnet" + ); + assertEq( + result.quarkOperations[0].scriptCalldata, + abi.encodeCall( + CCTPBridgeActions.bridgeUSDC, + ( + address(0xBd3fa81B58Ba92a82136038B25aDec7066af3155), + 2e6, + 6, + bytes32(uint256(uint160(0xa11ce))), + usdc_(1) + ) + ), + "calldata is CCTPBridgeActions.bridgeUSDC(address(0xBd3fa81B58Ba92a82136038B25aDec7066af3155), 2e6, 6, bytes32(uint256(uint160(0xa11ce))), usdc_(1)));" + ); + assertEq( + result.quarkOperations[0].expiry, BLOCK_TIMESTAMP + 7 days, "expiry is current blockTimestamp + 7 days" + ); + + // second operation + assertEq( + result.quarkOperations[1].scriptAddress, + CodeJarHelper.getCodeAddress(type(MorphoVaultActions).creationCode), + "script address for transfer is correct given the code jar address" + ); + assertEq( + result.quarkOperations[1].scriptCalldata, + abi.encodeCall( + MorphoVaultActions.deposit, (MorphoInfo.getMorphoVaultAddress(8453, "USDC"), usdc_(8453), 5e6) + ), + "calldata is MorphoVaultActions.deposit(MorphoInfo.getMorphoVaultAddress(8453, USDC), usdc_(8453), 5e6)" + ); + assertEq( + result.quarkOperations[1].expiry, BLOCK_TIMESTAMP + 7 days, "expiry is current blockTimestamp + 7 days" + ); + + // check the actions + // first action + assertEq(result.actions.length, 2, "two actions"); + assertEq(result.actions[0].chainId, 1, "operation is on chainid 1"); + assertEq(result.actions[0].quarkAccount, address(0xa11ce), "0xa11ce sends the funds"); + assertEq(result.actions[0].actionType, "BRIDGE", "action type is 'BRIDGE'"); + assertEq(result.actions[0].paymentMethod, "OFFCHAIN", "payment method is 'OFFCHAIN'"); + assertEq(result.actions[0].paymentToken, address(0), "payment token is null"); + assertEq(result.actions[0].paymentMaxCost, 0, "payment has no max cost, since 'OFFCHAIN'"); + assertEq( + result.actions[0].actionContext, + abi.encode( + Actions.BridgeActionContext({ + amount: 2e6, + price: USDC_PRICE, + token: USDC_1, + assetSymbol: "USDC", + chainId: 1, + recipient: address(0xa11ce), + destinationChainId: 8453, + bridgeType: Actions.BRIDGE_TYPE_CCTP + }) + ), + "action context encoded from BridgeActionContext" + ); + + // second action + assertEq(result.actions[1].chainId, 8453, "second action is on chainid 8453"); + assertEq(result.actions[1].quarkAccount, address(0xa11ce), "0xa11ce sends the funds"); + assertEq(result.actions[1].actionType, "MORPHO_VAULT_SUPPLY", "action type is 'MORPHO_VAULT_SUPPLY'"); + assertEq(result.actions[1].paymentMethod, "OFFCHAIN", "payment method is 'OFFCHAIN'"); + assertEq(result.actions[1].paymentToken, address(0), "payment token is null"); + assertEq(result.actions[1].paymentMaxCost, 0, "payment has no max cost, since 'OFFCHAIN'"); + assertEq( + result.actions[1].actionContext, + abi.encode( + Actions.MorphoVaultSupplyActionContext({ + amount: 5e6, + assetSymbol: "USDC", + chainId: 8453, + morphoVault: MorphoInfo.getMorphoVaultAddress(8453, "USDC"), + price: USDC_PRICE, + token: USDC_8453 + }) + ), + "action context encoded from SupplyActionContext" + ); + + // TODO: Check the contents of the EIP712 data + assertNotEq(result.eip712Data.digest, hex"", "non-empty digest"); + assertNotEq(result.eip712Data.domainSeparator, hex"", "non-empty domain separator"); + assertNotEq(result.eip712Data.hashStruct, hex"", "non-empty hashStruct"); + } + + function testMorphoVaultSupplyMaxWithBridge() public { + QuarkBuilder builder = new QuarkBuilder(); + QuarkBuilder.BuilderResult memory result = builder.morphoVaultSupply( + morphoSupplyIntent_(8453, type(uint256).max, "USDC"), + chainAccountsList_(6e6), // holding 3 USDC in total across chains 1, 8453 + paymentUsd_() + ); + + assertEq(result.paymentCurrency, "usd", "usd currency"); + + // Check the quark operations + // first operation + assertEq(result.quarkOperations.length, 2, "two operations"); + assertEq( + result.quarkOperations[0].scriptAddress, + CodeJarHelper.getCodeAddress(type(CCTPBridgeActions).creationCode), + "script address is correct given the code jar address on mainnet" + ); + assertEq( + result.quarkOperations[0].scriptCalldata, + abi.encodeCall( + CCTPBridgeActions.bridgeUSDC, + ( + address(0xBd3fa81B58Ba92a82136038B25aDec7066af3155), + 3e6, + 6, + bytes32(uint256(uint160(0xa11ce))), + usdc_(1) + ) + ), + "calldata is CCTPBridgeActions.bridgeUSDC(address(0xBd3fa81B58Ba92a82136038B25aDec7066af3155), 3e6, 6, bytes32(uint256(uint160(0xa11ce))), usdc_(1)));" + ); + assertEq( + result.quarkOperations[0].expiry, BLOCK_TIMESTAMP + 7 days, "expiry is current blockTimestamp + 7 days" + ); + + // second operation + assertEq( + result.quarkOperations[1].scriptAddress, + CodeJarHelper.getCodeAddress(type(MorphoVaultActions).creationCode), + "script address for transfer is correct given the code jar address" + ); + assertEq( + result.quarkOperations[1].scriptCalldata, + abi.encodeCall( + MorphoVaultActions.deposit, (MorphoInfo.getMorphoVaultAddress(8453, "USDC"), usdc_(8453), 6e6) + ), + "calldata is MorphoVaultActions.deposit, (MorphoInfo.getMorphoVaultAddress(8453, USDC), usdc_(8453), 6e6)" + ); + assertEq( + result.quarkOperations[1].expiry, BLOCK_TIMESTAMP + 7 days, "expiry is current blockTimestamp + 7 days" + ); + + // check the actions + // first action + assertEq(result.actions.length, 2, "two actions"); + assertEq(result.actions[0].chainId, 1, "operation is on chainid 1"); + assertEq(result.actions[0].quarkAccount, address(0xa11ce), "0xa11ce sends the funds"); + assertEq(result.actions[0].actionType, "BRIDGE", "action type is 'BRIDGE'"); + assertEq(result.actions[0].paymentMethod, "OFFCHAIN", "payment method is 'OFFCHAIN'"); + assertEq(result.actions[0].paymentToken, address(0), "payment token is null"); + assertEq(result.actions[0].paymentMaxCost, 0, "payment has no max cost, since 'OFFCHAIN'"); + assertEq( + result.actions[0].actionContext, + abi.encode( + Actions.BridgeActionContext({ + amount: 3e6, + price: USDC_PRICE, + token: USDC_1, + assetSymbol: "USDC", + chainId: 1, + recipient: address(0xa11ce), + destinationChainId: 8453, + bridgeType: Actions.BRIDGE_TYPE_CCTP + }) + ), + "action context encoded from BridgeActionContext" + ); + + // second action + assertEq(result.actions[1].chainId, 8453, "second action is on chainid 8453"); + assertEq(result.actions[1].quarkAccount, address(0xa11ce), "0xa11ce sends the funds"); + assertEq(result.actions[1].actionType, "MORPHO_VAULT_SUPPLY", "action type is 'MORPHO_VAULT_SUPPLY'"); + assertEq(result.actions[1].paymentMethod, "OFFCHAIN", "payment method is 'OFFCHAIN'"); + assertEq(result.actions[1].paymentToken, address(0), "payment token is null"); + assertEq(result.actions[1].paymentMaxCost, 0, "payment has no max cost, since 'OFFCHAIN'"); + assertEq( + result.actions[1].actionContext, + abi.encode( + Actions.MorphoVaultSupplyActionContext({ + amount: 6e6, + assetSymbol: "USDC", + chainId: 8453, + morphoVault: MorphoInfo.getMorphoVaultAddress(8453, "USDC"), + price: USDC_PRICE, + token: USDC_8453 + }) + ), + "action context encoded from SupplyActionContext" + ); + + // TODO: Check the contents of the EIP712 data + assertNotEq(result.eip712Data.digest, hex"", "non-empty digest"); + assertNotEq(result.eip712Data.domainSeparator, hex"", "non-empty domain separator"); + assertNotEq(result.eip712Data.hashStruct, hex"", "non-empty hashStruct"); + } + + function testMorphoVaultSupplyMaxWithBridgeAndQuotecall() public { + QuarkBuilder builder = new QuarkBuilder(); + PaymentInfo.PaymentMaxCost[] memory maxCosts = new PaymentInfo.PaymentMaxCost[](2); + maxCosts[0] = PaymentInfo.PaymentMaxCost({chainId: 1, amount: 0.5e6}); + maxCosts[1] = PaymentInfo.PaymentMaxCost({chainId: 8453, amount: 0.1e6}); + + // Note: There are 3e6 USDC on each chain, so the Builder should attempt to bridge 2 USDC to chain 8453 + QuarkBuilder.BuilderResult memory result = builder.morphoVaultSupply( + morphoSupplyIntent_(8453, type(uint256).max, "USDC"), + chainAccountsList_(6e6), // holding 3 USDC in total across chains 1, 8453 + paymentUsdc_(maxCosts) + ); + + address quotecallAddress = quotecallUsdc_(1); + address quotecallAddressBase = quotecallUsdc_(8453); + address cctpBridgeActionsAddress = CodeJarHelper.getCodeAddress(type(CCTPBridgeActions).creationCode); + + assertEq(result.paymentCurrency, "usdc", "usd currency"); + + // Check the quark operations + assertEq(result.quarkOperations.length, 2, "two operations"); + // first operation + assertEq( + result.quarkOperations[0].scriptAddress, + quotecallAddress, + "script address[0] has been wrapped with quotecall address" + ); + assertEq( + result.quarkOperations[0].scriptCalldata, + abi.encodeWithSelector( + Quotecall.run.selector, + cctpBridgeActionsAddress, + abi.encodeWithSelector( + CCTPBridgeActions.bridgeUSDC.selector, + address(0xBd3fa81B58Ba92a82136038B25aDec7066af3155), + 2.5e6, // 3e6 - 0.5e6 + 6, + bytes32(uint256(uint160(0xa11ce))), + usdc_(1) + ), + 0.5e6 + ), + "calldata is Quotecall.run(CCTPBridgeActions.bridgeUSDC(address(0xBd3fa81B58Ba92a82136038B25aDec7066af3155), 2.1e6, 6, bytes32(uint256(uint160(0xa11ce))), usdc_(1))), 0.5e6);" + ); + assertEq( + result.quarkOperations[0].expiry, BLOCK_TIMESTAMP + 7 days, "expiry is current blockTimestamp + 7 days" + ); + + // second operation + assertEq( + result.quarkOperations[1].scriptAddress, + quotecallAddressBase, + "script address[1] has been wrapped with quotecall address" + ); + assertEq( + result.quarkOperations[1].scriptCalldata, + abi.encodeWithSelector( + Quotecall.run.selector, + CodeJarHelper.getCodeAddress(type(MorphoVaultActions).creationCode), + abi.encodeCall( + MorphoVaultActions.deposit, (MorphoInfo.getMorphoVaultAddress(8453, "USDC"), usdc_(8453), 5.4e6) + ), + 0.1e6 + ), + "calldata is Quotecall.run(MorphoVaultActions.deposit, (MorphoInfo.getMorphoVaultAddress(8453, USDC), usdc_(8453), 5.4e6)), 0.1e6);" + ); + assertEq( + result.quarkOperations[1].expiry, BLOCK_TIMESTAMP + 7 days, "expiry is current blockTimestamp + 7 days" + ); + + // Check the actions + assertEq(result.actions.length, 2, "two actions"); + // first action + assertEq(result.actions[0].chainId, 1, "operation is on chainid 1"); + assertEq(result.actions[0].quarkAccount, address(0xa11ce), "0xa11ce sends the funds"); + assertEq(result.actions[0].actionType, "BRIDGE", "action type is 'BRIDGE'"); + assertEq(result.actions[0].paymentMethod, "QUOTE_CALL", "payment method is 'QUOTE_CALL'"); + assertEq(result.actions[0].paymentToken, USDC_1, "payment token is USDC on mainnet"); + assertEq(result.actions[0].paymentMaxCost, 0.5e6, "payment should have max cost of 0.5e6"); + assertEq( + result.actions[0].actionContext, + abi.encode( + Actions.BridgeActionContext({ + amount: 2.5e6, + price: USDC_PRICE, + token: USDC_1, + assetSymbol: "USDC", + chainId: 1, + recipient: address(0xa11ce), + destinationChainId: 8453, + bridgeType: Actions.BRIDGE_TYPE_CCTP + }) + ), + "action context encoded from BridgeActionContext" + ); + // second action + assertEq(result.actions[1].chainId, 8453, "operation is on chainid 8453"); + assertEq(result.actions[1].quarkAccount, address(0xa11ce), "0xa11ce sends the funds"); + assertEq(result.actions[1].actionType, "MORPHO_VAULT_SUPPLY", "action type is 'MORPHO_VAULT_SUPPLY'"); + assertEq(result.actions[1].paymentMethod, "QUOTE_CALL", "payment method is 'QUOTE_CALL'"); + assertEq(result.actions[1].paymentToken, USDC_8453, "payment token is USDC on Base"); + assertEq(result.actions[1].paymentMaxCost, 0.1e6, "payment should have max cost of 0.1e6"); + assertEq( + result.actions[1].actionContext, + abi.encode( + Actions.MorphoVaultSupplyActionContext({ + amount: 5.4e6, + assetSymbol: "USDC", + chainId: 8453, + morphoVault: MorphoInfo.getMorphoVaultAddress(8453, "USDC"), + price: USDC_PRICE, + token: USDC_8453 + }) + ), + "action context encoded from SupplyActionContext" + ); + + // TODO: Check the contents of the EIP712 data + assertNotEq(result.eip712Data.digest, hex"", "non-empty digest"); + assertNotEq(result.eip712Data.domainSeparator, hex"", "non-empty domain separator"); + assertNotEq(result.eip712Data.hashStruct, hex"", "non-empty hashStruct"); + } + + function testMorphoVaultSupplyWithBridgeAndPaycall() public { + QuarkBuilder builder = new QuarkBuilder(); + PaymentInfo.PaymentMaxCost[] memory maxCosts = new PaymentInfo.PaymentMaxCost[](2); + maxCosts[0] = PaymentInfo.PaymentMaxCost({chainId: 1, amount: 0.5e6}); + maxCosts[1] = PaymentInfo.PaymentMaxCost({chainId: 8453, amount: 0.1e6}); + + // Note: There are 3e6 USDC on each chain, so the Builder should attempt to bridge 2 USDC to chain 8453 + QuarkBuilder.BuilderResult memory result = builder.morphoVaultSupply( + morphoSupplyIntent_(8453, 5e6, "USDC"), + chainAccountsList_(6e6), // holding 3 USDC in total across chains 1, 8453 + paymentUsdc_(maxCosts) + ); + + address paycallAddress = paycallUsdc_(1); + address paycallAddressBase = paycallUsdc_(8453); + address cctpBridgeActionsAddress = CodeJarHelper.getCodeAddress(type(CCTPBridgeActions).creationCode); + + assertEq(result.paymentCurrency, "usdc", "usd currency"); + + // Check the quark operations + assertEq(result.quarkOperations.length, 2, "two operations"); + // first operation + assertEq( + result.quarkOperations[0].scriptAddress, + paycallAddress, + "script address[0] has been wrapped with paycall address" + ); + assertEq( + result.quarkOperations[0].scriptCalldata, + abi.encodeWithSelector( + Paycall.run.selector, + cctpBridgeActionsAddress, + abi.encodeWithSelector( + CCTPBridgeActions.bridgeUSDC.selector, + address(0xBd3fa81B58Ba92a82136038B25aDec7066af3155), + 2.1e6, + 6, + bytes32(uint256(uint160(0xa11ce))), + usdc_(1) + ), + 0.5e6 + ), + "calldata is Paycall.run(CCTPBridgeActions.bridgeUSDC(address(0xBd3fa81B58Ba92a82136038B25aDec7066af3155), 2.1e6, 6, bytes32(uint256(uint160(0xa11ce))), usdc_(1))), 0.5e6);" + ); + assertEq( + result.quarkOperations[0].expiry, BLOCK_TIMESTAMP + 7 days, "expiry is current blockTimestamp + 7 days" + ); + + // second operation + assertEq( + result.quarkOperations[1].scriptAddress, + paycallAddressBase, + "script address[1] has been wrapped with paycall address" + ); + assertEq( + result.quarkOperations[1].scriptCalldata, + abi.encodeWithSelector( + Paycall.run.selector, + CodeJarHelper.getCodeAddress(type(MorphoVaultActions).creationCode), + abi.encodeCall( + MorphoVaultActions.deposit, (MorphoInfo.getMorphoVaultAddress(8453, "USDC"), usdc_(8453), 5e6) + ), + 0.1e6 + ), + "calldata is Paycall.run(MorphoInfo.getMorphoVaultAddress(8453, USDC), usdc_(8453), 5e6), 0.1e6);" + ); + assertEq( + result.quarkOperations[1].expiry, BLOCK_TIMESTAMP + 7 days, "expiry is current blockTimestamp + 7 days" + ); + + // Check the actions + assertEq(result.actions.length, 2, "two actions"); + // first action + assertEq(result.actions[0].chainId, 1, "operation is on chainid 1"); + assertEq(result.actions[0].quarkAccount, address(0xa11ce), "0xa11ce sends the funds"); + assertEq(result.actions[0].actionType, "BRIDGE", "action type is 'BRIDGE'"); + assertEq(result.actions[0].paymentMethod, "PAY_CALL", "payment method is 'PAY_CALL'"); + assertEq(result.actions[0].paymentToken, USDC_1, "payment token is USDC on mainnet"); + assertEq(result.actions[0].paymentMaxCost, 0.5e6, "payment should have max cost of 0.5e6"); + assertEq( + result.actions[0].actionContext, + abi.encode( + Actions.BridgeActionContext({ + amount: 2.1e6, + price: USDC_PRICE, + token: USDC_1, + assetSymbol: "USDC", + chainId: 1, + recipient: address(0xa11ce), + destinationChainId: 8453, + bridgeType: Actions.BRIDGE_TYPE_CCTP + }) + ), + "action context encoded from BridgeActionContext" + ); + // second action + assertEq(result.actions[1].chainId, 8453, "operation is on chainid 8453"); + assertEq(result.actions[1].quarkAccount, address(0xa11ce), "0xa11ce sends the funds"); + assertEq(result.actions[1].actionType, "MORPHO_VAULT_SUPPLY", "action type is 'MORPHO_VAULT_SUPPLY'"); + assertEq(result.actions[1].paymentMethod, "PAY_CALL", "payment method is 'PAY_CALL'"); + assertEq(result.actions[1].paymentToken, USDC_8453, "payment token is USDC on Base"); + assertEq(result.actions[1].paymentMaxCost, 0.1e6, "payment should have max cost of 0.1e6"); + assertEq( + result.actions[1].actionContext, + abi.encode( + Actions.MorphoVaultSupplyActionContext({ + amount: 5e6, + assetSymbol: "USDC", + chainId: 8453, + morphoVault: MorphoInfo.getMorphoVaultAddress(8453, "USDC"), + price: USDC_PRICE, + token: USDC_8453 + }) + ), + "action context encoded from SupplyActionContext" + ); + + // TODO: Check the contents of the EIP712 data + assertNotEq(result.eip712Data.digest, hex"", "non-empty digest"); + assertNotEq(result.eip712Data.domainSeparator, hex"", "non-empty domain separator"); + assertNotEq(result.eip712Data.hashStruct, hex"", "non-empty hashStruct"); + } +} diff --git a/test/builder/QuarkBuilderMorphoVaultWithdraw.t.sol b/test/builder/QuarkBuilderMorphoVaultWithdraw.t.sol new file mode 100644 index 00000000..ec60d9cd --- /dev/null +++ b/test/builder/QuarkBuilderMorphoVaultWithdraw.t.sol @@ -0,0 +1,489 @@ +// SPDX-License-Identifier: BSD-3-Clause +pragma solidity ^0.8.23; + +import "forge-std/Test.sol"; + +import {Arrays} from "test/builder/lib/Arrays.sol"; +import {Accounts, PaymentInfo, QuarkBuilderTest} from "test/builder/lib/QuarkBuilderTest.sol"; +import {Actions} from "src/builder/Actions.sol"; +import {CCTPBridgeActions} from "src/BridgeScripts.sol"; +import {CodeJarHelper} from "src/builder/CodeJarHelper.sol"; +import {CometWithdrawActions, TransferActions} from "src/DeFiScripts.sol"; +import {MorphoInfo} from "src/builder/MorphoInfo.sol"; +import {MorphoVaultActions} from "src/MorphoScripts.sol"; +import {Paycall} from "src/Paycall.sol"; +import {QuarkBuilder} from "src/builder/QuarkBuilder.sol"; + +contract QuarkBuilderMorphoVaultWithdrawTest is Test, QuarkBuilderTest { + function morphoWithdrawIntent_(uint256 chainId, uint256 amount, string memory assetSymbol) + internal + pure + returns (QuarkBuilder.MorphoVaultWithdrawIntent memory) + { + return QuarkBuilder.MorphoVaultWithdrawIntent({ + amount: amount, + assetSymbol: assetSymbol, + blockTimestamp: BLOCK_TIMESTAMP, + chainId: chainId, + withdrawer: address(0xa11ce) + }); + } + + // XXX test that you have enough of the asset to withdraw + + function testMorphoVaultWithdraw() public { + QuarkBuilder builder = new QuarkBuilder(); + QuarkBuilder.BuilderResult memory result = builder.morphoVaultWithdraw( + morphoWithdrawIntent_(1, 2e6, "USDC"), + chainAccountsList_(2e6), // holding 2 USDC in total across 1, 8453 + paymentUsd_() + ); + + assertEq(result.paymentCurrency, "usd", "usd currency"); + + // Check the quark operations + assertEq(result.quarkOperations.length, 1, "one operation"); + assertEq( + result.quarkOperations[0].scriptAddress, + CodeJarHelper.getCodeAddress(type(MorphoVaultActions).creationCode), + "script address is correct given the code jar address on mainnet" + ); + assertEq( + result.quarkOperations[0].scriptCalldata, + abi.encodeCall(MorphoVaultActions.withdraw, (MorphoInfo.getMorphoVaultAddress(1, "USDC"), 2e6)), + "calldata is MorphoVaultActions.withdraw(MorphoInfo.getMorphoVaultAddress(1, USDC), 2e6);" + ); + assertEq( + result.quarkOperations[0].expiry, BLOCK_TIMESTAMP + 7 days, "expiry is current blockTimestamp + 7 days" + ); + + // check the actions + assertEq(result.actions.length, 1, "one action"); + assertEq(result.actions[0].chainId, 1, "operation is on chainId 1"); + assertEq(result.actions[0].quarkAccount, address(0xa11ce), "0xa11ce sends the funds"); + assertEq(result.actions[0].actionType, "MORPHO_VAULT_WITHDRAW", "action type is 'MORPHO_VAULT_WITHDRAW'"); + assertEq(result.actions[0].paymentMethod, "OFFCHAIN", "payment method is 'OFFCHAIN'"); + assertEq(result.actions[0].paymentToken, address(0), "payment token is null"); + assertEq(result.actions[0].paymentMaxCost, 0, "payment has no max cost, since 'OFFCHAIN'"); + assertEq( + result.actions[0].actionContext, + abi.encode( + Actions.MorphoVaultWithdrawActionContext({ + amount: 2e6, + assetSymbol: "USDC", + chainId: 1, + morphoVault: MorphoInfo.getMorphoVaultAddress(1, "USDC"), + price: USDC_PRICE, + token: USDC_1 + }) + ), + "action context encoded from WithdrawActionContext" + ); + + // TODO: Check the contents of the EIP712 data + assertNotEq(result.eip712Data.digest, hex"", "non-empty digest"); + assertNotEq(result.eip712Data.domainSeparator, hex"", "non-empty domain separator"); + assertNotEq(result.eip712Data.hashStruct, hex"", "non-empty hashStruct"); + } + + function testMorphoVaultWithdrawWithPaycall() public { + QuarkBuilder builder = new QuarkBuilder(); + PaymentInfo.PaymentMaxCost[] memory maxCosts = new PaymentInfo.PaymentMaxCost[](1); + maxCosts[0] = PaymentInfo.PaymentMaxCost({chainId: 1, amount: 0.1e6}); + QuarkBuilder.BuilderResult memory result = builder.morphoVaultWithdraw( + morphoWithdrawIntent_(1, 2e6, "USDC"), chainAccountsList_(3e6), paymentUsdc_(maxCosts) + ); + + address morphoVaultActionsAddress = CodeJarHelper.getCodeAddress(type(MorphoVaultActions).creationCode); + address paycallAddress = paycallUsdc_(1); + + assertEq(result.paymentCurrency, "usdc", "usdc currency"); + + // Check the quark operations + assertEq(result.quarkOperations.length, 1, "one operation"); + assertEq( + result.quarkOperations[0].scriptAddress, + paycallAddress, + "script address is correct given the code jar address on mainnet" + ); + assertEq( + result.quarkOperations[0].scriptCalldata, + abi.encodeWithSelector( + Paycall.run.selector, + morphoVaultActionsAddress, + abi.encodeWithSelector( + MorphoVaultActions.withdraw.selector, MorphoInfo.getMorphoVaultAddress(1, "USDC"), 2e6 + ), + 0.1e6 + ), + "calldata is Paycall.run(MorphoVaultActions.withdraw(MorphoInfo.getMorphoVaultAddress(1, USDC), 0.1e6);" + ); + assertEq( + result.quarkOperations[0].expiry, BLOCK_TIMESTAMP + 7 days, "expiry is current blockTimestamp + 7 days" + ); + + // check the actions + assertEq(result.actions.length, 1, "one action"); + assertEq(result.actions[0].chainId, 1, "operation is on chainid 1"); + assertEq(result.actions[0].quarkAccount, address(0xa11ce), "0xa11ce sends the funds"); + assertEq(result.actions[0].actionType, "MORPHO_VAULT_WITHDRAW", "action type is 'MORPHO_VAULT_WITHDRAW'"); + assertEq(result.actions[0].paymentMethod, "PAY_CALL", "payment method is 'PAY_CALL'"); + assertEq(result.actions[0].paymentToken, USDC_1, "payment token is USDC"); + assertEq(result.actions[0].paymentMaxCost, 0.1e6, "payment max is set to .1e6 in this test case"); + assertEq( + result.actions[0].actionContext, + abi.encode( + Actions.MorphoVaultWithdrawActionContext({ + amount: 2e6, + assetSymbol: "USDC", + chainId: 1, + morphoVault: MorphoInfo.getMorphoVaultAddress(1, "USDC"), + price: USDC_PRICE, + token: USDC_1 + }) + ), + "action context encoded from WithdrawActionContext" + ); + + // TODO: Check the contents of the EIP712 data + assertNotEq(result.eip712Data.digest, hex"", "non-empty digest"); + assertNotEq(result.eip712Data.domainSeparator, hex"", "non-empty domain separator"); + assertNotEq(result.eip712Data.hashStruct, hex"", "non-empty hashStruct"); + } + + function testMorphoVaultWithdrawPayFromWithdraw() public { + QuarkBuilder builder = new QuarkBuilder(); + PaymentInfo.PaymentMaxCost[] memory maxCosts = new PaymentInfo.PaymentMaxCost[](1); + maxCosts[0] = PaymentInfo.PaymentMaxCost({chainId: 1, amount: 0.5e6}); // action costs .5 USDC + QuarkBuilder.BuilderResult memory result = builder.morphoVaultWithdraw( + morphoWithdrawIntent_(1, 2e6, "USDC"), chainAccountsList_(0), paymentUsdc_(maxCosts) + ); + + address morphoVaultActionsAddress = CodeJarHelper.getCodeAddress(type(MorphoVaultActions).creationCode); + address paycallAddress = paycallUsdc_(1); + + assertEq(result.paymentCurrency, "usdc", "usdc currency"); + + // Check the quark operations + assertEq(result.quarkOperations.length, 1, "one operation"); + assertEq( + result.quarkOperations[0].scriptAddress, + paycallAddress, + "script address is correct given the code jar address on mainnet" + ); + assertEq( + result.quarkOperations[0].scriptCalldata, + abi.encodeWithSelector( + Paycall.run.selector, + morphoVaultActionsAddress, + abi.encodeWithSelector( + MorphoVaultActions.withdraw.selector, MorphoInfo.getMorphoVaultAddress(1, "USDC"), 2e6 + ), + 0.5e6 + ), + "calldata is Paycall.run(MorphoVaultWithdrawActions.withdraw(MorphoInfo.getMorphoVaultAddress(1, USDC), 2e6), 0.5e6);" + ); + assertEq( + result.quarkOperations[0].expiry, BLOCK_TIMESTAMP + 7 days, "expiry is current blockTimestamp + 7 days" + ); + + // check the actions + assertEq(result.actions.length, 1, "one action"); + assertEq(result.actions[0].chainId, 1, "operation is on chainid 1"); + assertEq(result.actions[0].quarkAccount, address(0xa11ce), "0xa11ce sends the funds"); + assertEq(result.actions[0].actionType, "MORPHO_VAULT_WITHDRAW", "action type is 'MORPHO_VAULT_WITHDRAW'"); + assertEq(result.actions[0].paymentMethod, "PAY_CALL", "payment method is 'PAY_CALL'"); + assertEq(result.actions[0].paymentToken, USDC_1, "payment token is USDC"); + assertEq(result.actions[0].paymentMaxCost, 0.5e6, "payment max is set to .5e6 in this test case"); + assertEq( + result.actions[0].actionContext, + abi.encode( + Actions.MorphoVaultWithdrawActionContext({ + amount: 2e6, + assetSymbol: "USDC", + chainId: 1, + morphoVault: MorphoInfo.getMorphoVaultAddress(1, "USDC"), + price: USDC_PRICE, + token: USDC_1 + }) + ), + "action context encoded from WithdrawActionContext" + ); + + // TODO: Check the contents of the EIP712 data + assertNotEq(result.eip712Data.digest, hex"", "non-empty digest"); + assertNotEq(result.eip712Data.domainSeparator, hex"", "non-empty domain separator"); + assertNotEq(result.eip712Data.hashStruct, hex"", "non-empty hashStruct"); + } + + function testMorphoVaultWithdrawNotEnoughFundsToBridge() public { + QuarkBuilder builder = new QuarkBuilder(); + PaymentInfo.PaymentMaxCost[] memory maxCosts = new PaymentInfo.PaymentMaxCost[](2); + maxCosts[0] = PaymentInfo.PaymentMaxCost({chainId: 1, amount: 1000e6}); // max cost is 1000 USDC + maxCosts[1] = PaymentInfo.PaymentMaxCost({chainId: 8453, amount: 0.1e6}); + vm.expectRevert(abi.encodeWithSelector(Actions.NotEnoughFundsToBridge.selector, "usdc", 9.98e8, 9.971e8)); + builder.morphoVaultWithdraw( + morphoWithdrawIntent_(1, 1e6, "USDC"), + chainAccountsList_(2e6), // holding 2 USDC in total across 1, 8453 + paymentUsdc_(maxCosts) + ); + } + + function testMorphoVaultWithdrawWithBridge() public { + QuarkBuilder builder = new QuarkBuilder(); + + PaymentInfo.PaymentMaxCost[] memory maxCosts = new PaymentInfo.PaymentMaxCost[](2); + maxCosts[0] = PaymentInfo.PaymentMaxCost({chainId: 1, amount: 0.1e6}); + maxCosts[1] = PaymentInfo.PaymentMaxCost({chainId: 8453, amount: 1e6}); // max cost on base is 1 USDC + + Accounts.ChainAccounts[] memory chainAccountsList = new Accounts.ChainAccounts[](2); + chainAccountsList[0] = Accounts.ChainAccounts({ + chainId: 1, + quarkStates: quarkStates_(address(0xa11ce), 12), + assetPositionsList: assetPositionsList_(1, address(0xa11ce), 3e6), // 3 USDC on mainnet + cometPositions: emptyCometPositions_(), + morphoPositions: emptyMorphoPositions_(), + morphoVaultPositions: emptyMorphoVaultPositions_() + }); + chainAccountsList[1] = Accounts.ChainAccounts({ + chainId: 8453, + quarkStates: quarkStates_(address(0xb0b), 2), + assetPositionsList: assetPositionsList_(8453, address(0xb0b), 0), // 0 USDC on base + cometPositions: emptyCometPositions_(), + morphoPositions: emptyMorphoPositions_(), + morphoVaultPositions: emptyMorphoVaultPositions_() + }); + + QuarkBuilder.BuilderResult memory result = builder.morphoVaultWithdraw( + morphoWithdrawIntent_(8453, 1e18, "WETH"), chainAccountsList, paymentUsdc_(maxCosts) + ); + + address paycallAddress = paycallUsdc_(1); + address paycallAddressBase = paycallUsdc_(8453); + address cctpBridgeActionsAddress = CodeJarHelper.getCodeAddress(type(CCTPBridgeActions).creationCode); + + assertEq(result.paymentCurrency, "usdc", "usdc currency"); + + // Check the quark operations + // first operation + assertEq(result.quarkOperations.length, 2, "two operations"); + assertEq( + result.quarkOperations[0].scriptAddress, + paycallAddress, + "script address is correct given the code jar address on base" + ); + assertEq( + result.quarkOperations[0].scriptCalldata, + abi.encodeWithSelector( + Paycall.run.selector, + cctpBridgeActionsAddress, + abi.encodeWithSelector( + CCTPBridgeActions.bridgeUSDC.selector, + address(0xBd3fa81B58Ba92a82136038B25aDec7066af3155), + 1e6, + 6, + bytes32(uint256(uint160(0xa11ce))), + usdc_(1) + ), + 0.1e6 + ), + "calldata is Paycall.run(CCTPBridgeActions.bridgeUSDC(0xBd3fa81B58Ba92a82136038B25aDec7066af3155, 1e6, 6, 0xa11ce, USDC_1)), 0.1e6);" + ); + assertEq( + result.quarkOperations[0].expiry, BLOCK_TIMESTAMP + 7 days, "expiry is current blockTimestamp + 7 days" + ); + + // second operation + assertEq( + result.quarkOperations[1].scriptAddress, + paycallAddressBase, + "script address[1] has been wrapped with paycall address" + ); + assertEq( + result.quarkOperations[1].scriptCalldata, + abi.encodeWithSelector( + Paycall.run.selector, + CodeJarHelper.getCodeAddress(type(MorphoVaultActions).creationCode), + abi.encodeCall(MorphoVaultActions.withdraw, (MorphoInfo.getMorphoVaultAddress(8453, "WETH"), 1e18)), + 1e6 + ), + "calldata is Paycall.run(MorphoVaultActions.withdraw(MorphoInfo.getMorphoVaultAddress(8453, WETH), 1e18);" + ); + assertEq( + result.quarkOperations[1].expiry, BLOCK_TIMESTAMP + 7 days, "expiry is current blockTimestamp + 7 days" + ); + + // Check the actions + assertEq(result.actions.length, 2, "two actions"); + // first action + assertEq(result.actions[0].chainId, 1, "operation is on chainid 1"); + assertEq(result.actions[0].quarkAccount, address(0xa11ce), "0xa11ce sends the funds"); + assertEq(result.actions[0].actionType, "BRIDGE", "action type is 'BRIDGE'"); + assertEq(result.actions[0].paymentMethod, "PAY_CALL", "payment method is 'PAY_CALL'"); + assertEq(result.actions[0].paymentToken, USDC_1, "payment token is USDC on mainnet"); + assertEq(result.actions[0].paymentMaxCost, 0.1e6, "payment should have max cost of 0.1e6"); + assertEq( + result.actions[0].actionContext, + abi.encode( + Actions.BridgeActionContext({ + amount: 1e6, + assetSymbol: "USDC", + bridgeType: Actions.BRIDGE_TYPE_CCTP, + chainId: 1, + destinationChainId: 8453, + price: USDC_PRICE, + recipient: address(0xa11ce), + token: USDC_1 + }) + ), + "action context encoded from BridgeActionContext" + ); + // second action + assertEq(result.actions[1].chainId, 8453, "operation is on chainid 8453"); + assertEq(result.actions[1].quarkAccount, address(0xa11ce), "0xa11ce sends the funds"); + assertEq(result.actions[1].actionType, "MORPHO_VAULT_WITHDRAW", "action type is 'MORPHO_VAULT_WITHDRAW'"); + assertEq(result.actions[1].paymentMethod, "PAY_CALL", "payment method is 'PAY_CALL'"); + assertEq(result.actions[1].paymentToken, USDC_8453, "payment token is USDC on Base"); + assertEq(result.actions[1].paymentMaxCost, 1e6, "payment should have max cost of 1e6"); + assertEq( + result.actions[1].actionContext, + abi.encode( + Actions.MorphoVaultWithdrawActionContext({ + amount: 1e18, + assetSymbol: "WETH", + chainId: 8453, + morphoVault: MorphoInfo.getMorphoVaultAddress(8453, "WETH"), + price: WETH_PRICE, + token: weth_(8453) + }) + ), + "action context encoded from WithdrawActionContext" + ); + + // TODO: Check the contents of the EIP712 data + assertNotEq(result.eip712Data.digest, hex"", "non-empty digest"); + assertNotEq(result.eip712Data.domainSeparator, hex"", "non-empty domain separator"); + assertNotEq(result.eip712Data.hashStruct, hex"", "non-empty hashStruct"); + } + + function testMorphoVaultWithdrawMax() public { + PaymentInfo.PaymentMaxCost[] memory maxCosts = new PaymentInfo.PaymentMaxCost[](1); + maxCosts[0] = PaymentInfo.PaymentMaxCost({chainId: 1, amount: 0.1e6}); + + MorphoVaultPortfolio[] memory morphoVaultPortfolios = new MorphoVaultPortfolio[](1); + morphoVaultPortfolios[0] = MorphoVaultPortfolio({ + assetSymbol: "USDC", + balance: 5e6, + vault: MorphoInfo.getMorphoVaultAddress(1, "USDC") + }); + + ChainPortfolio[] memory chainPortfolios = new ChainPortfolio[](1); + chainPortfolios[0] = ChainPortfolio({ + chainId: 1, + account: address(0xa11ce), + nextNonce: 12, + assetSymbols: Arrays.stringArray("USDC", "USDT", "LINK", "WETH"), + assetBalances: Arrays.uintArray(0, 0, 0, 0), + cometPortfolios: emptyCometPortfolios_(), + morphoPortfolios: emptyMorphoPortfolios_(), + morphoVaultPortfolios: morphoVaultPortfolios + }); + + QuarkBuilder builder = new QuarkBuilder(); + QuarkBuilder.BuilderResult memory result = builder.morphoVaultWithdraw( + morphoWithdrawIntent_(1, type(uint256).max, "USDC"), + chainAccountsFromChainPortfolios(chainPortfolios), // user has no assets + paymentUsdc_(maxCosts) // but will pay from withdrawn funds + ); + + address paycallAddress = paycallUsdc_(1); + address morphoVaultActionsAddress = CodeJarHelper.getCodeAddress(type(MorphoVaultActions).creationCode); + + assertEq(result.paymentCurrency, "usdc", "usdc currency"); + + // Check the quark operations + assertEq(result.quarkOperations.length, 1, "one operation"); + assertEq( + result.quarkOperations[0].scriptAddress, + paycallAddress, + "script address is correct given the code jar address on mainnet" + ); + assertEq( + result.quarkOperations[0].scriptCalldata, + abi.encodeWithSelector( + Paycall.run.selector, + morphoVaultActionsAddress, + abi.encodeWithSelector( + MorphoVaultActions.withdraw.selector, MorphoInfo.getMorphoVaultAddress(1, "USDC"), type(uint256).max + ), + 0.1e6 + ), + "calldata is Paycall.run(MorphoVaultActions.redeemAll(MorphoInfo.getMorphoVaultAddress(1, USDC)), 0.1e6);" + ); + assertEq( + result.quarkOperations[0].expiry, BLOCK_TIMESTAMP + 7 days, "expiry is current blockTimestamp + 7 days" + ); + + // check the actions + assertEq(result.actions.length, 1, "one action"); + assertEq(result.actions[0].chainId, 1, "operation is on chainId 1"); + assertEq(result.actions[0].quarkAccount, address(0xa11ce), "0xa11ce sends the funds"); + assertEq(result.actions[0].actionType, "MORPHO_VAULT_WITHDRAW", "action type is 'MORPHO_VAULT_WITHDRAW'"); + assertEq(result.actions[0].paymentMethod, "PAY_CALL", "payment method is 'PAY_CALL'"); + assertEq(result.actions[0].paymentToken, USDC_1, "payment token is USDC"); + assertEq(result.actions[0].paymentMaxCost, 0.1e6, "payment max is set to 0.1e6"); + assertEq( + result.actions[0].actionContext, + abi.encode( + Actions.MorphoVaultWithdrawActionContext({ + amount: type(uint256).max, + assetSymbol: "USDC", + chainId: 1, + morphoVault: MorphoInfo.getMorphoVaultAddress(1, "USDC"), + price: USDC_PRICE, + token: USDC_1 + }) + ), + "action context encoded from WithdrawActionContext" + ); + + // TODO: Check the contents of the EIP712 data + assertNotEq(result.eip712Data.digest, hex"", "non-empty digest"); + assertNotEq(result.eip712Data.domainSeparator, hex"", "non-empty domain separator"); + assertNotEq(result.eip712Data.hashStruct, hex"", "non-empty hashStruct"); + } + + function testMorphoVaultWithdrawMaxRevertsMaxCostTooHigh() public { + PaymentInfo.PaymentMaxCost[] memory maxCosts = new PaymentInfo.PaymentMaxCost[](1); + maxCosts[0] = PaymentInfo.PaymentMaxCost({chainId: 1, amount: 100e6}); // max cost is very high + + MorphoVaultPortfolio[] memory morphoVaultPortfolios = new MorphoVaultPortfolio[](1); + morphoVaultPortfolios[0] = MorphoVaultPortfolio({ + assetSymbol: "USDC", + balance: 5e6, + vault: MorphoInfo.getMorphoVaultAddress(1, "USDC") + }); + + ChainPortfolio[] memory chainPortfolios = new ChainPortfolio[](1); + chainPortfolios[0] = ChainPortfolio({ + chainId: 1, + account: address(0xa11ce), + nextNonce: 12, + assetSymbols: Arrays.stringArray("USDC", "USDT", "LINK", "WETH"), + assetBalances: Arrays.uintArray(0, 0, 0, 0), + cometPortfolios: emptyCometPortfolios_(), + morphoPortfolios: emptyMorphoPortfolios_(), + morphoVaultPortfolios: morphoVaultPortfolios + }); + + QuarkBuilder builder = new QuarkBuilder(); + + vm.expectRevert(QuarkBuilder.MaxCostTooHigh.selector); + + builder.morphoVaultWithdraw( + morphoWithdrawIntent_(1, type(uint256).max, "USDC"), + chainAccountsFromChainPortfolios(chainPortfolios), + paymentUsdc_(maxCosts) // user will pay for transaction with withdrawn funds, but it is not enough + ); + } +} diff --git a/test/builder/QuarkBuilderSwap.t.sol b/test/builder/QuarkBuilderSwap.t.sol index 6721b373..a980258d 100644 --- a/test/builder/QuarkBuilderSwap.t.sol +++ b/test/builder/QuarkBuilderSwap.t.sol @@ -251,7 +251,8 @@ contract QuarkBuilderSwapTest is Test, QuarkBuilderTest { quarkStates: quarkStates_(address(0xa11ce), 12), assetPositionsList: assetPositionsList, cometPositions: emptyCometPositions_(), - morphoPositions: emptyMorphoPositions_() + morphoPositions: emptyMorphoPositions_(), + morphoVaultPositions: emptyMorphoVaultPositions_() }); QuarkBuilder.BuilderResult memory result = builder.swap( buyUsdc_(1, weth_(1), 1e18, 3000e6, address(0xa11ce), BLOCK_TIMESTAMP), // swap 1 ETH on chain 1 to 3000 USDC @@ -411,21 +412,24 @@ contract QuarkBuilderSwapTest is Test, QuarkBuilderTest { quarkStates: quarkStates_(address(0xa11ce), 12), assetPositionsList: assetPositionsList_(1, address(0xa11ce), uint256(9005e6)), cometPositions: emptyCometPositions_(), - morphoPositions: emptyMorphoPositions_() + morphoPositions: emptyMorphoPositions_(), + morphoVaultPositions: emptyMorphoVaultPositions_() }); chainAccountsList[1] = Accounts.ChainAccounts({ chainId: 8453, quarkStates: quarkStates_(address(0xb0b), 2), assetPositionsList: assetPositionsList_(8453, address(0xb0b), uint256(0)), cometPositions: emptyCometPositions_(), - morphoPositions: emptyMorphoPositions_() + morphoPositions: emptyMorphoPositions_(), + morphoVaultPositions: emptyMorphoVaultPositions_() }); chainAccountsList[2] = Accounts.ChainAccounts({ chainId: 7777, quarkStates: quarkStates_(address(0xc0b), 5), assetPositionsList: assetPositionsList_(7777, address(0xc0b), uint256(0)), cometPositions: emptyCometPositions_(), - morphoPositions: emptyMorphoPositions_() + morphoPositions: emptyMorphoPositions_(), + morphoVaultPositions: emptyMorphoVaultPositions_() }); QuarkBuilder.BuilderResult memory result = builder.swap( diff --git a/test/builder/QuarkBuilderTransfer.t.sol b/test/builder/QuarkBuilderTransfer.t.sol index 368e409a..c6b63c47 100644 --- a/test/builder/QuarkBuilderTransfer.t.sol +++ b/test/builder/QuarkBuilderTransfer.t.sol @@ -610,7 +610,8 @@ contract QuarkBuilderTransferTest is Test, QuarkBuilderTest { quarkStates: quarkStates_(address(0xa11ce), 12), assetPositionsList: assetPositionsList_(1, address(0xa11ce), uint256(10e6)), cometPositions: emptyCometPositions_(), - morphoPositions: emptyMorphoPositions_() + morphoPositions: emptyMorphoPositions_(), + morphoVaultPositions: emptyMorphoVaultPositions_() }); QuarkBuilder.BuilderResult memory result = builder.transfer( @@ -687,14 +688,16 @@ contract QuarkBuilderTransferTest is Test, QuarkBuilderTest { quarkStates: quarkStates_(address(0xa11ce), 12), assetPositionsList: assetPositionsList_(1, address(0xa11ce), 8e6), cometPositions: emptyCometPositions_(), - morphoPositions: emptyMorphoPositions_() + morphoPositions: emptyMorphoPositions_(), + morphoVaultPositions: emptyMorphoVaultPositions_() }); chainAccountsList[1] = Accounts.ChainAccounts({ chainId: 8453, quarkStates: quarkStates_(address(0xb0b), 2), assetPositionsList: assetPositionsList_(8453, address(0xb0b), 4e6), cometPositions: emptyCometPositions_(), - morphoPositions: emptyMorphoPositions_() + morphoPositions: emptyMorphoPositions_(), + morphoVaultPositions: emptyMorphoVaultPositions_() }); QuarkBuilder.BuilderResult memory result = builder.transfer( @@ -824,21 +827,24 @@ contract QuarkBuilderTransferTest is Test, QuarkBuilderTest { quarkStates: quarkStates_(address(0xa11ce), 12), assetPositionsList: assetPositionsList_(1, address(0xa11ce), 8e6), cometPositions: emptyCometPositions_(), - morphoPositions: emptyMorphoPositions_() + morphoPositions: emptyMorphoPositions_(), + morphoVaultPositions: emptyMorphoVaultPositions_() }); chainAccountsList[1] = Accounts.ChainAccounts({ chainId: 8453, quarkStates: quarkStates_(address(0xb0b), 2), assetPositionsList: assetPositionsList_(8453, address(0xb0b), 4e6), cometPositions: emptyCometPositions_(), - morphoPositions: emptyMorphoPositions_() + morphoPositions: emptyMorphoPositions_(), + morphoVaultPositions: emptyMorphoVaultPositions_() }); chainAccountsList[2] = Accounts.ChainAccounts({ chainId: 7777, quarkStates: quarkStates_(address(0xfe11a), 2), assetPositionsList: assetPositionsList_(7777, address(0xfe11a), 5e6), cometPositions: emptyCometPositions_(), - morphoPositions: emptyMorphoPositions_() + morphoPositions: emptyMorphoPositions_(), + morphoVaultPositions: emptyMorphoVaultPositions_() }); // User has total holding of 17 USDC, but only 12 USDC is available for transfer/bridge to 8453, and missing 5 USDC stuck in random L2 so will revert with FundsUnavailable error @@ -926,7 +932,8 @@ contract QuarkBuilderTransferTest is Test, QuarkBuilderTest { quarkStates: quarkStates_(address(0xa11ce), 12), assetPositionsList: assetPositionsList, cometPositions: emptyCometPositions_(), - morphoPositions: emptyMorphoPositions_() + morphoPositions: emptyMorphoPositions_(), + morphoVaultPositions: emptyMorphoVaultPositions_() }); // Transfer 1.5ETH to 0xceecee on chain 1 @@ -1028,7 +1035,8 @@ contract QuarkBuilderTransferTest is Test, QuarkBuilderTest { quarkStates: quarkStates_(address(0xa11ce), 12), assetPositionsList: assetPositionsList, cometPositions: emptyCometPositions_(), - morphoPositions: emptyMorphoPositions_() + morphoPositions: emptyMorphoPositions_(), + morphoVaultPositions: emptyMorphoVaultPositions_() }); // Transfer 1.5ETH to 0xceecee on chain 1 @@ -1138,7 +1146,8 @@ contract QuarkBuilderTransferTest is Test, QuarkBuilderTest { quarkStates: quarkStates_(address(0xa11ce), 12), assetPositionsList: assetPositionsList, cometPositions: emptyCometPositions_(), - morphoPositions: emptyMorphoPositions_() + morphoPositions: emptyMorphoPositions_(), + morphoVaultPositions: emptyMorphoVaultPositions_() }); // Transfer max ETH to 0xceecee on chain 1 @@ -1249,7 +1258,8 @@ contract QuarkBuilderTransferTest is Test, QuarkBuilderTest { quarkStates: quarkStates_(address(0xa11ce), 12), assetPositionsList: assetPositionsList, cometPositions: emptyCometPositions_(), - morphoPositions: emptyMorphoPositions_() + morphoPositions: emptyMorphoPositions_(), + morphoVaultPositions: emptyMorphoVaultPositions_() }); // Transfer 1.5ETH to 0xceecee on chain 1 diff --git a/test/builder/lib/QuarkBuilderTest.sol b/test/builder/lib/QuarkBuilderTest.sol index 235af9de..863b6f72 100644 --- a/test/builder/lib/QuarkBuilderTest.sol +++ b/test/builder/lib/QuarkBuilderTest.sol @@ -95,21 +95,24 @@ contract QuarkBuilderTest { quarkStates: quarkStates_(address(0xa11ce), 12), assetPositionsList: assetPositionsList_(1, address(0xa11ce), uint256(amount / 2)), cometPositions: emptyCometPositions_(), - morphoPositions: emptyMorphoPositions_() + morphoPositions: emptyMorphoPositions_(), + morphoVaultPositions: emptyMorphoVaultPositions_() }); chainAccountsList[1] = Accounts.ChainAccounts({ chainId: 8453, quarkStates: quarkStates_(address(0xb0b), 2), assetPositionsList: assetPositionsList_(8453, address(0xb0b), uint256(amount / 2)), cometPositions: emptyCometPositions_(), - morphoPositions: emptyMorphoPositions_() + morphoPositions: emptyMorphoPositions_(), + morphoVaultPositions: emptyMorphoVaultPositions_() }); chainAccountsList[2] = Accounts.ChainAccounts({ chainId: 7777, quarkStates: quarkStates_(address(0xc0b), 5), assetPositionsList: assetPositionsList_(7777, address(0xc0b), uint256(0)), cometPositions: emptyCometPositions_(), - morphoPositions: emptyMorphoPositions_() + morphoPositions: emptyMorphoPositions_(), + morphoVaultPositions: emptyMorphoVaultPositions_() }); return chainAccountsList; } @@ -124,6 +127,11 @@ contract QuarkBuilderTest { return emptyMorphoPositions; } + function emptyMorphoVaultPositions_() internal pure returns (Accounts.MorphoVaultPositions[] memory) { + Accounts.MorphoVaultPositions[] memory emptyMorphoVaultPositions = new Accounts.MorphoVaultPositions[](0); + return emptyMorphoVaultPositions; + } + function quarkStates_() internal pure returns (Accounts.QuarkState[] memory) { Accounts.QuarkState[] memory quarkStates = new Accounts.QuarkState[](1); quarkStates[0] = quarkState_(); @@ -299,6 +307,7 @@ contract QuarkBuilderTest { uint256[] assetBalances; CometPortfolio[] cometPortfolios; MorphoPortfolio[] morphoPortfolios; + MorphoVaultPortfolio[] morphoVaultPortfolios; } struct CometPortfolio { @@ -317,6 +326,12 @@ contract QuarkBuilderTest { uint256 collateralBalance; } + struct MorphoVaultPortfolio { + string assetSymbol; + uint256 balance; + address vault; + } + function emptyCometPortfolios_() internal pure returns (CometPortfolio[] memory) { CometPortfolio[] memory emptyCometPortfolios = new CometPortfolio[](0); return emptyCometPortfolios; @@ -327,6 +342,11 @@ contract QuarkBuilderTest { return emptyMorphoPortfolios; } + function emptyMorphoVaultPortfolios_() internal pure returns (MorphoVaultPortfolio[] memory) { + MorphoVaultPortfolio[] memory emptyMorphoVaultPortfolios = new MorphoVaultPortfolio[](0); + return emptyMorphoVaultPortfolios; + } + function chainAccountsFromChainPortfolios(ChainPortfolio[] memory chainPortfolios) internal pure @@ -349,6 +369,9 @@ contract QuarkBuilderTest { ), morphoPositions: morphoPositionsForMorphoPortfolios( chainPortfolios[i].chainId, chainPortfolios[i].account, chainPortfolios[i].morphoPortfolios + ), + morphoVaultPositions: morphoVaultPositionsForMorphoVaultPortfolios( + chainPortfolios[i].chainId, chainPortfolios[i].account, chainPortfolios[i].morphoVaultPortfolios ) }); } @@ -423,6 +446,27 @@ contract QuarkBuilderTest { return morphoPositions; } + function morphoVaultPositionsForMorphoVaultPortfolios( + uint256 chainId, + address account, + MorphoVaultPortfolio[] memory morphoVaultPortfolios + ) internal pure returns (Accounts.MorphoVaultPositions[] memory) { + Accounts.MorphoVaultPositions[] memory morphoVaultPositions = + new Accounts.MorphoVaultPositions[](morphoVaultPortfolios.length); + for (uint256 i = 0; i < morphoVaultPortfolios.length; ++i) { + MorphoVaultPortfolio memory morphoVaultPortfolio = morphoVaultPortfolios[i]; + (address asset,,) = assetInfo(morphoVaultPortfolio.assetSymbol, chainId); + morphoVaultPositions[i] = Accounts.MorphoVaultPositions({ + asset: asset, + accounts: Arrays.addressArray(account), + balances: Arrays.uintArray(morphoVaultPortfolio.balance), + vault: morphoVaultPortfolio.vault + }); + } + + return morphoVaultPositions; + } + function baseAssetForComet(uint256 chainId, address comet) internal pure returns (address) { if (comet == COMET_1_USDC || comet == COMET_8453_USDC) { return usdc_(chainId);