diff --git a/src/app.rs b/src/app.rs index 57e6fa7..fd09e43 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); @@ -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/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/cli/tx_cmd.rs b/src/cli/tx_cmd.rs index 0a05b46..7f1724a 100644 --- a/src/cli/tx_cmd.rs +++ b/src/cli/tx_cmd.rs @@ -51,6 +51,20 @@ pub enum TxCmd { required = true )] collection_id: CollectionKey, + #[arg( + long = "init-owner", + value_name = "ADDRESS|ADDRESS_H160", + required = false, + help = "Init owner (addressH160 or revealed wallet address). Input0 must spend a UTXO from this address." + )] + 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 5bafa85..ab12417 100644 --- a/src/commands/tx.rs +++ b/src/commands/tx.rs @@ -3,13 +3,15 @@ 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::wallet::utxo_selection; 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,12 +39,16 @@ impl CommandRunner for cli::TxCmd { } => run_send_amount(ctx, to, *amount_sat, *fee_rate, passphrase.clone()), cli::TxCmd::RegisterOwnership { collection_id, + init_owner, + target_owner, slots, fee_rate, passphrase, } => run_register_ownership( ctx, collection_id, + init_owner.clone(), + target_owner.clone(), slots.clone(), *fee_rate, passphrase.clone(), @@ -80,19 +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")?; - 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 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, @@ -121,31 +120,65 @@ fn run_register_collection( fn run_register_ownership( ctx: &context::Context, collection_id: &CollectionKey, + init_owner: Option, + target_owner: Option, slots: SlotRanges, fee_rate: Option, passphrase: Option, ) -> Result<()> { let mut 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 wallet_utxos = wallet.list_unspent(0).context("list wallet UTXOs")?; + 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. - // 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 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 mandatory_inputs = match &init_spec { + Some(spec) => vec![utxo_selection::select_non_ownership_utxo_for_address( + &wallet_utxos, + &spec.address, + &ownership_outpoints, + )?], + None => Vec::new(), + }; let ownership_amount = Amount::from_sat(546); let ownership = RegisterOwnershipData::for_single_output( @@ -166,6 +199,7 @@ fn run_register_ownership( vec![(ownership_address.clone(), ownership_amount)], fee_rate, &lock_outpoints, + &mandatory_inputs, passphrase, ) .context("build tx")?; @@ -188,11 +222,17 @@ fn run_send_amount( passphrase: Option, ) -> Result<()> { let wallet = load_wallet(ctx)?; + 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)?; 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); @@ -207,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")); @@ -224,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) @@ -269,9 +300,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( @@ -310,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")); @@ -329,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 @@ -389,9 +413,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 @@ -430,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()) } @@ -443,6 +480,69 @@ fn resolve_passphrase(passphrase: Option) -> Result { Ok(SecretString::from(passphrase.unwrap_or_default())) } +#[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!( + "{flag_name} '{trimmed}' does not match network {}", + ctx.network + ) + })?; + 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(OwnerSpec { address, is_wallet }); + } + + 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.clone()) + .ok_or_else(|| { + anyhow!( + "{flag_name} '{trimmed}' not found in wallet addresses (run `brc721 wallet address` to generate one)" + ) + })?; + + Ok(OwnerSpec { + address, + is_wallet: true, + }) +} + +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") { + trimmed.to_string() + } else { + format!("0x{trimmed}") + }; + H160::from_str(&formatted).map_err(|_| { + anyhow!( + "invalid {flag_name} '{}' (expected a Bitcoin address or 0x + 40 hex chars)", + raw + ) + }) +} + fn parse_outpoints(outpoints: &[String]) -> Result> { outpoints .iter() @@ -453,38 +553,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, 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/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/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/parser/brc721_parser.rs b/src/parser/brc721_parser.rs index 9b5915d..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 { @@ -1616,6 +1626,198 @@ 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}; diff --git a/src/parser/register_collection.rs b/src/parser/register_collection.rs index 1e34dda..bf1e7f8 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::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}; + 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 + ); + } +} diff --git a/src/parser/register_ownership.rs b/src/parser/register_ownership.rs index d315296..5229188 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_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() { @@ -318,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..44f2174 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 09c5149..54b99fc 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 { @@ -356,6 +356,42 @@ fn db_find_unspent_ownership_utxo_for_slot( .optional() } +fn db_has_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_ranges r + WHERE + r.collection_id = ?1 + AND r.base_h160 = ?2 + 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 +559,22 @@ impl StorageRead for SqliteTx { )?) } + fn has_ownership_overlap( + &self, + collection_id: &CollectionKey, + base_h160: H160, + slot_start: u128, + slot_end: u128, + ) -> Result { + Ok(db_has_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 +850,19 @@ impl StorageRead for SqliteStorage { Ok(row) } + fn has_ownership_overlap( + &self, + collection_id: &CollectionKey, + base_h160: H160, + slot_start: u128, + slot_end: u128, + ) -> Result { + let found = self.with_conn(|conn| { + db_has_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..3e4fab8 100644 --- a/src/storage/traits.rs +++ b/src/storage/traits.rs @@ -73,6 +73,13 @@ pub trait StorageRead { ) -> Result>; fn list_unspent_ownership_utxos_by_owner(&self, owner_h160: H160) -> Result>; + fn has_ownership_overlap( + &self, + collection_id: &CollectionKey, + base_h160: H160, + slot_start: u128, + slot_end: u128, + ) -> Result; } pub trait StorageWrite { 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); + } +} diff --git a/src/wallet/brc721_wallet.rs b/src/wallet/brc721_wallet.rs index 19f44bb..395cf0c 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() } @@ -103,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) } @@ -148,6 +171,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()?; @@ -161,9 +185,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/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/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) } 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()); + } +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs index dfb8057..b564977 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,64 @@ 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 new file mode 100644 index 0000000..d54db9d --- /dev/null +++ b/tests/e2e_register_ownership_init_owner.rs @@ -0,0 +1,208 @@ +use bitcoin::Address; +use bitcoincore_rpc::{Auth, Client, RpcApi}; +use serde_json::json; +use std::fs; +use std::process::Output; +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) +} + +#[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"); + + 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") + .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); + + 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 + ); +} 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)