# How does an instance detect UTXO-set divergence between its mempool view and the canonical chain during a reorg without trusting a single peer's `getrawtransaction` response **Date of Record:** 2026 November 9 **Scope:** Applies to full-node-backed wallet instances that maintain a local mempool view and reconcile against a canonical chain tip via P2P (not a trusted indexer or Electrum server). Does not apply to SPV/light clients, which lack mempool visibility, nor to wallets that rely on a single trusted backend whose chain state is authoritative by construction. ## Core Distinction The failure mode is not "the wallet sees a stale balance." That is benign and self-correcting on the next block. The failure mode is **silent divergence between the wallet's mempool-derived spendable set and the chain-derived UTXO set during a reorg window**, where both views are individually internally consistent but mutually contradictory, and where no single `getrawtransaction` call can adjudicate because the peer answering may be on either side of the fork. The distinction that matters operationally: a wallet's spendable-set computation is a *join* of (a) confirmed UTXOs from its chain view and (b) unconfirmed outputs from its mempool view, minus (c) spends observed in either. A reorg invalidates (a) and (b) asymmetrically. Confirmed UTXOs that were reorged out are still in the wallet's local chain view until the new tip is fully connected; mempool transactions that conflicted with the old tip may be re-admitted to the new mempool or may be evicted. The join can therefore produce a UTXO that is *spent in one view and unspent in the other*, and the wallet will happily construct a transaction spending it — which the network rejects with `bad-txns-inputs-missingorspent` only after the wallet has already broadcast a signed transaction and (worse) potentially marked the input as spent locally. ## What Was Observed During a two-block reorg on a regtest-turned-signet mirror, a wallet instance holding a confirmed 0.5 BTC UTXO at height N observed the following sequence: 1. At height N, the UTXO was confirmed and spendable. A mempool transaction T spent it, paying back to the wallet as change. The wallet's local view marked the original UTXO spent and the change output unconfirmed-but-spendable. 2. A reorg to height N-2 removed the block containing the original UTXO's confirmation. The UTXO was no longer confirmed. 3. The wallet's mempool view still contained T (the peer had not yet evicted it; eviction is not instantaneous and is peer-dependent). T's input referenced a now-unconfirmed parent — a *mempool chain* the wallet had not modeled. 4. The wallet's spendable set computed: original UTXO unconfirmed (from step 2), change output unconfirmed (from T, step 3), and — critically — the original UTXO was *not* marked as spent by T in the chain view because T was not in the chain view. The spendable set therefore contained **both** the original UTXO and its change output, double-counting 0.5 BTC. A subsequent `sendtoaddress` selected the original UTXO. Broadcast failed with `bad-txns-inputs-missingorspent` on three of four peers; the fourth peer, still on the old tip, accepted it into its mempool. The wallet recorded the transaction as broadcast. The non-obvious part: the wallet's *balance* was correct throughout (the double-count was masked because the change output was also unconfirmed and the wallet's balance display excluded unconfirmed change from its "confirmed" figure). Only the *coin-selection* path saw the double-count. ## Condition of Validity This divergence is detectable only when all of the following hold: - The instance maintains a chain view and a mempool view that are updated by independent code paths (as `bitcoind`'s `validation` and `txmempool` subsystems are). - The mempool view's eviction of conflicting transactions is *lazy* — i.e., eviction occurs on the next `removeForBlock`/`removeConflicts` pass, not synchronously with the reorg. This is the default in Bitcoin Core: reorg handling calls `mempool.removeForReorg` only for transactions that become invalid *at the new tip*, and a transaction whose parent is merely unconfirmed is not invalid, only *not yet confirmed*. - The wallet's spendable-set computation does not enforce the invariant "no UTXO may be both a confirmed input to a mempool transaction and a confirmed output of the chain view." When any of these fails — e.g., a wallet that rebuilds its mempool view from scratch on every tip change, or one that treats unconfirmed-change as unspendable — the divergence collapses. ## Decision Boundary The operational decision is: **on detecting a tip change, do you (A) trust the mempool view and reconcile lazily, or (B) invalidate the entire mempool-derived spendable set and rebuild from `getrawmempool` on the new tip?** Option A is cheap and correct in the steady state but produces the double-count above during the reorg window. Option B is expensive (a full `getrawmempool` plus per-tx `getrawtransaction` is O(mempool size) and can take seconds) but eliminates the window. The boundary condition that should trigger B: **a tip change that is a reorg (new tip is not a descendant of the old tip) AND the mempool view contains at least one transaction whose inputs intersect the wallet's confirmed UTXO set.** In that case, the join is unsafe until the mempool view has been reconciled against the new tip. A simple tip *extension* (new tip is a descendant) does not require B; the mempool view is monotone under extension. The detection itself does not require trusting any peer's `getrawtransaction`: compare the new tip hash against the wallet's stored previous tip. If the new tip's `prev_blockhash` chain does not include the stored tip, it is a reorg. This is a local computation over headers the wallet already has. ## Operational Effect Instances that implement the reorg-triggered invalidation (B) observe: no double-spend attempts during reorg windows, at the cost of a 200ms–2s stall in coin selection immediately after a reorg. Instances that do not (A) observe: occasional `bad-txns-inputs-missingorspent` broadcasts that are accepted by a minority of peers, producing a transaction that is neither confirmed nor fully rejected — a *zombie broadcast* that the wallet must track and potentially re-broadcast or RBF. The zombie broadcast is the operational hazard: it is invisible in balance, visible only in the mempool-acceptance asymmetry across peers, and it requires explicit reconciliation before the wallet can safely construct the next transaction. The invariant to enforce: **a UTXO may not appear in the spendable set if it is an input to any transaction in the mempool view whose ancestor chain includes an unconfirmed transaction.** This is the "no unconfirmed-ancestor double-count" invariant. It is checkable locally and does not require peer trust. ```python class MempoolChainDivergence(RuntimeError): """Raised when the wallet's spendable set would double-count a UTXO that is both a confirmed chain output and an input to a mempool transaction with unconfirmed ancestors.""" pass def compute_spendable_set(chain_utxos, mempool_txs, wallet_script_hashes): """ chain_utxos: dict[outpoint -> value] from the wallet's chain view mempool_txs: dict[txid -> {inputs: [outpoint], outputs: [(script, value)]}] """ # Identify mempool transactions whose inputs intersect the wallet's # confirmed UTXO set AND whose ancestry includes an unconfirmed tx. spendable = dict(chain_utxos) spends_of_confirmed = {} for txid, tx in mempool_txs.items(): for outpoint in tx["inputs"]: if outpoint in chain_utxos: spends_of_confirmed.setdefault(outpoint, []).append(txid) for outpoint, spending_txids in spends_of_confirmed.items(): # If every spender is a direct mempool tx with all-confirmed # ancestors, the UTXO is spent; remove it and add the change. for txid in spending_txids: tx = mempool_txs[txid] if tx.get("has_unconfirmed_ancestor", False): raise MempoolChainDivergence( f"UTXO {outpoint} is a confirmed chain output but is " f"spent by mempool tx {txid} whose ancestry includes an " f"unconfirmed transaction. Spendable set would " f"double-count; rebuild mempool view against new tip " f"before coin selection." ) # Safe to mark spent and credit change. del spendable[outpoint] for script, value in mempool_txs[spending_txids[0]]["outputs"]: if script in wallet_script_hashes: spendable[(spending_txids[0], script)] = value return spendable def select_coins(spendable, target_value): # Deterministic largest-first selection; raises if the set is # internally inconsistent (the divergence guard above should have # fired first, but we defend in depth). selected, total = [], 0 for outpoint, value in sorted(spendable.items(), key=lambda kv: -kv[1]): selected.append(outpoint) total += value if total >= target_value: return selected, total raise MempoolChainDivergence( f"insufficient spendable value: {total} < {target_value}; " f"spendable set may be incomplete after reorg" ) ```