diff --git a/CHANGELOG-npm.md b/CHANGELOG-npm.md index 81042aa..554db18 100644 --- a/CHANGELOG-npm.md +++ b/CHANGELOG-npm.md @@ -1,5 +1,8 @@ # Changelog +## 0.12.0 +- btc: add support for OP_RETURN outputs + ## 0.11.0 - Add `btcXpubs()` diff --git a/CHANGELOG-rust.md b/CHANGELOG-rust.md index 4988f50..634b2db 100644 --- a/CHANGELOG-rust.md +++ b/CHANGELOG-rust.md @@ -1,5 +1,8 @@ # Changelog +## 0.11.0 +- btc: add support for OP_RETURN outputs + ## 0.10.0 - Add `btc_xpubs()` diff --git a/Cargo.lock b/Cargo.lock index 2fb1b52..f5d0cff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -162,7 +162,7 @@ checksum = "d965446196e3b7decd44aa7ee49e31d630118f90ef12f97900f262eb915c951d" [[package]] name = "bitbox-api" -version = "0.10.0" +version = "0.11.0" dependencies = [ "async-trait", "base32", diff --git a/Cargo.toml b/Cargo.toml index 4cc44c3..0025066 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "bitbox-api" authors = ["Marko Bencun "] -version = "0.10.0" +version = "0.11.0" homepage = "https://bitbox.swiss/" repository = "https://github.com/BitBoxSwiss/bitbox-api-rs/" readme = "README-rust.md" @@ -75,6 +75,10 @@ required-features = ["usb", "tokio/rt", "tokio/macros"] name = "btc_sign_psbt" required-features = ["usb", "tokio/rt", "tokio/macros"] +[[example]] +name = "btc_sign_op_return" +required-features = ["usb", "tokio/rt", "tokio/macros"] + [[example]] name = "btc_sign_msg" required-features = ["usb", "tokio/rt", "tokio/macros"] diff --git a/Makefile b/Makefile index dd5beab..efb77b2 100644 --- a/Makefile +++ b/Makefile @@ -10,6 +10,8 @@ example-btc-signtx: cargo run --example btc_signtx --features=usb,tokio/rt,tokio/macros example-btc-psbt: cargo run --example btc_sign_psbt --features=usb,tokio/rt,tokio/macros +example-btc-sign-op-return: + cargo run --example btc_sign_op_return --features=usb,tokio/rt,tokio/macros example-btc-sign-msg: cargo run --example btc_sign_msg --features=usb,tokio/rt,tokio/macros example-btc-miniscript: diff --git a/NPM_VERSION b/NPM_VERSION index 142464b..ac454c6 100644 --- a/NPM_VERSION +++ b/NPM_VERSION @@ -1 +1 @@ -0.11.0 \ No newline at end of file +0.12.0 diff --git a/examples/btc_sign_op_return.rs b/examples/btc_sign_op_return.rs new file mode 100644 index 0000000..a33e8a4 --- /dev/null +++ b/examples/btc_sign_op_return.rs @@ -0,0 +1,163 @@ +use std::str::FromStr; + +use bitbox_api::{pb, Keypath}; + +use bitcoin::{ + bip32::{ChildNumber, DerivationPath, Fingerprint, Xpub}, + blockdata::script::Builder, + opcodes::all, + psbt::Psbt, + secp256k1, transaction, Amount, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, + Witness, +}; +use semver::VersionReq; + +async fn connect_bitbox() -> bitbox_api::PairedBitBox { + let noise_config = Box::new(bitbox_api::NoiseConfigNoCache {}); + let device = bitbox_api::BitBox::::from_hid_device( + bitbox_api::usb::get_any_bitbox02().unwrap(), + noise_config, + ) + .await + .unwrap(); + let pairing = device.unlock_and_pair().await.unwrap(); + if let Some(pairing_code) = pairing.get_pairing_code().as_ref() { + println!("Pairing code\n{pairing_code}"); + } + pairing.wait_confirm().await.unwrap() +} + +async fn build_op_return_psbt( + paired: &bitbox_api::PairedBitBox, +) -> Psbt { + let coin = pb::BtcCoin::Tbtc; + let secp = secp256k1::Secp256k1::new(); + + let fingerprint_hex = paired.root_fingerprint().await.unwrap(); + let fingerprint = Fingerprint::from_str(&fingerprint_hex).unwrap(); + let account_path: DerivationPath = "m/84'/1'/0'".parse().unwrap(); + 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 account_keypath = Keypath::from(&account_path); + let account_xpub = paired + .btc_xpub( + coin, + &account_keypath, + pb::btc_pub_request::XPubType::Tpub, + false, + ) + .await + .unwrap(); + + let account_xpub = Xpub::from_str(&account_xpub).unwrap(); + + let input_pub = account_xpub + .derive_pub( + &secp, + &[ + ChildNumber::from_normal_idx(0).unwrap(), + ChildNumber::from_normal_idx(5).unwrap(), + ], + ) + .unwrap() + .to_pub(); + let change_pub = account_xpub + .derive_pub( + &secp, + &[ + ChildNumber::from_normal_idx(1).unwrap(), + ChildNumber::from_normal_idx(0).unwrap(), + ], + ) + .unwrap() + .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.clone())); + + psbt.outputs[0] + .bip32_derivation + .insert(change_pub.0, (fingerprint, change_path.clone())); + + psbt +} + +#[tokio::main(flavor = "current_thread")] +async fn main() { + let paired = connect_bitbox().await; + + let firmware_version = paired.version(); + if !VersionReq::parse(">=9.24.0") + .unwrap() + .matches(firmware_version) + { + eprintln!( + "Connected firmware {firmware_version} does not support OP_RETURN outputs (requires >=9.24.0)." + ); + return; + } + + let mut psbt = build_op_return_psbt(&paired).await; + + paired + .btc_sign_psbt( + pb::BtcCoin::Tbtc, + &mut psbt, + None, + pb::btc_sign_init_request::FormatUnit::Default, + ) + .await + .unwrap(); +} diff --git a/messages/btc.proto b/messages/btc.proto index 24669ff..9793542 100644 --- a/messages/btc.proto +++ b/messages/btc.proto @@ -175,6 +175,7 @@ enum BTCOutputType { P2WPKH = 3; P2WSH = 4; P2TR = 5; + OP_RETURN = 6; } message BTCSignOutputRequest { diff --git a/sandbox/package-lock.json b/sandbox/package-lock.json index 9006126..2e0b145 100644 --- a/sandbox/package-lock.json +++ b/sandbox/package-lock.json @@ -30,7 +30,7 @@ }, "../pkg": { "name": "bitbox-api", - "version": "0.10.1", + "version": "0.12.0", "license": "Apache-2.0" }, "node_modules/@esbuild/aix-ppc64": { diff --git a/src/btc.rs b/src/btc.rs index 1be5968..a458971 100644 --- a/src/btc.rs +++ b/src/btc.rs @@ -13,6 +13,8 @@ pub use bitcoin::{ Script, }; +use bitcoin::blockdata::{opcodes, script::Instruction}; + #[cfg(feature = "wasm")] use enum_assoc::Assoc; @@ -160,6 +162,8 @@ pub struct Payload { pub enum PayloadError { #[error("unrecognized pubkey script")] Unrecognized, + #[error("{0}")] + InvalidOpReturn(&'static str), } impl Payload { @@ -190,6 +194,46 @@ impl Payload { data: pkscript[2..].to_vec(), output_type: pb::BtcOutputType::P2tr, }) + } else if matches!(script.as_bytes().first(), Some(byte) if *byte == opcodes::all::OP_RETURN.to_u8()) + { + let mut instructions = script.instructions_minimal(); + match instructions.next() { + Some(Ok(Instruction::Op(op))) if op == opcodes::all::OP_RETURN => {} + _ => return Err(PayloadError::Unrecognized), + } + + let payload = match instructions.next() { + None => { + return Err(PayloadError::InvalidOpReturn( + "naked OP_RETURN is not supported", + )) + } + Some(Ok(Instruction::Op(op))) if op == opcodes::all::OP_PUSHBYTES_0 => Vec::new(), + Some(Ok(Instruction::PushBytes(push))) => push.as_bytes().to_vec(), + Some(Ok(_)) => { + return Err(PayloadError::InvalidOpReturn( + "no data push found after OP_RETURN", + )) + } + Some(Err(_)) => { + return Err(PayloadError::InvalidOpReturn( + "failed to parse OP_RETURN payload", + )) + } + }; + + match instructions.next() { + None => Ok(Payload { + data: payload, + output_type: pb::BtcOutputType::OpReturn, + }), + Some(Ok(_)) => Err(PayloadError::InvalidOpReturn( + "only one data push supported after OP_RETURN", + )), + Some(Err(_)) => Err(PayloadError::InvalidOpReturn( + "failed to parse OP_RETURN payload", + )), + } } else { Err(PayloadError::Unrecognized) } @@ -206,8 +250,7 @@ impl TryFrom<&bitcoin::TxOut> for TxExternalOutput { type Error = PsbtError; fn try_from(value: &bitcoin::TxOut) -> Result { Ok(TxExternalOutput { - payload: Payload::from_pkscript(value.script_pubkey.as_bytes()) - .map_err(|_| PsbtError::UnknownOutputType)?, + payload: Payload::from_pkscript(value.script_pubkey.as_bytes())?, value: value.value.to_sat(), }) } @@ -251,6 +294,18 @@ pub enum PsbtError { #[error("Unrecognized/unsupported output type.")] #[cfg_attr(feature = "wasm", assoc(js_code = "unknown-output-type"))] UnknownOutputType, + #[error("Invalid OP_RETURN script: {0}")] + #[cfg_attr(feature = "wasm", assoc(js_code = "invalid-op-return"))] + InvalidOpReturn(&'static str), +} + +impl From for PsbtError { + fn from(value: PayloadError) -> Self { + match value { + PayloadError::Unrecognized => PsbtError::UnknownOutputType, + PayloadError::InvalidOpReturn(message) => PsbtError::InvalidOpReturn(message), + } + } } enum OurKey { @@ -753,6 +808,15 @@ impl PairedBitBox { if transaction.script_configs.iter().any(is_taproot_simple) { self.validate_version(">=9.10.0")?; // taproot since 9.10.0 } + if transaction.outputs.iter().any(|output| { + matches!( + output, + TxOutput::External(tx_output) + if tx_output.payload.output_type == pb::BtcOutputType::OpReturn + ) + }) { + self.validate_version(">=9.24.0")?; + } let mut sigs: Vec> = Vec::new(); @@ -1181,6 +1245,62 @@ mod tests { output_type: pb::BtcOutputType::P2tr, } ); + + // OP_RETURN empty (OP_0) + let pkscript = hex::decode("6a00").unwrap(); + assert_eq!( + Payload::from_pkscript(&pkscript).unwrap(), + Payload { + data: Vec::new(), + output_type: pb::BtcOutputType::OpReturn, + } + ); + + // OP_RETURN with data push + let pkscript = hex::decode("6a03aabbcc").unwrap(); + assert_eq!( + Payload::from_pkscript(&pkscript).unwrap(), + Payload { + data: vec![0xaa, 0xbb, 0xcc], + output_type: pb::BtcOutputType::OpReturn, + } + ); + + // OP_RETURN with 80-byte payload (PUSHDATA1) + let mut pkscript = vec![opcodes::all::OP_RETURN.to_u8(), 0x4c, 0x50]; + pkscript.extend(std::iter::repeat_n(0xaa, 80)); + assert_eq!( + Payload::from_pkscript(&pkscript).unwrap(), + Payload { + data: vec![0xaa; 80], + output_type: pb::BtcOutputType::OpReturn, + } + ); + + // Invalid OP_RETURN scripts + let pkscript = hex::decode("6a").unwrap(); + assert!(matches!( + Payload::from_pkscript(&pkscript), + Err(PayloadError::InvalidOpReturn( + "naked OP_RETURN is not supported" + )) + )); + + let pkscript = hex::decode("6a6a").unwrap(); + assert!(matches!( + Payload::from_pkscript(&pkscript), + Err(PayloadError::InvalidOpReturn( + "no data push found after OP_RETURN" + )) + )); + + let pkscript = hex::decode("6a0000").unwrap(); + assert!(matches!( + Payload::from_pkscript(&pkscript), + Err(PayloadError::InvalidOpReturn( + "only one data push supported after OP_RETURN" + )) + )); } // Test that a PSBT containing only p2wpkh inputs is converted correctly to a transaction to be diff --git a/src/shiftcrypto.bitbox02.rs b/src/shiftcrypto.bitbox02.rs index a0f729d..ed93f59 100644 --- a/src/shiftcrypto.bitbox02.rs +++ b/src/shiftcrypto.bitbox02.rs @@ -1190,6 +1190,7 @@ pub enum BtcOutputType { P2wpkh = 3, P2wsh = 4, P2tr = 5, + OpReturn = 6, } impl BtcOutputType { /// String value of the enum field names used in the ProtoBuf definition. @@ -1204,6 +1205,7 @@ impl BtcOutputType { Self::P2wpkh => "P2WPKH", Self::P2wsh => "P2WSH", Self::P2tr => "P2TR", + Self::OpReturn => "OP_RETURN", } } /// Creates an enum from field names used in the ProtoBuf definition. @@ -1215,6 +1217,7 @@ impl BtcOutputType { "P2WPKH" => Some(Self::P2wpkh), "P2WSH" => Some(Self::P2wsh), "P2TR" => Some(Self::P2tr), + "OP_RETURN" => Some(Self::OpReturn), _ => None, } } diff --git a/tests/test_btc_psbt.rs b/tests/test_btc_psbt.rs index 5d2f85f..603fac1 100644 --- a/tests/test_btc_psbt.rs +++ b/tests/test_btc_psbt.rs @@ -12,10 +12,12 @@ use util::test_initialized_simulators; use bitbox_api::{btc::Xpub, pb}; use bitcoin::bip32::DerivationPath; +use bitcoin::opcodes::all; use bitcoin::psbt::Psbt; use bitcoin::secp256k1; use bitcoin::{ - transaction, Amount, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Witness, + blockdata::script::Builder, transaction, Amount, OutPoint, ScriptBuf, Sequence, Transaction, + TxIn, TxOut, Witness, }; use miniscript::psbt::PsbtExt; @@ -338,6 +340,109 @@ async fn test_btc_psbt_mixed_spend() { .await } +#[tokio::test] +async fn test_btc_psbt_op_return() { + test_initialized_simulators(async |bitbox| { + if !semver::VersionReq::parse(">=9.24.0") + .unwrap() + .matches(bitbox.version()) + { + // OP_RETURN outputs are supported from firmware v9.24.0. + return; + } + + let secp = secp256k1::Secp256k1::new(); + let fingerprint = util::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 = util::simulator_xpub_at(&secp, &input_path).to_pub(); + let change_pub = util::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.clone(), + }, + ], + }; + + 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.clone())); + + psbt.outputs[0] + .bip32_derivation + .insert(change_pub.0, (fingerprint, change_path.clone())); + + bitbox + .btc_sign_psbt( + pb::BtcCoin::Tbtc, + &mut psbt, + None, + pb::btc_sign_init_request::FormatUnit::Default, + ) + .await + .unwrap(); + + psbt.finalize_mut(&secp).unwrap(); + + let final_tx = psbt.clone().extract_tx_unchecked_fee_rate(); + assert_eq!(final_tx.output.len(), 2); + assert_eq!(final_tx.output[1].value, Amount::from_sat(0)); + assert_eq!(final_tx.output[1].script_pubkey, op_return_script); + + // Verify the signed tx, including that all sigs/witnesses are correct. + verify_transaction(psbt); + }) + .await +} + #[tokio::test] async fn test_btc_psbt_multisig_p2wsh() { test_initialized_simulators(async |bitbox| {