From c46a27de800ca0f0c10593e0f110780982dedcbb Mon Sep 17 00:00:00 2001 From: mblowes Date: Sun, 20 Sep 2026 14:06:12 +1000 Subject: [PATCH 01/15] test: regtest-only -ecashheight and -bitcoinpeermagic knobs 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) Claude-Session: https://claude.ai/code/session_01QRd31nLeodDD79WUeJGHEx --- src/chainparams.cpp | 1 + src/init.cpp | 4 ++++ src/kernel/chainparams.cpp | 2 ++ src/kernel/chainparams.h | 1 + src/net.cpp | 15 ++++++++++++++- 5 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/chainparams.cpp b/src/chainparams.cpp index 32c3bff73482..68797625c3bf 100644 --- a/src/chainparams.cpp +++ b/src/chainparams.cpp @@ -45,6 +45,7 @@ void ReadRegTestArgs(const ArgsManager& args, CChainParams::RegTestOptions& opti { if (auto value = args.GetBoolArg("-fastprune")) options.fastprune = *value; if (HasTestOption(args, "bip94")) options.enforce_bip94 = true; + options.ecash_height = args.GetIntArg("-ecashheight", 0); for (const std::string& arg : args.GetArgs("-testactivationheight")) { const auto found{arg.find('@')}; diff --git a/src/init.cpp b/src/init.cpp index 8ced906a5417..33290c9e9941 100644 --- a/src/init.cpp +++ b/src/init.cpp @@ -540,6 +540,10 @@ void SetupServerArgs(ArgsManager& argsman, bool can_listen_ipc) argsman.AddArg("-addnode=", strprintf("Add a node to connect to and attempt to keep the connection open (see the addnode RPC help for more info). This option can be specified multiple times to add multiple nodes; connections are limited to %u at a time and are counted separately from the -maxconnections limit.", MAX_ADDNODE_CONNECTIONS), ArgsManager::ALLOW_ANY | ArgsManager::NETWORK_ONLY, OptionsCategory::CONNECTION); argsman.AddArg("-bitcoinpeer=", "Connect to an operator-controlled, unpruned Bitcoin full node using Bitcoin's network magic, to fetch the pre-fork chain and, past the fork, feed our mempool from Bitcoin blocks. The connection should use loopback or a trusted private network because Bitcoin P2P is not authenticated. Up to four peers may be given when the block feed is active: one fetches blocks at a time, the others follow headers and take over if it disconnects or stalls. Each peer must advertise NODE_NETWORK and NODE_WITNESS. Always v1; never relays addresses or transaction gossip. Seed-node use only.", ArgsManager::ALLOW_ANY | ArgsManager::NETWORK_ONLY, OptionsCategory::CONNECTION); + argsman.AddArg("-bitcoinpeermagic=", "Speak this 4-byte network magic to -bitcoinpeer peers instead of Bitcoin mainnet's (regtest only, for tests).", + ArgsManager::ALLOW_ANY | ArgsManager::DEBUG_ONLY, OptionsCategory::CONNECTION); + argsman.AddArg("-ecashheight=", "Fork height at which eCash rules and the Bitcoin block feed activate (regtest only, for tests).", + ArgsManager::ALLOW_ANY | ArgsManager::DEBUG_ONLY, OptionsCategory::CHAINPARAMS); argsman.AddArg("-asmap=", strprintf("Specify asn mapping used for bucketing of the peers. Relative paths will be prefixed by the net-specific datadir location.%s", #ifdef ENABLE_EMBEDDED_ASMAP " If a bool arg is given (-asmap or -asmap=1), the embedded mapping data in the binary will be used." diff --git a/src/kernel/chainparams.cpp b/src/kernel/chainparams.cpp index 0f5326174623..cd58d80609e4 100644 --- a/src/kernel/chainparams.cpp +++ b/src/kernel/chainparams.cpp @@ -559,6 +559,8 @@ class CRegTestParams : public CChainParams explicit CRegTestParams(const RegTestOptions& opts) { m_chain_type = ChainType::REGTEST; + consensus.EcashHeight = opts.ecash_height; // regtest only (tests); 0 = plain Bitcoin regtest + consensus.EcashForkBits = 0x207fffff; // regtest powLimit: the fork block keeps regtest difficulty consensus.signet_blocks = false; consensus.signet_challenge.clear(); consensus.nSubsidyHalvingInterval = 150; diff --git a/src/kernel/chainparams.h b/src/kernel/chainparams.h index f7209bee1b06..aacd5fa4ac95 100644 --- a/src/kernel/chainparams.h +++ b/src/kernel/chainparams.h @@ -152,6 +152,7 @@ class CChainParams std::unordered_map activation_heights{}; bool fastprune{false}; bool enforce_bip94{false}; + int ecash_height{0}; //!< regtest only: fork height for eCash rules + the Bitcoin block feed (tests) }; static std::unique_ptr RegTest(const RegTestOptions& options); diff --git a/src/net.cpp b/src/net.cpp index 2da4b4b90733..de583283f4df 100644 --- a/src/net.cpp +++ b/src/net.cpp @@ -61,6 +61,19 @@ TRACEPOINT_SEMAPHORE(net, outbound_message); * under our own magic. */ static constexpr MessageStartChars BITCOIN_MAINNET_MAGIC{0xf9, 0xbe, 0xb4, 0xd9}; +/** Magic spoken to -bitcoinpeer peers: Bitcoin mainnet's, or -bitcoinpeermagic on regtest (tests). */ +static MessageStartChars BitcoinPeerMagic() +{ + if (Params().GetChainType() == ChainType::REGTEST) { + if (const auto bytes{TryParseHex(gArgs.GetArg("-bitcoinpeermagic", ""))}; bytes && bytes->size() == 4) { + MessageStartChars magic; + std::copy(bytes->begin(), bytes->end(), magic.begin()); + return magic; + } + } + return BITCOIN_MAINNET_MAGIC; +} + /** Maximum number of block-relay-only anchor connections */ static constexpr size_t MAX_BLOCK_RELAY_ONLY_ANCHORS = 2; static_assert (MAX_BLOCK_RELAY_ONLY_ANCHORS <= static_cast(MAX_BLOCK_RELAY_ONLY_CONNECTIONS), "MAX_BLOCK_RELAY_ONLY_ANCHORS must not exceed MAX_BLOCK_RELAY_ONLY_CONNECTIONS."); @@ -554,7 +567,7 @@ CNode* CConnman::ConnectNode(CAddress addrConnect, .i2p_sam_session = std::move(i2p_transient_session), .recv_flood_size = nReceiveFloodSize, .use_v2transport = use_v2transport, - .magic = bitcoin_magic ? std::optional{BITCOIN_MAINNET_MAGIC} : std::nullopt, + .magic = bitcoin_magic ? std::optional{BitcoinPeerMagic()} : std::nullopt, }); pnode->AddRef(); From 939968c9ff00a41fe55bb319d8f9901c44693dba Mon Sep 17 00:00:00 2001 From: mblowes Date: Sun, 20 Sep 2026 14:34:22 +1000 Subject: [PATCH 02/15] bridge: hold the rejections a slower chain actually produces 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) Claude-Session: https://claude.ai/code/session_0136NHdQjigzYu7QGnAhskQb --- src/net_processing.cpp | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/src/net_processing.cpp b/src/net_processing.cpp index 430354d679b8..79535757b58e 100644 --- a/src/net_processing.cpp +++ b/src/net_processing.cpp @@ -142,7 +142,7 @@ static constexpr size_t BRIDGE_MAX_FOREIGN_HEADERS{60000}; /** Bridge: file (in the network datadir) holding the last fed " ". */ static constexpr const char* BRIDGE_ANCHOR_FILE{"bridgefeed.dat"}; /** Bridge: transactions rejected for a reason that may clear later are held this long and re-offered. */ -static constexpr size_t BRIDGE_HELD_TXS_MAX{2000}; +static constexpr size_t BRIDGE_HELD_TXS_MAX{50000}; static constexpr auto BRIDGE_HELD_TXS_MAX_AGE{24h}; static constexpr auto BRIDGE_HELD_TXS_RETRY_INTERVAL{10min}; /** Default time during which a peer must stall block download progress before being disconnected. @@ -2995,8 +2995,16 @@ PeerManagerImpl::BridgeTxResult PeerManagerImpl::BridgeOfferTx(const CTransactio } if (reason == "txn-already-in-mempool" || reason == "txn-already-known") return BridgeTxResult::KNOWN; // May clear later: locktime or sequence not yet reached on our chain, ancestors still - // unconfirmed here, or mempool limits. - if (what == TxValidationResult::TX_PREMATURE_SPEND || reason == "too-long-mempool-chain" || reason == "mempool full" || reason == "mempool min fee not met") { + // unconfirmed here, or mempool limits. These are the reasons a node running the cluster + // mempool emits for a condition that a later block clears; "too-long-mempool-chain" is + // pre-cluster-mempool and is never emitted, so a chain that overflows a cluster used to + // fall through to OTHER and be dropped, taking every later Bitcoin transaction that spends + // its outputs with it. + if (what == TxValidationResult::TX_PREMATURE_SPEND || + reason == "too-large-cluster" || + reason == "TRUC-violation" || + reason == "mempool full" || + reason == "mempool min fee not met") { return BridgeTxResult::HELD; } return BridgeTxResult::OTHER; @@ -3006,8 +3014,12 @@ void PeerManagerImpl::BridgeHoldTx(const CTransactionRef& tx, std::chrono::micro { AssertLockHeld(g_msgproc_mutex); if (m_bridge_held_txs.size() >= BRIDGE_HELD_TXS_MAX) { - m_bridge_held_txids.erase(m_bridge_held_txs.front().first->GetHash()); - m_bridge_held_txs.pop_front(); + // Keep what is already queued: parents precede their children, so dropping the front + // would orphan the rest of a chain. The newcomer is lost instead, and that is a hole + // in the feed, so it is a warning rather than a silent eviction. + LogWarning("bridge: holding queue is full (%u); Bitcoin transaction %s is NOT fed\n", + BRIDGE_HELD_TXS_MAX, tx->GetHash().ToString()); + return; } m_bridge_held_txs.emplace_back(tx, now); m_bridge_held_txids.insert(tx->GetHash()); From 8472004ffdb490328ee66ce82e0e29d3b28dc2e6 Mon Sep 17 00:00:00 2001 From: mblowes Date: Tue, 22 Sep 2026 07:12:02 +1000 Subject: [PATCH 03/15] bridge: keep the held queue across a restart and size it for a real backlog 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) Claude-Session: https://claude.ai/code/session_0136NHdQjigzYu7QGnAhskQb --- src/net_processing.cpp | 99 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 94 insertions(+), 5 deletions(-) diff --git a/src/net_processing.cpp b/src/net_processing.cpp index 79535757b58e..648635b0f377 100644 --- a/src/net_processing.cpp +++ b/src/net_processing.cpp @@ -142,8 +142,17 @@ static constexpr size_t BRIDGE_MAX_FOREIGN_HEADERS{60000}; /** Bridge: file (in the network datadir) holding the last fed " ". */ static constexpr const char* BRIDGE_ANCHOR_FILE{"bridgefeed.dat"}; /** Bridge: transactions rejected for a reason that may clear later are held this long and re-offered. */ -static constexpr size_t BRIDGE_HELD_TXS_MAX{50000}; -static constexpr auto BRIDGE_HELD_TXS_MAX_AGE{24h}; +/** Bridge: total weight of held transactions kept before new ones are turned away. A Bitcoin + * block is 4 MWU, so this is a hundred blocks' worth (~180 MB of memory at the limit). The + * deepest backlog seen on betanet was ~207,000 transactions / 127 MWU, so this is roughly 3x + * the worst observed case and comparable to a default mempool. */ +static constexpr uint64_t BRIDGE_HELD_TXS_MAX_WEIGHT{100 * 4'000'000}; +/** Bridge: a held transaction is given up on after this long. It exists to bound the file, not + * to decide correctness: anything still valid is re-offered until it is accepted or provably + * dead, and a chain that deep takes days to unwind at one cluster per block. */ +static constexpr auto BRIDGE_HELD_TXS_MAX_AGE{30 * 24h}; +/** Bridge: file (in the network datadir) holding the queue across a restart. */ +static constexpr const char* BRIDGE_HELD_FILE{"bridgeheld.dat"}; static constexpr auto BRIDGE_HELD_TXS_RETRY_INTERVAL{10min}; /** Default time during which a peer must stall block download progress before being disconnected. * the actual timeout is increased temporarily if peers are disconnected for hitting the timeout */ @@ -722,6 +731,11 @@ class PeerManagerImpl final : public PeerManager void BridgeInitPeer(NodeId nodeid) EXCLUSIVE_LOCKS_REQUIRED(cs_main); /** Bridge: persist the last fed block. */ void BridgeSaveAnchor() EXCLUSIVE_LOCKS_REQUIRED(cs_main); + fs::path BridgeHeldPath() const; + /** Bridge: write the held queue, so a restart does not silently drop what it is holding. */ + void BridgeSaveHeld() EXCLUSIVE_LOCKS_REQUIRED(NetEventsInterface::g_msgproc_mutex); + /** Bridge: read the held queue back at the first offer after startup. */ + void BridgeLoadHeld() EXCLUSIVE_LOCKS_REQUIRED(NetEventsInterface::g_msgproc_mutex); /** Bridge: locator from the foreign tip down through the anchor and the fork parent into our chain. */ CBlockLocator BridgeLocator(NodeId nodeid) EXCLUSIVE_LOCKS_REQUIRED(cs_main); bool BridgeKnowsBlock(NodeId nodeid, const uint256& hash) EXCLUSIVE_LOCKS_REQUIRED(cs_main); @@ -909,6 +923,9 @@ class PeerManagerImpl final : public PeerManager std::set m_bridge_held_txids GUARDED_BY(NetEventsInterface::g_msgproc_mutex); uint256 m_bridge_held_tip GUARDED_BY(NetEventsInterface::g_msgproc_mutex); std::chrono::microseconds m_bridge_held_retried_at GUARDED_BY(NetEventsInterface::g_msgproc_mutex){0us}; + uint64_t m_bridge_held_weight GUARDED_BY(NetEventsInterface::g_msgproc_mutex){0}; + bool m_bridge_held_dirty GUARDED_BY(NetEventsInterface::g_msgproc_mutex){false}; + bool m_bridge_held_loaded GUARDED_BY(NetEventsInterface::g_msgproc_mutex){false}; /** Bridge: the Bitcoin peer fetching blocks. Others only follow headers until it leaves, stalls or idles. */ NodeId m_bridge_feed_peer GUARDED_BY(cs_main){-1}; /** Bridge: recently fed block hashes, so a peer that learns of one later does not queue it again. */ @@ -2787,6 +2804,71 @@ void PeerManagerImpl::BridgeSaveAnchor() if (!RenameOver(tmp, path)) LogWarning("bridge: cannot rename %s\n", fs::PathToString(tmp)); } +fs::path PeerManagerImpl::BridgeHeldPath() const +{ + return m_chainman.m_options.datadir / BRIDGE_HELD_FILE; +} + +void PeerManagerImpl::BridgeSaveHeld() +{ + AssertLockHeld(g_msgproc_mutex); + m_bridge_held_dirty = false; + const fs::path path{BridgeHeldPath()}; + if (m_bridge_held_txs.empty()) { + fs::remove(path); + return; + } + const fs::path tmp{path + ".tmp"}; + { + AutoFile out{fsbridge::fopen(tmp, "wb")}; + if (out.IsNull()) { + LogWarning("bridge: cannot write %s\n", fs::PathToString(tmp)); + return; + } + try { + out << static_cast(m_bridge_held_txs.size()); + for (const auto& [tx, since] : m_bridge_held_txs) { + out << TX_WITH_WITNESS(*tx) << static_cast(count_microseconds(since)); + } + } catch (const std::exception& e) { + LogWarning("bridge: cannot write %s: %s\n", fs::PathToString(tmp), e.what()); + return; + } + } + if (!RenameOver(tmp, path)) LogWarning("bridge: cannot rename %s\n", fs::PathToString(tmp)); +} + +void PeerManagerImpl::BridgeLoadHeld() +{ + AssertLockHeld(g_msgproc_mutex); + m_bridge_held_loaded = true; + AutoFile in{fsbridge::fopen(BridgeHeldPath(), "rb")}; + if (in.IsNull()) return; + try { + uint64_t count{0}; + in >> count; + for (uint64_t i = 0; i < count; ++i) { + CMutableTransaction mtx; + int64_t since{0}; + in >> TX_WITH_WITNESS(mtx) >> since; + CTransactionRef tx{MakeTransactionRef(std::move(mtx))}; + if (m_bridge_held_txids.insert(tx->GetHash()).second) { + m_bridge_held_txs.emplace_back(tx, std::chrono::microseconds{since}); + m_bridge_held_weight += GetTransactionWeight(*tx); + } + } + } catch (const std::exception& e) { + // A truncated file costs us the tail of the queue, not correctness: those transactions + // are re-offered when their block is re-fed, or stay missing exactly as before. + LogWarning("bridge: %s is unreadable past %u transactions: %s\n", + fs::PathToString(BridgeHeldPath()), m_bridge_held_txs.size(), e.what()); + } + if (!m_bridge_held_txs.empty()) { + LogInfo("bridge: resuming with %u held Bitcoin transaction(s), %u WU\n", + m_bridge_held_txs.size(), m_bridge_held_weight); + } +} + CBlockLocator PeerManagerImpl::BridgeLocator(NodeId nodeid) { AssertLockHeld(cs_main); @@ -3013,21 +3095,24 @@ PeerManagerImpl::BridgeTxResult PeerManagerImpl::BridgeOfferTx(const CTransactio void PeerManagerImpl::BridgeHoldTx(const CTransactionRef& tx, std::chrono::microseconds now) { AssertLockHeld(g_msgproc_mutex); - if (m_bridge_held_txs.size() >= BRIDGE_HELD_TXS_MAX) { + if (m_bridge_held_weight + GetTransactionWeight(*tx) > BRIDGE_HELD_TXS_MAX_WEIGHT) { // Keep what is already queued: parents precede their children, so dropping the front // would orphan the rest of a chain. The newcomer is lost instead, and that is a hole // in the feed, so it is a warning rather than a silent eviction. - LogWarning("bridge: holding queue is full (%u); Bitcoin transaction %s is NOT fed\n", - BRIDGE_HELD_TXS_MAX, tx->GetHash().ToString()); + LogWarning("bridge: holding queue is full (%u transactions, %u WU); Bitcoin transaction %s is NOT fed\n", + m_bridge_held_txs.size(), m_bridge_held_weight, tx->GetHash().ToString()); return; } m_bridge_held_txs.emplace_back(tx, now); m_bridge_held_txids.insert(tx->GetHash()); + m_bridge_held_weight += GetTransactionWeight(*tx); + m_bridge_held_dirty = true; } void PeerManagerImpl::BridgeRetryHeldTxs(std::chrono::microseconds now) { AssertLockHeld(g_msgproc_mutex); + if (!m_bridge_held_loaded) BridgeLoadHeld(); if (m_bridge_held_txs.empty()) return; const uint256 tip{WITH_LOCK(cs_main, return m_chainman.ActiveTip() ? m_chainman.ActiveTip()->GetBlockHash() : uint256{})}; if (tip == m_bridge_held_tip && now - m_bridge_held_retried_at < BRIDGE_HELD_TXS_RETRY_INTERVAL) return; @@ -3059,7 +3144,10 @@ void PeerManagerImpl::BridgeRetryHeldTxs(std::chrono::microseconds now) } } m_bridge_held_txs = std::move(held); + m_bridge_held_weight = 0; + for (const auto& [tx, _since] : m_bridge_held_txs) m_bridge_held_weight += GetTransactionWeight(*tx); if (accepted + dropped > 0) { + m_bridge_held_dirty = true; LogInfo("bridge: re-offered held Bitcoin transactions: %d accepted, %d dropped, %u still held\n", accepted, dropped, m_bridge_held_txs.size()); } } @@ -3139,6 +3227,7 @@ bool PeerManagerImpl::BridgeProcessBlock(CNode& pfrom, Peer& peer, const std::sh LOCK(cs_main); BridgeMarkProcessed(pfrom.GetId(), hash); if (m_bridge_anchor_dirty) BridgeSaveAnchor(); + if (m_bridge_held_dirty) BridgeSaveHeld(); return true; } From 0c481465f9edd28fd2b866bbdb014fafcd5b0528 Mon Sep 17 00:00:00 2001 From: mbdrivechains Date: Tue, 22 Sep 2026 11:22:10 +1000 Subject: [PATCH 04/15] bridge: put a parent below the fee floor in with the child that pays 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) Claude-Session: https://claude.ai/code/session_0136NHdQjigzYu7QGnAhskQb --- src/net_processing.cpp | 68 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 64 insertions(+), 4 deletions(-) diff --git a/src/net_processing.cpp b/src/net_processing.cpp index 648635b0f377..9f9119405fc2 100644 --- a/src/net_processing.cpp +++ b/src/net_processing.cpp @@ -752,6 +752,12 @@ class PeerManagerImpl final : public PeerManager /** Bridge: offer one Bitcoin transaction to the mempool as sendrawtransaction would; `reason` is set unless accepted or known. */ BridgeTxResult BridgeOfferTx(const CTransactionRef& tx, std::string& reason) EXCLUSIVE_LOCKS_REQUIRED(g_msgproc_mutex, !m_peer_mutex, !m_tx_download_mutex); + /** Bridge: a held transaction spending an output of `txid`, if there is one. */ + CTransactionRef BridgeHeldChild(const Txid& txid) const + EXCLUSIVE_LOCKS_REQUIRED(NetEventsInterface::g_msgproc_mutex); + /** Bridge: offer a held parent together with the held child that pays for it. */ + bool BridgeOfferPackage(const CTransactionRef& parent, const CTransactionRef& child) + EXCLUSIVE_LOCKS_REQUIRED(NetEventsInterface::g_msgproc_mutex); /** Bridge: keep a transaction for BridgeRetryHeldTxs (oldest evicted beyond BRIDGE_HELD_TXS_MAX). */ void BridgeHoldTx(const CTransactionRef& tx, std::chrono::microseconds now) EXCLUSIVE_LOCKS_REQUIRED(g_msgproc_mutex); /** Bridge: re-offer held transactions once our tip has moved or enough time has passed. */ @@ -3086,12 +3092,48 @@ PeerManagerImpl::BridgeTxResult PeerManagerImpl::BridgeOfferTx(const CTransactio reason == "too-large-cluster" || reason == "TRUC-violation" || reason == "mempool full" || - reason == "mempool min fee not met") { + reason == "mempool min fee not met" || + reason == "min relay fee not met") { return BridgeTxResult::HELD; } return BridgeTxResult::OTHER; } +CTransactionRef PeerManagerImpl::BridgeHeldChild(const Txid& txid) const +{ + AssertLockHeld(g_msgproc_mutex); + for (const auto& [tx, _since] : m_bridge_held_txs) { + for (const CTxIn& in : tx->vin) { + if (in.prevout.hash == txid) return tx; + } + } + return nullptr; +} + +bool PeerManagerImpl::BridgeOfferPackage(const CTransactionRef& parent, const CTransactionRef& child) +{ + AssertLockHeld(g_msgproc_mutex); + LOCK2(cs_main, m_tx_download_mutex); + const Package package{parent, child}; + const PackageMempoolAcceptResult result{ProcessNewPackage(m_chainman.ActiveChainstate(), m_mempool, package, + /*test_accept=*/false, /*client_maxfeerate=*/std::nullopt)}; + bool accepted{false}; + for (const CTransactionRef& tx : package) { + const auto it{result.m_tx_results.find(tx->GetWitnessHash())}; + if (it == result.m_tx_results.end()) continue; + if (it->second.m_result_type != MempoolAcceptResult::ResultType::VALID) continue; + m_mempool.AddUnbroadcastTx(tx->GetHash()); + m_txdownloadman.MempoolAcceptedTx(tx); + InitiateTxBroadcastToAll(tx->GetHash(), tx->GetWitnessHash()); + accepted = true; + } + if (!accepted) { + LogDebug(BCLog::MEMPOOL, "bridge: package %s + %s not accepted: %s\n", parent->GetHash().ToString(), + child->GetHash().ToString(), result.m_state.ToString()); + } + return accepted; +} + void PeerManagerImpl::BridgeHoldTx(const CTransactionRef& tx, std::chrono::microseconds now) { AssertLockHeld(g_msgproc_mutex); @@ -3118,7 +3160,8 @@ void PeerManagerImpl::BridgeRetryHeldTxs(std::chrono::microseconds now) if (tip == m_bridge_held_tip && now - m_bridge_held_retried_at < BRIDGE_HELD_TXS_RETRY_INTERVAL) return; m_bridge_held_tip = tip; m_bridge_held_retried_at = now; - int accepted{0}, dropped{0}; + int accepted{0}, dropped{0}, packaged{0}; + std::set packaged_children; std::deque> held; for (const auto& [tx, since] : m_bridge_held_txs) { std::string reason; @@ -3128,10 +3171,26 @@ void PeerManagerImpl::BridgeRetryHeldTxs(std::chrono::microseconds now) ++accepted; break; case BridgeTxResult::HELD: + // A transaction below the fee floor never gets in on its own: on Bitcoin a child paid + // for it, and that child is held behind it here. Offer the two together, exactly as a + // peer's 1p1c package would arrive. + if (reason == "min relay fee not met" || reason == "mempool min fee not met") { + if (const CTransactionRef child{BridgeHeldChild(tx->GetHash())}) { + if (BridgeOfferPackage(tx, child)) { + ++packaged; + packaged_children.insert(child->GetHash()); + break; + } + } + } keep = now - since < BRIDGE_HELD_TXS_MAX_AGE; if (!keep) ++dropped; break; case BridgeTxResult::KNOWN: + // Already in: either someone else relayed it, or it went in as the child of a package + // earlier in this pass. + if (packaged_children.erase(tx->GetHash()) > 0) ++packaged; else ++dropped; + break; case BridgeTxResult::MISSING: case BridgeTxResult::OTHER: ++dropped; @@ -3146,9 +3205,10 @@ void PeerManagerImpl::BridgeRetryHeldTxs(std::chrono::microseconds now) m_bridge_held_txs = std::move(held); m_bridge_held_weight = 0; for (const auto& [tx, _since] : m_bridge_held_txs) m_bridge_held_weight += GetTransactionWeight(*tx); - if (accepted + dropped > 0) { + if (accepted + dropped + packaged > 0) { m_bridge_held_dirty = true; - LogInfo("bridge: re-offered held Bitcoin transactions: %d accepted, %d dropped, %u still held\n", accepted, dropped, m_bridge_held_txs.size()); + LogInfo("bridge: re-offered held Bitcoin transactions: %d accepted, %d in packages, %d dropped, %u still held\n", + accepted, packaged, dropped, m_bridge_held_txs.size()); } } From deb2870c12f0a8ce29a73910f77c707c0773f7a0 Mon Sep 17 00:00:00 2001 From: mbdrivechains Date: Wed, 23 Sep 2026 19:13:46 +1000 Subject: [PATCH 05/15] bridge: never expire a held transaction that can still be accepted 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) Claude-Session: https://claude.ai/code/session_0136NHdQjigzYu7QGnAhskQb --- src/net_processing.cpp | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/src/net_processing.cpp b/src/net_processing.cpp index 9f9119405fc2..428bfad9c1c3 100644 --- a/src/net_processing.cpp +++ b/src/net_processing.cpp @@ -147,10 +147,6 @@ static constexpr const char* BRIDGE_ANCHOR_FILE{"bridgefeed.dat"}; * deepest backlog seen on betanet was ~207,000 transactions / 127 MWU, so this is roughly 3x * the worst observed case and comparable to a default mempool. */ static constexpr uint64_t BRIDGE_HELD_TXS_MAX_WEIGHT{100 * 4'000'000}; -/** Bridge: a held transaction is given up on after this long. It exists to bound the file, not - * to decide correctness: anything still valid is re-offered until it is accepted or provably - * dead, and a chain that deep takes days to unwind at one cluster per block. */ -static constexpr auto BRIDGE_HELD_TXS_MAX_AGE{30 * 24h}; /** Bridge: file (in the network datadir) holding the queue across a restart. */ static constexpr const char* BRIDGE_HELD_FILE{"bridgeheld.dat"}; static constexpr auto BRIDGE_HELD_TXS_RETRY_INTERVAL{10min}; @@ -2864,8 +2860,9 @@ void PeerManagerImpl::BridgeLoadHeld() } } } catch (const std::exception& e) { - // A truncated file costs us the tail of the queue, not correctness: those transactions - // are re-offered when their block is re-fed, or stay missing exactly as before. + // Only the tail is lost, and it is lost for good: the anchor has passed those blocks, so they are + // not fed again (a rewind -- removing bridgefeed.dat -- is the recovery). The file is written to a + // temporary and renamed over, so a crash mid-write cannot cause this; only damage to the file can. LogWarning("bridge: %s is unreadable past %u transactions: %s\n", fs::PathToString(BridgeHeldPath()), m_bridge_held_txs.size(), e.what()); } @@ -3161,6 +3158,7 @@ void PeerManagerImpl::BridgeRetryHeldTxs(std::chrono::microseconds now) m_bridge_held_tip = tip; m_bridge_held_retried_at = now; int accepted{0}, dropped{0}, packaged{0}; + std::optional oldest; std::set packaged_children; std::deque> held; for (const auto& [tx, since] : m_bridge_held_txs) { @@ -3183,8 +3181,9 @@ void PeerManagerImpl::BridgeRetryHeldTxs(std::chrono::microseconds now) } } } - keep = now - since < BRIDGE_HELD_TXS_MAX_AGE; - if (!keep) ++dropped; + // Still refused for a reason that clears. However long it has waited, it stays: the block it + // came in is never fed again, so dropping it here would lose it and everything spending it. + keep = true; break; case BridgeTxResult::KNOWN: // Already in: either someone else relayed it, or it went in as the child of a package @@ -3198,6 +3197,7 @@ void PeerManagerImpl::BridgeRetryHeldTxs(std::chrono::microseconds now) } if (keep) { held.emplace_back(tx, since); + if (!oldest || since < *oldest) oldest = since; } else { m_bridge_held_txids.erase(tx->GetHash()); } @@ -3205,10 +3205,13 @@ void PeerManagerImpl::BridgeRetryHeldTxs(std::chrono::microseconds now) m_bridge_held_txs = std::move(held); m_bridge_held_weight = 0; for (const auto& [tx, _since] : m_bridge_held_txs) m_bridge_held_weight += GetTransactionWeight(*tx); + const std::string waited{oldest ? strprintf(", oldest held %dh", std::chrono::duration_cast(now - *oldest).count()) : ""}; if (accepted + dropped + packaged > 0) { m_bridge_held_dirty = true; - LogInfo("bridge: re-offered held Bitcoin transactions: %d accepted, %d in packages, %d dropped, %u still held\n", - accepted, packaged, dropped, m_bridge_held_txs.size()); + LogInfo("bridge: re-offered held Bitcoin transactions: %d accepted, %d in packages, %d dropped, %u still held%s\n", + accepted, packaged, dropped, m_bridge_held_txs.size(), waited); + } else { + LogDebug(BCLog::MEMPOOL, "bridge: retry pass: nothing accepted, %u still held%s\n", m_bridge_held_txs.size(), waited); } } From ec1eb02936609cef8bb61115df4d783a56dd5cdb Mon Sep 17 00:00:00 2001 From: mbdrivechains Date: Wed, 23 Sep 2026 19:24:43 +1000 Subject: [PATCH 06/15] bridge: re-offer only what can make progress; package a child with all 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) Claude-Session: https://claude.ai/code/session_0136NHdQjigzYu7QGnAhskQb --- src/net_processing.cpp | 192 ++++++++++++++++++++++++++++------------- 1 file changed, 130 insertions(+), 62 deletions(-) diff --git a/src/net_processing.cpp b/src/net_processing.cpp index 428bfad9c1c3..ea2589d60d63 100644 --- a/src/net_processing.cpp +++ b/src/net_processing.cpp @@ -748,11 +748,15 @@ class PeerManagerImpl final : public PeerManager /** Bridge: offer one Bitcoin transaction to the mempool as sendrawtransaction would; `reason` is set unless accepted or known. */ BridgeTxResult BridgeOfferTx(const CTransactionRef& tx, std::string& reason) EXCLUSIVE_LOCKS_REQUIRED(g_msgproc_mutex, !m_peer_mutex, !m_tx_download_mutex); - /** Bridge: a held transaction spending an output of `txid`, if there is one. */ - CTransactionRef BridgeHeldChild(const Txid& txid) const + /** Bridge: offer held parents together with the held child that pays for them (sorted, child last). + * Returns the transactions that are in the mempool afterwards. */ + std::set BridgeOfferPackage(const Package& package) EXCLUSIVE_LOCKS_REQUIRED(NetEventsInterface::g_msgproc_mutex); - /** Bridge: offer a held parent together with the held child that pays for it. */ - bool BridgeOfferPackage(const CTransactionRef& parent, const CTransactionRef& child) + /** Bridge: whether any input of `tx` spends a held transaction. */ + bool BridgeHasHeldParent(const CTransaction& tx) const + EXCLUSIVE_LOCKS_REQUIRED(NetEventsInterface::g_msgproc_mutex); + /** Bridge: the held parents of `tx` (in input order, each once). */ + std::vector BridgeHeldParents(const CTransaction& tx) const EXCLUSIVE_LOCKS_REQUIRED(NetEventsInterface::g_msgproc_mutex); /** Bridge: keep a transaction for BridgeRetryHeldTxs (oldest evicted beyond BRIDGE_HELD_TXS_MAX). */ void BridgeHoldTx(const CTransactionRef& tx, std::chrono::microseconds now) EXCLUSIVE_LOCKS_REQUIRED(g_msgproc_mutex); @@ -922,12 +926,14 @@ class PeerManagerImpl final : public PeerManager bool m_bridge_anchor_dirty GUARDED_BY(cs_main){false}; /** Bridge: transactions rejected for a reason that may clear later, with when they were first offered. */ std::deque> m_bridge_held_txs GUARDED_BY(NetEventsInterface::g_msgproc_mutex); - std::set m_bridge_held_txids GUARDED_BY(NetEventsInterface::g_msgproc_mutex); + std::unordered_set m_bridge_held_txids GUARDED_BY(NetEventsInterface::g_msgproc_mutex); uint256 m_bridge_held_tip GUARDED_BY(NetEventsInterface::g_msgproc_mutex); std::chrono::microseconds m_bridge_held_retried_at GUARDED_BY(NetEventsInterface::g_msgproc_mutex){0us}; uint64_t m_bridge_held_weight GUARDED_BY(NetEventsInterface::g_msgproc_mutex){0}; bool m_bridge_held_dirty GUARDED_BY(NetEventsInterface::g_msgproc_mutex){false}; bool m_bridge_held_loaded GUARDED_BY(NetEventsInterface::g_msgproc_mutex){false}; + /** Bridge: a fed block left a transaction below the fee floor; retry at once so a child can carry it. */ + bool m_bridge_retry_now GUARDED_BY(NetEventsInterface::g_msgproc_mutex){false}; /** Bridge: the Bitcoin peer fetching blocks. Others only follow headers until it leaves, stalls or idles. */ NodeId m_bridge_feed_peer GUARDED_BY(cs_main){-1}; /** Bridge: recently fed block hashes, so a peer that learns of one later does not queue it again. */ @@ -3096,39 +3102,55 @@ PeerManagerImpl::BridgeTxResult PeerManagerImpl::BridgeOfferTx(const CTransactio return BridgeTxResult::OTHER; } -CTransactionRef PeerManagerImpl::BridgeHeldChild(const Txid& txid) const +bool PeerManagerImpl::BridgeHasHeldParent(const CTransaction& tx) const { AssertLockHeld(g_msgproc_mutex); - for (const auto& [tx, _since] : m_bridge_held_txs) { - for (const CTxIn& in : tx->vin) { - if (in.prevout.hash == txid) return tx; + return std::any_of(tx.vin.begin(), tx.vin.end(), [&](const CTxIn& in) { return m_bridge_held_txids.contains(in.prevout.hash); }); +} + +std::vector PeerManagerImpl::BridgeHeldParents(const CTransaction& tx) const +{ + AssertLockHeld(g_msgproc_mutex); + std::vector parents; + for (const CTxIn& in : tx.vin) { + if (m_bridge_held_txids.contains(in.prevout.hash) && + std::find(parents.begin(), parents.end(), in.prevout.hash) == parents.end()) { + parents.push_back(in.prevout.hash); } } - return nullptr; + return parents; } -bool PeerManagerImpl::BridgeOfferPackage(const CTransactionRef& parent, const CTransactionRef& child) +std::set PeerManagerImpl::BridgeOfferPackage(const Package& package) { AssertLockHeld(g_msgproc_mutex); LOCK2(cs_main, m_tx_download_mutex); - const Package package{parent, child}; const PackageMempoolAcceptResult result{ProcessNewPackage(m_chainman.ActiveChainstate(), m_mempool, package, /*test_accept=*/false, /*client_maxfeerate=*/std::nullopt)}; - bool accepted{false}; + std::set in_mempool; for (const CTransactionRef& tx : package) { const auto it{result.m_tx_results.find(tx->GetWitnessHash())}; if (it == result.m_tx_results.end()) continue; - if (it->second.m_result_type != MempoolAcceptResult::ResultType::VALID) continue; - m_mempool.AddUnbroadcastTx(tx->GetHash()); - m_txdownloadman.MempoolAcceptedTx(tx); - InitiateTxBroadcastToAll(tx->GetHash(), tx->GetWitnessHash()); - accepted = true; + switch (it->second.m_result_type) { + case MempoolAcceptResult::ResultType::VALID: + m_mempool.AddUnbroadcastTx(tx->GetHash()); + m_txdownloadman.MempoolAcceptedTx(tx); + InitiateTxBroadcastToAll(tx->GetHash(), tx->GetWitnessHash()); + in_mempool.insert(tx->GetHash()); + break; + case MempoolAcceptResult::ResultType::MEMPOOL_ENTRY: + case MempoolAcceptResult::ResultType::DIFFERENT_WITNESS: + in_mempool.insert(tx->GetHash()); + break; + case MempoolAcceptResult::ResultType::INVALID: + break; + } } - if (!accepted) { - LogDebug(BCLog::MEMPOOL, "bridge: package %s + %s not accepted: %s\n", parent->GetHash().ToString(), - child->GetHash().ToString(), result.m_state.ToString()); + if (!in_mempool.contains(package.back()->GetHash())) { + LogDebug(BCLog::MEMPOOL, "bridge: package of %u with child %s not accepted: %s\n", package.size(), + package.back()->GetHash().ToString(), result.m_state.ToString()); } - return accepted; + return in_mempool; } void PeerManagerImpl::BridgeHoldTx(const CTransactionRef& tx, std::chrono::microseconds now) @@ -3154,64 +3176,101 @@ void PeerManagerImpl::BridgeRetryHeldTxs(std::chrono::microseconds now) if (!m_bridge_held_loaded) BridgeLoadHeld(); if (m_bridge_held_txs.empty()) return; const uint256 tip{WITH_LOCK(cs_main, return m_chainman.ActiveTip() ? m_chainman.ActiveTip()->GetBlockHash() : uint256{})}; - if (tip == m_bridge_held_tip && now - m_bridge_held_retried_at < BRIDGE_HELD_TXS_RETRY_INTERVAL) return; + if (!m_bridge_retry_now && tip == m_bridge_held_tip && now - m_bridge_held_retried_at < BRIDGE_HELD_TXS_RETRY_INTERVAL) return; m_bridge_held_tip = tip; m_bridge_held_retried_at = now; - int accepted{0}, dropped{0}, packaged{0}; - std::optional oldest; - std::set packaged_children; + m_bridge_retry_now = false; + const auto started{SteadyClock::now()}; + int offered{0}, accepted{0}, packaged{0}, known{0}, dropped{0}; + // Held transactions this pass found merely below the fee floor, with their place in `held`: a child that + // pays for them can take them in as a package. + std::map below_floor; + std::set taken; // parents that went in as part of a package after being kept this pass std::deque> held; for (const auto& [tx, since] : m_bridge_held_txs) { - std::string reason; + // Parents precede their children, so every parent's fate this pass is already settled. + bool waits{false}, carries{true}; + for (const CTxIn& in : tx->vin) { + if (!m_bridge_held_txids.contains(in.prevout.hash)) continue; + waits = true; + if (!below_floor.contains(in.prevout.hash)) carries = false; + } bool keep{false}; - switch (BridgeOfferTx(tx, reason)) { - case BridgeTxResult::ACCEPTED: - ++accepted; - break; - case BridgeTxResult::HELD: - // A transaction below the fee floor never gets in on its own: on Bitcoin a child paid - // for it, and that child is held behind it here. Offer the two together, exactly as a - // peer's 1p1c package would arrive. - if (reason == "min relay fee not met" || reason == "mempool min fee not met") { - if (const CTransactionRef child{BridgeHeldChild(tx->GetHash())}) { - if (BridgeOfferPackage(tx, child)) { - ++packaged; - packaged_children.insert(child->GetHash()); - break; - } + if (waits) { + // A parent is still held, so this cannot go in by itself and the mempool need not be asked. It can go + // in as the child of a package when every held parent is refused only for being below the fee floor + // -- on Bitcoin this child paid for them. + keep = true; + const std::vector parents{carries ? BridgeHeldParents(*tx) : std::vector{}}; + if (carries && parents.size() < MAX_PACKAGE_COUNT) { + std::vector at; + for (const Txid& p : parents) at.push_back(below_floor.at(p)); + std::sort(at.begin(), at.end()); + Package package; + for (const size_t k : at) package.push_back(held[k].first); + package.push_back(tx); + const std::set in{BridgeOfferPackage(package)}; + for (const Txid& p : parents) { + if (!in.contains(p)) continue; + taken.insert(p); + below_floor.erase(p); + m_bridge_held_txids.erase(p); + ++packaged; + } + if (in.contains(tx->GetHash())) { + keep = false; + ++packaged; } } - // Still refused for a reason that clears. However long it has waited, it stays: the block it - // came in is never fed again, so dropping it here would lose it and everything spending it. - keep = true; - break; - case BridgeTxResult::KNOWN: - // Already in: either someone else relayed it, or it went in as the child of a package - // earlier in this pass. - if (packaged_children.erase(tx->GetHash()) > 0) ++packaged; else ++dropped; - break; - case BridgeTxResult::MISSING: - case BridgeTxResult::OTHER: - ++dropped; - break; + } else { + ++offered; + std::string reason; + switch (BridgeOfferTx(tx, reason)) { + case BridgeTxResult::ACCEPTED: + ++accepted; + break; + case BridgeTxResult::HELD: + // Still refused for a reason that clears. However long it has waited, it stays: the block it + // came in is never fed again, so dropping it here would lose it and everything spending it. + keep = true; + if (reason == "min relay fee not met" || reason == "mempool min fee not met") { + below_floor.emplace(tx->GetHash(), held.size()); + } + break; + case BridgeTxResult::KNOWN: + ++known; + break; + case BridgeTxResult::MISSING: + case BridgeTxResult::OTHER: + ++dropped; + break; + } } if (keep) { held.emplace_back(tx, since); - if (!oldest || since < *oldest) oldest = since; } else { m_bridge_held_txids.erase(tx->GetHash()); } } + if (!taken.empty()) { + std::erase_if(held, [&](const auto& entry) { return taken.contains(entry.first->GetHash()); }); + } m_bridge_held_txs = std::move(held); m_bridge_held_weight = 0; - for (const auto& [tx, _since] : m_bridge_held_txs) m_bridge_held_weight += GetTransactionWeight(*tx); - const std::string waited{oldest ? strprintf(", oldest held %dh", std::chrono::duration_cast(now - *oldest).count()) : ""}; - if (accepted + dropped + packaged > 0) { + std::optional oldest; + for (const auto& [tx, since] : m_bridge_held_txs) { + m_bridge_held_weight += GetTransactionWeight(*tx); + if (!oldest || since < *oldest) oldest = since; + } + const std::string summary{strprintf("%u still held%s (%d offered, %.1f ms)", m_bridge_held_txs.size(), + oldest ? strprintf(", oldest held %dh", std::chrono::duration_cast(now - *oldest).count()) : "", + offered, Ticks(SteadyClock::now() - started) / 1000.0)}; + if (accepted + packaged + known + dropped > 0) { m_bridge_held_dirty = true; - LogInfo("bridge: re-offered held Bitcoin transactions: %d accepted, %d in packages, %d dropped, %u still held%s\n", - accepted, packaged, dropped, m_bridge_held_txs.size(), waited); + LogInfo("bridge: re-offered held Bitcoin transactions: %d accepted, %d in packages, %d already there, %d dropped, %s\n", + accepted, packaged, known, dropped, summary); } else { - LogDebug(BCLog::MEMPOOL, "bridge: retry pass: nothing accepted, %u still held%s\n", m_bridge_held_txs.size(), waited); + LogDebug(BCLog::MEMPOOL, "bridge: retry pass: nothing accepted, %s\n", summary); } } @@ -3261,6 +3320,13 @@ bool PeerManagerImpl::BridgeProcessBlock(CNode& pfrom, Peer& peer, const std::sh for (size_t i{1}; i < block->vtx.size(); ++i) { const CTransactionRef& tx{block->vtx[i]}; std::string reason; + if (BridgeHasHeldParent(*tx)) { + // Its parent is held, so the mempool would only report missing inputs. + ++held; + ++reasons["parent-held"]; + BridgeHoldTx(tx, now); + continue; + } switch (BridgeOfferTx(tx, reason)) { case BridgeTxResult::ACCEPTED: ++accepted; @@ -3275,6 +3341,8 @@ bool PeerManagerImpl::BridgeProcessBlock(CNode& pfrom, Peer& peer, const std::sh ++held; ++reasons[reason]; BridgeHoldTx(tx, now); + // A child later in this block, or the next one, may pay for it: don't wait for our tip to move. + if (reason == "min relay fee not met" || reason == "mempool min fee not met") m_bridge_retry_now = true; break; case BridgeTxResult::OTHER: ++other; From fb3f32063000d0aeebf304487ad4a4b8fc9cbef9 Mon Sep 17 00:00:00 2001 From: mbdrivechains Date: Wed, 23 Sep 2026 19:32:22 +1000 Subject: [PATCH 07/15] bridge: hold what a later block clears, drop what never clears; keep 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) Claude-Session: https://claude.ai/code/session_0136NHdQjigzYu7QGnAhskQb --- src/net_processing.cpp | 34 +++++++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/src/net_processing.cpp b/src/net_processing.cpp index ea2589d60d63..4a6610b66982 100644 --- a/src/net_processing.cpp +++ b/src/net_processing.cpp @@ -41,6 +41,7 @@ #include #include #include +#include #include #include #include @@ -147,6 +148,12 @@ static constexpr const char* BRIDGE_ANCHOR_FILE{"bridgefeed.dat"}; * deepest backlog seen on betanet was ~207,000 transactions / 127 MWU, so this is roughly 3x * the worst observed case and comparable to a default mempool. */ static constexpr uint64_t BRIDGE_HELD_TXS_MAX_WEIGHT{100 * 4'000'000}; +/** Bridge: refused only on fee, which a child paying for the transaction can cure as a package (Core's + * package-reconsiderable fee failures; the RBF ones are excluded, package RBF being one-parent-one-child). */ +static bool BridgeBelowFloor(const std::string& reason) +{ + return reason == "min relay fee not met" || reason == "mempool min fee not met" || reason == "mempool full"; +} /** Bridge: file (in the network datadir) holding the queue across a restart. */ static constexpr const char* BRIDGE_HELD_FILE{"bridgeheld.dat"}; static constexpr auto BRIDGE_HELD_TXS_RETRY_INTERVAL{10min}; @@ -3084,7 +3091,16 @@ PeerManagerImpl::BridgeTxResult PeerManagerImpl::BridgeOfferTx(const CTransactio } return BridgeTxResult::MISSING; } - if (reason == "txn-already-in-mempool" || reason == "txn-already-known") return BridgeTxResult::KNOWN; + // Already here: in our mempool (possibly with another witness), or confirmed. + if (reason == "txn-already-in-mempool" || reason == "txn-same-nonwitness-data-in-mempool" || + reason == "txn-already-known") { + return BridgeTxResult::KNOWN; + } + // A version 3 transaction over the TRUC size cap can never be accepted, however long it waits. Its vsize + // here ignores the sigop adjustment, which only makes it larger, so nothing that could pass is dropped. + if (reason == "TRUC-violation" && tx->version == TRUC_VERSION && GetVirtualTransactionSize(*tx) > TRUC_MAX_VSIZE) { + return BridgeTxResult::OTHER; + } // May clear later: locktime or sequence not yet reached on our chain, ancestors still // unconfirmed here, or mempool limits. These are the reasons a node running the cluster // mempool emits for a condition that a later block clears; "too-long-mempool-chain" is @@ -3096,7 +3112,10 @@ PeerManagerImpl::BridgeTxResult PeerManagerImpl::BridgeOfferTx(const CTransactio reason == "TRUC-violation" || reason == "mempool full" || reason == "mempool min fee not met" || - reason == "min relay fee not met") { + reason == "min relay fee not met" || + // Its parent is in our mempool with ephemeral dust this transaction does not spend (a sibling spent it, + // or will). Every child must wait for that parent to confirm here; then it is accepted. + reason == "missing-ephemeral-spends") { return BridgeTxResult::HELD; } return BridgeTxResult::OTHER; @@ -3207,9 +3226,12 @@ void PeerManagerImpl::BridgeRetryHeldTxs(std::chrono::microseconds now) for (const Txid& p : parents) at.push_back(below_floor.at(p)); std::sort(at.begin(), at.end()); Package package; + int64_t package_weight{0}; for (const size_t k : at) package.push_back(held[k].first); package.push_back(tx); - const std::set in{BridgeOfferPackage(package)}; + for (const CTransactionRef& member : package) package_weight += GetTransactionWeight(*member); + // Core refuses a package over MAX_PACKAGE_WEIGHT outright; don't ask every pass. + const std::set in{package_weight <= int64_t{MAX_PACKAGE_WEIGHT} ? BridgeOfferPackage(package) : std::set{}}; for (const Txid& p : parents) { if (!in.contains(p)) continue; taken.insert(p); @@ -3233,9 +3255,7 @@ void PeerManagerImpl::BridgeRetryHeldTxs(std::chrono::microseconds now) // Still refused for a reason that clears. However long it has waited, it stays: the block it // came in is never fed again, so dropping it here would lose it and everything spending it. keep = true; - if (reason == "min relay fee not met" || reason == "mempool min fee not met") { - below_floor.emplace(tx->GetHash(), held.size()); - } + if (BridgeBelowFloor(reason)) below_floor.emplace(tx->GetHash(), held.size()); break; case BridgeTxResult::KNOWN: ++known; @@ -3342,7 +3362,7 @@ bool PeerManagerImpl::BridgeProcessBlock(CNode& pfrom, Peer& peer, const std::sh ++reasons[reason]; BridgeHoldTx(tx, now); // A child later in this block, or the next one, may pay for it: don't wait for our tip to move. - if (reason == "min relay fee not met" || reason == "mempool min fee not met") m_bridge_retry_now = true; + if (BridgeBelowFloor(reason)) m_bridge_retry_now = true; break; case BridgeTxResult::OTHER: ++other; From 2fda6639335598ca45da17c815fede1250024826 Mon Sep 17 00:00:00 2001 From: mbdrivechains Date: Wed, 23 Sep 2026 19:59:26 +1000 Subject: [PATCH 08/15] bridge: pause the Bitcoin feed when the queue is full, instead of turning 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) Claude-Session: https://claude.ai/code/session_0136NHdQjigzYu7QGnAhskQb --- src/init.cpp | 3 + src/net_processing.cpp | 140 +++++++++++++++++++++++++------------- src/net_processing.h | 11 +++ src/node/peerman_args.cpp | 4 ++ 4 files changed, 112 insertions(+), 46 deletions(-) diff --git a/src/init.cpp b/src/init.cpp index 33290c9e9941..2f33921fd57c 100644 --- a/src/init.cpp +++ b/src/init.cpp @@ -542,6 +542,9 @@ void SetupServerArgs(ArgsManager& argsman, bool can_listen_ipc) argsman.AddArg("-bitcoinpeer=", "Connect to an operator-controlled, unpruned Bitcoin full node using Bitcoin's network magic, to fetch the pre-fork chain and, past the fork, feed our mempool from Bitcoin blocks. The connection should use loopback or a trusted private network because Bitcoin P2P is not authenticated. Up to four peers may be given when the block feed is active: one fetches blocks at a time, the others follow headers and take over if it disconnects or stalls. Each peer must advertise NODE_NETWORK and NODE_WITNESS. Always v1; never relays addresses or transaction gossip. Seed-node use only.", ArgsManager::ALLOW_ANY | ArgsManager::NETWORK_ONLY, OptionsCategory::CONNECTION); argsman.AddArg("-bitcoinpeermagic=", "Speak this 4-byte network magic to -bitcoinpeer peers instead of Bitcoin mainnet's (regtest only, for tests).", ArgsManager::ALLOW_ANY | ArgsManager::DEBUG_ONLY, OptionsCategory::CONNECTION); + argsman.AddArg("-bridgeheldmaxweight=", strprintf("Pause fetching Bitcoin blocks while the transactions held from our last %d blocks weigh at least weight units; nothing held is dropped (default: %u)", + BRIDGE_HELD_YOUNG_BLOCKS, DEFAULT_BRIDGE_HELD_MAX_WEIGHT), + ArgsManager::ALLOW_ANY | ArgsManager::DEBUG_ONLY, OptionsCategory::CONNECTION); argsman.AddArg("-ecashheight=", "Fork height at which eCash rules and the Bitcoin block feed activate (regtest only, for tests).", ArgsManager::ALLOW_ANY | ArgsManager::DEBUG_ONLY, OptionsCategory::CHAINPARAMS); argsman.AddArg("-asmap=", strprintf("Specify asn mapping used for bucketing of the peers. Relative paths will be prefixed by the net-specific datadir location.%s", diff --git a/src/net_processing.cpp b/src/net_processing.cpp index 4a6610b66982..a899200b21cf 100644 --- a/src/net_processing.cpp +++ b/src/net_processing.cpp @@ -142,12 +142,6 @@ static constexpr int BRIDGE_FOREIGN_KEEP_HEIGHTS{2000}; static constexpr size_t BRIDGE_MAX_FOREIGN_HEADERS{60000}; /** Bridge: file (in the network datadir) holding the last fed " ". */ static constexpr const char* BRIDGE_ANCHOR_FILE{"bridgefeed.dat"}; -/** Bridge: transactions rejected for a reason that may clear later are held this long and re-offered. */ -/** Bridge: total weight of held transactions kept before new ones are turned away. A Bitcoin - * block is 4 MWU, so this is a hundred blocks' worth (~180 MB of memory at the limit). The - * deepest backlog seen on betanet was ~207,000 transactions / 127 MWU, so this is roughly 3x - * the worst observed case and comparable to a default mempool. */ -static constexpr uint64_t BRIDGE_HELD_TXS_MAX_WEIGHT{100 * 4'000'000}; /** Bridge: refused only on fee, which a child paying for the transaction can cure as a package (Core's * package-reconsiderable fee failures; the RBF ones are excluded, package RBF being one-parent-one-child). */ static bool BridgeBelowFloor(const std::string& reason) @@ -753,7 +747,7 @@ class PeerManagerImpl final : public PeerManager EXCLUSIVE_LOCKS_REQUIRED(g_msgproc_mutex, !m_peer_mutex, !m_tx_download_mutex); enum class BridgeTxResult { ACCEPTED, KNOWN, MISSING, HELD, OTHER }; /** Bridge: offer one Bitcoin transaction to the mempool as sendrawtransaction would; `reason` is set unless accepted or known. */ - BridgeTxResult BridgeOfferTx(const CTransactionRef& tx, std::string& reason) + BridgeTxResult BridgeOfferTx(const CTransactionRef& tx, std::string& reason, std::optional* feerate = nullptr) EXCLUSIVE_LOCKS_REQUIRED(g_msgproc_mutex, !m_peer_mutex, !m_tx_download_mutex); /** Bridge: offer held parents together with the held child that pays for them (sorted, child last). * Returns the transactions that are in the mempool afterwards. */ @@ -765,8 +759,10 @@ class PeerManagerImpl final : public PeerManager /** Bridge: the held parents of `tx` (in input order, each once). */ std::vector BridgeHeldParents(const CTransaction& tx) const EXCLUSIVE_LOCKS_REQUIRED(NetEventsInterface::g_msgproc_mutex); - /** Bridge: keep a transaction for BridgeRetryHeldTxs (oldest evicted beyond BRIDGE_HELD_TXS_MAX). */ - void BridgeHoldTx(const CTransactionRef& tx, std::chrono::microseconds now) EXCLUSIVE_LOCKS_REQUIRED(g_msgproc_mutex); + /** Bridge: keep a transaction for BridgeRetryHeldTxs. Never refuses: when the queue is full the feed pauses + * instead (BridgeRequestBlocks). `feerate` is set when it was refused on fee. */ + void BridgeHoldTx(const CTransactionRef& tx, std::chrono::microseconds now, int height, std::optional feerate) + EXCLUSIVE_LOCKS_REQUIRED(g_msgproc_mutex); /** Bridge: re-offer held transactions once our tip has moved or enough time has passed. */ void BridgeRetryHeldTxs(std::chrono::microseconds now) EXCLUSIVE_LOCKS_REQUIRED(g_msgproc_mutex, !m_peer_mutex, !m_tx_download_mutex); @@ -931,12 +927,24 @@ class PeerManagerImpl final : public PeerManager std::pair m_bridge_anchor GUARDED_BY(cs_main){uint256{}, -1}; bool m_bridge_anchor_loaded GUARDED_BY(cs_main){false}; bool m_bridge_anchor_dirty GUARDED_BY(cs_main){false}; - /** Bridge: transactions rejected for a reason that may clear later, with when they were first offered. */ - std::deque> m_bridge_held_txs GUARDED_BY(NetEventsInterface::g_msgproc_mutex); + /** Bridge: a Bitcoin transaction refused for a reason that may clear later. */ + struct BridgeHeld { + CTransactionRef tx; + std::chrono::microseconds since; //!< when it was first held + int since_height; //!< our tip when it was first held: its age is counted in our blocks + int64_t weight; //!< its weight, kept rather than recomputed on every pass + std::optional feerate; //!< while refused on fee: offered again only once the floor falls to it + }; + /** Bridge: held transactions, in feed order (parents before children). */ + std::deque m_bridge_held_txs GUARDED_BY(NetEventsInterface::g_msgproc_mutex); std::unordered_set m_bridge_held_txids GUARDED_BY(NetEventsInterface::g_msgproc_mutex); uint256 m_bridge_held_tip GUARDED_BY(NetEventsInterface::g_msgproc_mutex); std::chrono::microseconds m_bridge_held_retried_at GUARDED_BY(NetEventsInterface::g_msgproc_mutex){0us}; uint64_t m_bridge_held_weight GUARDED_BY(NetEventsInterface::g_msgproc_mutex){0}; + /** Bridge: the part of that held from our last BRIDGE_HELD_YOUNG_BLOCKS blocks; the feed pauses at the limit. */ + uint64_t m_bridge_young_weight GUARDED_BY(NetEventsInterface::g_msgproc_mutex){0}; + bool m_bridge_feed_paused GUARDED_BY(NetEventsInterface::g_msgproc_mutex){false}; + bool m_bridge_parked_warned GUARDED_BY(NetEventsInterface::g_msgproc_mutex){false}; bool m_bridge_held_dirty GUARDED_BY(NetEventsInterface::g_msgproc_mutex){false}; bool m_bridge_held_loaded GUARDED_BY(NetEventsInterface::g_msgproc_mutex){false}; /** Bridge: a fed block left a transaction below the fee floor; retry at once so a child can carry it. */ @@ -2842,8 +2850,8 @@ void PeerManagerImpl::BridgeSaveHeld() } try { out << static_cast(m_bridge_held_txs.size()); - for (const auto& [tx, since] : m_bridge_held_txs) { - out << TX_WITH_WITNESS(*tx) << static_cast(count_microseconds(since)); + for (const BridgeHeld& held : m_bridge_held_txs) { + out << TX_WITH_WITNESS(*held.tx) << static_cast(count_microseconds(held.since)); } } catch (const std::exception& e) { LogWarning("bridge: cannot write %s: %s\n", fs::PathToString(tmp), e.what()); @@ -2857,6 +2865,8 @@ void PeerManagerImpl::BridgeLoadHeld() { AssertLockHeld(g_msgproc_mutex); m_bridge_held_loaded = true; + // The file does not record ages in our blocks, so what it holds counts as young again. + const int height{WITH_LOCK(cs_main, return m_chainman.ActiveChain().Height())}; AutoFile in{fsbridge::fopen(BridgeHeldPath(), "rb")}; if (in.IsNull()) return; try { @@ -2868,8 +2878,10 @@ void PeerManagerImpl::BridgeLoadHeld() in >> TX_WITH_WITNESS(mtx) >> since; CTransactionRef tx{MakeTransactionRef(std::move(mtx))}; if (m_bridge_held_txids.insert(tx->GetHash()).second) { - m_bridge_held_txs.emplace_back(tx, std::chrono::microseconds{since}); - m_bridge_held_weight += GetTransactionWeight(*tx); + const int64_t weight{GetTransactionWeight(*tx)}; + m_bridge_held_txs.push_back(BridgeHeld{tx, std::chrono::microseconds{since}, height, weight, std::nullopt}); + m_bridge_held_weight += weight; + m_bridge_young_weight += weight; } } } catch (const std::exception& e) { @@ -3033,6 +3045,20 @@ void PeerManagerImpl::BridgeRequestBlocks(CNode& node, Peer& peer, std::chrono:: } if (fc.pending.empty()) return; + // Back-pressure. Nothing held is ever refused, so this is what bounds the queue: while what we hold from our + // recent blocks is at the limit, fetch no more. The blocks wait on the Bitcoin node, not in our memory. + if (m_bridge_young_weight >= m_opts.bridge_held_max_weight) { + if (!m_bridge_feed_paused) { + m_bridge_feed_paused = true; + LogInfo("bridge: holding %u WU from our last %d blocks (limit %u); pausing the Bitcoin feed until it drains\n", + m_bridge_young_weight, BRIDGE_HELD_YOUNG_BLOCKS, m_opts.bridge_held_max_weight); + } + return; + } + if (m_bridge_feed_paused) { + m_bridge_feed_paused = false; + LogInfo("bridge: resuming the Bitcoin feed (%u WU held from our last %d blocks)\n", m_bridge_young_weight, BRIDGE_HELD_YOUNG_BLOCKS); + } // One peer feeds at a time so blocks are fetched and offered strictly oldest-first. Another // peer takes the slot when the feeder is gone, has nothing queued, or has not been served // within BRIDGE_BLOCK_TIMEOUT. @@ -3060,7 +3086,7 @@ void PeerManagerImpl::BridgeRequestBlocks(CNode& node, Peer& peer, std::chrono:: MakeAndPushMessage(node, NetMsgType::GETDATA, std::vector{{MSG_BLOCK | MSG_WITNESS_FLAG, hash}}); } -PeerManagerImpl::BridgeTxResult PeerManagerImpl::BridgeOfferTx(const CTransactionRef& tx, std::string& reason) +PeerManagerImpl::BridgeTxResult PeerManagerImpl::BridgeOfferTx(const CTransactionRef& tx, std::string& reason, std::optional* feerate) { AssertLockHeld(g_msgproc_mutex); LOCK2(cs_main, m_tx_download_mutex); @@ -3079,6 +3105,7 @@ PeerManagerImpl::BridgeTxResult PeerManagerImpl::BridgeOfferTx(const CTransactio break; } reason = result.m_state.GetRejectReason(); + if (feerate) *feerate = result.m_effective_feerate; // set for fee refusals only LogDebug(BCLog::MEMPOOL, "bridge: Bitcoin tx %s not accepted: %s\n", tx->GetHash().ToString(), result.m_state.ToString()); const TxValidationResult what{result.m_state.GetResult()}; if (what == TxValidationResult::TX_MISSING_INPUTS) { @@ -3172,20 +3199,16 @@ std::set PeerManagerImpl::BridgeOfferPackage(const Package& package) return in_mempool; } -void PeerManagerImpl::BridgeHoldTx(const CTransactionRef& tx, std::chrono::microseconds now) +void PeerManagerImpl::BridgeHoldTx(const CTransactionRef& tx, std::chrono::microseconds now, int height, std::optional feerate) { AssertLockHeld(g_msgproc_mutex); - if (m_bridge_held_weight + GetTransactionWeight(*tx) > BRIDGE_HELD_TXS_MAX_WEIGHT) { - // Keep what is already queued: parents precede their children, so dropping the front - // would orphan the rest of a chain. The newcomer is lost instead, and that is a hole - // in the feed, so it is a warning rather than a silent eviction. - LogWarning("bridge: holding queue is full (%u transactions, %u WU); Bitcoin transaction %s is NOT fed\n", - m_bridge_held_txs.size(), m_bridge_held_weight, tx->GetHash().ToString()); - return; - } - m_bridge_held_txs.emplace_back(tx, now); + // Never refused, however full the queue: a transaction turned away here is never fed again, nor is anything + // spending it. BridgeRequestBlocks stops fetching instead, until the queue drains. + const int64_t weight{GetTransactionWeight(*tx)}; + m_bridge_held_txs.push_back(BridgeHeld{tx, now, height, weight, feerate}); m_bridge_held_txids.insert(tx->GetHash()); - m_bridge_held_weight += GetTransactionWeight(*tx); + m_bridge_held_weight += weight; + m_bridge_young_weight += weight; m_bridge_held_dirty = true; } @@ -3194,19 +3217,23 @@ void PeerManagerImpl::BridgeRetryHeldTxs(std::chrono::microseconds now) AssertLockHeld(g_msgproc_mutex); if (!m_bridge_held_loaded) BridgeLoadHeld(); if (m_bridge_held_txs.empty()) return; - const uint256 tip{WITH_LOCK(cs_main, return m_chainman.ActiveTip() ? m_chainman.ActiveTip()->GetBlockHash() : uint256{})}; + const auto [tip, tip_height]{WITH_LOCK(cs_main, return std::make_pair(m_chainman.ActiveTip() ? m_chainman.ActiveTip()->GetBlockHash() : uint256{}, + m_chainman.ActiveChain().Height()))}; if (!m_bridge_retry_now && tip == m_bridge_held_tip && now - m_bridge_held_retried_at < BRIDGE_HELD_TXS_RETRY_INTERVAL) return; m_bridge_held_tip = tip; m_bridge_held_retried_at = now; m_bridge_retry_now = false; const auto started{SteadyClock::now()}; - int offered{0}, accepted{0}, packaged{0}, known{0}, dropped{0}; + // What a fee refusal is measured against: the higher of the relay floor and the mempool's own. + const CFeeRate floor{std::max(m_mempool.GetMinFee(), m_mempool.m_opts.min_relay_feerate)}; + int offered{0}, unasked{0}, accepted{0}, packaged{0}, known{0}, dropped{0}; // Held transactions this pass found merely below the fee floor, with their place in `held`: a child that // pays for them can take them in as a package. std::map below_floor; std::set taken; // parents that went in as part of a package after being kept this pass - std::deque> held; - for (const auto& [tx, since] : m_bridge_held_txs) { + std::deque held; + for (BridgeHeld& entry : m_bridge_held_txs) { + const CTransactionRef& tx{entry.tx}; // Parents precede their children, so every parent's fate this pass is already settled. bool waits{false}, carries{true}; for (const CTxIn& in : tx->vin) { @@ -3226,10 +3253,12 @@ void PeerManagerImpl::BridgeRetryHeldTxs(std::chrono::microseconds now) for (const Txid& p : parents) at.push_back(below_floor.at(p)); std::sort(at.begin(), at.end()); Package package; - int64_t package_weight{0}; - for (const size_t k : at) package.push_back(held[k].first); + int64_t package_weight{entry.weight}; + for (const size_t k : at) { + package.push_back(held[k].tx); + package_weight += held[k].weight; + } package.push_back(tx); - for (const CTransactionRef& member : package) package_weight += GetTransactionWeight(*member); // Core refuses a package over MAX_PACKAGE_WEIGHT outright; don't ask every pass. const std::set in{package_weight <= int64_t{MAX_PACKAGE_WEIGHT} ? BridgeOfferPackage(package) : std::set{}}; for (const Txid& p : parents) { @@ -3244,10 +3273,17 @@ void PeerManagerImpl::BridgeRetryHeldTxs(std::chrono::microseconds now) ++packaged; } } + } else if (entry.feerate && *entry.feerate < floor && !m_mempool.exists(tx->GetHash())) { + // Refused on fee last time and its own feerate is still under the floor, so the mempool would refuse it + // again (the check looks at nothing else). Keep it without asking; a child can still carry it. + keep = true; + ++unasked; + below_floor.emplace(tx->GetHash(), held.size()); } else { ++offered; std::string reason; - switch (BridgeOfferTx(tx, reason)) { + std::optional feerate; + switch (BridgeOfferTx(tx, reason, &feerate)) { case BridgeTxResult::ACCEPTED: ++accepted; break; @@ -3255,6 +3291,7 @@ void PeerManagerImpl::BridgeRetryHeldTxs(std::chrono::microseconds now) // Still refused for a reason that clears. However long it has waited, it stays: the block it // came in is never fed again, so dropping it here would lose it and everything spending it. keep = true; + entry.feerate = BridgeBelowFloor(reason) ? feerate : std::nullopt; if (BridgeBelowFloor(reason)) below_floor.emplace(tx->GetHash(), held.size()); break; case BridgeTxResult::KNOWN: @@ -3267,24 +3304,33 @@ void PeerManagerImpl::BridgeRetryHeldTxs(std::chrono::microseconds now) } } if (keep) { - held.emplace_back(tx, since); + held.push_back(std::move(entry)); } else { m_bridge_held_txids.erase(tx->GetHash()); } } if (!taken.empty()) { - std::erase_if(held, [&](const auto& entry) { return taken.contains(entry.first->GetHash()); }); + std::erase_if(held, [&](const BridgeHeld& entry) { return taken.contains(entry.tx->GetHash()); }); } m_bridge_held_txs = std::move(held); m_bridge_held_weight = 0; + m_bridge_young_weight = 0; std::optional oldest; - for (const auto& [tx, since] : m_bridge_held_txs) { - m_bridge_held_weight += GetTransactionWeight(*tx); - if (!oldest || since < *oldest) oldest = since; - } - const std::string summary{strprintf("%u still held%s (%d offered, %.1f ms)", m_bridge_held_txs.size(), + for (const BridgeHeld& entry : m_bridge_held_txs) { + m_bridge_held_weight += entry.weight; + if (entry.since_height > tip_height - BRIDGE_HELD_YOUNG_BLOCKS) m_bridge_young_weight += entry.weight; + if (!oldest || entry.since < *oldest) oldest = entry.since; + } + // Held longer than BRIDGE_HELD_YOUNG_BLOCKS: kept (it may yet clear) but no longer able to pause the feed. + const uint64_t parked{m_bridge_held_weight - m_bridge_young_weight}; + if (parked > m_opts.bridge_held_max_weight && !m_bridge_parked_warned) { + LogWarning("bridge: %u WU has been held for over %d blocks without clearing (more than the %u WU limit); it is kept, but check what it is waiting for\n", + parked, BRIDGE_HELD_YOUNG_BLOCKS, m_opts.bridge_held_max_weight); + } + m_bridge_parked_warned = parked > m_opts.bridge_held_max_weight; + const std::string summary{strprintf("%u still held%s (%d offered, %d under the fee floor unasked, %.1f ms)", m_bridge_held_txs.size(), oldest ? strprintf(", oldest held %dh", std::chrono::duration_cast(now - *oldest).count()) : "", - offered, Ticks(SteadyClock::now() - started) / 1000.0)}; + offered, unasked, Ticks(SteadyClock::now() - started) / 1000.0)}; if (accepted + packaged + known + dropped > 0) { m_bridge_held_dirty = true; LogInfo("bridge: re-offered held Bitcoin transactions: %d accepted, %d in packages, %d already there, %d dropped, %s\n", @@ -3335,19 +3381,21 @@ bool PeerManagerImpl::BridgeProcessBlock(CNode& pfrom, Peer& peer, const std::sh // Offer every transaction to the mempool in block order. Only the oldest pending block is // requested, so block order is guaranteed without a response buffer. const auto now{GetTime()}; + const int tip_height{WITH_LOCK(cs_main, return m_chainman.ActiveChain().Height())}; int accepted{0}, known{0}, missing{0}, held{0}, other{0}; std::map reasons; for (size_t i{1}; i < block->vtx.size(); ++i) { const CTransactionRef& tx{block->vtx[i]}; std::string reason; + std::optional feerate; if (BridgeHasHeldParent(*tx)) { // Its parent is held, so the mempool would only report missing inputs. ++held; ++reasons["parent-held"]; - BridgeHoldTx(tx, now); + BridgeHoldTx(tx, now, tip_height, std::nullopt); continue; } - switch (BridgeOfferTx(tx, reason)) { + switch (BridgeOfferTx(tx, reason, &feerate)) { case BridgeTxResult::ACCEPTED: ++accepted; break; @@ -3360,7 +3408,7 @@ bool PeerManagerImpl::BridgeProcessBlock(CNode& pfrom, Peer& peer, const std::sh case BridgeTxResult::HELD: ++held; ++reasons[reason]; - BridgeHoldTx(tx, now); + BridgeHoldTx(tx, now, tip_height, BridgeBelowFloor(reason) ? feerate : std::nullopt); // A child later in this block, or the next one, may pay for it: don't wait for our tip to move. if (BridgeBelowFloor(reason)) m_bridge_retry_now = true; break; diff --git a/src/net_processing.h b/src/net_processing.h index 36ae021f679d..7c986bd1443f 100644 --- a/src/net_processing.h +++ b/src/net_processing.h @@ -73,10 +73,21 @@ struct PeerManagerInfo { bool ignores_incoming_txs{false}; }; +/** Bridge: the Bitcoin feed pauses while the transactions held from our recent blocks weigh this much. A Bitcoin + * block is 4 MWU, so this is a hundred blocks' worth -- about 100 MB serialized and 200-400 MB in memory at the + * limit, on top of the mempool. Nothing is refused when it is reached; the blocks wait on the Bitcoin node. */ +static constexpr uint64_t DEFAULT_BRIDGE_HELD_MAX_WEIGHT{100 * 4'000'000}; +/** Bridge: a held transaction counts toward that limit for this many of our blocks. Older ones -- which may never + * clear, like a transaction below the relay floor whose paying child is dead -- are kept, but no longer hold up the + * feed; the age is in our blocks, so the pause still holds while our chain itself is stalled. */ +static constexpr int BRIDGE_HELD_YOUNG_BLOCKS{144}; + class PeerManager : public CValidationInterface, public NetEventsInterface { public: struct Options { + //! Bridge: weight held from our last BRIDGE_HELD_YOUNG_BLOCKS blocks at which the Bitcoin feed pauses + uint64_t bridge_held_max_weight{DEFAULT_BRIDGE_HELD_MAX_WEIGHT}; //! Whether this node is running in -blocksonly mode bool ignore_incoming_txs{DEFAULT_BLOCKSONLY}; //! Whether transaction reconciliation protocol is enabled diff --git a/src/node/peerman_args.cpp b/src/node/peerman_args.cpp index 9745d69d5ae3..6f9f4ef3162a 100644 --- a/src/node/peerman_args.cpp +++ b/src/node/peerman_args.cpp @@ -25,6 +25,10 @@ void ApplyArgsManOptions(const ArgsManager& argsman, PeerManager::Options& optio if (auto value{argsman.GetBoolArg("-blocksonly")}) options.ignore_incoming_txs = *value; if (auto value{argsman.GetBoolArg("-privatebroadcast")}) options.private_broadcast = *value; + + if (auto value{argsman.GetIntArg("-bridgeheldmaxweight")}) { + options.bridge_held_max_weight = uint64_t(std::max(*value, 1)); + } } } // namespace node From 2ba370dc99b3faef7944826554a3c0e9418102f1 Mon Sep 17 00:00:00 2001 From: mbdrivechains Date: Wed, 23 Sep 2026 20:16:16 +1000 Subject: [PATCH 09/15] bridge: track fed transactions until they are mined here, and feed again 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) Claude-Session: https://claude.ai/code/session_0136NHdQjigzYu7QGnAhskQb --- src/net_processing.cpp | 455 ++++++++++++++++++++++++++++++----------- 1 file changed, 333 insertions(+), 122 deletions(-) diff --git a/src/net_processing.cpp b/src/net_processing.cpp index a899200b21cf..672c70107fa3 100644 --- a/src/net_processing.cpp +++ b/src/net_processing.cpp @@ -151,6 +151,15 @@ static bool BridgeBelowFloor(const std::string& reason) /** Bridge: file (in the network datadir) holding the queue across a restart. */ static constexpr const char* BRIDGE_HELD_FILE{"bridgeheld.dat"}; static constexpr auto BRIDGE_HELD_TXS_RETRY_INTERVAL{10min}; +/** Bridge: a tracked transaction mined here is remembered for this many of our blocks, so that one spending it is not + * taken for an orphan when our chain reorganises, or restarts behind where it was. */ +static constexpr int BRIDGE_CONFIRMED_KEEP_BLOCKS{12}; +/** Bridge: at most this many of our new blocks are read for confirmations in one pass. Past a longer gap the reading + * restarts at the tip, and what was mined in the gap is offered once more and found already here. */ +static constexpr int BRIDGE_SCAN_MAX_BLOCKS{2000}; +/** Bridge: bridgeheld.dat, version 2. The magic is larger than any count a version 1 file starts with. */ +static constexpr uint64_t BRIDGE_HELD_MAGIC{0x3244'4c45'4847'4442}; +static constexpr uint32_t BRIDGE_HELD_VERSION{2}; /** Default time during which a peer must stall block download progress before being disconnected. * the actual timeout is increased temporarily if peers are disconnected for hitting the timeout */ static constexpr auto BLOCK_STALLING_TIMEOUT_DEFAULT{2s}; @@ -746,6 +755,12 @@ class PeerManagerImpl final : public PeerManager bool BridgeProcessBlock(CNode& pfrom, Peer& peer, const std::shared_ptr& block) EXCLUSIVE_LOCKS_REQUIRED(g_msgproc_mutex, !m_peer_mutex, !m_tx_download_mutex); enum class BridgeTxResult { ACCEPTED, KNOWN, MISSING, HELD, OTHER }; + /** Bridge: WAITING was refused for a reason that may clear and is offered again; FED is in our mempool and is + * watched until it is mined here, so that a mempool we lose (an unclean stop leaves no mempool.dat) is fed again. */ + enum class BridgeState : uint8_t { WAITING, FED }; + /** Bridge: the inputs of a transaction here. AVAILABLE: all present (chain or mempool). WAITS: some missing, each + * from a transaction we track and expect. DEAD: one missing from a transaction we don't -- spent, or never here. */ + enum class BridgeInputs { AVAILABLE, WAITS, DEAD }; /** Bridge: offer one Bitcoin transaction to the mempool as sendrawtransaction would; `reason` is set unless accepted or known. */ BridgeTxResult BridgeOfferTx(const CTransactionRef& tx, std::string& reason, std::optional* feerate = nullptr) EXCLUSIVE_LOCKS_REQUIRED(g_msgproc_mutex, !m_peer_mutex, !m_tx_download_mutex); @@ -759,10 +774,17 @@ class PeerManagerImpl final : public PeerManager /** Bridge: the held parents of `tx` (in input order, each once). */ std::vector BridgeHeldParents(const CTransaction& tx) const EXCLUSIVE_LOCKS_REQUIRED(NetEventsInterface::g_msgproc_mutex); - /** Bridge: keep a transaction for BridgeRetryHeldTxs. Never refuses: when the queue is full the feed pauses + /** Bridge: start tracking a transaction, WAITING or FED. Never refuses: when the queue is full the feed pauses * instead (BridgeRequestBlocks). `feerate` is set when it was refused on fee. */ - void BridgeHoldTx(const CTransactionRef& tx, std::chrono::microseconds now, int height, std::optional feerate) + void BridgeTrackTx(const CTransactionRef& tx, BridgeState state, std::chrono::microseconds now, int height, std::optional feerate) EXCLUSIVE_LOCKS_REQUIRED(g_msgproc_mutex); + /** Bridge: whether we track `txid`: waiting, fed, or mined here lately. */ + bool BridgeTracked(const Txid& txid) const EXCLUSIVE_LOCKS_REQUIRED(NetEventsInterface::g_msgproc_mutex); + BridgeInputs BridgeClassifyInputs(const CTransaction& tx) EXCLUSIVE_LOCKS_REQUIRED(NetEventsInterface::g_msgproc_mutex, cs_main); + /** Bridge: whether the node is caught up enough to judge Bitcoin transactions at all. */ + bool BridgeReady(int tip_height) const EXCLUSIVE_LOCKS_REQUIRED(NetEventsInterface::g_msgproc_mutex); + /** Bridge: read our blocks since the last pass; returns the tracked transactions mined in them. */ + std::unordered_set BridgeScanMined() EXCLUSIVE_LOCKS_REQUIRED(NetEventsInterface::g_msgproc_mutex, !cs_main); /** Bridge: re-offer held transactions once our tip has moved or enough time has passed. */ void BridgeRetryHeldTxs(std::chrono::microseconds now) EXCLUSIVE_LOCKS_REQUIRED(g_msgproc_mutex, !m_peer_mutex, !m_tx_download_mutex); @@ -927,16 +949,24 @@ class PeerManagerImpl final : public PeerManager std::pair m_bridge_anchor GUARDED_BY(cs_main){uint256{}, -1}; bool m_bridge_anchor_loaded GUARDED_BY(cs_main){false}; bool m_bridge_anchor_dirty GUARDED_BY(cs_main){false}; - /** Bridge: a Bitcoin transaction refused for a reason that may clear later. */ + /** Bridge: a Bitcoin transaction we keep track of. */ struct BridgeHeld { CTransactionRef tx; - std::chrono::microseconds since; //!< when it was first held - int since_height; //!< our tip when it was first held: its age is counted in our blocks + std::chrono::microseconds since; //!< when it was first fed + int since_height; //!< our tip when it last began waiting: its age is counted in our blocks int64_t weight; //!< its weight, kept rather than recomputed on every pass std::optional feerate; //!< while refused on fee: offered again only once the floor falls to it + BridgeState state{BridgeState::WAITING}; }; - /** Bridge: held transactions, in feed order (parents before children). */ + /** Bridge: tracked transactions, WAITING and FED, in feed order (parents before children). */ std::deque m_bridge_held_txs GUARDED_BY(NetEventsInterface::g_msgproc_mutex); + /** Bridge: txids of the FED entries (m_bridge_held_txids has the WAITING ones). */ + std::unordered_set m_bridge_fed_txids GUARDED_BY(NetEventsInterface::g_msgproc_mutex); + /** Bridge: tracked transactions mined here lately, with the height, oldest first (BRIDGE_CONFIRMED_KEEP_BLOCKS). */ + std::deque> m_bridge_recent GUARDED_BY(NetEventsInterface::g_msgproc_mutex); + std::unordered_set m_bridge_recent_txids GUARDED_BY(NetEventsInterface::g_msgproc_mutex); + /** Bridge: the last of our blocks read for confirmations. Nothing is done until our chain is back at its height. */ + std::pair m_bridge_scanned GUARDED_BY(NetEventsInterface::g_msgproc_mutex){uint256{}, -1}; std::unordered_set m_bridge_held_txids GUARDED_BY(NetEventsInterface::g_msgproc_mutex); uint256 m_bridge_held_tip GUARDED_BY(NetEventsInterface::g_msgproc_mutex); std::chrono::microseconds m_bridge_held_retried_at GUARDED_BY(NetEventsInterface::g_msgproc_mutex){0us}; @@ -2837,7 +2867,7 @@ void PeerManagerImpl::BridgeSaveHeld() AssertLockHeld(g_msgproc_mutex); m_bridge_held_dirty = false; const fs::path path{BridgeHeldPath()}; - if (m_bridge_held_txs.empty()) { + if (m_bridge_held_txs.empty() && m_bridge_recent.empty()) { fs::remove(path); return; } @@ -2849,10 +2879,14 @@ void PeerManagerImpl::BridgeSaveHeld() return; } try { + out << BRIDGE_HELD_MAGIC << BRIDGE_HELD_VERSION << m_bridge_scanned.first << m_bridge_scanned.second; out << static_cast(m_bridge_held_txs.size()); - for (const BridgeHeld& held : m_bridge_held_txs) { - out << TX_WITH_WITNESS(*held.tx) << static_cast(count_microseconds(held.since)); + for (const BridgeHeld& entry : m_bridge_held_txs) { + out << TX_WITH_WITNESS(*entry.tx) << static_cast(count_microseconds(entry.since)) + << entry.since_height << static_cast(entry.state); } + out << static_cast(m_bridge_recent.size()); + for (const auto& [txid, height] : m_bridge_recent) out << txid.ToUint256() << height; } catch (const std::exception& e) { LogWarning("bridge: cannot write %s: %s\n", fs::PathToString(tmp), e.what()); return; @@ -2865,23 +2899,60 @@ void PeerManagerImpl::BridgeLoadHeld() { AssertLockHeld(g_msgproc_mutex); m_bridge_held_loaded = true; - // The file does not record ages in our blocks, so what it holds counts as young again. const int height{WITH_LOCK(cs_main, return m_chainman.ActiveChain().Height())}; AutoFile in{fsbridge::fopen(BridgeHeldPath(), "rb")}; if (in.IsNull()) return; + const auto add{[&](CTransactionRef tx, int64_t since, int since_height, BridgeState state) { + const Txid txid{tx->GetHash()}; + if (m_bridge_held_txids.contains(txid) || m_bridge_fed_txids.contains(txid)) return; // first one wins + const int64_t weight{GetTransactionWeight(*tx)}; + m_bridge_held_txs.push_back(BridgeHeld{std::move(tx), std::chrono::microseconds{since}, since_height, weight, std::nullopt, state}); + if (state == BridgeState::WAITING) { + m_bridge_held_txids.insert(txid); + m_bridge_held_weight += weight; + if (since_height > height - BRIDGE_HELD_YOUNG_BLOCKS) m_bridge_young_weight += weight; + } else { + m_bridge_fed_txids.insert(txid); + } + }}; try { - uint64_t count{0}; - in >> count; - for (uint64_t i = 0; i < count; ++i) { - CMutableTransaction mtx; - int64_t since{0}; - in >> TX_WITH_WITNESS(mtx) >> since; - CTransactionRef tx{MakeTransactionRef(std::move(mtx))}; - if (m_bridge_held_txids.insert(tx->GetHash()).second) { - const int64_t weight{GetTransactionWeight(*tx)}; - m_bridge_held_txs.push_back(BridgeHeld{tx, std::chrono::microseconds{since}, height, weight, std::nullopt}); - m_bridge_held_weight += weight; - m_bridge_young_weight += weight; + uint64_t first{0}; + in >> first; + if (first == BRIDGE_HELD_MAGIC) { + uint32_t version{0}; + in >> version; + if (version != BRIDGE_HELD_VERSION) { + LogWarning("bridge: %s is version %u, which this node cannot read; starting without it\n", + fs::PathToString(BridgeHeldPath()), version); + return; + } + in >> m_bridge_scanned.first >> m_bridge_scanned.second; + uint64_t count{0}; + in >> count; + for (uint64_t i = 0; i < count; ++i) { + CMutableTransaction mtx; + int64_t since{0}; + int since_height{0}; + uint8_t state{0}; + in >> TX_WITH_WITNESS(mtx) >> since >> since_height >> state; + add(MakeTransactionRef(std::move(mtx)), since, since_height, state == uint8_t(BridgeState::FED) ? BridgeState::FED : BridgeState::WAITING); + } + uint64_t recent{0}; + in >> recent; + for (uint64_t i = 0; i < recent; ++i) { + uint256 hash; + int mined_at{0}; + in >> hash >> mined_at; + const Txid txid{Txid::FromUint256(hash)}; + if (m_bridge_recent_txids.insert(txid).second) m_bridge_recent.emplace_back(txid, mined_at); + } + } else { + // Version 1: a count, then transactions, all waiting; how long they have waited in our blocks is unknown. + for (uint64_t i = 0; i < first; ++i) { + CMutableTransaction mtx; + int64_t since{0}; + in >> TX_WITH_WITNESS(mtx) >> since; + add(MakeTransactionRef(std::move(mtx)), since, height, BridgeState::WAITING); } } } catch (const std::exception& e) { @@ -2892,8 +2963,8 @@ void PeerManagerImpl::BridgeLoadHeld() fs::PathToString(BridgeHeldPath()), m_bridge_held_txs.size(), e.what()); } if (!m_bridge_held_txs.empty()) { - LogInfo("bridge: resuming with %u held Bitcoin transaction(s), %u WU\n", - m_bridge_held_txs.size(), m_bridge_held_weight); + LogInfo("bridge: resuming with %u held and %u fed Bitcoin transaction(s), %u WU held\n", + m_bridge_held_txids.size(), m_bridge_fed_txids.size(), m_bridge_held_weight); } } @@ -3034,6 +3105,9 @@ void PeerManagerImpl::BridgeRequestBlocks(CNode& node, Peer& peer, std::chrono:: if (fork_height <= 0) return; // Until the fork parent is connected, our UTXO set cannot judge Bitcoin's post-fork transactions. if (m_chainman.ActiveChain().Height() < fork_height - 1) return; + // Not before what we track is loaded, nor while we are catching up (BridgeReady): fed then, a transaction whose + // parent is in a mempool.dat not yet read, or was mined in a block not yet reconnected, looks like an orphan. + if (!m_bridge_held_loaded || !BridgeReady(m_chainman.ActiveChain().Height())) return; ForeignChain& fc{*Assert(State(node.GetId())->m_foreign)}; // Announcements normally keep us current; poll now and then in case one was missed. @@ -3109,12 +3183,11 @@ PeerManagerImpl::BridgeTxResult PeerManagerImpl::BridgeOfferTx(const CTransactio LogDebug(BCLog::MEMPOOL, "bridge: Bitcoin tx %s not accepted: %s\n", tx->GetHash().ToString(), result.m_state.ToString()); const TxValidationResult what{result.m_state.GetResult()}; if (what == TxValidationResult::TX_MISSING_INPUTS) { - // A child of a held transaction is held with it (parents precede children in the queue). - for (const CTxIn& in : tx->vin) { - if (m_bridge_held_txids.count(in.prevout.hash) > 0) { - reason = "parent-held"; - return BridgeTxResult::HELD; - } + // Held only if every missing input comes from a transaction we track and expect (parents precede children in + // the queue). Any other missing input means it can never go in, however many of its parents merely wait. + if (BridgeClassifyInputs(*tx) == BridgeInputs::WAITS) { + reason = "parent-held"; + return BridgeTxResult::HELD; } return BridgeTxResult::MISSING; } @@ -3140,6 +3213,8 @@ PeerManagerImpl::BridgeTxResult PeerManagerImpl::BridgeOfferTx(const CTransactio reason == "mempool full" || reason == "mempool min fee not met" || reason == "min relay fee not met" || + // It conflicts with more of our mempool than a replacement may evict; clears as they are mined or expire. + reason == "too many potential replacements" || // Its parent is in our mempool with ephemeral dust this transaction does not spend (a sibling spent it, // or will). Every child must wait for that parent to confirm here; then it is accepted. reason == "missing-ephemeral-spends") { @@ -3199,124 +3274,240 @@ std::set PeerManagerImpl::BridgeOfferPackage(const Package& package) return in_mempool; } -void PeerManagerImpl::BridgeHoldTx(const CTransactionRef& tx, std::chrono::microseconds now, int height, std::optional feerate) +void PeerManagerImpl::BridgeTrackTx(const CTransactionRef& tx, BridgeState state, std::chrono::microseconds now, int height, std::optional feerate) { AssertLockHeld(g_msgproc_mutex); // Never refused, however full the queue: a transaction turned away here is never fed again, nor is anything // spending it. BridgeRequestBlocks stops fetching instead, until the queue drains. const int64_t weight{GetTransactionWeight(*tx)}; - m_bridge_held_txs.push_back(BridgeHeld{tx, now, height, weight, feerate}); - m_bridge_held_txids.insert(tx->GetHash()); - m_bridge_held_weight += weight; - m_bridge_young_weight += weight; + m_bridge_held_txs.push_back(BridgeHeld{tx, now, height, weight, feerate, state}); + if (state == BridgeState::WAITING) { + m_bridge_held_txids.insert(tx->GetHash()); + m_bridge_held_weight += weight; + m_bridge_young_weight += weight; + } else { + m_bridge_fed_txids.insert(tx->GetHash()); + } m_bridge_held_dirty = true; } +bool PeerManagerImpl::BridgeTracked(const Txid& txid) const +{ + AssertLockHeld(g_msgproc_mutex); + return m_bridge_held_txids.contains(txid) || m_bridge_fed_txids.contains(txid) || m_bridge_recent_txids.contains(txid); +} + +PeerManagerImpl::BridgeInputs PeerManagerImpl::BridgeClassifyInputs(const CTransaction& tx) +{ + AssertLockHeld(g_msgproc_mutex); + AssertLockHeld(cs_main); + LOCK(m_mempool.cs); + // GetCoin, not HaveCoin: CCoinsViewMemPool overrides only GetCoin, and HaveCoin would skip the mempool. + const CCoinsViewMemPool view{&m_chainman.ActiveChainstate().CoinsTip(), m_mempool}; + bool waits{false}; + for (const CTxIn& in : tx.vin) { + if (view.GetCoin(in.prevout)) continue; + // Missing. From a transaction we track it waits: held, fed but lost from our mempool (it is offered again + // first), or mined here lately (our chain may yet come back to that block). From any other it is spent or was + // never here, and the transaction can never go in. + if (!BridgeTracked(in.prevout.hash)) return BridgeInputs::DEAD; + waits = true; + } + return waits ? BridgeInputs::WAITS : BridgeInputs::AVAILABLE; +} + +bool PeerManagerImpl::BridgeReady(int tip_height) const +{ + AssertLockHeld(g_msgproc_mutex); + // Not while the node is catching up. mempool.dat is loaded only after the blocks are imported, while networking + // already runs; and after a crash our chain restarts where it was last flushed -- up to an hour behind -- so what + // we saw mined may not be mined here again yet. Judging Bitcoin transactions then takes their children for orphans. + return m_mempool.GetLoadTried() && !m_chainman.m_blockman.LoadingBlocks() && !m_chainman.IsInitialBlockDownload() && + tip_height >= m_bridge_scanned.second; +} + +std::unordered_set PeerManagerImpl::BridgeScanMined() +{ + AssertLockHeld(g_msgproc_mutex); + std::vector connected; + int fork_height{-1}, tip_height{-1}; + { + LOCK(cs_main); + const CChain& chain{m_chainman.ActiveChain()}; + const CBlockIndex* tip{chain.Tip()}; + if (!tip) return {}; + tip_height = tip->nHeight; + const CBlockIndex* last{m_bridge_scanned.first.IsNull() ? nullptr : m_chainman.m_blockman.LookupBlockIndex(m_bridge_scanned.first)}; + const CBlockIndex* fork{last ? chain.FindFork(last) : nullptr}; + m_bridge_scanned = {tip->GetBlockHash(), tip->nHeight}; + if (!fork || tip->nHeight - fork->nHeight > BRIDGE_SCAN_MAX_BLOCKS) { + // Nothing read yet, or a gap too long to read: start from here. What was mined in a gap is offered once + // more and found already here. + if (fork) LogInfo("bridge: %d of our blocks since the last pass; not reading them for confirmations\n", tip->nHeight - fork->nHeight); + return {}; + } + fork_height = fork->nHeight; + for (const CBlockIndex* index{tip}; index != fork; index = index->pprev) connected.push_back(index); + } + // Reorganised away: what we saw mined above the fork is not mined any more. + while (!m_bridge_recent.empty() && m_bridge_recent.back().second > fork_height) { + m_bridge_recent_txids.erase(m_bridge_recent.back().first); + m_bridge_recent.pop_back(); + } + std::unordered_set mined; + for (auto it{connected.rbegin()}; it != connected.rend(); ++it) { + CBlock block; + if (!m_chainman.m_blockman.ReadBlock(block, **it)) { + LogWarning("bridge: cannot read our block %s for confirmations\n", (*it)->GetBlockHash().ToString()); + continue; // what it mined is just offered once more + } + for (const CTransactionRef& tx : block.vtx) { + const Txid& txid{tx->GetHash()}; + if (!m_bridge_held_txids.contains(txid) && !m_bridge_fed_txids.contains(txid)) continue; + mined.insert(txid); + if (m_bridge_recent_txids.insert(txid).second) m_bridge_recent.emplace_back(txid, (*it)->nHeight); + } + } + // Buried deep enough: forget it. + while (!m_bridge_recent.empty() && m_bridge_recent.front().second <= tip_height - BRIDGE_CONFIRMED_KEEP_BLOCKS) { + m_bridge_recent_txids.erase(m_bridge_recent.front().first); + m_bridge_recent.pop_front(); + } + return mined; +} + void PeerManagerImpl::BridgeRetryHeldTxs(std::chrono::microseconds now) { AssertLockHeld(g_msgproc_mutex); if (!m_bridge_held_loaded) BridgeLoadHeld(); - if (m_bridge_held_txs.empty()) return; const auto [tip, tip_height]{WITH_LOCK(cs_main, return std::make_pair(m_chainman.ActiveTip() ? m_chainman.ActiveTip()->GetBlockHash() : uint256{}, m_chainman.ActiveChain().Height()))}; + if (!BridgeReady(tip_height)) return; + if (m_bridge_held_txs.empty()) { + // Nothing tracked, so nothing to read our blocks for; keep our place so a later pass starts from here. + m_bridge_scanned = {tip, tip_height}; + return; + } if (!m_bridge_retry_now && tip == m_bridge_held_tip && now - m_bridge_held_retried_at < BRIDGE_HELD_TXS_RETRY_INTERVAL) return; m_bridge_held_tip = tip; m_bridge_held_retried_at = now; m_bridge_retry_now = false; const auto started{SteadyClock::now()}; + const std::unordered_set mined{BridgeScanMined()}; // What a fee refusal is measured against: the higher of the relay floor and the mempool's own. const CFeeRate floor{std::max(m_mempool.GetMinFee(), m_mempool.m_opts.min_relay_feerate)}; - int offered{0}, unasked{0}, accepted{0}, packaged{0}, known{0}, dropped{0}; + int offered{0}, unasked{0}, accepted{0}, refed{0}, packaged{0}, known{0}, confirmed{0}, dropped{0}; // Held transactions this pass found merely below the fee floor, with their place in `held`: a child that // pays for them can take them in as a package. std::map below_floor; - std::set taken; // parents that went in as part of a package after being kept this pass std::deque held; for (BridgeHeld& entry : m_bridge_held_txs) { - const CTransactionRef& tx{entry.tx}; - // Parents precede their children, so every parent's fate this pass is already settled. - bool waits{false}, carries{true}; - for (const CTxIn& in : tx->vin) { - if (!m_bridge_held_txids.contains(in.prevout.hash)) continue; - waits = true; - if (!below_floor.contains(in.prevout.hash)) carries = false; - } - bool keep{false}; - if (waits) { - // A parent is still held, so this cannot go in by itself and the mempool need not be asked. It can go - // in as the child of a package when every held parent is refused only for being below the fee floor - // -- on Bitcoin this child paid for them. - keep = true; - const std::vector parents{carries ? BridgeHeldParents(*tx) : std::vector{}}; - if (carries && parents.size() < MAX_PACKAGE_COUNT) { - std::vector at; - for (const Txid& p : parents) at.push_back(below_floor.at(p)); - std::sort(at.begin(), at.end()); - Package package; - int64_t package_weight{entry.weight}; - for (const size_t k : at) { - package.push_back(held[k].tx); - package_weight += held[k].weight; - } - package.push_back(tx); - // Core refuses a package over MAX_PACKAGE_WEIGHT outright; don't ask every pass. - const std::set in{package_weight <= int64_t{MAX_PACKAGE_WEIGHT} ? BridgeOfferPackage(package) : std::set{}}; - for (const Txid& p : parents) { - if (!in.contains(p)) continue; - taken.insert(p); - below_floor.erase(p); - m_bridge_held_txids.erase(p); - ++packaged; + const CTransactionRef tx{entry.tx}; + const Txid txid{tx->GetHash()}; + const bool was_fed{entry.state == BridgeState::FED}; + std::optional next; // unset: stop tracking it + if (mined.contains(txid)) { + // Mined here: done. It is remembered a while (m_bridge_recent), so nothing spending it is taken for an + // orphan if our chain reorganises or restarts behind this block. + ++confirmed; + } else if (was_fed && m_mempool.exists(txid)) { + next = BridgeState::FED; + } else { + // Waiting; or fed and since lost from our mempool (evicted, expired, or a restart without mempool.dat). + // Either way it is offered again, after its parents, which precede it and are settled by now. + bool waits{false}, carries{true}; + for (const CTxIn& in : tx->vin) { + if (!m_bridge_held_txids.contains(in.prevout.hash)) continue; + waits = true; + if (!below_floor.contains(in.prevout.hash)) carries = false; + } + if (waits) { + // A parent is still held, so this cannot go in by itself and the mempool need not be asked. It can go + // in as the child of a package when every held parent is refused only for being below the fee floor + // -- on Bitcoin this child paid for them. + next = BridgeState::WAITING; + const std::vector parents{carries ? BridgeHeldParents(*tx) : std::vector{}}; + if (carries && parents.size() < MAX_PACKAGE_COUNT) { + std::vector at; + for (const Txid& p : parents) at.push_back(below_floor.at(p)); + std::sort(at.begin(), at.end()); + Package package; + int64_t package_weight{entry.weight}; + for (const size_t k : at) { + package.push_back(held[k].tx); + package_weight += held[k].weight; + } + package.push_back(tx); + // Core refuses a package over MAX_PACKAGE_WEIGHT outright; don't ask every pass. + const std::set in{package_weight <= int64_t{MAX_PACKAGE_WEIGHT} ? BridgeOfferPackage(package) : std::set{}}; + for (const Txid& p : parents) { + if (!in.contains(p)) continue; + BridgeHeld& parent{held[below_floor.at(p)]}; + parent.state = BridgeState::FED; + parent.feerate.reset(); + m_bridge_held_txids.erase(p); + m_bridge_fed_txids.insert(p); + below_floor.erase(p); + ++packaged; + } + if (in.contains(txid)) { + next = BridgeState::FED; + ++packaged; + } } - if (in.contains(tx->GetHash())) { - keep = false; - ++packaged; + } else if (!was_fed && entry.feerate && *entry.feerate < floor && !m_mempool.exists(txid)) { + // Refused on fee last time and its own feerate is still under the floor, so the mempool would refuse + // it again (the check looks at nothing else). Keep it without asking; a child can still carry it. + next = BridgeState::WAITING; + ++unasked; + below_floor.emplace(txid, held.size()); + } else { + ++offered; + std::string reason; + std::optional feerate; + switch (BridgeOfferTx(tx, reason, &feerate)) { + case BridgeTxResult::ACCEPTED: + next = BridgeState::FED; + ++(was_fed ? refed : accepted); + break; + case BridgeTxResult::HELD: + // Still refused for a reason that clears. However long it has waited, it stays: the block it came + // in is never fed again, so dropping it here would lose it and everything spending it. + next = BridgeState::WAITING; + entry.feerate = BridgeBelowFloor(reason) ? feerate : std::nullopt; + if (BridgeBelowFloor(reason)) below_floor.emplace(txid, held.size()); + break; + case BridgeTxResult::KNOWN: + // Already here: in our mempool (someone else relayed it), so watch it as fed; or mined. + if (m_mempool.exists(txid)) next = BridgeState::FED; + ++known; + break; + case BridgeTxResult::MISSING: + case BridgeTxResult::OTHER: + ++dropped; + break; } } - } else if (entry.feerate && *entry.feerate < floor && !m_mempool.exists(tx->GetHash())) { - // Refused on fee last time and its own feerate is still under the floor, so the mempool would refuse it - // again (the check looks at nothing else). Keep it without asking; a child can still carry it. - keep = true; - ++unasked; - below_floor.emplace(tx->GetHash(), held.size()); - } else { - ++offered; - std::string reason; - std::optional feerate; - switch (BridgeOfferTx(tx, reason, &feerate)) { - case BridgeTxResult::ACCEPTED: - ++accepted; - break; - case BridgeTxResult::HELD: - // Still refused for a reason that clears. However long it has waited, it stays: the block it - // came in is never fed again, so dropping it here would lose it and everything spending it. - keep = true; - entry.feerate = BridgeBelowFloor(reason) ? feerate : std::nullopt; - if (BridgeBelowFloor(reason)) below_floor.emplace(tx->GetHash(), held.size()); - break; - case BridgeTxResult::KNOWN: - ++known; - break; - case BridgeTxResult::MISSING: - case BridgeTxResult::OTHER: - ++dropped; - break; - } } - if (keep) { - held.push_back(std::move(entry)); + m_bridge_held_txids.erase(txid); + m_bridge_fed_txids.erase(txid); + if (!next) continue; + if (*next == BridgeState::FED) { + entry.feerate.reset(); + m_bridge_fed_txids.insert(txid); } else { - m_bridge_held_txids.erase(tx->GetHash()); + if (was_fed) entry.since_height = tip_height; // waiting again, so young again + m_bridge_held_txids.insert(txid); } - } - if (!taken.empty()) { - std::erase_if(held, [&](const BridgeHeld& entry) { return taken.contains(entry.tx->GetHash()); }); + entry.state = *next; + held.push_back(std::move(entry)); } m_bridge_held_txs = std::move(held); m_bridge_held_weight = 0; m_bridge_young_weight = 0; std::optional oldest; for (const BridgeHeld& entry : m_bridge_held_txs) { + if (entry.state != BridgeState::WAITING) continue; m_bridge_held_weight += entry.weight; if (entry.since_height > tip_height - BRIDGE_HELD_YOUNG_BLOCKS) m_bridge_young_weight += entry.weight; if (!oldest || entry.since < *oldest) oldest = entry.since; @@ -3328,13 +3519,14 @@ void PeerManagerImpl::BridgeRetryHeldTxs(std::chrono::microseconds now) parked, BRIDGE_HELD_YOUNG_BLOCKS, m_opts.bridge_held_max_weight); } m_bridge_parked_warned = parked > m_opts.bridge_held_max_weight; - const std::string summary{strprintf("%u still held%s (%d offered, %d under the fee floor unasked, %.1f ms)", m_bridge_held_txs.size(), + const std::string summary{strprintf("%u still held, %u fed awaiting confirmation%s (%d offered, %d under the fee floor unasked, %.1f ms)", + m_bridge_held_txids.size(), m_bridge_fed_txids.size(), oldest ? strprintf(", oldest held %dh", std::chrono::duration_cast(now - *oldest).count()) : "", offered, unasked, Ticks(SteadyClock::now() - started) / 1000.0)}; - if (accepted + packaged + known + dropped > 0) { + if (accepted + refed + packaged + known + confirmed + dropped > 0) { m_bridge_held_dirty = true; - LogInfo("bridge: re-offered held Bitcoin transactions: %d accepted, %d in packages, %d already there, %d dropped, %s\n", - accepted, packaged, known, dropped, summary); + LogInfo("bridge: re-offered held Bitcoin transactions: %d accepted, %d fed again after being lost, %d in packages, %d already there, %d mined here, %d dropped, %s\n", + accepted, refed, packaged, known, confirmed, dropped, summary); } else { LogDebug(BCLog::MEMPOOL, "bridge: retry pass: nothing accepted, %s\n", summary); } @@ -3388,19 +3580,36 @@ bool PeerManagerImpl::BridgeProcessBlock(CNode& pfrom, Peer& peer, const std::sh const CTransactionRef& tx{block->vtx[i]}; std::string reason; std::optional feerate; - if (BridgeHasHeldParent(*tx)) { - // Its parent is held, so the mempool would only report missing inputs. - ++held; - ++reasons["parent-held"]; - BridgeHoldTx(tx, now, tip_height, std::nullopt); + if (BridgeTracked(tx->GetHash())) { + // Fed already: a Bitcoin reorg feeding a block again, or a restart before the last one was saved. + ++known; continue; } + if (BridgeHasHeldParent(*tx)) { + // Its parent is held, so the mempool would only report missing inputs -- unless another input is one we + // don't expect at all, and then it can never go in. + const BridgeInputs inputs{WITH_LOCK(cs_main, return BridgeClassifyInputs(*tx))}; + if (inputs == BridgeInputs::DEAD) { + ++missing; + continue; + } + if (inputs == BridgeInputs::WAITS) { + ++held; + ++reasons["parent-held"]; + BridgeTrackTx(tx, BridgeState::WAITING, now, tip_height, std::nullopt); + continue; + } + // AVAILABLE: the held parent reached our mempool by another route; offer this one. + } switch (BridgeOfferTx(tx, reason, &feerate)) { case BridgeTxResult::ACCEPTED: ++accepted; + BridgeTrackTx(tx, BridgeState::FED, now, tip_height, std::nullopt); break; case BridgeTxResult::KNOWN: ++known; + // Already in our mempool (another route): watch it like our own, in case the mempool is lost. + if (m_mempool.exists(tx->GetHash())) BridgeTrackTx(tx, BridgeState::FED, now, tip_height, std::nullopt); break; case BridgeTxResult::MISSING: ++missing; @@ -3408,7 +3617,7 @@ bool PeerManagerImpl::BridgeProcessBlock(CNode& pfrom, Peer& peer, const std::sh case BridgeTxResult::HELD: ++held; ++reasons[reason]; - BridgeHoldTx(tx, now, tip_height, BridgeBelowFloor(reason) ? feerate : std::nullopt); + BridgeTrackTx(tx, BridgeState::WAITING, now, tip_height, BridgeBelowFloor(reason) ? feerate : std::nullopt); // A child later in this block, or the next one, may pay for it: don't wait for our tip to move. if (BridgeBelowFloor(reason)) m_bridge_retry_now = true; break; @@ -6667,8 +6876,10 @@ bool PeerManagerImpl::SendMessages(CNode& node) MaybeSendSendHeaders(node, peer); - if (BridgeFeeds(peer)) { - // Held Bitcoin transactions may have become acceptable (our tip moved, their ancestors confirmed here). + if (BridgeEnabled()) { + // Held Bitcoin transactions may have become acceptable (our tip moved, their ancestors confirmed here), and fed + // ones may have been mined or lost. None of that needs a Bitcoin peer, so any peer runs it; the pass limits + // itself to one per tip change or retry interval. BridgeRetryHeldTxs(current_time); } From d97bc79e3710972595bd3e133ae2c91a95481285 Mon Sep 17 00:00:00 2001 From: mbdrivechains Date: Wed, 23 Sep 2026 20:22:52 +1000 Subject: [PATCH 10/15] bridge: never let the anchor reach disk ahead of the queue; sync both, 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) Claude-Session: https://claude.ai/code/session_0136NHdQjigzYu7QGnAhskQb --- src/net_processing.cpp | 98 +++++++++++++++++++++++++++++++----------- 1 file changed, 72 insertions(+), 26 deletions(-) diff --git a/src/net_processing.cpp b/src/net_processing.cpp index 672c70107fa3..4d8180c44d47 100644 --- a/src/net_processing.cpp +++ b/src/net_processing.cpp @@ -160,6 +160,9 @@ static constexpr int BRIDGE_SCAN_MAX_BLOCKS{2000}; /** Bridge: bridgeheld.dat, version 2. The magic is larger than any count a version 1 file starts with. */ static constexpr uint64_t BRIDGE_HELD_MAGIC{0x3244'4c45'4847'4442}; static constexpr uint32_t BRIDGE_HELD_VERSION{2}; +/** Bridge: the queue and the anchor are written at most this often (and at shutdown). A crash re-feeds the Bitcoin + * blocks since the last write, which is harmless: nothing tracked is fed twice. */ +static constexpr auto BRIDGE_SAVE_INTERVAL{60s}; /** Default time during which a peer must stall block download progress before being disconnected. * the actual timeout is increased temporarily if peers are disconnected for hitting the timeout */ static constexpr auto BLOCK_STALLING_TIMEOUT_DEFAULT{2s}; @@ -577,6 +580,7 @@ class PeerManagerImpl final : public PeerManager PeerManagerImpl(CConnman& connman, AddrMan& addrman, BanMan* banman, ChainstateManager& chainman, CTxMemPool& pool, node::Warnings& warnings, Options opts); + ~PeerManagerImpl() override; /** Overridden from CValidationInterface. */ void ActiveTipChange(const CBlockIndex& new_tip, bool) override @@ -736,10 +740,12 @@ class PeerManagerImpl final : public PeerManager /** Bridge: create the peer's ForeignChain, seeded with the persisted anchor and its ancestry. */ void BridgeInitPeer(NodeId nodeid) EXCLUSIVE_LOCKS_REQUIRED(cs_main); /** Bridge: persist the last fed block. */ - void BridgeSaveAnchor() EXCLUSIVE_LOCKS_REQUIRED(cs_main); + bool BridgeWriteAnchor(const std::pair& anchor) const; fs::path BridgeHeldPath() const; /** Bridge: write the held queue, so a restart does not silently drop what it is holding. */ - void BridgeSaveHeld() EXCLUSIVE_LOCKS_REQUIRED(NetEventsInterface::g_msgproc_mutex); + bool BridgeSaveHeld() EXCLUSIVE_LOCKS_REQUIRED(NetEventsInterface::g_msgproc_mutex); + /** Bridge: write the queue, then the anchor, if either changed and BRIDGE_SAVE_INTERVAL has passed (or `force`). */ + void BridgeMaybeSave(bool force) EXCLUSIVE_LOCKS_REQUIRED(NetEventsInterface::g_msgproc_mutex, !cs_main); /** Bridge: read the held queue back at the first offer after startup. */ void BridgeLoadHeld() EXCLUSIVE_LOCKS_REQUIRED(NetEventsInterface::g_msgproc_mutex); /** Bridge: locator from the foreign tip down through the anchor and the fork parent into our chain. */ @@ -977,6 +983,7 @@ class PeerManagerImpl final : public PeerManager bool m_bridge_parked_warned GUARDED_BY(NetEventsInterface::g_msgproc_mutex){false}; bool m_bridge_held_dirty GUARDED_BY(NetEventsInterface::g_msgproc_mutex){false}; bool m_bridge_held_loaded GUARDED_BY(NetEventsInterface::g_msgproc_mutex){false}; + SteadyClock::time_point m_bridge_saved_at GUARDED_BY(NetEventsInterface::g_msgproc_mutex){}; /** Bridge: a fed block left a transaction below the fee floor; retry at once so a child can carry it. */ bool m_bridge_retry_now GUARDED_BY(NetEventsInterface::g_msgproc_mutex){false}; /** Bridge: the Bitcoin peer fetching blocks. Others only follow headers until it leaves, stalls or idles. */ @@ -2176,6 +2183,14 @@ std::unique_ptr PeerManager::make(CConnman& connman, AddrMan& addrm return std::make_unique(connman, addrman, banman, chainman, pool, warnings, opts); } +PeerManagerImpl::~PeerManagerImpl() +{ + // The last word on what the bridge tracks. The message handler has stopped by now (connman is stopped before + // peerman is destroyed), and the chainstate and mempool are still up. + LOCK(NetEventsInterface::g_msgproc_mutex); + if (m_bridge_held_loaded) BridgeMaybeSave(/*force=*/true); +} + PeerManagerImpl::PeerManagerImpl(CConnman& connman, AddrMan& addrman, BanMan* banman, ChainstateManager& chainman, CTxMemPool& pool, node::Warnings& warnings, Options opts) @@ -2840,21 +2855,26 @@ void PeerManagerImpl::BridgeInitPeer(NodeId nodeid) fc.tip_height = m_bridge_anchor.second; } -void PeerManagerImpl::BridgeSaveAnchor() +bool PeerManagerImpl::BridgeWriteAnchor(const std::pair& anchor) const { - AssertLockHeld(cs_main); - m_bridge_anchor_dirty = false; const fs::path path{BridgeAnchorPath()}; const fs::path tmp{path + ".tmp"}; - { - std::ofstream out{tmp.std_path(), std::ios::trunc}; - out << m_bridge_anchor.first.ToString() << ' ' << m_bridge_anchor.second << '\n'; - if (!out) { - LogWarning("bridge: cannot write %s\n", fs::PathToString(tmp)); - return; - } + FILE* file{fsbridge::fopen(tmp, "wb")}; + if (!file) { + LogWarning("bridge: cannot write %s\n", fs::PathToString(tmp)); + return false; + } + const std::string line{strprintf("%s %d\n", anchor.first.ToString(), anchor.second)}; + const bool written{std::fwrite(line.data(), 1, line.size(), file) == line.size() && FileCommit(file)}; + if (std::fclose(file) != 0 || !written) { + LogWarning("bridge: cannot write %s\n", fs::PathToString(tmp)); + return false; + } + if (!RenameOver(tmp, path)) { + LogWarning("bridge: cannot rename %s\n", fs::PathToString(tmp)); + return false; } - if (!RenameOver(tmp, path)) LogWarning("bridge: cannot rename %s\n", fs::PathToString(tmp)); + return true; } fs::path PeerManagerImpl::BridgeHeldPath() const @@ -2862,21 +2882,21 @@ fs::path PeerManagerImpl::BridgeHeldPath() const return m_chainman.m_options.datadir / BRIDGE_HELD_FILE; } -void PeerManagerImpl::BridgeSaveHeld() +bool PeerManagerImpl::BridgeSaveHeld() { AssertLockHeld(g_msgproc_mutex); - m_bridge_held_dirty = false; const fs::path path{BridgeHeldPath()}; if (m_bridge_held_txs.empty() && m_bridge_recent.empty()) { fs::remove(path); - return; + m_bridge_held_dirty = false; + return true; } const fs::path tmp{path + ".tmp"}; { AutoFile out{fsbridge::fopen(tmp, "wb")}; if (out.IsNull()) { LogWarning("bridge: cannot write %s\n", fs::PathToString(tmp)); - return; + return false; } try { out << BRIDGE_HELD_MAGIC << BRIDGE_HELD_VERSION << m_bridge_scanned.first << m_bridge_scanned.second; @@ -2887,12 +2907,38 @@ void PeerManagerImpl::BridgeSaveHeld() } out << static_cast(m_bridge_recent.size()); for (const auto& [txid, height] : m_bridge_recent) out << txid.ToUint256() << height; + // On disk before it replaces the old file, so a power cut leaves one or the other, whole. + if (!out.Commit()) throw std::runtime_error("cannot sync"); + if (out.fclose() != 0) throw std::runtime_error("cannot close"); } catch (const std::exception& e) { LogWarning("bridge: cannot write %s: %s\n", fs::PathToString(tmp), e.what()); - return; + return false; } } - if (!RenameOver(tmp, path)) LogWarning("bridge: cannot rename %s\n", fs::PathToString(tmp)); + if (!RenameOver(tmp, path)) { + LogWarning("bridge: cannot rename %s\n", fs::PathToString(tmp)); + return false; + } + m_bridge_held_dirty = false; + return true; +} + +void PeerManagerImpl::BridgeMaybeSave(bool force) +{ + AssertLockHeld(g_msgproc_mutex); + const auto [anchor, anchor_dirty]{WITH_LOCK(cs_main, return std::make_pair(m_bridge_anchor, m_bridge_anchor_dirty))}; + if (!m_bridge_held_dirty && !anchor_dirty) return; + const auto now{SteadyClock::now()}; + if (!force && now - m_bridge_saved_at < BRIDGE_SAVE_INTERVAL) return; + m_bridge_saved_at = now; + // The anchor says how far Bitcoin has been fed, so it must never be on disk ahead of the queue holding what those + // blocks left behind: the queue is written and synced first, and the anchor only once that has succeeded. A crash + // in between re-feeds a block, which is harmless -- nothing tracked is fed twice. + if (m_bridge_held_dirty && !BridgeSaveHeld()) return; + if (anchor_dirty && BridgeWriteAnchor(anchor)) { + LOCK(cs_main); + if (m_bridge_anchor == anchor) m_bridge_anchor_dirty = false; + } } void PeerManagerImpl::BridgeLoadHeld() @@ -2956,9 +3002,9 @@ void PeerManagerImpl::BridgeLoadHeld() } } } catch (const std::exception& e) { - // Only the tail is lost, and it is lost for good: the anchor has passed those blocks, so they are - // not fed again (a rewind -- removing bridgefeed.dat -- is the recovery). The file is written to a - // temporary and renamed over, so a crash mid-write cannot cause this; only damage to the file can. + // Only the tail is lost, and it is lost for good: the anchor has passed those blocks, so they are not fed + // again (a rewind -- removing bridgefeed.dat -- is the recovery). The file is synced to a temporary and renamed + // over, so neither a crash nor a power cut mid-write can cause this; only damage to the file can. LogWarning("bridge: %s is unreadable past %u transactions: %s\n", fs::PathToString(BridgeHeldPath()), m_bridge_held_txs.size(), e.what()); } @@ -3530,6 +3576,7 @@ void PeerManagerImpl::BridgeRetryHeldTxs(std::chrono::microseconds now) } else { LogDebug(BCLog::MEMPOOL, "bridge: retry pass: nothing accepted, %s\n", summary); } + BridgeMaybeSave(/*force=*/false); } bool PeerManagerImpl::BridgeProcessBlock(CNode& pfrom, Peer& peer, const std::shared_ptr& block) @@ -3632,10 +3679,9 @@ bool PeerManagerImpl::BridgeProcessBlock(CNode& pfrom, Peer& peer, const std::sh LogInfo("bridge: Bitcoin block %s (height %d, peer=%d): %u tx, %d accepted, %d already known, %d missing inputs, %d held, %d other%s\n", hash.ToString(), height, pfrom.GetId(), block->vtx.size() - 1, accepted, known, missing, held, other, detail); - LOCK(cs_main); - BridgeMarkProcessed(pfrom.GetId(), hash); - if (m_bridge_anchor_dirty) BridgeSaveAnchor(); - if (m_bridge_held_dirty) BridgeSaveHeld(); + WITH_LOCK(cs_main, BridgeMarkProcessed(pfrom.GetId(), hash)); + // Outside cs_main -- the queue can run to a hundred MB -- and at most every BRIDGE_SAVE_INTERVAL. + BridgeMaybeSave(/*force=*/false); return true; } From f4e8d745e6f5c14c595954b5080fb993098533ce Mon Sep 17 00:00:00 2001 From: mbdrivechains Date: Wed, 23 Sep 2026 21:05:22 +1000 Subject: [PATCH 11/15] bridge: fixes from an adversarial review of the rework - 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) Claude-Session: https://claude.ai/code/session_0136NHdQjigzYu7QGnAhskQb --- src/init.cpp | 1 + src/net_processing.cpp | 145 +++++++++++++++++++++++++++++++++-------- src/net_processing.h | 3 + 3 files changed, 123 insertions(+), 26 deletions(-) diff --git a/src/init.cpp b/src/init.cpp index 2f33921fd57c..cb89f8ac34f9 100644 --- a/src/init.cpp +++ b/src/init.cpp @@ -320,6 +320,7 @@ void Shutdown(NodeContext& node) // using the other before destroying them. if (node.peerman && node.validation_signals) node.validation_signals->UnregisterValidationInterface(node.peerman.get()); if (node.connman) node.connman->Stop(); + if (node.peerman) node.peerman->FlushBridge(); StopTorControl(); diff --git a/src/net_processing.cpp b/src/net_processing.cpp index 4d8180c44d47..9311e3b402ed 100644 --- a/src/net_processing.cpp +++ b/src/net_processing.cpp @@ -156,7 +156,11 @@ static constexpr auto BRIDGE_HELD_TXS_RETRY_INTERVAL{10min}; static constexpr int BRIDGE_CONFIRMED_KEEP_BLOCKS{12}; /** Bridge: at most this many of our new blocks are read for confirmations in one pass. Past a longer gap the reading * restarts at the tip, and what was mined in the gap is offered once more and found already here. */ -static constexpr int BRIDGE_SCAN_MAX_BLOCKS{2000}; +static constexpr int BRIDGE_SCAN_MAX_BLOCKS{144}; +/** Bridge: after startup, how long the catch-up gate waits for a header that reaches the height it last read at. */ +static constexpr auto BRIDGE_CATCHUP_GRACE{2min}; +/** Bridge: how often an entry skipped for its feerate is offered anyway (prioritisetransaction; or it went dead). */ +static constexpr auto BRIDGE_FEE_RECHECK_INTERVAL{1h}; /** Bridge: bridgeheld.dat, version 2. The magic is larger than any count a version 1 file starts with. */ static constexpr uint64_t BRIDGE_HELD_MAGIC{0x3244'4c45'4847'4442}; static constexpr uint32_t BRIDGE_HELD_VERSION{2}; @@ -580,7 +584,7 @@ class PeerManagerImpl final : public PeerManager PeerManagerImpl(CConnman& connman, AddrMan& addrman, BanMan* banman, ChainstateManager& chainman, CTxMemPool& pool, node::Warnings& warnings, Options opts); - ~PeerManagerImpl() override; + void FlushBridge() override; /** Overridden from CValidationInterface. */ void ActiveTipChange(const CBlockIndex& new_tip, bool) override @@ -745,7 +749,7 @@ class PeerManagerImpl final : public PeerManager /** Bridge: write the held queue, so a restart does not silently drop what it is holding. */ bool BridgeSaveHeld() EXCLUSIVE_LOCKS_REQUIRED(NetEventsInterface::g_msgproc_mutex); /** Bridge: write the queue, then the anchor, if either changed and BRIDGE_SAVE_INTERVAL has passed (or `force`). */ - void BridgeMaybeSave(bool force) EXCLUSIVE_LOCKS_REQUIRED(NetEventsInterface::g_msgproc_mutex, !cs_main); + void BridgeMaybeSave(bool force) EXCLUSIVE_LOCKS_REQUIRED(NetEventsInterface::g_msgproc_mutex) LOCKS_EXCLUDED(::cs_main); /** Bridge: read the held queue back at the first offer after startup. */ void BridgeLoadHeld() EXCLUSIVE_LOCKS_REQUIRED(NetEventsInterface::g_msgproc_mutex); /** Bridge: locator from the foreign tip down through the anchor and the fork parent into our chain. */ @@ -773,7 +777,7 @@ class PeerManagerImpl final : public PeerManager /** Bridge: offer held parents together with the held child that pays for them (sorted, child last). * Returns the transactions that are in the mempool afterwards. */ std::set BridgeOfferPackage(const Package& package) - EXCLUSIVE_LOCKS_REQUIRED(NetEventsInterface::g_msgproc_mutex); + EXCLUSIVE_LOCKS_REQUIRED(NetEventsInterface::g_msgproc_mutex, !m_peer_mutex, !m_tx_download_mutex); /** Bridge: whether any input of `tx` spends a held transaction. */ bool BridgeHasHeldParent(const CTransaction& tx) const EXCLUSIVE_LOCKS_REQUIRED(NetEventsInterface::g_msgproc_mutex); @@ -788,9 +792,9 @@ class PeerManagerImpl final : public PeerManager bool BridgeTracked(const Txid& txid) const EXCLUSIVE_LOCKS_REQUIRED(NetEventsInterface::g_msgproc_mutex); BridgeInputs BridgeClassifyInputs(const CTransaction& tx) EXCLUSIVE_LOCKS_REQUIRED(NetEventsInterface::g_msgproc_mutex, cs_main); /** Bridge: whether the node is caught up enough to judge Bitcoin transactions at all. */ - bool BridgeReady(int tip_height) const EXCLUSIVE_LOCKS_REQUIRED(NetEventsInterface::g_msgproc_mutex); + bool BridgeReady(int tip_height, int best_header_height) EXCLUSIVE_LOCKS_REQUIRED(NetEventsInterface::g_msgproc_mutex); /** Bridge: read our blocks since the last pass; returns the tracked transactions mined in them. */ - std::unordered_set BridgeScanMined() EXCLUSIVE_LOCKS_REQUIRED(NetEventsInterface::g_msgproc_mutex, !cs_main); + std::unordered_set BridgeScanMined() EXCLUSIVE_LOCKS_REQUIRED(NetEventsInterface::g_msgproc_mutex) LOCKS_EXCLUDED(::cs_main); /** Bridge: re-offer held transactions once our tip has moved or enough time has passed. */ void BridgeRetryHeldTxs(std::chrono::microseconds now) EXCLUSIVE_LOCKS_REQUIRED(g_msgproc_mutex, !m_peer_mutex, !m_tx_download_mutex); @@ -984,6 +988,9 @@ class PeerManagerImpl final : public PeerManager bool m_bridge_held_dirty GUARDED_BY(NetEventsInterface::g_msgproc_mutex){false}; bool m_bridge_held_loaded GUARDED_BY(NetEventsInterface::g_msgproc_mutex){false}; SteadyClock::time_point m_bridge_saved_at GUARDED_BY(NetEventsInterface::g_msgproc_mutex){}; + SteadyClock::time_point m_bridge_loaded_at GUARDED_BY(NetEventsInterface::g_msgproc_mutex){}; + bool m_bridge_catchup_logged GUARDED_BY(NetEventsInterface::g_msgproc_mutex){false}; + std::chrono::microseconds m_bridge_fee_rechecked_at GUARDED_BY(NetEventsInterface::g_msgproc_mutex){0us}; /** Bridge: a fed block left a transaction below the fee floor; retry at once so a child can carry it. */ bool m_bridge_retry_now GUARDED_BY(NetEventsInterface::g_msgproc_mutex){false}; /** Bridge: the Bitcoin peer fetching blocks. Others only follow headers until it leaves, stalls or idles. */ @@ -2183,10 +2190,11 @@ std::unique_ptr PeerManager::make(CConnman& connman, AddrMan& addrm return std::make_unique(connman, addrman, banman, chainman, pool, warnings, opts); } -PeerManagerImpl::~PeerManagerImpl() +void PeerManagerImpl::FlushBridge() { - // The last word on what the bridge tracks. The message handler has stopped by now (connman is stopped before - // peerman is destroyed), and the chainstate and mempool are still up. + // The last word on what the bridge tracks: Shutdown calls this after stopping connman (so the message handler no + // longer holds g_msgproc_mutex) and before the mempool and chainstate go. + AssertLockNotHeld(NetEventsInterface::g_msgproc_mutex); LOCK(NetEventsInterface::g_msgproc_mutex); if (m_bridge_held_loaded) BridgeMaybeSave(/*force=*/true); } @@ -2888,6 +2896,7 @@ bool PeerManagerImpl::BridgeSaveHeld() const fs::path path{BridgeHeldPath()}; if (m_bridge_held_txs.empty() && m_bridge_recent.empty()) { fs::remove(path); + DirectoryCommit(path.parent_path()); m_bridge_held_dirty = false; return true; } @@ -2919,6 +2928,8 @@ bool PeerManagerImpl::BridgeSaveHeld() LogWarning("bridge: cannot rename %s\n", fs::PathToString(tmp)); return false; } + // The rename itself on disk before the anchor's: ordering must not rest on the filesystem. + DirectoryCommit(path.parent_path()); m_bridge_held_dirty = false; return true; } @@ -2926,6 +2937,7 @@ bool PeerManagerImpl::BridgeSaveHeld() void PeerManagerImpl::BridgeMaybeSave(bool force) { AssertLockHeld(g_msgproc_mutex); + AssertLockNotHeld(::cs_main); const auto [anchor, anchor_dirty]{WITH_LOCK(cs_main, return std::make_pair(m_bridge_anchor, m_bridge_anchor_dirty))}; if (!m_bridge_held_dirty && !anchor_dirty) return; const auto now{SteadyClock::now()}; @@ -2945,10 +2957,12 @@ void PeerManagerImpl::BridgeLoadHeld() { AssertLockHeld(g_msgproc_mutex); m_bridge_held_loaded = true; + m_bridge_loaded_at = SteadyClock::now(); + m_bridge_fee_rechecked_at = GetTime(); const int height{WITH_LOCK(cs_main, return m_chainman.ActiveChain().Height())}; AutoFile in{fsbridge::fopen(BridgeHeldPath(), "rb")}; if (in.IsNull()) return; - const auto add{[&](CTransactionRef tx, int64_t since, int since_height, BridgeState state) { + const auto add{[&](CTransactionRef tx, int64_t since, int since_height, BridgeState state) EXCLUSIVE_LOCKS_REQUIRED(NetEventsInterface::g_msgproc_mutex) { const Txid txid{tx->GetHash()}; if (m_bridge_held_txids.contains(txid) || m_bridge_fed_txids.contains(txid)) return; // first one wins const int64_t weight{GetTransactionWeight(*tx)}; @@ -3153,7 +3167,10 @@ void PeerManagerImpl::BridgeRequestBlocks(CNode& node, Peer& peer, std::chrono:: if (m_chainman.ActiveChain().Height() < fork_height - 1) return; // Not before what we track is loaded, nor while we are catching up (BridgeReady): fed then, a transaction whose // parent is in a mempool.dat not yet read, or was mined in a block not yet reconnected, looks like an orphan. - if (!m_bridge_held_loaded || !BridgeReady(m_chainman.ActiveChain().Height())) return; + if (!m_bridge_held_loaded || !BridgeReady(m_chainman.ActiveChain().Height(), + m_chainman.m_best_header ? m_chainman.m_best_header->nHeight : -1)) { + return; + } ForeignChain& fc{*Assert(State(node.GetId())->m_foreign)}; // Announcements normally keep us current; poll now and then in case one was missed. @@ -3260,19 +3277,36 @@ PeerManagerImpl::BridgeTxResult PeerManagerImpl::BridgeOfferTx(const CTransactio reason == "mempool min fee not met" || reason == "min relay fee not met" || // It conflicts with more of our mempool than a replacement may evict; clears as they are mined or expire. - reason == "too many potential replacements" || + reason.starts_with("too many potential replacements") || // Its parent is in our mempool with ephemeral dust this transaction does not spend (a sibling spent it, // or will). Every child must wait for that parent to confirm here; then it is accepted. reason == "missing-ephemeral-spends") { return BridgeTxResult::HELD; } + // A replacement refused where no input actually conflicts: all it would replace is a TRUC sibling (its parent's one + // unconfirmed child slot is taken). That clears when the parent is mined here. Refused over a real conflict, it + // yields to the eCash spend that was here first, as the Python bridge does. + if (reason.starts_with("insufficient fee") || reason == "replacement-failed") { + LOCK(m_mempool.cs); + bool conflicts{false}; + for (const CTxIn& in : tx->vin) { + if (m_mempool.GetConflictTx(in.prevout)) { + conflicts = true; + break; + } + } + if (!conflicts) return BridgeTxResult::HELD; + } return BridgeTxResult::OTHER; } bool PeerManagerImpl::BridgeHasHeldParent(const CTransaction& tx) const { AssertLockHeld(g_msgproc_mutex); - return std::any_of(tx.vin.begin(), tx.vin.end(), [&](const CTxIn& in) { return m_bridge_held_txids.contains(in.prevout.hash); }); + for (const CTxIn& in : tx.vin) { + if (m_bridge_held_txids.contains(in.prevout.hash)) return true; + } + return false; } std::vector PeerManagerImpl::BridgeHeldParents(const CTransaction& tx) const @@ -3362,20 +3396,40 @@ PeerManagerImpl::BridgeInputs PeerManagerImpl::BridgeClassifyInputs(const CTrans return waits ? BridgeInputs::WAITS : BridgeInputs::AVAILABLE; } -bool PeerManagerImpl::BridgeReady(int tip_height) const +bool PeerManagerImpl::BridgeReady(int tip_height, int best_header_height) { AssertLockHeld(g_msgproc_mutex); // Not while the node is catching up. mempool.dat is loaded only after the blocks are imported, while networking // already runs; and after a crash our chain restarts where it was last flushed -- up to an hour behind -- so what // we saw mined may not be mined here again yet. Judging Bitcoin transactions then takes their children for orphans. - return m_mempool.GetLoadTried() && !m_chainman.m_blockman.LoadingBlocks() && !m_chainman.IsInitialBlockDownload() && - tip_height >= m_bridge_scanned.second; + if (!m_mempool.GetLoadTried() || m_chainman.m_blockman.LoadingBlocks() || m_chainman.IsInitialBlockDownload()) return false; + if (tip_height >= m_bridge_scanned.second) { + m_bridge_catchup_logged = false; + return true; + } + // Behind the height we last read at. Wait while a known header reaches it (we are re-syncing), and briefly after + // startup while headers arrive. Otherwise it is not coming back -- a wiped or reset chain, an invalidated branch, + // a reorganisation to fewer blocks -- so read from here rather than stall. + if (best_header_height >= m_bridge_scanned.second || SteadyClock::now() - m_bridge_loaded_at < BRIDGE_CATCHUP_GRACE) { + if (!m_bridge_catchup_logged) { + LogInfo("bridge: waiting for our chain to reach height %d (now %d) before judging Bitcoin transactions\n", + m_bridge_scanned.second, tip_height); + m_bridge_catchup_logged = true; + } + return false; + } + LogInfo("bridge: our chain is at %d, below the height %d we last read at, and no known header gets back there; reading from here\n", + tip_height, m_bridge_scanned.second); + m_bridge_scanned = {uint256{}, -1}; + m_bridge_catchup_logged = false; + return true; } std::unordered_set PeerManagerImpl::BridgeScanMined() { AssertLockHeld(g_msgproc_mutex); - std::vector connected; + AssertLockNotHeld(::cs_main); + std::vector connected, disconnected; int fork_height{-1}, tip_height{-1}; { LOCK(cs_main); @@ -3394,12 +3448,37 @@ std::unordered_set PeerManagerImpl::BridgeScanMined() } fork_height = fork->nHeight; for (const CBlockIndex* index{tip}; index != fork; index = index->pprev) connected.push_back(index); + for (const CBlockIndex* index{last}; index != fork; index = index->pprev) disconnected.push_back(index); } - // Reorganised away: what we saw mined above the fork is not mined any more. + // Reorganised away: what we saw mined above the fork is not mined any more. Take it back from those blocks (still on + // disk) and track it as fed again, at the front of the queue -- its parents are below the fork or earlier among + // these, and anything spending it comes later -- so the pass finds it in the mempool, offers it again, or sees it + // mined on the new branch. Untracked, a later mempool loss would take it and everything spending it. + std::unordered_set unmined; while (!m_bridge_recent.empty() && m_bridge_recent.back().second > fork_height) { + unmined.insert(m_bridge_recent.back().first); m_bridge_recent_txids.erase(m_bridge_recent.back().first); m_bridge_recent.pop_back(); } + if (!unmined.empty()) { + std::vector back; + for (auto it{disconnected.rbegin()}; it != disconnected.rend(); ++it) { + CBlock block; + if (!m_chainman.m_blockman.ReadBlock(block, **it)) continue; + for (const CTransactionRef& tx : block.vtx) { + if (unmined.contains(tx->GetHash())) back.push_back(tx); + } + } + const auto now{GetTime()}; + for (auto it{back.rbegin()}; it != back.rend(); ++it) { + const Txid& txid{(*it)->GetHash()}; + if (m_bridge_held_txids.contains(txid) || m_bridge_fed_txids.contains(txid)) continue; + m_bridge_held_txs.push_front(BridgeHeld{*it, now, tip_height, GetTransactionWeight(**it), std::nullopt, BridgeState::FED}); + m_bridge_fed_txids.insert(txid); + } + m_bridge_held_dirty = true; + LogInfo("bridge: %u fed transaction(s) mined in blocks our chain reorganised away are tracked again\n", back.size()); + } std::unordered_set mined; for (auto it{connected.rbegin()}; it != connected.rend(); ++it) { CBlock block; @@ -3426,10 +3505,11 @@ void PeerManagerImpl::BridgeRetryHeldTxs(std::chrono::microseconds now) { AssertLockHeld(g_msgproc_mutex); if (!m_bridge_held_loaded) BridgeLoadHeld(); - const auto [tip, tip_height]{WITH_LOCK(cs_main, return std::make_pair(m_chainman.ActiveTip() ? m_chainman.ActiveTip()->GetBlockHash() : uint256{}, - m_chainman.ActiveChain().Height()))}; - if (!BridgeReady(tip_height)) return; - if (m_bridge_held_txs.empty()) { + const auto [tip, tip_height, best_header_height]{WITH_LOCK(cs_main, return std::make_tuple( + m_chainman.ActiveTip() ? m_chainman.ActiveTip()->GetBlockHash() : uint256{}, m_chainman.ActiveChain().Height(), + m_chainman.m_best_header ? m_chainman.m_best_header->nHeight : -1))}; + if (!BridgeReady(tip_height, best_header_height)) return; + if (m_bridge_held_txs.empty() && m_bridge_recent.empty()) { // Nothing tracked, so nothing to read our blocks for; keep our place so a later pass starts from here. m_bridge_scanned = {tip, tip_height}; return; @@ -3442,6 +3522,10 @@ void PeerManagerImpl::BridgeRetryHeldTxs(std::chrono::microseconds now) const std::unordered_set mined{BridgeScanMined()}; // What a fee refusal is measured against: the higher of the relay floor and the mempool's own. const CFeeRate floor{std::max(m_mempool.GetMinFee(), m_mempool.m_opts.min_relay_feerate)}; + // Now and then offer what the fee floor skips anyway: prioritisetransaction may have lifted it, or it may have gone + // dead (an input spent here) and should leave, taking what waits behind it. + const bool recheck_fees{now - m_bridge_fee_rechecked_at >= BRIDGE_FEE_RECHECK_INTERVAL}; + if (recheck_fees) m_bridge_fee_rechecked_at = now; int offered{0}, unasked{0}, accepted{0}, refed{0}, packaged{0}, known{0}, confirmed{0}, dropped{0}; // Held transactions this pass found merely below the fee floor, with their place in `held`: a child that // pays for them can take them in as a package. @@ -3501,7 +3585,7 @@ void PeerManagerImpl::BridgeRetryHeldTxs(std::chrono::microseconds now) ++packaged; } } - } else if (!was_fed && entry.feerate && *entry.feerate < floor && !m_mempool.exists(txid)) { + } else if (!was_fed && !recheck_fees && entry.feerate && *entry.feerate < floor && !m_mempool.exists(txid)) { // Refused on fee last time and its own feerate is still under the floor, so the mempool would refuse // it again (the check looks at nothing else). Keep it without asking; a child can still carry it. next = BridgeState::WAITING; @@ -3524,8 +3608,13 @@ void PeerManagerImpl::BridgeRetryHeldTxs(std::chrono::microseconds now) if (BridgeBelowFloor(reason)) below_floor.emplace(txid, held.size()); break; case BridgeTxResult::KNOWN: - // Already here: in our mempool (someone else relayed it), so watch it as fed; or mined. - if (m_mempool.exists(txid)) next = BridgeState::FED; + // Already here: in our mempool (someone else relayed it), so watch it as fed; or mined since this + // pass read our blocks, so remember it as mined like the rest. + if (m_mempool.exists(txid)) { + next = BridgeState::FED; + } else if (m_bridge_recent_txids.insert(txid).second) { + m_bridge_recent.emplace_back(txid, tip_height + 1); + } ++known; break; case BridgeTxResult::MISSING: @@ -3635,7 +3724,11 @@ bool PeerManagerImpl::BridgeProcessBlock(CNode& pfrom, Peer& peer, const std::sh if (BridgeHasHeldParent(*tx)) { // Its parent is held, so the mempool would only report missing inputs -- unless another input is one we // don't expect at all, and then it can never go in. - const BridgeInputs inputs{WITH_LOCK(cs_main, return BridgeClassifyInputs(*tx))}; + BridgeInputs inputs; + { + LOCK(cs_main); + inputs = BridgeClassifyInputs(*tx); + } if (inputs == BridgeInputs::DEAD) { ++missing; continue; diff --git a/src/net_processing.h b/src/net_processing.h index 7c986bd1443f..45e513e0e6e6 100644 --- a/src/net_processing.h +++ b/src/net_processing.h @@ -112,6 +112,9 @@ class PeerManager : public CValidationInterface, public NetEventsInterface CTxMemPool& pool, node::Warnings& warnings, Options opts); virtual ~PeerManager() = default; + /** Bridge: write what the Bitcoin block feed tracks. Called at shutdown, once the message handler has stopped. */ + virtual void FlushBridge() {} + /** * Attempt to manually fetch block from a given peer. We must already have the header. * From 6995d4483000bf319b76126384b4b420a9dfe144 Mon Sep 17 00:00:00 2001 From: mbdrivechains Date: Wed, 23 Sep 2026 21:05:31 +1000 Subject: [PATCH 12/15] contrib: regtest proofs for the Bitcoin block feed 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) Claude-Session: https://claude.ai/code/session_0136NHdQjigzYu7QGnAhskQb --- contrib/bridge-tests/README.md | 36 +++ contrib/bridge-tests/bridgelib.py | 303 ++++++++++++++++++ contrib/bridge-tests/run_all.py | 44 +++ contrib/bridge-tests/test_backpressure.py | 67 ++++ contrib/bridge-tests/test_cluster_leak.py | 33 ++ contrib/bridge-tests/test_crash_feed.py | 42 +++ .../bridge-tests/test_ephemeral_sibling.py | 50 +++ contrib/bridge-tests/test_fee_skip.py | 44 +++ contrib/bridge-tests/test_gate_release.py | 34 ++ contrib/bridge-tests/test_known_relayed.py | 38 +++ contrib/bridge-tests/test_mempool_loss.py | 44 +++ contrib/bridge-tests/test_mixed_dead_input.py | 45 +++ contrib/bridge-tests/test_no_expiry.py | 45 +++ contrib/bridge-tests/test_package_cpfp.py | 28 ++ .../bridge-tests/test_package_multi_parent.py | 37 +++ contrib/bridge-tests/test_reorg_retrack.py | 42 +++ contrib/bridge-tests/test_restart_persist.py | 35 ++ contrib/bridge-tests/test_retry_cost.py | 55 ++++ contrib/bridge-tests/test_stale_restart.py | 54 ++++ contrib/bridge-tests/test_truc_sibling.py | 38 +++ 20 files changed, 1114 insertions(+) create mode 100644 contrib/bridge-tests/README.md create mode 100755 contrib/bridge-tests/bridgelib.py create mode 100755 contrib/bridge-tests/run_all.py create mode 100755 contrib/bridge-tests/test_backpressure.py create mode 100755 contrib/bridge-tests/test_cluster_leak.py create mode 100755 contrib/bridge-tests/test_crash_feed.py create mode 100755 contrib/bridge-tests/test_ephemeral_sibling.py create mode 100755 contrib/bridge-tests/test_fee_skip.py create mode 100755 contrib/bridge-tests/test_gate_release.py create mode 100755 contrib/bridge-tests/test_known_relayed.py create mode 100755 contrib/bridge-tests/test_mempool_loss.py create mode 100755 contrib/bridge-tests/test_mixed_dead_input.py create mode 100755 contrib/bridge-tests/test_no_expiry.py create mode 100755 contrib/bridge-tests/test_package_cpfp.py create mode 100755 contrib/bridge-tests/test_package_multi_parent.py create mode 100755 contrib/bridge-tests/test_reorg_retrack.py create mode 100755 contrib/bridge-tests/test_restart_persist.py create mode 100755 contrib/bridge-tests/test_retry_cost.py create mode 100755 contrib/bridge-tests/test_stale_restart.py create mode 100755 contrib/bridge-tests/test_truc_sibling.py diff --git a/contrib/bridge-tests/README.md b/contrib/bridge-tests/README.md new file mode 100644 index 000000000000..442c25349cb0 --- /dev/null +++ b/contrib/bridge-tests/README.md @@ -0,0 +1,36 @@ +# Bridge regtest proofs + +Each test runs a regtest pair: **A**, a stock Bitcoin Core 29 node (the Bitcoin side), and **B**, the eCash node under +test, which reaches A over `-bitcoinpeer` and feeds A's post-fork blocks into its own mempool. Transactions are mined on +A with `generateblock`, so A's relay policy never sees them — exactly how a Bitcoin block arrives. Every test states the +failure it reproduces, and exits 0 only if nothing is lost. + +``` +BRIDGE_VANILLA_BIN=/path/to/bitcoin-29.0/bin ./run_all.py path/to/ecash/bitcoind +``` + +Python 3 standard library only. Datadirs go under `$BRIDGE_REGTEST_ROOT` (default `/tmp/bridge-regtest`); each test has +its own ports, so `run_all.py` runs two at a time. Pass one or more test names to run just those. + +| test | what it 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 the child that pays for it | 0/2 | 2/2 | +| `test_no_expiry` | a held transaction is never 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 sibling refused `missing-ephemeral-spends` waits for the dust parent to confirm | lost | in | +| `test_backpressure` | a full queue pauses the feed instead of refusing transactions; stuck ones 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 the node later loses (restart without `mempool.dat`) 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: children of lately-mined txs survive | lost | in | +| `test_mixed_dead_input` | a transaction with one live and one dead input is dropped at once, not held | — | dropped | +| `test_crash_feed` | SIGKILL mid-feed: blocks since the last write are re-fed, nothing lost or doubled | — | 100/100 | +| `test_reorg_retrack` | a fed transaction mined here and then reorganised away is tracked again | lost | in | +| `test_gate_release` | the catch-up gate releases when our chain will not get back to where it was | stalled | fed | +| `test_truc_sibling` | a v3 child refused only for its parent's one child slot waits, not dropped | lost | in | +| `test_retry_cost` | a pass costs what can progress, not the backlog (8,000 held) | 401 ms | ~7 ms | + +"Before" is the build just before the commit that fixes it; `test_backpressure` uses that build with its limit compiled +down to 6,000 WU. diff --git a/contrib/bridge-tests/bridgelib.py b/contrib/bridge-tests/bridgelib.py new file mode 100755 index 000000000000..c71b35050b25 --- /dev/null +++ b/contrib/bridge-tests/bridgelib.py @@ -0,0 +1,303 @@ +#!/usr/bin/env python3 +"""Shared regtest harness for the bridge tests. + +A = vanilla Bitcoin Core 29 (the Bitcoin side, reached by B over -bitcoinpeer). +B = the eCash node under test (-ecashheight=H), fed from A's post-fork blocks. + +Transactions are built on A with createrawtransaction/signrawtransactionwithwallet and mined with +generateblock, so A's own mempool policy never sees them -- exactly how a Bitcoin block arrives. +RPC goes over HTTP (batchable), not a bitcoin-cli process per call. + + pair = Pair("mytest", ecx=sys.argv[1], base=18600, b_args=["-limitclustercount=10"]).start() + try: ... + finally: pair.stop() +""" +import base64, hashlib, json, os, shutil, signal, subprocess, sys, time +import urllib.error, urllib.request + +# The Bitcoin side: a directory holding a stock Bitcoin Core 29 bitcoind. +VAN = os.environ.get("BRIDGE_VANILLA_BIN", "") +# The eCash node under test: given on the command line, else $BRIDGE_ECX_BIN, else this tree's build. +ECX_DEFAULT = os.environ.get("BRIDGE_ECX_BIN", os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "build", "bin", "bitcoind")) +REGTEST_ROOT = os.environ.get("BRIDGE_REGTEST_ROOT", "/tmp/bridge-regtest") +H = 150 # fork height +USER, PASS = "u", "p" +REGTEST_MAGIC = "fabfb5da" # A speaks plain regtest + + +class RPCError(Exception): + def __init__(self, method, err): + self.code, self.message = err.get("code"), err.get("message") + super().__init__(f"{method}: {self.code} {self.message}") + + +class Node: + def __init__(self, name, bitcoind, datadir, p2p, rpcport, args): + self.name, self.bitcoind, self.datadir = name, bitcoind, datadir + self.p2p, self.rpcport, self.args = p2p, rpcport, list(args) + self.proc = None + self._auth = "Basic " + base64.b64encode(f"{USER}:{PASS}".encode()).decode() + + # --- process ------------------------------------------------------------------------------ + def cmdline(self): + return [self.bitcoind, "-regtest", f"-datadir={self.datadir}", f"-port={self.p2p}", + f"-rpcport={self.rpcport}", f"-rpcuser={USER}", f"-rpcpassword={PASS}", + "-printtoconsole=0", "-dnsseed=0", "-listenonion=0", "-fallbackfee=0.0001"] + self.args + + def start(self, wait=120): + os.makedirs(self.datadir, exist_ok=True) + self.proc = subprocess.Popen(self.cmdline(), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + t0 = time.time() + while time.time() - t0 < wait: + try: + self.rpc("getblockchaininfo") + return self + except Exception: + if self.proc.poll() is not None: + raise RuntimeError(f"{self.name} exited with {self.proc.returncode}; see {self.logpath()}") + time.sleep(0.25) + raise RuntimeError(f"{self.name} did not come up") + + def stop(self): + if self.proc is None: + return + try: + self.rpc("stop") + except Exception: + pass + try: + self.proc.wait(timeout=90) + except subprocess.TimeoutExpired: + self.proc.kill() + self.proc.wait() + self.proc = None + + def kill(self): + """SIGKILL: no shutdown, no flush, no mempool.dat -- a crash, an OOM kill, or a container stop that timed out.""" + if self.proc is None: + return + self.proc.send_signal(signal.SIGKILL) + self.proc.wait() + self.proc = None + + def restart(self, args=None): + self.stop() + if args is not None: + self.args = list(args) + return self.start() + + # --- rpc ---------------------------------------------------------------------------------- + def _post(self, payload, path="/", timeout=600): + req = urllib.request.Request(f"http://127.0.0.1:{self.rpcport}{path}", json.dumps(payload).encode(), + {"Authorization": self._auth, "Content-Type": "application/json"}) + try: + with urllib.request.urlopen(req, timeout=timeout) as r: + return json.loads(r.read()) + except urllib.error.HTTPError as e: # Core answers an RPC error with HTTP 500 + a JSON body + return json.loads(e.read()) + + def rpc(self, method, *params, wallet=None): + out = self._post({"jsonrpc": "1.0", "id": 0, "method": method, "params": list(params)}, + f"/wallet/{wallet}" if wallet else "/") + if out.get("error"): + raise RPCError(method, out["error"]) + return out["result"] + + def try_rpc(self, method, *params, wallet=None): + try: + return self.rpc(method, *params, wallet=wallet), None + except RPCError as e: + return None, e + + def batch(self, calls, wallet=None): + """[(method, [params])] -> [(result, error-dict-or-None)] in order.""" + if not calls: + return [] + body = [{"jsonrpc": "1.0", "id": i, "method": m, "params": p} for i, (m, p) in enumerate(calls)] + out = self._post(body, f"/wallet/{wallet}" if wallet else "/") + out.sort(key=lambda x: x["id"]) + return [(x["result"], x["error"]) for x in out] + + # --- helpers ------------------------------------------------------------------------------ + def logpath(self): + return os.path.join(self.datadir, "regtest", "debug.log") + + def log(self): + try: + with open(self.logpath(), errors="replace") as f: + return f.read() + except FileNotFoundError: + return "" + + def mempool(self): + return set(self.rpc("getrawmempool")) + + def has(self, txid): + """In the mempool, or confirmed on the active chain. (txindex also finds a transaction in a block that was + reorganised away; getrawtransaction reports 0 confirmations for that, so it does not count.)""" + r, _ = self.try_rpc("getrawtransaction", txid, True) + return r is not None and ("blockhash" not in r or r.get("confirmations", 0) > 0) + + +def wait_for(pred, what, timeout=120, poll=0.5): + end = time.time() + timeout + last = None + while time.time() < end: + try: + v = pred() + if v: + return v + except Exception as e: + last = e + time.sleep(poll) + raise AssertionError(f"timeout waiting for {what}" + (f" (last error: {last})" if last else "")) + + +def kill_stale(root): + """Kill any bitcoind still running on a datadir under root (by PID, never by pattern on our own command line).""" + for d in os.listdir("/proc"): + if not d.isdigit(): + continue + try: + with open(f"/proc/{d}/cmdline", "rb") as f: + cmd = f.read().replace(b"\0", b" ").decode(errors="replace") + except OSError: + continue + if "bitcoind" in cmd and f"-datadir={root}/" in cmd: + try: + os.kill(int(d), signal.SIGKILL) + except OSError: + pass + + +class Pair: + def __init__(self, tag, ecx=None, base=18600, b_args=(), a_args=(), debug=("mempool",)): + self.root = os.path.join(REGTEST_ROOT, tag) + self.ecx = ecx or ECX_DEFAULT + self.a_p2p, self.a_rpc, self.b_p2p, self.b_rpc = base + 1, base + 2, base + 3, base + 4 + a = ["-listen=1", "-bind=127.0.0.1", "-connect=0"] + list(a_args) + b = ["-listen=0", "-connect=0", f"-ecashheight={H}", f"-bitcoinpeer=127.0.0.1:{self.a_p2p}", + f"-bitcoinpeermagic={REGTEST_MAGIC}", "-txindex=1", "-walletbroadcast=0", "-logtimemicros=1"] + b += [f"-debug={c}" for c in debug] + list(b_args) + self.A = Node("A", os.path.join(VAN, "bitcoind"), os.path.join(self.root, "a"), self.a_p2p, self.a_rpc, a) + self.B = Node("B", self.ecx, os.path.join(self.root, "b"), self.b_p2p, self.b_rpc, b) + self.addr = None + self.coins = [] + + def start(self): + if not os.path.isfile(os.path.join(VAN, "bitcoind")): + raise SystemExit("set BRIDGE_VANILLA_BIN to a directory holding a stock Bitcoin Core 29 bitcoind") + if not os.path.isfile(self.ecx): + raise SystemExit(f"no eCash bitcoind at {self.ecx}; pass one as the first argument") + kill_stale(self.root) + shutil.rmtree(self.root, ignore_errors=True) + self.A.start() + self.A.rpc("createwallet", "w") + self.addr = self.A.rpc("getnewaddress", wallet="w") + self.A.rpc("generatetoaddress", H + 120, self.addr) + self.B.start() + wait_for(lambda: self.B.rpc("getblockcount") >= H - 1, "B pre-fork sync") + self.B.rpc("createwallet", "wb") + self.b_addr = self.B.rpc("getnewaddress", wallet="wb") + self.B.rpc("generatetoaddress", 1, self.b_addr) + # coins that exist on both chains and are mature on B: coinbases from the shared pre-fork blocks + height = self.A.rpc("getblockcount") + self.coins = sorted((u for u in self.A.rpc("listunspent", wallet="w") + if u["confirmations"] >= height - (H - 100) + 1), + key=lambda u: -u["amount"]) + # let the feed catch up with A's (empty) post-fork blocks + self.wait_fed(self.A.rpc("getbestblockhash")) + return self + + def stop(self): + for n in (self.B, self.A): + try: + n.stop() + except Exception: + pass + + # --- building Bitcoin transactions ---------------------------------------------------------- + def coin(self): + c = self.coins.pop(0) + return {"txid": c["txid"], "vout": c["vout"], "amount": float(c["amount"]), "spk": c["scriptPubKey"]} + + def spend(self, inputs, outputs, version=2, locktime=0, sequence=None): + """inputs: [{"txid","vout","amount","spk"}] (parents may be unconfirmed); outputs: [(addr, amount)]. + Returns {"txid","hex","vout":[{"n","amount","spk"}]}.""" + ins = [{"txid": i["txid"], "vout": i["vout"]} | ({"sequence": sequence} if sequence is not None else {}) + for i in inputs] + outs = [{a: round(v, 8)} for a, v in outputs] + raw = self.A.rpc("createrawtransaction", ins, outs, locktime, False) + if version != 2: + raw = struct_version(raw, version) + prev = [{"txid": i["txid"], "vout": i["vout"], "scriptPubKey": i["spk"], "amount": round(i["amount"], 8)} + for i in inputs] + signed = self.A.rpc("signrawtransactionwithwallet", raw, prev, wallet="w") + assert signed.get("complete"), signed + dec = self.A.rpc("decoderawtransaction", signed["hex"]) + return {"txid": dec["txid"], "hex": signed["hex"], + "vout": [{"n": o["n"], "amount": float(o["value"]), "spk": o["scriptPubKey"]["hex"]} for o in dec["vout"]]} + + def out(self, tx, n=0): + o = tx["vout"][n] + return {"txid": tx["txid"], "vout": n, "amount": o["amount"], "spk": o["spk"]} + + def chain(self, length, fee=0.0001, start=None): + """A chain of `length` 1-in-1-out transactions, each spending the previous one's output 0.""" + prev = start or self.coin() + txs = [] + for _ in range(length): + t = self.spend([prev], [(self.addr, prev["amount"] - fee)]) + txs.append(t) + prev = self.out(t) + return txs + + def chain_fast(self, length, fee=0.0001, start=None): + """Same as chain(), but batched: txids don't commit to witnesses, so the whole chain can be laid out + unsigned first and signed in one batch. For chains in the thousands.""" + prev = start or self.coin() + addr_spk = self.A.rpc("getaddressinfo", self.addr, wallet="w")["scriptPubKey"] + raws, prevs = [], [] + for _ in range(length): + raw = self.A.rpc("createrawtransaction", [{"txid": prev["txid"], "vout": prev["vout"]}], + [{self.addr: round(prev["amount"] - fee, 8)}], 0, False) + raws.append(raw) + prevs.append({"txid": prev["txid"], "vout": prev["vout"], "scriptPubKey": prev["spk"], + "amount": round(prev["amount"], 8)}) + # an unsigned segwit spend serializes without a witness, so its hash is already the txid + txid = hashlib.sha256(hashlib.sha256(bytes.fromhex(raw)).digest()).digest()[::-1].hex() + prev = {"txid": txid, "vout": 0, "amount": round(prev["amount"] - fee, 8), "spk": addr_spk} + out = [] + for i in range(0, length, 500): + res = self.A.batch([("signrawtransactionwithwallet", [raws[j], [prevs[j]]]) for j in range(i, min(i + 500, length))], + wallet="w") + for r, e in res: + assert e is None and r["complete"], (e, r) + out.append(r["hex"]) + return out + + # --- mining --------------------------------------------------------------------------------- + def mine_bitcoin(self, hexes): + """Mine the given raw transactions into one Bitcoin block on A (no mempool policy). Returns the block hash.""" + return self.A.rpc("generateblock", self.addr, list(hexes))["hash"] + + def mine_ecash(self, n=1): + return self.B.rpc("generatetoaddress", n, self.b_addr, wallet="wb") + + def wait_fed(self, block_hash, timeout=120): + """Until B has fed the given Bitcoin block (the bridge logs every block it offers).""" + return wait_for(lambda: f"bridge: Bitcoin block {block_hash}" in self.B.log(), f"B to feed {block_hash[:16]}", + timeout) + + def present(self, txids): + return sum(self.B.has(t) for t in txids) + + +def struct_version(raw_hex, version): + """Rewrite the 4-byte little-endian nVersion of an unsigned raw transaction.""" + return version.to_bytes(4, "little").hex() + raw_hex[8:] + + +def result(ok, msg): + print(("RESULT: PASS - " if ok else "RESULT: FAIL - ") + msg, flush=True) + sys.exit(0 if ok else 1) diff --git a/contrib/bridge-tests/run_all.py b/contrib/bridge-tests/run_all.py new file mode 100755 index 000000000000..8098c9cf8663 --- /dev/null +++ b/contrib/bridge-tests/run_all.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +"""Run every bridge test against one eCash bitcoind, in two lanes (each test has its own ports and datadirs). + + BRIDGE_VANILLA_BIN=/path/to/bitcoin-29/bin ./run_all.py [] [--lanes N] [test ...] + +test_retry_cost.py is a measurement, not a pass/fail, and runs last on its own so the timing is not disturbed. +""" +import os, subprocess, sys, time +from concurrent.futures import ThreadPoolExecutor + +HERE = os.path.dirname(os.path.abspath(__file__)) +TESTS = ["test_cluster_leak", "test_restart_persist", "test_package_cpfp", "test_no_expiry", "test_package_multi_parent", + "test_ephemeral_sibling", "test_backpressure", "test_fee_skip", "test_mempool_loss", "test_known_relayed", + "test_stale_restart", "test_mixed_dead_input", "test_crash_feed", "test_reorg_retrack", "test_gate_release", + "test_truc_sibling"] + +args = [a for a in sys.argv[1:] if not a.startswith("--")] +lanes = int(sys.argv[sys.argv.index("--lanes") + 1]) if "--lanes" in sys.argv else 2 +if "--lanes" in sys.argv: + args.remove(str(lanes)) +ecx = [args.pop(0)] if args and os.path.isfile(args[0]) else [] +chosen = [t.removesuffix(".py") for t in args] or TESTS + +def run(name): + t0 = time.time() + p = subprocess.run([sys.executable, os.path.join(HERE, name + ".py")] + ecx, capture_output=True, text=True, timeout=1800) + line = next((l for l in reversed(p.stdout.splitlines()) if l.startswith("RESULT")), (p.stdout + p.stderr).strip().splitlines()[-1:] or ["(no output)"]) + return name, p.returncode, line if isinstance(line, str) else line[0], time.time() - t0 + +with ThreadPoolExecutor(lanes) as pool: + results = list(pool.map(run, chosen)) +if not args: + print("measuring test_retry_cost (alone) ...", flush=True) + p = subprocess.run([sys.executable, os.path.join(HERE, "test_retry_cost.py")] + (ecx or ["-"]) + ["8000"], + capture_output=True, text=True, timeout=1800) + cost = next((l for l in reversed(p.stdout.splitlines()) if l.startswith("RESULT")), "RESULT: (no measurement)") +print() +for name, rc, line, secs in results: + print(f"{'PASS' if rc == 0 else 'FAIL'} {name:28s} {secs:5.0f}s {line.removeprefix('RESULT: ')[:110]}") +if not args: + print(f"COST {'test_retry_cost':28s} {cost.removeprefix('RESULT: ')[:110]}") +failed = [n for n, rc, _, _ in results if rc != 0] +print(f"\n{len(results) - len(failed)}/{len(results)} passed" + (f"; failed: {', '.join(failed)}" if failed else "")) +sys.exit(1 if failed else 0) diff --git a/contrib/bridge-tests/test_backpressure.py b/contrib/bridge-tests/test_backpressure.py new file mode 100755 index 000000000000..0e48c563daf8 --- /dev/null +++ b/contrib/bridge-tests/test_backpressure.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Nothing held is turned away when the queue is full: the feed pauses, and resumes as it drains. And what can +never clear does not hold the feed paused for ever. + +Phase 1. The held-weight limit is 6,000 WU and B's cluster limit 10. Five Bitcoin blocks each carry a +20-transaction chain, so ten of each are held (~4,400 WU a block). A queue that turns newcomers away once full +refuses part of block 2's tail, and with it the rest of that chain; blocks 3-5 likewise. With back-pressure the +feed stops after block 2, resumes as B mines the held chains in, and all 100 arrive. + +Phase 2. Twenty zero-fee transactions that nothing will ever pay for push the held weight past the limit and +pause the feed, so a later Bitcoin block with an ordinary transaction X waits. After BRIDGE_HELD_YOUNG_BLOCKS of +B's blocks they stop counting toward the pause (they are kept, with a warning), the feed resumes, and X arrives. + +Usage: test_backpressure.py [] [--compiled-cap] + --compiled-cap: the binary has the 6,000 WU limit built in (the before case), so -bridgeheldmaxweight is not passed +exit 0 = nothing lost and the feed never stuck, 1 = otherwise +""" +import sys, time +from bridgelib import Pair, wait_for, result + +LIMIT, YOUNG = 6000, 144 +argv = [a for a in sys.argv[1:] if not a.startswith("--")] +compiled = "--compiled-cap" in sys.argv +b_args = ["-limitclustercount=10"] + ([] if compiled else [f"-bridgeheldmaxweight={LIMIT}"]) +pair = Pair("backpressure", ecx=(argv[0] if argv else None), base=18680, b_args=b_args) +try: + pair.start() + B = pair.B + # ---- phase 1 -------------------------------------------------------------------------------------------- + chains = [pair.chain(20) for _ in range(5)] + txids = [t["txid"] for c in chains for t in c] + for c in chains: + pair.mine_bitcoin([t["hex"] for t in c]) + print(f"phase 1: five Bitcoin blocks of 20-transaction chains; cluster limit 10, held limit {LIMIT} WU") + for i in range(40): + if pair.present(txids) == 100: + break + pair.mine_ecash(1) + time.sleep(2) + n1 = pair.present(txids) + log = B.log() + print(f" {n1}/100 arrived after {i} B blocks; feed paused {log.count('pausing the Bitcoin feed')}x, " + f"transactions turned away {log.count('is NOT fed')}") + ok1 = n1 == 100 + + # ---- phase 2 -------------------------------------------------------------------------------------------- + mark = len(B.log()) + stuck = [] + for _ in range(20): + c = pair.coin() + stuck.append(pair.spend([c], [(pair.addr, c["amount"])])) # zero fee, no child: never clears + pair.mine_bitcoin([z["hex"] for z in stuck]) + time.sleep(8) + c = pair.coin() + x = pair.spend([c], [(pair.addr, c["amount"] - 0.0001)]) + pair.mine_bitcoin([x["hex"]]) + time.sleep(8) + waited = not B.has(x["txid"]) + print(f"phase 2: 20 zero-fee transactions held; X {'waits behind the pause' if waited else 'arrived at once (no pause)'}") + pair.mine_ecash(YOUNG + 1) # they age past the young window + ok2 = bool(wait_for(lambda: B.has(x["txid"]), "X to arrive once the stuck ones age out", timeout=90)) + tail = B.log()[mark:] + print(f" after {YOUNG + 1} B blocks: X {'arrived' if ok2 else 'still waiting'}; " + f"resumed {tail.count('resuming the Bitcoin feed')}x; parked warning {tail.count('without clearing')}x") + result(ok1 and ok2, f"phase 1 {n1}/100, phase 2 X {'arrived' if ok2 else 'stuck'}") +finally: + pair.stop() diff --git a/contrib/bridge-tests/test_cluster_leak.py b/contrib/bridge-tests/test_cluster_leak.py new file mode 100755 index 000000000000..ba8752fe9efa --- /dev/null +++ b/contrib/bridge-tests/test_cluster_leak.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +"""A chain longer than B's cluster limit arrives whole. + +A 40-transaction chain is mined into one Bitcoin block; B's cluster limit is 10. The mempool refuses the tail +"too-large-cluster". The shipped bridge held "too-long-mempool-chain" -- the pre-cluster-mempool string, never +emitted by this Core -- so the tail fell through to OTHER and was dropped for good, with everything later spending +it. Held and re-offered as B mines, all 40 arrive. + +Usage: test_cluster_leak.py [] exit 0 = 40/40, 1 = tail lost +""" +import sys, time +from bridgelib import Pair, wait_for, result + +pair = Pair("cluster-leak", ecx=(sys.argv[1] if len(sys.argv) > 1 else None), base=18740, + b_args=["-limitclustercount=10"]) +try: + pair.start() + chain = pair.chain(40) + txids = [t["txid"] for t in chain] + pair.wait_fed(pair.mine_bitcoin([t["hex"] for t in chain])) + wait_for(lambda: pair.present(txids) >= 10, "the first ten") + print(f"fed: {pair.present(txids)}/40 accepted, the rest refused too-large-cluster") + for i in range(6): + pair.mine_ecash(1) + time.sleep(3) + n = pair.present(txids) + print(f" after B block {i + 1}: {n}/40") + if n == 40: + break + n = pair.present(txids) + result(n == 40, f"{n}/40 -- " + ("the whole chain arrived" if n == 40 else "the tail was dropped")) +finally: + pair.stop() diff --git a/contrib/bridge-tests/test_crash_feed.py b/contrib/bridge-tests/test_crash_feed.py new file mode 100755 index 000000000000..c273a1d5e86c --- /dev/null +++ b/contrib/bridge-tests/test_crash_feed.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +"""A hard kill in the middle of a feed loses nothing and doubles nothing. + +Five Bitcoin blocks of 20-transaction chains (ten of each held behind a cluster limit of 10) are fed back to back, +faster than the queue is written (at most once a minute), and B is killed (SIGKILL) the moment the last is fed: no +shutdown, no save, no mempool.dat. On disk the anchor is never ahead of the queue, so B re-feeds the blocks since the +last write; what it already tracks is not fed twice. All 100 arrive, and nothing is left held once they have. + +Usage: test_crash_feed.py [] exit 0 = 100/100 and nothing left over, 1 = otherwise +""" +import re, sys, time +from bridgelib import Pair, wait_for, result + +pair = Pair("crash-feed", ecx=(sys.argv[1] if len(sys.argv) > 1 else None), base=18730, + b_args=["-limitclustercount=10"]) +try: + pair.start() + B = pair.B + B.rpc("gettxoutsetinfo", "none") # flush, so the kill rolls B back only to here + chains = [pair.chain(20) for _ in range(5)] + txids = [t["txid"] for c in chains for t in c] + hashes = [pair.mine_bitcoin([t["hex"] for t in c]) for c in chains] + pair.wait_fed(hashes[-1]) + B.kill() + print(f"fed 5 blocks, then SIGKILL; {sum(1 for h in hashes if f'bridge: Bitcoin block {h}' in B.log())}/5 had been fed") + B.start() + for i in range(40): + if pair.present(txids) == 100: + break + pair.mine_ecash(1) + time.sleep(2) + n = pair.present(txids) + pair.mine_ecash(2) + time.sleep(4) + last = [l for l in B.log().splitlines() if "still held" in l][-1].split("bridge: ")[-1] + m = re.search(r"(\d+) still held, (\d+) fed", last) + left = (int(m.group(1)), int(m.group(2))) if m else (-1, -1) + refed = sum(1 for h in hashes if B.log().count(f"bridge: Bitcoin block {h}") > 1) + print(f"after restart: {n}/100 arrived; {refed} block(s) fed again; last pass: {last[:140]}") + result(n == 100 and left == (0, 0), f"{n}/100, {left[0]} held and {left[1]} fed left over") +finally: + pair.stop() diff --git a/contrib/bridge-tests/test_ephemeral_sibling.py b/contrib/bridge-tests/test_ephemeral_sibling.py new file mode 100755 index 000000000000..713bcebd911f --- /dev/null +++ b/contrib/bridge-tests/test_ephemeral_sibling.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +"""A sibling of an ephemeral-dust spender must wait for the parent to confirm, not be dropped. + +P pays nothing and carries a zero-value pay-to-anchor output (ephemeral dust) next to two ordinary +outputs. C1 spends the anchor and output 0 and pays for both; C2 spends only output 1. All three are +in one Bitcoin block. On eCash P is below the fee floor, so C1 carries it in as a package. C2 is then +offered with P in the mempool, and Core refuses it with "missing-ephemeral-spends": while P is +unconfirmed, every child must spend P's dust, and C1 already did. That clears the moment P confirms -- +but a bridge that treats the refusal as permanent drops C2, and everything spending it, for good. + +Usage: test_ephemeral_sibling.py [] exit 0 = C2 arrived after P confirmed, 1 = lost +""" +import sys, time +from bridgelib import Pair, result + +P2A = "bcrt1pfeesnyr2tx" +pair = Pair("ephemeral-sibling", ecx=(sys.argv[1] if len(sys.argv) > 1 else None), base=18670) +try: + pair.start() + A, B = pair.A, pair.B + c = pair.coin() + addr2 = A.rpc("getnewaddress", wallet="w") + raw = A.rpc("createrawtransaction", [{"txid": c["txid"], "vout": c["vout"]}], + [{pair.addr: 25}, {addr2: round(c["amount"] - 25, 8)}, {P2A: 0}]) + p_hex = A.rpc("signrawtransactionwithwallet", raw, [], wallet="w")["hex"] + pd = A.rpc("decoderawtransaction", p_hex) + prev = lambda n: {"txid": pd["txid"], "vout": n, "scriptPubKey": pd["vout"][n]["scriptPubKey"]["hex"], + "amount": pd["vout"][n]["value"]} + c1_raw = A.rpc("createrawtransaction", [{"txid": pd["txid"], "vout": 0}, {"txid": pd["txid"], "vout": 2}], [{pair.addr: 24.998}]) + c1 = A.rpc("signrawtransactionwithwallet", c1_raw, [prev(0), prev(2)], wallet="w") + c2_raw = A.rpc("createrawtransaction", [{"txid": pd["txid"], "vout": 1}], [{pair.addr: 24.999}]) + c2 = A.rpc("signrawtransactionwithwallet", c2_raw, [prev(1)], wallet="w") + assert c1["complete"] and c2["complete"] + ids = {"P": pd["txid"], "C1": A.rpc("decoderawtransaction", c1["hex"])["txid"], "C2": A.rpc("decoderawtransaction", c2["hex"])["txid"]} + pair.wait_fed(pair.mine_bitcoin([p_hex, c1["hex"], c2["hex"]])) + time.sleep(6) + show = lambda: ", ".join(f"{k} {'in' if B.has(v) else 'MISSING'}" for k, v in ids.items()) + print("after the feed:", show()) + for i in range(4): + pair.mine_ecash(1) # confirms P and C1; the pass that follows re-offers C2 + time.sleep(5) + print(f" after B block {i + 1}: {show()}") + if all(B.has(v) for v in ids.values()): + break + refused = [l.split("bridge: ")[-1][:140] for l in B.log().splitlines() if "missing-ephemeral-spends" in l] + print(f" missing-ephemeral-spends seen {len(refused)}x") + ok = all(B.has(v) for v in ids.values()) + result(ok, "C2 waited for P to confirm, then went in" if ok else "C2 was dropped although its refusal clears once P confirms") +finally: + pair.stop() diff --git a/contrib/bridge-tests/test_fee_skip.py b/contrib/bridge-tests/test_fee_skip.py new file mode 100755 index 000000000000..643b7703a501 --- /dev/null +++ b/contrib/bridge-tests/test_fee_skip.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +"""A transaction refused for its fee is not offered again while its feerate is still under the floor. + +A fan-out F (pays a fee, 200 outputs) and 200 zero-fee spends of it, one per output, all in one Bitcoin block. +F goes in; each spend is held below the relay floor, which never moves, and nothing will ever carry them. +Re-offering all 200 on every pass is the retry-cost problem again, driven by fees instead of chains -- and on a +congested mempool the whole frontier looks like this. The check compares the transaction's own feerate with the +floor and nothing else, so the answer is known without asking. Measured as the 'offered' count per pass. + +Usage: test_fee_skip.py [] exit 0 = none re-offered after the first sight, 1 = re-offered +""" +import re, sys, time +from bridgelib import Pair, result + +N = 200 +pair = Pair("fee-skip", ecx=(sys.argv[1] if len(sys.argv) > 1 else None), base=18690) +try: + pair.start() + A, B = pair.A, pair.B + addrs = [r for r, _ in A.batch([("getnewaddress", []) for _ in range(N + 1)], wallet="w")] + c = pair.coin() + raw = A.rpc("createrawtransaction", [{"txid": c["txid"], "vout": c["vout"]}], + [{a: 0.2} for a in addrs[:N]] + [{addrs[N]: round(c["amount"] - 0.2 * N - 0.01, 8)}]) + f = A.rpc("signrawtransactionwithwallet", raw, [], wallet="w") + fd = A.rpc("decoderawtransaction", f["hex"]) + raws = A.batch([("createrawtransaction", [[{"txid": fd["txid"], "vout": i}], [{pair.addr: 0.2}]]) for i in range(N)]) + prevs = [[{"txid": fd["txid"], "vout": i, "scriptPubKey": fd["vout"][i]["scriptPubKey"]["hex"], "amount": 0.2}] for i in range(N)] + zs = A.batch([("signrawtransactionwithwallet", [r, p]) for (r, _), p in zip(raws, prevs)], wallet="w") + assert all(z["complete"] for z, _ in zs) + pair.wait_fed(pair.mine_bitcoin([f["hex"]] + [z["hex"] for z, _ in zs])) + time.sleep(4) + print(f"fed: F and {N} zero-fee spends of it; F {'in' if B.has(fd['txid']) else 'MISSING'}") + offered = [] + for i in range(3): + mark = len(B.log()) + pair.mine_ecash(1) + time.sleep(4) + line = next((l for l in B.log()[mark:].splitlines() if "retry pass" in l or "re-offered held" in l), "") + m = re.search(r"\((\d+) offered", line) + offered.append(int(m.group(1)) if m else -1) + print(f" pass after B block {i + 1}: {line.split('bridge: ')[-1][:120]}") + result(max(offered) == 0, f"offered per pass {offered} for {N} held under the fee floor") +finally: + pair.stop() diff --git a/contrib/bridge-tests/test_gate_release.py b/contrib/bridge-tests/test_gate_release.py new file mode 100755 index 000000000000..809ff2df4fe0 --- /dev/null +++ b/contrib/bridge-tests/test_gate_release.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +"""The catch-up gate does not stall the bridge when our chain will not get back to where it was. + +Nothing is judged while our chain is below the height the bridge last read at (after a crash it re-syncs up to it). +Here B invalidates one of its own recent blocks: its tip drops below that height, and no header of a valid chain +reaches it any more. A gate that waits for the height for ever feeds nothing again -- here, a new Bitcoin transaction +X. Released once no known header gets back there (after a short grace for headers to arrive), it feeds X. + +Usage: test_gate_release.py [] exit 0 = X arrived, 1 = the bridge stalled +""" +import sys, time +from bridgelib import Pair, wait_for, result + +pair = Pair("gate-release", ecx=(sys.argv[1] if len(sys.argv) > 1 else None), base=18780) +try: + pair.start() + B = pair.B + hashes = pair.mine_ecash(6) + time.sleep(4) # passes read up to the tip + B.rpc("invalidateblock", hashes[2]) # our tip drops 4 blocks, onto a branch that ends there + print(f"B invalidated its own block; tip now {B.rpc('getblockcount')}, 4 below where the bridge last read") + c = pair.coin() + x = pair.spend([c], [(pair.addr, c["amount"] - 0.0001)]) + pair.mine_bitcoin([x["hex"]]) + try: + wait_for(lambda: B.has(x["txid"]), "X to be fed", timeout=200) + ok = True + except AssertionError: + ok = False + log = B.log() + result(ok, ("X fed" if ok else "X never fed: the bridge is waiting for a height that will not come back") + + (" (gate released)" if "reading from here" in log else "")) +finally: + pair.stop() diff --git a/contrib/bridge-tests/test_known_relayed.py b/contrib/bridge-tests/test_known_relayed.py new file mode 100755 index 000000000000..b2deeca0d8a2 --- /dev/null +++ b/contrib/bridge-tests/test_known_relayed.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +"""A Bitcoin transaction that reached our mempool by another route is watched like our own. + +X is sent straight to B (sendrawtransaction) before Bitcoin mines it -- as another bridge, or ordinary relay, would +put it there. When Bitcoin's block arrives the bridge finds X already in B's mempool. Untracked, X is lost with the +mempool on a restart without mempool.dat, and Y, which spends it, then has missing inputs and is dropped. Tracked as +fed, X is fed again and Y follows. + +Usage: test_known_relayed.py [] exit 0 = both arrived, 1 = lost +""" +import sys, time +from bridgelib import Pair, wait_for, result + +pair = Pair("known-relayed", ecx=(sys.argv[1] if len(sys.argv) > 1 else None), base=18710, + b_args=["-persistmempool=0"]) +try: + pair.start() + B = pair.B + c = pair.coin() + x = pair.spend([c], [(pair.addr, c["amount"] - 0.0001)]) + B.rpc("sendrawtransaction", x["hex"]) # another route got there first + pair.wait_fed(pair.mine_bitcoin([x["hex"]])) + print("X relayed to B first; Bitcoin's block with X fed after it") + B.restart() + assert not B.has(x["txid"]), "the mempool survived the restart; the test proves nothing" + print("B restarted without its mempool: X is gone") + y = pair.spend([pair.out(x)], [(pair.addr, x["vout"][0]["amount"] - 0.0001)]) + pair.wait_fed(pair.mine_bitcoin([y["hex"]])) + for i in range(5): + time.sleep(5) + if B.has(x["txid"]) and B.has(y["txid"]): + break + pair.mine_ecash(1) + ok = B.has(x["txid"]) and B.has(y["txid"]) + result(ok, "X was fed again and Y followed it" if ok else + f"X {'back' if B.has(x['txid']) else 'lost'}, Y {'in' if B.has(y['txid']) else 'dropped'}") +finally: + pair.stop() diff --git a/contrib/bridge-tests/test_mempool_loss.py b/contrib/bridge-tests/test_mempool_loss.py new file mode 100755 index 000000000000..98033e96d434 --- /dev/null +++ b/contrib/bridge-tests/test_mempool_loss.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +"""A fed transaction that the node later loses must be fed again. + +`mempool.dat` is written only on a clean shutdown, so a crash, an OOM kill or a container stop that +outruns the shutdown leaves the node with an empty mempool -- here simulated exactly with +-persistmempool=0. X is fed and accepted; B restarts and X is gone; the next Bitcoin block carries Y, +which spends X. The anchor has moved past X's block, so it is never re-fed: X is lost, Y then has +missing inputs and is dropped, and so is everything that later spends either. Fixed: fed +transactions are tracked until they confirm, and one that vanishes is offered again. + +Usage: test_mempool_loss.py [] exit 0 = both arrived, 1 = lost +""" +import sys, time +from bridgelib import Pair, wait_for, result + +pair = Pair("mempool-loss", ecx=(sys.argv[1] if len(sys.argv) > 1 else None), base=18650, + b_args=["-persistmempool=0"]) +try: + pair.start() + B = pair.B + c = pair.coin() + x = pair.spend([c], [(pair.addr, c["amount"] - 0.0001)]) + pair.wait_fed(pair.mine_bitcoin([x["hex"]])) + wait_for(lambda: B.has(x["txid"]), "X accepted into B's mempool") + print("X fed and in B's mempool") + + B.restart() # -persistmempool=0: it comes back with nothing + assert not B.has(x["txid"]), "the mempool survived the restart; the test proves nothing" + print("B restarted without its mempool: X is gone") + + y = pair.spend([pair.out(x)], [(pair.addr, x["vout"][0]["amount"] - 0.0001)]) + pair.wait_fed(pair.mine_bitcoin([y["hex"]])) + print("Bitcoin mined Y, which spends X") + for i in range(5): + time.sleep(5) + have = (B.has(x["txid"]), B.has(y["txid"])) + print(f" attempt {i + 1}: X {'present' if have[0] else 'MISSING'}, Y {'present' if have[1] else 'MISSING'}") + if all(have): + break + pair.mine_ecash(1) + ok = B.has(x["txid"]) and B.has(y["txid"]) + result(ok, "X was fed again and Y followed it" if ok else "X was lost with the mempool, and Y was dropped as an orphan") +finally: + pair.stop() diff --git a/contrib/bridge-tests/test_mixed_dead_input.py b/contrib/bridge-tests/test_mixed_dead_input.py new file mode 100755 index 000000000000..9cf453fc5e1a --- /dev/null +++ b/contrib/bridge-tests/test_mixed_dead_input.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +"""A transaction with one input we expect and one that is dead can never go in: drop it, don't hold it. + +P is fed and sits in B's mempool. T spends P's output and a post-fork Bitcoin coinbase output, which never exists on +eCash (by fork+4 days a third of Bitcoin's transactions descend from one); U spends T. B reports T's inputs missing. A +rule that holds a transaction whenever one of its parents is tracked keeps T -- P is -- and re-offers it every pass +until P is mined, with U and the rest of the chain held behind it, counting toward the pause. Checked input by input, +the coinbase input is missing and untracked: T is dead, and so is U. + +Usage: test_mixed_dead_input.py [] exit 0 = T and U dropped at once, 1 = held +""" +import re, sys, time +from bridgelib import H, Pair, wait_for, result + +pair = Pair("mixed-dead", ecx=(sys.argv[1] if len(sys.argv) > 1 else None), base=18720) +try: + pair.start() + A, B = pair.A, pair.B + c = pair.coin() + p = pair.spend([c], [(pair.addr, c["amount"] - 0.0001)]) + pair.wait_fed(pair.mine_bitcoin([p["hex"]])) + wait_for(lambda: p["txid"] in B.mempool(), "P fed") + tip = A.rpc("getblockcount") + dead = next(u for u in A.rpc("listunspent", wallet="w") # a mature post-fork coinbase output + if 100 <= u["confirmations"] <= tip - H + 1) + d = {"txid": dead["txid"], "vout": dead["vout"], "amount": float(dead["amount"]), "spk": dead["scriptPubKey"]} + t = pair.spend([pair.out(p), d], [(pair.addr, p["vout"][0]["amount"] + d["amount"] - 0.0002)]) + u = pair.spend([pair.out(t)], [(pair.addr, t["vout"][0]["amount"] - 0.0001)]) + h = pair.mine_bitcoin([t["hex"], u["hex"]]) + pair.wait_fed(h) + line = next(l for l in B.log().splitlines() if f"bridge: Bitcoin block {h}" in l) + print("feed:", line.split("bridge: ")[-1][:160]) + time.sleep(4) + pair.mine_ecash(1) # a pass: anything held would still be held after it + time.sleep(4) + held_now = [l for l in B.log().splitlines() if "still held" in l] + last = held_now[-1].split("bridge: ")[-1] if held_now else "(no pass reported)" + m = re.search(r"(\d+) still held", last) + held_count = int(m.group(1)) if m else 0 + missing = int(re.search(r"(\d+) missing inputs", line).group(1)) + print("after a pass:", last[:160]) + result(missing == 2 and held_count == 0 and not B.has(t["txid"]) and not B.has(u["txid"]), + f"T and U: {missing} dropped as dead at the feed, {held_count} held afterwards") +finally: + pair.stop() diff --git a/contrib/bridge-tests/test_no_expiry.py b/contrib/bridge-tests/test_no_expiry.py new file mode 100755 index 000000000000..cfe260c0de42 --- /dev/null +++ b/contrib/bridge-tests/test_no_expiry.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +"""A held transaction must never expire while it can still be accepted. + +A 20-transaction chain is mined into one Bitcoin block and B's cluster limit (10) holds the tail. +B's clock then jumps 31 days (setmocktime) and a retry pass runs while the tail is still blocked. +A queue that gives up on anything held past 30 days drops the tail -- and the Bitcoin block is never +re-fed, so it is gone for good, although every one of those transactions was still valid. Fixed: +the tail stays held and arrives as soon as B mines. + +Usage: test_no_expiry.py [] exit 0 = tail survived, 1 = lost +""" +import sys, time +from bridgelib import Pair, wait_for, result + +pair = Pair("no-expiry", ecx=(sys.argv[1] if len(sys.argv) > 1 else None), base=18610, + b_args=["-limitclustercount=10"]) +try: + pair.start() + B = pair.B + chain = pair.chain(20) + txids = [t["txid"] for t in chain] + pair.wait_fed(pair.mine_bitcoin([t["hex"] for t in chain])) + wait_for(lambda: pair.present(txids) == 10, "10 accepted with the tail held") + print("fed: 10/20 in B's mempool, 10 held behind the cluster limit") + + # 31 days pass on B while the tail is still cluster-blocked; a retry pass must run before B mines, + # or the tail would simply be accepted and the test would prove nothing. + mark = len(B.log()) + B.rpc("setmocktime", int(time.time()) + 31 * 86400) + ran = wait_for(lambda: ("re-offered held Bitcoin transactions" in B.log()[mark:] + or "bridge: retry pass" in B.log()[mark:]), "a retry pass after the clock jump", timeout=150) + tail = B.log()[mark:] + dropped_line = next((l for l in tail.splitlines() if "re-offered held" in l), "") + print("retry pass after the jump:", (dropped_line.split("bridge: ")[-1] if dropped_line else "(nothing changed)")) + + for i in range(4): + pair.mine_ecash(1) + time.sleep(4) + print(f" after B block {i + 1}: {pair.present(txids)}/20") + if pair.present(txids) == 20: + break + n = pair.present(txids) + result(n == 20, f"{n}/20 -- the tail " + ("survived the 31-day wait" if n == 20 else f"was dropped as expired ({20 - n} lost)")) +finally: + pair.stop() diff --git a/contrib/bridge-tests/test_package_cpfp.py b/contrib/bridge-tests/test_package_cpfp.py new file mode 100755 index 000000000000..9c043de33bd7 --- /dev/null +++ b/contrib/bridge-tests/test_package_cpfp.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +"""A parent below the fee floor goes in with the child that pays for it. + +A zero-fee parent and its CPFP child are mined into one Bitcoin block (generateblock: no relay policy on the +Bitcoin side, as with a real package). Alone, the parent is refused "min relay fee not met" and the child then lacks +its input; both used to be lost. Offered together to ProcessNewPackage, both go in. + +Usage: test_package_cpfp.py [] exit 0 = both in, 1 = lost +""" +import sys, time +from bridgelib import Pair, result + +pair = Pair("package-cpfp", ecx=(sys.argv[1] if len(sys.argv) > 1 else None), base=18760) +try: + pair.start() + c = pair.coin() + parent = pair.spend([c], [(pair.addr, c["amount"])]) # zero fee + child = pair.spend([pair.out(parent)], [(pair.addr, c["amount"] - 0.001)]) # pays for both + pair.wait_fed(pair.mine_bitcoin([parent["hex"], child["hex"]])) + for i in range(4): + time.sleep(4) + if pair.present([parent["txid"], child["txid"]]) == 2: + break + pair.mine_ecash(1) + n = pair.present([parent["txid"], child["txid"]]) + result(n == 2, "the zero-fee parent and its child went in together as a package" if n == 2 else f"{n}/2 arrived") +finally: + pair.stop() diff --git a/contrib/bridge-tests/test_package_multi_parent.py b/contrib/bridge-tests/test_package_multi_parent.py new file mode 100755 index 000000000000..62dfc102126a --- /dev/null +++ b/contrib/bridge-tests/test_package_multi_parent.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +"""A child that pays for two parents below the fee floor must go in with both. + +Two zero-fee parents and one child spending an output of each -- the child's fee pays for all three +-- are mined into one Bitcoin block. Neither parent is acceptable alone, and a one-parent-one-child +package fails too: the child still lacks its other parent. Only [parent, parent, child] works, which +Core's ProcessNewPackage accepts (a child with up to 24 parents). Fixed: all three arrive. + +Usage: test_package_multi_parent.py [] exit 0 = all three arrived, 1 = lost +""" +import sys, time +from bridgelib import Pair, result + +pair = Pair("package-multi", ecx=(sys.argv[1] if len(sys.argv) > 1 else None), base=18620) +try: + pair.start() + c1, c2 = pair.coin(), pair.coin() + p1 = pair.spend([c1], [(pair.addr, c1["amount"])]) # zero fee + p2 = pair.spend([c2], [(pair.addr, c2["amount"])]) # zero fee + child = pair.spend([pair.out(p1), pair.out(p2)], [(pair.addr, c1["amount"] + c2["amount"] - 0.002)]) + txids = [p1["txid"], p2["txid"], child["txid"]] + pair.wait_fed(pair.mine_bitcoin([p1["hex"], p2["hex"], child["hex"]])) + print(f"mined two zero-fee parents and their child: {pair.present(txids)}/3 on B after the feed") + for i in range(5): + time.sleep(5) + n = pair.present(txids) + print(f" attempt {i + 1}: {n}/3") + if n == 3: + break + pair.mine_ecash(1) # a tip change re-triggers the retry pass + n = pair.present(txids) + pkg = [l.split("bridge: ")[-1] for l in pair.B.log().splitlines() if "in packages" in l and " 0 in packages" not in l] + for l in pkg[:2]: + print(" ", l) + result(n == 3, f"{n}/3 -- " + ("both parents went in with their child" if n == 3 else "lost: no one-parent package can carry them")) +finally: + pair.stop() diff --git a/contrib/bridge-tests/test_reorg_retrack.py b/contrib/bridge-tests/test_reorg_retrack.py new file mode 100755 index 000000000000..5226713e8c09 --- /dev/null +++ b/contrib/bridge-tests/test_reorg_retrack.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +"""A fed transaction mined here and then reorganised away is tracked again. + +X is fed, then mined into a B block, so the bridge stops tracking its body. B's chain then reorganises (the block is +invalidated and replaced by an empty one): X is back in the mempool but no longer tracked. B then restarts without +mempool.dat, losing X, and Bitcoin mines Y, which spends X. Untracked, X is never fed again and Y is dropped as an +orphan. Taken back from the reorganised-away block and tracked as fed, X is fed again and Y follows. + +Usage: test_reorg_retrack.py [] exit 0 = both arrived, 1 = lost +""" +import sys, time +from bridgelib import Pair, wait_for, result + +pair = Pair("reorg-retrack", ecx=(sys.argv[1] if len(sys.argv) > 1 else None), base=18770, + b_args=["-persistmempool=0"]) +try: + pair.start() + B = pair.B + c = pair.coin() + x = pair.spend([c], [(pair.addr, c["amount"] - 0.0001)]) + pair.wait_fed(pair.mine_bitcoin([x["hex"]])) + wait_for(lambda: x["txid"] in B.mempool(), "X fed") + mined = pair.mine_ecash(1)[0] + time.sleep(4) # a pass reads the block and retires X as mined + B.rpc("invalidateblock", mined) + B.rpc("generateblock", pair.b_addr, []) # an empty block in its place: X is not mined any more + time.sleep(5) # a pass sees the reorg + print(f"X mined, then reorganised away; X {'in' if x['txid'] in B.mempool() else 'NOT in'} B's mempool") + B.restart() # -persistmempool=0: X is lost (the bridge may re-feed it at once) + y = pair.spend([pair.out(x)], [(pair.addr, x["vout"][0]["amount"] - 0.0001)]) + pair.wait_fed(pair.mine_bitcoin([y["hex"]])) + for i in range(5): + time.sleep(4) + if B.has(x["txid"]) and B.has(y["txid"]): + break + pair.mine_ecash(1) + retracked = "tracked again" in B.log() + ok = B.has(x["txid"]) and B.has(y["txid"]) + result(ok, f"X {'back' if B.has(x['txid']) else 'lost'}, Y {'in' if B.has(y['txid']) else 'dropped'}" + + (" (re-tracked after the reorg)" if retracked else "")) +finally: + pair.stop() diff --git a/contrib/bridge-tests/test_restart_persist.py b/contrib/bridge-tests/test_restart_persist.py new file mode 100755 index 000000000000..11d297833123 --- /dev/null +++ b/contrib/bridge-tests/test_restart_persist.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +"""What is held survives a restart. + +A 40-transaction chain behind a cluster limit of 10: 10 accepted, 30 held. B is stopped and restarted before any +of the 30 can go in. A queue kept only in memory loses them, and the Bitcoin block is not fed again. Kept in +bridgeheld.dat, they are loaded back and arrive as B mines. + +Usage: test_restart_persist.py [] exit 0 = 40/40, 1 = lost +""" +import os, sys, time +from bridgelib import Pair, wait_for, result + +pair = Pair("restart-persist", ecx=(sys.argv[1] if len(sys.argv) > 1 else None), base=18750, + b_args=["-limitclustercount=10"]) +try: + pair.start() + B = pair.B + chain = pair.chain(40) + txids = [t["txid"] for t in chain] + pair.wait_fed(pair.mine_bitcoin([t["hex"] for t in chain])) + wait_for(lambda: pair.present(txids) >= 10, "the first ten") + B.restart() # a clean stop writes the queue + held_file = os.path.join(B.datadir, "regtest", "bridgeheld.dat") + print(f"fed {pair.present(txids)}/40, restarted; bridgeheld.dat {'present' if os.path.exists(held_file) else 'MISSING'}") + for i in range(6): + pair.mine_ecash(1) + time.sleep(3) + n = pair.present(txids) + print(f" after B block {i + 1}: {n}/40") + if n == 40: + break + n = pair.present(txids) + result(n == 40, f"{n}/40 -- " + ("the queue survived the restart" if n == 40 else "the held tail was lost")) +finally: + pair.stop() diff --git a/contrib/bridge-tests/test_retry_cost.py b/contrib/bridge-tests/test_retry_cost.py new file mode 100755 index 000000000000..95dda831fd09 --- /dev/null +++ b/contrib/bridge-tests/test_retry_cost.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""The retry pass must cost what can make progress, not the size of the backlog. + +A 3,000-transaction chain is mined into one Bitcoin block; B's cluster limit (10) admits 10 and +holds the rest. Each B block then frees room for the next ten. A pass that re-validates every held +transaction does ~3,000 mempool acceptance attempts per block to admit ten -- each one failing on a +missing input whose parent is plainly still held. Scaled to betanet's backlog of ~170,000 that is +seconds of the message-handler thread per block. Measured here as the gap between B connecting a +block (UpdateTip) and the pass that follows it finishing ("re-offered held"). + +Usage: test_retry_cost.py [] [chain-length] prints per-pass cost; exit 0 always +(the verdict is the number, compared across builds) +""" +import re, statistics, sys, time +from datetime import datetime +from bridgelib import Pair, wait_for + +N = int(sys.argv[2]) if len(sys.argv) > 2 else 3000 +pair = Pair("retry-cost", ecx=(sys.argv[1] if len(sys.argv) > 1 and sys.argv[1] != "-" else None), base=18640, + b_args=["-limitclustercount=10"], debug=()) +TS = re.compile(r"^(\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d+)Z") + +def ts(line): + m = TS.match(line) + return datetime.strptime(m.group(1), "%Y-%m-%dT%H:%M:%S.%f").timestamp() if m else None + +try: + pair.start() + t0 = time.time() + hexes = pair.chain_fast(N) + print(f"built a {N}-transaction chain in {time.time() - t0:.0f}s") + h = pair.mine_bitcoin(hexes) + pair.wait_fed(h, timeout=600) + feed = next(l for l in pair.B.log().splitlines() if f"bridge: Bitcoin block {h}" in l) + print("feed:", feed.split("bridge: ")[-1][:150]) + + costs = [] + for i in range(4): + mark = len(pair.B.log()) + pair.mine_ecash(1) + wait_for(lambda: "re-offered held Bitcoin transactions" in pair.B.log()[mark:], "the retry pass after the block", timeout=300) + tail = pair.B.log()[mark:].splitlines() + tip = next(ts(l) for l in tail if "UpdateTip" in l) + done_line = next(l for l in tail if "re-offered held Bitcoin transactions" in l) + gap = (ts(done_line) - tip) * 1000 + own = re.search(r"([0-9.]+) ms\)", done_line) # builds that time the pass report it themselves + cost = float(own.group(1)) if own else gap + costs.append(cost) + print(f" block {i + 1}: pass {cost:7.1f} ms ({'self-reported' if own else 'block-to-pass gap'}; gap {gap:.1f} ms)" + f" | {done_line.split('bridge: ')[-1][:95]}") + med = statistics.median(costs) + print(f"RESULT: median retry pass {med:.1f} ms for ~{N} held " + f"(~{med / N * 170000:.0f} ms of the message thread per block at betanet's ~170,000, if linear)") +finally: + pair.stop() diff --git a/contrib/bridge-tests/test_stale_restart.py b/contrib/bridge-tests/test_stale_restart.py new file mode 100755 index 000000000000..3716ff6182fc --- /dev/null +++ b/contrib/bridge-tests/test_stale_restart.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""A crash that rolls our chain back must not turn the children of lately mined transactions into orphans. + +Core flushes the chainstate every 50-70 minutes, so a node killed hard restarts where it last flushed -- blocks +behind -- and with an empty mempool. Here B's chainstate is flushed; P (fed earlier) is then mined into a B block; +C, which spends P, is fed and sits in B's mempool. B is killed (SIGKILL) and restarts one block back, where P is +neither mined nor in its mempool, and C is gone. A second eCash node, B2, still has the lost block, as peers do. +Untracked, C is simply lost. Tracked, but judged before B has caught up, C's input looks missing and C is dropped for +good. Fixed: nothing is judged until B is back at the height it had reached; then C is fed again and goes in. + +Usage: test_stale_restart.py [] exit 0 = C came back, 1 = lost +""" +import os, sys, time +from bridgelib import H, Node, Pair, wait_for, result + +pair = Pair("stale-restart", ecx=(sys.argv[1] if len(sys.argv) > 1 else None), base=18700, + b_args=["-listen=1", "-bind=127.0.0.1"]) +b2 = Node("B2", pair.ecx, os.path.join(pair.root, "b2"), 18705, 18706, + ["-listen=0", f"-connect=127.0.0.1:{pair.b_p2p}", f"-ecashheight={H}", "-walletbroadcast=0"]) +try: + pair.start() + b2.start() + B = pair.B + wait_for(lambda: b2.rpc("getbestblockhash") == B.rpc("getbestblockhash"), "B2 to follow B") + + c = pair.coin() + p = pair.spend([c], [(pair.addr, c["amount"] - 0.0001)]) + pair.wait_fed(pair.mine_bitcoin([p["hex"]])) + wait_for(lambda: p["txid"] in B.mempool(), "P fed") + B.rpc("gettxoutsetinfo", "none") # flush: this is where a crash will roll B back to + h0 = B.rpc("getblockcount") + pair.mine_ecash(1) # P is mined at h0+1 + wait_for(lambda: b2.rpc("getblockcount") == h0 + 1, "B2 to have the block that will be lost") + time.sleep(3) # a pass sees P mined + + ch = pair.spend([pair.out(p)], [(pair.addr, p["vout"][0]["amount"] - 0.0001)]) + pair.wait_fed(pair.mine_bitcoin([ch["hex"]])) + wait_for(lambda: ch["txid"] in B.mempool(), "C fed") + print(f"P mined at {h0 + 1}, C (spends P) fed and in B's mempool; B last flushed at {h0}") + + B.kill() + B.start() + back = B.rpc("getblockcount") + print(f"B killed and restarted at height {back}" + (" -- one block back, P unmined, mempool empty" if back == h0 else " (not rolled back; inconclusive)")) + wait_for(lambda: B.rpc("getblockcount") >= h0 + 1, "B to recover the lost block from B2", timeout=120) + ok = bool(wait_for(lambda: B.has(ch["txid"]), "C to be fed again", timeout=60)) + refed = sum(1 for l in B.log().splitlines() if "fed again after being lost" in l and " 0 fed again" not in l) + result(back == h0 and ok, f"C {'is back' if ok else 'was lost'} after a restart {h0 + 1 - back} block(s) behind " + f"({refed} pass(es) fed something again)") +except AssertionError as e: + result(False, str(e)) +finally: + b2.stop() + pair.stop() diff --git a/contrib/bridge-tests/test_truc_sibling.py b/contrib/bridge-tests/test_truc_sibling.py new file mode 100755 index 000000000000..4fb039a165b5 --- /dev/null +++ b/contrib/bridge-tests/test_truc_sibling.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +"""A version 3 child refused only because its parent's one child slot is taken waits; it is not dropped. + +P (v3) and its child C1 are mined in one Bitcoin block; C2 (v3), spending another output of P, in the next. On +Bitcoin that is fine: P was mined. On eCash P and C1 may still be unconfirmed when C2 arrives, and TRUC allows a v3 +transaction one unconfirmed child, so C2 could only get in by evicting its sibling C1, and pays too little to. Core +refuses it "insufficient fee (including sibling eviction)". Nothing conflicts with C2 -- it clears the moment P is +mined here -- but read as a lost conflict it is dropped for good. Held, it goes in once B mines P. + +Usage: test_truc_sibling.py [] exit 0 = C2 arrived, 1 = dropped +""" +import sys, time +from bridgelib import Pair, wait_for, result + +pair = Pair("truc-sibling", ecx=(sys.argv[1] if len(sys.argv) > 1 else None), base=18790) +try: + pair.start() + A, B = pair.A, pair.B + c = pair.coin() + a2 = A.rpc("getnewaddress", wallet="w") + p = pair.spend([c], [(pair.addr, 25), (a2, round(c["amount"] - 25 - 0.001, 8))], version=3) + c1 = pair.spend([pair.out(p, 0)], [(pair.addr, 25 - 0.002)], version=3) # the child that pays well + c2 = pair.spend([pair.out(p, 1)], [(pair.addr, p["vout"][1]["amount"] - 0.0001)], version=3) # a sibling paying less + pair.wait_fed(pair.mine_bitcoin([p["hex"], c1["hex"]])) + wait_for(lambda: c1["txid"] in B.mempool(), "P and C1 fed") + h = pair.mine_bitcoin([c2["hex"]]) + pair.wait_fed(h) + line = next(l for l in B.log().splitlines() if f"bridge: Bitcoin block {h}" in l) + print("C2's block:", line.split("bridge: ")[-1][:150]) + for i in range(4): + pair.mine_ecash(1) # mines P and C1; the next pass offers C2 again + time.sleep(4) + if B.has(c2["txid"]): + break + ok = B.has(c2["txid"]) + result(ok, "C2 waited for P to be mined, then went in" if ok else "C2 was dropped as if it had lost a conflict") +finally: + pair.stop() From bd48f30f8c956d5fdef683f7fcc4137cfc8a0df6 Mon Sep 17 00:00:00 2001 From: mblowes Date: Wed, 7 Oct 2026 15:57:41 +1100 Subject: [PATCH 13/15] bridge: drop a zero-fee transaction whose fee payer can never go in 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) Claude-Session: https://claude.ai/code/session_0136NHdQjigzYu7QGnAhskQb --- contrib/bridge-tests/README.md | 1 + contrib/bridge-tests/run_all.py | 2 +- contrib/bridge-tests/test_unpaid_anchor.py | 91 ++++++++++++++++++++++ src/net_processing.cpp | 79 +++++++++++++++++++ 4 files changed, 172 insertions(+), 1 deletion(-) create mode 100755 contrib/bridge-tests/test_unpaid_anchor.py diff --git a/contrib/bridge-tests/README.md b/contrib/bridge-tests/README.md index 442c25349cb0..330f1fe345a3 100644 --- a/contrib/bridge-tests/README.md +++ b/contrib/bridge-tests/README.md @@ -30,6 +30,7 @@ its own ports, so `run_all.py` runs two at a time. Pass one or more test names t | `test_reorg_retrack` | a fed transaction mined here and then reorganised away is tracked again | lost | in | | `test_gate_release` | the catch-up gate releases when our chain will not get back to where it was | stalled | fed | | `test_truc_sibling` | a v3 child refused only for its parent's one child slot waits, not dropped | 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` | a pass costs what can progress, not the backlog (8,000 held) | 401 ms | ~7 ms | "Before" is the build just before the commit that fixes it; `test_backpressure` uses that build with its limit compiled diff --git a/contrib/bridge-tests/run_all.py b/contrib/bridge-tests/run_all.py index 8098c9cf8663..a18b930aa99c 100755 --- a/contrib/bridge-tests/run_all.py +++ b/contrib/bridge-tests/run_all.py @@ -12,7 +12,7 @@ TESTS = ["test_cluster_leak", "test_restart_persist", "test_package_cpfp", "test_no_expiry", "test_package_multi_parent", "test_ephemeral_sibling", "test_backpressure", "test_fee_skip", "test_mempool_loss", "test_known_relayed", "test_stale_restart", "test_mixed_dead_input", "test_crash_feed", "test_reorg_retrack", "test_gate_release", - "test_truc_sibling"] + "test_truc_sibling", "test_unpaid_anchor"] args = [a for a in sys.argv[1:] if not a.startswith("--")] lanes = int(sys.argv[sys.argv.index("--lanes") + 1]) if "--lanes" in sys.argv else 2 diff --git a/contrib/bridge-tests/test_unpaid_anchor.py b/contrib/bridge-tests/test_unpaid_anchor.py new file mode 100755 index 000000000000..6b072eab0997 --- /dev/null +++ b/contrib/bridge-tests/test_unpaid_anchor.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""A zero-fee transaction whose fee payer can never go in is unpaid: drop it and what waits behind it, don't hold it. + +T pays nothing and carries a zero-value pay-to-anchor output next to two ordinary ones -- the shape of a pre-signed +transaction whose fee is settled at broadcast. On Bitcoin, H spent the anchor and paid for both, adding a post-fork +coinbase output to cover the fee; C and D spend T's other outputs. All four are in one Bitcoin block. On eCash H can +never go in (that coinbase output never exists here), and nothing else can carry T: policy admits a transaction with +dust only at zero fee, prioritisetransaction or not, and only together with a child that spends the dust. A bridge +that holds T for its fee keeps T, C and D forever: re-offered, saved across restarts, counted in the queue. (On +betanet, 2026-10-07: 35 such transactions with 1,870 waiting behind them.) + +Second, later: the fee payer H2 is held behind T2 because its other input, from Y, was still expected; then Y loses +to a conflicting eCash spend Y' and is dropped. H2 now fails its package with T2 for good, so it goes, and T2 with it. + +Usage: test_unpaid_anchor.py [] exit 0 = both unpaid trees dropped, 1 = held +""" +import re, sys, time +from bridgelib import H, Pair, wait_for, result + +P2A = "bcrt1pfeesnyr2tx" +pair = Pair("unpaid-anchor", ecx=(sys.argv[1] if len(sys.argv) > 1 else None), base=18800) +try: + pair.start() + A, B = pair.A, pair.B + c = pair.coin() + tip = A.rpc("getblockcount") + dead = next(u for u in A.rpc("listunspent", wallet="w") # a mature post-fork coinbase output + if 100 <= u["confirmations"] <= tip - H + 1) + d = {"txid": dead["txid"], "vout": dead["vout"], "amount": float(dead["amount"]), "spk": dead["scriptPubKey"]} + half = round(c["amount"] / 2, 8) + t = pair.spend([c], [(pair.addr, half), (A.rpc("getnewaddress", wallet="w"), round(c["amount"] - half, 8)), (P2A, 0)], + version=3) + assert t["vout"][2]["spk"] == "51024e73" + ch = pair.spend([pair.out(t, 0)], [(pair.addr, half), (P2A, 0)], version=3) # one level down the tree + dd = pair.spend([pair.out(t, 1)], [(pair.addr, t["vout"][1]["amount"] - 0.0001)]) # pays its own fee + h = pair.spend([pair.out(t, 2), d], [(pair.addr, d["amount"] - 0.0001)], version=3) # the fee payer + ids = {"T": t["txid"], "C": ch["txid"], "D": dd["txid"], "H": h["txid"]} + blk = pair.mine_bitcoin([t["hex"], ch["hex"], dd["hex"], h["hex"]]) + pair.wait_fed(blk) + line = next(l for l in B.log().splitlines() if f"bridge: Bitcoin block {blk}" in l) + print("feed:", line.split("bridge: ")[-1][:170]) + time.sleep(4) + pair.mine_ecash(1) # a pass: anything held would still be held after it + time.sleep(4) + held_now = [l for l in B.log().splitlines() if "still held" in l] + last = held_now[-1].split("bridge: ")[-1] if held_now else "(no pass reported)" + m = re.search(r"(\d+) still held", last) + held_count = int(m.group(1)) if m else 0 + unpaid = [l.split("bridge: ")[-1] for l in B.log().splitlines() if "fee payer can never go in" in l] + print("after a pass:", last[:160]) + print("unpaid:", unpaid[-1][:160] if unpaid else "(none reported)") + present = [k for k, v in ids.items() if B.has(v)] + first = held_count == 0 and not present and len(unpaid) == 1 + print("first:", "T dropped as unpaid with C and D" if first else f"{held_count} held, in B: {present}") + + # Second: Y is fed, then replaced in B's mempool by Y' (a conflicting eCash spend of the same coin, paying more). + k = pair.coin() + y = pair.spend([k], [(pair.addr, k["amount"] - 0.0001)]) + pair.wait_fed(pair.mine_bitcoin([y["hex"]])) + wait_for(lambda: B.has(y["txid"]), "Y fed") + y2 = pair.spend([k], [(A.rpc("getnewaddress", wallet="w"), k["amount"] - 0.001)]) + B.rpc("sendrawtransaction", y2["hex"]) # eCash spent the coin differently: Y can never go in + assert B.has(y2["txid"]) and not B.has(y["txid"]) + c2 = pair.coin() + t2 = pair.spend([c2], [(pair.addr, half), (A.rpc("getnewaddress", wallet="w"), round(c2["amount"] - half, 8)), (P2A, 0)], + version=3) + ch2 = pair.spend([pair.out(t2, 0)], [(pair.addr, half), (P2A, 0)], version=3) + dd2 = pair.spend([pair.out(t2, 1)], [(pair.addr, t2["vout"][1]["amount"] - 0.0001)]) + h2 = pair.spend([pair.out(t2, 2), pair.out(y)], [(pair.addr, y["vout"][0]["amount"] - 0.0001)], version=3) + ids2 = {"T2": t2["txid"], "C2": ch2["txid"], "D2": dd2["txid"], "H2": h2["txid"]} + blk2 = pair.mine_bitcoin([t2["hex"], ch2["hex"], dd2["hex"], h2["hex"]]) + pair.wait_fed(blk2) + line2 = next(l for l in B.log().splitlines() if f"bridge: Bitcoin block {blk2}" in l) + print("feed:", line2.split("bridge: ")[-1][:170]) + for _ in range(60): # the pass after the feed; held, it never comes + unpaid = [l.split("bridge: ")[-1] for l in B.log().splitlines() if "fee payer can never go in" in l] + if len(unpaid) >= 2: + break + time.sleep(1) + print("unpaid:", unpaid[-1][:160] if len(unpaid) >= 2 else "(not reported)") + pair.mine_ecash(1) + time.sleep(4) + present2 = [k for k, v in ids2.items() if B.has(v)] + held_now = [l for l in B.log().splitlines() if "still held" in l] + m = re.search(r"(\d+) still held", held_now[-1]) if held_now else None + held2 = int(m.group(1)) if m else 0 + second = held2 == 0 and not present2 and len(unpaid) >= 2 and "and 2 waiting" in unpaid[-1] + result(first and second, ("T, C and D dropped as unpaid at the feed; " if first else "first tree held; ") + + ("T2, C2 and D2 dropped once H2's other input died" if second else f"second: {held2} held, in B: {present2}")) +finally: + pair.stop() diff --git a/src/net_processing.cpp b/src/net_processing.cpp index 9311e3b402ed..df2cfdaf4fe8 100644 --- a/src/net_processing.cpp +++ b/src/net_processing.cpp @@ -790,6 +790,11 @@ class PeerManagerImpl final : public PeerManager EXCLUSIVE_LOCKS_REQUIRED(g_msgproc_mutex); /** Bridge: whether we track `txid`: waiting, fed, or mined here lately. */ bool BridgeTracked(const Txid& txid) const EXCLUSIVE_LOCKS_REQUIRED(NetEventsInterface::g_msgproc_mutex); + /** Bridge: `tx` can never go in; note the outputs of waiting transactions it spends. */ + void BridgeNoteDeadSpends(const CTransaction& tx) EXCLUSIVE_LOCKS_REQUIRED(NetEventsInterface::g_msgproc_mutex); + /** Bridge: drop each waiting transaction whose dust (an anchor) was spent by one that can never go in -- its fee + * payer -- and everything waiting behind it. */ + void BridgeDropUnpaid(int tip_height) EXCLUSIVE_LOCKS_REQUIRED(NetEventsInterface::g_msgproc_mutex); BridgeInputs BridgeClassifyInputs(const CTransaction& tx) EXCLUSIVE_LOCKS_REQUIRED(NetEventsInterface::g_msgproc_mutex, cs_main); /** Bridge: whether the node is caught up enough to judge Bitcoin transactions at all. */ bool BridgeReady(int tip_height, int best_header_height) EXCLUSIVE_LOCKS_REQUIRED(NetEventsInterface::g_msgproc_mutex); @@ -993,6 +998,8 @@ class PeerManagerImpl final : public PeerManager std::chrono::microseconds m_bridge_fee_rechecked_at GUARDED_BY(NetEventsInterface::g_msgproc_mutex){0us}; /** Bridge: a fed block left a transaction below the fee floor; retry at once so a child can carry it. */ bool m_bridge_retry_now GUARDED_BY(NetEventsInterface::g_msgproc_mutex){false}; + /** Bridge: outputs of WAITING transactions that a transaction which can never go in spends, for BridgeDropUnpaid. */ + std::map> m_bridge_dead_spends GUARDED_BY(NetEventsInterface::g_msgproc_mutex); /** Bridge: the Bitcoin peer fetching blocks. Others only follow headers until it leaves, stalls or idles. */ NodeId m_bridge_feed_peer GUARDED_BY(cs_main){-1}; /** Bridge: recently fed block hashes, so a peer that learns of one later does not queue it again. */ @@ -3377,6 +3384,67 @@ bool PeerManagerImpl::BridgeTracked(const Txid& txid) const return m_bridge_held_txids.contains(txid) || m_bridge_fed_txids.contains(txid) || m_bridge_recent_txids.contains(txid); } +void PeerManagerImpl::BridgeNoteDeadSpends(const CTransaction& tx) +{ + AssertLockHeld(g_msgproc_mutex); + for (const CTxIn& in : tx.vin) { + if (m_bridge_held_txids.contains(in.prevout.hash)) m_bridge_dead_spends[in.prevout.hash].push_back(in.prevout.n); + } +} + +void PeerManagerImpl::BridgeDropUnpaid(int tip_height) +{ + AssertLockHeld(g_msgproc_mutex); + // 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 we hold can carry the + // transaction: it is unpaid, and would wait forever with everything behind it. (Anyone can still put it in here + // with a new fee payer: an anchor is spendable by anyone.) Dropped at once, not at the next pass, so that no save + // of the queue can keep it after the fee payer is gone. + int unpaid{0}, behind{0}; + while (!m_bridge_dead_spends.empty()) { + std::map> spends; + std::swap(spends, m_bridge_dead_spends); + std::unordered_set gone; + std::deque kept; + for (BridgeHeld& entry : m_bridge_held_txs) { + const CTransaction& tx{*entry.tx}; + bool drop{false}; + if (entry.state == BridgeState::WAITING) { + if (const auto it{spends.find(tx.GetHash())}; it != spends.end()) { + for (const uint32_t n : it->second) { + if (n < tx.vout.size() && IsDust(tx.vout[n], m_mempool.m_opts.dust_relay_feerate)) drop = true; + } + if (drop) ++unpaid; + } + if (!drop && std::any_of(tx.vin.begin(), tx.vin.end(), [&](const CTxIn& in) { return gone.contains(in.prevout.hash); })) { + drop = true; + ++behind; + } + } + if (!drop) { + kept.push_back(std::move(entry)); + continue; + } + gone.insert(tx.GetHash()); + m_bridge_held_txids.erase(tx.GetHash()); + BridgeNoteDeadSpends(tx); // it may in turn have been the fee payer of another waiting transaction + } + m_bridge_held_txs = std::move(kept); + } + if (unpaid + behind == 0) return; + m_bridge_held_weight = 0; + m_bridge_young_weight = 0; + for (const BridgeHeld& entry : m_bridge_held_txs) { + if (entry.state != BridgeState::WAITING) continue; + m_bridge_held_weight += entry.weight; + if (entry.since_height > tip_height - BRIDGE_HELD_YOUNG_BLOCKS) m_bridge_young_weight += entry.weight; + } + m_bridge_held_dirty = true; + LogInfo("bridge: dropped %d held Bitcoin transactions whose fee payer can never go in, and %d waiting behind them\n", + unpaid, behind); +} + PeerManagerImpl::BridgeInputs PeerManagerImpl::BridgeClassifyInputs(const CTransaction& tx) { AssertLockHeld(g_msgproc_mutex); @@ -3583,6 +3651,11 @@ void PeerManagerImpl::BridgeRetryHeldTxs(std::chrono::microseconds now) if (in.contains(txid)) { next = BridgeState::FED; ++packaged; + } else if (WITH_LOCK(cs_main, return BridgeClassifyInputs(*tx)) == BridgeInputs::DEAD) { + // Another input has since gone (a parent dropped): it can never go in, nor pay for them. + next.reset(); + ++dropped; + BridgeNoteDeadSpends(*tx); } } } else if (!was_fed && !recheck_fees && entry.feerate && *entry.feerate < floor && !m_mempool.exists(txid)) { @@ -3620,6 +3693,7 @@ void PeerManagerImpl::BridgeRetryHeldTxs(std::chrono::microseconds now) case BridgeTxResult::MISSING: case BridgeTxResult::OTHER: ++dropped; + BridgeNoteDeadSpends(*tx); break; } } @@ -3638,6 +3712,7 @@ void PeerManagerImpl::BridgeRetryHeldTxs(std::chrono::microseconds now) held.push_back(std::move(entry)); } m_bridge_held_txs = std::move(held); + if (!m_bridge_dead_spends.empty()) BridgeDropUnpaid(tip_height); m_bridge_held_weight = 0; m_bridge_young_weight = 0; std::optional oldest; @@ -3731,6 +3806,7 @@ bool PeerManagerImpl::BridgeProcessBlock(CNode& pfrom, Peer& peer, const std::sh } if (inputs == BridgeInputs::DEAD) { ++missing; + BridgeNoteDeadSpends(*tx); continue; } if (inputs == BridgeInputs::WAITS) { @@ -3753,6 +3829,7 @@ bool PeerManagerImpl::BridgeProcessBlock(CNode& pfrom, Peer& peer, const std::sh break; case BridgeTxResult::MISSING: ++missing; + BridgeNoteDeadSpends(*tx); break; case BridgeTxResult::HELD: ++held; @@ -3764,9 +3841,11 @@ bool PeerManagerImpl::BridgeProcessBlock(CNode& pfrom, Peer& peer, const std::sh case BridgeTxResult::OTHER: ++other; ++reasons[reason]; + BridgeNoteDeadSpends(*tx); break; } } + if (!m_bridge_dead_spends.empty()) BridgeDropUnpaid(tip_height); std::string detail; for (const auto& [reason, n] : reasons) detail += strprintf(" %s=%d", reason, n); LogInfo("bridge: Bitcoin block %s (height %d, peer=%d): %u tx, %d accepted, %d already known, %d missing inputs, %d held, %d other%s\n", From d84b6530fe292ef4ceed41773dbe99b3cc22e9e4 Mon Sep 17 00:00:00 2001 From: mblowes Date: Wed, 7 Oct 2026 15:57:41 +1100 Subject: [PATCH 14/15] contrib: test_mempool_loss no longer races the re-feed it tests 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) Claude-Session: https://claude.ai/code/session_0136NHdQjigzYu7QGnAhskQb --- contrib/bridge-tests/test_mempool_loss.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/contrib/bridge-tests/test_mempool_loss.py b/contrib/bridge-tests/test_mempool_loss.py index 98033e96d434..50f5bd0289c1 100755 --- a/contrib/bridge-tests/test_mempool_loss.py +++ b/contrib/bridge-tests/test_mempool_loss.py @@ -10,7 +10,7 @@ Usage: test_mempool_loss.py [] exit 0 = both arrived, 1 = lost """ -import sys, time +import re, sys, time from bridgelib import Pair, wait_for, result pair = Pair("mempool-loss", ecx=(sys.argv[1] if len(sys.argv) > 1 else None), base=18650, @@ -24,9 +24,16 @@ wait_for(lambda: B.has(x["txid"]), "X accepted into B's mempool") print("X fed and in B's mempool") + logged = len(B.log()) B.restart() # -persistmempool=0: it comes back with nothing - assert not B.has(x["txid"]), "the mempool survived the restart; the test proves nothing" - print("B restarted without its mempool: X is gone") + if B.has(x["txid"]): + # The bridge's first pass after startup can feed X again before we look. Then the log since the restart says + # so; if it doesn't, the mempool survived and the test would prove nothing. + refed = [int(n) for n in re.findall(r"(\d+) fed again after being lost", B.log()[logged:])] + assert refed and max(refed) >= 1, "the mempool survived the restart; the test proves nothing" + print("B restarted without its mempool; the bridge has already fed X again") + else: + print("B restarted without its mempool: X is gone") y = pair.spend([pair.out(x)], [(pair.addr, x["vout"][0]["amount"] - 0.0001)]) pair.wait_fed(pair.mine_bitcoin([y["hex"]])) From 3fdf04651d5ff35bfcfa1127ca3adb466de9b311 Mon Sep 17 00:00:00 2001 From: mblowes Date: Wed, 7 Oct 2026 21:38:45 +1100 Subject: [PATCH 15/15] contrib: test_known_relayed no longer races the re-feed it tests 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) Claude-Session: https://claude.ai/code/session_0136NHdQjigzYu7QGnAhskQb --- contrib/bridge-tests/test_known_relayed.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/contrib/bridge-tests/test_known_relayed.py b/contrib/bridge-tests/test_known_relayed.py index b2deeca0d8a2..f0359b99fd65 100755 --- a/contrib/bridge-tests/test_known_relayed.py +++ b/contrib/bridge-tests/test_known_relayed.py @@ -8,7 +8,7 @@ Usage: test_known_relayed.py [] exit 0 = both arrived, 1 = lost """ -import sys, time +import re, sys, time from bridgelib import Pair, wait_for, result pair = Pair("known-relayed", ecx=(sys.argv[1] if len(sys.argv) > 1 else None), base=18710, @@ -21,9 +21,16 @@ B.rpc("sendrawtransaction", x["hex"]) # another route got there first pair.wait_fed(pair.mine_bitcoin([x["hex"]])) print("X relayed to B first; Bitcoin's block with X fed after it") + logged = len(B.log()) B.restart() - assert not B.has(x["txid"]), "the mempool survived the restart; the test proves nothing" - print("B restarted without its mempool: X is gone") + if B.has(x["txid"]): + # The bridge's first pass after startup can feed X again before we look. Then the log since the restart says + # so; if it doesn't, the mempool survived and the test would prove nothing. + refed = [int(n) for n in re.findall(r"(\d+) fed again after being lost", B.log()[logged:])] + assert refed and max(refed) >= 1, "the mempool survived the restart; the test proves nothing" + print("B restarted without its mempool; the bridge has already fed X again") + else: + print("B restarted without its mempool: X is gone") y = pair.spend([pair.out(x)], [(pair.addr, x["vout"][0]["amount"] - 0.0001)]) pair.wait_fed(pair.mine_bitcoin([y["hex"]])) for i in range(5):