fix(spv): re-verify reservation action generation before SPV proof submission - #4276
Draft
piotr-roslaniec wants to merge 15 commits into
Draft
fix(spv): re-verify reservation action generation before SPV proof submission#4276piotr-roslaniec wants to merge 15 commits into
piotr-roslaniec wants to merge 15 commits into
Conversation
Replace the placeholder getter/no-op submitter for tbtc.ActionReservationReanchor with a real implementation: - getUnprovenReservationReanchorTransactions discovers unproven re-anchor Bitcoin transactions by walking ReservationReanchorRequested events, skipping settled/timed-out action generations, and matching candidate transactions against the still-registered anchor outpoint via ReservationByAnchorUtxo. - reservationReanchorTransactionProofSubmitter re-derives the (reservationKey, requestNonce) pair the generic proof-loop signature cannot carry, then submits via the existing SubmitReservationReanchorProof path. Extends the spv.Chain interface with PastReservationReanchorRequestedEvents and ReservationByAnchorUtxo (already present on TbtcChain); adds matching localChain test fakes. Reservation acceptance proof submission remains a documented placeholder pending its own watcher integration - out of scope here. Covers the discovery precision paths (shape mismatch, anchor mismatch, settled-action skip) and the submitter's nonce/key derivation with new unit tests.
Replace the placeholder Run() (guard checks only, no loop) with a real background poller: - WatchWallet registers a wallet public key hash for polling; dedupes registrations under a mutex-protected set. - Run(ctx) now blocks, checking every watched wallet immediately and then every poll interval, until ctx is done. Each iteration walks each watched wallet's reservations (WalletReservations) and calls the existing CheckReservationActionTimeouts per reservation. A failure checking one wallet is logged and does not abort the iteration or stop the loop. - startActionTimeoutRun (reservation_wiring.go) now threads ctx into Run(ctx) instead of discarding it; context.Canceled is treated as the expected shutdown path, not a failure to log. Run's signature changes from Run() to Run(ctx context.Context); the only call site (startActionTimeoutRun) is updated. Wallet discovery (who calls WatchWallet with which wallets) remains a separate, pre-existing gap shared by all three reservation watchers - see the 'PR H placeholder' comments on subscribeReservationWalletClosed and subscribeReservationActionTimedOut - and is out of scope here. Covers WatchWallet dedup, all three Run precondition guards, and an end-to-end test that Run notifies a timed-out action on its first (immediate) iteration and returns promptly on ctx cancellation.
|
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: trueThanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
…e proof submission submitDiscoveredReservationReanchorProof previously re-derived the submission nonce by reading the reservation's current RequestNonce. That field tracks the reservation's live action generation, which can have moved on since the discovered transaction was built - e.g. the original re-anchor action times out and a new, unrelated action generation becomes current while the SPV maintainer is still waiting out requiredConfirmations on the old transaction. Submitting the live nonce in that case pairs a stale, unrelated transaction with the wrong action generation. Fix: before submitting, fetch the action generation at the reservation's current nonce and require it to still be a Pending Reanchor action targeting the exact wallet the discovered transaction actually pays. Any mismatch is reported as an error so the proof loop treats the transaction as not-yet-submittable instead of silently misattributing the proof. Adds two regression tests: one for the action-no-longer-pending case, one for the pending-but-different-target-wallet case.
The prior comment framed the race as spanning proveTransactions waiting out requiredConfirmations. In fact the getter and submitter run back-to-back within the same proveTransactions call for a given transaction (spv.go:229 getter, :286 submitter); under-confirmed transactions are skipped and re-discovered on the next tick, not held. The staleness window is the narrow same-call gap between the getter's per-event Pending check and the submitter call, not a multi-block confirmation wait. The fix itself (verify the current action generation before submitting) is unchanged and still correct - only the severity/likelihood framing in the comment was wrong.
… nonce An error returned from transactionProofSubmitter propagates out of proveTransactions (spv.go:292-293), aborting the entire proving round for every other in-flight transaction across every proof type that tick, then restarting the whole SPV maintainer after the backoff. That is disproportionate for the two mismatch branches added in the prior commit (stale/superseded action generation, mismatched target wallet): both are an expected, if rare, outcome of a narrow same-tick race, not an infrastructure failure. Both branches now log a warning and return nil instead of an error, so proveTransactions treats the transaction as handled and moves on to the next one - it will simply not be rediscovered on the next tick since its action generation is no longer Pending. Flips both regression tests to assert a nil error and that the submission hook was not called, matching the corrected behavior.
Both skip branches previously claimed the transaction 'will simply not be rediscovered' on later ticks. That relied on an unverified assumption - that the Bridge allows at most one Pending action per reservation at a time - which is asserted nowhere in this Go client and could not be confirmed against the on-chain source for this action-generation model (not available in the local tbtc-v2 checkout). If that assumption is false, getUnprovenReservation- ReanchorTransactions' per-event Pending check would keep returning the same transaction and both branches would log the same warning every tick. Replaced with the honest, verifiable termination condition: this outpoint stops matching once the reservation's current generation lands its own correct re-anchor proof, at which point the existing 'no reservation is anchored at the spent outpoint' branch takes over instead. No behavior change - comment accuracy only.
Adopts the human-authored reservation watcher/proof-submission design that landed directly on m1/keep-core-client (commits 20cb160, 1de2631, 753d717, cb8ad1a) in parallel with this branch's own Row1/Row2 work, superseding it: their dedicated reservation-proof loop covers both acceptance and re-anchor proof submission (this branch's adapter only covered re-anchor), and their timeout-watcher rewrite replaces the admitted Run() placeholder the same way this branch's did. Conflict resolution: - pkg/maintainer/spv/{chain,chain_test,reservation_action_timeout_watch, reservation_action_timeout_watch_test,reservation_wiring,spv}.go: took the human's version wholesale (superseding design). - pkg/maintainer/spv/reservation_reanchor_proof*.go: took the human's version. Their commit only added a 9-line bounds-check fix on top of the pre-existing core proof-assembly primitive (SubmitReservation- ReanchorProof et al., already part of PR #4274's original scope, not this branch's contribution) - this branch's own addition was the 325-line generic-loop discovery adapter, now dead since the human's design discovers via events instead. Both PR H's original acceptance- proof file and the new dedicated loop call directly into the unmodified primitive, so it could not be deleted outright. Ported forward: added verifyReservationActionStillProvable to reservation_proof_loop.go plus dedicated tests - a submission-time re-check of the action generation's Pending state and target wallet, mirroring the safety margin this branch's now-dropped adapter had (submitDiscoveredReservationReanchorProof's stale-nonce/mismatched- wallet guard) that the human's design does not have on its own. Wired into both proveReservationAcceptanceActions and proveReservationReanchorActions.
Adopts the human's incremental-scan reservation proof loop redesign (persistent per-pass event cursor, walletEvent dedup wrapper pattern, map-based pending-action tracking replacing full-window rescans every poll) and re-ports the immediate-pre-submit staleness guard (verifyReservationActionStillProvable) on top of it, unchanged from the prior merge's design. Guard closes the window between the loop's top-of-pass Pending check and the actual SPV proof submission, after a Bitcoin transaction-history scan and proof assembly in between. Concatenated both sides' test files (theirs: scan-range arithmetic, transaction-finding, transaction-proving, full integration tests for both action loops; ours: five guard-specific unit tests) - zero overlapping coverage. Fixed one test-fixture gap surfaced by the guard: TestProveReservationReanchorActions's event fixture never set TargetWalletPublicKeyHash (dead field before the guard existed); populated it to match the fixture's registered action. Full repo: 49 packages, zero FAIL.
Lets callers log the actual observed action type/state instead of a bare uint8 via %v.
…rage verifyReservationActionStillProvable's doc comment overclaimed that it closes a submission-time race; submitReservationActionProof's own pre-existing re-fetch+check (right before SubmitReservationProof) is what actually prevents an incorrect or misdirected submission. Rewrite the comment to state the guard's real purpose: distinguishing an expected, benign skip (Warn-logged, deliberately not counted as a failed submission attempt) from a genuine error. Fix the skip Warnf to report the actually observed action type/state (via the new String() methods) instead of only the caller-supplied expected type formatted with bare %v. Fix a false-success log: a guard skip inside the submit closure returned nil, so proveReservationTransaction always logged 'successfully submitted proof' even when nothing was submitted. Add a sentinel error (errReservationActionNoLongerProvable) so the skip path is distinguishable from both a real submission and a real failure. Extract the two submit closures into named submitReservationAcceptanceActionProof / submitReservationReanchorActionProof functions so the re-anchor closure's wallet-field selection (event.TargetWalletPublicKeyHash, not SourceWalletPublicKeyHash) can be unit-tested directly, without going through Bitcoin transaction discovery (this package's local chain test double can only discover a transaction via the source wallet's outputs, which forces source and target to coincide in any end-to-end test and so can't catch a field swap between them). Test changes: - Fix TestVerifyReservationActionStillProvable_WrongActionType's fixture leaving TargetWalletPublicKeyHash at its zero value, which let the wallet-mismatch branch mask the action-type branch under test. - Consolidate the five near-duplicate TestVerifyReservationActionStillProvable_* tests into one table-driven TestVerifyReservationActionStillProvable, add a case for a genuine chain-read error (via the new localChain.getReservationActionErr injection field, instead of relying on 'no action installed' as an implicit error trigger) and a case for an absent/zero-value action (matching what the real chain adapter actually returns for a never-set entry). - Remove stale/inaccurate doc comments (dangling references to a 'prior design' and 'generic-loop adapter' that don't exist in this repo; a claim that a propagated error would abort the whole proving pass, when call sites always log-and-continue per event). - Add loop-level regression cases to TestProveReservationAcceptanceActions / TestProveReservationReanchorActions asserting zero submissions when the action is no longer pending at submission time. - Add TestSubmitReservationReanchorActionProof_UsesTargetWallet, which calls the extracted function directly (bypassing discovery) with a genuinely distinct source/target wallet, to catch a regression that swaps the two fields at the call site.
…ervation-readiness-fixes # Conflicts: # pkg/maintainer/spv/reservation_proof_loop.go # pkg/maintainer/spv/reservation_proof_loop_test.go
…dev package @keep-network/tbtc-v2@development does not publish ReservationRouter.json, so make generate fails outright (#4281). Add a development-only fallback that supplies a vendored copy of the ABI when the real artifact is missing, verified byte-identical to the currently committed bindings by round-tripping through the same abigen + keep-common generator invocation. Non-development builds are unaffected and still hard-fail on a missing artifact.
…th missing reservation methods @keep-network/tbtc-v2@development publishes Bridge.json and WalletProposalValidator.json, but both are stale relative to this reservation feature: missing isReservedDeposit (Bridge) and validateReservationAnchorProposal/validateReservationReanchorProposal (WalletProposalValidator), which the committed Go bindings already call. Patch the fetched artifact in place (development only, only when actually missing) with vendored method fragments extracted from the committed MetaData.ABI, verified by a full clean end-to-end run: fresh npm fetch, fresh make generate, go build/vet/test across the whole repo all pass (1887 tests, 89 packages).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a submission-time re-check of a reservation action generation's state
in the reservation SPV proof loop (
reservation_proof_loop.go), plus itstest coverage.
proveReservationAcceptanceActions/proveReservationReanchorActionscheckthat an action generation is
Pendingonce near the top of their loop,then run a Bitcoin transaction-history scan before reaching the submit
call - a window in which the generation could settle, time out, or be
superseded.
submitReservationActionProof(reservation_reanchor_proof.go)already re-fetches the action and rejects on type/state/target-wallet
mismatch immediately before
SubmitReservationProof, so no incorrect ormisdirected proof can be submitted either way. What was missing was a way
to tell that expected, benign outcome apart from a genuine failure in the
logs and metrics: previously both fell through to
submitReservationActionProof'sexisting checks and surfaced identically.
verifyReservationActionStillProvableadds an earlier, dedicated check whose only observable difference is
operational: an expected staleness skip is now
Warn-logged with theactual observed action type/state (not just the expected one) and is
deliberately not counted as a failed submission attempt, while a genuine
chain-read error still propagates and a genuine logic error caught later
still increments the existing failure metrics.
Testing
TestVerifyReservationActionStillProvable(table-driven): happy path,stale action generation, wrong action type, mismatched target wallet,
genuine chain-read error, and an absent/zero-value action generation.
TestProveReservationAcceptanceActions/TestProveReservationReanchorActionsconfirming the guard's skip pathis wired through the real loop functions (zero submissions for a stale
action generation).
go test -race ./pkg/maintainer/spv/... ./pkg/tbtc/...Known CI issue (not caused by this PR)
client-build-test-publishfails atmake generate(_address/ReservationRoutertarget):
@keep-network/tbtc-v2@developmenton npm does not publish aReservationRouter.jsonartifact for theReservationRoutercontract thatpkg/chain/ethereum/tbtc/gen/Makefilealready declares as a requiredcontract. Confirmed present on the base branch (
m1/keep-core-client)itself, independent of any change in this PR. Tracked in
threshold-network/keep-core#4281.
No workaround is applied in this PR - three were evaluated and rejected:
Job-level
continue-on-erroronclient-build-test-publish: doesn'tchange the job's own reported check conclusion (still shows failed), and
unblocks
client-integration-testinto a new failure of its own (missingDocker image artifact) that was previously a clean skip. Net worse.
Step-level
continue-on-erroron the failing build step: would makethe job report green, but that step also builds the image the same job's
go test ./...step runs against - skipping past it means the fullclient Go test suite silently never runs while CI reports success, for
any future build breakage, not just this known npm gap. Too high a
blast radius for a temporary workaround.
Excluding
ReservationRouterfrom codegen (required_contractsinpkg/chain/ethereum/tbtc/gen/Makefile):pkg/chain/ethereum/tbtc.goactively constructs and uses
tbtccontract.ReservationRouterinproduction code (
reservationRouterBinding,RequestReservationReanchor),so the generated bindings are load-bearing, not unused scaffolding.
ReservationRouter's address file specifically is unused (its bindingis constructed against the Bridge address, not its own - see
gen.go's//go:embedlist, which never embeds aReservationRouteraddress), soexcluding just that piece is safe, but the ABI/contract bindings still
need real ABI JSON that the npm package doesn't ship at all, and the
shared
clean:target inpkg/chain/ethereum/common/gen/Makefile(used by every contract package: ecdsa, threshold, random-beacon, tbtc)
unconditionally wipes
abi/*,contract/*, andcmd/*before rebuild,which would delete the already-correct, currently-committed
ReservationRouterbindings with nothing left to regenerate them. Areal fix means reworking that shared
cleanstep to be selectiveper-contract - out of scope for this PR, affects codegen for every
contract package in the repo, and needs its own review.
Excluding
ReservationRouterfrom codegen, restoring committedbindings from git afterward: verified empirically infeasible, not just
theoretically.
.dockerignoreexcludes**/gen/**/*.go(onlygen.goand
cmd/cmd.goare kept) and excludes.git(.*at the top) fromthe Docker build context - the already-committed
ReservationRouterbindings never reach the image in the first place, and there is no git
history inside it to check out from. Reproduced the exact CI failure
locally (
make get_artifacts && cd pkg/chain/ethereum/tbtc/gen && make environment=development) to confirmcleandeletes them before themissing-artifact error halts the build.
Vendoring a reconstructed
ReservationRouter.jsonsourced from theABI already embedded in the committed
abi/ReservationRouter.go(
ReservationRouterMetaData.ABI): also verified infeasible by round-triptest. That embedded string has already lost every struct
internalTypequalifier (
"struct BitcoinTx.Info"became"structBitcoinTx.Info")during the original
abigenrun, so regenerating from it producesdifferent, wrong type names (
Struct0/Struct2/Struct4instead ofBitcoinTxInfo/BitcoinTxProof/ReservationReservationRequest) thatwould not match what
pkg/chain/ethereum/tbtc.goexpects. Confirmed viaa real
abigenrun anddiffagainst the committed file - notbyte-identical. Hand-authoring the missing
internalTypevalues back inwould mean fabricating artifact content that can only legitimately come
from an upstream
solccompile of the realReservationRouter.sol.Update: a fifth approach - a
development-only vendored fallback forjust the wholly-missing
ReservationRouter.json- turned out to begenuinely viable and is landed (
pkg/chain/ethereum/tbtc/gen/Makefile+ReservationRouter.fallback-artifact.json). Its ABI is byte-for-byteverified: re-derived from the already-committed
ReservationRouterMetaData.ABIwith the
internalTypeprefix spaceabigen's metadata packer strips putback (a deterministic, checkable normalization, not fabricated data), then
round-tripped through the same
abigen+ keep-common generator invocationCI uses and diffed byte-identical against the committed
abi/ReservationRouter.go,contract/ReservationRouter.go, andcmd/ReservationRouter.go. Confirmed on CI:make generatenow completessuccessfully for all four contract packages (tbtc, threshold, beacon,
ecdsa), ~40s further than before.
That unmasked a second, larger external gap at the actual
go buildstep:
BridgeandWalletProposalValidator's live npm ABI has zeroreservation-related methods (
ValidateReservationAnchorProposal,ValidateReservationReanchorProposal,IsReservedDepositall missing),while the committed Go bindings for those two existing contracts already
expect them. Same root cause as
ReservationRouter(tbtc-v2's npmdevelopmenttag hasn't caught up with this reservation feature'sSolidity changes) but a different, riskier shape to fix: this is merging
specific missing methods into a live-fetched ABI shared with dozens of
unrelated methods on those two contracts, not swapping out one wholly
self-contained missing artifact. Not attempted - no clean byte-identical
verification path the way
ReservationRouterhad, and touches twocontracts with much larger surface area.
client-build-test-publishstaysred on this second gap.
Resolved. Fixed with a second vendored-fallback patch
(
pkg/chain/ethereum/tbtc/gen/Makefile+Bridge.reservation-methods-fallback.jsonWalletProposalValidator.reservation-methods-fallback.json): the fetchednpm artifact is patched in place (
developmentonly, only when the methodsare actually missing) by merging in vendored method fragments extracted
from the committed
BridgeMetaData.ABI/WalletProposalValidatorMetaData.ABI(same
internalTypeprefix-space restoration asReservationRouter'sfallback). Verified with a full clean end-to-end run - fresh npm fetch,
fresh
make generatefor all four contract packages,go build ./...,go vet ./..., and the fullgo test ./...suite (1887 tests, 89packages) - before landing.
CI is fully green: 12/12 checks pass, including
client-build-test-publishand
client-integration-test(previously always skipped on this PR,now running and passing for the first time). Both external gaps tracked
in #4281 are worked around in this PR; no
remaining CI issue caused by or blocking this PR.