From fc10e94f0abc90e1645210fff41d307cf2c4ae85 Mon Sep 17 00:00:00 2001 From: Berserker Date: Thu, 12 Mar 2026 13:51:48 +0000 Subject: [PATCH 1/3] Add method to prove asset ownership --- src/lib.rs | 9 +- src/utils.rs | 2 +- src/wallet/mod.rs | 3 +- src/wallet/objects.rs | 18 ++++ src/wallet/singlesig.rs | 119 +++++++++++++++++++++ src/wallet/test/mod.rs | 1 + src/wallet/test/prove_asset_ownership.rs | 128 +++++++++++++++++++++++ 7 files changed, 274 insertions(+), 6 deletions(-) create mode 100644 src/wallet/test/prove_asset_ownership.rs diff --git a/src/lib.rs b/src/lib.rs index 0c449eb8..a955b200 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -133,6 +133,7 @@ use bdk_wallet::{ OutPoint, OutPoint as BdkOutPoint, ScriptBuf, TxOut, bip32::{ChildNumber, DerivationPath, Fingerprint, KeySource, Xpriv, Xpub}, hashes::{Hash as Sha256Hash, sha256}, + key::{Keypair, TapTweak, XOnlyPublicKey}, psbt::{ExtractTxError, Psbt}, secp256k1::Secp256k1, }, @@ -303,10 +304,10 @@ use crate::{ keys::{Keys, WitnessVersion}, utils::{ ACCOUNT, DumbResolver, KEYCHAIN_BTC, KEYCHAIN_RGB, LOG_FILE, PURPOSE, RgbRuntime, - adjust_canonicalization, beneficiary_from_script_buf, from_str_or_number_mandatory, - from_str_or_number_optional, get_account_xpubs, get_coin_type, get_descriptors, - get_descriptors_from_xpubs, hash_bytes, hash_bytes_hex, load_rgb_runtime, now, - parse_address_str, setup_logger, str_to_xpub, + adjust_canonicalization, beneficiary_from_script_buf, derive_account_xprv_from_mnemonic, + from_str_or_number_mandatory, from_str_or_number_optional, get_account_xpubs, + get_coin_type, get_descriptors, get_descriptors_from_xpubs, hash_bytes, hash_bytes_hex, + load_rgb_runtime, now, parse_address_str, setup_logger, str_to_xpub, }, wallet::{ Balance, LocalRgbAllocation, LocalUnspent, NUM_KNOWN_SCHEMAS, Outpoint, SCHEMA_ID_CFA, diff --git a/src/utils.rs b/src/utils.rs index 4a617e47..327fbaf4 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -283,7 +283,7 @@ pub(crate) fn get_account_derivation_children( ] } -fn derive_account_xprv_from_mnemonic( +pub(crate) fn derive_account_xprv_from_mnemonic( bitcoin_network: &BitcoinNetwork, mnemonic: &str, rgb: bool, diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index ce602a94..e01c437b 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -31,7 +31,8 @@ pub use objects::{ PsbtOutputInfo, ReceiveData, Recipient, RecipientInfo, RecipientType, RgbAllocation, RgbInputInfo, RgbInspection, RgbOperationInfo, RgbOutputInfo, RgbTransitionInfo, Token, TokenLight, Transaction, TransactionType, Transfer, TransferKind, TransferTransportEndpoint, - TransportEndpoint, TypeOfTransition, Unspent, Utxo, WalletData, WalletDescriptors, WitnessData, + TransportEndpoint, TypeOfTransition, Unspent, Utxo, UtxoSignature, WalletData, + WalletDescriptors, WitnessData, }; #[cfg(any(feature = "electrum", feature = "esplora"))] pub use objects::{ diff --git a/src/wallet/objects.rs b/src/wallet/objects.rs index 1de14e09..de89dc8f 100644 --- a/src/wallet/objects.rs +++ b/src/wallet/objects.rs @@ -228,6 +228,24 @@ impl From for EmbeddedMedia { } } +/// A cryptographic proof of UTXO ownership via message signing. +/// +/// Contains a Bitcoin message signature and the public key that produced it, +/// allowing a third party to verify that the signer controls the private key +/// corresponding to the UTXO's scriptPubKey. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(feature = "camel_case", serde(rename_all = "camelCase"))] +pub struct UtxoSignature { + /// The outpoint (txid:vout) of the UTXO whose ownership is being proved + pub outpoint: Outpoint, + /// The message that was signed (the SHA256 digest, not the raw input) + pub message: Vec, + /// The BIP-340 Schnorr signature (64 bytes) + pub signature: Vec, + /// The 32-byte x-only taproot-tweaked public key matching the P2TR output's scriptPubKey + pub pubkey: Vec, +} + /// A proof of reserves. #[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)] #[cfg_attr(feature = "camel_case", serde(rename_all = "camelCase"))] diff --git a/src/wallet/singlesig.rs b/src/wallet/singlesig.rs index cd1ac1f3..21f5ee99 100644 --- a/src/wallet/singlesig.rs +++ b/src/wallet/singlesig.rs @@ -547,6 +547,125 @@ impl Wallet { batch_transfer_idx, }) } + + /// Prove ownership of an RGB asset by signing P2TR outputs in the consignment's witness TX. + /// + /// The signed message is `SHA256(txid || ":" || vout || ":" || message)`, binding the + /// signature to the specific UTXO. The caller can include a contract ID, nonce, or any + /// other context in `message`. + /// + /// The method finds all wallet-controlled P2TR outputs in the consignment's witness TX + /// and signs each one. Each returned [`UtxoSignature`] contains the 32-byte x-only tweaked + /// public key matching the P2TR output's scriptPubKey at bytes `[2..34]`. + /// + /// Returns an empty `Vec` if no owned P2TR outputs are found. + /// + /// A wallet with private keys (i.e. not watch-only) is required. + pub fn prove_asset_ownership( + &self, + consignment: &RgbTransfer, + message: &[u8], + ) -> Result, Error> { + info!( + self.logger(), + "Proving asset ownership for {}...", + consignment.contract_id() + ); + if self.watch_only() { + return Err(Error::WatchOnly); + } + + let mnemonic_str = self + .keys + .mnemonic + .as_ref() + .expect("non-watch-only wallet should have a mnemonic"); + let bundle = consignment + .bundled_witnesses() + .last() + .ok_or(Error::NoConsignment)?; + let tx = bundle.pub_witness.tx().ok_or(Error::NoConsignment)?; + let witness_txid = bundle.witness_id().to_string(); + let secp = Secp256k1::new(); + let mut signatures = Vec::new(); + + // pre-compute account xprvs for both keychains + let (rgb_account_xprv, _) = derive_account_xprv_from_mnemonic( + &self.wallet_data().bitcoin_network, + mnemonic_str, + true, + self.keys.witness_version, + )?; + let (vanilla_account_xprv, _) = derive_account_xprv_from_mnemonic( + &self.wallet_data().bitcoin_network, + mnemonic_str, + false, + self.keys.witness_version, + )?; + for (vout, output) in tx.output.iter().enumerate() { + if !output.script_pubkey.is_p2tr() { + continue; + } + let spk = output.script_pubkey.as_bytes(); + + let (keychain, derivation_index) = match self + .bdk_wallet() + .derivation_of_spk(output.script_pubkey.clone()) + { + Some(info) => info, + None => continue, + }; + let rgb = keychain == KeychainKind::External; + let account_xprv = if rgb { + &rgb_account_xprv + } else { + &vanilla_account_xprv + }; + + let keychain_index = if rgb { + KEYCHAIN_RGB + } else { + self.keys.vanilla_keychain.unwrap_or(KEYCHAIN_BTC) + }; + let child_path = vec![ + ChildNumber::from_normal_idx(keychain_index as u32).unwrap(), + ChildNumber::from_normal_idx(derivation_index).unwrap(), + ]; + let child_xprv = account_xprv.derive_priv(&secp, &child_path)?; + let keypair = Keypair::from_secret_key(&secp, &child_xprv.private_key); + let (xonly, _) = XOnlyPublicKey::from_keypair(&keypair); + let tweaked_keypair = keypair.tap_tweak(&secp, None).to_keypair(); + let (tweaked_xonly, _) = xonly.tap_tweak(&secp, None); + + // verify our tweaked key matches the scriptPubKey + if tweaked_xonly.serialize() != spk[2..34] { + continue; + } + let outpoint = Outpoint { + txid: witness_txid.clone(), + vout: vout as u32, + }; + let mut preimage = Vec::new(); + preimage.extend_from_slice(outpoint.txid.as_bytes()); + preimage.extend_from_slice(b":"); + preimage.extend_from_slice(outpoint.vout.to_string().as_bytes()); + preimage.extend_from_slice(b":"); + preimage.extend_from_slice(message); + let msg_hash: sha256::Hash = Sha256Hash::hash(&preimage); + + let msg = + bdk_wallet::bitcoin::secp256k1::Message::from_digest(msg_hash.to_byte_array()); + let sig = secp.sign_schnorr_no_aux_rand(&msg, &tweaked_keypair); + signatures.push(UtxoSignature { + outpoint, + message: msg_hash.to_byte_array().to_vec(), + signature: sig.as_ref().to_vec(), + pubkey: tweaked_xonly.serialize().to_vec(), + }); + } + info!(self.logger(), "Prove asset ownership completed"); + Ok(signatures) + } } /// Online APIs of the wallet. diff --git a/src/wallet/test/mod.rs b/src/wallet/test/mod.rs index 2f16b067..55a3270b 100644 --- a/src/wallet/test/mod.rs +++ b/src/wallet/test/mod.rs @@ -329,6 +329,7 @@ mod list_unspents; #[cfg(any(feature = "electrum", feature = "esplora"))] mod multisig; mod new; +mod prove_asset_ownership; mod refresh; mod rust_only; mod send; diff --git a/src/wallet/test/prove_asset_ownership.rs b/src/wallet/test/prove_asset_ownership.rs new file mode 100644 index 00000000..32c2ebe3 --- /dev/null +++ b/src/wallet/test/prove_asset_ownership.rs @@ -0,0 +1,128 @@ +use super::*; + +#[cfg(feature = "electrum")] +#[test] +#[parallel] +fn success() { + initialize(); + + let amount: u64 = 66; + let (mut wallet, online) = get_funded_wallet!(); + let (mut rcv_wallet, rcv_online) = get_funded_wallet!(); + let asset = test_issue_asset_nia(&mut wallet, online, None); + let receive_data = test_blind_receive(&mut rcv_wallet); + let recipient_map = HashMap::from([( + asset.asset_id.clone(), + vec![Recipient { + assignment: Assignment::Fungible(amount), + recipient_id: receive_data.recipient_id.clone(), + witness_data: None, + transport_endpoints: TRANSPORT_ENDPOINTS.clone(), + }], + )]); + let txid = test_send(&mut wallet, online, &recipient_map); + + let consignment_path = wallet.get_send_consignment_path(&asset.asset_id, &txid); + let consignment = RgbTransfer::load_file(consignment_path).unwrap(); + let message = b"test nonce"; + let signatures = wallet.prove_asset_ownership(&consignment, message).unwrap(); + assert!(!signatures.is_empty()); + + let secp = Secp256k1::new(); + let bundle = consignment.bundled_witnesses().last().unwrap(); + let tx = bundle.pub_witness.tx().unwrap(); + for sig in &signatures { + assert_eq!(sig.outpoint.txid, txid); + let mut preimage = Vec::new(); + preimage.extend_from_slice(sig.outpoint.txid.as_bytes()); + preimage.extend_from_slice(b":"); + preimage.extend_from_slice(sig.outpoint.vout.to_string().as_bytes()); + preimage.extend_from_slice(b":"); + preimage.extend_from_slice(message); + let expected_hash: sha256::Hash = Sha256Hash::hash(&preimage); + assert_eq!(sig.message, expected_hash.to_byte_array()); + + // Verify signatures + let xonly = XOnlyPublicKey::from_slice(&sig.pubkey).unwrap(); + let schnorr_sig = + bdk_wallet::bitcoin::secp256k1::schnorr::Signature::from_slice(&sig.signature).unwrap(); + let msg = + bdk_wallet::bitcoin::secp256k1::Message::from_digest(expected_hash.to_byte_array()); + secp.verify_schnorr(&schnorr_sig, &msg, &xonly).unwrap(); + + // verify pubkey matches witness TX P2TR output + let output = tx.output.get(sig.outpoint.vout as usize).unwrap(); + let spk = output.script_pubkey.as_bytes(); + assert_eq!(spk.len(), 34); + assert_eq!(spk[0], 0x51); + assert_eq!(spk[1], 0x20); + assert_eq!(&spk[2..34], sig.pubkey.as_slice()); + } + + // settle the first transfer so change becomes spendable + wait_for_refresh(&mut rcv_wallet, rcv_online, None, None); + wait_for_refresh(&mut wallet, online, Some(&asset.asset_id), None); + mine(false, false); + wait_for_refresh(&mut rcv_wallet, rcv_online, None, None); + wait_for_refresh(&mut wallet, online, Some(&asset.asset_id), None); + + // send to self with witness receive, both P2TR outputs should be ours + let receive_data = test_witness_receive(&mut wallet); + let recipient_map = HashMap::from([( + asset.asset_id.clone(), + vec![Recipient { + assignment: Assignment::Fungible(amount), + recipient_id: receive_data.recipient_id.clone(), + witness_data: Some(WitnessData { + amount_sat: 500, + blinding: None, + }), + transport_endpoints: TRANSPORT_ENDPOINTS.clone(), + }], + )]); + let txid = test_send(&mut wallet, online, &recipient_map); + let consignment_path = wallet.get_send_consignment_path(&asset.asset_id, &txid); + let consignment = RgbTransfer::load_file(consignment_path).unwrap(); + let signatures = wallet + .prove_asset_ownership(&consignment, b"self send") + .unwrap(); + assert_eq!(signatures.len(), 2); + let vouts: Vec = signatures.iter().map(|s| s.outpoint.vout).collect(); + let unique_vouts: HashSet = vouts.iter().copied().collect(); + assert_eq!(vouts.len(), unique_vouts.len()); +} + +#[cfg(feature = "electrum")] +#[test] +#[parallel] +fn fail() { + initialize(); + + let amount: u64 = 66; + let (mut wallet, online) = get_funded_wallet!(); + let (mut rcv_wallet, _rcv_online) = get_funded_wallet!(); + let asset = test_issue_asset_nia(&mut wallet, online, None); + let receive_data = test_blind_receive(&mut rcv_wallet); + let recipient_map = HashMap::from([( + asset.asset_id.clone(), + vec![Recipient { + assignment: Assignment::Fungible(amount), + recipient_id: receive_data.recipient_id.clone(), + witness_data: None, + transport_endpoints: TRANSPORT_ENDPOINTS.clone(), + }], + )]); + let txid = test_send(&mut wallet, online, &recipient_map); + let consignment_path = wallet.get_send_consignment_path(&asset.asset_id, &txid); + let consignment = RgbTransfer::load_file(consignment_path).unwrap(); + + let (wo_wallet, _wo_online) = get_funded_noutxo_wallet(false, None); + let result = wo_wallet.prove_asset_ownership(&consignment, b"test"); + assert!(matches!(result, Err(Error::WatchOnly))); + + let runtime = wallet.rgb_runtime().unwrap(); + let contract_id = ContractId::from_str(&asset.asset_id).unwrap(); + let empty_consignment = runtime.transfer(contract_id, [], [], None).unwrap(); + let result = wallet.prove_asset_ownership(&empty_consignment, b"test"); + assert!(matches!(result, Err(Error::NoConsignment))); +} From 3cec19b5a379f90e9b5a7c106948c874b4b8b9f3 Mon Sep 17 00:00:00 2001 From: Berserker Date: Tue, 21 Apr 2026 14:51:28 +0000 Subject: [PATCH 2/3] fixup! Add method to prove asset ownership --- src/wallet/singlesig.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/wallet/singlesig.rs b/src/wallet/singlesig.rs index 21f5ee99..daa1c4ac 100644 --- a/src/wallet/singlesig.rs +++ b/src/wallet/singlesig.rs @@ -150,6 +150,10 @@ impl RgbWalletOpsOnline for Wallet {} /// Offline APIs of the wallet. impl Wallet { + pub(crate) fn watch_only(&self) -> bool { + self.keys.mnemonic.is_none() + } + /// Create a new RGB singlesig wallet based on the provided [`WalletData`] and /// [`SinglesigKeys`]. pub fn new(wallet_data: WalletData, keys: SinglesigKeys) -> Result { @@ -671,10 +675,6 @@ impl Wallet { /// Online APIs of the wallet. #[cfg(any(feature = "electrum", feature = "esplora"))] impl Wallet { - pub(crate) fn watch_only(&self) -> bool { - self.keys.mnemonic.is_none() - } - fn check_xprv(&self) -> Result<(), Error> { if self.watch_only() { error!(self.logger(), "Invalid operation for a watch only wallet"); From c1cab64e3a66e4301f5f9741125e410b48020acb Mon Sep 17 00:00:00 2001 From: Berserker Date: Tue, 21 Apr 2026 15:00:43 +0000 Subject: [PATCH 3/3] fixup! fixup! Add method to prove asset ownership --- src/wallet/test/prove_asset_ownership.rs | 30 ++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/wallet/test/prove_asset_ownership.rs b/src/wallet/test/prove_asset_ownership.rs index 32c2ebe3..b772e461 100644 --- a/src/wallet/test/prove_asset_ownership.rs +++ b/src/wallet/test/prove_asset_ownership.rs @@ -126,3 +126,33 @@ fn fail() { let result = wallet.prove_asset_ownership(&empty_consignment, b"test"); assert!(matches!(result, Err(Error::NoConsignment))); } + +#[cfg(feature = "electrum")] +#[test] +#[parallel] +fn not_owned_returns_empty() { + initialize(); + + let amount: u64 = 66; + let (mut wallet, online) = get_funded_wallet!(); + let (mut rcv_wallet, _rcv_online) = get_funded_wallet!(); + let asset = test_issue_asset_nia(&mut wallet, online, None); + let receive_data = test_blind_receive(&mut rcv_wallet); + let recipient_map = HashMap::from([( + asset.asset_id.clone(), + vec![Recipient { + assignment: Assignment::Fungible(amount), + recipient_id: receive_data.recipient_id.clone(), + witness_data: None, + transport_endpoints: TRANSPORT_ENDPOINTS.clone(), + }], + )]); + let txid = test_send(&mut wallet, online, &recipient_map); + let consignment_path = wallet.get_send_consignment_path(&asset.asset_id, &txid); + let consignment = RgbTransfer::load_file(consignment_path).unwrap(); + + let signatures = rcv_wallet + .prove_asset_ownership(&consignment, b"not mine") + .unwrap(); + assert!(signatures.is_empty()); +}