fix(tbtc): reservation review remediation (37 findings from multi-agent review of #4282) - #4283
Merged
Merged
Conversation
…lection findDeposits selected any revealed deposit for sweeping regardless of whether it targeted a reservation vault, letting the deposit sweep task consume deposits that ProposeDepositsSweep's on-chain validation would reject anyway, silently starving reservation acceptance of the deposits it needs. Skip deposits for which chain.IsReservedDeposit returns true.
…llet reservationAnchorAction's event look-back subtracted reservationLookBackBlocks from startBlock unguarded (both uint64), wrapping to ~2^64 whenever startBlock < 216000 and breaking the anchor executor's own event lookup on any chain younger than that - every other call site in the diff already guards this subtraction. reservationAnchorAction.execute() also never verified action.TargetWalletPublicKeyHash against the actual signing wallet before building and signing an irreversible Bitcoin spend, unlike the sibling re-anchor executor which already performs this check. Added the same guard, plus direct shape-assertion tests for the assembled anchor/re-anchor transactions (inputs, outputs, fee-subtracted value, locking script) that were claimed done but missing.
ReservationAnchorProposal/ReservationReanchorProposal's Marshal panicked on a nil *big.Int field (3 fields across the two types), and the two Unmarshal implementations disagreed on zero-hash rejection: re-anchor rejected an all-zero TargetWalletPublicKeyHash but anchor accepted an all-zero DepositFundingTxHash. Both proposal types were also the only wire-format methods in the file missing the doc-comment convention every sibling type follows.
ReservationsActivationBlock was a single compile-time constant (26,500,000) with no per-network override, unlike DepositSweepEveryWindowActivationBlock's graceful testnet degrade - this gate's degrade path was 'feature entirely absent' on every lower-tip chain for years. Threaded ethereumNetwork through node.NewNode (mirroring the existing groupParameters/chain plumbing) down to the activation-block lookup, and copied DepositSweepEveryWindowActivationBlock's upgrade-precondition warning onto this gate's comment: a genuinely-old binary's coordination messages fail to unmarshal, causing followers to spuriously fault an honest, upgraded leader for the entire rollout window. Also wires the reservation metrics recorder into the proposal generator alongside the existing redemption metrics wiring, so cached coordination executors created before metrics are set still pick up the reservation gauges (see pkg/clientinfo saturation gauges commit).
The maintainer already warned when its reservation flag was on but the client's was off; the client emitted no mirrored warning for the reverse case, where the client originates reservation actions but the maintainer never proves them on-chain, guaranteeing action timeouts. Emit the paired warning naming the maintainer flag it depends on.
Free-slot and occupancy monitors were named as B-specific operational duties and leading indicators of the reservation saturation cliff, but only per-action execution counters existed - nobody could see reservation capacity approaching its cap before acceptances silently stopped. Registers four gauges (active_reservations_count, max_active_reservations, live_wallets_count, wallet_reservations_count) gated on the same reservationsEnabled flag as the existing wallet action counters, sourced from chain calls the acceptance/re-anchor tasks already make. Wires a metrics recorder into both tasks via the proposal generator, mirroring the existing redemption metrics wiring.
…ut memoization The reservation proof loop's retry eviction (after 3 failed GetReservationAction passes) deleted the pending event without rewinding the scan cursor, which had already advanced past the event's block - a transient RPC outage permanently stranded an already-confirmed Bitcoin anchor/re-anchor transaction's SPV proof. Eviction now rewinds the cursor behind the lost event's block so the next pass re-fetches and rediscovers it, applied symmetrically to both the acceptance and re-anchor paths. The stale-deposit watcher's deriveTimeoutFromReveal re-ran a full 216,000-block eth_getLogs scan every poll tick for every deposit with no requested action yet, with no memoization of the derived, immutable timeout. Added a per-deposit-key memoization map so subsequent ticks skip straight to the timeout comparison. Also completes the reanchor-proof submission counters (2 of 10 return paths in submitReservationActionProof were still uncounted) and adds TestIsReservedDeposit_PointerIdentity, pinning the fix for this PR's own historical pointer-identity map-key bug. Several P2/P3 findings in this cluster (M-7 nonce-aware timeout check, WalletMembersResolver architecture, dead-code cluster removal, tautological guard removal, unused alias, startStaleDepositPoll testability) remain deferred - see spv-cluster-followups.md. A first attempt combining these with the structural M-16 redesign broke 3 existing tests and was reverted; only the independently-safe P1 subset above is included here.
…ce reconciliation, caps Reservation acceptance: - findReservationAcceptanceCandidate ran an unbounded eth_getLogs scan from genesis per candidate deposit per coordination window - errors on RPC providers that cap eth_getLogs ranges (silently skipping the candidate forever), and made the RequestNonce+1 retry branch dead code. Bounded the scan to ReservationAcceptanceLookBackBlocks, matching every other event scan in the diff. - Added a vault-not-configured guard against the actual zero-address hex string the chain.Address converter produces (the prior check compared against "", which the converter never returns). - RequestNonce is predicted client-side before the on-chain write and was never reconciled by re-reading the reservation afterward; a follower would hard-fail GetReservationAction forever if the Bridge assigned a different nonce. Added hasPendingAction, a re-read-and- compare guard against duplicate in-flight requests, shared by both the acceptance and re-anchor paths. - maxActiveReservations==0 (the global circuit-breaker cap) was treated as unlimited instead of failing closed; added boundary tests at the exact ==cap/==cap+1 edge for all three amount-based caps. - Fixed the typed-nil interface return on the below-minimum path. Reservation re-anchor (M-27, resolved via tbtc-v2 source): the below-dust trigger requested a re-anchor from the client's ordinary operator key even though tbtc-v2's on-chain requestReservationReanchor caps that path to privileged callers, making the trigger dead code as shipped. Removed the privileged-caller trigger; wired NotifyMovingFundsBelowDust (permissionless, already present in the Bridge ABI but never called from this client) so a wallet whose reservations have already fully drained closes once its last reservation's own re-anchor completes and its main UTXO computes below the moving-funds dust threshold. Added a pre-check (AssembleReservationReanchorTransaction, build-and-discard) before requesting re-anchor, mirroring the acceptance path's existing pre-check. Moved hasPendingAction to reservation_acceptance.go, its only real caller, and corrected its doc comment. Extends the shared Chain interface (NotifyMovingFundsBelowDust) and its LocalChain/TbtcChain implementations to support the above.
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Team Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
piotr-roslaniec
force-pushed
the
m1/reservation-review-fixes
branch
from
September 3, 2026 14:05
b1d4fde to
ff1eba7
Compare
…vation-review-fixes # Conflicts: # pkg/tbtc/coordination.go # pkg/tbtc/coordination_test.go # pkg/tbtc/marshaling.go
…BroadcastChannel NewTimeTicker's piping goroutine selects between an already-elapsed timerTick.C and ctx.Done(); when ReleaseBroadcastChannel's cancel() races an elapsed tick, Go's pseudo-random select can let exactly one straggler tick (a harmless retransmission of an already-sent message) through before the goroutine observes cancellation. The test's strict zero-deliveries-after-release assertion made this flaky (~80% failure rate reproduced locally in isolation). Absorb the one possible straggler in a short settle window before measuring the real invariant: no continued firing once release has taken effect.
piotr-roslaniec
added a commit
that referenced
this pull request
Sep 3, 2026
…adcastChannel (#4284) Follow-up to #4283. That PR fixed `TestReleaseBroadcastChannel`'s flake (reproduced pre-existing on clean `origin/reservations-epic` at the time, ~2/5 failure rate in isolation - #4279's bug, not introduced by #4283's merge) by absorbing the one straggler tick `NewTimeTicker`'s cancel-vs-elapsed-timer race can let through after `ReleaseBroadcastChannel`. That fix's settle-window drain discarded its count unchecked, so a genuine regression where the ticker fires more than once after release would only surface at the second, stricter assertion - not at the settle step itself, where the failure is easier to diagnose. This bounds the settle window: at most one straggler, asserted explicitly. Verified 20/20 locally (`go test ./pkg/net/local/... -run TestReleaseBroadcastChannel -count=1`, repeated); full `go build`/`go vet`/`gofmt -l` clean.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Remediation for the 37 confirmed findings from a multi-agent review of PR #4282
(
dev<-reservations-epic, i.e. the accumulated content of #4274+#4276+#4277).37 raised -> 37 confirmed -> 0 dropped after arbitration and validation.
exclusion, reservation look-back underflow + target-wallet check, reservation
acceptance
eth_getLogsbounds + nonce reconciliation + caps, SPV proof-loopretry-eviction data loss (symptom fixed, structural root cause deferred - see
below), stale-deposit timeout memoization, below-dust re-anchor trigger removal
(M-27, resolved via tbtc-v2 source after user escalation).
Full-repo
go build,go vet, andgo test ./...all pass with these fixesapplied (verified after every commit and once more at closeout).
Deferred (1 P1 architectural root-cause + 7 P2/P3 symptoms/hygiene)
An arbiter-recommended structural fix for M-16 (remove the SPV proof loop's
persistent-cursor design entirely in favor of the stateless bounded-rescan
pattern every sibling proof type already uses) was attempted together with
the M-7 nonce-aware timeout fix and an M-14 dead-code removal. That combined
change broke three existing tests and was reverted rather than debugged under
time pressure. Only a narrower, independently-safe subset landed: a surgical
patch for M-3 (non-lossy cursor rewind) plus unrelated memoization/metrics/test
fixes. M-16's own P1 rating is only partially addressed - the persistent-cursor
design itself, and the M-7/M-14 symptoms it also breeds, remain unremoved.
pkg/maintainer/spv/reservation_proof_loop.go:227-246-reservationProofScanState's persistent cursor is the structural rootcause of M-3 (fixed surgically) and M-7 (below). Removing it in favor of
the stateless bounded-rescan pattern is what broke 3 tests on first
attempt and remains unimplemented.
reservation_action_timeout_watch.go:260-281-CheckReservationActionTimeoutsdeletespendingActionsentries on 3 of4 non-notifying outcomes without asserting the tracked
requestNonceagainst the freshly-derived one; same root cause as M-3.
reservation_action_timeout_watch.go:370+reservation_wiring.go:38-49-the timeout watcher's
WalletMembersResolveronly resolves wallets thelocal operator co-signs; an offline/disabled/colluding wallet's own
operators get zero independent timeout coverage.
reservation_proof_loop.go/reservation_proof_loop_test.go-findReservationAcceptanceTransaction,findReservationReanchorTransaction, and their wrapper helpers have zeroproduction callers; 14 tests exercise the unused wrapper instead of the
isMatching*predicates actually called in production.reservation_proof_loop.go:612,~817- two tautological guards arealgebraically always-false, masking that the real enforced constraint is
only
0 < fee <= TxMaxFee.reservation_wiring.go:237-320startStaleDepositPoll- theentire loop body runs untested inside a goroutine; existing tests assert
only that the goroutine starts.
reservation_action_timeout_watch.go:18-20- unused"backward-compatibility alias" constant, zero references.
reservation_proof_loop.go:644- duplicated, truncated commentfragment left by a merge.
Known conflicts with other open PRs in this stack - read before merging
This branched from
reservations-epicatbb3dcb398. Three other efforts arein flight against overlapping code and were not reconciled here, since they
belong to PRs this one doesn't own:
1.
pkg/tbtc/coordination.govs #4278 (hard conflict, not cosmetic)#4278 ("remove frequency gate on reservation checklist actions") drops
&& windowIndex%frequencyWindows == 0from the reservation-actions checklistgate (custody-critical, should run every window like
ActionRedemption) butits diff still references the old single
ReservationsActivationBlockconstant. This PR's
602d0ef11independently rewrote that sameifintoreservationsActivationBlock(ce.ethereumNetwork), a per-network table lookup(
ethereum.Mainnet: 26500000, everything else defaults to 0).A conflict resolution that naively favors this PR's side of that hunk
silently reinstates the frequency gate #4278 deliberately removed. Combined
resolution (verified against both intents):
2.
pkg/tbtc/marshaling.govs #4278 (duplicate, this PR's version wins)#4278 independently adds the same 4 missing
Marshal/Unmarshaldoc commentsthis PR's
7cbb8cc2fadds, but comment-only and with a capitalization bug(lowercases the exported type name, e.g.
"...converts the reservationAnchorProposal..."). This PR's version is a superset: correctlycapitalized comments plus the actual nil-guard/zero-hash-rejection logic
#4278 doesn't have. On merge, take this PR's 4 lines, drop #4278's.
3.
pkg/tbtcpg/reservation_acceptance_test.govs #4280 (whole-file conflict + one real design decision)#4280 ("M2 test-coverage backfill") independently rewrote large parts of the
same shared test harness this PR's
726f05ed7touched - the samereservationAcceptanceLocalChaintype, constructor, and ~14 shared methods,plus
scenarioReservationAcceptanceChain/registerReservedDeposits/expectedAnchorsEqual. This is a heavy line-level conflict across the wholefile, not just redundant test names. Specifics:
TestReservationAcceptanceTask_AmountCapBoundaries(this PR, cap boundariesonly) is a strict subset of test(reservations): M2 test-coverage backfill (7 of 8 items) #4280's
TestReservationAcceptanceTask_BoundaryChecks(adds
MaxReservationsPerWallet, net-of-feeReservationMinAmount,ActiveReservationsCount). Left in place rather than deleted preemptively -test(reservations): M2 test-coverage backfill (7 of 8 items) #4280 is still open and two-deep-stacked (on fix(tbtc): remove frequency gate on reservation checklist actions #4278, also open) and could
stall or be reworked; delete this PR's version only in the merge that
actually lands test(reservations): M2 test-coverage backfill (7 of 8 items) #4280.
TestReservationAcceptanceTask_VaultNotConfigured_ZeroAddress(finding: dead vault-not-configured guard) has no equivalent on test(reservations): M2 test-coverage backfill (7 of 8 items) #4280's
side - a "just take test(reservations): M2 test-coverage backfill (7 of 8 items) #4280's file" resolution silently drops it.
TestReservationAcceptanceTask_Stateless_PastEventsErrorexercisesPastReservationAcceptanceRequestedEventsreturning an error and assertsfail-closed skip-on-error. This PR's
hasPendingAction(from726f05ed7)no longer calls
PastReservationAcceptanceRequestedEventsat all - it usesa different, generation-scoped pending-action check instead. Ported onto
this PR's code as-is, that test would either pass vacuously or fail for an
unrelated reason. This PR intentionally left
PastReservationAcceptanceRequestedEventson the
tbtcpg.Chaininterface (chain.go:263) and the test double'sacceptanceEvents/acceptanceEventsErrfields in place, undeleted, eventhough they now have zero production callers - removing them here would
have foreclosed reconciling test(reservations): M2 test-coverage backfill (7 of 8 items) #4280's test against whichever pending-action
mechanism is ultimately kept. Whoever merges this PR and test(reservations): M2 test-coverage backfill (7 of 8 items) #4280 needs to
pick one mechanism and either delete the losing side's interface method/test
or keep both if there's a reason for two independent checks.
Testing
go build ./...,go vet ./...: clean.go test ./...: full repo suite, 0 failures (verified at closeout afterevery commit landed).