Merge remote-tracking branch 'agent/benma-agent/shared-btc-test-vectors'
What changed, and why it matters
This commit is a large merge that adds a new Rust crate called bitbox-test-vectors. It is purely a testing and quality-assurance change: it creates shared Bitcoin transaction test vectors (sample PSBTs and expected firmware behavior) so that the firmware, the Go client library, and the Rust client library can all use the same test data. There is no change to the actual device firmware logic, no new runtime feature, and no fix for a security bug. It is a test-infrastructure improvement.
No security action required. Treat as a normal test-infrastructure merge. Reviewers may want to confirm that bitbox-test-vectors is only used in test builds and not linked into the device firmware binary.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The merge introduces src/rust/bitbox-test-vectors/, registers it in the workspace, and wires it into bitbox02-rust as a dev/test dependency. The crate generates a canonical JSON file of Bitcoin transaction test vectors from Rust constructors. The only production code change visible in the diff is a small addition of semver to bitbox02-rust/Cargo.toml and the dependency on bitbox-test-vectors. The rest is new test code, test data, and documentation. No signing, cryptography, or protocol-handling code in the firmware is modified.
Changed components
src/rust/bitbox-test-vectors (new test crate)src/rust/Cargo.tomlsrc/rust/Cargo.locksrc/rust/bitbox02-rust/Cargo.tomlInspect captured patch +10061 / −2085
### src/rust/Cargo.lock
@@ -249,6 +249,20 @@ dependencies = [
name = "bitbox-securechip-sys"
version = "0.1.0"
+[[package]]
+name = "bitbox-test-vectors"
+version = "0.1.0"
+dependencies = [
+ "bitbox-secp256k1",
+ "bitcoin",
+ "hex",
+ "miniscript",
+ "semver 1.0.20",
+ "serde",
+ "serde_json",
+ "sha3",
+]
+
[[package]]
name = "bitbox-u2fhid"
version = "0.1.0"
@@ -306,6 +320,7 @@ dependencies = [
"bitbox-platform-host",
"bitbox-proto",
"bitbox-secp256k1",
+ "bitbox-test-vectors",
"bitbox-u2fhid",
"bitbox-usb-report-queue",
"bitbox02",
@@ -328,6 +343,7 @@ dependencies = [
"num-bigint",
"num-traits",
"prost",
+ "semver 1.0.20",
"serde",
"serde_json",
"sha2",
### src/rust/Cargo.toml
@@ -30,6 +30,7 @@ members = [
"bitbox-executor",
"bitbox03",
"bitbox-lvgl",
+ "bitbox-test-vectors",
]
resolver = "2"
### src/rust/bitbox-test-vectors/Cargo.toml
@@ -0,0 +1,22 @@
+# SPDX-License-Identifier: Apache-2.0
+
+[package]
+name = "bitbox-test-vectors"
+version = "0.1.0"
+edition = "2024"
+license = "Apache-2.0"
+publish = false
+
+[dependencies]
+bitcoin = { workspace = true, features = ["std"] }
+bitbox-secp256k1 = { path = "../bitbox-secp256k1" }
+hex = { workspace = true, features = ["std"] }
+miniscript = "13.0.0"
+semver = "1"
+serde = { workspace = true }
+serde_json = { workspace = true }
+sha3 = { workspace = true }
+
+[[bin]]
+name = "generate-btc-test-vectors"
+path = "src/main.rs"
### src/rust/bitbox-test-vectors/README.md
@@ -0,0 +1,53 @@
+# BitBox test vectors
+
+## Bitcoin transactions
+
+The Bitcoin transaction vector schema and generator are namespaced under `src/btc_transaction/`,
+leaving room for sibling vector families. Its readable Rust constructors in
+`src/btc_transaction/cases/` are the source of truth. Each scenario is authored once as a PSBT.
+Client libraries consume that PSBT through their public signing API, which converts it to the
+firmware protocol request. The firmware tests derive the same request directly from the PSBT and
+its options. A consumer should explicitly document and skip only the options its public API cannot
+express.
+
+Version expectations and the previous-transaction requirement live on the vector.
+
+Explicit derivation paths use canonical `m/...` strings rather than protocol-level integer arrays.
+Confirm and transaction-fee screens include their `longtouch` requirement. Client simulators whose
+stdout protocol omits that flag compare the remaining observable screen fields.
+`expected_signatures` describes the signature slots newly inserted by the signer, not the complete
+post-signing signature set. It records the input, key, optional Taproot leaf and sighash semantics
+without pinning nondeterministic signature bytes. Pre-existing cosigner signatures may remain in a
+PSBT, but an expected insertion slot must not already be populated.
+Consumers may separately pin a small set of deterministic signatures for implementation-specific
+regression coverage.
+
+Generate the canonical JSON explicitly from the firmware repository root:
+
+```sh
+cargo run --manifest-path src/rust/Cargo.toml \
+ -p bitbox-test-vectors --bin generate-btc-test-vectors
+```
+
+This is intentionally not a `build.rs`: ordinary builds must not rewrite committed fixtures. To
+verify that the checked-in artifact matches the constructors without writing anything, run:
+
+```sh
+cargo run --manifest-path src/rust/Cargo.toml \
+ -p bitbox-test-vectors --bin generate-btc-test-vectors -- --check
+```
+
+Passing one output path instead writes the generated JSON there. This is useful for inspecting a
+candidate artifact without changing the canonical file:
+
+```sh
+cargo run --manifest-path src/rust/Cargo.toml \
+ -p bitbox-test-vectors --bin generate-btc-test-vectors -- /tmp/btc-vectors.json
+```
+
+The canonical artifact is `testdata/btc-transaction-test-vectors.json`. Copy it byte-for-byte to:
+
+- `bitbox02-api-go/api/firmware/testdata/btc-transaction-test-vectors.json`
+- `bitbox-api-rs/tests/data/btc-transaction-test-vectors.json`
+
+Do not regenerate or edit either client copy independently.
### src/rust/bitbox-test-vectors/src/btc_transaction/cases/additional_psbt.rs
@@ -0,0 +1,723 @@
+// SPDX-License-Identifier: Apache-2.0
+
+//! Additional portable scenarios migrated from the firmware transaction-signing tests.
+
+use super::common::{
+ ecdsa_signature, secp, simulator_xprv, simulator_xpub_at, taproot_key_signature,
+ transaction_vector,
+};
+use super::metadata_psbt::{coin_purchase_payment_request, payment_request_signature};
+use super::screens;
+use crate::btc_transaction::{
+ Coin, ExpectedSignature, FormatUnit, PaymentRequest, PaymentRequestMemo, PsbtOutputOptions,
+ PsbtSignOptions, Screen, SimpleType, TestVector,
+};
+use bitcoin::bip32::DerivationPath;
+use bitcoin::blockdata::script::Builder;
+use bitcoin::hashes::Hash;
+use bitcoin::opcodes::all::OP_RETURN;
+use bitcoin::{
+ Amount, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Witness, transaction,
+};
+use std::collections::BTreeMap;
+
+const TBTC_EXTERNAL_XONLY: &str =
+ "e4adbb12c3426ec71ebb10688d8ae69d531ca822a2b790acee216a7f1b95b576";
+const BTC_EXTERNAL_XONLY: &str = "a60869f0dbcf1dc659c9cecbaf8050135ea9e8cdc487053f1dc6880949dc684c";
+const SILENT_PAYMENT_ADDRESS: &str = "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv";
+
+struct SimpleSpend {
+ psbt: bitcoin::psbt::Psbt,
+ expected_signature: ExpectedSignature,
+}
+
+fn path(value: &str) -> DerivationPath {
+ value.parse().unwrap()
+}
+
+fn simple_script(
+ secp: &bitcoin::secp256k1::Secp256k1<bitcoin::secp256k1::All>,
+ script_type: SimpleType,
+ xpub: &bitcoin::bip32::Xpub,
+) -> (ScriptBuf, Option<ScriptBuf>) {
+ match script_type {
+ SimpleType::P2wpkh => (ScriptBuf::new_p2wpkh(&xpub.to_pub().wpubkey_hash()), None),
+ SimpleType::P2wpkhP2sh => {
+ let redeem_script = ScriptBuf::new_p2wpkh(&xpub.to_pub().wpubkey_hash());
+ (
+ ScriptBuf::new_p2sh(&redeem_script.script_hash()),
+ Some(redeem_script),
+ )
+ }
+ SimpleType::P2tr => (ScriptBuf::new_p2tr(secp, xpub.to_x_only_pub(), None), None),
+ }
+}
+
+fn simple_spend(
+ script_type: SimpleType,
+ account: &str,
+ input_index: u32,
+ sequence: u32,
+ locktime: u32,
+ external_script: ScriptBuf,
+) -> SimpleSpend {
+ let secp = secp();
+ let fingerprint = simulator_xprv().fingerprint(&secp);
+ let input_path = path(&format!("{account}/0/{input_index}"));
+ let change_path = path(&format!("{account}/1/0"));
+ let input_xpub = simulator_xpub_at(&secp, &input_path);
+ let change_xpub = simulator_xpub_at(&secp, &change_path);
+ let (input_script, input_redeem_script) = simple_script(&secp, script_type, &input_xpub);
+ let (change_script, change_redeem_script) = simple_script(&secp, script_type, &change_xpub);
+
+ let prev_tx = Transaction {
+ version: transaction::Version::TWO,
+ lock_time: bitcoin::absolute::LockTime::ZERO,
+ input: vec![TxIn {
+ previous_output: OutPoint::null(),
+ script_sig: ScriptBuf::new(),
+ sequence: Sequence::MAX,
+ witness: Witness::new(),
+ }],
+ output: vec![TxOut {
+ value: Amount::from_sat(100_000_000),
+ script_pubkey: input_script,
+ }],
+ };
+ let tx = Transaction {
+ version: transaction::Version::TWO,
+ lock_time: bitcoin::absolute::LockTime::from_consensus(locktime),
+ input: vec![TxIn {
+ previous_output: OutPoint {
+ txid: prev_tx.compute_txid(),
+ vout: 0,
+ },
+ script_sig: ScriptBuf::new(),
+ sequence: Sequence(sequence),
+ witness: Witness::new(),
+ }],
+ output: vec![
+ TxOut {
+ value: Amount::from_sat(70_000_000),
+ script_pubkey: change_script,
+ },
+ TxOut {
+ value: Amount::from_sat(20_000_000),
+ script_pubkey: external_script,
+ },
+ ],
+ };
+ let mut psbt = bitcoin::psbt::Psbt::from_unsigned_tx(tx).unwrap();
+ // Keep the full previous transaction available even when this particular form only needs the
+ // witness UTXO. The derived firmware request may need it because of a non-Taproot owned output
+ // config.
+ psbt.inputs[0].non_witness_utxo = Some(prev_tx.clone());
+ psbt.inputs[0].witness_utxo = Some(prev_tx.output[0].clone());
+ psbt.inputs[0].redeem_script = input_redeem_script;
+ psbt.outputs[0].redeem_script = change_redeem_script;
+
+ let expected_signature = match script_type {
+ SimpleType::P2tr => {
+ psbt.inputs[0].tap_internal_key = Some(input_xpub.to_x_only_pub());
+ psbt.inputs[0].tap_key_origins.insert(
+ input_xpub.to_x_only_pub(),
+ (vec![], (fingerprint, input_path)),
+ );
+ psbt.outputs[0].tap_internal_key = Some(change_xpub.to_x_only_pub());
+ psbt.outputs[0].tap_key_origins.insert(
+ change_xpub.to_x_only_pub(),
+ (vec![], (fingerprint, change_path)),
+ );
+ taproot_key_signature(0, input_xpub.to_x_only_pub())
+ }
+ SimpleType::P2wpkh | SimpleType::P2wpkhP2sh => {
+ psbt.inputs[0]
+ .bip32_derivation
+ .insert(input_xpub.public_key, (fingerprint, input_path));
+ psbt.outputs[0]
+ .bip32_derivation
+ .insert(change_xpub.public_key, (fingerprint, change_path));
+ ecdsa_signature(0, input_xpub.public_key)
+ }
+ };
+
+ SimpleSpend {
+ psbt,
+ expected_signature,
+ }
+}
+
+fn p2tr_script(xonly: &str) -> ScriptBuf {
+ ScriptBuf::new_p2tr(&secp(), xonly.parse().unwrap(), None)
+}
+
+fn ltc_external_script() -> ScriptBuf {
+ let pubkey: bitcoin::PublicKey =
+ "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"
+ .parse()
+ .unwrap();
+ ScriptBuf::new_p2wpkh(&pubkey.wpubkey_hash().unwrap())
+}
+
+fn op_return_script(payload: &[u8]) -> ScriptBuf {
+ Builder::new()
+ .push_opcode(OP_RETURN)
+ .push_slice(bitcoin::script::PushBytesBuf::try_from(payload.to_vec()).unwrap())
+ .into_script()
+}
+
+fn op_return_spend(payload: &[u8], value: u64) -> SimpleSpend {
+ let mut spend = simple_spend(
+ SimpleType::P2wpkh,
+ "m/84'/1'/0'",
+ 0,
+ Sequence::MAX.0,
+ 0,
+ p2tr_script(TBTC_EXTERNAL_XONLY),
+ );
+ spend.psbt.unsigned_tx.output.insert(
+ 0,
+ TxOut {
+ value: Amount::from_sat(value),
+ script_pubkey: op_return_script(payload),
+ },
+ );
+ spend.psbt.outputs.insert(0, Default::default());
+ spend
+}
+
+fn op_return_nonascii() -> TestVector {
+ let spend = op_return_spend(&[1, 2, 3, 4, 5], 0);
+ transaction_vector(
+ "op-return-nonascii",
+ "Displays a non-ASCII OP_RETURN payload as hexadecimal and signs the transaction.",
+ Coin::Tbtc,
+ spend.psbt,
+ PsbtSignOptions::default(),
+ vec![spend.expected_signature],
+ screens::op_return_nonascii(),
+ )
+}
+
+fn op_return_nonzero_value() -> TestVector {
+ let spend = op_return_spend(b"hello world", 100);
+ transaction_vector(
+ "op-return-nonzero-value",
+ "Rejects a nonzero-value OP_RETURN output.",
+ Coin::Tbtc,
+ spend.psbt,
+ PsbtSignOptions::default(),
+ vec![],
+ screens::unsupported_then_invalid_input("9.24.0"),
+ )
+}
+
+fn op_return_silent_payment() -> TestVector {
+ let spend = op_return_spend(b"hello world", 0);
+ transaction_vector(
+ "op-return-silent-payment",
+ "Rejects silent-payment metadata on an OP_RETURN output.",
+ Coin::Tbtc,
+ spend.psbt,
+ PsbtSignOptions {
+ outputs: BTreeMap::from([(
+ 0,
+ PsbtOutputOptions {
+ silent_payment_address: Some(SILENT_PAYMENT_ADDRESS.into()),
+ payment_request_index: None,
+ },
+ )]),
+ ..Default::default()
+ },
+ vec![],
+ screens::unsupported_then_invalid_input("9.24.0"),
+ )
+}
+
+fn op_return_payment_request() -> TestVector {
+ let spend = op_return_spend(b"hello world", 0);
+ let signed_address = "tb1pff8vkq80pu2cgtu7ttgad2znw62v2lguhw6ptrppwns6nrpqau2qcuz37d";
+ transaction_vector(
+ "op-return-payment-request",
+ "Rejects payment-request metadata on an OP_RETURN output.",
+ Coin::Tbtc,
+ spend.psbt,
+ PsbtSignOptions {
+ outputs: BTreeMap::from([(
+ 0,
+ PsbtOutputOptions {
+ silent_payment_address: None,
+ payment_request_index: Some(0),
+ },
+ )]),
+ payment_requests: vec![PaymentRequest {
+ recipient_name: "Test Merchant".into(),
+ total_amount: 1,
+ nonce: String::new(),
+ memos: vec![PaymentRequestMemo::Text {
+ note: "TextMemo line1\nTextMemo line2".into(),
+ }],
+ signature: hex::encode(payment_request_signature(1, signed_address)),
+ }],
+ ..Default::default()
+ },
+ vec![],
+ screens::unsupported_then_invalid_input("9.24.0"),
+ )
+}
+
+fn all_change_spend(change_value: u64, with_op_return: bool) -> SimpleSpend {
+ let mut spend = simple_spend(
+ SimpleType::P2wpkh,
+ "m/84'/0'/0'",
+ 0,
+ Sequence::MAX.0,
+ 0,
+ p2tr_script(BTC_EXTERNAL_XONLY),
+ );
+ spend.psbt.unsigned_tx.output.truncate(1);
+ spend.psbt.outputs.truncate(1);
+ spend.psbt.unsigned_tx.output[0].value = Amount::from_sat(change_value);
+ if with_op_return {
+ spend.psbt.unsigned_tx.output.push(TxOut {
+ value: Amount::ZERO,
+ script_pubkey: op_return_script(b"metadata"),
+ });
+ spend.psbt.outputs.push(Default::default());
+ }
+ spend
+}
+
+fn all_change_high_fee(with_op_return: bool) -> TestVector {
+ let spend = all_change_spend(30_000_000, with_op_return);
+ transaction_vector(
+ if with_op_return {
+ "all-change-high-fee-op-return"
+ } else {
+ "all-change-high-fee"
+ },
+ if with_op_return {
+ "Uses the verified input total for a high-fee warning when all spendable outputs are change and an OP_RETURN output is present."
+ } else {
+ "Uses the verified input total for a high-fee warning when every output is change."
+ },
+ Coin::Btc,
+ spend.psbt,
+ PsbtSignOptions::default(),
+ vec![spend.expected_signature],
+ screens::all_change_high_fee(with_op_return),
+ )
+}
+
+fn all_change_fee_below_threshold() -> TestVector {
+ let spend = all_change_spend(95_000_000, false);
+ transaction_vector(
+ "all-change-fee-below-threshold",
+ "Does not warn when the fee of an all-change transaction is below 10% of the verified input total.",
+ Coin::Btc,
+ spend.psbt,
+ PsbtSignOptions::default(),
+ vec![spend.expected_signature],
+ screens::all_change_fee_below_threshold(),
+ )
+}
+
+fn multiple_output_types(
+ coin: Coin,
+ format_unit: FormatUnit,
+ high_fee_warning: bool,
+) -> TestVector {
+ assert!(!high_fee_warning || coin == Coin::Btc && format_unit == FormatUnit::Default);
+ let secp = secp();
+ let fingerprint = simulator_xprv().fingerprint(&secp);
+ let coin_type = match coin {
+ Coin::Btc => 0,
+ Coin::Ltc => 2,
+ Coin::Tbtc => panic!("multiple-output fixture has no TBTC address set"),
+ };
+ let account = format!("m/84'/{coin_type}'/10'");
+ let input_path = path(&format!("{account}/0/5"));
+ let change0_path = path(&format!("{account}/1/3"));
+ let change1_path = path(&format!("{account}/1/30"));
+ let input_xpub = simulator_xpub_at(&secp, &input_path);
+ let change0_xpub = simulator_xpub_at(&secp, &change0_path);
+ let change1_xpub = simulator_xpub_at(&secp, &change1_path);
+ let prev_tx = Transaction {
+ version: transaction::Version::ONE,
+ lock_time: bitcoin::absolute::LockTime::ZERO,
+ input: vec![TxIn {
+ previous_output: OutPoint::null(),
+ script_sig: ScriptBuf::new(),
+ sequence: Sequence::MAX,
+ witness: Witness::new(),
+ }],
+ output: vec![TxOut {
+ value: Amount::from_sat(2_030_000_000),
+ script_pubkey: ScriptBuf::new_p2wpkh(&input_xpub.to_pub().wpubkey_hash()),
+ }],
+ };
+ let tx = Transaction {
+ version: transaction::Version::ONE,
+ 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::MAX,
+ witness: Witness::new(),
+ }],
+ output: vec![
+ TxOut {
+ value: Amount::from_sat(100_000_000),
+ script_pubkey: ScriptBuf::new_p2pkh(&bitcoin::PubkeyHash::from_byte_array(
+ [0x11; 20],
+ )),
+ },
+ TxOut {
+ value: Amount::from_sat(if high_fee_warning {
+ 1_034_567_890
+ } else {
+ 1_234_567_890
+ }),
+ script_pubkey: ScriptBuf::new_p2sh(&bitcoin::ScriptHash::from_byte_array(
+ [0x22; 20],
+ )),
+ },
+ TxOut {
+ value: Amount::from_sat(6_000),
+ script_pubkey: ScriptBuf::new_p2wpkh(&bitcoin::WPubkeyHash::from_byte_array(
+ [0x33; 20],
+ )),
+ },
+ TxOut {
+ value: Amount::from_sat(7_000),
+ script_pubkey: ScriptBuf::new_p2wsh(&bitcoin::WScriptHash::from_byte_array(
+ [0x44; 32],
+ )),
+ },
+ TxOut {
+ value: Amount::from_sat(690_000_000),
+ script_pubkey: ScriptBuf::new_p2wpkh(&change0_xpub.to_pub().wpubkey_hash()),
+ },
+ TxOut {
+ value: Amount::from_sat(100),
+ script_pubkey: ScriptBuf::new_p2wpkh(&change1_xpub.to_pub().wpubkey_hash()),
+ },
+ ],
+ };
+ let mut psbt = bitcoin::psbt::Psbt::from_unsigned_tx(tx).unwrap();
+ psbt.inputs[0].non_witness_utxo = Some(prev_tx.clone());
+ psbt.inputs[0].witness_utxo = Some(prev_tx.output[0].clone());
+ psbt.inputs[0]
+ .bip32_derivation
+ .insert(input_xpub.public_key, (fingerprint, input_path));
+ psbt.outputs[4]
+ .bip32_derivation
+ .insert(change0_xpub.public_key, (fingerprint, change0_path));
+ psbt.outputs[5]
+ .bip32_derivation
+ .insert(change1_xpub.public_key, (fingerprint, change1_path));
+
+ let unit_name = match format_unit {
+ FormatUnit::Default => "coin",
+ FormatUnit::Sat => "satoshi",
+ };
+ let id = if high_fee_warning {
+ "high-fee-rounding".into()
+ } else {
+ format!(
+ "multiple-output-types-{}-{unit_name}",
+ match coin {
+ Coin::Btc => "bitcoin",
+ Coin::Ltc => "litecoin",
+ Coin::Tbtc => unreachable!(),
+ }
+ )
+ };
+ transaction_vector(
+ &id,
+ if high_fee_warning {
+ "Displays the 18.1% high-fee warning and requires the final longtouch on the warning instead of the fee screen."
+ } else {
+ "Displays P2PKH, P2SH, P2WPKH and P2WSH recipients, warns about two change outputs, and signs the transaction."
+ },
+ coin,
+ psbt,
+ PsbtSignOptions {
+ format_unit,
+ ..Default::default()
+ },
+ vec![ecdsa_signature(0, input_xpub.public_key)],
+ screens::multiple_output_types(coin, format_unit == FormatUnit::Sat, high_fee_warning),
+ )
+}
+
+fn swap_payment_request(unsupported_source: bool) -> TestVector {
+ let (coin, account, external_xonly, source_address, source_coin_type) = if unsupported_source {
+ (
+ Coin::Tbtc,
+ "m/84'/1'/0'",
+ TBTC_EXTERNAL_XONLY,
+ "tb1pff8vkq80pu2cgtu7ttgad2znw62v2lguhw6ptrppwns6nrpqau2qcuz37d",
+ 1,
+ )
+ } else {
+ (
+ Coin::Btc,
+ "m/84'/0'/0'",
+ BTC_EXTERNAL_XONLY,
+ "bc1pmg5dhafms6h9nts4dtehgkanym6yeccfmk5hx3ts3jxnm4zh2knqv80ha5",
+ 0,
+ )
+ };
+ let spend = simple_spend(
+ SimpleType::P2wpkh,
+ account,
+ 0,
+ Sequence::MAX.0,
+ 0,
+ p2tr_script(external_xonly),
+ );
+ let options = PsbtSignOptions {
+ outputs: BTreeMap::from([(
+ 1,
+ PsbtOutputOptions {
+ silent_payment_address: None,
+ payment_request_index: Some(0),
+ },
+ )]),
+ payment_requests: vec![coin_purchase_payment_request(
+ source_coin_type,
+ 20_000_000,
+ source_address,
+ )],
+ ..Default::default()
+ };
+ transaction_vector(
+ if unsupported_source {
+ "swap-payment-request-unsupported-source"
+ } else {
+ "swap-payment-request"
+ },
+ if unsupported_source {
+ "Rejects a coin-purchase payment request whose source account is Bitcoin testnet."
+ } else {
+ "Displays and signs a Bitcoin-to-Ethereum coin-purchase payment request."
+ },
+ coin,
+ spend.psbt,
+ options,
+ (!unsupported_source)
+ .then_some(spend.expected_signature)
+ .into_iter()
+ .collect(),
+ if unsupported_source {
+ screens::swap_payment_request_unsupported_source()
+ } else {
+ screens::swap_payment_request()
+ },
+ )
+}
+
+fn p2wpkh_p2sh() -> TestVector {
+ let spend = simple_spend(
+ SimpleType::P2wpkhP2sh,
+ "m/49'/1'/0'",
+ 0,
+ Sequence::MAX.0,
+ 0,
+ p2tr_script(TBTC_EXTERNAL_XONLY),
+ );
+ transaction_vector(
+ "p2wpkh-p2sh",
+ "Signs a nested SegWit input and recognizes nested SegWit change.",
+ Coin::Tbtc,
+ spend.psbt,
+ PsbtSignOptions::default(),
+ vec![spend.expected_signature],
+ screens::simple_tbtc(&[]),
+ )
+}
+
+fn high_address_index() -> TestVector {
+ let spend = simple_spend(
+ SimpleType::P2wpkh,
+ "m/84'/1'/0'",
+ 100_000,
+ Sequence::MAX.0,
+ 0,
+ p2tr_script(TBTC_EXTERNAL_XONLY),
+ );
+ transaction_vector(
+ "high-input-address-index",
+ "Spends an owned input at address index 100000 without treating the high spend path as a receive-address verification request.",
+ Coin::Tbtc,
+ spend.psbt,
+ PsbtSignOptions::default(),
+ vec![spend.expected_signature],
+ screens::simple_tbtc(&[]),
+ )
+}
+
+fn locktime(block: u32, sequence: u32, rbf: bool) -> TestVector {
+ let spend = simple_spend(
+ SimpleType::P2wpkh,
+ "m/84'/1'/0'",
+ 0,
+ sequence,
+ block,
+ p2tr_script(TBTC_EXTERNAL_XONLY),
+ );
+ let qualifier = if rbf { "rbf" } else { "non-rbf" };
+ let locktime_screen = Screen::Confirm {
+ title: String::new(),
+ body: format!(
+ "Locktime on block:\n{block}\nTransaction is {}RBF",
+ if rbf { "" } else { "not " }
+ ),
+ longtouch: false,
+ };
+ transaction_vector(
+ &format!("locktime-{qualifier}"),
+ &format!("Displays block locktime {block} and its {qualifier} sequence semantics."),
+ Coin::Tbtc,
+ spend.psbt,
+ PsbtSignOptions::default(),
+ vec![spend.expected_signature],
+ screens::simple_tbtc(&[locktime_screen]),
+ )
+}
+
+fn zero_locktime() -> TestVector {
+ let spend = simple_spend(
+ SimpleType::P2wpkh,
+ "m/84'/1'/0'",
+ 0,
+ Sequence::MAX.0 - 2,
+ 0,
+ p2tr_script(TBTC_EXTERNAL_XONLY),
+ );
+ transaction_vector(
+ "locktime-zero",
+ "Suppresses the locktime confirmation when locktime is zero even if the sequence signals RBF.",
+ Coin::Tbtc,
+ spend.psbt,
+ PsbtSignOptions::default(),
+ vec![spend.expected_signature],
+ screens::simple_tbtc(&[]),
+ )
+}
+
+fn p2tr_output_btc() -> TestVector {
+ let spend = simple_spend(
+ SimpleType::P2wpkh,
+ "m/84'/0'/0'",
+ 0,
+ Sequence::MAX.0,
+ 0,
+ p2tr_script(BTC_EXTERNAL_XONLY),
+ );
+ transaction_vector(
+ "p2tr-output-mainnet",
+ "Displays and signs a mainnet P2TR recipient output from a native SegWit account.",
+ Coin::Btc,
+ spend.psbt,
+ PsbtSignOptions::default(),
+ vec![spend.expected_signature],
+ screens::p2tr_output_btc(),
+ )
+}
+
+fn silent_payment_rejects_owned_output() -> TestVector {
+ let mut spend = simple_spend(
+ SimpleType::P2tr,
+ "m/86'/0'/0'",
+ 0,
+ Sequence::MAX.0,
+ 0,
+ p2tr_script(BTC_EXTERNAL_XONLY),
+ );
+ let secp = secp();
+ let fingerprint = simulator_xprv().fingerprint(&secp);
+ let receive_path = path("m/84'/0'/0'/0/0");
+ let receive_xpub = simulator_xpub_at(&secp, &receive_path);
+ spend.psbt.unsigned_tx.output[1].script_pubkey =
+ ScriptBuf::new_p2wpkh(&receive_xpub.to_pub().wpubkey_hash());
+ spend.psbt.outputs[1]
+ .bip32_derivation
+ .insert(receive_xpub.public_key, (fingerprint, receive_path));
+
+ let options = PsbtSignOptions {
+ outputs: BTreeMap::from([(
+ 1,
+ PsbtOutputOptions {
+ silent_payment_address: Some(SILENT_PAYMENT_ADDRESS.into()),
+ payment_request_index: None,
+ },
+ )]),
+ ..Default::default()
+ };
+ transaction_vector(
+ "silent-payment-owned-output",
+ "Covers version-specific handling of silent-payment metadata attached to an output owned by this device, rejected since v9.26.3.",
+ Coin::Btc,
+ spend.psbt,
+ options,
+ vec![spend.expected_signature],
+ screens::silent_payment_owned_output(),
+ )
+}
+
+fn ltc_locktime(sequence: u32, qualifier: &str) -> TestVector {
+ let spend = simple_spend(
+ SimpleType::P2wpkh,
+ "m/84'/2'/0'",
+ 0,
+ sequence,
+ 10,
+ ltc_external_script(),
+ );
+ let locktime_screen = Screen::Confirm {
+ title: String::new(),
+ body: "Locktime on block:\n10\n".into(),
+ longtouch: false,
+ };
+ transaction_vector(
+ &format!("locktime-litecoin-{qualifier}"),
+ "Displays a Litecoin block locktime without Bitcoin-specific RBF wording.",
+ Coin::Ltc,
+ spend.psbt,
+ PsbtSignOptions::default(),
+ vec![spend.expected_signature],
+ screens::simple_ltc(&[locktime_screen]),
+ )
+}
+
+pub fn all() -> Vec<TestVector> {
+ vec![
+ multiple_output_types(Coin::Btc, FormatUnit::Default, false),
+ multiple_output_types(Coin::Btc, FormatUnit::Sat, false),
+ multiple_output_types(Coin::Ltc, FormatUnit::Default, false),
+ multiple_output_types(Coin::Btc, FormatUnit::Default, true),
+ swap_payment_request(false),
+ swap_payment_request(true),
+ p2wpkh_p2sh(),
+ high_address_index(),
+ zero_locktime(),
+ locktime(10, Sequence::MAX.0 - 1, false),
+ locktime(10, Sequence::MAX.0 - 2, true),
+ ltc_locktime(Sequence::MAX.0 - 1, "non-rbf-sequence"),
+ ltc_locktime(Sequence::MAX.0 - 2, "rbf-sequence"),
+ p2tr_output_btc(),
+ silent_payment_rejects_owned_output(),
+ op_return_nonascii(),
+ op_return_nonzero_value(),
+ op_return_silent_payment(),
+ op_return_payment_request(),
+ all_change_high_fee(false),
+ all_change_high_fee(true),
+ all_change_fee_below_threshold(),
+ ]
+}
### src/rust/bitbox-test-vectors/src/btc_transaction/cases/common.rs
@@ -0,0 +1,400 @@
+// SPDX-License-Identifier: Apache-2.0
+
+use crate::btc_transaction::{
+ Coin, ExpectedSignature, FirmwareInput, FirmwareOutput, FirmwareSignRequest, OutputType,
+ PsbtForm, PsbtSignOptions, ScriptConfig, ScriptConfigWithKeypath, Sighash, SignatureKind,
+ SimpleType, TestVector, VersionExpectation,
+};
+use bitcoin::bip32::{DerivationPath, Xpriv, Xpub};
+use bitcoin::key::TapTweak;
+use bitcoin::script::Instruction;
+use bitcoin::secp256k1::{self, Secp256k1};
+use bitcoin::{ScriptBuf, TxOut};
+
+use std::collections::BTreeMap;
+
+pub const SOME_XPUB: &str = "tpubDFgycCkexSxkdZfeyaasDHityE97kiYM1BeCNoivDHvydGugKtoNobt4vEX6YSHNPy2cqmWQHKjKxciJuocepsGPGxcDZVmiMBnxgA1JKQk";
+
+pub fn secp() -> Secp256k1<secp256k1::All> {
+ Secp256k1::new()
+}
+
+pub fn simulator_xprv() -> Xpriv {
+ crate::btc_transaction::SIMULATOR_BIP32_XPRV
+ .parse()
+ .unwrap()
+}
+
+pub fn simulator_xpub_at<C: secp256k1::Signing>(
+ secp: &Secp256k1<C>,
+ path: &DerivationPath,
+) -> Xpub {
+ Xpub::from_priv(secp, &simulator_xprv().derive_priv(secp, path).unwrap())
+}
+
+pub fn keypath(path: &DerivationPath) -> String {
+ format!("m/{path}")
+}
+
+pub fn transaction_vector(
+ id: &str,
+ description: &str,
+ coin: Coin,
+ psbt: bitcoin::psbt::Psbt,
+ psbt_options: PsbtSignOptions,
+ expected_signatures: Vec<ExpectedSignature>,
+ expectations: Vec<VersionExpectation>,
+) -> TestVector {
+ let expected_needs_prevtxs = firmware_request_from_psbt(&psbt, &psbt_options)
+ .unwrap()
+ .needs_prevtxs();
+ TestVector {
+ id: id.into(),
+ description: description.into(),
+ coin,
+ psbt: PsbtForm {
+ transaction: hex::encode(psbt.serialize()),
+ options: psbt_options,
+ },
+ expected_needs_prevtxs,
+ expectations,
+ registrations: vec![],
+ expected_signatures,
+ expected_generated_outputs: BTreeMap::new(),
+ }
+}
+
+#[derive(Clone, Copy)]
+enum OurKey {
+ Segwit(bitcoin::secp256k1::PublicKey),
+ Taproot(bitcoin::secp256k1::XOnlyPublicKey),
+}
+
+impl OurKey {
+ fn bip352_pubkey(self, secp: &Secp256k1<secp256k1::All>) -> Vec<u8> {
+ match self {
+ Self::Segwit(pubkey) => pubkey.serialize().to_vec(),
+ // Taproot silent-payment inputs use the tweaked key-spend private key.
+ Self::Taproot(pubkey) => pubkey.tap_tweak(secp, None).0.serialize().to_vec(),
+ }
+ }
+}
+
+fn input_our_key(
+ input: &bitcoin::psbt::Input,
+ fingerprint: bitcoin::bip32::Fingerprint,
+) -> Option<(OurKey, DerivationPath)> {
+ input
+ .tap_key_origins
+ .iter()
+ .find_map(|(pubkey, (_, (candidate, keypath)))| {
+ (*candidate == fingerprint).then(|| (OurKey::Taproot(*pubkey), keypath.clone()))
+ })
+ .or_else(|| {
+ input
+ .bip32_derivation
+ .iter()
+ .find_map(|(pubkey, (candidate, keypath))| {
+ (*candidate == fingerprint).then(|| (OurKey::Segwit(*pubkey), keypath.clone()))
+ })
+ })
+}
+
+fn output_our_key(
+ output: &bitcoin::psbt::Output,
+ fingerprint: bitcoin::bip32::Fingerprint,
+) -> Option<(OurKey, DerivationPath)> {
+ output
+ .tap_key_origins
+ .iter()
+ .find_map(|(pubkey, (_, (candidate, keypath)))| {
+ (*candidate == fingerprint).then(|| (OurKey::Taproot(*pubkey), keypath.clone()))
+ })
+ .or_else(|| {
+ output
+ .bip32_derivation
+ .iter()
+ .find_map(|(pubkey, (candidate, keypath))| {
+ (*candidate == fingerprint).then(|| (OurKey::Segwit(*pubkey), keypath.clone()))
+ })
+ })
+}
+
+fn account_keypath(path: &DerivationPath) -> Result<String, String> {
+ let components = path.as_ref();
+ if components.len() < 3 {
+ return Err(format!("keypath m/{path} has no account prefix"));
+ }
+ Ok(keypath(&DerivationPath::from(components[..3].to_vec())))
+}
+
+fn script_config_from_utxo(
+ output: &TxOut,
+ keypath: &DerivationPath,
+ redeem_script: Option<&ScriptBuf>,
+) -> Result<ScriptConfigWithKeypath, String> {
+ let script_type = if output.script_pubkey.is_p2wpkh() {
+ SimpleType::P2wpkh
+ } else if output.script_pubkey.is_p2sh()
+ && redeem_script.is_some_and(|script| script.is_p2wpkh())
+ {
+ SimpleType::P2wpkhP2sh
+ } else if output.script_pubkey.is_p2tr() {
+ SimpleType::P2tr
+ } else {
+ return Err(format!(
+ "cannot infer a simple script config for {}",
+ output.script_pubkey
+ ));
+ };
+ Ok(ScriptConfigWithKeypath {
+ script_config: ScriptConfig::Simple { script_type },
+ keypath: account_keypath(keypath)?,
+ })
+}
+
+fn find_or_add_config(
+ configs: &mut Vec<ScriptConfigWithKeypath>,
+ config: ScriptConfigWithKeypath,
+) -> usize {
+ if let Some(index) = configs.iter().position(|candidate| candidate == &config) {
+ index
+ } else {
+ configs.push(config);
+ configs.len() - 1
+ }
+}
+
+fn same_account(
+ input_configs: &[ScriptConfigWithKeypath],
+ output_config: &ScriptConfigWithKeypath,
+) -> Result<bool, String> {
+ for input_config in input_configs {
+ if matches!(input_config.script_config, ScriptConfig::Simple { .. }) {
+ let input_path = input_config
+ .keypath
+ .parse::<DerivationPath>()
+ .map_err(|err| err.to_string())?;
+ let output_path = output_config
+ .keypath
+ .parse::<DerivationPath>()
+ .map_err(|err| err.to_string())?;
+ if input_path.as_ref().get(2) != output_path.as_ref().get(2) {
+ return Ok(false);
+ }
+ } else if input_config != output_config {
+ return Ok(false);
+ }
+ }
+ Ok(true)
+}
+
+fn external_output(output: &TxOut) -> Result<(OutputType, Vec<u8>), String> {
+ let script = output.script_pubkey.as_bytes();
+ if output.script_pubkey.is_p2pkh() {
+ Ok((OutputType::P2pkh, script[3..23].to_vec()))
+ } else if output.script_pubkey.is_p2sh() {
+ Ok((OutputType::P2sh, script[2..22].to_vec()))
+ } else if output.script_pubkey.is_p2wpkh() {
+ Ok((OutputType::P2wpkh, script[2..].to_vec()))
+ } else if output.script_pubkey.is_p2wsh() {
+ Ok((OutputType::P2wsh, script[2..].to_vec()))
+ } else if output.script_pubkey.is_p2tr() {
+ Ok((OutputType::P2tr, script[2..].to_vec()))
+ } else if output.script_pubkey.is_op_return() {
+ let mut instructions = output.script_pubkey.instructions_minimal();
+ match (
+ instructions.next(),
+ instructions.next(),
+ instructions.next(),
+ ) {
+ (Some(Ok(Instruction::Op(op))), Some(Ok(Instruction::PushBytes(payload))), None)
+ if op == bitcoin::opcodes::all::OP_RETURN =>
+ {
+ Ok((OutputType::OpReturn, payload.as_bytes().to_vec()))
+ }
+ _ => Err("unsupported OP_RETURN script".into()),
+ }
+ } else {
+ Err(format!(
+ "unsupported output script {}",
+ output.script_pubkey
+ ))
+ }
+}
+
+pub(in crate::btc_transaction) fn firmware_request_from_psbt(
+ psbt: &bitcoin::psbt::Psbt,
+ options: &PsbtSignOptions,
+) -> Result<FirmwareSignRequest, String> {
+ let secp = secp();
+ let fingerprint = simulator_xprv().fingerprint(&secp);
+ let contains_silent_payment = options
+ .outputs
+ .values()
+ .any(|output| output.silent_payment_address.is_some());
+ let mut script_configs = options
+ .force_script_config
+ .iter()
+ .cloned()
+ .collect::<Vec<_>>();
+ let forced_config = options.force_script_config.is_some();
+ let mut input_prev_txs = Vec::with_capacity(psbt.inputs.len());
+ let mut inputs = Vec::with_capacity(psbt.inputs.len());
+
+ for (index, (tx_input, psbt_input)) in
+ psbt.unsigned_tx.input.iter().zip(&psbt.inputs).enumerate()
+ {
+ let prev_tx = psbt_input.non_witness_utxo.as_ref();
+ let utxo = psbt_input.witness_utxo.as_ref().or_else(|| {
+ prev_tx.and_then(|tx| tx.output.get(tx_input.previous_output.vout as usize))
+ });
+ let utxo = utxo.ok_or_else(|| format!("PSBT input {index} has no spend UTXO"))?;
+ let (our_key, input_keypath) = input_our_key(psbt_input, fingerprint)
+ .ok_or_else(|| format!("PSBT input {index} has no simulator key"))?;
+ let script_config_index = if forced_config {
+ 0
+ } else {
+ let config =
+ script_config_from_utxo(utxo, &input_keypath, psbt_input.redeem_script.as_ref())?;
+ find_or_add_config(&mut script_configs, config)
+ };
+ input_prev_txs.push(prev_tx);
+ inputs.push(FirmwareInput {
+ prev_out_hash: tx_input.previous_output.txid,
+ prev_out_index: tx_input.previous_output.vout,
+ prev_out_value: utxo.value.to_sat(),
+ sequence: tx_input.sequence.to_consensus_u32(),
+ keypath: input_keypath,
+ script_config_index: script_config_index as u32,
+ prev_tx: None,
+ bip352_pubkey: contains_silent_payment.then(|| our_key.bip352_pubkey(&secp)),
+ });
+ }
+
+ let mut output_script_configs = Vec::new();
+ let mut outputs = Vec::with_capacity(psbt.outputs.len());
+ for (index, (tx_output, psbt_output)) in psbt
+ .unsigned_tx
+ .output
+ .iter()
+ .zip(&psbt.outputs)
+ .enumerate()
+ {
+ let output_options = options.outputs.get(&index);
+ let silent_payment_address =
+ output_options.and_then(|output| output.silent_payment_address.clone());
+ let payment_request_index = output_options.and_then(|output| output.payment_request_index);
+ if let Some((_, output_keypath)) = output_our_key(psbt_output, fingerprint) {
+ let config = if let Some(config) = &options.force_script_config {
+ config.clone()
+ } else {
+ script_config_from_utxo(
+ tx_output,
+ &output_keypath,
+ psbt_output.redeem_script.as_ref(),
+ )?
+ };
+ let (script_config_index, output_script_config_index) =
+ if same_account(&script_configs, &config)? {
+ (find_or_add_config(&mut script_configs, config) as u32, None)
+ } else {
+ let index = find_or_add_config(&mut output_script_configs, config);
+ (0, Some(index as u32))
+ };
+ outputs.push(FirmwareOutput {
+ ours: true,
+ value: tx_output.value.to_sat(),
+ output_type: OutputType::Unknown,
+ payload: Vec::new(),
+ keypath: Some(output_keypath),
+ script_config_index,
+ output_script_config_index,
+ silent_payment_address,
+ payment_request_index,
+ });
+ } else {
+ let (output_type, payload) =
+ if silent_payment_address.is_some() && !tx_output.script_pubkey.is_op_return() {
+ (OutputType::Unknown, Vec::new())
+ } else {
+ external_output(tx_output)?
+ };
+ outputs.push(FirmwareOutput {
+ ours: false,
+ value: tx_output.value.to_sat(),
+ output_type,
+ payload,
+ keypath: None,
+ script_config_index: 0,
+ output_script_config_index: None,
+ silent_payment_address,
+ payment_request_index,
+ });
+ }
+ }
+
+ let needs_prevtxs = script_configs
+ .iter()
+ .any(|config| !config.script_config.is_taproot());
+ if needs_prevtxs {
+ for (index, prev_tx) in input_prev_txs.into_iter().enumerate() {
+ inputs[index].prev_tx = Some(
+ prev_tx
+ .ok_or_else(|| format!("PSBT input {index} needs a non-witness UTXO"))?
+ .clone(),
+ );
+ }
+ }
+
+ Ok(FirmwareSignRequest {
+ script_configs,
+ output_script_configs,
+ version: psbt.unsigned_tx.version.0 as u32,
+ inputs,
+ outputs,
+ locktime: psbt.unsigned_tx.lock_time.to_consensus_u32(),
+ payment_requests: options.payment_requests.clone(),
+ format_unit: options.format_unit,
+ })
+}
+
+pub fn ecdsa_signature(
+ input_index: usize,
+ pubkey: bitcoin::secp256k1::PublicKey,
+) -> ExpectedSignature {
+ ExpectedSignature {
+ input_index,
+ kind: SignatureKind::Ecdsa,
+ pubkey: Some(pubkey.to_string()),
+ leaf_hash: None,
+ sighash: Sighash::All,
+ }
+}
+
+pub fn taproot_key_signature(
+ input_index: usize,
+ pubkey: bitcoin::secp256k1::XOnlyPublicKey,
+) -> ExpectedSignature {
+ ExpectedSignature {
+ input_index,
+ kind: SignatureKind::TaprootKey,
+ pubkey: Some(pubkey.to_string()),
+ leaf_hash: None,
+ sighash: Sighash::Default,
+ }
+}
+
+pub fn taproot_script_signature(
+ input_index: usize,
+ pubkey: bitcoin::secp256k1::XOnlyPublicKey,
+ leaf_hash: bitcoin::TapLeafHash,
+) -> ExpectedSignature {
+ ExpectedSignature {
+ input_index,
+ kind: SignatureKind::TaprootScript,
+ pubkey: Some(pubkey.to_string()),
+ leaf_hash: Some(leaf_hash.to_string()),
+ sighash: Sighash::Default,
+ }
+}
### src/rust/bitbox-test-vectors/src/btc_transaction/cases/descriptor_psbt.rs
@@ -0,0 +1,635 @@
+// SPDX-License-Identifier: Apache-2.0
+
+//! Portable multisig and policy scenarios backed by descriptors.
+
+use super::common::{
+ ecdsa_signature, keypath, secp, simulator_xprv, simulator_xpub_at, taproot_key_signature,
+ taproot_script_signature, transaction_vector,
+};
+use super::screens;
+use crate::btc_transaction::{
+ Coin, KeyOriginInfo, MultisigScriptType, PsbtSignOptions, Registration, ScriptConfig,
+ ScriptConfigWithKeypath, TestVector,
+};
+use bitcoin::bip32::{ChainCode, ChildNumber, DerivationPath, Fingerprint, KeySource, Xpriv, Xpub};
+use bitcoin::hashes::{Hash, sha256};
+use bitcoin::psbt::Psbt;
+use bitcoin::secp256k1::{PublicKey, XOnlyPublicKey};
+use bitcoin::taproot::TapLeafHash;
+use bitcoin::{
+ Amount, Network, NetworkKind, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Witness,
+ transaction,
+};
+use miniscript::psbt::PsbtExt;
+use std::collections::BTreeMap;
+
+const EXTERNAL_XONLY: &str = "e4adbb12c3426ec71ebb10688d8ae69d531ca822a2b790acee216a7f1b95b576";
+const POLICY_ACCOUNT: &str = "m/48'/1'/0'/3'";
+
+struct MultisigSpend {
+ psbt: Psbt,
+ config: ScriptConfig,
+ account: DerivationPath,
+ input_pubkey: PublicKey,
+}
+
+struct PolicySpend {
+ psbt: Psbt,
+ config: ScriptConfig,
+ account: DerivationPath,
+ input_pubkey: PublicKey,
+ display_keys: Vec<String>,
+}
+
+fn path(value: &str) -> DerivationPath {
+ value.parse().unwrap()
+}
+
+fn seeded_xpriv(index: u8) -> Xpriv {
+ let mut seed = [0u8; 32];
+ seed[0] = index;
+ Xpriv::new_master(Network::Testnet, &seed).unwrap()
+}
+
+fn account_xpub(root: &Xpriv, account: &DerivationPath) -> Xpub {
+ Xpub::from_priv(&secp(), &root.derive_priv(&secp(), account).unwrap())
+}
+
+fn descriptor_key(root: &Xpriv, account: &DerivationPath, xpub: Xpub, branches: &str) -> String {
+ format!(
+ "[{}/{}]{xpub}/{branches}/*",
+ root.fingerprint(&secp()),
+ account
+ )
+}
+
+fn descriptor_psbt(descriptor: &str, change_index: u32) -> Psbt {
+ let descriptor: miniscript::Descriptor<miniscript::DescriptorPublicKey> =
+ descriptor.parse().unwrap();
+ assert!(descriptor.sanity_check().is_ok());
+ let [receive, change] = descriptor
+ .into_single_descriptors()
+ .unwrap()
+ .try_into()
+ .unwrap();
+ let input_descriptor = receive.at_derivation_index(0).unwrap();
+ let change_descriptor = change.at_derivation_index(change_index).unwrap();
+
+ let prev_tx = Transaction {
+ version: transaction::Version::TWO,
+ lock_time: bitcoin::absolute::LockTime::ZERO,
+ input: vec![TxIn {
+ previous_output: OutPoint::null(),
+ script_sig: ScriptBuf::new(),
+ sequence: Sequence::MAX,
+ witness: Witness::new(),
+ }],
+ 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::MAX,
+ witness: Witness::new(),
+ }],
+ 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(), EXTERNAL_XONLY.parse().unwrap(), None),
+ },
+ ],
+ };
+ let mut psbt = Psbt::from_unsigned_tx(tx).unwrap();
+ psbt.inputs[0].witness_utxo = Some(prev_tx.output[0].clone());
+ if !input_descriptor.script_pubkey().is_p2tr() {
+ psbt.inputs[0].non_witness_utxo = Some(prev_tx);
+ }
+ psbt.update_input_with_descriptor(0, &input_descriptor)
+ .unwrap();
+ psbt.update_output_with_descriptor(0, &change_descriptor)
+ .unwrap();
+ psbt
+}
+
+fn add_cosigner_signature(psbt: &mut Psbt, root: &Xpriv) {
+ let ecdsa_before = psbt.inputs[0].partial_sigs.len();
+ let taproot_before = psbt.inputs[0].tap_script_sigs.len();
+ psbt.sign(root, &secp()).unwrap();
+ assert_eq!(
+ psbt.inputs[0].partial_sigs.len() + psbt.inputs[0].tap_script_sigs.len(),
+ ecdsa_before + taproot_before + 1
+ );
+}
+
+fn multisig_spend(
+ threshold: u32,
+ xpub_count: usize,
+ script_type: MultisigScriptType,
+ cosigner_signatures: usize,
+) -> MultisigSpend {
+ assert!(threshold as usize <= xpub_count);
+ assert!(cosigner_signatures < threshold as usize);
+
+ let secp = secp();
+ let account = match script_type {
+ MultisigScriptType::P2wsh => path("m/48'/1'/0'/2'"),
+ MultisigScriptType::P2wshP2sh => path("m/48'/1'/0'/1'"),
+ };
+ let our_xpub = simulator_xpub_at(&secp, &account);
+ let cosigner_roots = (1..xpub_count)
+ .map(|index| seeded_xpriv(u8::try_from(index).unwrap()))
+ .collect::<Vec<_>>();
+ let cosigner_xpubs = cosigner_roots
+ .iter()
+ .map(|root| account_xpub(root, &account))
+ .collect::<Vec<_>>();
+
+ let mut descriptor_keys = vec![format!(
+ "[{}/{}]{our_xpub}/<0;1>/*",
+ simulator_xprv().fingerprint(&secp),
+ account
+ )];
+ descriptor_keys.extend(
+ cosigner_roots
+ .iter()
+ .zip(&cosigner_xpubs)
+ .map(|(root, xpub)| descriptor_key(root, &account, *xpub, "<0;1>")),
+ );
+ let sortedmulti = format!("sortedmulti({threshold},{})", descriptor_keys.join(","));
+ let descriptor = match script_type {
+ MultisigScriptType::P2wsh => format!("wsh({sortedmulti})"),
+ MultisigScriptType::P2wshP2sh => format!("sh(wsh({sortedmulti}))"),
+ };
+ let mut psbt = descriptor_psbt(&descriptor, 0);
+ for root in cosigner_roots.iter().take(cosigner_signatures) {
+ add_cosigner_signature(&mut psbt, root);
+ }
+
+ let config = ScriptConfig::Multisig {
+ threshold,
+ xpubs: std::iter::once(our_xpub.to_string())
+ .chain(cosigner_xpubs.iter().map(ToString::to_string))
+ .collect(),
+ our_xpub_index: 0,
+ script_type,
+ };
+ let input_path = path(&format!("{account}/0/0"));
+ MultisigSpend {
+ psbt,
+ config,
+ account,
+ input_pubkey: simulator_xpub_at(&secp, &input_path).public_key,
+ }
+}
+
+fn multisig_vector(
+ id: &str,
+ description: &str,
+ threshold: u32,
+ xpub_count: usize,
+ script_type: MultisigScriptType,
+ cosigner_signatures: usize,
+ name: Option<&str>,
+) -> TestVector {
+ let spend = multisig_spend(threshold, xpub_count, script_type, cosigner_signatures);
+ let success = name.is_some();
+ let mut vector = transaction_vector(
+ id,
+ description,
+ Coin::Tbtc,
+ spend.psbt,
+ PsbtSignOptions {
+ force_script_config: Some(ScriptConfigWithKeypath {
+ script_config: spend.config.clone(),
+ keypath: keypath(&spend.account),
+ }),
+ ..Default::default()
+ },
+ success
+ .then(|| ecdsa_signature(0, spend.input_pubkey))
+ .into_iter()
+ .collect(),
+ match name {
+ Some(name) => screens::multisig(threshold, xpub_count, name),
+ None => screens::always_invalid_input(),
+ },
+ );
+ if let Some(name) = name {
+ vector.registrations.push(Registration {
+ script_config: spend.config,
+ keypath: Some(keypath(&spend.account)),
+ name: name.into(),
+ });
+ }
+ vector
+}
+
+fn policy_spend(policy: &str, change_index: u32, sign_cosigner: bool) -> PolicySpend {
+ let secp = secp();
+ let account = path(POLICY_ACCOUNT);
+ let our_xpub = simulator_xpub_at(&secp, &account);
+ let cosigner_root = seeded_xpriv(100);
+ let cosigner_xpub = account_xpub(&cosigner_root, &account);
+ let descriptor = policy
+ .replace("/**", "/<0;1>/*")
+ .replace(
+ "@0",
+ &format!(
+ "[{}/{}]{our_xpub}",
+ simulator_xprv().fingerprint(&secp),
+ account
+ ),
+ )
+ .replace(
+ "@1",
+ &format!(
+ "[{}/{}]{cosigner_xpub}",
+ cosigner_root.fingerprint(&secp),
+ account
+ ),
+ );
+ let mut psbt = descriptor_psbt(&descriptor, change_index);
+ if sign_cosigner {
+ add_cosigner_signature(&mut psbt, &cosigner_root);
+ }
+ let config = ScriptConfig::Policy {
+ policy: policy.into(),
+ keys: vec![
+ KeyOriginInfo {
+ root_fingerprint: Some(simulator_xprv().fingerprint(&secp).to_string()),
+ keypath: Some(keypath(&account)),
+ xpub: our_xpub.to_string(),
+ },
+ KeyOriginInfo {
+ root_fingerprint: None,
+ keypath: None,
+ xpub: cosigner_xpub.to_string(),
+ },
+ ],
+ };
+ let input_branch = if policy.contains("<10;11>") { 10 } else { 0 };
+ let input_path = path(&format!("{account}/{input_branch}/0"));
+ PolicySpend {
+ psbt,
+ config,
+ account,
+ input_pubkey: simulator_xpub_at(&secp, &input_path).public_key,
+ display_keys: vec![
+ format!("This device: {}", screens::DEVICE_POLICY_XPUB),
+ cosigner_xpub.to_string(),
+ ],
+ }
+}
+
+fn policy_vector(
+ id: &str,
+ description: &str,
+ policy: &str,
+ name: &str,
+ spend: PolicySpend,
+ account_override: Option<DerivationPath>,
+ success: bool,
+) -> TestVector {
+ let config_account = account_override.as_ref().unwrap_or(&spend.account);
+ let prefix = screens::policy_prefix(policy, name, &spend.display_keys);
+ let mut vector = transaction_vector(
+ id,
+ description,
+ Coin::Tbtc,
+ spend.psbt,
+ PsbtSignOptions {
+ force_script_config: Some(ScriptConfigWithKeypath {
+ script_config: spend.config.clone(),
+ keypath: keypath(config_account),
+ }),
+ ..Default::default()
+ },
+ success
+ .then(|| ecdsa_signature(0, spend.input_pubkey))
+ .into_iter()
+ .collect(),
+ if success {
+ screens::policy(policy, name, &spend.display_keys, false)
+ } else {
+ screens::always_invalid_input_with_screens(prefix)
+ },
+ );
+ vector.registrations.push(Registration {
+ script_config: spend.config,
+ keypath: None,
+ name: name.into(),
+ });
+ vector
+}
+
+fn unspendable_xpub(cosigner: Xpub, ours: Xpub, script_key_pair_repetitions: usize) -> Xpub {
+ let mut keys = Vec::with_capacity(66 * script_key_pair_repetitions);
+ for _ in 0..script_key_pair_repetitions {
+ keys.extend_from_slice(&cosigner.public_key.serialize());
+ keys.extend_from_slice(&ours.public_key.serialize());
+ }
+ Xpub {
+ network: NetworkKind::Test,
+ depth: 0,
+ parent_fingerprint: Default::default(),
+ child_number: ChildNumber::from_normal_idx(0).unwrap(),
+ public_key: "0250929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0"
+ .parse()
+ .unwrap(),
+ chain_code: ChainCode::from(sha256::Hash::hash(&keys).to_byte_array()),
+ }
+}
+
+fn policy_tr_keyspend_with_script_tree() -> TestVector {
+ let policy = "tr(@0/**,pk(@1/**))";
+ let name = "test tr tweaked keyspend";
+ let PolicySpend {
+ psbt,
+ config,
+ account,
+ input_pubkey,
+ display_keys,
+ } = policy_spend(policy, 0, false);
+ let mut vector = transaction_vector(
+ "policy-tr-keyspend-with-script-tree",
+ "Signs a Taproot policy key path whose tweak commits to a script tree.",
+ Coin::Tbtc,
+ psbt,
+ PsbtSignOptions {
+ force_script_config: Some(ScriptConfigWithKeypath {
+ script_config: config.clone(),
+ keypath: keypath(&account),
+ }),
+ ..Default::default()
+ },
+ vec![taproot_key_signature(0, input_pubkey.x_only_public_key().0)],
+ screens::policy(policy, name, &display_keys, true),
+ );
+ vector.registrations.push(Registration {
+ script_config: config,
+ keypath: None,
+ name: name.into(),
+ });
+ vector
+}
+
+fn taproot_script_key(
+ origins: &BTreeMap<XOnlyPublicKey, (Vec<TapLeafHash>, KeySource)>,
+ fingerprint: Fingerprint,
+ keypath: &DerivationPath,
+) -> (XOnlyPublicKey, TapLeafHash) {
+ let (pubkey, (leaf_hashes, _)) = origins
+ .iter()
+ .find(|(_, (_, (candidate_fingerprint, candidate_keypath)))| {
+ *candidate_fingerprint == fingerprint && candidate_keypath == keypath
+ })
+ .unwrap();
+ assert_eq!(leaf_hashes.len(), 1);
+ (*pubkey, leaf_hashes[0])
+}
+
+struct UnspendablePolicy<'a> {
+ id: &'a str,
+ description: &'a str,
+ policy: &'a str,
+ name: &'a str,
+ cosigner_seed: u8,
+ cosigner_account: &'a str,
+ display_cosigner_origin: bool,
+ script_key_pair_repetitions: usize,
+}
+
+fn unspendable_policy_vector(spec: UnspendablePolicy<'_>) -> TestVector {
+ let secp = secp();
+ let account = path(POLICY_ACCOUNT);
+ let cosigner_account = path(spec.cosigner_account);
+ let our_xpub = simulator_xpub_at(&secp, &account);
+ let cosigner_root = seeded_xpriv(spec.cosigner_seed);
+ let cosigner_xpub = account_xpub(&cosigner_root, &cosigner_account);
+ let nums_xpub = unspendable_xpub(cosigner_xpub, our_xpub, spec.script_key_pair_repetitions);
+ let descriptor = spec
+ .policy
+ .replace("@0", &nums_xpub.to_string())
+ .replace(
+ "@1",
+ &format!(
+ "[{}/{}]{cosigner_xpub}",
+ cosigner_root.fingerprint(&secp),
+ cosigner_account
+ ),
+ )
+ .replace(
+ "@2",
+ &format!(
+ "[{}/{}]{our_xpub}",
+ simulator_xprv().fingerprint(&secp),
+ account
+ ),
+ );
+ let mut psbt = descriptor_psbt(&descriptor, 0);
+
+ let input_path = path(&format!("{account}/0/0"));
+ let cosigner_input_path = path(&format!("{cosigner_account}/0/0"));
+ let our_fingerprint = simulator_xprv().fingerprint(&secp);
+ let cosigner_fingerprint = cosigner_root.fingerprint(&secp);
+ let (input_pubkey, leaf_hash) = taproot_script_key(
+ &psbt.inputs[0].tap_key_origins,
+ our_fingerprint,
+ &input_path,
+ );
+ let (cosigner_input_pubkey, cosigner_leaf_hash) = taproot_script_key(
+ &psbt.inputs[0].tap_key_origins,
+ cosigner_fingerprint,
+ &cosigner_input_path,
+ );
+ assert_eq!(leaf_hash, cosigner_leaf_hash);
+
+ psbt.sign(&cosigner_root, &secp).unwrap();
+ assert!(
+ psbt.inputs[0]
+ .tap_script_sigs
+ .contains_key(&(cosigner_input_pubkey, leaf_hash))
+ );
+ psbt.inputs[0]
+ .tap_script_sigs
+ .retain(|key, _| key == &(cosigner_input_pubkey, leaf_hash));
+
+ let output_path = path(&format!("{account}/1/0"));
+ let cosigner_output_path = path(&format!("{cosigner_account}/1/0"));
+ for (origins, our_path, cosigner_path) in [
+ (
+ &mut psbt.inputs[0].tap_key_origins,
+ &input_path,
+ &cosigner_input_path,
+ ),
+ (
+ &mut psbt.outputs[0].tap_key_origins,
+ &output_path,
+ &cosigner_output_path,
+ ),
+ ] {
+ origins.retain(|_, (_, (fingerprint, keypath))| {
+ (*fingerprint != our_fingerprint || keypath == our_path)
+ && (*fingerprint != cosigner_fingerprint || keypath == cosigner_path)
+ });
+ }
+
+ let cosigner_origin = format!("[{cosigner_fingerprint}/{cosigner_account}]{cosigner_xpub}");
+ let config = ScriptConfig::Policy {
+ policy: spec.policy.into(),
+ keys: vec![
+ KeyOriginInfo {
+ root_fingerprint: None,
+ keypath: None,
+ xpub: nums_xpub.to_string(),
+ },
+ KeyOriginInfo {
+ root_fingerprint: spec
+ .display_cosigner_origin
+ .then(|| cosigner_fingerprint.to_string()),
+ keypath: spec
+ .display_cosigner_origin
+ .then(|| keypath(&cosigner_account)),
+ xpub: cosigner_xpub.to_string(),
+ },
+ KeyOriginInfo {
+ root_fingerprint: Some(our_fingerprint.to_string()),
+ keypath: Some(keypath(&account)),
+ xpub: our_xpub.to_string(),
+ },
+ ],
+ };
+ let display_keys = vec![
+ format!("Provably unspendable: {nums_xpub}"),
+ if spec.display_cosigner_origin {
+ cosigner_origin
+ } else {
+ cosigner_xpub.to_string()
+ },
+ format!("This device: {}", screens::DEVICE_POLICY_XPUB),
+ ];
+ let mut vector = transaction_vector(
+ spec.id,
+ spec.description,
+ Coin::Tbtc,
+ psbt,
+ PsbtSignOptions {
+ force_script_config: Some(ScriptConfigWithKeypath {
+ script_config: config.clone(),
+ keypath: keypath(&account),
+ }),
+ ..Default::default()
+ },
+ vec![taproot_script_signature(0, input_pubkey, leaf_hash)],
+ screens::policy(spec.policy, spec.name, &display_keys, true),
+ );
+ vector.registrations.push(Registration {
+ script_config: config,
+ keypath: None,
+ name: spec.name.into(),
+ });
+ vector
+}
+
+fn policy_tr_unspendable_internal_key() -> TestVector {
+ unspendable_policy_vector(UnspendablePolicy {
+ id: "policy-tr-unspendable-internal-key",
+ description: "Signs a Taproot script path with a provably unspendable internal key and displays that property in the policy review.",
+ policy: "tr(@0/<0;1>/*,multi_a(2,@1/<0;1>/*,@2/<0;1>/*))",
+ name: "test unspendable policy",
+ cosigner_seed: 101,
+ cosigner_account: POLICY_ACCOUNT,
+ display_cosigner_origin: false,
+ script_key_pair_repetitions: 1,
+ })
+}
+
+fn policy_tr_unspendable_internal_key_complex() -> TestVector {
+ unspendable_policy_vector(UnspendablePolicy {
+ id: "policy-tr-unspendable-internal-key-complex",
+ description: "Signs the satisfiable branch of a multi-leaf Taproot policy with a provably unspendable internal key, distinct multipaths and a relative-timelock sibling branch.",
+ policy: "tr(@0/<0;1>/*,{and_v(v:multi_a(1,@1/<2;3>/*,@2/<2;3>/*),older(2)),multi_a(2,@1/<0;1>/*,@2/<0;1>/*)})",
+ name: "test complex unspendable",
+ cosigner_seed: 102,
+ cosigner_account: "m/48'/1'/0'/2'",
+ display_cosigner_origin: true,
+ script_key_pair_repetitions: 2,
+ })
+}
+
+pub fn all() -> Vec<TestVector> {
+ let multipath_policy = "wsh(multi(2,@0/<10;11>/*,@1/<20;21>/*))";
+ let standard_policy = "wsh(multi(2,@0/**,@1/**))";
+ vec![
+ multisig_vector(
+ "multisig-not-registered",
+ "Rejects a valid 1-of-2 P2WSH multisig transaction when its account has not been registered.",
+ 1,
+ 2,
+ MultisigScriptType::P2wsh,
+ 0,
+ None,
+ ),
+ multisig_vector(
+ "multisig-p2wsh-p2sh",
+ "Signs and finalizes a registered 1-of-2 nested P2SH-P2WSH multisig transaction.",
+ 1,
+ 2,
+ MultisigScriptType::P2wshP2sh,
+ 0,
+ Some("test sh-wsh multisig"),
+ ),
+ multisig_vector(
+ "multisig-large",
+ "Adds the seventh signature to a registered 7-of-15 P2WSH multisig transaction whose PSBT already contains six cosigner signatures.",
+ 7,
+ 15,
+ MultisigScriptType::P2wsh,
+ 6,
+ Some("test large multisig"),
+ ),
+ policy_tr_keyspend_with_script_tree(),
+ policy_tr_unspendable_internal_key(),
+ policy_tr_unspendable_internal_key_complex(),
+ policy_vector(
+ "policy-different-multipath-derivations",
+ "Signs and finalizes a registered WSH policy whose two keys use different receive and change branches.",
+ multipath_policy,
+ "test multipath policy",
+ policy_spend(multipath_policy, 0, true),
+ None,
+ true,
+ ),
+ policy_vector(
+ "policy-wrong-account-keypath",
+ "Rejects a registered policy when the signing account keypath does not match the device key in the policy.",
+ standard_policy,
+ "test policy account",
+ policy_spend(standard_policy, 0, false),
+ Some(path("m/48'/1'/0'/4'")),
+ false,
+ ),
+ policy_vector(
+ "policy-change-index-too-high",
+ "Rejects a registered policy change output at address index 10000.",
+ standard_policy,
+ "test policy account",
+ policy_spend(standard_policy, 10_000, false),
+ None,
+ false,
+ ),
+ ]
+}
### src/rust/bitbox-test-vectors/src/btc_transaction/cases/metadata_psbt.rs
@@ -0,0 +1,579 @@
+// SPDX-License-Identifier: Apache-2.0
+
+//! PSBT scenarios covering output metadata and compatibility behavior.
+
+use super::common::{
+ ecdsa_signature, keypath, secp, simulator_xprv, simulator_xpub_at, taproot_key_signature,
+ transaction_vector,
+};
+use super::screens;
+use crate::btc_transaction::{
+ Coin, PaymentRequest, PaymentRequestMemo, PsbtOutputOptions, PsbtSignOptions, TestVector,
+};
+use bitcoin::bip32::DerivationPath;
+use bitcoin::consensus::encode::VarInt;
+use bitcoin::hashes::{Hash, HashEngine, sha256};
+use bitcoin::psbt::Psbt;
+use bitcoin::secp256k1::{Message, SecretKey};
+use bitcoin::{
+ Amount, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Witness, transaction,
+};
+use sha3::Digest;
+use std::collections::BTreeMap;
+
+const SILENT_PAYMENT_ADDRESS: &str = "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv";
+
+pub fn all() -> Vec<TestVector> {
+ vec![
+ taproot_spends_to_non_taproot(),
+ silent_payment(),
+ send_self(false),
+ send_self(true),
+ payment_request(),
+ payment_request_rejects_owned_output(),
+ ]
+}
+
+fn path(value: &str) -> DerivationPath {
+ value.parse().unwrap()
+}
+
+fn dummy_input() -> TxIn {
+ TxIn {
+ previous_output: "3131313131313131313131313131313131313131313131313131313131313131:0"
+ .parse()
+ .unwrap(),
+ script_sig: ScriptBuf::new(),
+ sequence: Sequence::MAX,
+ witness: Witness::default(),
+ }
+}
+
+fn unsigned_input(prev_tx: &Transaction, vout: u32) -> TxIn {
+ TxIn {
+ previous_output: OutPoint {
+ txid: prev_tx.compute_txid(),
+ vout,
+ },
+ script_sig: ScriptBuf::new(),
+ sequence: Sequence::MAX,
+ witness: Witness::default(),
+ }
+}
+
+// All inputs are Taproot, but the change output is not Taproot. Some firmware versions
+// conservatively request the previous transactions in this case.
+fn taproot_spends_to_non_taproot() -> TestVector {
+ let secp = secp();
+ let fingerprint = simulator_xprv().fingerprint(&secp);
+ let input0_path = path("m/86'/1'/0'/0/0");
+ let input1_path = path("m/86'/1'/0'/0/1");
+ let change_path = path("m/84'/1'/0'/1/0");
+ let input0_xpub = simulator_xpub_at(&secp, &input0_path);
+ let input1_xpub = simulator_xpub_at(&secp, &input1_path);
+ let change_xpub = simulator_xpub_at(&secp, &change_path);
+
+ // 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![dummy_input()],
+ output: vec![
+ TxOut {
+ value: Amount::from_sat(100_000_000),
+ script_pubkey: ScriptBuf::new_p2tr(&secp, input0_xpub.to_x_only_pub(), None),
+ },
+ TxOut {
+ value: Amount::from_sat(100_000_000),
+ script_pubkey: ScriptBuf::new_p2tr(&secp, input1_xpub.to_x_only_pub(), None),
+ },
+ ],
+ };
+ let tx = Transaction {
+ version: transaction::Version::TWO,
+ lock_time: bitcoin::absolute::LockTime::ZERO,
+ input: vec![unsigned_input(&prev_tx, 0), unsigned_input(&prev_tx, 1)],
+ output: vec![
+ TxOut {
+ value: Amount::from_sat(100_000_000),
+ script_pubkey: ScriptBuf::new_p2wpkh(&change_xpub.to_pub().wpubkey_hash()),
+ },
+ 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();
+ for (index, (xpub, input_path)) in [(input0_xpub, input0_path), (input1_xpub, input1_path)]
+ .into_iter()
+ .enumerate()
+ {
+ psbt.inputs[index].witness_utxo = Some(prev_tx.output[index].clone());
+ psbt.inputs[index].non_witness_utxo = Some(prev_tx.clone());
+ psbt.inputs[index].tap_internal_key = Some(xpub.to_x_only_pub());
+ psbt.inputs[index]
+ .tap_key_origins
+ .insert(xpub.to_x_only_pub(), (vec![], (fingerprint, input_path)));
+ }
+ psbt.outputs[0]
+ .bip32_derivation
+ .insert(change_xpub.to_pub().0, (fingerprint, change_path));
+
+ transaction_vector(
+ "taproot-to-non-taproot-change",
+ "Signs all-Taproot inputs with P2WPKH change, requiring previous transactions for the added non-Taproot output config.",
+ Coin::Tbtc,
+ psbt,
+ PsbtSignOptions::default(),
+ vec![
+ taproot_key_signature(0, input0_xpub.to_x_only_pub()),
+ taproot_key_signature(1, input1_xpub.to_x_only_pub()),
+ ],
+ screens::taproot_to_non_taproot_change(),
+ )
+}
+
+// Test that a mixed-input PSBT can ask the device to generate a BIP352 output.
+fn silent_payment() -> TestVector {
+ let secp = secp();
+ let fingerprint = simulator_xprv().fingerprint(&secp);
+ let change_path = path("m/86'/0'/0'/1/0");
+ let input0_path = path("m/86'/0'/0'/0/0"); // P2TR
+ let input1_path = path("m/84'/0'/0'/0/0"); // P2WPKH
+ let input2_path = path("m/49'/0'/0'/0/0"); // P2SH-P2WPKH
+ let change_xpub = simulator_xpub_at(&secp, &change_path);
+ let input0_xpub = simulator_xpub_at(&secp, &input0_path);
+ let input1_xpub = simulator_xpub_at(&secp, &input1_path);
+ let input2_xpub = simulator_xpub_at(&secp, &input2_path);
+ let input2_redeem = ScriptBuf::new_p2wpkh(&input2_xpub.to_pub().wpubkey_hash());
+
+ let prev_tx = Transaction {
+ version: transaction::Version::TWO,
+ lock_time: bitcoin::absolute::LockTime::ZERO,
+ input: vec![dummy_input()],
+ output: vec![
+ TxOut {
+ value: Amount::from_sat(100_000_000),
+ script_pubkey: ScriptBuf::new_p2tr(&secp, input0_xpub.to_x_only_pub(), None),
+ },
+ TxOut {
+ value: Amount::from_sat(100_000_000),
+ script_pubkey: ScriptBuf::new_p2wpkh(&input1_xpub.to_pub().wpubkey_hash()),
+ },
+ TxOut {
+ value: Amount::from_sat(100_000_000),
+ script_pubkey: ScriptBuf::new_p2sh(&input2_redeem.script_hash()),
+ },
+ ],
+ };
+ let tx = Transaction {
+ version: transaction::Version::TWO,
+ lock_time: bitcoin::absolute::LockTime::ZERO,
+ input: (0..3).map(|vout| unsigned_input(&prev_tx, vout)).collect(),
+ output: vec![
+ TxOut {
+ value: Amount::from_sat(100_000_000),
+ script_pubkey: ScriptBuf::new_p2tr(&secp, change_xpub.to_x_only_pub(), None),
+ },
+ TxOut {
+ value: Amount::from_sat(20_000_000),
+ // The device fills this script from the silent-payment address.
+ script_pubkey: ScriptBuf::new(),
+ },
+ ],
+ };
+ let mut psbt = Psbt::from_unsigned_tx(tx).unwrap();
+ psbt.inputs[0].non_witness_utxo = Some(prev_tx.clone());
+ psbt.inputs[0].witness_utxo = Some(prev_tx.output[0].clone());
+ psbt.inputs[0].tap_internal_key = Some(input0_xpub.to_x_only_pub());
+ psbt.inputs[0].tap_key_origins.insert(
+ input0_xpub.to_x_only_pub(),
+ (vec![], (fingerprint, input0_path)),
+ );
+ psbt.inputs[1].non_witness_utxo = Some(prev_tx.clone());
+ psbt.inputs[1].witness_utxo = Some(prev_tx.output[1].clone());
+ psbt.inputs[1]
+ .bip32_derivation
+ .insert(input1_xpub.to_pub().0, (fingerprint, input1_path));
+ psbt.inputs[2].non_witness_utxo = Some(prev_tx);
+ psbt.inputs[2].witness_utxo = psbt.inputs[2]
+ .non_witness_utxo
+ .as_ref()
+ .map(|tx| tx.output[2].clone());
+ psbt.inputs[2].redeem_script = Some(input2_redeem);
+ psbt.inputs[2]
+ .bip32_derivation
+ .insert(input2_xpub.to_pub().0, (fingerprint, input2_path));
+ psbt.outputs[0].tap_internal_key = Some(change_xpub.to_x_only_pub());
+ psbt.outputs[0].tap_key_origins.insert(
+ change_xpub.to_x_only_pub(),
+ (vec![], (fingerprint, change_path)),
+ );
+
+ let mut outputs = BTreeMap::new();
+ outputs.insert(
+ 1,
+ PsbtOutputOptions {
+ silent_payment_address: Some(SILENT_PAYMENT_ADDRESS.into()),
+ payment_request_index: None,
+ },
+ );
+ let mut result = transaction_vector(
+ "silent-payment",
+ "Signs mixed inputs with BIP352 metadata and a device-generated silent-payment output.",
+ Coin::Btc,
+ psbt,
+ PsbtSignOptions {
+ outputs,
+ ..Default::default()
+ },
+ vec![
+ taproot_key_signature(0, input0_xpub.to_x_only_pub()),
+ ecdsa_signature(1, input1_xpub.to_pub().0),
+ ecdsa_signature(2, input2_xpub.to_pub().0),
+ ],
+ screens::silent_payment(),
+ );
+ result.expected_generated_outputs.insert(
+ 1,
+ "5120d826829cb603fc008e5ef99d0818f2126d3569c3ab8a6cd069f07a20e892bd59".into(),
+ );
+ result
+}
+
+// Tests that the output is recognized as the same account or another account on this device.
+fn send_self(different_account: bool) -> TestVector {
+ let secp = secp();
+ let fingerprint = simulator_xprv().fingerprint(&secp);
+ let input_path = path("m/86'/1'/0'/0/0");
+ let change_path = path("m/49'/1'/0'/1/0");
+ let send_self_path = if different_account {
+ path("m/84'/1'/1'/0/0")
+ } else {
+ path("m/84'/1'/0'/0/0")
+ };
+ let input_xpub = simulator_xpub_at(&secp, &input_path);
+ let change_xpub = simulator_xpub_at(&secp, &change_path);
+ let send_self_xpub = simulator_xpub_at(&secp, &send_self_path);
+ let change_redeem = ScriptBuf::new_p2wpkh(&change_xpub.to_pub().wpubkey_hash());
+
+ let prev_tx = Transaction {
+ version: transaction::Version::TWO,
+ lock_time: bitcoin::absolute::LockTime::ZERO,
+ input: vec![dummy_input()],
+ output: vec![TxOut {
+ value: Amount::from_sat(100_000_000),
+ script_pubkey: ScriptBuf::new_p2tr(&secp, input_xpub.to_x_only_pub(), None),
+ }],
+ };
+ let tx = Transaction {
+ version: transaction::Version::TWO,
+ lock_time: bitcoin::absolute::LockTime::ZERO,
+ input: vec![unsigned_input(&prev_tx, 0)],
+ output: vec![
+ TxOut {
+ value: Amount::from_sat(50_000_000),
+ script_pubkey: ScriptBuf::new_p2sh(&change_redeem.script_hash()),
+ },
+ TxOut {
+ value: Amount::from_sat(20_000_000),
+ script_pubkey: ScriptBuf::new_p2wpkh(&send_self_xpub.to_pub().wpubkey_hash()),
+ },
+ ],
+ };
+ let mut psbt = Psbt::from_unsigned_tx(tx).unwrap();
+ psbt.inputs[0].non_witness_utxo = Some(prev_tx.clone());
+ psbt.inputs[0].witness_utxo = Some(prev_tx.output[0].clone());
+ psbt.inputs[0].tap_internal_key = Some(input_xpub.to_x_only_pub());
+ psbt.inputs[0].tap_key_origins.insert(
+ input_xpub.to_x_only_pub(),
+ (vec![], (fingerprint, input_path)),
+ );
+ psbt.outputs[0].redeem_script = Some(change_redeem);
+ psbt.outputs[0]
+ .bip32_derivation
+ .insert(change_xpub.to_pub().0, (fingerprint, change_path));
+ psbt.outputs[1]
+ .bip32_derivation
+ .insert(send_self_xpub.to_pub().0, (fingerprint, send_self_path));
+
+ transaction_vector(
+ if different_account {
+ "send-self-different-account"
+ } else {
+ "send-self-same-account"
+ },
+ if different_account {
+ "Covers ownership detection for another account and version-specific account labels."
+ } else {
+ "Covers ownership detection for the input account, suppression of change confirmation and version-specific account labels."
+ },
+ Coin::Tbtc,
+ psbt,
+ PsbtSignOptions::default(),
+ vec![taproot_key_signature(0, input_xpub.to_x_only_pub())],
+ if different_account {
+ screens::send_self_different_account()
+ } else {
+ screens::send_self_same_account()
+ },
+ )
+}
+
+fn hash_len_prefixed(engine: &mut sha256::HashEngine, value: &[u8]) {
+ engine.input(&bitcoin::consensus::serialize(&VarInt(value.len() as u64)));
+ engine.input(value);
+}
+
+pub(super) fn payment_request_signature(value: u64, address: &str) -> Vec<u8> {
+ let mut sighash = sha256::Hash::engine();
+ sighash.input(b"SL\x00\x24");
+ hash_len_prefixed(&mut sighash, b"");
+ hash_len_prefixed(&mut sighash, b"Test Merchant");
+ sighash.input(&bitcoin::consensus::serialize(&VarInt(1)));
+ sighash.input(&1u32.to_le_bytes());
+ hash_len_prefixed(&mut sighash, b"TextMemo line1\nTextMemo line2");
+ sighash.input(&1u32.to_le_bytes());
+
+ let mut output_hash = sha256::Hash::engine();
+ output_hash.input(&value.to_le_bytes());
+ hash_len_prefixed(&mut output_hash, address.as_bytes());
+ sighash.input(sha256::Hash::from_engine(output_hash).as_byte_array());
+
+ let digest = sha256::Hash::from_engine(sighash).to_byte_array();
+ let secret_key = SecretKey::from_slice(b"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
+ secp()
+ .sign_ecdsa(&Message::from_digest(digest), &secret_key)
+ .serialize_compact()
+ .to_vec()
+}
+
+fn checksummed_eth_address(pubkey: &bitcoin::secp256k1::PublicKey) -> String {
+ let hash = sha3::Keccak256::digest(&pubkey.serialize_uncompressed()[1..]);
+ let mut address = hex::encode(&hash[hash.len() - 20..]).into_bytes();
+ let checksum = sha3::Keccak256::digest(&address);
+ for (index, byte) in address.iter_mut().enumerate() {
+ let nibble = if index % 2 == 0 {
+ checksum[index / 2] >> 4
+ } else {
+ checksum[index / 2] & 0x0f
+ };
+ if *byte > b'9' && nibble > 7 {
+ *byte -= 32;
+ }
+ }
+ format!("0x{}", String::from_utf8(address).unwrap())
+}
+
+pub(super) fn coin_purchase_payment_request(
+ source_coin_type: u32,
+ value: u64,
+ source_address: &str,
+) -> PaymentRequest {
+ let address_keypath = path("m/44'/60'/0'/0/0");
+ let private_key = simulator_xprv()
+ .derive_priv(&secp(), &address_keypath)
+ .unwrap()
+ .private_key;
+ let destination_address = checksummed_eth_address(
+ &bitcoin::secp256k1::PublicKey::from_secret_key(&secp(), &private_key),
+ );
+ let destination_amount = "0.25 ETH";
+
+ let mut sighash = sha256::Hash::engine();
+ sighash.input(b"SL\x00\x24");
+ hash_len_prefixed(&mut sighash, b"");
+ hash_len_prefixed(&mut sighash, b"Test Merchant");
+ sighash.input(&bitcoin::consensus::serialize(&VarInt(1)));
+ sighash.input(&3u32.to_le_bytes());
+ sighash.input(&60u32.to_le_bytes());
+ hash_len_prefixed(&mut sighash, destination_amount.as_bytes());
+ hash_len_prefixed(&mut sighash, destination_address.as_bytes());
+ sighash.input(&source_coin_type.to_le_bytes());
+
+ let mut output_hash = sha256::Hash::engine();
+ output_hash.input(&value.to_le_bytes());
+ hash_len_prefixed(&mut output_hash, source_address.as_bytes());
+ sighash.input(sha256::Hash::from_engine(output_hash).as_byte_array());
+
+ let digest = sha256::Hash::from_engine(sighash).to_byte_array();
+ let secret_key = SecretKey::from_slice(b"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
+ let signature = secp()
+ .sign_ecdsa(&Message::from_digest(digest), &secret_key)
+ .serialize_compact();
+ PaymentRequest {
+ recipient_name: "Test Merchant".into(),
+ total_amount: value,
+ nonce: String::new(),
+ memos: vec![PaymentRequestMemo::CoinPurchase {
+ coin_type: 60,
+ amount: destination_amount.into(),
+ address: destination_address,
+ address_keypath: keypath(&address_keypath),
+ }],
+ signature: hex::encode(signature),
+ }
+}
+
+fn payment_request_options(output_index: usize, value: u64, address: &str) -> PsbtSignOptions {
+ PsbtSignOptions {
+ outputs: BTreeMap::from([(
+ output_index,
+ PsbtOutputOptions {
+ silent_payment_address: None,
+ payment_request_index: Some(0),
+ },
+ )]),
+ payment_requests: vec![PaymentRequest {
+ recipient_name: "Test Merchant".into(),
+ total_amount: value,
+ nonce: String::new(),
+ memos: vec![PaymentRequestMemo::Text {
+ note: "TextMemo line1\nTextMemo line2".into(),
+ }],
+ signature: hex::encode(payment_request_signature(value, address)),
+ }],
+ ..Default::default()
+ }
+}
+
+fn payment_request() -> TestVector {
+ let secp = secp();
+ let fingerprint = simulator_xprv().fingerprint(&secp);
+ let input_path = path("m/86'/1'/0'/0/0");
+ let change_path = path("m/49'/1'/0'/1/0");
+ let input_xpub = simulator_xpub_at(&secp, &input_path);
+ let change_xpub = simulator_xpub_at(&secp, &change_path);
+ let change_redeem = ScriptBuf::new_p2wpkh(&change_xpub.to_pub().wpubkey_hash());
+ let address = "tb1q9kvhpyd32aqhpsc8yrdm48gx5dnadq63lservm";
+ let recipient_script = address
+ .parse::<bitcoin::Address<_>>()
+ .unwrap()
+ .assume_checked()
+ .script_pubkey();
+ let value = 20_000_000;
+
+ let prev_tx = Transaction {
+ version: transaction::Version::TWO,
+ lock_time: bitcoin::absolute::LockTime::ZERO,
+ input: vec![dummy_input()],
+ output: vec![TxOut {
+ value: Amount::from_sat(100_000_000),
+ script_pubkey: ScriptBuf::new_p2tr(&secp, input_xpub.to_x_only_pub(), None),
+ }],
+ };
+ let tx = Transaction {
+ version: transaction::Version::TWO,
+ lock_time: bitcoin::absolute::LockTime::ZERO,
+ input: vec![unsigned_input(&prev_tx, 0)],
+ output: vec![
+ TxOut {
+ value: Amount::from_sat(50_000_000),
+ script_pubkey: ScriptBuf::new_p2sh(&change_redeem.script_hash()),
+ },
+ TxOut {
+ value: Amount::from_sat(value),
+ script_pubkey: recipient_script,
+ },
+ ],
+ };
+ let mut psbt = Psbt::from_unsigned_tx(tx).unwrap();
+ psbt.inputs[0].non_witness_utxo = Some(prev_tx.clone());
+ psbt.inputs[0].witness_utxo = Some(prev_tx.output[0].clone());
+ psbt.inputs[0].tap_internal_key = Some(input_xpub.to_x_only_pub());
+ psbt.inputs[0].tap_key_origins.insert(
+ input_xpub.to_x_only_pub(),
+ (vec![], (fingerprint, input_path)),
+ );
+ psbt.outputs[0].redeem_script = Some(change_redeem);
+ psbt.outputs[0]
+ .bip32_derivation
+ .insert(change_xpub.to_pub().0, (fingerprint, change_path));
+
+ transaction_vector(
+ "payment-request",
+ "Covers signed SLIP-24 payment-request metadata, merchant and multiline memo screens, address suppression and the pre-v9.24 simulator merchant limitation.",
+ Coin::Tbtc,
+ psbt,
+ payment_request_options(1, value, address),
+ vec![taproot_key_signature(0, input_xpub.to_x_only_pub())],
+ screens::payment_request(),
+ )
+}
+
+fn payment_request_rejects_owned_output() -> TestVector {
+ let secp = secp();
+ let fingerprint = simulator_xprv().fingerprint(&secp);
+ let input_path = path("m/86'/1'/0'/0/0");
+ let change_path = path("m/86'/1'/0'/1/0");
+ let receive_path = path("m/84'/1'/0'/0/0");
+ let input_xpub = simulator_xpub_at(&secp, &input_path);
+ let change_xpub = simulator_xpub_at(&secp, &change_path);
+ let receive_xpub = simulator_xpub_at(&secp, &receive_path);
+ let receive_address = bitcoin::Address::p2wpkh(
+ &bitcoin::CompressedPublicKey(receive_xpub.public_key),
+ bitcoin::Network::Testnet,
+ )
+ .to_string();
+
+ let prev_tx = Transaction {
+ version: transaction::Version::TWO,
+ lock_time: bitcoin::absolute::LockTime::ZERO,
+ input: vec![dummy_input()],
+ output: vec![TxOut {
+ value: Amount::from_sat(100_000_000),
+ script_pubkey: ScriptBuf::new_p2tr(&secp, input_xpub.to_x_only_pub(), None),
+ }],
+ };
+ let value = 20_000_000;
+ let tx = Transaction {
+ version: transaction::Version::TWO,
+ lock_time: bitcoin::absolute::LockTime::ZERO,
+ input: vec![unsigned_input(&prev_tx, 0)],
+ output: vec![
+ TxOut {
+ value: Amount::from_sat(70_000_000),
+ script_pubkey: ScriptBuf::new_p2tr(&secp, change_xpub.to_x_only_pub(), None),
+ },
+ TxOut {
+ value: Amount::from_sat(value),
+ script_pubkey: ScriptBuf::new_p2wpkh(&receive_xpub.to_pub().wpubkey_hash()),
+ },
+ ],
+ };
+ let mut psbt = Psbt::from_unsigned_tx(tx).unwrap();
+ psbt.inputs[0].non_witness_utxo = Some(prev_tx.clone());
+ psbt.inputs[0].witness_utxo = Some(prev_tx.output[0].clone());
+ psbt.inputs[0].tap_internal_key = Some(input_xpub.to_x_only_pub());
+ psbt.inputs[0].tap_key_origins.insert(
+ input_xpub.to_x_only_pub(),
+ (vec![], (fingerprint, input_path)),
+ );
+ psbt.outputs[0].tap_internal_key = Some(change_xpub.to_x_only_pub());
+ psbt.outputs[0].tap_key_origins.insert(
+ change_xpub.to_x_only_pub(),
+ (vec![], (fingerprint, change_path)),
+ );
+ psbt.outputs[1]
+ .bip32_derivation
+ .insert(receive_xpub.public_key, (fingerprint, receive_path));
+
+ transaction_vector(
+ "payment-request-owned-output",
+ "Covers version-specific handling of payment-request metadata attached to an output owned by this device, rejected since v9.26.3.",
+ Coin::Tbtc,
+ psbt,
+ payment_request_options(1, value, &receive_address),
+ vec![taproot_key_signature(0, input_xpub.to_x_only_pub())],
+ screens::payment_request_owned_output(),
+ )
+}
### src/rust/bitbox-test-vectors/src/btc_transaction/cases/mod.rs
@@ -0,0 +1,20 @@
+// SPDX-License-Identifier: Apache-2.0
+
+use crate::btc_transaction::TestVector;
+
+mod additional_psbt;
+mod common;
+mod descriptor_psbt;
+mod metadata_psbt;
+mod screens;
+mod standard_psbt;
+
+pub(super) use common::firmware_request_from_psbt;
+
+pub fn all() -> Vec<TestVector> {
+ let mut vectors = standard_psbt::all();
+ vectors.extend(metadata_psbt::all());
+ vectors.extend(additional_psbt::all());
+ vectors.extend(descriptor_psbt::all());
+ vectors
+}
### src/rust/bitbox-test-vectors/src/btc_transaction/cases/screens.rs
@@ -0,0 +1,817 @@
+// SPDX-License-Identifier: Apache-2.0
+
+use super::common::SOME_XPUB;
+use crate::btc_transaction::{Coin, Outcome, Screen, VersionExpectation};
+
+const TBTC_EXTERNAL_ADDRESS: &str =
+ "tb1pff8vkq80pu2cgtu7ttgad2znw62v2lguhw6ptrppwns6nrpqau2qcuz37d";
+const TBTC_EXTERNAL_ADDRESS_GROUPED: &str =
+ "tb1p ff8v kq80 pu2c gtu7 ttga d2zn w62v 2lgu hw6p trpp wns6 nrpq au2q cuz3 7d";
+const BTC_P2TR_ADDRESS: &str = "bc1pmg5dhafms6h9nts4dtehgkanym6yeccfmk5hx3ts3jxnm4zh2knqv80ha5";
+const BTC_P2TR_ADDRESS_GROUPED: &str =
+ "bc1p mg5d hafm s6h9 nts4 dteh gkan ym6y eccf mk5h x3ts 3jxn m4zh 2knq v80h a5";
+const LTC_EXTERNAL_ADDRESS: &str = "ltc1qw508d6qejxtdg4y5r3zarvary0c5xw7kgmn4n9";
+const LTC_EXTERNAL_ADDRESS_GROUPED: &str = "ltc1 qw50 8d6q ejxt dg4y 5r3z arva ry0c 5xw7 kgmn 4n9";
+const SILENT_PAYMENT_ADDRESS: &str = "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv";
+const SILENT_PAYMENT_ADDRESS_GROUPED: &str = "sp1q qgst e7k9 hx0q ftg6 qmwl kqtw uy6c ycya vzmz j85c 6qdf hjdp djtd gqju exzk 6mur w56s uy3e 0rd2 cgqv ycxt tddw svgx e2us fpxu mr70 xc9p kqwv";
+const PSBT_SAME_ACCOUNT_ADDRESS: &str = "tb1ql34ny8mcpgjqr0ngsnjmlpzjpgncyz2ygh2gye";
+const PSBT_SAME_ACCOUNT_ADDRESS_GROUPED: &str =
+ "tb1q l34n y8mc pgjq r0ng snjm lpzj pgnc yz2y gh2g ye";
+const PSBT_OTHER_ACCOUNT_ADDRESS: &str = "tb1qvrcm2akp30d7ecnqdjk8qdu09962ak005rcp6j";
+const PSBT_OTHER_ACCOUNT_ADDRESS_GROUPED: &str =
+ "tb1q vrcm 2akp 30d7 ecnq djk8 qdu0 9962 ak00 5rcp 6j";
+// Simulator builds before v9.20 did not report transaction address or fee screens. Vectors whose
+// only earlier difference would be such an incomplete capture start at v9.20, so their screen lists
+// cannot be mistaken for the screens shown by the device.
+const COMPLETE_TRANSACTION_SCREEN_CAPTURE_VERSION: &str = "9.20.0";
+pub const DEVICE_POLICY_XPUB: &str = "[4c00739d/48'/1'/0'/3']tpubDF5MSzQdK2GfjmkNvrCZzpJhFt3if1HmrAdimugmGqWDCXYpkjxHpFZYuDxYYDAnnFMLMjLkMGvij2XV8pLtHBejgGy5RvNW4875nFGBDWv";
+
+fn success(
+ min_version: Option<&str>,
+ max_version_exclusive: Option<&str>,
+ screens: Vec<Screen>,
+) -> VersionExpectation {
+ VersionExpectation {
+ min_version: min_version.map(Into::into),
+ max_version_exclusive: max_version_exclusive.map(Into::into),
+ outcome: Outcome::Success,
+ unsupported_version: None,
+ screens,
+ }
+}
+
+fn unsupported_before(version: &str) -> VersionExpectation {
+ VersionExpectation {
+ min_version: None,
+ max_version_exclusive: Some(version.into()),
+ outcome: Outcome::Unsupported,
+ unsupported_version: Some(version.into()),
+ screens: vec![],
+ }
+}
+
+fn invalid_input_before(version: &str) -> VersionExpectation {
+ invalid_input(None, Some(version))
+}
+
+fn invalid_input(
+ min_version: Option<&str>,
+ max_version_exclusive: Option<&str>,
+) -> VersionExpectation {
+ invalid_input_with_screens(min_version, max_version_exclusive, vec![])
+}
+
+fn invalid_input_with_screens(
+ min_version: Option<&str>,
+ max_version_exclusive: Option<&str>,
+ screens: Vec<Screen>,
+) -> VersionExpectation {
+ VersionExpectation {
+ min_version: min_version.map(Into::into),
+ max_version_exclusive: max_version_exclusive.map(Into::into),
+ outcome: Outcome::InvalidInput,
+ unsupported_version: None,
+ screens,
+ }
+}
+
+fn status() -> Screen {
+ Screen::Status {
+ title: "Transaction".into(),
+ body: "confirmed".into(),
+ }
+}
+
+fn address(amount: &str, address: &str) -> Screen {
+ Screen::TransactionAddress {
+ amount: amount.into(),
+ address: address.into(),
+ }
+}
+
+fn final_fee(amount: &str, fee: &str) -> Screen {
+ Screen::TransactionFee {
+ amount: amount.into(),
+ fee: fee.into(),
+ longtouch: true,
+ }
+}
+
+fn warning_fee(amount: &str, fee: &str) -> Screen {
+ Screen::TransactionFee {
+ amount: amount.into(),
+ fee: fee.into(),
+ longtouch: false,
+ }
+}
+
+fn high_fee(percent: u32) -> Screen {
+ high_fee_decimal(&format!("{percent}.0"))
+}
+
+fn high_fee_decimal(percent: &str) -> Screen {
+ Screen::Confirm {
+ title: "High fee".into(),
+ body: format!("The fee is {percent}%\nthe send amount.\nProceed?"),
+ longtouch: true,
+ }
+}
+
+fn high_fee_total_inputs(percent: u32) -> Screen {
+ Screen::Confirm {
+ title: "High fee".into(),
+ body: format!("The fee is {percent}.0%\nof all inputs.\nProceed?"),
+ longtouch: true,
+ }
+}
+
+fn transaction_screens(
+ amount: &str,
+ output_address: &str,
+ total: &str,
+ transaction_fee: &str,
+ high_fee_percent: &str,
+) -> Vec<Screen> {
+ vec![
+ address(amount, output_address),
+ warning_fee(total, transaction_fee),
+ high_fee_decimal(high_fee_percent),
+ status(),
+ ]
+}
+
+fn standard_expectations(
+ prefix: Vec<Screen>,
+ total: &str,
+ transaction_fee: &str,
+ high_fee_percent: u32,
+) -> Vec<VersionExpectation> {
+ let suffix_920 = vec![
+ address("0.20000000 TBTC", TBTC_EXTERNAL_ADDRESS),
+ warning_fee(total, transaction_fee),
+ high_fee(high_fee_percent),
+ status(),
+ ];
+ let suffix_926 = vec![
+ address("0.20000000 TBTC", TBTC_EXTERNAL_ADDRESS_GROUPED),
+ warning_fee(total, transaction_fee),
+ high_fee(high_fee_percent),
+ status(),
+ ];
+
+ vec![
+ success(
+ Some(COMPLETE_TRANSACTION_SCREEN_CAPTURE_VERSION),
+ Some("9.26.0"),
+ with_suffix(&prefix, suffix_920),
+ ),
+ success(Some("9.26.0"), None, with_suffix(&prefix, suffix_926)),
+ ]
+}
+
+fn simple_expectations(
+ middle: &[Screen],
+ external_address: &str,
+ grouped_external_address: &str,
+ unit: &str,
+) -> Vec<VersionExpectation> {
+ let amount = format!("0.20000000 {unit}");
+ let total = format!("0.30000000 {unit}");
+ let fee_amount = format!("0.10000000 {unit}");
+ let suffix = |address_value: &str| {
+ let mut screens = vec![address(&amount, address_value)];
+ screens.extend_from_slice(middle);
+ screens.extend([warning_fee(&total, &fee_amount), high_fee(50), status()]);
+ screens
+ };
+
+ vec![
+ success(
+ Some(COMPLETE_TRANSACTION_SCREEN_CAPTURE_VERSION),
+ Some("9.26.0"),
+ suffix(external_address),
+ ),
+ success(Some("9.26.0"), None, suffix(grouped_external_address)),
+ ]
+}
+
+fn taproot_policy_expectations(prefix: Vec<Screen>) -> Vec<VersionExpectation> {
+ let suffix_920 = vec![
+ address("0.20000000 TBTC", TBTC_EXTERNAL_ADDRESS),
+ warning_fee("0.30000000 TBTC", "0.10000000 TBTC"),
+ high_fee(50),
+ status(),
+ ];
+ let suffix_926 = vec![
+ address("0.20000000 TBTC", TBTC_EXTERNAL_ADDRESS_GROUPED),
+ warning_fee("0.30000000 TBTC", "0.10000000 TBTC"),
+ high_fee(50),
+ status(),
+ ];
+
+ vec![
+ unsupported_before("9.21.0"),
+ success(
+ Some("9.21.0"),
+ Some("9.26.0"),
+ with_suffix(&prefix, suffix_920),
+ ),
+ success(Some("9.26.0"), None, with_suffix(&prefix, suffix_926)),
+ ]
+}
+
+fn with_suffix(prefix: &[Screen], suffix: Vec<Screen>) -> Vec<Screen> {
+ prefix.iter().cloned().chain(suffix).collect()
+}
+
+pub fn taproot_key_spend() -> Vec<VersionExpectation> {
+ standard_expectations(vec![], "1.00000000 TBTC", "0.80000000 TBTC", 400)
+}
+
+pub fn simple_tbtc(middle: &[Screen]) -> Vec<VersionExpectation> {
+ simple_expectations(
+ middle,
+ TBTC_EXTERNAL_ADDRESS,
+ TBTC_EXTERNAL_ADDRESS_GROUPED,
+ "TBTC",
+ )
+}
+
+pub fn simple_ltc(middle: &[Screen]) -> Vec<VersionExpectation> {
+ simple_expectations(
+ middle,
+ LTC_EXTERNAL_ADDRESS,
+ LTC_EXTERNAL_ADDRESS_GROUPED,
+ "LTC",
+ )
+}
+
+pub fn multiple_output_types(
+ coin: Coin,
+ sat: bool,
+ high_fee_warning: bool,
+) -> Vec<VersionExpectation> {
+ assert!(!high_fee_warning || coin == Coin::Btc && !sat);
+ let (unit, addresses, grouped_addresses) = match coin {
+ Coin::Btc => (
+ "BTC",
+ [
+ "12ZEw5Hcv1hTb6YUQJ69y1V7uhcoDz92PH",
+ "34oVnh4gNviJGMnNvgquMeLAxvXJuaRVMZ",
+ "bc1qxvenxvenxvenxvenxvenxvenxvenxven2ymjt8",
+ "bc1qg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zqd8sxw4",
+ ],
+ [
+ "12ZE w5Hc v1hT b6YU QJ69 y1V7 uhco Dz92 PH",
+ "34oV nh4g NviJ GMnN vgqu MeLA xvXJ uaRV MZ",
+ "bc1q xven xven xven xven xven xven xven xven 2ymj t8",
+ "bc1q g3zy g3zy g3zy g3zy g3zy g3zy g3zy g3zy g3zy g3zy g3zy g3zy g3zq d8sx w4",
+ ],
+ ),
+ Coin::Ltc => (
+ "LTC",
+ [
+ "LLnCCHbSzfwWquEdaS5TF2Yt7uz5Qb1SZ1",
+ "MB1e6aUeL3Zj4s4H2ZqFBHaaHd7kvvzTco",
+ "ltc1qxvenxvenxvenxvenxvenxvenxvenxvenwcpknh",
+ "ltc1qg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zqwr7k5s",
+ ],
+ [
+ "LLnC CHbS zfwW quEd aS5T F2Yt 7uz5 Qb1S Z1",
+ "MB1e 6aUe L3Zj 4s4H 2ZqF BHaa Hd7k vvzT co",
+ "ltc1 qxve nxve nxve nxve nxve nxve nxve nxve nwcp knh",
+ "ltc1 qg3z yg3z yg3z yg3z yg3z yg3z yg3z yg3z yg3z yg3z yg3z yg3z yg3z qwr7 k5s",
+ ],
+ ),
+ Coin::Tbtc => panic!("multiple-output fixture has no TBTC address set"),
+ };
+ let amounts = if sat {
+ [
+ "100000000 sat".into(),
+ "1234567890 sat".into(),
+ "6000 sat".into(),
+ "7000 sat".into(),
+ ]
+ } else {
+ [
+ format!("1.00000000 {unit}"),
+ format!(
+ "{} {unit}",
+ if high_fee_warning {
+ "10.34567890"
+ } else {
+ "12.34567890"
+ }
+ ),
+ format!("0.00006000 {unit}"),
+ format!("0.00007000 {unit}"),
+ ]
+ };
+ let change_warning = || Screen::Confirm {
+ title: "Warning".into(),
+ body: "There are 2\nchange outputs.\nProceed?".into(),
+ longtouch: false,
+ };
+ let final_screens = |addresses: [&str; 4]| {
+ let mut result = addresses
+ .into_iter()
+ .zip(&amounts)
+ .map(|(output_address, amount)| address(amount, output_address))
+ .collect::<Vec<_>>();
+ result.push(change_warning());
+ result.push(if sat {
+ final_fee("1339999900 sat", "5419010 sat")
+ } else if high_fee_warning {
+ warning_fee("13.39999900 BTC", "2.05419010 BTC")
+ } else {
+ final_fee(
+ &format!("13.39999900 {unit}"),
+ &format!("0.05419010 {unit}"),
+ )
+ });
+ if high_fee_warning {
+ result.push(high_fee_decimal("18.1"));
+ }
+ result.push(status());
+ result
+ };
+ vec![
+ success(
+ Some(COMPLETE_TRANSACTION_SCREEN_CAPTURE_VERSION),
+ Some("9.26.0"),
+ final_screens(addresses),
+ ),
+ success(Some("9.26.0"), None, final_screens(grouped_addresses)),
+ ]
+}
+
+pub fn p2tr_output_btc() -> Vec<VersionExpectation> {
+ simple_expectations(&[], BTC_P2TR_ADDRESS, BTC_P2TR_ADDRESS_GROUPED, "BTC")
+}
+
+pub fn always_invalid_input() -> Vec<VersionExpectation> {
+ vec![invalid_input(None, None)]
+}
+
+pub fn always_invalid_input_with_screens(screens: Vec<Screen>) -> Vec<VersionExpectation> {
+ vec![invalid_input_with_screens(None, None, screens)]
+}
+
+pub fn unsupported_then_invalid_input(version: &str) -> Vec<VersionExpectation> {
+ vec![
+ unsupported_before(version),
+ invalid_input(Some(version), None),
+ ]
+}
+
+pub fn swap_payment_request() -> Vec<VersionExpectation> {
+ vec![
+ invalid_input(None, Some("9.24.0")),
+ invalid_input_with_screens(
+ Some("9.24.0"),
+ Some("9.26.0"),
+ vec![address("0.20000000 BTC", "Test Merchant")],
+ ),
+ success(
+ Some("9.26.0"),
+ None,
+ vec![
+ address("0.20000000 BTC", "Test Merchant"),
+ Screen::Swap {
+ title: "Swap".into(),
+ from: "0.20000000 BTC".into(),
+ to: "0.25 ETH".into(),
+ },
+ warning_fee("0.30000000 BTC", "0.10000000 BTC"),
+ high_fee(50),
+ status(),
+ ],
+ ),
+ ]
+}
+
+pub fn swap_payment_request_unsupported_source() -> Vec<VersionExpectation> {
+ vec![
+ invalid_input(None, Some("9.24.0")),
+ invalid_input_with_screens(
+ Some("9.24.0"),
+ Some("9.26.0"),
+ vec![address("0.20000000 TBTC", "Test Merchant")],
+ ),
+ invalid_input(Some("9.26.0"), None),
+ ]
+}
+
+pub fn mixed_spend() -> Vec<VersionExpectation> {
+ standard_expectations(vec![], "2.00000000 TBTC", "1.80000000 TBTC", 900)
+}
+
+pub fn op_return() -> Vec<VersionExpectation> {
+ vec![
+ unsupported_before("9.24.0"),
+ success(
+ Some("9.24.0"),
+ None,
+ vec![
+ Screen::Confirm {
+ title: "OP_RETURN".into(),
+ body: "hello world".into(),
+ longtouch: false,
+ },
+ final_fee("0.01000000 TBTC", "0.01000000 TBTC"),
+ status(),
+ ],
+ ),
+ ]
+}
+
+pub fn op_return_nonascii() -> Vec<VersionExpectation> {
+ let screens = |external_address: &str| {
+ vec![
+ Screen::Confirm {
+ title: "OP_RETURN\ndata (hex)".into(),
+ body: "0102030405".into(),
+ longtouch: false,
+ },
+ address("0.20000000 TBTC", external_address),
+ warning_fee("0.30000000 TBTC", "0.10000000 TBTC"),
+ high_fee(50),
+ status(),
+ ]
+ };
+ vec![
+ unsupported_before("9.24.0"),
+ success(
+ Some("9.24.0"),
+ Some("9.26.0"),
+ screens(TBTC_EXTERNAL_ADDRESS),
+ ),
+ success(Some("9.26.0"), None, screens(TBTC_EXTERNAL_ADDRESS_GROUPED)),
+ ]
+}
+
+pub fn all_change_high_fee(with_op_return: bool) -> Vec<VersionExpectation> {
+ let prefix = || {
+ with_op_return
+ .then(|| Screen::Confirm {
+ title: "OP_RETURN".into(),
+ body: "metadata".into(),
+ longtouch: false,
+ })
+ .into_iter()
+ .collect::<Vec<_>>()
+ };
+ let previous_screens = || {
+ let mut screens = prefix();
+ screens.extend([final_fee("0.70000000 BTC", "0.70000000 BTC"), status()]);
+ screens
+ };
+ let current_screens = || {
+ let mut screens = prefix();
+ screens.extend([
+ warning_fee("0.70000000 BTC", "0.70000000 BTC"),
+ high_fee_total_inputs(70),
+ status(),
+ ]);
+ screens
+ };
+
+ if with_op_return {
+ vec![
+ unsupported_before("9.24.0"),
+ success(Some("9.24.0"), Some("9.27.0"), previous_screens()),
+ success(Some("9.27.0"), None, current_screens()),
+ ]
+ } else {
+ vec![
+ success(
+ Some(COMPLETE_TRANSACTION_SCREEN_CAPTURE_VERSION),
+ Some("9.27.0"),
+ previous_screens(),
+ ),
+ success(Some("9.27.0"), None, current_screens()),
+ ]
+ }
+}
+
+pub fn all_change_fee_below_threshold() -> Vec<VersionExpectation> {
+ vec![success(
+ Some(COMPLETE_TRANSACTION_SCREEN_CAPTURE_VERSION),
+ None,
+ vec![final_fee("0.05000000 BTC", "0.05000000 BTC"), status()],
+ )]
+}
+
+pub fn multisig(threshold: u32, xpub_count: usize, name: &str) -> Vec<VersionExpectation> {
+ standard_expectations(
+ vec![
+ Screen::Confirm {
+ title: "Spend from".into(),
+ body: format!("{threshold}-of-{xpub_count}\nBTC Testnet multisig"),
+ longtouch: false,
+ },
+ Screen::Confirm {
+ title: "Spend from".into(),
+ body: name.into(),
+ longtouch: false,
+ },
+ ],
+ "0.30000000 TBTC",
+ "0.10000000 TBTC",
+ 50,
+ )
+}
+
+pub fn multisig_p2wsh() -> Vec<VersionExpectation> {
+ multisig(1, 2, "test wsh multisig")
+}
+
+pub fn policy_prefix(policy: &str, name: &str, keys: &[String]) -> Vec<Screen> {
+ let mut result = vec![
+ Screen::Confirm {
+ title: "Spend from".into(),
+ body: format!("BTC Testnet\npolicy with\n{} keys", keys.len()),
+ longtouch: false,
+ },
+ Screen::Confirm {
+ title: "Name".into(),
+ body: name.into(),
+ longtouch: false,
+ },
+ Screen::Confirm {
+ title: "".into(),
+ body: "Show policy\ndetails?".into(),
+ longtouch: false,
+ },
+ Screen::Confirm {
+ title: "Policy".into(),
+ body: policy.into(),
+ longtouch: false,
+ },
+ ];
+ result.extend(keys.iter().enumerate().map(|(index, key)| Screen::Confirm {
+ title: format!("Key {}/{}", index + 1, keys.len()),
+ body: key.clone(),
+ longtouch: false,
+ }));
+ result
+}
+
+pub fn policy(policy: &str, name: &str, keys: &[String], taproot: bool) -> Vec<VersionExpectation> {
+ let prefix = policy_prefix(policy, name, keys);
+ if taproot {
+ taproot_policy_expectations(prefix)
+ } else {
+ standard_expectations(prefix, "0.30000000 TBTC", "0.10000000 TBTC", 50)
+ }
+}
+
+pub fn policy_wsh() -> Vec<VersionExpectation> {
+ policy(
+ "wsh(or_b(pk(@0/<0;1>/*),s:pk(@1/<0;1>/*)))",
+ "test wsh policy",
+ &[
+ format!("This device: {DEVICE_POLICY_XPUB}"),
+ SOME_XPUB.into(),
+ ],
+ false,
+ )
+}
+
+pub fn policy_tr_keyspend() -> Vec<VersionExpectation> {
+ policy(
+ "tr(@0/<0;1>/*)",
+ "test tr keyspend policy",
+ &[format!("This device: {DEVICE_POLICY_XPUB}")],
+ true,
+ )
+}
+
+pub fn policy_tr_scriptspend() -> Vec<VersionExpectation> {
+ policy(
+ "tr(@0/<0;1>/*,pk(@1/<0;1>/*))",
+ "test tr scriptspend policy",
+ &[
+ SOME_XPUB.into(),
+ format!("This device: {DEVICE_POLICY_XPUB}"),
+ ],
+ true,
+ )
+}
+
+pub fn taproot_to_non_taproot_change() -> Vec<VersionExpectation> {
+ standard_expectations(vec![], "1.00000000 TBTC", "0.80000000 TBTC", 400)
+}
+
+pub fn silent_payment() -> Vec<VersionExpectation> {
+ vec![
+ unsupported_before("9.21.0"),
+ success(
+ Some("9.21.0"),
+ Some("9.26.0"),
+ transaction_screens(
+ "0.20000000 BTC",
+ SILENT_PAYMENT_ADDRESS,
+ "2.00000000 BTC",
+ "1.80000000 BTC",
+ "900.0",
+ ),
+ ),
+ success(
+ Some("9.26.0"),
+ None,
+ transaction_screens(
+ "0.20000000 BTC",
+ SILENT_PAYMENT_ADDRESS_GROUPED,
+ "2.00000000 BTC",
+ "1.80000000 BTC",
+ "900.0",
+ ),
+ ),
+ ]
+}
+
+pub fn send_self_same_account() -> Vec<VersionExpectation> {
+ let old_address = format!("This BitBox02: {PSBT_SAME_ACCOUNT_ADDRESS}");
+ let address = format!("This BitBox (same account): {PSBT_SAME_ACCOUNT_ADDRESS}");
+ let grouped_address =
+ format!("This BitBox (same account): {PSBT_SAME_ACCOUNT_ADDRESS_GROUPED}");
+
+ vec![
+ success(
+ Some(COMPLETE_TRANSACTION_SCREEN_CAPTURE_VERSION),
+ Some("9.22.0"),
+ transaction_screens(
+ "0.20000000 TBTC",
+ &old_address,
+ "0.50000000 TBTC",
+ "0.30000000 TBTC",
+ "150.0",
+ ),
+ ),
+ success(
+ Some("9.22.0"),
+ Some("9.26.0"),
+ transaction_screens(
+ "0.20000000 TBTC",
+ &address,
+ "0.50000000 TBTC",
+ "0.30000000 TBTC",
+ "150.0",
+ ),
+ ),
+ success(
+ Some("9.26.0"),
+ None,
+ transaction_screens(
+ "0.20000000 TBTC",
+ &grouped_address,
+ "0.50000000 TBTC",
+ "0.30000000 TBTC",
+ "150.0",
+ ),
+ ),
+ ]
+}
+
+pub fn silent_payment_owned_output() -> Vec<VersionExpectation> {
+ let old_address = format!("This BitBox02: {SILENT_PAYMENT_ADDRESS}");
+ let address = format!("This BitBox (same account): {SILENT_PAYMENT_ADDRESS}");
+ let grouped_address = format!("This BitBox (same account): {SILENT_PAYMENT_ADDRESS_GROUPED}");
+
+ vec![
+ unsupported_before("9.21.0"),
+ success(
+ Some("9.21.0"),
+ Some("9.22.0"),
+ transaction_screens(
+ "0.20000000 BTC",
+ &old_address,
+ "0.30000000 BTC",
+ "0.10000000 BTC",
+ "50.0",
+ ),
+ ),
+ success(
+ Some("9.22.0"),
+ Some("9.26.0"),
+ transaction_screens(
+ "0.20000000 BTC",
+ &address,
+ "0.30000000 BTC",
+ "0.10000000 BTC",
+ "50.0",
+ ),
+ ),
+ success(
+ Some("9.26.0"),
+ Some("9.26.3"),
+ transaction_screens(
+ "0.20000000 BTC",
+ &grouped_address,
+ "0.30000000 BTC",
+ "0.10000000 BTC",
+ "50.0",
+ ),
+ ),
+ invalid_input(Some("9.26.3"), None),
+ ]
+}
+
+pub fn payment_request_owned_output() -> Vec<VersionExpectation> {
+ vec![
+ invalid_input(None, Some("9.24.0")),
+ success(
+ Some("9.24.0"),
+ Some("9.26.3"),
+ payment_request_screens("0.30000000 TBTC", "0.10000000 TBTC", 50),
+ ),
+ invalid_input(Some("9.26.3"), None),
+ ]
+}
+
+fn send_self_different_account_modern() -> Vec<VersionExpectation> {
+ let address = format!("This BitBox (account #2): {PSBT_OTHER_ACCOUNT_ADDRESS}");
+ let grouped_address = format!("This BitBox (account #2): {PSBT_OTHER_ACCOUNT_ADDRESS_GROUPED}");
+
+ vec![
+ success(
+ Some("9.22.0"),
+ Some("9.26.0"),
+ transaction_screens(
+ "0.20000000 TBTC",
+ &address,
+ "0.50000000 TBTC",
+ "0.30000000 TBTC",
+ "150.0",
+ ),
+ ),
+ success(
+ Some("9.26.0"),
+ None,
+ transaction_screens(
+ "0.20000000 TBTC",
+ &grouped_address,
+ "0.50000000 TBTC",
+ "0.30000000 TBTC",
+ "150.0",
+ ),
+ ),
+ ]
+}
+
+pub fn send_self_different_account() -> Vec<VersionExpectation> {
+ vec![success(
+ Some(COMPLETE_TRANSACTION_SCREEN_CAPTURE_VERSION),
+ Some("9.22.0"),
+ transaction_screens(
+ "0.20000000 TBTC",
+ PSBT_OTHER_ACCOUNT_ADDRESS,
+ "0.50000000 TBTC",
+ "0.30000000 TBTC",
+ "150.0",
+ ),
+ )]
+ .into_iter()
+ .chain(send_self_different_account_modern())
+ .collect()
+}
+
+fn payment_request_screens(
+ total: &str,
+ transaction_fee: &str,
+ high_fee_percent: u32,
+) -> Vec<Screen> {
+ vec![
+ address("0.20000000 TBTC", "Test Merchant"),
+ Screen::Confirm {
+ title: "".into(),
+ body: "Memo from\n\nTest Merchant".into(),
+ longtouch: false,
+ },
+ Screen::Confirm {
+ title: "Memo 1/2".into(),
+ body: "TextMemo line1".into(),
+ longtouch: false,
+ },
+ Screen::Confirm {
+ title: "Memo 2/2".into(),
+ body: "TextMemo line2".into(),
+ longtouch: false,
+ },
+ warning_fee(total, transaction_fee),
+ high_fee(high_fee_percent),
+ status(),
+ ]
+}
+
+pub fn payment_request() -> Vec<VersionExpectation> {
+ vec![
+ invalid_input_before("9.24.0"),
+ success(
+ Some("9.24.0"),
+ None,
+ payment_request_screens("0.50000000 TBTC", "0.30000000 TBTC", 150),
+ ),
+ ]
+}
### src/rust/bitbox-test-vectors/src/btc_transaction/cases/standard_psbt.rs
@@ -0,0 +1,873 @@
+// SPDX-License-Identifier: Apache-2.0
+
+use super::common::{
+ SOME_XPUB, ecdsa_signature, keypath, secp, simulator_xprv, simulator_xpub_at,
+ taproot_key_signature, taproot_script_signature, transaction_vector,
+};
+use super::screens;
+use crate::btc_transaction::{
+ Coin, KeyOriginInfo, MultisigScriptType, PsbtSignOptions, Registration, ScriptConfig,
+ ScriptConfigWithKeypath, TestVector,
+};
+use bitcoin::bip32::{DerivationPath, Xpub};
+use bitcoin::opcodes::all;
+use bitcoin::psbt::Psbt;
+use bitcoin::{
+ Amount, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Witness,
+ blockdata::script::Builder, transaction,
+};
+use miniscript::psbt::PsbtExt;
+
+pub fn all() -> Vec<TestVector> {
+ vec![
+ taproot_key_spend(),
+ mixed_spend(),
+ op_return(),
+ multisig_p2wsh(),
+ policy_wsh(),
+ policy_tr_keyspend(),
+ policy_tr_scriptspend(),
+ ]
+}
+
+/// Test signing where all inputs are BIP86 Taproot keyspends.
+fn taproot_key_spend() -> TestVector {
+ let secp = secp();
+ let fingerprint = simulator_xprv().fingerprint(&secp);
+
+ let change_path: DerivationPath = "m/86'/1'/0'/1/0".parse().unwrap();
+ let change_xpub = simulator_xpub_at(&secp, &change_path);
+
+ let input0_path: DerivationPath = "m/86'/1'/0'/0/0".parse().unwrap();
+ let input0_xpub = simulator_xpub_at(&secp, &input0_path);
+
+ let input1_path: DerivationPath = "m/86'/1'/0'/0/1".parse().unwrap();
+ let input1_xpub = simulator_xpub_at(&secp, &input1_path);
+
+ // 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: ScriptBuf::new_p2tr(&secp, input0_xpub.to_x_only_pub(), None),
+ },
+ TxOut {
+ value: Amount::from_sat(100_000_000),
+ script_pubkey: ScriptBuf::new_p2tr(&secp, input1_xpub.to_x_only_pub(), None),
+ },
+ ],
+ };
+
+ 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(),
+ },
+ TxIn {
+ previous_output: OutPoint {
+ txid: prev_tx.compute_txid(),
+ vout: 1,
+ },
+ script_sig: ScriptBuf::new(),
+ sequence: Sequence(0xFFFFFFFF),
+ witness: Witness::default(),
+ },
+ ],
+ output: vec![
+ TxOut {
+ value: Amount::from_sat(100_000_000),
+ script_pubkey: ScriptBuf::new_p2tr(&secp, change_xpub.to_x_only_pub(), None),
+ },
+ 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());
+ psbt.inputs[0].tap_internal_key = Some(input0_xpub.to_x_only_pub());
+ psbt.inputs[0].tap_key_origins.insert(
+ input0_xpub.to_x_only_pub(),
+ (vec![], (fingerprint, input0_path.clone())),
+ );
+ psbt.inputs[1].witness_utxo = Some(prev_tx.output[1].clone());
+ psbt.inputs[1].tap_internal_key = Some(input1_xpub.to_x_only_pub());
+ psbt.inputs[1].tap_key_origins.insert(
+ input1_xpub.to_x_only_pub(),
+ (vec![], (fingerprint, input1_path.clone())),
+ );
+
+ psbt.outputs[0].tap_internal_key = Some(change_xpub.to_x_only_pub());
+ psbt.outputs[0].tap_key_origins.insert(
+ change_xpub.to_x_only_pub(),
+ (vec![], (fingerprint, change_path)),
+ );
+
+ transaction_vector(
+ "taproot-key-spend",
+ "Signs two BIP86 Taproot key-spend inputs and recognizes a BIP86 change output.",
+ Coin::Tbtc,
+ psbt,
+ PsbtSignOptions::default(),
+ vec![
+ taproot_key_signature(0, input0_xpub.to_x_only_pub()),
+ taproot_key_signature(1, input1_xpub.to_x_only_pub()),
+ ],
+ screens::taproot_key_spend(),
+ )
+}
+
+/// Test signing with mixed input types: P2TR, P2WPKH and P2SH-P2WPKH.
+fn mixed_spend() -> TestVector {
+ let secp = secp();
+ let fingerprint = simulator_xprv().fingerprint(&secp);
+
+ let change_path: DerivationPath = "m/86'/1'/0'/1/0".parse().unwrap();
+ let change_xpub = simulator_xpub_at(&secp, &change_path);
+
+ let input0_path: DerivationPath = "m/86'/1'/0'/0/0".parse().unwrap();
+ let input0_xpub = simulator_xpub_at(&secp, &input0_path);
+
+ let input1_path: DerivationPath = "m/84'/1'/0'/0/0".parse().unwrap();
+ let input1_xpub = simulator_xpub_at(&secp, &input1_path);
+
+ let input2_path: DerivationPath = "m/49'/1'/0'/0/0".parse().unwrap();
+ let input2_xpub = simulator_xpub_at(&secp, &input2_path);
+
+ let input2_redeemscript = ScriptBuf::new_p2wpkh(&input2_xpub.to_pub().wpubkey_hash());
+
+ // 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: ScriptBuf::new_p2tr(&secp, input0_xpub.to_x_only_pub(), None),
+ },
+ TxOut {
+ value: Amount::from_sat(100_000_000),
+ script_pubkey: ScriptBuf::new_p2wpkh(&input1_xpub.to_pub().wpubkey_hash()),
+ },
+ TxOut {
+ value: Amount::from_sat(100_000_000),
+ script_pubkey: ScriptBuf::new_p2sh(&input2_redeemscript.clone().into()),
+ },
+ ],
+ };
+
+ let tx = Transaction {
+ version: transaction::Version::TWO,
+ lock_time: bitcoin::absolute::LockTime::ZERO,
+ input: (0..3)
+ .map(|vout| TxIn {
+ previous_output: OutPoint {
+ txid: prev_tx.compute_txid(),
+ vout,
+ },
+ script_sig: ScriptBuf::new(),
+ sequence: Sequence(0xFFFFFFFF),
+ witness: Witness::default(),
+ })
+ .collect(),
+ output: vec![
+ TxOut {
+ value: Amount::from_sat(100_000_000),
+ script_pubkey: ScriptBuf::new_p2tr(&secp, change_xpub.to_x_only_pub(), None),
+ },
+ 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].non_witness_utxo = Some(prev_tx.clone());
+ psbt.inputs[0].tap_internal_key = Some(input0_xpub.to_x_only_pub());
+ psbt.inputs[0].tap_key_origins.insert(
+ input0_xpub.to_x_only_pub(),
+ (vec![], (fingerprint, input0_path)),
+ );
+
+ psbt.inputs[1].non_witness_utxo = Some(prev_tx.clone());
+ psbt.inputs[1]
+ .bip32_derivation
+ .insert(input1_xpub.to_pub().0, (fingerprint, input1_path));
+
+ psbt.inputs[2].non_witness_utxo = Some(prev_tx);
+ psbt.inputs[2].redeem_script = Some(input2_redeemscript);
+ psbt.inputs[2]
+ .bip32_derivation
+ .insert(input2_xpub.to_pub().0, (fingerprint, input2_path));
+
+ psbt.outputs[0].tap_internal_key = Some(change_xpub.to_x_only_pub());
+ psbt.outputs[0].tap_key_origins.insert(
+ change_xpub.to_x_only_pub(),
+ (vec![], (fingerprint, change_path)),
+ );
+
+ transaction_vector(
+ "mixed-spend",
+ "Signs P2TR, native P2WPKH and nested P2SH-P2WPKH inputs from one previous transaction and recognizes BIP86 change.",
+ Coin::Tbtc,
+ psbt,
+ PsbtSignOptions::default(),
+ vec![
+ taproot_key_signature(0, input0_xpub.to_x_only_pub()),
+ ecdsa_signature(1, input1_xpub.to_pub().0),
+ ecdsa_signature(2, input2_xpub.to_pub().0),
+ ],
+ screens::mixed_spend(),
+ )
+}
+
+fn op_return() -> TestVector {
+ let secp = secp();
+ let fingerprint = simulator_xprv().fingerprint(&secp);
+
+ let input_path: DerivationPath = "m/84'/1'/0'/0/5".parse().unwrap();
+ let change_path: DerivationPath = "m/84'/1'/0'/1/0".parse().unwrap();
+
+ let input_pub = simulator_xpub_at(&secp, &input_path).to_pub();
+ let change_pub = simulator_xpub_at(&secp, &change_path).to_pub();
+
+ 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(50_000_000),
+ script_pubkey: ScriptBuf::new_p2wpkh(&input_pub.wpubkey_hash()),
+ }],
+ };
+
+ let op_return_data = b"hello world";
+ let op_return_script = Builder::new()
+ .push_opcode(all::OP_RETURN)
+ .push_slice(op_return_data)
+ .into_script();
+
+ 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(49_000_000),
+ script_pubkey: ScriptBuf::new_p2wpkh(&change_pub.wpubkey_hash()),
+ },
+ TxOut {
+ value: Amount::from_sat(0),
+ script_pubkey: op_return_script,
+ },
+ ],
+ };
+
+ let mut psbt = Psbt::from_unsigned_tx(tx).unwrap();
+
+ psbt.inputs[0].non_witness_utxo = Some(prev_tx.clone());
+ psbt.inputs[0].witness_utxo = Some(prev_tx.output[0].clone());
+ psbt.inputs[0]
+ .bip32_derivation
+ .insert(input_pub.0, (fingerprint, input_path));
+
+ psbt.outputs[0]
+ .bip32_derivation
+ .insert(change_pub.0, (fingerprint, change_path));
+
+ transaction_vector(
+ "op-return",
+ "Signs a P2WPKH spend with a zero-value OP_RETURN output containing one printable data push and a P2WPKH change output.",
+ Coin::Tbtc,
+ psbt,
+ PsbtSignOptions::default(),
+ vec![ecdsa_signature(0, input_pub.0)],
+ screens::op_return(),
+ )
+}
+
+/// Test a registered 1-of-2 P2WSH multisig account. The historical test name says 1-of-3, but the
+/// client test has always used two xpubs; this vector retains the exact transaction construction.
+fn multisig_p2wsh() -> TestVector {
+ let secp = secp();
+ let our_root_fingerprint = simulator_xprv().fingerprint(&secp);
+
+ let threshold: u32 = 1;
+ let keypath_account: DerivationPath = "m/48'/1'/0'/2'".parse().unwrap();
+
+ let our_xpub: Xpub = simulator_xpub_at(&secp, &keypath_account);
+ let some_xpub: Xpub = SOME_XPUB.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<miniscript::DescriptorPublicKey> = format!(
+ "wsh(sortedmulti({},[{}/48'/1'/0'/2']{}/<0;1>/*,{}/<0;1>/*))",
+ threshold, our_root_fingerprint, our_xpub, some_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 multisig_config = ScriptConfig::Multisig {
+ threshold,
+ xpubs: vec![our_xpub.to_string(), some_xpub.to_string()],
+ our_xpub_index: 0,
+ script_type: MultisigScriptType::P2wsh,
+ };
+
+ // 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].non_witness_utxo = Some(prev_tx);
+ // 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();
+
+ let input_path: DerivationPath = "m/48'/1'/0'/2'/0/0".parse().unwrap();
+ let input_pubkey = simulator_xpub_at(&secp, &input_path).public_key;
+ let mut vector = transaction_vector(
+ "multisig-p2wsh",
+ "Signs the device branch of a registered 1-of-2 sortedmulti P2WSH input, recognizes descriptor-derived change, and finalizes the witness.",
+ Coin::Tbtc,
+ psbt,
+ PsbtSignOptions {
+ force_script_config: Some(ScriptConfigWithKeypath {
+ script_config: multisig_config.clone(),
+ keypath: keypath(&keypath_account),
+ }),
+ ..Default::default()
+ },
+ vec![ecdsa_signature(0, input_pubkey)],
+ screens::multisig_p2wsh(),
+ );
+ vector.registrations = vec![Registration {
+ script_config: multisig_config,
+ keypath: Some(keypath(&keypath_account)),
+ name: "test wsh multisig".into(),
+ }];
+ vector
+}
+
+fn policy_wsh() -> TestVector {
+ let secp = secp();
+ // Policy string following BIP-388 syntax, input to the BitBox.
+ let policy = "wsh(or_b(pk(@0/<0;1>/*),s:pk(@1/<0;1>/*)))";
+
+ let our_root_fingerprint = simulator_xprv().fingerprint(&secp);
+ let keypath_account: DerivationPath = "m/48'/1'/0'/3'".parse().unwrap();
+ let our_xpub: Xpub = simulator_xpub_at(&secp, &keypath_account);
+ let some_xpub: Xpub = SOME_XPUB.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<miniscript::DescriptorPublicKey> = policy
+ .replace(
+ "@0",
+ &format!("[{}/48'/1'/0'/3']{}", our_root_fingerprint, our_xpub),
+ )
+ .replace("@1", &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 policy_config = ScriptConfig::Policy {
+ policy: policy.into(),
+ keys: vec![
+ // Our key: root fingerprint and keypath are required.
+ KeyOriginInfo {
+ root_fingerprint: Some(our_root_fingerprint.to_string()),
+ keypath: Some(keypath(&keypath_account)),
+ xpub: our_xpub.to_string(),
+ },
+ // Foreign key: root fingerprint and keypath are optional.
+ KeyOriginInfo {
+ root_fingerprint: None,
+ keypath: None,
+ xpub: some_xpub.to_string(),
+ },
+ ],
+ };
+
+ // 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].non_witness_utxo = Some(prev_tx);
+ // 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();
+
+ let input_path: DerivationPath = "m/48'/1'/0'/3'/0/0".parse().unwrap();
+ let input_pubkey = simulator_xpub_at(&secp, &input_path).public_key;
+ let mut vector = transaction_vector(
+ "policy-wsh",
+ "Signs the device branch of a registered BIP388 WSH or-policy and recognizes descriptor-derived change.",
+ Coin::Tbtc,
+ psbt,
+ PsbtSignOptions {
+ force_script_config: Some(ScriptConfigWithKeypath {
+ script_config: policy_config.clone(),
+ keypath: keypath(&keypath_account),
+ }),
+ ..Default::default()
+ },
+ vec![ecdsa_signature(0, input_pubkey)],
+ screens::policy_wsh(),
+ );
+ vector.registrations = vec![Registration {
+ script_config: policy_config,
+ keypath: None,
+ name: "test wsh policy".into(),
+ }];
+ vector
+}
+
+fn policy_tr_keyspend() -> TestVector {
+ let secp = secp();
+ // Policy string following BIP-388 syntax, input to the BitBox.
+ let policy = "tr(@0/<0;1>/*)";
+
+ let our_root_fingerprint = simulator_xprv().fingerprint(&secp);
+ let keypath_account: DerivationPath = "m/48'/1'/0'/3'".parse().unwrap();
+ let our_xpub: Xpub = 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<miniscript::DescriptorPublicKey> = 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 policy_config = ScriptConfig::Policy {
+ policy: policy.into(),
+ keys: vec![
+ // Our key: root fingerprint and keypath are required.
+ KeyOriginInfo {
+ root_fingerprint: Some(our_root_fingerprint.to_string()),
+ keypath: Some(keypath(&keypath_account)),
+ xpub: our_xpub.to_string(),
+ },
+ ],
+ };
+
+ // 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();
+
+ let input_path: DerivationPath = "m/48'/1'/0'/3'/0/0".parse().unwrap();
+ let input_pubkey = simulator_xpub_at(&secp, &input_path).to_x_only_pub();
+ let mut vector = transaction_vector(
+ "policy-tr-keyspend",
+ "Signs the key-spend path of a registered BIP388 Taproot policy using only a witness UTXO and recognizes descriptor-derived change.",
+ Coin::Tbtc,
+ psbt,
+ PsbtSignOptions {
+ force_script_config: Some(ScriptConfigWithKeypath {
+ script_config: policy_config.clone(),
+ keypath: keypath(&keypath_account),
+ }),
+ ..Default::default()
+ },
+ vec![taproot_key_signature(0, input_pubkey)],
+ screens::policy_tr_keyspend(),
+ );
+ vector.registrations = vec![Registration {
+ script_config: policy_config,
+ keypath: None,
+ name: "test tr keyspend policy".into(),
+ }];
+ vector
+}
+
+fn policy_tr_scriptspend() -> TestVector {
+ let secp = secp();
+ // Policy string following BIP-388 syntax, input to the BitBox.
+ let policy = "tr(@0/<0;1>/*,pk(@1/<0;1>/*))";
+
+ let our_root_fingerprint = simulator_xprv().fingerprint(&secp);
+ let keypath_account: DerivationPath = "m/48'/1'/0'/3'".parse().unwrap();
+ let our_xpub: Xpub = simulator_xpub_at(&secp, &keypath_account);
+ let some_xpub: Xpub = SOME_XPUB.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<miniscript::DescriptorPublicKey> = 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 policy_config = ScriptConfig::Policy {
+ policy: policy.into(),
+ keys: vec![
+ // Foreign key: root fingerprint and keypath are optional.
+ KeyOriginInfo {
+ root_fingerprint: None,
+ keypath: None,
+ xpub: some_xpub.to_string(),
+ },
+ // Our key: root fingerprint and keypath are required.
+ KeyOriginInfo {
+ root_fingerprint: Some(our_root_fingerprint.to_string()),
+ keypath: Some(keypath(&keypath_account)),
+ xpub: our_xpub.to_string(),
+ },
+ ],
+ };
+
+ // 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();
+
+ let input_path: DerivationPath = "m/48'/1'/0'/3'/0/0".parse().unwrap();
+ let input_pubkey = simulator_xpub_at(&secp, &input_path).to_x_only_pub();
+ let leaf_hash = psbt.inputs[0].tap_key_origins.get(&input_pubkey).unwrap().0[0];
+ let mut vector = transaction_vector(
+ "policy-tr-scriptspend",
+ "Signs the script path owned by the device in a registered BIP388 Taproot policy whose internal key belongs to another signer.",
+ Coin::Tbtc,
+ psbt,
+ PsbtSignOptions {
+ force_script_config: Some(ScriptConfigWithKeypath {
+ script_config: policy_config.clone(),
+ keypath: keypath(&keypath_account),
+ }),
+ ..Default::default()
+ },
+ vec![taproot_script_signature(0, input_pubkey, leaf_hash)],
+ screens::policy_tr_scriptspend(),
+ );
+ vector.registrations = vec![Registration {
+ script_config: policy_config,
+ keypath: None,
+ name: "test tr scriptspend policy".into(),
+ }];
+ vector
+}
### src/rust/bitbox-test-vectors/src/btc_transaction/mod.rs
@@ -0,0 +1,481 @@
+// SPDX-License-Identifier: Apache-2.0
+
+//! Readable source and deterministic generator for Bitcoin transaction test vectors.
+
+mod cases;
+
+use semver::Version;
+use serde::{Deserialize, Serialize};
+use std::collections::{BTreeMap, BTreeSet};
+
+pub const GENERATED_FILENAME: &str = "btc-transaction-test-vectors.json";
+
+/// BIP32 xprv derived from the mnemonic restored by all client simulator tests:
+/// boring mistake dish oyster truth pigeon viable emerge sort crash wire portion cannon couple
+/// enact box walk height pull today solid off enable tide
+const SIMULATOR_BIP32_XPRV: &str = "xprv9s21ZrQH143K2qxpAMxVdyeza5dUBxY11XbJ7eKvRF51sQyhiFXgmn4P4ALi3Nf6bcG8cmPDvMMEFiAVjtXsqeZ47PJfBJif7uSYycMsx9c";
+const SIMULATOR_SEED: &str = "boring mistake dish oyster truth pigeon viable emerge sort crash wire portion cannon couple enact box walk height pull today solid off enable tide";
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct TestVectorFile {
+ pub simulator_seed: String,
+ pub vectors: Vec<TestVector>,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct TestVector {
+ pub id: String,
+ pub description: String,
+ pub coin: Coin,
+ pub psbt: PsbtForm,
+ pub expected_needs_prevtxs: bool,
+ /// Contiguous expectations for applicable firmware versions. If the first range has a
+ /// `min_version`, clients skip this vector on older firmware.
+ pub expectations: Vec<VersionExpectation>,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub registrations: Vec<Registration>,
+ /// Signature slots that signing must newly insert. Signature bytes are deliberately omitted.
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub expected_signatures: Vec<ExpectedSignature>,
+ /// Output scripts that successful signing must generate, keyed by output index.
+ #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
+ pub expected_generated_outputs: BTreeMap<usize, String>,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct PsbtForm {
+ pub transaction: String,
+ #[serde(default, skip_serializing_if = "PsbtSignOptions::is_empty")]
+ pub options: PsbtSignOptions,
+}
+
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum Coin {
+ Btc,
+ Tbtc,
+ Ltc,
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize)]
+pub struct VersionExpectation {
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub min_version: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub max_version_exclusive: Option<String>,
+ pub outcome: Outcome,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub unsupported_version: Option<String>,
+ pub screens: Vec<Screen>,
+}
+
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum Outcome {
+ Success,
+ Unsupported,
+ InvalidInput,
+}
+
+/// Screens expected from the firmware UI. Released simulator stdout omits `longtouch`.
+#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
+#[serde(tag = "type", rename_all = "snake_case")]
+pub enum Screen {
+ Confirm {
+ title: String,
+ body: String,
+ longtouch: bool,
+ },
+ TransactionAddress {
+ amount: String,
+ address: String,
+ },
+ TransactionFee {
+ amount: String,
+ fee: String,
+ longtouch: bool,
+ },
+ Status {
+ title: String,
+ body: String,
+ },
+ Swap {
+ title: String,
+ from: String,
+ to: String,
+ },
+}
+
+#[derive(Clone, Debug, Default, Serialize, Deserialize)]
+pub struct PsbtSignOptions {
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub force_script_config: Option<ScriptConfigWithKeypath>,
+ #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
+ pub outputs: BTreeMap<usize, PsbtOutputOptions>,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub payment_requests: Vec<PaymentRequest>,
+ #[serde(default, skip_serializing_if = "FormatUnit::is_default")]
+ pub format_unit: FormatUnit,
+}
+
+impl PsbtSignOptions {
+ fn is_empty(&self) -> bool {
+ self.force_script_config.is_none()
+ && self.outputs.is_empty()
+ && self.payment_requests.is_empty()
+ && self.format_unit == FormatUnit::Default
+ }
+}
+
+#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
+pub struct PsbtOutputOptions {
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub silent_payment_address: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub payment_request_index: Option<u32>,
+}
+
+#[derive(Debug, Copy, Clone, Default, Eq, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum FormatUnit {
+ #[default]
+ Default,
+ Sat,
+}
+
+impl FormatUnit {
+ fn is_default(&self) -> bool {
+ *self == Self::Default
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
+pub struct PaymentRequest {
+ pub recipient_name: String,
+ pub total_amount: u64,
+ #[serde(default, skip_serializing_if = "String::is_empty")]
+ pub nonce: String,
+ pub memos: Vec<PaymentRequestMemo>,
+ pub signature: String,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
+#[serde(tag = "type", rename_all = "snake_case")]
+pub enum PaymentRequestMemo {
+ Text {
+ note: String,
+ },
+ CoinPurchase {
+ coin_type: u32,
+ amount: String,
+ address: String,
+ address_keypath: String,
+ },
+}
+
+#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
+pub struct ScriptConfigWithKeypath {
+ pub script_config: ScriptConfig,
+ pub keypath: String,
+}
+
+#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
+#[serde(tag = "type", rename_all = "snake_case")]
+pub enum ScriptConfig {
+ Simple {
+ script_type: SimpleType,
+ },
+ Multisig {
+ threshold: u32,
+ xpubs: Vec<String>,
+ our_xpub_index: u32,
+ script_type: MultisigScriptType,
+ },
+ Policy {
+ policy: String,
+ keys: Vec<KeyOriginInfo>,
+ },
+}
+
+impl ScriptConfig {
+ fn is_taproot(&self) -> bool {
+ match self {
+ Self::Simple {
+ script_type: SimpleType::P2tr,
+ } => true,
+ Self::Policy { policy, .. } => policy.starts_with("tr("),
+ _ => false,
+ }
+ }
+}
+
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum SimpleType {
+ P2wpkh,
+ P2wpkhP2sh,
+ P2tr,
+}
+
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum MultisigScriptType {
+ P2wsh,
+ P2wshP2sh,
+}
+
+#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
+pub struct KeyOriginInfo {
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub root_fingerprint: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub keypath: Option<String>,
+ pub xpub: String,
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize)]
+pub struct Registration {
+ pub script_config: ScriptConfig,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub keypath: Option<String>,
+ pub name: String,
+}
+
+#[derive(Debug)]
+pub struct FirmwareSignRequest {
+ pub script_configs: Vec<ScriptConfigWithKeypath>,
+ pub output_script_configs: Vec<ScriptConfigWithKeypath>,
+ pub version: u32,
+ pub inputs: Vec<FirmwareInput>,
+ pub outputs: Vec<FirmwareOutput>,
+ pub locktime: u32,
+ pub payment_requests: Vec<PaymentRequest>,
+ pub format_unit: FormatUnit,
+}
+
+impl FirmwareSignRequest {
+ fn needs_prevtxs(&self) -> bool {
+ self.script_configs
+ .iter()
+ .any(|config| !config.script_config.is_taproot())
+ }
+}
+
+#[derive(Debug)]
+pub struct FirmwareInput {
+ pub prev_out_hash: bitcoin::Txid,
+ pub prev_out_index: u32,
+ pub prev_out_value: u64,
+ pub sequence: u32,
+ pub keypath: bitcoin::bip32::DerivationPath,
+ pub script_config_index: u32,
+ pub prev_tx: Option<bitcoin::Transaction>,
+ pub bip352_pubkey: Option<Vec<u8>>,
+}
+
+#[derive(Debug)]
+pub struct FirmwareOutput {
+ pub ours: bool,
+ pub value: u64,
+ pub output_type: OutputType,
+ pub payload: Vec<u8>,
+ pub keypath: Option<bitcoin::bip32::DerivationPath>,
+ pub script_config_index: u32,
+ pub output_script_config_index: Option<u32>,
+ pub silent_payment_address: Option<String>,
+ pub payment_request_index: Option<u32>,
+}
+
+#[derive(Debug, Copy, Clone)]
+pub enum OutputType {
+ Unknown,
+ P2pkh,
+ P2sh,
+ P2wpkh,
+ P2wsh,
+ P2tr,
+ OpReturn,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ExpectedSignature {
+ pub input_index: usize,
+ pub kind: SignatureKind,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub pubkey: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub leaf_hash: Option<String>,
+ pub sighash: Sighash,
+}
+
+#[derive(Debug, Copy, Clone, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum SignatureKind {
+ Ecdsa,
+ TaprootKey,
+ TaprootScript,
+}
+
+#[derive(Debug, Copy, Clone, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum Sighash {
+ All,
+ Default,
+}
+
+/// Validate invariants that execution alone cannot establish.
+fn validate(file: &TestVectorFile) -> Result<(), String> {
+ if file.vectors.is_empty() {
+ return Err("test vector file contains no vectors".into());
+ }
+
+ let mut ids = BTreeSet::new();
+ for vector in &file.vectors {
+ let id = vector.id.trim();
+ if id.is_empty() {
+ return Err("test vector has an empty id".into());
+ }
+ if !ids.insert(id) {
+ return Err(format!("duplicate test vector id '{id}'"));
+ }
+ validate_version_expectations(id, &vector.expectations)?;
+ }
+ Ok(())
+}
+
+fn parse_psbt(vector: &TestVector) -> Result<bitcoin::psbt::Psbt, String> {
+ let id = &vector.id;
+ let serialized = hex::decode(&vector.psbt.transaction)
+ .map_err(|err| format!("test vector '{id}' contains invalid PSBT hex: {err}"))?;
+ bitcoin::psbt::Psbt::deserialize(&serialized)
+ .map_err(|err| format!("test vector '{id}' contains an invalid PSBT: {err}"))
+}
+
+/// Derive the firmware signing request represented by a test vector.
+pub fn derive_sign_request(vector: &TestVector) -> Result<FirmwareSignRequest, String> {
+ let psbt = parse_psbt(vector)?;
+ cases::firmware_request_from_psbt(&psbt, &vector.psbt.options).map_err(|err| {
+ format!(
+ "test vector '{}' cannot derive its firmware signing request: {err}",
+ vector.id
+ )
+ })
+}
+
+fn validate_version_expectations(
+ vector_id: &str,
+ expectations: &[VersionExpectation],
+) -> Result<(), String> {
+ if expectations.is_empty() {
+ return Err(format!("test vector '{vector_id}' has no expectations"));
+ }
+
+ let mut previous_max = None;
+ for (index, expectation) in expectations.iter().enumerate() {
+ let min =
+ parse_version_bound(vector_id, "min_version", expectation.min_version.as_deref())?;
+ let max = parse_version_bound(
+ vector_id,
+ "max_version_exclusive",
+ expectation.max_version_exclusive.as_deref(),
+ )?;
+ if index > 0 && min != previous_max {
+ return Err(format!(
+ "test vector '{vector_id}' has a gap or unordered version expectations"
+ ));
+ }
+ if let (Some(min), Some(max)) = (&min, &max)
+ && min >= max
+ {
+ return Err(format!(
+ "test vector '{vector_id}' has an empty or reversed version range [{min}, {max})"
+ ));
+ }
+ if index + 1 < expectations.len() && max.is_none() {
+ return Err(format!(
+ "test vector '{vector_id}' has an unbounded non-final expectation"
+ ));
+ }
+
+ previous_max = max;
+ }
+
+ if previous_max.is_some() {
+ return Err(format!(
+ "test vector '{vector_id}' has a bounded final version expectation"
+ ));
+ }
+ Ok(())
+}
+
+fn parse_version_bound(
+ vector_id: &str,
+ field: &str,
+ value: Option<&str>,
+) -> Result<Option<Version>, String> {
+ value
+ .map(|value| {
+ Version::parse(value).map_err(|err| {
+ format!("test vector '{vector_id}' has invalid {field} '{value}': {err}")
+ })
+ })
+ .transpose()
+}
+
+pub fn test_vectors() -> TestVectorFile {
+ TestVectorFile {
+ simulator_seed: SIMULATOR_SEED.into(),
+ vectors: cases::all(),
+ }
+}
+
+pub fn try_generate_json() -> Result<String, String> {
+ let file = test_vectors();
+ validate(&file)?;
+ let mut result = serde_json::to_string_pretty(&file).map_err(|err| err.to_string())?;
+ result.push('\n');
+ Ok(result)
+}
+
+#[cfg(test)]
+mod tests {
+ fn assert_invalid(update: impl FnOnce(&mut super::TestVectorFile), expected: &str) {
+ let mut file = super::test_vectors();
+ update(&mut file);
+ let error = super::validate(&file).unwrap_err();
+ assert!(
+ error.contains(expected),
+ "expected validation error containing '{expected}', got '{error}'"
+ );
+ }
+
+ #[test]
+ fn test_generated_vectors_are_current() {
+ let expected = include_str!("../../testdata/btc-transaction-test-vectors.json");
+ assert_eq!(super::try_generate_json().unwrap(), expected);
+ }
+
+ #[test]
+ fn test_validate_vector_identity() {
+ assert_invalid(|file| file.vectors[0].id.clear(), "empty id");
+ assert_invalid(
+ |file| file.vectors[1].id = file.vectors[0].id.clone(),
+ "duplicate test vector id",
+ );
+ }
+
+ #[test]
+ fn test_validate_version_expectations() {
+ let mut file = super::test_vectors();
+ // A leading min version makes the vector inapplicable to older firmware; it is not a gap.
+ file.vectors[0].expectations[0].min_version = Some("1.0.0".into());
+ super::validate(&file).unwrap();
+
+ assert_invalid(
+ |file| {
+ file.vectors[0].expectations[1].min_version = Some("99.0.0".into());
+ },
+ "gap or unordered version expectations",
+ );
+ }
+}
### src/rust/bitbox-test-vectors/src/lib.rs
@@ -0,0 +1,8 @@
+// SPDX-License-Identifier: Apache-2.0
+
+//! Readable sources and deterministic generators for portable BitBox test vectors.
+
+// The firmware workspace links rust-bitcoin against its unprefixed secp256k1-zkp build.
+use bitbox_secp256k1 as _;
+
+pub mod btc_transaction;
### src/rust/bitbox-test-vectors/src/main.rs
@@ -0,0 +1,79 @@
+// SPDX-License-Identifier: Apache-2.0
+
+use std::ffi::OsString;
+use std::path::{Path, PathBuf};
+
+enum Mode {
+ Write(PathBuf),
+ Check,
+ Help,
+}
+
+fn canonical_path() -> PathBuf {
+ PathBuf::from(env!("CARGO_MANIFEST_DIR"))
+ .join("testdata")
+ .join(bitbox_test_vectors::btc_transaction::GENERATED_FILENAME)
+}
+
+fn parse_args() -> Result<Mode, String> {
+ let args: Vec<OsString> = std::env::args_os().skip(1).collect();
+ match args.as_slice() {
+ [] => Ok(Mode::Write(canonical_path())),
+ [arg] if arg == "--check" => Ok(Mode::Check),
+ [arg] if arg == "--help" || arg == "-h" => Ok(Mode::Help),
+ [output] if !output.to_string_lossy().starts_with('-') => {
+ Ok(Mode::Write(PathBuf::from(output)))
+ }
+ _ => Err("usage: generate-btc-test-vectors [--check | OUTPUT]".into()),
+ }
+}
+
+fn write(output: &Path, generated: &str) -> Result<(), String> {
+ if let Some(parent) = output
+ .parent()
+ .filter(|parent| !parent.as_os_str().is_empty())
+ {
+ std::fs::create_dir_all(parent)
+ .map_err(|err| format!("failed to create {}: {err}", parent.display()))?;
+ }
+ std::fs::write(output, generated)
+ .map_err(|err| format!("failed to write {}: {err}", output.display()))?;
+ println!("Wrote {}", output.display());
+ Ok(())
+}
+
+fn check(generated: &str) -> Result<(), String> {
+ let canonical = canonical_path();
+ let committed = std::fs::read_to_string(&canonical)
+ .map_err(|err| format!("failed to read {}: {err}", canonical.display()))?;
+ if committed != generated {
+ return Err(format!(
+ "{} is stale; run generate-btc-test-vectors",
+ canonical.display()
+ ));
+ }
+ println!("{} is current", canonical.display());
+ Ok(())
+}
+
+fn run() -> Result<(), String> {
+ let mode = parse_args()?;
+ if matches!(mode, Mode::Help) {
+ println!("usage: generate-btc-test-vectors [--check | OUTPUT]");
+ return Ok(());
+ }
+
+ let generated = bitbox_test_vectors::btc_transaction::try_generate_json()?;
+ match mode {
+ Mode::Write(output) => write(&output, &generated),
+ Mode::Check => check(&generated),
+ Mode::Help => unreachable!(),
+ }
+}
+
+fn main() {
+ if let Err(err) = run() {
+ eprintln!("error: {err}");
+ std::process::exit(1);
+ }
+}
### src/rust/bitbox-test-vectors/testdata/btc-transaction-test-vectors.json
[binary or diff unavailable]
### src/rust/bitbox02-rust/Cargo.toml
@@ -122,6 +122,8 @@ firmware = []
[dev-dependencies]
async_test = { path = "../async_test" }
+bitbox-test-vectors = { path = "../bitbox-test-vectors" }
+semver = "1"
serde = { workspace = true }
serde_json = { workspace = true }
util = { path = "../util", features = ["testing"] }
### src/rust/bitbox02-rust/src/hww/api/bitcoin/signtx.rs
[binary or diff unavailable]
### versions.json
@@ -1,5 +1,5 @@
{
- "firmware": "v9.26.5",
+ "firmware": "v9.27.0",
"bootloader": "v1.2.2",
"stage0": 1
}Why this scored 15/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.