Skip to content

Feature/MvP-4632: validator parity + RPC absurd-fee guard#1013

Merged
ctnguyen merged 8 commits into
bsv-blockchain:mainfrom
ctnguyen:Feature/MvP-4632_drop2
Jun 5, 2026
Merged

Feature/MvP-4632: validator parity + RPC absurd-fee guard#1013
ctnguyen merged 8 commits into
bsv-blockchain:mainfrom
ctnguyen:Feature/MvP-4632_drop2

Conversation

@ctnguyen

@ctnguyen ctnguyen commented Jun 2, 2026

Copy link
Copy Markdown
Collaborator

Final PR for tracking issue #4632.

Summary

  • T13 — consensus rejection of unconfirmed parents. Surfaces bad-txns-unconfirmed-input-in-block when a tx's parent UTXO has no recorded BlockHeights. Uses an internal sentinel at the fallback source + boundary translation to MEMPOOL_HEIGHT in ScriptVerifierGoBDK. SkipScriptValidation bypass also guarded.
  • T11 — RPC absurd-fee guard. Policy.MaxRawTxFee (default 10M sats = 0.1 BSV); handleSendRawTransaction rejects with absurdly-high-fee when over the cap; allowhighfees=true bypasses. RPC-only by design, not validator-level — see commit message.
  • T1 — doc-only. Comment block on the BIP68 policy-mode gate in Validator.go explaining why it's deliberate (post-Genesis no-op + pre-Genesis policy mode unreachable on mainnet).

Notes for reviewers

  • T11 is deliberately RPC-only. The validator-level placement (svnode parity) gives no real protection beyond RPC because svnode's P2P path uses the same default ceiling. Rationale in the commit body.
  • T13 uses a teranode-internal sentinel (0xFFFFFFFF) distinct from MEMPOOL_HEIGHT (0x7FFFFFFF). Reason: in mainline block validation the in-block-parent path and the unconfirmed-fallback path both produce the candidate height — a post-hoc height scan can't distinguish them. Sentinel at source is the only safe identifier.
  • NullStore.Get now returns BlockHeights: [1] instead of empty. This aligns with NullStore's "everything succeeds" purpose and prevents the new consensus rejection from spuriously firing in tests that don't model parent state.

Test plan

  • go test ./services/validator/... ./services/rpc/... ./settings/... ./stores/utxo/nullstore/... passes
  • go vet ./... and go build ./... clean
  • Manual: submit a tx via sendrawtransaction with fee > 0.1 BSV → expect absurdly-high-fee rejection; retry with allowhighfees=true → expect acceptance
  • Manual: bitcoin-cli help sendrawtransactionallowhighfees description no longer says "has no effect"

Out of scope

Other items in #4632 are either already merged in prior sub-PRs, marked skip with documented reasoning, BDK-side (tracked separately), or delegated externally. See the tracking issue for the full status.

@github-actions

github-actions Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

🤖 Claude Code Review

Status: Complete

Current Review:

This PR implements three distinct features with strong code quality and comprehensive test coverage:

T13 - Consensus rejection of unconfirmed parents:

  • Properly uses internal sentinel (0xFFFFFFFF) distinct from BDK's MEMPOOL_HEIGHT
  • Block-scoped ParentMetadata accumulator correctly threads through subtree validation
  • SkipScriptValidation bypass guard prevents silent acceptance
  • Test coverage includes assembled-path integration tests and unit tests

T11 - RPC absurd-fee guard:

  • Correctly scoped to RPC handler only, not validator-level
  • Implements svnode parity with 0.1 BSV default ceiling
  • Proper allowhighfees bypass and MaxRawTxFee=0 disable
  • Test coverage validates all branches including help text update
  • NullStore and TestDaemon properly disable for test scenarios

T1 - BIP68 documentation:

  • Comment block in Validator.go:1578-1591 clearly explains policy-mode gate
  • Rationale correctly identifies post-Genesis no-op and pre-Genesis mainnet unreachability
  • Test coverage confirms post-Genesis bypass behavior

Documentation Accuracy:

  • RPC help text updated to reflect new allowhighfees behavior
  • MaxRawTxFee setting documented with purpose and usage
  • NullStore comment correctly explains BlockHeights:[1] change rationale
  • Code comments accurately describe implementation and match behavior

Code Quality:

  • Proper error handling and validation throughout
  • Clear separation of concerns (RPC vs validator vs BDK adapter)
  • Thread-safe accumulator design with snapshot+delta for parallel phase
  • Minimal changes principle followed (no unnecessary refactoring)

No issues found. All three tasks are correctly implemented with strong engineering discipline.

@github-actions

github-actions Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Benchmark Comparison Report

Baseline: main (unknown)

Current: PR-1013 (c5be1cc)

Summary

  • Regressions: 0
  • Improvements: 0
  • Unchanged: 148
  • Significance level: p < 0.05
All benchmark results (sec/op)
Benchmark Baseline Current Change p-value
_NewBlockFromBytes-4 1.612µ 1.618µ ~ 1.000
SplitSyncedParentMap_SetIfNotExists/256_buckets-4 71.35n 71.88n ~ 0.400
SplitSyncedParentMap_SetIfNotExists/16_buckets-4 71.30n 71.00n ~ 0.400
SplitSyncedParentMap_SetIfNotExists/1_bucket-4 71.15n 71.18n ~ 1.000
SplitSyncedParentMap_ConcurrentSetIfNotExists/256_buckets... 34.07n 33.48n ~ 0.700
SplitSyncedParentMap_ConcurrentSetIfNotExists/16_buckets_... 57.28n 56.86n ~ 0.700
SplitSyncedParentMap_ConcurrentSetIfNotExists/1_bucket_pa... 143.9n 142.5n ~ 1.000
MiningCandidate_Stringify_Short-4 219.6n 220.6n ~ 0.200
MiningCandidate_Stringify_Long-4 1.659µ 1.627µ ~ 0.700
MiningSolution_Stringify-4 859.0n 853.4n ~ 0.500
BlockInfo_MarshalJSON-4 1.735µ 1.746µ ~ 0.200
NewFromBytes-4 128.0n 124.6n ~ 0.700
AddTxBatchColumnar_Validation-4 2.663µ 2.402µ ~ 0.200
OffsetValidationLoop-4 729.3n 543.3n ~ 0.100
Mine_EasyDifficulty-4 67.33µ 66.75µ ~ 0.200
Mine_WithAddress-4 7.086µ 7.007µ ~ 0.200
BlockAssembler_AddTx-4 0.02480n 0.02839n ~ 1.000
AddNode-4 10.47 10.26 ~ 1.000
AddNodeWithMap-4 10.91 10.42 ~ 0.700
DirectSubtreeAdd/4_per_subtree-4 62.92n 59.83n ~ 0.100
DirectSubtreeAdd/64_per_subtree-4 29.45n 29.34n ~ 1.000
DirectSubtreeAdd/256_per_subtree-4 28.10n 28.32n ~ 0.600
DirectSubtreeAdd/1024_per_subtree-4 26.77n 26.72n ~ 0.200
DirectSubtreeAdd/2048_per_subtree-4 26.39n 26.22n ~ 0.500
SubtreeProcessorAdd/4_per_subtree-4 291.9n 293.3n ~ 1.000
SubtreeProcessorAdd/64_per_subtree-4 289.8n 288.6n ~ 0.700
SubtreeProcessorAdd/256_per_subtree-4 288.0n 289.1n ~ 1.000
SubtreeProcessorAdd/1024_per_subtree-4 282.9n 278.4n ~ 0.100
SubtreeProcessorAdd/2048_per_subtree-4 280.6n 275.9n ~ 0.200
SubtreeProcessorRotate/4_per_subtree-4 284.1n 287.4n ~ 0.200
SubtreeProcessorRotate/64_per_subtree-4 280.4n 281.7n ~ 1.000
SubtreeProcessorRotate/256_per_subtree-4 279.6n 284.4n ~ 0.100
SubtreeProcessorRotate/1024_per_subtree-4 279.2n 281.6n ~ 0.100
SubtreeNodeAddOnly/4_per_subtree-4 55.67n 55.47n ~ 0.400
SubtreeNodeAddOnly/64_per_subtree-4 36.28n 36.24n ~ 0.700
SubtreeNodeAddOnly/256_per_subtree-4 35.25n 35.23n ~ 1.000
SubtreeNodeAddOnly/1024_per_subtree-4 34.72n 34.81n ~ 0.700
SubtreeCreationOnly/4_per_subtree-4 112.2n 113.2n ~ 0.600
SubtreeCreationOnly/64_per_subtree-4 358.6n 366.0n ~ 0.200
SubtreeCreationOnly/256_per_subtree-4 1.266µ 1.274µ ~ 0.700
SubtreeCreationOnly/1024_per_subtree-4 3.919µ 3.886µ ~ 0.700
SubtreeCreationOnly/2048_per_subtree-4 7.164µ 7.201µ ~ 0.700
SubtreeProcessorOverheadBreakdown/64_per_subtree-4 283.7n 281.0n ~ 0.400
SubtreeProcessorOverheadBreakdown/1024_per_subtree-4 285.4n 280.4n ~ 0.200
ParallelGetAndSetIfNotExists/1k_nodes-4 2.024m 2.011m ~ 0.400
ParallelGetAndSetIfNotExists/10k_nodes-4 5.284m 5.343m ~ 0.100
ParallelGetAndSetIfNotExists/50k_nodes-4 7.152m 7.359m ~ 0.700
ParallelGetAndSetIfNotExists/100k_nodes-4 9.661m 9.883m ~ 0.700
SequentialGetAndSetIfNotExists/1k_nodes-4 1.790m 1.767m ~ 0.100
SequentialGetAndSetIfNotExists/10k_nodes-4 4.480m 4.405m ~ 0.100
SequentialGetAndSetIfNotExists/50k_nodes-4 13.86m 13.55m ~ 0.200
SequentialGetAndSetIfNotExists/100k_nodes-4 25.02m 24.89m ~ 0.400
ProcessOwnBlockSubtreeNodesParallel/1k_nodes-4 2.103m 2.056m ~ 0.100
ProcessOwnBlockSubtreeNodesParallel/10k_nodes-4 8.628m 8.458m ~ 0.100
ProcessOwnBlockSubtreeNodesParallel/100k_nodes-4 13.60m 13.25m ~ 0.100
ProcessOwnBlockSubtreeNodesSequential/1k_nodes-4 1.819m 1.796m ~ 0.400
ProcessOwnBlockSubtreeNodesSequential/10k_nodes-4 8.079m 8.026m ~ 0.100
ProcessOwnBlockSubtreeNodesSequential/100k_nodes-4 43.55m 43.30m ~ 0.200
DiskTxMap_SetIfNotExists-4 3.570µ 3.615µ ~ 1.000
DiskTxMap_SetIfNotExists_Parallel-4 3.189µ 3.441µ ~ 0.200
DiskTxMap_ExistenceOnly-4 345.2n 320.2n ~ 1.000
Queue-4 152.4n 152.3n ~ 0.700
AtomicPointer-4 2.530n 2.814n ~ 0.100
ReorgOptimizations/DedupFilterPipeline/Old/10K-4 663.3µ 683.6µ ~ 0.100
ReorgOptimizations/DedupFilterPipeline/New/10K-4 598.1µ 615.2µ ~ 0.700
ReorgOptimizations/AllMarkFalse/Old/10K-4 101.28µ 98.46µ ~ 0.700
ReorgOptimizations/AllMarkFalse/New/10K-4 50.28µ 49.99µ ~ 0.700
ReorgOptimizations/HashSlicePool/Old/10K-4 51.50µ 40.91µ ~ 0.100
ReorgOptimizations/HashSlicePool/New/10K-4 8.692µ 8.738µ ~ 0.700
ReorgOptimizations/NodeFlags/Old/10K-4 3.396µ 3.455µ ~ 0.400
ReorgOptimizations/NodeFlags/New/10K-4 1.131µ 1.214µ ~ 0.100
ReorgOptimizations/DedupFilterPipeline/Old/100K-4 8.274m 8.040m ~ 0.400
ReorgOptimizations/DedupFilterPipeline/New/100K-4 8.940m 8.426m ~ 0.100
ReorgOptimizations/AllMarkFalse/Old/100K-4 957.5µ 917.4µ ~ 0.400
ReorgOptimizations/AllMarkFalse/New/100K-4 544.6µ 546.3µ ~ 0.100
ReorgOptimizations/HashSlicePool/Old/100K-4 405.7µ 430.3µ ~ 0.700
ReorgOptimizations/HashSlicePool/New/100K-4 203.0µ 203.1µ ~ 1.000
ReorgOptimizations/NodeFlags/Old/100K-4 37.02µ 37.26µ ~ 0.700
ReorgOptimizations/NodeFlags/New/100K-4 12.78µ 13.13µ ~ 0.100
TxMapSetIfNotExists-4 38.37n 38.17n ~ 0.200
TxMapSetIfNotExistsDuplicate-4 31.82n 31.89n ~ 0.600
ChannelSendReceive-4 465.9n 464.9n ~ 1.000
CalcBlockWork-4 473.1n 488.3n ~ 0.200
CalculateWork-4 639.1n 645.2n ~ 0.700
CheckOldBlockIDs/on-chain-prefetch/1000-4 60.83µ 60.41µ ~ 1.000
CheckOldBlockIDs/off-chain-prefetch/1000-4 50.42µ 50.10µ ~ 0.100
CheckOldBlockIDs/on-chain-prefetch/10000-4 431.0µ 431.4µ ~ 0.400
CheckOldBlockIDs/off-chain-prefetch/10000-4 354.1µ 357.8µ ~ 0.400
BuildBlockLocatorString_Helpers/Size_10-4 1.358µ 1.369µ ~ 0.600
BuildBlockLocatorString_Helpers/Size_100-4 12.96µ 13.10µ ~ 0.100
BuildBlockLocatorString_Helpers/Size_1000-4 127.7µ 129.9µ ~ 0.100
CatchupWithHeaderCache-4 104.7m 104.6m ~ 1.000
_BufferPoolAllocation/16KB-4 3.979µ 3.913µ ~ 0.700
_BufferPoolAllocation/32KB-4 10.40µ 10.16µ ~ 1.000
_BufferPoolAllocation/64KB-4 17.28µ 19.09µ ~ 0.100
_BufferPoolAllocation/128KB-4 35.41µ 33.18µ ~ 0.100
_BufferPoolAllocation/512KB-4 109.4µ 119.1µ ~ 0.200
_BufferPoolConcurrent/32KB-4 19.68µ 19.20µ ~ 0.700
_BufferPoolConcurrent/64KB-4 30.67µ 30.76µ ~ 1.000
_BufferPoolConcurrent/512KB-4 149.6µ 144.3µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/16KB-4 634.7µ 636.9µ ~ 0.700
_SubtreeDeserializationWithBufferSizes/32KB-4 622.8µ 610.9µ ~ 0.700
_SubtreeDeserializationWithBufferSizes/64KB-4 620.5µ 601.2µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/128KB-4 626.4µ 603.9µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/512KB-4 588.4µ 589.6µ ~ 0.700
_SubtreeDataDeserializationWithBufferSizes/16KB-4 36.11m 36.77m ~ 0.100
_SubtreeDataDeserializationWithBufferSizes/32KB-4 36.27m 36.78m ~ 0.100
_SubtreeDataDeserializationWithBufferSizes/64KB-4 35.99m 36.48m ~ 0.100
_SubtreeDataDeserializationWithBufferSizes/128KB-4 36.09m 36.41m ~ 0.100
_SubtreeDataDeserializationWithBufferSizes/512KB-4 35.79m 36.46m ~ 0.100
_PooledVsNonPooled/Pooled-4 736.4n 737.6n ~ 0.400
_PooledVsNonPooled/NonPooled-4 8.740µ 7.938µ ~ 0.100
_MemoryFootprint/Current_512KB_32concurrent-4 6.418µ 6.475µ ~ 0.400
_MemoryFootprint/Proposed_32KB_32concurrent-4 9.590µ 9.575µ ~ 0.700
_MemoryFootprint/Alternative_64KB_32concurrent-4 9.238µ 9.230µ ~ 1.000
_prepareTxsPerLevel-4 429.9m 424.6m ~ 1.000
_prepareTxsPerLevelOrdered-4 3.631m 3.709m ~ 0.400
_prepareTxsPerLevel_Comparison/Original-4 427.8m 416.1m ~ 0.400
_prepareTxsPerLevel_Comparison/Optimized-4 3.915m 3.650m ~ 0.100
SubtreeSizes/10k_tx_4_per_subtree-4 987.4µ 1029.1µ ~ 0.400
SubtreeSizes/10k_tx_16_per_subtree-4 234.0µ 235.2µ ~ 0.700
SubtreeSizes/10k_tx_64_per_subtree-4 58.03µ 55.52µ ~ 0.200
SubtreeSizes/10k_tx_256_per_subtree-4 14.24µ 14.02µ ~ 0.700
SubtreeSizes/10k_tx_512_per_subtree-4 7.060µ 6.986µ ~ 0.400
SubtreeSizes/10k_tx_1024_per_subtree-4 3.447µ 3.471µ ~ 0.200
SubtreeSizes/10k_tx_2k_per_subtree-4 1.706µ 1.717µ ~ 0.700
BlockSizeScaling/10k_tx_64_per_subtree-4 55.37µ 54.83µ ~ 0.200
BlockSizeScaling/10k_tx_256_per_subtree-4 13.78µ 13.94µ ~ 0.700
BlockSizeScaling/10k_tx_1024_per_subtree-4 3.455µ 3.394µ ~ 0.200
BlockSizeScaling/50k_tx_64_per_subtree-4 290.8µ 287.4µ ~ 0.400
BlockSizeScaling/50k_tx_256_per_subtree-4 68.90µ 69.72µ ~ 1.000
BlockSizeScaling/50k_tx_1024_per_subtree-4 17.23µ 16.87µ ~ 0.100
SubtreeAllocations/small_subtrees_exists_check-4 115.4µ 115.3µ ~ 1.000
SubtreeAllocations/small_subtrees_data_fetch-4 127.2µ 123.0µ ~ 0.200
SubtreeAllocations/small_subtrees_full_validation-4 238.4µ 236.1µ ~ 0.100
SubtreeAllocations/medium_subtrees_exists_check-4 7.031µ 6.999µ ~ 0.700
SubtreeAllocations/medium_subtrees_data_fetch-4 7.411µ 7.376µ ~ 1.000
SubtreeAllocations/medium_subtrees_full_validation-4 13.96µ 13.77µ ~ 0.700
SubtreeAllocations/large_subtrees_exists_check-4 1.662µ 1.656µ ~ 0.400
SubtreeAllocations/large_subtrees_data_fetch-4 1.775µ 1.759µ ~ 0.700
SubtreeAllocations/large_subtrees_full_validation-4 3.423µ 3.429µ ~ 1.000
StoreBlock_Sequential/BelowCSVHeight-4 342.1µ 333.9µ ~ 0.100
StoreBlock_Sequential/AboveCSVHeight-4 336.5µ 340.7µ ~ 0.100
GetUtxoHashes-4 278.8n 281.2n ~ 0.200
GetUtxoHashes_ManyOutputs-4 47.41µ 46.22µ ~ 0.400
_NewMetaDataFromBytes-4 212.9n 216.5n ~ 0.600
_Bytes-4 392.0n 393.7n ~ 0.600
_MetaBytes-4 138.1n 137.6n ~ 0.700

