Skip to content

fix(legacy): clear stall deadlines across BlockPriority streams#1030

Merged
oskarszoon merged 8 commits into
bsv-blockchain:mainfrom
oskarszoon:fix/legacy-scall
Jun 5, 2026
Merged

fix(legacy): clear stall deadlines across BlockPriority streams#1030
oskarszoon merged 8 commits into
bsv-blockchain:mainfrom
oskarszoon:fix/legacy-scall

Conversation

@oskarszoon

@oskarszoon oskarszoon commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

Problem

Legacy sync stalls on large blocks once legacy_allowBlockPriority is enabled. After BlockPriority negotiates and the DATA1 stream opens, headers download, but the sync peer is then disconnected mid block-download. There are two distinct stall mechanisms; this PR fixes both.

Both share a root cause: under the BlockPriority stream policy, block/header replies arrive on the DATA1 stream (a separate Peer from the GENERAL sync peer), and every progress signal the watchdogs relied on was message-granular — so a multi-GB block that takes minutes to stream in registers as "no progress" until it fully completes.

Stall 1 — per-message stall deadlines orphaned across streams

peer.go's stall handler arms a response deadline on the peer that sent the request (GENERAL), but BlockPriority delivers the response on DATA1 (a different Peer with its own handler). The GENERAL deadline is never cleared → spurious "<cmd> timeout" (observed: a 90s headers timeout ~90s into a 1.1GB block at height 804,008).

Fix:

  • Cross-stream clearsignalReceived fans the receive/clear to every stream peer of the association (always notifies self, then siblings; quit-guarded).
  • Block-in-flight suppressionexpiredStallResponse does not treat non-block deadlines (headers, inv) as stalls while a block response is pending (headers reply is head-of-line blocked behind the block on DATA1).
  • Handoff refresh — on a genuine block completion, queued deadlines are refreshed to their full per-command budget (responseStallBudget); gated on a block actually having been pending so unsolicited tx relay can't defer a stalled getheaders.

The "extended/chunked block fetch" theory was ruled out: wire.SetLimits(4e9) already permits ~4GB blocks, a <4GB block is a single normal block message (extmsg framing only triggers above 4GB per bitcoin-sv protocol.cpp), and the BSV protoconf spec exempts block/cmpctblock from maxRecvPayloadLength. No wire-protocol change needed.

Stall 2 — netsync sync-peer rotation during a fat-block download

netsync's handleCheckSyncPeer rotates the sync peer when no block completes within maxLastBlockTime (3 min); in headers-first mode it skips only the speed check, not this one. A ~3GB block at height 804,157 streams in on DATA1 and completes no block within 3 min, so the healthy, actively-downloading peer is disconnected (updateSyncPeer - disconnect old sync peer), the header state is reset to the pre-block height, and the next peer repeats the loop forever (lastBlockTime only updates on a completed block; updateNetwork sampled only the GENERAL peer's bytes, excluding DATA1).

Fix — byte-granular download progress:

  • activityConn wraps the peer conn so each read advances a byte-granular readBytes counter (and refreshes lastRecv) even while one large message is still streaming in. Association.ReadBytes sums it across all streams.
  • netsync samples association readBytes per tick and suppresses the last-block-time rotation while association throughput is healthy (≥ minSyncPeerNetworkSpeed, 50 KiB/s default — above chatter, far below block-transfer rates).
  • peer.go extends the block deadline (instead of disconnecting) while a block response is pending and association throughput is healthy, so a block that legitimately takes longer than stallResponseTimeoutBlocks (5 min) still completes.

Abuse bound (MaxBlockDownloadTime, 30 min)

healthyDownload is gauged from association-wide read bytes, not block-stream bytes specifically — so a peer could in principle fake ≥50 KiB/s of inv/addr/ping filler without ever delivering a valid block. To stop that holding the single sync-peer slot indefinitely, both layers cap the throughput-based reprieve at an absolute wall-clock window (peer.MaxBlockDownloadTime, 30 min). Past it the peer is disconnected (peer layer) / rotated (netsync) regardless of throughput. So: a peer delivering no throughput is dropped immediately by the existing deadlines; a filler-faking peer is dropped within ~30 min. Honest fat blocks are unaffected (a 4 GB block need only average ~2.3 MB/s; the verified 1.1 GB block completed in <3 min on mainnet). Unsigned throughput-delta subtractions are guarded against a stream being removed mid-window.

Stall 3 — sync-peer rotated mid block-validation

Even with Stall 2 fixed, a fat block that finishes downloading is then validated for minutes inside HandleBlockDirect (extend txs / createUtxos / validate / subtree writes — ~2m17s for the 3GB block 804,157). During that window the sync peer is idle (download done → 0 throughput) and updateLastBlockTime had not refreshed because (a) it fired only at block accept, after validation, and (b) it was gated on peer == syncPeer — but the block arrives on the DATA1 stream peer, a different Peer from the GENERAL sync peer, so under multistream it never fired at all. Result: the 3-min maxLastBlockTime check rotated a healthy, just-delivered sync peer mid-validation (updateSyncPeer - disconnect old sync peer), churning to a slower peer and re-downloading headers per fat block. Non-fatal (the block was already downloaded, so it still got accepted), but wasteful.

Fix:

  • syncPeerStateFor matches the current sync peer or any stream of its association, so a block delivered on DATA1 counts as the sync peer's.
  • The sync peer's last-block time is refreshed at block receipt (HandleBlockDirect entry) as well as at accept, so the minutes-long validation isn't mistaken for a stall.

Residual: a block whose validation alone exceeds maxLastBlockTime (3 min) could still trip a mid-validation rotation (804,157 validated in 2m17s, under the bar); left as a follow-up if observed on larger blocks.

Default flip

legacy_allowBlockPriority now defaults to true (settings.conf, code default, struct tag) — required for reliable large-block download. The struct-tag description was also corrected (it wrongly described "block priority hints" rather than the multistream stream policy). Fallback for non-BlockPriority peers is covered by TestMultistreamBackwardCompatibility / TestMultistreamOnlyStandardPeer / TestMultistreamDisabledRejectsConnection.

This is the highest-blast-radius change here (it alters P2P negotiation for every node on upgrade) and is kept independently revertible from the bug-fix logic — set legacy_allowBlockPriority = false to back it out without reverting the stall fixes. Worth release-note visibility.

