Skip to content

fix(validator): synchronise mtpStore with sync.RWMutex#838

Merged
oskarszoon merged 4 commits into
bsv-blockchain:mainfrom
oskarszoon:fix/validator-script-hang
May 11, 2026
Merged

fix(validator): synchronise mtpStore with sync.RWMutex#838
oskarszoon merged 4 commits into
bsv-blockchain:mainfrom
oskarszoon:fix/validator-script-hang

Conversation

@oskarszoon

@oskarszoon oskarszoon commented May 9, 2026

Copy link
Copy Markdown
Contributor

TL;DR

Validator's in-memory Median Time Past cache (mtpStore) had no synchronisation. Two concurrent populate calls — followed by a third reader — could each see an empty cache, both fetch ~1.45 M entries, race on the append, and corrupt the slice. Once corrupted, the BIP68 guard in validateTransaction rejected every transaction, and ValidateSubtreeInternal's outer retry loop re-issued the 1.45 M-entry fetch on every retry — pegging CPU and stalling sync indefinitely.

This PR adds a sync.RWMutex around mtpStore, so populates serialise (the second caller fast-paths out instead of re-fetching) and readers don't observe torn state during a populate.

Why this only surfaced at one block on testnet

The bug is not block-specific — there is nothing about block 1,451,504 that triggers it. The trigger is a timing window during the first EnsureMTPLoaded call after validator startup, which exists at every block ≥ CSVHeight (testnet CSVHeight = 770,112).

What aligned on the testnet stall:

  1. The validator was cold-started (empty mtpStore) and was being driven by a legacy sync.
  2. The first legacy CheckSubtreeFromBlock RPC for a post-CSV block kicked off EnsureMTPLoaded, which had to fetch ~1.45 M MTP entries from the blockchain store. That fetch is the only slow operation in the call.
  3. While the fetch was still in flight, the legacy client's request timeout fired and it retried CheckSubtreeFromBlock. The retry entered EnsureMTPLoaded with len(mtpStore) still 0, started its own 1.45 M-entry fetch, and raced the original on the slice append.
  4. The corruption tripped the BIP68 height guard in validateTransaction. ValidateSubtreeInternal's outer retry loop re-issued the fetch on every retry — minutes per attempt — producing the multi-hour CPU peg observed on the box.

Block 1,451,504 is simply where the validator happened to be when these timings aligned. Mainnet has not surfaced it because mainnet validators tend to be long-lived (the cache populates once and the race window closes), and mainnet legacy peers are less prone to the retry-on-timeout pattern testnet sees.

Detail: the two races

  1. Same-block writer-writer. Two concurrent CheckSubtreeFromBlock RPCs (legacy retries a call while the first is still in flight) both observed an empty mtpStore, both fetched ~1.45 M MTP entries, and both raced on the append — corrupting the store. The corruption tripped the guard in validateTransaction, which surfaced an error to the outer retry loop in ValidateSubtreeInternal. Each retry repeated the 1.45 M-entry fetch, producing the multi-hour CPU peg and sync stall observed on testnet around block 1,451,504.
  2. Cross-block reader-writer. While block N's per-transaction goroutines read mtpStore inside validateTransaction, block N+1's EnsureMTPLoaded can run concurrently. The append re-allocates the backing array and the overlap patch mutates entries in place, so unsynchronised readers observe torn slice headers / cell values.

Fix

services/validator/Validator.go:

  • mtpMu sync.RWMutex on Validator.
  • EnsureMTPLoaded acquires the write lock for the full body. The second same-block caller fast-paths out after acquiring the lock when the range it needs is already populated (no second gRPC fetch).
  • validateTransaction reads the MTP values via a small readMTPsLocked helper that takes the read lock only for the lookups themselves and releases it before ValidateBIP68 runs. Cross-block writers serialise behind any in-flight readers; the per-tx ECDSA / sequence-lock work is unconstrained by the lock.

services/validator/Validator_test.go:

  • TestEnsureMTPLoaded_ConcurrentCallsNeitherHangsNorRaces: two same-block EnsureMTPLoaded calls against an empty store. Mock is .Once() and the test asserts mockClient.AssertExpectations(t), so a regression that re-fetches without racing (the production failure mode) fails the assertion. Fails under -race without the write lock; passes with it.
  • TestEnsureMTPLoaded_CrossBlockReadersAndWritersDoNotRace: eight readers indexing mtpStore via the validateTransaction pattern while a writer extends the store to a later block height. Fails under -race without the read lock; passes with it. Asserts mock expectations to defend against accidental over-fetch.