Threshold: >10% with p < 0.05 | Generated: 2026-06-05 08:46 UTC

@ctnguyen ctnguyen force-pushed the Feature/MvP-4632_drop2 branch from 86cda10 to c9462fd Compare June 2, 2026 09:39
@ordishs

ordishs commented Jun 2, 2026

Copy link
Copy Markdown
Collaborator

T13 — consensus-critical: in-block parents can be wrongly stamped with the unconfirmed sentinel

I think T13 will reject valid blocks in consensus mode. There are two independent paths by which an earlier-in-block parent (which is perfectly legal to spend) gets the unconfirmedParentHeight = 0xFFFFFFFF sentinel, which the BDK adapter then translates to MEMPOOL_HEIGHTbad-txns-unconfirmed-input-in-block. Since this only fires during block validation (consensus mode) and only for certain transaction-DAG shapes, it's a chain-split risk: teranode would reject blocks that svnode/the rest of the network accept.

Worth confirming against svnode parity: in block connection, outputs created by an earlier tx in the same block are added to the block's CCoinsViewCache at the candidate block height (not MEMPOOL_HEIGHT). UnconfirmedInputInBlock is meant to fire only when an input references a coin that is neither in a prior block nor created earlier in the current block. The correct height for an in-block parent is therefore the candidate height — exactly what ParentMetadata.BlockHeight supplies.

