Skip to content

fix(tbtc): reservation review remediation (37 findings from multi-agent review of #4282) - #4283

Merged
piotr-roslaniec merged 11 commits into
reservations-epicfrom
m1/reservation-review-fixes
Sep 3, 2026
Merged

fix(tbtc): reservation review remediation (37 findings from multi-agent review of #4282)#4283
piotr-roslaniec merged 11 commits into
reservations-epicfrom
m1/reservation-review-fixes

Conversation

@piotr-roslaniec

Copy link
Copy Markdown
Collaborator

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.

  • P1 (6 of 7 fully fixed, 1 partially fixed): deposit-sweep reservation-vault
    exclusion, reservation look-back underflow + target-wallet check, reservation
    acceptance eth_getLogs bounds + nonce reconciliation + caps, SPV proof-loop
    retry-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).
  • P2/P3 (22 of 30 fixed, 8 explicitly deferred): see "Deferred" below.

Full-repo go build, go vet, and go test ./... all pass with these fixes
applied (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.

  1. M-16 (P1) pkg/maintainer/spv/reservation_proof_loop.go:227-246 -
    reservationProofScanState's persistent cursor is the structural root
    cause 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.
  2. M-7 (P2) reservation_action_timeout_watch.go:260-281 -
    CheckReservationActionTimeouts deletes pendingActions entries on 3 of
    4 non-notifying outcomes without asserting the tracked requestNonce
    against the freshly-derived one; same root cause as M-3.
  3. P2 reservation_action_timeout_watch.go:370 + reservation_wiring.go:38-49 -
    the timeout watcher's WalletMembersResolver only resolves wallets the
    local operator co-signs; an offline/disabled/colluding wallet's own
    operators get zero independent timeout coverage.
  4. P2 dead-code cluster in reservation_proof_loop.go /
    reservation_proof_loop_test.go - findReservationAcceptanceTransaction,
    findReservationReanchorTransaction, and their wrapper helpers have zero
    production callers; 14 tests exercise the unused wrapper instead of the
    isMatching* predicates actually called in production.
  5. P2 reservation_proof_loop.go:612,~817 - two tautological guards are
    algebraically always-false, masking that the real enforced constraint is
    only 0 < fee <= TxMaxFee.
  6. P2 reservation_wiring.go:237-320 startStaleDepositPoll - the
    entire loop body runs untested inside a goroutine; existing tests assert
    only that the goroutine starts.
  7. P3 reservation_action_timeout_watch.go:18-20 - unused
    "backward-compatibility alias" constant, zero references.
  8. P3 reservation_proof_loop.go:644 - duplicated, truncated comment
    fragment left by a merge.

Known conflicts with other open PRs in this stack - read before merging

This branched from reservations-epic at bb3dcb398. Three other efforts are
in flight against overlapping code and were not reconciled here, since they
belong to PRs this one doesn't own:

1. pkg/tbtc/coordination.go vs #4278 (hard conflict, not cosmetic)

#4278 ("remove frequency gate on reservation checklist actions") drops
&& windowIndex%frequencyWindows == 0 from the reservation-actions checklist
gate (custody-critical, should run every window like ActionRedemption) but
its diff still references the old single ReservationsActivationBlock
constant. This PR's 602d0ef11 independently rewrote that same if into
reservationsActivationBlock(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):

// Reservation actions (acceptance, re-anchor) are custody-critical like
// Redemption and are checked on every coordination window once the
// activation block is reached, not frequency-gated like the
// throughput-driven DepositSweep/MovedFundsSweep/MovingFunds actions
// above: a delayed reservation acceptance or re-anchor risks the
// on-chain ReservationActionTimeout backstop firing before the wallet
// subsystem gets a chance to act. The activation block is a per-network
// table (reservationsActivationBlock), not a single global constant, but
// it is still config-independent and globally observable from chain
// height alone -- which is what keeps leader and follower checklists in
// agreement without relying on local config.
if coordinationBlock >= reservationsActivationBlock(ce.ethereumNetwork) {
    actions = append(actions, ActionReservationAnchor)
    actions = append(actions, ActionReservationReanchor)
}

2. pkg/tbtc/marshaling.go vs #4278 (duplicate, this PR's version wins)

#4278 independently adds the same 4 missing Marshal/Unmarshal doc comments
this PR's 7cbb8cc2f adds, 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: correctly
capitalized 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.go vs #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 726f05ed7 touched - the same
reservationAcceptanceLocalChain type, constructor, and ~14 shared methods,
plus scenarioReservationAcceptanceChain/registerReservedDeposits/
expectedAnchorsEqual. This is a heavy line-level conflict across the whole
file, not just redundant test names. Specifics:

Testing

  • go build ./..., go vet ./...: clean.
  • go test ./...: full repo suite, 0 failures (verified at closeout after
    every commit landed).

…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.
@coderabbitai

coderabbitai Bot commented Sep 3, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Team

Run ID: 728e0686-0081-4f3d-a9af-ecf61e448f7b

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@piotr-roslaniec
piotr-roslaniec force-pushed the m1/reservation-review-fixes branch from b1d4fde to ff1eba7 Compare September 3, 2026 14:05
…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
piotr-roslaniec merged commit 59bd97f into reservations-epic Sep 3, 2026
17 checks passed
@piotr-roslaniec
piotr-roslaniec deleted the m1/reservation-review-fixes branch September 3, 2026 15:04
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant