diff --git a/src/commands/tx.rs b/src/commands/tx.rs index e6cbb24..5bafa85 100644 --- a/src/commands/tx.rs +++ b/src/commands/tx.rs @@ -80,6 +80,20 @@ 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 msg = RegisterCollectionData { evm_collection_address, rebaseable, @@ -91,7 +105,7 @@ fn run_register_collection( let passphrase = resolve_passphrase(passphrase)?; let tx = wallet - .build_tx(output, fee_rate, passphrase) + .build_tx(output, fee_rate, &lock_outpoints, passphrase) .context("build tx")?; let txid = wallet.broadcast(&tx)?; @@ -112,6 +126,19 @@ fn run_register_ownership( 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() + ); + } // 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. @@ -138,6 +165,7 @@ fn run_register_ownership( output, vec![(ownership_address.clone(), ownership_amount)], fee_rate, + &lock_outpoints, passphrase, ) .context("build tx")?; diff --git a/src/integration_tests/txs.rs b/src/integration_tests/txs.rs index ae82604..5a4c8d3 100644 --- a/src/integration_tests/txs.rs +++ b/src/integration_tests/txs.rs @@ -4,7 +4,7 @@ use crate::{ }; use age::secrecy::SecretString; use bdk_wallet::bip39::{Language, Mnemonic}; -use bitcoin::{Amount, Network}; +use bitcoin::{Amount, Network, OutPoint}; use bitcoincore_rpc::{Auth, Client, RpcApi}; use corepc_node::Node; use ethereum_types::H160; @@ -48,10 +48,12 @@ fn test_build_tx_creates_signed_tx_with_custom_output() { output.script_pubkey.to_string(), "OP_RETURN OP_PUSHNUM_15 OP_PUSHBYTES_22 00000000000000000000000000000000000000000000" ); + let lock_outpoints: Vec = Vec::new(); let tx = wallet .build_tx( output, Some(1.5), + &lock_outpoints, SecretString::from("passphrase".to_string()), ) .expect("build tx"); diff --git a/src/wallet/brc721_wallet.rs b/src/wallet/brc721_wallet.rs index 1161030..19f44bb 100644 --- a/src/wallet/brc721_wallet.rs +++ b/src/wallet/brc721_wallet.rs @@ -116,12 +116,28 @@ impl Brc721Wallet { &self, output: bitcoin::TxOut, fee_rate: Option, + lock_outpoints: &[OutPoint], passphrase: SecretString, ) -> Result { - let psbt = self - .remote - .create_psbt_from_txout(output, fee_rate) - .context("create psbt from outputs")?; + 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_from_txout(output, 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_res.context("create psbt from outputs")?; self.sign(psbt, &passphrase) } @@ -131,12 +147,30 @@ impl Brc721Wallet { op_return: bitcoin::TxOut, payments: Vec<(Address, Amount)>, fee_rate: Option, + lock_outpoints: &[OutPoint], passphrase: SecretString, ) -> Result { - let 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_from_opreturn_and_payments(op_return, payments, fee_rate) - .context("create psbt from op_return + payments")?; + .create_psbt_from_opreturn_and_payments(op_return, payments, 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_res.context("create psbt from op_return + payments")?; self.sign(psbt, &passphrase) }