Path 1 — ParentMetadata height overwritten on the extend==true fallthrough

services/validator/Validator.go getUtxoBlockHeightAndExtendForParentTx:

if validationOptions.ParentMetadata != nil {
    if parentMeta, found := validationOptions.ParentMetadata[parentTxHash]; found {
        for _, idx := range idxs {
            utxoHeights[idx] = parentMeta.BlockHeight   // correct candidate height
        }
        if !extend {
            return nil
        }
        // fall through to UTXO store to get the full tx for extending
    }
}
...
txMeta, err := v.utxoStore.Get(gCtx, &parentTxHash, f...)
...
if len(txMeta.BlockHeights) == 0 {
    for _, idx := range idxs {
        utxoHeights[idx] = unconfirmedParentHeight       // ⚠️ clobbers the height just set
    }
}

When extend == true and the parent is an in-block parent supplied via ParentMetadata, the correct candidate height is set, then the fallthrough re-runs the len(BlockHeights)==0 branch and overwrites it with the sentinel. An in-block parent created earlier in the same block always has empty BlockHeightsBlockHeights is only appended by SetMined, never by Create — so this branch always triggers for in-block parents.

extend is !tx.IsExtended(), and extendTxWithInBlockParents (check_block_subtrees.go) only marks a tx extended if all inputs extend from the level-1 parent map. So a tx that isn't fully extended from level-1 (see triggers below) keeps extend == true.

Before this PR the same fallthrough overwrote with blockState.Height + 1, which equals parentMeta.BlockHeight — a harmless no-op. This PR turns it into a consensus-breaking overwrite.

Path 2 — skip-level parents never enter ParentMetadata at all

ParentMetadata and the extend parent-map are built only from txsPerLevel[level-1] (check_block_subtrees.go), but prepareTxsPerLevel assigns level = max(parentLevel) + 1 (SubtreeValidation.go), so a tx can directly spend a parent more than one level below it. A tx at level 2 spending a level-0 in-block grandparent: that input is not in the level-1 ParentMetadata, so it goes straight to the store → empty BlockHeights → sentinel → false rejection. This path mis-fires even if Path 1 is fixed.

Triggering DAG shapes (single block — both common in real blocks)

  1. A tx spending one in-block parent + one already-confirmed (prior-block) parent. The confirmed parent isn't in the level-1 map, so the tx is never fully extended → extend == true → Path 1 clobbers the in-block parent's height. (chained payments, CPFP, consolidation mixing fresh change with older UTXOs)
  2. A tx spending an in-block grandparent (skip-level dependency) → Path 2.

A tx whose inputs are all satisfied by level-1 in-block parents is fully extended (extend == false) and hits the early return — which is why the happy path and the existing WithParentMetadata test (called with extend == false) pass. The extend == true + ParentMetadata case is currently untested.

Suggested fixes

  1. Don't overwrite a height already set from ParentMetadata. Track whether the slot came from ParentMetadata; when falling through only to extend, use the store data solely to populate PreviousTxSatoshis / PreviousTxScript and skip the height-stamping block for those indices:

    cameFromParentMetadata := false
    if validationOptions != nil && validationOptions.ParentMetadata != nil {
        if parentMeta, found := validationOptions.ParentMetadata[parentTxHash]; found {
            for _, idx := range idxs {
                utxoHeights[idx] = parentMeta.BlockHeight
            }
            cameFromParentMetadata = true
            if !extend {
                return nil
            }
        }
    }
    ...
    if !cameFromParentMetadata {
        if len(txMeta.BlockHeights) == 0 {
            for _, idx := range idxs {
                utxoHeights[idx] = unconfirmedParentHeight
            }
        } else {
            for _, idx := range idxs {
                utxoHeights[idx] = txMeta.BlockHeights[0]
            }
        }
    }
  2. Make ParentMetadata cover all earlier-in-block parents, not just level-1. Accumulate metadata across all processed lower levels (level-1 … 0) so a skip-level parent still resolves to the candidate height. The metadata entries are just {hash: blockHeight} and are cheap to retain even though the tx bodies are released at the level-2 memory-release step. This closes Path 2.

  3. Tests:

    • Unit: getUtxoBlockHeightAndExtendForParentTx with ParentMetadata set and extend == true and store returns empty BlockHeights → assert the slot equals parentMeta.BlockHeight, not the sentinel.
    • Integration (subtreevalidation): a block containing (a) a tx spending an in-block parent + a confirmed parent, and (b) a skip-level dependency → assert the block is accepted in consensus mode.

The genuine "reject a mempool-only parent inside a block" case (parent neither in a prior block nor anywhere in this block) is already handled by the ErrTxMissingParent flow and by BDK — the sentinel should be reserved for that and never applied to an in-block parent.

Smaller notes (non-blocking)

  • T11: PreviousOutputsDecorate now runs in the RPC handler on every non-extended sendrawtransaction, and the validator still does its own height lookup afterward — some duplicated store work on the RPC path. Acceptable for an RPC entry point, just flagging.
  • T11: SetExtended(true) after decorate assumes PreviousOutputsDecorate populates all inputs or errors. If any backend ever leaves an input at PreviousTxSatoshis == 0 without erroring, the computed fee is understated. The inputSats > outputSats guard prevents underflow, so this is estimate-accuracy only.
  • NullStore.Get returning BlockHeights: [1] is reasonable, but it's a global change to a shared test double — worth a glance for any consumer asserting the old empty shape.

