From 9e14c69b82e6b7b84adbd0f5481d093ff9373456 Mon Sep 17 00:00:00 2001 From: Marko Bencun Date: Tue, 9 Jul 2024 10:30:04 +0200 Subject: [PATCH] btc: support Taproot policies This mostly involves fixing PSBT signing to be able to handle Taproot leaf script spends. Integration tests with the simulator are added. The signed PSBTs are checked to be correctly signed by rust-miniscript and rust-bitcoinconsensus. --- CHANGELOG-npm.md | 1 + CHANGELOG-rust.md | 1 + src/btc.rs | 87 ++++++--- tests/simulators.json | 8 + tests/subtests/test_btc_psbt.rs | 315 ++++++++++++++++++++++++++++++++ 5 files changed, 387 insertions(+), 25 deletions(-) diff --git a/CHANGELOG-npm.md b/CHANGELOG-npm.md index dffaae0..cbf6953 100644 --- a/CHANGELOG-npm.md +++ b/CHANGELOG-npm.md @@ -5,6 +5,7 @@ ## 0.7.0 - btc: handle error when an input's previous transaction is required but missing - btc: add support for regtest +- btc: add support for Taproot wallet policies ## 0.6.0 diff --git a/CHANGELOG-rust.md b/CHANGELOG-rust.md index fe36322..609926f 100644 --- a/CHANGELOG-rust.md +++ b/CHANGELOG-rust.md @@ -5,6 +5,7 @@ ## 0.6.0 - btc: handle error when an input's previous transaction is required but missing - btc: add support for regtest +- btc: add support for Taproot wallet policies - cardano: added support for vote delegation ## 0.5.0 diff --git a/src/btc.rs b/src/btc.rs index f092807..4ede88a 100644 --- a/src/btc.rs +++ b/src/btc.rs @@ -242,9 +242,9 @@ pub enum PsbtError { #[error("{0}")] #[cfg_attr(feature = "wasm", assoc(js_code = "sign-error"))] SignError(#[from] bitcoin::psbt::SignError), - #[error("The BitBox does not support Taproot script path spending.")] - #[cfg_attr(feature = "wasm", assoc(js_code = "unsupported-tap-script"))] - UnsupportedTapScript, + #[error("Taproot pubkeys must be unique across the internal key and all leaf scripts.")] + #[cfg_attr(feature = "wasm", assoc(js_code = "key-not-unique"))] + KeyNotUnique, #[error("Could not find our key in an input.")] #[cfg_attr(feature = "wasm", assoc(js_code = "key-not-found"))] KeyNotFound, @@ -256,6 +256,11 @@ pub enum PsbtError { enum OurKey { Segwit(bitcoin::secp256k1::PublicKey, Keypath), TaprootInternal(Keypath), + TaprootScript( + bitcoin::secp256k1::XOnlyPublicKey, + bitcoin::taproot::TapLeafHash, + Keypath, + ), } impl OurKey { @@ -263,6 +268,7 @@ impl OurKey { match self { OurKey::Segwit(_, kp) => kp.clone(), OurKey::TaprootInternal(kp) => kp.clone(), + OurKey::TaprootScript(_, _, kp) => kp.clone(), } } } @@ -336,19 +342,34 @@ fn find_our_key( our_root_fingerprint: &[u8], output_info: T, ) -> Result { - if let Some(tap_internal_key) = output_info.get_tap_internal_key() { - let (leaf_hashes, (fingerprint, derivation_path)) = output_info - .get_tap_key_origins() - .get(tap_internal_key) - .ok_or(PsbtError::KeyNotFound)?; - if !leaf_hashes.is_empty() { - return Err(PsbtError::UnsupportedTapScript); - } + for (xonly, (leaf_hashes, (fingerprint, derivation_path))) in + output_info.get_tap_key_origins().iter() + { if &fingerprint[..] == our_root_fingerprint { // TODO: check for fingerprint collision - return Ok(OurKey::TaprootInternal(derivation_path.into())); + + if let Some(tap_internal_key) = output_info.get_tap_internal_key() { + if tap_internal_key == xonly { + if !leaf_hashes.is_empty() { + // TODO change err msg, we don't support the + // same key as internal key and also in a leaf + // script. + return Err(PsbtError::KeyNotUnique); + } + return Ok(OurKey::TaprootInternal(derivation_path.into())); + } + } + if leaf_hashes.len() != 1 { + // TODO change err msg, per BIP-388 all pubkeys are + // unique, so it can't be in multiple leafs. + return Err(PsbtError::KeyNotUnique); + } + return Ok(OurKey::TaprootScript( + *xonly, + leaf_hashes[0], + derivation_path.into(), + )); } - return Err(PsbtError::KeyNotFound); } for (pubkey, (fingerprint, derivation_path)) in output_info.get_bip32_derivation().iter() { if &fingerprint[..] == our_root_fingerprint { @@ -576,15 +597,26 @@ pub fn make_script_config_policy(policy: &str, keys: &[KeyOriginInfo]) -> pb::Bt } } -fn is_taproot(script_config: &pb::BtcScriptConfigWithKeypath) -> bool { - matches!(script_config, - pb::BtcScriptConfigWithKeypath { - script_config: - Some(pb::BtcScriptConfig { - config: Some(pb::btc_script_config::Config::SimpleType(simple_type)), - }), - .. - } if *simple_type == pb::btc_script_config::SimpleType::P2tr as i32) +fn is_taproot_simple(script_config: &pb::BtcScriptConfigWithKeypath) -> bool { + matches!( + script_config.script_config.as_ref(), + Some(pb::BtcScriptConfig { + config: Some(pb::btc_script_config::Config::SimpleType(simple_type)), + }) if *simple_type == pb::btc_script_config::SimpleType::P2tr as i32 + ) +} + +fn is_taproot_policy(script_config: &pb::BtcScriptConfigWithKeypath) -> bool { + matches!( + script_config.script_config.as_ref(), + Some(pb::BtcScriptConfig { + config: Some(pb::btc_script_config::Config::Policy(policy)), + }) if policy.policy.as_str().starts_with("tr("), + ) +} + +fn is_schnorr(script_config: &pb::BtcScriptConfigWithKeypath) -> bool { + is_taproot_simple(script_config) | is_taproot_policy(script_config) } impl PairedBitBox { @@ -681,7 +713,7 @@ impl PairedBitBox { format_unit: pb::btc_sign_init_request::FormatUnit, ) -> Result>, Error> { self.validate_version(">=9.4.0")?; // anti-klepto since 9.4.0 - if transaction.script_configs.iter().any(is_taproot) { + if transaction.script_configs.iter().any(is_taproot_simple) { self.validate_version(">=9.10.0")?; // taproot since 9.10.0 } @@ -709,7 +741,7 @@ impl PairedBitBox { let input_index: usize = next_response.index as _; let tx_input: &TxInput = &transaction.inputs[input_index]; - let input_is_schnorr = is_taproot( + let input_is_schnorr = is_schnorr( &transaction.script_configs[tx_input.script_config_index as usize], ); let perform_antiklepto = is_inputs_pass2 && !input_is_schnorr; @@ -897,7 +929,12 @@ impl PairedBitBox { psbt_input.tap_key_sig = Some( bitcoin::taproot::Signature::from_slice(signature) .map_err(|_| Error::InvalidSignature)?, - ) + ); + } + OurKey::TaprootScript(xonly, leaf_hash, _) => { + let sig = bitcoin::taproot::Signature::from_slice(signature) + .map_err(|_| Error::InvalidSignature)?; + psbt_input.tap_script_sigs.insert((xonly, leaf_hash), sig); } } } diff --git a/tests/simulators.json b/tests/simulators.json index bc8c72f..8a9300d 100644 --- a/tests/simulators.json +++ b/tests/simulators.json @@ -2,5 +2,13 @@ { "url": "https://github.com/BitBoxSwiss/bitbox02-firmware/releases/download/firmware%2Fv9.19.0/bitbox02-multi-v9.19.0-simulator1.0.0-linux-amd64", "sha256": "e28be3fd6c7777624ad2574546ba125b7f134f095fa951acc8fb7295f3d33931" + }, + { + "url": "https://github.com/BitBoxSwiss/bitbox02-firmware/releases/download/firmware%2Fv9.20.0/bitbox02-multi-v9.20.0-simulator1.0.0-linux-amd64", + "sha256": "ac32c1a71bd0a3a934bc7b94268f651c655f2e3afbb954811a256e551a420b3d" + }, + { + "url": "https://github.com/BitBoxSwiss/bitbox02-firmware/releases/download/firmware%2Fv9.21.0/bitbox02-multi-v9.21.0-simulator1.0.0-linux-amd64", + "sha256": "72031b226ea344970a6a1506893838a63b075e0bad726557ab9d941b42c534f5" } ] diff --git a/tests/subtests/test_btc_psbt.rs b/tests/subtests/test_btc_psbt.rs index 8dfdbe6..2291eca 100644 --- a/tests/subtests/test_btc_psbt.rs +++ b/tests/subtests/test_btc_psbt.rs @@ -57,6 +57,8 @@ pub async fn test(bitbox: &PairedBitBox) { test_mixed_spend(bitbox).await; test_multisig_p2wsh(bitbox).await; test_policy_wsh(bitbox).await; + test_policy_tr_keyspend(bitbox).await; + test_policy_tr_scriptspend(bitbox).await; } // Test signing; all inputs are BIP86 Taproot keyspends. @@ -620,3 +622,316 @@ async fn test_policy_wsh(bitbox: &PairedBitBox) { // Verify the signed tx, including that all sigs/witnesses are correct. verify_transaction(psbt); } + +async fn test_policy_tr_keyspend(bitbox: &PairedBitBox) { + if !semver::VersionReq::parse(">=9.21.0") + .unwrap() + .matches(bitbox.version()) + { + // Taproot policies were added in v9.21.0. + return; + } + let secp = secp256k1::Secp256k1::new(); + + let coin = pb::BtcCoin::Tbtc; + // Policy string following BIP-388 syntax, input to the BitBox. + let policy = "tr(@0/<0;1>/*)"; + + let our_root_fingerprint = super::simulator_xprv().fingerprint(&secp); + + let keypath_account: DerivationPath = "m/48'/1'/0'/3'".parse().unwrap(); + + let our_xpub: Xpub = super::simulator_xpub_at(&secp, &keypath_account); + + // We use the miniscript library to build a multipath descriptor including key origin so we can + // easily derive the receive/change descriptor, pubkey scripts, populate the PSBT input key + // infos and convert the sigs to final witnesses. + + let multi_descriptor: miniscript::Descriptor = policy + .replace( + "@0", + &format!("[{}/48'/1'/0'/3']{}", &our_root_fingerprint, &our_xpub), + ) + .parse::>() + .unwrap(); + assert!(multi_descriptor.sanity_check().is_ok()); + + let [descriptor_receive, descriptor_change] = multi_descriptor + .into_single_descriptors() + .unwrap() + .try_into() + .unwrap(); + // Derive /0/0 (first receive) and /1/0 (first change) descriptors. + let input_descriptor = descriptor_receive.at_derivation_index(0).unwrap(); + let change_descriptor = descriptor_change.at_derivation_index(0).unwrap(); + + let keys = &[ + // Our key: root fingerprint and keypath are required. + bitbox_api::btc::KeyOriginInfo { + root_fingerprint: Some(our_root_fingerprint), + keypath: Some((&keypath_account).into()), + xpub: our_xpub, + }, + ]; + let policy_config = bitbox_api::btc::make_script_config_policy(policy, keys); + + // Register policy if not already registered. This must be done before any receive address is + // created or any transaction is signed. + let is_registered = bitbox + .btc_is_script_config_registered(coin, &policy_config, None) + .await + .unwrap(); + + if !is_registered { + bitbox + .btc_register_script_config( + coin, + &policy_config, + None, + pb::btc_register_script_config_request::XPubType::AutoXpubTpub, + Some("test tr keyspend policy"), + ) + .await + .unwrap(); + } + + // A previous tx which creates some UTXOs we can reference later. + let prev_tx = Transaction { + version: transaction::Version::TWO, + lock_time: bitcoin::absolute::LockTime::ZERO, + input: vec![TxIn { + previous_output: "3131313131313131313131313131313131313131313131313131313131313131:0" + .parse() + .unwrap(), + script_sig: ScriptBuf::new(), + sequence: Sequence(0xFFFFFFFF), + witness: Witness::default(), + }], + output: vec![TxOut { + value: Amount::from_sat(100_000_000), + script_pubkey: input_descriptor.script_pubkey(), + }], + }; + + let tx = Transaction { + version: transaction::Version::TWO, + lock_time: bitcoin::absolute::LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint { + txid: prev_tx.compute_txid(), + vout: 0, + }, + script_sig: ScriptBuf::new(), + sequence: Sequence(0xFFFFFFFF), + witness: Witness::default(), + }], + output: vec![ + TxOut { + value: Amount::from_sat(70_000_000), + script_pubkey: change_descriptor.script_pubkey(), + }, + TxOut { + value: Amount::from_sat(20_000_000), + script_pubkey: ScriptBuf::new_p2tr( + &secp, + // random private key: + // 9dbb534622a6100a39b73dece43c6d4db14b9a612eb46a6c64c2bb849e283ce8 + "e4adbb12c3426ec71ebb10688d8ae69d531ca822a2b790acee216a7f1b95b576" + .parse() + .unwrap(), + None, + ), + }, + ], + }; + + let mut psbt = Psbt::from_unsigned_tx(tx).unwrap(); + + // Add input and change infos. + psbt.inputs[0].witness_utxo = Some(prev_tx.output[0].clone()); + // These add the input/output bip32_derivation entries / key infos. + psbt.update_input_with_descriptor(0, &input_descriptor) + .unwrap(); + psbt.update_output_with_descriptor(0, &change_descriptor) + .unwrap(); + + // Sign. + bitbox + .btc_sign_psbt( + pb::BtcCoin::Tbtc, + &mut psbt, + Some(pb::BtcScriptConfigWithKeypath { + script_config: Some(policy_config), + keypath: keypath_account.to_u32_vec(), + }), + pb::btc_sign_init_request::FormatUnit::Default, + ) + .await + .unwrap(); + + // Finalize, add witnesses. + psbt.finalize_mut(&secp).unwrap(); + + // Verify the signed tx, including that all sigs/witnesses are correct. + verify_transaction(psbt); +} + +async fn test_policy_tr_scriptspend(bitbox: &PairedBitBox) { + if !semver::VersionReq::parse(">=9.21.0") + .unwrap() + .matches(bitbox.version()) + { + // Taproot policies were added in v9.21.0. + return; + } + let secp = secp256k1::Secp256k1::new(); + + let coin = pb::BtcCoin::Tbtc; + // Policy string following BIP-388 syntax, input to the BitBox. + let policy = "tr(@0/<0;1>/*,pk(@1/<0;1>/*))"; + + let our_root_fingerprint = super::simulator_xprv().fingerprint(&secp); + + let keypath_account: DerivationPath = "m/48'/1'/0'/3'".parse().unwrap(); + + let our_xpub: Xpub = super::simulator_xpub_at(&secp, &keypath_account); + let some_xpub: Xpub = "tpubDFgycCkexSxkdZfeyaasDHityE97kiYM1BeCNoivDHvydGugKtoNobt4vEX6YSHNPy2cqmWQHKjKxciJuocepsGPGxcDZVmiMBnxgA1JKQk".parse().unwrap(); + + // We use the miniscript library to build a multipath descriptor including key origin so we can + // easily derive the receive/change descriptor, pubkey scripts, populate the PSBT input key + // infos and convert the sigs to final witnesses. + + let multi_descriptor: miniscript::Descriptor = policy + .replace( + "@1", + &format!("[{}/48'/1'/0'/3']{}", &our_root_fingerprint, &our_xpub), + ) + .replace("@0", &some_xpub.to_string()) + .parse::>() + .unwrap(); + assert!(multi_descriptor.sanity_check().is_ok()); + + let [descriptor_receive, descriptor_change] = multi_descriptor + .into_single_descriptors() + .unwrap() + .try_into() + .unwrap(); + // Derive /0/0 (first receive) and /1/0 (first change) descriptors. + let input_descriptor = descriptor_receive.at_derivation_index(0).unwrap(); + let change_descriptor = descriptor_change.at_derivation_index(0).unwrap(); + + let keys = &[ + // Foreign key: root fingerprint and keypath are optional. + bitbox_api::btc::KeyOriginInfo { + root_fingerprint: None, + keypath: None, + xpub: some_xpub, + }, + // Our key: root fingerprint and keypath are required. + bitbox_api::btc::KeyOriginInfo { + root_fingerprint: Some(our_root_fingerprint), + keypath: Some((&keypath_account).into()), + xpub: our_xpub, + }, + ]; + let policy_config = bitbox_api::btc::make_script_config_policy(policy, keys); + + // Register policy if not already registered. This must be done before any receive address is + // created or any transaction is signed. + let is_registered = bitbox + .btc_is_script_config_registered(coin, &policy_config, None) + .await + .unwrap(); + + if !is_registered { + bitbox + .btc_register_script_config( + coin, + &policy_config, + None, + pb::btc_register_script_config_request::XPubType::AutoXpubTpub, + Some("test tr scriptspend policy"), + ) + .await + .unwrap(); + } + + // A previous tx which creates some UTXOs we can reference later. + let prev_tx = Transaction { + version: transaction::Version::TWO, + lock_time: bitcoin::absolute::LockTime::ZERO, + input: vec![TxIn { + previous_output: "3131313131313131313131313131313131313131313131313131313131313131:0" + .parse() + .unwrap(), + script_sig: ScriptBuf::new(), + sequence: Sequence(0xFFFFFFFF), + witness: Witness::default(), + }], + output: vec![TxOut { + value: Amount::from_sat(100_000_000), + script_pubkey: input_descriptor.script_pubkey(), + }], + }; + + let tx = Transaction { + version: transaction::Version::TWO, + lock_time: bitcoin::absolute::LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint { + txid: prev_tx.compute_txid(), + vout: 0, + }, + script_sig: ScriptBuf::new(), + sequence: Sequence(0xFFFFFFFF), + witness: Witness::default(), + }], + output: vec![ + TxOut { + value: Amount::from_sat(70_000_000), + script_pubkey: change_descriptor.script_pubkey(), + }, + TxOut { + value: Amount::from_sat(20_000_000), + script_pubkey: ScriptBuf::new_p2tr( + &secp, + // random private key: + // 9dbb534622a6100a39b73dece43c6d4db14b9a612eb46a6c64c2bb849e283ce8 + "e4adbb12c3426ec71ebb10688d8ae69d531ca822a2b790acee216a7f1b95b576" + .parse() + .unwrap(), + None, + ), + }, + ], + }; + + let mut psbt = Psbt::from_unsigned_tx(tx).unwrap(); + + // Add input and change infos. + psbt.inputs[0].witness_utxo = Some(prev_tx.output[0].clone()); + // These add the input/output bip32_derivation entries / key infos. + psbt.update_input_with_descriptor(0, &input_descriptor) + .unwrap(); + psbt.update_output_with_descriptor(0, &change_descriptor) + .unwrap(); + // Sign. + bitbox + .btc_sign_psbt( + pb::BtcCoin::Tbtc, + &mut psbt, + Some(pb::BtcScriptConfigWithKeypath { + script_config: Some(policy_config), + keypath: keypath_account.to_u32_vec(), + }), + pb::btc_sign_init_request::FormatUnit::Default, + ) + .await + .unwrap(); + + // Finalize, add witnesses. + psbt.finalize_mut(&secp).unwrap(); + + // Verify the signed tx, including that all sigs/witnesses are correct. + verify_transaction(psbt); +}