Verification

  • Unit: cross-stream fan-out (red→green), suppression matrix, per-command refresh budget, unsolicited-tx-does-not-refresh, self-notify teardown edge, hasHealthyDownloadThroughput matrix, CheckSyncPeer keeps an actively-downloading peer, activityConn byte counting, Association.ReadBytes sum.
  • -race on services/legacy/peer, services/legacy/netsync, settings; go vet, gofmt, go build ./... clean.
  • E2E multistream suite (TestMultistreamLegacySync + siblings) passes; no stall-timeout regressions.
  • Mainnet: the 1.1GB fat block at height 804,008 synced end-to-end with the Stall 1 fix. The ~3GB block at 804,157 then synced end-to-end with Stalls 1+2 deployed — it downloaded in ~5.6 min with no rotation during download, was accepted, and sync advanced to 804,158. The mid-validation rotation observed on 804,157 (download already complete, so non-fatal) is what Stall 3 fixes. Continued mainnet observation through the fat-block range before merge.

CI note: the legacy-sync job failure is a pre-existing flake (fixed-port bind in TestLegacyTxBroadcast942 → nil-deref panic in Server.Stop), reproduces on unrelated branches, tracked in #1032.

Under the BlockPriority multistream policy a peer is split into two TCP
connections (GENERAL + DATA1), each a separate Peer with its own stall
handler and pendingResponses map. Teranode sends getheaders/getdata on
GENERAL, arming the response deadline there, while the svnode delivers
the headers/block reply on DATA1. The GENERAL deadline was never cleared
by the DATA1-delivered response, so the sync peer false-disconnected
with a spurious "headers timeout" ~90s into a large block download.

Changes:
- signalReceived() fans the receive/clear signal out to every stream
  peer in the association, so a response on any stream clears the
  deadline armed on another. Non-association peers notify only
  themselves, preserving single-stream behaviour. Sends are guarded by
  each target's quit channel.
- expiredStallResponse() suppresses non-block deadlines while a block
  fetch is in flight: headers and blocks share the DATA1 stream, so a
  follow-on getheaders reply is head-of-line blocked behind a multi-GB
  block. Liveness during a block fetch stays gated by the block's own
  (much longer) deadline.
- On block receipt, queued deadlines are refreshed (extend-only) to
  close the handoff window once the block stops suppressing them.
- Enable legacy_allowBlockPriority by default (settings.conf, code
  default and struct tag) - required to download large mainnet blocks
  reliably - and correct the struct-tag description, which wrongly
  described "block priority hints" instead of the multistream policy.

Investigation note: the earlier "extended/chunked block fetch"
hypothesis was ruled out. wire.SetLimits(4e9) already allows ~4GB
blocks, a 1.1GB block is a single normal block message (extmsg framing
only triggers above 4GB per bitcoin-sv protocol.cpp), and the BSV
protoconf spec exempts block/cmpctblock from maxRecvPayloadLength. No
wire-protocol change is needed.

Verified: unit tests for the cross-stream fan-out (red->green) and the
suppression matrix; -race on peer/netsync/settings; e2e multistream
sync (TestMultistreamLegacySync and the multistream suite) with no
stall-timeout regressions.
@github-actions

github-actions Bot commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

🤖 Claude Code Review

Status: Complete

Current Review:

This PR fixes two critical legacy sync stalls triggered when downloading large blocks with BlockPriority enabled. The implementation is thorough and well-tested.

No issues found. The code demonstrates careful engineering:

  • Comprehensive test coverage (7 new test files with edge cases)
  • Detailed inline documentation explaining the problem, fix, and abuse bounds
  • Minimal, surgical changes with clear separation of concerns
  • The default flip to legacy_allowBlockPriority = true is reversible without rolling back bug fixes

All changes align with project conventions from CLAUDE.md and the PR description accurately describes the implementation.

The post-block deadline refresh reset every queued response to the 30s
base (stallResponseTimeout), but getheaders is granted 90s
(stallResponseTimeout*3) in maybeAddDeadline. A headers reply
head-of-line blocked behind a block was therefore refreshed to 30s
instead of its 90s budget. Practically safe (the response is already
produced and queued behind the block, so only drain latency remains),
but make it obviously correct: introduce responseStallBudget(cmd) and
refresh each response to its own allowance.
@github-actions

github-actions Bot commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

Benchmark Comparison Report

Baseline: main (unknown)

Current: PR-1030 (21b1f8e)

Summary

  • Regressions: 0
  • Improvements: 0
  • Unchanged: 144
  • Significance level: p < 0.05
