diff --git a/.gas-snapshot b/.gas-snapshot index 8d99b218..4545dad2 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,42 @@ 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) +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 +83,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 +99,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/.github/workflows/gas-snapshot.yml b/.github/workflows/gas-snapshot.yml index a148fc20..d27946d1 100644 --- a/.github/workflows/gas-snapshot.yml +++ b/.github/workflows/gas-snapshot.yml @@ -29,11 +29,14 @@ 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: - NODE_PROVIDER_BYPASS_KEY: ${{ secrets.NODE_PROVIDER_BYPASS_KEY }} + 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 6dd5d024..d57dffa4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -37,4 +37,7 @@ jobs: forge test -vvv id: test env: - NODE_PROVIDER_BYPASS_KEY: ${{ secrets.NODE_PROVIDER_BYPASS_KEY }} + 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/.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/README.md b/README.md index df06aded..18c7da40 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`. @@ -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/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/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*' 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 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 new file mode 100644 index 00000000..f67a3e11 --- /dev/null +++ b/src/RecurringSwap.sol @@ -0,0 +1,278 @@ +// 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 {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"; + +/** + * @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; + 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 = ISwapRouter02(swapParams.uniswapRouter).exactOutput( + IV3SwapRouter.ExactOutputParams({ + path: swapParams.path, + recipient: swapParams.recipient, + amountOut: amountOut, + amountInMaximum: amountIn + }) + ); + actualAmountOut = amountOut; + } else { + // Exact in swap + actualAmountOut = ISwapRouter02(swapParams.uniswapRouter).exactInput( + IV3SwapRouter.ExactInputParams({ + path: swapParams.path, + recipient: swapParams.recipient, + 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.path + ), + abi.encodePacked( + config.slippageParams.maxSlippage, + keccak256(abi.encodePacked(config.slippageParams.priceFeeds)), + keccak256(abi.encodePacked(config.slippageParams.shouldInvert)) + ) + ) + ); + } +} 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 a5a78004..2a736aa0 100644 --- a/src/builder/Actions.sol +++ b/src/builder/Actions.sol @@ -1,10 +1,13 @@ // 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 {Math} from "src/lib/Math.sol"; +import {PriceFeeds} from "./PriceFeeds.sol"; +import {Strings} from "./Strings.sol"; +import {UniswapRouter} from "./UniswapRouter.sol"; import { ApproveAndSwap, @@ -16,6 +19,7 @@ import { } from "../DeFiScripts.sol"; import {Math} from "src/lib/Math.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"; @@ -34,6 +38,7 @@ library Actions { string constant ACTION_TYPE_CLAIM_REWARDS = "CLAIM_REWARDS"; string constant ACTION_TYPE_MORPHO_CLAIM_REWARDS = "MORPHO_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"; @@ -56,6 +61,9 @@ 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 ===== */ error BridgingUnsupportedForAsset(); @@ -120,6 +128,23 @@ library Actions { uint256 feeAmount; uint256 chainId; address sender; + bool isExactOut; + 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; } @@ -319,6 +344,21 @@ library Actions { string outputAssetSymbol; address outputToken; uint256 outputTokenPrice; + bool isExactOut; + } + + 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 { @@ -382,15 +422,6 @@ library Actions { address token; } - struct MorphoVaultSupplyContext { - uint256 amount; - string assetSymbol; - uint256 chainId; - address morphoVault; - uint256 price; - address token; - } - struct MorphoBorrowActionContext { uint256 amount; string assetSymbol; @@ -405,15 +436,6 @@ library Actions { address token; } - struct MorphoVaultWithdrawContext { - uint256 amount; - string assetSymbol; - uint256 chainId; - address morphoVault; - uint256 price; - address token; - } - struct MorphoClaimRewardsActionContext { uint256[] amounts; string[] assetSymbols; @@ -1127,7 +1149,7 @@ library Actions { }); // Construct Action - MorphoVaultSupplyContext memory vaultSupplyActionContext = MorphoVaultSupplyContext({ + MorphoVaultSupplyActionContext memory vaultSupplyActionContext = MorphoVaultSupplyActionContext({ amount: vaultSupply.amount, assetSymbol: assetPositions.symbol, chainId: vaultSupply.chainId, @@ -1184,7 +1206,7 @@ library Actions { }); // Construct Action - MorphoVaultWithdrawContext memory vaultWithdrawActionContext = MorphoVaultWithdrawContext({ + MorphoVaultWithdrawActionContext memory vaultWithdrawActionContext = MorphoVaultWithdrawActionContext({ amount: vaultWithdraw.amount, assetSymbol: assetPositions.symbol, chainId: vaultWithdraw.chainId, @@ -1383,7 +1405,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({ @@ -1402,6 +1425,91 @@ 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.sellToken, + tokenOut: swap.buyToken, + amount: swap.isExactOut ? swap.buyAmount : swap.sellAmount, + isExactOut: swap.isExactOut, + 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 - AVERAGE_BLOCK_TIME, + 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 @@ -1482,6 +1590,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/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 c5448c75..93bf1f10 100644 --- a/src/builder/PaymentInfo.sol +++ b/src/builder/PaymentInfo.sol @@ -33,7 +33,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", @@ -50,6 +50,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/PriceFeeds.sol b/src/builder/PriceFeeds.sol new file mode 100644 index 00000000..6efc8d89 --- /dev/null +++ b/src/builder/PriceFeeds.sol @@ -0,0 +1,332 @@ +// 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[](10); + 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 + }); + 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[](5); + 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 + }); + priceFeeds[3] = PriceFeed({ + chainId: 8453, + baseSymbol: "cbETH", + quoteSymbol: "USD", + priceFeed: 0xd7818272B9e248357d13057AAb0B417aF31E817d + }); + priceFeeds[4] = PriceFeed({ + chainId: 8453, + baseSymbol: "cbETH", + quoteSymbol: "ETH", + priceFeed: 0x806b4Ac04501c29769051e42783cF04dCE41440b + }); + 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 a5b1a136..fa8aaf8f 100644 --- a/src/builder/QuarkBuilder.sol +++ b/src/builder/QuarkBuilder.sol @@ -54,6 +54,7 @@ contract QuarkBuilder { /* ===== Helper Functions ===== */ /* ===== Main Implementation ===== */ + struct CometRepayIntent { uint256 amount; string assetSymbol; @@ -839,6 +840,7 @@ contract QuarkBuilder { address feeToken; uint256 feeAmount; address sender; + bool isExactOut; uint256 blockTimestamp; } @@ -960,6 +962,133 @@ contract QuarkBuilder { feeAmount: swapIntent.feeAmount, chainId: swapIntent.chainId, sender: swapIntent.sender, + isExactOut: swapIntent.isExactOut, + 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) + }); + } + + 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, @@ -1802,19 +1931,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)) { @@ -1833,7 +1956,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; } /** @@ -1988,8 +2139,8 @@ contract QuarkBuilder { } } else if (Strings.stringEqIgnoreCase(nonBridgeAction.actionType, Actions.ACTION_TYPE_MORPHO_VAULT_SUPPLY)) { - Actions.MorphoVaultSupplyContext memory morphoVaultSupplyActionContext = - abi.decode(nonBridgeAction.actionContext, (Actions.MorphoVaultSupplyContext)); + Actions.MorphoVaultSupplyActionContext memory morphoVaultSupplyActionContext = + abi.decode(nonBridgeAction.actionContext, (Actions.MorphoVaultSupplyActionContext)); if (Strings.stringEqIgnoreCase(morphoVaultSupplyActionContext.assetSymbol, paymentTokenSymbol)) { paymentTokenCost += morphoVaultSupplyActionContext.amount; } @@ -1999,6 +2150,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/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 new file mode 100644 index 00000000..5ff189fa --- /dev/null +++ b/src/builder/UniswapRouter.sol @@ -0,0 +1,44 @@ +// 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/ + /// 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}); + // 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/src/vendor/manifest.json b/src/vendor/manifest.json index f4aac3ca..9140a980 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, @@ -132,6 +132,72 @@ } ] }, + "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": { @@ -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/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/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 9e85fd19..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; @@ -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()))); @@ -530,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/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 new file mode 100644 index 00000000..418ebf39 --- /dev/null +++ b/test/RecurringSwap.t.sol @@ -0,0 +1,824 @@ +// 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 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; + + constructor() { + // Fork setup + vm.createSelectFork( + vm.envString("MAINNET_RPC_URL"), + 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 + ); + (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 + ); + (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, + 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 + ); + (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, + 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 + ); + (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 = type(uint256).max; + (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 = type(uint256).max; + (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 + ); + (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 = type(uint256).max; + 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 = type(uint256).max; + cancelOp = new QuarkOperationHelper().newBasicOpWithCalldata( + aliceWallet, + recurringSwap, + abi.encodeWithSelector(RecurringSwap.cancel.selector), + ScriptType.ScriptAddress + ); + 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); + (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 + ); + (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 + ); + (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 + ); + (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 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 + ); + (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 + ); + (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, + 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/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..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"; @@ -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..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"; @@ -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..bd70b2ac 100644 --- a/test/UniswapSwapActions.t.sol +++ b/test/UniswapSwapActions.t.sol @@ -35,16 +35,14 @@ 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 { // 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()))); @@ -70,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 }) ), @@ -99,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 }) ), @@ -132,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 }) ), @@ -159,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 }) ), @@ -191,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 }) ), @@ -217,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 }) ), @@ -249,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 }) ), @@ -275,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 }) ), @@ -309,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/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()))); diff --git a/test/builder/PriceFeeds.t.sol b/test/builder/PriceFeeds.t.sol new file mode 100644 index 00000000..2280e9d9 --- /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, 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) + || 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/QuarkBuilderCometWithdraw.t.sol b/test/builder/QuarkBuilderCometWithdraw.t.sol index c35203f0..259f258a 100644 --- a/test/builder/QuarkBuilderCometWithdraw.t.sol +++ b/test/builder/QuarkBuilderCometWithdraw.t.sol @@ -504,4 +504,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 + ); + } } diff --git a/test/builder/QuarkBuilderMorphoVaultSupply.t.sol b/test/builder/QuarkBuilderMorphoVaultSupply.t.sol index eada04b8..befdd1c6 100644 --- a/test/builder/QuarkBuilderMorphoVaultSupply.t.sol +++ b/test/builder/QuarkBuilderMorphoVaultSupply.t.sol @@ -151,7 +151,7 @@ contract QuarkBuilderMorphoVaultTest is Test, QuarkBuilderTest { assertEq( result.actions[0].actionContext, abi.encode( - Actions.MorphoVaultSupplyContext({ + Actions.MorphoVaultSupplyActionContext({ amount: 1e6, assetSymbol: "USDC", chainId: 1, @@ -222,7 +222,7 @@ contract QuarkBuilderMorphoVaultTest is Test, QuarkBuilderTest { assertEq( result.actions[0].actionContext, abi.encode( - Actions.MorphoVaultSupplyContext({ + Actions.MorphoVaultSupplyActionContext({ amount: 3e6, assetSymbol: "USDC", chainId: 1, @@ -318,7 +318,7 @@ contract QuarkBuilderMorphoVaultTest is Test, QuarkBuilderTest { assertEq( result.actions[0].actionContext, abi.encode( - Actions.MorphoVaultSupplyContext({ + Actions.MorphoVaultSupplyActionContext({ amount: 1e18, assetSymbol: "WETH", chainId: 1, @@ -385,7 +385,7 @@ contract QuarkBuilderMorphoVaultTest is Test, QuarkBuilderTest { assertEq( result.actions[0].actionContext, abi.encode( - Actions.MorphoVaultSupplyContext({ + Actions.MorphoVaultSupplyActionContext({ amount: 1e6, assetSymbol: "USDC", chainId: 1, @@ -492,7 +492,7 @@ contract QuarkBuilderMorphoVaultTest is Test, QuarkBuilderTest { assertEq( result.actions[1].actionContext, abi.encode( - Actions.MorphoVaultSupplyContext({ + Actions.MorphoVaultSupplyActionContext({ amount: 5e6, assetSymbol: "USDC", chainId: 8453, @@ -599,7 +599,7 @@ contract QuarkBuilderMorphoVaultTest is Test, QuarkBuilderTest { assertEq( result.actions[1].actionContext, abi.encode( - Actions.MorphoVaultSupplyContext({ + Actions.MorphoVaultSupplyActionContext({ amount: 6e6, assetSymbol: "USDC", chainId: 8453, @@ -722,7 +722,7 @@ contract QuarkBuilderMorphoVaultTest is Test, QuarkBuilderTest { assertEq( result.actions[1].actionContext, abi.encode( - Actions.MorphoVaultSupplyContext({ + Actions.MorphoVaultSupplyActionContext({ amount: 5.4e6, assetSymbol: "USDC", chainId: 8453, @@ -845,7 +845,7 @@ contract QuarkBuilderMorphoVaultTest is Test, QuarkBuilderTest { assertEq( result.actions[1].actionContext, abi.encode( - Actions.MorphoVaultSupplyContext({ + Actions.MorphoVaultSupplyActionContext({ amount: 5e6, assetSymbol: "USDC", chainId: 8453, diff --git a/test/builder/QuarkBuilderMorphoVaultWithdraw.t.sol b/test/builder/QuarkBuilderMorphoVaultWithdraw.t.sol index cffc4166..ec60d9cd 100644 --- a/test/builder/QuarkBuilderMorphoVaultWithdraw.t.sol +++ b/test/builder/QuarkBuilderMorphoVaultWithdraw.t.sol @@ -68,7 +68,7 @@ contract QuarkBuilderMorphoVaultWithdrawTest is Test, QuarkBuilderTest { assertEq( result.actions[0].actionContext, abi.encode( - Actions.MorphoVaultWithdrawContext({ + Actions.MorphoVaultWithdrawActionContext({ amount: 2e6, assetSymbol: "USDC", chainId: 1, @@ -133,7 +133,7 @@ contract QuarkBuilderMorphoVaultWithdrawTest is Test, QuarkBuilderTest { assertEq( result.actions[0].actionContext, abi.encode( - Actions.MorphoVaultWithdrawContext({ + Actions.MorphoVaultWithdrawActionContext({ amount: 2e6, assetSymbol: "USDC", chainId: 1, @@ -198,7 +198,7 @@ contract QuarkBuilderMorphoVaultWithdrawTest is Test, QuarkBuilderTest { assertEq( result.actions[0].actionContext, abi.encode( - Actions.MorphoVaultWithdrawContext({ + Actions.MorphoVaultWithdrawActionContext({ amount: 2e6, assetSymbol: "USDC", chainId: 1, @@ -348,7 +348,7 @@ contract QuarkBuilderMorphoVaultWithdrawTest is Test, QuarkBuilderTest { assertEq( result.actions[1].actionContext, abi.encode( - Actions.MorphoVaultWithdrawContext({ + Actions.MorphoVaultWithdrawActionContext({ amount: 1e18, assetSymbol: "WETH", chainId: 8453, @@ -435,7 +435,7 @@ contract QuarkBuilderMorphoVaultWithdrawTest is Test, QuarkBuilderTest { assertEq( result.actions[0].actionContext, abi.encode( - Actions.MorphoVaultWithdrawContext({ + Actions.MorphoVaultWithdrawActionContext({ amount: type(uint256).max, assetSymbol: "USDC", chainId: 1, diff --git a/test/builder/QuarkBuilderRecurringSwap.t.sol b/test/builder/QuarkBuilderRecurringSwap.t.sol new file mode 100644 index 00000000..b918efd1 --- /dev/null +++ b/test/builder/QuarkBuilderRecurringSwap.t.sol @@ -0,0 +1,431 @@ +// 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.sellToken, + tokenOut: swap.buyToken, + amount: swap.isExactOut ? swap.buyAmount : swap.sellAmount, + isExactOut: swap.isExactOut, + 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 - Actions.AVERAGE_BLOCK_TIME, + 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 +} diff --git a/test/builder/QuarkBuilderSwap.t.sol b/test/builder/QuarkBuilderSwap.t.sol index fe8f0727..a980258d 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" @@ -311,7 +313,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" @@ -386,7 +389,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" @@ -487,7 +491,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" @@ -625,7 +630,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" @@ -760,7 +766,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" @@ -894,7 +901,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" @@ -1029,7 +1037,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" 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 - ) - ) - ); - } -} 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" + ); + } + } + } +}