hw-wallet 0.1.2

Shared wallet orchestration utilities for CLI and FFI
Documentation
use trezor_connect::thp::{
    BtcHDNode, BtcInputScriptType, BtcMultisig, BtcMultisigPubkeysOrder, BtcOutputScriptType,
    BtcSignTx,
};

use super::owner::TxOwner;
use super::*;
use crate::error::WalletError;

const BTC_PARSE_WITH_REF_TXS: &str =
    include_str!("../../../../tests/data/bitcoin/btc_parse_with_ref_txs.json");
const BTC_BUILD_WITH_REF_TXS: &str =
    include_str!("../../../../tests/data/bitcoin/btc_build_with_ref_txs.json");
const BTC_MISSING_REF_TXS: &str =
    include_str!("../../../../tests/data/bitcoin/btc_missing_ref_txs.json");
const BTC_PREV_INDEX_OOB: &str =
    include_str!("../../../../tests/data/bitcoin/btc_prev_index_oob.json");
const BTC_RBF_WITH_PAYMENT_REQ: &str =
    include_str!("../../../../tests/data/bitcoin/btc_rbf_with_payment_req.json");
const BTC_MULTISIG_SIGN: &str =
    include_str!("../../../../tests/data/bitcoin/btc_multisig_sign.json");

#[test]
fn parse_btc_tx_json() {
    let tx = TxInput::from_json(BTC_PARSE_WITH_REF_TXS).unwrap();
    assert_eq!(tx.version, 2);
    assert_eq!(tx.inputs.len(), 1);
    assert_eq!(tx.outputs.len(), 1);
    assert_eq!(tx.ref_txs.len(), 1);
}

#[test]
fn build_btc_sign_request() {
    let tx = TxInput::from_json(BTC_BUILD_WITH_REF_TXS).unwrap();
    let btc = BtcSignTx::try_from(tx).unwrap();
    assert_eq!(btc.inputs[0].amount, 100);
    assert_eq!(btc.outputs[0].amount, 90);
    assert_eq!(
        btc.outputs[0].script_type,
        BtcOutputScriptType::PayToWitness
    );
    assert_eq!(btc.ref_txs.len(), 1);
}

fn output(address: Option<&str>, path: Option<&str>, script_type: Option<&str>) -> TxInputOutput {
    TxInputOutput {
        address: address.map(str::to_string),
        path: path.map(str::to_string),
        amount: "1000".to_string(),
        script_type: script_type.map(str::to_string),
        multisig: None,
        op_return_data: None,
        orig_hash: None,
        orig_index: None,
        payment_req_index: None,
    }
}

const ADDRESS: &str = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4";

#[test]
fn external_output_defaults_to_pay_to_address() {
    for script_type in [None, Some("paytoaddress"), Some("PAYTOADDRESS")] {
        let built = output(Some(ADDRESS), None, script_type)
            .into_sign_output(TxOwner::Signing)
            .unwrap();
        assert_eq!(built.script_type, BtcOutputScriptType::PayToAddress);
    }
}

#[test]
fn external_output_rejects_non_pay_to_address_script_types() {
    for (owner, script_type) in [
        (TxOwner::Signing, "paytowitness"),
        (TxOwner::Signing, "paytop2shwitness"),
        (TxOwner::Signing, "paytotaproot"),
        (TxOwner::Signing, "paytoscripthash"),
        (TxOwner::Signing, "paytomultisig"),
        (TxOwner::Signing, "paytoopreturn"),
        (TxOwner::Original, "paytowitness"),
    ] {
        let err = output(Some(ADDRESS), None, Some(script_type))
            .into_sign_output(owner)
            .unwrap_err();
        assert!(
            matches!(err, WalletError::Signing(_)),
            "{script_type}: {err}"
        );
        assert!(
            err.to_string().contains(&format!(
                "{} with address must use script_type PayToAddress",
                owner.output_label()
            )),
            "{script_type}: {err}"
        );
    }
}

