Skip to content

bridge: never lose a Bitcoin transaction the node could still accept - #1

Open
mblowes wants to merge 15 commits into
ecash-com:betanet-bridgefrom
mbdrivechains:bridge-retry-fix
Open

mblowes wants to merge 15 commits into
ecash-com:betanet-bridgefrom
mbdrivechains:bridge-retry-fix

Conversation

@mblowes

@mblowes mblowes commented Sep 20, 2026 •

Copy link
Copy Markdown

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

today commit
Chained transactions refused too-large-cluster are dropped. The bridge held the legacy too-long-mempool-chain, which the cluster mempool never emits. 2
A restart drops everything held; the queue lived only in memory. 3
A zero-fee parent and the child paying for it are both lost. 4 (one parent), 6 (up to 24)
A held transaction is dropped after 30 days, valid or not. 5
A sibling refused missing-ephemeral-spends is dropped, though it clears once the dust parent confirms. 7
A full queue turns new transactions away. 8: the feed pauses instead
A transaction accepted and then lost from the mempool is never fed again. A crash, OOM kill or container stop leaves no mempool.dat; eviction and expiry do the same. 9
A crash rolls the chainstate back up to an hour, so children of lately mined transactions look like orphans. 9
A crash between writing the anchor and the queue loses that block's transactions. Neither file was synced. 10
A tracked transaction mined here and then reorganised away falls out of tracking; a later mempool loss takes it and its descendants. 11
A v3 child refused only because its parent's single unconfirmed-child slot is taken (TRUC sibling eviction) is read as a lost conflict and dropped. 11

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:

BRIDGE_VANILLA_BIN=/path/to/bitcoin-29.0/bin contrib/bridge-tests/run_all.py build/bin/bitcoind
test proves before after
test_cluster_leak a 40-tx chain behind a cluster limit of 10 arrives whole 10/40 40/40
test_restart_persist what is held survives a restart 10/40 40/40
test_package_cpfp a zero-fee parent goes in with its paying child 0/2 2/2
test_no_expiry nothing is dropped for its age (31-day setmocktime jump) 10/20 20/20
test_package_multi_parent a child paying for two zero-fee parents goes in with both 0/3 3/3
test_ephemeral_sibling a missing-ephemeral-spends sibling waits for the dust parent lost in
test_backpressure a full queue pauses the feed rather than refusing; stuck entries cannot hold it paused 63/100 100/100
test_fee_skip a transaction under the fee floor is not re-offered every pass 200 offered/pass 0
test_mempool_loss a fed transaction lost with the mempool is fed again lost in
test_known_relayed one that reached the mempool by another route is tracked too lost in
test_stale_restart SIGKILL, restart a block behind, re-sync from a second node: nothing orphaned lost in
test_mixed_dead_input one live and one dead input: dropped at once, not held — dropped
test_crash_feed SIGKILL mid-feed: nothing lost, nothing doubled — 100/100
test_reorg_retrack a fed tx mined here, then reorganised away, is tracked again lost in
test_gate_release the catch-up gate releases when our chain won't get back to where it was stalled fed
test_truc_sibling a v3 child refused for its parent's one child slot waits lost in
test_unpaid_anchor a zero-fee anchor transaction whose fee payer can never go in is dropped with what waits behind it, at the feed and when the fee payer's other input dies later 7 held 0
test_retry_cost pass cost at 8,000 held 401 ms ~7 ms

Behaviour worth knowing

  • Nothing held is refused or expired. Back-pressure bounds the queue instead. While the weight held from our last 144 blocks is at -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.
  • Fed transactions are tracked until mined here, and remembered for 12 blocks after. Nothing is judged while the node is loading or in IBD, before mempool.dat is loaded, or before its chain is back at the height of the last pass. The pass needs no Bitcoin peer.
  • Missing inputs are judged one input at a time. A transaction waits only if every missing input comes from one we track. By fork+4 days a third of Bitcoin's transactions descended from a post-fork coinbase, so one live input and one dead is common, and such a transaction is dropped at once.
  • An unpaid transaction is dropped, not held for ever (commit 13). 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), 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.
  • On disk. bridgeheld.dat is now version 2; version 1 is still read. Both files are synced and written outside cs_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.
  • Reorganisations. A tracked transaction mined in a block that our chain reorganises away is taken back from that block and tracked as fed again, ahead of anything spending it. The Python bridge does not do this.
  • Catch-up. The gate that waits for our chain to return to where it was releases, with a log line, if no known header gets back there within two minutes of startup (a wiped chain, an invalidated branch).

Checked beyond the regtests. net_processing.cpp is clean under clang -Wthread-safety: zero warnings, down from two in commit 4's code. In test_bitcoin, denialofservice_tests and 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 now OP_DRIVECHAIN.
  • transaction_tests: GARP's 499999999 nLockTime appears in tx_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_main it 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 (keep bridgeheld.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 -ecashheight and -bitcoinpeermagic, and commit 12 adds the tests (commit 13 adds one more beside its fix, and commits 14 and 15 remove the same race from test_mempool_loss and test_known_relayed); drop them if you test another way. Branch: betanet-bridge.

🤖 Generated with Claude Code

https://claude.ai/code/session_0136NHdQjigzYu7QGnAhskQb

mblowes and others added 12 commits September 20, 2026 14:18
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
@mblowes mblowes changed the title bridge: hold the rejections the cluster mempool actually emits bridge: never lose a Bitcoin transaction the node could still accept Sep 23, 2026
mblowes and others added 3 commits October 7, 2026 15:57
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

No deployments
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.

1 participant