T11 and T1 look good. Recommend holding T13 until the in-block-parent paths are fixed and covered by a consensus-mode regression test.

@ctnguyen ctnguyen force-pushed the Feature/MvP-4632_drop2 branch from c9462fd to 4cf90c3 Compare June 3, 2026 05:56
@ordishs

ordishs commented Jun 3, 2026

Copy link
Copy Markdown
Collaborator

Re-review — both consensus paths now closed ✅

Re-reviewed after the three fix commits. The root cause is fixed at the right layer, not just patched — and the in-block-parent metadata is now wired through the validator service boundary, which Path 2 genuinely required. Reviewed from the PR diff; I did not run go test/go build locally.

Path 1 — fixed as recommended

getUtxoBlockHeightAndExtendForParentTx now tracks cameFromParentMetadata and gates the post-Get height-stamping block on !cameFromParentMetadata. The ParentMetadata candidate height survives the extend fallthrough; the store lookup is used only to fetch the parent body for input extension. Directly covered by TestGetUtxoBlockHeightAndExtendForParentTx_ParentMetadataPreservedThroughExtendFallthrough and _UnconfirmedSentinelOnlyAppliedWhenNoParentMetadata.

Path 2 — fixed structurally

The level-1-only buildParentMetadata is replaced by a block-scoped parentMetadataAccumulator that:

  • accumulates every successful tx across all levels (post-g.Wait() merges), so skip-level grandparents resolve;
  • survives across batches (a parent created in batch K is visible to a child in batch K+1; block order guarantees parents precede children);
  • seeds already-known (cache / UTXO-hit) in-block txs at the candidate height before the missed==0 early return, closing the all-known-batch hole;
  • handles cross-subtree dependencies via the existing ordered-retry safety net: a child whose parent is in a concurrently-validating Phase-2 subtree fails (sentinel rejection → subtree failure), then Phase 3 retries sequentially in block order against the live accumulator that now holds the parent. Traced this — a spurious rejection fails the subtree rather than silently dropping the tx, so the retry path is correct.

The concurrency design matches its comments: per-tx goroutines only ever see a pre-filtered private map (filterParentMetadataForInputs runs on the caller goroutine before g.Go); the shared accumulator is mutated only at g.Wait() barriers; Phase 2 uses a read-only shared snapshot + per-subtree delta merged first-writer-wins in block order. First-writer-wins preserves the "one BlockHeight per in-block parent" invariant.

gRPC/HTTP wiring — good defensive posture

ParentMetadata now crosses the validator service boundary (new ParentTxMetadata proto message, field 10). It fails closed: parentMetadataFromWire rejects nil / malformed-length entries with a request-level error rather than silently dropping them — precisely because a dropped entry would re-introduce the sentinel rejection. The HTTP /tx path switched to a protobuf body (application/x-protobuf) with a preserved legacy application/octet-stream fallback. All callers of the now-error-returning optionsFromValidateRequest are updated (Server, Client batch fallback, both candidate_*_api_test.go).

Test coverage