All benchmark results (sec/op)
Benchmark Baseline Current Change p-value
_NewBlockFromBytes-4 1.748µ 1.746µ ~ 1.000
SplitSyncedParentMap_SetIfNotExists/256_buckets-4 61.69n 61.66n ~ 0.200
SplitSyncedParentMap_SetIfNotExists/16_buckets-4 61.69n 61.74n ~ 0.400
SplitSyncedParentMap_SetIfNotExists/1_bucket-4 61.82n 61.75n ~ 0.200
SplitSyncedParentMap_ConcurrentSetIfNotExists/256_buckets... 33.16n 29.94n ~ 0.100
SplitSyncedParentMap_ConcurrentSetIfNotExists/16_buckets_... 54.22n 52.13n ~ 0.200
SplitSyncedParentMap_ConcurrentSetIfNotExists/1_bucket_pa... 118.7n 118.8n ~ 1.000
MiningCandidate_Stringify_Short-4 262.9n 262.6n ~ 1.000
MiningCandidate_Stringify_Long-4 1.899µ 1.905µ ~ 0.200
MiningSolution_Stringify-4 960.9n 967.2n ~ 0.700
BlockInfo_MarshalJSON-4 1.794µ 1.801µ ~ 0.200
NewFromBytes-4 129.9n 143.6n ~ 0.700
AddTxBatchColumnar_Validation-4 2.536µ 2.493µ ~ 1.000
OffsetValidationLoop-4 639.4n 637.2n ~ 1.000
Mine_EasyDifficulty-4 61.01µ 60.60µ ~ 0.400
Mine_WithAddress-4 6.895µ 7.242µ ~ 0.400
BlockAssembler_AddTx-4 0.02637n 0.02818n ~ 1.000
AddNode-4 11.65 11.34 ~ 0.400
AddNodeWithMap-4 12.48 13.10 ~ 0.200
DirectSubtreeAdd/4_per_subtree-4 58.24n 59.43n ~ 0.700
DirectSubtreeAdd/64_per_subtree-4 28.85n 28.92n ~ 0.300
DirectSubtreeAdd/256_per_subtree-4 28.20n 27.80n ~ 0.400
DirectSubtreeAdd/1024_per_subtree-4 26.51n 26.53n ~ 0.700
DirectSubtreeAdd/2048_per_subtree-4 26.05n 26.07n ~ 1.000
SubtreeProcessorAdd/4_per_subtree-4 294.7n 293.4n ~ 1.000
SubtreeProcessorAdd/64_per_subtree-4 289.6n 285.4n ~ 0.100
SubtreeProcessorAdd/256_per_subtree-4 288.6n 283.4n ~ 0.400
SubtreeProcessorAdd/1024_per_subtree-4 280.9n 275.2n ~ 0.400
SubtreeProcessorAdd/2048_per_subtree-4 281.7n 276.8n ~ 0.100
SubtreeProcessorRotate/4_per_subtree-4 282.2n 281.1n ~ 0.700
SubtreeProcessorRotate/64_per_subtree-4 279.7n 281.9n ~ 0.200
SubtreeProcessorRotate/256_per_subtree-4 277.7n 279.2n ~ 0.200
SubtreeProcessorRotate/1024_per_subtree-4 278.0n 279.1n ~ 0.100
SubtreeNodeAddOnly/4_per_subtree-4 55.36n 54.92n ~ 0.200
SubtreeNodeAddOnly/64_per_subtree-4 36.29n 36.01n ~ 0.100
SubtreeNodeAddOnly/256_per_subtree-4 35.17n 35.18n ~ 1.000
SubtreeNodeAddOnly/1024_per_subtree-4 34.66n 34.43n ~ 0.200
SubtreeCreationOnly/4_per_subtree-4 111.2n 109.8n ~ 0.200
SubtreeCreationOnly/64_per_subtree-4 352.2n 346.1n ~ 0.200
SubtreeCreationOnly/256_per_subtree-4 1.230µ 1.223µ ~ 0.700
SubtreeCreationOnly/1024_per_subtree-4 3.932µ 3.799µ ~ 0.700
SubtreeCreationOnly/2048_per_subtree-4 6.802µ 6.801µ ~ 0.700
SubtreeProcessorOverheadBreakdown/64_per_subtree-4 279.2n 277.3n ~ 0.200
SubtreeProcessorOverheadBreakdown/1024_per_subtree-4 283.8n 279.6n ~ 0.100
ParallelGetAndSetIfNotExists/1k_nodes-4 1.977m 2.018m ~ 0.100
ParallelGetAndSetIfNotExists/10k_nodes-4 5.120m 5.256m ~ 0.100
ParallelGetAndSetIfNotExists/50k_nodes-4 7.034m 7.303m ~ 0.100
ParallelGetAndSetIfNotExists/100k_nodes-4 9.677m 9.718m ~ 1.000
SequentialGetAndSetIfNotExists/1k_nodes-4 1.776m 1.816m ~ 0.100
SequentialGetAndSetIfNotExists/10k_nodes-4 4.435m 4.656m ~ 0.100
SequentialGetAndSetIfNotExists/50k_nodes-4 13.84m 13.98m ~ 0.400
SequentialGetAndSetIfNotExists/100k_nodes-4 24.90m 25.58m ~ 0.100
ProcessOwnBlockSubtreeNodesParallel/1k_nodes-4 2.086m 2.140m ~ 0.400
ProcessOwnBlockSubtreeNodesParallel/10k_nodes-4 8.338m 8.607m ~ 0.100
ProcessOwnBlockSubtreeNodesParallel/100k_nodes-4 13.15m 13.56m ~ 0.100
ProcessOwnBlockSubtreeNodesSequential/1k_nodes-4 1.793m 1.848m ~ 0.400
ProcessOwnBlockSubtreeNodesSequential/10k_nodes-4 8.171m 8.111m ~ 1.000
ProcessOwnBlockSubtreeNodesSequential/100k_nodes-4 43.47m 44.52m ~ 0.100
DiskTxMap_SetIfNotExists-4 3.667µ 3.604µ ~ 1.000
DiskTxMap_SetIfNotExists_Parallel-4 3.388µ 3.345µ ~ 0.700
DiskTxMap_ExistenceOnly-4 316.9n 312.3n ~ 0.100
Queue-4 185.3n 185.7n ~ 0.600
AtomicPointer-4 3.661n 3.621n ~ 0.100
ReorgOptimizations/DedupFilterPipeline/Old/10K-4 891.0µ 815.9µ ~ 0.100
ReorgOptimizations/DedupFilterPipeline/New/10K-4 804.0µ 753.3µ ~ 0.100
ReorgOptimizations/AllMarkFalse/Old/10K-4 109.1µ 108.9µ ~ 1.000
ReorgOptimizations/AllMarkFalse/New/10K-4 64.11µ 64.55µ ~ 0.100
ReorgOptimizations/HashSlicePool/Old/10K-4 58.27µ 61.44µ ~ 0.700
ReorgOptimizations/HashSlicePool/New/10K-4 10.95µ 11.14µ ~ 0.400
ReorgOptimizations/NodeFlags/Old/10K-4 5.346µ 4.555µ ~ 0.100
ReorgOptimizations/NodeFlags/New/10K-4 2.371µ 1.605µ ~ 0.100
ReorgOptimizations/DedupFilterPipeline/Old/100K-4 9.810m 9.310m ~ 0.100
ReorgOptimizations/DedupFilterPipeline/New/100K-4 9.605m 9.806m ~ 0.100
ReorgOptimizations/AllMarkFalse/Old/100K-4 1.150m 1.083m ~ 0.100
ReorgOptimizations/AllMarkFalse/New/100K-4 705.1µ 704.4µ ~ 1.000
ReorgOptimizations/HashSlicePool/Old/100K-4 537.5µ 491.8µ ~ 0.700
ReorgOptimizations/HashSlicePool/New/100K-4 193.1µ 195.2µ ~ 0.100
ReorgOptimizations/NodeFlags/Old/100K-4 49.83µ 48.15µ ~ 0.400
ReorgOptimizations/NodeFlags/New/100K-4 16.84µ 16.70µ ~ 0.700
TxMapSetIfNotExists-4 49.43n 49.50n ~ 0.700
TxMapSetIfNotExistsDuplicate-4 41.30n 41.28n ~ 0.800
ChannelSendReceive-4 600.4n 582.8n ~ 0.100
CalcBlockWork-4 501.1n 505.5n ~ 1.000
CalculateWork-4 684.5n 685.8n ~ 1.000
BuildBlockLocatorString_Helpers/Size_10-4 1.426µ 1.678µ ~ 0.100
BuildBlockLocatorString_Helpers/Size_100-4 13.67µ 13.60µ ~ 1.000
BuildBlockLocatorString_Helpers/Size_1000-4 168.9µ 133.0µ ~ 0.200
CatchupWithHeaderCache-4 104.7m 105.0m ~ 0.100
_BufferPoolAllocation/16KB-4 4.017µ 4.064µ ~ 0.700
_BufferPoolAllocation/32KB-4 9.219µ 8.630µ ~ 0.700
_BufferPoolAllocation/64KB-4 19.06µ 18.16µ ~ 0.400
_BufferPoolAllocation/128KB-4 37.39µ 29.39µ ~ 0.100
_BufferPoolAllocation/512KB-4 123.0µ 153.2µ ~ 0.100
_BufferPoolConcurrent/32KB-4 19.87µ 19.91µ ~ 0.700
_BufferPoolConcurrent/64KB-4 31.82µ 31.70µ ~ 0.700
_BufferPoolConcurrent/512KB-4 146.3µ 147.0µ ~ 1.000
_SubtreeDeserializationWithBufferSizes/16KB-4 668.5µ 657.5µ ~ 0.200
_SubtreeDeserializationWithBufferSizes/32KB-4 674.1µ 653.3µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/64KB-4 667.4µ 671.7µ ~ 0.400
_SubtreeDeserializationWithBufferSizes/128KB-4 661.3µ 627.4µ ~ 0.400
_SubtreeDeserializationWithBufferSizes/512KB-4 667.4µ 610.7µ ~ 0.100
_SubtreeDataDeserializationWithBufferSizes/16KB-4 36.65m 36.56m ~ 0.400
_SubtreeDataDeserializationWithBufferSizes/32KB-4 36.79m 36.55m ~ 0.100
_SubtreeDataDeserializationWithBufferSizes/64KB-4 36.72m 36.67m ~ 0.700
_SubtreeDataDeserializationWithBufferSizes/128KB-4 36.72m 36.59m ~ 0.200
_SubtreeDataDeserializationWithBufferSizes/512KB-4 36.40m 36.00m ~ 0.400
_PooledVsNonPooled/Pooled-4 739.8n 740.1n ~ 1.000
_PooledVsNonPooled/NonPooled-4 8.600µ 7.807µ ~ 0.200
_MemoryFootprint/Current_512KB_32concurrent-4 6.718µ 6.573µ ~ 0.700
_MemoryFootprint/Proposed_32KB_32concurrent-4 9.727µ 9.666µ ~ 0.700
_MemoryFootprint/Alternative_64KB_32concurrent-4 9.660µ 9.297µ ~ 0.700
SubtreeSizes/10k_tx_4_per_subtree-4 1.349m 1.348m ~ 1.000
SubtreeSizes/10k_tx_16_per_subtree-4 330.1µ 324.5µ ~ 0.400
SubtreeSizes/10k_tx_64_per_subtree-4 76.90µ 76.66µ ~ 0.400
SubtreeSizes/10k_tx_256_per_subtree-4 19.09µ 19.24µ ~ 1.000
SubtreeSizes/10k_tx_512_per_subtree-4 9.466µ 9.451µ ~ 1.000
SubtreeSizes/10k_tx_1024_per_subtree-4 4.775µ 4.696µ ~ 0.200
SubtreeSizes/10k_tx_2k_per_subtree-4 2.336µ 2.359µ ~ 1.000
BlockSizeScaling/10k_tx_64_per_subtree-4 74.72µ 74.66µ ~ 1.000
BlockSizeScaling/10k_tx_256_per_subtree-4 18.94µ 18.74µ ~ 0.100
BlockSizeScaling/10k_tx_1024_per_subtree-4 4.713µ 4.699µ ~ 0.700
BlockSizeScaling/50k_tx_64_per_subtree-4 399.8µ 394.3µ ~ 0.700
BlockSizeScaling/50k_tx_256_per_subtree-4 94.66µ 94.54µ ~ 1.000
BlockSizeScaling/50k_tx_1024_per_subtree-4 23.32µ 22.99µ ~ 0.200
SubtreeAllocations/small_subtrees_exists_check-4 161.1µ 162.0µ ~ 1.000
SubtreeAllocations/small_subtrees_data_fetch-4 167.7µ 168.8µ ~ 1.000
SubtreeAllocations/small_subtrees_full_validation-4 325.5µ 326.1µ ~ 1.000
SubtreeAllocations/medium_subtrees_exists_check-4 9.387µ 9.469µ ~ 0.700
SubtreeAllocations/medium_subtrees_data_fetch-4 9.828µ 9.825µ ~ 0.700
SubtreeAllocations/medium_subtrees_full_validation-4 18.88µ 18.87µ ~ 1.000
SubtreeAllocations/large_subtrees_exists_check-4 2.276µ 2.264µ ~ 1.000
SubtreeAllocations/large_subtrees_data_fetch-4 2.394µ 2.386µ ~ 0.200
SubtreeAllocations/large_subtrees_full_validation-4 4.716µ 4.730µ ~ 0.400
_prepareTxsPerLevel-4 409.4m 400.3m ~ 1.000
_prepareTxsPerLevelOrdered-4 3.634m 4.047m ~ 0.400
_prepareTxsPerLevel_Comparison/Original-4 401.9m 408.3m ~ 0.100
_prepareTxsPerLevel_Comparison/Optimized-4 3.960m 4.241m ~ 0.200
StoreBlock_Sequential/BelowCSVHeight-4 318.1µ 313.4µ ~ 0.700
StoreBlock_Sequential/AboveCSVHeight-4 313.6µ 313.4µ ~ 1.000
GetUtxoHashes-4 283.1n 280.3n ~ 0.100
GetUtxoHashes_ManyOutputs-4 46.31µ 47.32µ ~ 0.400
_NewMetaDataFromBytes-4 217.3n 214.7n ~ 0.400
_Bytes-4 403.3n 395.1n ~ 0.700
_MetaBytes-4 139.5n 137.9n ~ 0.100

