From b4e9daa992132242562d48c114edd16ba5fa4c25 Mon Sep 17 00:00:00 2001 From: /dev/fd0 Date: Tue, 9 Jun 2026 17:08:26 +0530 Subject: [PATCH 1/2] Reject BitName registrations without a matching reservation --- lib/state/bitnames.rs | 23 +++++++++++++---------- lib/state/error.rs | 5 +++++ lib/state/mod.rs | 20 ++++++++++++++++++++ 3 files changed, 38 insertions(+), 10 deletions(-) diff --git a/lib/state/bitnames.rs b/lib/state/bitnames.rs index 3778463..3981e9f 100644 --- a/lib/state/bitnames.rs +++ b/lib/state/bitnames.rs @@ -505,21 +505,24 @@ impl Dbs { bitname_data: &crate::types::MutableBitNameData, height: u32, ) -> Result<(), Error> { - // Find the reservation to burn - let implied_commitment = - filled_tx.implied_reservation_commitment().expect( - "A BitName registration tx should have an implied commitment", - ); - let burned_reservation_txid = - filled_tx.spent_reservations().find_map(|(_, filled_output)| { - let (txid, commitment) = filled_output.reservation_data() - .expect("A spent reservation should correspond to a commitment"); + // Find the reservation to burn: the spent reservation whose commitment + // equals keyed_hash(revealed_nonce, name_hash). This is enforced by + // `validate_bitnames`; returning an error here rather than panicking + // guards against any unvalidated connection path. + let implied_commitment = filled_tx + .implied_reservation_commitment() + .ok_or(Error::NoReservationForRegistration { bitname })?; + let burned_reservation_txid = filled_tx + .spent_reservations() + .find_map(|(_, filled_output)| { + let (txid, commitment) = filled_output.reservation_data()?; if *commitment == implied_commitment { Some(txid) } else { None } - }).expect("A BitName registration tx should correspond to a burned reservation"); + }) + .ok_or(Error::NoReservationForRegistration { bitname })?; if !self.reservations.delete(rwtxn, burned_reservation_txid)? { return Err(Error::MissingReservation { txid: *burned_reservation_txid, diff --git a/lib/state/error.rs b/lib/state/error.rs index 1d16103..0f2834b 100644 --- a/lib/state/error.rs +++ b/lib/state/error.rs @@ -37,6 +37,11 @@ pub enum BitName { MissingReservation { txid: Txid }, #[error("no BitNames to update")] NoBitNamesToUpdate, + #[error( + "no spent BitName reservation matches the commitment for the \ + registration of {bitname}" + )] + NoReservationForRegistration { bitname: BitNameId }, } impl From for BitName { diff --git a/lib/state/mod.rs b/lib/state/mod.rs index c50d1ab..6d41064 100644 --- a/lib/state/mod.rs +++ b/lib/state/mod.rs @@ -407,6 +407,26 @@ impl State { if self.bitnames.try_get_bitname(rotxn, &name_hash)?.is_some() { return Err(Error::BitNameAlreadyRegistered { name_hash }); } + // A registration must burn the reservation that commits to it, + // i.e. a spent reservation whose commitment equals + // keyed_hash(revealed_nonce, name_hash). Without this check, + // `apply_registration` would later fail to find the reservation + // to burn. + if let Some(implied_commitment) = + tx.implied_reservation_commitment() + { + let burns_matching_reservation = + tx.spent_reservations().any(|(_, filled_output)| { + filled_output.reservation_commitment() + == Some(&implied_commitment) + }); + if !burns_matching_reservation { + return Err(error::BitName::NoReservationForRegistration { + bitname: name_hash, + } + .into()); + } + } if n_bitname_outputs == n_bitname_inputs + 1 { return Ok(()); }; From 317d2a5ae1044990b28d07533aac06287a668063 Mon Sep 17 00:00:00 2001 From: /dev/fd0 Date: Tue, 9 Jun 2026 17:11:09 +0530 Subject: [PATCH 2/2] Add test for rejecting registrations without a matching reservation --- lib/state/mod.rs | 93 +++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 85 insertions(+), 8 deletions(-) diff --git a/lib/state/mod.rs b/lib/state/mod.rs index 6d41064..b22b8c1 100644 --- a/lib/state/mod.rs +++ b/lib/state/mod.rs @@ -686,20 +686,25 @@ impl Watchable<()> for State { mod tests { use ed25519_dalek::SigningKey; - use super::{Error, State}; use crate::{ authorization, + state::{Error, State, error}, types::{ - Address, AuthorizedTransaction, BitcoinOutputContent, FilledOutput, - FilledOutputContent, OutPoint, OutPointKey, Output, OutputContent, - Transaction, VerifyingKey, + Address, AuthorizedTransaction, BitName, BitcoinOutputContent, + FilledOutput, FilledOutputContent, FilledTransaction, Hash, + MutableBitNameData, OutPoint, OutPointKey, Output, OutputContent, + Transaction, TxData, Txid, VerifyingKey, }, }; /// Open a fresh state in a unique temporary directory. - fn new_state() -> (sneed::Env, State) { - let path = std::env::temp_dir() - .join(format!("plain_bitnames_auth_count_{}", std::process::id())); + fn new_state(dir_name_suffix: &str) -> (sneed::Env, State) { + let dir_name = if dir_name_suffix.is_empty() { + format!("plain_bitnames_{}", std::process::id()) + } else { + format!("plain_bitnames_{dir_name_suffix}_{}", std::process::id()) + }; + let path = std::env::temp_dir().join(dir_name); let _remove_result = std::fs::remove_dir_all(&path); std::fs::create_dir_all(&path).unwrap(); let env = { @@ -738,13 +743,47 @@ mod tests { outpoint } + /// Build a BitName registration that registers `name_hash` with + /// `revealed_nonce`, while spending a single reservation that commits to + /// `reservation_commitment`. + fn registration_tx( + name_hash: BitName, + revealed_nonce: Hash, + reservation_commitment: Hash, + ) -> FilledTransaction { + let address = Address([0; 20]); + let mut transaction = Transaction::new( + vec![OutPoint::Regular { + txid: Txid([0; 32]), + vout: 0, + }], + vec![Output::new(address, OutputContent::BitName)], + ); + transaction.data = Some(TxData::BitNameRegistration { + name_hash, + revealed_nonce, + bitname_data: Box::new(MutableBitNameData::default()), + }); + let reservation = FilledOutput::new( + address, + FilledOutputContent::BitNameReservation( + Txid([0; 32]), + reservation_commitment, + ), + ); + FilledTransaction { + transaction, + spent_utxos: vec![reservation], + } + } + /// A transaction that spends an input without supplying an authorization /// for it must be rejected. Otherwise the `zip` of authorizations and /// spent UTXOs silently skips the unauthorized input, allowing any UTXO to /// be spent without a signature. #[test] fn validate_transaction_rejects_missing_authorization() { - let (env, state) = new_state(); + let (env, state) = new_state("auth_count"); let signing_key = SigningKey::from_bytes(&[1u8; 32]); let verifying_key: VerifyingKey = signing_key.verifying_key().into(); let address = authorization::get_address(&verifying_key); @@ -788,4 +827,42 @@ mod tests { .validate_transaction(&rotxn, &authorized) .expect("correctly authorized tx should validate"); } + + /// A registration whose spent reservation does not commit to the + /// registered name must be rejected. Otherwise it passes validation and + /// later panics in `apply_registration`, which fails to find the + /// reservation to burn. + #[test] + fn validate_bitnames_rejects_registration_without_matching_reservation() { + let (env, state) = new_state("registration"); + let rotxn = env.read_txn().unwrap(); + let name_hash = BitName([7; 32]); + let revealed_nonce: Hash = [3; 32]; + let implied_commitment: Hash = + blake3::keyed_hash(&revealed_nonce, &name_hash.0).into(); + + // The reservation commits to something other than the registered name. + let mismatched_commitment: Hash = [0; 32]; + assert_ne!(mismatched_commitment, implied_commitment); + let tx = + registration_tx(name_hash, revealed_nonce, mismatched_commitment); + let err = state.validate_bitnames(&rotxn, &tx).expect_err( + "registration without a matching reservation must be rejected", + ); + assert!( + matches!( + err, + Error::BitName( + error::BitName::NoReservationForRegistration { bitname } + ) if bitname == name_hash + ), + "unexpected error: {err:?}" + ); + + // The same registration burning the matching reservation is accepted. + let tx = registration_tx(name_hash, revealed_nonce, implied_commitment); + state.validate_bitnames(&rotxn, &tx).expect( + "registration burning the matching reservation should validate", + ); + } }