Test plan

  • go vet ./services/validator/... — clean
  • go test -race ./services/validator/ — 277 passed, 0 failed
  • go test -race -run TestEnsureMTPLoaded ./services/validator/ — both targeted race tests pass
  • golangci-lint run ./services/validator/... — clean
  • staticcheck ./services/validator/... — clean
  • Reviewer to confirm no other call sites read mtpStore outside validateTransaction (already grepped — only EnsureMTPLoaded itself touches it elsewhere, under the write lock)

Commits

  • cdec5b3d3 — original fix: sync.RWMutex + reader / writer locking + both race tests
  • d334b5f77 — review feedback: extract readMTPsLocked to drop the read lock before ValidateBIP68; tighten test mock expectations (Once() + AssertExpectations); replace require.* in goroutines with main-goroutine assertion

Notes for reviewer

The mtpStore field and EnsureMTPLoaded were introduced in #547 ("Re-implement BIP68"); the race has been latent since that PR.

EnsureMTPLoaded intentionally holds the write lock through GetMedianTimePastRange — releasing for the fetch reintroduces the same-block redundant-fetch race. The cold-start fetch (~1.45 M entries) runs once at startup before any per-tx readers; per-block extension fetches are tiny (12-overlap + new heights) and fast. A golang.org/x/sync/singleflight wrapper would be a cleaner alternative if read-blocking ever shows up in profiling. (Open thread: r3213721754.)

@github-actions

github-actions Bot commented May 9, 2026

Copy link
Copy Markdown
Contributor

🤖 Claude Code Review

Status: Complete

Summary

This PR correctly addresses a critical race condition in the validator's MTP cache. The implementation is sound and all previously raised review feedback has been addressed.

Code Quality: The synchronization is correctly implemented with sync.RWMutex, properly protecting both same-block writer-writer and cross-block reader-writer scenarios.

Test Coverage: Comprehensive race tests added covering both failure modes. Mock expectations properly enforce single-fetch behavior. All Copilot feedback addressed in commit d334b5f.

Documentation: Inline comments accurately describe the locking strategy and trade-offs. The PR description provides excellent context on root cause and timing.

No issues found. The fix is minimal, surgical, and correctly synchronized.


History:

  • ✅ All Copilot review feedback addressed by author in commit d334b5f
  • ✅ Verified proper RWMutex usage throughout
  • ✅ Confirmed test coverage for both race scenarios

Comment thread services/validator/Validator.go Outdated

This comment was marked as outdated.

Two distinct races on Validator.mtpStore:

  1. Same-block writer-writer. Two concurrent CheckSubtreeFromBlock RPCs
     (legacy retry while the first call is still in-flight) both reached
     EnsureMTPLoaded with an empty mtpStore. Both fetched ~1.45 M MTP
     values and raced on the append, corrupting the store. The corruption
     triggered the guard in validateTransaction, which returned an error
     and fed the outer retry loop in ValidateSubtreeInternal: each retry
     re-fetched 1.45 M entries, producing the multi-hour CPU peg and sync
     stall observed on testnet at block 1,451,504.

  2. Cross-block reader-writer. While block N's per-transaction goroutines
     read mtpStore inside validateTransaction, block N+1's EnsureMTPLoaded
     can run concurrently. Its append re-allocates the backing array and
     its overlap patch mutates entries in place, so unsynchronised readers
     observe torn slice headers / cell values.

Fix: introduce mtpMu sync.RWMutex on Validator.

  - EnsureMTPLoaded acquires the write lock for the full body. The second
    same-block caller fast-paths out after acquiring the lock when the
    range it needs is already populated.
  - validateTransaction acquires the read lock around its MTP lookups
    (length guard + utxo / block MTP fetches), so cross-block writers
    serialise behind in-flight readers.

Per-block contention is negligible: EnsureMTPLoaded runs once per block
before per-tx goroutines start, and per-tx readers only contend with each
other on the read lock.

Tests:

  - TestEnsureMTPLoaded_ConcurrentCallsNeitherHangsNorRaces fires two
    same-block EnsureMTPLoaded calls against an empty store; fails under
    -race without the write lock, passes with it.
  - TestEnsureMTPLoaded_CrossBlockReadersAndWritersDoNotRace runs eight
    reader goroutines indexing mtpStore via the validateTransaction
    pattern while a writer extends the store to a later block height;
    fails under -race without the read lock, passes with it.
