Skip to content

Reservations epic -> dev tracking - #4282

Draft
piotr-roslaniec wants to merge 363 commits into
devfrom
reservations-epic
Draft

piotr-roslaniec wants to merge 363 commits into
devfrom
reservations-epic

Conversation

@piotr-roslaniec

@piotr-roslaniec piotr-roslaniec commented Sep 3, 2026 •

Copy link
Copy Markdown
Collaborator

Reservations epic -> dev tracking

This PR aggregates the UTXO reservations epic (reservations-epic) and tracks its promotion to dev.

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.

  • The fixes are public. Every reservation fix in this milestone is on this branch, and the contract-side counterpart is on tbtc-v2 #1116 at 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.
  • The external audit has not started. The quote request went out on 2026-10-29 and no auditor has replied yet. What exists today is an internal audit of this branch, published as a record in docs/security/pr-4282-audit/ (with the re-run under rerun/); it is not an external review and it is not an audit verdict.
  • The next step is the M1 code freeze at the head named below, targeted at 2026-11-06 at the earliest. The external audit reviews the frozen heads (this PR's head and tbtc-v2 Increase group size to 64 #1116's head). The Sepolia deployment and the testnet drill run inside the audit window, in parallel, because there is no time for them before it starts; they are not the next step and they are not a precondition for the freeze.
  • The merge into dev comes 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 8139ef3c0 was 175 commits ahead / 231 commits behind dev (cf9be7030), which is why the three-dot diff used to show only the epic's unique commits. The current head 4ced1c8398 carries dev: it is 299 commits ahead / 0 commits behind dev (measured 2026-10-29), and git rev-list --count 4ced1c8398..dev prints 0.

Landing the review line (2026-10-28)

The epic carries the reservation work that was reviewed off this branch, landed on reservations-epic on 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.

  • Head: 4ced1c8398 test(reservations): cover four uncovered reservation review mutations (#4367). The reservation line sits on top of the previous public head 8139ef3c0, 355 commits ahead of it (283 non-merge, 72 merge), and it contains dev (cf9be7030).
  • Reservation line proper, per the review line's classification: 52 reservation commits, 3 plain commits, 4 rebuild fixups (flattened-import migration, per-item error aggregate type, wallet-transaction fee and unpack and API adaptations, coordination-test scoping) and 4 follow-ups (two docs naming the public tbtc-v2 source of the vendored validator build, a refactor giving the reservation round error its own name, and the CI pin to tbtc-v2).
  • Six commits cleared the CI failures the first push showed: e3bdf2590 gofmt in the reservation test files, e76208212 enables corepack so the tbtc-v2 compile step runs the declared yarn 4, 535b9a9d0 restores an integer guard the merge dropped and bounds the reservation conversions, 48d94ed50 skips the vendored third_party modules in the gosec run, 8d14cbf55 repins TBTC_V2_REF to tbtc-v2 4c98e6b82, and f03cc61dc re-includes scripts/check-reservation-flags.sh in the build image's docker context (the test image inherits .dockerignore, which excludes scripts/) and makes the missing-script case fail loudly instead of reading as a clean exit 0.
  • 36 commits landed after 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.
  • Files changed against dev: 360 files, +137,455 / -776. Most of the additions are the published audit evidence under docs/security/ (128 files); the code sits mostly under pkg/maintainer (49), pkg/tbtcpg (46), pkg/tbtc (46) and pkg/chain (40).
  • Two stops were resolved by hand: the regenerated ReservationRouter bindings (import block) and one SPV proof-loop hunk. Two further stops replayed from the rerere cache, and the rest applied cleanly.
  • Local evidence at the previous head: go build ./..., go vet on pkg/tbtc, pkg/tbtcpg, pkg/maintainer/spv and pkg/chain/ethereum, go test ./pkg/tbtcpg/... ./pkg/maintainer/spv/..., ./pkg/tbtc -run 'Reserv|Activation', ./pkg/chain/ethereum -run 'Reserv|Validator' and ./pkg/clientinfo all pass. TBTC_V2_REF pins tbtc-v2 4c98e6b82, and make -C pkg/chain/ethereum/tbtc/gen verify-vendored-fallback plus testdata/walletproposalvalidator/verify.sh exit 0 against that build with corepack active. CI status is in the Status section.

What the reservation line brings:

  • Reservation keeper monitoring (D28). Watchdog gauges for reservation proofs, alerts when an action passes its deadline, wallet discovery for those alerts, a failed gauge read failing the observation pass, gauges registered when only reservation proofs run, and a proof-loop liveness gauge. Docs describe the gauges and their escalation.
  • Reservation deadlines on chain time. Reservation actions are judged against the chain head rather than the host clock, and the SPV side alerts on actions past their deadline.
  • SPV and proof loops. A reservation proof pass keeps going past a per-item error instead of stopping on the first one, crashed reservation watchers restart with backoff, and a Live wallet's expired reservation is stranded rather than left open.
  • Parameter floors. The client applies the contract floors the paths used to skip: the re-anchor floor rejects a negative anchor value before the comparison, and the re-anchor target amount is checked for headroom before the request is sent.
  • Re-anchor off a retiring wallet. The client sends the source wallet member proof with a re-anchor request and falls back to the least-loaded Live wallet when every target is full; the router bindings are regenerated for the new request.
  • Hand-off capacity bindings. The testnet build is bound to the hand-off capacity contracts and to the release-to-pool entry, with a test that a deposit released to the pool is swept.
  • Sweeping and stranding. A wallet is requeued for a stranding recheck when the close handler fails, and the stale-deposit poll keeps its cursor and evaluates its budget on chain time.
  • Tests. Acceptance then re-anchor on one reservation across two windows, the DepositSweep lead and reservation checklist paths, keeper observer boundaries and error paths, keeper mutation-survivor tests, the reservation action read, validation, assembly and broadcast error paths, and the chained lifecycle test.
  • Docs and style. Where reservation cap reverts can come from, the published internal audit record with its executed evidence, gofmt and helper cleanups, and a refactor that describes the reservation fixes by behaviour instead of by tracker IDs.

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:

Neither fix PR performs the dev reconciliation step.

Spec PR: keep-core #4366 docs(spec): reservations spec v5 for the M1 spec freeze, branch carry/reservations-spec-public-v5 at 506eabe560, targeting reservations-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

The entire #4274 stack is now merged into reservations-epic.

Other PRs targeting reservations-epic

Cumulative diff note

The epic also carries shared pkg/net/local release/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

  1. New reservations work should target reservations-epic (or stack onto an open reservations PR).
  2. This PR stays open as the running reservations-epic -> dev gate.
  3. Next step: freeze at 4ced1c8398 for 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 into dev. 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 reports mergeable: MERGEABLE and mergeStateStatus: CLEAN at 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, including client-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 and reservation-router-vendored-fallback-verify.

One note on an earlier run: the first attempt of 40731829443 at f03cc61dc failed client-build-artifacts at "Build Docker Runtime Image", where go mod tidy && go mod download stopped 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.

@coderabbitai

coderabbitai Bot commented Sep 3, 2026 •

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

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
…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 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 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 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
piotr-roslaniec and others added 30 commits October 31, 2026 10:00
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.
…in tbtc-v2 (#4368)

Closes the client half of the reservations freeze: mutation-survivor tests across pkg/tbtcpg, pkg/maintainer/spv and pkg/tbtc, the documented equivalent-survivor record, a scoped race-detector job, and the tbtc-v2 pin the ABI gate compiles. All Client checks green at 9f2a011.
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

No deployments
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