From 37aa0d2ecb4a1f37a45b473729c100b2991c4e2d Mon Sep 17 00:00:00 2001 From: Joseph Delong Date: Fri, 14 Aug 2026 20:09:20 -0500 Subject: [PATCH] fix: settle rewards before book rotation (V12 #227435) --- .gas-snapshot.runtime | 126 ++++++++++++++++---------------- coverage.exclusions.json | 82 ++++++++++----------- script/prove_protocol.py | 4 +- src/DeepstateV1.sol | 89 ++++++++++++++++++---- test/DeepstateV1.t.sol | 89 ++++++++++++++++++++++ test/DeepstateV1Invariant.t.sol | 63 ++++++++++++---- 6 files changed, 318 insertions(+), 135 deletions(-) diff --git a/.gas-snapshot.runtime b/.gas-snapshot.runtime index b052a20..e4c9202 100644 --- a/.gas-snapshot.runtime +++ b/.gas-snapshot.runtime @@ -1,8 +1,8 @@ -DeepstateV1IntegratorFeeGasTest:testGas_IntegratorAndProtocolFeeFill() (gas: 129346) -DeepstateV1IntegratorFeeGasTest:testGas_IntegratorFillAskFullyMatchesSingleBid() (gas: 103757) -DeepstateV1IntegratorFeeGasTest:testGas_IntegratorFillBidFullyMatchesSingleAsk() (gas: 103866) -DeepstateV1IntegratorFeeGasTest:testGas_IntegratorFillRestBidEmptyBook() (gas: 151809) -DeepstateV1IntegratorFeeGasTest:testGas_IntegratorRouteNetsRepeatedFeeToken() (gas: 122482) +DeepstateV1IntegratorFeeGasTest:testGas_IntegratorAndProtocolFeeFill() (gas: 129349) +DeepstateV1IntegratorFeeGasTest:testGas_IntegratorFillAskFullyMatchesSingleBid() (gas: 103760) +DeepstateV1IntegratorFeeGasTest:testGas_IntegratorFillBidFullyMatchesSingleAsk() (gas: 103869) +DeepstateV1IntegratorFeeGasTest:testGas_IntegratorFillRestBidEmptyBook() (gas: 151812) +DeepstateV1IntegratorFeeGasTest:testGas_IntegratorRouteNetsRepeatedFeeToken() (gas: 122488) RadixMatchingEngineFeeGasTest:testGas_CancelAskSkipsPathologicalBidTree() (gas: 39659) RadixMatchingEngineFeeGasTest:testGas_CancelFilledBidClaim() (gas: 36739) RadixMatchingEngineFeeGasTest:testGas_CancelFullDepthBidCombRightmost() (gas: 423078) @@ -10,24 +10,24 @@ RadixMatchingEngineFeeGasTest:testGas_CancelMaxValidDepthAskCombRightmost() (gas RadixMatchingEngineFeeGasTest:testGas_CancelPartialBid() (gas: 48797) RadixMatchingEngineFeeGasTest:testGas_CancelUnfilledAsk() (gas: 39876) RadixMatchingEngineFeeGasTest:testGas_CancelUnfilledBid() (gas: 39717) -RadixMatchingEngineFeeGasTest:testGas_FillAskConsumesDirtySamePriceBidSubtree() (gas: 87846) -RadixMatchingEngineFeeGasTest:testGas_FillAskConsumesFullDepthBidComb() (gas: 352993) -RadixMatchingEngineFeeGasTest:testGas_FillAskFullyMatchesSingleBid() (gas: 78197) -RadixMatchingEngineFeeGasTest:testGas_FillAskPartiallyMatchesSingleBid() (gas: 97892) -RadixMatchingEngineFeeGasTest:testGas_FillBidConsumesAskAndRestsRemainder() (gas: 130827) -RadixMatchingEngineFeeGasTest:testGas_FillBidConsumesDirtySamePriceAskSubtree() (gas: 87959) -RadixMatchingEngineFeeGasTest:testGas_FillBidConsumesMaxValidDepthAskComb() (gas: 324083) -RadixMatchingEngineFeeGasTest:testGas_FillBidConsumesSamePriceAskSubtree() (gas: 77951) -RadixMatchingEngineFeeGasTest:testGas_FillBidFullyMatchesSingleAsk() (gas: 77851) -RadixMatchingEngineFeeGasTest:testGas_FillBidPartiallyMatchesSingleAsk() (gas: 97765) -RadixMatchingEngineFeeGasTest:testGas_FillRestAskEmptyBook() (gas: 150894) -RadixMatchingEngineFeeGasTest:testGas_FillRestBidEmptyBook() (gas: 152651) -RadixMatchingEngineFeeGasTest:testGas_LargeRandomBookAskMatchesOneBid() (gas: 122101) -RadixMatchingEngineFeeGasTest:testGas_LargeRandomBookBidMatchesOneAsk() (gas: 137291) -RadixMatchingEngineFeeGasTest:testGas_LargeRandomBookPartialFillsAsk() (gas: 138429) -RadixMatchingEngineFeeGasTest:testGas_LargeRandomBookPartialFillsBid() (gas: 123225) -RadixMatchingEngineFeeGasTest:testGas_LargeRandomBookRestsAsk() (gas: 163968) -RadixMatchingEngineFeeGasTest:testGas_LargeRandomBookRestsBid() (gas: 173286) +RadixMatchingEngineFeeGasTest:testGas_FillAskConsumesDirtySamePriceBidSubtree() (gas: 87849) +RadixMatchingEngineFeeGasTest:testGas_FillAskConsumesFullDepthBidComb() (gas: 352996) +RadixMatchingEngineFeeGasTest:testGas_FillAskFullyMatchesSingleBid() (gas: 78200) +RadixMatchingEngineFeeGasTest:testGas_FillAskPartiallyMatchesSingleBid() (gas: 97895) +RadixMatchingEngineFeeGasTest:testGas_FillBidConsumesAskAndRestsRemainder() (gas: 130830) +RadixMatchingEngineFeeGasTest:testGas_FillBidConsumesDirtySamePriceAskSubtree() (gas: 87962) +RadixMatchingEngineFeeGasTest:testGas_FillBidConsumesMaxValidDepthAskComb() (gas: 324086) +RadixMatchingEngineFeeGasTest:testGas_FillBidConsumesSamePriceAskSubtree() (gas: 77954) +RadixMatchingEngineFeeGasTest:testGas_FillBidFullyMatchesSingleAsk() (gas: 77854) +RadixMatchingEngineFeeGasTest:testGas_FillBidPartiallyMatchesSingleAsk() (gas: 97768) +RadixMatchingEngineFeeGasTest:testGas_FillRestAskEmptyBook() (gas: 150897) +RadixMatchingEngineFeeGasTest:testGas_FillRestBidEmptyBook() (gas: 152654) +RadixMatchingEngineFeeGasTest:testGas_LargeRandomBookAskMatchesOneBid() (gas: 122104) +RadixMatchingEngineFeeGasTest:testGas_LargeRandomBookBidMatchesOneAsk() (gas: 137294) +RadixMatchingEngineFeeGasTest:testGas_LargeRandomBookPartialFillsAsk() (gas: 138432) +RadixMatchingEngineFeeGasTest:testGas_LargeRandomBookPartialFillsBid() (gas: 123228) +RadixMatchingEngineFeeGasTest:testGas_LargeRandomBookRestsAsk() (gas: 163971) +RadixMatchingEngineFeeGasTest:testGas_LargeRandomBookRestsBid() (gas: 173289) RadixMatchingEngineGasTest:testGas_CancelAskSkipsPathologicalBidTree() (gas: 39659) RadixMatchingEngineGasTest:testGas_CancelFilledBidClaim() (gas: 36739) RadixMatchingEngineGasTest:testGas_CancelFullDepthBidCombRightmost() (gas: 423078) @@ -35,30 +35,30 @@ RadixMatchingEngineGasTest:testGas_CancelMaxValidDepthAskCombRightmost() (gas: 4 RadixMatchingEngineGasTest:testGas_CancelPartialBid() (gas: 48797) RadixMatchingEngineGasTest:testGas_CancelUnfilledAsk() (gas: 39876) RadixMatchingEngineGasTest:testGas_CancelUnfilledBid() (gas: 39717) -RadixMatchingEngineGasTest:testGas_FillAskConsumesDirtySamePriceBidSubtree() (gas: 87242) -RadixMatchingEngineGasTest:testGas_FillAskConsumesFullDepthBidComb() (gas: 327303) -RadixMatchingEngineGasTest:testGas_FillAskFullyMatchesSingleBid() (gas: 77593) -RadixMatchingEngineGasTest:testGas_FillAskPartiallyMatchesSingleBid() (gas: 97288) -RadixMatchingEngineGasTest:testGas_FillBidConsumesAskAndRestsRemainder() (gas: 130230) -RadixMatchingEngineGasTest:testGas_FillBidConsumesDirtySamePriceAskSubtree() (gas: 87365) -RadixMatchingEngineGasTest:testGas_FillBidConsumesMaxValidDepthAskComb() (gas: 323489) -RadixMatchingEngineGasTest:testGas_FillBidConsumesSamePriceAskSubtree() (gas: 77357) -RadixMatchingEngineGasTest:testGas_FillBidFullyMatchesSingleAsk() (gas: 77257) -RadixMatchingEngineGasTest:testGas_FillBidPartiallyMatchesSingleAsk() (gas: 97171) -RadixMatchingEngineGasTest:testGas_FillRestAskEmptyBook() (gas: 150668) -RadixMatchingEngineGasTest:testGas_FillRestBidEmptyBook() (gas: 152432) -RadixMatchingEngineGasTest:testGas_LargeRandomBookAskMatchesOneBid() (gas: 121497) -RadixMatchingEngineGasTest:testGas_LargeRandomBookBidMatchesOneAsk() (gas: 136697) -RadixMatchingEngineGasTest:testGas_LargeRandomBookPartialFillsAsk() (gas: 137835) -RadixMatchingEngineGasTest:testGas_LargeRandomBookPartialFillsBid() (gas: 122621) -RadixMatchingEngineGasTest:testGas_LargeRandomBookRestsAsk() (gas: 163742) -RadixMatchingEngineGasTest:testGas_LargeRandomBookRestsBid() (gas: 173067) +RadixMatchingEngineGasTest:testGas_FillAskConsumesDirtySamePriceBidSubtree() (gas: 87245) +RadixMatchingEngineGasTest:testGas_FillAskConsumesFullDepthBidComb() (gas: 327306) +RadixMatchingEngineGasTest:testGas_FillAskFullyMatchesSingleBid() (gas: 77596) +RadixMatchingEngineGasTest:testGas_FillAskPartiallyMatchesSingleBid() (gas: 97291) +RadixMatchingEngineGasTest:testGas_FillBidConsumesAskAndRestsRemainder() (gas: 130233) +RadixMatchingEngineGasTest:testGas_FillBidConsumesDirtySamePriceAskSubtree() (gas: 87368) +RadixMatchingEngineGasTest:testGas_FillBidConsumesMaxValidDepthAskComb() (gas: 323492) +RadixMatchingEngineGasTest:testGas_FillBidConsumesSamePriceAskSubtree() (gas: 77360) +RadixMatchingEngineGasTest:testGas_FillBidFullyMatchesSingleAsk() (gas: 77260) +RadixMatchingEngineGasTest:testGas_FillBidPartiallyMatchesSingleAsk() (gas: 97174) +RadixMatchingEngineGasTest:testGas_FillRestAskEmptyBook() (gas: 150671) +RadixMatchingEngineGasTest:testGas_FillRestBidEmptyBook() (gas: 152435) +RadixMatchingEngineGasTest:testGas_LargeRandomBookAskMatchesOneBid() (gas: 121500) +RadixMatchingEngineGasTest:testGas_LargeRandomBookBidMatchesOneAsk() (gas: 136700) +RadixMatchingEngineGasTest:testGas_LargeRandomBookPartialFillsAsk() (gas: 137838) +RadixMatchingEngineGasTest:testGas_LargeRandomBookPartialFillsBid() (gas: 122624) +RadixMatchingEngineGasTest:testGas_LargeRandomBookRestsAsk() (gas: 163745) +RadixMatchingEngineGasTest:testGas_LargeRandomBookRestsBid() (gas: 173070) RadixMatchingEngineGasTestNativeDirtySpine:testGas_ObservedNativeBidCancelAfterThreePartialMatches() (gas: 88476) -RadixMatchingEngineGasTestNativeDirtySpine:testGas_ObservedNativeBidPartialMatchAfterDirtySpineRest() (gas: 98430) -RadixMatchingEngineGasTestNativeDirtySpine:testGas_ObservedNativeBidRestsAfterFiveLevelDirtySpine() (gas: 528120) +RadixMatchingEngineGasTestNativeDirtySpine:testGas_ObservedNativeBidPartialMatchAfterDirtySpineRest() (gas: 98433) +RadixMatchingEngineGasTestNativeDirtySpine:testGas_ObservedNativeBidRestsAfterFiveLevelDirtySpine() (gas: 528123) RadixMatchingEngineGasTestNativeDirtySpine:testGas_PathologicalNativeBidCancelAfterThreePartialMatches() (gas: 356523) -RadixMatchingEngineGasTestNativeDirtySpine:testGas_PathologicalNativeBidPartialMatchAfterDirtySpineRest() (gas: 394232) -RadixMatchingEngineGasTestNativeDirtySpine:testGas_PathologicalNativeBidRestsAfterFullDepthDirtySpine() (gas: 3931430) +RadixMatchingEngineGasTestNativeDirtySpine:testGas_PathologicalNativeBidPartialMatchAfterDirtySpineRest() (gas: 394235) +RadixMatchingEngineGasTestNativeDirtySpine:testGas_PathologicalNativeBidRestsAfterFullDepthDirtySpine() (gas: 3931433) RadixMatchingEngineHookGasTest:testGas_CancelAskSkipsPathologicalBidTree() (gas: 43698) RadixMatchingEngineHookGasTest:testGas_CancelFilledBidClaim() (gas: 37486) RadixMatchingEngineHookGasTest:testGas_CancelFullDepthBidCombRightmost() (gas: 446433) @@ -66,21 +66,21 @@ RadixMatchingEngineHookGasTest:testGas_CancelMaxValidDepthAskCombRightmost() (ga RadixMatchingEngineHookGasTest:testGas_CancelPartialBid() (gas: 53587) RadixMatchingEngineHookGasTest:testGas_CancelUnfilledAsk() (gas: 43915) RadixMatchingEngineHookGasTest:testGas_CancelUnfilledBid() (gas: 44505) -RadixMatchingEngineHookGasTest:testGas_FillAskConsumesDirtySamePriceBidSubtree() (gas: 95252) -RadixMatchingEngineHookGasTest:testGas_FillAskConsumesFullDepthBidComb() (gas: 414778) -RadixMatchingEngineHookGasTest:testGas_FillAskFullyMatchesSingleBid() (gas: 85769) -RadixMatchingEngineHookGasTest:testGas_FillAskPartiallyMatchesSingleBid() (gas: 105472) -RadixMatchingEngineHookGasTest:testGas_FillBidConsumesAskAndRestsRemainder() (gas: 139207) -RadixMatchingEngineHookGasTest:testGas_FillBidConsumesDirtySamePriceAskSubtree() (gas: 94232) -RadixMatchingEngineHookGasTest:testGas_FillBidConsumesMaxValidDepthAskComb() (gas: 410105) -RadixMatchingEngineHookGasTest:testGas_FillBidConsumesSamePriceAskSubtree() (gas: 93337) -RadixMatchingEngineHookGasTest:testGas_FillBidFullyMatchesSingleAsk() (gas: 84184) -RadixMatchingEngineHookGasTest:testGas_FillBidPartiallyMatchesSingleAsk() (gas: 104106) -RadixMatchingEngineHookGasTest:testGas_FillRestAskEmptyBook() (gas: 140357) -RadixMatchingEngineHookGasTest:testGas_FillRestBidEmptyBook() (gas: 142118) -RadixMatchingEngineHookGasTest:testGas_LargeRandomBookAskMatchesOneBid() (gas: 138973) -RadixMatchingEngineHookGasTest:testGas_LargeRandomBookBidMatchesOneAsk() (gas: 154775) -RadixMatchingEngineHookGasTest:testGas_LargeRandomBookPartialFillsAsk() (gas: 146860) -RadixMatchingEngineHookGasTest:testGas_LargeRandomBookPartialFillsBid() (gas: 133256) -RadixMatchingEngineHookGasTest:testGas_LargeRandomBookRestsAsk() (gas: 193723) -RadixMatchingEngineHookGasTest:testGas_LargeRandomBookRestsBid() (gas: 198650) +RadixMatchingEngineHookGasTest:testGas_FillAskConsumesDirtySamePriceBidSubtree() (gas: 95255) +RadixMatchingEngineHookGasTest:testGas_FillAskConsumesFullDepthBidComb() (gas: 414781) +RadixMatchingEngineHookGasTest:testGas_FillAskFullyMatchesSingleBid() (gas: 85772) +RadixMatchingEngineHookGasTest:testGas_FillAskPartiallyMatchesSingleBid() (gas: 105475) +RadixMatchingEngineHookGasTest:testGas_FillBidConsumesAskAndRestsRemainder() (gas: 139210) +RadixMatchingEngineHookGasTest:testGas_FillBidConsumesDirtySamePriceAskSubtree() (gas: 94235) +RadixMatchingEngineHookGasTest:testGas_FillBidConsumesMaxValidDepthAskComb() (gas: 410108) +RadixMatchingEngineHookGasTest:testGas_FillBidConsumesSamePriceAskSubtree() (gas: 93340) +RadixMatchingEngineHookGasTest:testGas_FillBidFullyMatchesSingleAsk() (gas: 84187) +RadixMatchingEngineHookGasTest:testGas_FillBidPartiallyMatchesSingleAsk() (gas: 104109) +RadixMatchingEngineHookGasTest:testGas_FillRestAskEmptyBook() (gas: 140360) +RadixMatchingEngineHookGasTest:testGas_FillRestBidEmptyBook() (gas: 142121) +RadixMatchingEngineHookGasTest:testGas_LargeRandomBookAskMatchesOneBid() (gas: 138976) +RadixMatchingEngineHookGasTest:testGas_LargeRandomBookBidMatchesOneAsk() (gas: 154778) +RadixMatchingEngineHookGasTest:testGas_LargeRandomBookPartialFillsAsk() (gas: 146863) +RadixMatchingEngineHookGasTest:testGas_LargeRandomBookPartialFillsBid() (gas: 133259) +RadixMatchingEngineHookGasTest:testGas_LargeRandomBookRestsAsk() (gas: 193726) +RadixMatchingEngineHookGasTest:testGas_LargeRandomBookRestsBid() (gas: 198653) \ No newline at end of file diff --git a/coverage.exclusions.json b/coverage.exclusions.json index c7953ea..ea7b783 100644 --- a/coverage.exclusions.json +++ b/coverage.exclusions.json @@ -98,49 +98,49 @@ 2033, 2057, 2067, - 2306, - 2316, - 2379, - 2383, - 2421, - 2424, - 2465, - 2466, - 2467, - 2468, - 2481, - 2482, - 2483, - 2484, - 2485, - 2486, - 2660, - 2759, - 2760, - 2940, - 2941, - 2942, - 2969, - 2973, - 2975, - 3000, - 3004, - 3006, + 2314, + 2324, + 2387, + 2391, + 2429, + 2432, + 2473, + 2474, + 2475, + 2476, + 2489, + 2490, + 2491, + 2492, + 2493, + 2494, + 2666, + 2765, + 2766, + 2997, + 2998, + 2999, 3026, 3030, 3032, - 3070, - 3079, - 3084, - 3100, - 3105, - 3153, - 3154, - 3172, - 3173, - 3227, - 3235, - 3300 + 3057, + 3061, + 3063, + 3083, + 3087, + 3089, + 3127, + 3136, + 3141, + 3157, + 3162, + 3210, + 3211, + 3229, + 3230, + 3284, + 3292, + 3357 ], "statements": 145, "functions": [], @@ -154,7 +154,7 @@ "1938:149:0", "1945:150:0", "1975:151:0", - "2940:198:0" + "2997:202:0" ] }, "src/libraries/TickMath32.sol": { diff --git a/script/prove_protocol.py b/script/prove_protocol.py index ba6ba7e..afd601d 100644 --- a/script/prove_protocol.py +++ b/script/prove_protocol.py @@ -88,8 +88,8 @@ def bind_model_to_source(): "prefixLength = uint8(LibBit.clz(differingBits << 192));", "one := and(shr(sub(63, depth), key), 1)", "nextNonceAfter = nonce - 1;", - "if (oldEpoch == _POOL_EPOCH_MASK) revert EpochExhausted();", - "epoch = oldEpoch + 1;", + "if (rotation.epoch == _POOL_EPOCH_MASK) revert EpochExhausted();", + "++rotation.epoch;", "return (poolState & _POOL_HOOK_ACTIVE_MASK) | epoch;", "uint160 quantity = _quantity(a) + _quantity(b);", "quoteAmount += _subtreeQuote(book, leftNode, restingIsBid);", diff --git a/src/DeepstateV1.sol b/src/DeepstateV1.sol index fab44fb..7777b68 100644 --- a/src/DeepstateV1.sol +++ b/src/DeepstateV1.sol @@ -2169,6 +2169,14 @@ contract DeepstateV1 is Ownable { uint256 integratorFeeAmount; } + /// @notice Scratch state for rotating one exhausted pool book. + struct BookRotationState { + bytes32 poolId; + uint256 poolState; + uint256 epoch; + uint256 hookFlags; + } + /// @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. @@ -2502,10 +2510,8 @@ contract DeepstateV1 is Ownable { /// @return soldAmount Current collateral sold by that order, saturated at `uint160.max`. function topOrder(bytes32 id, bool isBid) external view returns (uint32 nonce, uint160 soldAmount) { Book storage book = books[id]; - bytes32 root = isBid ? book.tree[_ROOT_NODE].rightNode : book.tree[_ROOT_NODE].leftNode; - if (root == bytes32(0)) return (0, 0); - - bytes32 order = _rightmostLeaf(book, root); + bytes32 order = _topOrder(book, isBid); + if (order == bytes32(0)) return (0, 0); return (_nonce(order), _hookAmount(order, isBid)); } @@ -2824,23 +2830,43 @@ contract DeepstateV1 is Ownable { /// route a separate leg to the next active epoch to create maker liquidity. Hook flags are /// removed from the old book and copied into the newly initialized book. function _rotateIfExhausted(address token0, address token1) private { - bytes32 pid = poolId(token0, token1); - uint256 poolState = _poolEpochAndHookFlags[pid]; - uint256 oldEpoch = _poolEpoch(poolState); - if (oldEpoch == _POOL_EPOCH_MASK) revert EpochExhausted(); - uint256 bookHookFlags = _bookHookFlags(poolState); - if (bookHookFlags != 0) _setBookHookFlags(books[bookId(token0, token1, oldEpoch)], 0); + BookRotationState memory rotation = + BookRotationState({poolId: poolId(token0, token1), poolState: 0, epoch: 0, hookFlags: 0}); + rotation.poolState = _poolEpochAndHookFlags[rotation.poolId]; + rotation.epoch = _poolEpoch(rotation.poolState); + if (rotation.epoch == _POOL_EPOCH_MASK) revert EpochExhausted(); + rotation.hookFlags = _bookHookFlags(rotation.poolState); + if (rotation.hookFlags != 0) { + _closeBookHooks(token0, token1, rotation.poolId, rotation.epoch, rotation.hookFlags); + } - uint256 epoch; unchecked { - epoch = oldEpoch + 1; + ++rotation.epoch; } - _poolEpochAndHookFlags[pid] = _withPoolEpoch(poolState, epoch); + _poolEpochAndHookFlags[rotation.poolId] = _withPoolEpoch(rotation.poolState, rotation.epoch); - bytes32 id = bookId(token0, token1, epoch); + bytes32 id = bookId(token0, token1, rotation.epoch); Book storage nextBook = books[id]; - _initializeBookWithHookFlags(nextBook, bookHookFlags); - emit BookInitialized(pid, id, epoch); + _initializeBookWithHookFlags(nextBook, rotation.hookFlags); + emit BookInitialized(rotation.poolId, id, rotation.epoch); + } + + /// @notice Finalize enabled sides and remove hook flags from an exhausted historical book. + function _closeBookHooks(address token0, address token1, bytes32 pid, uint256 oldEpoch, uint256 bookHookFlags) + private + { + bytes32 oldBookId = bookId(token0, token1, oldEpoch); + Book storage oldBook = books[oldBookId]; + address hook = poolHook[pid]; + if (hook != address(0)) { + if (bookHookFlags & _BOOK_HOOK_TOKEN0_ACTIVE != 0) { + _executeBookClosureHook(hook, pid, oldBookId, oldBook, token1, true); + } + if (bookHookFlags & _BOOK_HOOK_TOKEN1_ACTIVE != 0) { + _executeBookClosureHook(hook, pid, oldBookId, oldBook, token0, false); + } + } + _setBookHookFlags(oldBook, 0); } /// @notice Consume transient top-change data and call the configured pool hook if needed. @@ -2862,12 +2888,43 @@ contract DeepstateV1 is Ownable { address token = isBid ? token1 : token0; uint160 outgoingAmount = _hookAmount(outgoingOrder, isBid); + _callHook(hook, pid, id, token, outgoingAmount, incomingNonce); + } + + /// @notice Finalize one enabled side before an exhausted book loses its hook flags. + function _executeBookClosureHook( + address hook, + bytes32 pid, + bytes32 id, + Book storage book, + address token, + bool isBid + ) private { + bytes32 outgoingOrder = _topOrder(book, isBid); + _callHook(hook, pid, id, token, _hookAmount(outgoingOrder, isBid), 0); + } + + /// @notice Call a configured hook inside the engine's bounded best-effort failure domain. + function _callHook( + address hook, + bytes32 pid, + bytes32 id, + address token, + uint160 outgoingAmount, + uint32 incomingNonce + ) private { // A gas cap makes the best-effort guarantee meaningful for a hook that loops or otherwise // consumes all gas. Reverts and out-of-gas inside this bounded call are intentionally ignored. // slither-disable-next-line calls-loop try IHook(hook).execute{gas: _HOOK_GAS_LIMIT}(pid, id, token, outgoingAmount, incomingNonce) {} catch {} } + /// @notice Return one side's live best order, or zero when that side is empty. + function _topOrder(Book storage book, bool isBid) private view returns (bytes32 order) { + bytes32 root = isBid ? book.tree[_ROOT_NODE].rightNode : book.tree[_ROOT_NODE].leftNode; + if (root != bytes32(0)) order = _rightmostLeaf(book, root); + } + /// @notice Convert one live order into the collateral amount it is offering to sell. /// @dev Bids sell quote (`token1`) and asks sell base (`token0`). Saturation is sufficient for /// bounded reward thresholds while avoiding a hook ABI expansion for extreme quote values. diff --git a/test/DeepstateV1.t.sol b/test/DeepstateV1.t.sol index 6e569e0..e422bae 100644 --- a/test/DeepstateV1.t.sol +++ b/test/DeepstateV1.t.sol @@ -36,6 +36,51 @@ contract GasBurningHook { } } +contract RotationRecordingHook { + struct Call { + bytes32 poolId; + bytes32 bookId; + address token; + uint160 outgoingAmount; + uint32 incomingNonce; + } + + address internal immutable ENGINE; + Call[] internal calls; + + constructor(address engine_) { + ENGINE = engine_; + } + + function execute(bytes32 poolId, bytes32 bookId, address token, uint160 outgoingAmount, uint32 incomingNonce) + external + { + require(msg.sender == ENGINE, "only engine"); + calls.push( + Call({ + poolId: poolId, + bookId: bookId, + token: token, + outgoingAmount: outgoingAmount, + incomingNonce: incomingNonce + }) + ); + } + + function callCount() external view returns (uint256) { + return calls.length; + } + + function callAt(uint256 index) + external + view + returns (bytes32 poolId, bytes32 bookId, address token, uint160 outgoingAmount, uint32 incomingNonce) + { + Call storage recorded = calls[index]; + return (recorded.poolId, recorded.bookId, recorded.token, recorded.outgoingAmount, recorded.incomingNonce); + } +} + contract DeepstateV1Harness is DeepstateV1 { function setNonceAndFlags(bytes32 id, uint256 nonceAndFlags) external { books[id].nonceAndFlags = nonceAndFlags; @@ -830,6 +875,50 @@ contract DeepstateV1Test is Test { assertEq(engine.ownerOfOrder(engine.orderId(newBook, resting)), address(0)); } + function test_RotationFinalizesBothEnabledOldBookTopsBeforeDisablingHooks() public { + RotationRecordingHook hook = new RotationRecordingHook(address(engine)); + engine.setPoolHookConfig(address(token0), address(token1), address(hook), true, true); + + vm.prank(alice); + bytes32 ask = engine.fill(_fill(0, _order(20, 7, 0), false, false, false)); + vm.prank(bob); + bytes32 bid = engine.fill(_fill(0, _order(10, 5, 0), true, false, false)); + + bytes32 pid = engine.poolId(address(token0), address(token1)); + bytes32 oldBook = engine.bookId(address(token0), address(token1), 0); + uint256 callsBeforeRotation = hook.callCount(); + assertEq(callsBeforeRotation, 2); + + engine.setNonceAndFlags(oldBook, 2 | (uint256(1) << 34) | (uint256(1) << 35)); + vm.prank(alice); + engine.fill(_fill(0, _order(5, 3, 0), true, false, false)); + + assertEq(engine.poolEpoch(pid), 1); + assertEq(hook.callCount(), callsBeforeRotation + 2); + + (bytes32 bidPid, bytes32 bidBook, address bidToken, uint160 bidAmount, uint32 bidIncoming) = + hook.callAt(callsBeforeRotation); + assertEq(bidPid, pid); + assertEq(bidBook, oldBook); + assertEq(bidToken, address(token1)); + assertEq(bidAmount, _quoteValue(10, 5, true)); + assertEq(bidIncoming, 0); + + (bytes32 askPid, bytes32 askBook, address askToken, uint160 askAmount, uint32 askIncoming) = + hook.callAt(callsBeforeRotation + 1); + assertEq(askPid, pid); + assertEq(askBook, oldBook); + assertEq(askToken, address(token0)); + assertEq(askAmount, 7); + assertEq(askIncoming, 0); + + vm.prank(bob); + engine.cancel(address(token0), address(token1), 0, bid); + vm.prank(alice); + engine.cancel(address(token0), address(token1), 0, ask); + assertEq(hook.callCount(), callsBeforeRotation + 2); + } + function test_FinalEpochCannotAliasHookFlagsAndFailedRestIsAtomic() public { uint256 finalEpoch = (uint256(1) << 254) - 1; bytes32 pid = engine.poolId(address(token0), address(token1)); diff --git a/test/DeepstateV1Invariant.t.sol b/test/DeepstateV1Invariant.t.sol index 46999e2..e709da2 100644 --- a/test/DeepstateV1Invariant.t.sol +++ b/test/DeepstateV1Invariant.t.sol @@ -590,7 +590,7 @@ contract DeepstateV1MultiPoolHandler is Test { try ENGINE.fill(params) returns (bytes32 restingOrder) { Vm.Log[] memory entries = vm.getRecordedLogs(); (RestLog[] memory rests, uint256 restCount) = _restLogs(entries, actors[actorIndex]); - ExpectedHook[] memory expectedHooks = new ExpectedHook[](3); + ExpectedHook[] memory expectedHooks = new ExpectedHook[](5); AppliedLeg memory applied = _applyLeg(params, actorIndex, rests, 0, expectedHooks, 0, 0); assertEq(applied.restCursor, restCount, "single rest log count"); @@ -622,7 +622,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); + ExpectedHook[] memory expectedHooks = new ExpectedHook[](5); AppliedLeg memory applied = _applyLeg(params, actorIndex, rests, 0, expectedHooks, 0, integratorBps); assertEq(applied.restCursor, restCount, "integrator single rest log count"); @@ -648,7 +648,7 @@ contract DeepstateV1MultiPoolHandler is Test { try ENGINE.fillRoute(fills) { Vm.Log[] memory entries = vm.getRecordedLogs(); (RestLog[] memory rests, uint256 restCount) = _restLogs(entries, actors[actorIndex]); - ExpectedHook[] memory expectedHooks = new ExpectedHook[](fills.length * 3); + ExpectedHook[] memory expectedHooks = new ExpectedHook[](fills.length * 5); int256[3] memory deltas; uint256 restCursor; uint256 hookCount; @@ -687,7 +687,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[](fills.length * 3); + ExpectedHook[] memory expectedHooks = new ExpectedHook[](fills.length * 5); int256[3] memory deltas; uint256 restCursor; uint256 hookCount; @@ -757,6 +757,20 @@ contract DeepstateV1MultiPoolHandler is Test { hookCount = _appendHookIfNeeded( expectedHooks, hookCount, matched.poolIndex, rested.bookId, params.isBid, beforeRest, afterRest ); + + (address lower, address upper,,) = _pair(matched.poolIndex); + bytes32 pid = ENGINE.poolId(lower, upper); + if ( + rested.bookId == matched.routedBook && ENGINE.nextNonce(lower, upper, params.epoch) == 1 + && ENGINE.poolEpoch(pid) == params.epoch + 1 + ) { + hookCount = _appendBookClosureHook( + expectedHooks, hookCount, matched.poolIndex, rested.bookId, true, _top(rested.bookId, true) + ); + hookCount = _appendBookClosureHook( + expectedHooks, hookCount, matched.poolIndex, rested.bookId, false, _top(rested.bookId, false) + ); + } } (applied.amount0, applied.amount1) = _applyLegAmounts(params, matched, integratorBps); @@ -972,24 +986,47 @@ contract DeepstateV1MultiPoolHandler is Test { if (bookHookMasks[id] & sideFlag == 0 || poolHookKinds[poolIndex] != 1) return hookCount; (address lower, address upper,,) = _pair(poolIndex); - uint160 outgoingAmount; - if (beforeTop.exists) { - uint256 soldAmount = - isBid ? _quoteValue(_tick(beforeTop.order), beforeTop.quantity, true) : beforeTop.quantity; - // The preceding comparison proves the sold amount fits in 160 bits. - // forge-lint: disable-next-line(unsafe-typecast) - outgoingAmount = soldAmount > type(uint160).max ? type(uint160).max : uint160(soldAmount); - } expectedHooks[hookCount] = ExpectedHook({ poolId: ENGINE.poolId(lower, upper), bookId: id, token: isBid ? upper : lower, - outgoingAmount: outgoingAmount, + outgoingAmount: _topHookAmount(beforeTop, isBid), incomingNonce: afterTop.exists ? afterTop.nonce : 0 }); return hookCount + 1; } + function _appendBookClosureHook( + ExpectedHook[] memory expectedHooks, + uint256 hookCount, + uint8 poolIndex, + bytes32 id, + bool isBid, + TopOrder memory top + ) private view returns (uint256) { + uint8 sideFlag = isBid ? 1 : 2; + if (bookHookMasks[id] & sideFlag == 0 || poolHookKinds[poolIndex] != 1) return hookCount; + + (address lower, address upper,,) = _pair(poolIndex); + expectedHooks[hookCount] = ExpectedHook({ + poolId: ENGINE.poolId(lower, upper), + bookId: id, + token: isBid ? upper : lower, + outgoingAmount: _topHookAmount(top, isBid), + incomingNonce: 0 + }); + return hookCount + 1; + } + + function _topHookAmount(TopOrder memory top, bool isBid) private pure returns (uint160 outgoingAmount) { + if (!top.exists) return 0; + + uint256 soldAmount = isBid ? _quoteValue(_tick(top.order), top.quantity, true) : top.quantity; + // The preceding comparison proves the sold amount fits in 160 bits. + // forge-lint: disable-next-line(unsafe-typecast) + return soldAmount > type(uint160).max ? type(uint160).max : uint160(soldAmount); + } + function _assertHookCalls(uint256 start, ExpectedHook[] memory expectedHooks, uint256 expectedCount) private { uint256 end = RECORDING_HOOK.callCount(); assertEq(end - start, expectedCount, "hook call count");