@oskarszoon oskarszoon force-pushed the fix/validator-script-hang branch from 9ac5a94 to cdec5b3 Compare May 9, 2026 18:42
@oskarszoon oskarszoon changed the title fix(validator): serialise concurrent EnsureMTPLoaded callers fix(validator): synchronise mtpStore with sync.RWMutex May 9, 2026
@github-actions

github-actions Bot commented May 9, 2026

Copy link
Copy Markdown
Contributor

Benchmark Comparison Report

Baseline: main (unknown)

Current: PR-838 (58a8494)

Summary

  • Regressions: 0
  • Improvements: 0
  • Unchanged: 142
  • Significance level: p < 0.05
All benchmark results (sec/op)
Benchmark Baseline Current Change p-value
_NewBlockFromBytes-4 1.962µ 1.681µ ~ 0.100
SplitSyncedParentMap_SetIfNotExists/256_buckets-4 61.71n 61.67n ~ 1.000
SplitSyncedParentMap_SetIfNotExists/16_buckets-4 61.54n 61.82n ~ 0.200
SplitSyncedParentMap_SetIfNotExists/1_bucket-4 61.63n 61.60n ~ 1.000
SplitSyncedParentMap_ConcurrentSetIfNotExists/256_buckets... 30.39n 31.59n ~ 0.400
SplitSyncedParentMap_ConcurrentSetIfNotExists/16_buckets_... 52.31n 53.63n ~ 0.400
SplitSyncedParentMap_ConcurrentSetIfNotExists/1_bucket_pa... 115.1n 118.4n ~ 0.100
MiningCandidate_Stringify_Short-4 263.3n 264.1n ~ 0.400
MiningCandidate_Stringify_Long-4 1.918µ 1.932µ ~ 0.200
MiningSolution_Stringify-4 988.4n 986.7n ~ 0.400
BlockInfo_MarshalJSON-4 1.791µ 1.804µ ~ 0.700
NewFromBytes-4 125.6n 140.9n ~ 0.100
Mine_EasyDifficulty-4 60.52µ 60.15µ ~ 0.100
Mine_WithAddress-4 6.756µ 6.741µ ~ 0.100
DirectSubtreeAdd/4_per_subtree-4 60.58n 61.30n ~ 0.700
DirectSubtreeAdd/64_per_subtree-4 31.42n 32.10n ~ 0.400
DirectSubtreeAdd/256_per_subtree-4 30.25n 30.35n ~ 0.200
DirectSubtreeAdd/1024_per_subtree-4 29.16n 29.14n ~ 0.900
DirectSubtreeAdd/2048_per_subtree-4 28.67n 28.77n ~ 0.400
SubtreeProcessorAdd/4_per_subtree-4 278.3n 283.2n ~ 0.400
SubtreeProcessorAdd/64_per_subtree-4 276.1n 278.6n ~ 0.100
SubtreeProcessorAdd/256_per_subtree-4 274.0n 277.0n ~ 0.100
SubtreeProcessorAdd/1024_per_subtree-4 265.3n 269.4n ~ 0.100
SubtreeProcessorAdd/2048_per_subtree-4 264.5n 268.4n ~ 0.200
SubtreeProcessorRotate/4_per_subtree-4 271.1n 273.6n ~ 0.700
SubtreeProcessorRotate/64_per_subtree-4 269.3n 272.2n ~ 0.100
SubtreeProcessorRotate/256_per_subtree-4 270.1n 270.9n ~ 1.000
SubtreeProcessorRotate/1024_per_subtree-4 274.8n 270.2n ~ 0.100
SubtreeNodeAddOnly/4_per_subtree-4 54.29n 54.25n ~ 0.200
SubtreeNodeAddOnly/64_per_subtree-4 34.42n 34.51n ~ 0.700
SubtreeNodeAddOnly/256_per_subtree-4 33.44n 33.41n ~ 0.400
SubtreeNodeAddOnly/1024_per_subtree-4 32.66n 32.76n ~ 1.000
SubtreeCreationOnly/4_per_subtree-4 111.3n 112.9n ~ 0.100
SubtreeCreationOnly/64_per_subtree-4 392.0n 394.8n ~ 0.400
SubtreeCreationOnly/256_per_subtree-4 1.319µ 1.333µ ~ 0.100
SubtreeCreationOnly/1024_per_subtree-4 4.378µ 4.357µ ~ 0.700
SubtreeCreationOnly/2048_per_subtree-4 7.889µ 8.048µ ~ 0.100
SubtreeProcessorOverheadBreakdown/64_per_subtree-4 268.2n 269.8n ~ 0.100
SubtreeProcessorOverheadBreakdown/1024_per_subtree-4 268.0n 268.7n ~ 0.400
ParallelGetAndSetIfNotExists/1k_nodes-4 578.3µ 806.6µ ~ 0.100
ParallelGetAndSetIfNotExists/10k_nodes-4 1.302m 1.558m ~ 0.100
ParallelGetAndSetIfNotExists/50k_nodes-4 6.709m 6.689m ~ 0.700
ParallelGetAndSetIfNotExists/100k_nodes-4 13.48m 13.41m ~ 0.700
SequentialGetAndSetIfNotExists/1k_nodes-4 657.2µ 652.4µ ~ 0.700
SequentialGetAndSetIfNotExists/10k_nodes-4 2.772m 2.735m ~ 0.400
SequentialGetAndSetIfNotExists/50k_nodes-4 10.33m 10.38m ~ 0.400
SequentialGetAndSetIfNotExists/100k_nodes-4 19.86m 20.06m ~ 0.700
ProcessOwnBlockSubtreeNodesParallel/1k_nodes-4 633.3µ 623.2µ ~ 0.100
ProcessOwnBlockSubtreeNodesParallel/10k_nodes-4 4.213m 4.158m ~ 0.400
ProcessOwnBlockSubtreeNodesParallel/100k_nodes-4 16.64m 16.45m ~ 0.100
ProcessOwnBlockSubtreeNodesSequential/1k_nodes-4 692.1µ 685.8µ ~ 0.400
ProcessOwnBlockSubtreeNodesSequential/10k_nodes-4 5.674m 5.712m ~ 0.100
ProcessOwnBlockSubtreeNodesSequential/100k_nodes-4 37.23m 37.17m ~ 0.700
DiskTxMap_SetIfNotExists-4 3.723µ 4.100µ ~ 0.400
DiskTxMap_SetIfNotExists_Parallel-4 3.540µ 3.725µ ~ 0.100
DiskTxMap_ExistenceOnly-4 324.2n 362.8n ~ 0.100
Queue-4 194.1n 196.6n ~ 0.100
AtomicPointer-4 4.625n 4.608n ~ 1.000
ReorgOptimizations/DedupFilterPipeline/Old/10K-4 894.3µ 876.4µ ~ 0.400
ReorgOptimizations/DedupFilterPipeline/New/10K-4 874.7µ 865.7µ ~ 1.000
ReorgOptimizations/AllMarkFalse/Old/10K-4 121.5µ 114.4µ ~ 0.100
ReorgOptimizations/AllMarkFalse/New/10K-4 62.86µ 62.46µ ~ 0.700
ReorgOptimizations/HashSlicePool/Old/10K-4 73.76µ 66.44µ ~ 0.700
ReorgOptimizations/HashSlicePool/New/10K-4 11.45µ 11.35µ ~ 0.400
ReorgOptimizations/NodeFlags/Old/10K-4 6.540µ 5.457µ ~ 0.100
ReorgOptimizations/NodeFlags/New/10K-4 2.713µ 1.848µ ~ 0.100
ReorgOptimizations/DedupFilterPipeline/Old/100K-4 10.77m 10.71m ~ 1.000
ReorgOptimizations/DedupFilterPipeline/New/100K-4 10.56m 10.40m ~ 1.000
ReorgOptimizations/AllMarkFalse/Old/100K-4 1.189m 1.205m ~ 1.000
ReorgOptimizations/AllMarkFalse/New/100K-4 682.7µ 684.3µ ~ 0.200
ReorgOptimizations/HashSlicePool/Old/100K-4 717.6µ 704.1µ ~ 0.100
ReorgOptimizations/HashSlicePool/New/100K-4 311.9µ 346.1µ ~ 0.200
ReorgOptimizations/NodeFlags/Old/100K-4 55.91µ 58.94µ ~ 0.100
ReorgOptimizations/NodeFlags/New/100K-4 19.41µ 21.18µ ~ 0.100
TxMapSetIfNotExists-4 52.22n 51.99n ~ 0.700
TxMapSetIfNotExistsDuplicate-4 37.94n 38.16n ~ 0.100
ChannelSendReceive-4 630.0n 631.1n ~ 1.000
BlockAssembler_AddTx-4 0.02269n 0.02162n ~ 1.000
AddNode-4 9.036 8.846 ~ 0.700
AddNodeWithMap-4 8.683 8.551 ~ 1.000
CalcBlockWork-4 499.6n 501.0n ~ 0.700
CalculateWork-4 682.9n 686.5n ~ 0.400
BuildBlockLocatorString_Helpers/Size_10-4 1.342µ 1.527µ ~ 0.700
BuildBlockLocatorString_Helpers/Size_100-4 12.79µ 12.67µ ~ 0.100
BuildBlockLocatorString_Helpers/Size_1000-4 150.5µ 125.2µ ~ 0.700
CatchupWithHeaderCache-4 104.3m 104.3m ~ 0.700
_BufferPoolAllocation/16KB-4 3.369µ 3.498µ ~ 0.200
_BufferPoolAllocation/32KB-4 8.737µ 7.864µ ~ 1.000
_BufferPoolAllocation/64KB-4 18.09µ 14.82µ ~ 0.100
_BufferPoolAllocation/128KB-4 28.98µ 30.56µ ~ 0.100
_BufferPoolAllocation/512KB-4 112.9µ 105.1µ ~ 0.100
_BufferPoolConcurrent/32KB-4 19.28µ 18.65µ ~ 0.700
_BufferPoolConcurrent/64KB-4 28.69µ 27.94µ ~ 0.100
_BufferPoolConcurrent/512KB-4 143.1µ 144.5µ ~ 0.700
_SubtreeDeserializationWithBufferSizes/16KB-4 654.0µ 629.3µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/32KB-4 639.1µ 635.4µ ~ 0.700
_SubtreeDeserializationWithBufferSizes/64KB-4 630.3µ 634.7µ ~ 0.400
_SubtreeDeserializationWithBufferSizes/128KB-4 626.8µ 629.1µ ~ 0.400
_SubtreeDeserializationWithBufferSizes/512KB-4 659.2µ 630.6µ ~ 0.100
_SubtreeDataDeserializationWithBufferSizes/16KB-4 35.54m 36.36m ~ 0.200
_SubtreeDataDeserializationWithBufferSizes/32KB-4 35.37m 35.64m ~ 0.100
_SubtreeDataDeserializationWithBufferSizes/64KB-4 35.60m 35.92m ~ 0.100
_SubtreeDataDeserializationWithBufferSizes/128KB-4 35.19m 35.70m ~ 0.400
_SubtreeDataDeserializationWithBufferSizes/512KB-4 35.12m 35.05m ~ 0.700
_PooledVsNonPooled/Pooled-4 737.7n 741.0n ~ 0.100
_PooledVsNonPooled/NonPooled-4 7.113µ 7.210µ ~ 0.700
_MemoryFootprint/Current_512KB_32concurrent-4 6.956µ 6.929µ ~ 0.700
_MemoryFootprint/Proposed_32KB_32concurrent-4 10.005µ 9.702µ ~ 0.400
_MemoryFootprint/Alternative_64KB_32concurrent-4 9.771µ 8.909µ ~ 0.100
_prepareTxsPerLevel-4 410.1m 406.8m ~ 1.000
_prepareTxsPerLevelOrdered-4 3.531m 3.628m ~ 0.400
_prepareTxsPerLevel_Comparison/Original-4 420.9m 422.4m ~ 0.700
_prepareTxsPerLevel_Comparison/Optimized-4 3.490m 3.798m ~ 0.100
SubtreeSizes/10k_tx_4_per_subtree-4 1.345m 1.338m ~ 0.400
SubtreeSizes/10k_tx_16_per_subtree-4 320.6µ 319.9µ ~ 0.700
SubtreeSizes/10k_tx_64_per_subtree-4 76.07µ 75.10µ ~ 0.400
SubtreeSizes/10k_tx_256_per_subtree-4 19.07µ 18.99µ ~ 0.400
SubtreeSizes/10k_tx_512_per_subtree-4 9.466µ 9.409µ ~ 0.400
SubtreeSizes/10k_tx_1024_per_subtree-4 4.742µ 4.671µ ~ 0.700
SubtreeSizes/10k_tx_2k_per_subtree-4 2.352µ 2.340µ ~ 0.700
BlockSizeScaling/10k_tx_64_per_subtree-4 75.48µ 73.92µ ~ 0.200
BlockSizeScaling/10k_tx_256_per_subtree-4 18.79µ 18.72µ ~ 0.700
BlockSizeScaling/10k_tx_1024_per_subtree-4 4.689µ 4.706µ ~ 0.700
BlockSizeScaling/50k_tx_64_per_subtree-4 394.0µ 400.7µ ~ 0.400
BlockSizeScaling/50k_tx_256_per_subtree-4 94.61µ 92.34µ ~ 0.100
BlockSizeScaling/50k_tx_1024_per_subtree-4 23.67µ 23.07µ ~ 0.700
SubtreeAllocations/small_subtrees_exists_check-4 160.3µ 156.9µ ~ 0.700
SubtreeAllocations/small_subtrees_data_fetch-4 163.0µ 163.5µ ~ 1.000
SubtreeAllocations/small_subtrees_full_validation-4 328.6µ 330.8µ ~ 0.400
SubtreeAllocations/medium_subtrees_exists_check-4 9.302µ 9.427µ ~ 0.700
SubtreeAllocations/medium_subtrees_data_fetch-4 9.719µ 9.732µ ~ 1.000
SubtreeAllocations/medium_subtrees_full_validation-4 18.88µ 19.09µ ~ 0.400
SubtreeAllocations/large_subtrees_exists_check-4 2.220µ 2.269µ ~ 0.300
SubtreeAllocations/large_subtrees_data_fetch-4 2.364µ 2.356µ ~ 1.000
SubtreeAllocations/large_subtrees_full_validation-4 4.775µ 4.765µ ~ 1.000
StoreBlock_Sequential/BelowCSVHeight-4 329.1µ 328.7µ ~ 0.400
StoreBlock_Sequential/AboveCSVHeight-4 342.4µ 332.7µ ~ 1.000
GetUtxoHashes-4 279.9n 269.0n ~ 0.200
GetUtxoHashes_ManyOutputs-4 45.97µ 46.29µ ~ 0.400
_NewMetaDataFromBytes-4 229.9n 232.5n ~ 0.800
_Bytes-4 608.8n 608.1n ~ 1.000
_MetaBytes-4 561.2n 568.5n ~ 0.700