#[test]
fn change_output_derives_script_type_from_path_purpose() {
    for (path, expected) in [
        ("m/44'/0'/0'/1/0", BtcOutputScriptType::PayToAddress),
        ("m/49'/0'/0'/1/0", BtcOutputScriptType::PayToP2shWitness),
        ("m/84'/0'/0'/1/0", BtcOutputScriptType::PayToWitness),
        ("m/86'/0'/0'/1/0", BtcOutputScriptType::PayToTaproot),
        ("m/10025'/0'/0'/1'/1/0", BtcOutputScriptType::PayToTaproot),
        ("m/48'/0'/0'/1'/1/0", BtcOutputScriptType::PayToP2shWitness),
        ("m/48'/0'/0'/2'/1/0", BtcOutputScriptType::PayToWitness),
        ("m/48'/0'/0'/3'/1/0", BtcOutputScriptType::PayToAddress),
        ("m/0/1", BtcOutputScriptType::PayToAddress),
    ] {
        let built = output(None, Some(path), None)
            .into_sign_output(TxOwner::Signing)
            .unwrap();
        assert_eq!(built.script_type, expected, "{path}");
    }
}

#[test]
fn change_output_derives_multisig_script_type_for_bip48_legacy_path() {
    let err = output(None, Some("m/48'/0'/0'/0'/1/0"), None)
        .into_sign_output(TxOwner::Signing)
        .unwrap_err();
    assert!(
        err.to_string()
            .contains("PayToMultisig output requires multisig metadata")
    );
}

#[test]
fn change_output_keeps_explicit_change_script_type() {
    for (script_type, expected) in [
        ("paytoaddress", BtcOutputScriptType::PayToAddress),
        ("paytop2shwitness", BtcOutputScriptType::PayToP2shWitness),
        ("paytowitness", BtcOutputScriptType::PayToWitness),
        ("paytotaproot", BtcOutputScriptType::PayToTaproot),
    ] {
        let built = output(None, Some("m/84'/0'/0'/1/0"), Some(script_type))
            .into_sign_output(TxOwner::Signing)
            .unwrap();
        assert_eq!(built.script_type, expected, "{script_type}");
    }
}

#[test]
fn change_output_rejects_non_change_script_types() {
    for script_type in ["paytoopreturn", "paytoscripthash"] {
        let err = output(None, Some("m/84'/0'/0'/1/0"), Some(script_type))
            .into_sign_output(TxOwner::Signing)
            .unwrap_err();
        assert!(
            err.to_string()
                .contains("bitcoin output with path cannot use script_type"),
            "{script_type}: {err}"
        );
    }
}

#[test]
fn op_return_output_requires_no_address_or_path_and_data() {
    let mut op_return = output(None, None, Some("paytoopreturn"));
    op_return.amount = "0".to_string();
    op_return.op_return_data = Some("deadbeef".to_string());
    let built = op_return.into_sign_output(TxOwner::Signing).unwrap();
    assert_eq!(built.script_type, BtcOutputScriptType::PayToOpReturn);
    assert_eq!(built.op_return_data, Some(vec![0xde, 0xad, 0xbe, 0xef]));

    let err = output(None, None, Some("paytoopreturn"))
        .into_sign_output(TxOwner::Signing)
        .unwrap_err();
    assert!(err.to_string().contains("requires op_return_data"));

    let err = output(None, None, None)
        .into_sign_output(TxOwner::Signing)
        .unwrap_err();
    assert!(err.to_string().contains("requires either address or path"));
}

#[test]
fn op_return_output_requires_zero_amount() {
    for owner in [TxOwner::Signing, TxOwner::Original] {
        let mut op_return = output(None, None, Some("paytoopreturn"));
        op_return.op_return_data = Some("deadbeef".to_string());
        let err = op_return.into_sign_output(owner).unwrap_err();
        assert!(matches!(err, WalletError::Signing(_)), "{err}");
        assert!(
            err.to_string().contains(&format!(
                "{} with script_type PayToOpReturn must have zero amount, got 1000",
                owner.output_label()
            )),
            "{err}"
        );

        let mut op_return = output(None, None, Some("paytoopreturn"));
        op_return.amount = "0".to_string();
        op_return.op_return_data = Some("deadbeef".to_string());
        let built = op_return.into_sign_output(owner).unwrap();
        assert_eq!(built.amount, 0);
    }
}

