Merge rust-bitcoin/rust-bitcoin#6958: primitives: Use txid for coinbase ntxid
What changed, and why it matters
This change fixes how a secondary transaction identifier called 'ntxid' is calculated for coinbase transactions (the special first transaction in each Bitcoin block). Previously, the code treated the coinbase's script_sig like a normal signature and cleared it when computing ntxid. Because coinbase script_sig actually contains required block-height data, two different valid coinbases could end up with the same ntxid. The fix makes coinbase ntxid equal to the regular txid, so distinct coinbases stay distinct. Any database or index that used the old coinbase ntxid as a key would need to recompute those keys.
Review any systems using coinbase ntxid as a lookup key and recompute those keys. Ensure downstream consumers of rust-bitcoin understand the semantic change for coinbase transactions. Consider whether the old behavior could have caused duplicate-key bugs in deployed services.
Security signals we found
Identifier collision risk in transaction indexing/keying
Incorrect normalization of consensus-critical coinbase script_sig
Potential key reuse/collision for stores indexed by coinbase ntxid
Fix references an external audit issue (project-loupe/audit-rust-bitcoin#157)
Evidence from the diff
The patch changes Transaction::compute_ntxid in rust-bitcoin so that for coinbase transactions it returns Ntxid::from_byte_array(self.compute_txid().to_byte_array()). For non-coinbase transactions the existing normalization behavior (clearing script_sig and witness) is unchanged. The rationale is that a coinbase script_sig is not an authorizing signature; it carries consensus-mandated data such as the BIP-34 height commitment, so normalizing it away can cause different valid coinbases to collide on the same ntxid. The commit adds tests verifying that two coinbases with different BIP-34 heights have different ntxids and that coinbase ntxid now equals txid. It also adjusts an existing test to use a non-coinbase input so its prior assertion remains valid.
Changed components
primitives/src/hash_types/ntxid.rsprimitives/src/transaction.rsTransaction::compute_ntxidNtxid documentationInspect captured patch +49 / −0
### primitives/src/hash_types/ntxid.rs
@@ -20,6 +20,9 @@ use hashes::sha256d;
///
/// This gives a way to identify a transaction that is "the same" as another in the sense of
/// having the same inputs and outputs.
+///
+/// A coinbase `script_sig` is not a signature and may contain a BIP-34 height commitment, so
+/// for a coinbase transaction the ntxid is equal to the txid.
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Ntxid(sha256d::Hash);
### primitives/src/transaction.rs
@@ -201,8 +201,15 @@ impl Transaction {
///
/// This gives a way to identify a transaction that is "the same" as another in the sense of
/// having the same inputs and outputs.
+ ///
+ /// A coinbase `script_sig` is not a signature and may contain a BIP-34 height commitment, so
+ /// for a coinbase transaction the ntxid is equal to the txid.
#[doc(alias = "ntxid")]
pub fn compute_ntxid(&self) -> Ntxid {
+ if self.is_coinbase() {
+ return Ntxid::from_byte_array(self.compute_txid().to_byte_array());
+ }
+
let normalized = Self {
version: self.version,
lock_time: self.lock_time,
@@ -2301,6 +2308,7 @@ mod tests {
#[cfg(feature = "alloc")]
fn compute_ntxid_ignores_script_sig_and_witness() {
let mut tx_in = TxIn::EMPTY_COINBASE;
+ tx_in.previous_output = OutPoint { txid: Txid::from_byte_array([0xAA; 32]), vout: 0 };
tx_in.script_sig = ScriptSigBuf::from_bytes(vec![1, 2, 3]);
tx_in.witness = Witness::from_slice(&[&[0xAAu8][..]]);
@@ -2319,6 +2327,44 @@ mod tests {
assert_eq!(ntxid, Ntxid::from_byte_array(tx.compute_txid().to_byte_array()));
}
+ #[test]
+ #[cfg(feature = "alloc")]
+ fn compute_ntxid_preserves_coinbase_script_sig() {
+ let mut tx_in = TxIn::EMPTY_COINBASE;
+ // BIP-34 height 840001.
+ tx_in.script_sig = ScriptSigBuf::from_bytes(vec![0x03, 0x41, 0xd1, 0x0c]);
+
+ let first = Transaction {
+ version: Version::ONE,
+ lock_time: absolute::LockTime::ZERO,
+ inputs: vec![tx_in],
+ outputs: vec![TxOut { amount: Amount::ONE_SAT, script_pubkey: ScriptPubKeyBuf::new() }],
+ };
+
+ let mut second = first.clone();
+ // BIP-34 height 840002.
+ second.inputs[0].script_sig = ScriptSigBuf::from_bytes(vec![0x03, 0x42, 0xd1, 0x0c]);
+
+ assert_ne!(first.compute_txid(), second.compute_txid());
+ assert_ne!(first.compute_ntxid(), second.compute_ntxid());
+ }
+
+ #[test]
+ #[cfg(feature = "alloc")]
+ fn compute_ntxid_matches_coinbase_txid() {
+ let mut tx_in = TxIn::EMPTY_COINBASE;
+ tx_in.script_sig = ScriptSigBuf::from_bytes(vec![0x03, 0x41, 0xd1, 0x0c]);
+
+ let tx = Transaction {
+ version: Version::ONE,
+ lock_time: absolute::LockTime::ZERO,
+ inputs: vec![tx_in],
+ outputs: vec![TxOut { amount: Amount::ONE_SAT, script_pubkey: ScriptPubKeyBuf::new() }],
+ };
+
+ assert_eq!(tx.compute_ntxid().to_byte_array(), tx.compute_txid().to_byte_array());
+ }
+
#[test]
#[cfg(feature = "alloc")]
fn transaction_decoder_push_bytes_after_done_is_false() {Why this scored 49/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.