Strong at the component level: the two regression tests above, plus accumulator semantics (filter, first-writer-wins, Phase-2 delta merge, failed-Phase-2-doesn't-contribute), wire round-trip, fail-closed (malformed length + nil entry), non-symmetric hash byte order, defensive hash copy, content-type parsing, and both HTTP body paths end-to-end.

Residual notes (non-blocking)

  1. No end-to-end block-DAG acceptance test. Every component is tested, but nothing drives a full CheckBlockSubtrees with a block containing (a) a skip-level grandparent dependency and (b) a mixed in-block + confirmed parent, asserting the block is accepted in consensus mode. Given the orchestration complexity (batches × levels × Phase 2/3 retry), this black-box test is the best guard against a future refactor silently re-opening Path 2. Recommend adding it before merge.
  2. Memory / allocation on very large blocks. The accumulator retains a hash → *ParentTxMetadata entry for every successful tx for the whole CheckBlockSubtrees duration, and add allocates a fresh *ParentTxMetadata per tx even though BlockHeight is constant across the block. Interning a single shared &ParentTxMetadata{BlockHeight: blockHeight} per block would eliminate millions of tiny allocations on large blocks. Acknowledged tradeoff vs. the old per-level map; flag as a perf follow-up.

Verdict

The consensus-split regression is resolved correctly. Good to merge once the end-to-end block-acceptance integration test (note 1) is added — that closes the gap between "the pieces are proven" and "the assembled path is proven." Thanks for the thorough fix.

@ordishs ordishs left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review — note 1 resolved ✅ — approving

The new commit adds check_block_subtrees_assembled_path_test.go, the end-to-end block-DAG acceptance test from the prior note — and it's well-constructed.

What it does well:

  • Drives the real CheckBlockSubtrees pipeline (parse → batch → level-order → per-tx filter → validator) with a purpose-built DAG: G (level 0), P (level 1, spends G), C (level 2, spends P + G skip-level + Gext external). This is exactly the Path 2 shape — C's skip-level edge to grandparent G is what the old level-1-only buildParentMetadata dropped.
  • Pre-flight invariant asserts the level builder actually places C at level 2 before the real assertions run, so a future leveling refactor can't make the test silently vacuous.
  • Captures Options at the ValidateWithOptions boundary and asserts on every recorded call — both the batch-loop pass and the ordered-retry pass — so a regression that breaks only one pipeline still fails.
  • Asserts the full invariant set: G has no ParentMetadata; P carries G@candidate; C carries G@candidate across the level skip (the core guard); C does not carry Gext (negative assertion against accidental over-seeding of confirmed external parents, which would corrupt CSV/locktime); and resp.Blessed == true.

Layering is sound: the validator is mocked here, so this proves "the accumulator threads correct per-tx ParentMetadata through the assembled pipeline." The complementary links — ParentMetadata → no sentinel → correct height, sentinel → BDK rejection, and the wire round-trip — are each covered by their own unit tests. In aggregate the path is fully covered.

Status of prior notes

  • Note 1 (E2E block-DAG acceptance test): resolved.
  • Note 2 (intern the per-tx *ParentTxMetadata / accumulator memory on very large blocks): still open — non-blocking perf follow-up. Fine to defer.

Verdict

The consensus-split regression (both paths), the gRPC/HTTP wiring with fail-closed semantics, and the assembled-path regression guard are all in place. T11 (RPC absurd-fee guard) and T1 (BIP68 doc) remain correct. Approving — the only remaining item is the optional allocation/memory optimization in note 2, which can land in a follow-up.

Reviewed from the diff with symbol existence spot-checked against the tree; I did not run go test locally.

@ctnguyen ctnguyen force-pushed the Feature/MvP-4632_drop2 branch 2 times, most recently from fbbc6c1 to 6ddc7c9 Compare June 4, 2026 23:41
ctnguyen added 8 commits June 5, 2026 15:33
Consensus-mode parity fix for the case where a tx's parent UTXO exists
in the teranode UTXO store but has no recorded BlockHeights (parent not
yet confirmed). The validator previously substituted `blockState.Height
+ 1` for these slots, silently accepting txs that svnode rejects as
`bad-txns-unconfirmed-input-in-block`. BDK already enforces the
rejection when it sees MEMPOOL_HEIGHT (bdk/core/txvalidator.cpp:779) —
the gap was teranode never passing the sentinel.

- Mark the unconfirmed-parent fallback at the source with a
  teranode-internal sentinel `unconfirmedParentHeight = 0xFFFFFFFF`
  (distinct from MEMPOOL_HEIGHT, impossible as a real height).
- Translate at the BDK adapter boundary into MEMPOOL_HEIGHT in
  consensus mode or the candidate height in policy mode (matching
  svnode's GetInputScriptBlockHeight at validation.cpp:2668-2675).
  MEMPOOL_HEIGHT stays confined to the BDK-adapter file.
- Guard the SkipScriptValidation early-return so legacy-catchup
  callers also reject the sentinel in consensus mode — without it, the
  sentinel would propagate to BIP68 height arithmetic
  (int32(0xFFFFFFFF) = -1) and clamped MTP lookups, silently accepting.
- NullStore.Get now returns a non-empty BlockHeights so tests using
  the no-op store represent confirmed parents by default; three
  pre-existing tests that relied on the old silent acceptance updated.
User-protection guard against a fat-fingered fee on the
sendrawtransaction JSON-RPC. This is NOT consensus or policy
enforcement — an absurd-fee tx is a perfectly valid transaction that
miners would accept; the only reason to reject it is operator safety.

Deliberately divergent from bitcoin-sv's placement. svnode enforces
the ceiling inside its validator's TxnValidation pipeline because it
passes a per-call nAbsurdFee from RPC down to the validator
(allowhighfees=true → Amount(0) → disabled for that call). We do not
mirror that placement: svnode's P2P path uses DEFAULT_TRANSACTION_MAXFEE
(0.1 BSV) which doesn't reject anything realistic, so the
validator-level placement gives no real protection beyond the RPC
entry point. Putting it in our validator would pollute the policy
path with a check that exists purely to catch a fat-fingered RPC
call. Operators needing strict svnode parity can later add an
Options.AbsurdFeeCeiling to the validator; the seam stays clean.

- New Policy.MaxRawTxFee (uint64 sats); default 10M = 0.1 BSV,
  matching bitcoin-sv DEFAULT_TRANSACTION_MAXFEE = COIN/10. Wired
  through settings.NewSettings via getUint64("maxrawtxfee", …) so
  the runtime default is non-zero — the struct tag alone is
  decorative. Settings-wiring tests pin default, env-override, and
  env-zero opt-out.
- handleSendRawTransaction extends the tx (RPC arrives as wire
  bytes), computes fee = inputSats - outputSats, and rejects with
  `absurdly-high-fee <fee> > <ceiling>` when over the cap.
  allowhighfees=true bypasses; MaxRawTxFee=0 disables globally.
- After PreviousOutputsDecorate succeeds, call tx.SetExtended(true)
  so the downstream validator does not re-decorate and the gRPC
  client serialises extended bytes instead of wire bytes.
- rpcserverhelp.go updated (was: "has no effect").

Tests: 5 handler-level (below ceiling accept; above ceiling reject
with full error-string assertions; allowhighfees bypass; ceiling=0
disables; help-text drift pin) + 3 settings-wiring.
Pt.1 of a 3-part fix for the consensus bug PR review identified in
the original T13 commit (f56e5cd): in-block parents can be wrongly
stamped with unconfirmedParentHeight, surfacing
bad-txns-unconfirmed-input-in-block on blocks svnode accepts.

The root cause has two paths, but a prerequisite for fixing either
is that Options.ParentMetadata reaches the validator in distributed
mode (UseLocalValidator=false, production default). Today it
doesn't: the proto has no parent_metadata field and the HTTP fallback
sends raw tx bytes plus lossy query-string scalars only. The metadata
is silently dropped on every wire crossing. This commit closes that
wire gap. No behavioural change in isolation — pt.2 (validator-core
preservation across the extend==true fallthrough) and pt.3
(block-scoped accumulator + per-tx filtered metadata in
subtreevalidation) need this wire to be in place first.

- Proto: new ParentTxMetadata message (bytes parent_hash, uint32
  block_height) and repeated parent_metadata on
  ValidateTransactionRequest. bytes parent_hash carries the
  chainhash.Hash internal byte order directly — no hex encoding,
  no display-order conversion, no risk of round-tripping to the
  wrong key.
- Client: buildValidateTxRequest now marshals Options.ParentMetadata
  via a new parentMetadataToWire helper, with a defensive 32-byte
  copy per entry so the wire form does not alias the source map.
- Server: optionsFromValidateRequest reconstructs the in-memory map
  via parentMetadataFromWire. Fails closed on malformed entries
  (nil or wrong-length parent_hash) with a request-level error
  rather than silently dropping — silent drop would resurrect
  exactly the consensus rejection shape this PR series is meant to
  remove. Error is propagated through both the gRPC handler and the
  HTTP-fallback batch retry path in Client.go.
- HTTP fallback: validateTransactionViaHTTP now sends
  application/x-protobuf with a marshalled ValidateTransactionRequest
  in the body. Server's handleSingleTx discriminates on Content-Type
  and falls back to the legacy raw-body + query-string path for
  non-protobuf callers (backward compatible with any existing
  non-protobuf client).

Tests in services/validator/parent_metadata_api_test.go cover:
proto round-trip; empty/missing field; full Client → Server
round-trip; non-symmetric-hash byte-order pin (catches any future
drift to a hex-encoded string key); defensive-copy invariant;
isProtobufContentType across 10 Content-Type variants; HTTP
protobuf-body end-to-end; legacy octet-stream backward-compat;
fail-closed on nil entry; fail-closed on 5 malformed-length
variants.
…3 fix pt.2]

Pt.2 of 3 (continues b472731b3 / pt.1 wire prep). Closes Path 1 of
the consensus bug PR review identified in the original T13 commit
(e03ad5c): the extend==true fallthrough in
getUtxoBlockHeightAndExtendForParentTx silently overwrites the
candidate height that ParentMetadata supplied for an in-block
parent.

When extend==true and the parent appears in ParentMetadata, the
function correctly stamps the candidate height up front but must
also fetch the parent tx body from the UTXO store for input
extension. The post-Get height-stamping block was unconditional:
in-block parents have empty BlockHeights (Create writes the tx row
but the blocks_transactions join row is only added by
SetMinedMulti) so the "empty → sentinel" branch fires and clobbers
the candidate height with unconfirmedParentHeight. The BDK adapter
then translates that to MEMPOOL_HEIGHT and rejects with
bad-txns-unconfirmed-input-in-block on a perfectly legitimate
block — chain-split risk in consensus mode.

This commit adds a cameFromParentMetadata flag and gates the
post-Get stamping on !cameFromParentMetadata. The Get call still
happens (input extension needs the parent tx body); only the
height stamping is skipped for the affected indices.

Tests in services/validator/Validator_test.go:

- _ParentMetadataPreservedThroughExtendFallthrough — positive pin:
  candidate height survives extend==true; PreviousTxSatoshis /
  PreviousTxScript populated from the parent body with deliberately
  distinctive values (placeholder vs parent value are different, so
  the assertion cannot pass by accident if extension never ran).
- _UnconfirmedSentinelOnlyAppliedWhenNoParentMetadata — negative
  drift pin, sharp != sentinel assertion to catch any future
  refactor that reintroduces an unconditional overwrite.
- _FallbackWritesUnconfirmedSentinel reinforced with an explicit
  Options.ParentMetadata == nil assertion so its intent (the
  no-ParentMetadata code path) is pinned, not implicit.

Sanity-checked: temporarily disabling the gate fails the
preservation pin at the height assertion (expected 12345, got
0xffffffff), confirming the test catches the regression shape.

Pt.3 still needed for the rest of the consensus fix:

- Path 2 (skip-level parents) — a tx spending an in-block
  grandparent is not in ParentMetadata at all because the current
  builder only fills level-1.
- Cross-batch in-block parents in CheckBlockSubtrees — batch 1's
  parent is not in batch 2's local ParentMetadata.
- Per-tx filtered metadata to avoid O(N²) wire bandwidth in
  distributed validator mode (every per-tx gRPC call currently
  serialises the full block-scoped accumulator).
…[pt.3]

Plumbs ParentMetadata through block-validation so the validator can answer
"is this in-block parent confirmed?" for every tx in a block — not just the
level-1 children that have a direct UTXO-store hit AND not just the
subtree-being-validated, but every parent the candidate block contains.

Without this, in-block parents fall through to the UTXO-store BlockHeights
path, find them empty (the parent's blocks_transactions row is only written
by SetMinedMulti after the block is accepted), the validator stamps
unconfirmedParentHeight, and BDK rejects legitimate blocks with
bad-txns-unconfirmed-input-in-block.

Block-scoped accumulator spans all batches and the ordered-retry phases:

- CheckBlockSubtrees hoists a single map[chainhash.Hash]*ParentTxMetadata
  for the whole block, wrapped in a parentMetadataAccumulator composite
  (snapshot + delta — see below).
- processTransactionsInLevels / processMissingTransactions: per-tx wire
  bandwidth is bounded by filterParentMetadataForInputs, which returns
  only the accumulator entries referenced by the tx's input prevouts (nil
  when there are no matches, so the proto field is absent on the wire).
  After each level's g.Wait(), successful-tx hashes are merged into the
  accumulator under first-writer-wins semantics.