#[test]
fn build_btc_sign_request_with_orig_txs_and_payment_reqs() {
    let tx = TxInput::from_json(BTC_RBF_WITH_PAYMENT_REQ).unwrap();
    let btc = BtcSignTx::try_from(tx).unwrap();
    assert_eq!(btc.orig_txs.len(), 1);
    assert_eq!(btc.payment_reqs.len(), 1);
    assert_eq!(
        btc.inputs[0].orig_hash.as_deref(),
        Some([0x11; 32].as_slice())
    );
    assert_eq!(btc.outputs[0].payment_req_index, Some(0));
}

#[test]
fn build_btc_sign_request_rejects_unresolvable_ref_txs() {
    for (fixture, expected) in [
        (
            BTC_MISSING_REF_TXS,
            "ref_txs must include transaction 1111111111111111111111111111111111111111111111111111111111111111",
        ),
        (
            BTC_PREV_INDEX_OOB,
            "input 0 prev_index 2 out of bounds for ref_txs hash",
        ),
    ] {
        let tx = TxInput::from_json(fixture).unwrap();
        let err = BtcSignTx::try_from(tx).unwrap_err();
        assert!(err.to_string().contains(expected), "{err}");
    }
}

#[test]
fn build_btc_sign_request_rejects_broken_tx_links() {
    type BreakLink = fn(&mut TxInput);
    let cases: [(BreakLink, &str); 4] = [
        (
            |tx| tx.ref_txs[1].hash = tx.ref_txs[0].hash.clone(),
            "duplicate ref_txs hash e3b0c442",
        ),
        (
            |tx| tx.inputs[0].orig_index = None,
            "inputs[0] must specify both orig_hash and orig_index",
        ),
        (
            |tx| tx.inputs[1].orig_index = Some(9),
            "orig_index 9 out of bounds for inputs[1]",
        ),
        (
            |tx| tx.outputs[1].orig_hash = Some("22".repeat(32)),
            "missing original transaction 2222",
        ),
    ];
    for (break_link, expected) in cases {
        let mut tx = TxInput::from_json(BTC_RBF_WITH_PAYMENT_REQ).unwrap();
        break_link(&mut tx);
        let err = BtcSignTx::try_from(tx).unwrap_err();
        assert!(err.to_string().contains(expected), "{expected}: {err}");
    }
}

#[test]
fn build_btc_sign_request_with_multisig_input_and_output() {
    let tx = TxInput::from_json(BTC_MULTISIG_SIGN).unwrap();
    let btc = BtcSignTx::try_from(tx).unwrap();

    assert_eq!(btc.inputs.len(), 1);
    assert_eq!(btc.inputs[0].script_type, BtcInputScriptType::SpendMultisig);
    let input_ms = btc.inputs[0].multisig.as_ref().expect("input multisig");
    assert_eq!(input_ms.m, 2);
    assert_eq!(input_ms.pubkeys.len(), 3);
    assert_eq!(input_ms.signatures.len(), 3);
    assert_eq!(input_ms.pubkeys_order, BtcMultisigPubkeysOrder::Preserved);
    assert!(input_ms.signatures.iter().all(|s| s.is_empty()));
    assert_eq!(input_ms.pubkeys[0].node.depth, 4);
    assert_eq!(input_ms.pubkeys[0].node.fingerprint, 0x9DFF_15C0);
    assert_eq!(input_ms.pubkeys[0].node.child_num, 0x8000_0000);
    assert_eq!(input_ms.pubkeys[0].address_n, vec![0, 0]);
    assert_eq!(input_ms.pubkeys[0].node.public_key.len(), 33);

    assert_eq!(btc.outputs[0].multisig, None);
    assert_eq!(btc.outputs[0].amount, 90_000);

    assert_eq!(
        btc.outputs[1].script_type,
        BtcOutputScriptType::PayToMultisig
    );
    let output_ms = btc.outputs[1].multisig.as_ref().expect("output multisig");
    assert_eq!(output_ms.m, 2);
    assert_eq!(output_ms.nodes.len(), 3);
    assert_eq!(output_ms.address_n, vec![1, 0]);
    assert_eq!(
        output_ms.pubkeys_order,
        BtcMultisigPubkeysOrder::Lexicographic
    );
    assert!(output_ms.pubkeys.is_empty());
}