Threshold: >10% with p < 0.05 | Generated: 2026-06-04 14:19 UTC

@oskarszoon oskarszoon requested review from liam and ordishs June 4, 2026 09:21

@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.

Approve — root cause diagnosis is sound and the layered fix (cross-stream clear fan-out, block-in-flight suppression, extend-only refresh) is clean and well-tested. Verified locally: new tests pass under -race, gofmt clean, go vet ./services/legacy/peer/ clean.

A few comments to address (none blocking the mechanics):

1. (Medium) Refresh fires on every relayed tx, not just block completions. The refresh loop lives in the CmdBlock/CmdMerkleBlock/CmdTx/CmdNotFound receive case, so a plain mempool-relayed tx (not a response to our getdata) also refreshes all pending non-block deadlines to their full budget. A genuinely-stalled getheaders could be perpetually deferred while unrelated tx traffic flows. Impact during pure IBD is likely low, but this silently weakens stall detection on the shared handler for all peers. Consider gating the refresh on "a block-family deadline was actually pending" rather than running on any tx receive.

2. (Medium / operational) Default flip to true is the biggest blast radius. Enables multistream negotiation network-wide for every deployment and rewrites the longdesc that previously warned about priority-based attacks. Relies on graceful fallback for peers that don't advertise BlockPriority — worth explicitly confirming that path is exercised. A meaningful default-behavior change worth calling out to operators.

