Repository navigation
Conversation
Lets the Bitcoin block feed be exercised on a regtest pair: one node keeps Bitcoin's regtest magic, the other runs with an eCash fork height and dials it as a -bitcoinpeer. The fork block keeps regtest difficulty. No effect on any other network. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QRd31nLeodDD79WUeJGHEx
BridgeOfferTx held TX_PREMATURE_SPEND, "too-long-mempool-chain", "mempool full" and "mempool min fee not met". "too-long-mempool-chain" is the legacy mempool's string; v31 runs the cluster mempool, which says "too-large-cluster" instead and never emits the old one. So a Bitcoin block's worth of chained transactions offered into a mempool whose clusters are capped fell through to OTHER and was dropped for good, and every later Bitcoin transaction spending those outputs was then missing inputs forever. TRUC-violation was dropped the same way. Measured on betanet: replay of Bitcoin's post-fork transactions ran at 99.2% while blocks were ~21 s apart, then fell to ~52% within hours of the first difficulty retarget stretching them to ~5.5 min; 122,741 transactions were absent only because an ancestor was absent, and sampled roots are accepted by testmempoolaccept today. Also: the holding queue capped at 2,000, fewer than one Bitcoin block's transactions, and evicted the oldest entry when full - the parents, orphaning the rest of a chain. Raise it to 50,000, keep what is queued, and warn when a transaction is turned away. Regtest (test/test_cluster_leak.py, cluster limit 10, a 40-tx chain in one Bitcoin block): before, 10/40 fed and 30 dropped permanently; after, 40/40 recovered over three blocks. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0136NHdQjigzYu7QGnAhskQb
…acklog The queue lived only in memory and was capped at 50,000 transactions with a 24 h expiry. All three bite exactly when the feed is behind, which is when the queue matters: betanet's backlog has been 207,000 transactions (127 MWU), its deepest chains have waited more than a day, and a node restart silently dropped whatever was held - and with it every later Bitcoin transaction spending those outputs. Hold up to BRIDGE_HELD_TXS_MAX_WEIGHT (400 MWU, a hundred blocks' worth, ~3x the worst backlog observed and comparable to a default mempool) instead of a count, give up after 30 days rather than 24 hours, and write the queue to bridgeheld.dat beside the anchor with the same temp-file-and-rename discipline. A truncated or unreadable file costs the tail of the queue, not correctness: those transactions are re-offered when their block is re-fed. Regtest (test/test_restart_persist.py): a 40-tx chain, cluster limit 10, the node stopped with 30 held and restarted before any can drain - 40/40 arrive over three blocks. Without this, the 30 never return. test_cluster_leak.py still 40/40. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0136NHdQjigzYu7QGnAhskQb
…for it A transaction whose feerate is under the floor is refused on its own. On Bitcoin that is normal: a child pays for it, and the two are relayed as a package. The bridge offers one transaction at a time, so the parent was classified OTHER and dropped outright, and the child then had missing inputs -- both lost, and with them anything later spending either. Hold "min relay fee not met" as well, and when the retry pass meets a held transaction rejected for a fee reason, look for the held child that spends it and offer the two to ProcessNewPackage, exactly as a peer's 1p1c package arrives. The existing "a child of a held transaction is held with it" rule already keeps the pair together, so the child is there to be found. test/test_package_cpfp.py: a zero-fee parent and its CPFP child mined into one Bitcoin block with generateblock (no relay policy on the Bitcoin side, as in the real case). Before: neither ever arrives, through six eCash blocks. After: both, on the first retry -- "0 accepted, 2 in packages, 0 dropped, 0 still held". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0136NHdQjigzYu7QGnAhskQb
The held queue gave up on anything older than 30 days, whether or not it was still valid, although the comment above the constant said the opposite: "anything still valid is re-offered until it is accepted or provably dead". The block such a transaction came in is never fed again (the anchor has passed it), so dropping it loses it and every later transaction spending it. A held transaction now leaves the queue only when it is accepted, already known, or refused for a reason that does not clear. The time it was first held is kept, and the oldest is reported in the retry summary instead. Also correct what a truncated bridgeheld.dat costs: the tail is lost, not re-fed, and only damage to the file can cause it (it is written to a temporary and renamed over). test/test_no_expiry.py: a 20-transaction chain, 10 held behind the cluster limit; B's clock jumps 31 days (setmocktime) and a retry pass runs while the tail is still blocked. Before: "10 dropped, 0 still held", 10/20 ever arrive. After: the tail stays held, 20/20 once B mines one block. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0136NHdQjigzYu7QGnAhskQb
…l its held parents
Two things about the retry pass.
It re-ran mempool acceptance on every held transaction, although almost all
of a deep backlog are children whose parent is still held and can only fail
with missing inputs. At 8,000 held that was 401 ms of the message-handler
thread per pass to admit ten; at the ~170,000 betanet has carried, seconds a
block. Parents precede their children in the queue, so by the time a
transaction comes up its parents' fate this pass is settled: if one is still
held, the transaction stays held without asking the mempool. The feed does
the same for a newly arrived transaction whose parent is held. Held txids move
to a salted hash set, since every held input is looked up. Same pass at 8,000
held: 4.0 ms; the cost is now the handful of transactions actually offered.
And a child could only carry one parent below the fee floor. A child whose
held parents are all refused only for the floor is now offered to
ProcessNewPackage with all of them (a child with up to 24 parents, sorted,
child last), which is what it paid for on Bitcoin. Anything the package puts
in the mempool leaves the queue; anything it doesn't stays held. A fed block
that leaves something below the floor triggers a pass at once, rather than on
our next tip change, so its child can carry it without waiting.
test/test_package_multi_parent.py: two zero-fee parents and one child that
pays for all three, in one Bitcoin block. Before: 0/3, ever. After: 3/3 on
the first pass ("3 in packages").
test/test_retry_cost.py: a 3,000- and an 8,000-transaction chain behind a
cluster limit of 10; median pass 159 -> 3.1 ms and 401 -> 4.0 ms.
test_package_cpfp.py, test_cluster_leak.py, test_restart_persist.py and
test_no_expiry.py still pass.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0136NHdQjigzYu7QGnAhskQb
…packages in bounds Four corrections to how refusals are read, from checking each against the acceptance code in this tree: - missing-ephemeral-spends was dropped, but it clears. While a parent with ephemeral dust is unconfirmed here, every child must spend that dust (CheckEphemeralSpends gathers all of an in-mempool parent's dust, whoever spent it). A sibling of the child that did is refused until the parent confirms, then accepted. Packaging a parent with its paying child, as the previous commit does, produces exactly this case. It is now held. - txn-same-nonwitness-data-in-mempool (our transaction, another witness) is already here, not a failure. - A version 3 transaction over TRUC_MAX_VSIZE can never be accepted; it was held for ever as a TRUC-violation. It is now dropped (its vsize here omits the sigop adjustment, which only enlarges it, so nothing that could pass is). - "mempool full" is a fee refusal a paying child can cure, like the two floor refusals, so it also makes a parent eligible for a package. The RBF refusals are not: package RBF is one-parent-one-child. A package over MAX_PACKAGE_WEIGHT is no longer offered, since Core refuses it outright. test/test_ephemeral_sibling.py: P (no fee, an ordinary output each for two children, and a zero-value pay-to-anchor) with C1 spending the anchor and paying for P, and C2 spending only its own output, all in one Bitcoin block. Before: P and C1 go in as a package, C2 is refused missing-ephemeral-spends and dropped, and never arrives. After: C2 waits, and goes in on the first eCash block that confirms P. The earlier tests still pass. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0136NHdQjigzYu7QGnAhskQb
…ning transactions away Past 400 MWU the held queue refused newcomers with a warning, and a refused transaction is lost with everything that spends it: its block is never fed again. Nothing held is refused any more. Instead BridgeRequestBlocks stops fetching Bitcoin blocks while the weight held from our last 144 blocks is at the limit, and resumes as it drains; the blocks wait on the Bitcoin node, not in our memory. The limit becomes -bridgeheldmaxweight (default 400 MWU, debug-only), for tests and for operators sizing a seed. Only entries held within our last 144 blocks count toward the pause, so what can never clear -- a transaction under the relay floor with no child to pay for it, say -- cannot hold the feed up for ever: it ages out of the count (it is still kept, and a warning names the weight if it exceeds the limit). The age is in our blocks, not seconds, so the pause still holds while our own chain is stalled and the queue genuinely cannot drain. And a transaction refused on fee is not offered again while its own feerate is still under the floor (the higher of the relay floor and the mempool's), since that check looks at nothing else; the mempool is still consulted for arrival by another route, and such a transaction can still go in as a package parent. On a congested mempool the whole frontier is in this state, and re-offering it every pass was the retry cost again. The queue entry keeps its weight, age and fee refusal, rather than recomputing weight each pass. The memory figure for the limit is corrected: ~100 MB serialized, 200-400 MB in memory. test/test_backpressure.py, at a 6,000 WU limit: five Bitcoin blocks of 20-tx chains behind a cluster limit of 10. Before (commit 7's refusal, same limit): 63/100 arrive. After: 100/100 in three eCash blocks, the feed paused twice, nothing refused. Then twenty zero-fee transactions nothing will pay for pause the feed; a later transaction waits, and arrives once they age out. test/test_fee_skip.py: 200 zero-fee spends held under the floor. Before: all 200 offered every pass (10.9 ms). After: none (0.1 ms). The earlier tests pass, and a pass at 8,000 held still costs ~3.5 ms. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0136NHdQjigzYu7QGnAhskQb
…ain any we lose Once accepted, a Bitcoin transaction was forgotten. If our mempool then lost it -- mempool.dat is only written on a clean shutdown, so a crash, an OOM kill or a container stop that outruns the shutdown leaves none; or eviction, or expiry -- it was never fed again (the anchor has passed its block), and every later transaction spending it failed on missing inputs and was dropped. On a long Bitcoin chain that is the whole rest of the chain. The queue now tracks fed transactions too: WAITING (refused, offered again) or FED (in our mempool). Each pass reads our blocks since the last one and retires what was mined in them, as the Python bridge follows ECX blocks; a FED transaction that is neither in our mempool nor mined is offered again, after its parents, and so is anything that reached our mempool by another route (another bridge, ordinary relay), which is tracked as FED on sight, at feed, retry or in a package. Nothing tracked is fed twice, so a Bitcoin reorg re-feeding a block, or a restart re-feeding one, is harmless. Missing inputs are now judged one input at a time, as the Python bridge does: a transaction waits only if every missing input comes from one we track and expect. Any other missing input is dead -- by fork+4 days a third of Bitcoin's transactions descend from a post-fork coinbase, and a transaction with one live and one dead input is common -- and it is dropped at once, at feed or retry, rather than held until its live parent is mined. A crash also rolls our chainstate back to its last flush, up to about an hour. A transaction mined in a lost block is not mined here again until the block is reconnected, and meanwhile its children would look like orphans. So mined transactions are remembered for 12 of our blocks, and nothing is judged while the node is loading, in IBD, before mempool.dat is loaded, or before our chain is back at the height of our last pass. The pass needs no Bitcoin peer, so any peer runs it. "too many potential replacements" clears as the conflicts are mined or expire, so it is held (as the Python bridge does). bridgeheld.dat becomes version 2 (state, age, what was mined lately, where we read to); version 1 files are read as all waiting. test/test_mempool_loss.py: X fed, B restarts without mempool.dat, Y (spends X) arrives. Before: both lost. After: X fed again, Y follows. test/test_known_relayed.py: X reaches B's mempool by RPC first; same restart. Before: both lost. After: both in. test/test_stale_restart.py: B's chainstate is flushed, P is mined in the next B block, C (spends P) is fed; B is SIGKILLed, restarts a block back with no mempool, and recovers the block from a second eCash node. Before: C lost. After: C fed again. test/test_mixed_dead_input.py: T spends a fed parent and a post-fork coinbase output. Dropped at the feed, with its child, not held. Every earlier test passes; a pass at 8,000 held costs ~7 ms (it now reads the new block). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0136NHdQjigzYu7QGnAhskQb
…, off cs_main After each Bitcoin block the anchor (how far Bitcoin has been fed) was written first and the queue second. A crash in between left the anchor past a block whose held and fed transactions were not on disk, and the mempool that held the fed ones went with the crash: they were never fed again. Neither file was synced before being renamed over its predecessor, so a power cut could leave an empty or truncated one; a failed queue write still let the anchor advance; and the queue, which can run to a hundred MB, was written while holding cs_main, after every block. Now the queue is written and synced first, and the anchor is written (and synced) only once that has succeeded, so on disk the anchor is never ahead of the queue. A crash in between re-feeds a block, which is harmless: nothing tracked is fed twice. Writes happen outside cs_main, at most once a minute (a crash re-feeds the blocks since), after a pass as well as after a block, and at shutdown -- the message handler has stopped by the time peerman is destroyed, and the chainstate and mempool are still up. test/test_crash_feed.py: five blocks of 20-tx chains fed back to back, faster than the queue is written, then SIGKILL. After restart the five are fed again, what was already tracked is skipped, 100/100 arrive and nothing is left held or fed. All thirteen tests pass. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0136NHdQjigzYu7QGnAhskQb
- A tracked transaction mined in one of our blocks was kept only as a txid once mined. If our chain then reorganised that block away, the txid was forgotten, and a later mempool loss took the transaction and everything spending it. The reorganised-away blocks are now read back and what was mined in them is tracked as fed again, at the front of the queue (its parents are below the fork or earlier among them; what spends it comes later). A pass keeps running while anything is remembered as mined, so a reorg is always seen, and a transaction found mined mid-pass is remembered like the rest. - A version 3 child refused "insufficient fee (including sibling eviction)" or "replacement-failed" when none of its inputs actually conflicts -- its parent's one unconfirmed-child slot is simply taken -- was read as a lost conflict and dropped. It clears when the parent is mined here, so it is held; a real conflict still yields. "too many potential replacements" is matched with its suffix. - The catch-up gate waited for our chain to return to the height it last read at, for ever if it never did: a wiped or reset chain, an invalidated branch. It now waits only while a known header reaches that height, or for two minutes after startup while headers arrive; then it says so and reads from here. - The shutdown save lived in ~PeerManagerImpl and took g_msgproc_mutex, which deadlocks unit tests that destroy a PeerManager while holding it (denialofservice_tests). It is now PeerManager::FlushBridge, called from Shutdown after connman has stopped. - Thread-safety annotations: clang -Wthread-safety on net_processing.cpp gave 13 warnings (2 of them from the package commit); now none. !cs_main on a global recursive mutex becomes LOCKS_EXCLUDED with AssertLockNotHeld, lambdas touching g_msgproc_mutex state are annotated or unrolled, and BridgeOfferPackage declares the locks it must not hold. - The queue's rename is made durable (DirectoryCommit) before the anchor is written, so the ordering does not rest on the filesystem. At most 144 of our blocks are read for confirmations in one pass (was 2,000). An entry skipped for its feerate is offered anyway once an hour, so prioritisetransaction reaches it and a dead one leaves. test/test_reorg_retrack.py: X fed, mined, reorganised away; B restarts without mempool.dat; Y spends X. Before: X lost, Y dropped. After: both in. test/test_gate_release.py: B invalidates one of its own blocks, dropping below the height last read. Before: nothing is fed again. After: X is fed. test/test_truc_sibling.py: P and C1 (v3) in one block, C2 (v3, spends P, pays less than C1) in the next. Before: C2 dropped. After: C2 in once P is mined. All sixteen regtests pass. In test_bitcoin, denialofservice_tests and the net, tx-download, package, validation and mempool suites pass; four suites fail on this tree for reasons of the fork itself (BIP324 vectors vs the network magic, port 8333, NOP8 as OP_DRIVECHAIN, the 499999999 nLockTime in tx_valid), and nothing else fails when run on its own. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0136NHdQjigzYu7QGnAhskQb
Sixteen tests and a cost measurement, each reproducing a way the bridge lost
a Bitcoin transaction eCash would still accept (or stalled, or cost too much)
on the build before its fix, and showing it gone after. Each runs a stock
Bitcoin Core 29 node against the eCash node under test, which reaches it over
-bitcoinpeer; transactions are mined with generateblock so no relay policy
touches them, exactly as a Bitcoin block arrives. Standard-library Python.
BRIDGE_VANILLA_BIN=/path/to/bitcoin-29.0/bin contrib/bridge-tests/run_all.py build/bin/bitcoind
Needs commit 1's regtest-only -ecashheight and -bitcoinpeermagic. Drop both
commits if you test another way.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0136NHdQjigzYu7QGnAhskQb
Policy admits a transaction with dust (an anchor) only at zero fee, prioritisetransaction or not, and only in a package with a child that spends the dust. On Bitcoin that child was its fee payer. When the fee payer can never go in here -- typically it added a post-fork Bitcoin coin to pay the fee, so it is dropped as missing inputs -- nothing can carry the transaction. It was held for ever with everything waiting behind it: re-offered, written to bridgeheld.dat on every save, counted in the queue. On betanet on 2026-10-07 that was 35 transactions, mostly trees of zero-fee v3 transactions, with 1,870 waiting behind them. A transaction dropped as one that can never go in (missing inputs, or any refusal that never clears) now notes the held outputs it spends. A held transaction whose dust was among them is unpaid: it and everything waiting behind it are dropped at once, before the queue is next saved, both at the feed and in a retry pass. A held child whose package with its held parents fails, and whose other input has since gone, is judged dead too, so a fee payer cannot wait behind its own parent for ever. A zero-fee transaction without dust is unaffected: prioritisetransaction can still let it in. contrib/bridge-tests/test_unpaid_anchor.py: before 7 held, after 0. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0136NHdQjigzYu7QGnAhskQb
The bridge's first pass after a restart can feed X again before the test looks,
and the test then took X's presence for a mempool that survived the restart and
failed itself ("proves nothing") -- 3 of 5 runs, with and without the fix it
tests. If X is back, the log since the restart must now say it was fed again
after being lost; otherwise the mempool really survived and the test still
refuses to judge.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0136NHdQjigzYu7QGnAhskQb
The same race commit 14 fixed in test_mempool_loss: the bridge's first pass
after a restart fed X again 50 ms after startup, before the test looked, and
the test took X's presence for a mempool that survived the restart ("proves
nothing"; 1 of 2 full runs). If X is back, the log since the restart must now
say it was fed again after being lost; otherwise the mempool really survived
and the test still refuses to judge.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0136NHdQjigzYu7QGnAhskQb
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The block feed loses Bitcoin transactions that eCash would still accept, and each one it loses takes with it every later transaction spending its outputs — on a long Bitcoin chain, the rest of the chain. This PR closes every such path we found. Each is proven by a regtest that reproduces the loss on the build before the fix and shows it gone after.
Measured on betanet (2026-09-20). Replay ran at 99.2% while ECX blocks were ~21 s apart, and fell to ~52% within hours of the first retarget stretching them to ~5.5 min. 122,741 transactions were absent only because an ancestor was, and 12 of 12 sampled roots were accepted by
testmempoolaccept— offered once, refused, never offered again. That was the first bug below. The rest turned up by measuring the feed under the backlog betanet actually carried (up to ~300,000 held) and by adversarial review against this tree's validation code.What loses a transaction, and what stops it
too-large-clusterare dropped. The bridge held the legacytoo-long-mempool-chain, which the cluster mempool never emits.missing-ephemeral-spendsis dropped, though it clears once the dust parent confirms.mempool.dat; eviction and expiry do the same.Cost. A retry pass re-validated every held transaction: 401 ms of the message handler per pass at 8,000 held, and seconds per block at betanet's 170,000. It now costs what can make progress: ~7 ms at 8,000 (commit 6). A transaction still under the fee floor costs nothing (commit 8).
Tests
contrib/bridge-tests/(commits 12-15) holds seventeen regtest proofs plus a cost measurement. Each runs a stock Bitcoin Core 29 node against this one, standard-library Python only:test_cluster_leaktest_restart_persisttest_package_cpfptest_no_expirysetmocktimejump)test_package_multi_parenttest_ephemeral_siblingmissing-ephemeral-spendssibling waits for the dust parenttest_backpressuretest_fee_skiptest_mempool_losstest_known_relayedtest_stale_restarttest_mixed_dead_inputtest_crash_feedtest_reorg_retracktest_gate_releasetest_truc_siblingtest_unpaid_anchortest_retry_costBehaviour worth knowing
-bridgeheldmaxweight(default 400 MWU, about 200-400 MB in memory at the limit), no more Bitcoin blocks are fetched; they wait on the Bitcoin node. Entries older than 144 blocks stop counting. They are kept, and a warning names their weight, so what can never clear cannot pause the feed for ever.mempool.datis loaded, or before its chain is back at the height of the last pass. The pass needs no Bitcoin peer.prioritisetransactionor not, and only in a package with a child that spends the dust; on Bitcoin that child was its fee payer. When the fee payer can never go in here (typically it added a post-fork Bitcoin coin to pay the fee), nothing can carry the transaction, so it and everything waiting behind it are dropped at once. On betanet on 2026-10-07 that was 35 transactions, mostly trees of zero-fee v3 transactions, with 1,870 waiting behind them. Anyone can still put one in by hand with a new fee payer: an anchor is spendable by anyone.bridgeheld.datis now version 2; version 1 is still read. Both files are synced and written outsidecs_main, at most once a minute and at shutdown, queue first. A crash re-feeds the blocks since the last write, and nothing tracked is fed twice.Checked beyond the regtests.
net_processing.cppis clean underclang -Wthread-safety: zero warnings, down from two in commit 4's code. Intest_bitcoin,denialofservice_testsand the net, tx-download, package, validation and mempool suites pass.Four suites fail because of the fork itself, not this PR:
bip324_tests: the network magic is in the salt, so the fixed test vectors no longer match.net_peer_connection_tests: it expects port 8333.script_tests: NOP8 is nowOP_DRIVECHAIN.transaction_tests: GARP's 499999999 nLockTime appears intx_valid.In a full run, the abort in the second of these cascades into the suites after it; each of those passes on its own. Review of an earlier revision of this PR found thirteen new thread-safety warnings and a destructor that deadlocked
denialofservice_tests. Both are fixed here.Known limitation. The queue is serialised and synced in the message handler: up to ~100 MB, once a minute, at betanet's peak backlog. That is a fraction of a second on SSD every minute, against the per-block full rewrite under
cs_mainit replaces. Moving it to a background writer is a clean follow-up.Repairing a seed that has already lost transactions
There is no start-height option, but a full rewind exists. Stop the node, remove
bridgefeed.dat(keepbridgeheld.dat) and restart: the feed re-walks every block since the fork. It is safe to repeat, since what is already here or tracked is skipped. It is also all-or-nothing, which on betanet means re-fetching every post-fork block.The Python bridge
https://github.com/mbdrivechains/garp-replay does the same job from outside the node, needing no node changes. It has fed betanet since 2026-09-20 and carries most of its replay; other relays place some. With this PR the node matches it on everything but replay from a chosen height, which stays outside the node. The Python bridge remains the backup, and the way to repair from a height.
Commit 1 adds regtest-only
-ecashheightand-bitcoinpeermagic, and commit 12 adds the tests (commit 13 adds one more beside its fix, and commits 14 and 15 remove the same race fromtest_mempool_lossandtest_known_relayed); drop them if you test another way. Branch:betanet-bridge.🤖 Generated with Claude Code
https://claude.ai/code/session_0136NHdQjigzYu7QGnAhskQb