fn hd_node(chain_code: String, public_key: String) -> TxInputHDNode {
    TxInputHDNode {
        depth: 0,
        fingerprint: 0,
        child_num: 0,
        chain_code,
        public_key,
    }
}

fn valid_hd_node() -> TxInputHDNode {
    hd_node("00".repeat(32), format!("02{}", "00".repeat(32)))
}

#[test]
fn multisig_rejects_invalid_threshold_or_cosigners() {
    for (m, cosigners, expected) in [
        (0, 1, "threshold m must be at least 1"),
        (2, 0, "requires at least one cosigner"),
        (5, 1, "threshold m=5 exceeds number of cosigners n=1"),
    ] {
        let ms = TxInputMultisig {
            pubkeys: (0..cosigners)
                .map(|_| TxInputHDNodePath {
                    node: TxInputHDNodeRef::Node(valid_hd_node()),
                    address_n: vec![],
                })
                .collect(),
            signatures: vec![],
            m,
            nodes: vec![],
            address_n: vec![],
            pubkeys_order: TxInputMultisigPubkeysOrder::Preserved,
        };
        let err = BtcMultisig::try_from(ms).unwrap_err();
        assert!(err.to_string().contains(expected), "{err}");
    }
}

#[test]
fn parse_hd_node_ref_decodes_xpub_string() {
    let node = BtcHDNode::try_from(&TxInputHDNodeRef::Xpub(
        "tpubDF4tYm8PaDydbLMZZRqcquYZ6AvxFmyTv6RhSokPh6YxccaCxP1gF2VABKV9wsinAdUbsbdLx1vcXdJH8qRcQMM9VYd926rWM685CepPUdN"
            .to_string(),
    ))
    .unwrap();
    assert_eq!(node.depth, 4);
    assert_eq!(node.fingerprint, 0x9DFF_15C0);
    assert_eq!(node.child_num, 0x8000_0000);
    assert_eq!(node.chain_code.len(), 32);
    assert_eq!(node.public_key.len(), 33);
    assert_eq!(node.public_key[0], 0x03);
}

#[test]
fn parse_hd_node_ref_rejects_invalid_xpub_checksum() {
    let err = BtcHDNode::try_from(&TxInputHDNodeRef::Xpub(
        "tpubDF4tYm8PaDydbLMZZRqcquYZ6AvxFmyTv6RhSokPh6YxccaCxP1gF2VABKV9wsinAdUbsbdLx1vcXdJH8qRcQMM9VYd926rWM685CepPUdA"
            .to_string(),
    ))
    .unwrap_err();
    assert!(err.to_string().contains("checksum mismatch"));
}

#[test]
fn hd_node_rejects_bad_key_material_lengths() {
    for (node, expected) in [
        (
            hd_node("aabb".into(), format!("02{}", "00".repeat(32))),
            "chain_code length: expected 32 bytes, got 2",
        ),
        (
            hd_node("00".repeat(32), "00".repeat(32)),
            "public_key length: expected 33 bytes, got 32",
        ),
    ] {
        let err = BtcHDNode::try_from(&node).unwrap_err();
        assert!(err.to_string().contains(expected), "{err}");
    }
}