Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,7 @@ pub(crate) fn get_account_derivation_children(coin_type: u32) -> Vec<ChildNumber
]
}

fn derive_account_xprv_from_mnemonic(
pub(crate) fn derive_account_xprv_from_mnemonic(
bitcoin_network: BitcoinNetwork,
mnemonic: &str,
rgb: bool,
Expand Down
136 changes: 136 additions & 0 deletions src/wallet/offline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,24 @@ impl From<RgbEmbeddedMedia> 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<u8>,
/// The BIP-340 Schnorr signature (64 bytes)
pub signature: Vec<u8>,
/// The 32-byte x-only taproot-tweaked public key matching the P2TR output's scriptPubKey
pub pubkey: Vec<u8>,
}

/// A proof of reserves.
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
#[cfg_attr(feature = "camel_case", serde(rename_all = "camelCase"))]
Expand Down Expand Up @@ -3727,4 +3745,122 @@ 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<Vec<UtxoSignature>, 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,
)?;
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.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(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)
}
}
1 change: 1 addition & 0 deletions src/wallet/test/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
129 changes: 129 additions & 0 deletions src/wallet/test/prove_asset_ownership.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
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(&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<u32> = signatures.iter().map(|s| s.outpoint.vout).collect();
let unique_vouts: HashSet<u32> = 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)));
}