diff --git a/rust/chains/tw_bitcoin/src/entry.rs b/rust/chains/tw_bitcoin/src/entry.rs index 8cf1ef93594..67d597814b4 100644 --- a/rust/chains/tw_bitcoin/src/entry.rs +++ b/rust/chains/tw_bitcoin/src/entry.rs @@ -1,5 +1,6 @@ use crate::modules::compiler::BitcoinCompiler; use crate::modules::planner::BitcoinPlanner; +use crate::modules::psbt_planner::PsbtPlanner; use crate::modules::signer::BitcoinSigner; use crate::modules::transaction_util::BitcoinTransactionUtil; use std::str::FromStr; @@ -102,6 +103,7 @@ impl CoinEntry for BitcoinEntry { impl UtxoEntry for BitcoinEntry { type PsbtSigningInput<'a> = Proto::PsbtSigningInput<'a>; type PsbtSigningOutput = Proto::PsbtSigningOutput<'static>; + type PsbtTransactionPlan = Proto::TransactionPlan<'static>; #[inline] fn sign_psbt( @@ -111,4 +113,13 @@ impl UtxoEntry for BitcoinEntry { ) -> Self::PsbtSigningOutput { BitcoinSigner::sign_psbt(coin, &input) } + + #[inline] + fn plan_psbt( + &self, + coin: &dyn CoinContext, + input: Self::PsbtSigningInput<'_>, + ) -> Self::PsbtTransactionPlan { + PsbtPlanner::plan_psbt(coin, &input) + } } diff --git a/rust/chains/tw_bitcoin/src/modules/mod.rs b/rust/chains/tw_bitcoin/src/modules/mod.rs index fc470aafecb..d275e69e9de 100644 --- a/rust/chains/tw_bitcoin/src/modules/mod.rs +++ b/rust/chains/tw_bitcoin/src/modules/mod.rs @@ -6,6 +6,7 @@ pub mod compiler; pub mod planner; pub mod protobuf_builder; pub mod psbt; +pub mod psbt_planner; pub mod psbt_request; pub mod signer; pub mod signing_request; diff --git a/rust/chains/tw_bitcoin/src/modules/psbt_planner.rs b/rust/chains/tw_bitcoin/src/modules/psbt_planner.rs new file mode 100644 index 00000000000..a57945daeef --- /dev/null +++ b/rust/chains/tw_bitcoin/src/modules/psbt_planner.rs @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Copyright © 2017 Trust Wallet. + +use crate::modules::psbt_request::PsbtRequest; +use crate::modules::signing_request::SigningRequestBuilder; +use crate::modules::tx_builder::script_parser::StandardScriptParser; +use crate::modules::tx_builder::BitcoinChainInfo; +use tw_coin_entry::coin_context::CoinContext; +use tw_coin_entry::error::prelude::*; +use tw_coin_entry::signing_output_error; +use tw_proto::BitcoinV2::Proto; +use tw_proto::BitcoinV2::Proto::mod_Input::OneOfclaiming_script as ClaimingScriptProto; +use tw_proto::BitcoinV2::Proto::mod_Output::OneOfto_recipient as ToRecipientProto; +use tw_utxo::transaction::standard_transaction::{TransactionInput, TransactionOutput}; +use tw_utxo::transaction::transaction_interface::TransactionInterface; +use tw_utxo::transaction::UtxoToSign; + +pub struct PsbtPlanner; + +impl PsbtPlanner { + pub fn plan_psbt( + coin: &dyn CoinContext, + input: &Proto::PsbtSigningInput, + ) -> Proto::TransactionPlan<'static> { + Self::plan_psbt_impl(coin, input) + .unwrap_or_else(|e| signing_output_error!(Proto::TransactionPlan, e)) + } + + pub fn plan_psbt_impl( + coin: &dyn CoinContext, + input: &Proto::PsbtSigningInput, + ) -> SigningResult> { + let chain_info = SigningRequestBuilder::chain_info(coin, &input.chain_info)?; + let PsbtRequest { unsigned_tx, .. } = PsbtRequest::build(input)?; + + let total_input = unsigned_tx.total_input()?; + let total_output = unsigned_tx.total_output()?; + let fee_estimate = total_input + .checked_sub(total_output) + .or_tw_err(SigningErrorType::Error_not_enough_utxos) + .context("PSBT sum(input) < sum(output)")?; + + let vsize_estimate = unsigned_tx.estimate_transaction().vsize() as u64; + + let inputs: Vec<_> = unsigned_tx + .input_args() + .iter() + .zip(unsigned_tx.inputs()) + .map(|(unsigned_txin, txin)| Self::utxo_to_proto(unsigned_txin, txin, &chain_info)) + .collect::>()?; + + let outputs: Vec<_> = unsigned_tx + .outputs() + .iter() + .map(|txout| Self::output_to_proto(txout, &chain_info)) + .collect::>()?; + + Ok(Proto::TransactionPlan { + inputs, + outputs, + available_amount: total_input, + send_amount: total_input, + vsize_estimate, + fee_estimate, + change: 0, + ..Proto::TransactionPlan::default() + }) + } + + pub fn utxo_to_proto( + unsigned_txin: &UtxoToSign, + txin: &TransactionInput, + chain_info: &BitcoinChainInfo, + ) -> SigningResult> { + let out_point = Proto::OutPoint { + hash: txin.previous_output.hash.to_vec().into(), + vout: txin.previous_output.index, + }; + let sequence = Proto::mod_Input::Sequence { + sequence: txin.sequence, + }; + + let from_address = StandardScriptParser + .parse(&unsigned_txin.prevout_script_pubkey)? + .try_to_address(chain_info)? + .or_tw_err(SigningErrorType::Error_invalid_utxo) + .context("Unexpected UTXO scriptPubkey")? + .to_string(); + + Ok(Proto::Input { + out_point: Some(out_point), + value: unsigned_txin.amount, + sighash_type: unsigned_txin.sighash_ty.raw_sighash(), + sequence: Some(sequence), + claiming_script: ClaimingScriptProto::receiver_address(from_address.into()), + }) + } + + pub fn output_to_proto( + output: &TransactionOutput, + chain_info: &BitcoinChainInfo, + ) -> SigningResult> { + let to_recipient = match StandardScriptParser + .parse(&output.script_pubkey)? + .try_to_address(chain_info)? + { + Some(to_addr) => ToRecipientProto::to_address(to_addr.to_string().into()), + // Cannot convert the output scriptPubkey into an address. Return it as is. + None => ToRecipientProto::custom_script_pubkey(output.script_pubkey.to_vec().into()), + }; + + Ok(Proto::Output { + value: output.value, + to_recipient, + }) + } +} diff --git a/rust/chains/tw_bitcoin/src/modules/psbt_request/utxo_psbt.rs b/rust/chains/tw_bitcoin/src/modules/psbt_request/utxo_psbt.rs index 6b1b0c12273..58d144de377 100644 --- a/rust/chains/tw_bitcoin/src/modules/psbt_request/utxo_psbt.rs +++ b/rust/chains/tw_bitcoin/src/modules/psbt_request/utxo_psbt.rs @@ -3,7 +3,7 @@ // Copyright © 2017 Trust Wallet. use crate::modules::tx_builder::public_keys::PublicKeys; -use crate::modules::tx_builder::script_parser::{ConditionScript, ConditionScriptParser}; +use crate::modules::tx_builder::script_parser::{StandardScript, StandardScriptParser}; use secp256k1::ThirtyTwoByteHash; use tw_coin_entry::error::prelude::*; use tw_hash::H256; @@ -60,17 +60,7 @@ impl<'a> UtxoPsbt<'a> { let script = Script::from(prev_out.script_pubkey.to_bytes()); let builder = self.prepare_builder(prev_out.value)?; - match ConditionScriptParser.parse(&script)? { - ConditionScript::P2PK(pubkey) => builder.p2pk(&pubkey), - ConditionScript::P2PKH(pubkey_hash) => { - let pubkey = self.public_keys.get_ecdsa_public_key(&pubkey_hash)?; - builder.p2pkh(&pubkey) - }, - ConditionScript::P2WPKH(_) | ConditionScript::P2TR(_) => { - SigningError::err(SigningErrorType::Error_invalid_params) - .context("P2WPKH and P2TR scripts should be specified in 'witness_utxo'") - }, - } + self.build_utxo_with_script(builder, &script) } pub fn build_witness_utxo( @@ -79,27 +69,41 @@ impl<'a> UtxoPsbt<'a> { ) -> SigningResult<(TransactionInput, UtxoToSign)> { let script = Script::from(witness_utxo.script_pubkey.to_bytes()); let builder = self.prepare_builder(witness_utxo.value)?; + self.build_utxo_with_script(builder, &script) + } - match ConditionScriptParser.parse(&script)? { - ConditionScript::P2PK(_) | ConditionScript::P2PKH(_) => { - SigningError::err(SigningErrorType::Error_invalid_params) - .context("P2PK and P2PKH scripts should be specified in 'non_witness_utxo'") + fn build_utxo_with_script( + &self, + builder: UtxoBuilder, + script: &Script, + ) -> SigningResult<(TransactionInput, UtxoToSign)> { + match StandardScriptParser.parse(script)? { + StandardScript::P2PK(pubkey) => builder.p2pk(&pubkey), + StandardScript::P2PKH(pubkey_hash) => { + let pubkey = self.public_keys.get_ecdsa_public_key(&pubkey_hash)?; + builder.p2pkh(&pubkey) }, - ConditionScript::P2WPKH(pubkey_hash) => { + StandardScript::P2WPKH(pubkey_hash) => { let pubkey = self.public_keys.get_ecdsa_public_key(&pubkey_hash)?; builder.p2wpkh(&pubkey) }, - ConditionScript::P2TR(_) if self.has_tap_scripts() => { - SigningError::err(SigningErrorType::Error_not_supported) - .context("P2TR script path is not supported for PSBT at the moment") - }, - ConditionScript::P2TR(tweaked_pubkey) => { + StandardScript::P2TR(tweaked_pubkey) => { + if self.has_tap_scripts() { + return SigningError::err(SigningErrorType::Error_not_supported) + .context("P2TR script path is not supported for PSBT at the moment"); + } builder.p2tr_key_path_with_tweaked_pubkey(&tweaked_pubkey) }, + StandardScript::P2SH(_) | StandardScript::P2WSH(_) => { + SigningError::err(SigningErrorType::Error_not_supported) + .context("P2SH and P2WSH scriptPubkey's are not supported yet") + }, + StandardScript::OpReturn(_) => SigningError::err(SigningErrorType::Error_invalid_utxo) + .context("Cannot spend an OP_RETURN output"), } } - pub fn prepare_builder(&self, amount: u64) -> SigningResult { + fn prepare_builder(&self, amount: u64) -> SigningResult { let prevout_hash = H256::from(self.utxo.previous_output.txid.to_raw_hash().into_32()); let prevout_index = self.utxo.previous_output.vout; let sequence = self.utxo.sequence.0; diff --git a/rust/chains/tw_bitcoin/src/modules/signing_request/mod.rs b/rust/chains/tw_bitcoin/src/modules/signing_request/mod.rs index 8795d56fefd..81548a754fb 100644 --- a/rust/chains/tw_bitcoin/src/modules/signing_request/mod.rs +++ b/rust/chains/tw_bitcoin/src/modules/signing_request/mod.rs @@ -8,6 +8,7 @@ use crate::modules::tx_builder::utxo_protobuf::UtxoProtobuf; use crate::modules::tx_builder::BitcoinChainInfo; use tw_coin_entry::coin_context::CoinContext; use tw_coin_entry::error::prelude::*; +use tw_misc::traits::OptionalEmpty; use tw_proto::BitcoinV2::Proto; use tw_utxo::dust::DustPolicy; use tw_utxo::modules::tx_planner::{PlanRequest, RequestType}; @@ -138,7 +139,7 @@ impl SigningRequestBuilder { } } - fn chain_info( + pub fn chain_info( coin: &dyn CoinContext, chain_info: &Option, ) -> SigningResult { @@ -150,17 +151,22 @@ impl SigningRequestBuilder { } if let Some(info) = chain_info { + let hrp = info.hrp.to_string().empty_or_some(); return Ok(BitcoinChainInfo { p2pkh_prefix: prefix_to_u8(info.p2pkh_prefix, "p2pkh")?, p2sh_prefix: prefix_to_u8(info.p2sh_prefix, "p2sh")?, + hrp, }); } // Try to get the chain info from the context. + // Note that not all Bitcoin forks support HRP (segwit addresses). + let hrp = coin.hrp(); match (coin.p2pkh_prefix(), coin.p2sh_prefix()) { (Some(p2pkh_prefix), Some(p2sh_prefix)) => Ok(BitcoinChainInfo { p2pkh_prefix, p2sh_prefix, + hrp, }), _ => SigningError::err(SigningErrorType::Error_invalid_params) .context("Neither 'SigningInput.chain_info' nor p2pkh/p2sh prefixes specified in the registry.json") diff --git a/rust/chains/tw_bitcoin/src/modules/tx_builder/mod.rs b/rust/chains/tw_bitcoin/src/modules/tx_builder/mod.rs index 5929ee21605..37c99c33e53 100644 --- a/rust/chains/tw_bitcoin/src/modules/tx_builder/mod.rs +++ b/rust/chains/tw_bitcoin/src/modules/tx_builder/mod.rs @@ -10,4 +10,6 @@ pub mod utxo_protobuf; pub struct BitcoinChainInfo { pub p2pkh_prefix: u8, pub p2sh_prefix: u8, + /// Note that not all Bitcoin forks support HRP (segwit addresses). + pub hrp: Option, } diff --git a/rust/chains/tw_bitcoin/src/modules/tx_builder/script_parser.rs b/rust/chains/tw_bitcoin/src/modules/tx_builder/script_parser.rs index 781131742f2..95b15f536c4 100644 --- a/rust/chains/tw_bitcoin/src/modules/tx_builder/script_parser.rs +++ b/rust/chains/tw_bitcoin/src/modules/tx_builder/script_parser.rs @@ -2,56 +2,117 @@ // // Copyright © 2017 Trust Wallet. +use crate::modules::tx_builder::BitcoinChainInfo; use tw_coin_entry::error::prelude::*; -use tw_hash::H160; +use tw_hash::{H160, H256}; use tw_keypair::{ecdsa, schnorr}; +use tw_memory::Data; +use tw_utxo::address::legacy::LegacyAddress; +use tw_utxo::address::segwit::SegwitAddress; +use tw_utxo::address::standard_bitcoin::StandardBitcoinAddress; +use tw_utxo::address::taproot::TaprootAddress; use tw_utxo::script::standard_script::conditions; use tw_utxo::script::Script; -pub enum ConditionScript { +pub enum StandardScript { /// Compressed or uncompressed public key bytes. P2PK(ecdsa::secp256k1::PublicKey), /// Public key hash. P2PKH(H160), + /// Script hash. + P2SH(H160), /// Public key hash. P2WPKH(H160), + /// Script hash. + P2WSH(H256), /// Tweaked public key. /// The public key can be tweaked as either key-path or script-path, P2TR(schnorr::XOnlyPublicKey), + /// OP_RETURN payload. + OpReturn(Data), } -pub struct ConditionScriptParser; +impl StandardScript { + pub fn try_to_address( + &self, + chain_info: &BitcoinChainInfo, + ) -> AddressResult> { + let try_hrp = || chain_info.hrp.clone().ok_or(AddressError::MissingPrefix); -impl ConditionScriptParser { + match self { + StandardScript::P2PK(pubkey) => { + // Display P2PK input as P2PKH. + LegacyAddress::p2pkh_with_public_key(chain_info.p2pkh_prefix, &pubkey) + .map(StandardBitcoinAddress::Legacy) + .map(Some) + }, + StandardScript::P2PKH(pubkey_hash) => { + LegacyAddress::new(chain_info.p2pkh_prefix, pubkey_hash.as_slice()) + .map(StandardBitcoinAddress::Legacy) + .map(Some) + }, + StandardScript::P2SH(script_hash) => { + LegacyAddress::new(chain_info.p2sh_prefix, script_hash.as_slice()) + .map(StandardBitcoinAddress::Legacy) + .map(Some) + }, + StandardScript::P2WPKH(pubkey_hash) => { + SegwitAddress::new(try_hrp()?, pubkey_hash.to_vec()) + .map(StandardBitcoinAddress::Segwit) + .map(Some) + }, + StandardScript::P2WSH(script_hash) => { + SegwitAddress::new(try_hrp()?, script_hash.to_vec()) + .map(StandardBitcoinAddress::Segwit) + .map(Some) + }, + StandardScript::P2TR(tweaked_pubkey) => { + TaprootAddress::new(try_hrp()?, tweaked_pubkey.bytes().to_vec()) + .map(StandardBitcoinAddress::Taproot) + .map(Some) + }, + StandardScript::OpReturn(_) => Ok(None), + } + } +} + +pub struct StandardScriptParser; + +impl StandardScriptParser { /// Later, this method can be moved to a trait. - pub fn parse(&self, script: &Script) -> SigningResult { + pub fn parse(&self, script: &Script) -> SigningResult { if let Some(pubkey) = conditions::match_p2pk(script) { // P2PK let pubkey = ecdsa::secp256k1::PublicKey::try_from(pubkey) .into_tw() .context("P2PK scriptPubkey must contain a valid ecdsa secp256k1 public key")?; - Ok(ConditionScript::P2PK(pubkey)) + Ok(StandardScript::P2PK(pubkey)) } else if let Some(pubkey_hash) = conditions::match_p2pkh(script) { // P2PKH - Ok(ConditionScript::P2PKH(pubkey_hash)) + Ok(StandardScript::P2PKH(pubkey_hash)) + } else if let Some(script_hash) = conditions::match_p2sh(script) { + // P2SH + Ok(StandardScript::P2SH(script_hash)) } else if let Some(pubkey_hash) = conditions::match_p2wpkh(script) { // P2WPKH - Ok(ConditionScript::P2WPKH(pubkey_hash)) + Ok(StandardScript::P2WPKH(pubkey_hash)) + } else if let Some(script_hash) = conditions::match_p2wsh(script) { + // P2WSH + Ok(StandardScript::P2WSH(script_hash)) } else if let Some(tweaked_pubkey) = conditions::match_p2tr(script) { // P2TR let tweaked_pubkey_x_only = schnorr::XOnlyPublicKey::try_from(tweaked_pubkey.as_slice()) .into_tw() .context("P2TR scriptPubkey must contain a valid tweaked schnorr public key")?; - Ok(ConditionScript::P2TR(tweaked_pubkey_x_only)) - } else if conditions::is_p2sh(script) || conditions::is_p2wsh(script) { - // P2SH or P2WSH - SigningError::err(SigningErrorType::Error_script_output) - .context("P2SH and P2WSH scriptPubkey's are not supported yet") + Ok(StandardScript::P2TR(tweaked_pubkey_x_only)) + } else if let Some(payload) = conditions::match_op_return(script) { + // OP_RETURN + Ok(StandardScript::OpReturn(payload)) } else { // Unknown SigningError::err(SigningErrorType::Error_script_output).context( - "The given custom scriptPubkey is not supported. Consider using a Proto.Input.InputBuilder", + "The given custom scriptPubkey is not supported. Consider using a proper Input/Output builder", ) } } diff --git a/rust/chains/tw_bitcoin/src/modules/tx_builder/utxo_protobuf.rs b/rust/chains/tw_bitcoin/src/modules/tx_builder/utxo_protobuf.rs index 88b8ce0c97f..d536915a3c8 100644 --- a/rust/chains/tw_bitcoin/src/modules/tx_builder/utxo_protobuf.rs +++ b/rust/chains/tw_bitcoin/src/modules/tx_builder/utxo_protobuf.rs @@ -3,7 +3,7 @@ // Copyright © 2017 Trust Wallet. use crate::modules::tx_builder::public_keys::PublicKeys; -use crate::modules::tx_builder::script_parser::{ConditionScript, ConditionScriptParser}; +use crate::modules::tx_builder::script_parser::{StandardScript, StandardScriptParser}; use crate::modules::tx_builder::BitcoinChainInfo; use std::str::FromStr; use tw_coin_entry::error::prelude::*; @@ -137,19 +137,25 @@ impl<'a> UtxoProtobuf<'a> { let script = Script::from(script_data); let builder = self.prepare_builder()?; - match ConditionScriptParser.parse(&script)? { - ConditionScript::P2PK(pk) => builder.p2pk(&pk), - ConditionScript::P2PKH(pubkey_hash) => { + match StandardScriptParser.parse(&script)? { + StandardScript::P2PK(pk) => builder.p2pk(&pk), + StandardScript::P2PKH(pubkey_hash) => { let pubkey = self.public_keys.get_ecdsa_public_key(&pubkey_hash)?; builder.p2pkh(&pubkey) }, - ConditionScript::P2WPKH(pubkey_hash) => { + StandardScript::P2WPKH(pubkey_hash) => { let pubkey = self.public_keys.get_ecdsa_public_key(&pubkey_hash)?; builder.p2wpkh(&pubkey) }, - ConditionScript::P2TR(tweaked_pubkey) => { + StandardScript::P2TR(tweaked_pubkey) => { builder.p2tr_key_path_with_tweaked_pubkey(&tweaked_pubkey) }, + StandardScript::P2SH(_) | StandardScript::P2WSH(_) => { + SigningError::err(SigningErrorType::Error_not_supported) + .context("P2SH and P2WSH scriptPubkey's are not supported yet") + }, + StandardScript::OpReturn(_) => SigningError::err(SigningErrorType::Error_invalid_utxo) + .context("Cannot spend an OP_RETURN output"), } } diff --git a/rust/frameworks/tw_utxo/src/script/standard_script/conditions.rs b/rust/frameworks/tw_utxo/src/script/standard_script/conditions.rs index b40b59b37b6..d16c21c4dd5 100644 --- a/rust/frameworks/tw_utxo/src/script/standard_script/conditions.rs +++ b/rust/frameworks/tw_utxo/src/script/standard_script/conditions.rs @@ -4,6 +4,7 @@ use secp256k1::XOnlyPublicKey; use tw_hash::H160; use tw_hash::H256; use tw_hash::H264; +use tw_memory::Data; use tw_misc::traits::ToBytesVec; use super::opcodes::*; @@ -170,14 +171,14 @@ pub fn match_p2pkh(s: &Script) -> Option { } } -// /// Returns a script hash if matched. -// pub fn match_p2sh(s: &Script) -> Option { -// if is_p2sh(s) { -// Some(H160::try_from(&s.as_slice()[2..22]).expect("is_p2sh checks the length")) -// } else { -// None -// } -// } +/// Returns a script hash if matched. +pub fn match_p2sh(s: &Script) -> Option { + if is_p2sh(s) { + Some(H160::try_from(&s.as_slice()[2..22]).expect("is_p2sh checks the length")) + } else { + None + } +} /// Returns a public key hash if matched. pub fn match_p2wpkh(s: &Script) -> Option { @@ -188,14 +189,14 @@ pub fn match_p2wpkh(s: &Script) -> Option { } } -// /// Returns a script hash if matched. -// pub fn match_p2wsh(s: &Script) -> Option { -// if is_p2wsh(s) { -// Some(H256::try_from(&s.as_slice()[2..]).expect("is_p2wsh checks the length")) -// } else { -// None -// } -// } +/// Returns a script hash if matched. +pub fn match_p2wsh(s: &Script) -> Option { + if is_p2wsh(s) { + Some(H256::try_from(&s.as_slice()[2..]).expect("is_p2wsh checks the length")) + } else { + None + } +} /// Returns a tweaked schnorr public key if matched. pub fn match_p2tr(s: &Script) -> Option { @@ -205,3 +206,12 @@ pub fn match_p2tr(s: &Script) -> Option { None } } + +/// Returns an OP_RETURN payload. +pub fn match_op_return(s: &Script) -> Option { + if is_op_return(s) { + Some(s.as_slice()[1..].to_vec()) + } else { + None + } +} diff --git a/rust/frameworks/tw_utxo/src/transaction/unsigned_transaction.rs b/rust/frameworks/tw_utxo/src/transaction/unsigned_transaction.rs index 4f553815d7a..75f43f780ea 100644 --- a/rust/frameworks/tw_utxo/src/transaction/unsigned_transaction.rs +++ b/rust/frameworks/tw_utxo/src/transaction/unsigned_transaction.rs @@ -49,6 +49,10 @@ where &self.utxo_args } + pub fn outputs(&self) -> &[Transaction::Output] { + self.transaction.outputs() + } + pub fn outputs_mut(&mut self) -> &mut [Transaction::Output] { self.transaction.outputs_mut() } diff --git a/rust/frameworks/tw_utxo/src/utxo_entry.rs b/rust/frameworks/tw_utxo/src/utxo_entry.rs index 1f19764f3c4..8d8321b299c 100644 --- a/rust/frameworks/tw_utxo/src/utxo_entry.rs +++ b/rust/frameworks/tw_utxo/src/utxo_entry.rs @@ -9,16 +9,25 @@ use tw_proto::{deserialize, serialize, MessageRead, MessageWrite, ProtoResult}; pub trait UtxoEntry { type PsbtSigningInput<'a>: MessageRead<'a>; type PsbtSigningOutput: MessageWrite; + type PsbtTransactionPlan: MessageWrite; fn sign_psbt( &self, coin: &dyn CoinContext, input: Self::PsbtSigningInput<'_>, ) -> Self::PsbtSigningOutput; + + fn plan_psbt( + &self, + coin: &dyn CoinContext, + input: Self::PsbtSigningInput<'_>, + ) -> Self::PsbtTransactionPlan; } pub trait UtxoEntryExt { fn sign_psbt(&self, coin: &dyn CoinContext, input: &[u8]) -> ProtoResult; + + fn plan_psbt(&self, coin: &dyn CoinContext, input: &[u8]) -> ProtoResult; } impl UtxoEntryExt for T { @@ -27,4 +36,10 @@ impl UtxoEntryExt for T { let output = ::sign_psbt(self, coin, input); serialize(&output) } + + fn plan_psbt(&self, coin: &dyn CoinContext, input: &[u8]) -> ProtoResult { + let input: T::PsbtSigningInput<'_> = deserialize(input)?; + let output = ::plan_psbt(self, coin, input); + serialize(&output) + } } diff --git a/rust/tw_any_coin/tests/chains/bitcoin/bitcoin_plan/mod.rs b/rust/tw_any_coin/tests/chains/bitcoin/bitcoin_plan/mod.rs index bc8b8048d52..953c7578968 100644 --- a/rust/tw_any_coin/tests/chains/bitcoin/bitcoin_plan/mod.rs +++ b/rust/tw_any_coin/tests/chains/bitcoin/bitcoin_plan/mod.rs @@ -6,3 +6,4 @@ mod plan_exact; mod plan_exact_error; mod plan_max; mod plan_max_error; +mod plan_psbt; diff --git a/rust/tw_any_coin/tests/chains/bitcoin/bitcoin_plan/plan_psbt.rs b/rust/tw_any_coin/tests/chains/bitcoin/bitcoin_plan/plan_psbt.rs new file mode 100644 index 00000000000..70adc5b626a --- /dev/null +++ b/rust/tw_any_coin/tests/chains/bitcoin/bitcoin_plan/plan_psbt.rs @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Copyright © 2017 Trust Wallet. + +use crate::chains::common::bitcoin::input::out_point; +use crate::chains::common::bitcoin::psbt_plan::BitcoinPsbtPlanHelper; +use crate::chains::common::bitcoin::{btc_info, input, output, RecipientType}; +use tw_coin_registry::coin_type::CoinType; +use tw_encoding::hex::DecodeHex; +use tw_keypair::ecdsa; +use tw_proto::BitcoinV2::Proto; + +#[test] +fn test_bitcoin_plan_psbt_thorchain_swap_witness() { + let private_key = ecdsa::secp256k1::PrivateKey::try_from( + "f00ffbe44c5c2838c13d2778854ac66b75e04eb6054f0241989e223223ad5e55", + ) + .unwrap(); + + let psbt = "70736274ff0100bc0200000001147010db5fbcf619067c1090fec65c131443fbc80fb4aaeebe940e44206098c60000000000ffffffff0360ea000000000000160014f22a703617035ef7f490743d50f26ae08c30d0a70000000000000000426a403d3a474149412e41544f4d3a636f736d6f7331737377797a666d743675396a373437773537753438746778646575393573757a666c6d7175753a303a743a35303e12000000000000160014b139199ec796f36fc42e637f42da8e3e6720aa9d000000000001011f6603010000000000160014b139199ec796f36fc42e637f42da8e3e6720aa9d00000000".decode_hex().unwrap(); + let input = Proto::PsbtSigningInput { + psbt: psbt.into(), + public_keys: vec![private_key.public().compressed().to_vec().into()], + chain_info: btc_info(), + ..Proto::PsbtSigningInput::default() + }; + + let utxo_0 = Proto::Input { + out_point: out_point( + "c6986020440e94beeeaab40fc8fb4314135cc6fe90107c0619f6bc5fdb107014", + 0, + ), + value: 66_406, + sighash_type: 1, + sequence: input::sequence(u32::MAX), + claiming_script: input::receiver_address("bc1qkyu3n8k8jmekl3pwvdl59k5w8enjp25akz2r3z"), + }; + + let out_0 = Proto::Output { + value: 60_000, + to_recipient: output::to_address("bc1q7g48qdshqd000aysws74pun2uzxrp598gcfum0"), + }; + let out_1 = Proto::Output { + value: 0, + to_recipient: RecipientType::custom_script_pubkey( + "6a403d3a474149412e41544f4d3a636f736d6f7331737377797a666d743675396a373437773537753438746778646575393573757a666c6d7175753a303a743a3530" + .decode_hex() + .unwrap() + .into() + ), + }; + let out_2 = Proto::Output { + value: 4_670, + to_recipient: output::to_address("bc1qkyu3n8k8jmekl3pwvdl59k5w8enjp25akz2r3z"), + }; + + let expected = Proto::TransactionPlan { + inputs: vec![utxo_0], + outputs: vec![out_0, out_1, out_2], + available_amount: 66_406, + send_amount: 66_406, + vsize_estimate: 216, + fee_estimate: 1736, + change: 0, + ..Proto::TransactionPlan::default() + }; + + BitcoinPsbtPlanHelper::new(&input) + .coin(CoinType::Bitcoin) + .plan_psbt(expected); +} diff --git a/rust/tw_any_coin/tests/chains/common/bitcoin/mod.rs b/rust/tw_any_coin/tests/chains/common/bitcoin/mod.rs index 3aac42ba046..1c9c1ecd2f0 100644 --- a/rust/tw_any_coin/tests/chains/common/bitcoin/mod.rs +++ b/rust/tw_any_coin/tests/chains/common/bitcoin/mod.rs @@ -10,6 +10,7 @@ pub mod compile; pub mod data; pub mod plan; pub mod preimage; +pub mod psbt_plan; pub mod psbt_sign; pub mod sign; @@ -39,10 +40,11 @@ pub use tw_proto::BitcoinV2::Proto::mod_PublicKeyOrHash::OneOfvariant as PublicK use tw_proto::BitcoinV2::Proto; -pub fn btc_info() -> Option { +pub fn btc_info() -> Option> { Some(Proto::ChainInfo { p2pkh_prefix: BITCOIN_P2PKH_PREFIX as u32, p2sh_prefix: BITCOIN_P2SH_PREFIX as u32, + hrp: "bc".into(), }) } @@ -66,6 +68,10 @@ pub mod input { }) } + pub fn sequence(sequence: u32) -> Option { + Some(Proto::mod_Input::Sequence { sequence }) + } + pub fn claiming_script_builder(ty: InputBuilderType<'static>) -> ClaimingScriptType<'static> { ClaimingScriptType::script_builder(InputBuilder { variant: ty }) } diff --git a/rust/tw_any_coin/tests/chains/common/bitcoin/psbt_plan.rs b/rust/tw_any_coin/tests/chains/common/bitcoin/psbt_plan.rs new file mode 100644 index 00000000000..f86f50041c8 --- /dev/null +++ b/rust/tw_any_coin/tests/chains/common/bitcoin/psbt_plan.rs @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Copyright © 2017 Trust Wallet. + +use tw_coin_registry::coin_type::CoinType; +use tw_coin_registry::dispatcher::{coin_dispatcher, utxo_dispatcher}; +use tw_proto::BitcoinV2::Proto; +use tw_proto::{deserialize, serialize}; + +pub struct BitcoinPsbtPlanHelper<'a> { + input: &'a Proto::PsbtSigningInput<'a>, + coin_type: Option, +} + +impl<'a> BitcoinPsbtPlanHelper<'a> { + pub fn new(input: &'a Proto::PsbtSigningInput<'a>) -> Self { + BitcoinPsbtPlanHelper { + input, + coin_type: None, + } + } + + pub fn coin(mut self, coin_type: CoinType) -> Self { + self.coin_type = Some(coin_type); + self + } + + #[track_caller] + pub fn plan_psbt(self, expected: Proto::TransactionPlan) { + let coin_type = self + .coin_type + .expect("'BitcoinSignHelper::coin_type' is not set"); + + let input = serialize(self.input).unwrap(); + + // TODO call `tw_bitcoin_plan_psbt` when all tests are moved to another crate. + let (ctx, _entry) = coin_dispatcher(coin_type).expect("Unknown CoinType"); + let output_bytes = utxo_dispatcher(coin_type) + .expect("CoinType is not UTXO, i.e `utxo_dispatcher` failed") + .plan_psbt(&ctx, &input) + .unwrap(); + + let output: Proto::TransactionPlan = deserialize(&output_bytes).unwrap(); + + assert_eq!(output.error, expected.error, "{}", output.error_message); + + assert_eq!(output.inputs, expected.inputs, "Wrong transaction UTXOs"); + assert_eq!( + output.outputs, expected.outputs, + "Wrong transaction outputs" + ); + + assert_eq!( + output.available_amount, expected.available_amount, + "Wrong available amount" + ); + assert_eq!( + output.send_amount, expected.send_amount, + "Wrong send amount" + ); + assert_eq!( + output.vsize_estimate, expected.vsize_estimate, + "Wrong vsize" + ); + assert_eq!(output.fee_estimate, expected.fee_estimate, "Wrong fee"); + assert_eq!(output.change, expected.change, "Wrong change"); + } +} diff --git a/rust/tw_hash/src/hash_array.rs b/rust/tw_hash/src/hash_array.rs index 6bbae8a85db..936e02118f9 100644 --- a/rust/tw_hash/src/hash_array.rs +++ b/rust/tw_hash/src/hash_array.rs @@ -96,7 +96,7 @@ impl Hash { /// This is a [`Hash::split`] helper that ensures that `L + R == N` at compile time. /// Assertion example: -/// ```rust(ignore) +/// ```ignore /// let hash = H256::default(); /// let (left, right): (H128, H160) = hash.split(); /// diff --git a/src/proto/BitcoinV2.proto b/src/proto/BitcoinV2.proto index 13c2ba2c185..5f58e161174 100644 --- a/src/proto/BitcoinV2.proto +++ b/src/proto/BitcoinV2.proto @@ -182,8 +182,10 @@ message Output { message ChainInfo { // P2PKH prefix for this chain. uint32 p2pkh_prefix = 1; - // P2SH prefix for this coin type + // P2SH prefix for this coin type. uint32 p2sh_prefix = 2; + // HRP for this coin type if applicable. + string hrp = 3; } enum TransactionVersion { @@ -273,7 +275,7 @@ message Transaction { message TransactionPlan { // A possible error, `OK` if none. Common.Proto.SigningError error = 1; - /// Error description. + // Error description. string error_message = 2; // Selected unspent transaction outputs (subset of all input UTXOs). repeated Input inputs = 3; @@ -301,7 +303,7 @@ message PreSigningOutput { Common.Proto.SigningError error = 1; // Error description. string error_message = 2; - /// The sighashes to be signed; ECDSA for legacy and Segwit, Schnorr for Taproot. + // The sighashes to be signed; ECDSA for legacy and Segwit, Schnorr for Taproot. repeated Sighash sighashes = 4; enum SigningMethod { @@ -359,14 +361,17 @@ message PsbtSigningInput { // Partly signed transaction to be signed. bytes psbt = 1; // User private keys. - // Only required if the `sign` method is called. + // Only required if the `signPSBT` method is called. repeated bytes private_keys = 2; // User public keys. - // Only required if the `plan` method is called. + // Only required if the `planPSBT` method is called. repeated bytes public_keys = 3; + // Chain info includes p2pkh, p2sh, hrp address prefixes. + // The parameter needs to be set when `planPSBT` is called. + ChainInfo chain_info = 4; // Whether disable auxiliary random data when signing. // Use for testing **ONLY**. - bool dangerous_use_fixed_schnorr_rng = 4; + bool dangerous_use_fixed_schnorr_rng = 5; } message PsbtSigningOutput {