validator: align finality with sv-node (nLockTime/BIP68/candidate-MTP) + migrate fee/consolidation to BDK [MVP-4632 pt.1]#975
Conversation
|
🤖 Claude Code Review Status: Complete This PR implements significant validator changes for correct finality time handling across pre-CSV and post-CSV eras, matching bitcoin-sv behavior. The implementation is well-structured with comprehensive testing. Key Changes:
Strengths:
No critical issues found. The implementation follows Bitcoin consensus rules correctly and handles edge cases defensively. |
oskarszoon
left a comment
There was a problem hiding this comment.
Approve with changes. Core consensus migration is correct (strict ValidLockTime matches bitcoin-sv IsFinalTx, BIP68 -1 matches CalculateSequenceLocks, finality selection matches ContextualCheckBlock, T7 zero-txid aligns to bitcoin-sv IsNull). BDK migration coverage at cdfa78 confirmed. Fix the items below before merge:
P0 — era-routing branch is untested. validateTransactions decides between CandidateBlockTime and CandidateParentMedianTime based on blockHeightUint32 < CSVHeight. All PreValidateTransactions unit tests pass literal 0, 0 for both — only the soft-fall path is exercised, never the era-selection logic the PR introduces. TestSyncManager_validateTransactions is t.Skip()'d. Add two table cases (pre-CSV and post-CSV heights) that assert the era-correct field is set.
P1 — silent zero on height conversion (services/legacy/netsync/handle_block.go:1187). The if blockHeightUint32, hErr := ...; hErr == nil { ... } block silently drops both candidateBlockTime and candidateParentMedianTime to zero on failure → soft-fall (the path this PR is eliminating). Outer guard at line 1216 catches the error today, but the duplicate conversion is fragile. Convert once at top and return the error immediately, matching validateTransactionsLegacyMode:654.
P1 — walkParentChain breaks silently on nil header (handle_block.go:773 + check_block_subtrees.go:1243). When GetBlockHeader returns (nil, meta, nil) the loop breaks and the caller computes a median from however many headers were collected — no error. Near genesis this is correct, mid-chain on transient cache miss it silently yields an incomplete MTP. Treat (nil, _, nil) as an error inside walkParentChain, or only tolerate short returns when blockHeight < 11.
P1 — CheckSubtreeFromBlock peer-priority path leaves CandidateParentMedianTime unpopulated (services/subtreevalidation/Server.go:759-780). Mainline paths close the finality-MTP race; peer-priority falls back to tip MTP. Two nodes processing the same subtree under different tip-MTP snapshots can diverge on finality. The request has PreviousBlockHash — call fetchCandidateParentMedianTime(ctx, req.GetPreviousBlockHash()) here too.
Discussion (non-blocking):
- Strict
<ValidLockTimeflip is consensus-correct vs bitcoin-sv. Confirm TTN history doesn't contain a boundary-equality tx mined into a block — if clean, no further action. - BDK migration boundary under-tested in teranode: no startup-config panic test, no e2e fee-reject through
mapBDKValidationError. BDK side is covered; add the teranode bridge tests.
P2s (extract walkParentChain to shared package, deduplicate MTP fetch, HTTP parse warnings, InsufficientFee wire-divergence with sv-node) are tracked in the review notes — happy to file follow-up issues.
1f023d5 to
a6112a0
Compare
Benchmark Comparison ReportBaseline: Current: Summary
All benchmark results (sec/op)
Threshold: >10% with p < 0.05 | Generated: 2026-05-29 11:58 UTC |
ordishs
left a comment
There was a problem hiding this comment.
Approval
Solid work — the design is well-reasoned, doc comments are top-tier, and test pinning is more thorough than most consensus-affecting PRs. Approving with a recommendation to address the three silent-failure / contract concerns below before merge. The rest of the review notes are reasonable follow-ups.
Please address before merge
1. walkParentChain should not silently truncate on nil headers (services/legacy/netsync/handle_block.go:145-167, also duplicated in services/subtreevalidation/check_block_subtrees.go)
if header == nil {
break
}BIP113 MTP requires exactly 11 headers. An early break silently computes MTP over a smaller window. Gated by blockHeight >= CSVHeight so unreachable on mainnet, but on regtest or test setups walking close to genesis this produces a wrong MTP. Return an error when the loop terminates with fewer than depth headers due to a nil response.
2. extractValidationParams silently ignores ParseUint errors (services/validator/Server.go:737-746)
if v, err := strconv.ParseUint(candidateBlockTimeStr, 10, 32); err == nil {
options.CandidateBlockTime = uint32(v)
}A malformed value (candidateBlockTime=abc) leaves the field at 0 and silently degrades to skip-finality (pre-CSV) or tip-MTP fallback (post-CSV). Add a warn log on parse failure so future regressions on the HTTP fallback path don't ship silently.
3. selectFinalityComparisonTime post-CSV soft-fall masks caller bugs (services/validator/Validator.go:324-358)
The post-CSV default arm soft-falls to blockState.MedianTime when CandidateParentMedianTime == 0. The doc comment correctly states this is reserved for CheckSubtree, but a block-validation caller that forgets to populate the field gets the soft-fall instead of a hard error — exactly the asynchronous-MTP race this whole change exists to eliminate.
Consider enforcing the contract in the API rather than via documentation. Options:
- A required-field flag (
Options.RequireCandidateParentMedianTime) that block-validation callers set - A separate validator entry point (
ValidateForBlock) that requires the field - A distinguished sentinel ("explicitly unset") so the soft-fall only fires when explicitly opted into
Documentation-only contracts on async race-prone paths tend to erode.
Other notes (non-blocking)
The full review with positive observations and additional non-blocking items (helper duplication, missing panic-path tests, comment placement in CheckSubtree, MinConsolidationInputMaturity not wired, consensus-tightening notes) is in a separate comment.
a6112a0 to
143337f
Compare
b9730f3 to
12b0c5b
Compare
12b0c5b to
9a53d36
Compare
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
|
oskarszoon
left a comment
There was a problem hiding this comment.
Re-reviewed at 9a53d36ff. All blocking items from my previous review and ordishs's three are fixed — verified, not just acknowledged:
- Era-routing now tested (
TestCandidateFinalityTimesForBlock_EraSelection, pre/post-CSV + boundary). - Height conversion done once + error returned; silent-zero path gone.
walkParentChainhard-errors on nil header / nil link in both the netsync and subtreevalidation copies — no more silent short-window MTP.CheckSubtreeFromBlockpopulatesCandidateParentMedianTimeon the peer path, behind the existing parent-resolution check (no peer-DoS).selectFinalityComparisonTimepost-CSV soft-fall removed — a caller that doesn't populate the field now hard-errors instead of silently falling back to tip MTP. Right fix.extractValidationParamswarns on parse failure.
Core consensus re-confirmed (strict < IsFinalTx, BIP68 −1, era selection); build/vet/tests clean; BDK pin unchanged at cdfa78.
One thing before merge, not a code change: confirm TTN block history has no already-mined boundary-equality (locktime == limit) tx — the strict < flip would reject such a block on revalidation. Mainline is safe by construction.
Non-blocking follow-ups, fine as separate issues: teranode-side BDK boundary tests (startup-config panic + e2e fee-reject; BDK side is already covered), a "don't expose externally" guard on the HTTP candidate-time params, and extracting the duplicated walkParentChain helper. Approving.