Threshold: >10% with p < 0.05 | Generated: 2026-05-09 20:05 UTC

This comment was marked as outdated.

oskarszoon added 3 commits May 9, 2026 21:11
- validateTransaction releases the read lock immediately after the
  mtpStore lookups (extracted to readMTPsLocked) instead of holding it
  through ValidateBIP68. Reduces contention with cross-block writers.
- Same-block race test now asserts exactly-one fetch via Once() +
  AssertExpectations, so a regression that re-fetches without racing
  (the production failure mode) is caught.
- Cross-block race test asserts EnsureMTPLoaded errors from the main
  goroutine instead of via require.* in a child goroutine (require uses
  Goexit which only stops the goroutine it runs in), and asserts mock
  expectations to defend against accidental over-fetch.
Three unit tests for the readMTPsLocked helper extracted in d334b5f:

- TestReadMTPsLocked_HappyPath: returns the expected per-input MTPs and
  block MTP for a fully populated store.
- TestReadMTPsLocked_ClampsOutOfRangeUTXOs: utxoHeights at or above the
  store length fall back to the blockMTP value (matches production
  behaviour for unconfirmed parents whose effective height exceeds
  blockMTPHeight).
- TestReadMTPsLocked_GuardFiresOnUnpopulatedStore: the missing-load guard
  returns a processing error when mtpStore is too short for the
  requested blockMTPHeight.

Lifts coverage on the new code added by the mutex / readMTPsLocked
refactor.
Two unit tests driving validateTransaction through the BIP68 branch:

- TestValidateTransaction_BIP68PathReadsMTPStore: exercises the
  SkipPolicyChecks=true path with a populated mtpStore so the
  readMTPsLocked call site, error wiring, and ValidateBIP68 invocation
  are all covered.
- TestValidateTransaction_BIP68GuardFiresOnUnpopulatedStore: drives
  validateTransaction with an empty mtpStore so the missing-load guard
  surfaces a processing error to the caller.

Together with the readMTPsLocked unit tests, lifts coverage on the new
code introduced by the mutex / readMTPsLocked refactor above the Sonar
quality-gate threshold.
@sonarqubecloud

sonarqubecloud Bot commented May 9, 2026

Copy link
Copy Markdown

@oskarszoon oskarszoon merged commit b55e1f1 into bsv-blockchain:main May 11, 2026
25 checks passed
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.

4 participants