From 760a0a27ebeb482fd8341509a20e796e3b5c1834 Mon Sep 17 00:00:00 2001 From: Alessandro Siniscalchi Date: Wed, 28 Jan 2026 08:55:37 +0000 Subject: [PATCH 01/16] add param to set the target owner --- src/cli/tx_cmd.rs | 7 ++++ src/commands/tx.rs | 75 ++++++++++++++++++++++++++++++++++--- src/wallet/brc721_wallet.rs | 4 ++ 3 files changed, 80 insertions(+), 6 deletions(-) diff --git a/src/cli/tx_cmd.rs b/src/cli/tx_cmd.rs index 0a05b46..e89e5f6 100644 --- a/src/cli/tx_cmd.rs +++ b/src/cli/tx_cmd.rs @@ -51,6 +51,13 @@ pub enum TxCmd { required = true )] collection_id: CollectionKey, + #[arg( + long = "register-address", + value_name = "ADDRESS|ADDRESS_H160", + required = false, + help = "Ownership output address (Bitcoin address or addressH160 from `wallet address`); defaults to a new wallet address" + )] + register_address: Option, #[arg( long = "slots", value_name = "RANGES", diff --git a/src/commands/tx.rs b/src/commands/tx.rs index 5bafa85..a234290 100644 --- a/src/commands/tx.rs +++ b/src/commands/tx.rs @@ -3,8 +3,8 @@ use std::str::FromStr; use super::CommandRunner; use crate::storage::traits::{CollectionKey, StorageRead}; use crate::types::{ - Brc721OpReturnOutput, Brc721Payload, IndexRanges, MixData, RegisterCollectionData, - RegisterOwnershipData, SlotRanges, + h160_from_script_pubkey, Brc721OpReturnOutput, Brc721Payload, IndexRanges, MixData, + RegisterCollectionData, RegisterOwnershipData, SlotRanges, }; use crate::wallet::passphrase::prompt_passphrase_once; use crate::{cli, context, wallet::brc721_wallet::Brc721Wallet}; @@ -37,12 +37,14 @@ impl CommandRunner for cli::TxCmd { } => run_send_amount(ctx, to, *amount_sat, *fee_rate, passphrase.clone()), cli::TxCmd::RegisterOwnership { collection_id, + register_address, slots, fee_rate, passphrase, } => run_register_ownership( ctx, collection_id, + register_address.clone(), slots.clone(), *fee_rate, passphrase.clone(), @@ -121,6 +123,7 @@ fn run_register_collection( fn run_register_ownership( ctx: &context::Context, collection_id: &CollectionKey, + register_address: Option, slots: SlotRanges, fee_rate: Option, passphrase: Option, @@ -142,10 +145,8 @@ fn run_register_ownership( // Output 1 is the ownership UTXO tracked by the indexer for this registration. // Use a new wallet-derived address so the NFTs are spendable by this wallet. - let ownership_address = wallet - .reveal_next_payment_address() - .context("derive ownership address")? - .address; + let ownership_address = + resolve_register_ownership_address(ctx, &mut wallet, register_address)?; let ownership_amount = Amount::from_sat(546); let ownership = RegisterOwnershipData::for_single_output( @@ -443,6 +444,68 @@ fn resolve_passphrase(passphrase: Option) -> Result { Ok(SecretString::from(passphrase.unwrap_or_default())) } +fn resolve_register_ownership_address( + ctx: &context::Context, + wallet: &mut Brc721Wallet, + register_address: Option, +) -> Result
{ + let Some(raw) = register_address else { + return Ok(wallet + .reveal_next_payment_address() + .context("derive ownership address")? + .address); + }; + + let trimmed = raw.trim(); + if let Ok(address) = Address::from_str(trimmed) { + let address = address.require_network(ctx.network).with_context(|| { + format!("register-address '{trimmed}' does not match network {}", ctx.network) + })?; + let known = wallet + .revealed_payment_addresses() + .iter() + .any(|info| info.address == address); + if !known { + log::warn!( + "register-address '{}' is not a revealed wallet address; ownership will be assigned to an external address", + address + ); + } + return Ok(address); + } + + let h160 = parse_address_h160(trimmed)?; + let address = wallet + .revealed_payment_addresses() + .into_iter() + .find(|info| h160_from_script_pubkey(&info.address.script_pubkey()) == h160) + .map(|info| info.address) + .ok_or_else(|| { + anyhow!( + "register-address '{}' not found in wallet addresses (run `brc721 wallet address` to generate one)", + trimmed + ) + })?; + + Ok(address) +} + +fn parse_address_h160(raw: &str) -> Result { + let trimmed = raw.trim(); + let trimmed = trimmed.strip_prefix("addressH160=").unwrap_or(trimmed); + let formatted = if trimmed.starts_with("0x") || trimmed.starts_with("0X") { + trimmed.to_string() + } else { + format!("0x{trimmed}") + }; + H160::from_str(&formatted).map_err(|_| { + anyhow!( + "invalid register-address '{}' (expected a Bitcoin address or 0x + 40 hex chars)", + raw + ) + }) +} + fn parse_outpoints(outpoints: &[String]) -> Result> { outpoints .iter() diff --git a/src/wallet/brc721_wallet.rs b/src/wallet/brc721_wallet.rs index 19f44bb..f6b8bb6 100644 --- a/src/wallet/brc721_wallet.rs +++ b/src/wallet/brc721_wallet.rs @@ -68,6 +68,10 @@ impl Brc721Wallet { self.local.reveal_next_payment_address() } + pub fn revealed_payment_addresses(&self) -> Vec { + self.local.revealed_payment_addresses() + } + pub fn balances(&self) -> Result { self.remote.balances() } From 451f8aa51341bff783b5ace457dc8197d26e3f38 Mon Sep 17 00:00:00 2001 From: Alessandro Siniscalchi Date: Wed, 28 Jan 2026 08:56:51 +0000 Subject: [PATCH 02/16] fmt --- src/commands/tx.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/commands/tx.rs b/src/commands/tx.rs index a234290..26f7cf9 100644 --- a/src/commands/tx.rs +++ b/src/commands/tx.rs @@ -145,8 +145,7 @@ fn run_register_ownership( // Output 1 is the ownership UTXO tracked by the indexer for this registration. // Use a new wallet-derived address so the NFTs are spendable by this wallet. - let ownership_address = - resolve_register_ownership_address(ctx, &mut wallet, register_address)?; + let ownership_address = resolve_register_ownership_address(ctx, &mut wallet, register_address)?; let ownership_amount = Amount::from_sat(546); let ownership = RegisterOwnershipData::for_single_output( @@ -459,7 +458,10 @@ fn resolve_register_ownership_address( let trimmed = raw.trim(); if let Ok(address) = Address::from_str(trimmed) { let address = address.require_network(ctx.network).with_context(|| { - format!("register-address '{trimmed}' does not match network {}", ctx.network) + format!( + "register-address '{trimmed}' does not match network {}", + ctx.network + ) })?; let known = wallet .revealed_payment_addresses() From 699313181358029e30b191f21a8a78b32657b702 Mon Sep 17 00:00:00 2001 From: Alessandro Siniscalchi Date: Thu, 29 Jan 2026 09:38:13 +0000 Subject: [PATCH 03/16] specify owners --- src/cli/tx_cmd.rs | 13 ++- src/commands/tx.rs | 164 +++++++++++++++++++++++++++--------- src/wallet/brc721_wallet.rs | 10 ++- src/wallet/remote_wallet.rs | 10 ++- 4 files changed, 152 insertions(+), 45 deletions(-) diff --git a/src/cli/tx_cmd.rs b/src/cli/tx_cmd.rs index e89e5f6..7f1724a 100644 --- a/src/cli/tx_cmd.rs +++ b/src/cli/tx_cmd.rs @@ -52,12 +52,19 @@ pub enum TxCmd { )] collection_id: CollectionKey, #[arg( - long = "register-address", + long = "init-owner", value_name = "ADDRESS|ADDRESS_H160", required = false, - help = "Ownership output address (Bitcoin address or addressH160 from `wallet address`); defaults to a new wallet address" + help = "Init owner (addressH160 or revealed wallet address). Input0 must spend a UTXO from this address." )] - register_address: Option, + init_owner: Option, + #[arg( + long = "target-owner", + value_name = "ADDRESS|ADDRESS_H160", + required = false, + help = "Ownership output address (Bitcoin address or addressH160). Defaults to init-owner or a new wallet address." + )] + target_owner: Option, #[arg( long = "slots", value_name = "RANGES", diff --git a/src/commands/tx.rs b/src/commands/tx.rs index 26f7cf9..982f989 100644 --- a/src/commands/tx.rs +++ b/src/commands/tx.rs @@ -10,6 +10,7 @@ use crate::wallet::passphrase::prompt_passphrase_once; use crate::{cli, context, wallet::brc721_wallet::Brc721Wallet}; use age::secrecy::SecretString; use anyhow::{anyhow, Context, Result}; +use bdk_wallet::AddressInfo; use bitcoin::{Address, Amount, OutPoint}; use ethereum_types::H160; use std::collections::BTreeSet; @@ -37,14 +38,16 @@ impl CommandRunner for cli::TxCmd { } => run_send_amount(ctx, to, *amount_sat, *fee_rate, passphrase.clone()), cli::TxCmd::RegisterOwnership { collection_id, - register_address, + init_owner, + target_owner, slots, fee_rate, passphrase, } => run_register_ownership( ctx, collection_id, - register_address.clone(), + init_owner.clone(), + target_owner.clone(), slots.clone(), *fee_rate, passphrase.clone(), @@ -123,17 +126,18 @@ fn run_register_collection( fn run_register_ownership( ctx: &context::Context, collection_id: &CollectionKey, - register_address: Option, + init_owner: Option, + target_owner: Option, slots: SlotRanges, fee_rate: Option, passphrase: Option, ) -> Result<()> { let mut wallet = load_wallet(ctx)?; + let wallet_utxos = wallet.list_unspent(0).context("list wallet UTXOs")?; let mut lock_outpoints = Vec::new(); let db_path = ctx.data_dir.join("brc721.sqlite"); if db_path.exists() { let storage = crate::storage::SqliteStorage::new(&db_path); - let wallet_utxos = wallet.list_unspent(0).context("list wallet UTXOs")?; lock_outpoints = compute_wallet_token_outpoints_to_lock(&storage, &wallet_utxos, &[]) .context("compute lock set")?; } else { @@ -144,8 +148,51 @@ fn run_register_ownership( } // Output 1 is the ownership UTXO tracked by the indexer for this registration. - // Use a new wallet-derived address so the NFTs are spendable by this wallet. - let ownership_address = resolve_register_ownership_address(ctx, &mut wallet, register_address)?; + let revealed_addresses = wallet.revealed_payment_addresses(); + let mut init_spec = init_owner + .as_deref() + .map(|raw| resolve_owner_spec(ctx, &revealed_addresses, raw, true, "--init-owner")) + .transpose()?; + let target_spec = target_owner + .as_deref() + .map(|raw| resolve_owner_spec(ctx, &revealed_addresses, raw, false, "--target-owner")) + .transpose()?; + + if init_spec.is_none() { + if let Some(spec) = &target_spec { + if spec.is_wallet { + init_spec = Some(spec.clone()); + } else { + let raw = target_owner.as_deref().unwrap_or_default(); + return Err(anyhow!( + "--target-owner '{}' is not a revealed wallet address; provide --init-owner to select input0", + raw + )); + } + } + } + + let ownership_address = match &target_spec { + Some(spec) => spec.address.clone(), + None => match &init_spec { + Some(spec) => spec.address.clone(), + None => { + wallet + .reveal_next_payment_address() + .context("derive ownership address")? + .address + } + }, + }; + let lock_set = lock_outpoints.iter().cloned().collect::>(); + let mandatory_inputs = match &init_spec { + Some(spec) => vec![select_init_owner_outpoint( + &wallet_utxos, + &spec.address, + &lock_set, + )?], + None => Vec::new(), + }; let ownership_amount = Amount::from_sat(546); let ownership = RegisterOwnershipData::for_single_output( @@ -166,6 +213,7 @@ fn run_register_ownership( vec![(ownership_address.clone(), ownership_amount)], fee_rate, &lock_outpoints, + &mandatory_inputs, passphrase, ) .context("build tx")?; @@ -443,56 +491,96 @@ fn resolve_passphrase(passphrase: Option) -> Result { Ok(SecretString::from(passphrase.unwrap_or_default())) } -fn resolve_register_ownership_address( - ctx: &context::Context, - wallet: &mut Brc721Wallet, - register_address: Option, -) -> Result
{ - let Some(raw) = register_address else { - return Ok(wallet - .reveal_next_payment_address() - .context("derive ownership address")? - .address); - }; +#[derive(Clone)] +struct OwnerSpec { + address: Address, + is_wallet: bool, +} +fn resolve_owner_spec( + ctx: &context::Context, + revealed: &[AddressInfo], + raw: &str, + require_wallet: bool, + flag_name: &str, +) -> Result { let trimmed = raw.trim(); if let Ok(address) = Address::from_str(trimmed) { let address = address.require_network(ctx.network).with_context(|| { format!( - "register-address '{trimmed}' does not match network {}", + "{flag_name} '{trimmed}' does not match network {}", ctx.network ) })?; - let known = wallet - .revealed_payment_addresses() - .iter() - .any(|info| info.address == address); - if !known { - log::warn!( - "register-address '{}' is not a revealed wallet address; ownership will be assigned to an external address", - address - ); + let is_wallet = revealed.iter().any(|info| info.address == address); + if require_wallet && !is_wallet { + return Err(anyhow!( + "{flag_name} '{trimmed}' is not a revealed wallet address (run `brc721 wallet address` to generate one)" + )); } - return Ok(address); + return Ok(OwnerSpec { address, is_wallet }); } - let h160 = parse_address_h160(trimmed)?; - let address = wallet - .revealed_payment_addresses() - .into_iter() + let h160 = parse_address_h160(trimmed, flag_name)?; + let address = revealed + .iter() .find(|info| h160_from_script_pubkey(&info.address.script_pubkey()) == h160) - .map(|info| info.address) + .map(|info| info.address.clone()) .ok_or_else(|| { anyhow!( - "register-address '{}' not found in wallet addresses (run `brc721 wallet address` to generate one)", - trimmed + "{flag_name} '{trimmed}' not found in wallet addresses (run `brc721 wallet address` to generate one)" ) })?; - Ok(address) + Ok(OwnerSpec { + address, + is_wallet: true, + }) +} + +fn select_init_owner_outpoint( + wallet_utxos: &[bitcoincore_rpc::json::ListUnspentResultEntry], + owner_address: &Address, + disallowed: &BTreeSet, +) -> Result { + let script_pubkey = owner_address.script_pubkey(); + let mut best: Option<&bitcoincore_rpc::json::ListUnspentResultEntry> = None; + + for utxo in wallet_utxos { + if utxo.script_pub_key != script_pubkey { + continue; + } + let outpoint = OutPoint { + txid: utxo.txid, + vout: utxo.vout, + }; + if disallowed.contains(&outpoint) { + continue; + } + match best { + None => best = Some(utxo), + Some(current) => { + if utxo.amount.to_sat() > current.amount.to_sat() { + best = Some(utxo); + } + } + } + } + + let Some(utxo) = best else { + return Err(anyhow!( + "no spendable UTXO found for init-owner address {} (fund it with a non-NFT UTXO)", + owner_address + )); + }; + + Ok(OutPoint { + txid: utxo.txid, + vout: utxo.vout, + }) } -fn parse_address_h160(raw: &str) -> Result { +fn parse_address_h160(raw: &str, flag_name: &str) -> Result { let trimmed = raw.trim(); let trimmed = trimmed.strip_prefix("addressH160=").unwrap_or(trimmed); let formatted = if trimmed.starts_with("0x") || trimmed.starts_with("0X") { @@ -502,7 +590,7 @@ fn parse_address_h160(raw: &str) -> Result { }; H160::from_str(&formatted).map_err(|_| { anyhow!( - "invalid register-address '{}' (expected a Bitcoin address or 0x + 40 hex chars)", + "invalid {flag_name} '{}' (expected a Bitcoin address or 0x + 40 hex chars)", raw ) }) diff --git a/src/wallet/brc721_wallet.rs b/src/wallet/brc721_wallet.rs index f6b8bb6..d86003e 100644 --- a/src/wallet/brc721_wallet.rs +++ b/src/wallet/brc721_wallet.rs @@ -152,6 +152,7 @@ impl Brc721Wallet { payments: Vec<(Address, Amount)>, fee_rate: Option, lock_outpoints: &[OutPoint], + mandatory_inputs: &[OutPoint], passphrase: SecretString, ) -> Result { let locked = self.remote.list_locked_unspent()?; @@ -165,9 +166,12 @@ impl Brc721Wallet { .lock_unspent_outpoints(&to_lock) .context("lock token outpoints")?; - let psbt_res = self - .remote - .create_psbt_from_opreturn_and_payments(op_return, payments, fee_rate); + let psbt_res = self.remote.create_psbt_from_opreturn_and_payments( + op_return, + payments, + fee_rate, + mandatory_inputs, + ); let unlock_res = self.remote.unlock_unspent_outpoints(&to_lock); if let Err(unlock_err) = unlock_res { diff --git a/src/wallet/remote_wallet.rs b/src/wallet/remote_wallet.rs index df0402c..3bccf0f 100644 --- a/src/wallet/remote_wallet.rs +++ b/src/wallet/remote_wallet.rs @@ -312,6 +312,7 @@ impl RemoteWallet { op_return: TxOut, payments: Vec<(Address, Amount)>, fee_rate: Option, + mandatory_inputs: &[OutPoint], ) -> Result { let client = self.watch_client()?; @@ -325,6 +326,7 @@ impl RemoteWallet { } let change_position = outputs_vec.len(); let outputs = serde_json::Value::Array(outputs_vec); + let inputs = serde_json::Value::Array(mandatory_inputs.iter().map(outpoint_json).collect()); let mut options = serde_json::json!({}); if let Some(fr) = fee_rate { @@ -332,12 +334,15 @@ impl RemoteWallet { } // Keep all user-specified outputs at the front, so indices in the OP_RETURN mapping remain stable. options["changePosition"] = serde_json::json!(change_position); + if !mandatory_inputs.is_empty() { + options["add_inputs"] = serde_json::json!(true); + } let funded: serde_json::Value = client .call( "walletcreatefundedpsbt", &[ - serde_json::json!([]), + inputs, outputs, serde_json::json!(0), options, @@ -351,6 +356,9 @@ impl RemoteWallet { psbt = substitute_first_opreturn_script(psbt, script).context("dummy not found")?; psbt = move_opreturn_first(psbt); + if !mandatory_inputs.is_empty() { + psbt = reorder_psbt_inputs(psbt, mandatory_inputs)?; + } Ok(psbt) } From 69d602b718acc380746a3497d24685df65993110 Mon Sep 17 00:00:00 2001 From: Alessandro Siniscalchi Date: Fri, 30 Jan 2026 08:16:23 +0000 Subject: [PATCH 04/16] log and not usong the ownership utxo : --- src/app.rs | 8 +++--- src/cli/args.rs | 6 ++-- src/cli/mod.rs | 2 +- src/commands/tx.rs | 71 ++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 2 -- src/tracing.rs | 39 +++++++++++++++++++++++-- 6 files changed, 116 insertions(+), 12 deletions(-) diff --git a/src/app.rs b/src/app.rs index 57e6fa7..07aa342 100644 --- a/src/app.rs +++ b/src/app.rs @@ -147,15 +147,15 @@ fn determine_start_block(storage: &S, default: u64) -> Result { // --- Entry Point --- pub async fn run() -> Result<()> { - crate::tracing::init(None); - log::info!("🚀 Starting brc721"); - + let dotenv_path = crate::cli::load_dotenv(); let cli = crate::cli::parse(); + crate::tracing::init(cli.log_file.as_deref().map(Path::new)); + log::info!("Loaded env from {}", dotenv_path); + log::info!("🚀 Starting brc721"); let ctx = context::Context::from_cli(&cli)?; if let Some(path) = ctx.log_file.as_deref() { log::info!("📝 Log file: {}", path.to_string_lossy()); - crate::tracing::init(ctx.log_file.as_deref().map(Path::new)); } log::info!("🔗 Bitcoin Core RPC URL: {}", ctx.rpc_url); diff --git a/src/cli/args.rs b/src/cli/args.rs index 282b059..50f0bed 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -104,11 +104,13 @@ pub struct Cli { pub cmd: Option, } -pub fn parse() -> Cli { +pub fn load_dotenv() -> String { let dotenv_path = env::var("DOTENV_PATH").unwrap_or(".env".into()); dotenvy::from_filename(&dotenv_path).ok(); + dotenv_path +} - log::info!("Loaded env from {}", dotenv_path); +pub fn parse() -> Cli { Cli::parse() } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index a23ad13..8da2a8e 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -8,4 +8,4 @@ pub use command::Command; pub use tx_cmd::TxCmd; pub use wallet_cmd::WalletCmd; -pub use args::parse; +pub use args::{load_dotenv, parse}; diff --git a/src/commands/tx.rs b/src/commands/tx.rs index 982f989..810e0e2 100644 --- a/src/commands/tx.rs +++ b/src/commands/tx.rs @@ -140,6 +140,11 @@ fn run_register_ownership( let storage = crate::storage::SqliteStorage::new(&db_path); lock_outpoints = compute_wallet_token_outpoints_to_lock(&storage, &wallet_utxos, &[]) .context("compute lock set")?; + } else if init_owner.is_some() { + return Err(anyhow!( + "scanner database not found at {} (required when using --init-owner)", + db_path.to_string_lossy() + )); } else { log::warn!( "scanner database not found at {} (proceeding without ownership UTXO locks)", @@ -692,10 +697,76 @@ fn parse_mix_outputs( #[cfg(test)] mod tests { use super::*; + use bitcoin::{Amount, Network, Txid}; + use bitcoincore_rpc::json::ListUnspentResultEntry; #[test] fn parse_outpoints_rejects_invalid() { let res = parse_outpoints(&["not-an-outpoint".to_string()]); assert!(res.is_err()); } + + fn make_utxo( + txid_hex: &str, + vout: u32, + script_pub_key: bitcoin::ScriptBuf, + amount_sat: u64, + ) -> ListUnspentResultEntry { + ListUnspentResultEntry { + txid: Txid::from_str(txid_hex).unwrap(), + vout, + address: None, + label: None, + redeem_script: None, + witness_script: None, + script_pub_key, + amount: Amount::from_sat(amount_sat), + confirmations: 1, + spendable: true, + solvable: true, + descriptor: None, + safe: true, + } + } + + #[test] + fn select_init_owner_outpoint_skips_disallowed() { + let address = Address::from_str("bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh") + .unwrap() + .require_network(Network::Bitcoin) + .unwrap(); + let script_pub_key = address.script_pubkey(); + let utxo_nft = make_utxo(&"00".repeat(32), 0, script_pub_key.clone(), 1000); + let utxo_ok = make_utxo(&"11".repeat(32), 1, script_pub_key.clone(), 2000); + + let mut disallowed = BTreeSet::new(); + disallowed.insert(OutPoint { + txid: utxo_nft.txid, + vout: utxo_nft.vout, + }); + + let selected = + select_init_owner_outpoint(&[utxo_nft, utxo_ok], &address, &disallowed).unwrap(); + assert_eq!(selected.txid, Txid::from_str(&"11".repeat(32)).unwrap()); + assert_eq!(selected.vout, 1); + } + + #[test] + fn select_init_owner_outpoint_errors_when_only_disallowed() { + let address = Address::from_str("bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh") + .unwrap() + .require_network(Network::Bitcoin) + .unwrap(); + let script_pub_key = address.script_pubkey(); + let utxo_nft = make_utxo(&"00".repeat(32), 0, script_pub_key.clone(), 1000); + + let mut disallowed = BTreeSet::new(); + disallowed.insert(OutPoint { + txid: utxo_nft.txid, + vout: utxo_nft.vout, + }); + + let res = select_init_owner_outpoint(&[utxo_nft], &address, &disallowed); + assert!(res.is_err()); + } } diff --git a/src/main.rs b/src/main.rs index cf19c45..90484bd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,8 +16,6 @@ use anyhow::Result; #[tokio::main] async fn main() -> Result<()> { - crate::tracing::init(None); - if let Err(e) = app::run().await { log::error!("Fatal error: {:#}", e); std::process::exit(1); diff --git a/src/tracing.rs b/src/tracing.rs index aad2f64..f65c2fa 100644 --- a/src/tracing.rs +++ b/src/tracing.rs @@ -18,11 +18,9 @@ pub fn init(log_file: Option<&Path>) { .open(log_file) .ok() .map(|file| { - let (writer, guard) = tracing_appender::non_blocking(file); - std::mem::forget(guard); tracing_subscriber::fmt::layer() .with_ansi(false) - .with_writer(writer) + .with_writer(file) }) }); @@ -34,3 +32,38 @@ pub fn init(log_file: Option<&Path>) { .with(file_layer) .try_init(); } + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::thread::sleep; + use std::time::{Duration, SystemTime, UNIX_EPOCH}; + use tempfile::TempDir; + + #[test] + fn init_writes_logs_to_file() { + let dir = TempDir::new().expect("temp dir"); + let log_path = dir.path().join("brc721.log"); + + init(Some(&log_path)); + + let marker = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("time") + .as_nanos(); + log::info!("file log test marker={}", marker); + + for _ in 0..20 { + if let Ok(contents) = fs::read_to_string(&log_path) { + if contents.contains(&format!("marker={}", marker)) { + return; + } + } + sleep(Duration::from_millis(50)); + } + + let contents = fs::read_to_string(&log_path).unwrap_or_default(); + panic!("expected log marker in file, got: {}", contents); + } +} From f5be8253c59ebe3ebdc09a32522d8faad9b1289b Mon Sep 17 00:00:00 2001 From: Alessandro Siniscalchi Date: Fri, 30 Jan 2026 08:42:18 +0000 Subject: [PATCH 05/16] add e2e test for ownership utxo in input --- tests/e2e_register_ownership_init_owner.rs | 244 +++++++++++++++++++++ 1 file changed, 244 insertions(+) create mode 100644 tests/e2e_register_ownership_init_owner.rs diff --git a/tests/e2e_register_ownership_init_owner.rs b/tests/e2e_register_ownership_init_owner.rs new file mode 100644 index 0000000..1a2a838 --- /dev/null +++ b/tests/e2e_register_ownership_init_owner.rs @@ -0,0 +1,244 @@ +use bitcoin::Address; +use bitcoincore_rpc::{Auth, Client, RpcApi}; +use serde_json::json; +use std::fs; +use std::process::{Child, ExitStatus, Output, Stdio}; +use std::str::FromStr; +use std::thread::sleep; +use std::time::{Duration, Instant}; +use tempfile::TempDir; +use testcontainers::runners::SyncRunner; + +mod common; + +const MNEMONIC: &str = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + +fn combined_output(output: &Output) -> String { + let out = String::from_utf8_lossy(&output.stdout); + let err = String::from_utf8_lossy(&output.stderr); + format!("{}{}", out, err) +} + +fn parse_txid(output: &Output) -> bitcoin::Txid { + let combined = combined_output(output); + for line in combined.lines() { + if line.contains("txid:") { + let txid_str = line + .split_whitespace() + .last() + .expect("txid token at end of line"); + return txid_str.parse().expect("txid"); + } + } + panic!("txid not found in output:\n{}", combined); +} + +fn parse_owner_output_address(output: &Output) -> Address { + let combined = combined_output(output); + for line in combined.lines() { + if let Some(pos) = line.find("owner_output=") { + let rest = &line[pos + "owner_output=".len()..]; + let end = rest.find(',').unwrap_or(rest.len()); + let addr_str = rest[..end].trim(); + return Address::from_str(addr_str) + .expect("owner address") + .assume_checked(); + } + } + panic!("owner_output not found in output:\n{}", combined); +} + +fn collection_id_for_confirmed_tx(root: &Client, txid: &bitcoin::Txid) -> (u64, u32) { + let txid_str = txid.to_string(); + let tx_verbose: serde_json::Value = root + .call("getrawtransaction", &[json!(txid_str), json!(true)]) + .expect("getrawtransaction verbose"); + let blockhash = tx_verbose + .get("blockhash") + .and_then(|v| v.as_str()) + .expect("blockhash present after confirmation"); + + let block: serde_json::Value = root + .call("getblock", &[json!(blockhash), json!(1)]) + .expect("getblock"); + + let height = block + .get("height") + .and_then(|v| v.as_u64()) + .expect("block height"); + + let txs = block + .get("tx") + .and_then(|v| v.as_array()) + .expect("tx array"); + let tx_index = txs + .iter() + .position(|v| v.as_str() == Some(txid_str.as_str())) + .expect("txid must be in its block") as u32; + + (height, tx_index) +} + +struct DaemonGuard { + child: Child, +} + +impl DaemonGuard { + fn new(child: Child) -> Self { + Self { child } + } + + fn try_wait(&mut self) -> Option { + self.child.try_wait().expect("try_wait") + } + + fn stop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +impl Drop for DaemonGuard { + fn drop(&mut self) { + self.stop(); + } +} + +#[test] +fn e2e_register_ownership_init_owner_rejects_nft_utxo() { + let image = common::bitcoind_image(); + let container = image.start().expect("start bitcoind container"); + let rpc_url = common::rpc_url(&container); + let auth = Auth::UserPass("dev".into(), "dev".into()); + + let root_client = Client::new(&rpc_url, auth.clone()).expect("rpc client initial"); + + // Wallet: create and fund to pay fees + let data_dir = TempDir::new().expect("temp dir"); + let output = common::base_cmd(&rpc_url, &data_dir) + .arg("wallet") + .arg("init") + .arg("--passphrase") + .arg("passphrase") + .arg("--mnemonic") + .arg(MNEMONIC) + .output() + .expect("run wallet init"); + assert!(output.status.success(), "{:?}", output); + + let addr = common::wallet_address(&rpc_url, &data_dir); + root_client.generate_to_address(101, &addr).expect("mine"); + + // Register a collection so we can use a real collection id (HEIGHT:TX_INDEX) + let output = common::base_cmd(&rpc_url, &data_dir) + .arg("tx") + .arg("register-collection") + .arg("--evm-collection-address") + .arg("0xffff0123ffffffffffffffffffffffff3210ffff") + .arg("--passphrase") + .arg("passphrase") + .output() + .expect("run tx register-collection"); + assert!(output.status.success(), "{:?}", output); + let collection_txid = parse_txid(&output); + + // Mine a block to confirm the collection tx, then compute (height, tx_index) + root_client + .generate_to_address(1, &addr) + .expect("mine confirm collection"); + let (collection_height, collection_tx_index) = + collection_id_for_confirmed_tx(&root_client, &collection_txid); + let collection_id = format!("{collection_height}:{collection_tx_index}"); + + // Register ownership to create an ownership UTXO (vout1). + let output = common::base_cmd(&rpc_url, &data_dir) + .arg("tx") + .arg("register-ownership") + .arg("--collection-id") + .arg(&collection_id) + .arg("--slots") + .arg("0") + .arg("--passphrase") + .arg("passphrase") + .output() + .expect("run tx register-ownership"); + assert!(output.status.success(), "{:?}", output); + let ownership_txid = parse_txid(&output); + let owner_address = parse_owner_output_address(&output); + + // Confirm the ownership tx so the scanner can index it. + root_client + .generate_to_address(1, &addr) + .expect("mine confirm ownership"); + let (ownership_height, ownership_tx_index) = + collection_id_for_confirmed_tx(&root_client, &ownership_txid); + + // Start the daemon to build the scanner DB and index the ownership UTXO. + let log_path = data_dir.path().join("daemon.log"); + let mut daemon_cmd = common::base_cmd(&rpc_url, &data_dir); + daemon_cmd + .arg("--start") + .arg("0") + .arg("--confirmations") + .arg("0") + .arg("--log-file") + .arg(&log_path) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + + let mut daemon = DaemonGuard::new(daemon_cmd.spawn().expect("start daemon")); + + let deadline = Instant::now() + Duration::from_secs(20); + let needle = format!( + "register-ownership indexed (block {} tx {}", + ownership_height, ownership_tx_index + ); + loop { + if let Ok(contents) = fs::read_to_string(&log_path) { + if contents.contains(&needle) { + break; + } + } + if let Some(status) = daemon.try_wait() { + panic!("daemon exited early: {}", status); + } + if Instant::now() > deadline { + panic!( + "timed out waiting for scanner to index ownership tx at {}#{} (log={})", + ownership_height, + ownership_tx_index, + log_path.display() + ); + } + sleep(Duration::from_millis(100)); + } + + daemon.stop(); + + // Attempt to reuse the ownership address as init-owner. This must fail because + // it only has an ownership UTXO, which is disallowed for input0. + let output = common::base_cmd(&rpc_url, &data_dir) + .arg("tx") + .arg("register-ownership") + .arg("--collection-id") + .arg(&collection_id) + .arg("--slots") + .arg("1") + .arg("--init-owner") + .arg(owner_address.to_string()) + .arg("--passphrase") + .arg("passphrase") + .output() + .expect("run tx register-ownership with init-owner"); + assert!( + !output.status.success(), + "expected failure when init-owner only has an ownership UTXO" + ); + let combined = combined_output(&output); + assert!( + combined.contains("no spendable UTXO found for init-owner address"), + "unexpected output:\n{}", + combined + ); +} From 5d97319e51d703ef2f7ab142835a7000b9bdb2f2 Mon Sep 17 00:00:00 2001 From: Alessandro Siniscalchi Date: Fri, 30 Jan 2026 16:39:40 +0000 Subject: [PATCH 06/16] send btc do not use ownership utxo --- src/commands/tx.rs | 15 ++++++++++++++- src/integration_tests/txs.rs | 9 ++++++++- src/wallet/brc721_wallet.rs | 23 +++++++++++++++++++++-- 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/src/commands/tx.rs b/src/commands/tx.rs index 810e0e2..20ed115 100644 --- a/src/commands/tx.rs +++ b/src/commands/tx.rs @@ -241,11 +241,24 @@ fn run_send_amount( passphrase: Option, ) -> Result<()> { let wallet = load_wallet(ctx)?; + let mut lock_outpoints = Vec::new(); + let db_path = ctx.data_dir.join("brc721.sqlite"); + if db_path.exists() { + let storage = crate::storage::SqliteStorage::new(&db_path); + let wallet_utxos = wallet.list_unspent(0).context("list wallet UTXOs")?; + lock_outpoints = compute_wallet_token_outpoints_to_lock(&storage, &wallet_utxos, &[]) + .context("compute lock set")?; + } else { + log::warn!( + "scanner database not found at {} (proceeding without ownership UTXO locks)", + db_path.to_string_lossy() + ); + } let amount = Amount::from_sat(amount_sat); let address = Address::from_str(to)?.require_network(ctx.network)?; let passphrase = resolve_passphrase(passphrase)?; let tx = wallet - .build_payment_tx(&address, amount, fee_rate, passphrase) + .build_payment_tx(&address, amount, fee_rate, &lock_outpoints, passphrase) .context("build payment tx")?; let txid = wallet.broadcast(&tx)?; log::info!("✅ Sent {} sat to {} (txid: {})", amount_sat, to, txid); diff --git a/src/integration_tests/txs.rs b/src/integration_tests/txs.rs index 5a4c8d3..8e6cf1c 100644 --- a/src/integration_tests/txs.rs +++ b/src/integration_tests/txs.rs @@ -132,8 +132,15 @@ fn test_send_amount_between_wallets_via_psbt() { let amount = Amount::from_btc(1.0).expect("valid amount"); let fee = 2.5; // Send from wallet0 to wallet1 via PSBT flow + let lock_outpoints: Vec = Vec::new(); let tx = wallet0 - .build_payment_tx(address1, amount, Some(fee), SecretString::from(passphrase)) + .build_payment_tx( + address1, + amount, + Some(fee), + &lock_outpoints, + SecretString::from(passphrase), + ) .expect("build payment tx"); wallet0.broadcast(&tx).expect("broadcast"); diff --git a/src/wallet/brc721_wallet.rs b/src/wallet/brc721_wallet.rs index d86003e..395cf0c 100644 --- a/src/wallet/brc721_wallet.rs +++ b/src/wallet/brc721_wallet.rs @@ -107,11 +107,30 @@ impl Brc721Wallet { target_address: &Address, amount: Amount, fee_rate: Option, + lock_outpoints: &[OutPoint], passphrase: SecretString, ) -> Result { - let psbt: Psbt = self + let locked = self.remote.list_locked_unspent()?; + let to_lock = lock_outpoints + .iter() + .filter(|outpoint| !locked.contains(outpoint)) + .cloned() + .collect::>(); + + self.remote + .lock_unspent_outpoints(&to_lock) + .context("lock token outpoints")?; + + let psbt_res = self .remote - .create_psbt_for_payment(target_address, amount, fee_rate)?; + .create_psbt_for_payment(target_address, amount, fee_rate); + + let unlock_res = self.remote.unlock_unspent_outpoints(&to_lock); + if let Err(unlock_err) = unlock_res { + log::warn!("Failed to unlock outpoints: {unlock_err:#}"); + } + + let psbt: Psbt = psbt_res.context("create psbt for payment")?; self.sign(psbt, &passphrase) } From afb9deb63480052adb6dbaffbfa7fc7e9dea0b20 Mon Sep 17 00:00:00 2001 From: Alessandro Siniscalchi Date: Fri, 30 Jan 2026 16:48:11 +0000 Subject: [PATCH 07/16] refactor' --- src/commands/tx.rs | 177 +++++------------------------------ src/wallet/mod.rs | 1 + src/wallet/utxo_selection.rs | 155 ++++++++++++++++++++++++++++++ 3 files changed, 180 insertions(+), 153 deletions(-) create mode 100644 src/wallet/utxo_selection.rs diff --git a/src/commands/tx.rs b/src/commands/tx.rs index 20ed115..b6fecab 100644 --- a/src/commands/tx.rs +++ b/src/commands/tx.rs @@ -7,6 +7,7 @@ use crate::types::{ RegisterCollectionData, RegisterOwnershipData, SlotRanges, }; use crate::wallet::passphrase::prompt_passphrase_once; +use crate::wallet::utxo_selection; use crate::{cli, context, wallet::brc721_wallet::Brc721Wallet}; use age::secrecy::SecretString; use anyhow::{anyhow, Context, Result}; @@ -90,8 +91,10 @@ fn run_register_collection( if db_path.exists() { let storage = crate::storage::SqliteStorage::new(&db_path); let wallet_utxos = wallet.list_unspent(0).context("list wallet UTXOs")?; - lock_outpoints = compute_wallet_token_outpoints_to_lock(&storage, &wallet_utxos, &[]) - .context("compute lock set")?; + let ownership_outpoints = + utxo_selection::ownership_outpoints_for_wallet(&storage, &wallet_utxos) + .context("compute ownership outpoints")?; + lock_outpoints = utxo_selection::lock_outpoints_for_fees(&ownership_outpoints, &[]); } else { log::warn!( "scanner database not found at {} (proceeding without ownership UTXO locks)", @@ -135,11 +138,14 @@ fn run_register_ownership( let mut wallet = load_wallet(ctx)?; let wallet_utxos = wallet.list_unspent(0).context("list wallet UTXOs")?; let mut lock_outpoints = Vec::new(); + let mut ownership_outpoints = BTreeSet::new(); let db_path = ctx.data_dir.join("brc721.sqlite"); if db_path.exists() { let storage = crate::storage::SqliteStorage::new(&db_path); - lock_outpoints = compute_wallet_token_outpoints_to_lock(&storage, &wallet_utxos, &[]) - .context("compute lock set")?; + ownership_outpoints = + utxo_selection::ownership_outpoints_for_wallet(&storage, &wallet_utxos) + .context("compute ownership outpoints")?; + lock_outpoints = utxo_selection::lock_outpoints_for_fees(&ownership_outpoints, &[]); } else if init_owner.is_some() { return Err(anyhow!( "scanner database not found at {} (required when using --init-owner)", @@ -189,12 +195,11 @@ fn run_register_ownership( } }, }; - let lock_set = lock_outpoints.iter().cloned().collect::>(); let mandatory_inputs = match &init_spec { - Some(spec) => vec![select_init_owner_outpoint( + Some(spec) => vec![utxo_selection::select_non_ownership_utxo_for_address( &wallet_utxos, &spec.address, - &lock_set, + &ownership_outpoints, )?], None => Vec::new(), }; @@ -246,8 +251,10 @@ fn run_send_amount( if db_path.exists() { let storage = crate::storage::SqliteStorage::new(&db_path); let wallet_utxos = wallet.list_unspent(0).context("list wallet UTXOs")?; - lock_outpoints = compute_wallet_token_outpoints_to_lock(&storage, &wallet_utxos, &[]) - .context("compute lock set")?; + let ownership_outpoints = + utxo_selection::ownership_outpoints_for_wallet(&storage, &wallet_utxos) + .context("compute ownership outpoints")?; + lock_outpoints = utxo_selection::lock_outpoints_for_fees(&ownership_outpoints, &[]); } else { log::warn!( "scanner database not found at {} (proceeding without ownership UTXO locks)", @@ -335,9 +342,11 @@ fn run_send_assets( let dust_amount = Amount::from_sat(dust_sat); let passphrase = resolve_passphrase(passphrase)?; + let ownership_outpoints = + utxo_selection::ownership_outpoints_for_wallet(&storage, &wallet_utxos) + .context("compute ownership outpoints")?; let lock_outpoints = - compute_wallet_token_outpoints_to_lock(&storage, &wallet_utxos, &token_outpoints) - .context("compute lock set")?; + utxo_selection::lock_outpoints_for_fees(&ownership_outpoints, &token_outpoints); let tx = wallet .build_implicit_transfer_tx( @@ -455,9 +464,11 @@ fn run_mix( } } + let ownership_outpoints = + utxo_selection::ownership_outpoints_for_wallet(&storage, &wallet_utxos) + .context("compute ownership outpoints")?; let lock_outpoints = - compute_wallet_token_outpoints_to_lock(&storage, &wallet_utxos, &token_outpoints) - .context("compute lock set")?; + utxo_selection::lock_outpoints_for_fees(&ownership_outpoints, &token_outpoints); let dust_amount = Amount::from_sat(dust_sat); let payments = output_addresses @@ -556,48 +567,6 @@ fn resolve_owner_spec( }) } -fn select_init_owner_outpoint( - wallet_utxos: &[bitcoincore_rpc::json::ListUnspentResultEntry], - owner_address: &Address, - disallowed: &BTreeSet, -) -> Result { - let script_pubkey = owner_address.script_pubkey(); - let mut best: Option<&bitcoincore_rpc::json::ListUnspentResultEntry> = None; - - for utxo in wallet_utxos { - if utxo.script_pub_key != script_pubkey { - continue; - } - let outpoint = OutPoint { - txid: utxo.txid, - vout: utxo.vout, - }; - if disallowed.contains(&outpoint) { - continue; - } - match best { - None => best = Some(utxo), - Some(current) => { - if utxo.amount.to_sat() > current.amount.to_sat() { - best = Some(utxo); - } - } - } - } - - let Some(utxo) = best else { - return Err(anyhow!( - "no spendable UTXO found for init-owner address {} (fund it with a non-NFT UTXO)", - owner_address - )); - }; - - Ok(OutPoint { - txid: utxo.txid, - vout: utxo.vout, - }) -} - fn parse_address_h160(raw: &str, flag_name: &str) -> Result { let trimmed = raw.trim(); let trimmed = trimmed.strip_prefix("addressH160=").unwrap_or(trimmed); @@ -624,38 +593,6 @@ fn parse_outpoints(outpoints: &[String]) -> Result> { .collect() } -fn compute_wallet_token_outpoints_to_lock( - storage: &crate::storage::SqliteStorage, - wallet_utxos: &[bitcoincore_rpc::json::ListUnspentResultEntry], - spending: &[OutPoint], -) -> Result> { - let spending_set = spending.iter().cloned().collect::>(); - - // Build a set of wallet-owned outpoints that the index considers ownership UTXOs. - let mut wallet_token_outpoints = BTreeSet::new(); - for utxo in wallet_utxos { - let txid = utxo.txid.to_string(); - let vout = utxo.vout; - if storage - .list_unspent_ownership_utxos_by_outpoint(&txid, vout) - .with_context(|| format!("query ownership ranges for {txid}:{vout}"))? - .is_empty() - { - continue; - } - - wallet_token_outpoints.insert(OutPoint { - txid: utxo.txid, - vout, - }); - } - - Ok(wallet_token_outpoints - .difference(&spending_set) - .cloned() - .collect()) -} - fn parse_mix_outputs( outputs: &[String], network: bitcoin::Network, @@ -710,76 +647,10 @@ fn parse_mix_outputs( #[cfg(test)] mod tests { use super::*; - use bitcoin::{Amount, Network, Txid}; - use bitcoincore_rpc::json::ListUnspentResultEntry; #[test] fn parse_outpoints_rejects_invalid() { let res = parse_outpoints(&["not-an-outpoint".to_string()]); assert!(res.is_err()); } - - fn make_utxo( - txid_hex: &str, - vout: u32, - script_pub_key: bitcoin::ScriptBuf, - amount_sat: u64, - ) -> ListUnspentResultEntry { - ListUnspentResultEntry { - txid: Txid::from_str(txid_hex).unwrap(), - vout, - address: None, - label: None, - redeem_script: None, - witness_script: None, - script_pub_key, - amount: Amount::from_sat(amount_sat), - confirmations: 1, - spendable: true, - solvable: true, - descriptor: None, - safe: true, - } - } - - #[test] - fn select_init_owner_outpoint_skips_disallowed() { - let address = Address::from_str("bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh") - .unwrap() - .require_network(Network::Bitcoin) - .unwrap(); - let script_pub_key = address.script_pubkey(); - let utxo_nft = make_utxo(&"00".repeat(32), 0, script_pub_key.clone(), 1000); - let utxo_ok = make_utxo(&"11".repeat(32), 1, script_pub_key.clone(), 2000); - - let mut disallowed = BTreeSet::new(); - disallowed.insert(OutPoint { - txid: utxo_nft.txid, - vout: utxo_nft.vout, - }); - - let selected = - select_init_owner_outpoint(&[utxo_nft, utxo_ok], &address, &disallowed).unwrap(); - assert_eq!(selected.txid, Txid::from_str(&"11".repeat(32)).unwrap()); - assert_eq!(selected.vout, 1); - } - - #[test] - fn select_init_owner_outpoint_errors_when_only_disallowed() { - let address = Address::from_str("bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh") - .unwrap() - .require_network(Network::Bitcoin) - .unwrap(); - let script_pub_key = address.script_pubkey(); - let utxo_nft = make_utxo(&"00".repeat(32), 0, script_pub_key.clone(), 1000); - - let mut disallowed = BTreeSet::new(); - disallowed.insert(OutPoint { - txid: utxo_nft.txid, - vout: utxo_nft.vout, - }); - - let res = select_init_owner_outpoint(&[utxo_nft], &address, &disallowed); - assert!(res.is_err()); - } } diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 590a11d..c6d9e42 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -4,3 +4,4 @@ mod master_key_store; pub mod passphrase; mod remote_wallet; mod signer; +pub(crate) mod utxo_selection; diff --git a/src/wallet/utxo_selection.rs b/src/wallet/utxo_selection.rs new file mode 100644 index 0000000..5e45ab5 --- /dev/null +++ b/src/wallet/utxo_selection.rs @@ -0,0 +1,155 @@ +use crate::storage::traits::StorageRead; +use anyhow::{anyhow, Context, Result}; +use bitcoin::{Address, OutPoint}; +use bitcoincore_rpc::json::ListUnspentResultEntry; +use std::collections::BTreeSet; + +pub fn ownership_outpoints_for_wallet( + storage: &S, + wallet_utxos: &[ListUnspentResultEntry], +) -> Result> { + let mut wallet_token_outpoints = BTreeSet::new(); + for utxo in wallet_utxos { + let txid = utxo.txid.to_string(); + let vout = utxo.vout; + if storage + .list_unspent_ownership_utxos_by_outpoint(&txid, vout) + .with_context(|| format!("query ownership ranges for {txid}:{vout}"))? + .is_empty() + { + continue; + } + + wallet_token_outpoints.insert(OutPoint { + txid: utxo.txid, + vout, + }); + } + + Ok(wallet_token_outpoints) +} + +pub fn lock_outpoints_for_fees( + ownership_outpoints: &BTreeSet, + spending: &[OutPoint], +) -> Vec { + let spending_set = spending.iter().cloned().collect::>(); + ownership_outpoints + .difference(&spending_set) + .cloned() + .collect() +} + +pub fn select_non_ownership_utxo_for_address( + wallet_utxos: &[ListUnspentResultEntry], + owner_address: &Address, + disallowed: &BTreeSet, +) -> Result { + let script_pubkey = owner_address.script_pubkey(); + let mut best: Option<&ListUnspentResultEntry> = None; + + for utxo in wallet_utxos { + if utxo.script_pub_key != script_pubkey { + continue; + } + let outpoint = OutPoint { + txid: utxo.txid, + vout: utxo.vout, + }; + if disallowed.contains(&outpoint) { + continue; + } + match best { + None => best = Some(utxo), + Some(current) => { + if utxo.amount.to_sat() > current.amount.to_sat() { + best = Some(utxo); + } + } + } + } + + let Some(utxo) = best else { + return Err(anyhow!( + "no spendable UTXO found for init-owner address {} (fund it with a non-NFT UTXO)", + owner_address + )); + }; + + Ok(OutPoint { + txid: utxo.txid, + vout: utxo.vout, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use bitcoin::{Amount, Network, Txid}; + use std::str::FromStr; + + fn make_utxo( + txid_hex: &str, + vout: u32, + script_pub_key: bitcoin::ScriptBuf, + amount_sat: u64, + ) -> ListUnspentResultEntry { + ListUnspentResultEntry { + txid: Txid::from_str(txid_hex).unwrap(), + vout, + address: None, + label: None, + redeem_script: None, + witness_script: None, + script_pub_key, + amount: Amount::from_sat(amount_sat), + confirmations: 1, + spendable: true, + solvable: true, + descriptor: None, + safe: true, + } + } + + #[test] + fn select_non_ownership_utxo_for_address_skips_disallowed() { + let address = Address::from_str("bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh") + .unwrap() + .require_network(Network::Bitcoin) + .unwrap(); + let script_pub_key = address.script_pubkey(); + let utxo_nft = make_utxo(&"00".repeat(32), 0, script_pub_key.clone(), 1000); + let utxo_ok = make_utxo(&"11".repeat(32), 1, script_pub_key.clone(), 2000); + + let mut disallowed = BTreeSet::new(); + disallowed.insert(OutPoint { + txid: utxo_nft.txid, + vout: utxo_nft.vout, + }); + + let selected = + select_non_ownership_utxo_for_address(&[utxo_nft, utxo_ok], &address, &disallowed) + .unwrap(); + assert_eq!(selected.txid, Txid::from_str(&"11".repeat(32)).unwrap()); + assert_eq!(selected.vout, 1); + } + + #[test] + fn select_non_ownership_utxo_for_address_errors_when_only_disallowed() { + let address = Address::from_str("bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh") + .unwrap() + .require_network(Network::Bitcoin) + .unwrap(); + let script_pub_key = address.script_pubkey(); + let utxo_nft = make_utxo(&"00".repeat(32), 0, script_pub_key.clone(), 1000); + + let mut disallowed = BTreeSet::new(); + disallowed.insert(OutPoint { + txid: utxo_nft.txid, + vout: utxo_nft.vout, + }); + + let res = select_non_ownership_utxo_for_address(&[utxo_nft], &address, &disallowed); + assert!(res.is_err()); + } +} From 3186328157eb0a4f72e594620dd970faec4992c6 Mon Sep 17 00:00:00 2001 From: Alessandro Siniscalchi Date: Fri, 30 Jan 2026 17:37:26 +0000 Subject: [PATCH 08/16] require db storage --- src/commands/tx.rs | 100 +++++++-------------- tests/common/mod.rs | 67 +++++++++++++- tests/e2e_register_collection.rs | 4 + tests/e2e_register_ownership.rs | 4 + tests/e2e_register_ownership_init_owner.rs | 46 ++-------- tests/e2e_send_amount.rs | 4 + 6 files changed, 113 insertions(+), 112 deletions(-) diff --git a/src/commands/tx.rs b/src/commands/tx.rs index b6fecab..ab12417 100644 --- a/src/commands/tx.rs +++ b/src/commands/tx.rs @@ -86,21 +86,12 @@ fn run_register_collection( passphrase: Option, ) -> Result<()> { let wallet = load_wallet(ctx)?; - let mut lock_outpoints = Vec::new(); - let db_path = ctx.data_dir.join("brc721.sqlite"); - if db_path.exists() { - let storage = crate::storage::SqliteStorage::new(&db_path); - let wallet_utxos = wallet.list_unspent(0).context("list wallet UTXOs")?; - let ownership_outpoints = - utxo_selection::ownership_outpoints_for_wallet(&storage, &wallet_utxos) - .context("compute ownership outpoints")?; - lock_outpoints = utxo_selection::lock_outpoints_for_fees(&ownership_outpoints, &[]); - } else { - log::warn!( - "scanner database not found at {} (proceeding without ownership UTXO locks)", - db_path.to_string_lossy() - ); - } + let storage = require_scanner_storage(ctx)?; + let wallet_utxos = wallet.list_unspent(0).context("list wallet UTXOs")?; + let ownership_outpoints = + utxo_selection::ownership_outpoints_for_wallet(&storage, &wallet_utxos) + .context("compute ownership outpoints")?; + let lock_outpoints = utxo_selection::lock_outpoints_for_fees(&ownership_outpoints, &[]); let msg = RegisterCollectionData { evm_collection_address, @@ -137,26 +128,11 @@ fn run_register_ownership( ) -> Result<()> { let mut wallet = load_wallet(ctx)?; let wallet_utxos = wallet.list_unspent(0).context("list wallet UTXOs")?; - let mut lock_outpoints = Vec::new(); - let mut ownership_outpoints = BTreeSet::new(); - let db_path = ctx.data_dir.join("brc721.sqlite"); - if db_path.exists() { - let storage = crate::storage::SqliteStorage::new(&db_path); - ownership_outpoints = - utxo_selection::ownership_outpoints_for_wallet(&storage, &wallet_utxos) - .context("compute ownership outpoints")?; - lock_outpoints = utxo_selection::lock_outpoints_for_fees(&ownership_outpoints, &[]); - } else if init_owner.is_some() { - return Err(anyhow!( - "scanner database not found at {} (required when using --init-owner)", - db_path.to_string_lossy() - )); - } else { - log::warn!( - "scanner database not found at {} (proceeding without ownership UTXO locks)", - db_path.to_string_lossy() - ); - } + let storage = require_scanner_storage(ctx)?; + let ownership_outpoints = + utxo_selection::ownership_outpoints_for_wallet(&storage, &wallet_utxos) + .context("compute ownership outpoints")?; + let lock_outpoints = utxo_selection::lock_outpoints_for_fees(&ownership_outpoints, &[]); // Output 1 is the ownership UTXO tracked by the indexer for this registration. let revealed_addresses = wallet.revealed_payment_addresses(); @@ -246,21 +222,12 @@ fn run_send_amount( passphrase: Option, ) -> Result<()> { let wallet = load_wallet(ctx)?; - let mut lock_outpoints = Vec::new(); - let db_path = ctx.data_dir.join("brc721.sqlite"); - if db_path.exists() { - let storage = crate::storage::SqliteStorage::new(&db_path); - let wallet_utxos = wallet.list_unspent(0).context("list wallet UTXOs")?; - let ownership_outpoints = - utxo_selection::ownership_outpoints_for_wallet(&storage, &wallet_utxos) - .context("compute ownership outpoints")?; - lock_outpoints = utxo_selection::lock_outpoints_for_fees(&ownership_outpoints, &[]); - } else { - log::warn!( - "scanner database not found at {} (proceeding without ownership UTXO locks)", - db_path.to_string_lossy() - ); - } + let storage = require_scanner_storage(ctx)?; + let wallet_utxos = wallet.list_unspent(0).context("list wallet UTXOs")?; + let ownership_outpoints = + utxo_selection::ownership_outpoints_for_wallet(&storage, &wallet_utxos) + .context("compute ownership outpoints")?; + let lock_outpoints = utxo_selection::lock_outpoints_for_fees(&ownership_outpoints, &[]); let amount = Amount::from_sat(amount_sat); let address = Address::from_str(to)?.require_network(ctx.network)?; let passphrase = resolve_passphrase(passphrase)?; @@ -280,14 +247,7 @@ fn run_send_assets( fee_rate: Option, passphrase: Option, ) -> Result<()> { - let db_path = ctx.data_dir.join("brc721.sqlite"); - if !db_path.exists() { - return Err(anyhow!( - "scanner database not found at {} (run the daemon to build an index)", - db_path.to_string_lossy() - )); - } - + let storage = require_scanner_storage(ctx)?; let token_outpoints = parse_outpoints(outpoints)?; if token_outpoints.is_empty() { return Err(anyhow!("at least one --outpoint is required")); @@ -297,8 +257,6 @@ fn run_send_assets( return Err(anyhow!("duplicate --outpoint provided")); } - let storage = crate::storage::SqliteStorage::new(&db_path); - for outpoint in &token_outpoints { let groups = storage .list_unspent_ownership_utxos_by_outpoint(&outpoint.txid.to_string(), outpoint.vout) @@ -385,14 +343,7 @@ fn run_mix( fee_rate: Option, passphrase: Option, ) -> Result<()> { - let db_path = ctx.data_dir.join("brc721.sqlite"); - if !db_path.exists() { - return Err(anyhow!( - "scanner database not found at {} (run the daemon to build an index)", - db_path.to_string_lossy() - )); - } - + let storage = require_scanner_storage(ctx)?; let token_outpoints = parse_outpoints(outpoints)?; if token_outpoints.is_empty() { return Err(anyhow!("at least one --outpoint is required")); @@ -404,8 +355,6 @@ fn run_mix( let (output_addresses, mix_data) = parse_mix_outputs(outputs, ctx.network)?; - let storage = crate::storage::SqliteStorage::new(&db_path); - let mut total_tokens = 0u128; for outpoint in &token_outpoints { let groups = storage @@ -507,6 +456,17 @@ fn run_mix( Ok(()) } +fn require_scanner_storage(ctx: &context::Context) -> Result { + let db_path = ctx.data_dir.join("brc721.sqlite"); + if !db_path.exists() { + return Err(anyhow!( + "scanner database not found at {} (run the daemon to build an index)", + db_path.to_string_lossy() + )); + } + Ok(crate::storage::SqliteStorage::new(&db_path)) +} + fn load_wallet(ctx: &context::Context) -> Result { Brc721Wallet::load(&ctx.data_dir, ctx.network, &ctx.rpc_url, ctx.auth.clone()) } diff --git a/tests/common/mod.rs b/tests/common/mod.rs index dfb8057..5df7f84 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -1,6 +1,9 @@ use bitcoin::Address; -use std::process::Command as ProcCommand; +use std::path::Path; +use std::process::{Child, Command as ProcCommand, ExitStatus, Stdio}; use std::str::FromStr; +use std::thread::sleep; +use std::time::{Duration, Instant}; use tempfile::TempDir; use testcontainers::core::{ContainerPort, WaitFor}; use testcontainers::{Container, ContainerRequest, GenericImage, ImageExt}; @@ -51,6 +54,68 @@ pub fn base_cmd(rpc_url: &String, data_dir: &TempDir) -> ProcCommand { command } +#[allow(dead_code)] +pub struct DaemonGuard { + child: Child, +} + +#[allow(dead_code)] +impl DaemonGuard { + fn new(child: Child) -> Self { + Self { child } + } + + pub fn try_wait(&mut self) -> Option { + self.child.try_wait().expect("try_wait") + } + + pub fn stop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +#[allow(dead_code)] +impl Drop for DaemonGuard { + fn drop(&mut self) { + self.stop(); + } +} + +#[allow(dead_code)] +pub fn start_daemon( + rpc_url: &String, + data_dir: &TempDir, + log_path: Option<&Path>, +) -> DaemonGuard { + let mut cmd = base_cmd(rpc_url, data_dir); + cmd.arg("--start").arg("0").arg("--confirmations").arg("0"); + if let Some(path) = log_path { + cmd.arg("--log-file").arg(path); + } + cmd.stdout(Stdio::null()).stderr(Stdio::null()); + + DaemonGuard::new(cmd.spawn().expect("start daemon")) +} + +#[allow(dead_code)] +pub fn wait_for_scanner_db(data_dir: &TempDir) { + let db_path = data_dir.path().join("regtest").join("brc721.sqlite"); + let deadline = Instant::now() + Duration::from_secs(10); + loop { + if db_path.exists() { + return; + } + if Instant::now() > deadline { + panic!( + "timed out waiting for scanner database at {}", + db_path.display() + ); + } + sleep(Duration::from_millis(50)); + } +} + pub fn wallet_address(rpc_url: &String, data_dir: &TempDir) -> Address { let output = base_cmd(rpc_url, data_dir) .arg("wallet") diff --git a/tests/e2e_register_collection.rs b/tests/e2e_register_collection.rs index 5b0025c..5c5c922 100644 --- a/tests/e2e_register_collection.rs +++ b/tests/e2e_register_collection.rs @@ -34,6 +34,10 @@ fn e2e_register_collection() { // Fund wallet so it can broadcast root_client.generate_to_address(101, &addr).expect("mine"); + let mut daemon = common::start_daemon(&rpc_url, &data_dir, None); + common::wait_for_scanner_db(&data_dir); + daemon.stop(); + // Send register-collection let output = common::base_cmd(&rpc_url, &data_dir) .arg("tx") diff --git a/tests/e2e_register_ownership.rs b/tests/e2e_register_ownership.rs index 2df59d2..57a6669 100644 --- a/tests/e2e_register_ownership.rs +++ b/tests/e2e_register_ownership.rs @@ -107,6 +107,10 @@ fn e2e_register_ownership_broadcasts_and_has_expected_outputs() { // Fund wallet so it can broadcast root_client.generate_to_address(101, &addr).expect("mine"); + let mut daemon = common::start_daemon(&rpc_url, &data_dir, None); + common::wait_for_scanner_db(&data_dir); + daemon.stop(); + // Register a collection so we can use a real collection id (HEIGHT:TX_INDEX) let output = common::base_cmd(&rpc_url, &data_dir) .arg("tx") diff --git a/tests/e2e_register_ownership_init_owner.rs b/tests/e2e_register_ownership_init_owner.rs index 1a2a838..d54db9d 100644 --- a/tests/e2e_register_ownership_init_owner.rs +++ b/tests/e2e_register_ownership_init_owner.rs @@ -2,7 +2,7 @@ use bitcoin::Address; use bitcoincore_rpc::{Auth, Client, RpcApi}; use serde_json::json; use std::fs; -use std::process::{Child, ExitStatus, Output, Stdio}; +use std::process::Output; use std::str::FromStr; use std::thread::sleep; use std::time::{Duration, Instant}; @@ -80,31 +80,6 @@ fn collection_id_for_confirmed_tx(root: &Client, txid: &bitcoin::Txid) -> (u64, (height, tx_index) } -struct DaemonGuard { - child: Child, -} - -impl DaemonGuard { - fn new(child: Child) -> Self { - Self { child } - } - - fn try_wait(&mut self) -> Option { - self.child.try_wait().expect("try_wait") - } - - fn stop(&mut self) { - let _ = self.child.kill(); - let _ = self.child.wait(); - } -} - -impl Drop for DaemonGuard { - fn drop(&mut self) { - self.stop(); - } -} - #[test] fn e2e_register_ownership_init_owner_rejects_nft_utxo() { let image = common::bitcoind_image(); @@ -130,6 +105,10 @@ fn e2e_register_ownership_init_owner_rejects_nft_utxo() { let addr = common::wallet_address(&rpc_url, &data_dir); root_client.generate_to_address(101, &addr).expect("mine"); + let log_path = data_dir.path().join("daemon.log"); + let mut daemon = common::start_daemon(&rpc_url, &data_dir, Some(&log_path)); + common::wait_for_scanner_db(&data_dir); + // Register a collection so we can use a real collection id (HEIGHT:TX_INDEX) let output = common::base_cmd(&rpc_url, &data_dir) .arg("tx") @@ -174,21 +153,6 @@ fn e2e_register_ownership_init_owner_rejects_nft_utxo() { let (ownership_height, ownership_tx_index) = collection_id_for_confirmed_tx(&root_client, &ownership_txid); - // Start the daemon to build the scanner DB and index the ownership UTXO. - let log_path = data_dir.path().join("daemon.log"); - let mut daemon_cmd = common::base_cmd(&rpc_url, &data_dir); - daemon_cmd - .arg("--start") - .arg("0") - .arg("--confirmations") - .arg("0") - .arg("--log-file") - .arg(&log_path) - .stdout(Stdio::null()) - .stderr(Stdio::null()); - - let mut daemon = DaemonGuard::new(daemon_cmd.spawn().expect("start daemon")); - let deadline = Instant::now() + Duration::from_secs(20); let needle = format!( "register-ownership indexed (block {} tx {}", diff --git a/tests/e2e_send_amount.rs b/tests/e2e_send_amount.rs index 7769ef0..6c7e37c 100644 --- a/tests/e2e_send_amount.rs +++ b/tests/e2e_send_amount.rs @@ -31,6 +31,10 @@ fn e2e_send_amount() { // Mine coins to wallet A so it has UTXOs root_client.generate_to_address(101, &addr_a).expect("mine"); + let mut daemon = common::start_daemon(&rpc_url, &data_dir_a, None); + common::wait_for_scanner_db(&data_dir_a); + daemon.stop(); + // Wallet B: create and get receive address let data_dir_b = TempDir::new().expect("temp dir"); let output = common::base_cmd(&rpc_url, &data_dir_b) From c60f356d0c9f1a87ad8ab702aee0a3e252bdf78e Mon Sep 17 00:00:00 2001 From: Alessandro Siniscalchi Date: Sat, 31 Jan 2026 07:39:26 +0000 Subject: [PATCH 09/16] register collection of already registered one is skipped --- src/parser/register_collection.rs | 80 ++++++++++++++++++++++++++++++- 1 file changed, 78 insertions(+), 2 deletions(-) diff --git a/src/parser/register_collection.rs b/src/parser/register_collection.rs index 1e34dda..9aa0742 100644 --- a/src/parser/register_collection.rs +++ b/src/parser/register_collection.rs @@ -1,13 +1,30 @@ -use crate::storage::traits::{CollectionKey, StorageWrite}; +use crate::storage::traits::{CollectionKey, StorageRead, StorageWrite}; use crate::types::{Brc721Error, Brc721Tx, RegisterCollectionData}; -pub fn digest( +pub fn digest( payload: &RegisterCollectionData, _brc721_tx: &Brc721Tx<'_>, storage: &S, block_height: u64, tx_index: u32, ) -> Result<(), Brc721Error> { + let existing = storage + .list_collections() + .map_err(|e| Brc721Error::StorageError(e.to_string()))? + .into_iter() + .find(|collection| collection.evm_collection_address == payload.evm_collection_address); + + if let Some(existing) = existing { + log::warn!( + "register-collection already registered for evm_address={:#x} (existing={}, skipping {}:{})", + payload.evm_collection_address, + existing.key, + block_height, + tx_index + ); + return Ok(()); + } + let key = CollectionKey::new(block_height, tx_index); storage @@ -15,3 +32,62 @@ pub fn digest( .map_err(|e| Brc721Error::StorageError(e.to_string()))?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::storage::SqliteStorage; + use crate::storage::traits::{Storage, StorageTx}; + use crate::types::{parse_brc721_tx, Brc721OpReturnOutput, Brc721Payload}; + use bitcoin::absolute; + use bitcoin::{transaction, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, Witness}; + use ethereum_types::H160; + + fn build_register_collection_tx(payload: RegisterCollectionData) -> Transaction { + let op_return = Brc721OpReturnOutput::new(Brc721Payload::RegisterCollection(payload)) + .into_txout() + .expect("build op_return output"); + let txin = TxIn { + previous_output: OutPoint::null(), + script_sig: ScriptBuf::new(), + sequence: Sequence::MAX, + witness: Witness::default(), + }; + Transaction { + version: transaction::Version(2), + lock_time: absolute::LockTime::ZERO, + input: vec![txin], + output: vec![op_return], + } + } + + #[test] + fn register_collection_skips_duplicate() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let storage = SqliteStorage::new(temp_dir.path().join("dup_collection.db")); + storage.init().expect("init db"); + + let payload = RegisterCollectionData { + evm_collection_address: H160::from_low_u64_be(42), + rebaseable: false, + }; + let collection_tx = build_register_collection_tx(payload.clone()); + let brc721_tx = parse_brc721_tx(&collection_tx) + .expect("parse tx") + .expect("brc721 tx"); + + let db_tx = storage.begin_tx().expect("begin tx"); + + digest(&payload, &brc721_tx, &db_tx, 100, 0).expect("first digest"); + digest(&payload, &brc721_tx, &db_tx, 101, 1).expect("second digest"); + db_tx.commit().expect("commit tx"); + + let collections = storage.list_collections().expect("list collections"); + assert_eq!(collections.len(), 1); + assert_eq!(collections[0].key.to_string(), "100:0"); + assert_eq!( + collections[0].evm_collection_address, + payload.evm_collection_address + ); + } +} From a548da37758cffbdb28fd8a574816f7edc961bae Mon Sep 17 00:00:00 2001 From: Alessandro Siniscalchi Date: Sat, 31 Jan 2026 07:48:57 +0000 Subject: [PATCH 10/16] register ownership overlap are skipped --- src/parser/register_ownership.rs | 158 ++++++++++++++++++++++++++++++- src/storage/sqlite.rs | 71 ++++++++++++++ src/storage/traits.rs | 26 +++++ 3 files changed, 253 insertions(+), 2 deletions(-) diff --git a/src/parser/register_ownership.rs b/src/parser/register_ownership.rs index d315296..c799d8a 100644 --- a/src/parser/register_ownership.rs +++ b/src/parser/register_ownership.rs @@ -145,6 +145,25 @@ pub fn digest( return Ok(()); } + for range in payload.groups.iter().flat_map(|group| group.ranges.iter()) { + let overlaps = storage + .has_unspent_ownership_overlap(&collection_key, base_h160, range.start, range.end) + .map_err(|e| Brc721Error::StorageError(e.to_string()))?; + if overlaps { + log::warn!( + "register-ownership overlaps existing assets (block {} tx {}, collection {}, slots {}..={}, input0_prevout={:?}, base_address={})", + block_height, + tx_index, + collection_key, + range.start, + range.end, + input0_prevout, + base_h160_log + ); + return Ok(()); + } + } + let txid = brc721_tx.txid().to_string(); for (group_index, group) in payload.groups.iter().enumerate() { @@ -224,13 +243,16 @@ mod tests { use super::*; use crate::storage::traits::{ Collection, CollectionKey, OwnershipRange, OwnershipRangeWithGroup, OwnershipUtxo, - OwnershipUtxoSave, StorageRead, StorageWrite, + OwnershipUtxoSave, Storage, StorageRead, StorageTx, StorageWrite, }; + use crate::storage::SqliteStorage; use crate::types::{Brc721OpReturnOutput, Brc721Payload, SlotRanges}; use anyhow::Result as AnyResult; use bitcoin::blockdata::transaction::Version; + use bitcoin::hashes::Hash; use bitcoin::{ - absolute, Amount, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Witness, + absolute, Amount, OutPoint, PubkeyHash, ScriptBuf, Sequence, Transaction, TxIn, TxOut, + Txid, Witness, }; use bitcoincore_rpc::Error as RpcError; use ethereum_types::H160; @@ -265,6 +287,63 @@ mod tests { } } + struct FixedRpc { + prev_tx: Transaction, + } + + impl BitcoinRpc for FixedRpc { + fn get_block_count(&self) -> Result { + unimplemented!() + } + fn get_block_hash(&self, _height: u64) -> Result { + unimplemented!() + } + fn get_block(&self, _hash: &bitcoin::BlockHash) -> Result { + unimplemented!() + } + fn get_raw_transaction( + &self, + _txid: &bitcoin::Txid, + ) -> Result { + Ok(self.prev_tx.clone()) + } + fn wait_for_new_block(&self, _timeout: u64) -> Result<(), RpcError> { + unimplemented!() + } + } + + fn build_register_ownership_tx( + payload: &RegisterOwnershipData, + prev_txid: Txid, + prev_vout: u32, + owner_script: ScriptBuf, + ) -> Transaction { + let op_return = + Brc721OpReturnOutput::new(Brc721Payload::RegisterOwnership(payload.clone())) + .into_txout() + .expect("opreturn txout"); + Transaction { + version: Version(2), + lock_time: absolute::LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint { + txid: prev_txid, + vout: prev_vout, + }, + script_sig: ScriptBuf::new(), + sequence: Sequence::MAX, + witness: Witness::default(), + }], + output: vec![ + op_return, + TxOut { + value: Amount::from_sat(546), + script_pubkey: owner_script, + }, + ], + } + } + struct DummyStorage { collection: CollectionKey, } @@ -402,4 +481,79 @@ mod tests { let err = digest(&payload, &brc721_tx, &rpc, &storage, 10, 0).unwrap_err(); assert!(format!("{err}").contains("txindex")); } + + #[test] + fn register_ownership_skips_duplicate_assets() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let storage = SqliteStorage::new(temp_dir.path().join("dup_ownership.db")); + storage.init().expect("init db"); + + let collection_key = CollectionKey::new(100, 0); + let setup_tx = storage.begin_tx().expect("begin tx"); + setup_tx + .save_collection(collection_key.clone(), H160::from_low_u64_be(1), false) + .expect("save collection"); + setup_tx.commit().expect("commit collection"); + + let slots = SlotRanges::from_str("0..=1").expect("slots parse"); + let payload = RegisterOwnershipData::for_single_output( + collection_key.block_height, + collection_key.tx_index, + slots, + ) + .expect("payload"); + + let owner_script = ScriptBuf::new_p2pkh(&PubkeyHash::hash(b"owner")); + let prev_script = ScriptBuf::new_p2pkh(&PubkeyHash::hash(b"base")); + let prev_tx = Transaction { + version: Version(2), + lock_time: absolute::LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint::null(), + script_sig: ScriptBuf::new(), + sequence: Sequence::MAX, + witness: Witness::default(), + }], + output: vec![TxOut { + value: Amount::from_sat(1000), + script_pubkey: prev_script, + }], + }; + let rpc = FixedRpc { prev_tx }; + + let tx_a = build_register_ownership_tx( + &payload, + Txid::from_str(&"11".repeat(32)).unwrap(), + 0, + owner_script.clone(), + ); + let brc721_a = crate::types::parse_brc721_tx(&tx_a) + .expect("parse tx a") + .expect("brc721 tx a"); + + let tx_b = build_register_ownership_tx( + &payload, + Txid::from_str(&"22".repeat(32)).unwrap(), + 0, + owner_script, + ); + let brc721_b = crate::types::parse_brc721_tx(&tx_b) + .expect("parse tx b") + .expect("brc721 tx b"); + + let db_tx = storage.begin_tx().expect("begin tx"); + digest(&payload, &brc721_a, &rpc, &db_tx, 200, 0).expect("first digest"); + digest(&payload, &brc721_b, &rpc, &db_tx, 201, 0).expect("second digest"); + db_tx.commit().expect("commit digests"); + + let utxos_a = storage + .list_unspent_ownership_utxos_by_outpoint(&tx_a.compute_txid().to_string(), 1) + .expect("list utxos a"); + let utxos_b = storage + .list_unspent_ownership_utxos_by_outpoint(&tx_b.compute_txid().to_string(), 1) + .expect("list utxos b"); + + assert_eq!(utxos_a.len(), 1); + assert!(utxos_b.is_empty()); + } } diff --git a/src/storage/sqlite.rs b/src/storage/sqlite.rs index 09c5149..191c8b9 100644 --- a/src/storage/sqlite.rs +++ b/src/storage/sqlite.rs @@ -356,6 +356,48 @@ fn db_find_unspent_ownership_utxo_for_slot( .optional() } +fn db_has_unspent_ownership_overlap( + conn: &Connection, + collection_id: &CollectionKey, + base_h160: H160, + slot_start: u128, + slot_end: u128, +) -> rusqlite::Result { + if slot_start > slot_end { + return Ok(false); + } + let start_blob = encode_slot96(slot_start); + let end_blob = encode_slot96(slot_end); + let found: Option = conn + .query_row( + r#" + SELECT 1 + FROM ownership_utxos u + JOIN ownership_ranges r + ON r.reg_txid = u.reg_txid + AND r.reg_vout = u.reg_vout + AND r.collection_id = u.collection_id + AND r.base_h160 = u.base_h160 + WHERE + u.collection_id = ?1 + AND u.base_h160 = ?2 + AND u.spent_txid IS NULL + AND r.slot_start <= ?3 + AND r.slot_end >= ?4 + LIMIT 1 + "#, + params![ + collection_id.to_string(), + format!("0x{:x}", base_h160), + end_blob.as_slice(), + start_blob.as_slice() + ], + |row| row.get(0), + ) + .optional()?; + Ok(found.is_some()) +} + fn db_list_unspent_ownership_utxos_by_owner( conn: &Connection, owner_h160: H160, @@ -523,6 +565,22 @@ impl StorageRead for SqliteTx { )?) } + fn has_unspent_ownership_overlap( + &self, + collection_id: &CollectionKey, + base_h160: H160, + slot_start: u128, + slot_end: u128, + ) -> Result { + Ok(db_has_unspent_ownership_overlap( + &self.conn, + collection_id, + base_h160, + slot_start, + slot_end, + )?) + } + fn list_unspent_ownership_utxos_by_owner( &self, owner_h160: H160, @@ -798,6 +856,19 @@ impl StorageRead for SqliteStorage { Ok(row) } + fn has_unspent_ownership_overlap( + &self, + collection_id: &CollectionKey, + base_h160: H160, + slot_start: u128, + slot_end: u128, + ) -> Result { + let found = self.with_conn(|conn| { + db_has_unspent_ownership_overlap(conn, collection_id, base_h160, slot_start, slot_end) + })?; + Ok(found) + } + fn list_unspent_ownership_utxos_by_owner( &self, owner_h160: H160, diff --git a/src/storage/traits.rs b/src/storage/traits.rs index db7dfa0..3b39471 100644 --- a/src/storage/traits.rs +++ b/src/storage/traits.rs @@ -73,6 +73,32 @@ pub trait StorageRead { ) -> Result>; fn list_unspent_ownership_utxos_by_owner(&self, owner_h160: H160) -> Result>; + + fn has_unspent_ownership_overlap( + &self, + collection_id: &CollectionKey, + base_h160: H160, + slot_start: u128, + slot_end: u128, + ) -> Result { + if slot_start > slot_end { + return Ok(false); + } + let mut slot = slot_start; + loop { + if self + .find_unspent_ownership_utxo_for_slot(collection_id, base_h160, slot)? + .is_some() + { + return Ok(true); + } + if slot == slot_end { + break; + } + slot += 1; + } + Ok(false) + } } pub trait StorageWrite { From a86fd08e8e4c9c5c1fa297b7807cdf0daf78c59f Mon Sep 17 00:00:00 2001 From: Alessandro Siniscalchi Date: Sat, 31 Jan 2026 07:52:08 +0000 Subject: [PATCH 11/16] increase schema version --- src/parser/register_collection.rs | 2 +- src/storage/sqlite.rs | 2 +- tests/common/mod.rs | 6 +----- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/parser/register_collection.rs b/src/parser/register_collection.rs index 9aa0742..bf1e7f8 100644 --- a/src/parser/register_collection.rs +++ b/src/parser/register_collection.rs @@ -36,8 +36,8 @@ pub fn digest( #[cfg(test)] mod tests { use super::*; - use crate::storage::SqliteStorage; use crate::storage::traits::{Storage, StorageTx}; + use crate::storage::SqliteStorage; use crate::types::{parse_brc721_tx, Brc721OpReturnOutput, Brc721Payload}; use bitcoin::absolute; use bitcoin::{transaction, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, Witness}; diff --git a/src/storage/sqlite.rs b/src/storage/sqlite.rs index 191c8b9..8f1805c 100644 --- a/src/storage/sqlite.rs +++ b/src/storage/sqlite.rs @@ -11,7 +11,7 @@ use super::{ Block, }; -const DB_SCHEMA_VERSION: i64 = 7; +const DB_SCHEMA_VERSION: i64 = 8; #[derive(Clone)] pub struct SqliteStorage { diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 5df7f84..b564977 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -83,11 +83,7 @@ impl Drop for DaemonGuard { } #[allow(dead_code)] -pub fn start_daemon( - rpc_url: &String, - data_dir: &TempDir, - log_path: Option<&Path>, -) -> DaemonGuard { +pub fn start_daemon(rpc_url: &String, data_dir: &TempDir, log_path: Option<&Path>) -> DaemonGuard { let mut cmd = base_cmd(rpc_url, data_dir); cmd.arg("--start").arg("0").arg("--confirmations").arg("0"); if let Some(path) = log_path { From 0a73f8ca2e9e8d88a4a2e8bad20164ee0630b454 Mon Sep 17 00:00:00 2001 From: Alessandro Siniscalchi Date: Sat, 31 Jan 2026 10:05:07 +0000 Subject: [PATCH 12/16] skip register ownership' ' --- src/parser/register_ownership.rs | 49 +++++++++++++++++++------------- 1 file changed, 30 insertions(+), 19 deletions(-) diff --git a/src/parser/register_ownership.rs b/src/parser/register_ownership.rs index c799d8a..63ee065 100644 --- a/src/parser/register_ownership.rs +++ b/src/parser/register_ownership.rs @@ -288,7 +288,10 @@ mod tests { } struct FixedRpc { - prev_tx: Transaction, + first_txid: Txid, + first_tx: Transaction, + second_txid: Txid, + second_tx: Transaction, } impl BitcoinRpc for FixedRpc { @@ -303,9 +306,21 @@ mod tests { } fn get_raw_transaction( &self, - _txid: &bitcoin::Txid, + txid: &bitcoin::Txid, ) -> Result { - Ok(self.prev_tx.clone()) + if txid == &self.first_txid { + Ok(self.first_tx.clone()) + } else if txid == &self.second_txid { + Ok(self.second_tx.clone()) + } else { + Err(RpcError::JsonRpc(bitcoincore_rpc::jsonrpc::Error::Rpc( + bitcoincore_rpc::jsonrpc::error::RpcError { + code: -5, + message: "No such mempool or blockchain transaction. Use -txindex or provide a block hash.".into(), + data: None, + }, + ))) + } } fn wait_for_new_block(&self, _timeout: u64) -> Result<(), RpcError> { unimplemented!() @@ -504,7 +519,6 @@ mod tests { .expect("payload"); let owner_script = ScriptBuf::new_p2pkh(&PubkeyHash::hash(b"owner")); - let prev_script = ScriptBuf::new_p2pkh(&PubkeyHash::hash(b"base")); let prev_tx = Transaction { version: Version(2), lock_time: absolute::LockTime::ZERO, @@ -516,27 +530,24 @@ mod tests { }], output: vec![TxOut { value: Amount::from_sat(1000), - script_pubkey: prev_script, + script_pubkey: owner_script.clone(), }], }; - let rpc = FixedRpc { prev_tx }; - - let tx_a = build_register_ownership_tx( - &payload, - Txid::from_str(&"11".repeat(32)).unwrap(), - 0, - owner_script.clone(), - ); + let prev_txid = Txid::from_str(&"11".repeat(32)).unwrap(); + + let tx_a = build_register_ownership_tx(&payload, prev_txid, 0, owner_script.clone()); + let tx_aid = tx_a.compute_txid(); + let tx_b = build_register_ownership_tx(&payload, tx_aid, 1, owner_script); + let rpc = FixedRpc { + first_txid: prev_txid, + first_tx: prev_tx, + second_txid: tx_aid, + second_tx: tx_a.clone(), + }; let brc721_a = crate::types::parse_brc721_tx(&tx_a) .expect("parse tx a") .expect("brc721 tx a"); - let tx_b = build_register_ownership_tx( - &payload, - Txid::from_str(&"22".repeat(32)).unwrap(), - 0, - owner_script, - ); let brc721_b = crate::types::parse_brc721_tx(&tx_b) .expect("parse tx b") .expect("brc721 tx b"); From 7021d303a062af0cb9f7abc27c407c338cb05407 Mon Sep 17 00:00:00 2001 From: Alessandro Siniscalchi Date: Sat, 31 Jan 2026 11:42:23 +0000 Subject: [PATCH 13/16] add failing test for overlap register ownership --- src/parser/brc721_parser.rs | 193 ++++++++++++++++++++++++++++++++++++ 1 file changed, 193 insertions(+) diff --git a/src/parser/brc721_parser.rs b/src/parser/brc721_parser.rs index 9b5915d..ae6b670 100644 --- a/src/parser/brc721_parser.rs +++ b/src/parser/brc721_parser.rs @@ -1616,6 +1616,199 @@ mod tests { assert_eq!(*storage.inner.last_hash.lock().unwrap(), None); } + #[test] + fn register_ownership_overlap_after_spend_is_not_skipped() { + use crate::types::{ + Brc721OpReturnOutput, Brc721Payload, RegisterCollectionData, RegisterOwnershipData, + SlotRanges, + }; + use bitcoin::{absolute, transaction, PubkeyHash, Sequence, Txid, Witness}; + use std::collections::BTreeMap; + use std::str::FromStr; + + struct MapRpc { + txs: BTreeMap, + } + + impl crate::bitcoin_rpc::BitcoinRpc for MapRpc { + fn get_block_count(&self) -> Result { + unimplemented!() + } + fn get_block_hash(&self, _height: u64) -> Result { + unimplemented!() + } + fn get_block(&self, _hash: &bitcoin::BlockHash) -> Result { + unimplemented!() + } + fn get_raw_transaction( + &self, + txid: &bitcoin::Txid, + ) -> Result { + self.txs.get(txid).cloned().ok_or_else(|| { + RpcError::JsonRpc(bitcoincore_rpc::jsonrpc::Error::Rpc( + bitcoincore_rpc::jsonrpc::error::RpcError { + code: -5, + message: "No such mempool or blockchain transaction. Use -txindex or provide a block hash.".into(), + data: None, + }, + )) + }) + } + fn wait_for_new_block(&self, _timeout: u64) -> Result<(), RpcError> { + unimplemented!() + } + } + + let block_height = 900_000; + let temp_dir = tempfile::tempdir().expect("temp dir"); + let storage = + crate::storage::SqliteStorage::new(temp_dir.path().join("brc721_overlap.db")); + storage.init().expect("init db"); + + let collection_payload = RegisterCollectionData { + evm_collection_address: H160::from_low_u64_be(1), + rebaseable: false, + }; + let collection_op_return = + Brc721OpReturnOutput::new(Brc721Payload::RegisterCollection(collection_payload)) + .into_txout() + .expect("collection op_return"); + let collection_tx = Transaction { + version: transaction::Version(2), + lock_time: absolute::LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint::null(), + script_sig: ScriptBuf::new(), + sequence: Sequence::MAX, + witness: Witness::default(), + }], + output: vec![collection_op_return], + }; + + let owner_script = ScriptBuf::new_p2pkh(&PubkeyHash::hash(b"owner")); + let prev_tx = Transaction { + version: transaction::Version(2), + lock_time: absolute::LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint::null(), + script_sig: ScriptBuf::new(), + sequence: Sequence::MAX, + witness: Witness::default(), + }], + output: vec![TxOut { + value: Amount::from_sat(1000), + script_pubkey: owner_script.clone(), + }], + }; + let prev_txid = Txid::from_str(&"11".repeat(32)).unwrap(); + + let payload_a = RegisterOwnershipData::for_single_output( + block_height, + 0, + SlotRanges::from_str("0").expect("slots parse"), + ) + .expect("payload a"); + let op_return_a = + Brc721OpReturnOutput::new(Brc721Payload::RegisterOwnership(payload_a.clone())) + .into_txout() + .expect("op_return a"); + let tx_a = Transaction { + version: transaction::Version(2), + lock_time: absolute::LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint { + txid: prev_txid, + vout: 0, + }, + script_sig: ScriptBuf::new(), + sequence: Sequence::MAX, + witness: Witness::default(), + }], + output: vec![ + op_return_a, + TxOut { + value: Amount::from_sat(546), + script_pubkey: owner_script.clone(), + }, + ], + }; + let tx_aid = tx_a.compute_txid(); + + let payload_b = RegisterOwnershipData::for_single_output( + block_height, + 0, + SlotRanges::from_str("0..=1").expect("slots parse"), + ) + .expect("payload b"); + let op_return_b = + Brc721OpReturnOutput::new(Brc721Payload::RegisterOwnership(payload_b.clone())) + .into_txout() + .expect("op_return b"); + let tx_b = Transaction { + version: transaction::Version(2), + lock_time: absolute::LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint { + txid: tx_aid, + vout: 1, + }, + script_sig: ScriptBuf::new(), + sequence: Sequence::MAX, + witness: Witness::default(), + }], + output: vec![ + op_return_b, + TxOut { + value: Amount::from_sat(546), + script_pubkey: owner_script, + }, + ], + }; + let tx_bid = tx_b.compute_txid(); + + let mut txs = BTreeMap::new(); + txs.insert(prev_txid, prev_tx); + txs.insert(tx_aid, tx_a.clone()); + let rpc = MapRpc { txs }; + + let header = bitcoin::block::Header { + version: bitcoin::block::Version::ONE, + prev_blockhash: bitcoin::BlockHash::from_raw_hash( + bitcoin::hashes::sha256d::Hash::all_zeros(), + ), + merkle_root: bitcoin::TxMerkleNode::from_raw_hash( + bitcoin::hashes::sha256d::Hash::all_zeros(), + ), + time: 0, + bits: bitcoin::CompactTarget::from_consensus(0), + nonce: 0, + }; + let block = Block { + header, + txdata: vec![collection_tx, tx_a, tx_b], + }; + + let parser = Brc721Parser::new(); + let tx = storage.begin_tx().expect("begin tx"); + parser + .parse_block(&tx, &block, block_height, &rpc) + .expect("parse block"); + tx.commit().expect("commit"); + + let mut ranges = storage + .list_unspent_ownership_ranges_by_outpoint(&tx_bid.to_string(), 1) + .expect("list ranges"); + ranges.sort_by(|a, b| { + a.slot_start + .cmp(&b.slot_start) + .then_with(|| a.slot_end.cmp(&b.slot_end)) + }); + + assert_eq!(ranges.len(), 1); + assert_eq!(ranges[0].slot_start, 0); + assert_eq!(ranges[0].slot_end, 0); + } + #[test] fn parse_block_ignores_register_ownership_for_unknown_collection() { use crate::types::{Brc721OpReturnOutput, RegisterOwnershipData, SlotRanges}; From 11d82b839845cbfd9cd77380fc06fa9bfc0bdd42 Mon Sep 17 00:00:00 2001 From: Alessandro Siniscalchi Date: Sat, 31 Jan 2026 11:50:05 +0000 Subject: [PATCH 14/16] removed unuseful unit test --- src/parser/brc721_parser.rs | 3 +- src/parser/register_ownership.rs | 150 +------------------------------ 2 files changed, 3 insertions(+), 150 deletions(-) diff --git a/src/parser/brc721_parser.rs b/src/parser/brc721_parser.rs index ae6b670..69dbb38 100644 --- a/src/parser/brc721_parser.rs +++ b/src/parser/brc721_parser.rs @@ -1661,8 +1661,7 @@ mod tests { let block_height = 900_000; let temp_dir = tempfile::tempdir().expect("temp dir"); - let storage = - crate::storage::SqliteStorage::new(temp_dir.path().join("brc721_overlap.db")); + let storage = crate::storage::SqliteStorage::new(temp_dir.path().join("brc721_overlap.db")); storage.init().expect("init db"); let collection_payload = RegisterCollectionData { diff --git a/src/parser/register_ownership.rs b/src/parser/register_ownership.rs index 63ee065..1eeb3ee 100644 --- a/src/parser/register_ownership.rs +++ b/src/parser/register_ownership.rs @@ -243,16 +243,13 @@ mod tests { use super::*; use crate::storage::traits::{ Collection, CollectionKey, OwnershipRange, OwnershipRangeWithGroup, OwnershipUtxo, - OwnershipUtxoSave, Storage, StorageRead, StorageTx, StorageWrite, + OwnershipUtxoSave, StorageRead, StorageWrite, }; - use crate::storage::SqliteStorage; use crate::types::{Brc721OpReturnOutput, Brc721Payload, SlotRanges}; use anyhow::Result as AnyResult; use bitcoin::blockdata::transaction::Version; - use bitcoin::hashes::Hash; use bitcoin::{ - absolute, Amount, OutPoint, PubkeyHash, ScriptBuf, Sequence, Transaction, TxIn, TxOut, - Txid, Witness, + absolute, Amount, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Witness, }; use bitcoincore_rpc::Error as RpcError; use ethereum_types::H160; @@ -287,78 +284,6 @@ mod tests { } } - struct FixedRpc { - first_txid: Txid, - first_tx: Transaction, - second_txid: Txid, - second_tx: Transaction, - } - - impl BitcoinRpc for FixedRpc { - fn get_block_count(&self) -> Result { - unimplemented!() - } - fn get_block_hash(&self, _height: u64) -> Result { - unimplemented!() - } - fn get_block(&self, _hash: &bitcoin::BlockHash) -> Result { - unimplemented!() - } - fn get_raw_transaction( - &self, - txid: &bitcoin::Txid, - ) -> Result { - if txid == &self.first_txid { - Ok(self.first_tx.clone()) - } else if txid == &self.second_txid { - Ok(self.second_tx.clone()) - } else { - Err(RpcError::JsonRpc(bitcoincore_rpc::jsonrpc::Error::Rpc( - bitcoincore_rpc::jsonrpc::error::RpcError { - code: -5, - message: "No such mempool or blockchain transaction. Use -txindex or provide a block hash.".into(), - data: None, - }, - ))) - } - } - fn wait_for_new_block(&self, _timeout: u64) -> Result<(), RpcError> { - unimplemented!() - } - } - - fn build_register_ownership_tx( - payload: &RegisterOwnershipData, - prev_txid: Txid, - prev_vout: u32, - owner_script: ScriptBuf, - ) -> Transaction { - let op_return = - Brc721OpReturnOutput::new(Brc721Payload::RegisterOwnership(payload.clone())) - .into_txout() - .expect("opreturn txout"); - Transaction { - version: Version(2), - lock_time: absolute::LockTime::ZERO, - input: vec![TxIn { - previous_output: OutPoint { - txid: prev_txid, - vout: prev_vout, - }, - script_sig: ScriptBuf::new(), - sequence: Sequence::MAX, - witness: Witness::default(), - }], - output: vec![ - op_return, - TxOut { - value: Amount::from_sat(546), - script_pubkey: owner_script, - }, - ], - } - } - struct DummyStorage { collection: CollectionKey, } @@ -496,75 +421,4 @@ mod tests { let err = digest(&payload, &brc721_tx, &rpc, &storage, 10, 0).unwrap_err(); assert!(format!("{err}").contains("txindex")); } - - #[test] - fn register_ownership_skips_duplicate_assets() { - let temp_dir = tempfile::tempdir().expect("temp dir"); - let storage = SqliteStorage::new(temp_dir.path().join("dup_ownership.db")); - storage.init().expect("init db"); - - let collection_key = CollectionKey::new(100, 0); - let setup_tx = storage.begin_tx().expect("begin tx"); - setup_tx - .save_collection(collection_key.clone(), H160::from_low_u64_be(1), false) - .expect("save collection"); - setup_tx.commit().expect("commit collection"); - - let slots = SlotRanges::from_str("0..=1").expect("slots parse"); - let payload = RegisterOwnershipData::for_single_output( - collection_key.block_height, - collection_key.tx_index, - slots, - ) - .expect("payload"); - - let owner_script = ScriptBuf::new_p2pkh(&PubkeyHash::hash(b"owner")); - let prev_tx = Transaction { - version: Version(2), - lock_time: absolute::LockTime::ZERO, - input: vec![TxIn { - previous_output: OutPoint::null(), - script_sig: ScriptBuf::new(), - sequence: Sequence::MAX, - witness: Witness::default(), - }], - output: vec![TxOut { - value: Amount::from_sat(1000), - script_pubkey: owner_script.clone(), - }], - }; - let prev_txid = Txid::from_str(&"11".repeat(32)).unwrap(); - - let tx_a = build_register_ownership_tx(&payload, prev_txid, 0, owner_script.clone()); - let tx_aid = tx_a.compute_txid(); - let tx_b = build_register_ownership_tx(&payload, tx_aid, 1, owner_script); - let rpc = FixedRpc { - first_txid: prev_txid, - first_tx: prev_tx, - second_txid: tx_aid, - second_tx: tx_a.clone(), - }; - let brc721_a = crate::types::parse_brc721_tx(&tx_a) - .expect("parse tx a") - .expect("brc721 tx a"); - - let brc721_b = crate::types::parse_brc721_tx(&tx_b) - .expect("parse tx b") - .expect("brc721 tx b"); - - let db_tx = storage.begin_tx().expect("begin tx"); - digest(&payload, &brc721_a, &rpc, &db_tx, 200, 0).expect("first digest"); - digest(&payload, &brc721_b, &rpc, &db_tx, 201, 0).expect("second digest"); - db_tx.commit().expect("commit digests"); - - let utxos_a = storage - .list_unspent_ownership_utxos_by_outpoint(&tx_a.compute_txid().to_string(), 1) - .expect("list utxos a"); - let utxos_b = storage - .list_unspent_ownership_utxos_by_outpoint(&tx_b.compute_txid().to_string(), 1) - .expect("list utxos b"); - - assert_eq!(utxos_a.len(), 1); - assert!(utxos_b.is_empty()); - } } From 432a08425a2361828eca494fd398e35bccdc0441 Mon Sep 17 00:00:00 2001 From: Alessandro Siniscalchi Date: Sat, 31 Jan 2026 11:58:07 +0000 Subject: [PATCH 15/16] fix logic, test passing --- src/app.rs | 10 ++++++++++ src/core.rs | 10 ++++++++++ src/parser/brc721_parser.rs | 10 ++++++++++ src/parser/register_ownership.rs | 12 +++++++++++- src/rest/handlers.rs | 31 +++++++++++++++++++++++++++++++ src/storage/sqlite.rs | 22 ++++++++-------------- src/storage/traits.rs | 24 +++--------------------- 7 files changed, 83 insertions(+), 36 deletions(-) diff --git a/src/app.rs b/src/app.rs index 07aa342..fd09e43 100644 --- a/src/app.rs +++ b/src/app.rs @@ -255,6 +255,16 @@ mod tests { ) -> Result> { Ok(vec![]) } + + fn has_ownership_overlap( + &self, + _collection_id: &CollectionKey, + _base_h160: H160, + _slot_start: u128, + _slot_end: u128, + ) -> Result { + Ok(false) + } } impl StorageWrite for DummyStorage { diff --git a/src/core.rs b/src/core.rs index b7b1df1..df3bb52 100644 --- a/src/core.rs +++ b/src/core.rs @@ -213,6 +213,16 @@ mod tests { ) -> Result> { Ok(vec![]) } + + fn has_ownership_overlap( + &self, + _collection_id: &CollectionKey, + _base_h160: H160, + _slot_start: u128, + _slot_end: u128, + ) -> Result { + Ok(false) + } } impl StorageWrite for DummyStorage { diff --git a/src/parser/brc721_parser.rs b/src/parser/brc721_parser.rs index 69dbb38..8a0a8f8 100644 --- a/src/parser/brc721_parser.rs +++ b/src/parser/brc721_parser.rs @@ -1530,6 +1530,16 @@ mod tests { ) -> anyhow::Result> { Ok(vec![]) } + + fn has_ownership_overlap( + &self, + _collection_id: &CollectionKey, + _base_h160: H160, + _slot_start: u128, + _slot_end: u128, + ) -> anyhow::Result { + Ok(false) + } } impl StorageWrite for DummyStorage { diff --git a/src/parser/register_ownership.rs b/src/parser/register_ownership.rs index 1eeb3ee..5229188 100644 --- a/src/parser/register_ownership.rs +++ b/src/parser/register_ownership.rs @@ -147,7 +147,7 @@ pub fn digest( for range in payload.groups.iter().flat_map(|group| group.ranges.iter()) { let overlaps = storage - .has_unspent_ownership_overlap(&collection_key, base_h160, range.start, range.end) + .has_ownership_overlap(&collection_key, base_h160, range.start, range.end) .map_err(|e| Brc721Error::StorageError(e.to_string()))?; if overlaps { log::warn!( @@ -337,6 +337,16 @@ mod tests { ) -> AnyResult> { Ok(vec![]) } + + fn has_ownership_overlap( + &self, + _collection_id: &CollectionKey, + _base_h160: H160, + _slot_start: u128, + _slot_end: u128, + ) -> AnyResult { + Ok(false) + } } impl StorageWrite for DummyStorage { diff --git a/src/rest/handlers.rs b/src/rest/handlers.rs index 73bb3f7..50341db 100644 --- a/src/rest/handlers.rs +++ b/src/rest/handlers.rs @@ -885,6 +885,27 @@ mod tests { .cloned() .collect()) } + + fn has_ownership_overlap( + &self, + collection_id: &CollectionKey, + base_h160: H160, + slot_start: u128, + slot_end: u128, + ) -> anyhow::Result { + if slot_start > slot_end { + return Ok(false); + } + let ranges = self.ownership_ranges.read().unwrap(); + Ok(ranges.iter().any( + |(_, _, range_collection_id, range_base_h160, range)| { + range_collection_id == collection_id + && *range_base_h160 == base_h160 + && range.slot_start <= slot_end + && range.slot_end >= slot_start + }, + )) + } } impl Storage for TestStorage { @@ -948,6 +969,16 @@ mod tests { ) -> anyhow::Result> { Err(anyhow!("not implemented")) } + + fn has_ownership_overlap( + &self, + _collection_id: &CollectionKey, + _base_h160: H160, + _slot_start: u128, + _slot_end: u128, + ) -> anyhow::Result { + Err(anyhow!("not implemented")) + } } impl StorageWrite for NoopTx { diff --git a/src/storage/sqlite.rs b/src/storage/sqlite.rs index 8f1805c..54b99fc 100644 --- a/src/storage/sqlite.rs +++ b/src/storage/sqlite.rs @@ -356,7 +356,7 @@ fn db_find_unspent_ownership_utxo_for_slot( .optional() } -fn db_has_unspent_ownership_overlap( +fn db_has_ownership_overlap( conn: &Connection, collection_id: &CollectionKey, base_h160: H160, @@ -372,16 +372,10 @@ fn db_has_unspent_ownership_overlap( .query_row( r#" SELECT 1 - FROM ownership_utxos u - JOIN ownership_ranges r - ON r.reg_txid = u.reg_txid - AND r.reg_vout = u.reg_vout - AND r.collection_id = u.collection_id - AND r.base_h160 = u.base_h160 + FROM ownership_ranges r WHERE - u.collection_id = ?1 - AND u.base_h160 = ?2 - AND u.spent_txid IS NULL + r.collection_id = ?1 + AND r.base_h160 = ?2 AND r.slot_start <= ?3 AND r.slot_end >= ?4 LIMIT 1 @@ -565,14 +559,14 @@ impl StorageRead for SqliteTx { )?) } - fn has_unspent_ownership_overlap( + fn has_ownership_overlap( &self, collection_id: &CollectionKey, base_h160: H160, slot_start: u128, slot_end: u128, ) -> Result { - Ok(db_has_unspent_ownership_overlap( + Ok(db_has_ownership_overlap( &self.conn, collection_id, base_h160, @@ -856,7 +850,7 @@ impl StorageRead for SqliteStorage { Ok(row) } - fn has_unspent_ownership_overlap( + fn has_ownership_overlap( &self, collection_id: &CollectionKey, base_h160: H160, @@ -864,7 +858,7 @@ impl StorageRead for SqliteStorage { slot_end: u128, ) -> Result { let found = self.with_conn(|conn| { - db_has_unspent_ownership_overlap(conn, collection_id, base_h160, slot_start, slot_end) + db_has_ownership_overlap(conn, collection_id, base_h160, slot_start, slot_end) })?; Ok(found) } diff --git a/src/storage/traits.rs b/src/storage/traits.rs index 3b39471..09102a5 100644 --- a/src/storage/traits.rs +++ b/src/storage/traits.rs @@ -73,32 +73,14 @@ pub trait StorageRead { ) -> Result>; fn list_unspent_ownership_utxos_by_owner(&self, owner_h160: H160) -> Result>; - - fn has_unspent_ownership_overlap( + fn has_ownership_overlap( &self, collection_id: &CollectionKey, base_h160: H160, slot_start: u128, slot_end: u128, - ) -> Result { - if slot_start > slot_end { - return Ok(false); - } - let mut slot = slot_start; - loop { - if self - .find_unspent_ownership_utxo_for_slot(collection_id, base_h160, slot)? - .is_some() - { - return Ok(true); - } - if slot == slot_end { - break; - } - slot += 1; - } - Ok(false) - } + ) -> Result; + } pub trait StorageWrite { From c38c0e0a309afa43f207de983da43e3ab5c5424e Mon Sep 17 00:00:00 2001 From: Alessandro Siniscalchi Date: Sat, 31 Jan 2026 16:30:26 +0000 Subject: [PATCH 16/16] fmt' | : --- src/rest/handlers.rs | 8 ++++---- src/storage/traits.rs | 1 - 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/rest/handlers.rs b/src/rest/handlers.rs index 50341db..44f2174 100644 --- a/src/rest/handlers.rs +++ b/src/rest/handlers.rs @@ -897,14 +897,14 @@ mod tests { return Ok(false); } let ranges = self.ownership_ranges.read().unwrap(); - Ok(ranges.iter().any( - |(_, _, range_collection_id, range_base_h160, range)| { + Ok(ranges + .iter() + .any(|(_, _, range_collection_id, range_base_h160, range)| { range_collection_id == collection_id && *range_base_h160 == base_h160 && range.slot_start <= slot_end && range.slot_end >= slot_start - }, - )) + })) } } diff --git a/src/storage/traits.rs b/src/storage/traits.rs index 09102a5..3e4fab8 100644 --- a/src/storage/traits.rs +++ b/src/storage/traits.rs @@ -80,7 +80,6 @@ pub trait StorageRead { slot_start: u128, slot_end: u128, ) -> Result; - } pub trait StorageWrite {