- Already-known txs that hit the cache/store pre-check (e.g. validated
  earlier via the peer-announced subtree path) are seeded into the
  accumulator at the candidate block's height BEFORE the missed==0 early
  return — closing the gap where a child of such a parent in the same
  block would otherwise fall through to the UTXO-store BlockHeights path
  and trigger bad-txns-unconfirmed-input-in-block.
- validateMissingSubtreesWithOrderedRetryAccumulated splits Phase 2
  (parallel) from Phase 3 (sequential) for the block-validation ordered
  retry. Phase 2 hands each goroutine a parentMetadataAccumulator with a
  SHARED read-only snapshot of the live accumulator + a small fresh
  per-subtree delta — no per-subtree full-copy. After g.Wait(), only
  fully-successful subtrees' (small) deltas merge into the live
  accumulator in block-subtree order. Failed-Phase-2 deltas are dropped;
  those subtrees retry in Phase 3 against the live accumulator
  sequentially. This preserves consensus determinism: failed-Phase-2
  partial writes never enter the accumulator.
- ValidateSubtreeInternal stays as a public shim over a private
  validateSubtreeInternalImpl that accepts the block accumulator; the
  public signature is unchanged.

Tests:
- TestProcessTransactionsInLevels/SeedsAlreadyKnownTxsIntoAccumulator —
  regression for the "parent already in store/cache, child missing in
  same block" scenario. Sanity-verified by temporarily disabling the
  seeding code; test correctly fails.
- TestParentMetadataAccumulatorAdd — first-writer-wins semantics
  (subsequent adds for the same hash are no-ops, regardless of whether
  the prior entry came from snapshot or delta).
- TestFilterParentMetadataForInputs — match/miss/empty/nil-tx/duplicate
  cases plus snapshot+delta-read-merge and delta-wins-over-snapshot.
- TestValidateMissingSubtreesWithOrderedRetryAccumulated_Phase2DeltasMergeAfterWait
  — pre-seeds the live accumulator and asserts every Phase 2 goroutine
  sees the seed via the shared snapshot AND enters with an empty delta
  (proves both snapshot sharing and delta isolation).
- TestValidateMissingSubtreesWithOrderedRetryAccumulated_FailedPhase2DoesNotContribute
  — pins that failed-Phase-2 partial contributions are dropped; Phase 3
  retry's contributions reach the live accumulator.
… wiring

Adds TestCheckBlockSubtrees_AssembledPath_SkipLevelAndMixedParent, an
end-to-end test that drives the full CheckBlockSubtrees pipeline (parse →
batch → level-order → per-tx filter → validator) against a candidate
block whose DAG exercises both shapes the consensus-split bug surfaced
through:

  - a skip-level grandparent dependency (C at level 2 spends both P AND
    G directly — the C → G edge crosses one level);
  - a mixed in-block + confirmed external parent (C also spends Gext,
    which lives in the UTXO store with BlockHeights=[99] and must NOT
    enter the block-scoped accumulator).

DAG: G has 2 outputs; P spends G.vout 0 (level 1); C spends P.vout 0 AND
G.vout 1 (level 2 — the G edge is the skip-level case) AND Gext.vout 0
(the confirmed external case). C also referencing P is load-bearing for
the skip-level claim — without that edge the level builder would place
C at level 1 alongside P, and a previous-level-only buildParentMetadata
implementation would still feed G to C correctly, making the test
vacuous.