Summary
First of a split series for MVP-4632 (kept small for reviewability). Brings the
Teranode validator's transaction-finality logic into exact agreement with
sv-node, and moves fee + consolidation validation out of Teranode into BDK so
there is a single source of truth.
This changes which transactions are considered final, and which time a block is
judged against. Reviewers/operators must read this section.
<(was<=), matching sv-nodeIsFinalTx. A tx whose locktime equals the comparison height/MTP is no longerfinal. Teranode was previously looser than sv-node at the boundary.
ContextualCheckBlock:CalculateSequenceLocks(boundary-only; post-Genesis short-circuits earlier).implCheckPrevOutputs(IsNull()semantics — Teranode was stricter thansv-node here).
Fee / consolidation → BDK
Deletes Teranode
checkFees/isConsolidationTx/isDustReturnTx(and ~1.8klines of their tests); pushes
MinMiningTxFee+ 4 consolidation settings into BDKat startup (fatal-on-error); maps BDK's
InsufficientFeeDoS code into the rejectpath. Net deletion — logic now lives once, in BDK.
Plumbing
New
Options.CandidateBlockTime/Options.CandidateParentMedianTime(+ proto field 9, additive/backwards-compatible), populated on the mainline
block-validation paths in
legacy/netsyncandsubtreevalidation. Post-CSV MTPis derived from the candidate parent via
GetBlockHeaders(parentHash, 11)with ahash-keyed
walkParentChainfallback for reorg-race cache recovery.BDK bump
gobdk→v1.2.5-0.20260526081552-cdfa7814ee5d(adds the fee/consolidationsetters this PR consumes).
Testing
New: candidate-block-time + candidate-parent-MTP API tests, finality-selection
tests, BIP68 boundary test, median-timestamp tests (netsync + subtreevalidation).
Removed: fee/consolidation unit tests (logic moved to BDK, covered there).
Scope / follow-ups
services/validator/CheckSubtree(peer-facing gRPC) intentionally does notpopulate candidate times — request carries no block header.
Relates to: MVP-4632
Task mapping (provenance)
implCheckPrevOutputs.services/validator/TxValidator.goValidLockTimeflipped to strict>; policy/next-tip finality now runs in all eras using tip MTP; coinbase rejection moved ahead of the finality block. Test setups updated.util/lock_time.go,util/lock_time_test.go,services/validator/Validator.go,services/validator/Validator_test.go,services/propagation/Server_test.goCalculateSequenceLocks; boundary test added.services/validator/TxValidator.go,services/validator/TxValidator_bip68_test.goMinMiningTxFee(viamath.Round) + 4 consolidation settings into BDK at startup with fatal-on-error; deletedcheckFees/isConsolidationTx/isDustReturnTxand their tests; mapped BDK's newInsufficientFeeDoS code into the teranode reject path; refreshed policylongdescs.services/validator/ScriptVerifierGoBDK.go,services/validator/TxValidator.go,services/validator/Validator_test.go,services/validator/TxValidator_test.go,settings/policy_settings.goOptions.CandidateBlockTime+WithCandidateBlockTime;validateInternalconsumes it on the pre-CSV consensus path with soft-fall when unset. Populated at the mainline block-validation callsites inlegacy/netsync(3 sites) andsubtreevalidation/check_block_subtrees.go(2 sites;processTransactionsInLevelssignature extended). Peer-facingServer.goCheckSubtreeintentionally unpopulated (gRPC request has no block header).services/validator/options.go,services/validator/Validator.go,services/legacy/netsync/handle_block.go,services/subtreevalidation/check_block_subtrees.go,services/subtreevalidation/Server.goOptions.CandidateParentMedianTime(proto field 9, parallel HTTP-fallback wiring); always populated on mainline block-validation paths viaGetBlockHeaders(parentHash, 11)+ chain re-anchor, with a hash-keyedwalkParentChainfallback for reorg-race cache-poisoning recovery.services/validator/options.go,services/validator/Validator.go,services/validator/Client.go,services/validator/Server.go,services/validator/validator_api/validator_api.proto,services/legacy/netsync/handle_block.go,services/subtreevalidation/check_block_subtrees.go,services/subtreevalidation/Server.go