3. (Low) Target scenario unverified. The PR honestly notes the mainnet fat-block (804,008, ~1.1GB) path was never exercised. Unit + E2E coverage proves the deadline-clearing mechanics, but not that a real 1.1GB block now syncs end-to-end — the one claim the fix exists to satisfy. A live mainnet run past 804,008 (with HandleBlockDirect ... DONE and no headers timeout) should ideally gate merge.

4. (Low) Hot-path fan-out cost / teardown edge. Every received message now does N cross-goroutine stallControl sends (buffer 1, quit-guarded — no deadlock, just ~2x stall-control traffic). And a peer mid-RemoveStream (association set but no longer in a.streams) won't self-notify via StreamPeers(). Both narrow/low risk.

Mechanics LGTM; please address #1 and confirm the live mainnet check (#3) before relying on this for production large-block sync.

Addresses PR review (ordishs bsv-blockchain#1): the deadline refresh ran for the whole
block-family receive case, so an unsolicited relayed tx (a CmdTx that was
not a response to our getdata) also refreshed every pending non-block
deadline. Ambient tx traffic could therefore perpetually defer a
genuinely stalled getheaders, silently weakening stall detection on the
shared handler for all peers.

Extract clearBlockResponseGroup() and only refresh the remaining
deadlines when a block-family response was actually outstanding.

Also (review bsv-blockchain#4) guarantee signalReceived notifies the peer itself even
when it has already been removed from the association's stream set
(mid-teardown), rather than relying solely on StreamPeers().

Tests: clearBlockResponseGroup (block completion refreshes queued
deadline; unsolicited tx does not; tx-as-getdata-response does) and the
self-notify teardown edge.
…ress

Second stall mechanism, distinct from the per-message stall handler. The
netsync watchdog (handleCheckSyncPeer) rotates the sync peer when no
block completes within maxLastBlockTime (3 min) — and in headers-first
mode it skips only the speed check, not this one. A multi-GB block (e.g.
~3 GB at height 804157) streams in on the DATA1 stream and completes no
block within 3 min, so a healthy, actively-downloading sync peer is
disconnected mid-download ("updateSyncPeer - disconnect old sync peer"),
the header state is reset to the pre-block height, and the next peer
repeats the loop forever.

Root cause (same family as the deadline orphan): every progress signal
here is message-granular. lastBlockTime updates only on a completed
block, and updateNetwork sampled only the GENERAL peer's BytesReceived,
which excludes the DATA1 stream actually carrying the block.

Add byte-granular download progress and use it to tell an active
download apart from a stall:
- activityConn wraps the peer conn so each read advances a byte-granular
  readBytes counter (and refreshes lastRecv) even while a single large
  message is still streaming in. Association.ReadBytes sums it across all
  streams (GENERAL + DATA1).
- netsync: sample association readBytes per tick; suppress the
  last-block-time rotation while association throughput is healthy
  (>= minSyncPeerNetworkSpeed, 50 KiB/s default, comfortably above
  chatter and far below block-transfer rates). A genuinely stalled peer
  delivers no throughput and is still rotated; a withholding-but-chatty
  peer is dropped by the peer layer's block-response deadline.
- peer.go: while a block response is pending and association throughput
  is healthy, extend the block deadline instead of disconnecting, so a
  block that legitimately takes longer than stallResponseTimeoutBlocks
  (5 min) to arrive still completes.

Tests: hasHealthyDownloadThroughput matrix; CheckSyncPeer keeps an
actively-downloading peer despite last-block-time; activityConn byte
counting; Association.ReadBytes sum.
@oskarszoon oskarszoon requested a review from ordishs June 4, 2026 12:12
@oskarszoon

Copy link
Copy Markdown
Contributor Author

Thanks. Addressed in fad0fa7f3, and a follow-up commit 3c87cc76a adds a second fix found during mainnet testing — details below. Description updated to cover both.

#1 — good catch. The refresh now lives in clearBlockResponseGroup and only fires when a block-family deadline was actually pending, so unsolicited tx relay no longer defers a stalled getheaders. Test added for that exact case.

#2 — fallback for non-BlockPriority peers is covered by TestMultistreamBackwardCompatibility, TestMultistreamOnlyStandardPeer, and TestMultistreamDisabledRejectsConnection, all green.

#4signalReceived now always notifies self first (covers the mid-RemoveStream window), then fans out to siblings. Test added.

#3 — this is the important one. The 1.1GB block at 804,008 now syncs end-to-end on mainnet with the deadline fix. But the next fat block (~3GB at 804,157) exposed a second, independent stall: netsync's handleCheckSyncPeer rotates the sync peer when no block completes within maxLastBlockTime (3 min). A multi-GB block streams in on DATA1 and completes no block in that window, so a healthy, actively-downloading peer gets hit with updateSyncPeer - disconnect old sync peer and the header state resets — looping forever. Same root-cause family: progress was measured message-granular, and the sampled bytes were the GENERAL peer's only (excludes DATA1).

3c87cc76a adds byte-granular download progress (activityConnreadBytes, summed association-wide) and uses it to keep an actively-downloading peer: netsync won't rotate while association throughput is healthy (≥ minSyncPeerNetworkSpeed), and the peer-layer block deadline is extended on the same signal so blocks taking >5 min still complete. A genuinely stalled peer (no throughput) is still rotated.

The 3GB/804,157 path is unit-verified but not yet proven live — I'll confirm it on mainnet the same way as 804,008 before this merges.

Separately: the red legacy-sync check is a pre-existing flake (fixed-port bind in TestLegacyTxBroadcast942 → nil-deref panic in Server.Stop), reproduces on unrelated branches, filed as #1032.

@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 of the latest revision (through 952a2fba0). Verified locally on the PR head: go build OK, go test -race ./services/legacy/peer/ ./services/legacy/netsync/ green, gofmt clean, go vet clean. Good test coverage of the new logic.

Prior comments resolved:

  • #1 (refresh on ambient tx) — clearBlockResponseGroup now gates the deadline refresh on blockWasPending, so an unsolicited relayed tx clears the group without deferring a genuinely-stalled getheaders.
  • #5 (teardown self-notify edge) — signalReceived now always notifies self first, then fans to siblings (skipping self).
  • #3 (fat-block path) — now handled in code rather than left as a manual step. The new byte-granular progress signal (activityConn wrapping conn.Read, Peer.ReadBytes, association-wide aggregation) is checked at both layers: the peer stall handler extends a block deadline while the association reads ≥50 KiB/s, and netsync suppresses the maxLastBlockTime rotation while throughput is healthy. This is a clean, well-tested solution to the core multi-GB-block problem.

New residual concern (Medium, security): the byte-progress gate introduces a slow-drip rotation-evasion vector. activityConn.Read counts raw socket bytes regardless of message validity, and a healthy throughput now overrides both the peer-layer block deadline and netsync's maxLastBlockTime (3 min) rotation. A malicious sync peer can declare a large block and dribble bytes at just ≥50 KiB/s without ever completing a valid block — holding the single sync-peer slot and stalling IBD until the 4 GB wire limit forces disconnect (~22 h). Previously maxLastBlockTime would rotate a peer that completed no block regardless of throughput; that backstop is now overridden by throughput, and 50 KiB/s is a low bar. The honest-peer tradeoff is reasonable, but consider an absolute wall-clock cap per in-flight block (bound the number of throughput-based extensions) so a withholding-but-chatty peer is eventually rotated.

Minor (Low): the peer-layer extension handles one expired block-family command per tick (Block/MerkleBlock/Tx/NotFound are armed together but expiredStallResponse returns the first by map iteration). No disconnect occurs, so it's functionally correct — siblings just get extended one-per-tick. Tidy-up only.

Still verified as not-done in the PR body: an actual mainnet run past block 804,008. The byte-progress logic + tests de-risk it substantially, but the live confirmation remains the real proof.

LGTM to merge once you've decided on the slow-drip cap (#new) and have the mainnet confirmation.

@ordishs

ordishs commented Jun 4, 2026

Copy link
Copy Markdown
Collaborator

Code Review

Reviewed all 4 commits. The diagnosis of both stall mechanisms is well-reasoned, the commit history shows good responsiveness to earlier review rounds, and test coverage is strong. Verified locally: go vet clean on both packages; all new tests pass under -race.

One design concern I'd like resolved before merge, plus a smaller correctness nit.

🟠 The two fixes together remove the hard cap on how long one peer can hold the sync slot without delivering a block

The PR body / commit message state that a "withholding-but-chatty peer is dropped by the peer layer's block-response deadline." I don't think that holds, because the peer layer also extends on healthy throughput:

// peer.go:1711
if isBlockResponseCommand(command) && healthyDownload {
    pendingResponses[command] = now.Add(stallResponseTimeoutBlocks) // extend, not disconnect
}

healthyDownload is true whenever association-wide readBytes advances ≥ 50 KiB/s — any bytes on any stream, not block-stream bytes specifically. So a peer that streams a steady ≥50 KiB/s of valid filler (inv/addr/ping) while never delivering the block would have both:

  • netsync rotation suppressed (hasHealthyDownloadThroughput → true), and
  • its block deadline extended every tick at the peer layer, indefinitely.

Previously stallResponseTimeoutBlocks (5 min) was a hard ceiling on a non-delivering peer. After this change there's no upper bound as long as the peer keeps the byte counter moving, which is cheap to fake. Suggested options:

  • count only block-stream (DATA1) / block-message bytes toward healthyDownload, not all association traffic; or
  • impose an absolute (ideally size-aware) cap on total block-deadline extensions before forcing rotation.

At minimum, please correct the PR/commit narrative so the residual risk isn't documented as already mitigated. There's also currently no test exercising this extend branch.

🟡 Unsigned underflow when association readBytes decreases

Both delta computations subtract uint64s assumed monotonic:

  • peer.go:1703recvDelta := curAssocReadBytes - lastAssocReadBytes
  • manager.go:827recvDiff := sps.assocReadBytes - sps.assocReadBytesLastTick

Association.ReadBytes() sums over current streams. If a stream (e.g. DATA1) is removed between samples, the sum drops, the subtraction wraps to a huge value, and both throughput checks report "healthy" — extending a deadline / suppressing rotation exactly when a stream just died. Low probability and self-corrects within a tick or two via inQuit/outQuit, but worth a if cur < last { delta = 0 } guard.

🟢 Minor

  • Channel coupling (signalReceived, peer.go:1463): the blocking fan-out to each sibling's buffered-1 stallControl lightly couples the receiving stream's inHandler to the sibling stall handler's scheduling. No deadlock risk (stall handlers never send back), but worth a note in the comment.
  • Naming: two functions named associationReadBytes (method on *Peer, plus a free function in netsync) — mildly confusing across the two packages.
  • activityConn correctly overrides only Read and embeds net.Conn; I checked for conn.(*net.TCPConn)-style assertions the wrapper would break — there are none. 👍

Default flip — operational visibility

legacy_allowBlockPriority = true is the highest-blast-radius change here: it alters network behavior for every node on upgrade. The struct-tag description fix is a real improvement. Consider keeping the default flip independently revertible from the bug-fix logic, and giving it explicit release-note visibility, so it can be backed out separately if mainnet surfaces an issue. The pending height-804,157 mainnet verification noted in the PR should land before merge as stated.

Test coverage gaps for follow-up

  • peer-layer extend path (healthyDownload + expired block deadline → extend) — the branch with the liveness concern above, currently unverified.
  • the underflow case (stream removed between samples).

Overall: well-investigated and the mechanical changes look correct. Main ask is the 🟠 liveness item before merge.

Addresses PR review (security, medium): the byte-progress gate let a
malicious sync peer dribble bytes at just above the 50 KiB/s threshold
without ever completing a valid block, holding the single sync-peer slot
and stalling IBD for ~22h (until the 4 GB wire limit forced disconnect).
Healthy throughput overrode both the peer-layer block deadline and
netsync's maxLastBlockTime rotation with no upper bound.

Add MaxBlockDownloadTime (30 min) as an absolute wall-clock ceiling on a
single block fetch, shared by both layers:
- peer.go tracks blockFetchStart; shouldExtendBlockDeadline extends only
  while throughput is healthy AND within the cap. Past it the block
  deadline fires and the peer is disconnected regardless of throughput.
- netsync caps the last-block-time suppression at the same window: past
  it the sync peer is rotated regardless of throughput.

A 4 GB block need only average ~2.3 MB/s to finish inside the window, so
honest fat blocks are unaffected (the verified 1.1 GB block completed in
under 3 min on mainnet). The cap is tunable via the shared constant.

Also (review, low): the peer-layer extension now refreshes the whole
block-response group together each tick rather than one command per tick
by map-iteration order.

Tests: shouldExtendBlockDeadline cap matrix; netsync rotates a slow-drip
peer once past the cap.
@oskarszoon oskarszoon self-assigned this Jun 4, 2026
…lper

Addresses ordishs' fuller review:

- (correctness) Association.ReadBytes sums over the streams present at
  sample time, so removing a stream (e.g. DATA1) between samples drops
  the sum. The unsigned delta subtraction would then wrap to a huge
  value and read as "healthy" exactly when a stream just died — wrongly
  extending a block deadline / suppressing rotation. Guard both deltas
  (peer stall handler and netsync hasHealthyDownloadThroughput) so a
  decrease counts as zero progress.
- (cleanup) Export Peer.AssociationReadBytes and drop the duplicate
  netsync free function of the same name, removing the cross-package
  naming collision.
- (cleanup) Document the buffered-1 fan-out coupling in signalReceived
  (no deadlock risk; stall handlers never send back).

Tests: hasHealthyDownloadThroughput treats a counter decrease as unhealthy.
@oskarszoon

Copy link
Copy Markdown
Contributor Author

Addressed in 2df3dcf43 (on top of the wall-clock cap in 5122a5769).

🟠 hard cap removed / "dropped by block deadline" claim — correct, that claim was wrong: the peer layer extends on healthy throughput too, so the 5-min ceiling wasn't actually a backstop. Fixed two ways:

  • MaxBlockDownloadTime (30 min) now caps the throughput-based reprieve at both layers — the peer-layer block deadline and netsync's maxLastBlockTime rotation. Past that window the peer is disconnected / rotated regardless of throughput, so a non-delivering peer is dropped within ~30 min instead of holding the slot to the ~22h wire limit.
  • You're right that healthyDownload is gauged from association-wide readBytes, not block-stream bytes, so cheap filler (inv/addr/ping) can keep the counter moving — the wall-clock cap is what bounds that. I considered restricting the counter to DATA1/block-message bytes, but activityConn counts raw socket bytes and can't cheaply distinguish message types; the cap is the pragmatic bound. Happy to do the stream-scoped version as a follow-up if you'd prefer it over (or in addition to) the cap.
  • PR narrative corrected accordingly. Added TestShouldExtendBlockDeadline covering the extend/cap decision.

🟡 unsigned underflow — real bug, fixed. Association.ReadBytes sums the streams present at sample time, so a removed stream drops the sum and the uint64 delta wrapped to a huge "healthy" value exactly when a stream died. Both deltas (peer stall handler and hasHealthyDownloadThroughput) now guard cur < last and treat a decrease as zero progress. Test added.

🟢 naming — exported Peer.AssociationReadBytes and dropped the duplicate netsync free function of the same name; one helper now.

🟢 channel coupling — noted in the signalReceived doc (buffered-1 fan-out lightly couples the receiver's inHandler to sibling stall-handler scheduling; no deadlock since stall handlers never send back).

Default flip — kept independently revertible: legacy_allowBlockPriority = false backs it out without touching the stall fixes. Called out in the PR body for release-note visibility.

Mainnet run past 804,157 remains the real gate and will land before merge as noted.

@oskarszoon oskarszoon requested a review from ordishs June 4, 2026 13:18
@ordishs

ordishs commented Jun 4, 2026

Copy link
Copy Markdown
Collaborator

Re-review — both findings resolved ✅

Reviewed the two follow-up commits (5122a5769, 2df3dcf43). Re-ran against the PR head (2df3dcf43): go vet clean, all peer + netsync tests pass under -race.

🟠 → resolved: slow-drip liveness bound

MaxBlockDownloadTime (30 min) is now a shared wall-clock ceiling enforced at both layers, which closes the hole cleanly:

  • peer.goshouldExtendBlockDeadline extends only while now - blockFetchStart < MaxBlockDownloadTime; past it the deadline fires and the peer is disconnected regardless of throughput. blockFetchStart is armed when a block first goes in flight and reset when clearBlockResponseGroup reports a real completion — correct lifecycle.
  • netsync — the rotation suppression is gated on lastBlockSince < peerpkg.MaxBlockDownloadTime, so a chatty-withholding peer is rotated once past the window.

Nice extras: the extend now refreshes the whole block-response group together (fixes the map-iteration-order nit), and the narrative/comments were corrected. The logic is unit-tested at the cap boundary (TestShouldExtendBlockDeadline, plus the netsync slow-drip rotation test).

🟡 → resolved: unsigned underflow

Both delta sites now guard the subtraction (peer stall handler treats a decrease as 0; hasHealthyDownloadThroughput returns false), so a stream removed between samples can no longer wrap to a spurious "healthy" value. Covered by the new counter decrease (stream removed) test.

🟢 cleanups picked up

AssociationReadBytes is exported and the duplicate netsync free function removed (collision gone); the buffered-1 fan-out coupling in signalReceived is now documented.

Residual note (non-blocking)

The two caps measure from different origins — peer.go from blockFetchStart (current fetch), netsync from lastBlockTime (last completed block) — both bounded to 30 min, which is what matters for the DoS bound. One consequence worth being aware of: an honest max-size (~4 GB) block on a slow link (below ~2.3 MB/s) would exceed the window and get rotated, looping. That's strictly better than the pre-PR 3-min maxLastBlockTime behavior and the cap is a tunable shared constant, so I'd leave it as-is — just flagging for the changelog.

LGTM pending the mainnet height-804,157 verification the PR already commits to before merge.

@oskarszoon

Copy link
Copy Markdown
Contributor Author

Mainnet validation: synced past block 804,157 (~3 GB) end-to-end. ~5 min to download, then 2m17s to fully validate and process — HandleBlockDirect ... DONE, no rotation and no headers/block timeout. The download alone exceeds both the old 3-min maxLastBlockTime rotation and the 5-min block deadline, so this is exactly the case the byte-progress fix exists for; it sits well inside the 30-min MaxBlockDownloadTime cap.

@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.

Approving. Both review findings are resolved cleanly:

  • Slow-drip liveness hole bounded by the shared MaxBlockDownloadTime (30 min) cap, enforced at both the peer and netsync layers.
  • Unsigned throughput-delta underflow guarded at both sample sites.

Verified against the PR head (2df3dcf43): go vet clean, peer + netsync tests pass under -race (including the new cap-boundary and counter-decrease cases). The default flip to legacy_allowBlockPriority = true is the highest-blast-radius change — please keep it changelog-visible and ideally independently revertible. Ship after the mainnet height-804,157 verification the PR already commits to.

…eceipt

Observed on mainnet: block 804157 (~3 GB) downloaded fine (~5.6 min — the
byte-progress gate held across the whole download), but the sync peer was
then disconnected ("updateSyncPeer - disconnect old sync peer")
mid-processing, ~7 min after sync start, and a fresh peer + header
re-download took its place. Progress wasn't lost (the block was already
downloaded, so it still got accepted), but it churned away the good, fast
sync peer on every fat block.

Two compounding causes:
1. updateLastBlockTime fired only at block *accept* — after the
   multi-minute HandleBlockDirect validation — so the timer was stale
   throughout processing.
2. The update was gated on `peer == syncPeer`, but under BlockPriority the
   block is delivered on the DATA1 stream peer (a different Peer from the
   GENERAL sync peer), so it never fired at all during multistream sync.

Fix: syncPeerStateFor matches the current sync peer OR any stream of its
association, and the sync peer's last-block-time is now refreshed at block
*receipt* (HandleBlockDirect entry) as well as at accept. A peer that just
delivered a multi-GB block is therefore not rotated while we validate it.

Residual: a block whose validation alone exceeds maxLastBlockTime (3 min)
could still trip a mid-processing rotation; 804157 validated in 2m17s.
Left as a follow-up if observed on larger blocks.

Tests: syncPeerStateFor matches the sync peer and an association sibling,
not unrelated peers.
@oskarszoon

Copy link
Copy Markdown
Contributor Author

Mainnet update: the ~3GB block at 804,157 synced end-to-end with the byte-progress fix — downloaded in ~5.6 min with no rotation during download, accepted, and sync advanced to 804,158. So the core fat-block path works.

One residual surfaced and is fixed in e63a39747 (Stall 3 in the description): the sync peer was rotated mid-validation of 804,157 (download already complete, so non-fatal — the block still got accepted). Two causes: updateLastBlockTime fired only at accept (after the ~2m17s validation), and it was gated on peer == syncPeer while the block actually arrives on the DATA1 stream peer — so under multistream it never fired at all. Now it's association-aware and refreshes at block receipt, so a peer that just delivered a multi-GB block isn't rotated while we validate it.

Residual noted: validation alone exceeding maxLastBlockTime (3 min) could still trip it — 804,157 validated in 2m17s, under the bar — follow-up if a larger block hits it.

@ordishs

ordishs commented Jun 4, 2026

Copy link
Copy Markdown
Collaborator

Re-review — e63a39747 (last-block-time refresh on association block receipt)

Good catch from the mainnet 804157 run — this closes the last gap: the block downloaded fine but the sync peer was rotated during validation because lastBlockTime was (a) only updated at block accept, after the multi-minute HandleBlockDirect, and (b) gated on peer == syncPeer, which the DATA1 stream peer never satisfies under BlockPriority. Both causes are addressed.

Verified against the PR head (e63a39747): go vet clean, netsync tests pass under -race including the new TestSyncPeerStateFor.

Correctness checks:

  • syncPeerStateFor matches the sync peer or an association sibling via AssociationRef() equality, with full nil-guarding (sp/sps/p) — correct, and the right generalization of the old peer == sp check. The handleBlockMsg accept-time refresh is migrated to it too, so multistream sync now refreshes at accept as well.
  • The new receipt-time refresh is placed after the blockExists early-return and immediately before the long validation work — exactly the window that needed protecting. Single caller (handleBlockMsg:1343), so the receipt refresh runs before validation and the existing accept refresh still runs after.
  • Lock ordering is clean: loadSyncPeerAndState (syncPeerMu.RLock) releases before updateLastBlockTime takes sps.mu — no nesting.

Residual (already noted in the commit, non-blocking): a block whose validation alone exceeds maxLastBlockTime (3 min) can still trip a mid-processing rotation — the throughput gate won't help since no bytes flow during validation. 804157 validated in 2m17s so it's under the line today, but on a larger/heavier block this will resurface. Reasonable to leave as the documented follow-up; if you want to fully close it, refreshing lastBlockTime periodically during validation (or exempting the in-validation peer from rotation) would do it.

Approval stands. Ship after the mainnet verification, which this commit now reflects.

@sonarqubecloud

sonarqubecloud Bot commented Jun 4, 2026

Copy link
Copy Markdown

@oskarszoon oskarszoon merged commit 4fb8729 into bsv-blockchain:main Jun 5, 2026
34 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.

3 participants