The test guards that structural invariant directly: a pre-flight check
calls server.selectPrepareTxsPerLevel and asserts maxLevel=2 with
G@level 0, P@level 1, C@level 2. A future refactor of the level builder
or accidental DAG edit that re-collapses the skip-level shape is caught
before the per-tx assertions run.

A recording wrapper around MockValidatorClient captures the resolved
Options per-tx as a slice — BOTH processTransactionsInLevels (batch
loop) and the ordered-retry processMissingTransactions call
ValidateWithOptions for each tx, and the test asserts the accumulator
wiring is correct on BOTH pipelines, not just whichever one happened to
be called last. The wrapper also returns Data with TxInpoints populated
from NewTxInpointsFromTx(tx) so the downstream subtreeMeta serialisation
in validateSubtreeInternalImpl succeeds.

Assertions walk every recorded call:

  - G's ParentMetadata empty in every call (no in-block parent).
  - P's ParentMetadata[G] present at candidate height in every call.
  - C's ParentMetadata[G] present at candidate height in every call —
    the regression assertion that pins skip-level survival.
  - C's ParentMetadata does NOT contain Gext in any call — guards
    against an over-eager refactor that seeds confirmed externals at
    candidate height (which would break CSV/locktime).
  - response.Blessed == true.

Sanity-verified two ways:
  1. Blanked ParentMetadata in each of the two pipelines in turn — test
     fails for both, naming the at-fault pipeline by call index.
  2. Simulated a previous-level-only buildParentMetadata(txsPerLevel[level-1])
     behaviour in processTransactionsInLevels — test fails at the
     skip-level C → G assertion, the precise bug shape.

Closes the gap left after the accumulator commit: every component was
tested individually, but nothing drove the assembled orchestration
end-to-end. Guards against a future refactor silently re-opening the
consensus-split path even when component tests stay green.

Also strips reviewer-attribution wording from comments in
check_block_subtrees_test.go and parent_metadata_accumulator_test.go —
documentation describes behavior, not provenance.

Verified: go build, go vet, gofmt clean. Test passes; both sanity
teardowns correctly fail and name the at-fault pipeline.
@ctnguyen ctnguyen force-pushed the Feature/MvP-4632_drop2 branch from 6ddc7c9 to 07417af Compare June 5, 2026 08:33
@sonarqubecloud

sonarqubecloud Bot commented Jun 5, 2026

Copy link
Copy Markdown

@freemans13 freemans13 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving after a deep review of all three items (T13 consensus, T11 RPC absurd-fee, T1 doc).

T13 — sound. The consensus-rejection mechanism (internal unconfirmedParentHeight sentinel → BDK MEMPOOL_HEIGHT at the adapter boundary) is the right design over a post-hoc height scan. I traced the one path that could falsely reject a valid block — a mixed-parent child (in-block parent + confirmed parent, arriving non-extended) where the ParentMetadata candidate height could be clobbered by the sentinel in the extend=true fall-through. The PR already handles this correctly:

  • cameFromParentMetadata gate preserves the candidate height through extension (extension still runs for the tx body).
  • Full gRPC + HTTP wiring of parent_metadata (field 10) so a remote validator receives it; HTTP fallback now uses an application/x-protobuf body for field parity with gRPC.
  • parentMetadataFromWire fails closed on malformed/nil/wrong-length entries rather than silently dropping them — exactly right, since a dropped entry would re-trigger the false bad-txns-unconfirmed-input-in-block.
  • Block-scoped accumulator with cross-batch seeding (check_block_subtrees.go:991) closes the "parent validated in an earlier batch" hole; deterministic Phase-2 snapshot+delta merge ordering. Backed by substantial new tests.

T11 / T1 — fine as RPC-only / doc-only, well justified in the comments and commit bodies.

Non-blocking follow-ups:

  1. (LOW) The cross-batch seeding stamps every already-validated in-batch tx with the candidate blockHeight. Worth a one-line confirmation that txMetaSlice[i].isSet there can never be a prior-block-mined tx with real BlockHeights (benign post-Genesis regardless, since BIP68 short-circuits).
  2. (LOW) sendrawtransaction stores the tx blob before the absurd-fee gate, so a rejected absurd-fee tx leaves a dangling blob; and the default-on guard adds one UTXO round-trip + pre-extends the tx handed to the validator (functionally sound via the IsExtended() guards).

Please confirm CI is green on the current head given the proto was regenerated.

@ctnguyen ctnguyen merged commit 03cf4b8 into bsv-blockchain:main Jun 5, 2026
34 checks passed
@ctnguyen ctnguyen deleted the Feature/MvP-4632_drop2 branch June 5, 2026 13:06
oskarszoon added a commit to oskarszoon/teranode that referenced this pull request Jun 11, 2026
…r with candidate-height resolution

Replaces the per-block ParentMetadata accumulator (bsv-blockchain#1013) on the
block-validation path with the WithUnconfirmedParentsAtCandidateHeight
validator option (introduced for the legacy path in bsv-blockchain#1065), then deletes the
accumulator machinery.

In-block parents resolve the unconfirmedParentHeight sentinel to the candidate
block height inside validateTransaction — identical outcome to the accumulator
for genuine in-block parents, without the hot-path seeding, per-tx metadata
filtering, per-level mutex merges, or per-tx ParentMetadata wire payload. On a
5.24M-tx chained block this removes ~34% end-to-end overhead.

Safety:
- PoW (nBits + HasMetTargetDifficulty) now runs before subtree blessing in
  ValidateBlock, so the fail-open option cannot be triggered by a no-PoW header.
- Floater backstop: a parent that is unconfirmed and not in the block fails open
  at tx level, then block.Valid surfaces ErrBlockIncomplete. When caught up
  (FSM not CATCHINGBLOCKS/LEGACYSYNCING) that is a genuine floater
  (SetMinedMulti->MinedSet invariant), so the block is invalidated/rolled back at
  every acceptance sink (optimistic background, non-optimistic, revalidation
  worker); while syncing it stays incomplete and is retried, preserving bsv-blockchain#1031.
- Recorded parent BlockHeights are untouched (sentinel exact-match only), so
  fork/revalidation height resolution is unchanged.

Deprecates proto field 10 (parent_metadata) via reserved; deletes ParentTxMetadata.
Preserves the in_block provenance option (bsv-blockchain#1073, field 11); unconfirmed-parents
resolution stays field 12.

Closes bsv-blockchain#1066
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.

3 participants