From 25dc0696be195ec524f4579afe12613a5623a46e 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/error.rs | 14 +++ src/lib.rs | 4 +- src/utils.rs | 2 +- src/wallet/offline.rs | 144 +++++++++++++++++++++++ src/wallet/test/mod.rs | 1 + src/wallet/test/prove_asset_ownership.rs | 119 +++++++++++++++++++ 6 files changed, 282 insertions(+), 2 deletions(-) create mode 100644 src/wallet/test/prove_asset_ownership.rs diff --git a/src/error.rs b/src/error.rs index 642a9f7b..a2a81279 100644 --- a/src/error.rs +++ b/src/error.rs @@ -262,6 +262,13 @@ pub enum Error { details: String, }, + /// The provided outpoint is invalid + #[error("Invalid outpoint: {details}")] + InvalidOutpoint { + /// Error details + details: String, + }, + /// The provided asset precision is invalid #[error("Invalid precision: {details}")] InvalidPrecision { @@ -445,6 +452,13 @@ pub enum Error { txid: String, }, + /// The specified UTXO was not found in the wallet + #[error("UTXO not found: {outpoint}")] + UtxoNotFound { + /// The outpoint that was not found + outpoint: String, + }, + /// The backup version is not supported #[error("Backup version not supported")] UnsupportedBackupVersion { diff --git a/src/lib.rs b/src/lib.rs index 66352e5e..cbccf215 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -130,6 +130,7 @@ use bdk_wallet::{ constants::ChainHash, hashes::{Hash as Sha256Hash, sha256}, psbt::{ExtractTxError, Psbt}, + key::{Keypair, TapTweak, XOnlyPublicKey}, secp256k1::Secp256k1, }, chain::{CanonicalizationParams, ChainPosition}, @@ -283,7 +284,8 @@ use crate::{ }, error::InternalError, utils::{ - DumbResolver, LOG_FILE, RgbRuntime, adjust_canonicalization, beneficiary_from_script_buf, + DumbResolver, KEYCHAIN_BTC, KEYCHAIN_RGB, LOG_FILE, RgbRuntime, + 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_descriptors, get_descriptors_from_xpubs, load_rgb_runtime, now, parse_address_str, setup_logger, str_to_xpub, diff --git a/src/utils.rs b/src/utils.rs index efd67a05..21cbeb9c 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -284,7 +284,7 @@ pub(crate) fn get_account_derivation_children(coin_type: u32) -> Vec 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"))] @@ -3727,4 +3745,130 @@ impl Wallet { info!(self.logger, "List unspents completed"); Ok(unspents) } + + /// 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 + .wallet_data + .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, + )?; + let (vanilla_account_xprv, _) = derive_account_xprv_from_mnemonic( + self.wallet_data.bitcoin_network, + mnemonic_str, + false, + )?; + let unspent_map: HashMap = self + .bdk_wallet + .list_unspent() + .map(|u| (u.outpoint, u)) + .collect(); + + for (vout, output) in tx.outputs().enumerate() { + let spk = output.script_pubkey.as_slice(); + // P2TR: OP_1 (0x51) OP_PUSH32 (0x20) <32-byte x-only pubkey> + if spk.len() != 34 || spk[0] != 0x51 || spk[1] != 0x20 { + continue; + } + + let bdk_outpoint = OutPoint::from_str(&format!("{}:{}", witness_txid, vout)) + .map_err(|e| Error::InvalidOutpoint { + details: e.to_string(), + })?; + let local_output = match unspent_map.get(&bdk_outpoint) { + Some(lo) => lo, + None => continue, + }; + let rgb = local_output.keychain == KeychainKind::External; + let account_xprv = if rgb { + &rgb_account_xprv + } else { + &vanilla_account_xprv + }; + + let keychain_index = if rgb { + KEYCHAIN_RGB + } else { + self.wallet_data.vanilla_keychain.unwrap_or(KEYCHAIN_BTC) + }; + let child_path = vec![ + ChildNumber::from_normal_idx(keychain_index as u32).unwrap(), + ChildNumber::from_normal_idx(local_output.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) + } } diff --git a/src/wallet/test/mod.rs b/src/wallet/test/mod.rs index 1439f426..a92c2d06 100644 --- a/src/wallet/test/mod.rs +++ b/src/wallet/test/mod.rs @@ -264,6 +264,7 @@ mod list_transactions; mod list_transfers; mod list_unspents; 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..c2db32cb --- /dev/null +++ b/src/wallet/test/prove_asset_ownership.rs @@ -0,0 +1,119 @@ +use super::*; + +#[cfg(feature = "electrum")] +#[test] +#[parallel] +fn success() { + initialize(); + + let amount: u64 = 66; + let (mut wallet, online) = get_funded_wallet!(); + let (rcv_wallet, _rcv_online) = get_funded_wallet!(); + let asset = test_issue_asset_nia(&mut wallet, &online, None); + let receive_data = test_blind_receive(&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.outputs().nth(sig.outpoint.vout as usize).unwrap(); + let spk = output.script_pubkey.as_slice(); + assert_eq!(spk.len(), 34); + assert_eq!(spk[0], 0x51); + assert_eq!(spk[1], 0x20); + assert_eq!(&spk[2..34], sig.pubkey.as_slice()); + } + + // send to self — both outputs should be ours + let receive_data = test_blind_receive(&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 = 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 (rcv_wallet, _rcv_online) = get_funded_wallet!(); + let asset = test_issue_asset_nia(&mut wallet, &online, None); + let receive_data = test_blind_receive(&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 5ec2bb809cbf1706076c73f007df75fb487e55da Mon Sep 17 00:00:00 2001 From: Berserker Date: Mon, 23 Mar 2026 17:11:28 +0000 Subject: [PATCH 2/3] fixup! Add method to prove asset ownership --- src/wallet/offline.rs | 28 +++++++++--------------- src/wallet/test/prove_asset_ownership.rs | 22 ++++++++++++++----- 2 files changed, 26 insertions(+), 24 deletions(-) diff --git a/src/wallet/offline.rs b/src/wallet/offline.rs index b845b3ce..37c2a3c8 100644 --- a/src/wallet/offline.rs +++ b/src/wallet/offline.rs @@ -3798,28 +3798,20 @@ impl Wallet { mnemonic_str, false, )?; - let unspent_map: HashMap = self - .bdk_wallet - .list_unspent() - .map(|u| (u.outpoint, u)) - .collect(); - - for (vout, output) in tx.outputs().enumerate() { - let spk = output.script_pubkey.as_slice(); - // P2TR: OP_1 (0x51) OP_PUSH32 (0x20) <32-byte x-only pubkey> - if spk.len() != 34 || spk[0] != 0x51 || spk[1] != 0x20 { + for (vout, output) in tx.output.iter().enumerate() { + if !output.script_pubkey.is_p2tr() { continue; } + let spk = output.script_pubkey.as_bytes(); - let bdk_outpoint = OutPoint::from_str(&format!("{}:{}", witness_txid, vout)) - .map_err(|e| Error::InvalidOutpoint { - details: e.to_string(), - })?; - let local_output = match unspent_map.get(&bdk_outpoint) { - Some(lo) => lo, + let (keychain, derivation_index) = match self + .bdk_wallet + .derivation_of_spk(output.script_pubkey.clone()) + { + Some(info) => info, None => continue, }; - let rgb = local_output.keychain == KeychainKind::External; + let rgb = keychain == KeychainKind::External; let account_xprv = if rgb { &rgb_account_xprv } else { @@ -3833,7 +3825,7 @@ impl Wallet { }; let child_path = vec![ ChildNumber::from_normal_idx(keychain_index as u32).unwrap(), - ChildNumber::from_normal_idx(local_output.derivation_index).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); diff --git a/src/wallet/test/prove_asset_ownership.rs b/src/wallet/test/prove_asset_ownership.rs index c2db32cb..924dc802 100644 --- a/src/wallet/test/prove_asset_ownership.rs +++ b/src/wallet/test/prove_asset_ownership.rs @@ -8,7 +8,7 @@ fn success() { let amount: u64 = 66; let (mut wallet, online) = get_funded_wallet!(); - let (rcv_wallet, _rcv_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(&rcv_wallet); let recipient_map = HashMap::from([( @@ -52,22 +52,32 @@ fn success() { secp.verify_schnorr(&schnorr_sig, &msg, &xonly).unwrap(); // verify pubkey matches witness TX P2TR output - let output = tx.outputs().nth(sig.outpoint.vout as usize).unwrap(); - let spk = output.script_pubkey.as_slice(); + 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()); } - // send to self — both outputs should be ours - let receive_data = test_blind_receive(&wallet); + // 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: None, + witness_data: Some(WitnessData { + amount_sat: 500, + blinding: None, + }), transport_endpoints: TRANSPORT_ENDPOINTS.clone(), }], )]); From 940a7384d9ecb72b8b777f51e5c77c734f8fc1f9 Mon Sep 17 00:00:00 2001 From: Berserker Date: Tue, 24 Mar 2026 14:05:53 +0000 Subject: [PATCH 3/3] fixup! fixup! Add method to prove asset ownership --- src/error.rs | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/src/error.rs b/src/error.rs index a2a81279..642a9f7b 100644 --- a/src/error.rs +++ b/src/error.rs @@ -262,13 +262,6 @@ pub enum Error { details: String, }, - /// The provided outpoint is invalid - #[error("Invalid outpoint: {details}")] - InvalidOutpoint { - /// Error details - details: String, - }, - /// The provided asset precision is invalid #[error("Invalid precision: {details}")] InvalidPrecision { @@ -452,13 +445,6 @@ pub enum Error { txid: String, }, - /// The specified UTXO was not found in the wallet - #[error("UTXO not found: {outpoint}")] - UtxoNotFound { - /// The outpoint that was not found - outpoint: String, - }, - /// The backup version is not supported #[error("Backup version not supported")] UnsupportedBackupVersion {