]> Untitled Git - bdk/commitdiff
fix(chain): skip coinbase txs with a `last_seen` in canonicalization
author志宇 <hello@evanlinjin.me>
Tue, 13 Oct 2026 01:45:43 +0000 (01:45 +0000)
committer志宇 <hello@evanlinjin.me>
Tue, 13 Oct 2026 01:45:43 +0000 (01:45 +0000)
A coinbase tx cannot be in the mempool, so a `last_seen` value on one is
bad data and must not make it canonical. Previously this was only a
`debug_assert!`, so release builds canonicalized it as unconfirmed.

A coinbase that is an ancestor of a canonical tx, or is assumed
canonical, stays canonical even if no anchor is held for it. As that is
a reachable state, `CanonicalTxOut::is_mature` no longer asserts on it
and returns `false` as maturity cannot be proven.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
crates/chain/src/canonical.rs
crates/chain/src/canonical_task.rs
crates/chain/tests/test_tx_graph_conflicts.rs

index 20603c8dd83572f0b4ebb9e0c4b9eac32b19159a..30d0e9bf2a320fd8789f552714227bd27e1d43d2 100644 (file)
@@ -140,10 +140,10 @@ impl<A: Anchor> CanonicalTxOut<ChainPosition<A>> {
         if self.is_on_coinbase {
             let conf_height = match self.pos.confirmation_height_upper_bound() {
                 Some(height) => height,
-                None => {
-                    debug_assert!(false, "coinbase tx can never be unconfirmed");
-                    return false;
-                }
+                // A coinbase can be canonical without us holding an anchor for it (i.e. it is an
+                // ancestor of a canonical tx, or is assumed canonical). With no known
+                // confirmation height, maturity cannot be proven.
+                None => return false,
             };
             let age = tip.saturating_sub(conf_height);
             if age + 1 < COINBASE_MATURITY {
index a7cb9230a27df1e85799e311cfed644985ca4feb..9b7d0e614f2ec66bab3e049718c52946e85caa5f 100644 (file)
@@ -102,11 +102,9 @@ impl<'g, A: Anchor> ChainQuery for CanonicalTask<'g, A> {
                 }
                 CanonicalStage::SeenTxs => {
                     if let Some((txid, tx, last_seen)) = self.unprocessed_seen_txs.next() {
-                        debug_assert!(
-                            !tx.is_coinbase(),
-                            "Coinbase txs must not have `last_seen` (in mempool) value"
-                        );
-                        if !self.is_canonicalized(txid) {
+                        // A coinbase tx cannot be in the mempool, so a `last_seen` value on one is
+                        // not evidence of it being canonical.
+                        if !self.is_canonicalized(txid) && !tx.is_coinbase() {
                             let observed_in = ObservedIn::Mempool(last_seen);
                             self.mark_canonical(
                                 txid,
index 07760a8a1db0ec9bb07313c13e53c5d1b0d35f1e..3619ac19e33ad1d633a6b5783391f21b93395168 100644 (file)
@@ -931,6 +931,72 @@ fn test_tx_conflict_handling() {
             exp_balance: Balance {
                 ..Default::default()
             }
+        },
+        Scenario {
+            name: "coinbase tx with last_seen is not canonical",
+            tx_templates: &[
+                TxTemplate {
+                    tx_name: "coinbase",
+                    inputs: &[TxInTemplate::Coinbase],
+                    outputs: &[TxOutTemplate::new(21_000, Some(0))],
+                    // A coinbase cannot be in the mempool, so this is bad data.
+                    last_seen: Some(100),
+                    ..Default::default()
+                }
+            ],
+            exp_chain_txs: HashSet::from([]),
+            exp_chain_txouts: HashSet::from([]),
+            exp_unspents: HashSet::from([]),
+            exp_balance: Balance {
+                ..Default::default()
+            }
+        },
+        Scenario {
+            name: "unanchored coinbase tx is canonical via an unconfirmed spend",
+            tx_templates: &[
+                TxTemplate {
+                    tx_name: "coinbase",
+                    inputs: &[TxInTemplate::Coinbase],
+                    // The second output is unspent, so `balance` checks its maturity.
+                    outputs: &[TxOutTemplate::new(21_000, Some(0)), TxOutTemplate::new(4_000, Some(1))],
+                    ..Default::default()
+                },
+                TxTemplate {
+                    tx_name: "spend",
+                    inputs: &[TxInTemplate::PrevTx("coinbase", 0)],
+                    outputs: &[TxOutTemplate::new(20_000, Some(2))],
+                    last_seen: Some(100),
+                    ..Default::default()
+                }
+            ],
+            exp_chain_txs: HashSet::from(["coinbase", "spend"]),
+            exp_chain_txouts: HashSet::from([("coinbase", 0), ("coinbase", 1), ("spend", 0)]),
+            exp_unspents: HashSet::from([("coinbase", 1), ("spend", 0)]),
+            exp_balance: Balance {
+                // No anchor is held for the coinbase, so its maturity cannot be proven.
+                immature: Amount::from_sat(4_000),
+                trusted_pending: Amount::from_sat(20_000),
+                ..Default::default()
+            }
+        },
+        Scenario {
+            name: "assumed canonical coinbase tx without anchor is canonical",
+            tx_templates: &[
+                TxTemplate {
+                    tx_name: "coinbase",
+                    inputs: &[TxInTemplate::Coinbase],
+                    outputs: &[TxOutTemplate::new(21_000, Some(0))],
+                    assume_canonical: true,
+                    ..Default::default()
+                }
+            ],
+            exp_chain_txs: HashSet::from(["coinbase"]),
+            exp_chain_txouts: HashSet::from([("coinbase", 0)]),
+            exp_unspents: HashSet::from([("coinbase", 0)]),
+            exp_balance: Balance {
+                immature: Amount::from_sat(21_000),
+                ..Default::default()
+            }
         }
     ];