From 9377dae51925e59415a6ebe7fd580ef4ee0b3b5b Mon Sep 17 00:00:00 2001 From: ross <92001561+z0r0z@users.noreply.github.com> Date: Thu, 16 Apr 2026 16:45:35 +0000 Subject: [PATCH 1/2] harden Swapboard for deployment: audit fixes, batch cancel, enriched events - Zero out amountA/amountB on full fill for clean state and storage refunds - Add early active checks in fillOrderWithEth and fillOrderUnwrap for correct error messages - Extract _emitFillEvent helper to eliminate duplicated emit logic across 3 fill paths - Add cancelOrders and cancelOrdersUnwrap for batch cancel support - Extract _cancelOrder and _cancelOrderUnwrap internals for DRY batch/single paths - Enrich OrderFilled and OrderPartiallyFilled events with maker, tokenA, tokenB for indexer parity - Enrich OrderCanceled event with maker, tokenA, amountA - Pin Solidity pragma to ^0.8.34 (avoids transient storage clearing bug in <=0.8.33) - Bump optimizer runs to 9,999,999 for maximum runtime gas efficiency - Add .claude-skills/ to gitignore - Add 22 new tests covering batch cancel, zeroed state, early active checks, and enriched events Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 1 + contracts/.gas-snapshot | 289 +++-- contracts/foundry.toml | 5 +- contracts/lib/forge-std | 2 +- contracts/src/Swapboard.sol | 426 +++++-- contracts/src/interfaces/ISwapboard.sol | 194 ++- contracts/src/interfaces/IWETH.sol | 2 +- contracts/test/Swapboard.integration.t.sol | 76 +- contracts/test/Swapboard.t.sol | 1073 ++++++++++++++++- contracts/test/SwapboardETH.t.sol | 757 +++++++++++- contracts/test/exploit/ExploitVectors.t.sol | 90 +- contracts/test/invariant/Handler.sol | 5 +- contracts/test/invariant/Invariants.t.sol | 24 +- contracts/test/mocks/ReentrantAttacker.sol | 2 +- .../security-research/ProvenExploits.t.sol | 27 +- 15 files changed, 2519 insertions(+), 454 deletions(-) diff --git a/.gitignore b/.gitignore index dd03d33..4359af6 100644 --- a/.gitignore +++ b/.gitignore @@ -60,6 +60,7 @@ image.png # Claude settings .claude/ +.claude-skills/ spec.md CLAUDE.md diff --git a/contracts/.gas-snapshot b/contracts/.gas-snapshot index 02cf76d..7279901 100644 --- a/contracts/.gas-snapshot +++ b/contracts/.gas-snapshot @@ -1,83 +1,102 @@ -AdvancedExploitTests:test_EXPLOIT_negativeRebase_fundsLocked() (gas: 972549) -AdvancedExploitTests:test_EXPLOIT_phantomTokenB_freeTokenA() (gas: 686007) -AdvancedExploitTests:test_EXPLOIT_rebaseToken_unfairDistribution() (gas: 1265242) -AdvancedExploitTests:test_EXPLOIT_upgradeableToken_becomeFOT() (gas: 997494) -AdvancedExploitTests:test_multipleOrdersSameTokenPair() (gas: 905011) -ExploitVectorTests:test_edgeCase_manyOrders_sequentialIds() (gas: 14613986) -ExploitVectorTests:test_edgeCase_maxUint256() (gas: 374205) -ExploitVectorTests:test_edgeCase_precompileAddresses() (gas: 97260) -ExploitVectorTests:test_edgeCase_selfTrade() (gas: 297151) -ExploitVectorTests:test_exploit_accessControl_cancelByNonMaker() (gas: 251173) -ExploitVectorTests:test_exploit_blacklistToken_blocksBlacklistedUser() (gas: 966015) -ExploitVectorTests:test_exploit_dos_getOrders_largeArray() (gas: 17178114) -ExploitVectorTests:test_exploit_doubleCancel() (gas: 251744) -ExploitVectorTests:test_exploit_doubleFill() (gas: 337984) -ExploitVectorTests:test_exploit_fot_createOrder_rejected() (gas: 684853) -ExploitVectorTests:test_exploit_fot_fillOrder_makerReceivesLess() (gas: 933762) -ExploitVectorTests:test_exploit_griefing_dustOrders() (gas: 14751813) -ExploitVectorTests:test_exploit_pausableToken_blocksOperations() (gas: 854821) -ExploitVectorTests:test_exploit_rebaseToken_positiveRebase() (gas: 940505) -ExploitVectorTests:test_exploit_reentrancy_cancelOrder() (gas: 1054985) -ExploitVectorTests:test_exploit_reentrancy_fillOrder_tokenA() (gas: 1155488) -GasBenchmarks:test_gas_canFill() (gas: 219826) -GasBenchmarks:test_gas_cancelOrder() (gas: 183313) -GasBenchmarks:test_gas_createOrder() (gas: 218799) -GasBenchmarks:test_gas_fillOrder() (gas: 242914) -GasBenchmarks:test_gas_getOrder() (gas: 221481) -GasBenchmarks:test_gas_getOrders_10() (gas: 1547953) -GasBenchmarks:test_gas_getOrders_100() (gas: 14805917) -SwapboardETHTest:testFuzz_cancelOrderUnwrap(uint256) (runs: 256, μ: 234224, ~: 234010) -SwapboardETHTest:testFuzz_createOrderWithEth(uint256,uint256) (runs: 256, μ: 248124, ~: 248223) -SwapboardETHTest:testFuzz_fillOrderUnwrap(uint256,uint256) (runs: 257, μ: 254183, ~: 254242) -SwapboardETHTest:testFuzz_fillOrderWithEth(uint256,uint256) (runs: 257, μ: 284459, ~: 284522) -SwapboardETHTest:test_cancelOrderUnwrap() (gas: 218146) -SwapboardETHTest:test_cancelOrderUnwrap_event() (gas: 214314) -SwapboardETHTest:test_cancelOrderUnwrap_revert_ethTransferFailed() (gas: 269832) -SwapboardETHTest:test_cancelOrderUnwrap_revert_notMaker() (gas: 248239) -SwapboardETHTest:test_cancelOrderUnwrap_revert_notWETH() (gas: 225530) -SwapboardETHTest:test_cancelOrderUnwrap_revert_orderNotActive() (gas: 217339) -SwapboardETHTest:test_cancelOrderUnwrap_revert_orderNotFound() (gas: 18427) -SwapboardETHTest:test_createNormal_fillWithEth() (gas: 276747) -SwapboardETHTest:test_createOrderWithEth() (gas: 246826) -SwapboardETHTest:test_createOrderWithEth_event() (gas: 245663) -SwapboardETHTest:test_createOrderWithEth_revert_notAContract() (gas: 25874) -SwapboardETHTest:test_createOrderWithEth_revert_sameToken() (gas: 24977) -SwapboardETHTest:test_createOrderWithEth_revert_zeroAddress() (gas: 22765) -SwapboardETHTest:test_createOrderWithEth_revert_zeroAmount() (gas: 24895) -SwapboardETHTest:test_createOrderWithEth_revert_zeroETH() (gas: 18161) -SwapboardETHTest:test_createOrderWithEth_sequentialIds() (gas: 396681) -SwapboardETHTest:test_createOrderWithEth_wethBalance() (gas: 244733) -SwapboardETHTest:test_createWithEth_cancelNormal() (gas: 233946) -SwapboardETHTest:test_createWithEth_cancelUnwrap() (gas: 216213) -SwapboardETHTest:test_createWithEth_fillNormal() (gas: 255734) -SwapboardETHTest:test_createWithEth_fillUnwrap() (gas: 246987) -SwapboardETHTest:test_fillOrderUnwrap() (gas: 251203) -SwapboardETHTest:test_fillOrderUnwrap_event() (gas: 245699) -SwapboardETHTest:test_fillOrderUnwrap_revert_ethTransferFailed() (gas: 314583) -SwapboardETHTest:test_fillOrderUnwrap_revert_notWETH() (gas: 229399) -SwapboardETHTest:test_fillOrderUnwrap_revert_orderNotActive() (gas: 248409) -SwapboardETHTest:test_fillOrderUnwrap_revert_orderNotFound() (gas: 18449) -SwapboardETHTest:test_fillOrderWithEth() (gas: 278899) -SwapboardETHTest:test_fillOrderWithEth_event() (gas: 274580) -SwapboardETHTest:test_fillOrderWithEth_revert_amountMismatch_tooHigh() (gas: 234049) -SwapboardETHTest:test_fillOrderWithEth_revert_amountMismatch_tooLow() (gas: 234070) -SwapboardETHTest:test_fillOrderWithEth_revert_notWETH() (gas: 236061) -SwapboardETHTest:test_fillOrderWithEth_revert_orderNotActive() (gas: 282549) -SwapboardETHTest:test_fillOrderWithEth_revert_orderNotFound() (gas: 25078) -SwapboardETHTest:test_multipleETHOrders() (gas: 591818) -SwapboardETHTest:test_receive_revert_nonWETH() (gas: 21574) -SwapboardIntegrationTest:test_batchOperations() (gas: 1454621) -SwapboardIntegrationTest:test_differentDecimalTokens() (gas: 263872) -SwapboardIntegrationTest:test_dustAmounts() (gas: 236545) -SwapboardIntegrationTest:test_eventSequence() (gas: 238948) -SwapboardIntegrationTest:test_getOrdersWithNonExistent() (gas: 400539) -SwapboardIntegrationTest:test_largeAmounts() (gas: 244470) -SwapboardIntegrationTest:test_multipleUsersMultipleOrders() (gas: 867509) -SwapboardIntegrationTest:test_orderLifecycle_createCancel() (gas: 204070) -SwapboardIntegrationTest:test_orderLifecycle_createFill() (gas: 241619) -SwapboardIntegrationTest:test_raceCondition_fillAndCancel() (gas: 238986) -SwapboardIntegrationTest:test_raceCondition_twoFillersOneOrder() (gas: 264573) -SwapboardIntegrationTest:test_stressTest_manyOrders() (gas: 13680565) +ExploitVectorTests:test_edgeCase_manyOrders_sequentialIds() (gas: 12431745) +ExploitVectorTests:test_edgeCase_maxUint256() (gas: 370625) +ExploitVectorTests:test_edgeCase_precompileAddresses() (gas: 55232) +ExploitVectorTests:test_edgeCase_selfTrade() (gas: 287924) +ExploitVectorTests:test_exploit_accessControl_cancelByNonMaker() (gas: 224363) +ExploitVectorTests:test_exploit_blacklistToken_blocksBlacklistedUser() (gas: 979735) +ExploitVectorTests:test_exploit_dos_getOrders_largeArray() (gas: 15432755) +ExploitVectorTests:test_exploit_doubleCancel() (gas: 227835) +ExploitVectorTests:test_exploit_doubleFill() (gas: 331813) +ExploitVectorTests:test_exploit_fot_createOrder_rejected() (gas: 680182) +ExploitVectorTests:test_exploit_fot_fillOrder_makerReceivesLess() (gas: 930179) +ExploitVectorTests:test_exploit_griefing_dustOrders() (gas: 12589439) +ExploitVectorTests:test_exploit_pausableToken_blocksOperations() (gas: 885397) +ExploitVectorTests:test_exploit_rebaseToken_positiveRebase() (gas: 936922) +ExploitVectorTests:test_exploit_reentrancy_cancelOrder() (gas: 1053541) +ExploitVectorTests:test_exploit_reentrancy_fillOrder_tokenA() (gas: 1154824) +GasBenchmarks:test_gas_canFill() (gas: 196051) +GasBenchmarks:test_gas_cancelOrder() (gas: 179231) +GasBenchmarks:test_gas_createOrder() (gas: 194993) +GasBenchmarks:test_gas_fillOrder() (gas: 239331) +GasBenchmarks:test_gas_getOrder() (gas: 197745) +GasBenchmarks:test_gas_getOrders_10() (gas: 1329000) +GasBenchmarks:test_gas_getOrders_100() (gas: 12637240) +ProvenExploitTests:test_EXPLOIT_negativeRebase_fundsLocked() (gas: 982906) +ProvenExploitTests:test_EXPLOIT_phantomTokenB_freeTokenA() (gas: 682424) +ProvenExploitTests:test_EXPLOIT_rebaseToken_unfairDistribution() (gas: 1260076) +ProvenExploitTests:test_EXPLOIT_upgradeableToken_becomeFOT() (gas: 992896) +ProvenExploitTests:test_multipleOrdersSameTokenPair() (gas: 895161) +SwapboardETHTest:testFuzz_cancelOrderUnwrap(uint256) (runs: 256, μ: 228487, ~: 228194) +SwapboardETHTest:testFuzz_createOrderWithEth(uint256,uint256) (runs: 256, μ: 224352, ~: 224444) +SwapboardETHTest:testFuzz_fillOrderUnwrap(uint256,uint256) (runs: 256, μ: 231314, ~: 231406) +SwapboardETHTest:testFuzz_fillOrderUnwrap_partial(uint256,uint256,uint256) (runs: 257, μ: 270050, ~: 273837) +SwapboardETHTest:testFuzz_fillOrderWithEth(uint256,uint256) (runs: 256, μ: 269606, ~: 269718) +SwapboardETHTest:testFuzz_fillOrderWithEth_partial(uint256,uint256,uint256) (runs: 256, μ: 275496, ~: 292053) +SwapboardETHTest:test_cancelOrderUnwrap() (gas: 208403) +SwapboardETHTest:test_cancelOrderUnwrap_event() (gas: 203492) +SwapboardETHTest:test_cancelOrderUnwrap_revert_ethTransferFailed() (gas: 262489) +SwapboardETHTest:test_cancelOrderUnwrap_revert_notMaker() (gas: 221428) +SwapboardETHTest:test_cancelOrderUnwrap_revert_notWETH() (gas: 198805) +SwapboardETHTest:test_cancelOrderUnwrap_revert_orderNotActive() (gas: 204489) +SwapboardETHTest:test_cancelOrderUnwrap_revert_orderNotFound() (gas: 13733) +SwapboardETHTest:test_createNormal_fillWithEth() (gas: 262621) +SwapboardETHTest:test_createOrderWithEth() (gas: 223012) +SwapboardETHTest:test_createOrderWithEth_event() (gas: 222137) +SwapboardETHTest:test_createOrderWithEth_partialFillFlag() (gas: 221514) +SwapboardETHTest:test_createOrderWithEth_revert_notAContract() (gas: 21211) +SwapboardETHTest:test_createOrderWithEth_revert_sameToken() (gas: 20338) +SwapboardETHTest:test_createOrderWithEth_revert_zeroAddress() (gas: 18131) +SwapboardETHTest:test_createOrderWithEth_revert_zeroAmount() (gas: 20261) +SwapboardETHTest:test_createOrderWithEth_revert_zeroETH() (gas: 13530) +SwapboardETHTest:test_createOrderWithEth_sequentialIds() (gas: 351040) +SwapboardETHTest:test_createOrderWithEth_wethBalance() (gas: 220890) +SwapboardETHTest:test_createWithEth_cancelNormal() (gas: 229801) +SwapboardETHTest:test_createWithEth_cancelUnwrap() (gas: 205869) +SwapboardETHTest:test_createWithEth_fillNormal() (gas: 252117) +SwapboardETHTest:test_createWithEth_fillUnwrap() (gas: 224977) +SwapboardETHTest:test_fillOrderUnwrap() (gas: 230275) +SwapboardETHTest:test_fillOrderUnwrap_event() (gas: 223396) +SwapboardETHTest:test_fillOrderUnwrap_fullFillViaZero() (gas: 224599) +SwapboardETHTest:test_fillOrderUnwrap_partialFill() (gas: 266741) +SwapboardETHTest:test_fillOrderUnwrap_partialFill_event() (gas: 266535) +SwapboardETHTest:test_fillOrderUnwrap_partialFill_thenCancel() (gas: 254519) +SwapboardETHTest:test_fillOrderUnwrap_revert_deadlineExpired() (gas: 246396) +SwapboardETHTest:test_fillOrderUnwrap_revert_ethTransferFailed() (gas: 291012) +SwapboardETHTest:test_fillOrderUnwrap_revert_notWETH() (gas: 202837) +SwapboardETHTest:test_fillOrderUnwrap_revert_orderNotActive() (gas: 224369) +SwapboardETHTest:test_fillOrderUnwrap_revert_orderNotFound() (gas: 13857) +SwapboardETHTest:test_fillOrderUnwrap_revert_partialNotAllowed() (gas: 247439) +SwapboardETHTest:test_fillOrderWithEth() (gas: 265376) +SwapboardETHTest:test_fillOrderWithEth_event() (gas: 259921) +SwapboardETHTest:test_fillOrderWithEth_fullFillOnPartialOrder() (gas: 264156) +SwapboardETHTest:test_fillOrderWithEth_partialFill() (gas: 286431) +SwapboardETHTest:test_fillOrderWithEth_partialFill_event() (gas: 283249) +SwapboardETHTest:test_fillOrderWithEth_revert_amountMismatch_tooHigh() (gas: 207565) +SwapboardETHTest:test_fillOrderWithEth_revert_deadlineExpired() (gas: 206723) +SwapboardETHTest:test_fillOrderWithEth_revert_notWETH() (gas: 209546) +SwapboardETHTest:test_fillOrderWithEth_revert_orderNotActive() (gas: 267809) +SwapboardETHTest:test_fillOrderWithEth_revert_orderNotFound() (gas: 20511) +SwapboardETHTest:test_fillOrderWithEth_revert_partialNotAllowed() (gas: 207572) +SwapboardETHTest:test_fillOrderWithEth_revert_zeroValue() (gas: 199675) +SwapboardETHTest:test_fillOrderWithEth_sequentialPartialFills() (gas: 376493) +SwapboardETHTest:test_fillOrderWithEth_zeroValue_cannotDrainWeth() (gas: 383144) +SwapboardETHTest:test_multipleETHOrders() (gas: 563900) +SwapboardETHTest:test_partialFill_thenCancelUnwrap() (gas: 280838) +SwapboardETHTest:test_partialFill_thenFillOrderUnwrap() (gas: 286560) +SwapboardETHTest:test_partialFill_thenFillOrderWithEth() (gas: 312752) +SwapboardETHTest:test_receive_revert_nonWETH() (gas: 21575) +SwapboardIntegrationTest:test_batchOperations() (gas: 1435403) +SwapboardIntegrationTest:test_differentDecimalTokens() (gas: 246325) +SwapboardIntegrationTest:test_dustAmounts() (gas: 213281) +SwapboardIntegrationTest:test_eventSequence() (gas: 215905) +SwapboardIntegrationTest:test_getOrdersWithNonExistent() (gas: 351389) +SwapboardIntegrationTest:test_largeAmounts() (gas: 221262) +SwapboardIntegrationTest:test_multipleUsersMultipleOrders() (gas: 818907) +SwapboardIntegrationTest:test_orderLifecycle_createCancel() (gas: 188237) +SwapboardIntegrationTest:test_orderLifecycle_createFill() (gas: 218572) +SwapboardIntegrationTest:test_raceCondition_fillAndCancel() (gas: 213456) +SwapboardIntegrationTest:test_raceCondition_twoFillersOneOrder() (gas: 244611) +SwapboardIntegrationTest:test_stressTest_manyOrders() (gas: 13519823) SwapboardInvariantTest:invariant_activeOrderCount() (runs: 256, calls: 128000, reverts: 0) SwapboardInvariantTest:invariant_balanceEqualsActiveOrderSum() (runs: 256, calls: 128000, reverts: 0) SwapboardInvariantTest:invariant_callSummary() (runs: 256, calls: 128000, reverts: 0) @@ -86,39 +105,77 @@ SwapboardInvariantTest:invariant_noOvercounting() (runs: 256, calls: 128000, rev SwapboardInvariantTest:invariant_nonNegativeBalance() (runs: 256, calls: 128000, reverts: 0) SwapboardInvariantTest:invariant_orderAccounting() (runs: 256, calls: 128000, reverts: 0) SwapboardInvariantTest:invariant_solvency() (runs: 256, calls: 128000, reverts: 0) -SwapboardStatelessInvariantTest:testFuzz_cancelOrder_makerRegainsTokenA(uint256,uint256) (runs: 256, μ: 183479, ~: 183641) -SwapboardStatelessInvariantTest:testFuzz_createOrder_contractBalanceIncrease(uint256,uint256) (runs: 256, μ: 219009, ~: 219171) -SwapboardStatelessInvariantTest:testFuzz_createOrder_makerBalanceDecrease(uint256,uint256) (runs: 256, μ: 218941, ~: 219103) -SwapboardStatelessInvariantTest:testFuzz_fillOrder_makerGainsTokenB(uint256,uint256) (runs: 256, μ: 243107, ~: 243269) -SwapboardStatelessInvariantTest:testFuzz_fillOrder_takerGainsTokenA(uint256,uint256) (runs: 256, μ: 243173, ~: 243335) -SwapboardStatelessInvariantTest:testFuzz_nextOrderId_monotonic(uint256) (runs: 256, μ: 3981354, ~: 3861863) -SwapboardStatelessInvariantTest:testFuzz_orderStateFinal_afterFill(uint256,uint256) (runs: 256, μ: 245763, ~: 245925) -SwapboardTest:testFuzz_createOrder(uint256,uint256) (runs: 256, μ: 228637, ~: 228235) -SwapboardTest:testFuzz_fillOrder(uint256,uint256) (runs: 256, μ: 277660, ~: 277386) -SwapboardTest:test_canFill() (gas: 220596) -SwapboardTest:test_canFill_false_nonExistent() (gas: 7867) -SwapboardTest:test_canFill_false_notActive() (gas: 198027) -SwapboardTest:test_cancelOrder() (gas: 202197) -SwapboardTest:test_cancelOrder_revert_notMaker() (gas: 227767) -SwapboardTest:test_cancelOrder_revert_orderNotActive() (gas: 201055) -SwapboardTest:test_cancelOrder_revert_orderNotFound() (gas: 18791) -SwapboardTest:test_createOrder() (gas: 226581) -SwapboardTest:test_createOrder_revert_FOT() (gas: 665426) -SwapboardTest:test_createOrder_revert_notAContract_tokenA() (gas: 22741) -SwapboardTest:test_createOrder_revert_notAContract_tokenB() (gas: 50573) -SwapboardTest:test_createOrder_revert_sameToken() (gas: 47401) -SwapboardTest:test_createOrder_revert_zeroAddress_tokenA() (gas: 19622) -SwapboardTest:test_createOrder_revert_zeroAddress_tokenB() (gas: 47326) -SwapboardTest:test_createOrder_revert_zeroAmount_amountA() (gas: 49477) -SwapboardTest:test_createOrder_revert_zeroAmount_amountB() (gas: 49456) -SwapboardTest:test_events_orderCanceled() (gas: 198329) -SwapboardTest:test_events_orderCreated() (gas: 222812) -SwapboardTest:test_events_orderFilled() (gas: 263654) -SwapboardTest:test_fillOrder() (gas: 267051) -SwapboardTest:test_fillOrder_revert_orderNotActive() (gas: 265866) -SwapboardTest:test_fillOrder_revert_orderNotFound() (gas: 18734) -SwapboardTest:test_getOrders() (gas: 518715) -SwapboardTest:test_getOrders_empty() (gas: 6539) -SwapboardTest:test_initialState() (gas: 7673) -SwapboardTest:test_multipleOrders() (gas: 510033) -SwapboardTest:test_selfFill() (gas: 262996) \ No newline at end of file +SwapboardStatelessInvariantTest:testFuzz_cancelOrder_makerRegainsTokenA(uint256,uint256) (runs: 256, μ: 179395, ~: 179562) +SwapboardStatelessInvariantTest:testFuzz_createOrder_contractBalanceIncrease(uint256,uint256) (runs: 256, μ: 195201, ~: 195368) +SwapboardStatelessInvariantTest:testFuzz_createOrder_makerBalanceDecrease(uint256,uint256) (runs: 256, μ: 195133, ~: 195300) +SwapboardStatelessInvariantTest:testFuzz_fillOrder_makerGainsTokenB(uint256,uint256) (runs: 256, μ: 239519, ~: 239686) +SwapboardStatelessInvariantTest:testFuzz_fillOrder_takerGainsTokenA(uint256,uint256) (runs: 256, μ: 239585, ~: 239752) +SwapboardStatelessInvariantTest:testFuzz_nextOrderId_monotonic(uint256) (runs: 256, μ: 3341482, ~: 3291696) +SwapboardStatelessInvariantTest:testFuzz_orderStateFinal_afterFill(uint256,uint256) (runs: 256, μ: 242300, ~: 242467) +SwapboardTest:testFuzz_createOrder(uint256,uint256) (runs: 256, μ: 204938, ~: 204535) +SwapboardTest:testFuzz_fillOrder(uint256,uint256) (runs: 256, μ: 258171, ~: 257768) +SwapboardTest:testFuzz_fillOrder_partial(uint256,uint256,uint256) (runs: 256, μ: 277533, ~: 283932) +SwapboardTest:test_canFill() (gas: 196825) +SwapboardTest:test_canFill_false_nonExistent() (gas: 7872) +SwapboardTest:test_canFill_false_notActive() (gas: 180671) +SwapboardTest:test_cancelOrder() (gas: 185920) +SwapboardTest:test_cancelOrder_revert_notMaker() (gas: 200975) +SwapboardTest:test_cancelOrder_revert_orderNotActive() (gas: 181595) +SwapboardTest:test_cancelOrder_revert_orderNotFound() (gas: 14120) +SwapboardTest:test_createOrder() (gas: 202818) +SwapboardTest:test_createOrder_partialFillFlag() (gas: 328098) +SwapboardTest:test_createOrder_revert_FOT() (gas: 660809) +SwapboardTest:test_createOrder_revert_notAContract_tokenA() (gas: 18073) +SwapboardTest:test_createOrder_revert_notAContract_tokenB() (gas: 45928) +SwapboardTest:test_createOrder_revert_sameToken() (gas: 42753) +SwapboardTest:test_createOrder_revert_zeroAddress_tokenA() (gas: 14955) +SwapboardTest:test_createOrder_revert_zeroAddress_tokenB() (gas: 42659) +SwapboardTest:test_createOrder_revert_zeroAmount_amountA() (gas: 44798) +SwapboardTest:test_createOrder_revert_zeroAmount_amountB() (gas: 44833) +SwapboardTest:test_createOrders_atomic_reverts() (gas: 224210) +SwapboardTest:test_createOrders_basic() (gas: 336662) +SwapboardTest:test_createOrders_empty() (gas: 7163) +SwapboardTest:test_createOrders_mixedTokenPairs() (gas: 914554) +SwapboardTest:test_createOrders_thenFillOrders() (gas: 539785) +SwapboardTest:test_events_orderCanceled() (gas: 181019) +SwapboardTest:test_events_orderCreated() (gas: 199331) +SwapboardTest:test_events_orderFilled() (gas: 246100) +SwapboardTest:test_fillOrder() (gas: 250414) +SwapboardTest:test_fillOrder_deadlineZero_noExpiry() (gas: 247206) +SwapboardTest:test_fillOrder_partial_basic() (gas: 271594) +SwapboardTest:test_fillOrder_partial_contractBalanceInvariant() (gas: 422625) +SwapboardTest:test_fillOrder_partial_deadlineZero_noExpiry() (gas: 269146) +SwapboardTest:test_fillOrder_partial_event() (gas: 269358) +SwapboardTest:test_fillOrder_partial_fullFillEmitsOrderFilled() (gas: 246142) +SwapboardTest:test_fillOrder_partial_fullFillOnExactRemaining() (gas: 248092) +SwapboardTest:test_fillOrder_partial_fullFillWhenExceedsRemaining() (gas: 269416) +SwapboardTest:test_fillOrder_partial_largeAmountsNoOverflow() (gas: 282854) +SwapboardTest:test_fillOrder_partial_multipleFillers() (gas: 319605) +SwapboardTest:test_fillOrder_partial_revert_deadlineExpired() (gas: 225831) +SwapboardTest:test_fillOrder_partial_revert_notPartialFillable() (gas: 226559) +SwapboardTest:test_fillOrder_partial_revert_orderNotActive() (gas: 210505) +SwapboardTest:test_fillOrder_partial_revert_orderNotFound() (gas: 14310) +SwapboardTest:test_fillOrder_partial_revert_zeroComputedAmountA() (gas: 226308) +SwapboardTest:test_fillOrder_partial_roundsDownFavoringMaker() (gas: 269852) +SwapboardTest:test_fillOrder_partial_selfFill() (gas: 248228) +SwapboardTest:test_fillOrder_partial_sequentialFillsDrainOrder() (gas: 381478) +SwapboardTest:test_fillOrder_partial_thenCancel() (gas: 258462) +SwapboardTest:test_fillOrder_partial_thenFillOrder() (gas: 316183) +SwapboardTest:test_fillOrder_revert_deadlineExpired() (gas: 225604) +SwapboardTest:test_fillOrder_revert_orderNotActive() (gas: 246286) +SwapboardTest:test_fillOrder_revert_orderNotFound() (gas: 14346) +SwapboardTest:test_fillOrder_worksOnPartialFillableOrder() (gas: 248112) +SwapboardTest:test_fillOrder_zeroFillsRemainderAfterPartial() (gas: 316183) +SwapboardTest:test_fillOrders_atomic_reverts() (gas: 271508) +SwapboardTest:test_fillOrders_basic() (gas: 403619) +SwapboardTest:test_fillOrders_empty() (gas: 7013) +SwapboardTest:test_fillOrders_partialAndFull() (gas: 422890) +SwapboardTest:test_fillOrders_revert_deadlineExpired() (gas: 10084) +SwapboardTest:test_fillOrders_revert_lengthMismatch() (gas: 10052) +SwapboardTest:test_fillOrders_sameOrderTwice() (gas: 291147) +SwapboardTest:test_fillOrders_withPartialFills() (gas: 425131) +SwapboardTest:test_getOrders() (gas: 451776) +SwapboardTest:test_getOrders_empty() (gas: 6659) +SwapboardTest:test_initialState() (gas: 7694) +SwapboardTest:test_multipleOrders() (gas: 442667) +SwapboardTest:test_selfFill() (gas: 239752) \ No newline at end of file diff --git a/contracts/foundry.toml b/contracts/foundry.toml index 2c8a613..f2227d3 100644 --- a/contracts/foundry.toml +++ b/contracts/foundry.toml @@ -2,10 +2,9 @@ src = "src" out = "out" libs = ["lib"] -dotenv = "../.env" -solc = "0.8.33" +solc = "0.8.34" optimizer = true -optimizer_runs = 10000 +optimizer_runs = 9_999_999 via_ir = false evm_version = "cancun" diff --git a/contracts/lib/forge-std b/contracts/lib/forge-std index 1801b05..2999b65 160000 --- a/contracts/lib/forge-std +++ b/contracts/lib/forge-std @@ -1 +1 @@ -Subproject commit 1801b0541f4fda118a10798fd3486bb7051c5dd6 +Subproject commit 2999b6563e1f07971a09d48b82f3fac910d72a05 diff --git a/contracts/src/Swapboard.sol b/contracts/src/Swapboard.sol index 5c54959..4b0ca83 100644 --- a/contracts/src/Swapboard.sol +++ b/contracts/src/Swapboard.sol @@ -1,25 +1,29 @@ // SPDX-License-Identifier: AGPL-3.0-only -pragma solidity ^0.8.33; +pragma solidity ^0.8.34; import {ReentrancyGuardTransient} from "@openzeppelin/contracts/utils/ReentrancyGuardTransient.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; import {ISwapboard} from "./interfaces/ISwapboard.sol"; import {IWETH} from "./interfaces/IWETH.sol"; /// @title Swapboard /// @author Zak Cole (numbergroup.xyz) for Ethereum Community Foundation +/// @custom:coauthor z0r0z (zfi.wei) for Ethereum and the onchain traders /// @notice Trustless OTC bulletin board for ERC20 token swaps on Ethereum /// @dev This contract implements a simple orderbook for peer-to-peer token swaps. /// /// Key properties: /// - No admin functions, fees, or upgrades -/// - Orders are filled atomically (all-or-nothing) -/// - Fee-on-transfer tokens are rejected for tokenA (selling token) +/// - Orders support full fills (default) or partial fills (opt-in at creation) +/// - Partial fill math uses OpenZeppelin Math.mulDiv for overflow-safe +/// proportional computation; rounding always favors the maker +/// - Fee-on-transfer tokens are rejected for tokenA on deposit (selling token) /// - Reentrancy protected via OpenZeppelin ReentrancyGuardTransient (EIP-1153) /// /// Security considerations: -/// - Front-running is possible on fillOrder (inherent to on-chain orderbooks) +/// - Front-running is possible on fill functions (inherent to on-chain orderbooks) /// - Rebasing tokens may cause unexpected behavior /// - Malicious tokens can cause fund loss - users must verify token contracts /// @@ -40,7 +44,7 @@ contract Swapboard is ISwapboard, ReentrancyGuardTransient { constructor( address _weth - ) { + ) payable { if (_weth == address(0)) revert ZeroAddress(); if (_weth.code.length == 0) revert NotAContract(_weth); weth = _weth; @@ -51,15 +55,53 @@ contract Swapboard is ISwapboard, ReentrancyGuardTransient { if (msg.sender != weth) revert NotWETH(weth, msg.sender); } - /// @inheritdoc ISwapboard - /// @dev Token addresses are identity-based. Aliased or rebranded tokens at different - /// addresses are treated as distinct tokens. Users must verify token addresses. - function createOrder( + /// @dev Validates an order and computes fill amounts. Updates storage for both + /// full and partial fills. fillAmountB == 0 means "fill entire order". + function _computeFill( + Order storage order, + uint256 orderId, + uint256 fillAmountB + ) + internal + returns (address maker, uint256 transferAmountA, uint256 transferAmountB, bool fullFill) + { + bool active; + (maker, active) = (order.maker, order.active); + if (maker == address(0)) revert OrderNotFound(orderId); + if (!active) revert OrderNotActive(orderId); + + uint256 amountA = order.amountA; + uint256 amountB = order.amountB; + + // Full fill: fillAmountB is 0 (meaning "all") or covers the remainder + if (fillAmountB == 0 || fillAmountB >= amountB) { + order.active = false; + order.amountA = 0; + order.amountB = 0; + return (maker, amountA, amountB, true); + } + + // Partial fill + if (!order.partialFill) revert PartialFillNotAllowed(orderId); + + // Rounds down, favoring maker; overflow-safe via 512-bit intermediate + transferAmountA = Math.mulDiv(fillAmountB, amountA, amountB); + if (transferAmountA == 0) revert ZeroFillAmount(); + + order.amountA = amountA - transferAmountA; + order.amountB = amountB - fillAmountB; + + return (maker, transferAmountA, fillAmountB, false); + } + + /// @dev Internal create logic shared by createOrder and createOrders. + function _createOrder( address tokenA, uint256 amountA, address tokenB, - uint256 amountB - ) external nonReentrant returns (uint256 orderId) { + uint256 amountB, + bool partialFill + ) internal returns (uint256 orderId) { if (tokenA == address(0)) revert ZeroAddress(); if (tokenB == address(0)) revert ZeroAddress(); if (amountA == 0) revert ZeroAmount(); @@ -79,63 +121,65 @@ contract Swapboard is ISwapboard, ReentrancyGuardTransient { if (received != amountA) { revert BalanceMismatch(amountA, received); } + } + + orderId = _storeOrder(msg.sender, tokenA, amountA, tokenB, amountB, partialFill); + } + + /// @dev Writes order to storage, emits event, and returns the new order ID. + function _storeOrder( + address maker, + address tokenA, + uint256 amountA, + address tokenB, + uint256 amountB, + bool partialFill + ) internal returns (uint256 orderId) { + unchecked { orderId = nextOrderId++; } orders[orderId] = Order({ - maker: msg.sender, + maker: maker, active: true, + partialFill: partialFill, tokenA: tokenA, amountA: amountA, tokenB: tokenB, amountB: amountB }); - emit OrderCreated(orderId, msg.sender, tokenA, amountA, tokenB, amountB); + emit OrderCreated(orderId, maker, tokenA, amountA, tokenB, amountB, partialFill); } /// @inheritdoc ISwapboard - /// @dev Fee-on-transfer tokenB: maker receives less than amountB. This is maker's risk. - function fillOrder( - uint256 orderId, - uint256 deadline - ) external nonReentrant { - if (deadline != 0 && block.timestamp > deadline) revert DeadlineExpired(); - - Order storage order = orders[orderId]; - - (address maker, bool active) = (order.maker, order.active); - if (maker == address(0)) revert OrderNotFound(orderId); - if (!active) revert OrderNotActive(orderId); - - order.active = false; - - // Transfer tokenB from taker to maker - // Note: If tokenB is fee-on-transfer, maker receives less than amountB - IERC20(order.tokenB).safeTransferFrom(msg.sender, maker, order.amountB); - - // Transfer tokenA from contract to taker - IERC20(order.tokenA).safeTransfer(msg.sender, order.amountA); - - emit OrderFilled(orderId, msg.sender); + /// @dev Token addresses are identity-based. Aliased or rebranded tokens at different + /// addresses are treated as distinct tokens. Users must verify token addresses. + function createOrder( + address tokenA, + uint256 amountA, + address tokenB, + uint256 amountB, + bool partialFill + ) external nonReentrant returns (uint256 orderId) { + orderId = _createOrder(tokenA, amountA, tokenB, amountB, partialFill); } /// @inheritdoc ISwapboard - function cancelOrder( - uint256 orderId - ) external nonReentrant { - Order storage order = orders[orderId]; - - (address maker, bool active) = (order.maker, order.active); - if (maker == address(0)) revert OrderNotFound(orderId); - if (!active) revert OrderNotActive(orderId); - if (msg.sender != maker) revert NotMaker(orderId, msg.sender, maker); - - order.active = false; - - IERC20(order.tokenA).safeTransfer(maker, order.amountA); - - emit OrderCanceled(orderId); + /// @dev Gas scales linearly with array length. + function createOrders( + CreateOrderParams[] calldata params + ) external nonReentrant returns (uint256[] memory orderIds) { + orderIds = new uint256[](params.length); + for (uint256 i; i < params.length; ++i) { + orderIds[i] = _createOrder( + params[i].tokenA, + params[i].amountA, + params[i].tokenB, + params[i].amountB, + params[i].partialFill + ); + } } /// @inheritdoc ISwapboard @@ -143,7 +187,8 @@ contract Swapboard is ISwapboard, ReentrancyGuardTransient { /// addresses are treated as distinct tokens. Users must verify token addresses. function createOrderWithEth( address tokenB, - uint256 amountB + uint256 amountB, + bool partialFill ) external payable nonReentrant returns (uint256 orderId) { if (msg.value == 0) revert ZeroETH(); if (tokenB == address(0)) revert ZeroAddress(); @@ -153,20 +198,119 @@ contract Swapboard is ISwapboard, ReentrancyGuardTransient { IWETH(weth).deposit{value: msg.value}(); - unchecked { - orderId = nextOrderId++; + orderId = _storeOrder(msg.sender, weth, msg.value, tokenB, amountB, partialFill); + } + + /// @dev Emits the appropriate fill event (full or partial). + function _emitFillEvent( + uint256 orderId, + address taker, + address maker, + address tokenA, + address tokenB, + uint256 transferAmountA, + uint256 transferAmountB, + uint256 remainingAmountA, + uint256 remainingAmountB, + bool fullFill + ) internal { + if (fullFill) { + emit OrderFilled( + orderId, taker, maker, tokenA, transferAmountA, tokenB, transferAmountB + ); + } else { + emit OrderPartiallyFilled( + orderId, + taker, + maker, + tokenA, + transferAmountA, + tokenB, + transferAmountB, + remainingAmountA, + remainingAmountB + ); } + } - orders[orderId] = Order({ - maker: msg.sender, - active: true, - tokenA: weth, - amountA: msg.value, - tokenB: tokenB, - amountB: amountB - }); + /// @dev Internal fill logic shared by fillOrder and fillOrders. + /// Fee-on-transfer tokenB: maker receives less than expected. This is maker's risk. + function _fillOrder( + uint256 orderId, + uint256 fillAmountB + ) internal { + Order storage order = orders[orderId]; + + (address maker, uint256 transferAmountA, uint256 transferAmountB, bool fullFill) = + _computeFill(order, orderId, fillAmountB); + + address tokenA = order.tokenA; + address tokenB = order.tokenB; + + // Transfer tokenB from taker to maker + IERC20(tokenB).safeTransferFrom(msg.sender, maker, transferAmountB); + + // Transfer tokenA from contract to taker + IERC20(tokenA).safeTransfer(msg.sender, transferAmountA); + + _emitFillEvent( + orderId, + msg.sender, + maker, + tokenA, + tokenB, + transferAmountA, + transferAmountB, + order.amountA, + order.amountB, + fullFill + ); + } + + /// @inheritdoc ISwapboard + function fillOrder( + uint256 orderId, + uint256 deadline, + uint256 fillAmountB + ) external nonReentrant { + if (deadline != 0 && block.timestamp > deadline) revert DeadlineExpired(); + _fillOrder(orderId, fillAmountB); + } - emit OrderCreated(orderId, msg.sender, weth, msg.value, tokenB, amountB); + /// @inheritdoc ISwapboard + /// @dev Fills are atomic — if any fill reverts, the entire batch reverts. + /// Gas scales linearly with array length. + function fillOrders( + uint256[] calldata orderIds, + uint256 deadline, + uint256[] calldata fillAmountsB + ) external nonReentrant { + if (deadline != 0 && block.timestamp > deadline) revert DeadlineExpired(); + if (orderIds.length != fillAmountsB.length) revert LengthMismatch(); + + for (uint256 i; i < orderIds.length; ++i) { + _fillOrder(orderIds[i], fillAmountsB[i]); + } + } + + /// @inheritdoc ISwapboard + /// @dev Only skips inactive/nonexistent orders — every other revert path aborts + /// the batch. See ISwapboard for the full list. + function tryFillOrders( + uint256[] calldata orderIds, + uint256 deadline, + uint256[] calldata fillAmountsB + ) external nonReentrant returns (bool[] memory filled) { + if (deadline != 0 && block.timestamp > deadline) revert DeadlineExpired(); + if (orderIds.length != fillAmountsB.length) revert LengthMismatch(); + + filled = new bool[](orderIds.length); + for (uint256 i; i < orderIds.length; ++i) { + if (orders[orderIds[i]].active) { + _fillOrder(orderIds[i], fillAmountsB[i]); + filled[i] = true; + } + } } /// @inheritdoc ISwapboard @@ -176,83 +320,172 @@ contract Swapboard is ISwapboard, ReentrancyGuardTransient { ) external payable nonReentrant { if (deadline != 0 && block.timestamp > deadline) revert DeadlineExpired(); + if (msg.value == 0) revert ZeroETH(); + Order storage order = orders[orderId]; + if (order.maker == address(0)) revert OrderNotFound(orderId); + if (!order.active) revert OrderNotActive(orderId); + if (order.tokenB != weth) revert NotWETH(weth, order.tokenB); + if (msg.value > order.amountB) revert ETHAmountMismatch(order.amountB, msg.value); + // Defensive: fail early before _computeFill, since msg.value is payment, not a sentinel + if (msg.value < order.amountB && !order.partialFill) revert PartialFillNotAllowed(orderId); - (address maker, bool active) = (order.maker, order.active); - if (maker == address(0)) revert OrderNotFound(orderId); - if (!active) revert OrderNotActive(orderId); + (address maker, uint256 transferAmountA, uint256 transferAmountB, bool fullFill) = + _computeFill(order, orderId, msg.value); - uint256 amountB = order.amountB; + address tokenA = order.tokenA; - if (order.tokenB != weth) revert NotWETH(weth, order.tokenB); - if (msg.value != amountB) revert ETHAmountMismatch(amountB, msg.value); + // Wrap ETH to WETH and transfer to maker + IWETH(weth).deposit{value: msg.value}(); + IERC20(weth).safeTransfer(maker, transferAmountB); - order.active = false; + // Transfer tokenA from contract to taker + IERC20(tokenA).safeTransfer(msg.sender, transferAmountA); + + _emitFillEvent( + orderId, + msg.sender, + maker, + tokenA, + weth, + transferAmountA, + transferAmountB, + order.amountA, + order.amountB, + fullFill + ); + } - IWETH(weth).deposit{value: msg.value}(); - IERC20(weth).safeTransfer(maker, amountB); + /// @inheritdoc ISwapboard + function fillOrderUnwrap( + uint256 orderId, + uint256 deadline, + uint256 fillAmountB + ) external nonReentrant { + if (deadline != 0 && block.timestamp > deadline) revert DeadlineExpired(); + + Order storage order = orders[orderId]; + if (order.maker == address(0)) revert OrderNotFound(orderId); + if (!order.active) revert OrderNotActive(orderId); + if (order.tokenA != weth) revert NotWETH(weth, order.tokenA); + + (address maker, uint256 transferAmountA, uint256 transferAmountB, bool fullFill) = + _computeFill(order, orderId, fillAmountB); - IERC20(order.tokenA).safeTransfer(msg.sender, order.amountA); + address tokenB = order.tokenB; - emit OrderFilled(orderId, msg.sender); + // Transfer tokenB from taker to maker + IERC20(tokenB).safeTransferFrom(msg.sender, maker, transferAmountB); + + // Unwrap WETH and transfer ETH to taker + IWETH(weth).withdraw(transferAmountA); + + bool success; + assembly { + success := call(gas(), caller(), transferAmountA, 0, 0, 0, 0) + } + if (!success) revert ETHTransferFailed(msg.sender); + + _emitFillEvent( + orderId, + msg.sender, + maker, + weth, + tokenB, + transferAmountA, + transferAmountB, + order.amountA, + order.amountB, + fullFill + ); } - /// @inheritdoc ISwapboard - function cancelOrderUnwrap( + /// @dev Internal cancel logic shared by cancelOrder and cancelOrders. + function _cancelOrder( uint256 orderId - ) external nonReentrant { + ) internal { Order storage order = orders[orderId]; (address maker, bool active) = (order.maker, order.active); if (maker == address(0)) revert OrderNotFound(orderId); if (!active) revert OrderNotActive(orderId); if (msg.sender != maker) revert NotMaker(orderId, msg.sender, maker); - if (order.tokenA != weth) revert NotWETH(weth, order.tokenA); + address tokenA = order.tokenA; uint256 amountA = order.amountA; order.active = false; + order.amountA = 0; + order.amountB = 0; - IWETH(weth).withdraw(amountA); + IERC20(tokenA).safeTransfer(maker, amountA); - bool success; - assembly { - success := call(gas(), maker, amountA, 0, 0, 0, 0) - } - if (!success) revert ETHTransferFailed(maker); + emit OrderCanceled(orderId, maker, tokenA, amountA); + } - emit OrderCanceled(orderId); + /// @inheritdoc ISwapboard + function cancelOrder( + uint256 orderId + ) external nonReentrant { + _cancelOrder(orderId); } /// @inheritdoc ISwapboard - function fillOrderUnwrap( - uint256 orderId, - uint256 deadline + /// @dev Atomic: if any cancellation reverts, the entire batch reverts. + /// Gas scales linearly with array length. + function cancelOrders( + uint256[] calldata orderIds ) external nonReentrant { - if (deadline != 0 && block.timestamp > deadline) revert DeadlineExpired(); + for (uint256 i; i < orderIds.length; ++i) { + _cancelOrder(orderIds[i]); + } + } + /// @dev Internal cancel-and-unwrap logic shared by cancelOrderUnwrap and cancelOrdersUnwrap. + function _cancelOrderUnwrap( + uint256 orderId + ) internal { Order storage order = orders[orderId]; (address maker, bool active) = (order.maker, order.active); if (maker == address(0)) revert OrderNotFound(orderId); if (!active) revert OrderNotActive(orderId); + if (msg.sender != maker) revert NotMaker(orderId, msg.sender, maker); if (order.tokenA != weth) revert NotWETH(weth, order.tokenA); uint256 amountA = order.amountA; order.active = false; - - IERC20(order.tokenB).safeTransferFrom(msg.sender, maker, order.amountB); + order.amountA = 0; + order.amountB = 0; IWETH(weth).withdraw(amountA); bool success; assembly { - success := call(gas(), caller(), amountA, 0, 0, 0, 0) + success := call(gas(), maker, amountA, 0, 0, 0, 0) } - if (!success) revert ETHTransferFailed(msg.sender); + if (!success) revert ETHTransferFailed(maker); + + emit OrderCanceled(orderId, maker, weth, amountA); + } - emit OrderFilled(orderId, msg.sender); + /// @inheritdoc ISwapboard + function cancelOrderUnwrap( + uint256 orderId + ) external nonReentrant { + _cancelOrderUnwrap(orderId); + } + + /// @inheritdoc ISwapboard + /// @dev Atomic: if any cancellation reverts, the entire batch reverts. + /// Gas scales linearly with array length. + function cancelOrdersUnwrap( + uint256[] calldata orderIds + ) external nonReentrant { + for (uint256 i; i < orderIds.length; ++i) { + _cancelOrderUnwrap(orderIds[i]); + } } /// @inheritdoc ISwapboard @@ -268,11 +501,8 @@ contract Swapboard is ISwapboard, ReentrancyGuardTransient { uint256[] calldata orderIds ) external view returns (Order[] memory result) { result = new Order[](orderIds.length); - for (uint256 i; i < orderIds.length;) { + for (uint256 i; i < orderIds.length; ++i) { result[i] = orders[orderIds[i]]; - unchecked { - ++i; - } } } diff --git a/contracts/src/interfaces/ISwapboard.sol b/contracts/src/interfaces/ISwapboard.sol index 3d75ca7..db83b27 100644 --- a/contracts/src/interfaces/ISwapboard.sol +++ b/contracts/src/interfaces/ISwapboard.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: AGPL-3.0-only -pragma solidity ^0.8.33; +pragma solidity ^0.8.34; /// @title ISwapboard /// @author Zak Cole (numbergroup.xyz) for Ethereum Community Foundation @@ -9,14 +9,16 @@ pragma solidity ^0.8.33; interface ISwapboard { /// @notice Represents a single OTC order /// @param maker Address that created the order and deposited tokenA + /// @param active Whether the order can still be filled or cancelled + /// @param partialFill Whether the order allows partial fills /// @param tokenA Address of the token being sold (held in escrow) - /// @param amountA Amount of tokenA deposited by maker (in base units) + /// @param amountA Amount of tokenA remaining in the order (in base units) /// @param tokenB Address of the token maker wants to receive - /// @param amountB Amount of tokenB required to fill the order (in base units) - /// @param active Whether the order can still be filled or cancelled + /// @param amountB Amount of tokenB still required to fill the order (in base units) struct Order { address maker; bool active; + bool partialFill; address tokenA; uint256 amountA; address tokenB; @@ -30,23 +32,66 @@ interface ISwapboard { /// @param amountA Amount of tokenA deposited /// @param tokenB Address of the token wanted /// @param amountB Amount of tokenB required to fill + /// @param partialFill Whether the order allows partial fills event OrderCreated( uint256 indexed orderId, address indexed maker, address tokenA, uint256 amountA, address tokenB, - uint256 amountB + uint256 amountB, + bool partialFill ); - /// @notice Emitted when an order is filled by a taker + /// @notice Emitted when an order is fully filled by a taker + /// @dev May follow one or more OrderPartiallyFilled events for the same orderId /// @param orderId Unique identifier for the filled order /// @param taker Address that filled the order - event OrderFilled(uint256 indexed orderId, address indexed taker); + /// @param maker Address that created the order + /// @param tokenA Address of the token transferred to the taker + /// @param amountA Amount of tokenA transferred to the taker + /// @param tokenB Address of the token transferred to the maker + /// @param amountB Amount of tokenB transferred to the maker + event OrderFilled( + uint256 indexed orderId, + address indexed taker, + address indexed maker, + address tokenA, + uint256 amountA, + address tokenB, + uint256 amountB + ); + + /// @notice Emitted when an order is partially filled by a taker + /// @param orderId Unique identifier for the partially filled order + /// @param taker Address that partially filled the order + /// @param maker Address that created the order + /// @param tokenA Address of the token transferred to the taker + /// @param amountAFilled Amount of tokenA transferred to the taker + /// @param tokenB Address of the token transferred to the maker + /// @param amountBFilled Amount of tokenB transferred to the maker + /// @param amountARemaining Amount of tokenA still in escrow + /// @param amountBRemaining Amount of tokenB still required + event OrderPartiallyFilled( + uint256 indexed orderId, + address indexed taker, + address indexed maker, + address tokenA, + uint256 amountAFilled, + address tokenB, + uint256 amountBFilled, + uint256 amountARemaining, + uint256 amountBRemaining + ); /// @notice Emitted when an order is cancelled by its maker /// @param orderId Unique identifier for the cancelled order - event OrderCanceled(uint256 indexed orderId); + /// @param maker Address that created and cancelled the order + /// @param tokenA Address of the token returned to the maker + /// @param amountA Amount of tokenA returned to the maker (remaining after prior fills) + event OrderCanceled( + uint256 indexed orderId, address indexed maker, address tokenA, uint256 amountA + ); /// @notice Thrown when a zero address is provided for a token error ZeroAddress(); @@ -86,8 +131,8 @@ interface ISwapboard { /// @param actual The actual token address error NotWETH(address expected, address actual); - /// @notice Thrown when msg.value does not match the required ETH amount - /// @param required The required ETH amount + /// @notice Thrown when msg.value exceeds the remaining order amount + /// @param required The maximum acceptable ETH amount /// @param sent The actual msg.value error ETHAmountMismatch(uint256 required, uint256 sent); @@ -101,36 +146,121 @@ interface ISwapboard { /// @notice Thrown when a fill is attempted after the specified deadline error DeadlineExpired(); + /// @notice Thrown when a partial fill is attempted on a non-partial order + /// @param orderId The order ID that does not allow partial fills + error PartialFillNotAllowed(uint256 orderId); + + /// @notice Thrown when the computed fillAmountA rounds to zero + /// @dev Means the fill is too small to transfer any tokenA + error ZeroFillAmount(); + + /// @notice Thrown when array arguments have mismatched lengths + error LengthMismatch(); + + /// @notice Parameters for creating an order in a batch + /// @param tokenA Address of the ERC20 token to sell + /// @param amountA Amount of tokenA to deposit (in base units) + /// @param tokenB Address of the ERC20 token wanted in exchange + /// @param amountB Amount of tokenB required to fill the order (in base units) + /// @param partialFill Whether the order allows partial fills + struct CreateOrderParams { + address tokenA; + uint256 amountA; + address tokenB; + uint256 amountB; + bool partialFill; + } + /// @notice Creates a new OTC order by depositing tokenA /// @dev Transfers tokenA from caller to contract. Reverts if token is fee-on-transfer. /// @param tokenA Address of the ERC20 token to sell /// @param amountA Amount of tokenA to deposit (in base units) /// @param tokenB Address of the ERC20 token wanted in exchange /// @param amountB Amount of tokenB required to fill the order (in base units) + /// @param partialFill Whether the order allows partial fills /// @return orderId The unique identifier for the created order function createOrder( address tokenA, uint256 amountA, address tokenB, - uint256 amountB + uint256 amountB, + bool partialFill ) external returns (uint256 orderId); - /// @notice Fills an existing order by transferring tokenB to maker - /// @dev Transfers tokenB from caller to maker, transfers tokenA from contract to caller + /// @notice Creates multiple orders in a single transaction + /// @dev Atomic: if any order creation reverts, the entire batch reverts. + /// Gas scales linearly with array length. + /// @param params Array of CreateOrderParams structs + /// @return orderIds Array of unique identifiers for the created orders + function createOrders( + CreateOrderParams[] calldata params + ) external returns (uint256[] memory orderIds); + + /// @notice Fills an existing order, fully or partially + /// @dev When fillAmountB is 0 or >= remaining amountB, fills the entire order. + /// When fillAmountB < remaining amountB, performs a partial fill (requires + /// partialFill flag). Partial fillAmountA = fillAmountB * amountA / amountB, + /// rounded down (favoring maker). + /// Fee-on-transfer tokenB: maker receives less than the nominal amount. + /// This is the maker's risk — verify token behavior before creating orders. /// @param orderId The unique identifier of the order to fill /// @param deadline Unix timestamp after which the fill reverts (0 = no deadline) + /// @param fillAmountB Amount of tokenB to pay (0 = fill entire order) function fillOrder( uint256 orderId, - uint256 deadline + uint256 deadline, + uint256 fillAmountB + ) external; + + /// @notice Fills multiple orders in a single transaction + /// @dev Non-payable — use WETH for ETH-denominated fills. Atomic: if any fill + /// reverts, the entire batch reverts. Gas scales linearly with array length. + /// Fee-on-transfer tokenB: maker receives less than the nominal amount. + /// This is the maker's risk — verify token behavior before creating orders. + /// @param orderIds Array of order identifiers to fill + /// @param deadline Unix timestamp after which the fill reverts (0 = no deadline) + /// @param fillAmountsB Array of tokenB amounts to pay (0 = fill entire order) + function fillOrders( + uint256[] calldata orderIds, + uint256 deadline, + uint256[] calldata fillAmountsB ) external; - /// @notice Cancels an existing order and returns tokenA to maker - /// @dev Only callable by the order's maker + /// @notice Batch fill that skips inactive/nonexistent orders without reverting + /// @dev NOT a general best-effort fill. The only failure mode handled here is an + /// inactive or nonexistent order — those are skipped and marked false in the + /// returned array. Every other revert path (insufficient tokenB allowance, + /// tokenB transfer failure, partial-fill-not-allowed, zero-fill rounding, + /// expired deadline, length mismatch) aborts the entire batch. Integrators + /// must pre-validate allowances and partial-fill flags off-chain. + /// Fee-on-transfer tokenB: maker receives less than the nominal amount. + /// This is the maker's risk — verify token behavior before creating orders. + /// @param orderIds Array of order identifiers to fill + /// @param deadline Unix timestamp after which the fill reverts (0 = no deadline) + /// @param fillAmountsB Array of tokenB amounts to pay (0 = fill entire order) + /// @return filled Array of booleans indicating which orders were filled + function tryFillOrders( + uint256[] calldata orderIds, + uint256 deadline, + uint256[] calldata fillAmountsB + ) external returns (bool[] memory filled); + + /// @notice Cancels an existing order and returns remaining tokenA to maker + /// @dev Only callable by the order's maker. For partially filled orders, + /// returns only the remaining amountA still held in escrow. /// @param orderId The unique identifier of the order to cancel function cancelOrder( uint256 orderId ) external; + /// @notice Cancels multiple orders in a single transaction + /// @dev Only callable by the orders' maker. Atomic: if any cancellation reverts, + /// the entire batch reverts. Gas scales linearly with array length. + /// @param orderIds Array of order identifiers to cancel + function cancelOrders( + uint256[] calldata orderIds + ) external; + /// @notice Retrieves the details of a single order /// @param orderId The unique identifier of the order /// @return order The Order struct containing all order details @@ -161,14 +291,18 @@ interface ISwapboard { /// @dev Wraps msg.value to WETH and stores order with tokenA = WETH /// @param tokenB Address of the ERC20 token wanted in exchange /// @param amountB Amount of tokenB required to fill the order (in base units) + /// @param partialFill Whether the order allows partial fills /// @return orderId The unique identifier for the created order function createOrderWithEth( address tokenB, - uint256 amountB + uint256 amountB, + bool partialFill ) external payable returns (uint256 orderId); - /// @notice Fills an order by sending ETH (auto-wrapped to WETH) - /// @dev Requires order.tokenB == WETH and msg.value == order.amountB + /// @notice Fills an order where tokenB is WETH by sending ETH + /// @dev msg.value is the fill amount. For full fills, msg.value must equal + /// remaining amountB. For partial fills (msg.value < amountB), the order + /// must have partialFill enabled. Reverts if msg.value > amountB. /// @param orderId The unique identifier of the order to fill /// @param deadline Unix timestamp after which the fill reverts (0 = no deadline) function fillOrderWithEth( @@ -176,19 +310,31 @@ interface ISwapboard { uint256 deadline ) external payable; - /// @notice Cancels an order where tokenA is WETH, returning ETH to maker - /// @dev Only callable by the order's maker. Unwraps WETH to ETH. + /// @notice Cancels an order where tokenA is WETH, returning remaining ETH to maker + /// @dev Only callable by the order's maker. Unwraps remaining WETH to ETH. /// @param orderId The unique identifier of the order to cancel function cancelOrderUnwrap( uint256 orderId ) external; - /// @notice Fills an order where tokenA is WETH, receiving ETH instead - /// @dev Pays tokenB normally, receives ETH after WETH unwrap + /// @notice Cancels multiple orders where tokenA is WETH, returning remaining ETH to maker + /// @dev Only callable by the orders' maker. Atomic: if any cancellation reverts, + /// the entire batch reverts. Gas scales linearly with array length. + /// @param orderIds Array of order identifiers to cancel + function cancelOrdersUnwrap( + uint256[] calldata orderIds + ) external; + + /// @notice Fills an order where tokenA is WETH, receiving ETH instead of WETH + /// @dev When fillAmountB is 0 or >= remaining amountB, fills the entire order. + /// When fillAmountB < remaining amountB, performs a partial fill (requires + /// partialFill flag). Taker receives ETH after WETH unwrap. /// @param orderId The unique identifier of the order to fill /// @param deadline Unix timestamp after which the fill reverts (0 = no deadline) + /// @param fillAmountB Amount of tokenB to pay (0 = fill entire order) function fillOrderUnwrap( uint256 orderId, - uint256 deadline + uint256 deadline, + uint256 fillAmountB ) external; } diff --git a/contracts/src/interfaces/IWETH.sol b/contracts/src/interfaces/IWETH.sol index 6c249bb..11dfc73 100644 --- a/contracts/src/interfaces/IWETH.sol +++ b/contracts/src/interfaces/IWETH.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: AGPL-3.0-only -pragma solidity ^0.8.33; +pragma solidity ^0.8.34; interface IWETH { function deposit() external payable; diff --git a/contracts/test/Swapboard.integration.t.sol b/contracts/test/Swapboard.integration.t.sol index 77fe644..d9e5d65 100644 --- a/contracts/test/Swapboard.integration.t.sol +++ b/contracts/test/Swapboard.integration.t.sol @@ -42,18 +42,18 @@ contract SwapboardIntegrationTest is Test { function test_multipleUsersMultipleOrders() public { vm.startPrank(alice); weth.approve(address(board), 100 ether); - uint256 order0 = board.createOrder(address(weth), 10 ether, address(usdc), 30_000e6); - uint256 order1 = board.createOrder(address(weth), 20 ether, address(usdc), 58_000e6); + uint256 order0 = board.createOrder(address(weth), 10 ether, address(usdc), 30_000e6, false); + uint256 order1 = board.createOrder(address(weth), 20 ether, address(usdc), 58_000e6, false); vm.stopPrank(); vm.startPrank(bob); usdc.approve(address(board), 1_000_000e6); - uint256 order2 = board.createOrder(address(usdc), 100_000e6, address(weth), 35 ether); + uint256 order2 = board.createOrder(address(usdc), 100_000e6, address(weth), 35 ether, false); vm.stopPrank(); vm.startPrank(dave); wbtc.approve(address(board), 10e8); - uint256 order3 = board.createOrder(address(wbtc), 1e8, address(usdc), 95_000e6); + uint256 order3 = board.createOrder(address(wbtc), 1e8, address(usdc), 95_000e6, false); vm.stopPrank(); assertEq(board.nextOrderId(), 4); @@ -64,8 +64,8 @@ contract SwapboardIntegrationTest is Test { vm.startPrank(charlie); usdc.approve(address(board), 200_000e6); - board.fillOrder(order0, 0); - board.fillOrder(order3, 0); + board.fillOrder(order0, 0, 0); + board.fillOrder(order3, 0, 0); vm.stopPrank(); assertFalse(board.canFill(order0)); @@ -82,7 +82,8 @@ contract SwapboardIntegrationTest is Test { function test_orderLifecycle_createFill() public { vm.startPrank(alice); weth.approve(address(board), 50 ether); - uint256 orderId = board.createOrder(address(weth), 50 ether, address(usdc), 150_000e6); + uint256 orderId = + board.createOrder(address(weth), 50 ether, address(usdc), 150_000e6, false); vm.stopPrank(); uint256 bobWethBefore = weth.balanceOf(bob); @@ -90,7 +91,7 @@ contract SwapboardIntegrationTest is Test { vm.startPrank(bob); usdc.approve(address(board), 150_000e6); - board.fillOrder(orderId, 0); + board.fillOrder(orderId, 0, 0); vm.stopPrank(); assertEq(weth.balanceOf(bob), bobWethBefore + 50 ether); @@ -106,7 +107,8 @@ contract SwapboardIntegrationTest is Test { vm.startPrank(alice); weth.approve(address(board), 50 ether); - uint256 orderId = board.createOrder(address(weth), 50 ether, address(usdc), 150_000e6); + uint256 orderId = + board.createOrder(address(weth), 50 ether, address(usdc), 150_000e6, false); assertEq(weth.balanceOf(alice), aliceWethBefore - 50 ether); assertEq(weth.balanceOf(address(board)), 50 ether); @@ -124,7 +126,7 @@ contract SwapboardIntegrationTest is Test { function test_raceCondition_twoFillersOneOrder() public { vm.startPrank(alice); weth.approve(address(board), 10 ether); - uint256 orderId = board.createOrder(address(weth), 10 ether, address(usdc), 30_000e6); + uint256 orderId = board.createOrder(address(weth), 10 ether, address(usdc), 30_000e6, false); vm.stopPrank(); vm.prank(bob); @@ -134,11 +136,11 @@ contract SwapboardIntegrationTest is Test { usdc.approve(address(board), 30_000e6); vm.prank(bob); - board.fillOrder(orderId, 0); + board.fillOrder(orderId, 0, 0); vm.prank(charlie); vm.expectRevert(abi.encodeWithSelector(ISwapboard.OrderNotActive.selector, orderId)); - board.fillOrder(orderId, 0); + board.fillOrder(orderId, 0, 0); assertEq(weth.balanceOf(bob), 1000 ether + 10 ether); assertEq(weth.balanceOf(charlie), 0); @@ -147,14 +149,14 @@ contract SwapboardIntegrationTest is Test { function test_raceCondition_fillAndCancel() public { vm.startPrank(alice); weth.approve(address(board), 10 ether); - uint256 orderId = board.createOrder(address(weth), 10 ether, address(usdc), 30_000e6); + uint256 orderId = board.createOrder(address(weth), 10 ether, address(usdc), 30_000e6, false); vm.stopPrank(); vm.prank(bob); usdc.approve(address(board), 30_000e6); vm.prank(bob); - board.fillOrder(orderId, 0); + board.fillOrder(orderId, 0, 0); vm.prank(alice); vm.expectRevert(abi.encodeWithSelector(ISwapboard.OrderNotActive.selector, orderId)); @@ -167,7 +169,7 @@ contract SwapboardIntegrationTest is Test { uint256[] memory orderIds = new uint256[](10); for (uint256 i = 0; i < 10; i++) { - orderIds[i] = board.createOrder(address(weth), 10 ether, address(usdc), 30_000e6); + orderIds[i] = board.createOrder(address(weth), 10 ether, address(usdc), 30_000e6, false); } vm.stopPrank(); @@ -184,11 +186,11 @@ contract SwapboardIntegrationTest is Test { vm.startPrank(bob); usdc.approve(address(board), 150_000e6); - board.fillOrder(orderIds[0], 0); - board.fillOrder(orderIds[2], 0); - board.fillOrder(orderIds[4], 0); - board.fillOrder(orderIds[6], 0); - board.fillOrder(orderIds[8], 0); + board.fillOrder(orderIds[0], 0, 0); + board.fillOrder(orderIds[2], 0, 0); + board.fillOrder(orderIds[4], 0, 0); + board.fillOrder(orderIds[6], 0, 0); + board.fillOrder(orderIds[8], 0, 0); vm.stopPrank(); vm.startPrank(alice); @@ -208,12 +210,12 @@ contract SwapboardIntegrationTest is Test { function test_differentDecimalTokens() public { vm.startPrank(dave); wbtc.approve(address(board), 1e8); - uint256 orderId = board.createOrder(address(wbtc), 1e8, address(dai), 95_000 ether); + uint256 orderId = board.createOrder(address(wbtc), 1e8, address(dai), 95_000 ether, false); vm.stopPrank(); vm.startPrank(bob); dai.approve(address(board), 95_000 ether); - board.fillOrder(orderId, 0); + board.fillOrder(orderId, 0, 0); vm.stopPrank(); assertEq(wbtc.balanceOf(bob), 1e8); @@ -226,7 +228,7 @@ contract SwapboardIntegrationTest is Test { vm.startPrank(alice); weth.approve(address(board), largeAmount); - uint256 orderId = board.createOrder(address(weth), largeAmount, address(usdc), 1e6); + uint256 orderId = board.createOrder(address(weth), largeAmount, address(usdc), 1e6, false); vm.stopPrank(); ISwapboard.Order memory order = board.getOrder(orderId); @@ -234,7 +236,7 @@ contract SwapboardIntegrationTest is Test { vm.startPrank(bob); usdc.approve(address(board), 1e6); - board.fillOrder(orderId, 0); + board.fillOrder(orderId, 0, 0); vm.stopPrank(); assertEq(weth.balanceOf(bob), 1000 ether + largeAmount); @@ -243,12 +245,12 @@ contract SwapboardIntegrationTest is Test { function test_dustAmounts() public { vm.startPrank(alice); weth.approve(address(board), 1); - uint256 orderId = board.createOrder(address(weth), 1, address(usdc), 1); + uint256 orderId = board.createOrder(address(weth), 1, address(usdc), 1, false); vm.stopPrank(); vm.startPrank(bob); usdc.approve(address(board), 1); - board.fillOrder(orderId, 0); + board.fillOrder(orderId, 0, 0); vm.stopPrank(); assertEq(weth.balanceOf(bob), 1000 ether + 1); @@ -260,24 +262,28 @@ contract SwapboardIntegrationTest is Test { weth.approve(address(board), 10 ether); vm.expectEmit(true, true, false, true); - emit ISwapboard.OrderCreated(0, alice, address(weth), 10 ether, address(usdc), 30_000e6); - uint256 orderId = board.createOrder(address(weth), 10 ether, address(usdc), 30_000e6); + emit ISwapboard.OrderCreated( + 0, alice, address(weth), 10 ether, address(usdc), 30_000e6, false + ); + uint256 orderId = board.createOrder(address(weth), 10 ether, address(usdc), 30_000e6, false); vm.stopPrank(); vm.startPrank(bob); usdc.approve(address(board), 30_000e6); - vm.expectEmit(true, true, false, true); - emit ISwapboard.OrderFilled(orderId, bob); - board.fillOrder(orderId, 0); + vm.expectEmit(true, true, true, true); + emit ISwapboard.OrderFilled( + orderId, bob, alice, address(weth), 10 ether, address(usdc), 30_000e6 + ); + board.fillOrder(orderId, 0, 0); vm.stopPrank(); } function test_getOrdersWithNonExistent() public { vm.startPrank(alice); weth.approve(address(board), 20 ether); - board.createOrder(address(weth), 10 ether, address(usdc), 30_000e6); - board.createOrder(address(weth), 10 ether, address(usdc), 30_000e6); + board.createOrder(address(weth), 10 ether, address(usdc), 30_000e6, false); + board.createOrder(address(weth), 10 ether, address(usdc), 30_000e6, false); vm.stopPrank(); uint256[] memory ids = new uint256[](4); @@ -302,7 +308,7 @@ contract SwapboardIntegrationTest is Test { weth.approve(address(board), numOrders * 1 ether); for (uint256 i = 0; i < numOrders; i++) { - board.createOrder(address(weth), 1 ether, address(usdc), 3000e6); + board.createOrder(address(weth), 1 ether, address(usdc), 3000e6, false); } vm.stopPrank(); @@ -312,7 +318,7 @@ contract SwapboardIntegrationTest is Test { vm.startPrank(bob); usdc.approve(address(board), numOrders * 3000e6); for (uint256 i = 0; i < numOrders; i++) { - board.fillOrder(i, 0); + board.fillOrder(i, 0, 0); } vm.stopPrank(); diff --git a/contracts/test/Swapboard.t.sol b/contracts/test/Swapboard.t.sol index e5fbf37..de53f22 100644 --- a/contracts/test/Swapboard.t.sol +++ b/contracts/test/Swapboard.t.sol @@ -49,7 +49,8 @@ contract SwapboardTest is Test { vm.startPrank(maker); tokenA.approve(address(board), AMOUNT_A); - uint256 orderId = board.createOrder(address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B); + uint256 orderId = + board.createOrder(address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B, false); vm.stopPrank(); assertEq(orderId, 0); @@ -70,7 +71,7 @@ contract SwapboardTest is Test { function test_createOrder_revert_zeroAddress_tokenA() public { vm.startPrank(maker); vm.expectRevert(ISwapboard.ZeroAddress.selector); - board.createOrder(address(0), AMOUNT_A, address(tokenB), AMOUNT_B); + board.createOrder(address(0), AMOUNT_A, address(tokenB), AMOUNT_B, false); vm.stopPrank(); } @@ -78,7 +79,7 @@ contract SwapboardTest is Test { vm.startPrank(maker); tokenA.approve(address(board), AMOUNT_A); vm.expectRevert(ISwapboard.ZeroAddress.selector); - board.createOrder(address(tokenA), AMOUNT_A, address(0), AMOUNT_B); + board.createOrder(address(tokenA), AMOUNT_A, address(0), AMOUNT_B, false); vm.stopPrank(); } @@ -86,7 +87,7 @@ contract SwapboardTest is Test { vm.startPrank(maker); tokenA.approve(address(board), AMOUNT_A); vm.expectRevert(ISwapboard.ZeroAmount.selector); - board.createOrder(address(tokenA), 0, address(tokenB), AMOUNT_B); + board.createOrder(address(tokenA), 0, address(tokenB), AMOUNT_B, false); vm.stopPrank(); } @@ -94,7 +95,7 @@ contract SwapboardTest is Test { vm.startPrank(maker); tokenA.approve(address(board), AMOUNT_A); vm.expectRevert(ISwapboard.ZeroAmount.selector); - board.createOrder(address(tokenA), AMOUNT_A, address(tokenB), 0); + board.createOrder(address(tokenA), AMOUNT_A, address(tokenB), 0, false); vm.stopPrank(); } @@ -102,14 +103,14 @@ contract SwapboardTest is Test { vm.startPrank(maker); tokenA.approve(address(board), AMOUNT_A); vm.expectRevert(ISwapboard.SameToken.selector); - board.createOrder(address(tokenA), AMOUNT_A, address(tokenA), AMOUNT_B); + board.createOrder(address(tokenA), AMOUNT_A, address(tokenA), AMOUNT_B, false); vm.stopPrank(); } function test_createOrder_revert_notAContract_tokenA() public { vm.startPrank(maker); vm.expectRevert(abi.encodeWithSelector(ISwapboard.NotAContract.selector, address(0x999))); - board.createOrder(address(0x999), AMOUNT_A, address(tokenB), AMOUNT_B); + board.createOrder(address(0x999), AMOUNT_A, address(tokenB), AMOUNT_B, false); vm.stopPrank(); } @@ -117,7 +118,7 @@ contract SwapboardTest is Test { vm.startPrank(maker); tokenA.approve(address(board), AMOUNT_A); vm.expectRevert(abi.encodeWithSelector(ISwapboard.NotAContract.selector, address(0x999))); - board.createOrder(address(tokenA), AMOUNT_A, address(0x999), AMOUNT_B); + board.createOrder(address(tokenA), AMOUNT_A, address(0x999), AMOUNT_B, false); vm.stopPrank(); } @@ -131,19 +132,20 @@ contract SwapboardTest is Test { vm.expectRevert( abi.encodeWithSelector(ISwapboard.BalanceMismatch.selector, 100 ether, 95 ether) ); - board.createOrder(address(fot), 100 ether, address(tokenB), AMOUNT_B); + board.createOrder(address(fot), 100 ether, address(tokenB), AMOUNT_B, false); vm.stopPrank(); } function test_fillOrder() public { vm.startPrank(maker); tokenA.approve(address(board), AMOUNT_A); - uint256 orderId = board.createOrder(address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B); + uint256 orderId = + board.createOrder(address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B, false); vm.stopPrank(); vm.startPrank(taker); tokenB.approve(address(board), AMOUNT_B); - board.fillOrder(orderId, 0); + board.fillOrder(orderId, 0, 0); vm.stopPrank(); ISwapboard.Order memory order = board.getOrder(orderId); @@ -157,29 +159,31 @@ contract SwapboardTest is Test { function test_fillOrder_revert_orderNotFound() public { vm.startPrank(taker); vm.expectRevert(abi.encodeWithSelector(ISwapboard.OrderNotFound.selector, 999)); - board.fillOrder(999, 0); + board.fillOrder(999, 0, 0); vm.stopPrank(); } function test_fillOrder_revert_orderNotActive() public { vm.startPrank(maker); tokenA.approve(address(board), AMOUNT_A); - uint256 orderId = board.createOrder(address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B); + uint256 orderId = + board.createOrder(address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B, false); vm.stopPrank(); vm.startPrank(taker); tokenB.approve(address(board), AMOUNT_B); - board.fillOrder(orderId, 0); + board.fillOrder(orderId, 0, 0); vm.expectRevert(abi.encodeWithSelector(ISwapboard.OrderNotActive.selector, orderId)); - board.fillOrder(orderId, 0); + board.fillOrder(orderId, 0, 0); vm.stopPrank(); } function test_cancelOrder() public { vm.startPrank(maker); tokenA.approve(address(board), AMOUNT_A); - uint256 orderId = board.createOrder(address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B); + uint256 orderId = + board.createOrder(address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B, false); uint256 balanceBefore = tokenA.balanceOf(maker); board.cancelOrder(orderId); @@ -201,7 +205,8 @@ contract SwapboardTest is Test { function test_cancelOrder_revert_orderNotActive() public { vm.startPrank(maker); tokenA.approve(address(board), AMOUNT_A); - uint256 orderId = board.createOrder(address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B); + uint256 orderId = + board.createOrder(address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B, false); board.cancelOrder(orderId); vm.expectRevert(abi.encodeWithSelector(ISwapboard.OrderNotActive.selector, orderId)); @@ -212,7 +217,8 @@ contract SwapboardTest is Test { function test_cancelOrder_revert_notMaker() public { vm.startPrank(maker); tokenA.approve(address(board), AMOUNT_A); - uint256 orderId = board.createOrder(address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B); + uint256 orderId = + board.createOrder(address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B, false); vm.stopPrank(); vm.startPrank(taker); @@ -221,10 +227,160 @@ contract SwapboardTest is Test { vm.stopPrank(); } + // ============ cancelOrders ============ + + function test_cancelOrders_basic() public { + vm.startPrank(maker); + tokenA.approve(address(board), AMOUNT_A * 3); + uint256 id0 = board.createOrder(address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B, false); + uint256 id1 = board.createOrder(address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B, false); + uint256 id2 = board.createOrder(address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B, false); + + uint256 balanceBefore = tokenA.balanceOf(maker); + + uint256[] memory ids = new uint256[](3); + ids[0] = id0; + ids[1] = id1; + ids[2] = id2; + board.cancelOrders(ids); + vm.stopPrank(); + + assertFalse(board.getOrder(id0).active); + assertFalse(board.getOrder(id1).active); + assertFalse(board.getOrder(id2).active); + assertEq(tokenA.balanceOf(maker), balanceBefore + AMOUNT_A * 3); + } + + function test_cancelOrders_empty() public { + uint256[] memory ids = new uint256[](0); + board.cancelOrders(ids); + } + + function test_cancelOrders_events() public { + vm.startPrank(maker); + tokenA.approve(address(board), AMOUNT_A * 2); + uint256 id0 = board.createOrder(address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B, false); + uint256 id1 = board.createOrder(address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B, false); + + vm.expectEmit(true, true, false, true); + emit ISwapboard.OrderCanceled(id0, maker, address(tokenA), AMOUNT_A); + vm.expectEmit(true, true, false, true); + emit ISwapboard.OrderCanceled(id1, maker, address(tokenA), AMOUNT_A); + + uint256[] memory ids = new uint256[](2); + ids[0] = id0; + ids[1] = id1; + board.cancelOrders(ids); + vm.stopPrank(); + } + + function test_cancelOrders_revert_notMaker() public { + vm.startPrank(maker); + tokenA.approve(address(board), AMOUNT_A * 2); + uint256 id0 = board.createOrder(address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B, false); + uint256 id1 = board.createOrder(address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B, false); + vm.stopPrank(); + + uint256[] memory ids = new uint256[](2); + ids[0] = id0; + ids[1] = id1; + + vm.startPrank(taker); + vm.expectRevert(abi.encodeWithSelector(ISwapboard.NotMaker.selector, id0, taker, maker)); + board.cancelOrders(ids); + vm.stopPrank(); + } + + function test_cancelOrders_revert_orderNotActive() public { + vm.startPrank(maker); + tokenA.approve(address(board), AMOUNT_A * 2); + uint256 id0 = board.createOrder(address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B, false); + uint256 id1 = board.createOrder(address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B, false); + board.cancelOrder(id0); + + uint256[] memory ids = new uint256[](2); + ids[0] = id0; + ids[1] = id1; + + vm.expectRevert(abi.encodeWithSelector(ISwapboard.OrderNotActive.selector, id0)); + board.cancelOrders(ids); + vm.stopPrank(); + } + + function test_cancelOrders_atomic_reverts() public { + // If second cancel fails, first cancel is rolled back + vm.startPrank(maker); + tokenA.approve(address(board), AMOUNT_A * 2); + uint256 id0 = board.createOrder(address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B, false); + uint256 id1 = board.createOrder(address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B, false); + board.cancelOrder(id1); // pre-cancel id1 + + uint256[] memory ids = new uint256[](2); + ids[0] = id0; + ids[1] = id1; + + vm.expectRevert(abi.encodeWithSelector(ISwapboard.OrderNotActive.selector, id1)); + board.cancelOrders(ids); + vm.stopPrank(); + + // id0 should still be active (atomic rollback) + assertTrue(board.getOrder(id0).active); + } + + function test_cancelOrders_mixedTokens() public { + // Cancel orders with different token pairs in one batch + MockERC20 tokenC = new MockERC20("Token C", "TKC", 18); + tokenC.mint(maker, AMOUNT_A); + + vm.startPrank(maker); + tokenA.approve(address(board), AMOUNT_A); + tokenC.approve(address(board), AMOUNT_A); + uint256 id0 = board.createOrder(address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B, false); + uint256 id1 = board.createOrder(address(tokenC), AMOUNT_A, address(tokenB), AMOUNT_B, false); + + uint256[] memory ids = new uint256[](2); + ids[0] = id0; + ids[1] = id1; + board.cancelOrders(ids); + vm.stopPrank(); + + assertFalse(board.getOrder(id0).active); + assertFalse(board.getOrder(id1).active); + } + + function test_cancelOrders_afterPartialFill() public { + vm.startPrank(maker); + tokenA.approve(address(board), AMOUNT_A * 2); + uint256 id0 = board.createOrder(address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B, true); + uint256 id1 = board.createOrder(address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B, false); + vm.stopPrank(); + + // Partially fill id0 + uint256 halfB = AMOUNT_B / 2; + vm.startPrank(taker); + tokenB.approve(address(board), halfB); + board.fillOrder(id0, 0, halfB); + vm.stopPrank(); + + uint256 remainingA = board.getOrder(id0).amountA; + uint256 balanceBefore = tokenA.balanceOf(maker); + + vm.startPrank(maker); + uint256[] memory ids = new uint256[](2); + ids[0] = id0; + ids[1] = id1; + board.cancelOrders(ids); + vm.stopPrank(); + + // Maker gets back remaining from partial + full from id1 + assertEq(tokenA.balanceOf(maker), balanceBefore + remainingA + AMOUNT_A); + } + function test_canFill() public { vm.startPrank(maker); tokenA.approve(address(board), AMOUNT_A); - uint256 orderId = board.createOrder(address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B); + uint256 orderId = + board.createOrder(address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B, false); vm.stopPrank(); assertTrue(board.canFill(orderId)); @@ -233,7 +389,8 @@ contract SwapboardTest is Test { function test_canFill_false_notActive() public { vm.startPrank(maker); tokenA.approve(address(board), AMOUNT_A); - uint256 orderId = board.createOrder(address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B); + uint256 orderId = + board.createOrder(address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B, false); board.cancelOrder(orderId); vm.stopPrank(); @@ -248,9 +405,12 @@ contract SwapboardTest is Test { vm.startPrank(maker); tokenA.approve(address(board), AMOUNT_A * 3); - uint256 order0 = board.createOrder(address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B); - uint256 order1 = board.createOrder(address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B * 2); - uint256 order2 = board.createOrder(address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B * 3); + uint256 order0 = + board.createOrder(address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B, false); + uint256 order1 = + board.createOrder(address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B * 2, false); + uint256 order2 = + board.createOrder(address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B * 3, false); vm.stopPrank(); uint256[] memory ids = new uint256[](3); @@ -276,9 +436,12 @@ contract SwapboardTest is Test { vm.startPrank(maker); tokenA.approve(address(board), AMOUNT_A * 3); - uint256 order0 = board.createOrder(address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B); - uint256 order1 = board.createOrder(address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B * 2); - uint256 order2 = board.createOrder(address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B * 3); + uint256 order0 = + board.createOrder(address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B, false); + uint256 order1 = + board.createOrder(address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B * 2, false); + uint256 order2 = + board.createOrder(address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B * 3, false); vm.stopPrank(); assertEq(order0, 0); @@ -293,8 +456,9 @@ contract SwapboardTest is Test { tokenB.mint(maker, AMOUNT_B); tokenB.approve(address(board), AMOUNT_B); - uint256 orderId = board.createOrder(address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B); - board.fillOrder(orderId, 0); + uint256 orderId = + board.createOrder(address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B, false); + board.fillOrder(orderId, 0, 0); vm.stopPrank(); assertEq(tokenA.balanceOf(maker), AMOUNT_A * 10 - AMOUNT_A + AMOUNT_A); @@ -306,35 +470,41 @@ contract SwapboardTest is Test { tokenA.approve(address(board), AMOUNT_A); vm.expectEmit(true, true, false, true); - emit ISwapboard.OrderCreated(0, maker, address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B); + emit ISwapboard.OrderCreated( + 0, maker, address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B, false + ); - board.createOrder(address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B); + board.createOrder(address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B, false); vm.stopPrank(); } function test_events_orderFilled() public { vm.startPrank(maker); tokenA.approve(address(board), AMOUNT_A); - uint256 orderId = board.createOrder(address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B); + uint256 orderId = + board.createOrder(address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B, false); vm.stopPrank(); vm.startPrank(taker); tokenB.approve(address(board), AMOUNT_B); - vm.expectEmit(true, true, false, true); - emit ISwapboard.OrderFilled(orderId, taker); + vm.expectEmit(true, true, true, true); + emit ISwapboard.OrderFilled( + orderId, taker, maker, address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B + ); - board.fillOrder(orderId, 0); + board.fillOrder(orderId, 0, 0); vm.stopPrank(); } function test_events_orderCanceled() public { vm.startPrank(maker); tokenA.approve(address(board), AMOUNT_A); - uint256 orderId = board.createOrder(address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B); + uint256 orderId = + board.createOrder(address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B, false); - vm.expectEmit(true, false, false, true); - emit ISwapboard.OrderCanceled(orderId); + vm.expectEmit(true, true, false, true); + emit ISwapboard.OrderCanceled(orderId, maker, address(tokenA), AMOUNT_A); board.cancelOrder(orderId); vm.stopPrank(); @@ -351,7 +521,8 @@ contract SwapboardTest is Test { vm.startPrank(maker); tokenA.approve(address(board), amountA); - uint256 orderId = board.createOrder(address(tokenA), amountA, address(tokenB), amountB); + uint256 orderId = + board.createOrder(address(tokenA), amountA, address(tokenB), amountB, false); vm.stopPrank(); ISwapboard.Order memory order = board.getOrder(orderId); @@ -371,12 +542,13 @@ contract SwapboardTest is Test { vm.startPrank(maker); tokenA.approve(address(board), amountA); - uint256 orderId = board.createOrder(address(tokenA), amountA, address(tokenB), amountB); + uint256 orderId = + board.createOrder(address(tokenA), amountA, address(tokenB), amountB, false); vm.stopPrank(); vm.startPrank(taker); tokenB.approve(address(board), amountB); - board.fillOrder(orderId, 0); + board.fillOrder(orderId, 0, 0); vm.stopPrank(); assertEq(tokenA.balanceOf(taker), amountA); @@ -386,7 +558,8 @@ contract SwapboardTest is Test { function test_fillOrder_revert_deadlineExpired() public { vm.startPrank(maker); tokenA.approve(address(board), AMOUNT_A); - uint256 orderId = board.createOrder(address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B); + uint256 orderId = + board.createOrder(address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B, false); vm.stopPrank(); vm.warp(1000); @@ -394,23 +567,835 @@ contract SwapboardTest is Test { vm.startPrank(taker); tokenB.approve(address(board), AMOUNT_B); vm.expectRevert(ISwapboard.DeadlineExpired.selector); - board.fillOrder(orderId, 999); + board.fillOrder(orderId, 999, 0); vm.stopPrank(); } function test_fillOrder_deadlineZero_noExpiry() public { vm.startPrank(maker); tokenA.approve(address(board), AMOUNT_A); - uint256 orderId = board.createOrder(address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B); + uint256 orderId = + board.createOrder(address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B, false); vm.stopPrank(); vm.warp(type(uint256).max); vm.startPrank(taker); tokenB.approve(address(board), AMOUNT_B); - board.fillOrder(orderId, 0); + board.fillOrder(orderId, 0, 0); + vm.stopPrank(); + + assertFalse(board.getOrder(orderId).active); + } + + // ============ Partial Fill Tests ============ + + function _createPartialOrder() internal returns (uint256 orderId) { + vm.startPrank(maker); + tokenA.approve(address(board), AMOUNT_A); + orderId = board.createOrder(address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B, true); + vm.stopPrank(); + } + + function test_fillOrder_partial_basic() public { + uint256 orderId = _createPartialOrder(); + uint256 halfB = AMOUNT_B / 2; + uint256 expectedA = (halfB * AMOUNT_A) / AMOUNT_B; + + vm.startPrank(taker); + tokenB.approve(address(board), halfB); + board.fillOrder(orderId, 0, halfB); + vm.stopPrank(); + + ISwapboard.Order memory order = board.getOrder(orderId); + assertTrue(order.active); + assertEq(order.amountA, AMOUNT_A - expectedA); + assertEq(order.amountB, AMOUNT_B - halfB); + assertEq(tokenA.balanceOf(taker), expectedA); + assertEq(tokenB.balanceOf(maker), halfB); + } + + function test_fillOrder_partial_multipleFillers() public { + uint256 orderId = _createPartialOrder(); + address taker2 = address(0x3); + tokenB.mint(taker2, AMOUNT_B); + + uint256 quarterB = AMOUNT_B / 4; + + // First taker fills 25% + vm.startPrank(taker); + tokenB.approve(address(board), quarterB); + board.fillOrder(orderId, 0, quarterB); + vm.stopPrank(); + + uint256 remainB = AMOUNT_B - quarterB; + + // Second taker fills the rest + vm.startPrank(taker2); + tokenB.approve(address(board), remainB); + board.fillOrder(orderId, 0, remainB); + vm.stopPrank(); + + ISwapboard.Order memory order = board.getOrder(orderId); + assertFalse(order.active); + assertEq(tokenB.balanceOf(maker), AMOUNT_B); + } + + function test_fillOrder_partial_fullFillWhenExceedsRemaining() public { + uint256 orderId = _createPartialOrder(); + + vm.startPrank(taker); + tokenB.approve(address(board), AMOUNT_B * 2); + // Passing more than amountB should behave as full fill + board.fillOrder(orderId, 0, AMOUNT_B * 2); + vm.stopPrank(); + + ISwapboard.Order memory order = board.getOrder(orderId); + assertFalse(order.active); + assertEq(tokenA.balanceOf(taker), AMOUNT_A); + assertEq(tokenB.balanceOf(maker), AMOUNT_B); + } + + function test_fillOrder_partial_fullFillOnExactRemaining() public { + uint256 orderId = _createPartialOrder(); + + vm.startPrank(taker); + tokenB.approve(address(board), AMOUNT_B); + board.fillOrder(orderId, 0, AMOUNT_B); + vm.stopPrank(); + + assertFalse(board.getOrder(orderId).active); + assertEq(tokenA.balanceOf(taker), AMOUNT_A); + } + + function test_fillOrder_partial_revert_notPartialFillable() public { + vm.startPrank(maker); + tokenA.approve(address(board), AMOUNT_A); + uint256 orderId = + board.createOrder(address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B, false); + vm.stopPrank(); + + vm.startPrank(taker); + tokenB.approve(address(board), AMOUNT_B / 2); + vm.expectRevert(abi.encodeWithSelector(ISwapboard.PartialFillNotAllowed.selector, orderId)); + board.fillOrder(orderId, 0, AMOUNT_B / 2); + vm.stopPrank(); + } + + function test_fillOrder_partial_revert_deadlineExpired() public { + uint256 orderId = _createPartialOrder(); + + vm.warp(1001); + + vm.startPrank(taker); + tokenB.approve(address(board), AMOUNT_B / 2); + vm.expectRevert(ISwapboard.DeadlineExpired.selector); + board.fillOrder(orderId, 1000, AMOUNT_B / 2); + vm.stopPrank(); + } + + function test_fillOrder_partial_revert_orderNotActive() public { + uint256 orderId = _createPartialOrder(); + + // Cancel the order first + vm.prank(maker); + board.cancelOrder(orderId); + + vm.startPrank(taker); + tokenB.approve(address(board), AMOUNT_B / 2); + vm.expectRevert(abi.encodeWithSelector(ISwapboard.OrderNotActive.selector, orderId)); + board.fillOrder(orderId, 0, AMOUNT_B / 2); + vm.stopPrank(); + } + + function test_fillOrder_partial_roundsDownFavoringMaker() public { + // Create order: 10 tokenA for 3 tokenB (indivisible ratio) + vm.startPrank(maker); + tokenA.approve(address(board), 10); + uint256 orderId = board.createOrder(address(tokenA), 10, address(tokenB), 3, true); + vm.stopPrank(); + + // Fill 1 tokenB: expected tokenA = 1 * 10 / 3 = 3 (rounds down from 3.33) + vm.startPrank(taker); + tokenB.approve(address(board), 1); + board.fillOrder(orderId, 0, 1); + vm.stopPrank(); + + assertEq(tokenA.balanceOf(taker), 3); + ISwapboard.Order memory order = board.getOrder(orderId); + assertEq(order.amountA, 7); // 10 - 3 + assertEq(order.amountB, 2); // 3 - 1 + } + + function test_fillOrder_partial_revert_zeroComputedAmountA() public { + // Create order: 1 tokenA for 1000 tokenB + vm.startPrank(maker); + tokenA.approve(address(board), 1); + uint256 orderId = board.createOrder(address(tokenA), 1, address(tokenB), 1000, true); + vm.stopPrank(); + + // Fill 1 tokenB: expected tokenA = 1 * 1 / 1000 = 0 (rounds to zero) + vm.startPrank(taker); + tokenB.approve(address(board), 1); + vm.expectRevert(ISwapboard.ZeroFillAmount.selector); + board.fillOrder(orderId, 0, 1); + vm.stopPrank(); + } + + function test_fillOrder_partial_event() public { + uint256 orderId = _createPartialOrder(); + uint256 halfB = AMOUNT_B / 2; + uint256 expectedA = (halfB * AMOUNT_A) / AMOUNT_B; + + vm.startPrank(taker); + tokenB.approve(address(board), halfB); + + vm.expectEmit(true, true, true, true); + emit ISwapboard.OrderPartiallyFilled( + orderId, + taker, + maker, + address(tokenA), + expectedA, + address(tokenB), + halfB, + AMOUNT_A - expectedA, + AMOUNT_B - halfB + ); + board.fillOrder(orderId, 0, halfB); + vm.stopPrank(); + } + + function test_fillOrder_worksOnPartialFillableOrder() public { + uint256 orderId = _createPartialOrder(); + + vm.startPrank(taker); + tokenB.approve(address(board), AMOUNT_B); + board.fillOrder(orderId, 0, 0); + vm.stopPrank(); + + assertFalse(board.getOrder(orderId).active); + assertEq(tokenA.balanceOf(taker), AMOUNT_A); + } + + function test_fillOrder_partial_largeAmountsNoOverflow() public { + // fillAmountB * amountA overflows uint256 when both exceed 2^128. + // mulDiv handles this via 512-bit intermediate math; naive multiplication would revert. + uint256 largeA = 2 ** 255; + uint256 largeB = 2 ** 255 - 1; + + tokenA.mint(maker, largeA); + tokenB.mint(taker, largeB); + + vm.startPrank(maker); + tokenA.approve(address(board), largeA); + uint256 orderId = board.createOrder(address(tokenA), largeA, address(tokenB), largeB, true); + vm.stopPrank(); + + uint256 halfB = largeB / 2; + vm.startPrank(taker); + tokenB.approve(address(board), halfB); + board.fillOrder(orderId, 0, halfB); + vm.stopPrank(); + + ISwapboard.Order memory order = board.getOrder(orderId); + assertTrue(order.active); + assertTrue(order.amountA > 0); + assertEq(order.amountB, largeB - halfB); + } + + function test_fillOrder_partial_revert_orderNotFound() public { + vm.startPrank(taker); + vm.expectRevert(abi.encodeWithSelector(ISwapboard.OrderNotFound.selector, 999)); + board.fillOrder(999, 0, 1); + vm.stopPrank(); + } + + function test_fillOrder_partial_thenCancel() public { + uint256 orderId = _createPartialOrder(); + uint256 halfB = AMOUNT_B / 2; + uint256 expectedA = (halfB * AMOUNT_A) / AMOUNT_B; + + vm.startPrank(taker); + tokenB.approve(address(board), halfB); + board.fillOrder(orderId, 0, halfB); + vm.stopPrank(); + + uint256 makerBalanceBefore = tokenA.balanceOf(maker); + + vm.prank(maker); + board.cancelOrder(orderId); + + // Maker gets back only the remaining tokenA, not the original full amount + uint256 remaining = AMOUNT_A - expectedA; + assertEq(tokenA.balanceOf(maker), makerBalanceBefore + remaining); + assertFalse(board.getOrder(orderId).active); + } + + function test_fillOrder_partial_thenFillOrder() public { + uint256 orderId = _createPartialOrder(); + uint256 quarterB = AMOUNT_B / 4; + uint256 filledA = (quarterB * AMOUNT_A) / AMOUNT_B; + + // Partial fill 25% + vm.startPrank(taker); + tokenB.approve(address(board), quarterB); + board.fillOrder(orderId, 0, quarterB); + vm.stopPrank(); + + uint256 remainB = AMOUNT_B - quarterB; + uint256 remainA = AMOUNT_A - filledA; + + // Full fill the remainder via fillOrder + address taker2 = address(0x3); + tokenB.mint(taker2, remainB); + vm.startPrank(taker2); + tokenB.approve(address(board), remainB); + board.fillOrder(orderId, 0, 0); + vm.stopPrank(); + + assertFalse(board.getOrder(orderId).active); + assertEq(tokenA.balanceOf(taker2), remainA); + assertEq(tokenB.balanceOf(maker), AMOUNT_B); + } + + function test_fillOrder_partial_sequentialFillsDrainOrder() public { + // 100 tokenA for 10 tokenB — fill 1 tokenB at a time + uint256 amtA = 100; + uint256 amtB = 10; + tokenA.mint(maker, amtA); + tokenB.mint(taker, amtB); + + vm.startPrank(maker); + tokenA.approve(address(board), amtA); + uint256 orderId = board.createOrder(address(tokenA), amtA, address(tokenB), amtB, true); + vm.stopPrank(); + + vm.startPrank(taker); + tokenB.approve(address(board), amtB); + + // Fill 1 tokenB nine times (partial), then 1 more (triggers full fill path) + for (uint256 i = 0; i < 10; i++) { + board.fillOrder(orderId, 0, 1); + } + vm.stopPrank(); + + assertFalse(board.getOrder(orderId).active); + assertEq(tokenA.balanceOf(taker), amtA); + assertEq(tokenB.balanceOf(maker), amtB); + } + + function test_fillOrder_partial_contractBalanceInvariant() public { + // Create two partial orders + tokenA.mint(maker, AMOUNT_A); + vm.startPrank(maker); + tokenA.approve(address(board), AMOUNT_A * 2); + uint256 id0 = board.createOrder(address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B, true); + uint256 id1 = board.createOrder(address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B, true); + vm.stopPrank(); + + // Partially fill both + uint256 halfB = AMOUNT_B / 2; + tokenB.mint(taker, AMOUNT_B); + vm.startPrank(taker); + tokenB.approve(address(board), AMOUNT_B); + board.fillOrder(id0, 0, halfB); + board.fillOrder(id1, 0, halfB); + vm.stopPrank(); + + // Contract balance should equal sum of remaining amountA across active orders + ISwapboard.Order memory o0 = board.getOrder(id0); + ISwapboard.Order memory o1 = board.getOrder(id1); + assertEq(tokenA.balanceOf(address(board)), o0.amountA + o1.amountA); + } + + function test_fillOrder_partial_selfFill() public { + // Maker fills their own partial order + uint256 orderId = _createPartialOrder(); + uint256 halfB = AMOUNT_B / 2; + tokenB.mint(maker, halfB); + + vm.startPrank(maker); + tokenB.approve(address(board), halfB); + board.fillOrder(orderId, 0, halfB); + vm.stopPrank(); + + assertTrue(board.getOrder(orderId).active); + } + + function test_fillOrder_partial_fullFillEmitsOrderFilled() public { + uint256 orderId = _createPartialOrder(); + + vm.startPrank(taker); + tokenB.approve(address(board), AMOUNT_B); + + vm.expectEmit(true, true, true, false); + emit ISwapboard.OrderFilled( + orderId, taker, maker, address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B + ); + board.fillOrder(orderId, 0, AMOUNT_B); + vm.stopPrank(); + } + + function test_fillOrder_partial_deadlineZero_noExpiry() public { + uint256 orderId = _createPartialOrder(); + + vm.warp(type(uint256).max); + + vm.startPrank(taker); + tokenB.approve(address(board), AMOUNT_B / 2); + board.fillOrder(orderId, 0, AMOUNT_B / 2); + vm.stopPrank(); + + assertTrue(board.getOrder(orderId).active); + } + + function testFuzz_fillOrder_partial( + uint256 amountA, + uint256 amountB, + uint256 fillAmountB + ) public { + amountA = bound(amountA, 1, 1000 ether); + amountB = bound(amountB, 1, 1000 ether); + fillAmountB = bound(fillAmountB, 1, amountB); + + tokenA.mint(maker, amountA); + tokenB.mint(taker, fillAmountB); + + vm.startPrank(maker); + tokenA.approve(address(board), amountA); + uint256 orderId = + board.createOrder(address(tokenA), amountA, address(tokenB), amountB, true); + vm.stopPrank(); + + uint256 expectedA = (fillAmountB * amountA) / amountB; + + // If fillAmountB equals amountB, it's a full fill — always succeeds + // If fillAmountB < amountB and expectedA == 0, it should revert + if (fillAmountB < amountB && expectedA == 0) { + vm.startPrank(taker); + tokenB.approve(address(board), fillAmountB); + vm.expectRevert(ISwapboard.ZeroFillAmount.selector); + board.fillOrder(orderId, 0, fillAmountB); + vm.stopPrank(); + return; + } + + vm.startPrank(taker); + tokenB.approve(address(board), fillAmountB); + board.fillOrder(orderId, 0, fillAmountB); + vm.stopPrank(); + + ISwapboard.Order memory order = board.getOrder(orderId); + + if (fillAmountB >= amountB) { + // Full fill + assertFalse(order.active); + assertEq(tokenA.balanceOf(taker), amountA); + } else { + // Partial fill + assertTrue(order.active); + assertEq(tokenA.balanceOf(taker), expectedA); + assertEq(order.amountA, amountA - expectedA); + assertEq(order.amountB, amountB - fillAmountB); + // Contract holds exactly the remaining tokenA for this order + assertEq(tokenA.balanceOf(address(board)), amountA - expectedA); + } + } + + // ============ fillOrders ============ + + function test_fillOrders_basic() public { + // Create two orders + tokenA.mint(maker, AMOUNT_A); + vm.startPrank(maker); + tokenA.approve(address(board), AMOUNT_A * 2); + uint256 id0 = board.createOrder(address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B, false); + uint256 id1 = board.createOrder(address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B, false); + vm.stopPrank(); + + tokenB.mint(taker, AMOUNT_B); + vm.startPrank(taker); + tokenB.approve(address(board), AMOUNT_B * 2); + + uint256[] memory ids = new uint256[](2); + ids[0] = id0; + ids[1] = id1; + uint256[] memory amounts = new uint256[](2); + amounts[0] = 0; // full fill + amounts[1] = 0; // full fill + + board.fillOrders(ids, 0, amounts); + vm.stopPrank(); + + assertFalse(board.getOrder(id0).active); + assertFalse(board.getOrder(id1).active); + assertEq(tokenA.balanceOf(taker), AMOUNT_A * 2); + assertEq(tokenB.balanceOf(maker), AMOUNT_B * 2); + } + + function test_fillOrders_partialAndFull() public { + tokenA.mint(maker, AMOUNT_A); + vm.startPrank(maker); + tokenA.approve(address(board), AMOUNT_A * 2); + uint256 id0 = board.createOrder(address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B, true); + uint256 id1 = board.createOrder(address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B, false); + vm.stopPrank(); + + uint256 halfB = AMOUNT_B / 2; + tokenB.mint(taker, AMOUNT_B); + vm.startPrank(taker); + tokenB.approve(address(board), AMOUNT_B + halfB); + + uint256[] memory ids = new uint256[](2); + ids[0] = id0; + ids[1] = id1; + uint256[] memory amounts = new uint256[](2); + amounts[0] = halfB; // partial + amounts[1] = 0; // full + + board.fillOrders(ids, 0, amounts); + vm.stopPrank(); + + assertTrue(board.getOrder(id0).active); + assertFalse(board.getOrder(id1).active); + } + + function test_fillOrders_revert_lengthMismatch() public { + uint256[] memory ids = new uint256[](2); + uint256[] memory amounts = new uint256[](1); + + vm.expectRevert(ISwapboard.LengthMismatch.selector); + board.fillOrders(ids, 0, amounts); + } + + function test_fillOrders_revert_deadlineExpired() public { + vm.warp(1001); + + uint256[] memory ids = new uint256[](0); + uint256[] memory amounts = new uint256[](0); + + vm.expectRevert(ISwapboard.DeadlineExpired.selector); + board.fillOrders(ids, 1000, amounts); + } + + function test_fillOrders_atomic_reverts() public { + // Create one valid order + vm.startPrank(maker); + tokenA.approve(address(board), AMOUNT_A); + uint256 id0 = board.createOrder(address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B, false); + vm.stopPrank(); + + vm.startPrank(taker); + tokenB.approve(address(board), AMOUNT_B * 2); + + uint256[] memory ids = new uint256[](2); + ids[0] = id0; + ids[1] = 999; // non-existent + uint256[] memory amounts = new uint256[](2); + amounts[0] = 0; + amounts[1] = 0; + + // Entire batch reverts because order 999 doesn't exist + vm.expectRevert(abi.encodeWithSelector(ISwapboard.OrderNotFound.selector, 999)); + board.fillOrders(ids, 0, amounts); + vm.stopPrank(); + + // First order should still be active (batch was atomic) + assertTrue(board.getOrder(id0).active); + } + + function test_fillOrders_empty() public { + uint256[] memory ids = new uint256[](0); + uint256[] memory amounts = new uint256[](0); + + // Should succeed as a no-op + board.fillOrders(ids, 0, amounts); + } + + // ============ createOrders ============ + + function test_createOrders_basic() public { + tokenA.mint(maker, AMOUNT_A); + vm.startPrank(maker); + tokenA.approve(address(board), AMOUNT_A * 2); + + ISwapboard.CreateOrderParams[] memory params = new ISwapboard.CreateOrderParams[](2); + params[0] = ISwapboard.CreateOrderParams( + address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B, false + ); + params[1] = ISwapboard.CreateOrderParams( + address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B, true + ); + + uint256[] memory ids = board.createOrders(params); + vm.stopPrank(); + + assertEq(ids.length, 2); + assertEq(ids[0], 0); + assertEq(ids[1], 1); + + ISwapboard.Order memory o0 = board.getOrder(ids[0]); + assertFalse(o0.partialFill); + assertTrue(o0.active); + + ISwapboard.Order memory o1 = board.getOrder(ids[1]); + assertTrue(o1.partialFill); + assertTrue(o1.active); + + assertEq(tokenA.balanceOf(address(board)), AMOUNT_A * 2); + } + + function test_createOrders_empty() public { + ISwapboard.CreateOrderParams[] memory params = new ISwapboard.CreateOrderParams[](0); + uint256[] memory ids = board.createOrders(params); + assertEq(ids.length, 0); + } + + function test_createOrders_atomic_reverts() public { + vm.startPrank(maker); + tokenA.approve(address(board), AMOUNT_A * 2); + + ISwapboard.CreateOrderParams[] memory params = new ISwapboard.CreateOrderParams[](2); + params[0] = ISwapboard.CreateOrderParams( + address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B, false + ); + params[1] = + ISwapboard.CreateOrderParams(address(tokenA), AMOUNT_A, address(tokenB), 0, false); // zero amountB + + vm.expectRevert(ISwapboard.ZeroAmount.selector); + board.createOrders(params); + vm.stopPrank(); + + // No orders created (atomic) + assertEq(board.nextOrderId(), 0); + } + + function test_createOrders_mixedTokenPairs() public { + MockERC20 tokenC = new MockERC20("Token C", "TKC", 18); + tokenA.mint(maker, AMOUNT_A); + tokenC.mint(maker, AMOUNT_A); + + vm.startPrank(maker); + tokenA.approve(address(board), AMOUNT_A); + tokenC.approve(address(board), AMOUNT_A); + + ISwapboard.CreateOrderParams[] memory params = new ISwapboard.CreateOrderParams[](2); + params[0] = ISwapboard.CreateOrderParams( + address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B, false + ); + params[1] = ISwapboard.CreateOrderParams( + address(tokenC), AMOUNT_A, address(tokenA), AMOUNT_B, true + ); + + uint256[] memory ids = board.createOrders(params); + vm.stopPrank(); + + ISwapboard.Order memory o0 = board.getOrder(ids[0]); + assertEq(o0.tokenA, address(tokenA)); + assertEq(o0.tokenB, address(tokenB)); + + ISwapboard.Order memory o1 = board.getOrder(ids[1]); + assertEq(o1.tokenA, address(tokenC)); + assertEq(o1.tokenB, address(tokenA)); + assertTrue(o1.partialFill); + } + + function test_fillOrders_withPartialFills() public { + tokenA.mint(maker, AMOUNT_A); + vm.startPrank(maker); + tokenA.approve(address(board), AMOUNT_A * 2); + uint256 id0 = board.createOrder(address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B, true); + uint256 id1 = board.createOrder(address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B, true); + vm.stopPrank(); + + uint256 quarterB = AMOUNT_B / 4; + uint256 halfB = AMOUNT_B / 2; + tokenB.mint(taker, AMOUNT_B); + vm.startPrank(taker); + tokenB.approve(address(board), quarterB + halfB); + + uint256[] memory ids = new uint256[](2); + ids[0] = id0; + ids[1] = id1; + uint256[] memory amounts = new uint256[](2); + amounts[0] = quarterB; + amounts[1] = halfB; + + board.fillOrders(ids, 0, amounts); + vm.stopPrank(); + + // Both orders still active with reduced amounts + ISwapboard.Order memory o0 = board.getOrder(id0); + assertTrue(o0.active); + assertEq(o0.amountB, AMOUNT_B - quarterB); + + ISwapboard.Order memory o1 = board.getOrder(id1); + assertTrue(o1.active); + assertEq(o1.amountB, AMOUNT_B - halfB); + } + + function test_fillOrders_sameOrderTwice() public { + // Partially fill the same order twice in one batch + vm.startPrank(maker); + tokenA.approve(address(board), AMOUNT_A); + uint256 orderId = + board.createOrder(address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B, true); + vm.stopPrank(); + + uint256 quarterB = AMOUNT_B / 4; + tokenB.mint(taker, AMOUNT_B); + vm.startPrank(taker); + tokenB.approve(address(board), quarterB * 2); + + uint256[] memory ids = new uint256[](2); + ids[0] = orderId; + ids[1] = orderId; + uint256[] memory amounts = new uint256[](2); + amounts[0] = quarterB; + amounts[1] = quarterB; + + board.fillOrders(ids, 0, amounts); + vm.stopPrank(); + + ISwapboard.Order memory order = board.getOrder(orderId); + assertTrue(order.active); + assertEq(order.amountB, AMOUNT_B - quarterB * 2); + } + + function test_createOrders_thenFillOrders() public { + // End-to-end: batch create, then batch fill + tokenA.mint(maker, AMOUNT_A * 2); + vm.startPrank(maker); + tokenA.approve(address(board), AMOUNT_A * 3); + + ISwapboard.CreateOrderParams[] memory params = new ISwapboard.CreateOrderParams[](3); + params[0] = ISwapboard.CreateOrderParams( + address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B, false + ); + params[1] = ISwapboard.CreateOrderParams( + address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B, false + ); + params[2] = ISwapboard.CreateOrderParams( + address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B, false + ); + + uint256[] memory ids = board.createOrders(params); + vm.stopPrank(); + + tokenB.mint(taker, AMOUNT_B * 2); + vm.startPrank(taker); + tokenB.approve(address(board), AMOUNT_B * 3); + + uint256[] memory fillAmounts = new uint256[](3); + fillAmounts[0] = 0; + fillAmounts[1] = 0; + fillAmounts[2] = 0; + + board.fillOrders(ids, 0, fillAmounts); + vm.stopPrank(); + + assertFalse(board.getOrder(ids[0]).active); + assertFalse(board.getOrder(ids[1]).active); + assertFalse(board.getOrder(ids[2]).active); + } + + function test_createOrder_partialFillFlag() public { + uint256 orderId = _createPartialOrder(); + ISwapboard.Order memory order = board.getOrder(orderId); + assertTrue(order.partialFill); + + vm.startPrank(maker); + tokenA.approve(address(board), AMOUNT_A); + uint256 orderId2 = + board.createOrder(address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B, false); + vm.stopPrank(); + + ISwapboard.Order memory order2 = board.getOrder(orderId2); + assertFalse(order2.partialFill); + } + + function test_fillOrder_zeroFillsRemainderAfterPartial() public { + uint256 orderId = _createPartialOrder(); + uint256 halfB = AMOUNT_B / 2; + uint256 filledA = (halfB * AMOUNT_A) / AMOUNT_B; + + // Partial fill half + vm.startPrank(taker); + tokenB.approve(address(board), halfB); + board.fillOrder(orderId, 0, halfB); + vm.stopPrank(); + + uint256 remainA = AMOUNT_A - filledA; + uint256 remainB = AMOUNT_B - halfB; + + // fillAmountB == 0 fills the remainder, not the original amount + address taker2 = address(0x3); + tokenB.mint(taker2, remainB); + vm.startPrank(taker2); + tokenB.approve(address(board), remainB); + board.fillOrder(orderId, 0, 0); vm.stopPrank(); assertFalse(board.getOrder(orderId).active); + assertEq(tokenA.balanceOf(taker2), remainA); + assertEq(tokenB.balanceOf(maker), AMOUNT_B); + } + + // ============ Zeroed state after full fill ============ + + function test_fullFill_zerosAmounts() public { + vm.startPrank(maker); + tokenA.approve(address(board), AMOUNT_A); + uint256 orderId = + board.createOrder(address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B, false); + vm.stopPrank(); + + vm.startPrank(taker); + tokenB.approve(address(board), AMOUNT_B); + board.fillOrder(orderId, 0, 0); + vm.stopPrank(); + + ISwapboard.Order memory order = board.getOrder(orderId); + assertFalse(order.active); + assertEq(order.amountA, 0, "amountA should be zeroed after full fill"); + assertEq(order.amountB, 0, "amountB should be zeroed after full fill"); + } + + function test_fullFill_zerosAmounts_afterPartials() public { + vm.startPrank(maker); + tokenA.approve(address(board), AMOUNT_A); + uint256 orderId = + board.createOrder(address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B, true); + vm.stopPrank(); + + // Partial fill half + uint256 halfB = AMOUNT_B / 2; + vm.startPrank(taker); + tokenB.approve(address(board), AMOUNT_B); + board.fillOrder(orderId, 0, halfB); + + // Full fill remainder (fillAmountB == 0 means fill all) + board.fillOrder(orderId, 0, 0); + vm.stopPrank(); + + ISwapboard.Order memory order = board.getOrder(orderId); + assertFalse(order.active); + assertEq(order.amountA, 0, "amountA should be zeroed after final fill"); + assertEq(order.amountB, 0, "amountB should be zeroed after final fill"); + } + + function test_cancelOrder_zerosAmounts() public { + vm.startPrank(maker); + tokenA.approve(address(board), AMOUNT_A); + uint256 orderId = + board.createOrder(address(tokenA), AMOUNT_A, address(tokenB), AMOUNT_B, false); + board.cancelOrder(orderId); + vm.stopPrank(); + + ISwapboard.Order memory order = board.getOrder(orderId); + assertFalse(order.active); + // Cancel doesn't zero amounts (only fill does) — amounts reflect what was remaining + // This is fine since active=false gates all access } } diff --git a/contracts/test/SwapboardETH.t.sol b/contracts/test/SwapboardETH.t.sol index 8d8a7a6..6336531 100644 --- a/contracts/test/SwapboardETH.t.sol +++ b/contracts/test/SwapboardETH.t.sol @@ -43,7 +43,8 @@ contract SwapboardETHTest is Test { function test_createOrderWithEth() public { vm.prank(maker); - uint256 orderId = board.createOrderWithEth{value: ETH_AMOUNT}(address(token), TOKEN_AMOUNT); + uint256 orderId = + board.createOrderWithEth{value: ETH_AMOUNT}(address(token), TOKEN_AMOUNT, false); ISwapboard.Order memory order = board.getOrder(orderId); assertEq(order.maker, maker); @@ -60,7 +61,7 @@ contract SwapboardETHTest is Test { uint256 wethBefore = weth.balanceOf(address(board)); vm.prank(maker); - board.createOrderWithEth{value: ETH_AMOUNT}(address(token), TOKEN_AMOUNT); + board.createOrderWithEth{value: ETH_AMOUNT}(address(token), TOKEN_AMOUNT, false); assertEq(weth.balanceOf(address(board)), wethBefore + ETH_AMOUNT); } @@ -68,47 +69,49 @@ contract SwapboardETHTest is Test { function test_createOrderWithEth_event() public { vm.expectEmit(true, true, false, true); emit ISwapboard.OrderCreated( - 0, maker, address(weth), ETH_AMOUNT, address(token), TOKEN_AMOUNT + 0, maker, address(weth), ETH_AMOUNT, address(token), TOKEN_AMOUNT, false ); vm.prank(maker); - board.createOrderWithEth{value: ETH_AMOUNT}(address(token), TOKEN_AMOUNT); + board.createOrderWithEth{value: ETH_AMOUNT}(address(token), TOKEN_AMOUNT, false); } function test_createOrderWithEth_revert_zeroETH() public { vm.prank(maker); vm.expectRevert(ISwapboard.ZeroETH.selector); - board.createOrderWithEth{value: 0}(address(token), TOKEN_AMOUNT); + board.createOrderWithEth{value: 0}(address(token), TOKEN_AMOUNT, false); } function test_createOrderWithEth_revert_zeroAddress() public { vm.prank(maker); vm.expectRevert(ISwapboard.ZeroAddress.selector); - board.createOrderWithEth{value: ETH_AMOUNT}(address(0), TOKEN_AMOUNT); + board.createOrderWithEth{value: ETH_AMOUNT}(address(0), TOKEN_AMOUNT, false); } function test_createOrderWithEth_revert_zeroAmount() public { vm.prank(maker); vm.expectRevert(ISwapboard.ZeroAmount.selector); - board.createOrderWithEth{value: ETH_AMOUNT}(address(token), 0); + board.createOrderWithEth{value: ETH_AMOUNT}(address(token), 0, false); } function test_createOrderWithEth_revert_sameToken() public { vm.prank(maker); vm.expectRevert(ISwapboard.SameToken.selector); - board.createOrderWithEth{value: ETH_AMOUNT}(address(weth), TOKEN_AMOUNT); + board.createOrderWithEth{value: ETH_AMOUNT}(address(weth), TOKEN_AMOUNT, false); } function test_createOrderWithEth_revert_notAContract() public { vm.prank(maker); vm.expectRevert(abi.encodeWithSelector(ISwapboard.NotAContract.selector, address(0xDEAD))); - board.createOrderWithEth{value: ETH_AMOUNT}(address(0xDEAD), TOKEN_AMOUNT); + board.createOrderWithEth{value: ETH_AMOUNT}(address(0xDEAD), TOKEN_AMOUNT, false); } function test_createOrderWithEth_sequentialIds() public { vm.startPrank(maker); - uint256 id0 = board.createOrderWithEth{value: ETH_AMOUNT}(address(token), TOKEN_AMOUNT); - uint256 id1 = board.createOrderWithEth{value: ETH_AMOUNT}(address(token), TOKEN_AMOUNT); + uint256 id0 = + board.createOrderWithEth{value: ETH_AMOUNT}(address(token), TOKEN_AMOUNT, false); + uint256 id1 = + board.createOrderWithEth{value: ETH_AMOUNT}(address(token), TOKEN_AMOUNT, false); vm.stopPrank(); assertEq(id0, 0); @@ -121,7 +124,8 @@ contract SwapboardETHTest is Test { // Maker creates order: sells token, wants WETH vm.startPrank(maker); token.approve(address(board), TOKEN_AMOUNT); - uint256 orderId = board.createOrder(address(token), TOKEN_AMOUNT, address(weth), ETH_AMOUNT); + uint256 orderId = + board.createOrder(address(token), TOKEN_AMOUNT, address(weth), ETH_AMOUNT, false); vm.stopPrank(); uint256 makerWethBefore = weth.balanceOf(maker); @@ -139,11 +143,14 @@ contract SwapboardETHTest is Test { function test_fillOrderWithEth_event() public { vm.startPrank(maker); token.approve(address(board), TOKEN_AMOUNT); - uint256 orderId = board.createOrder(address(token), TOKEN_AMOUNT, address(weth), ETH_AMOUNT); + uint256 orderId = + board.createOrder(address(token), TOKEN_AMOUNT, address(weth), ETH_AMOUNT, false); vm.stopPrank(); - vm.expectEmit(true, true, false, true); - emit ISwapboard.OrderFilled(orderId, taker); + vm.expectEmit(true, true, true, true); + emit ISwapboard.OrderFilled( + orderId, taker, maker, address(token), TOKEN_AMOUNT, address(weth), ETH_AMOUNT + ); vm.prank(taker); board.fillOrderWithEth{value: ETH_AMOUNT}(orderId, 0); @@ -153,7 +160,8 @@ contract SwapboardETHTest is Test { // Order wants tokenB, not WETH vm.startPrank(maker); token.approve(address(board), TOKEN_AMOUNT); - uint256 orderId = board.createOrder(address(token), TOKEN_AMOUNT, address(tokenB), 1 ether); + uint256 orderId = + board.createOrder(address(token), TOKEN_AMOUNT, address(tokenB), 1 ether, false); vm.stopPrank(); vm.prank(taker); @@ -163,25 +171,24 @@ contract SwapboardETHTest is Test { board.fillOrderWithEth{value: 1 ether}(orderId, 0); } - function test_fillOrderWithEth_revert_amountMismatch_tooLow() public { + function test_fillOrderWithEth_revert_partialNotAllowed() public { vm.startPrank(maker); token.approve(address(board), TOKEN_AMOUNT); - uint256 orderId = board.createOrder(address(token), TOKEN_AMOUNT, address(weth), ETH_AMOUNT); + uint256 orderId = + board.createOrder(address(token), TOKEN_AMOUNT, address(weth), ETH_AMOUNT, false); vm.stopPrank(); + // msg.value < amountB on a non-partial order reverts vm.prank(taker); - vm.expectRevert( - abi.encodeWithSelector( - ISwapboard.ETHAmountMismatch.selector, ETH_AMOUNT, ETH_AMOUNT - 1 - ) - ); + vm.expectRevert(abi.encodeWithSelector(ISwapboard.PartialFillNotAllowed.selector, orderId)); board.fillOrderWithEth{value: ETH_AMOUNT - 1}(orderId, 0); } function test_fillOrderWithEth_revert_amountMismatch_tooHigh() public { vm.startPrank(maker); token.approve(address(board), TOKEN_AMOUNT); - uint256 orderId = board.createOrder(address(token), TOKEN_AMOUNT, address(weth), ETH_AMOUNT); + uint256 orderId = + board.createOrder(address(token), TOKEN_AMOUNT, address(weth), ETH_AMOUNT, false); vm.stopPrank(); vm.prank(taker); @@ -202,7 +209,8 @@ contract SwapboardETHTest is Test { function test_fillOrderWithEth_revert_orderNotActive() public { vm.startPrank(maker); token.approve(address(board), TOKEN_AMOUNT); - uint256 orderId = board.createOrder(address(token), TOKEN_AMOUNT, address(weth), ETH_AMOUNT); + uint256 orderId = + board.createOrder(address(token), TOKEN_AMOUNT, address(weth), ETH_AMOUNT, false); vm.stopPrank(); // Fill once @@ -219,7 +227,8 @@ contract SwapboardETHTest is Test { function test_cancelOrderUnwrap() public { vm.prank(maker); - uint256 orderId = board.createOrderWithEth{value: ETH_AMOUNT}(address(token), TOKEN_AMOUNT); + uint256 orderId = + board.createOrderWithEth{value: ETH_AMOUNT}(address(token), TOKEN_AMOUNT, false); uint256 makerEthBefore = maker.balance; @@ -234,10 +243,11 @@ contract SwapboardETHTest is Test { function test_cancelOrderUnwrap_event() public { vm.prank(maker); - uint256 orderId = board.createOrderWithEth{value: ETH_AMOUNT}(address(token), TOKEN_AMOUNT); + uint256 orderId = + board.createOrderWithEth{value: ETH_AMOUNT}(address(token), TOKEN_AMOUNT, false); - vm.expectEmit(true, false, false, true); - emit ISwapboard.OrderCanceled(orderId); + vm.expectEmit(true, true, false, true); + emit ISwapboard.OrderCanceled(orderId, maker, address(weth), ETH_AMOUNT); vm.prank(maker); board.cancelOrderUnwrap(orderId); @@ -247,7 +257,8 @@ contract SwapboardETHTest is Test { // Create a regular ERC20 order vm.startPrank(maker); token.approve(address(board), TOKEN_AMOUNT); - uint256 orderId = board.createOrder(address(token), TOKEN_AMOUNT, address(weth), ETH_AMOUNT); + uint256 orderId = + board.createOrder(address(token), TOKEN_AMOUNT, address(weth), ETH_AMOUNT, false); vm.stopPrank(); vm.prank(maker); @@ -259,7 +270,8 @@ contract SwapboardETHTest is Test { function test_cancelOrderUnwrap_revert_notMaker() public { vm.prank(maker); - uint256 orderId = board.createOrderWithEth{value: ETH_AMOUNT}(address(token), TOKEN_AMOUNT); + uint256 orderId = + board.createOrderWithEth{value: ETH_AMOUNT}(address(token), TOKEN_AMOUNT, false); vm.prank(taker); vm.expectRevert(abi.encodeWithSelector(ISwapboard.NotMaker.selector, orderId, taker, maker)); @@ -274,7 +286,8 @@ contract SwapboardETHTest is Test { function test_cancelOrderUnwrap_revert_orderNotActive() public { vm.prank(maker); - uint256 orderId = board.createOrderWithEth{value: ETH_AMOUNT}(address(token), TOKEN_AMOUNT); + uint256 orderId = + board.createOrderWithEth{value: ETH_AMOUNT}(address(token), TOKEN_AMOUNT, false); vm.prank(maker); board.cancelOrderUnwrap(orderId); @@ -289,7 +302,8 @@ contract SwapboardETHTest is Test { vm.deal(address(rejecter), 10 ether); vm.prank(address(rejecter)); - uint256 orderId = board.createOrderWithEth{value: ETH_AMOUNT}(address(token), TOKEN_AMOUNT); + uint256 orderId = + board.createOrderWithEth{value: ETH_AMOUNT}(address(token), TOKEN_AMOUNT, false); vm.prank(address(rejecter)); vm.expectRevert( @@ -298,19 +312,145 @@ contract SwapboardETHTest is Test { board.cancelOrderUnwrap(orderId); } + // ============ cancelOrdersUnwrap ============ + + function test_cancelOrdersUnwrap_basic() public { + vm.startPrank(maker); + uint256 id0 = board.createOrderWithEth{value: 1 ether}(address(token), TOKEN_AMOUNT, false); + uint256 id1 = board.createOrderWithEth{value: 2 ether}(address(token), TOKEN_AMOUNT, false); + uint256 id2 = board.createOrderWithEth{value: 3 ether}(address(token), TOKEN_AMOUNT, false); + + uint256 makerEthBefore = maker.balance; + + uint256[] memory ids = new uint256[](3); + ids[0] = id0; + ids[1] = id1; + ids[2] = id2; + board.cancelOrdersUnwrap(ids); + vm.stopPrank(); + + assertFalse(board.getOrder(id0).active); + assertFalse(board.getOrder(id1).active); + assertFalse(board.getOrder(id2).active); + assertEq(maker.balance, makerEthBefore + 6 ether); + assertEq(weth.balanceOf(address(board)), 0); + } + + function test_cancelOrdersUnwrap_empty() public { + uint256[] memory ids = new uint256[](0); + board.cancelOrdersUnwrap(ids); + } + + function test_cancelOrdersUnwrap_events() public { + vm.startPrank(maker); + uint256 id0 = board.createOrderWithEth{value: 1 ether}(address(token), TOKEN_AMOUNT, false); + uint256 id1 = board.createOrderWithEth{value: 2 ether}(address(token), TOKEN_AMOUNT, false); + + vm.expectEmit(true, true, false, true); + emit ISwapboard.OrderCanceled(id0, maker, address(weth), 1 ether); + vm.expectEmit(true, true, false, true); + emit ISwapboard.OrderCanceled(id1, maker, address(weth), 2 ether); + + uint256[] memory ids = new uint256[](2); + ids[0] = id0; + ids[1] = id1; + board.cancelOrdersUnwrap(ids); + vm.stopPrank(); + } + + function test_cancelOrdersUnwrap_revert_notWETH() public { + vm.startPrank(maker); + token.approve(address(board), TOKEN_AMOUNT); + uint256 id0 = + board.createOrderWithEth{value: ETH_AMOUNT}(address(token), TOKEN_AMOUNT, false); + uint256 id1 = + board.createOrder(address(token), TOKEN_AMOUNT, address(weth), ETH_AMOUNT, false); + + uint256[] memory ids = new uint256[](2); + ids[0] = id0; + ids[1] = id1; + + vm.expectRevert( + abi.encodeWithSelector(ISwapboard.NotWETH.selector, address(weth), address(token)) + ); + board.cancelOrdersUnwrap(ids); + vm.stopPrank(); + } + + function test_cancelOrdersUnwrap_revert_notMaker() public { + vm.startPrank(maker); + uint256 id0 = + board.createOrderWithEth{value: ETH_AMOUNT}(address(token), TOKEN_AMOUNT, false); + vm.stopPrank(); + + uint256[] memory ids = new uint256[](1); + ids[0] = id0; + + vm.prank(taker); + vm.expectRevert(abi.encodeWithSelector(ISwapboard.NotMaker.selector, id0, taker, maker)); + board.cancelOrdersUnwrap(ids); + } + + function test_cancelOrdersUnwrap_atomic_reverts() public { + vm.startPrank(maker); + uint256 id0 = board.createOrderWithEth{value: 1 ether}(address(token), TOKEN_AMOUNT, false); + uint256 id1 = board.createOrderWithEth{value: 2 ether}(address(token), TOKEN_AMOUNT, false); + board.cancelOrderUnwrap(id1); // pre-cancel id1 + + uint256[] memory ids = new uint256[](2); + ids[0] = id0; + ids[1] = id1; + + vm.expectRevert(abi.encodeWithSelector(ISwapboard.OrderNotActive.selector, id1)); + board.cancelOrdersUnwrap(ids); + vm.stopPrank(); + + // id0 should still be active (atomic rollback) + assertTrue(board.getOrder(id0).active); + } + + function test_cancelOrdersUnwrap_afterPartialFill() public { + vm.startPrank(maker); + uint256 id0 = + board.createOrderWithEth{value: ETH_AMOUNT}(address(token), TOKEN_AMOUNT, true); + uint256 id1 = + board.createOrderWithEth{value: ETH_AMOUNT}(address(token), TOKEN_AMOUNT, false); + vm.stopPrank(); + + // Partially fill id0 + uint256 halfToken = TOKEN_AMOUNT / 2; + vm.startPrank(taker); + token.approve(address(board), halfToken); + board.fillOrder(id0, 0, halfToken); + vm.stopPrank(); + + uint256 remainingWeth = board.getOrder(id0).amountA; + uint256 makerEthBefore = maker.balance; + + vm.startPrank(maker); + uint256[] memory ids = new uint256[](2); + ids[0] = id0; + ids[1] = id1; + board.cancelOrdersUnwrap(ids); + vm.stopPrank(); + + assertEq(maker.balance, makerEthBefore + remainingWeth + ETH_AMOUNT); + } + // ============ fillOrderUnwrap ============ function test_fillOrderUnwrap() public { // Maker creates WETH order vm.prank(maker); - uint256 orderId = board.createOrderWithEth{value: ETH_AMOUNT}(address(token), TOKEN_AMOUNT); + uint256 orderId = + board.createOrderWithEth{value: ETH_AMOUNT}(address(token), TOKEN_AMOUNT, false); uint256 takerEthBefore = taker.balance; uint256 makerTokenBefore = token.balanceOf(maker); vm.startPrank(taker); token.approve(address(board), TOKEN_AMOUNT); - board.fillOrderUnwrap(orderId, 0); + board.fillOrderUnwrap(orderId, 0, 0); vm.stopPrank(); ISwapboard.Order memory order = board.getOrder(orderId); @@ -322,14 +462,17 @@ contract SwapboardETHTest is Test { function test_fillOrderUnwrap_event() public { vm.prank(maker); - uint256 orderId = board.createOrderWithEth{value: ETH_AMOUNT}(address(token), TOKEN_AMOUNT); + uint256 orderId = + board.createOrderWithEth{value: ETH_AMOUNT}(address(token), TOKEN_AMOUNT, false); vm.startPrank(taker); token.approve(address(board), TOKEN_AMOUNT); - vm.expectEmit(true, true, false, true); - emit ISwapboard.OrderFilled(orderId, taker); - board.fillOrderUnwrap(orderId, 0); + vm.expectEmit(true, true, true, true); + emit ISwapboard.OrderFilled( + orderId, taker, maker, address(weth), ETH_AMOUNT, address(token), TOKEN_AMOUNT + ); + board.fillOrderUnwrap(orderId, 0, 0); vm.stopPrank(); } @@ -337,39 +480,42 @@ contract SwapboardETHTest is Test { // Create a regular ERC20 order (tokenA is not WETH) vm.startPrank(maker); token.approve(address(board), TOKEN_AMOUNT); - uint256 orderId = board.createOrder(address(token), TOKEN_AMOUNT, address(tokenB), 1 ether); + uint256 orderId = + board.createOrder(address(token), TOKEN_AMOUNT, address(tokenB), 1 ether, false); vm.stopPrank(); vm.prank(taker); vm.expectRevert( abi.encodeWithSelector(ISwapboard.NotWETH.selector, address(weth), address(token)) ); - board.fillOrderUnwrap(orderId, 0); + board.fillOrderUnwrap(orderId, 0, 0); } function test_fillOrderUnwrap_revert_orderNotFound() public { vm.prank(taker); vm.expectRevert(abi.encodeWithSelector(ISwapboard.OrderNotFound.selector, 999)); - board.fillOrderUnwrap(999, 0); + board.fillOrderUnwrap(999, 0, 0); } function test_fillOrderUnwrap_revert_orderNotActive() public { vm.prank(maker); - uint256 orderId = board.createOrderWithEth{value: ETH_AMOUNT}(address(token), TOKEN_AMOUNT); + uint256 orderId = + board.createOrderWithEth{value: ETH_AMOUNT}(address(token), TOKEN_AMOUNT, false); vm.startPrank(taker); token.approve(address(board), TOKEN_AMOUNT); - board.fillOrderUnwrap(orderId, 0); + board.fillOrderUnwrap(orderId, 0, 0); vm.stopPrank(); vm.prank(taker); vm.expectRevert(abi.encodeWithSelector(ISwapboard.OrderNotActive.selector, orderId)); - board.fillOrderUnwrap(orderId, 0); + board.fillOrderUnwrap(orderId, 0, 0); } function test_fillOrderUnwrap_revert_ethTransferFailed() public { vm.prank(maker); - uint256 orderId = board.createOrderWithEth{value: ETH_AMOUNT}(address(token), TOKEN_AMOUNT); + uint256 orderId = + board.createOrderWithEth{value: ETH_AMOUNT}(address(token), TOKEN_AMOUNT, false); ETHRejecter rejecter = new ETHRejecter(); token.mint(address(rejecter), TOKEN_AMOUNT); @@ -380,7 +526,7 @@ contract SwapboardETHTest is Test { vm.expectRevert( abi.encodeWithSelector(ISwapboard.ETHTransferFailed.selector, address(rejecter)) ); - board.fillOrderUnwrap(orderId, 0); + board.fillOrderUnwrap(orderId, 0, 0); vm.stopPrank(); } @@ -400,13 +546,14 @@ contract SwapboardETHTest is Test { function test_createWithEth_fillNormal() public { // Create with ETH, fill with normal fillOrder (taker gets WETH) vm.prank(maker); - uint256 orderId = board.createOrderWithEth{value: ETH_AMOUNT}(address(token), TOKEN_AMOUNT); + uint256 orderId = + board.createOrderWithEth{value: ETH_AMOUNT}(address(token), TOKEN_AMOUNT, false); uint256 takerWethBefore = weth.balanceOf(taker); vm.startPrank(taker); token.approve(address(board), TOKEN_AMOUNT); - board.fillOrder(orderId, 0); + board.fillOrder(orderId, 0, 0); vm.stopPrank(); assertEq(weth.balanceOf(taker), takerWethBefore + ETH_AMOUNT); @@ -415,7 +562,8 @@ contract SwapboardETHTest is Test { function test_createWithEth_cancelNormal() public { // Create with ETH, cancel with normal cancelOrder (maker gets WETH) vm.prank(maker); - uint256 orderId = board.createOrderWithEth{value: ETH_AMOUNT}(address(token), TOKEN_AMOUNT); + uint256 orderId = + board.createOrderWithEth{value: ETH_AMOUNT}(address(token), TOKEN_AMOUNT, false); uint256 makerWethBefore = weth.balanceOf(maker); @@ -429,7 +577,8 @@ contract SwapboardETHTest is Test { // Create normal order wanting WETH, fill with ETH vm.startPrank(maker); token.approve(address(board), TOKEN_AMOUNT); - uint256 orderId = board.createOrder(address(token), TOKEN_AMOUNT, address(weth), ETH_AMOUNT); + uint256 orderId = + board.createOrder(address(token), TOKEN_AMOUNT, address(weth), ETH_AMOUNT, false); vm.stopPrank(); uint256 makerWethBefore = weth.balanceOf(maker); @@ -445,13 +594,14 @@ contract SwapboardETHTest is Test { function test_createWithEth_fillUnwrap() public { // Full ETH round-trip: maker deposits ETH, taker receives ETH vm.prank(maker); - uint256 orderId = board.createOrderWithEth{value: ETH_AMOUNT}(address(token), TOKEN_AMOUNT); + uint256 orderId = + board.createOrderWithEth{value: ETH_AMOUNT}(address(token), TOKEN_AMOUNT, false); uint256 takerEthBefore = taker.balance; vm.startPrank(taker); token.approve(address(board), TOKEN_AMOUNT); - board.fillOrderUnwrap(orderId, 0); + board.fillOrderUnwrap(orderId, 0, 0); vm.stopPrank(); assertEq(taker.balance, takerEthBefore + ETH_AMOUNT); @@ -463,7 +613,8 @@ contract SwapboardETHTest is Test { uint256 makerEthBefore = maker.balance; vm.prank(maker); - uint256 orderId = board.createOrderWithEth{value: ETH_AMOUNT}(address(token), TOKEN_AMOUNT); + uint256 orderId = + board.createOrderWithEth{value: ETH_AMOUNT}(address(token), TOKEN_AMOUNT, false); assertEq(maker.balance, makerEthBefore - ETH_AMOUNT); @@ -476,9 +627,9 @@ contract SwapboardETHTest is Test { function test_multipleETHOrders() public { vm.startPrank(maker); - uint256 id0 = board.createOrderWithEth{value: 1 ether}(address(token), TOKEN_AMOUNT); - uint256 id1 = board.createOrderWithEth{value: 2 ether}(address(token), TOKEN_AMOUNT); - uint256 id2 = board.createOrderWithEth{value: 3 ether}(address(token), TOKEN_AMOUNT); + uint256 id0 = board.createOrderWithEth{value: 1 ether}(address(token), TOKEN_AMOUNT, false); + uint256 id1 = board.createOrderWithEth{value: 2 ether}(address(token), TOKEN_AMOUNT, false); + uint256 id2 = board.createOrderWithEth{value: 3 ether}(address(token), TOKEN_AMOUNT, false); vm.stopPrank(); assertEq(weth.balanceOf(address(board)), 6 ether); @@ -492,7 +643,7 @@ contract SwapboardETHTest is Test { // Fill one vm.startPrank(taker); token.approve(address(board), TOKEN_AMOUNT); - board.fillOrderUnwrap(id0, 0); + board.fillOrderUnwrap(id0, 0, 0); vm.stopPrank(); assertEq(weth.balanceOf(address(board)), 3 ether); @@ -515,7 +666,7 @@ contract SwapboardETHTest is Test { vm.deal(maker, ethAmount); vm.prank(maker); - uint256 orderId = board.createOrderWithEth{value: ethAmount}(address(token), amountB); + uint256 orderId = board.createOrderWithEth{value: ethAmount}(address(token), amountB, false); ISwapboard.Order memory order = board.getOrder(orderId); assertEq(order.amountA, ethAmount); @@ -539,7 +690,8 @@ contract SwapboardETHTest is Test { vm.startPrank(maker); token.approve(address(board), tokenAmount); - uint256 orderId = board.createOrder(address(token), tokenAmount, address(weth), ethAmount); + uint256 orderId = + board.createOrder(address(token), tokenAmount, address(weth), ethAmount, false); vm.stopPrank(); vm.prank(taker); @@ -556,7 +708,8 @@ contract SwapboardETHTest is Test { vm.deal(maker, ethAmount); vm.prank(maker); - uint256 orderId = board.createOrderWithEth{value: ethAmount}(address(token), TOKEN_AMOUNT); + uint256 orderId = + board.createOrderWithEth{value: ethAmount}(address(token), TOKEN_AMOUNT, false); uint256 makerEthBefore = maker.balance; @@ -569,7 +722,8 @@ contract SwapboardETHTest is Test { function test_fillOrderWithEth_revert_deadlineExpired() public { vm.startPrank(maker); token.approve(address(board), TOKEN_AMOUNT); - uint256 orderId = board.createOrder(address(token), TOKEN_AMOUNT, address(weth), ETH_AMOUNT); + uint256 orderId = + board.createOrder(address(token), TOKEN_AMOUNT, address(weth), ETH_AMOUNT, false); vm.stopPrank(); vm.warp(1000); @@ -581,14 +735,15 @@ contract SwapboardETHTest is Test { function test_fillOrderUnwrap_revert_deadlineExpired() public { vm.prank(maker); - uint256 orderId = board.createOrderWithEth{value: ETH_AMOUNT}(address(token), TOKEN_AMOUNT); + uint256 orderId = + board.createOrderWithEth{value: ETH_AMOUNT}(address(token), TOKEN_AMOUNT, false); vm.warp(1000); vm.startPrank(taker); token.approve(address(board), TOKEN_AMOUNT); vm.expectRevert(ISwapboard.DeadlineExpired.selector); - board.fillOrderUnwrap(orderId, 999); + board.fillOrderUnwrap(orderId, 999, 0); vm.stopPrank(); } @@ -606,14 +761,484 @@ contract SwapboardETHTest is Test { uint256 makerTokenBefore = token.balanceOf(maker); vm.prank(maker); - uint256 orderId = board.createOrderWithEth{value: ethAmount}(address(token), tokenAmount); + uint256 orderId = + board.createOrderWithEth{value: ethAmount}(address(token), tokenAmount, false); vm.startPrank(taker); token.approve(address(board), tokenAmount); - board.fillOrderUnwrap(orderId, 0); + board.fillOrderUnwrap(orderId, 0, 0); vm.stopPrank(); assertEq(taker.balance, takerEthBefore + ethAmount); assertEq(token.balanceOf(maker), makerTokenBefore + tokenAmount); } + + // ============ Partial Fill + ETH Interactions ============ + + function test_partialFill_thenCancelUnwrap() public { + // Create ETH order (WETH as tokenA), partial-fillable + vm.prank(maker); + uint256 orderId = + board.createOrderWithEth{value: ETH_AMOUNT}(address(token), TOKEN_AMOUNT, true); + + // Partial fill half + uint256 halfToken = TOKEN_AMOUNT / 2; + vm.startPrank(taker); + token.approve(address(board), halfToken); + board.fillOrder(orderId, 0, halfToken); + vm.stopPrank(); + + ISwapboard.Order memory order = board.getOrder(orderId); + uint256 remainingWeth = order.amountA; + uint256 makerEthBefore = maker.balance; + + // Maker cancels and unwraps remaining WETH to ETH + vm.prank(maker); + board.cancelOrderUnwrap(orderId); + + assertEq(maker.balance, makerEthBefore + remainingWeth); + assertFalse(board.getOrder(orderId).active); + } + + function test_partialFill_thenFillOrderWithEth() public { + // Create order: maker sells token, wants WETH as tokenB, partial-fillable + vm.startPrank(maker); + token.approve(address(board), TOKEN_AMOUNT); + uint256 orderId = + board.createOrder(address(token), TOKEN_AMOUNT, address(weth), ETH_AMOUNT, true); + vm.stopPrank(); + + // Partial fill half via ERC20 WETH path + uint256 halfEth = ETH_AMOUNT / 2; + vm.deal(taker, ETH_AMOUNT); + vm.startPrank(taker); + weth.deposit{value: halfEth}(); + weth.approve(address(board), halfEth); + board.fillOrder(orderId, 0, halfEth); + vm.stopPrank(); + + ISwapboard.Order memory order = board.getOrder(orderId); + uint256 remainEth = order.amountB; + + // Fill the rest with native ETH via fillOrderWithEth + vm.prank(taker); + board.fillOrderWithEth{value: remainEth}(orderId, 0); + + assertFalse(board.getOrder(orderId).active); + } + + function test_partialFill_thenFillOrderUnwrap() public { + // Create ETH order (WETH as tokenA), partial-fillable + vm.prank(maker); + uint256 orderId = + board.createOrderWithEth{value: ETH_AMOUNT}(address(token), TOKEN_AMOUNT, true); + + // Partial fill half + uint256 halfToken = TOKEN_AMOUNT / 2; + vm.startPrank(taker); + token.approve(address(board), halfToken); + board.fillOrder(orderId, 0, halfToken); + vm.stopPrank(); + + ISwapboard.Order memory order = board.getOrder(orderId); + uint256 remainToken = order.amountB; + uint256 remainWeth = order.amountA; + uint256 takerEthBefore = taker.balance; + + // Fill the rest via fillOrderUnwrap — taker pays token, receives ETH + vm.startPrank(taker); + token.approve(address(board), remainToken); + board.fillOrderUnwrap(orderId, 0, 0); + vm.stopPrank(); + + assertFalse(board.getOrder(orderId).active); + assertEq(taker.balance, takerEthBefore + remainWeth); + } + + function test_createOrderWithEth_partialFillFlag() public { + vm.prank(maker); + uint256 orderId = + board.createOrderWithEth{value: ETH_AMOUNT}(address(token), TOKEN_AMOUNT, true); + + ISwapboard.Order memory order = board.getOrder(orderId); + assertTrue(order.partialFill); + assertTrue(order.active); + assertEq(order.tokenA, address(weth)); + } + + function test_fillOrderWithEth_partialFill() public { + // Maker sells token, wants WETH, allows partial fills + vm.startPrank(maker); + token.approve(address(board), TOKEN_AMOUNT); + uint256 orderId = + board.createOrder(address(token), TOKEN_AMOUNT, address(weth), ETH_AMOUNT, true); + vm.stopPrank(); + + uint256 halfEth = ETH_AMOUNT / 2; + uint256 expectedToken = (halfEth * TOKEN_AMOUNT) / ETH_AMOUNT; + uint256 takerTokenBefore = token.balanceOf(taker); + + vm.prank(taker); + board.fillOrderWithEth{value: halfEth}(orderId, 0); + + ISwapboard.Order memory order = board.getOrder(orderId); + assertTrue(order.active); + assertEq(order.amountB, ETH_AMOUNT - halfEth); + assertEq(token.balanceOf(taker), takerTokenBefore + expectedToken); + assertEq(weth.balanceOf(maker), halfEth); + } + + function test_fillOrderWithEth_partialFill_event() public { + vm.startPrank(maker); + token.approve(address(board), TOKEN_AMOUNT); + uint256 orderId = + board.createOrder(address(token), TOKEN_AMOUNT, address(weth), ETH_AMOUNT, true); + vm.stopPrank(); + + uint256 halfEth = ETH_AMOUNT / 2; + uint256 expectedToken = (halfEth * TOKEN_AMOUNT) / ETH_AMOUNT; + + vm.expectEmit(true, true, true, true); + emit ISwapboard.OrderPartiallyFilled( + orderId, + taker, + maker, + address(token), + expectedToken, + address(weth), + halfEth, + TOKEN_AMOUNT - expectedToken, + ETH_AMOUNT - halfEth + ); + + vm.prank(taker); + board.fillOrderWithEth{value: halfEth}(orderId, 0); + } + + function test_fillOrderWithEth_revert_zeroValue() public { + vm.startPrank(maker); + token.approve(address(board), TOKEN_AMOUNT); + uint256 orderId = + board.createOrder(address(token), TOKEN_AMOUNT, address(weth), ETH_AMOUNT, true); + vm.stopPrank(); + + vm.prank(taker); + vm.expectRevert(ISwapboard.ZeroETH.selector); + board.fillOrderWithEth{value: 0}(orderId, 0); + } + + function test_fillOrderWithEth_zeroValue_cannotDrainWeth() public { + // Prove that msg.value == 0 cannot drain WETH held for other orders + vm.prank(maker); + uint256 wethOrderId = + board.createOrderWithEth{value: ETH_AMOUNT}(address(token), TOKEN_AMOUNT, false); + + // Contract now holds 1 ETH as WETH from the above order + assertEq(weth.balanceOf(address(board)), ETH_AMOUNT); + + // Create another order that wants WETH + vm.startPrank(maker); + token.approve(address(board), TOKEN_AMOUNT); + uint256 targetOrderId = + board.createOrder(address(token), TOKEN_AMOUNT, address(weth), ETH_AMOUNT, true); + vm.stopPrank(); + + // Attempt free fill with msg.value == 0 + vm.prank(taker); + vm.expectRevert(ISwapboard.ZeroETH.selector); + board.fillOrderWithEth{value: 0}(targetOrderId, 0); + + // WETH order is untouched + assertTrue(board.getOrder(wethOrderId).active); + assertEq(weth.balanceOf(address(board)), ETH_AMOUNT); + } + + function test_fillOrderUnwrap_partialFill() public { + // Maker sells ETH (WETH), wants token, allows partial fills + vm.prank(maker); + uint256 orderId = + board.createOrderWithEth{value: ETH_AMOUNT}(address(token), TOKEN_AMOUNT, true); + + uint256 halfToken = TOKEN_AMOUNT / 2; + uint256 expectedEth = (halfToken * ETH_AMOUNT) / TOKEN_AMOUNT; + uint256 takerEthBefore = taker.balance; + + vm.startPrank(taker); + token.approve(address(board), halfToken); + board.fillOrderUnwrap(orderId, 0, halfToken); + vm.stopPrank(); + + ISwapboard.Order memory order = board.getOrder(orderId); + assertTrue(order.active); + assertEq(order.amountB, TOKEN_AMOUNT - halfToken); + assertEq(order.amountA, ETH_AMOUNT - expectedEth); + assertEq(taker.balance, takerEthBefore + expectedEth); + } + + function test_fillOrderUnwrap_partialFill_event() public { + vm.prank(maker); + uint256 orderId = + board.createOrderWithEth{value: ETH_AMOUNT}(address(token), TOKEN_AMOUNT, true); + + uint256 halfToken = TOKEN_AMOUNT / 2; + uint256 expectedEth = (halfToken * ETH_AMOUNT) / TOKEN_AMOUNT; + + vm.startPrank(taker); + token.approve(address(board), halfToken); + + vm.expectEmit(true, true, true, true); + emit ISwapboard.OrderPartiallyFilled( + orderId, + taker, + maker, + address(weth), + expectedEth, + address(token), + halfToken, + ETH_AMOUNT - expectedEth, + TOKEN_AMOUNT - halfToken + ); + board.fillOrderUnwrap(orderId, 0, halfToken); + vm.stopPrank(); + } + + function test_fillOrderUnwrap_revert_partialNotAllowed() public { + vm.prank(maker); + uint256 orderId = + board.createOrderWithEth{value: ETH_AMOUNT}(address(token), TOKEN_AMOUNT, false); + + uint256 halfToken = TOKEN_AMOUNT / 2; + vm.startPrank(taker); + token.approve(address(board), halfToken); + vm.expectRevert(abi.encodeWithSelector(ISwapboard.PartialFillNotAllowed.selector, orderId)); + board.fillOrderUnwrap(orderId, 0, halfToken); + vm.stopPrank(); + } + + function test_fillOrderUnwrap_fullFillViaZero() public { + vm.prank(maker); + uint256 orderId = + board.createOrderWithEth{value: ETH_AMOUNT}(address(token), TOKEN_AMOUNT, false); + + uint256 takerEthBefore = taker.balance; + + vm.startPrank(taker); + token.approve(address(board), TOKEN_AMOUNT); + board.fillOrderUnwrap(orderId, 0, 0); + vm.stopPrank(); + + assertFalse(board.getOrder(orderId).active); + assertEq(taker.balance, takerEthBefore + ETH_AMOUNT); + } + + function test_fillOrderWithEth_sequentialPartialFills() public { + // Maker sells token, wants WETH, allows partial fills + vm.startPrank(maker); + token.approve(address(board), TOKEN_AMOUNT); + uint256 orderId = + board.createOrder(address(token), TOKEN_AMOUNT, address(weth), ETH_AMOUNT, true); + vm.stopPrank(); + + uint256 quarterEth = ETH_AMOUNT / 4; + + // Four partial fills of 0.25 ETH each + for (uint256 i = 0; i < 4; i++) { + vm.prank(taker); + board.fillOrderWithEth{value: quarterEth}(orderId, 0); + } + + assertFalse(board.getOrder(orderId).active); + assertEq(weth.balanceOf(maker), ETH_AMOUNT); + } + + function test_fillOrderUnwrap_partialFill_thenCancel() public { + vm.prank(maker); + uint256 orderId = + board.createOrderWithEth{value: ETH_AMOUNT}(address(token), TOKEN_AMOUNT, true); + + // Partial fill half + uint256 halfToken = TOKEN_AMOUNT / 2; + vm.startPrank(taker); + token.approve(address(board), halfToken); + board.fillOrderUnwrap(orderId, 0, halfToken); + vm.stopPrank(); + + ISwapboard.Order memory order = board.getOrder(orderId); + uint256 remainingWeth = order.amountA; + uint256 makerEthBefore = maker.balance; + + // Maker cancels, gets remaining ETH back + vm.prank(maker); + board.cancelOrderUnwrap(orderId); + + assertFalse(board.getOrder(orderId).active); + assertEq(maker.balance, makerEthBefore + remainingWeth); + } + + function test_fillOrderWithEth_fullFillOnPartialOrder() public { + // msg.value == amountB on a partial-fill-enabled order does a full fill + vm.startPrank(maker); + token.approve(address(board), TOKEN_AMOUNT); + uint256 orderId = + board.createOrder(address(token), TOKEN_AMOUNT, address(weth), ETH_AMOUNT, true); + vm.stopPrank(); + + uint256 takerTokenBefore = token.balanceOf(taker); + + vm.prank(taker); + board.fillOrderWithEth{value: ETH_AMOUNT}(orderId, 0); + + assertFalse(board.getOrder(orderId).active); + assertEq(token.balanceOf(taker), takerTokenBefore + TOKEN_AMOUNT); + assertEq(weth.balanceOf(maker), ETH_AMOUNT); + } + + function testFuzz_fillOrderWithEth_partial( + uint256 ethAmount, + uint256 tokenAmount, + uint256 fillEth + ) public { + ethAmount = bound(ethAmount, 2, 10 ether); + tokenAmount = bound(tokenAmount, 2, 1e12); + fillEth = bound(fillEth, 1, ethAmount); + + token.mint(maker, tokenAmount); + vm.deal(taker, fillEth); + + vm.startPrank(maker); + token.approve(address(board), tokenAmount); + uint256 orderId = + board.createOrder(address(token), tokenAmount, address(weth), ethAmount, true); + vm.stopPrank(); + + uint256 expectedToken; + if (fillEth >= ethAmount) { + expectedToken = tokenAmount; + } else { + expectedToken = (fillEth * tokenAmount) / ethAmount; + if (expectedToken == 0) { + vm.prank(taker); + vm.expectRevert(ISwapboard.ZeroFillAmount.selector); + board.fillOrderWithEth{value: fillEth}(orderId, 0); + return; + } + } + + uint256 takerTokenBefore = token.balanceOf(taker); + uint256 makerWethBefore = weth.balanceOf(maker); + + vm.prank(taker); + board.fillOrderWithEth{value: fillEth}(orderId, 0); + + assertEq(token.balanceOf(taker) - takerTokenBefore, expectedToken); + assertEq(weth.balanceOf(maker) - makerWethBefore, fillEth); + } + + function testFuzz_fillOrderUnwrap_partial( + uint256 ethAmount, + uint256 tokenAmount, + uint256 fillToken + ) public { + ethAmount = bound(ethAmount, 2, 10 ether); + tokenAmount = bound(tokenAmount, 2, 1e12); + fillToken = bound(fillToken, 1, tokenAmount); + + token.mint(taker, fillToken); + vm.deal(maker, ethAmount); + + vm.prank(maker); + uint256 orderId = + board.createOrderWithEth{value: ethAmount}(address(token), tokenAmount, true); + + uint256 expectedEth; + if (fillToken >= tokenAmount) { + expectedEth = ethAmount; + } else { + expectedEth = (fillToken * ethAmount) / tokenAmount; + if (expectedEth == 0) { + vm.startPrank(taker); + token.approve(address(board), fillToken); + vm.expectRevert(ISwapboard.ZeroFillAmount.selector); + board.fillOrderUnwrap(orderId, 0, fillToken); + vm.stopPrank(); + return; + } + } + + uint256 takerEthBefore = taker.balance; + uint256 makerTokenBefore = token.balanceOf(maker); + + vm.startPrank(taker); + token.approve(address(board), fillToken); + board.fillOrderUnwrap(orderId, 0, fillToken); + vm.stopPrank(); + + assertEq(taker.balance - takerEthBefore, expectedEth); + assertEq(token.balanceOf(maker) - makerTokenBefore, fillToken); + } + + // ============ Zeroed state after full fill ============ + + function test_fullFill_zerosAmounts() public { + vm.startPrank(maker); + token.approve(address(board), TOKEN_AMOUNT); + uint256 orderId = + board.createOrder(address(token), TOKEN_AMOUNT, address(weth), ETH_AMOUNT, false); + vm.stopPrank(); + + vm.prank(taker); + board.fillOrderWithEth{value: ETH_AMOUNT}(orderId, 0); + + ISwapboard.Order memory order = board.getOrder(orderId); + assertFalse(order.active); + assertEq(order.amountA, 0); + assertEq(order.amountB, 0); + } + + function test_fullFillUnwrap_zerosAmounts() public { + vm.prank(maker); + uint256 orderId = + board.createOrderWithEth{value: ETH_AMOUNT}(address(token), TOKEN_AMOUNT, false); + + vm.startPrank(taker); + token.approve(address(board), TOKEN_AMOUNT); + board.fillOrderUnwrap(orderId, 0, 0); + vm.stopPrank(); + + ISwapboard.Order memory order = board.getOrder(orderId); + assertFalse(order.active); + assertEq(order.amountA, 0); + assertEq(order.amountB, 0); + } + + // ============ Early active check on ETH paths ============ + + function test_fillOrderWithEth_revert_cancelledOrder_givesActiveError() public { + vm.startPrank(maker); + token.approve(address(board), TOKEN_AMOUNT); + uint256 orderId = + board.createOrder(address(token), TOKEN_AMOUNT, address(weth), ETH_AMOUNT, false); + board.cancelOrder(orderId); + vm.stopPrank(); + + // Should revert with OrderNotActive, not ETHAmountMismatch or other + vm.prank(taker); + vm.expectRevert(abi.encodeWithSelector(ISwapboard.OrderNotActive.selector, orderId)); + board.fillOrderWithEth{value: ETH_AMOUNT}(orderId, 0); + } + + function test_fillOrderUnwrap_revert_cancelledOrder_givesActiveError() public { + vm.prank(maker); + uint256 orderId = + board.createOrderWithEth{value: ETH_AMOUNT}(address(token), TOKEN_AMOUNT, false); + + vm.prank(maker); + board.cancelOrderUnwrap(orderId); + + vm.startPrank(taker); + token.approve(address(board), TOKEN_AMOUNT); + vm.expectRevert(abi.encodeWithSelector(ISwapboard.OrderNotActive.selector, orderId)); + board.fillOrderUnwrap(orderId, 0, 0); + vm.stopPrank(); + } } diff --git a/contracts/test/exploit/ExploitVectors.t.sol b/contracts/test/exploit/ExploitVectors.t.sol index ef6ce76..c867093 100644 --- a/contracts/test/exploit/ExploitVectors.t.sol +++ b/contracts/test/exploit/ExploitVectors.t.sol @@ -47,8 +47,9 @@ contract ExploitVectorTests is Test { maliciousToken.approve(address(board), 1000 ether); vm.prank(maker); - uint256 orderId = - board.createOrder(address(maliciousToken), 100 ether, address(tokenB), 100 ether); + uint256 orderId = board.createOrder( + address(maliciousToken), 100 ether, address(tokenB), 100 ether, false + ); // Taker tries to fill - malicious token will try to reenter tokenB.mint(taker, 1000 ether); @@ -57,7 +58,7 @@ contract ExploitVectorTests is Test { // This should not allow reentrancy due to nonReentrant vm.prank(taker); - board.fillOrder(orderId, 0); + board.fillOrder(orderId, 0, 0); // Verify order was filled correctly (no double spend) assertFalse(board.canFill(orderId), "Order should be filled"); @@ -73,8 +74,9 @@ contract ExploitVectorTests is Test { maliciousToken.approve(address(board), 1000 ether); vm.prank(maker); - uint256 orderId = - board.createOrder(address(maliciousToken), 100 ether, address(tokenB), 100 ether); + uint256 orderId = board.createOrder( + address(maliciousToken), 100 ether, address(tokenB), 100 ether, false + ); maliciousToken.setOrderId(orderId); maliciousToken.setAttacker(maker); @@ -103,7 +105,7 @@ contract ExploitVectorTests is Test { vm.expectRevert( abi.encodeWithSelector(ISwapboard.BalanceMismatch.selector, 100 ether, 95 ether) ); - board.createOrder(address(fot), 100 ether, address(tokenB), 100 ether); + board.createOrder(address(fot), 100 ether, address(tokenB), 100 ether, false); } /// @notice KNOWN LIMITATION: FOT tokenB on fillOrder causes maker to receive less. @@ -118,7 +120,8 @@ contract ExploitVectorTests is Test { tokenA.approve(address(board), 1000 ether); vm.prank(maker); - uint256 orderId = board.createOrder(address(tokenA), 100 ether, address(fotB), 100 ether); + uint256 orderId = + board.createOrder(address(tokenA), 100 ether, address(fotB), 100 ether, false); // Taker has FOT tokens fotB.mint(taker, 1000 ether); @@ -129,7 +132,7 @@ contract ExploitVectorTests is Test { // Fill succeeds but maker receives less due to FOT vm.prank(taker); - board.fillOrder(orderId, 0); + board.fillOrder(orderId, 0, 0); uint256 makerFotAfter = fotB.balanceOf(maker); @@ -149,7 +152,7 @@ contract ExploitVectorTests is Test { for (uint256 i = 0; i < 10; i++) { vm.prank(maker); - board.createOrder(address(tokenA), 10 ether, address(tokenB), 10 ether); + board.createOrder(address(tokenA), 10 ether, address(tokenB), 10 ether, false); } // Query with large array including non-existent IDs @@ -181,7 +184,7 @@ contract ExploitVectorTests is Test { // Create many dust orders for (uint256 i = 0; i < 100; i++) { vm.prank(attacker); - board.createOrder(address(tokenA), 1, address(tokenB), 1); + board.createOrder(address(tokenA), 1, address(tokenB), 1, false); } // These can all be filled/cancelled normally @@ -190,7 +193,7 @@ contract ExploitVectorTests is Test { tokenB.approve(address(board), 100); vm.prank(taker); - board.fillOrder(0, 0); + board.fillOrder(0, 0, 0); assertEq(tokenA.balanceOf(taker), 1); } @@ -204,7 +207,8 @@ contract ExploitVectorTests is Test { tokenA.approve(address(board), 100 ether); vm.prank(maker); - uint256 orderId = board.createOrder(address(tokenA), 100 ether, address(tokenB), 100 ether); + uint256 orderId = + board.createOrder(address(tokenA), 100 ether, address(tokenB), 100 ether, false); // Attacker tries to cancel vm.prank(attacker); @@ -228,16 +232,17 @@ contract ExploitVectorTests is Test { tokenB.approve(address(board), 200 ether); vm.prank(maker); - uint256 orderId = board.createOrder(address(tokenA), 100 ether, address(tokenB), 100 ether); + uint256 orderId = + board.createOrder(address(tokenA), 100 ether, address(tokenB), 100 ether, false); // First fill succeeds vm.prank(taker); - board.fillOrder(orderId, 0); + board.fillOrder(orderId, 0, 0); // Second fill fails vm.prank(taker); vm.expectRevert(abi.encodeWithSelector(ISwapboard.OrderNotActive.selector, orderId)); - board.fillOrder(orderId, 0); + board.fillOrder(orderId, 0, 0); } /// @notice Test double-cancel prevention @@ -247,7 +252,8 @@ contract ExploitVectorTests is Test { tokenA.approve(address(board), 100 ether); vm.prank(maker); - uint256 orderId = board.createOrder(address(tokenA), 100 ether, address(tokenB), 100 ether); + uint256 orderId = + board.createOrder(address(tokenA), 100 ether, address(tokenB), 100 ether, false); // First cancel succeeds vm.prank(maker); @@ -275,7 +281,7 @@ contract ExploitVectorTests is Test { vm.prank(maker); uint256 orderId = - board.createOrder(address(pausableToken), 100 ether, address(tokenB), 100 ether); + board.createOrder(address(pausableToken), 100 ether, address(tokenB), 100 ether, false); // Pause the token pausableToken.pause(); @@ -287,7 +293,7 @@ contract ExploitVectorTests is Test { vm.prank(taker); vm.expectRevert("Paused"); - board.fillOrder(orderId, 0); + board.fillOrder(orderId, 0, 0); // Cancel should also fail vm.prank(maker); @@ -298,7 +304,7 @@ contract ExploitVectorTests is Test { pausableToken.unpause(); vm.prank(taker); - board.fillOrder(orderId, 0); + board.fillOrder(orderId, 0, 0); assertEq(pausableToken.balanceOf(taker), 100 ether); } @@ -313,8 +319,9 @@ contract ExploitVectorTests is Test { blacklistToken.approve(address(board), 1000 ether); vm.prank(maker); - uint256 orderId = - board.createOrder(address(blacklistToken), 100 ether, address(tokenB), 100 ether); + uint256 orderId = board.createOrder( + address(blacklistToken), 100 ether, address(tokenB), 100 ether, false + ); // Blacklist the taker blacklistToken.blacklist(taker); @@ -326,13 +333,13 @@ contract ExploitVectorTests is Test { // Taker fill fails due to blacklist (transfer to blacklisted address) vm.prank(taker); vm.expectRevert("Blacklisted"); - board.fillOrder(orderId, 0); + board.fillOrder(orderId, 0, 0); // Un-blacklisted user can still fill blacklistToken.unblacklist(taker); vm.prank(taker); - board.fillOrder(orderId, 0); + board.fillOrder(orderId, 0, 0); assertEq(blacklistToken.balanceOf(taker), 100 ether); } @@ -349,7 +356,7 @@ contract ExploitVectorTests is Test { vm.prank(maker); uint256 orderId = - board.createOrder(address(rebaseToken), 100 ether, address(tokenB), 100 ether); + board.createOrder(address(rebaseToken), 100 ether, address(tokenB), 100 ether, false); // Positive rebase happens (balances increase by 10%) rebaseToken.rebase(110); // 110% of original @@ -361,7 +368,7 @@ contract ExploitVectorTests is Test { tokenB.approve(address(board), 100 ether); vm.prank(taker); - board.fillOrder(orderId, 0); + board.fillOrder(orderId, 0, 0); // Taker receives approximately 100 ether (may have rounding due to rebase math) // The rebase share-based accounting causes small rounding differences @@ -386,10 +393,10 @@ contract ExploitVectorTests is Test { vm.prank(maker); uint256 orderId = - board.createOrder(address(tokenA), largeAmount, address(tokenB), largeAmount); + board.createOrder(address(tokenA), largeAmount, address(tokenB), largeAmount, false); vm.prank(taker); - board.fillOrder(orderId, 0); + board.fillOrder(orderId, 0, 0); assertEq(tokenA.balanceOf(taker), largeAmount); assertEq(tokenB.balanceOf(maker), largeAmount); @@ -409,10 +416,11 @@ contract ExploitVectorTests is Test { uint256 tokenBBefore = tokenB.balanceOf(maker); vm.prank(maker); - uint256 orderId = board.createOrder(address(tokenA), 100 ether, address(tokenB), 100 ether); + uint256 orderId = + board.createOrder(address(tokenA), 100 ether, address(tokenB), 100 ether, false); vm.prank(maker); - board.fillOrder(orderId, 0); + board.fillOrder(orderId, 0, 0); // Self-trade results in same balances (minus potential gas) assertEq(tokenA.balanceOf(maker), tokenABefore); @@ -425,7 +433,7 @@ contract ExploitVectorTests is Test { for (uint160 i = 1; i <= 9; i++) { vm.prank(maker); vm.expectRevert(abi.encodeWithSelector(ISwapboard.NotAContract.selector, address(i))); - board.createOrder(address(i), 100 ether, address(tokenB), 100 ether); + board.createOrder(address(i), 100 ether, address(tokenB), 100 ether, false); } } @@ -438,7 +446,7 @@ contract ExploitVectorTests is Test { for (uint256 i = 0; i < 100; i++) { vm.prank(maker); uint256 orderId = - board.createOrder(address(tokenA), 10 ether, address(tokenB), 10 ether); + board.createOrder(address(tokenA), 10 ether, address(tokenB), 10 ether, false); assertEq(orderId, i, "Order ID should be sequential"); } @@ -477,7 +485,7 @@ contract GasBenchmarks is Test { function test_gas_createOrder() public { vm.prank(maker); uint256 gasBefore = gasleft(); - board.createOrder(address(tokenA), 100 ether, address(tokenB), 100 ether); + board.createOrder(address(tokenA), 100 ether, address(tokenB), 100 ether, false); uint256 gasUsed = gasBefore - gasleft(); console2.log("createOrder gas:", gasUsed); @@ -486,11 +494,12 @@ contract GasBenchmarks is Test { function test_gas_fillOrder() public { vm.prank(maker); - uint256 orderId = board.createOrder(address(tokenA), 100 ether, address(tokenB), 100 ether); + uint256 orderId = + board.createOrder(address(tokenA), 100 ether, address(tokenB), 100 ether, false); vm.prank(taker); uint256 gasBefore = gasleft(); - board.fillOrder(orderId, 0); + board.fillOrder(orderId, 0, 0); uint256 gasUsed = gasBefore - gasleft(); console2.log("fillOrder gas:", gasUsed); @@ -499,7 +508,8 @@ contract GasBenchmarks is Test { function test_gas_cancelOrder() public { vm.prank(maker); - uint256 orderId = board.createOrder(address(tokenA), 100 ether, address(tokenB), 100 ether); + uint256 orderId = + board.createOrder(address(tokenA), 100 ether, address(tokenB), 100 ether, false); vm.prank(maker); uint256 gasBefore = gasleft(); @@ -512,7 +522,8 @@ contract GasBenchmarks is Test { function test_gas_getOrder() public { vm.prank(maker); - uint256 orderId = board.createOrder(address(tokenA), 100 ether, address(tokenB), 100 ether); + uint256 orderId = + board.createOrder(address(tokenA), 100 ether, address(tokenB), 100 ether, false); uint256 gasBefore = gasleft(); board.getOrder(orderId); @@ -525,7 +536,7 @@ contract GasBenchmarks is Test { function test_gas_getOrders_10() public { for (uint256 i = 0; i < 10; i++) { vm.prank(maker); - board.createOrder(address(tokenA), 100 ether, address(tokenB), 100 ether); + board.createOrder(address(tokenA), 100 ether, address(tokenB), 100 ether, false); } uint256[] memory ids = new uint256[](10); @@ -544,7 +555,7 @@ contract GasBenchmarks is Test { function test_gas_getOrders_100() public { for (uint256 i = 0; i < 100; i++) { vm.prank(maker); - board.createOrder(address(tokenA), 100 ether, address(tokenB), 100 ether); + board.createOrder(address(tokenA), 100 ether, address(tokenB), 100 ether, false); } uint256[] memory ids = new uint256[](100); @@ -562,7 +573,8 @@ contract GasBenchmarks is Test { function test_gas_canFill() public { vm.prank(maker); - uint256 orderId = board.createOrder(address(tokenA), 100 ether, address(tokenB), 100 ether); + uint256 orderId = + board.createOrder(address(tokenA), 100 ether, address(tokenB), 100 ether, false); uint256 gasBefore = gasleft(); board.canFill(orderId); diff --git a/contracts/test/invariant/Handler.sol b/contracts/test/invariant/Handler.sol index e4de3c6..90a7715 100644 --- a/contracts/test/invariant/Handler.sol +++ b/contracts/test/invariant/Handler.sol @@ -87,7 +87,8 @@ contract SwapboardHandler is Test { calls_createOrder++; - uint256 orderId = board.createOrder(address(tokenA), amountA, address(tokenB), amountB); + uint256 orderId = + board.createOrder(address(tokenA), amountA, address(tokenB), amountB, false); ghost_totalTokenADeposited += amountA; ghost_ordersCreated++; @@ -120,7 +121,7 @@ contract SwapboardHandler is Test { calls_fillOrder++; - board.fillOrder(orderId, 0); + board.fillOrder(orderId, 0, 0); ghost_totalTokenAWithdrawn += order.amountA; ghost_ordersFilled++; diff --git a/contracts/test/invariant/Invariants.t.sol b/contracts/test/invariant/Invariants.t.sol index 7eaee08..0387858 100644 --- a/contracts/test/invariant/Invariants.t.sol +++ b/contracts/test/invariant/Invariants.t.sol @@ -142,7 +142,7 @@ contract SwapboardStatelessInvariantTest is Test { uint256 balanceBefore = tokenA.balanceOf(maker); vm.prank(maker); - board.createOrder(address(tokenA), amountA, address(tokenB), amountB); + board.createOrder(address(tokenA), amountA, address(tokenB), amountB, false); uint256 balanceAfter = tokenA.balanceOf(maker); assertEq(balanceBefore - balanceAfter, amountA, "Maker balance decrease incorrect"); @@ -159,7 +159,7 @@ contract SwapboardStatelessInvariantTest is Test { uint256 balanceBefore = tokenA.balanceOf(address(board)); vm.prank(maker); - board.createOrder(address(tokenA), amountA, address(tokenB), amountB); + board.createOrder(address(tokenA), amountA, address(tokenB), amountB, false); uint256 balanceAfter = tokenA.balanceOf(address(board)); assertEq(balanceAfter - balanceBefore, amountA, "Contract balance increase incorrect"); @@ -174,12 +174,13 @@ contract SwapboardStatelessInvariantTest is Test { amountB = bound(amountB, 1, 1e30); vm.prank(maker); - uint256 orderId = board.createOrder(address(tokenA), amountA, address(tokenB), amountB); + uint256 orderId = + board.createOrder(address(tokenA), amountA, address(tokenB), amountB, false); uint256 takerBalanceBefore = tokenA.balanceOf(taker); vm.prank(taker); - board.fillOrder(orderId, 0); + board.fillOrder(orderId, 0, 0); uint256 takerBalanceAfter = tokenA.balanceOf(taker); assertEq( @@ -196,12 +197,13 @@ contract SwapboardStatelessInvariantTest is Test { amountB = bound(amountB, 1, 1e30); vm.prank(maker); - uint256 orderId = board.createOrder(address(tokenA), amountA, address(tokenB), amountB); + uint256 orderId = + board.createOrder(address(tokenA), amountA, address(tokenB), amountB, false); uint256 makerBalanceBefore = tokenB.balanceOf(maker); vm.prank(taker); - board.fillOrder(orderId, 0); + board.fillOrder(orderId, 0, 0); uint256 makerBalanceAfter = tokenB.balanceOf(maker); assertEq( @@ -220,7 +222,8 @@ contract SwapboardStatelessInvariantTest is Test { uint256 balanceInitial = tokenA.balanceOf(maker); vm.prank(maker); - uint256 orderId = board.createOrder(address(tokenA), amountA, address(tokenB), amountB); + uint256 orderId = + board.createOrder(address(tokenA), amountA, address(tokenB), amountB, false); vm.prank(maker); board.cancelOrder(orderId); @@ -238,12 +241,13 @@ contract SwapboardStatelessInvariantTest is Test { amountB = bound(amountB, 1, 1e30); vm.prank(maker); - uint256 orderId = board.createOrder(address(tokenA), amountA, address(tokenB), amountB); + uint256 orderId = + board.createOrder(address(tokenA), amountA, address(tokenB), amountB, false); assertTrue(board.canFill(orderId), "Order should be fillable"); vm.prank(taker); - board.fillOrder(orderId, 0); + board.fillOrder(orderId, 0, 0); assertFalse(board.canFill(orderId), "Order should not be fillable after fill"); @@ -261,7 +265,7 @@ contract SwapboardStatelessInvariantTest is Test { for (uint256 i = 0; i < n; i++) { vm.prank(maker); - board.createOrder(address(tokenA), 1 ether, address(tokenB), 1 ether); + board.createOrder(address(tokenA), 1 ether, address(tokenB), 1 ether, false); uint256 currentId = board.nextOrderId(); assertGt(currentId, prevId, "nextOrderId did not increase"); diff --git a/contracts/test/mocks/ReentrantAttacker.sol b/contracts/test/mocks/ReentrantAttacker.sol index f72dcb6..c1be1aa 100644 --- a/contracts/test/mocks/ReentrantAttacker.sol +++ b/contracts/test/mocks/ReentrantAttacker.sol @@ -100,7 +100,7 @@ contract ReentrantAttacker { if (keccak256(bytes(attackType)) == keccak256(bytes("fill"))) { // Try to fill the same order again - try board.fillOrder(orderId, 0) {} catch {} + try board.fillOrder(orderId, 0, 0) {} catch {} } else if (keccak256(bytes(attackType)) == keccak256(bytes("cancel"))) { // Try to cancel the same order again try board.cancelOrder(orderId) {} catch {} diff --git a/contracts/test/security-research/ProvenExploits.t.sol b/contracts/test/security-research/ProvenExploits.t.sol index df69272..7e29796 100644 --- a/contracts/test/security-research/ProvenExploits.t.sol +++ b/contracts/test/security-research/ProvenExploits.t.sol @@ -24,7 +24,6 @@ pragma solidity ^0.8.33; import {Test, console2} from "forge-std/Test.sol"; import {Swapboard} from "../../src/Swapboard.sol"; -import {ISwapboard} from "../../src/interfaces/ISwapboard.sol"; import {MockERC20} from "../mocks/MockERC20.sol"; import {MockWETH} from "../mocks/MockWETH.sol"; @@ -264,7 +263,7 @@ contract ProvenExploitTests is Test { vm.prank(maker); uint256 orderId = - board.createOrder(address(tokenA), 100 ether, address(phantomB), 100 ether); + board.createOrder(address(tokenA), 100 ether, address(phantomB), 100 ether, false); // Taker has no phantom tokens, but the transfer "succeeds" anyway uint256 takerPhantomBefore = phantomB.balanceOf(taker); @@ -276,7 +275,7 @@ contract ProvenExploitTests is Test { // Fill order - phantom token claims transfer succeeded vm.prank(taker); - board.fillOrder(orderId, 0); + board.fillOrder(orderId, 0, 0); // EXPLOIT RESULT: Taker got 100 tokenA for FREE assertEq(tokenA.balanceOf(taker), 100 ether, "Taker stole tokenA for free"); @@ -302,7 +301,7 @@ contract ProvenExploitTests is Test { vm.prank(maker); uint256 orderId = - board.createOrder(address(rebaseToken), 100 ether, address(tokenB), 100 ether); + board.createOrder(address(rebaseToken), 100 ether, address(tokenB), 100 ether, false); // Verify order created correctly uint256 contractBalanceBefore = rebaseToken.balanceOf(address(board)); @@ -322,7 +321,7 @@ contract ProvenExploitTests is Test { vm.prank(taker); vm.expectRevert("Insufficient shares"); - board.fillOrder(orderId, 0); + board.fillOrder(orderId, 0, 0); // Maker tries to cancel - ALSO FAILS because contract doesn't have enough vm.prank(maker); @@ -349,7 +348,7 @@ contract ProvenExploitTests is Test { vm.prank(maker); uint256 orderId = - board.createOrder(address(tokenA), 100 ether, address(upgradeableB), 100 ether); + board.createOrder(address(tokenA), 100 ether, address(upgradeableB), 100 ether, false); // Token gets "upgraded" to enable FOT (5% fee) upgradeableB.setFeeOnTransfer(true); @@ -359,11 +358,10 @@ contract ProvenExploitTests is Test { vm.prank(taker); upgradeableB.approve(address(board), 200 ether); - uint256 takerUpgradeableBefore = upgradeableB.balanceOf(taker); uint256 makerUpgradeableBefore = upgradeableB.balanceOf(maker); vm.prank(taker); - board.fillOrder(orderId, 0); + board.fillOrder(orderId, 0, 0); // Taker got full 100 tokenA assertEq(tokenA.balanceOf(taker), 100 ether); @@ -391,11 +389,11 @@ contract ProvenExploitTests is Test { vm.prank(maker); uint256 order0 = - board.createOrder(address(rebaseToken), 100 ether, address(tokenB), 100 ether); + board.createOrder(address(rebaseToken), 100 ether, address(tokenB), 100 ether, false); vm.prank(maker); uint256 order1 = - board.createOrder(address(rebaseToken), 100 ether, address(tokenB), 100 ether); + board.createOrder(address(rebaseToken), 100 ether, address(tokenB), 100 ether, false); // Contract has 200 tokens assertEq(rebaseToken.balanceOf(address(board)), 200 ether); @@ -412,7 +410,7 @@ contract ProvenExploitTests is Test { tokenB.approve(address(board), 200 ether); vm.prank(taker); - board.fillOrder(order0, 0); + board.fillOrder(order0, 0, 0); // Rounding due to share-based accounting assertApproxEqAbs(rebaseToken.balanceOf(taker), 100 ether, 2); @@ -427,7 +425,7 @@ contract ProvenExploitTests is Test { tokenB.approve(address(board), 200 ether); vm.prank(taker2); - board.fillOrder(order1, 0); + board.fillOrder(order1, 0, 0); // 40 tokens STUCK in contract forever uint256 stuckTokens = rebaseToken.balanceOf(address(board)); @@ -453,7 +451,8 @@ contract ProvenExploitTests is Test { uint256[] memory orderIds = new uint256[](5); for (uint256 i = 0; i < 5; i++) { vm.prank(maker); - orderIds[i] = board.createOrder(address(tokenA), 100 ether, address(tokenB), 100 ether); + orderIds[i] = + board.createOrder(address(tokenA), 100 ether, address(tokenB), 100 ether, false); } // Fill all in reverse order @@ -462,7 +461,7 @@ contract ProvenExploitTests is Test { for (uint256 i = 5; i > 0; i--) { vm.prank(taker); - board.fillOrder(orderIds[i - 1], 0); + board.fillOrder(orderIds[i - 1], 0, 0); } // All balances correct From 22b2f3ae72d02ac7530a6f871524b56bf7ab8752 Mon Sep 17 00:00:00 2001 From: ross <92001561+z0r0z@users.noreply.github.com> Date: Thu, 16 Apr 2026 18:33:12 +0000 Subject: [PATCH 2/2] update gas snapshot for partial-fill changes Co-Authored-By: Claude Opus 4.7 (1M context) --- contracts/.gas-snapshot | 368 +++++++++++++++++++++------------------- 1 file changed, 195 insertions(+), 173 deletions(-) diff --git a/contracts/.gas-snapshot b/contracts/.gas-snapshot index 7279901..d8b361c 100644 --- a/contracts/.gas-snapshot +++ b/contracts/.gas-snapshot @@ -1,102 +1,113 @@ -ExploitVectorTests:test_edgeCase_manyOrders_sequentialIds() (gas: 12431745) -ExploitVectorTests:test_edgeCase_maxUint256() (gas: 370625) -ExploitVectorTests:test_edgeCase_precompileAddresses() (gas: 55232) -ExploitVectorTests:test_edgeCase_selfTrade() (gas: 287924) -ExploitVectorTests:test_exploit_accessControl_cancelByNonMaker() (gas: 224363) -ExploitVectorTests:test_exploit_blacklistToken_blocksBlacklistedUser() (gas: 979735) -ExploitVectorTests:test_exploit_dos_getOrders_largeArray() (gas: 15432755) -ExploitVectorTests:test_exploit_doubleCancel() (gas: 227835) -ExploitVectorTests:test_exploit_doubleFill() (gas: 331813) -ExploitVectorTests:test_exploit_fot_createOrder_rejected() (gas: 680182) -ExploitVectorTests:test_exploit_fot_fillOrder_makerReceivesLess() (gas: 930179) -ExploitVectorTests:test_exploit_griefing_dustOrders() (gas: 12589439) -ExploitVectorTests:test_exploit_pausableToken_blocksOperations() (gas: 885397) -ExploitVectorTests:test_exploit_rebaseToken_positiveRebase() (gas: 936922) -ExploitVectorTests:test_exploit_reentrancy_cancelOrder() (gas: 1053541) -ExploitVectorTests:test_exploit_reentrancy_fillOrder_tokenA() (gas: 1154824) -GasBenchmarks:test_gas_canFill() (gas: 196051) -GasBenchmarks:test_gas_cancelOrder() (gas: 179231) -GasBenchmarks:test_gas_createOrder() (gas: 194993) -GasBenchmarks:test_gas_fillOrder() (gas: 239331) -GasBenchmarks:test_gas_getOrder() (gas: 197745) -GasBenchmarks:test_gas_getOrders_10() (gas: 1329000) -GasBenchmarks:test_gas_getOrders_100() (gas: 12637240) -ProvenExploitTests:test_EXPLOIT_negativeRebase_fundsLocked() (gas: 982906) -ProvenExploitTests:test_EXPLOIT_phantomTokenB_freeTokenA() (gas: 682424) -ProvenExploitTests:test_EXPLOIT_rebaseToken_unfairDistribution() (gas: 1260076) -ProvenExploitTests:test_EXPLOIT_upgradeableToken_becomeFOT() (gas: 992896) -ProvenExploitTests:test_multipleOrdersSameTokenPair() (gas: 895161) -SwapboardETHTest:testFuzz_cancelOrderUnwrap(uint256) (runs: 256, μ: 228487, ~: 228194) -SwapboardETHTest:testFuzz_createOrderWithEth(uint256,uint256) (runs: 256, μ: 224352, ~: 224444) -SwapboardETHTest:testFuzz_fillOrderUnwrap(uint256,uint256) (runs: 256, μ: 231314, ~: 231406) -SwapboardETHTest:testFuzz_fillOrderUnwrap_partial(uint256,uint256,uint256) (runs: 257, μ: 270050, ~: 273837) -SwapboardETHTest:testFuzz_fillOrderWithEth(uint256,uint256) (runs: 256, μ: 269606, ~: 269718) -SwapboardETHTest:testFuzz_fillOrderWithEth_partial(uint256,uint256,uint256) (runs: 256, μ: 275496, ~: 292053) -SwapboardETHTest:test_cancelOrderUnwrap() (gas: 208403) -SwapboardETHTest:test_cancelOrderUnwrap_event() (gas: 203492) -SwapboardETHTest:test_cancelOrderUnwrap_revert_ethTransferFailed() (gas: 262489) -SwapboardETHTest:test_cancelOrderUnwrap_revert_notMaker() (gas: 221428) -SwapboardETHTest:test_cancelOrderUnwrap_revert_notWETH() (gas: 198805) -SwapboardETHTest:test_cancelOrderUnwrap_revert_orderNotActive() (gas: 204489) -SwapboardETHTest:test_cancelOrderUnwrap_revert_orderNotFound() (gas: 13733) -SwapboardETHTest:test_createNormal_fillWithEth() (gas: 262621) -SwapboardETHTest:test_createOrderWithEth() (gas: 223012) -SwapboardETHTest:test_createOrderWithEth_event() (gas: 222137) -SwapboardETHTest:test_createOrderWithEth_partialFillFlag() (gas: 221514) -SwapboardETHTest:test_createOrderWithEth_revert_notAContract() (gas: 21211) -SwapboardETHTest:test_createOrderWithEth_revert_sameToken() (gas: 20338) -SwapboardETHTest:test_createOrderWithEth_revert_zeroAddress() (gas: 18131) -SwapboardETHTest:test_createOrderWithEth_revert_zeroAmount() (gas: 20261) -SwapboardETHTest:test_createOrderWithEth_revert_zeroETH() (gas: 13530) -SwapboardETHTest:test_createOrderWithEth_sequentialIds() (gas: 351040) -SwapboardETHTest:test_createOrderWithEth_wethBalance() (gas: 220890) -SwapboardETHTest:test_createWithEth_cancelNormal() (gas: 229801) -SwapboardETHTest:test_createWithEth_cancelUnwrap() (gas: 205869) -SwapboardETHTest:test_createWithEth_fillNormal() (gas: 252117) -SwapboardETHTest:test_createWithEth_fillUnwrap() (gas: 224977) -SwapboardETHTest:test_fillOrderUnwrap() (gas: 230275) -SwapboardETHTest:test_fillOrderUnwrap_event() (gas: 223396) -SwapboardETHTest:test_fillOrderUnwrap_fullFillViaZero() (gas: 224599) -SwapboardETHTest:test_fillOrderUnwrap_partialFill() (gas: 266741) -SwapboardETHTest:test_fillOrderUnwrap_partialFill_event() (gas: 266535) -SwapboardETHTest:test_fillOrderUnwrap_partialFill_thenCancel() (gas: 254519) -SwapboardETHTest:test_fillOrderUnwrap_revert_deadlineExpired() (gas: 246396) -SwapboardETHTest:test_fillOrderUnwrap_revert_ethTransferFailed() (gas: 291012) -SwapboardETHTest:test_fillOrderUnwrap_revert_notWETH() (gas: 202837) -SwapboardETHTest:test_fillOrderUnwrap_revert_orderNotActive() (gas: 224369) -SwapboardETHTest:test_fillOrderUnwrap_revert_orderNotFound() (gas: 13857) -SwapboardETHTest:test_fillOrderUnwrap_revert_partialNotAllowed() (gas: 247439) -SwapboardETHTest:test_fillOrderWithEth() (gas: 265376) -SwapboardETHTest:test_fillOrderWithEth_event() (gas: 259921) -SwapboardETHTest:test_fillOrderWithEth_fullFillOnPartialOrder() (gas: 264156) -SwapboardETHTest:test_fillOrderWithEth_partialFill() (gas: 286431) -SwapboardETHTest:test_fillOrderWithEth_partialFill_event() (gas: 283249) -SwapboardETHTest:test_fillOrderWithEth_revert_amountMismatch_tooHigh() (gas: 207565) -SwapboardETHTest:test_fillOrderWithEth_revert_deadlineExpired() (gas: 206723) -SwapboardETHTest:test_fillOrderWithEth_revert_notWETH() (gas: 209546) -SwapboardETHTest:test_fillOrderWithEth_revert_orderNotActive() (gas: 267809) -SwapboardETHTest:test_fillOrderWithEth_revert_orderNotFound() (gas: 20511) -SwapboardETHTest:test_fillOrderWithEth_revert_partialNotAllowed() (gas: 207572) -SwapboardETHTest:test_fillOrderWithEth_revert_zeroValue() (gas: 199675) -SwapboardETHTest:test_fillOrderWithEth_sequentialPartialFills() (gas: 376493) -SwapboardETHTest:test_fillOrderWithEth_zeroValue_cannotDrainWeth() (gas: 383144) -SwapboardETHTest:test_multipleETHOrders() (gas: 563900) -SwapboardETHTest:test_partialFill_thenCancelUnwrap() (gas: 280838) -SwapboardETHTest:test_partialFill_thenFillOrderUnwrap() (gas: 286560) -SwapboardETHTest:test_partialFill_thenFillOrderWithEth() (gas: 312752) -SwapboardETHTest:test_receive_revert_nonWETH() (gas: 21575) -SwapboardIntegrationTest:test_batchOperations() (gas: 1435403) -SwapboardIntegrationTest:test_differentDecimalTokens() (gas: 246325) -SwapboardIntegrationTest:test_dustAmounts() (gas: 213281) -SwapboardIntegrationTest:test_eventSequence() (gas: 215905) -SwapboardIntegrationTest:test_getOrdersWithNonExistent() (gas: 351389) -SwapboardIntegrationTest:test_largeAmounts() (gas: 221262) -SwapboardIntegrationTest:test_multipleUsersMultipleOrders() (gas: 818907) -SwapboardIntegrationTest:test_orderLifecycle_createCancel() (gas: 188237) -SwapboardIntegrationTest:test_orderLifecycle_createFill() (gas: 218572) -SwapboardIntegrationTest:test_raceCondition_fillAndCancel() (gas: 213456) -SwapboardIntegrationTest:test_raceCondition_twoFillersOneOrder() (gas: 244611) -SwapboardIntegrationTest:test_stressTest_manyOrders() (gas: 13519823) +ExploitVectorTests:test_edgeCase_manyOrders_sequentialIds() (gas: 12413590) +ExploitVectorTests:test_edgeCase_maxUint256() (gas: 332456) +ExploitVectorTests:test_edgeCase_precompileAddresses() (gas: 53807) +ExploitVectorTests:test_edgeCase_selfTrade() (gas: 275169) +ExploitVectorTests:test_exploit_accessControl_cancelByNonMaker() (gas: 224056) +ExploitVectorTests:test_exploit_blacklistToken_blocksBlacklistedUser() (gas: 901919) +ExploitVectorTests:test_exploit_dos_getOrders_largeArray() (gas: 15322759) +ExploitVectorTests:test_exploit_doubleCancel() (gas: 226536) +ExploitVectorTests:test_exploit_doubleFill() (gas: 310304) +ExploitVectorTests:test_exploit_fot_createOrder_rejected() (gas: 679971) +ExploitVectorTests:test_exploit_fot_fillOrder_makerReceivesLess() (gas: 892000) +ExploitVectorTests:test_exploit_griefing_dustOrders() (gas: 12533377) +ExploitVectorTests:test_exploit_pausableToken_blocksOperations() (gas: 801190) +ExploitVectorTests:test_exploit_rebaseToken_positiveRebase() (gas: 898767) +ExploitVectorTests:test_exploit_reentrancy_cancelOrder() (gas: 1014581) +ExploitVectorTests:test_exploit_reentrancy_fillOrder_tokenA() (gas: 1116645) +GasBenchmarks:test_gas_canFill() (gas: 195870) +GasBenchmarks:test_gas_cancelOrder() (gas: 158129) +GasBenchmarks:test_gas_createOrder() (gas: 194824) +GasBenchmarks:test_gas_fillOrder() (gas: 204613) +GasBenchmarks:test_gas_getOrder() (gas: 197525) +GasBenchmarks:test_gas_getOrders_10() (gas: 1326458) +GasBenchmarks:test_gas_getOrders_100() (gas: 12611928) +ProvenExploitTests:test_EXPLOIT_negativeRebase_fundsLocked() (gas: 903322) +ProvenExploitTests:test_EXPLOIT_phantomTokenB_freeTokenA() (gas: 644251) +ProvenExploitTests:test_EXPLOIT_rebaseToken_unfairDistribution() (gas: 1183802) +ProvenExploitTests:test_EXPLOIT_upgradeableToken_becomeFOT() (gas: 954735) +ProvenExploitTests:test_multipleOrdersSameTokenPair() (gas: 750802) +SwapboardETHTest:testFuzz_cancelOrderUnwrap(uint256) (runs: 256, μ: 211147, ~: 210934) +SwapboardETHTest:testFuzz_createOrderWithEth(uint256,uint256) (runs: 256, μ: 224132, ~: 224211) +SwapboardETHTest:testFuzz_fillOrderUnwrap(uint256,uint256) (runs: 256, μ: 232192, ~: 232236) +SwapboardETHTest:testFuzz_fillOrderUnwrap_partial(uint256,uint256,uint256) (runs: 256, μ: 271028, ~: 274343) +SwapboardETHTest:testFuzz_fillOrderWithEth(uint256,uint256) (runs: 256, μ: 262793, ~: 262849) +SwapboardETHTest:testFuzz_fillOrderWithEth_partial(uint256,uint256,uint256) (runs: 256, μ: 276128, ~: 292863) +SwapboardETHTest:test_cancelOrderUnwrap() (gas: 195044) +SwapboardETHTest:test_cancelOrderUnwrap_event() (gas: 193788) +SwapboardETHTest:test_cancelOrderUnwrap_revert_ethTransferFailed() (gas: 237624) +SwapboardETHTest:test_cancelOrderUnwrap_revert_notMaker() (gas: 221230) +SwapboardETHTest:test_cancelOrderUnwrap_revert_notWETH() (gas: 198494) +SwapboardETHTest:test_cancelOrderUnwrap_revert_orderNotActive() (gas: 191975) +SwapboardETHTest:test_cancelOrderUnwrap_revert_orderNotFound() (gas: 13728) +SwapboardETHTest:test_cancelOrdersUnwrap_afterPartialFill() (gas: 379926) +SwapboardETHTest:test_cancelOrdersUnwrap_atomic_reverts() (gas: 321351) +SwapboardETHTest:test_cancelOrdersUnwrap_basic() (gas: 454183) +SwapboardETHTest:test_cancelOrdersUnwrap_empty() (gas: 6414) +SwapboardETHTest:test_cancelOrdersUnwrap_events() (gas: 323510) +SwapboardETHTest:test_cancelOrdersUnwrap_revert_notMaker() (gas: 222193) +SwapboardETHTest:test_cancelOrdersUnwrap_revert_notWETH() (gas: 332826) +SwapboardETHTest:test_createNormal_fillWithEth() (gas: 255098) +SwapboardETHTest:test_createOrderWithEth() (gas: 222737) +SwapboardETHTest:test_createOrderWithEth_event() (gas: 221971) +SwapboardETHTest:test_createOrderWithEth_partialFillFlag() (gas: 221333) +SwapboardETHTest:test_createOrderWithEth_revert_notAContract() (gas: 21077) +SwapboardETHTest:test_createOrderWithEth_revert_sameToken() (gas: 20233) +SwapboardETHTest:test_createOrderWithEth_revert_zeroAddress() (gas: 18093) +SwapboardETHTest:test_createOrderWithEth_revert_zeroAmount() (gas: 20223) +SwapboardETHTest:test_createOrderWithEth_revert_zeroETH() (gas: 13438) +SwapboardETHTest:test_createOrderWithEth_sequentialIds() (gas: 350782) +SwapboardETHTest:test_createOrderWithEth_wethBalance() (gas: 220721) +SwapboardETHTest:test_createWithEth_cancelNormal() (gas: 196359) +SwapboardETHTest:test_createWithEth_cancelUnwrap() (gas: 193122) +SwapboardETHTest:test_createWithEth_fillNormal() (gas: 230733) +SwapboardETHTest:test_createWithEth_fillUnwrap() (gas: 225030) +SwapboardETHTest:test_fillOrderUnwrap() (gas: 229176) +SwapboardETHTest:test_fillOrderUnwrap_event() (gas: 226836) +SwapboardETHTest:test_fillOrderUnwrap_fullFillViaZero() (gas: 224644) +SwapboardETHTest:test_fillOrderUnwrap_partialFill() (gas: 267553) +SwapboardETHTest:test_fillOrderUnwrap_partialFill_event() (gas: 270743) +SwapboardETHTest:test_fillOrderUnwrap_partialFill_thenCancel() (gas: 248655) +SwapboardETHTest:test_fillOrderUnwrap_revert_cancelledOrder_givesActiveError() (gas: 214100) +SwapboardETHTest:test_fillOrderUnwrap_revert_deadlineExpired() (gas: 246219) +SwapboardETHTest:test_fillOrderUnwrap_revert_ethTransferFailed() (gas: 291088) +SwapboardETHTest:test_fillOrderUnwrap_revert_notWETH() (gas: 202693) +SwapboardETHTest:test_fillOrderUnwrap_revert_orderNotActive() (gas: 224269) +SwapboardETHTest:test_fillOrderUnwrap_revert_orderNotFound() (gas: 13806) +SwapboardETHTest:test_fillOrderUnwrap_revert_partialNotAllowed() (gas: 247336) +SwapboardETHTest:test_fillOrderWithEth() (gas: 257239) +SwapboardETHTest:test_fillOrderWithEth_event() (gas: 254423) +SwapboardETHTest:test_fillOrderWithEth_fullFillOnPartialOrder() (gas: 256277) +SwapboardETHTest:test_fillOrderWithEth_partialFill() (gas: 287182) +SwapboardETHTest:test_fillOrderWithEth_partialFill_event() (gas: 285427) +SwapboardETHTest:test_fillOrderWithEth_revert_amountMismatch_tooHigh() (gas: 207406) +SwapboardETHTest:test_fillOrderWithEth_revert_cancelledOrder_givesActiveError() (gas: 183341) +SwapboardETHTest:test_fillOrderWithEth_revert_deadlineExpired() (gas: 206498) +SwapboardETHTest:test_fillOrderWithEth_revert_notWETH() (gas: 209368) +SwapboardETHTest:test_fillOrderWithEth_revert_orderNotActive() (gas: 258820) +SwapboardETHTest:test_fillOrderWithEth_revert_orderNotFound() (gas: 20460) +SwapboardETHTest:test_fillOrderWithEth_revert_partialNotAllowed() (gas: 207460) +SwapboardETHTest:test_fillOrderWithEth_revert_zeroValue() (gas: 199450) +SwapboardETHTest:test_fillOrderWithEth_sequentialPartialFills() (gas: 380484) +SwapboardETHTest:test_fillOrderWithEth_zeroValue_cannotDrainWeth() (gas: 382695) +SwapboardETHTest:test_fullFillUnwrap_zerosAmounts() (gas: 224394) +SwapboardETHTest:test_fullFill_zerosAmounts() (gas: 253585) +SwapboardETHTest:test_multipleETHOrders() (gas: 486982) +SwapboardETHTest:test_partialFill_thenCancelUnwrap() (gas: 253770) +SwapboardETHTest:test_partialFill_thenFillOrderUnwrap() (gas: 274942) +SwapboardETHTest:test_partialFill_thenFillOrderWithEth() (gas: 314832) +SwapboardETHTest:test_receive_revert_nonWETH() (gas: 21525) +SwapboardIntegrationTest:test_batchOperations() (gas: 1201963) +SwapboardIntegrationTest:test_differentDecimalTokens() (gas: 241957) +SwapboardIntegrationTest:test_dustAmounts() (gas: 214631) +SwapboardIntegrationTest:test_eventSequence() (gas: 218709) +SwapboardIntegrationTest:test_getOrdersWithNonExistent() (gas: 350547) +SwapboardIntegrationTest:test_largeAmounts() (gas: 222576) +SwapboardIntegrationTest:test_multipleUsersMultipleOrders() (gas: 742258) +SwapboardIntegrationTest:test_orderLifecycle_createCancel() (gas: 181117) +SwapboardIntegrationTest:test_orderLifecycle_createFill() (gas: 219671) +SwapboardIntegrationTest:test_raceCondition_fillAndCancel() (gas: 214825) +SwapboardIntegrationTest:test_raceCondition_twoFillersOneOrder() (gas: 240538) +SwapboardIntegrationTest:test_stressTest_manyOrders() (gas: 11007016) SwapboardInvariantTest:invariant_activeOrderCount() (runs: 256, calls: 128000, reverts: 0) SwapboardInvariantTest:invariant_balanceEqualsActiveOrderSum() (runs: 256, calls: 128000, reverts: 0) SwapboardInvariantTest:invariant_callSummary() (runs: 256, calls: 128000, reverts: 0) @@ -105,77 +116,88 @@ SwapboardInvariantTest:invariant_noOvercounting() (runs: 256, calls: 128000, rev SwapboardInvariantTest:invariant_nonNegativeBalance() (runs: 256, calls: 128000, reverts: 0) SwapboardInvariantTest:invariant_orderAccounting() (runs: 256, calls: 128000, reverts: 0) SwapboardInvariantTest:invariant_solvency() (runs: 256, calls: 128000, reverts: 0) -SwapboardStatelessInvariantTest:testFuzz_cancelOrder_makerRegainsTokenA(uint256,uint256) (runs: 256, μ: 179395, ~: 179562) -SwapboardStatelessInvariantTest:testFuzz_createOrder_contractBalanceIncrease(uint256,uint256) (runs: 256, μ: 195201, ~: 195368) -SwapboardStatelessInvariantTest:testFuzz_createOrder_makerBalanceDecrease(uint256,uint256) (runs: 256, μ: 195133, ~: 195300) -SwapboardStatelessInvariantTest:testFuzz_fillOrder_makerGainsTokenB(uint256,uint256) (runs: 256, μ: 239519, ~: 239686) -SwapboardStatelessInvariantTest:testFuzz_fillOrder_takerGainsTokenA(uint256,uint256) (runs: 256, μ: 239585, ~: 239752) -SwapboardStatelessInvariantTest:testFuzz_nextOrderId_monotonic(uint256) (runs: 256, μ: 3341482, ~: 3291696) -SwapboardStatelessInvariantTest:testFuzz_orderStateFinal_afterFill(uint256,uint256) (runs: 256, μ: 242300, ~: 242467) -SwapboardTest:testFuzz_createOrder(uint256,uint256) (runs: 256, μ: 204938, ~: 204535) -SwapboardTest:testFuzz_fillOrder(uint256,uint256) (runs: 256, μ: 258171, ~: 257768) -SwapboardTest:testFuzz_fillOrder_partial(uint256,uint256,uint256) (runs: 256, μ: 277533, ~: 283932) -SwapboardTest:test_canFill() (gas: 196825) -SwapboardTest:test_canFill_false_nonExistent() (gas: 7872) -SwapboardTest:test_canFill_false_notActive() (gas: 180671) -SwapboardTest:test_cancelOrder() (gas: 185920) -SwapboardTest:test_cancelOrder_revert_notMaker() (gas: 200975) -SwapboardTest:test_cancelOrder_revert_orderNotActive() (gas: 181595) -SwapboardTest:test_cancelOrder_revert_orderNotFound() (gas: 14120) -SwapboardTest:test_createOrder() (gas: 202818) -SwapboardTest:test_createOrder_partialFillFlag() (gas: 328098) -SwapboardTest:test_createOrder_revert_FOT() (gas: 660809) -SwapboardTest:test_createOrder_revert_notAContract_tokenA() (gas: 18073) -SwapboardTest:test_createOrder_revert_notAContract_tokenB() (gas: 45928) -SwapboardTest:test_createOrder_revert_sameToken() (gas: 42753) -SwapboardTest:test_createOrder_revert_zeroAddress_tokenA() (gas: 14955) -SwapboardTest:test_createOrder_revert_zeroAddress_tokenB() (gas: 42659) -SwapboardTest:test_createOrder_revert_zeroAmount_amountA() (gas: 44798) -SwapboardTest:test_createOrder_revert_zeroAmount_amountB() (gas: 44833) -SwapboardTest:test_createOrders_atomic_reverts() (gas: 224210) -SwapboardTest:test_createOrders_basic() (gas: 336662) -SwapboardTest:test_createOrders_empty() (gas: 7163) -SwapboardTest:test_createOrders_mixedTokenPairs() (gas: 914554) -SwapboardTest:test_createOrders_thenFillOrders() (gas: 539785) -SwapboardTest:test_events_orderCanceled() (gas: 181019) -SwapboardTest:test_events_orderCreated() (gas: 199331) -SwapboardTest:test_events_orderFilled() (gas: 246100) -SwapboardTest:test_fillOrder() (gas: 250414) -SwapboardTest:test_fillOrder_deadlineZero_noExpiry() (gas: 247206) -SwapboardTest:test_fillOrder_partial_basic() (gas: 271594) -SwapboardTest:test_fillOrder_partial_contractBalanceInvariant() (gas: 422625) -SwapboardTest:test_fillOrder_partial_deadlineZero_noExpiry() (gas: 269146) -SwapboardTest:test_fillOrder_partial_event() (gas: 269358) -SwapboardTest:test_fillOrder_partial_fullFillEmitsOrderFilled() (gas: 246142) -SwapboardTest:test_fillOrder_partial_fullFillOnExactRemaining() (gas: 248092) -SwapboardTest:test_fillOrder_partial_fullFillWhenExceedsRemaining() (gas: 269416) -SwapboardTest:test_fillOrder_partial_largeAmountsNoOverflow() (gas: 282854) -SwapboardTest:test_fillOrder_partial_multipleFillers() (gas: 319605) -SwapboardTest:test_fillOrder_partial_revert_deadlineExpired() (gas: 225831) -SwapboardTest:test_fillOrder_partial_revert_notPartialFillable() (gas: 226559) -SwapboardTest:test_fillOrder_partial_revert_orderNotActive() (gas: 210505) -SwapboardTest:test_fillOrder_partial_revert_orderNotFound() (gas: 14310) -SwapboardTest:test_fillOrder_partial_revert_zeroComputedAmountA() (gas: 226308) -SwapboardTest:test_fillOrder_partial_roundsDownFavoringMaker() (gas: 269852) -SwapboardTest:test_fillOrder_partial_selfFill() (gas: 248228) -SwapboardTest:test_fillOrder_partial_sequentialFillsDrainOrder() (gas: 381478) -SwapboardTest:test_fillOrder_partial_thenCancel() (gas: 258462) -SwapboardTest:test_fillOrder_partial_thenFillOrder() (gas: 316183) -SwapboardTest:test_fillOrder_revert_deadlineExpired() (gas: 225604) -SwapboardTest:test_fillOrder_revert_orderNotActive() (gas: 246286) -SwapboardTest:test_fillOrder_revert_orderNotFound() (gas: 14346) -SwapboardTest:test_fillOrder_worksOnPartialFillableOrder() (gas: 248112) -SwapboardTest:test_fillOrder_zeroFillsRemainderAfterPartial() (gas: 316183) -SwapboardTest:test_fillOrders_atomic_reverts() (gas: 271508) -SwapboardTest:test_fillOrders_basic() (gas: 403619) -SwapboardTest:test_fillOrders_empty() (gas: 7013) -SwapboardTest:test_fillOrders_partialAndFull() (gas: 422890) -SwapboardTest:test_fillOrders_revert_deadlineExpired() (gas: 10084) -SwapboardTest:test_fillOrders_revert_lengthMismatch() (gas: 10052) -SwapboardTest:test_fillOrders_sameOrderTwice() (gas: 291147) -SwapboardTest:test_fillOrders_withPartialFills() (gas: 425131) -SwapboardTest:test_getOrders() (gas: 451776) -SwapboardTest:test_getOrders_empty() (gas: 6659) -SwapboardTest:test_initialState() (gas: 7694) -SwapboardTest:test_multipleOrders() (gas: 442667) -SwapboardTest:test_selfFill() (gas: 239752) \ No newline at end of file +SwapboardStatelessInvariantTest:testFuzz_cancelOrder_makerRegainsTokenA(uint256,uint256) (runs: 256, μ: 158127, ~: 158224) +SwapboardStatelessInvariantTest:testFuzz_createOrder_contractBalanceIncrease(uint256,uint256) (runs: 256, μ: 195003, ~: 195167) +SwapboardStatelessInvariantTest:testFuzz_createOrder_makerBalanceDecrease(uint256,uint256) (runs: 256, μ: 194935, ~: 195099) +SwapboardStatelessInvariantTest:testFuzz_fillOrder_makerGainsTokenB(uint256,uint256) (runs: 256, μ: 204630, ~: 204727) +SwapboardStatelessInvariantTest:testFuzz_fillOrder_takerGainsTokenA(uint256,uint256) (runs: 256, μ: 204683, ~: 204780) +SwapboardStatelessInvariantTest:testFuzz_nextOrderId_monotonic(uint256) (runs: 256, μ: 3332966, ~: 3163407) +SwapboardStatelessInvariantTest:testFuzz_orderStateFinal_afterFill(uint256,uint256) (runs: 256, μ: 206814, ~: 206911) +SwapboardTest:testFuzz_createOrder(uint256,uint256) (runs: 256, μ: 204650, ~: 204248) +SwapboardTest:testFuzz_fillOrder(uint256,uint256) (runs: 256, μ: 255755, ~: 255482) +SwapboardTest:testFuzz_fillOrder_partial(uint256,uint256,uint256) (runs: 256, μ: 277689, ~: 284510) +SwapboardTest:test_canFill() (gas: 196580) +SwapboardTest:test_canFill_false_nonExistent() (gas: 7905) +SwapboardTest:test_canFill_false_notActive() (gas: 175149) +SwapboardTest:test_cancelOrder() (gas: 179272) +SwapboardTest:test_cancelOrder_revert_notMaker() (gas: 200648) +SwapboardTest:test_cancelOrder_revert_orderNotActive() (gas: 175895) +SwapboardTest:test_cancelOrder_revert_orderNotFound() (gas: 14082) +SwapboardTest:test_cancelOrder_zerosAmounts() (gas: 176434) +SwapboardTest:test_cancelOrders_afterPartialFill() (gas: 355861) +SwapboardTest:test_cancelOrders_atomic_reverts() (gas: 282122) +SwapboardTest:test_cancelOrders_basic() (gas: 390252) +SwapboardTest:test_cancelOrders_empty() (gas: 6428) +SwapboardTest:test_cancelOrders_events() (gas: 282573) +SwapboardTest:test_cancelOrders_mixedTokens() (gas: 818189) +SwapboardTest:test_cancelOrders_revert_notMaker() (gas: 324215) +SwapboardTest:test_cancelOrders_revert_orderNotActive() (gas: 288980) +SwapboardTest:test_createOrder() (gas: 202426) +SwapboardTest:test_createOrder_partialFillFlag() (gas: 327540) +SwapboardTest:test_createOrder_revert_FOT() (gas: 660623) +SwapboardTest:test_createOrder_revert_notAContract_tokenA() (gas: 17971) +SwapboardTest:test_createOrder_revert_notAContract_tokenB() (gas: 45774) +SwapboardTest:test_createOrder_revert_sameToken() (gas: 42638) +SwapboardTest:test_createOrder_revert_zeroAddress_tokenA() (gas: 14894) +SwapboardTest:test_createOrder_revert_zeroAddress_tokenB() (gas: 42568) +SwapboardTest:test_createOrder_revert_zeroAmount_amountA() (gas: 44729) +SwapboardTest:test_createOrder_revert_zeroAmount_amountB() (gas: 44719) +SwapboardTest:test_createOrders_atomic_reverts() (gas: 223855) +SwapboardTest:test_createOrders_basic() (gas: 336001) +SwapboardTest:test_createOrders_empty() (gas: 7167) +SwapboardTest:test_createOrders_mixedTokenPairs() (gas: 913763) +SwapboardTest:test_createOrders_thenFillOrders() (gas: 483796) +SwapboardTest:test_events_orderCanceled() (gas: 176388) +SwapboardTest:test_events_orderCreated() (gas: 199130) +SwapboardTest:test_events_orderFilled() (gas: 243232) +SwapboardTest:test_fillOrder() (gas: 245180) +SwapboardTest:test_fillOrder_deadlineZero_noExpiry() (gas: 242620) +SwapboardTest:test_fillOrder_partial_basic() (gas: 272165) +SwapboardTest:test_fillOrder_partial_contractBalanceInvariant() (gas: 423972) +SwapboardTest:test_fillOrder_partial_deadlineZero_noExpiry() (gas: 269810) +SwapboardTest:test_fillOrder_partial_event() (gas: 271372) +SwapboardTest:test_fillOrder_partial_fullFillEmitsOrderFilled() (gas: 243279) +SwapboardTest:test_fillOrder_partial_fullFillOnExactRemaining() (gas: 243352) +SwapboardTest:test_fillOrder_partial_fullFillWhenExceedsRemaining() (gas: 244436) +SwapboardTest:test_fillOrder_partial_largeAmountsNoOverflow() (gas: 283469) +SwapboardTest:test_fillOrder_partial_multipleFillers() (gas: 317186) +SwapboardTest:test_fillOrder_partial_revert_deadlineExpired() (gas: 225568) +SwapboardTest:test_fillOrder_partial_revert_notPartialFillable() (gas: 226241) +SwapboardTest:test_fillOrder_partial_revert_orderNotActive() (gas: 198897) +SwapboardTest:test_fillOrder_partial_revert_orderNotFound() (gas: 14269) +SwapboardTest:test_fillOrder_partial_revert_zeroComputedAmountA() (gas: 226020) +SwapboardTest:test_fillOrder_partial_roundsDownFavoringMaker() (gas: 270530) +SwapboardTest:test_fillOrder_partial_selfFill() (gas: 248863) +SwapboardTest:test_fillOrder_partial_sequentialFillsDrainOrder() (gas: 361432) +SwapboardTest:test_fillOrder_partial_thenCancel() (gas: 251743) +SwapboardTest:test_fillOrder_partial_thenFillOrder() (gas: 318204) +SwapboardTest:test_fillOrder_revert_deadlineExpired() (gas: 225327) +SwapboardTest:test_fillOrder_revert_orderNotActive() (gas: 241941) +SwapboardTest:test_fillOrder_revert_orderNotFound() (gas: 14261) +SwapboardTest:test_fillOrder_worksOnPartialFillableOrder() (gas: 243368) +SwapboardTest:test_fillOrder_zeroFillsRemainderAfterPartial() (gas: 318239) +SwapboardTest:test_fillOrders_atomic_reverts() (gas: 246118) +SwapboardTest:test_fillOrders_basic() (gas: 373574) +SwapboardTest:test_fillOrders_empty() (gas: 7001) +SwapboardTest:test_fillOrders_partialAndFull() (gas: 385432) +SwapboardTest:test_fillOrders_revert_deadlineExpired() (gas: 10028) +SwapboardTest:test_fillOrders_revert_lengthMismatch() (gas: 9998) +SwapboardTest:test_fillOrders_sameOrderTwice() (gas: 292746) +SwapboardTest:test_fillOrders_withPartialFills() (gas: 426450) +SwapboardTest:test_fullFill_zerosAmounts() (gas: 242587) +SwapboardTest:test_fullFill_zerosAmounts_afterPartials() (gas: 254138) +SwapboardTest:test_getOrders() (gas: 450950) +SwapboardTest:test_getOrders_empty() (gas: 6686) +SwapboardTest:test_initialState() (gas: 7693) +SwapboardTest:test_multipleOrders() (gas: 442088) +SwapboardTest:test_selfFill() (gas: 241124) \ No newline at end of file