From 40db8a0db033608bb520f4dc3b31efab9bea393f Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 3 Aug 2026 04:35:57 -0500 Subject: [PATCH] feat: add opt-in integrator taker fees --- .gas-snapshot.runtime | 113 ++++---- .github/workflows/verify.yml | 2 + Makefile | 4 +- coverage.exclusions.json | 79 +++--- script/prove_protocol.py | 72 ++++- src/DeepstateV1.sol | 234 ++++++++++++++++ test/DeepstateV1.t.sol | 324 +++++++++++++++++++++++ test/DeepstateV1Invariant.t.sol | 277 ++++++++++++++++--- test/DeepstateV1NativeETH.t.sol | 107 ++++++++ test/DeepstateV1NativeETHInvariant.t.sol | 184 ++++++++++--- test/RadixMatchingEngineFormal.t.sol | 87 ++++++ test/RadixMatchingEngineGas.t.sol | 130 +++++++++ 12 files changed, 1442 insertions(+), 171 deletions(-) diff --git a/.gas-snapshot.runtime b/.gas-snapshot.runtime index 2fa1404..0e6960b 100644 --- a/.gas-snapshot.runtime +++ b/.gas-snapshot.runtime @@ -1,3 +1,8 @@ +DeepstateV1IntegratorFeeGasTest:testGas_IntegratorAndProtocolFeeFill() (gas: 129374) +DeepstateV1IntegratorFeeGasTest:testGas_IntegratorFillAskFullyMatchesSingleBid() (gas: 103768) +DeepstateV1IntegratorFeeGasTest:testGas_IntegratorFillBidFullyMatchesSingleAsk() (gas: 103888) +DeepstateV1IntegratorFeeGasTest:testGas_IntegratorFillRestBidEmptyBook() (gas: 151809) +DeepstateV1IntegratorFeeGasTest:testGas_IntegratorRouteNetsRepeatedFeeToken() (gas: 122504) RadixMatchingEngineFeeGasTest:testGas_CancelAskSkipsPathologicalBidTree() (gas: 39664) RadixMatchingEngineFeeGasTest:testGas_CancelFilledBidClaim() (gas: 36750) RadixMatchingEngineFeeGasTest:testGas_CancelFullDepthBidCombRightmost() (gas: 423078) @@ -5,24 +10,24 @@ RadixMatchingEngineFeeGasTest:testGas_CancelMaxValidDepthAskCombRightmost() (gas RadixMatchingEngineFeeGasTest:testGas_CancelPartialBid() (gas: 48804) RadixMatchingEngineFeeGasTest:testGas_CancelUnfilledAsk() (gas: 39881) RadixMatchingEngineFeeGasTest:testGas_CancelUnfilledBid() (gas: 39717) -RadixMatchingEngineFeeGasTest:testGas_FillAskConsumesDirtySamePriceBidSubtree() (gas: 87785) -RadixMatchingEngineFeeGasTest:testGas_FillAskConsumesFullDepthBidComb() (gas: 352938) -RadixMatchingEngineFeeGasTest:testGas_FillAskFullyMatchesSingleBid() (gas: 78136) -RadixMatchingEngineFeeGasTest:testGas_FillAskPartiallyMatchesSingleBid() (gas: 97831) -RadixMatchingEngineFeeGasTest:testGas_FillBidConsumesAskAndRestsRemainder() (gas: 130796) -RadixMatchingEngineFeeGasTest:testGas_FillBidConsumesDirtySamePriceAskSubtree() (gas: 87928) -RadixMatchingEngineFeeGasTest:testGas_FillBidConsumesMaxValidDepthAskComb() (gas: 324052) -RadixMatchingEngineFeeGasTest:testGas_FillBidConsumesSamePriceAskSubtree() (gas: 77920) -RadixMatchingEngineFeeGasTest:testGas_FillBidFullyMatchesSingleAsk() (gas: 77820) -RadixMatchingEngineFeeGasTest:testGas_FillBidPartiallyMatchesSingleAsk() (gas: 97734) -RadixMatchingEngineFeeGasTest:testGas_FillRestAskEmptyBook() (gas: 150833) -RadixMatchingEngineFeeGasTest:testGas_FillRestBidEmptyBook() (gas: 152609) -RadixMatchingEngineFeeGasTest:testGas_LargeRandomBookAskMatchesOneBid() (gas: 122040) -RadixMatchingEngineFeeGasTest:testGas_LargeRandomBookBidMatchesOneAsk() (gas: 137260) -RadixMatchingEngineFeeGasTest:testGas_LargeRandomBookPartialFillsAsk() (gas: 138398) -RadixMatchingEngineFeeGasTest:testGas_LargeRandomBookPartialFillsBid() (gas: 123164) -RadixMatchingEngineFeeGasTest:testGas_LargeRandomBookRestsAsk() (gas: 163907) -RadixMatchingEngineFeeGasTest:testGas_LargeRandomBookRestsBid() (gas: 173244) +RadixMatchingEngineFeeGasTest:testGas_FillAskConsumesDirtySamePriceBidSubtree() (gas: 87846) +RadixMatchingEngineFeeGasTest:testGas_FillAskConsumesFullDepthBidComb() (gas: 352999) +RadixMatchingEngineFeeGasTest:testGas_FillAskFullyMatchesSingleBid() (gas: 78197) +RadixMatchingEngineFeeGasTest:testGas_FillAskPartiallyMatchesSingleBid() (gas: 97892) +RadixMatchingEngineFeeGasTest:testGas_FillBidConsumesAskAndRestsRemainder() (gas: 130838) +RadixMatchingEngineFeeGasTest:testGas_FillBidConsumesDirtySamePriceAskSubtree() (gas: 87970) +RadixMatchingEngineFeeGasTest:testGas_FillBidConsumesMaxValidDepthAskComb() (gas: 324094) +RadixMatchingEngineFeeGasTest:testGas_FillBidConsumesSamePriceAskSubtree() (gas: 77962) +RadixMatchingEngineFeeGasTest:testGas_FillBidFullyMatchesSingleAsk() (gas: 77862) +RadixMatchingEngineFeeGasTest:testGas_FillBidPartiallyMatchesSingleAsk() (gas: 97776) +RadixMatchingEngineFeeGasTest:testGas_FillRestAskEmptyBook() (gas: 150894) +RadixMatchingEngineFeeGasTest:testGas_FillRestBidEmptyBook() (gas: 152651) +RadixMatchingEngineFeeGasTest:testGas_LargeRandomBookAskMatchesOneBid() (gas: 122101) +RadixMatchingEngineFeeGasTest:testGas_LargeRandomBookBidMatchesOneAsk() (gas: 137302) +RadixMatchingEngineFeeGasTest:testGas_LargeRandomBookPartialFillsAsk() (gas: 138440) +RadixMatchingEngineFeeGasTest:testGas_LargeRandomBookPartialFillsBid() (gas: 123225) +RadixMatchingEngineFeeGasTest:testGas_LargeRandomBookRestsAsk() (gas: 163968) +RadixMatchingEngineFeeGasTest:testGas_LargeRandomBookRestsBid() (gas: 173286) RadixMatchingEngineGasTest:testGas_CancelAskSkipsPathologicalBidTree() (gas: 39664) RadixMatchingEngineGasTest:testGas_CancelFilledBidClaim() (gas: 36750) RadixMatchingEngineGasTest:testGas_CancelFullDepthBidCombRightmost() (gas: 423078) @@ -30,24 +35,24 @@ RadixMatchingEngineGasTest:testGas_CancelMaxValidDepthAskCombRightmost() (gas: 4 RadixMatchingEngineGasTest:testGas_CancelPartialBid() (gas: 48804) RadixMatchingEngineGasTest:testGas_CancelUnfilledAsk() (gas: 39881) RadixMatchingEngineGasTest:testGas_CancelUnfilledBid() (gas: 39717) -RadixMatchingEngineGasTest:testGas_FillAskConsumesDirtySamePriceBidSubtree() (gas: 87178) -RadixMatchingEngineGasTest:testGas_FillAskConsumesFullDepthBidComb() (gas: 327239) -RadixMatchingEngineGasTest:testGas_FillAskFullyMatchesSingleBid() (gas: 77529) -RadixMatchingEngineGasTest:testGas_FillAskPartiallyMatchesSingleBid() (gas: 97224) -RadixMatchingEngineGasTest:testGas_FillBidConsumesAskAndRestsRemainder() (gas: 130196) -RadixMatchingEngineGasTest:testGas_FillBidConsumesDirtySamePriceAskSubtree() (gas: 87331) -RadixMatchingEngineGasTest:testGas_FillBidConsumesMaxValidDepthAskComb() (gas: 323455) -RadixMatchingEngineGasTest:testGas_FillBidConsumesSamePriceAskSubtree() (gas: 77323) -RadixMatchingEngineGasTest:testGas_FillBidFullyMatchesSingleAsk() (gas: 77223) -RadixMatchingEngineGasTest:testGas_FillBidPartiallyMatchesSingleAsk() (gas: 97137) -RadixMatchingEngineGasTest:testGas_FillRestAskEmptyBook() (gas: 150604) -RadixMatchingEngineGasTest:testGas_FillRestBidEmptyBook() (gas: 152387) -RadixMatchingEngineGasTest:testGas_LargeRandomBookAskMatchesOneBid() (gas: 121433) -RadixMatchingEngineGasTest:testGas_LargeRandomBookBidMatchesOneAsk() (gas: 136663) -RadixMatchingEngineGasTest:testGas_LargeRandomBookPartialFillsAsk() (gas: 137801) -RadixMatchingEngineGasTest:testGas_LargeRandomBookPartialFillsBid() (gas: 122557) -RadixMatchingEngineGasTest:testGas_LargeRandomBookRestsAsk() (gas: 163678) -RadixMatchingEngineGasTest:testGas_LargeRandomBookRestsBid() (gas: 173022) +RadixMatchingEngineGasTest:testGas_FillAskConsumesDirtySamePriceBidSubtree() (gas: 87242) +RadixMatchingEngineGasTest:testGas_FillAskConsumesFullDepthBidComb() (gas: 327303) +RadixMatchingEngineGasTest:testGas_FillAskFullyMatchesSingleBid() (gas: 77593) +RadixMatchingEngineGasTest:testGas_FillAskPartiallyMatchesSingleBid() (gas: 97288) +RadixMatchingEngineGasTest:testGas_FillBidConsumesAskAndRestsRemainder() (gas: 130241) +RadixMatchingEngineGasTest:testGas_FillBidConsumesDirtySamePriceAskSubtree() (gas: 87376) +RadixMatchingEngineGasTest:testGas_FillBidConsumesMaxValidDepthAskComb() (gas: 323500) +RadixMatchingEngineGasTest:testGas_FillBidConsumesSamePriceAskSubtree() (gas: 77368) +RadixMatchingEngineGasTest:testGas_FillBidFullyMatchesSingleAsk() (gas: 77268) +RadixMatchingEngineGasTest:testGas_FillBidPartiallyMatchesSingleAsk() (gas: 97182) +RadixMatchingEngineGasTest:testGas_FillRestAskEmptyBook() (gas: 150668) +RadixMatchingEngineGasTest:testGas_FillRestBidEmptyBook() (gas: 152432) +RadixMatchingEngineGasTest:testGas_LargeRandomBookAskMatchesOneBid() (gas: 121497) +RadixMatchingEngineGasTest:testGas_LargeRandomBookBidMatchesOneAsk() (gas: 136708) +RadixMatchingEngineGasTest:testGas_LargeRandomBookPartialFillsAsk() (gas: 137846) +RadixMatchingEngineGasTest:testGas_LargeRandomBookPartialFillsBid() (gas: 122621) +RadixMatchingEngineGasTest:testGas_LargeRandomBookRestsAsk() (gas: 163742) +RadixMatchingEngineGasTest:testGas_LargeRandomBookRestsBid() (gas: 173067) RadixMatchingEngineHookGasTest:testGas_CancelAskSkipsPathologicalBidTree() (gas: 43705) RadixMatchingEngineHookGasTest:testGas_CancelFilledBidClaim() (gas: 37497) RadixMatchingEngineHookGasTest:testGas_CancelFullDepthBidCombRightmost() (gas: 446433) @@ -55,21 +60,21 @@ RadixMatchingEngineHookGasTest:testGas_CancelMaxValidDepthAskCombRightmost() (ga RadixMatchingEngineHookGasTest:testGas_CancelPartialBid() (gas: 53592) RadixMatchingEngineHookGasTest:testGas_CancelUnfilledAsk() (gas: 43922) RadixMatchingEngineHookGasTest:testGas_CancelUnfilledBid() (gas: 44505) -RadixMatchingEngineHookGasTest:testGas_FillAskConsumesDirtySamePriceBidSubtree() (gas: 95188) -RadixMatchingEngineHookGasTest:testGas_FillAskConsumesFullDepthBidComb() (gas: 414714) -RadixMatchingEngineHookGasTest:testGas_FillAskFullyMatchesSingleBid() (gas: 85705) -RadixMatchingEngineHookGasTest:testGas_FillAskPartiallyMatchesSingleBid() (gas: 105408) -RadixMatchingEngineHookGasTest:testGas_FillBidConsumesAskAndRestsRemainder() (gas: 139173) -RadixMatchingEngineHookGasTest:testGas_FillBidConsumesDirtySamePriceAskSubtree() (gas: 94198) -RadixMatchingEngineHookGasTest:testGas_FillBidConsumesMaxValidDepthAskComb() (gas: 410071) -RadixMatchingEngineHookGasTest:testGas_FillBidConsumesSamePriceAskSubtree() (gas: 93303) -RadixMatchingEngineHookGasTest:testGas_FillBidFullyMatchesSingleAsk() (gas: 84150) -RadixMatchingEngineHookGasTest:testGas_FillBidPartiallyMatchesSingleAsk() (gas: 104072) -RadixMatchingEngineHookGasTest:testGas_FillRestAskEmptyBook() (gas: 140293) -RadixMatchingEngineHookGasTest:testGas_FillRestBidEmptyBook() (gas: 142073) -RadixMatchingEngineHookGasTest:testGas_LargeRandomBookAskMatchesOneBid() (gas: 138909) -RadixMatchingEngineHookGasTest:testGas_LargeRandomBookBidMatchesOneAsk() (gas: 154741) -RadixMatchingEngineHookGasTest:testGas_LargeRandomBookPartialFillsAsk() (gas: 146826) -RadixMatchingEngineHookGasTest:testGas_LargeRandomBookPartialFillsBid() (gas: 133192) -RadixMatchingEngineHookGasTest:testGas_LargeRandomBookRestsAsk() (gas: 193659) -RadixMatchingEngineHookGasTest:testGas_LargeRandomBookRestsBid() (gas: 198605) +RadixMatchingEngineHookGasTest:testGas_FillAskConsumesDirtySamePriceBidSubtree() (gas: 95252) +RadixMatchingEngineHookGasTest:testGas_FillAskConsumesFullDepthBidComb() (gas: 414778) +RadixMatchingEngineHookGasTest:testGas_FillAskFullyMatchesSingleBid() (gas: 85769) +RadixMatchingEngineHookGasTest:testGas_FillAskPartiallyMatchesSingleBid() (gas: 105472) +RadixMatchingEngineHookGasTest:testGas_FillBidConsumesAskAndRestsRemainder() (gas: 139218) +RadixMatchingEngineHookGasTest:testGas_FillBidConsumesDirtySamePriceAskSubtree() (gas: 94243) +RadixMatchingEngineHookGasTest:testGas_FillBidConsumesMaxValidDepthAskComb() (gas: 410116) +RadixMatchingEngineHookGasTest:testGas_FillBidConsumesSamePriceAskSubtree() (gas: 93348) +RadixMatchingEngineHookGasTest:testGas_FillBidFullyMatchesSingleAsk() (gas: 84195) +RadixMatchingEngineHookGasTest:testGas_FillBidPartiallyMatchesSingleAsk() (gas: 104117) +RadixMatchingEngineHookGasTest:testGas_FillRestAskEmptyBook() (gas: 140357) +RadixMatchingEngineHookGasTest:testGas_FillRestBidEmptyBook() (gas: 142118) +RadixMatchingEngineHookGasTest:testGas_LargeRandomBookAskMatchesOneBid() (gas: 138973) +RadixMatchingEngineHookGasTest:testGas_LargeRandomBookBidMatchesOneAsk() (gas: 154786) +RadixMatchingEngineHookGasTest:testGas_LargeRandomBookPartialFillsAsk() (gas: 146871) +RadixMatchingEngineHookGasTest:testGas_LargeRandomBookPartialFillsBid() (gas: 133256) +RadixMatchingEngineHookGasTest:testGas_LargeRandomBookRestsAsk() (gas: 193723) +RadixMatchingEngineHookGasTest:testGas_LargeRandomBookRestsBid() (gas: 198650) diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 2536f1e..21a1064 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -139,6 +139,8 @@ jobs: contract: RadixMatchingEngineHookGasTest - label: fees contract: RadixMatchingEngineFeeGasTest + - label: integrator-fees + contract: DeepstateV1IntegratorFeeGasTest env: FOUNDRY_DISABLE_NIGHTLY_WARNING: "1" steps: diff --git a/Makefile b/Makefile index 303837d..202b569 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ INVARIANT_DEPTH ?= 64 INVARIANT_SHARDS ?= 8 INVARIANT_SHARD ?= 1 INVARIANT_CONTRACTS ?= .*(RadixMatchingEngineInvariantTest|DeepstateV1MultiPoolInvariantTest|DeepstateV1NativeETHInvariantTest).* -GAS_CONTRACTS ?= RadixMatchingEngine(Gas|HookGas|FeeGas)Test +GAS_CONTRACTS ?= (RadixMatchingEngine(Gas|HookGas|FeeGas)Test|DeepstateV1IntegratorFeeGasTest) COVERAGE_FILES ?= src/DeepstateV1.sol,src/libraries/TickMath32.sol COVERAGE_EXCLUSIONS ?= coverage.exclusions.json # Coverage runs every behavioral, routing, boundary, and math test. Formal proofs, stateful @@ -61,7 +61,7 @@ snapshot-runtime-check: forge snapshot --isolate --force --match-contract '$(GAS_CONTRACTS)' --check .gas-snapshot.runtime build-size: - forge build --sizes + forge build --sizes src/DeepstateV1.sol tick-reference: python3 script/check_tick_math.py --check test/TickMath32.t.sol diff --git a/coverage.exclusions.json b/coverage.exclusions.json index 280256f..c7953ea 100644 --- a/coverage.exclusions.json +++ b/coverage.exclusions.json @@ -98,42 +98,51 @@ 2033, 2057, 2067, - 2282, - 2292, - 2320, - 2324, - 2366, - 2367, - 2368, - 2369, - 2382, + 2306, + 2316, + 2379, 2383, - 2384, - 2385, - 2386, - 2387, - 2561, - 2630, - 2631, - 2811, - 2812, - 2813, - 2840, - 2844, - 2846, - 2871, - 2875, - 2877, - 2915, - 2924, - 2929, - 2945, - 2950, - 2998, - 2999, - 3066 + 2421, + 2424, + 2465, + 2466, + 2467, + 2468, + 2481, + 2482, + 2483, + 2484, + 2485, + 2486, + 2660, + 2759, + 2760, + 2940, + 2941, + 2942, + 2969, + 2973, + 2975, + 3000, + 3004, + 3006, + 3026, + 3030, + 3032, + 3070, + 3079, + 3084, + 3100, + 3105, + 3153, + 3154, + 3172, + 3173, + 3227, + 3235, + 3300 ], - "statements": 136, + "statements": 145, "functions": [], "branches": [ "240:0:0", @@ -145,7 +154,7 @@ "1938:149:0", "1945:150:0", "1975:151:0", - "2811:190:0" + "2940:198:0" ] }, "src/libraries/TickMath32.sol": { diff --git a/script/prove_protocol.py b/script/prove_protocol.py index ac54e9f..1da2c82 100644 --- a/script/prove_protocol.py +++ b/script/prove_protocol.py @@ -78,6 +78,7 @@ def bind_model_to_source(): "uint256 private constant _NONCE_MASK = type(uint32).max;", "uint256 private constant _POOL_EPOCH_MASK = (uint256(1) << 254) - 1;", "uint256 private constant _FEE_DELTA_DOMAIN = uint256(1) << 255;", + "uint256 private constant _INTEGRATOR_FEE_DELTA_DOMAIN = uint256(1) << 254;", "0x64b7215baea1c17e16f66db8b03af21431032e2e221c15ac43b0752d82a69b31", "0xf95dfdfe2cf36f265e91ff578507ac6f6f9bffb77b95dcb89aef8ed16e5b1f45", "0x7f8fe082ccb9a281fa5fa179f118219e229bb906df4e4aa8aee95977b867f45d", @@ -99,6 +100,7 @@ def bind_model_to_source(): "uint256 next = current + amount;", "if (current < type(int256).min + signedAmount) revert DeltaOverflow();", "return bytes32(_FEE_DELTA_DOMAIN | uint256(uint160(token)));", + "return bytes32(_INTEGRATOR_FEE_DELTA_DOMAIN | uint256(uint160(token)));", "feeAmount = whole * uint256(feeBps) + (remainder * uint256(feeBps)) / _BPS_DENOMINATOR;", "token0Delta = int256(uint256(baseFilled));", "token0Delta = -int256(uint256(baseFilled));", @@ -107,6 +109,9 @@ def bind_model_to_source(): "baseAmount = filledQuantity;", "baseAmount = remainingQuantity;", "nextToken0Delta = token0Delta - int256(feeAmount);", + "_applyFillFee(isBid, token0Delta, token1Delta, _configBps(integratorConfig));", + "settlement.token0Delta -= int256(settlement.integratorFeeAmount);", + "settlement.token1Delta -= int256(settlement.integratorFeeAmount);", "nativeRefund = _nativeRefund(nativeDelta);", "nativeRefund = _nativeRefund(token0 == address(0) ? amount0 : int256(0));", "if (msg.value < required) revert InvalidNativeValue();", @@ -114,6 +119,8 @@ def bind_model_to_source(): "_refundNativeValue(nativeRefund);", "if (amount != 0) _safeTransferOut(address(0), msg.sender, amount);", "_safeTransferOut(params.isBid ? params.token0 : params.token1, feeRecipient, feeAmount);", + "_safeTransferOut(outputToken, _configRecipient(protocolConfig), settlement.protocolFeeAmount);", + "_safeTransferOut(outputToken, integratorFee.recipient, settlement.integratorFeeAmount);", "if (baseAmount != 0) _safeTransferOut(token0, owner, baseAmount);", "_safeTransferOut(token, msg.sender, uint256(amount));", "_safeTransferOut(token0, msg.sender, uint256(amount0));", @@ -128,9 +135,9 @@ def bind_model_to_source(): if missing: raise ProofFailure(f"production/model binding is stale; missing source fragment: {missing[0]}") - # Every native outflow must pass through the six modelled call sites and the single transfer + # Every native outflow must pass through the nine modelled call sites and the single transfer # helper. A future direct ETH transfer or additional helper call must extend the proof first. - if source.count("_safeTransferOut(") != 7: + if source.count("_safeTransferOut(") != 10: raise ProofFailure("production/model binding is stale; unexpected native-capable outflow site count") if source.count("safeTransferETH(") != 1: raise ProofFailure("production/model binding is stale; unexpected direct native transfer count") @@ -369,6 +376,7 @@ def prove_fees(p): And( implementation >= 0, implementation <= amount, + implementation <= amount / 100, amount == amount - implementation + implementation, ), *assumptions, @@ -383,6 +391,29 @@ def prove_fees(p): gross <= INT256_MAX, ) + gross = Int("combined_fee_gross") + protocol_fee = Int("combined_protocol_fee") + integrator_fee = Int("combined_integrator_fee") + net_output = gross - protocol_fee - integrator_fee + combined_domain = ( + gross >= 0, + gross <= INT256_MAX, + protocol_fee >= 0, + protocol_fee <= gross / 100, + integrator_fee >= 0, + integrator_fee <= gross / 100, + ) + p.prove( + "A6.independent-fees-conserve-gross-output", + And(net_output >= 0, gross == net_output + protocol_fee + integrator_fee), + *combined_domain, + ) + p.prove( + "A6.independent-fees-are-order-invariant", + gross - protocol_fee - integrator_fee == gross - integrator_fee - protocol_fee, + *combined_domain, + ) + def prove_keys_and_radix(p): p1 = Int("tick_1") @@ -534,14 +565,23 @@ def prove_nonce_epoch_and_namespaces(p): user_b_256 = ZeroExt(96, address_b) fee_a = BitVecVal(1 << 255, 256) | user_a_256 fee_b = BitVecVal(1 << 255, 256) | user_b_256 + integrator_fee_a = BitVecVal(1 << 254, 256) | user_a_256 + integrator_fee_b = BitVecVal(1 << 254, 256) | user_b_256 p.prove("N1.user-token-slot-injective", Implies(address_a != address_b, user_a != user_b)) p.prove("N1.fee-token-slot-injective", Implies(address_a != address_b, fee_a != fee_b)) + p.prove( + "N1.integrator-fee-token-slot-injective", + Implies(address_a != address_b, integrator_fee_a != integrator_fee_b), + ) p.prove("N1.user-fee-slot-disjoint", user_a_256 != fee_b) + p.prove("N1.user-integrator-fee-slot-disjoint", user_a_256 != integrator_fee_b) + p.prove("N1.protocol-integrator-fee-slot-disjoint", fee_a != integrator_fee_b) fixed = {name: BitVecVal(value, 256) for name, value in FIXED_TRANSIENT_SLOTS.items()} for name, slot in fixed.items(): p.prove(f"N1.user-fixed-slot-disjoint.{name}", user_a_256 != slot) p.prove(f"N1.fee-fixed-slot-disjoint.{name}", fee_a != slot) + p.prove(f"N1.integrator-fee-fixed-slot-disjoint.{name}", integrator_fee_a != slot) fixed_items = list(fixed.items()) for index, (left_name, left_slot) in enumerate(fixed_items): for right_name, right_slot in fixed_items[index + 1:]: @@ -916,8 +956,9 @@ def prove_route_and_signed_accounting(p): before = Int("engine_balance_before") delta = Int("user_delta") fee = Int("protocol_fee") - after = before - delta - fee - p.prove("N4.settlement-conservation", after + delta + fee == before) + integrator_fee = Int("integrator_fee") + after = before - delta - fee - integrator_fee + p.prove("N4.settlement-conservation", after + delta + fee + integrator_fee == before) current = Int("current_signed_delta") debit = Int("unsigned_debit") @@ -940,6 +981,19 @@ def prove_route_and_signed_accounting(p): prior_fee + leg_fee < UINT256, ) + prior_integrator_fee = Int("route_prior_integrator_fee") + leg_integrator_fee = Int("route_leg_integrator_fee") + p.prove( + "N4.checked-integrator-fee-prefix-is-exact", + And( + prior_integrator_fee + leg_integrator_fee >= 0, + prior_integrator_fee + leg_integrator_fee < UINT256, + ), + prior_integrator_fee >= 0, + leg_integrator_fee >= 0, + prior_integrator_fee + leg_integrator_fee < UINT256, + ) + token0_delta = Int("leg_token0_delta") token1_delta = Int("leg_token1_delta") base_filled = Int("leg_base_filled") @@ -1009,11 +1063,12 @@ def prove_native_eth_solvency(p): ask_liability = Int("native_ask_liability") bid_liability = Int("native_bid_liability") matched_asks = Int("native_matched_asks") - native_fee = Int("native_bid_fee") + native_fee = Int("native_bid_protocol_fee") + native_integrator_fee = Int("native_bid_integrator_fee") liability_before = ask_liability + bid_liability liability_after_bid = ask_liability - matched_asks + bid_liability - user_bid_output = matched_asks - native_fee - engine_after_bid = engine_before - user_bid_output - native_fee + user_bid_output = matched_asks - native_fee - native_integrator_fee + engine_after_bid = engine_before - user_bid_output - native_fee - native_integrator_fee bid_fill_domain = ( engine_before >= liability_before, ask_liability >= 0, @@ -1021,7 +1076,8 @@ def prove_native_eth_solvency(p): matched_asks >= 0, matched_asks <= ask_liability, native_fee >= 0, - native_fee <= matched_asks, + native_integrator_fee >= 0, + native_fee + native_integrator_fee <= matched_asks, ) p.prove( "A7.incoming-bid-preserves-native-surplus", diff --git a/src/DeepstateV1.sol b/src/DeepstateV1.sol index 13a83be..fed699c 100644 --- a/src/DeepstateV1.sol +++ b/src/DeepstateV1.sol @@ -2131,6 +2131,14 @@ contract DeepstateV1 is Ownable { bool fillOrKill; } + /// @notice Call-scoped fee paid to an integrator from matched taker output. + /// @param recipient Address paid the fee. Zero is valid only when `bps` is zero. + /// @param bps Fee rate in basis points, capped at 100 (1%). + struct IntegratorFee { + address recipient; + uint16 bps; + } + /// @notice Mutable in-memory accumulator for a route. /// @dev /// `touched` and `feeTouched` are deduplicated token lists. The actual signed token deltas live @@ -2147,6 +2155,20 @@ contract DeepstateV1 is Ownable { uint256 feeTouchedCount; } + /// @notice Integrator-fee tokens accumulated by an opt-in atomic route. + struct IntegratorRouteState { + address[] touched; + uint256 touchedCount; + } + + /// @notice Caller deltas and recipient amounts after applying both taker fees. + struct FillFeeSettlement { + int256 token0Delta; + int256 token1Delta; + uint256 protocolFeeAmount; + uint256 integratorFeeAmount; + } + /// @dev Low 254 bits of `_poolEpochAndHookFlags`; high bits are hook activation flags. uint256 private constant _POOL_EPOCH_MASK = (uint256(1) << 254) - 1; /// @dev Pool flag enabling hooks when token0 buyers change, i.e. bid-side top changes. @@ -2169,6 +2191,8 @@ contract DeepstateV1 is Ownable { uint256 private constant _HOOK_GAS_LIMIT = 200_000; /// @dev High-bit domain separator for transient protocol fee slots. uint256 private constant _FEE_DELTA_DOMAIN = uint256(1) << 255; + /// @dev Independent transient namespace for call-scoped integrator fees. + uint256 private constant _INTEGRATOR_FEE_DELTA_DOMAIN = uint256(1) << 254; /// @notice Latest restable epoch for each sorted token pair. mapping(bytes32 poolId => uint256 epochAndHookFlags) private _poolEpochAndHookFlags; @@ -2296,6 +2320,41 @@ contract DeepstateV1 is Ownable { _refundNativeValue(nativeRefund); } + /// @notice Submit one bid or ask with an optional call-scoped integrator taker fee. + /// @param params Fill parameters. + /// @param integratorFee Integrator recipient and basis-point rate. + /// @return restingOrder Packed order node if any quantity rested, otherwise zero. + /// @dev + /// The integrator fee has exactly the protocol taker-fee base and rounding: both are computed + /// independently as `floor(grossMatchedOutput * bps / 10_000)`. Neither fee applies to + /// unmatched quantity, resting collateral, maker claims, or cancellations. + function fillWithIntegratorFee(FillParams calldata params, IntegratorFee calldata integratorFee) + external + payable + nonReentrant + returns (bytes32 restingOrder) + { + uint256 integratorConfig = _validateIntegratorFee(integratorFee); + int256 token0Delta; + int256 token1Delta; + (restingOrder, token0Delta, token1Delta) = _executeFill(params); + + uint256 protocolConfig = _feeConfig; + FillFeeSettlement memory settlement = + _applyFillFees(params.isBid, token0Delta, token1Delta, protocolConfig, integratorConfig); + + uint256 nativeRefund = + _settleDeltas(params.token0, settlement.token0Delta, params.token1, settlement.token1Delta); + address outputToken = params.isBid ? params.token0 : params.token1; + if (settlement.protocolFeeAmount != 0) { + _safeTransferOut(outputToken, _configRecipient(protocolConfig), settlement.protocolFeeAmount); + } + if (settlement.integratorFeeAmount != 0) { + _safeTransferOut(outputToken, integratorFee.recipient, settlement.integratorFeeAmount); + } + _refundNativeValue(nativeRefund); + } + /// @notice Execute multiple routed fills atomically and settle each touched token once. /// @param fills Sequential route legs. /// @dev @@ -2328,6 +2387,46 @@ contract DeepstateV1 is Ownable { _refundNativeValue(nativeRefund); } + /// @notice Execute an atomic route with an optional call-scoped integrator taker fee. + /// @param fills Sequential route legs. + /// @param integratorFee Integrator recipient and basis-point rate. + /// @dev + /// Each leg applies the integrator fee exactly where the protocol taker fee applies: to that + /// leg's gross matched output. Fees are accumulated by token and each recipient receives one + /// transfer per token after caller settlement. + function fillRouteWithIntegratorFee(FillParams[] calldata fills, IntegratorFee calldata integratorFee) + external + payable + nonReentrant + { + uint256 integratorConfig = _validateIntegratorFee(integratorFee); + uint256 length = fills.length; + // slither-disable-next-line uninitialized-local + RouteState memory route; + route.touched = new address[](length * 2); + uint256 protocolConfig = _feeConfig; + if (protocolConfig != 0) route.feeTouched = new address[](length); + // slither-disable-next-line uninitialized-local + IntegratorRouteState memory integratorRoute; + if (integratorConfig != 0) integratorRoute.touched = new address[](length); + + for (uint256 i; i < length;) { + _executeIntegratorRouteLeg(fills[i], protocolConfig, integratorConfig, route, integratorRoute); + unchecked { + ++i; + } + } + + uint256 nativeRefund = _settleTouched(route.touched, route.touchedCount); + if (protocolConfig != 0) { + _settleFees(_configRecipient(protocolConfig), route.feeTouched, route.feeTouchedCount); + } + if (integratorConfig != 0) { + _settleIntegratorFees(integratorFee.recipient, integratorRoute.touched, integratorRoute.touchedCount); + } + _refundNativeValue(nativeRefund); + } + /// @notice Cancel open quantity or claim filled proceeds from one book. /// @param token0 Lower token address in the pair. /// @param token1 Higher token address in the pair. @@ -2569,6 +2668,36 @@ contract DeepstateV1 is Ownable { route.touchedCount = _addDelta(params.token1, token1Delta, route.touched, route.touchedCount); } + /// @notice Execute one opt-in route leg using the same gross-output fee base for both recipients. + function _executeIntegratorRouteLeg( + FillParams calldata params, + uint256 protocolConfig, + uint256 integratorConfig, + RouteState memory route, + IntegratorRouteState memory integratorRoute + ) private { + int256 token0Delta; + int256 token1Delta; + (, token0Delta, token1Delta) = _executeFill(params); + + FillFeeSettlement memory settlement = + _applyFillFees(params.isBid, token0Delta, token1Delta, protocolConfig, integratorConfig); + address outputToken = params.isBid ? params.token0 : params.token1; + + if (protocolConfig != 0) { + route.feeTouchedCount = + _addFee(outputToken, settlement.protocolFeeAmount, route.feeTouched, route.feeTouchedCount); + } + if (integratorConfig != 0) { + integratorRoute.touchedCount = _addIntegratorFee( + outputToken, settlement.integratorFeeAmount, integratorRoute.touched, integratorRoute.touchedCount + ); + } + + route.touchedCount = _addDelta(params.token0, settlement.token0Delta, route.touched, route.touchedCount); + route.touchedCount = _addDelta(params.token1, settlement.token1Delta, route.touched, route.touchedCount); + } + /// @notice Match against an initialized routed book, or validate an order that may only rest. /// @param params Fill parameters. /// @param routedBookId Book id derived from `params`. @@ -2877,6 +3006,32 @@ contract DeepstateV1 is Ownable { return touchedCount; } + /// @notice Add one route integrator fee to its independent transient namespace. + function _addIntegratorFee(address token, uint256 amount, address[] memory touched, uint256 touchedCount) + private + returns (uint256) + { + if (amount == 0) return touchedCount; + + if (!_isTouched(token, touched, touchedCount)) { + touched[touchedCount] = token; + unchecked { + ++touchedCount; + } + } + + bytes32 slot = _integratorFeeSlot(token); + uint256 current; + assembly { + current := tload(slot) + } + uint256 next = current + amount; + assembly { + tstore(slot, next) + } + return touchedCount; + } + /// @notice Return whether a token is already in a touched-token list. function _isTouched(address token, address[] memory touched, uint256 touchedCount) private pure returns (bool) { for (uint256 i; i < touchedCount;) { @@ -3007,6 +3162,80 @@ contract DeepstateV1 is Ownable { } } + /// @notice Transfer route integrator fees after caller and protocol-fee settlement. + function _settleIntegratorFees(address recipient, address[] memory touched, uint256 touchedCount) private { + for (uint256 i; i < touchedCount;) { + address token = touched[i]; + bytes32 slot = _integratorFeeSlot(token); + uint256 amount; + assembly { + amount := tload(slot) + tstore(slot, 0) + } + + if (amount != 0) _safeTransferOut(token, recipient, amount); + + unchecked { + ++i; + } + } + } + + /// @notice Apply protocol and integrator taker fees to the same raw matched-output delta. + /// @dev Calling `_applyFillFee` independently for each config guarantees identical fee bases + /// and rounding. The combined deduction is bounded by 2% because each rate is capped at 1%. + function _applyFillFees( + bool isBid, + int256 token0Delta, + int256 token1Delta, + uint256 protocolConfig, + uint256 integratorConfig + ) private pure returns (FillFeeSettlement memory settlement) { + settlement.token0Delta = token0Delta; + settlement.token1Delta = token1Delta; + + if (protocolConfig != 0) { + (settlement.token0Delta, settlement.token1Delta, settlement.protocolFeeAmount) = + _applyFillFee(isBid, token0Delta, token1Delta, _configBps(protocolConfig)); + } + + if (integratorConfig != 0) { + (,, settlement.integratorFeeAmount) = + _applyFillFee(isBid, token0Delta, token1Delta, _configBps(integratorConfig)); + if (settlement.integratorFeeAmount != 0) { + if (isBid) { + settlement.token0Delta -= int256(settlement.integratorFeeAmount); + } else { + settlement.token1Delta -= int256(settlement.integratorFeeAmount); + } + } + } + } + + /// @notice Validate and pack a call-scoped fee using the protocol fee constraints. + function _validateIntegratorFee(IntegratorFee calldata integratorFee) private pure returns (uint256 config) { + address recipient = integratorFee.recipient; + uint16 bps = integratorFee.bps; + if (bps > _MAX_FEE_BPS || (recipient == address(0) && bps != 0)) revert InvalidFeeConfig(); + config = uint256(uint160(recipient)) | (uint256(bps) << 160); + } + + /// @notice Extract a recipient from a packed fee configuration. + function _configRecipient(uint256 config) private pure returns (address recipient) { + /// @solidity memory-safe-assembly + assembly { + recipient := config + } + } + + /// @notice Extract a basis-point rate from a packed fee configuration. + function _configBps(uint256 config) private pure returns (uint16 bps) { + /// @solidity memory-safe-assembly + assembly { + bps := shr(160, config) + } + } + /// @notice Carve protocol fee from the taker's outgoing matched token. /// @param isBid True when the taker is buying token0; fee is taken from positive token0 output. /// @param token0Delta Caller-relative token0 delta before fee. @@ -3056,6 +3285,11 @@ contract DeepstateV1 is Ownable { return bytes32(_FEE_DELTA_DOMAIN | uint256(uint160(token))); } + /// @notice Transient storage slot for integrator fees in one token. + function _integratorFeeSlot(address token) private pure returns (bytes32) { + return bytes32(_INTEGRATOR_FEE_DELTA_DOMAIN | uint256(uint160(token))); + } + /// @notice Validate the caller's net native ETH debit and return any excess value. /// @dev A positive or zero caller-relative delta requires no ETH, so all `msg.value` is excess. function _nativeRefund(int256 nativeDelta) private view returns (uint256 refund) { diff --git a/test/DeepstateV1.t.sol b/test/DeepstateV1.t.sol index 04e6a99..6e569e0 100644 --- a/test/DeepstateV1.t.sol +++ b/test/DeepstateV1.t.sol @@ -63,6 +63,7 @@ contract DeepstateV1Test is Test { address internal alice = address(0xA11CE); address internal bob = address(0xB0B); address internal feeRecipient = address(0xFEE); + address internal integratorRecipient = address(0x1A7E); function setUp() public { RoutingTestERC20 a = new RoutingTestERC20("A", "A"); @@ -245,6 +246,160 @@ contract DeepstateV1Test is Test { assertEq(token1.balanceOf(feeRecipient), fee); } + function test_IntegratorFeeValidationMatchesProtocolFeeConstraints() public { + DeepstateV1.IntegratorFee memory fee = _integratorFee(integratorRecipient, 101); + vm.expectRevert(DeepstateV1.InvalidFeeConfig.selector); + engine.fillWithIntegratorFee(_fill(0, _order(10, 5, 0), true, false, false), fee); + + fee = _integratorFee(address(0), 1); + vm.expectRevert(DeepstateV1.InvalidFeeConfig.selector); + engine.fillWithIntegratorFee(_fill(0, _order(10, 5, 0), true, false, false), fee); + + fee = _integratorFee(integratorRecipient, 0); + vm.prank(alice); + bytes32 resting = engine.fillWithIntegratorFee(_fill(0, _order(10, 5, 0), true, false, false), fee); + assertTrue(resting != bytes32(0)); + assertEq(token0.balanceOf(integratorRecipient), 0); + assertEq(token1.balanceOf(integratorRecipient), 0); + } + + function test_IntegratorBidFeeUsesSameGrossOutputAndRoundingAsProtocolFee() public { + uint160 quantity = 12_345; + uint16 protocolBps = 37; + uint16 integratorBps = 61; + engine.setFeeConfig(feeRecipient, protocolBps); + + vm.prank(alice); + engine.fill(_fill(0, _order(10, quantity, 0), false, false, false)); + + uint256 bobToken0Before = token0.balanceOf(bob); + uint256 bobToken1Before = token1.balanceOf(bob); + uint256 protocolFee = uint256(quantity) * protocolBps / 10_000; + uint256 integratorFee = uint256(quantity) * integratorBps / 10_000; + + vm.prank(bob); + engine.fillWithIntegratorFee( + _fill(0, _order(10, quantity, 0), true, true, true), _integratorFee(integratorRecipient, integratorBps) + ); + + assertEq(token0.balanceOf(bob), bobToken0Before + quantity - protocolFee - integratorFee); + assertEq(token1.balanceOf(bob), bobToken1Before - _quoteValue(10, quantity, false)); + assertEq(token0.balanceOf(feeRecipient), protocolFee); + assertEq(token0.balanceOf(integratorRecipient), integratorFee); + assertEq(token1.balanceOf(feeRecipient), 0); + assertEq(token1.balanceOf(integratorRecipient), 0); + } + + function test_IntegratorAskFeeUsesSameGrossOutputAndRoundingAsProtocolFee() public { + uint160 quantity = 12_345; + int32 tick = 10; + uint16 protocolBps = 37; + uint16 integratorBps = 61; + engine.setFeeConfig(feeRecipient, protocolBps); + + vm.prank(alice); + engine.fill(_fill(0, _order(tick, quantity, 0), true, false, false)); + + uint256 quoteOutput = _quoteValue(tick, quantity, true); + uint256 protocolFee = quoteOutput * protocolBps / 10_000; + uint256 integratorFee = quoteOutput * integratorBps / 10_000; + uint256 bobToken0Before = token0.balanceOf(bob); + uint256 bobToken1Before = token1.balanceOf(bob); + + vm.prank(bob); + engine.fillWithIntegratorFee( + _fill(0, _order(tick, quantity, 0), false, true, true), _integratorFee(integratorRecipient, integratorBps) + ); + + assertEq(token0.balanceOf(bob), bobToken0Before - quantity); + assertEq(token1.balanceOf(bob), bobToken1Before + quoteOutput - protocolFee - integratorFee); + assertEq(token1.balanceOf(feeRecipient), protocolFee); + assertEq(token1.balanceOf(integratorRecipient), integratorFee); + assertEq(token0.balanceOf(feeRecipient), 0); + assertEq(token0.balanceOf(integratorRecipient), 0); + } + + function test_IntegratorFeeAppliesOnlyToMatchedOutputBeforeRemainderRests() public { + uint160 makerQuantity = 10_000; + uint160 takerQuantity = 15_000; + engine.setFeeConfig(feeRecipient, 100); + + vm.prank(alice); + engine.fill(_fill(0, _order(10, makerQuantity, 0), false, false, false)); + + uint256 bobToken0Before = token0.balanceOf(bob); + uint256 bobToken1Before = token1.balanceOf(bob); + vm.prank(bob); + bytes32 remainder = engine.fillWithIntegratorFee( + _fill(0, _order(10, takerQuantity, 0), true, false, false), _integratorFee(integratorRecipient, 100) + ); + + assertTrue(remainder != bytes32(0)); + assertEq(token0.balanceOf(bob), bobToken0Before + 9_800); + assertEq(token0.balanceOf(feeRecipient), 100); + assertEq(token0.balanceOf(integratorRecipient), 100); + + vm.prank(bob); + (, uint256 returnedCollateral) = engine.cancel(address(token0), address(token1), 0, remainder); + assertEq(returnedCollateral, _quoteValue(10, takerQuantity - makerQuantity, true)); + assertEq(token1.balanceOf(bob), bobToken1Before - _quoteValue(10, makerQuantity, false)); + assertEq(token0.balanceOf(feeRecipient), 100); + assertEq(token0.balanceOf(integratorRecipient), 100); + } + + function test_UnmatchedIntegratorFillRestsAndCancelRemainsFeeFree() public { + vm.prank(alice); + bytes32 resting = engine.fillWithIntegratorFee( + _fill(0, _order(10, 10_000, 0), true, false, false), _integratorFee(integratorRecipient, 100) + ); + + assertTrue(resting != bytes32(0)); + assertEq(token0.balanceOf(integratorRecipient), 0); + assertEq(token1.balanceOf(integratorRecipient), 0); + + vm.prank(alice); + engine.cancel(address(token0), address(token1), 0, resting); + assertEq(token0.balanceOf(integratorRecipient), 0); + assertEq(token1.balanceOf(integratorRecipient), 0); + } + + function test_ProtocolAndIntegratorCanShareRecipientWithoutCompounding() public { + uint160 quantity = 10_000; + engine.setFeeConfig(feeRecipient, 100); + + vm.prank(alice); + engine.fill(_fill(0, _order(10, quantity, 0), false, false, false)); + + vm.prank(bob); + engine.fillWithIntegratorFee( + _fill(0, _order(10, quantity, 0), true, true, true), _integratorFee(feeRecipient, 100) + ); + + assertEq(token0.balanceOf(feeRecipient), 200); + } + + function test_MaxSignedAskOutputSupportsProtocolAndIntegratorFees() public { + uint160 quantity = uint160(uint256(1) << 154); + uint256 quoteOutput = _quoteValue(type(int32).max, quantity, true); + engine.setFeeConfig(feeRecipient, 100); + + token1.mint(alice, quoteOutput); + vm.prank(alice); + engine.fill(_fill(0, _order(type(int32).max, quantity, 0), true, false, false)); + + token0.mint(bob, quantity); + uint256 bobQuoteBefore = token1.balanceOf(bob); + vm.prank(bob); + engine.fillWithIntegratorFee( + _fill(0, _order(type(int32).max, quantity, 0), false, true, true), _integratorFee(integratorRecipient, 100) + ); + + uint256 fee = quoteOutput / 100; + assertEq(token1.balanceOf(bob), bobQuoteBefore + quoteOutput - fee - fee); + assertEq(token1.balanceOf(feeRecipient), fee); + assertEq(token1.balanceOf(integratorRecipient), fee); + } + function test_GasBurningHookCannotBlockFill() public { GasBurningHook hook = new GasBurningHook(); engine.setPoolHookConfig(address(token0), address(token1), address(hook), true, false); @@ -432,6 +587,108 @@ contract DeepstateV1Test is Test { assertEq(token1.balanceOf(feeRecipient), 0); } + function test_IntegratorRouteAccumulatesRepeatedFeesForOneToken() public { + engine.setFeeConfig(feeRecipient, 100); + + vm.prank(alice); + engine.fill(_fill(0, _order(10, 20_000, 0), false, false, false)); + + uint256 bobToken0Before = token0.balanceOf(bob); + uint256 bobToken1Before = token1.balanceOf(bob); + DeepstateV1.FillParams[] memory route = new DeepstateV1.FillParams[](2); + route[0] = _fill(0, _order(10, 10_000, 0), true, true, true); + route[1] = _fill(0, _order(10, 10_000, 0), true, true, true); + + vm.prank(bob); + engine.fillRouteWithIntegratorFee(route, _integratorFee(integratorRecipient, 100)); + + assertEq(token0.balanceOf(bob), bobToken0Before + 19_600); + assertEq(token1.balanceOf(bob), bobToken1Before - _quoteValue(10, 20_000, false)); + assertEq(token0.balanceOf(feeRecipient), 200); + assertEq(token0.balanceOf(integratorRecipient), 200); + } + + function test_IntegratorRouteChargesEveryLegExactlyLikeProtocolTakerFee() public { + RoutingTestERC20 a = new RoutingTestERC20("Route A", "RA"); + RoutingTestERC20 b = new RoutingTestERC20("Route B", "RB"); + RoutingTestERC20 c = new RoutingTestERC20("Route C", "RC"); + RoutingTestERC20[3] memory sorted = _sortTokens(a, b, c); + address makerAb = address(0xAB01); + address makerBc = address(0xBC01); + address taker = address(0xC0FFEE); + uint160 quantity = 100_000; + uint256 protocolFee = 1_000; + uint256 integratorFee = 500; + engine.setFeeConfig(feeRecipient, 100); + + sorted[1].mint(makerAb, quantity); + sorted[2].mint(makerBc, quantity); + sorted[0].mint(taker, quantity); + sorted[1].mint(taker, protocolFee + integratorFee); + _approveRouteTokens(sorted, makerAb); + _approveRouteTokens(sorted, makerBc); + _approveRouteTokens(sorted, taker); + + vm.prank(makerAb); + engine.fill(_fillFor(sorted[0], sorted[1], _order(0, quantity, 0), true, false, false)); + vm.prank(makerBc); + engine.fill(_fillFor(sorted[1], sorted[2], _order(0, quantity, 0), true, false, false)); + + DeepstateV1.FillParams[] memory route = new DeepstateV1.FillParams[](2); + route[0] = _fillFor(sorted[0], sorted[1], _order(0, quantity, 0), false, true, true); + route[1] = _fillFor(sorted[1], sorted[2], _order(0, quantity, 0), false, true, true); + + vm.prank(taker); + engine.fillRouteWithIntegratorFee(route, _integratorFee(integratorRecipient, 50)); + + assertEq(sorted[0].balanceOf(taker), 0); + assertEq(sorted[1].balanceOf(taker), 0); + assertEq(sorted[2].balanceOf(taker), quantity - protocolFee - integratorFee); + assertEq(sorted[1].balanceOf(feeRecipient), protocolFee); + assertEq(sorted[2].balanceOf(feeRecipient), protocolFee); + assertEq(sorted[1].balanceOf(integratorRecipient), integratorFee); + assertEq(sorted[2].balanceOf(integratorRecipient), integratorFee); + } + + function test_IntegratorRouteZeroOutputLegChargesNoFee() public { + vm.prank(alice); + engine.fill(_fill(0, _order(10, 5, 0), false, false, false)); + + DeepstateV1.FillParams[] memory route = new DeepstateV1.FillParams[](1); + route[0] = _fill(0, _order(9, 1, 0), true, true, false); + + vm.prank(bob); + engine.fillRouteWithIntegratorFee(route, _integratorFee(integratorRecipient, 100)); + + assertEq(token0.balanceOf(integratorRecipient), 0); + assertEq(token1.balanceOf(integratorRecipient), 0); + } + + function test_IntegratorLateRouteFailureRevertsMatchingAndAllFeesAtomically() public { + engine.setFeeConfig(feeRecipient, 100); + vm.prank(alice); + bytes32 restingAsk = engine.fill(_fill(0, _order(10, 5, 0), false, false, false)); + + bytes32 id = engine.bookId(address(token0), address(token1), 0); + uint256 bobToken0Before = token0.balanceOf(bob); + uint256 bobToken1Before = token1.balanceOf(bob); + DeepstateV1.FillParams[] memory route = new DeepstateV1.FillParams[](2); + route[0] = _fill(0, _order(10, 3, 0), true, true, false); + route[1] = _fill(0, _order(10, 3, 0), true, true, true); + + vm.prank(bob); + vm.expectRevert(DeepstateV1.FillOrKill.selector); + engine.fillRouteWithIntegratorFee(route, _integratorFee(integratorRecipient, 100)); + + (bytes32 askRoot,) = engine.roots(address(token0), address(token1), 0); + assertEq(askRoot, restingAsk); + assertEq(engine.ownerOfOrder(engine.orderId(id, restingAsk)), alice); + assertEq(token0.balanceOf(bob), bobToken0Before); + assertEq(token1.balanceOf(bob), bobToken1Before); + assertEq(token0.balanceOf(feeRecipient), 0); + assertEq(token0.balanceOf(integratorRecipient), 0); + } + function test_LateRouteFailureRevertsEarlierLegAtomically() public { vm.prank(alice); bytes32 restingAsk = engine.fill(_fill(0, _order(10, 5, 0), false, false, false)); @@ -675,6 +932,73 @@ contract DeepstateV1Test is Test { engine.restBookForTest(id, 1, 10, 5, true, alice); } + function testFuzz_IntegratorBidFeeEqualsIndependentProtocolFormula( + uint128 quantitySeed, + uint8 protocolBpsSeed, + uint8 integratorBpsSeed + ) public { + uint160 quantity = uint160(bound(uint256(quantitySeed), 1, 1e24)); + uint16 protocolBps = uint16(bound(uint256(protocolBpsSeed), 0, 100)); + uint16 integratorBps = uint16(bound(uint256(integratorBpsSeed), 0, 100)); + engine.setFeeConfig(feeRecipient, protocolBps); + token0.mint(alice, quantity); + token1.mint(bob, _quoteValue(0, quantity, false)); + + vm.prank(alice); + engine.fill(_fill(0, _order(0, quantity, 0), false, false, false)); + + uint256 bobBaseBefore = token0.balanceOf(bob); + vm.prank(bob); + engine.fillWithIntegratorFee( + _fill(0, _order(0, quantity, 0), true, true, true), _integratorFee(integratorRecipient, integratorBps) + ); + + uint256 protocolFee = uint256(quantity) * protocolBps / 10_000; + uint256 integratorFee = uint256(quantity) * integratorBps / 10_000; + assertEq(token0.balanceOf(bob), bobBaseBefore + quantity - protocolFee - integratorFee); + assertEq(token0.balanceOf(feeRecipient), protocolFee); + assertEq(token0.balanceOf(integratorRecipient), integratorFee); + } + + function testFuzz_IntegratorAskFeeEqualsIndependentProtocolFormula( + uint96 quantitySeed, + int24 tickSeed, + uint8 protocolBpsSeed, + uint8 integratorBpsSeed + ) public { + uint160 quantity = uint160(bound(uint256(quantitySeed), 1, 1e24)); + int32 tick = int32(bound(int256(tickSeed), -100_000, 100_000)); + uint16 protocolBps = uint16(bound(uint256(protocolBpsSeed), 0, 100)); + uint16 integratorBps = uint16(bound(uint256(integratorBpsSeed), 0, 100)); + uint256 quoteOutput = _quoteValue(tick, quantity, true); + engine.setFeeConfig(feeRecipient, protocolBps); + token1.mint(alice, quoteOutput); + token0.mint(bob, quantity); + + vm.prank(alice); + engine.fill(_fill(0, _order(tick, quantity, 0), true, false, false)); + + uint256 bobQuoteBefore = token1.balanceOf(bob); + vm.prank(bob); + engine.fillWithIntegratorFee( + _fill(0, _order(tick, quantity, 0), false, true, true), _integratorFee(integratorRecipient, integratorBps) + ); + + uint256 protocolFee = quoteOutput * protocolBps / 10_000; + uint256 integratorFee = quoteOutput * integratorBps / 10_000; + assertEq(token1.balanceOf(bob), bobQuoteBefore + quoteOutput - protocolFee - integratorFee); + assertEq(token1.balanceOf(feeRecipient), protocolFee); + assertEq(token1.balanceOf(integratorRecipient), integratorFee); + } + + function _integratorFee(address recipient, uint16 bps) + internal + pure + returns (DeepstateV1.IntegratorFee memory fee) + { + fee = DeepstateV1.IntegratorFee({recipient: recipient, bps: bps}); + } + function _fill(uint256 epoch, bytes32 order, bool isBid, bool noRest, bool fillOrKill) internal view diff --git a/test/DeepstateV1Invariant.t.sol b/test/DeepstateV1Invariant.t.sol index 13b569c..46999e2 100644 --- a/test/DeepstateV1Invariant.t.sol +++ b/test/DeepstateV1Invariant.t.sol @@ -129,6 +129,15 @@ contract DeepstateV1MultiPoolHandler is Test { uint256 hookCount; } + struct MatchedLeg { + bytes32 routedBook; + uint256 quoteAmount; + uint160 remaining; + uint160 baseFilled; + uint8 poolIndex; + bool restAllowed; + } + DeepstateV1MultiPoolHarness internal immutable ENGINE; MultiPoolInvariantERC20 internal immutable TOKEN0; MultiPoolInvariantERC20 internal immutable TOKEN1; @@ -142,11 +151,13 @@ contract DeepstateV1MultiPoolHandler is Test { int32 internal constant MIN_FUZZ_TICK = -100_000_000; int32 internal constant MAX_FUZZ_TICK = 100_000_000; address internal constant FEE_RECIPIENT = address(0xFEE); + address internal constant INTEGRATOR_RECIPIENT = address(0x1A7E); bytes32 internal constant ORDER_RESTED_TOPIC = keccak256("OrderRested(bytes32,bytes32,address,bool)"); address[6] internal actors; uint256[3][6] internal expectedActorBalances; uint256[3] internal expectedFeeBalances; + uint256[3] internal expectedIntegratorBalances; uint256[3] internal expectedEpochs; uint8[3] internal poolHookMasks; uint8[3] internal poolHookKinds; @@ -226,6 +237,30 @@ contract DeepstateV1MultiPoolHandler is Test { _place(poolSeed, actorSeed, tickSeed, quantitySeed, epochSeed, false, noRest); } + function placeBidWithIntegrator( + uint256 poolSeed, + uint256 actorSeed, + int32 tickSeed, + uint256 quantitySeed, + uint256 epochSeed, + bool noRest, + uint16 bpsSeed + ) external { + _placeWithIntegrator(poolSeed, actorSeed, tickSeed, quantitySeed, epochSeed, true, noRest, bpsSeed); + } + + function placeAskWithIntegrator( + uint256 poolSeed, + uint256 actorSeed, + int32 tickSeed, + uint256 quantitySeed, + uint256 epochSeed, + bool noRest, + uint16 bpsSeed + ) external { + _placeWithIntegrator(poolSeed, actorSeed, tickSeed, quantitySeed, epochSeed, false, noRest, bpsSeed); + } + function routeForward( uint256 actorSeed, int32 firstTickSeed, @@ -254,6 +289,36 @@ contract DeepstateV1MultiPoolHandler is Test { _performRoute(uint8(bound(actorSeed, 0, actors.length - 1)), fills); } + function routeForwardWithIntegrator( + uint256 actorSeed, + int32 firstTickSeed, + int32 secondTickSeed, + uint256 firstQuantitySeed, + uint256 secondQuantitySeed, + uint256 flags, + uint16 bpsSeed + ) external { + DeepstateV1.FillParams[] memory fills = new DeepstateV1.FillParams[](2); + fills[0] = _routeParam(0, firstTickSeed, firstQuantitySeed, true, flags & 1 != 0, flags >> 2); + fills[1] = _routeParam(1, secondTickSeed, secondQuantitySeed, true, flags & 2 != 0, flags >> 3); + _performIntegratorRoute(uint8(bound(actorSeed, 0, actors.length - 1)), fills, uint16(bound(bpsSeed, 1, 100))); + } + + function routeReverseWithIntegrator( + uint256 actorSeed, + int32 firstTickSeed, + int32 secondTickSeed, + uint256 firstQuantitySeed, + uint256 secondQuantitySeed, + uint256 flags, + uint16 bpsSeed + ) external { + DeepstateV1.FillParams[] memory fills = new DeepstateV1.FillParams[](2); + fills[0] = _routeParam(0, firstTickSeed, firstQuantitySeed, false, flags & 1 != 0, flags >> 2); + fills[1] = _routeParam(1, secondTickSeed, secondQuantitySeed, false, flags & 2 != 0, flags >> 3); + _performIntegratorRoute(uint8(bound(actorSeed, 0, actors.length - 1)), fills, uint16(bound(bpsSeed, 1, 100))); + } + function cancel(uint256 orderSeed) external { uint256 length = trackedOrders.length; if (length == 0) return; @@ -351,6 +416,10 @@ contract DeepstateV1MultiPoolHandler is Test { return expectedFeeBalances[tokenIndex]; } + function expectedIntegratorBalance(uint256 tokenIndex) external view returns (uint256) { + return expectedIntegratorBalances[tokenIndex]; + } + function expectedEngineBalance(uint256 tokenIndex) external view returns (uint256 amount) { for (uint256 i; i < trackedOrders.length; ++i) { TrackedOrder storage tracked = trackedOrders[i]; @@ -471,6 +540,32 @@ contract DeepstateV1MultiPoolHandler is Test { _performSingle(actorIndex, _fillParams(poolIndex, epoch, tick, quantity, isBid, noRest)); } + function _placeWithIntegrator( + uint256 poolSeed, + uint256 actorSeed, + int32 tickSeed, + uint256 quantitySeed, + uint256 epochSeed, + bool isBid, + bool noRest, + uint16 bpsSeed + ) private { + uint8 poolIndex = uint8(bound(poolSeed, 0, 2)); + uint8 actorIndex = uint8(bound(actorSeed, 0, actors.length - 1)); + uint256 epoch = _selectedEpoch(poolIndex, epochSeed); + int32 tick = int32(bound(tickSeed, MIN_FUZZ_TICK, MAX_FUZZ_TICK)); + uint160 quantity = uint160(bound(quantitySeed, 1, uint256(MAX_ORDER_QUANTITY))); + + (address lower, address upper,,) = _pair(poolIndex); + if (ENGINE.nextNonce(lower, upper, epoch) == 0) noRest = false; + if (trackedOrders.length >= MAX_TRACKED_ORDERS) noRest = true; + if (noRest && ENGINE.nextNonce(lower, upper, epoch) == 0) return; + + _performIntegratorSingle( + actorIndex, _fillParams(poolIndex, epoch, tick, quantity, isBid, noRest), uint16(bound(bpsSeed, 1, 100)) + ); + } + function _routeParam( uint8 poolIndex, int32 tickSeed, @@ -496,7 +591,7 @@ contract DeepstateV1MultiPoolHandler is Test { Vm.Log[] memory entries = vm.getRecordedLogs(); (RestLog[] memory rests, uint256 restCount) = _restLogs(entries, actors[actorIndex]); ExpectedHook[] memory expectedHooks = new ExpectedHook[](3); - AppliedLeg memory applied = _applyLeg(params, actorIndex, rests, 0, expectedHooks, 0); + AppliedLeg memory applied = _applyLeg(params, actorIndex, rests, 0, expectedHooks, 0, 0); assertEq(applied.restCursor, restCount, "single rest log count"); if (restCount == 0) assertEq(restingOrder, bytes32(0), "single unexpected return"); @@ -514,6 +609,38 @@ contract DeepstateV1MultiPoolHandler is Test { } } + function _performIntegratorSingle(uint8 actorIndex, DeepstateV1.FillParams memory params, uint16 integratorBps) + private + { + uint256 hookStart = RECORDING_HOOK.callCount(); + vm.recordLogs(); + vm.prank(actors[actorIndex]); + try ENGINE.fillWithIntegratorFee( + params, DeepstateV1.IntegratorFee({recipient: INTEGRATOR_RECIPIENT, bps: integratorBps}) + ) returns ( + bytes32 restingOrder + ) { + Vm.Log[] memory entries = vm.getRecordedLogs(); + (RestLog[] memory rests, uint256 restCount) = _restLogs(entries, actors[actorIndex]); + ExpectedHook[] memory expectedHooks = new ExpectedHook[](3); + AppliedLeg memory applied = _applyLeg(params, actorIndex, rests, 0, expectedHooks, 0, integratorBps); + + assertEq(applied.restCursor, restCount, "integrator single rest log count"); + if (restCount == 0) assertEq(restingOrder, bytes32(0), "integrator single unexpected return"); + else assertEq(restingOrder, rests[0].order, "integrator single resting return"); + + (,, uint8 lowerIndex, uint8 upperIndex) = _pair(_poolIndex(params.token0, params.token1)); + _applyActorDelta(actorIndex, lowerIndex, applied.amount0); + _applyActorDelta(actorIndex, upperIndex, applied.amount1); + _assertHookCalls(hookStart, expectedHooks, applied.hookCount); + ++singleFillCalls; + } catch (bytes memory reason) { + vm.getRecordedLogs(); + ++unexpectedFillReverts; + _recordUnexpected(reason); + } + } + function _performRoute(uint8 actorIndex, DeepstateV1.FillParams[] memory fills) private { uint256 hookStart = RECORDING_HOOK.callCount(); vm.recordLogs(); @@ -527,7 +654,8 @@ contract DeepstateV1MultiPoolHandler is Test { uint256 hookCount; for (uint256 i; i < fills.length; ++i) { - AppliedLeg memory applied = _applyLeg(fills[i], actorIndex, rests, restCursor, expectedHooks, hookCount); + AppliedLeg memory applied = + _applyLeg(fills[i], actorIndex, rests, restCursor, expectedHooks, hookCount, 0); restCursor = applied.restCursor; hookCount = applied.hookCount; (,, uint8 lowerIndex, uint8 upperIndex) = _pair(_poolIndex(fills[i].token0, fills[i].token1)); @@ -548,79 +676,140 @@ contract DeepstateV1MultiPoolHandler is Test { } } + function _performIntegratorRoute(uint8 actorIndex, DeepstateV1.FillParams[] memory fills, uint16 integratorBps) + private + { + uint256 hookStart = RECORDING_HOOK.callCount(); + vm.recordLogs(); + vm.prank(actors[actorIndex]); + try ENGINE.fillRouteWithIntegratorFee( + fills, DeepstateV1.IntegratorFee({recipient: INTEGRATOR_RECIPIENT, bps: integratorBps}) + ) { + Vm.Log[] memory entries = vm.getRecordedLogs(); + (RestLog[] memory rests, uint256 restCount) = _restLogs(entries, actors[actorIndex]); + ExpectedHook[] memory expectedHooks = new ExpectedHook[](fills.length * 3); + int256[3] memory deltas; + uint256 restCursor; + uint256 hookCount; + + for (uint256 i; i < fills.length; ++i) { + AppliedLeg memory applied = + _applyLeg(fills[i], actorIndex, rests, restCursor, expectedHooks, hookCount, integratorBps); + restCursor = applied.restCursor; + hookCount = applied.hookCount; + (,, uint8 lowerIndex, uint8 upperIndex) = _pair(_poolIndex(fills[i].token0, fills[i].token1)); + deltas[lowerIndex] += applied.amount0; + deltas[upperIndex] += applied.amount1; + } + + assertEq(restCursor, restCount, "integrator route rest log count"); + for (uint8 tokenIndex; tokenIndex < 3; ++tokenIndex) { + _applyActorDelta(actorIndex, tokenIndex, deltas[tokenIndex]); + } + _assertHookCalls(hookStart, expectedHooks, hookCount); + ++routeCalls; + } catch (bytes memory reason) { + vm.getRecordedLogs(); + ++unexpectedRouteReverts; + _recordUnexpected(reason); + } + } + function _applyLeg( DeepstateV1.FillParams memory params, uint8 actorIndex, RestLog[] memory rests, uint256 restCursor, ExpectedHook[] memory expectedHooks, - uint256 hookCount + uint256 hookCount, + uint16 integratorBps ) private returns (AppliedLeg memory applied) { - uint8 poolIndex = _poolIndex(params.token0, params.token1); - bytes32 routedBook = ENGINE.bookId(params.token0, params.token1, params.epoch); - TopOrder memory beforeMatched = _top(routedBook, !params.isBid); - (uint160 remaining, uint160 baseFilled, uint256 quoteAmount) = - _matchModel(routedBook, params.isBid, _tick(params.order), _quantity(params.order)); - TopOrder memory afterMatched = _top(routedBook, !params.isBid); + MatchedLeg memory matched; + matched.poolIndex = _poolIndex(params.token0, params.token1); + matched.routedBook = ENGINE.bookId(params.token0, params.token1, params.epoch); + TopOrder memory beforeMatched = _top(matched.routedBook, !params.isBid); + (matched.remaining, matched.baseFilled, matched.quoteAmount) = + _matchModel(matched.routedBook, params.isBid, _tick(params.order), _quantity(params.order)); + TopOrder memory afterMatched = _top(matched.routedBook, !params.isBid); hookCount = _appendHookIfNeeded( - expectedHooks, hookCount, poolIndex, routedBook, !params.isBid, beforeMatched, afterMatched + expectedHooks, hookCount, matched.poolIndex, matched.routedBook, !params.isBid, beforeMatched, afterMatched ); - bool restAllowed = !params.noRest && params.epoch >= expectedEpochs[poolIndex]; + matched.restAllowed = !params.noRest && params.epoch >= expectedEpochs[matched.poolIndex]; - if (remaining != 0 && restAllowed) { + if (matched.remaining != 0 && matched.restAllowed) { assertLt(restCursor, rests.length, "missing rest log"); RestLog memory rested = rests[restCursor++]; assertEq(rested.owner, actors[actorIndex], "rest owner"); assertEq(rested.isBid, params.isBid, "rest side"); assertEq(_tick(rested.order), _tick(params.order), "rest tick"); - assertEq(_quantity(rested.order), remaining, "rest quantity"); + assertEq(_quantity(rested.order), matched.remaining, "rest quantity"); assertEq(uint32(uint256(rested.order) >> 32), 0, "rest correction"); assertTrue( - rested.bookId == routedBook || rested.bookId == ENGINE.activeBookId(params.token0, params.token1), + rested.bookId == matched.routedBook + || rested.bookId == ENGINE.activeBookId(params.token0, params.token1), "rest book scope" ); TopOrder memory beforeRest = _top(rested.bookId, params.isBid); - _trackRest(poolIndex, params.epoch, actorIndex, rested); + _trackRest(matched.poolIndex, params.epoch, actorIndex, rested); TopOrder memory afterRest = _top(rested.bookId, params.isBid); hookCount = _appendHookIfNeeded( - expectedHooks, hookCount, poolIndex, rested.bookId, params.isBid, beforeRest, afterRest + expectedHooks, hookCount, matched.poolIndex, rested.bookId, params.isBid, beforeRest, afterRest ); } - uint256 feeAmount; + (applied.amount0, applied.amount1) = _applyLegAmounts(params, matched, integratorBps); + applied.restCursor = restCursor; + applied.hookCount = hookCount; + } + + function _applyLegAmounts(DeepstateV1.FillParams memory params, MatchedLeg memory matched, uint16 integratorBps) + private + returns (int256 amount0, int256 amount1) + { if (params.isBid) { - applied.amount0 = int256(uint256(baseFilled)); + amount0 = int256(uint256(matched.baseFilled)); // Model bounds keep every quote below `int256.max`, matching production validation. // forge-lint: disable-next-line(unsafe-typecast) - applied.amount1 = -int256(quoteAmount); - if (remaining != 0 && restAllowed) { - applied.amount1 -= int256(_quoteValue(_tick(params.order), remaining, true)); + amount1 = -int256(matched.quoteAmount); + if (matched.remaining != 0 && matched.restAllowed) { + amount1 -= int256(_quoteValue(_tick(params.order), matched.remaining, true)); } if (expectedFeeRecipient != address(0) && expectedFeeBps != 0) { - feeAmount = _feeAmount(baseFilled, expectedFeeBps); + uint256 feeAmount = _feeAmount(matched.baseFilled, expectedFeeBps); // forge-lint: disable-next-line(unsafe-typecast) - applied.amount0 -= int256(feeAmount); - (,, uint8 lowerIndex,) = _pair(poolIndex); + amount0 -= int256(feeAmount); + (,, uint8 lowerIndex,) = _pair(matched.poolIndex); expectedFeeBalances[lowerIndex] += feeAmount; } + if (integratorBps != 0) { + uint256 integratorAmount = _feeAmount(matched.baseFilled, integratorBps); + // forge-lint: disable-next-line(unsafe-typecast) + amount0 -= int256(integratorAmount); + (,, uint8 lowerIndex,) = _pair(matched.poolIndex); + expectedIntegratorBalances[lowerIndex] += integratorAmount; + } } else { - applied.amount0 = -int256(uint256(baseFilled)); + amount0 = -int256(uint256(matched.baseFilled)); // forge-lint: disable-next-line(unsafe-typecast) - applied.amount1 = int256(quoteAmount); - if (remaining != 0 && restAllowed) applied.amount0 -= int256(uint256(remaining)); + amount1 = int256(matched.quoteAmount); + if (matched.remaining != 0 && matched.restAllowed) amount0 -= int256(uint256(matched.remaining)); if (expectedFeeRecipient != address(0) && expectedFeeBps != 0) { - feeAmount = _feeAmount(quoteAmount, expectedFeeBps); + uint256 feeAmount = _feeAmount(matched.quoteAmount, expectedFeeBps); // forge-lint: disable-next-line(unsafe-typecast) - applied.amount1 -= int256(feeAmount); - (,,, uint8 upperIndex) = _pair(poolIndex); + amount1 -= int256(feeAmount); + (,,, uint8 upperIndex) = _pair(matched.poolIndex); expectedFeeBalances[upperIndex] += feeAmount; } + if (integratorBps != 0) { + uint256 integratorAmount = _feeAmount(matched.quoteAmount, integratorBps); + // forge-lint: disable-next-line(unsafe-typecast) + amount1 -= int256(integratorAmount); + (,,, uint8 upperIndex) = _pair(matched.poolIndex); + expectedIntegratorBalances[upperIndex] += integratorAmount; + } } - - applied.restCursor = restCursor; - applied.hookCount = hookCount; } function _cancel(uint256 index) private { @@ -999,15 +1188,19 @@ contract DeepstateV1MultiPoolInvariantTest is StdInvariant, Test { excludeContract(address(recordingHook)); excludeContract(address(revertingHook)); - bytes4[] memory selectors = new bytes4[](8); + bytes4[] memory selectors = new bytes4[](12); selectors[0] = DeepstateV1MultiPoolHandler.placeBid.selector; selectors[1] = DeepstateV1MultiPoolHandler.placeAsk.selector; - selectors[2] = DeepstateV1MultiPoolHandler.routeForward.selector; - selectors[3] = DeepstateV1MultiPoolHandler.routeReverse.selector; - selectors[4] = DeepstateV1MultiPoolHandler.cancel.selector; - selectors[5] = DeepstateV1MultiPoolHandler.configureFee.selector; - selectors[6] = DeepstateV1MultiPoolHandler.configureHook.selector; - selectors[7] = DeepstateV1MultiPoolHandler.rotatePool.selector; + selectors[2] = DeepstateV1MultiPoolHandler.placeBidWithIntegrator.selector; + selectors[3] = DeepstateV1MultiPoolHandler.placeAskWithIntegrator.selector; + selectors[4] = DeepstateV1MultiPoolHandler.routeForward.selector; + selectors[5] = DeepstateV1MultiPoolHandler.routeReverse.selector; + selectors[6] = DeepstateV1MultiPoolHandler.routeForwardWithIntegrator.selector; + selectors[7] = DeepstateV1MultiPoolHandler.routeReverseWithIntegrator.selector; + selectors[8] = DeepstateV1MultiPoolHandler.cancel.selector; + selectors[9] = DeepstateV1MultiPoolHandler.configureFee.selector; + selectors[10] = DeepstateV1MultiPoolHandler.configureHook.selector; + selectors[11] = DeepstateV1MultiPoolHandler.rotatePool.selector; targetSelector(FuzzSelector({addr: address(handler), selectors: selectors})); } @@ -1026,6 +1219,9 @@ contract DeepstateV1MultiPoolInvariantTest is StdInvariant, Test { MultiPoolInvariantERC20 token = MultiPoolInvariantERC20(handler.tokenAt(tokenIndex)); assertEq(token.balanceOf(address(engine)), handler.expectedEngineBalance(tokenIndex), "engine collateral"); assertEq(token.balanceOf(address(0xFEE)), handler.expectedFeeBalance(tokenIndex), "fee balance"); + assertEq( + token.balanceOf(address(0x1A7E)), handler.expectedIntegratorBalance(tokenIndex), "integrator balance" + ); assertEq(token.totalSupply(), handler.expectedSupply(tokenIndex), "token supply"); assertEq(_trackedBalanceSum(token), token.totalSupply(), "tracked supply"); } @@ -1099,6 +1295,7 @@ contract DeepstateV1MultiPoolInvariantTest is StdInvariant, Test { } total += token.balanceOf(address(engine)); total += token.balanceOf(address(0xFEE)); + total += token.balanceOf(address(0x1A7E)); } function _assertTop(bytes32 id, bytes32 root, bool isBid) private view { diff --git a/test/DeepstateV1NativeETH.t.sol b/test/DeepstateV1NativeETH.t.sol index 9f84f21..02a627b 100644 --- a/test/DeepstateV1NativeETH.t.sol +++ b/test/DeepstateV1NativeETH.t.sol @@ -37,6 +37,20 @@ contract NativeRecordingHook { } } +contract NativeFeeReceiver { + uint256 public receiveCalls; + + receive() external payable { + ++receiveCalls; + } +} + +contract NativeRejectingFeeRecipient { + receive() external payable { + revert(); + } +} + contract NativeReentrantBuyer { DeepstateV1 internal immutable ENGINE; address internal immutable QUOTE; @@ -79,6 +93,7 @@ contract DeepstateV1NativeETHTest is Test { address internal bob = address(0xB0B); address internal makerB = address(0xBEEF); address internal feeRecipient = address(0xFEE); + address internal integratorRecipient = address(0x1A7E); function setUp() public { engine = new DeepstateV1(); @@ -301,6 +316,98 @@ contract DeepstateV1NativeETHTest is Test { assertEq(address(engine).balance, 0); } + function test_NativeIntegratorBidFeeMatchesProtocolTakerFee() public { + uint160 quantity = 10_000; + engine.setFeeConfig(feeRecipient, 100); + + vm.prank(alice); + engine.fill{value: quantity}(_fill(quoteA, _order(0, quantity), false, false, false)); + + uint256 bobEthBefore = bob.balance; + vm.prank(bob); + engine.fillWithIntegratorFee( + _fill(quoteA, _order(0, quantity), true, true, true), + DeepstateV1.IntegratorFee({recipient: integratorRecipient, bps: 50}) + ); + + assertEq(bob.balance, bobEthBefore + 9_850); + assertEq(feeRecipient.balance, 100); + assertEq(integratorRecipient.balance, 50); + assertEq(address(engine).balance, 0); + } + + function test_NativeIntegratorAskFeeTakesOnlyOutgoingQuoteAndRefundsExcessETH() public { + uint160 quantity = 10_000; + uint256 excess = 1 ether; + engine.setFeeConfig(feeRecipient, 100); + + vm.prank(alice); + engine.fill(_fill(quoteA, _order(0, quantity), true, false, false)); + + uint256 bobEthBefore = bob.balance; + uint256 bobQuoteBefore = quoteA.balanceOf(bob); + vm.prank(bob); + engine.fillWithIntegratorFee{value: uint256(quantity) + excess}( + _fill(quoteA, _order(0, quantity), false, true, true), + DeepstateV1.IntegratorFee({recipient: integratorRecipient, bps: 50}) + ); + + assertEq(bob.balance, bobEthBefore - quantity); + assertEq(quoteA.balanceOf(bob), bobQuoteBefore + 9_850); + assertEq(quoteA.balanceOf(feeRecipient), 100); + assertEq(quoteA.balanceOf(integratorRecipient), 50); + assertEq(address(engine).balance, quantity); + } + + function test_NativeIntegratorRouteConsolidatesRepeatedFeeToken() public { + uint160 quantity = 10_000; + NativeFeeReceiver receiver = new NativeFeeReceiver(); + + vm.startPrank(alice); + engine.fill{value: quantity}(_fill(quoteA, _order(0, quantity), false, false, false)); + engine.fill{value: quantity}(_fill(quoteA, _order(0, quantity), false, false, false)); + vm.stopPrank(); + + DeepstateV1.FillParams[] memory route = new DeepstateV1.FillParams[](2); + route[0] = _fill(quoteA, _order(0, quantity), true, true, true); + route[1] = _fill(quoteA, _order(0, quantity), true, true, true); + + uint256 bobEthBefore = bob.balance; + vm.prank(bob); + engine.fillRouteWithIntegratorFee(route, DeepstateV1.IntegratorFee({recipient: address(receiver), bps: 100})); + + assertEq(bob.balance, bobEthBefore + 19_800); + assertEq(address(receiver).balance, 200); + assertEq(receiver.receiveCalls(), 1); + assertEq(address(engine).balance, 0); + } + + function test_RevertingNativeIntegratorPayoutRevertsFillAtomically() public { + uint160 quantity = 10_000; + NativeRejectingFeeRecipient rejector = new NativeRejectingFeeRecipient(); + + vm.prank(alice); + bytes32 restingAsk = engine.fill{value: quantity}(_fill(quoteA, _order(0, quantity), false, false, false)); + + bytes32 id = engine.bookId(address(0), address(quoteA), 0); + uint256 bobEthBefore = bob.balance; + uint256 bobQuoteBefore = quoteA.balanceOf(bob); + vm.prank(bob); + vm.expectRevert(); + engine.fillWithIntegratorFee( + _fill(quoteA, _order(0, quantity), true, true, true), + DeepstateV1.IntegratorFee({recipient: address(rejector), bps: 100}) + ); + + (bytes32 askRoot,) = engine.roots(address(0), address(quoteA), 0); + assertEq(askRoot, restingAsk); + assertEq(engine.ownerOfOrder(engine.orderId(id, restingAsk)), alice); + assertEq(bob.balance, bobEthBefore); + assertEq(quoteA.balanceOf(bob), bobQuoteBefore); + assertEq(address(engine).balance, quantity); + assertEq(address(rejector).balance, 0); + } + function test_NativePoolHookReportsTheBidSideSoldToken() public { NativeRecordingHook hook = new NativeRecordingHook(); engine.setPoolHookConfig(address(0), address(quoteA), address(hook), true, false); diff --git a/test/DeepstateV1NativeETHInvariant.t.sol b/test/DeepstateV1NativeETHInvariant.t.sol index 904b58b..787aad6 100644 --- a/test/DeepstateV1NativeETHInvariant.t.sol +++ b/test/DeepstateV1NativeETHInvariant.t.sol @@ -32,6 +32,15 @@ contract DeepstateV1NativeETHHandler is Test { bool active; } + struct MatchedFill { + bytes32 restingOrder; + uint256 quoteAmount; + uint160 remaining; + uint160 baseFilled; + uint32 nonceBefore; + bool didRest; + } + DeepstateV1 internal immutable ENGINE; NativeInvariantERC20 internal immutable QUOTE; bytes32 internal immutable BOOK_ID; @@ -45,6 +54,7 @@ contract DeepstateV1NativeETHHandler is Test { int32 internal constant MAX_FUZZ_TICK = 100_000_000; uint32 internal constant MAX_ORDER_NONCE = type(uint32).max; address internal constant FEE_RECIPIENT = address(0xFEE); + address internal constant INTEGRATOR_RECIPIENT = address(0x1A7E); address[6] internal actors; uint256[6] internal expectedNativeBalances; @@ -53,6 +63,8 @@ contract DeepstateV1NativeETHHandler is Test { uint256 internal expectedNativeFees; uint256 internal expectedQuoteFees; + uint256 internal expectedNativeIntegratorFees; + uint256 internal expectedQuoteIntegratorFees; address internal expectedFeeRecipient; uint16 internal expectedFeeBps; @@ -68,6 +80,7 @@ contract DeepstateV1NativeETHHandler is Test { actors = [address(0xA11CE), address(0xB0B), address(0xCA201), address(0xD00D), address(0xE1EE), address(0xF00D)]; vm.deal(FEE_RECIPIENT, 0); + vm.deal(INTEGRATOR_RECIPIENT, 0); for (uint256 i; i < actors.length; ++i) { address actor = actors[i]; @@ -89,7 +102,8 @@ contract DeepstateV1NativeETHHandler is Test { int32(bound(tickSeed, MIN_FUZZ_TICK, MAX_FUZZ_TICK)), uint160(bound(quantitySeed, 1, uint256(MAX_ORDER_QUANTITY))), true, - false + false, + 0 ); ++restingFillCalls; } @@ -102,7 +116,8 @@ contract DeepstateV1NativeETHHandler is Test { int32(bound(tickSeed, MIN_FUZZ_TICK, MAX_FUZZ_TICK)), uint160(bound(quantitySeed, 1, uint256(MAX_ORDER_QUANTITY))), false, - false + false, + 0 ); ++restingFillCalls; } @@ -118,7 +133,8 @@ contract DeepstateV1NativeETHHandler is Test { type(int32).max, uint160(bound(quantitySeed, 1, maximum)), true, - true + true, + 0 ); ++noRestFillCalls; } @@ -134,7 +150,64 @@ contract DeepstateV1NativeETHHandler is Test { type(int32).min, uint160(bound(quantitySeed, 1, maximum)), false, - true + true, + 0 + ); + ++noRestFillCalls; + } + + function placeBidWithIntegrator(uint256 actorSeed, int32 tickSeed, uint256 quantitySeed, uint16 bpsSeed) external { + if (trackedOrders.length >= MAX_TRACKED_ORDERS) return; + _performFill( + uint8(bound(actorSeed, 0, actors.length - 1)), + int32(bound(tickSeed, MIN_FUZZ_TICK, MAX_FUZZ_TICK)), + uint160(bound(quantitySeed, 1, uint256(MAX_ORDER_QUANTITY))), + true, + false, + uint16(bound(bpsSeed, 1, 100)) + ); + ++restingFillCalls; + } + + function placeAskWithIntegrator(uint256 actorSeed, int32 tickSeed, uint256 quantitySeed, uint16 bpsSeed) external { + if (trackedOrders.length >= MAX_TRACKED_ORDERS) return; + _performFill( + uint8(bound(actorSeed, 0, actors.length - 1)), + int32(bound(tickSeed, MIN_FUZZ_TICK, MAX_FUZZ_TICK)), + uint160(bound(quantitySeed, 1, uint256(MAX_ORDER_QUANTITY))), + false, + false, + uint16(bound(bpsSeed, 1, 100)) + ); + ++restingFillCalls; + } + + function takeAsBidWithIntegrator(uint256 actorSeed, uint256 quantitySeed, uint16 bpsSeed) external { + uint256 available = _totalRemaining(false); + if (available == 0) return; + uint256 maximum = available < MAX_ORDER_QUANTITY ? available : MAX_ORDER_QUANTITY; + _performFill( + uint8(bound(actorSeed, 0, actors.length - 1)), + type(int32).max, + uint160(bound(quantitySeed, 1, maximum)), + true, + true, + uint16(bound(bpsSeed, 1, 100)) + ); + ++noRestFillCalls; + } + + function takeAsAskWithIntegrator(uint256 actorSeed, uint256 quantitySeed, uint16 bpsSeed) external { + uint256 available = _totalRemaining(true); + if (available == 0) return; + uint256 maximum = available < MAX_ORDER_QUANTITY ? available : MAX_ORDER_QUANTITY; + _performFill( + uint8(bound(actorSeed, 0, actors.length - 1)), + type(int32).min, + uint160(bound(quantitySeed, 1, maximum)), + false, + true, + uint16(bound(bpsSeed, 1, 100)) ); ++noRestFillCalls; } @@ -193,6 +266,14 @@ contract DeepstateV1NativeETHHandler is Test { return expectedQuoteFees; } + function expectedNativeIntegratorFeeBalance() external view returns (uint256) { + return expectedNativeIntegratorFees; + } + + function expectedQuoteIntegratorFeeBalance() external view returns (uint256) { + return expectedQuoteIntegratorFees; + } + function initialNativeSupply() external pure returns (uint256) { return INITIAL_NATIVE_BALANCE * 6; } @@ -247,45 +328,70 @@ contract DeepstateV1NativeETHHandler is Test { return (expectedFeeRecipient, expectedFeeBps); } - function _performFill(uint8 actorIndex, int32 tick, uint160 quantity, bool isBid, bool noRest) private { + function _performFill(uint8 actorIndex, int32 tick, uint160 quantity, bool isBid, bool noRest, uint16 integratorBps) + private + { address actor = actors[actorIndex]; - uint32 nonceBefore = ENGINE.nextNonce(address(0), address(QUOTE), 0); + MatchedFill memory matched; + matched.nonceBefore = ENGINE.nextNonce(address(0), address(QUOTE), 0); uint256 nativeValue = (isBid ? 0 : quantity) + NATIVE_OVERPAYMENT; vm.prank(actor); - bytes32 restingOrder = ENGINE.fill{value: nativeValue}(_fillParams(tick, quantity, isBid, noRest)); + if (integratorBps == 0) { + matched.restingOrder = ENGINE.fill{value: nativeValue}(_fillParams(tick, quantity, isBid, noRest)); + } else { + matched.restingOrder = ENGINE.fillWithIntegratorFee{value: nativeValue}( + _fillParams(tick, quantity, isBid, noRest), + DeepstateV1.IntegratorFee({recipient: INTEGRATOR_RECIPIENT, bps: integratorBps}) + ); + } - (uint160 remaining, uint160 baseFilled, uint256 quoteAmount) = _matchModel(isBid, tick, quantity); + (matched.remaining, matched.baseFilled, matched.quoteAmount) = _matchModel(isBid, tick, quantity); uint32 nonceAfter = ENGINE.nextNonce(address(0), address(QUOTE), 0); - bool didRest = remaining != 0 && !noRest; - if (didRest) { - uint32 assignedNonce = nonceBefore == 0 ? MAX_ORDER_NONCE : nonceBefore; - assertEq(_tick(restingOrder), tick, "rest tick"); - assertEq(_quantity(restingOrder), remaining, "rest quantity"); - assertEq(_nonce(restingOrder), assignedNonce, "rest nonce"); + matched.didRest = matched.remaining != 0 && !noRest; + if (matched.didRest) { + uint32 assignedNonce = matched.nonceBefore == 0 ? MAX_ORDER_NONCE : matched.nonceBefore; + assertEq(_tick(matched.restingOrder), tick, "rest tick"); + assertEq(_quantity(matched.restingOrder), matched.remaining, "rest quantity"); + assertEq(_nonce(matched.restingOrder), assignedNonce, "rest nonce"); assertEq(nonceAfter, assignedNonce - 1, "next nonce after rest"); - _trackRest(restingOrder, actor, actorIndex, isBid); + _trackRest(matched.restingOrder, actor, actorIndex, isBid); } else { - assertEq(restingOrder, bytes32(0), "unexpected rest"); - assertEq(nonceAfter, nonceBefore, "nonce changed without rest"); - if (noRest) assertEq(remaining, 0, "no-rest taker not fully matched"); + assertEq(matched.restingOrder, bytes32(0), "unexpected rest"); + assertEq(nonceAfter, matched.nonceBefore, "nonce changed without rest"); + if (noRest) assertEq(matched.remaining, 0, "no-rest taker not fully matched"); } - uint256 feeAmount; - if (expectedFeeRecipient != address(0) && expectedFeeBps != 0) { - feeAmount = _feeAmount(isBid ? baseFilled : quoteAmount, expectedFeeBps); - } + _applyFillAccounting(actorIndex, tick, isBid, integratorBps, matched); + } + + function _applyFillAccounting( + uint8 actorIndex, + int32 tick, + bool isBid, + uint16 integratorBps, + MatchedFill memory matched + ) private { + uint256 grossOutput = isBid ? matched.baseFilled : matched.quoteAmount; + uint256 feeAmount = + expectedFeeRecipient != address(0) && expectedFeeBps != 0 ? _feeAmount(grossOutput, expectedFeeBps) : 0; + uint256 integratorAmount = integratorBps == 0 ? 0 : _feeAmount(grossOutput, integratorBps); if (isBid) { - expectedNativeBalances[actorIndex] += uint256(baseFilled) - feeAmount; - expectedQuoteBalances[actorIndex] -= quoteAmount; - if (didRest) expectedQuoteBalances[actorIndex] -= _quoteValue(tick, remaining, true); + expectedNativeBalances[actorIndex] += uint256(matched.baseFilled) - feeAmount - integratorAmount; + expectedQuoteBalances[actorIndex] -= matched.quoteAmount; + if (matched.didRest) { + expectedQuoteBalances[actorIndex] -= _quoteValue(tick, matched.remaining, true); + } expectedNativeFees += feeAmount; + expectedNativeIntegratorFees += integratorAmount; } else { - expectedNativeBalances[actorIndex] -= uint256(baseFilled) + (didRest ? uint256(remaining) : 0); - expectedQuoteBalances[actorIndex] += quoteAmount - feeAmount; + expectedNativeBalances[actorIndex] -= uint256(matched.baseFilled) + + (matched.didRest ? uint256(matched.remaining) : 0); + expectedQuoteBalances[actorIndex] += matched.quoteAmount - feeAmount - integratorAmount; expectedQuoteFees += feeAmount; + expectedQuoteIntegratorFees += integratorAmount; } } @@ -441,6 +547,7 @@ contract DeepstateV1NativeETHInvariantTest is StdInvariant, Test { DeepstateV1NativeETHHandler internal handler; address internal constant FEE_RECIPIENT = address(0xFEE); + address internal constant INTEGRATOR_RECIPIENT = address(0x1A7E); function setUp() public { engine = new DeepstateV1(); @@ -451,13 +558,17 @@ contract DeepstateV1NativeETHInvariantTest is StdInvariant, Test { excludeContract(address(engine)); excludeContract(address(quote)); - bytes4[] memory selectors = new bytes4[](6); + bytes4[] memory selectors = new bytes4[](10); selectors[0] = DeepstateV1NativeETHHandler.placeBid.selector; selectors[1] = DeepstateV1NativeETHHandler.placeAsk.selector; selectors[2] = DeepstateV1NativeETHHandler.takeAsBid.selector; selectors[3] = DeepstateV1NativeETHHandler.takeAsAsk.selector; - selectors[4] = DeepstateV1NativeETHHandler.cancel.selector; - selectors[5] = DeepstateV1NativeETHHandler.configureFee.selector; + selectors[4] = DeepstateV1NativeETHHandler.placeBidWithIntegrator.selector; + selectors[5] = DeepstateV1NativeETHHandler.placeAskWithIntegrator.selector; + selectors[6] = DeepstateV1NativeETHHandler.takeAsBidWithIntegrator.selector; + selectors[7] = DeepstateV1NativeETHHandler.takeAsAskWithIntegrator.selector; + selectors[8] = DeepstateV1NativeETHHandler.cancel.selector; + selectors[9] = DeepstateV1NativeETHHandler.configureFee.selector; targetSelector(FuzzSelector({addr: address(handler), selectors: selectors})); } @@ -469,8 +580,9 @@ contract DeepstateV1NativeETHInvariantTest is StdInvariant, Test { /// @notice Every actor and fee balance must match the independent settlement model. function invariant_NativeActorFeeAndSupplyAccounting() public view { - uint256 nativeTotal = address(engine).balance + FEE_RECIPIENT.balance; - uint256 quoteTotal = quote.balanceOf(address(engine)) + quote.balanceOf(FEE_RECIPIENT); + uint256 nativeTotal = address(engine).balance + FEE_RECIPIENT.balance + INTEGRATOR_RECIPIENT.balance; + uint256 quoteTotal = + quote.balanceOf(address(engine)) + quote.balanceOf(FEE_RECIPIENT) + quote.balanceOf(INTEGRATOR_RECIPIENT); for (uint256 i; i < handler.actorCount(); ++i) { address actor = handler.actorAt(i); @@ -482,6 +594,14 @@ contract DeepstateV1NativeETHInvariantTest is StdInvariant, Test { assertEq(FEE_RECIPIENT.balance, handler.expectedNativeFeeBalance(), "native fee balance"); assertEq(quote.balanceOf(FEE_RECIPIENT), handler.expectedQuoteFeeBalance(), "quote fee balance"); + assertEq( + INTEGRATOR_RECIPIENT.balance, handler.expectedNativeIntegratorFeeBalance(), "native integrator fee balance" + ); + assertEq( + quote.balanceOf(INTEGRATOR_RECIPIENT), + handler.expectedQuoteIntegratorFeeBalance(), + "quote integrator fee balance" + ); assertEq(nativeTotal, handler.initialNativeSupply(), "native conservation"); assertEq(quoteTotal, handler.initialQuoteSupply(), "quote conservation"); assertEq(quote.totalSupply(), handler.initialQuoteSupply(), "quote total supply"); diff --git a/test/RadixMatchingEngineFormal.t.sol b/test/RadixMatchingEngineFormal.t.sol index 9515a8e..7af5b81 100644 --- a/test/RadixMatchingEngineFormal.t.sol +++ b/test/RadixMatchingEngineFormal.t.sol @@ -685,6 +685,7 @@ contract NativeAccountingFormalTest is Test { address private constant ALICE = address(0xA11CE); address private constant BOB = address(0xB0B); address private constant FEE_RECIPIENT = address(0xFEE); + address private constant INTEGRATOR_RECIPIENT = address(0x1A7E); FormalERC20 private quote; DeepstateV1 private engine; @@ -734,6 +735,92 @@ contract NativeAccountingFormalTest is Test { assertEq(quote.balanceOf(address(engine)), 0); } + /// @dev Fuzzes production-bytecode settlement across protocol and integrator fee rates. + /// Complete-domain fee-rate and rounding proofs live in `script/prove_protocol.py` because + /// symbolic execution forks excessively across both independent settlement paths. + function testFuzz_NativeBidWithBothFeesPreservesSolvency( + uint8 quantityRaw, + uint8 protocolBpsRaw, + uint8 integratorBpsRaw + ) public { + vm.assume(quantityRaw >= 1 && quantityRaw <= 8); + vm.assume(protocolBpsRaw <= 100); + vm.assume(integratorBpsRaw <= 100); + uint160 quantity = uint160(quantityRaw) * 10_000; + uint16 protocolBps = uint16(protocolBpsRaw); + uint16 integratorBps = uint16(integratorBpsRaw); + uint256 protocolFee = uint256(quantityRaw) * uint256(protocolBps); + uint256 integratorFee = uint256(quantityRaw) * uint256(integratorBps); + + engine.setFeeConfig(FEE_RECIPIENT, protocolBps); + vm.prank(ALICE); + bytes32 ask = engine.fill{value: quantity}(_params(_order(0, quantity, 0), false, false)); + + uint256 bobBefore = BOB.balance; + vm.prank(BOB); + assertEq( + engine.fillWithIntegratorFee( + _params(_order(0, quantity, 0), true, true), + DeepstateV1.IntegratorFee({ + recipient: integratorBps == 0 ? address(0) : INTEGRATOR_RECIPIENT, bps: integratorBps + }) + ), + bytes32(0) + ); + + assertEq(BOB.balance, bobBefore + quantity - protocolFee - integratorFee); + assertEq(FEE_RECIPIENT.balance, protocolFee); + assertEq(INTEGRATOR_RECIPIENT.balance, integratorFee); + assertEq(address(engine).balance, 0); + + vm.prank(ALICE); + (, uint256 quoteClaim) = engine.cancel(address(0), address(quote), 0, ask); + assertEq(quoteClaim, quantity); + assertEq(quote.balanceOf(address(engine)), 0); + } + + /// @dev Fuzzes the same production-bytecode settlement when quote is the ask taker's output. + function testFuzz_NativeAskWithBothFeesPreservesSolvency( + uint8 quantityRaw, + uint8 protocolBpsRaw, + uint8 integratorBpsRaw + ) public { + vm.assume(quantityRaw >= 1 && quantityRaw <= 8); + vm.assume(protocolBpsRaw <= 100); + vm.assume(integratorBpsRaw <= 100); + uint160 quantity = uint160(quantityRaw) * 10_000; + uint16 protocolBps = uint16(protocolBpsRaw); + uint16 integratorBps = uint16(integratorBpsRaw); + uint256 protocolFee = uint256(quantityRaw) * uint256(protocolBps); + uint256 integratorFee = uint256(quantityRaw) * uint256(integratorBps); + + engine.setFeeConfig(FEE_RECIPIENT, protocolBps); + vm.prank(ALICE); + bytes32 bid = engine.fill(_params(_order(0, quantity, 0), true, false)); + + uint256 bobQuoteBefore = quote.balanceOf(BOB); + vm.prank(BOB); + assertEq( + engine.fillWithIntegratorFee{value: quantity}( + _params(_order(0, quantity, 0), false, true), + DeepstateV1.IntegratorFee({ + recipient: integratorBps == 0 ? address(0) : INTEGRATOR_RECIPIENT, bps: integratorBps + }) + ), + bytes32(0) + ); + + assertEq(quote.balanceOf(BOB), bobQuoteBefore + quantity - protocolFee - integratorFee); + assertEq(quote.balanceOf(FEE_RECIPIENT), protocolFee); + assertEq(quote.balanceOf(INTEGRATOR_RECIPIENT), integratorFee); + assertEq(address(engine).balance, quantity); + + vm.prank(ALICE); + (uint256 nativeClaim,) = engine.cancel(address(0), address(quote), 0, bid); + assertEq(nativeClaim, quantity); + assertEq(address(engine).balance, 0); + } + /// @dev Proves underfill, exact fill, and overfill when a native ask fills a bid and may rest. /// Native received always equals the filled-bid claim plus any new resting-ask liability. function testFuzz_FormalNativeAskFillAndRestPreserveSolvency(uint8 bidQuantityRaw, uint8 askQuantityRaw) public { diff --git a/test/RadixMatchingEngineGas.t.sol b/test/RadixMatchingEngineGas.t.sol index 7023c02..b825e31 100644 --- a/test/RadixMatchingEngineGas.t.sol +++ b/test/RadixMatchingEngineGas.t.sol @@ -759,3 +759,133 @@ contract RadixMatchingEngineFeeGasTest is RadixMatchingEngineGasTest { engine.setFeeConfig(address(0xFEE), 100); } } + +contract DeepstateV1IntegratorFeeGasTest is Test { + GasTestERC20 internal token0; + GasTestERC20 internal token1; + DeepstateV1 internal engine; + + address internal constant MAKER = address(0xA11CE); + address internal constant TAKER = address(0xB0B); + address internal constant INTEGRATOR = address(0x1A7E); + + function setUp() public { + GasTestERC20 tokenA = new GasTestERC20(); + GasTestERC20 tokenB = new GasTestERC20(); + (token0, token1) = address(tokenA) < address(tokenB) ? (tokenA, tokenB) : (tokenB, tokenA); + engine = new DeepstateV1(); + _fundAndApprove(MAKER); + _fundAndApprove(TAKER); + } + + function testGas_IntegratorFillRestBidEmptyBook() public { + vm.pauseGasMetering(); + vm.prank(TAKER); + vm.resumeGasMetering(); + bytes32 restingOrder = + engine.fillWithIntegratorFee(_params(_order(0, 10_000, 0), true, false), _integratorFee()); + vm.pauseGasMetering(); + + assertEq(_quantity(restingOrder), 10_000); + assertEq(token0.balanceOf(INTEGRATOR), 0); + vm.resumeGasMetering(); + } + + function testGas_IntegratorFillBidFullyMatchesSingleAsk() public { + vm.pauseGasMetering(); + vm.prank(MAKER); + engine.fill(_params(_order(0, 10_000, 0), false, false)); + + vm.prank(TAKER); + vm.resumeGasMetering(); + engine.fillWithIntegratorFee(_params(_order(0, 10_000, 0), true, true), _integratorFee()); + vm.pauseGasMetering(); + + assertEq(token0.balanceOf(INTEGRATOR), 10); + vm.resumeGasMetering(); + } + + function testGas_IntegratorFillAskFullyMatchesSingleBid() public { + vm.pauseGasMetering(); + vm.prank(MAKER); + engine.fill(_params(_order(0, 10_000, 0), true, false)); + + vm.prank(TAKER); + vm.resumeGasMetering(); + engine.fillWithIntegratorFee(_params(_order(0, 10_000, 0), false, true), _integratorFee()); + vm.pauseGasMetering(); + + assertEq(token1.balanceOf(INTEGRATOR), 10); + vm.resumeGasMetering(); + } + + function testGas_IntegratorAndProtocolFeeFill() public { + vm.pauseGasMetering(); + engine.setFeeConfig(address(0xFEE), 100); + vm.prank(MAKER); + engine.fill(_params(_order(0, 10_000, 0), false, false)); + + vm.prank(TAKER); + vm.resumeGasMetering(); + engine.fillWithIntegratorFee(_params(_order(0, 10_000, 0), true, true), _integratorFee()); + vm.pauseGasMetering(); + + assertEq(token0.balanceOf(address(0xFEE)), 100); + assertEq(token0.balanceOf(INTEGRATOR), 10); + vm.resumeGasMetering(); + } + + function testGas_IntegratorRouteNetsRepeatedFeeToken() public { + vm.pauseGasMetering(); + vm.prank(MAKER); + engine.fill(_params(_order(0, 20_000, 0), false, false)); + + DeepstateV1.FillParams[] memory route = new DeepstateV1.FillParams[](2); + route[0] = _params(_order(0, 10_000, 0), true, true); + route[1] = _params(_order(0, 10_000, 0), true, true); + vm.prank(TAKER); + vm.resumeGasMetering(); + engine.fillRouteWithIntegratorFee(route, _integratorFee()); + vm.pauseGasMetering(); + + assertEq(token0.balanceOf(INTEGRATOR), 20); + vm.resumeGasMetering(); + } + + function _fundAndApprove(address account) private { + token0.mint(account, 1_000_000); + token1.mint(account, 1_000_000); + vm.startPrank(account); + token0.approve(address(engine), type(uint256).max); + token1.approve(address(engine), type(uint256).max); + vm.stopPrank(); + } + + function _params(bytes32 order, bool isBid, bool noRest) + private + view + returns (DeepstateV1.FillParams memory params) + { + params = DeepstateV1.FillParams({ + token0: address(token0), + token1: address(token1), + epoch: 0, + order: order, + isBid: isBid, + noRest: noRest, + fillOrKill: false + }); + } + + function _integratorFee() private pure returns (DeepstateV1.IntegratorFee memory fee) { + fee = DeepstateV1.IntegratorFee({recipient: INTEGRATOR, bps: 10}); + } + + function _order(int32 tick, uint160 quantity, uint32 nonce) private pure returns (bytes32) { + return bytes32((uint256(uint32(tick)) << 224) | (uint256(quantity) << 64) | uint256(nonce)); + } + + function _quantity(bytes32 order) private pure returns (uint160) { + return uint160(uint256(order) >> 64); + } +}