Repository navigation
Reservations epic -> dev tracking - #4282
Draft
piotr-roslaniec wants to merge 363 commits into
Draft
piotr-roslaniec wants to merge 363 commits into
piotr-roslaniec wants to merge 363 commits into
Conversation
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: true
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. |
piotr-roslaniec
added a commit
that referenced
this pull request
Sep 3, 2026
…nt review of #4282) (#4283) ## 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): ```go // 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: - `TestReservationAcceptanceTask_AmountCapBoundaries` (this PR, cap boundaries only) is a strict subset of #4280's `TestReservationAcceptanceTask_BoundaryChecks` (adds `MaxReservationsPerWallet`, net-of-fee `ReservationMinAmount`, `ActiveReservationsCount`). Left in place rather than deleted preemptively - #4280 is still open and two-deep-stacked (on #4278, also open) and could stall or be reworked; delete this PR's version only in the merge that actually lands #4280. - This PR's `TestReservationAcceptanceTask_VaultNotConfigured_ZeroAddress` (finding: dead vault-not-configured guard) has no equivalent on #4280's side - a "just take #4280's file" resolution silently drops it. - **Real design decision, not just a merge conflict:** #4280's `TestReservationAcceptanceTask_Stateless_PastEventsError` exercises `PastReservationAcceptanceRequestedEvents` returning an error and asserts fail-closed skip-on-error. This PR's `hasPendingAction` (from `726f05ed7`) no longer calls `PastReservationAcceptanceRequestedEvents` at all - it uses a 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 `PastReservationAcceptanceRequestedEvents` on the `tbtcpg.Chain` interface (`chain.go:263`) and the test double's `acceptanceEvents`/`acceptanceEventsErr` fields in place, undeleted, even though they now have zero production callers** - removing them here would have foreclosed reconciling #4280's test against whichever pending-action mechanism is ultimately kept. Whoever merges this PR and #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 after every commit landed).
piotr-roslaniec
added a commit
that referenced
this pull request
Sep 4, 2026
…m steps COPY ./ci-shims/tbtc-artifacts in the Dockerfile sourced a directory excluded by .gitignore and created only by client.yml's shim step; release.yml builds the identical Dockerfile with no equivalent step, so a fresh-checkout release build hard-failed on the missing COPY source. Un-ignore the directory and track a placeholder so it always exists regardless of which workflow builds the image. Also delete the client.yml steps gated on github.base_ref == 'reservations-epic': PR #4282's base is dev, so the gate never fires for this PR, and once the epic lands on dev it can never fire again. The gen/Makefile fallback rules already supply the same artifact surface and remain the only reachable mechanism.
piotr-roslaniec
added a commit
that referenced
this pull request
Sep 7, 2026
#4282 40 confirmed findings fixed (2 P0, 7 P1, 24 P2, 7 P3); 1 P2 finding (in-kind fee reserve watch) intentionally left unfixed - its only two remediations are building unscoped new functionality or editing a separate spec repo, both out of scope here. P0: - wire notifyReservationAcceptanceTimedOut end to end (vendored ABI, Go bindings, chain interfaces, ethereum adapter, watcher dispatch by ActionType) - acceptance-type timeout notifications could never succeed on-chain before this - gate deposit-sweep's reserved-deposit filter on the reservation vault address instead of calling isReservedDeposit unconditionally on every deposit pre-activation, which risked halting sweeping network-wide during the binary-upgrade-before-Bridge-upgrade window P1: - action-timeout watcher no longer requires wallet-member resolution to notify a Reanchor timeout, so a dead/closed wallet's stuck action is now permissionlessly resolvable - drop the load-failure eviction that permanently dropped a tracked action from timeout coverage after 3 RPC hiccups - remove the mainnet reservations-activation-block placeholder pending a real rollout height - correct GetReservation/GetReservationAction doc comments (absence is Unknown state, not an error) - propagate chain-read errors instead of masking them as no-op - memoize per-pass proof-invariant chain reads shared across proof-loop transactions - add the missing reservations-remaining below-dust regression subtest P2/P3: gauge visibility, nonce-bound preload validation, notifiedAt semantics, address-comparison case sensitivity, wallet-vault fee-floor check, dual-flag config validation, duplicate/stale comments and tests, and assorted simplifications. Also reverts an in-flight hard-fail config-validation check for the Tbtc/Spv reservations dual-flag pairing back to a warning: start's config categories never load the Maintainer section, so the check would have hard-failed startup for any legitimate split deployment running the SPV maintainer as a separate process. Self-verification follow-up (same review pass, iterated against external review of the fixes themselves): re-checked every "fixed" claim above against the actual diff and against the reasoning behind each fix, and made three further corrections: - deposit-sweep follower-side (ValidateDepositSweepProposal) never actually got the reservation-vault gate described above - only the leader side (deposit_sweep task) did, so a follower could still sign a sweep a leader had already correctly rejected. Implemented the matching gate on the follower path: fetch ReservationParameters with the same bounded retry as the leader (fewer attempts only makes a wrong "reservations aren't live" guess more likely, never less, so there is no safety argument for the follower retrying less than the leader), then hard-reject on any IsReservedDeposit error or a confirmed reservation for any deposit the cheap vault-match prefilter flags as a candidate - unlike the neighboring fee soft-check just below, which stays deliberately log-only because a merely underpriced sweep is not irreversible the way sweeping a reservation is. Added the regression test proving detection (TestValidateDepositSweepProposal_RejectsReservedDeposit) and its counterpart proving the fail-open path still sweeps normally when ReservationParameters is unavailable (TestValidateDepositSweepProposal_SweepsWhenReservationParametersUnavailable, mirroring the leader's existing test of the same shape). - the P1 loadFailures-eviction removal above initially grew a backoff mechanism to avoid re-logging every poll tick during a persistent failure, but the backoff suppressed the downstream timeout-notify check for the same tick it suppressed the retry - a single transient RPC error could have delayed a genuinely-overdue Bridge notification by up to 10 minutes, worse than the log spam it was meant to prevent (a real outage fails every tracked action identically regardless of backoff, so there was nothing for a per-entry backoff to usefully save). Removed it; kept the simpler P1 fix alone - retry every tick, evict only on an observed non-Pending state. - reservation_wiring.go's walletMembersResolver parameter was threaded through WireReservationWatchers but never read by anything downstream of the P1 fix that removed its only consumer; deleted the dead parameter from the function, its test, and the cmd/start.go call site (which used to feed it the WalletMembersResolver tbtc.Initialize returned), and corrected the call site's comment (the paired-flag validation is a warning, not a hard error, per the note above). Finished the resulting clean cutover: with cmd/start.go (Initialize's only caller anywhere in the repo) no longer reading it, tbtc.Initialize now returns only error, and the WalletMembersResolver interface plus node.ResolveWalletMembers (its one implementation) are deleted with it.
piotr-roslaniec
added a commit
that referenced
this pull request
Sep 7, 2026
piotr-roslaniec
added a commit
that referenced
this pull request
Sep 7, 2026
…4282 Comment-only audit and fix pass across all comments this PR added or modified: drift against current code, self-containment (drop references to other repos / markdown docs that don't exist in keep-core), redundant text, and inconsistent voice. No behavioral change - gofmt/build/vet clean, diff confirmed comment-only against d1697f5.
piotr-roslaniec
force-pushed
the
reservations-epic
branch
from
September 8, 2026 14:39
ad0014d to
53cac0a
Compare
piotr-roslaniec
added a commit
that referenced
this pull request
Sep 8, 2026
…m steps COPY ./ci-shims/tbtc-artifacts in the Dockerfile sourced a directory excluded by .gitignore and created only by client.yml's shim step; release.yml builds the identical Dockerfile with no equivalent step, so a fresh-checkout release build hard-failed on the missing COPY source. Un-ignore the directory and track a placeholder so it always exists regardless of which workflow builds the image. Also delete the client.yml steps gated on github.base_ref == 'reservations-epic': PR #4282's base is dev, so the gate never fires for this PR, and once the epic lands on dev it can never fire again. The gen/Makefile fallback rules already supply the same artifact surface and remain the only reachable mechanism.
piotr-roslaniec
added a commit
that referenced
this pull request
Sep 8, 2026
#4282 40 confirmed findings fixed (2 P0, 7 P1, 24 P2, 7 P3); 1 P2 finding (in-kind fee reserve watch) intentionally left unfixed - its only two remediations are building unscoped new functionality or editing a separate spec repo, both out of scope here. P0: - wire notifyReservationAcceptanceTimedOut end to end (vendored ABI, Go bindings, chain interfaces, ethereum adapter, watcher dispatch by ActionType) - acceptance-type timeout notifications could never succeed on-chain before this - gate deposit-sweep's reserved-deposit filter on the reservation vault address instead of calling isReservedDeposit unconditionally on every deposit pre-activation, which risked halting sweeping network-wide during the binary-upgrade-before-Bridge-upgrade window P1: - action-timeout watcher no longer requires wallet-member resolution to notify a Reanchor timeout, so a dead/closed wallet's stuck action is now permissionlessly resolvable - drop the load-failure eviction that permanently dropped a tracked action from timeout coverage after 3 RPC hiccups - remove the mainnet reservations-activation-block placeholder pending a real rollout height - correct GetReservation/GetReservationAction doc comments (absence is Unknown state, not an error) - propagate chain-read errors instead of masking them as no-op - memoize per-pass proof-invariant chain reads shared across proof-loop transactions - add the missing reservations-remaining below-dust regression subtest P2/P3: gauge visibility, nonce-bound preload validation, notifiedAt semantics, address-comparison case sensitivity, wallet-vault fee-floor check, dual-flag config validation, duplicate/stale comments and tests, and assorted simplifications. Also reverts an in-flight hard-fail config-validation check for the Tbtc/Spv reservations dual-flag pairing back to a warning: start's config categories never load the Maintainer section, so the check would have hard-failed startup for any legitimate split deployment running the SPV maintainer as a separate process. Self-verification follow-up (same review pass, iterated against external review of the fixes themselves): re-checked every "fixed" claim above against the actual diff and against the reasoning behind each fix, and made three further corrections: - deposit-sweep follower-side (ValidateDepositSweepProposal) never actually got the reservation-vault gate described above - only the leader side (deposit_sweep task) did, so a follower could still sign a sweep a leader had already correctly rejected. Implemented the matching gate on the follower path: fetch ReservationParameters with the same bounded retry as the leader (fewer attempts only makes a wrong "reservations aren't live" guess more likely, never less, so there is no safety argument for the follower retrying less than the leader), then hard-reject on any IsReservedDeposit error or a confirmed reservation for any deposit the cheap vault-match prefilter flags as a candidate - unlike the neighboring fee soft-check just below, which stays deliberately log-only because a merely underpriced sweep is not irreversible the way sweeping a reservation is. Added the regression test proving detection (TestValidateDepositSweepProposal_RejectsReservedDeposit) and its counterpart proving the fail-open path still sweeps normally when ReservationParameters is unavailable (TestValidateDepositSweepProposal_SweepsWhenReservationParametersUnavailable, mirroring the leader's existing test of the same shape). - the P1 loadFailures-eviction removal above initially grew a backoff mechanism to avoid re-logging every poll tick during a persistent failure, but the backoff suppressed the downstream timeout-notify check for the same tick it suppressed the retry - a single transient RPC error could have delayed a genuinely-overdue Bridge notification by up to 10 minutes, worse than the log spam it was meant to prevent (a real outage fails every tracked action identically regardless of backoff, so there was nothing for a per-entry backoff to usefully save). Removed it; kept the simpler P1 fix alone - retry every tick, evict only on an observed non-Pending state. - reservation_wiring.go's walletMembersResolver parameter was threaded through WireReservationWatchers but never read by anything downstream of the P1 fix that removed its only consumer; deleted the dead parameter from the function, its test, and the cmd/start.go call site (which used to feed it the WalletMembersResolver tbtc.Initialize returned), and corrected the call site's comment (the paired-flag validation is a warning, not a hard error, per the note above). Finished the resulting clean cutover: with cmd/start.go (Initialize's only caller anywhere in the repo) no longer reading it, tbtc.Initialize now returns only error, and the WalletMembersResolver interface plus node.ResolveWalletMembers (its one implementation) are deleted with it.
piotr-roslaniec
added a commit
that referenced
this pull request
Sep 8, 2026
…4282 Comment-only audit and fix pass across all comments this PR added or modified: drift against current code, self-containment (drop references to other repos / markdown docs that don't exist in keep-core), redundant text, and inconsistent voice. No behavioral change - gofmt/build/vet clean, diff confirmed comment-only against d1697f5.
piotr-roslaniec
force-pushed
the
reservations-epic
branch
from
September 8, 2026 15:03
53cac0a to
9c0a612
Compare
piotr-roslaniec
added a commit
that referenced
this pull request
Sep 8, 2026
…m steps COPY ./ci-shims/tbtc-artifacts in the Dockerfile sourced a directory excluded by .gitignore and created only by client.yml's shim step; release.yml builds the identical Dockerfile with no equivalent step, so a fresh-checkout release build hard-failed on the missing COPY source. Un-ignore the directory and track a placeholder so it always exists regardless of which workflow builds the image. Also delete the client.yml steps gated on github.base_ref == 'reservations-epic': PR #4282's base is dev, so the gate never fires for this PR, and once the epic lands on dev it can never fire again. The gen/Makefile fallback rules already supply the same artifact surface and remain the only reachable mechanism.
piotr-roslaniec
added a commit
that referenced
this pull request
Sep 8, 2026
#4282 40 confirmed findings fixed (2 P0, 7 P1, 24 P2, 7 P3); 1 P2 finding (in-kind fee reserve watch) intentionally left unfixed - its only two remediations are building unscoped new functionality or editing a separate spec repo, both out of scope here. P0: - wire notifyReservationAcceptanceTimedOut end to end (vendored ABI, Go bindings, chain interfaces, ethereum adapter, watcher dispatch by ActionType) - acceptance-type timeout notifications could never succeed on-chain before this - gate deposit-sweep's reserved-deposit filter on the reservation vault address instead of calling isReservedDeposit unconditionally on every deposit pre-activation, which risked halting sweeping network-wide during the binary-upgrade-before-Bridge-upgrade window P1: - action-timeout watcher no longer requires wallet-member resolution to notify a Reanchor timeout, so a dead/closed wallet's stuck action is now permissionlessly resolvable - drop the load-failure eviction that permanently dropped a tracked action from timeout coverage after 3 RPC hiccups - remove the mainnet reservations-activation-block placeholder pending a real rollout height - correct GetReservation/GetReservationAction doc comments (absence is Unknown state, not an error) - propagate chain-read errors instead of masking them as no-op - memoize per-pass proof-invariant chain reads shared across proof-loop transactions - add the missing reservations-remaining below-dust regression subtest P2/P3: gauge visibility, nonce-bound preload validation, notifiedAt semantics, address-comparison case sensitivity, wallet-vault fee-floor check, dual-flag config validation, duplicate/stale comments and tests, and assorted simplifications. Also reverts an in-flight hard-fail config-validation check for the Tbtc/Spv reservations dual-flag pairing back to a warning: start's config categories never load the Maintainer section, so the check would have hard-failed startup for any legitimate split deployment running the SPV maintainer as a separate process. Self-verification follow-up (same review pass, iterated against external review of the fixes themselves): re-checked every "fixed" claim above against the actual diff and against the reasoning behind each fix, and made three further corrections: - deposit-sweep follower-side (ValidateDepositSweepProposal) never actually got the reservation-vault gate described above - only the leader side (deposit_sweep task) did, so a follower could still sign a sweep a leader had already correctly rejected. Implemented the matching gate on the follower path: fetch ReservationParameters with the same bounded retry as the leader (fewer attempts only makes a wrong "reservations aren't live" guess more likely, never less, so there is no safety argument for the follower retrying less than the leader), then hard-reject on any IsReservedDeposit error or a confirmed reservation for any deposit the cheap vault-match prefilter flags as a candidate - unlike the neighboring fee soft-check just below, which stays deliberately log-only because a merely underpriced sweep is not irreversible the way sweeping a reservation is. Added the regression test proving detection (TestValidateDepositSweepProposal_RejectsReservedDeposit) and its counterpart proving the fail-open path still sweeps normally when ReservationParameters is unavailable (TestValidateDepositSweepProposal_SweepsWhenReservationParametersUnavailable, mirroring the leader's existing test of the same shape). - the P1 loadFailures-eviction removal above initially grew a backoff mechanism to avoid re-logging every poll tick during a persistent failure, but the backoff suppressed the downstream timeout-notify check for the same tick it suppressed the retry - a single transient RPC error could have delayed a genuinely-overdue Bridge notification by up to 10 minutes, worse than the log spam it was meant to prevent (a real outage fails every tracked action identically regardless of backoff, so there was nothing for a per-entry backoff to usefully save). Removed it; kept the simpler P1 fix alone - retry every tick, evict only on an observed non-Pending state. - reservation_wiring.go's walletMembersResolver parameter was threaded through WireReservationWatchers but never read by anything downstream of the P1 fix that removed its only consumer; deleted the dead parameter from the function, its test, and the cmd/start.go call site (which used to feed it the WalletMembersResolver tbtc.Initialize returned), and corrected the call site's comment (the paired-flag validation is a warning, not a hard error, per the note above). Finished the resulting clean cutover: with cmd/start.go (Initialize's only caller anywhere in the repo) no longer reading it, tbtc.Initialize now returns only error, and the WalletMembersResolver interface plus node.ResolveWalletMembers (its one implementation) are deleted with it.
piotr-roslaniec
added a commit
that referenced
this pull request
Sep 8, 2026
…4282 Comment-only audit and fix pass across all comments this PR added or modified: drift against current code, self-containment (drop references to other repos / markdown docs that don't exist in keep-core), redundant text, and inconsistent voice. No behavioral change - gofmt/build/vet clean, diff confirmed comment-only against d1697f5.
piotr-roslaniec
force-pushed
the
reservations-epic
branch
from
September 8, 2026 15:40
9c0a612 to
4390a79
Compare
piotr-roslaniec
added a commit
that referenced
this pull request
Sep 8, 2026
…m steps COPY ./ci-shims/tbtc-artifacts in the Dockerfile sourced a directory excluded by .gitignore and created only by client.yml's shim step; release.yml builds the identical Dockerfile with no equivalent step, so a fresh-checkout release build hard-failed on the missing COPY source. Un-ignore the directory and track a placeholder so it always exists regardless of which workflow builds the image. Also delete the client.yml steps gated on github.base_ref == 'reservations-epic': PR #4282's base is dev, so the gate never fires for this PR, and once the epic lands on dev it can never fire again. The gen/Makefile fallback rules already supply the same artifact surface and remain the only reachable mechanism.
piotr-roslaniec
added a commit
that referenced
this pull request
Sep 8, 2026
#4282 40 confirmed findings fixed (2 P0, 7 P1, 24 P2, 7 P3); 1 P2 finding (in-kind fee reserve watch) intentionally left unfixed - its only two remediations are building unscoped new functionality or editing a separate spec repo, both out of scope here. P0: - wire notifyReservationAcceptanceTimedOut end to end (vendored ABI, Go bindings, chain interfaces, ethereum adapter, watcher dispatch by ActionType) - acceptance-type timeout notifications could never succeed on-chain before this - gate deposit-sweep's reserved-deposit filter on the reservation vault address instead of calling isReservedDeposit unconditionally on every deposit pre-activation, which risked halting sweeping network-wide during the binary-upgrade-before-Bridge-upgrade window P1: - action-timeout watcher no longer requires wallet-member resolution to notify a Reanchor timeout, so a dead/closed wallet's stuck action is now permissionlessly resolvable - drop the load-failure eviction that permanently dropped a tracked action from timeout coverage after 3 RPC hiccups - remove the mainnet reservations-activation-block placeholder pending a real rollout height - correct GetReservation/GetReservationAction doc comments (absence is Unknown state, not an error) - propagate chain-read errors instead of masking them as no-op - memoize per-pass proof-invariant chain reads shared across proof-loop transactions - add the missing reservations-remaining below-dust regression subtest P2/P3: gauge visibility, nonce-bound preload validation, notifiedAt semantics, address-comparison case sensitivity, wallet-vault fee-floor check, dual-flag config validation, duplicate/stale comments and tests, and assorted simplifications. Also reverts an in-flight hard-fail config-validation check for the Tbtc/Spv reservations dual-flag pairing back to a warning: start's config categories never load the Maintainer section, so the check would have hard-failed startup for any legitimate split deployment running the SPV maintainer as a separate process. Self-verification follow-up (same review pass, iterated against external review of the fixes themselves): re-checked every "fixed" claim above against the actual diff and against the reasoning behind each fix, and made three further corrections: - deposit-sweep follower-side (ValidateDepositSweepProposal) never actually got the reservation-vault gate described above - only the leader side (deposit_sweep task) did, so a follower could still sign a sweep a leader had already correctly rejected. Implemented the matching gate on the follower path: fetch ReservationParameters with the same bounded retry as the leader (fewer attempts only makes a wrong "reservations aren't live" guess more likely, never less, so there is no safety argument for the follower retrying less than the leader), then hard-reject on any IsReservedDeposit error or a confirmed reservation for any deposit the cheap vault-match prefilter flags as a candidate - unlike the neighboring fee soft-check just below, which stays deliberately log-only because a merely underpriced sweep is not irreversible the way sweeping a reservation is. Added the regression test proving detection (TestValidateDepositSweepProposal_RejectsReservedDeposit) and its counterpart proving the fail-open path still sweeps normally when ReservationParameters is unavailable (TestValidateDepositSweepProposal_SweepsWhenReservationParametersUnavailable, mirroring the leader's existing test of the same shape). - the P1 loadFailures-eviction removal above initially grew a backoff mechanism to avoid re-logging every poll tick during a persistent failure, but the backoff suppressed the downstream timeout-notify check for the same tick it suppressed the retry - a single transient RPC error could have delayed a genuinely-overdue Bridge notification by up to 10 minutes, worse than the log spam it was meant to prevent (a real outage fails every tracked action identically regardless of backoff, so there was nothing for a per-entry backoff to usefully save). Removed it; kept the simpler P1 fix alone - retry every tick, evict only on an observed non-Pending state. - reservation_wiring.go's walletMembersResolver parameter was threaded through WireReservationWatchers but never read by anything downstream of the P1 fix that removed its only consumer; deleted the dead parameter from the function, its test, and the cmd/start.go call site (which used to feed it the WalletMembersResolver tbtc.Initialize returned), and corrected the call site's comment (the paired-flag validation is a warning, not a hard error, per the note above). Finished the resulting clean cutover: with cmd/start.go (Initialize's only caller anywhere in the repo) no longer reading it, tbtc.Initialize now returns only error, and the WalletMembersResolver interface plus node.ResolveWalletMembers (its one implementation) are deleted with it.
piotr-roslaniec
added a commit
that referenced
this pull request
Sep 8, 2026
…4282 Comment-only audit and fix pass across all comments this PR added or modified: drift against current code, self-containment (drop references to other repos / markdown docs that don't exist in keep-core), redundant text, and inconsistent voice. No behavioral change - gofmt/build/vet clean, diff confirmed comment-only against d1697f5.
…nding-tx fetch depositSweepAction.execute() derived reservationsActive from proposalProcessingStartBlock, a strictly later block than the coordinationBlock the leader uses in executeLeaderRoutine, breaking the invariant that both sides evaluate the activation predicate at the same height. The action now carries and uses the real coordinationBlock, threaded from processCoordinationResult through handleDepositSweepProposal rather than reversed from a fixed offset (the reversal was an unguarded uint64 subtraction that could underflow). The reservation-acceptance funding-tx worker pool eagerly fetched every phase-one candidate's Bitcoin transaction body/confirmations before the oldest-first eligibility loop could pick one and stop, turning ~2 RPCs into up to the full backlog size. It now schedules candidates lazily, oldest-first, and stops dispatching once an eligible candidate is found, with new coverage for the concurrency cap, per-candidate timeout, and ordering under out-of-order completion.
Marshal was tightened to reject non-positive big.Int values on AnchorTxFee, ReservationKey, and ReanchorTxFee, but Unmarshal did not enforce the same invariant on receipt, so a hand-crafted protobuf carrying a non-empty zero-value byte encoding for any of the three fields passed unchanged. The vendored-fallback-verify CI job's rationale comment claimed to diff against a "real, currently-deployed" contract ABI when it actually diffs against the ABI compiled from the pinned upstream tbtc-v2 ref; also removed a dangling reference to prior-review finding IDs whose source artifacts don't exist in this checkout.
…nderflow now is a caller-supplied, not-guaranteed-monotonic tick token. If now ever precedes a deposit's recorded notifiedAt (clock adjustment, or a non-monotonic tick), now-notifiedAt underflows to approximately 2^32 and the backoff check fails open, resubmitting NotifyStaleReservedDeposit immediately instead of waiting out actionTimeoutRenotifyInterval. now < notifiedAt is now treated the same as "interval not yet elapsed".
…t wiring docs - misconfiguration self-check now logs at Errorf instead of Fatalf/os.Exit, so a correctly-configured node on a quiet network no longer self-terminates - initial watcher-scan goroutines publish a scanResult even when their pass panics, so a panic can no longer permanently block the self-check goroutine on resultCh and leak it - corrected wiring doc comments describing the self-check's actual behavior and the deferred (not immediate) stranding recheck after an action timeout - simplified reservationOperatorStaggerOffset's internal hash reduction to plain uint64 modulo (signature unchanged) - added an integration test proving the real WireReservationWatchers wiring drains a deferred stranding recheck end to end
…flow - add the same now < notifiedAt underflow guard the stale-deposit watcher already has, preventing a backward clock step from causing a renotify storm on the action-timeout watcher - drainStrandingRechecks now re-queues a wallet on a transient GetWallet or stranding-check error instead of permanently dropping its recheck
…osits An IsReservedDeposit RPC error inside findDeposits now returns a hard error instead of silently dropping the deposit, matching pkg/tbtc/deposit_sweep.go's posture. Previously an RPC error could make a still-reserved deposit look unreserved to the moving-funds unswept-deposit check. Also corrects the reservationsActive doc comment to reference the coordination block, not the current block.
…path - reservationsActive doc now correctly describes evaluation at the coordination block, not the current/processing block - removed a stale coordinationBlock field-doc sentence contradicted by the code and its sibling comment - added a regression test for ValidateDepositSweepProposal's fail-closed behavior when reservationsActive is true and ReservationParameters errors
US-12 of the reservations freeze-day matrix: a reserved deposit's refund branch is actually SPENT, and the on-chain bookkeeping follows. Stage A - the Bitcoin script spend (pkg/bitcoin/deposit_refund_spend_test.go): - TestDepositRefundScript_MatchesSolidityRevealTemplate - TestDepositRefundBranchSpend_ValidatesOnlyAfterRefundLocktime - TestDepositWalletBranchSpend_DoesNotConsumeTheRefundPath The redeem script is assembled byte for byte as tbtc-v2's Deposit._revealDeposit does, the P2SH hash is computed over it, and the refund branch is spent through btcd's consensus-accurate script engine. The refund branch validates at and after the refund locktime, does not validate before it (OP_CHECKLOCKTIMEVERIFY), and is reachable only by the depositor's refund key. Stage B - the on-chain record and counters (pkg/maintainer/spv/reservation_refund_deadline_test.go): - TestReservedDepositRefund_StaleNotificationFollowsTheRefundDeadline - TestReservedDepositRefund_StaleNotificationIsStrictlyAfterTheDeadline - TestReservedDepositRefund_StaleNotificationUsesTheRevealedLocktime - TestReservedDepositRefund_StaleNotificationIsNotAutomatic - TestReservedDepositRefund_StaleNotificationIsPerDeposit The watcher derives the deadline from the reveal event's RefundLocktime and notifies only once the chain head has strictly passed it; the notification is a one-shot transaction, not an automatic reaction. Stage C - the acceptance spend (pkg/bitcoin/deposit_acceptance_anchor_test.go): - TestDepositAcceptanceAnchorSpend_RemovesTheFundingRefundPath The acceptance anchor and the refund spend the same funding output; once the anchor confirms, the refund spend is rejected as a double spend by btcd's consensus input check. Each stage carries a negative control.
…tion
The satoshi units vector was checked against a test-local constant, so
it proved local multiplication rather than the keep-core unit
boundary, and the post-horizon stale-deposit case could not tell a
saturating clock from a wrapping one.
Move the shared vector into a package both sides of the unit boundary
can read, and drive its pairs through the production consumers:
- pkg/chain/ethereum: the ReservationVault fee adapters return the
vault's in-kind fee debt in satoshi and its fee reserve in TBTC
base units, each unchanged;
- pkg/tbtcpg: the acceptance task's gauge publisher reports each
reading on its own scale.
Both carry a negative control that requires the comparison to reject
the mis-scaled readings a unit error would produce.
The stale-deposit watcher gains a case whose deadline sits below the
horizon by more than the maximum operator stagger, with the chain head
one second past it: the saturating clock reads the head as the horizon
and notifies, the wrapping clock reads it as zero and does not. The
existing deadline-at-the-horizon case is kept as the documented limit
of the assumption.
…spend A tracked reservation generation whose wallet has not broadcast the spending transaction yet was skipped in silence. The re-anchor proving block ran only when the anchor outpoint had a spender, and the acceptance path returned early when the wallet's history held no transaction for the reserved deposit. In both cases an operator could not tell "the wallet has not broadcast yet" from "the proof loop is dead" except by noticing the absence of a line. Log the reservation key and the outpoint each path is waiting for, at info level, and cover both branches: one test per path pins the line, and a negative control pins that a generation with a matching spend logs nothing.
The reservation tests carried comment blocks without the blank line gofmt requires between them, so `gofmt -l` was non-empty and the client format job failed. gofmt -w with the toolchain named by go.mod; no expression on any line changed.
MisScaledReadings built its factor with big.NewInt(int64(debtSat)), which gosec reports as G115: the satoshi argument is a uint64, and a value above MaxInt64 would wrap to a negative factor instead of failing. Build the factor from SetUint64, which covers the whole range and is what the comparison under test already assumes. The negative control is unchanged for every value it is given: for a debt that fits int64 both forms hold the same number.
The header comment carried a stale reading: 60 living mutants with 2 left undocumented. At 8e7e2b3 the bar keep-core.go.mutation.survivors reads 58 living mutants over the 13 reservation files (spv 37, tbtcpg 21), and the 52 entries below match all 58 - six of them pin two sites each - so the reading is 0 against the ceiling of 40 and no survivor is left as an open gap. Comment only: no entry, line pin or reason changes.
…yml (#4369) ## What this changes The header comment of `equivalent-mutants.yml` carried a stale reading of the mutation bar: 60 living mutants, 58 of them listed, 2 left undocumented. That count was copied from the comment into the PR #4368 body and never matched a measurement. This fixes the comment; no entry, line pin or reason changes. ## The measured reading `keep-core.go.mutation.survivors`, measured over the 13 `reservation*` files of `pkg/tbtcpg` and `pkg/maintainer/spv` with gremlins v0.6.0 (`--workers 4 --timeout-coefficient 10`) at `8e7e2b30f`: ``` raw living mutants 58 (pkg/maintainer/spv 37, pkg/tbtcpg 21) listed in equivalent-mutants.yml 58 bar reading 0 ceiling <= 40 verdict PASS ``` The 52 entries below match all 58 living mutants — six of them pin two sites each (`reservation_event_scan.go:117`, `reservation_keeper_observer.go:420`/`432`, `reservation_wiring.go:384`/`962` and `415`/`449`, `reservation_acceptance.go:760`/`863` and `768`/`872`) — so nothing is left undocumented and the reading is 0. The same script run against this branch's tree, with the gremlins reports from that head: ``` $ go-mutation-survivors.py --repo . --json pkg/tbtcpg=… --json pkg/maintainer/spv=… --ceiling 40 raw living mutants 58 (pkg/maintainer/spv 37, pkg/tbtcpg 21) listed in equivalent-mutants.yml 58 bar reading 0 verdict PASS ``` The two commits between `8e7e2b30f` and the merge head touch only reservation test files and `internal/testdata`, so the mutant set does not move: the reading holds at the merged head. ## Files - `equivalent-mutants.yml` — header comment only (8 insertions, 7 deletions); every entry unchanged. ## Gates | Gate | Result | | --- | --- | | Mutation bar | `go-mutation-survivors.py` over the 13 reservation files: 58 raw living, 58 listed, reading 0, ceiling `≤ 40`, PASS (exit 0). | | Leak check | `leak-check.sh` over `a9ffc9c0e3..448d153`: 1 commit, 8 added lines, 1 path, 0 patch-id collisions, 0 text hits, 0 private repository names, 0 sha hits. PASS, exit 0. | | CI | `Client` and the other workflows on this head. |
## What this changes `TbtcChain.WalletReservations` derived a wallet's custodied reservation keys from two event queries - `ReservationAcceptanceRequested` and `ReservationReanchorRequested` - each issued once over `[registrationBlock, chain tip]` with no end block. The generated binding turns that into a single `eth_getLogs` for the whole span. A wallet closed long after it registered spans millions of blocks there, and providers that cap the query range reject the request outright, which fails the call and, with it, every re-anchor pass for that wallet's reservations. The scan now walks the same range in `tbtc.DepositRevealLookupChunkBlocks` (10,000-block) chunks, ascending, merging both event types - the same shape the other reservation scans in this repo use (`pkg/maintainer/spv/reservation_event_scan.go` and the acceptance task's request scan). The range is queried up to the current block explicitly, so every chunk has an end block. A failing chunk still fails the call: the partial set is discarded rather than treated as the wallet's complete custody, and the caller retries on its next pass. `scanWalletReservationEventsInChunks` is extracted as a pure function, mirroring the rest of this file, so the chunk boundaries and the merge are tested directly: a range inside one chunk, a range spanning two chunks and a remainder, an exactly-one-chunk-wide range, an inverted range, a range ending at the uint64 maximum, and a chunk failure naming its range.
…reservation ## What this changes `ReservationReanchorTask.Run` returned from the whole pass when `GetReservation` failed for one candidate, so a transient read failure on one reservation denied every later reservation of the same wallet its hand-off in that coordination window. A source wallet cannot finish closing until all of its reservations have moved, so the coupling turned one RPC failure into stalled custody for the reservations behind it in the list. The read now logs and skips that candidate, the way the reservation-action reads beside it already do. The first error is preserved, and a pass that ends without a proposal while any reservation was unreadable returns it: that pass is incomplete, not empty, and must not be reported as the benign "nothing to do yet" outcome the coordinator treats as success. Tests: one unreadable reservation ahead of a re-anchorable one still yields the proposal; a pass with no readable reservation fails with the incomplete-read error.
…ow-dust notification ## What this changes Once a `MovingFunds` wallet's custody read came back empty, `Run` called `NotifyMovingFundsBelowDust` when its main UTXO had fallen below the moving funds dust threshold. An empty read is not proof the wallet is drained: the key set is derived from event logs filtered through per-reservation `GetReservation` lookups, and a lookup that fails is skipped rather than failing the read, so one transient RPC failure produced exactly the same empty list as a genuinely drained wallet. The notification is accepted by the Bridge while anchors remain and starts the wallet's move to `Closing` earlier than its reservations warrant. The notification now requires `WalletReservationsCount` - the contract's own custody figure for the wallet - to be zero first. A non-zero count skips it, and so does a count read that fails; the wallet stays in `MovingFunds`, so the next pass retries. The existing history gate is unchanged and still runs before the (unbounded, three-query) history scan is paid. Tests: an empty custody read beside a non-zero count, and an unavailable count, both leave the notification unsubmitted; the drained wallet still notifies exactly once (existing coverage).
…gauge ## What this changes `countReanchorsWithoutTarget` computed the anchor floor as `ReservationMinAmount + ReservationTxMaxFee` in uint64 before clamping it into the int64 range. The contract only requires `minAmount > txMaxFee > 0`, so the pair has no shared upper bound: a governance-set `minAmount` near the uint64 maximum with a small non-zero fee wraps the sum, turning an unattainable floor into a tiny one. Ordinary anchors then clear the floor check, and the gauge under-reports Active reservations that have no reachable target - the exact signal it exists to report. The addition now saturates at the uint64 maximum when it would wrap, and the existing int64 clamp then treats that floor as above any real anchor value. Detection is by comparing the sum with an operand, so a zero fee - where the sum equals the min amount - is not mistaken for a wrap. Tests: the reported pair (`MaxUint64` + 2), a sum landing exactly on the uint64 maximum, a zero fee pair, and an ordinary pair.
The "no reservations eligible for re-anchor" summary sat behind an extra `reservationsReadErr == nil` guard so an incomplete pass would not claim nothing was eligible. That guard's two arms differ only in one info line, which no assertion can see, so the condition is unpinnable by a test and shows up as a mutation survivor on a line this branch changes. The per-item failures are already logged where they happen and the incomplete-read error is returned at the end of the pass, so the summary can state what is actually true in both cases - no proposal was made in this pass - and the `waitingOnAgedTarget` branch keeps the only effect the tests can pin: the gauge value.
… the registration lookup ## What this changes The custody scan's end block came from `tc.blockCounter.CurrentBlock()`. That counter advances only on new-head subscription notifications, while `WalletReservationsCount` and every other reservation read go to the node's current state over ordinary RPC. With a stalled or disconnected head subscription the scan therefore stopped below the blocks that explain a non-zero count: the key set came back empty with no error, and the re-anchor and stranding consumers read that as a wallet with nothing left to move. The scan now takes its end block from `walletReservationScanEndBlock`, which reads the latest header from the node over RPC through the adapter's existing `LatestBlockNumber` (the same fresh-height read the RPC health probe uses), with the adapter's usual 30-second header timeout. A failed read fails the scan instead of falling back to a cached height, so the caller retries on its next pass rather than acting on a truncated answer. The registration walk uses the same tip for the same reason. The registration-block lookup, which runs before the custody scan, still issued one unbounded `eth_getLogs` per doubling window (its filter carried no end block). On a chain old enough for a widened window, a range-capping provider rejected that request before the new chunked custody scan was ever reached. Both scans now share one chunk walker, `fetchEventRangeInChunks`, which ascends in `tbtc.DepositRevealLookupChunkBlocks` chunks and lets the caller stop early: the registration walk stops at the first chunk that yields a registration, because ascending chunks put the window's earliest registration there. ## Tests `TestWalletReservationScanEndBlock` builds a real block counter over a reader whose head subscription never delivers, so the cached height stays at its startup value while the RPC client reports a later one, and pins both halves: the scan ends at the node's height, and a failing node read is an error rather than a fallback. Against the cached read the first case ends at 1000 instead of 1200 and the second returns no error at all. `TestResolveEarliestWalletRegistrationBlock` now asserts on ranges: queries ascend contiguously, none exceeds the chunk size, the walk stops at the chunk holding the registration, and it still terminates at genesis. Against a single unbounded fetch the chunk-width assertions fail (a window of 216,013 blocks in one query).
…ministically
## What this changes
The restart test's shared fixture panicked on the first
`PastReservationAcceptanceRequestedEvents` call, but the keeper observer runs
on the same interval and scans acceptance events too. Whichever goroutine
reached that scan first consumed the fault: when the observer won,
`WireReservationWatchers` returned and the test's assertions still passed
because the action-timeout watcher had never failed at all - so a build whose
restart guard returns on a live context could pass this test. That is the
timing-dependent gap behind the surviving mutation on the
action-timeout watcher's restart check.
The fault is now anchored to `ReservationCaps`, which only the
action-timeout watcher reads (through `checkReservationParamsFeasibility`),
and the test waits for a poll pass to complete that read after the panic
before checking notification work, so a later notification can only come
from the restarted watcher.
The restart suite gains the missing subtest: a zero poll interval makes that
watcher's `Run` fail on every attempt, and the test requires the watcher's own
"is not running" log to repeat, which the other watchers' messages cannot
stand in for.
## Mutation
With the restart guard negated at `reservation_wiring.go:663`
(`ctx.Err() != nil` -> `ctx.Err() == nil`), the integration test fails
("condition was not met before timeout") and the new subtest fails
("expected the action-timeout watcher to be restarted after its Run error
... logged 1 times"). Gremlins over `pkg/maintainer/spv` at this head reports
that mutant killed.
…4370) Draft. Four defects found in the reservations client during review of the reservations work, plus a review round on this branch; each change carries a regression test that fails without it. ## 1. The wallet reservation event scan was unchunked `TbtcChain.WalletReservations` issued one `eth_getLogs` per event type over `[registrationBlock, chain tip]`. Providers that cap the query range reject that, and the call fails before any of the wallet's reservations can be handed off. The scan now walks the range in 10,000-block chunks, and the registration-block lookup that runs before it - which also carried no end block and was the other half of the same exposure - is chunked the same way through one shared walker. The registration walk stops at the first chunk that yields a registration, since ascending chunks put the window's earliest there. ## 2. The scan tip came from the subscription cache The scan's end block came from the new-head subscription cache while `WalletReservationsCount` and every other reservation read go to the node's latest state over ordinary RPC. With a stalled head subscription the scan stopped below the blocks that explain a non-zero count and returned an empty key set with no error, which the re-anchor and stranding consumers treat as a drained wallet. The tip now comes from the node over RPC (the adapter's existing `LatestBlockNumber`, the same fresh-height read the RPC health probe uses) for both the custody scan and the registration walk; a failing read fails the call instead of falling back to the cached height. ## 3. One unreadable reservation aborted the whole pass `ReservationReanchorTask.Run` returned from the pass when `GetReservation` failed for one candidate, so a transient read failure on one reservation denied every reservation behind it in the list its hand-off in that window. The read now logs and skips that candidate, like the action reads beside it, and the pass still fails when it ends without a proposal while any reservation was unreadable - an incomplete pass must not be reported as the benign no-op. ## 4. An empty custody read could start a below-dust notification early `Run` notified the Bridge that a `MovingFunds` wallet had fallen below the moving funds dust threshold whenever the custody read came back empty. That read derives the key set from event logs filtered through per-reservation lookups, and a failed lookup is skipped rather than failing the read, so one transient RPC failure produced the same empty list as a drained wallet. The notification is accepted while anchors remain and starts the wallet's move to `Closing` before its reservations were released. The notification now requires `WalletReservationsCount` - the contract's own custody figure - to be zero, and defers to the next pass when that count cannot be read. ## 5. The keeper's anchor floor could wrap `countReanchorsWithoutTarget` summed `ReservationMinAmount + ReservationTxMaxFee` in uint64 before clamping into the int64 range. The contract only requires `minAmount > txMaxFee > 0`, so the pair has no shared upper bound: a large `minAmount` with a small non-zero fee wraps the sum into a tiny floor, ordinary anchors clear it, and the gauge under-reports reservations that have no reachable target. The addition saturates now. ## Tests and revert evidence Each test below was re-run against the parent commit of its fix, or against a temporarily reverted helper, and fails there: - `TestWalletReservationScanEndBlock` builds a real block counter over a reader whose head subscription never delivers, so the cached height stays at its startup value while RPC reports a later one. With the cached read behind the scan: `expected the scan to end at the node's block [1200], got [1000]`, and the failing-RPC case returns no error at all. - `TestScanWalletReservationEventsInChunks` - with one full-range query: `expected 3 calls, actual 1 ([{1000 21005}])`. - `TestResolveEarliestWalletRegistrationBlock` now asserts on ranges: with a single unbounded fetch, `query 0 spans 216013 blocks ..., more than the chunk size 10000`. - `TestReservationReanchorTask_UnreadableReservationDoesNotStarveThePass` / `...AllReservationsUnreadableFailsThePass` - with the per-item handling reverted: `unexpected error: cannot get reservation [0x1b58]: [reservation not found]`. - `TestReservationReanchorTask_EmptyCustodyReadRequiresZeroCount` - with the count gate reverted: `expected no below-dust notification while the wallet still custodies a reservation, got 1`. - `TestReservationKeeperObserver_AnchorFloorDoesNotWrap` - with the saturating add reverted: `min amount 18446744073709551615, tx max fee 2: no-target count = 0, want 1`. - The action-timeout watcher's restart fault is now anchored to `ReservationCaps`, which only that watcher reads, so the keeper observer (same interval, same acceptance scan) can no longer consume it. With the restart guard negated: the integration test fails (`condition was not met before timeout`) and the new restart subtest fails (`expected the action-timeout watcher to be restarted after its Run error ... logged 1 times`). ## Mutation bar `go-mutation-survivors.py` (ceiling 40) over the `reservation*` files of `pkg/tbtcpg` and `pkg/maintainer/spv`, from gremlins v0.6.0 reports taken at this head with the gate's own flags (`--workers 4 --timeout-coefficient 10`): ``` raw living mutants 58 (pkg/maintainer/spv 37, pkg/tbtcpg 21) listed in equivalent-mutants.yml 58 bar reading 0 ceiling <= 40 verdict PASS ``` No mutant on a line this branch changes survives, and the previously unlisted `pkg/maintainer/spv/reservation_wiring.go:663` mutant is killed (`pkg/maintainer/spv`: 703 killed / 43 lived, against 702 / 44 before the restart tests were reworked).
The pin named d1bf5bc281, a superseded head of the reservations-upgrade integration branch, so the ABI gate compiled a tbtc-v2 tree the freeze does not ship. The delta is eight merges: tbtc-v2#1178, #1179, #1180, #1181, #1184, #1185, #1186 and #1188. None of them changes the code of the four artifacts the gate stages. The transitive import closure of Bridge, WalletProposalValidator, ReservationRouter and ReservationVault is the same 28 files at both pins, and every one of them is byte-identical once the comment lines are stripped, so the compiled ABIs and the executable bytecode are unchanged and only the metadata hash can move, which verify.sh strips before it compares. BridgeGovernance does change in that range: #1179 adds requestReservationReanchor(uint256,bytes20) and pendingReservationCapsUpdate(). It is not one of the artifacts the gate stages and not one of the module's required_contracts, so the gate never reads it, and nothing in the four staged artifacts' import closure reaches it.
## Why `TBTC_V2_REF` in `.github/workflows/client.yml` still named `d1bf5bc281`, a superseded head of the tbtc-v2 `reservations-upgrade` integration branch. The `reservation-router-vendored-fallback-verify` job checks out that commit, compiles it, and compares the result against this repo's committed bindings, the vendored reservation fallback ABIs and the vendored harness validator. At `d1bf5bc281` it was compiling a tbtc-v2 tree the freeze does not ship. This bump moves the pin to `72b891eec`, the current `reservations-upgrade` head. ## What the delta is, and why the gate stays green The delta is eight merges: tbtc-v2#1178, #1179, #1180, #1181, #1184, #1185, #1186 and #1188. None of them changes the code of the four artifacts this job stages: - the transitive import closure of `Bridge`, `WalletProposalValidator`, `ReservationRouter` and `ReservationVault` is the same **28 files** at both `d1bf5bc281` and `72b891eec`; - every one of those 28 files is **byte-identical once the comment lines are stripped**, at both pins. So the compiled ABIs and the executable bytecode are unchanged, and only the metadata hash can move — which `verify.sh` strips before it compares. The changes in that range are documentation (`Reservation.sol`, `BridgeState.sol`, `IReservationBridge.sol` and `ReservationRouter.sol` carry comment-only edits), tests, deploy scripts and tooling. `BridgeGovernance` does change in that range: #1179 adds `requestReservationReanchor(uint256,bytes20)` and `pendingReservationCapsUpdate()`. It is not one of the artifacts this job stages, not one of the module's `required_contracts`, and nothing in the four staged artifacts' import closure reaches it, so the gate never reads it. The build inputs are unchanged too: `solidity/package.json`, `solidity/yarn.lock` and the Hardhat config are identical at both pins, so the compile is the same compile. ## Scope Two files, both comments and one pin value: - `.github/workflows/client.yml`: the `TBTC_V2_REF` value, and the comment block above it rewritten to state this delta instead of the previous one. - `pkg/chain/ethereum/testdata/walletproposalvalidator/regenerate.sh`: the provenance note, which names the same commit, moved with the pin. No Go code, no contract, no ABI, no generated binding changes. ## This is a draft It is opened as a draft on purpose. The code freeze is 2026-11-06 and the spec freeze 2026-11-05; this pin has to be green before it is merged, and the merge is a human decision. ## Leak check `cleanup/leak-check.sh` over `931dcd63c..4d3d197`: `RESULT: PASS`, rc 0, `hits=0`, `allowed=0`, `sha-hits=0`, `repo-name-hits=0`, 1,561 embargo patch-ids, 73 private repository names. No allowlist entry was added or changed to make it pass.
`reservation-router-vendored-fallback-verify` proves that the vendored reservation ABI fragments, the harness validator and the committed Go bindings still match the contracts compiled from `TBTC_V2_REF`. It is the only check that catches drift in that pin, but it was not in `client-gate.needs`, so its result never entered the aggregator's verdict: a red or cancelled run left the gate green. Add it to `client-gate.needs`, read its result, and require `success` on every event. It has no job-level `if:` and no path filter, so it is never legitimately skipped, and the gate now fails on a failure, a cancellation or a skip. The gate's own comment records that expectation. Also add `reservations-epic` to the push triggers: it is the integration branch for the reservations release, and a merge into it currently produced no run other than `pull_request` ones. The `paths-ignore` list is unchanged.
The deferred stranding recheck queue was a map drained in full at the head of every poll tick, ahead of the deadline-critical action-timeout batch. Each recheck costs a GetWallet, a WalletReservations scan of the wallet's whole reservation history and one GetReservation per candidate key, so a tick's cost grew with the number of wallets waiting and the timeout notices were held behind it. Wallets are re-queued while they keep asking for a later pass, so the queue only ever grew. The map becomes a FIFO with set semantics and the drain re-examines at most reservationStrandingRecheckMaxWalletsPerTick wallets per tick, oldest first, leaving the rest queued. A wallet queued again keeps its place, so a repeatedly re-queued wallet cannot starve the ones behind it, and the queue mutex is never held across a chain read.
Three paths dropped a wallet from stranding coverage on a transient failure, and none of them could recover it: the wallet-close event that brings a wallet to the stranding check is one-shot and never replayed. - A failed NotifyReservationStranded logged and continued, leaving the reservation Active under a Closed wallet with nothing left to retry the notification. It now flags the wallet for another pass. - The startup catch-up scan discarded the check's recheck flag (`checked, _, checkErr`) and only logged a failed check. The per-wallet loop becomes checkStrandingForStartupScanWallets, which returns the wallets it could not settle; WireReservationWatchers queues them on the action-timeout watcher's stranding recheck queue once it exists. - retryReservationStrandingStartupScan now takes that watcher and queues a wallet its second chance still cannot settle, so the second chance is not the last one. - The OnWalletClosed handler could not queue anything when resolving the event's wallet ID to a public key hash failed. It now queues the wallet ID, and the recheck drain resolves the ID before checking, re-queueing the ID if the resolution fails again.
Three scans that reach back to a wallet's registration — or to genesis — issued an eth_getLogs over the whole resulting range: the stranding watch's wallet-registration lookup (one query per doubled window), the adapter's WalletTerminationCause (three queries bounded only at the start), and the re-anchor task's walletHasReservationHistory (three genesis-to-tip queries). Providers cap the span of a single log query, and one rejection fails the whole call: the lookup loses the wallet's stranding check, the termination cause degrades to Unknown, and the history read answers "no history" for a wallet that has some. tbtc.ReservationEventScanMaxBlockSpan is the one configurable span (a package var defaulting to DepositRevealLookupChunkBlocks) and ReservationEventScanChunkSpan() reads it with a zero-value fallback. The adapter's fetchEventRangeInChunks takes its width from it and gains countEventRangeInChunks, which stops at the first chunk that yields an event; WalletTerminationCause now needs the node's tip and walks each of its three scans in chunks. The spv registration lookup walks each window with the new fetchPastEventsInChunksUntil, passing an explicit EndBlock per chunk. walletHasReservationHistory bounds its range by the block counter and scans it through reservationHistoryExistsInChunks.
resolveCustodiedReservationKeys skipped a reservation whose GetReservation lookup failed and returned the keys that did resolve, so a transient RPC error produced a custody set the caller could not tell apart from a complete one. Every consumer reads that set as the wallet's whole custody: the stranding watcher notifies only what it finds (a stranded anchor goes unnotified and its capacity stays pinned), the re-anchor task picks its sources from it, and the below-dust gate treats an empty set as a drained wallet. A failed lookup now fails the whole read, named with the unreadable key, and the caller retries on its next pass. Comparing the result against walletReservationsCount is not a sound alternative: the count includes the reserved capacity of pending re-anchor targets, so it is legitimately higher than the key set.
- ReservationsVaultTrusted opened with a branch for an empty ReservationVault string. ReservationParameters always formats the address through convertReservationParametersFromAbiType, so the unconfigured case arrives as the zero address and that branch is unreachable; the zero-address check is the live one and now says so. - TestStaleDepositPollTickReadsAreBoundedPerTick assigned the second tick's tracked count and never read it (SA4006), leaving the eviction path uncovered. It is now asserted. - The unused uint64Ptr, uint32Ptr and satoshiUnitsVectorFile test identifiers (found with golangci-lint -E unused) are gone. - The WalletTerminationCause doc now also names the chain tip as a read that can fail the call.
…ed-verify Run client CI on pushes to reservations-epic and make client-gate require the vendored fallback verification job.
A wallet whose stranding recheck failed was re-queued and then popped again inside the same drain, so with fewer than the per-tick cap queued one wallet consumed the whole budget: up to 25 consecutive checks of the same wallet, each with its own retry pause, while the wallets waiting behind it stayed queued. On a wallet behind a persistently failing read that is minutes of the poll tick spent on one wallet. The drain now removes its bounded batch from the queue under one lock acquisition and rechecks those targets afterwards, so a re-queued target goes to the tail of what is left and is picked up by the next tick. The cap is then a cap on distinct wallets per tick, which is what the field doc already claims.
The scans this package issues walked their ranges in chunks of a hardcoded 10,000 blocks, so lowering ReservationEventScanMaxBlockSpan for a provider with a tighter eth_getLogs cap shortened the chain handle's scans but not these: the wallet registration lookup behind the stranding path, the wallet-close path that depends on it and the reservation event cursors all kept sending default-sized queries and kept being rejected. An operator had to set the cap in two places, and only one of them was documented. reservationEventScanSpan now reads the same effective span the chain handle uses, resolved once per walk so every chunk of one walk stays under the same bound.
gofmt -l flagged both files, which is what the client-format check runs.
…her-liveness Make the reservations watcher retry transient failures, bound each drain, chunk event scans by a configurable span, and fail reads instead of returning a truncated custody set.
This branch has not been deployed
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.
Reservations epic -> dev tracking
This PR aggregates the UTXO reservations epic (
reservations-epic) and tracks its promotion todev.M1 status (2026-10-29)
Read this before the diff: the branch is feature-complete for M1 and waiting on the freeze and the external audit, not on a merge.
d1bf5bc281. There is no private review line and no patch held back for publication: the work that used to be described that way landed here on 2026-10-28, and the diff below contains it.docs/security/pr-4282-audit/(with the re-run underrerun/); it is not an external review and it is not an audit verdict.devcomes after the audit, once its findings are fixed and re-reviewed. This PR stays open and draft until then.Diff caveat
The previous public head
8139ef3c0was 175 commits ahead / 231 commits behinddev(cf9be7030), which is why the three-dot diff used to show only the epic's unique commits. The current head4ced1c8398carriesdev: it is 299 commits ahead / 0 commits behinddev(measured 2026-10-29), andgit rev-list --count 4ced1c8398..devprints 0.Landing the review line (2026-10-28)
The epic carries the reservation work that was reviewed off this branch, landed on
reservations-epicon 2026-10-28. Reservations are a feature in development, so that work is public: this branch is where it lives and where it is reviewed. The branches it came from are archived and are not a place for new reservation work.4ced1c8398test(reservations): cover four uncovered reservation review mutations (#4367). The reservation line sits on top of the previous public head8139ef3c0, 355 commits ahead of it (283 non-merge, 72 merge), and it containsdev(cf9be7030).e3bdf2590gofmt in the reservation test files,e76208212enables corepack so the tbtc-v2 compile step runs the declared yarn 4,535b9a9d0restores an integer guard the merge dropped and bounds the reservation conversions,48d94ed50skips the vendoredthird_partymodules in the gosec run,8d14cbf55repinsTBTC_V2_REFto tbtc-v24c98e6b82, andf03cc61dcre-includesscripts/check-reservation-flags.shin the build image's docker context (the test image inherits.dockerignore, which excludesscripts/) and makes the missing-script case fail loudly instead of reading as a clean exit 0.f03cc61dc(26 non-merge, 10 merge). They are review follow-ups, not new scope: reservation floors the client paths had skipped, stranding of a Live wallet's expired reservation, the Closing wallet's closing period held before stranding, the proof-loop liveness gauge, a startup warning when the network's activation order is broken, the published internal audit record, and the mutation-survivor test coverage that closes the remaining review findings.dev: 360 files, +137,455 / -776. Most of the additions are the published audit evidence underdocs/security/(128 files); the code sits mostly underpkg/maintainer(49),pkg/tbtcpg(46),pkg/tbtc(46) andpkg/chain(40).ReservationRouterbindings (import block) and one SPV proof-loop hunk. Two further stops replayed from the rerere cache, and the rest applied cleanly.go build ./...,go vetonpkg/tbtc,pkg/tbtcpg,pkg/maintainer/spvandpkg/chain/ethereum,go test ./pkg/tbtcpg/... ./pkg/maintainer/spv/...,./pkg/tbtc -run 'Reserv|Activation',./pkg/chain/ethereum -run 'Reserv|Validator'and./pkg/clientinfoall pass.TBTC_V2_REFpins tbtc-v24c98e6b82, andmake -C pkg/chain/ethereum/tbtc/gen verify-vendored-fallbackplustestdata/walletproposalvalidator/verify.shexit 0 against that build with corepack active. CI status is in the Status section.What the reservation line brings:
Cross-repo M1 review (2026-09-28)
A review of this tracker and tbtc-v2 #1116 against the M1 specification raised 60 candidates; two validation passes confirmed 48 (3 P0, 6 P1, 27 P2, 12 P3) and dropped 7. Remediation is split across:
docs/reservations-spec@3c5603fa5- reconciled M1 requirements, architecture, readiness and delivery status.Neither fix PR performs the
devreconciliation step.Spec PR: keep-core #4366
docs(spec): reservations spec v5 for the M1 spec freeze, branchcarry/reservations-spec-public-v5at506eabe560, targetingreservations-epic. It is open and in draft; it carries the spec, its approval follow-ups and its changelog. The spec freeze is 2026-11-05 and this branch's code freeze is 2026-11-06 at the earliest, so the spec PR lands before this one merges.Merge stack (#4274 chain) - COMPLETE
m1/keep-core-client->reservations-epic- feat(tbtc): wire reservation executors and watchers (merged)m1/reservation-readiness-fixes->reservations-epic- fix(spv): re-verify reservation action generation before SPV proof submission (merged)m1/reservation-protobuf-marshaling->reservations-epic- test(tbtc): add reservation proposal marshaling coverage (merged)m1/reservation-coordination-checklist->reservations-epic- fix(tbtc): remove frequency gate on reservation checklist actions (merged)m1/reservation-multisigner-integration-test->m1/reservation-coordination-checklist- test(tbtc): multi-signer simulated integration test for reservation coordination (merged into fix(tbtc): remove frequency gate on reservation checklist actions #4278's branch prior to fix(tbtc): remove frequency gate on reservation checklist actions #4278 landing; content is inreservations-epic)m1/reservation-test-coverage-backfill->reservations-epic- test(reservations): M2 test-coverage backfill (7 of 8 items) (merged)The entire #4274 stack is now merged into
reservations-epic.Other PRs targeting
reservations-epicfeat/utxo-reservation-wallet-support->reservations-epic- superseded by the acceptedreservations-epic/ Reservations epic -> dev tracking #4282 implementation line; do not reconcile or merge this earlier parallel attempt.Cumulative diff note
The epic also carries shared
pkg/net/localrelease/handler changes inherited from its merge stack, including #4284's release-boundary drain fix. They are intentional network/test-stability work, not reservation protocol logic. The review-line merge adds the client-side reservation work listed above.How to use this PR
reservations-epic(or stack onto an open reservations PR).reservations-epic -> devgate.4ced1c8398for the M1 code freeze (2026-11-06 at the earliest), hand that head to the external audit, fix and re-review the findings, then merge this PR intodev. The Sepolia deployment and the testnet drill happen inside the audit window and do not block the freeze.Status
The #4274 stack is complete and the review-line landing plus its review follow-ups are on the branch at
4ced1c8398. Cross-repo review remediation has landed in #4343 and tbtc-v2 #1161. #4238 is superseded, not a remaining merge decision. What is left, in order: the M1 code freeze, the external audit on the frozen head, the audit fixes and re-review, then the merge. GitHub reportsmergeable: MERGEABLEandmergeStateStatus: CLEANat this head; the PR is still a draft.CI at
4ced1c8398, as read on 2026-10-29 from the PR checks, is green on all five workflows: Solidity ECDSA, Solidity ECDSA docs, Solidity Random Beacon, Solidity Random Beacon docs, and Client. The Client workflow passes all 18 jobs, includingclient-format,client-vet,client-lint,client-scan,client-build-image,client-build-artifacts,client-go-tests (1),client-go-tests (2),client-build-test-publish,client-integration-test,client-bench,client-gate,electrum-lifecycle, both vendor byte-identity jobs andreservation-router-vendored-fallback-verify.One note on an earlier run: the first attempt of 40731829443 at
f03cc61dcfailedclient-build-artifactsat "Build Docker Runtime Image", wherego mod tidy && go mod downloadstopped on a transient proxy error (github.com/ipfs/go-ipfs-util@v0.0.3: read "https://proxy.golang.org/...": stream error: stream ID 89; INTERNAL_ERROR; received from peer). Rerunning the failed jobs at the same commit passed, and the whole workflow was green there before the follow-up commits landed.