diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2676a73 --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +ETH_MAINNET_RPC_URL= +GNOSIS_RPC_URL= +ARBITRUM_ONE_RPC_URL= +BASE_RPC_URL= +OPTIMISM_RPC_URL= +POLYGON_RPC_URL= +BNB_MAINNET_RPC_URL= +PLASMA_RPC_URL= +AVALANCHE_RPC_URL= +INK_RPC_URL= +LINEA_RPC_URL= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f90b562..c05bc40 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,6 +67,18 @@ jobs: runs-on: ubuntu-latest permissions: contents: read + env: + ETH_MAINNET_RPC_URL: ${{ secrets.ETH_MAINNET_RPC_URL }} + GNOSIS_RPC_URL: ${{ secrets.GNOSIS_RPC_URL }} + ARBITRUM_ONE_RPC_URL: ${{ secrets.ARBITRUM_ONE_RPC_URL }} + BASE_RPC_URL: ${{ secrets.BASE_RPC_URL }} + OPTIMISM_RPC_URL: ${{ secrets.OPTIMISM_RPC_URL }} + POLYGON_RPC_URL: ${{ secrets.POLYGON_RPC_URL }} + BNB_MAINNET_RPC_URL: ${{ secrets.BNB_MAINNET_RPC_URL }} + PLASMA_RPC_URL: ${{ secrets.PLASMA_RPC_URL }} + AVALANCHE_RPC_URL: ${{ secrets.AVALANCHE_RPC_URL }} + INK_RPC_URL: ${{ secrets.INK_RPC_URL }} + LINEA_RPC_URL: ${{ secrets.LINEA_RPC_URL }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # action v6.0.2 with: @@ -74,6 +86,6 @@ jobs: submodules: recursive - uses: ./.github/actions/setup - name: Test - run: just test + run: just test --gas-snapshot-check true - name: Coverage run: just coverage-check diff --git a/.gitmodules b/.gitmodules index 888d42d..690924b 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,6 @@ [submodule "lib/forge-std"] path = lib/forge-std url = https://github.com/foundry-rs/forge-std +[submodule "lib/openzeppelin-contracts"] + path = lib/openzeppelin-contracts + url = https://github.com/OpenZeppelin/openzeppelin-contracts diff --git a/Justfile b/Justfile index 6caa624..d55540e 100644 --- a/Justfile +++ b/Justfile @@ -59,16 +59,16 @@ slither: PATH="$PWD/{{NPM_BIN}}:$PATH" uv run --project dev slither src --config-file slither.config.json # Run tests -test: - {{FORGE}} test -vvv --show-progress --gas-snapshot-check true +test *args: + {{FORGE}} test -vvv --show-progress {{args}} # Print coverage summary coverage-summary: - {{FORGE}} coverage --no-match-coverage "^(test|script)/" --report summary + {{FORGE}} coverage --no-match-coverage "^(test|script|lib)/" --report summary # Generate lcov coverage report coverage-lcov: - {{FORGE}} coverage --no-match-coverage "^(test|script)/" --report lcov + {{FORGE}} coverage --no-match-coverage "^(test|script|lib)/" --report lcov # Fail if the minimum of all four coverage metrics (lines/statements/branches/funcs) on the `Total` row is below `COVERAGE_MIN` (default `100`) coverage-check: diff --git a/foundry.lock b/foundry.lock index eb03d76..a3954b2 100644 --- a/foundry.lock +++ b/foundry.lock @@ -1,5 +1,11 @@ { "lib/forge-std": { "rev": "1801b0541f4fda118a10798fd3486bb7051c5dd6" + }, + "lib/openzeppelin-contracts": { + "tag": { + "name": "v5.6.1", + "rev": "5fd1781b1454fd1ef8e722282f86f9293cacf256" + } } } \ No newline at end of file diff --git a/foundry.toml b/foundry.toml index c012882..773b08c 100644 --- a/foundry.toml +++ b/foundry.toml @@ -2,6 +2,9 @@ src = "src" out = "out" libs = ["lib"] +remappings = [ + "@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/" +] solc = "0.8.34" # See latest release at: https://github.com/argotorg/solidity/releases [fmt] @@ -13,4 +16,4 @@ wrap_comments = true deny = "warnings" # Why not always: sometimes you just want to code and see what comes out verbosity = 3 # Outputs stack traces for failed tests. fuzz.seed = "0" -fuzz.runs = 10_000 +fuzz.runs = 10_000 \ No newline at end of file diff --git a/lib/openzeppelin-contracts b/lib/openzeppelin-contracts new file mode 160000 index 0000000..5fd1781 --- /dev/null +++ b/lib/openzeppelin-contracts @@ -0,0 +1 @@ +Subproject commit 5fd1781b1454fd1ef8e722282f86f9293cacf256 diff --git a/snapshots/Solver7702DelegateForkTest.json b/snapshots/Solver7702DelegateForkTest.json new file mode 100644 index 0000000..b288ecf --- /dev/null +++ b/snapshots/Solver7702DelegateForkTest.json @@ -0,0 +1,34 @@ +{ + "arbitrum - delegated call - simple WETH -> USDC order attempt": "15438", + "arbitrum - direct call - simple WETH -> USDC order attempt": "11450", + "avalanche - delegated call - simple WETH -> USDC order attempt": "15438", + "avalanche - direct call - simple WETH -> USDC order attempt": "11450", + "base - delegated call - simple WETH -> USDC order attempt": "15434", + "base - direct call - simple WETH -> USDC order attempt": "11450", + "bnb - delegated call - simple WETH -> USDC order attempt": "15438", + "bnb - direct call - simple WETH -> USDC order attempt": "11450", + "ethereum - delegated call - simple WETH -> USDC order attempt": "15439", + "ethereum - direct call - simple WETH -> USDC order attempt": "11450", + "gnosis - delegated call - simple WETH -> USDC order attempt": "15438", + "gnosis - direct call - simple WETH -> USDC order attempt": "11450", + "historical order - USDC -> EURA - delegated call": "190277", + "historical order - USDC -> EURA - direct call": "186425", + "historical order - USDC permit -> MOG - delegated call": "446922", + "historical order - USDC permit -> MOG - direct call": "443150", + "historical order - USDT -> AAVE - delegated call": "783333", + "historical order - USDT -> AAVE - direct call": "779746", + "historical order - WETH -> USDC - delegated call": "253798", + "historical order - WETH -> USDC - direct call": "249951", + "historical order - YFI -> USDC - delegated call": "814481", + "historical order - YFI -> USDC - direct call": "811173", + "ink - delegated call - simple WETH -> USDC order attempt": "15434", + "ink - direct call - simple WETH -> USDC order attempt": "11450", + "linea - delegated call - simple WETH -> USDC order attempt": "15434", + "linea - direct call - simple WETH -> USDC order attempt": "11450", + "optimism - delegated call - simple WETH -> USDC order attempt": "15439", + "optimism - direct call - simple WETH -> USDC order attempt": "11449", + "plasma - delegated call - simple WETH -> USDC order attempt": "15438", + "plasma - direct call - simple WETH -> USDC order attempt": "11450", + "polygon - delegated call - simple WETH -> USDC order attempt": "15438", + "polygon - direct call - simple WETH -> USDC order attempt": "11450" +} \ No newline at end of file diff --git a/snapshots/Solver7702DelegateIntegrationTest.json b/snapshots/Solver7702DelegateIntegrationTest.json new file mode 100644 index 0000000..6ea05a6 --- /dev/null +++ b/snapshots/Solver7702DelegateIntegrationTest.json @@ -0,0 +1,9 @@ +{ + "delegated submission - reverts - UnauthorizedCaller": "460", + "delegated submission - success - interactions across phases": "44663", + "delegated submission - success - large settlement with interactions": "114609", + "delegated submission - success - small settlement": "24071", + "direct submission - success - interactions across phases": "155237", + "direct submission - success - large settlement with interactions": "222656", + "direct submission - success - small settlement": "115158" +} \ No newline at end of file diff --git a/snapshots/Solver7702DelegateTest.json b/snapshots/Solver7702DelegateTest.json new file mode 100644 index 0000000..89c1a68 --- /dev/null +++ b/snapshots/Solver7702DelegateTest.json @@ -0,0 +1,21 @@ +{ + "constructor - success - distinct callers": "1212494", + "constructor - success - duplicate callers": "1212494", + "constructor - success - zero address callers": "1212494", + "delegate fallback - reverts - custom error": "4398", + "delegate fallback - reverts - empty revert data": "3753", + "delegate fallback - reverts - non-empty revert data": "3828", + "delegate fallback - reverts - panic": "3405", + "delegate fallback - reverts - sending ETH to nonpayable target": "9875", + "delegate fallback - reverts - string error": "4057", + "delegate fallback - reverts - unauthorized caller": "460", + "delegate fallback - success - approved caller slot 0 forwards payload": "21184", + "delegate fallback - success - approved caller slot 1 forwards payload": "23430", + "delegate fallback - success - approved caller slot 2 forwards payload": "23490", + "delegate fallback - success - approved caller slot 3 forwards payload": "23550", + "delegate fallback - success - approved caller slot 4 forwards payload": "23576", + "delegate fallback - success - packed calldata bubbles return data": "3767", + "delegate fallback - success - packed calldata forwards zero ETH": "23749", + "delegate fallback - success - target has no code": "3131", + "delegate fallback - success - target is zero address": "631" +} \ No newline at end of file diff --git a/src/Solver7702Delegate.sol b/src/Solver7702Delegate.sol index 21ed6b2..1c45dec 100644 --- a/src/Solver7702Delegate.sol +++ b/src/Solver7702Delegate.sol @@ -34,6 +34,7 @@ contract Solver7702Delegate { } /// @notice Fallback function to handle calls to the delegate + /// @dev Expected calldata format is `bytes20(target) || targetCalldata`. fallback() external payable { // Simply receive ETH if (msg.data.length < 20) return; diff --git a/test/.solhint.json b/test/.solhint.json index f02cc02..5f8d252 100644 --- a/test/.solhint.json +++ b/test/.solhint.json @@ -1,6 +1,8 @@ { "rules": { "func-name-mixedcase": "off", + "gas-strict-inequalities": "off", + "no-empty-blocks": "off", "use-natspec": "off" } } diff --git a/test/BaseTest.t.sol b/test/BaseTest.t.sol new file mode 100644 index 0000000..167c055 --- /dev/null +++ b/test/BaseTest.t.sol @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity ^0.8.34; + +import {Test} from "forge-std/Test.sol"; + +import {Solver7702Delegate} from "src/Solver7702Delegate.sol"; + +struct ApprovedCallers { + /// @notice First approved caller slot. + address first; + /// @notice Second approved caller slot. + address second; + /// @notice Third approved caller slot. + address third; + /// @notice Fourth approved caller slot. + address fourth; + /// @notice Fifth approved caller slot. + address fifth; +} + +/// @notice Shared setup and helpers for Solver7702Delegate tests. +abstract contract BaseTest is Test { + /// @notice Private key for the EOA that gets EIP-7702 delegation attached. + uint256 internal constant SOLVER_PRIVATE_KEY = uint256(keccak256("SOLVER_PRIVATE_KEY")); + /// @notice ETH value used by tests that forward native token value. + uint256 internal constant MSG_VALUE = 1 ether; + /// @notice Number of bytes used to encode the packed target address. + uint256 internal constant PACKED_TARGET_LENGTH = 20; + /// @notice Shared token and mock settlement amount. + uint256 internal constant TEST_AMOUNT = 100 ether; + /// @notice Shared order UID used by mock settlement tests. + bytes32 internal constant TEST_ORDER_UID = keccak256("TEST_ORDER_UID"); + /// @notice Selector for RawRevert(uint256,string). + bytes4 internal constant RAW_REVERT_SELECTOR = bytes4(keccak256("RawRevert(uint256,string)")); + + /// @notice Shared solver address. + address internal solver; + /// @notice Shared recipient address. + address internal recipient; + /// @notice Shared unauthorized caller address. + address internal unauthorizedCaller; + /// @notice Shared approved callers. + ApprovedCallers internal approvedCallers; + /// @notice Shared delegate contract. + Solver7702Delegate internal delegateContract; + + /// @notice Creates the default solver, approved callers, and delegate contract. + function setUp() public virtual { + solver = vm.addr(SOLVER_PRIVATE_KEY); + recipient = makeAddr("RECIPIENT"); + unauthorizedCaller = makeAddr("UNAUTHORIZED_CALLER"); + approvedCallers = ApprovedCallers({ + first: makeAddr("APPROVED_CALLER_0"), + second: makeAddr("APPROVED_CALLER_1"), + third: makeAddr("APPROVED_CALLER_2"), + fourth: makeAddr("APPROVED_CALLER_3"), + fifth: makeAddr("APPROVED_CALLER_4") + }); + + delegateContract = new Solver7702Delegate( + [ + approvedCallers.first, + approvedCallers.second, + approvedCallers.third, + approvedCallers.fourth, + approvedCallers.fifth + ] + ); + } + + /// @notice Encodes the delegate fallback calldata as a 20-byte target followed by payload. + function _packedCalldata(address target, bytes memory payload) internal pure returns (bytes memory) { + return abi.encodePacked(bytes20(target), payload); + } + + /// @notice Attaches the delegate code to the solver EOA. + function _attachDelegation(uint256 solverPrivateKey) internal { + vm.signAndAttachDelegation(address(delegateContract), solverPrivateKey); + } + + /// @notice Decodes and checks the return data from PayableFallbackTarget. + function _assertFallbackReturn( + bytes memory returnData, + address expectedSender, + uint256 expectedValue, + bytes memory expectedPayload, + uint256 expectedBalance + ) internal pure { + (address sender, uint256 value, bytes memory payload, uint256 balance) = + abi.decode(returnData, (address, uint256, bytes, uint256)); + + assertEq(sender, expectedSender); + assertEq(value, expectedValue); + assertEq(payload, expectedPayload); + assertEq(balance, expectedBalance); + } +} diff --git a/test/Solver7702Delegate.fork.t.sol b/test/Solver7702Delegate.fork.t.sol new file mode 100644 index 0000000..c59f7f1 --- /dev/null +++ b/test/Solver7702Delegate.fork.t.sol @@ -0,0 +1,547 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +// solhint-disable avoid-low-level-calls, gas-small-strings, max-states-count +pragma solidity ^0.8.34; + +import {BaseTest} from "test/BaseTest.t.sol"; +import {IGPv2Authenticator} from "test/dependencies/settlement/IGPv2Authenticator.sol"; +import {IGPv2Settlement} from "test/dependencies/settlement/IGPv2Settlement.sol"; +import {SettlementUtils} from "test/utils/SettlementUtils.sol"; + +contract Solver7702DelegateForkTest is BaseTest { + address internal constant GPV2_SETTLEMENT = 0x9008D19f58AAbD9eD0D60971565AA8510560ab41; + address internal constant GPV2_AUTHENTICATOR = 0x2c4c28DDBdAc9C5E7055b4C863b72eA0149D8aFE; + + struct NetworkConfig { + string name; + string rpcEnv; + uint256 forkBlock; + address settlement; + address authenticator; + address usdc; + address weth; + } + + struct HistoricalOrder { + string label; + bytes orderUid; + bytes32 txHash; + uint256 forkBlock; + uint256 settlementBlock; + address sellToken; + address buyToken; + uint256 sellAmount; + uint256 buyAmount; + address owner; + } + + function setUp() public override { + // Each test selects its own fork from a real network config. + } + + // ~~~~~~~~~~~~~~~~~~~~ NETWORK CONFIG TESTS ~~~~~~~~~~~~~~~~~~~~ + + function test_fork_submission_attemptsSimpleWethForUsdcOrder_ethereum() public { + // ~~~~~~~~~~ Setup / Call / Assertions ~~~~~~~~~~ + _attemptSimpleWethForUsdcOrder(_ethereumNetworkConfig()); + } + + function test_fork_submission_attemptsSimpleWethForUsdcOrder_arbitrum() public { + // ~~~~~~~~~~ Setup / Call / Assertions ~~~~~~~~~~ + _attemptSimpleWethForUsdcOrder( + NetworkConfig({ + name: "arbitrum", + rpcEnv: "ARBITRUM_ONE_RPC_URL", + forkBlock: 0, // historical blocks not supported in RPC + settlement: GPV2_SETTLEMENT, + authenticator: GPV2_AUTHENTICATOR, + usdc: 0xaf88d065e77c8cC2239327C5EDb3A432268e5831, + weth: 0x82aF49447D8a07e3bd95BD0d56f35241523fBab1 + }) + ); + } + + function test_fork_submission_attemptsSimpleWethForUsdcOrder_base() public { + // ~~~~~~~~~~ Setup / Call / Assertions ~~~~~~~~~~ + _attemptSimpleWethForUsdcOrder( + NetworkConfig({ + name: "base", + rpcEnv: "BASE_RPC_URL", + forkBlock: 46_025_000, + settlement: GPV2_SETTLEMENT, + authenticator: GPV2_AUTHENTICATOR, + usdc: 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913, + weth: 0x4200000000000000000000000000000000000006 + }) + ); + } + + function test_fork_submission_attemptsSimpleWethForUsdcOrder_bnb() public { + // ~~~~~~~~~~ Setup / Call / Assertions ~~~~~~~~~~ + _attemptSimpleWethForUsdcOrder( + NetworkConfig({ + name: "bnb", + rpcEnv: "BNB_MAINNET_RPC_URL", + forkBlock: 0, // historical blocks not supported in RPC + settlement: GPV2_SETTLEMENT, + authenticator: GPV2_AUTHENTICATOR, + usdc: 0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d, + weth: 0x2170Ed0880ac9A755fd29B2688956BD959F933F8 + }) + ); + } + + function test_fork_submission_attemptsSimpleWethForUsdcOrder_gnosis() public { + // ~~~~~~~~~~ Setup / Call / Assertions ~~~~~~~~~~ + _attemptSimpleWethForUsdcOrder( + NetworkConfig({ + name: "gnosis", + rpcEnv: "GNOSIS_RPC_URL", + forkBlock: 46_185_000, + settlement: GPV2_SETTLEMENT, + authenticator: GPV2_AUTHENTICATOR, + usdc: 0xDDAfbb505ad214D7b80b1f830fcCc89B60fb7A83, + weth: 0x6A023CCd1ff6F2045C3309768eAd9E68F978f6e1 + }) + ); + } + + function test_fork_submission_attemptsSimpleWethForUsdcOrder_optimism() public { + // ~~~~~~~~~~ Setup / Call / Assertions ~~~~~~~~~~ + _attemptSimpleWethForUsdcOrder( + NetworkConfig({ + name: "optimism", + rpcEnv: "OPTIMISM_RPC_URL", + forkBlock: 151_400_000, + settlement: GPV2_SETTLEMENT, + authenticator: GPV2_AUTHENTICATOR, + usdc: 0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85, + weth: 0x4200000000000000000000000000000000000006 + }) + ); + } + + function test_fork_submission_attemptsSimpleWethForUsdcOrder_polygon() public { + // ~~~~~~~~~~ Setup / Call / Assertions ~~~~~~~~~~ + _attemptSimpleWethForUsdcOrder( + NetworkConfig({ + name: "polygon", + rpcEnv: "POLYGON_RPC_URL", + forkBlock: 0, // historical blocks not supported in RPC + settlement: GPV2_SETTLEMENT, + authenticator: GPV2_AUTHENTICATOR, + usdc: 0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359, + weth: 0x7ceB23fD6bC0adD59E62ac25578270cFf1b9f619 + }) + ); + } + + function test_fork_submission_attemptsSimpleWethForUsdcOrder_plasma() public { + // ~~~~~~~~~~ Setup / Call / Assertions ~~~~~~~~~~ + _attemptSimpleWethForUsdcOrder( + NetworkConfig({ + name: "plasma", + rpcEnv: "PLASMA_RPC_URL", + forkBlock: 21_916_000, + settlement: GPV2_SETTLEMENT, + authenticator: GPV2_AUTHENTICATOR, + usdc: 0xB8CE59FC3717ada4C02eaDF9682A9e934F625ebb, + weth: 0x9895D81bB462A195b4922ED7De0e3ACD007c32CB + }) + ); + } + + function test_fork_submission_attemptsSimpleWethForUsdcOrder_avalanche() public { + // ~~~~~~~~~~ Setup / Call / Assertions ~~~~~~~~~~ + _attemptSimpleWethForUsdcOrder( + NetworkConfig({ + name: "avalanche", + rpcEnv: "AVALANCHE_RPC_URL", + forkBlock: 0, // historical blocks not supported in RPC + settlement: GPV2_SETTLEMENT, + authenticator: GPV2_AUTHENTICATOR, + usdc: 0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E, + weth: 0x49D5c2BdFfac6CE2BFdB6640F4F80f226bc10bAB + }) + ); + } + + function test_fork_submission_attemptsSimpleWethForUsdcOrder_ink() public { + // ~~~~~~~~~~ Setup / Call / Assertions ~~~~~~~~~~ + _attemptSimpleWethForUsdcOrder( + NetworkConfig({ + name: "ink", + rpcEnv: "INK_RPC_URL", + forkBlock: 0, // historical blocks not supported in RPC + settlement: GPV2_SETTLEMENT, + authenticator: GPV2_AUTHENTICATOR, + usdc: 0xF1815bd50389c46847f0Bda824eC8da914045D14, + weth: 0x4200000000000000000000000000000000000006 + }) + ); + } + + function test_fork_submission_attemptsSimpleWethForUsdcOrder_linea() public { + // ~~~~~~~~~~ Setup / Call / Assertions ~~~~~~~~~~ + _attemptSimpleWethForUsdcOrder( + NetworkConfig({ + name: "linea", + rpcEnv: "LINEA_RPC_URL", + forkBlock: 30_655_000, + settlement: GPV2_SETTLEMENT, + authenticator: GPV2_AUTHENTICATOR, + usdc: 0x176211869cA2b568f2A7D4EE941E073a821EE1ff, + weth: 0xe5D7C2a44FfDDf6b295A15c148167daaAf5Cf34f + }) + ); + } + + function test_fork_submission_revertsWith_NotSolver_ethereum() public { + // ~~~~~~~~~~ Setup ~~~~~~~~~~ + NetworkConfig memory config = _ethereumNetworkConfig(); + _selectForkAndSetUp(config); + + IGPv2Authenticator authenticator = IGPv2Authenticator(config.authenticator); + vm.prank(authenticator.manager()); + authenticator.removeSolver(solver); + assertFalse(authenticator.isSolver(solver)); + + bytes memory payload = SettlementUtils.realGPv2SettleCalldata(config.usdc, config.weth, address(0)); + bytes memory expectedRevertData = abi.encodeWithSignature("Error(string)", "GPv2: not a solver"); + + // ~~~~~~~~~~ Call ~~~~~~~~~~ + vm.prank(approvedCallers.first); + (bool success, bytes memory returnData) = solver.call(_packedCalldata(config.settlement, payload)); + + // ~~~~~~~~~~ Assertions ~~~~~~~~~~ + assertFalse(success, "submission succeeded when not solver"); + assertEq(returnData, expectedRevertData, "revert data mismatch"); + } + + function test_fork_submission_revertsWith_ForbiddenInteraction_ethereum() public { + // ~~~~~~~~~~ Setup ~~~~~~~~~~ + NetworkConfig memory config = _ethereumNetworkConfig(); + _selectForkAndSetUp(config); + + address vaultRelayer = IGPv2Settlement(config.settlement).vaultRelayer(); + bytes memory payload = SettlementUtils.realGPv2SettleCalldata(config.usdc, config.weth, vaultRelayer); + bytes memory expectedRevertData = abi.encodeWithSignature("Error(string)", "GPv2: forbidden interaction"); + + // ~~~~~~~~~~ Call ~~~~~~~~~~ + vm.prank(approvedCallers.first); + (bool success, bytes memory returnData) = solver.call(_packedCalldata(config.settlement, payload)); + + // ~~~~~~~~~~ Assertions ~~~~~~~~~~ + assertFalse(success, "submission succeeded with forbidden interaction"); + assertEq(returnData, expectedRevertData, "revert data mismatch"); + } + + // ~~~~~~~~~~~~~~~~~~~~ HISTORICAL ORDER TESTS ~~~~~~~~~~~~~~~~~~~~ + + // https://explorer.cow.fi/mainnet/orders/0x6c4aa56cadbc45ff53fa35550902a488752218cac1e629ff812d7cb9ff0e1a78b31e6a0bf8d0ad664f58a7374ac0539ec51c3d236a023b7d + function test_fork_historicalOrder_directVsDelegated_usdcForEura() public { + // ~~~~~~~~~~ Setup / Call / Assertions ~~~~~~~~~~ + _runHistoricalOrder( + HistoricalOrder({ + label: "USDC -> EURA", + orderUid: hex"6c4aa56cadbc45ff53fa35550902a488752218cac1e629ff812d7cb9ff0e1a78b31e6a0bf8d0ad664f58a7374ac0539ec51c3d236a023b7d", + txHash: 0x8db7514f572db097bc1dce61402f347c1ace164a9e2de7f0dd0f32443f2e9d7f, + forkBlock: 25_074_097, + settlementBlock: 25_074_098, + sellToken: 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48, + buyToken: 0x57e114B691Db790C35207b2e685D4A43181e6061, + sellAmount: 489_089_774, + buyAmount: 3_742_033_366_022_621_078_227, + owner: 0xb31E6A0bf8d0Ad664f58A7374ac0539eC51c3D23 + }) + ); + } + + // https://explorer.cow.fi/mainnet/orders/0x391884a8e90bf92e99cfdd9ce97959214e9b13da7022b95d8a797c71caeb4cd2ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff + function test_fork_historicalOrder_directVsDelegated_wethForUsdc() public { + // ~~~~~~~~~~ Setup / Call / Assertions ~~~~~~~~~~ + _runHistoricalOrder( + HistoricalOrder({ + label: "WETH -> USDC", + orderUid: hex"391884a8e90bf92e99cfdd9ce97959214e9b13da7022b95d8a797c71caeb4cd2ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", + txHash: 0x80a37a1af03bd0c3d8030c764b66c332d580d497a04e3e00c9746de49e47cf4e, + forkBlock: 25_099_780, + settlementBlock: 25_099_781, + sellToken: 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2, + buyToken: 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48, + sellAmount: 3_860_000_000_000_000_000, + buyAmount: 8_692_179_691, + owner: 0xbA3cB449bD2B4ADddBc894D8697F5170800EAdeC + }) + ); + } + + // https://explorer.cow.fi/mainnet/orders/0xde339bcddeadcb054d7cf6a8421ed059ba19ce50d9aa8fedc348c041cb0302d8a5f84b556d5fd8959165eff0324dcfea164fa0896a06f646 + function test_fork_historicalOrder_directVsDelegated_yfiForUsdc() public { + // ~~~~~~~~~~ Setup / Call / Assertions ~~~~~~~~~~ + _runHistoricalOrder( + HistoricalOrder({ + label: "YFI -> USDC", + orderUid: hex"de339bcddeadcb054d7cf6a8421ed059ba19ce50d9aa8fedc348c041cb0302d8a5f84b556d5fd8959165eff0324dcfea164fa0896a06f646", + txHash: 0x38793b2b90a472f43ebe9ff35105132cb4e2be906a89f4bb0017bdedf29b8f53, + forkBlock: 25_099_837, + settlementBlock: 25_099_838, + sellToken: 0x4d1C297d39C5c1277964D0E3f8Aa901493664530, + buyToken: 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48, + sellAmount: 86_957_000_000_000_000_000_000, + buyAmount: 2_351_389_220, + owner: 0xa5F84b556d5FD8959165Eff0324DCFEa164fA089 + }) + ); + } + + // https://explorer.cow.fi/mainnet/orders/0xe4a4cb076fcabe07910b078ff008c53780566a323294aabf7de07048a27e0d9bbb08a33f8829c7f8f3684f67723326b2ebf5a86c6a06f55c + function test_fork_historicalOrder_directVsDelegated_usdtForAave() public { + // ~~~~~~~~~~ Setup / Call / Assertions ~~~~~~~~~~ + _runHistoricalOrder( + HistoricalOrder({ + label: "USDT -> AAVE", + orderUid: hex"e4a4cb076fcabe07910b078ff008c53780566a323294aabf7de07048a27e0d9bbb08a33f8829c7f8f3684f67723326b2ebf5a86c6a06f55c", + txHash: 0x67ffdb1c1afb013b3c848a891fca0b6924bd9584be9ee7776d09b772dcb6739a, + forkBlock: 25_099_804, + settlementBlock: 25_099_805, + sellToken: 0xdAC17F958D2ee523a2206206994597C13D831ec7, + buyToken: 0xe76C6c83af64e4C60245D8C7dE953DF673a7A33D, + sellAmount: 16_693_724_350, + buyAmount: 9_699_459_097_528_824_221_061, + owner: 0xbb08A33f8829C7f8F3684f67723326B2EbF5a86c + }) + ); + } + + // https://explorer.cow.fi/mainnet/orders/0x1abdbe80040b0e51ed87b12c7a18f46dcf72141b5e3ff011eb94fa382f65bbab55bc5e6ad29da823854822749fd2eda2775c78bd6a06f48c + function test_fork_historicalOrder_directVsDelegated_usdcPermitForMog() public { + // ~~~~~~~~~~ Setup / Call / Assertions ~~~~~~~~~~ + _runHistoricalOrder( + HistoricalOrder({ + label: "USDC permit -> MOG", + orderUid: hex"1abdbe80040b0e51ed87b12c7a18f46dcf72141b5e3ff011eb94fa382f65bbab55bc5e6ad29da823854822749fd2eda2775c78bd6a06f48c", + txHash: 0xc157c9b4214a1c901ad25c1da17be6094490971bae4475ff55971d309f463702, + forkBlock: 25_099_786, + settlementBlock: 25_099_787, + sellToken: 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48, + buyToken: 0x1Aad217B8F78dbA5E6693460e8470F8b1A3977f3, + sellAmount: 2_452_152_963, + buyAmount: 24_674_903_497_290_529_676, + owner: 0x55BC5E6Ad29DA823854822749FD2Eda2775C78bd + }) + ); + } + + function _attemptSimpleWethForUsdcOrder(NetworkConfig memory config) internal { + // ~~~~~~~~~~ Setup ~~~~~~~~~~ + _selectForkAndSetUp(config); + bytes memory payload = SettlementUtils.realGPv2WethForUsdcOrderCalldata(config.usdc, config.weth, solver); + bytes memory delegatedCalldata = _packedCalldata(config.settlement, payload); + _assertPayloadSelector(payload, IGPv2Settlement.settle.selector); + + uint256 snapshot = vm.snapshotState(); + + // ~~~~~~~~~~ Call ~~~~~~~~~~ + vm.prank(solver); + vm.startSnapshotGas(string.concat(config.name, " - direct call - simple WETH -> USDC order attempt")); + (bool directSuccess, bytes memory directReturnData) = config.settlement.call(payload); + vm.stopSnapshotGas(); + + vm.revertToState(snapshot); + + vm.prank(approvedCallers.first); + vm.startSnapshotGas(string.concat(config.name, " - delegated call - simple WETH -> USDC order attempt")); + (bool delegatedSuccess, bytes memory delegatedReturnData) = solver.call(delegatedCalldata); + vm.stopSnapshotGas(); + + // ~~~~~~~~~~ Assertions ~~~~~~~~~~ + assertFalse(directSuccess, "direct simple order attempt unexpectedly succeeded"); + assertFalse(delegatedSuccess, "delegated simple order attempt unexpectedly succeeded"); + assertEq(delegatedSuccess, directSuccess, "success mismatch"); + assertEq(keccak256(delegatedReturnData), keccak256(directReturnData), "return data mismatch"); + assertFalse( + keccak256(directReturnData) == keccak256(abi.encodeWithSignature("Error(string)", "GPv2: not a solver")), + "direct solver was not allowlisted" + ); + assertFalse( + keccak256(delegatedReturnData) == keccak256(abi.encodeWithSignature("Error(string)", "GPv2: not a solver")), + "delegated solver was not allowlisted" + ); + } + + function _runHistoricalOrder(HistoricalOrder memory order) internal { + // ~~~~~~~~~~ Setup ~~~~~~~~~~ + NetworkConfig memory config = _ethereumNetworkConfig(); + config.forkBlock = order.forkBlock; + _selectForkAndSetUp(config); + + bytes memory payload = _historicalSettlementCalldata(config, order.txHash); + bytes memory delegatedCalldata = _packedCalldata(config.settlement, payload); + assertGt(payload.length, 4, "missing historical calldata"); + _assertPayloadSelector(payload, IGPv2Settlement.settle.selector); + + uint256 snapshot = vm.snapshotState(); + + // ~~~~~~~~~~ Call ~~~~~~~~~~ + vm.prank(solver); + vm.startSnapshotGas(string.concat("historical order - ", order.label, " - direct call")); + (bool directSuccess, bytes memory directReturnData) = config.settlement.call(payload); + vm.stopSnapshotGas(); + + vm.revertToState(snapshot); + + vm.prank(approvedCallers.first); + vm.startSnapshotGas(string.concat("historical order - ", order.label, " - delegated call")); + (bool delegatedSuccess, bytes memory delegatedReturnData) = solver.call(delegatedCalldata); + vm.stopSnapshotGas(); + + // ~~~~~~~~~~ Assertions ~~~~~~~~~~ + assertTrue(directSuccess, "direct submission failed"); + assertTrue(delegatedSuccess, "delegated submission failed"); + assertEq(delegatedSuccess, directSuccess, "success mismatch"); + assertEq(keccak256(delegatedReturnData), keccak256(directReturnData), "return data mismatch"); + assertEq(directReturnData.length, 0, "direct return data must be empty"); + assertEq(delegatedReturnData.length, 0, "delegated return data must be empty"); + assertEq(order.settlementBlock - 1, order.forkBlock, "fork must be before settlement"); + } + + function _assertPayloadSelector(bytes memory payload, bytes4 expectedSelector) internal pure { + assertGt(payload.length, 4, "missing calldata selector"); + // casting to bytes4 is safe because the length is checked just above. + // forge-lint: disable-next-line(unsafe-typecast) + assertEq(bytes4(payload), expectedSelector, "calldata selector mismatch"); + } + + function _historicalSettlementCalldata(NetworkConfig memory config, bytes32 txHash) + internal + returns (bytes memory payload) + { + (, address target, bytes memory transactionPayload) = _historicalTargetAndCalldata(txHash); + if (target == config.settlement && _hasSelector(transactionPayload, IGPv2Settlement.settle.selector)) { + return transactionPayload; + } + + payload = _extractNestedCall(transactionPayload, IGPv2Settlement.settle.selector); + assertGt(payload.length, 4, "missing nested settlement calldata"); + } + + function _hasSelector(bytes memory payload, bytes4 selector) internal pure returns (bool) { + if (payload.length < 4) { + return false; + } + return payload[0] == selector[0] && payload[1] == selector[1] && payload[2] == selector[2] + && payload[3] == selector[3]; + } + + function _extractNestedCall(bytes memory payload, bytes4 selector) internal pure returns (bytes memory nestedCall) { + uint256 selectorOffset = _findSelector(payload, selector); + if (selectorOffset == type(uint256).max) { + return nestedCall; + } + + uint256 nestedLength = payload.length - selectorOffset; + if (selectorOffset >= 32) { + uint256 encodedLength = _readUint(payload, selectorOffset - 32); + if (encodedLength >= 4 && selectorOffset + encodedLength <= payload.length) { + nestedLength = encodedLength; + } + } + + nestedCall = new bytes(nestedLength); + for (uint256 i; i < nestedLength; ++i) { + nestedCall[i] = payload[selectorOffset + i]; + } + } + + function _findSelector(bytes memory payload, bytes4 selector) internal pure returns (uint256 offset) { + if (payload.length < 4) { + return type(uint256).max; + } + for (uint256 i; i <= payload.length - 4; ++i) { + if ( + payload[i] == selector[0] && payload[i + 1] == selector[1] && payload[i + 2] == selector[2] + && payload[i + 3] == selector[3] + ) { + return i; + } + } + return type(uint256).max; + } + + function _selectForkAndSetUp(NetworkConfig memory config) internal { + string memory rpcUrl = vm.envOr(config.rpcEnv, string("")); + if (bytes(rpcUrl).length == 0 && keccak256(bytes(config.rpcEnv)) == keccak256(bytes("ETH_MAINNET_RPC_URL"))) { + rpcUrl = vm.envOr("MAINNET_RPC_URL", string("")); + } + vm.skip(bytes(rpcUrl).length == 0, string.concat("missing ", config.rpcEnv)); + if (config.forkBlock == 0) { + vm.createSelectFork(rpcUrl); + } else { + vm.createSelectFork(rpcUrl, config.forkBlock); + } + + super.setUp(); + assertEq(address(IGPv2Settlement(config.settlement).authenticator()), config.authenticator); + + IGPv2Authenticator authenticator = IGPv2Authenticator(config.authenticator); + vm.prank(authenticator.manager()); + authenticator.addSolver(solver); + assertTrue(authenticator.isSolver(solver)); + + _attachDelegation(SOLVER_PRIVATE_KEY); + } + + function _historicalTargetAndCalldata(bytes32 txHash) + internal + returns (address from, address to, bytes memory input) + { + bytes memory data = vm.rpc("eth_getTransactionByHash", string.concat("[\"", vm.toString(txHash), "\"]")); + uint256 tupleStart = _readUint(data, 0); + uint256 fromOffset = 0x80; + uint256 toOffset = 0x1c0; + uint256 inputOffsetOffset = 0x100; + + from = _readAddress(data, tupleStart + fromOffset); + if (from == address(0)) { + fromOffset = 0xa0; + toOffset = 0x1e0; + inputOffsetOffset = 0x120; + from = _readAddress(data, tupleStart + fromOffset); + } + + to = _readAddress(data, tupleStart + toOffset); + uint256 inputOffset = _readUint(data, tupleStart + inputOffsetOffset); + uint256 inputStart = tupleStart + inputOffset; + input = _readBytes(data, inputStart); + } + + function _readAddress(bytes memory data, uint256 offset) internal pure returns (address value) { + value = address(uint160(_readUint(data, offset))); + } + + function _readUint(bytes memory data, uint256 offset) internal pure returns (uint256 value) { + for (uint256 i; i < 32; ++i) { + value = (value << 8) | uint8(data[offset + i]); + } + } + + function _readBytes(bytes memory data, uint256 offset) internal pure returns (bytes memory value) { + uint256 length = _readUint(data, offset); + value = new bytes(length); + for (uint256 i; i < length; ++i) { + value[i] = data[offset + 32 + i]; + } + } + + function _ethereumNetworkConfig() internal pure returns (NetworkConfig memory config) { + config = NetworkConfig({ + name: "ethereum", + rpcEnv: "ETH_MAINNET_RPC_URL", + forkBlock: 25_099_000, + settlement: GPV2_SETTLEMENT, + authenticator: GPV2_AUTHENTICATOR, + usdc: 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48, + weth: 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2 + }); + } +} diff --git a/test/Solver7702Delegate.integration.t.sol b/test/Solver7702Delegate.integration.t.sol new file mode 100644 index 0000000..da76b15 --- /dev/null +++ b/test/Solver7702Delegate.integration.t.sol @@ -0,0 +1,173 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +// solhint-disable avoid-low-level-calls, gas-small-strings +pragma solidity ^0.8.34; + +import {Solver7702Delegate} from "src/Solver7702Delegate.sol"; +import {BaseTest} from "test/BaseTest.t.sol"; +import {MockGPv2Authenticator} from "test/mocks/MockGPv2Authenticator.sol"; +import {MockGPv2Settlement} from "test/mocks/MockGPv2Settlement.sol"; +import {SettlementUtils} from "test/utils/SettlementUtils.sol"; + +contract Solver7702DelegateIntegrationTest is BaseTest { + uint256 internal constant SMALL_GPV2_TOKEN_COUNT = 2; + uint256 internal constant SMALL_GPV2_TRADE_COUNT = 1; + uint256 internal constant SMALL_GPV2_INTERACTION_COUNT = 0; + + uint256 internal constant LARGE_GPV2_TOKEN_COUNT = 8; + uint256 internal constant LARGE_GPV2_TRADE_COUNT = 16; + uint256 internal constant LARGE_GPV2_INTERACTION_COUNT = 6; + + MockGPv2Authenticator internal authenticator; + MockGPv2Settlement internal gpv2Settlement; + + function setUp() public override { + super.setUp(); + + authenticator = new MockGPv2Authenticator(address(this)); + authenticator.addSolver(solver); + gpv2Settlement = new MockGPv2Settlement(authenticator, makeAddr("VAULT_RELAYER")); + + _attachDelegation(SOLVER_PRIVATE_KEY); + } + + // ~~~~~~~~~~~~~~~~~~~~ SUCCESS CASES ~~~~~~~~~~~~~~~~~~~~ + + function test_integration_submission_success_smallSettlement() public { + // ~~~~~~~~~~ Setup ~~~~~~~~~~ + (bytes memory payload, bytes32 expectedPayloadHash) = SettlementUtils.gpv2SettlementCalldata( + SMALL_GPV2_TOKEN_COUNT, SMALL_GPV2_TRADE_COUNT, SMALL_GPV2_INTERACTION_COUNT, recipient, address(0) + ); + + // ~~~~~~~~~~ Call ~~~~~~~~~~ + vm.prank(solver); + vm.startSnapshotGas("direct submission - success - small settlement"); + (bool directSuccess,) = address(gpv2Settlement).call(payload); + vm.stopSnapshotGas(); + + vm.prank(approvedCallers.first); + vm.startSnapshotGas("delegated submission - success - small settlement"); + (bool success, bytes memory returnData) = solver.call(_packedCalldata(address(gpv2Settlement), payload)); + vm.stopSnapshotGas(); + + // ~~~~~~~~~~ Assertions ~~~~~~~~~~ + assertTrue(directSuccess, "direct submission failed"); + assertTrue(success, "delegated submission failed"); + assertEq(returnData.length, 0, "return data length mismatch"); + assertEq(gpv2Settlement.lastSender(), solver, "sender mismatch"); + assertEq(gpv2Settlement.lastPayloadHash(), expectedPayloadHash, "payload hash mismatch"); + assertEq(gpv2Settlement.lastTokenCount(), SMALL_GPV2_TOKEN_COUNT, "token count mismatch"); + assertEq(gpv2Settlement.lastTradeCount(), SMALL_GPV2_TRADE_COUNT, "trade count mismatch"); + } + + function test_integration_submission_success_largeSettlementWithInteractions() public { + // ~~~~~~~~~~ Setup ~~~~~~~~~~ + (bytes memory payload, bytes32 expectedPayloadHash) = SettlementUtils.gpv2SettlementCalldata( + LARGE_GPV2_TOKEN_COUNT, LARGE_GPV2_TRADE_COUNT, LARGE_GPV2_INTERACTION_COUNT, recipient, address(0) + ); + + // ~~~~~~~~~~ Call ~~~~~~~~~~ + vm.prank(solver); + vm.startSnapshotGas("direct submission - success - large settlement with interactions"); + (bool directSuccess,) = address(gpv2Settlement).call(payload); + vm.stopSnapshotGas(); + + vm.prank(approvedCallers.first); + vm.startSnapshotGas("delegated submission - success - large settlement with interactions"); + (bool success, bytes memory returnData) = solver.call(_packedCalldata(address(gpv2Settlement), payload)); + vm.stopSnapshotGas(); + + // ~~~~~~~~~~ Assertions ~~~~~~~~~~ + assertTrue(directSuccess, "direct submission failed"); + assertTrue(success, "delegated submission failed"); + assertEq(returnData.length, 0, "return data length mismatch"); + assertEq(gpv2Settlement.lastSender(), solver, "sender mismatch"); + assertEq(gpv2Settlement.lastPayloadHash(), expectedPayloadHash, "payload hash mismatch"); + assertEq(gpv2Settlement.lastTokenCount(), LARGE_GPV2_TOKEN_COUNT, "token count mismatch"); + assertEq(gpv2Settlement.lastTradeCount(), LARGE_GPV2_TRADE_COUNT, "trade count mismatch"); + assertEq(gpv2Settlement.lastInteractionCount(), LARGE_GPV2_INTERACTION_COUNT, "interaction count mismatch"); + } + + function test_integration_submission_success_interactionsAcrossPhases() public { + // ~~~~~~~~~~ Setup ~~~~~~~~~~ + uint256[3] memory interactionCounts = [uint256(2), uint256(3), uint256(1)]; + uint256 totalInteractions = interactionCounts[0] + interactionCounts[1] + interactionCounts[2]; + (bytes memory payload, bytes32 expectedPayloadHash) = SettlementUtils.gpv2SettlementCalldata( + SMALL_GPV2_TOKEN_COUNT, SMALL_GPV2_TRADE_COUNT, interactionCounts, recipient, address(0) + ); + + // ~~~~~~~~~~ Call ~~~~~~~~~~ + vm.prank(solver); + vm.startSnapshotGas("direct submission - success - interactions across phases"); + (bool directSuccess,) = address(gpv2Settlement).call(payload); + vm.stopSnapshotGas(); + + vm.prank(approvedCallers.first); + vm.startSnapshotGas("delegated submission - success - interactions across phases"); + (bool success, bytes memory returnData) = solver.call(_packedCalldata(address(gpv2Settlement), payload)); + vm.stopSnapshotGas(); + + // ~~~~~~~~~~ Assertions ~~~~~~~~~~ + assertTrue(directSuccess, "direct submission failed"); + assertTrue(success, "delegated submission failed"); + assertEq(returnData.length, 0, "return data length mismatch"); + assertEq(gpv2Settlement.lastSender(), solver, "sender mismatch"); + assertEq(gpv2Settlement.lastPayloadHash(), expectedPayloadHash, "payload hash mismatch"); + assertEq(gpv2Settlement.lastInteractionCount(), totalInteractions, "interaction count mismatch"); + } + + // ~~~~~~~~~~~~~~~~~~~~ REVERT CASES ~~~~~~~~~~~~~~~~~~~~ + + function test_integration_submission_revertsWith_UnauthorizedCaller() public { + // ~~~~~~~~~~ Setup ~~~~~~~~~~ + (bytes memory payload,) = SettlementUtils.gpv2SettlementCalldata( + SMALL_GPV2_TOKEN_COUNT, SMALL_GPV2_TRADE_COUNT, SMALL_GPV2_INTERACTION_COUNT, recipient, address(0) + ); + + // ~~~~~~~~~~ Call ~~~~~~~~~~ + vm.prank(unauthorizedCaller); + (bool success, bytes memory returnData) = solver.call(_packedCalldata(address(gpv2Settlement), payload)); + vm.snapshotGasLastCall("delegated submission - reverts - UnauthorizedCaller"); + + // ~~~~~~~~~~ Assertions ~~~~~~~~~~ + assertFalse(success, "submission succeeded when unauthorized caller"); + assertEq( + returnData, + abi.encodeWithSelector(Solver7702Delegate.Unauthorized.selector, unauthorizedCaller), + "revert data mismatch" + ); + } + + function test_integration_submission_revertsWith_NotSolver() public { + // ~~~~~~~~~~ Setup ~~~~~~~~~~ + (bytes memory payload,) = SettlementUtils.gpv2SettlementCalldata( + SMALL_GPV2_TOKEN_COUNT, SMALL_GPV2_TRADE_COUNT, SMALL_GPV2_INTERACTION_COUNT, recipient, address(0) + ); + authenticator.removeSolver(solver); + + // ~~~~~~~~~~ Call ~~~~~~~~~~ + bytes memory expectedRevertData = abi.encodeWithSelector(MockGPv2Settlement.NotSolver.selector, solver); + vm.prank(approvedCallers.first); + (bool success, bytes memory returnData) = solver.call(_packedCalldata(address(gpv2Settlement), payload)); + + // ~~~~~~~~~~ Assertions ~~~~~~~~~~ + assertFalse(success, "submission succeeded when not solver"); + assertEq(returnData, expectedRevertData, "revert data mismatch"); + } + + function test_integration_submission_revertsWith_ClearingPriceLengthMismatch() public { + // ~~~~~~~~~~ Setup ~~~~~~~~~~ + bytes memory payload = SettlementUtils.gpv2SettlementCalldataWithMissingClearingPrice( + SMALL_GPV2_TOKEN_COUNT, SMALL_GPV2_TRADE_COUNT, recipient, address(0) + ); + + // ~~~~~~~~~~ Call ~~~~~~~~~~ + bytes memory expectedRevertData = + abi.encodeWithSelector(MockGPv2Settlement.ClearingPriceLengthMismatch.selector); + vm.prank(approvedCallers.first); + (bool success, bytes memory returnData) = solver.call(_packedCalldata(address(gpv2Settlement), payload)); + + // ~~~~~~~~~~ Assertions ~~~~~~~~~~ + assertFalse(success, "submission succeeded when clearing price length mismatch"); + assertEq(returnData, expectedRevertData, "revert data mismatch"); + } +} diff --git a/test/Solver7702Delegate.t.sol b/test/Solver7702Delegate.t.sol new file mode 100644 index 0000000..3c57176 --- /dev/null +++ b/test/Solver7702Delegate.t.sol @@ -0,0 +1,529 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +// solhint-disable avoid-low-level-calls, gas-small-strings +pragma solidity ^0.8.34; + +import {stdError} from "forge-std/StdError.sol"; + +import {Solver7702Delegate} from "src/Solver7702Delegate.sol"; +import {BaseTest} from "test/BaseTest.t.sol"; +import {NonpayableTarget} from "test/mocks/targets/NonpayableTarget.sol"; +import {PayableFallbackTarget} from "test/mocks/targets/PayableFallbackTarget.sol"; +import {RawRevertTarget} from "test/mocks/targets/RawRevertTarget.sol"; +import {RevertingTarget} from "test/mocks/targets/RevertingTarget.sol"; + +contract Solver7702DelegateTest is BaseTest { + PayableFallbackTarget internal fallbackTarget; + NonpayableTarget internal nonpayableTarget; + RawRevertTarget internal rawRevertTarget; + RevertingTarget internal revertingTarget; + + address internal targetWithoutCode; + + function setUp() public override { + super.setUp(); + + fallbackTarget = new PayableFallbackTarget(); + nonpayableTarget = new NonpayableTarget(); + rawRevertTarget = new RawRevertTarget(); + revertingTarget = new RevertingTarget(); + + targetWithoutCode = makeAddr("TARGET_WITHOUT_CODE"); + } + + // ~~~~~~~~~~~~~~~~~~~~ SUCCESS CASES ~~~~~~~~~~~~~~~~~~~~ + + function test_unit_constructor_success_whenApprovedCallersAreDistinct() public { + // ~~~~~~~~~~ Setup ~~~~~~~~~~ + address[5] memory callers = [ + makeAddr("DISTINCT_CALLER_0"), + makeAddr("DISTINCT_CALLER_1"), + makeAddr("DISTINCT_CALLER_2"), + makeAddr("DISTINCT_CALLER_3"), + makeAddr("DISTINCT_CALLER_4") + ]; + Solver7702Delegate distinctDelegate = new Solver7702Delegate(callers); + vm.snapshotGasLastCall("constructor - success - distinct callers"); + + uint256 fallbackTargetBalanceBefore = address(fallbackTarget).balance; + // ~~~~~~~~~~ Call + Assertions ~~~~~~~~~~ + for (uint256 i; i < callers.length; ++i) { + bytes memory payload = abi.encodePacked("distinct ", i); + vm.prank(callers[i]); + (bool success, bytes memory returnData) = + address(distinctDelegate).call(_packedCalldata(address(fallbackTarget), payload)); + + assertTrue(success, "distinct approved caller should forward"); + _assertFallbackReturn(returnData, address(distinctDelegate), 0, payload, fallbackTargetBalanceBefore); + } + + assertEq(_fallbackCallCount(), callers.length, "fallback target should be called by each distinct caller"); + } + + function test_unit_constructor_success_whenApprovedCallersContainDuplicates() public { + // ~~~~~~~~~~ Setup ~~~~~~~~~~ + address repeatedCaller = makeAddr("REPEATED_CALLER"); + address[5] memory callers = [ + repeatedCaller, + repeatedCaller, + makeAddr("DUPLICATE_CALLER_2"), + repeatedCaller, + makeAddr("DUPLICATE_CALLER_4") + ]; + Solver7702Delegate duplicateDelegate = new Solver7702Delegate(callers); + vm.snapshotGasLastCall("constructor - success - duplicate callers"); + + bytes memory payload = hex"123456"; + uint256 fallbackTargetBalanceBefore = address(fallbackTarget).balance; + + // ~~~~~~~~~~ Call ~~~~~~~~~~ + vm.prank(repeatedCaller); + (bool success, bytes memory returnData) = + address(duplicateDelegate).call(_packedCalldata(address(fallbackTarget), payload)); + + // ~~~~~~~~~~ Assertions ~~~~~~~~~~ + assertTrue(success, "duplicate approved caller should forward"); + _assertFallbackReturn(returnData, address(duplicateDelegate), 0, payload, fallbackTargetBalanceBefore); + } + + function test_unit_constructor_success_whenApprovedCallersContainZeroAddress() public { + // ~~~~~~~~~~ Setup ~~~~~~~~~~ + address[5] memory callers = + [address(0), approvedCallers.second, address(0), approvedCallers.fourth, approvedCallers.fifth]; + + // ~~~~~~~~~~ Call ~~~~~~~~~~ + Solver7702Delegate zeroAddressDelegate = new Solver7702Delegate(callers); + vm.snapshotGasLastCall("constructor - success - zero address callers"); + + // ~~~~~~~~~~ Assertions ~~~~~~~~~~ + assertGt(address(zeroAddressDelegate).code.length, 0, "delegate should deploy with zero-address callers"); + } + + function test_unit_fallback_success_emptyCalldataReceivesEth() public { + // ~~~~~~~~~~ Setup ~~~~~~~~~~ + uint256 delegateBalanceBefore = address(delegateContract).balance; + vm.deal(unauthorizedCaller, MSG_VALUE); + + // ~~~~~~~~~~ Call ~~~~~~~~~~ + vm.prank(unauthorizedCaller); + (bool success, bytes memory returnData) = address(delegateContract).call{value: MSG_VALUE}(""); + + // ~~~~~~~~~~ Assertions ~~~~~~~~~~ + assertTrue(success, "empty calldata ETH transfer should succeed"); + assertEq(returnData.length, 0, "empty calldata should return no data"); + assertEq( + address(delegateContract).balance, + delegateBalanceBefore + MSG_VALUE, + "delegate should receive forwarded ETH" + ); + assertEq(_fallbackCallCount(), 0, "empty calldata should not call fallback target"); + } + + function test_unit_fallback_success_shortCalldataDoesNothing() public { + // ~~~~~~~~~~ Call + Assertions ~~~~~~~~~~ + for (uint256 length = 1; length < PACKED_TARGET_LENGTH; ++length) { + bytes memory shortCalldata = _bytesWithLength(length, bytes32(length)); + + vm.prank(unauthorizedCaller); + (bool success, bytes memory returnData) = address(delegateContract).call(shortCalldata); + + assertTrue(success, "short calldata should succeed"); + assertEq(returnData.length, 0, "short calldata should return no data"); + assertEq(_fallbackCallCount(), 0, "short calldata should not call fallback target"); + } + } + + function test_fuzz_fallback_success_shortCalldataDoesNothing(uint8 length, bytes32 seed) public { + // ~~~~~~~~~~ Setup ~~~~~~~~~~ + bytes memory shortCalldata = _bytesWithLength(bound(length, 0, PACKED_TARGET_LENGTH - 1), seed); + + // ~~~~~~~~~~ Call ~~~~~~~~~~ + vm.prank(unauthorizedCaller); + (bool success, bytes memory returnData) = address(delegateContract).call(shortCalldata); + + // ~~~~~~~~~~ Assertions ~~~~~~~~~~ + assertTrue(success, "fuzzed short calldata should succeed"); + assertEq(returnData.length, 0, "fuzzed short calldata should return no data"); + assertEq(_fallbackCallCount(), 0, "fuzzed short calldata should not call fallback target"); + } + + function test_unit_fallback_success_forwardsPayloadFromApprovedCallers() public { + // ~~~~~~~~~~ Setup ~~~~~~~~~~ + uint256 totalValue; + address[5] memory callers = _approvedCallers(); + + // Prime callCount so slot 0 does not include the one-off zero-to-nonzero SSTORE cost. + // This keeps the per-caller gas snapshots focused on the delegate fallback path. + (bool primed,) = address(fallbackTarget).call(""); + assertTrue(primed, "fallback target priming call should succeed"); + + uint256 fallbackTargetBalanceBefore = address(fallbackTarget).balance; + + // ~~~~~~~~~~ Call + Assertions ~~~~~~~~~~ + for (uint256 i; i < callers.length; ++i) { + bytes memory payload = abi.encodePacked("slot ", i, bytes32(uint256(100 + i))); + totalValue += MSG_VALUE; + vm.deal(callers[i], MSG_VALUE); + + vm.prank(callers[i]); + vm.startSnapshotGas(_packedCalldataGasSnapshotName(i)); + (bool success, bytes memory returnData) = + address(delegateContract).call{value: MSG_VALUE}(_packedCalldata(address(fallbackTarget), payload)); + vm.stopSnapshotGas(); + + assertTrue(success, "approved caller should forward payload"); + _assertFallbackReturn( + returnData, address(delegateContract), MSG_VALUE, payload, fallbackTargetBalanceBefore + totalValue + ); + } + + assertEq(_fallbackCallCount(), callers.length + 1, "fallback target should include priming call"); + assertEq( + address(fallbackTarget).balance, + fallbackTargetBalanceBefore + totalValue, + "fallback target should receive total forwarded ETH" + ); + } + + function test_fuzz_fallback_success_forwardsPayloadFromApprovedCaller( + uint8 callerIndex, + bytes memory payload, + uint96 value + ) public { + // ~~~~~~~~~~ Setup ~~~~~~~~~~ + address[5] memory callers = _approvedCallers(); + address caller = callers[uint256(callerIndex) % callers.length]; + uint256 fallbackTargetBalanceBefore = address(fallbackTarget).balance; + vm.deal(caller, value); + + // ~~~~~~~~~~ Call ~~~~~~~~~~ + vm.prank(caller); + (bool success, bytes memory returnData) = + address(delegateContract).call{value: value}(_packedCalldata(address(fallbackTarget), payload)); + + // ~~~~~~~~~~ Assertions ~~~~~~~~~~ + assertTrue(success, "fuzzed approved caller should forward payload"); + if (payload.length == 0) { + assertEq(returnData.length, 0, "empty payload should return no data"); + assertEq(address(fallbackTarget).balance, fallbackTargetBalanceBefore + value, "target should receive ETH"); + assertEq(_fallbackCallCount(), 1, "receive should be called once"); + return; + } + _assertFallbackReturn( + returnData, address(delegateContract), value, payload, fallbackTargetBalanceBefore + value + ); + assertEq(_fallbackCallCount(), 1, "fallback target should be called once"); + } + + function test_unit_fallback_success_forwardsPayload() public { + // ~~~~~~~~~~ Setup ~~~~~~~~~~ + bytes memory payload = abi.encodePacked(bytes4(0x12345678), abi.encode(uint256(1), "payload")); + uint256 fallbackTargetBalanceBefore = address(fallbackTarget).balance; + + // ~~~~~~~~~~ Call ~~~~~~~~~~ + vm.prank(approvedCallers.first); + (bool success, bytes memory returnData) = + address(delegateContract).call(_packedCalldata(address(fallbackTarget), payload)); + + // ~~~~~~~~~~ Assertions ~~~~~~~~~~ + assertTrue(success, "approved caller should forward payload"); + _assertFallbackReturn(returnData, address(delegateContract), 0, payload, fallbackTargetBalanceBefore); + } + + function test_unit_fallback_success_forwardsZeroMsgValue() public { + // ~~~~~~~~~~ Setup ~~~~~~~~~~ + bytes memory payload = hex"abcdef"; + uint256 fallbackTargetBalanceBefore = address(fallbackTarget).balance; + + // ~~~~~~~~~~ Call ~~~~~~~~~~ + vm.prank(approvedCallers.first); + (bool success, bytes memory returnData) = + address(delegateContract).call(_packedCalldata(address(fallbackTarget), payload)); + vm.snapshotGasLastCall("delegate fallback - success - packed calldata forwards zero ETH"); + + // ~~~~~~~~~~ Assertions ~~~~~~~~~~ + assertTrue(success, "zero-value call should forward"); + _assertFallbackReturn(returnData, address(delegateContract), 0, payload, fallbackTargetBalanceBefore); + assertEq(_fallbackCallCount(), 1, "zero-value call should hit fallback target once"); + } + + function test_unit_fallback_success_forwardsMsgValue() public { + // ~~~~~~~~~~ Setup ~~~~~~~~~~ + bytes memory payload = hex"abcdef"; + uint256 fallbackTargetBalanceBefore = address(fallbackTarget).balance; + vm.deal(approvedCallers.first, MSG_VALUE); + + // ~~~~~~~~~~ Call ~~~~~~~~~~ + vm.prank(approvedCallers.first); + (bool success, bytes memory returnData) = + address(delegateContract).call{value: MSG_VALUE}(_packedCalldata(address(fallbackTarget), payload)); + + // ~~~~~~~~~~ Assertions ~~~~~~~~~~ + assertTrue(success, "ETH call should forward"); + _assertFallbackReturn( + returnData, address(delegateContract), MSG_VALUE, payload, fallbackTargetBalanceBefore + MSG_VALUE + ); + assertEq( + address(fallbackTarget).balance, + fallbackTargetBalanceBefore + MSG_VALUE, + "fallback target should receive forwarded ETH" + ); + } + + function test_unit_fallback_success_bubblesExactReturnData() public { + // ~~~~~~~~~~ Setup ~~~~~~~~~~ + bytes memory expectedReturnData = hex"000102030405deadbeef"; + bytes memory payload = abi.encodeCall(PayableFallbackTarget.returnRaw, (expectedReturnData)); + + // ~~~~~~~~~~ Call ~~~~~~~~~~ + vm.prank(approvedCallers.first); + (bool success, bytes memory returnData) = + address(delegateContract).call(_packedCalldata(address(fallbackTarget), payload)); + vm.snapshotGasLastCall("delegate fallback - success - packed calldata bubbles return data"); + + // ~~~~~~~~~~ Assertions ~~~~~~~~~~ + assertTrue(success, "call should bubble exact return data"); + assertEq(returnData, expectedReturnData, "return data should match target output"); + } + + function test_fuzz_fallback_success_bubblesReturnData(bytes memory expectedReturnData) public { + // ~~~~~~~~~~ Setup ~~~~~~~~~~ + bytes memory payload = abi.encodeCall(PayableFallbackTarget.returnRaw, (expectedReturnData)); + + // ~~~~~~~~~~ Call ~~~~~~~~~~ + vm.prank(approvedCallers.first); + (bool success, bytes memory returnData) = + address(delegateContract).call(_packedCalldata(address(fallbackTarget), payload)); + + // ~~~~~~~~~~ Assertions ~~~~~~~~~~ + assertTrue(success, "fuzzed call should bubble return data"); + assertEq(returnData, expectedReturnData, "fuzzed return data should match target output"); + } + + function test_unit_fallback_success_whenTargetHasNoCode() public { + // ~~~~~~~~~~ Setup ~~~~~~~~~~ + bytes memory payload = hex"12345678"; + + // ~~~~~~~~~~ Call ~~~~~~~~~~ + vm.prank(approvedCallers.first); + (bool success, bytes memory returnData) = + address(delegateContract).call(_packedCalldata(targetWithoutCode, payload)); + vm.snapshotGasLastCall("delegate fallback - success - target has no code"); + + // ~~~~~~~~~~ Assertions ~~~~~~~~~~ + assertTrue(success, "call to target without code should succeed"); + assertEq(returnData.length, 0, "target without code should return no data"); + } + + function test_unit_fallback_success_whenTargetIsZeroAddress() public { + // ~~~~~~~~~~ Setup ~~~~~~~~~~ + bytes memory payload = hex"12345678"; + + // ~~~~~~~~~~ Call ~~~~~~~~~~ + vm.prank(approvedCallers.first); + (bool success, bytes memory returnData) = address(delegateContract).call(_packedCalldata(address(0), payload)); + vm.snapshotGasLastCall("delegate fallback - success - target is zero address"); + + // ~~~~~~~~~~ Assertions ~~~~~~~~~~ + assertTrue(success, "call to zero address should succeed"); + assertEq(returnData.length, 0, "zero address target should return no data"); + } + + // ~~~~~~~~~~~~~~~~~~~~ REVERT CASES ~~~~~~~~~~~~~~~~~~~~ + + function test_unit_fallback_revertsWith_Unauthorized_whenCallerNotApproved() public { + // ~~~~~~~~~~ Setup ~~~~~~~~~~ + bytes memory payload = hex"1234"; + + // ~~~~~~~~~~ Call ~~~~~~~~~~ + vm.prank(unauthorizedCaller); + (bool success, bytes memory returnData) = + address(delegateContract).call(_packedCalldata(address(fallbackTarget), payload)); + vm.snapshotGasLastCall("delegate fallback - reverts - unauthorized caller"); + + // ~~~~~~~~~~ Assertions ~~~~~~~~~~ + assertFalse(success, "unauthorized caller should revert"); + assertEq( + returnData, + abi.encodeWithSelector(Solver7702Delegate.Unauthorized.selector, unauthorizedCaller), + "unauthorized caller should return Unauthorized error" + ); + } + + function test_fuzz_fallback_revertsWith_UnauthorizedCaller( + uint256 callerPrivateKey, + bytes20 rawTarget, + bytes memory payload + ) public { + // ~~~~~~~~~~ Setup ~~~~~~~~~~ + callerPrivateKey = bound(callerPrivateKey, 1, type(uint128).max); + address caller = vm.addr(callerPrivateKey); + vm.assume(!_isCallerApproved(caller)); + + // ~~~~~~~~~~ Call ~~~~~~~~~~ + vm.prank(caller); + (bool success, bytes memory returnData) = address(delegateContract).call(abi.encodePacked(rawTarget, payload)); + + // ~~~~~~~~~~ Assertions ~~~~~~~~~~ + assertFalse(success, "fuzzed unauthorized caller should revert"); + assertEq( + returnData, + abi.encodeWithSelector(Solver7702Delegate.Unauthorized.selector, caller), + "fuzzed unauthorized caller should return Unauthorized error" + ); + } + + function test_unit_fallback_revertsWith_NonEmptyRevertData() public { + // ~~~~~~~~~~ Setup ~~~~~~~~~~ + bytes memory expectedRevertData = abi.encodeWithSelector(RAW_REVERT_SELECTOR, uint256(42), "bad call"); + bytes memory payload = abi.encodeCall(RawRevertTarget.revertRaw, (expectedRevertData)); + + // ~~~~~~~~~~ Call ~~~~~~~~~~ + vm.prank(approvedCallers.first); + (bool success, bytes memory returnData) = + address(delegateContract).call(_packedCalldata(address(rawRevertTarget), payload)); + vm.snapshotGasLastCall("delegate fallback - reverts - non-empty revert data"); + + // ~~~~~~~~~~ Assertions ~~~~~~~~~~ + assertFalse(success, "target revert should bubble failure"); + assertEq(returnData, expectedRevertData, "non-empty revert data should match target revert"); + } + + function test_fuzz_fallback_revertsWith_NonEmptyRevertData(bytes memory expectedRevertData) public { + // ~~~~~~~~~~ Setup ~~~~~~~~~~ + bytes memory payload = abi.encodeCall(RawRevertTarget.revertRaw, (expectedRevertData)); + + // ~~~~~~~~~~ Call ~~~~~~~~~~ + vm.prank(approvedCallers.first); + (bool success, bytes memory returnData) = + address(delegateContract).call(_packedCalldata(address(rawRevertTarget), payload)); + + // ~~~~~~~~~~ Assertions ~~~~~~~~~~ + assertFalse(success, "fuzzed target revert should bubble failure"); + assertEq(returnData, expectedRevertData, "fuzzed revert data should match target revert"); + } + + function test_unit_fallback_revertsWith_EmptyRevertData() public { + // ~~~~~~~~~~ Setup ~~~~~~~~~~ + bytes memory payload = abi.encodeCall(RawRevertTarget.revertRaw, ("")); + + // ~~~~~~~~~~ Call ~~~~~~~~~~ + vm.prank(approvedCallers.first); + (bool success, bytes memory returnData) = + address(delegateContract).call(_packedCalldata(address(rawRevertTarget), payload)); + vm.snapshotGasLastCall("delegate fallback - reverts - empty revert data"); + + // ~~~~~~~~~~ Assertions ~~~~~~~~~~ + assertFalse(success, "empty target revert should fail"); + assertEq(returnData.length, 0, "empty target revert should return no data"); + } + + function test_unit_fallback_revertsWith_CustomError() public { + // ~~~~~~~~~~ Setup ~~~~~~~~~~ + bytes memory customPayload = hex"c0ffee"; + bytes memory payload = abi.encodeCall(RevertingTarget.revertWithCustomError, (77, customPayload)); + + // ~~~~~~~~~~ Call ~~~~~~~~~~ + bytes memory expectedRevertData = abi.encodeWithSelector( + RevertingTarget.TargetCustomError.selector, address(delegateContract), 77, customPayload + ); + vm.prank(approvedCallers.first); + (bool success, bytes memory returnData) = + address(delegateContract).call(_packedCalldata(address(revertingTarget), payload)); + vm.snapshotGasLastCall("delegate fallback - reverts - custom error"); + + // ~~~~~~~~~~ Assertions ~~~~~~~~~~ + assertFalse(success, "custom error should bubble failure"); + assertEq(returnData, expectedRevertData, "custom error data should match target revert"); + } + + function test_unit_fallback_revertsWith_StringError() public { + // ~~~~~~~~~~ Setup ~~~~~~~~~~ + string memory reason = "target reverted"; + bytes memory payload = abi.encodeCall(RevertingTarget.revertWithString, (reason)); + bytes memory expectedRevertData = abi.encodeWithSignature("Error(string)", reason); + + // ~~~~~~~~~~ Call ~~~~~~~~~~ + vm.prank(approvedCallers.first); + (bool success, bytes memory returnData) = + address(delegateContract).call(_packedCalldata(address(revertingTarget), payload)); + vm.snapshotGasLastCall("delegate fallback - reverts - string error"); + + // ~~~~~~~~~~ Assertions ~~~~~~~~~~ + assertFalse(success, "string error should bubble failure"); + assertEq(returnData, expectedRevertData, "string error data should match target revert"); + } + + function test_unit_fallback_revertsWith_Panic() public { + // ~~~~~~~~~~ Setup ~~~~~~~~~~ + bytes memory payload = abi.encodeCall(RevertingTarget.revertWithPanic, ()); + bytes memory expectedRevertData = stdError.arithmeticError; + + // ~~~~~~~~~~ Call ~~~~~~~~~~ + vm.prank(approvedCallers.first); + (bool success, bytes memory returnData) = + address(delegateContract).call(_packedCalldata(address(revertingTarget), payload)); + vm.snapshotGasLastCall("delegate fallback - reverts - panic"); + + // ~~~~~~~~~~ Assertions ~~~~~~~~~~ + assertFalse(success, "panic should bubble failure"); + assertEq(returnData, expectedRevertData, "panic data should match target revert"); + } + + function test_unit_fallback_revertsWith_SendingEthToNonpayableTarget() public { + // ~~~~~~~~~~ Setup ~~~~~~~~~~ + bytes memory payload = hex"12345678"; + vm.deal(approvedCallers.first, MSG_VALUE); + + // ~~~~~~~~~~ Call ~~~~~~~~~~ + vm.prank(approvedCallers.first); + (bool success, bytes memory returnData) = + address(delegateContract).call{value: MSG_VALUE}(_packedCalldata(address(nonpayableTarget), payload)); + vm.snapshotGasLastCall("delegate fallback - reverts - sending ETH to nonpayable target"); + + // ~~~~~~~~~~ Assertions ~~~~~~~~~~ + assertFalse(success, "nonpayable target should reject ETH"); + assertEq(returnData.length, 0, "nonpayable target revert should return no data"); + } + + function _fallbackCallCount() internal view returns (uint256) { + return uint256(vm.load(address(fallbackTarget), bytes32(0))); + } + + function _isCallerApproved(address caller) internal view returns (bool) { + return caller == approvedCallers.first || caller == approvedCallers.second || caller == approvedCallers.third + || caller == approvedCallers.fourth || caller == approvedCallers.fifth; + } + + function _approvedCallers() internal view returns (address[5] memory callers) { + callers = [ + approvedCallers.first, + approvedCallers.second, + approvedCallers.third, + approvedCallers.fourth, + approvedCallers.fifth + ]; + } + + function _bytesWithLength(uint256 length, bytes32 seed) internal pure returns (bytes memory data) { + data = new bytes(length); + for (uint256 i; i < length; ++i) { + data[i] = bytes1(uint8(uint256(keccak256(abi.encode(seed, i))))); + } + } + + function _packedCalldataGasSnapshotName(uint256 callerIndex) internal pure returns (string memory) { + if (callerIndex == 0) { + return "delegate fallback - success - approved caller slot 0 forwards payload"; + } + if (callerIndex == 1) { + return "delegate fallback - success - approved caller slot 1 forwards payload"; + } + if (callerIndex == 2) { + return "delegate fallback - success - approved caller slot 2 forwards payload"; + } + if (callerIndex == 3) { + return "delegate fallback - success - approved caller slot 3 forwards payload"; + } + return "delegate fallback - success - approved caller slot 4 forwards payload"; + } +} diff --git a/test/dependencies/settlement/IGPv2Authenticator.sol b/test/dependencies/settlement/IGPv2Authenticator.sol new file mode 100644 index 0000000..4785cf0 --- /dev/null +++ b/test/dependencies/settlement/IGPv2Authenticator.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity ^0.8.34; + +interface IGPv2Authenticator { + function manager() external view returns (address); + + function addSolver(address solver) external; + + function removeSolver(address solver) external; + + function isSolver(address prospectiveSolver) external view returns (bool); +} diff --git a/test/dependencies/settlement/IGPv2Settlement.sol b/test/dependencies/settlement/IGPv2Settlement.sol new file mode 100644 index 0000000..62c8419 --- /dev/null +++ b/test/dependencies/settlement/IGPv2Settlement.sol @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +// solhint-disable gas-struct-packing +pragma solidity ^0.8.34; + +import {IGPv2Authenticator} from "test/dependencies/settlement/IGPv2Authenticator.sol"; + +interface IGPv2Settlement { + struct Trade { + uint256 sellTokenIndex; + uint256 buyTokenIndex; + address receiver; + uint256 sellAmount; + uint256 buyAmount; + uint32 validTo; + bytes32 appData; + uint256 feeAmount; + uint256 flags; + uint256 executedAmount; + bytes signature; + } + + struct Interaction { + address target; + uint256 value; + bytes callData; + } + + event Settlement(address indexed solver); + + function authenticator() external view returns (IGPv2Authenticator); + + function vaultRelayer() external view returns (address); + + function settle( + address[] calldata tokens, + uint256[] calldata clearingPrices, + Trade[] calldata trades, + Interaction[][3] calldata interactions + ) external; +} diff --git a/test/mocks/MockGPv2Authenticator.sol b/test/mocks/MockGPv2Authenticator.sol new file mode 100644 index 0000000..f2509be --- /dev/null +++ b/test/mocks/MockGPv2Authenticator.sol @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity ^0.8.34; + +import {IGPv2Authenticator} from "test/dependencies/settlement/IGPv2Authenticator.sol"; + +/// @notice Minimal GPv2 authenticator for tests. +/// @dev Lets tests add or remove solver addresses without pulling in the production authenticator. +contract MockGPv2Authenticator is IGPv2Authenticator { + address public immutable MANAGER; + + mapping(address solver => bool allowed) public solvers; + + constructor(address manager_) { + MANAGER = manager_; + } + + /// @notice Marks `solver` as allowed to submit settlements. + function addSolver(address solver) external { + solvers[solver] = true; + } + + /// @notice Removes `solver` from the allowlist. + function removeSolver(address solver) external { + solvers[solver] = false; + } + + /// @notice Returns whether `prospectiveSolver` is currently allowed. + function isSolver(address prospectiveSolver) external view returns (bool) { + return solvers[prospectiveSolver]; + } + + /// @notice Returns the mock manager address used by fork tests. + function manager() external view returns (address) { + return MANAGER; + } +} diff --git a/test/mocks/MockGPv2Settlement.sol b/test/mocks/MockGPv2Settlement.sol new file mode 100644 index 0000000..c3a0fe3 --- /dev/null +++ b/test/mocks/MockGPv2Settlement.sol @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +// solhint-disable avoid-low-level-calls +pragma solidity ^0.8.34; + +import {IGPv2Authenticator} from "test/dependencies/settlement/IGPv2Authenticator.sol"; +import {IGPv2Settlement} from "test/dependencies/settlement/IGPv2Settlement.sol"; + +/// @notice Minimal GPv2 settlement target for integration tests. +/// @dev Checks the solver allowlist, records settlement shape, and executes interactions so delegate tests cover +/// settlement-style payloads without depending on the real mainnet contract. +contract MockGPv2Settlement is IGPv2Settlement { + error ClearingPriceLengthMismatch(); + error NotSolver(address sender); + + IGPv2Authenticator public immutable AUTHENTICATOR; + address public immutable VAULT_RELAYER; + + address public lastSender; + bytes32 public lastPayloadHash; + uint256 public lastTokenCount; + uint256 public lastTradeCount; + uint256 public lastInteractionCount; + + constructor(IGPv2Authenticator authenticator_, address vaultRelayer_) { + AUTHENTICATOR = authenticator_; + VAULT_RELAYER = vaultRelayer_; + } + + /// @notice Allows tests to fund this mock before settlement interactions forward ETH. + receive() external payable {} + + /// @notice Records a settlement submission from an allowlisted solver. + /// @dev Reverts with interaction returndata unchanged to match the production bubbling behavior tests need. + function settle( + address[] calldata tokens, + uint256[] calldata clearingPrices, + Trade[] calldata trades, + Interaction[][3] calldata interactions + ) external { + if (!AUTHENTICATOR.isSolver(msg.sender)) { + revert NotSolver(msg.sender); + } + if (tokens.length != clearingPrices.length) { + revert ClearingPriceLengthMismatch(); + } + + uint256 interactionCount; + for (uint256 phase; phase < interactions.length; ++phase) { + interactionCount += interactions[phase].length; + for (uint256 i; i < interactions[phase].length; ++i) { + Interaction calldata interaction = interactions[phase][i]; + (bool success, bytes memory returnData) = + interaction.target.call{value: interaction.value}(interaction.callData); + if (!success) { + assembly { + revert(add(returnData, 0x20), mload(returnData)) + } + } + } + } + + lastSender = msg.sender; + lastPayloadHash = keccak256(abi.encode(tokens, clearingPrices, trades, interactions)); + lastTokenCount = tokens.length; + lastTradeCount = trades.length; + lastInteractionCount = interactionCount; + + emit Settlement(msg.sender); + } + + /// @notice Returns the authenticator used to decide whether `msg.sender` is a solver. + function authenticator() external view returns (IGPv2Authenticator) { + return AUTHENTICATOR; + } + + /// @notice Returns the vault relayer address used by GPv2 forbidden-interaction tests. + function vaultRelayer() external view returns (address) { + return VAULT_RELAYER; + } +} diff --git a/test/mocks/MockSettlement.sol b/test/mocks/MockSettlement.sol new file mode 100644 index 0000000..a1ff22f --- /dev/null +++ b/test/mocks/MockSettlement.sol @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity ^0.8.34; + +/// @notice Simple payable settlement target for delegate integration tests. +/// @dev Records sender, value, and payload data so tests can prove calls execute from the delegated solver account. +contract MockSettlement { + uint256 public callCount; + address public lastSender; + uint256 public lastValue; + bytes32 public lastOrderUid; + uint256 public lastAmount; + bytes32 public lastPayloadHash; + uint256 public lastPayloadLength; + + /// @notice Records a small settlement-like call and returns its order id. + function settle(bytes32 orderUid, uint256 amount) external payable returns (bytes32) { + ++callCount; + lastSender = msg.sender; + lastValue = msg.value; + lastOrderUid = orderUid; + lastAmount = amount; + + return orderUid; + } + + /// @notice Records a variable-size payload and returns its hash. + function settlePayload(bytes calldata settlementPayload) external payable returns (bytes32) { + ++callCount; + lastSender = msg.sender; + lastValue = msg.value; + lastPayloadHash = keccak256(settlementPayload); + lastPayloadLength = settlementPayload.length; + + return lastPayloadHash; + } +} diff --git a/test/mocks/targets/NonpayableTarget.sol b/test/mocks/targets/NonpayableTarget.sol new file mode 100644 index 0000000..0050f76 --- /dev/null +++ b/test/mocks/targets/NonpayableTarget.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity ^0.8.34; + +/// @notice Nonpayable target used to test value forwarding into a rejecting target. +/// @dev Calls with ETH fail before fallback body execution and return empty revert data. +contract NonpayableTarget { + event Called(bytes payload); + + /// @notice Emits payload when called without ETH. + fallback() external { + emit Called(msg.data); + } +} diff --git a/test/mocks/targets/PayableFallbackTarget.sol b/test/mocks/targets/PayableFallbackTarget.sol new file mode 100644 index 0000000..8c3fec5 --- /dev/null +++ b/test/mocks/targets/PayableFallbackTarget.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity ^0.8.34; + +/// @notice Payable target that records fallback and receive calls. +/// @dev Used to prove that Solver7702Delegate forwards sender context, ETH value, and payload bytes unchanged. +contract PayableFallbackTarget { + uint256 private callCount; + + /// @notice Counts plain ETH receives with empty calldata. + receive() external payable { + ++callCount; + } + + /// @notice Returns observed call data so tests can assert exact forwarding. + fallback() external payable { + ++callCount; + + bytes memory returnData = abi.encode(msg.sender, msg.value, msg.data, address(this).balance); + assembly { + return(add(returnData, 0x20), mload(returnData)) + } + } + + /// @notice Returns `rawReturnData` as the complete returndata payload. + function returnRaw(bytes calldata rawReturnData) external payable { + bytes memory returnData = rawReturnData; + assembly { + return(add(returnData, 0x20), mload(returnData)) + } + } +} diff --git a/test/mocks/targets/RawRevertTarget.sol b/test/mocks/targets/RawRevertTarget.sol new file mode 100644 index 0000000..df692eb --- /dev/null +++ b/test/mocks/targets/RawRevertTarget.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity ^0.8.34; + +/// @notice Target that reverts with arbitrary bytes without ABI wrapping. +/// @dev Used to prove that Solver7702Delegate bubbles revert data exactly, including empty data. +contract RawRevertTarget { + /// @notice Reverts with `rawRevertData` as the complete revert payload. + function revertRaw(bytes calldata rawRevertData) external payable { + bytes memory revertData = rawRevertData; + assembly { + revert(add(revertData, 0x20), mload(revertData)) + } + } +} diff --git a/test/mocks/targets/RevertingTarget.sol b/test/mocks/targets/RevertingTarget.sol new file mode 100644 index 0000000..4d69043 --- /dev/null +++ b/test/mocks/targets/RevertingTarget.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity ^0.8.34; + +/// @notice Target with normal Solidity revert shapes. +/// @dev Used to cover custom errors, revert strings, panic data, and empty revert data. +contract RevertingTarget { + error TargetCustomError(address sender, uint256 value, bytes payload); + + /// @notice Reverts with a typed custom error carrying sender, value, and payload. + function revertWithCustomError(uint256 value, bytes calldata payload) external payable { + revert TargetCustomError(msg.sender, value, payload); + } + + /// @notice Reverts with the standard `Error(string)` ABI payload. + function revertWithString(string calldata reason) external pure { + revert(reason); + } + + /// @notice Triggers Solidity panic code 0x11 for arithmetic underflow. + function revertWithPanic() external pure { + uint256 value; + --value; + } + + /// @notice Reverts with no data. + function revertWithoutData() external pure { + assembly { + revert(0, 0) + } + } +} diff --git a/test/utils/MathUtils.sol b/test/utils/MathUtils.sol new file mode 100644 index 0000000..dce6e0b --- /dev/null +++ b/test/utils/MathUtils.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity ^0.8.34; + +/// @notice Small math helpers for tests. +library MathUtils { + /// @notice Returns `left - right`, or zero when `right` is larger. + function nonNegativeDelta(uint256 left, uint256 right) internal pure returns (uint256) { + if (left < right) { + return 0; + } + + return left - right; + } +} diff --git a/test/utils/SettlementUtils.sol b/test/utils/SettlementUtils.sol new file mode 100644 index 0000000..f7da481 --- /dev/null +++ b/test/utils/SettlementUtils.sol @@ -0,0 +1,211 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity ^0.8.34; + +import {IGPv2Settlement} from "test/dependencies/settlement/IGPv2Settlement.sol"; + +/// @notice Helpers for building mock and real GPv2 settlement calldata in tests. +library SettlementUtils { + /// @notice Selector for MockInteraction(uint256). + bytes4 private constant MOCK_INTERACTION_SELECTOR = bytes4(keccak256("mockInteraction(uint256)")); + + /// @notice Builds calldata for a mock GPv2 settlement with the given shape. + function gpv2SettlementCalldata( + uint256 tokenCount, + uint256 tradeCount, + uint256 interactionCount, + address recipient, + address fallbackTarget + ) internal pure returns (bytes memory payload, bytes32 payloadHash) { + return gpv2SettlementCalldata( + tokenCount, tradeCount, [interactionCount, uint256(0), uint256(0)], recipient, fallbackTarget + ); + } + + /// @notice Builds calldata for a mock GPv2 settlement with interactions split by phase. + function gpv2SettlementCalldata( + uint256 tokenCount, + uint256 tradeCount, + uint256[3] memory interactionCounts, + address recipient, + address fallbackTarget + ) internal pure returns (bytes memory payload, bytes32 payloadHash) { + ( + address[] memory tokens, + uint256[] memory clearingPrices, + IGPv2Settlement.Trade[] memory trades, + IGPv2Settlement.Interaction[][3] memory interactions + ) = gpv2SettlementData(tokenCount, tradeCount, interactionCounts, recipient, fallbackTarget); + + payload = abi.encodeCall(IGPv2Settlement.settle, (tokens, clearingPrices, trades, interactions)); + payloadHash = keccak256(abi.encode(tokens, clearingPrices, trades, interactions)); + } + + /// @notice Builds malformed calldata with one fewer clearing price than token. + function gpv2SettlementCalldataWithMissingClearingPrice( + uint256 tokenCount, + uint256 tradeCount, + address recipient, + address fallbackTarget + ) internal pure returns (bytes memory payload) { + uint256 clearingPriceCount = tokenCount == 0 ? 0 : tokenCount - 1; + (address[] memory tokens,) = gpv2Tokens(tokenCount); + (, uint256[] memory clearingPrices) = gpv2Tokens(clearingPriceCount); + IGPv2Settlement.Trade[] memory trades = gpv2Trades(tradeCount, tokenCount, recipient); + IGPv2Settlement.Interaction[][3] memory interactions = + gpv2Interactions([uint256(0), uint256(0), uint256(0)], fallbackTarget); + + payload = abi.encodeCall(IGPv2Settlement.settle, (tokens, clearingPrices, trades, interactions)); + } + + /// @notice Builds fork-test calldata for the real GPv2 settlement contract using the configured token pair. + function realGPv2SettleCalldata(address usdc, address weth, address forbiddenTarget) + internal + pure + returns (bytes memory) + { + address[] memory tokens = new address[](2); + uint256[] memory clearingPrices = new uint256[](2); + IGPv2Settlement.Trade[] memory trades = new IGPv2Settlement.Trade[](0); + IGPv2Settlement.Interaction[][3] memory interactions; + + tokens[0] = usdc; + tokens[1] = weth; + clearingPrices[0] = 1 ether; + clearingPrices[1] = 1 ether; + + if (forbiddenTarget == address(0)) { + interactions[0] = new IGPv2Settlement.Interaction[](0); + } else { + interactions[0] = new IGPv2Settlement.Interaction[](1); + interactions[0][0] = IGPv2Settlement.Interaction({target: forbiddenTarget, value: 0, callData: ""}); + } + interactions[1] = new IGPv2Settlement.Interaction[](0); + interactions[2] = new IGPv2Settlement.Interaction[](0); + + return abi.encodeCall(IGPv2Settlement.settle, (tokens, clearingPrices, trades, interactions)); + } + + /// @notice Builds fork-test calldata for a simple WETH -> USDC trade attempt on the real GPv2 settlement. + function realGPv2WethForUsdcOrderCalldata(address usdc, address weth, address owner) + internal + pure + returns (bytes memory) + { + address[] memory tokens = new address[](2); + uint256[] memory clearingPrices = new uint256[](2); + IGPv2Settlement.Trade[] memory trades = new IGPv2Settlement.Trade[](1); + IGPv2Settlement.Interaction[][3] memory interactions; + + tokens[0] = weth; + tokens[1] = usdc; + clearingPrices[0] = 1 ether; + clearingPrices[1] = 3000 * 1 ether; + + trades[0] = IGPv2Settlement.Trade({ + sellTokenIndex: 0, + buyTokenIndex: 1, + receiver: owner, + sellAmount: 1 ether, + buyAmount: 3000e6, + validTo: 4_102_444_800, + appData: bytes32(0), + feeAmount: 0, + flags: 0, + executedAmount: 1 ether, + signature: abi.encodePacked(owner) + }); + + interactions[0] = new IGPv2Settlement.Interaction[](0); + interactions[1] = new IGPv2Settlement.Interaction[](0); + interactions[2] = new IGPv2Settlement.Interaction[](0); + + return abi.encodeCall(IGPv2Settlement.settle, (tokens, clearingPrices, trades, interactions)); + } + + /// @notice Builds the mock GPv2 settlement data tuple. + function gpv2SettlementData( + uint256 tokenCount, + uint256 tradeCount, + uint256[3] memory interactionCounts, + address recipient, + address fallbackTarget + ) + private + pure + returns ( + address[] memory tokens, + uint256[] memory clearingPrices, + IGPv2Settlement.Trade[] memory trades, + IGPv2Settlement.Interaction[][3] memory interactions + ) + { + (tokens, clearingPrices) = gpv2Tokens(tokenCount); + trades = gpv2Trades(tradeCount, tokenCount, recipient); + interactions = gpv2Interactions(interactionCounts, fallbackTarget); + } + + /// @notice Builds deterministic mock token addresses and clearing prices. + function gpv2Tokens(uint256 tokenCount) + private + pure + returns (address[] memory tokens, uint256[] memory clearingPrices) + { + tokens = new address[](tokenCount); + clearingPrices = new uint256[](tokenCount); + for (uint256 i; i < tokenCount; ++i) { + tokens[i] = address(uint160(uint256(keccak256(abi.encode("token", i))))); + clearingPrices[i] = 1 ether + i; + } + } + + /// @notice Builds deterministic mock trades. + function gpv2Trades(uint256 tradeCount, uint256 tokenCount, address recipient) + private + pure + returns (IGPv2Settlement.Trade[] memory trades) + { + trades = new IGPv2Settlement.Trade[](tradeCount); + for (uint256 i; i < tradeCount; ++i) { + trades[i] = IGPv2Settlement.Trade({ + sellTokenIndex: i % tokenCount, + buyTokenIndex: (i + 1) % tokenCount, + receiver: recipient, + sellAmount: 10 ether + i, + buyAmount: 9 ether + i, + validTo: 4_102_444_800, + appData: keccak256(abi.encode("appData", i)), + feeAmount: i, + flags: 0, + executedAmount: 0, + signature: abi.encodePacked(bytes32(i)) + }); + } + } + + /// @notice Builds the pre, intra, and post settlement interaction lists. + function gpv2Interactions(uint256[3] memory interactionCounts, address fallbackTarget) + private + pure + returns (IGPv2Settlement.Interaction[][3] memory interactions) + { + uint256 interactionIndex; + for (uint256 phase; phase < interactions.length; ++phase) { + interactions[phase] = new IGPv2Settlement.Interaction[](interactionCounts[phase]); + for (uint256 i; i < interactionCounts[phase]; ++i) { + interactions[phase][i] = gpv2Interaction(interactionIndex, fallbackTarget); + ++interactionIndex; + } + } + } + + /// @notice Builds one mock GPv2 interaction. + function gpv2Interaction(uint256 index, address fallbackTarget) + private + pure + returns (IGPv2Settlement.Interaction memory interaction) + { + interaction = IGPv2Settlement.Interaction({ + target: fallbackTarget, value: 0, callData: abi.encodeWithSelector(MOCK_INTERACTION_SELECTOR, index) + }); + } +}