From 8354f9bf1b965233ccf1f2e616f9d8147b0a4b8c Mon Sep 17 00:00:00 2001 From: 0xh3rman <119309671+0xh3rman@users.noreply.github.com> Date: Wed, 27 May 2026 00:13:20 +0900 Subject: [PATCH] impl Bitcoin chain signer for BTC/BCH/ZEC --- Cargo.lock | 106 +++++++- Cargo.toml | 4 +- crates/gem_bitcoin/Cargo.toml | 10 +- crates/gem_bitcoin/src/address.rs | 85 ++++++ crates/gem_bitcoin/src/hash.rs | 34 +++ crates/gem_bitcoin/src/lib.rs | 9 + crates/gem_bitcoin/src/provider/preload.rs | 44 +++- .../gem_bitcoin/src/signer/address/bitcoin.rs | 26 ++ .../src/signer/address/bitcoin_cash.rs | 52 ++++ crates/gem_bitcoin/src/signer/address/doge.rs | 10 + .../src/signer/address/litecoin.rs | 11 + crates/gem_bitcoin/src/signer/address/mod.rs | 190 ++++++++++++++ .../gem_bitcoin/src/signer/address/script.rs | 127 +++++++++ .../gem_bitcoin/src/signer/address/zcash.rs | 21 ++ crates/gem_bitcoin/src/signer/bitcoin_cash.rs | 65 +++++ crates/gem_bitcoin/src/signer/chain_signer.rs | 48 ++++ crates/gem_bitcoin/src/signer/encoding.rs | 26 +- crates/gem_bitcoin/src/signer/mod.rs | 241 +++++++++++++++++ crates/gem_bitcoin/src/signer/planner/fee.rs | 110 ++++++++ .../gem_bitcoin/src/signer/planner/inputs.rs | 102 ++++++++ crates/gem_bitcoin/src/signer/planner/mod.rs | 12 + .../gem_bitcoin/src/signer/planner/outputs.rs | 35 +++ .../gem_bitcoin/src/signer/planner/request.rs | 90 +++++++ .../gem_bitcoin/src/signer/planner/spend.rs | 232 +++++++++++++++++ .../gem_bitcoin/src/signer/planner/types.rs | 38 +++ crates/gem_bitcoin/src/signer/transaction.rs | 165 ++++++++++++ crates/gem_bitcoin/src/signer/zcash.rs | 244 ++++++++++++++++++ .../gem_bitcoin/src/testkit/address_mock.rs | 59 +++++ crates/gem_bitcoin/src/testkit/mod.rs | 6 + .../gem_bitcoin/src/testkit/planner_mock.rs | 68 +++++ crates/gem_bitcoin/src/testkit/signer_mock.rs | 166 ++++++++++++ .../src/testkit/transaction_mock.rs | 10 + crates/gem_hash/Cargo.toml | 2 +- crates/gem_hash/src/blake2.rs | 47 ++-- crates/primitives/src/testkit/mock_zcash.rs | 29 +++ crates/primitives/src/testkit/mod.rs | 1 + .../src/transaction_load_metadata.rs | 7 + crates/swapper/src/chainflip/provider.rs | 132 ++++++---- crates/swapper/src/fees/reserve.rs | 39 ++- crates/swapper/src/near_intents/provider.rs | 6 +- crates/swapper/src/testkit.rs | 9 + crates/swapper/src/thorchain/provider.rs | 10 +- gemstone/Cargo.toml | 2 +- gemstone/src/address.rs | 6 +- gemstone/src/signer/chain.rs | 5 +- 45 files changed, 2640 insertions(+), 101 deletions(-) create mode 100644 crates/gem_bitcoin/src/address.rs create mode 100644 crates/gem_bitcoin/src/hash.rs create mode 100644 crates/gem_bitcoin/src/signer/address/bitcoin.rs create mode 100644 crates/gem_bitcoin/src/signer/address/bitcoin_cash.rs create mode 100644 crates/gem_bitcoin/src/signer/address/doge.rs create mode 100644 crates/gem_bitcoin/src/signer/address/litecoin.rs create mode 100644 crates/gem_bitcoin/src/signer/address/mod.rs create mode 100644 crates/gem_bitcoin/src/signer/address/script.rs create mode 100644 crates/gem_bitcoin/src/signer/address/zcash.rs create mode 100644 crates/gem_bitcoin/src/signer/bitcoin_cash.rs create mode 100644 crates/gem_bitcoin/src/signer/chain_signer.rs create mode 100644 crates/gem_bitcoin/src/signer/planner/fee.rs create mode 100644 crates/gem_bitcoin/src/signer/planner/inputs.rs create mode 100644 crates/gem_bitcoin/src/signer/planner/mod.rs create mode 100644 crates/gem_bitcoin/src/signer/planner/outputs.rs create mode 100644 crates/gem_bitcoin/src/signer/planner/request.rs create mode 100644 crates/gem_bitcoin/src/signer/planner/spend.rs create mode 100644 crates/gem_bitcoin/src/signer/planner/types.rs create mode 100644 crates/gem_bitcoin/src/signer/transaction.rs create mode 100644 crates/gem_bitcoin/src/signer/zcash.rs create mode 100644 crates/gem_bitcoin/src/testkit/address_mock.rs create mode 100644 crates/gem_bitcoin/src/testkit/planner_mock.rs create mode 100644 crates/gem_bitcoin/src/testkit/signer_mock.rs create mode 100644 crates/primitives/src/testkit/mock_zcash.rs diff --git a/Cargo.lock b/Cargo.lock index 811d8376a..b2dd13ed7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -865,6 +865,12 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + [[package]] name = "arrayvec" version = "0.7.6" @@ -1160,6 +1166,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[package]] +name = "base58ck" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c8d66485a3a2ea485c1913c4572ce0256067a5377ac8c75c4960e1cda98605f" +dependencies = [ + "bitcoin-internals", + "bitcoin_hashes 0.14.1", +] + [[package]] name = "base64" version = "0.22.1" @@ -1240,12 +1256,50 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bitcoin" +version = "0.32.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf93e61f2dbc3e3c41234ca26a65e2c0b0975c52e0f069ab9893ebbede584d3" +dependencies = [ + "base58ck", + "bech32", + "bitcoin-internals", + "bitcoin-io", + "bitcoin-units", + "bitcoin_hashes 0.14.1", + "hex-conservative", + "hex_lit", + "secp256k1 0.29.1", +] + +[[package]] +name = "bitcoin-internals" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30bdbe14aa07b06e6cfeffc529a1f099e5fbe249524f8125358604df99a4bed2" + [[package]] name = "bitcoin-io" version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dee39a0ee5b4095224a0cfc6bf4cc1baf0f9624b96b367e53b66d974e51d953" +[[package]] +name = "bitcoin-units" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346568ebaab2918487cea76dd55dae13c27bb618cdb737c952e69eb2017c4118" +dependencies = [ + "bitcoin-internals", +] + +[[package]] +name = "bitcoin_hashes" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b375d62f341cef9cd9e77793ec8f1db3fc9ce2e4d57e982c8fe697a2c16af3b6" + [[package]] name = "bitcoin_hashes" version = "0.14.1" @@ -1256,6 +1310,15 @@ dependencies = [ "hex-conservative", ] +[[package]] +name = "bitcoincash-addr" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad79afbfd27efc52fc928b198a365a7ee9da8d881a18c16d88764880b675e543" +dependencies = [ + "bitcoin_hashes 0.7.6", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -1292,6 +1355,17 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "blake2b_simd" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b79834656f71332577234b50bfc009996f7449e0c056884e6a02492ded0ca2f3" +dependencies = [ + "arrayref", + "arrayvec", + "constant_time_eq", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -1831,6 +1905,12 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + [[package]] name = "convert_case" version = "0.6.0" @@ -3121,6 +3201,10 @@ name = "gem_bitcoin" version = "1.0.0" dependencies = [ "async-trait", + "bech32", + "bitcoin", + "bitcoincash-addr", + "bs58", "chain_traits", "chrono", "futures", @@ -3260,7 +3344,7 @@ dependencies = [ name = "gem_hash" version = "1.0.0" dependencies = [ - "blake2", + "blake2b_simd", "hex", "sha2 0.11.0", "sha3 0.10.8", @@ -3819,6 +3903,12 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "hex_lit" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd" + [[package]] name = "hickory-proto" version = "0.25.2" @@ -6629,13 +6719,23 @@ dependencies = [ "zeroize", ] +[[package]] +name = "secp256k1" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" +dependencies = [ + "bitcoin_hashes 0.14.1", + "secp256k1-sys 0.10.1", +] + [[package]] name = "secp256k1" version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b50c5943d326858130af85e049f2661ba3c78b26589b8ab98e65e80ae44a1252" dependencies = [ - "bitcoin_hashes", + "bitcoin_hashes 0.14.1", "rand 0.8.5", "secp256k1-sys 0.10.1", "serde", @@ -6647,7 +6747,7 @@ version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c3c81b43dc2d8877c216a3fccf76677ee1ebccd429566d3e67447290d0c42b2" dependencies = [ - "bitcoin_hashes", + "bitcoin_hashes 0.14.1", "rand 0.9.2", "secp256k1-sys 0.11.0", ] diff --git a/Cargo.toml b/Cargo.toml index 007cf9aac..bdcf1aa0e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -91,7 +91,9 @@ chrono = { version = "0.4.43", features = ["serde"] } # crypto base64 = { version = "0.22.1" } bech32 = { version = "0.11.1" } -blake2 = { version = "0.10.6" } +bitcoin = { version = "0.32.7" } +bitcoincash-addr = { version = "0.5.2" } +blake2b_simd = { version = "1.0.4" } bs58 = { version = "0.5.1", features = ["check"] } hex = { version = "0.4.3" } crc = { version = "3.4.0" } diff --git a/crates/gem_bitcoin/Cargo.toml b/crates/gem_bitcoin/Cargo.toml index 66c73a36d..098a0390f 100644 --- a/crates/gem_bitcoin/Cargo.toml +++ b/crates/gem_bitcoin/Cargo.toml @@ -6,8 +6,8 @@ publish = false [features] default = [] -rpc = ["dep:chain_traits", "dep:gem_client"] -signer = ["dep:signer", "dep:gem_hash", "dep:hex"] +rpc = ["dep:chain_traits", "dep:gem_client", "signer"] +signer = ["dep:bech32", "dep:bitcoin", "dep:bitcoincash-addr", "dep:bs58", "dep:gem_hash", "dep:hex", "dep:signer"] reqwest = ["gem_client/reqwest"] unit_tests = ["signer"] chain_integration_tests = ["rpc", "reqwest", "primitives/testkit", "settings/testkit"] @@ -29,8 +29,14 @@ serde_serializers = { path = "../serde_serializers", features = ["bigint"] } signer = { path = "../signer", optional = true } gem_hash = { path = "../gem_hash", optional = true } hex = { workspace = true, optional = true } +bitcoin = { workspace = true, optional = true } +bitcoincash-addr = { workspace = true, optional = true } +bech32 = { workspace = true, optional = true } +bs58 = { workspace = true, optional = true } [dev-dependencies] +gem_client = { path = "../gem_client", features = ["testkit"] } tokio = { workspace = true, features = ["macros", "rt"] } reqwest = { workspace = true } +primitives = { path = "../primitives", features = ["testkit"] } settings = { path = "../settings", features = ["testkit"] } diff --git a/crates/gem_bitcoin/src/address.rs b/crates/gem_bitcoin/src/address.rs new file mode 100644 index 000000000..5f3730118 --- /dev/null +++ b/crates/gem_bitcoin/src/address.rs @@ -0,0 +1,85 @@ +use bitcoin::ScriptBuf; +use primitives::{Address as AddressTrait, BitcoinChain, Chain}; + +use crate::signer::address::script_for_address; + +#[derive(Debug, Clone)] +pub struct BitcoinAddress { + chain: BitcoinChain, + address: String, + script_pubkey: ScriptBuf, +} + +impl BitcoinAddress { + pub fn try_parse_for_chain(address: &str, chain: BitcoinChain) -> Option { + let script_pubkey = script_for_address(chain, address).ok()?.script_pubkey; + Some(Self { + chain, + address: address.to_string(), + script_pubkey, + }) + } + + pub fn is_valid_for_chain(address: &str, chain: Chain) -> bool { + BitcoinChain::from_chain(chain).is_some_and(|chain| Self::try_parse_for_chain(address, chain).is_some()) + } + + pub fn bitcoin_chain(&self) -> BitcoinChain { + self.chain + } +} + +impl AddressTrait for BitcoinAddress { + fn try_parse(address: &str) -> Option { + [ + BitcoinChain::Bitcoin, + BitcoinChain::BitcoinCash, + BitcoinChain::Litecoin, + BitcoinChain::Doge, + BitcoinChain::Zcash, + ] + .into_iter() + .find_map(|chain| Self::try_parse_for_chain(address, chain)) + } + + fn as_bytes(&self) -> &[u8] { + self.script_pubkey.as_bytes() + } + + fn encode(&self) -> String { + self.address.clone() + } +} + +pub fn validate_address(address: &str, chain: Chain) -> bool { + BitcoinAddress::is_valid_for_chain(address, chain) +} + +#[cfg(test)] +mod tests { + use super::*; + use primitives::Address as AddressTrait; + + #[test] + fn test_validate_address() { + let bitcoin = BitcoinAddress::mock(); + let bitcoin_cash = BitcoinAddress::mock_with_chain(BitcoinChain::BitcoinCash); + let litecoin = BitcoinAddress::mock_with_chain(BitcoinChain::Litecoin); + let doge = BitcoinAddress::mock_with_chain(BitcoinChain::Doge); + let zcash = BitcoinAddress::mock_with_chain(BitcoinChain::Zcash); + + assert!(validate_address(&bitcoin.encode(), Chain::Bitcoin)); + assert!(validate_address(&bitcoin_cash.encode(), Chain::BitcoinCash)); + assert!(validate_address(bitcoin_cash.encode().strip_prefix("bitcoincash:").unwrap(), Chain::BitcoinCash)); + assert!(validate_address(&litecoin.encode(), Chain::Litecoin)); + assert!(validate_address(&doge.encode(), Chain::Doge)); + assert!(validate_address(&zcash.encode(), Chain::Zcash)); + assert!(!validate_address(&bitcoin.encode(), Chain::Litecoin)); + assert!(!validate_address("invalid", Chain::Bitcoin)); + + let parsed = BitcoinAddress::try_parse_for_chain(&bitcoin.encode(), BitcoinChain::Bitcoin).unwrap(); + assert_eq!(parsed.bitcoin_chain().get_chain(), Chain::Bitcoin); + assert_eq!(parsed.encode(), bitcoin.encode()); + assert_eq!(hex::encode(parsed.as_bytes()), "0014751e76e8199196d454941c45d1b3a323f1433bd6"); + } +} diff --git a/crates/gem_bitcoin/src/hash.rs b/crates/gem_bitcoin/src/hash.rs new file mode 100644 index 000000000..befe093f1 --- /dev/null +++ b/crates/gem_bitcoin/src/hash.rs @@ -0,0 +1,34 @@ +use bitcoin::hashes::{Hash, hash160 as bitcoin_hash160, sha256d}; +use primitives::SignerError; + +pub(crate) const HASH160_LEN: usize = 20; + +pub(crate) fn double_sha256(bytes: &[u8]) -> [u8; 32] { + sha256d::Hash::hash(bytes).to_byte_array() +} + +pub(crate) fn hash160(bytes: &[u8]) -> [u8; HASH160_LEN] { + bitcoin_hash160::Hash::hash(bytes).to_byte_array() +} + +pub(crate) fn public_key_hash(public_key: &[u8]) -> [u8; HASH160_LEN] { + hash160(public_key) +} + +pub(crate) fn hash20(bytes: &[u8]) -> Result<[u8; HASH160_LEN], SignerError> { + bytes.try_into().map_err(SignerError::from_display) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_hashes() { + assert_eq!(hex::encode(double_sha256(b"")), "5df6e0e2761359d30a8275058e299fcc0381534545f55cf43e41983f5d4c9456"); + assert_eq!(hex::encode(hash160(b"")), "b472a266d0bd89c13706a4132ccfb16f7c3b9fcb"); + assert_eq!(public_key_hash(b""), hash160(b"")); + assert_eq!(hash20(&[1u8; HASH160_LEN]).unwrap(), [1u8; HASH160_LEN]); + assert!(hash20(&[1u8; HASH160_LEN - 1]).is_err()); + } +} diff --git a/crates/gem_bitcoin/src/lib.rs b/crates/gem_bitcoin/src/lib.rs index 07e4f514b..9ffcd9018 100644 --- a/crates/gem_bitcoin/src/lib.rs +++ b/crates/gem_bitcoin/src/lib.rs @@ -1,5 +1,11 @@ pub mod models; +#[cfg(feature = "signer")] +pub(crate) mod hash; + +#[cfg(feature = "signer")] +pub mod address; + #[cfg(feature = "rpc")] pub mod provider; @@ -15,5 +21,8 @@ pub mod testkit; #[cfg(feature = "rpc")] pub use provider::map_transaction; +#[cfg(feature = "signer")] +pub use address::{BitcoinAddress, validate_address}; + #[cfg(feature = "rpc")] pub use rpc::client::BitcoinClient; diff --git a/crates/gem_bitcoin/src/provider/preload.rs b/crates/gem_bitcoin/src/provider/preload.rs index 5cce4332c..d7535e792 100644 --- a/crates/gem_bitcoin/src/provider/preload.rs +++ b/crates/gem_bitcoin/src/provider/preload.rs @@ -1,6 +1,5 @@ use async_trait::async_trait; use chain_traits::ChainTransactionLoad; -use futures; use num_bigint::BigInt; use number_formatter::BigNumberFormatter; use std::error::Error; @@ -13,6 +12,7 @@ use primitives::{ use crate::models::Address; use crate::provider::preload_mapper::{map_transaction_preload, map_transaction_preload_zcash, map_utxos}; use crate::rpc::client::BitcoinClient; +use crate::signer::estimate_transaction_fee; #[async_trait] impl ChainTransactionLoad for BitcoinClient { @@ -32,10 +32,9 @@ impl ChainTransactionLoad for BitcoinClient { } async fn get_transaction_load(&self, input: TransactionLoadInput) -> Result> { - Ok(TransactionLoadData { - fee: input.default_fee(), - metadata: input.metadata, - }) + let fee = estimate_transaction_fee(self.chain, &input)?; + + Ok(TransactionLoadData { fee, metadata: input.metadata }) } async fn get_transaction_fee_rates(&self, _input_type: TransactionInputType) -> Result, Box> { @@ -45,9 +44,7 @@ impl ChainTransactionLoad for BitcoinClient { let (slow, normal, fast) = futures::try_join!(self.get_fee(priority.slow), self.get_fee(priority.normal), self.get_fee(priority.fast))?; Ok(map_fee_rates(slow, normal, fast, self.chain)) } - BitcoinChain::Zcash => { - return Ok(vec![FeeRate::new(FeePriority::Normal, GasPriceType::regular(BigInt::from(1_000).clone()))]); - } + BitcoinChain::Zcash => Ok(vec![FeeRate::new(FeePriority::Normal, GasPriceType::regular(BigInt::from(1_000)))]), } } @@ -86,6 +83,13 @@ fn map_fee_rates(slow: BigInt, normal: BigInt, fast: BigInt, chain: BitcoinChain #[cfg(test)] mod tests { use super::*; + use gem_client::testkit::MockClient; + use primitives::TransactionLoadMetadata; + + use crate::{ + rpc::client::BitcoinClient, + testkit::signer_mock::{transfer_input, utxo_with}, + }; #[test] fn test_calculate_fee_rate() { @@ -120,4 +124,28 @@ mod tests { assert_eq!(FeeRate::find(&rates, FeePriority::Normal).unwrap().gas_price_type.gas_price(), BigInt::from(2000)); assert_eq!(FeeRate::find(&rates, FeePriority::Fast).unwrap().gas_price_type.gas_price(), BigInt::from(3000)); } + + #[tokio::test] + async fn test_get_transaction_load_estimates_with_signer_planner() { + let client = BitcoinClient::new(MockClient::new(), BitcoinChain::BitcoinCash); + let mut input = transfer_input(BitcoinChain::BitcoinCash).input; + input.metadata = TransactionLoadMetadata::Bitcoin { + utxos: vec![utxo_with( + "0000000000000000000000000000000000000000000000000000000000000001", + 0, + "50000", + &input.sender_address, + )], + }; + + let load = client.get_transaction_load(input).await.unwrap(); + + assert_eq!(load.fee.fee, BigInt::from(1130u64)); + + let client = BitcoinClient::new(MockClient::new(), BitcoinChain::Zcash); + let input = transfer_input(BitcoinChain::Zcash).input; + let load = client.get_transaction_load(input).await.unwrap(); + + assert_eq!(load.fee.fee, BigInt::from(10000u64)); + } } diff --git a/crates/gem_bitcoin/src/signer/address/bitcoin.rs b/crates/gem_bitcoin/src/signer/address/bitcoin.rs new file mode 100644 index 000000000..31fd8a0d8 --- /dev/null +++ b/crates/gem_bitcoin/src/signer/address/bitcoin.rs @@ -0,0 +1,26 @@ +use std::str::FromStr; + +use ::bitcoin::{Address, AddressType, Network, address::AddressData}; +use primitives::SignerError; + +use super::script::{AddressScript, LockingScript}; + +pub(super) fn script(address: &str) -> Result { + let address = Address::from_str(address) + .map_err(SignerError::from_display)? + .require_network(Network::Bitcoin) + .map_err(SignerError::from_display)?; + let script_pubkey = address.script_pubkey(); + + match address.to_address_data() { + AddressData::P2pkh { .. } => Ok(AddressScript::new(script_pubkey, LockingScript::P2pkh)), + AddressData::P2sh { .. } => Ok(AddressScript::new(script_pubkey, LockingScript::P2sh)), + AddressData::Segwit { .. } => match address.address_type() { + Some(AddressType::P2wpkh) => Ok(AddressScript::new(script_pubkey, LockingScript::P2wpkh)), + Some(AddressType::P2wsh) => Ok(AddressScript::new(script_pubkey, LockingScript::P2wsh)), + Some(AddressType::P2tr) => Ok(AddressScript::new(script_pubkey, LockingScript::P2tr)), + _ => Err(SignerError::from_display("unsupported Bitcoin address type")), + }, + _ => Err(SignerError::from_display("unsupported Bitcoin address type")), + } +} diff --git a/crates/gem_bitcoin/src/signer/address/bitcoin_cash.rs b/crates/gem_bitcoin/src/signer/address/bitcoin_cash.rs new file mode 100644 index 000000000..8a64325d8 --- /dev/null +++ b/crates/gem_bitcoin/src/signer/address/bitcoin_cash.rs @@ -0,0 +1,52 @@ +use std::fmt; + +use bitcoincash_addr::{ + Address as CashAddress, HashType as CashHashType, Network as CashNetwork, base58::DecodingError as Base58DecodingError, cashaddr::DecodingError as CashaddrDecodingError, +}; +use primitives::SignerError; + +use super::script::{AddressScript, LockingScript, p2pkh_script, p2sh_script}; +use crate::hash::hash20; + +const CASHADDR_PREFIX: &str = "bitcoincash:"; + +struct DecodeAddressError { + cashaddr: CashaddrDecodingError, + base58: Base58DecodingError, +} + +impl From<(CashaddrDecodingError, Base58DecodingError)> for DecodeAddressError { + fn from((cashaddr, base58): (CashaddrDecodingError, Base58DecodingError)) -> Self { + Self { cashaddr, base58 } + } +} + +impl fmt::Display for DecodeAddressError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "invalid Bitcoin Cash address: cashaddr: {}; base58: {}", self.cashaddr, self.base58) + } +} + +pub(super) fn script(address: &str) -> Result { + let address = decode_address(address).map_err(SignerError::from_display)?; + if address.network != CashNetwork::Main { + return Err(SignerError::from_display("unsupported Bitcoin Cash address network")); + } + let hash = hash20(&address.body)?; + match address.hash_type { + CashHashType::Key => Ok(AddressScript::new(p2pkh_script(hash), LockingScript::P2pkh)), + CashHashType::Script => Ok(AddressScript::new(p2sh_script(hash), LockingScript::P2sh)), + } +} + +fn decode_address(address: &str) -> Result { + match CashAddress::decode(address) { + Ok(address) => Ok(address), + Err(error) => { + if address.contains(':') { + return Err(error.into()); + } + CashAddress::decode(&format!("{CASHADDR_PREFIX}{address}")).map_err(|_| error.into()) + } + } +} diff --git a/crates/gem_bitcoin/src/signer/address/doge.rs b/crates/gem_bitcoin/src/signer/address/doge.rs new file mode 100644 index 000000000..820e26d74 --- /dev/null +++ b/crates/gem_bitcoin/src/signer/address/doge.rs @@ -0,0 +1,10 @@ +use primitives::SignerError; + +use super::script::AddressScript; + +const P2PKH_VERSIONS: [u8; 1] = [30]; +const P2SH_VERSIONS: [u8; 1] = [22]; + +pub(super) fn script(address: &str) -> Result { + AddressScript::from_prefixed_address(address, &P2PKH_VERSIONS, &P2SH_VERSIONS, None) +} diff --git a/crates/gem_bitcoin/src/signer/address/litecoin.rs b/crates/gem_bitcoin/src/signer/address/litecoin.rs new file mode 100644 index 000000000..2ef028f53 --- /dev/null +++ b/crates/gem_bitcoin/src/signer/address/litecoin.rs @@ -0,0 +1,11 @@ +use primitives::SignerError; + +use super::script::AddressScript; + +const P2PKH_VERSIONS: [u8; 1] = [48]; +const P2SH_VERSIONS: [u8; 2] = [5, 50]; +const HRP: &str = "ltc"; + +pub(super) fn script(address: &str) -> Result { + AddressScript::from_prefixed_address(address, &P2PKH_VERSIONS, &P2SH_VERSIONS, Some(HRP)) +} diff --git a/crates/gem_bitcoin/src/signer/address/mod.rs b/crates/gem_bitcoin/src/signer/address/mod.rs new file mode 100644 index 000000000..700205000 --- /dev/null +++ b/crates/gem_bitcoin/src/signer/address/mod.rs @@ -0,0 +1,190 @@ +mod bitcoin; +mod bitcoin_cash; +mod doge; +mod litecoin; +mod script; +mod zcash; + +use primitives::{BitcoinChain, SignerError}; + +pub(crate) use crate::hash::public_key_hash; +use script::AddressScript; +pub(crate) use script::{UnlockingScript, script_for_public_key_hash}; +#[cfg(test)] +pub(crate) use zcash::TRANSPARENT_P2PKH_PREFIX as ZCASH_TRANSPARENT_P2PKH_PREFIX; + +pub(crate) fn script_for_address(chain: BitcoinChain, address: &str) -> Result { + match chain { + BitcoinChain::Bitcoin => bitcoin::script(address), + BitcoinChain::Litecoin => litecoin::script(address), + BitcoinChain::Doge => doge::script(address), + BitcoinChain::BitcoinCash => bitcoin_cash::script(address), + BitcoinChain::Zcash => zcash::script(address), + } +} + +#[cfg(test)] +mod tests { + use primitives::BitcoinChain; + + use super::{ + script::{LockingScript, UnlockingScript}, + script_for_address, + }; + + fn assert_address_script( + chain: BitcoinChain, + address: &str, + locking_script: LockingScript, + unlocking_script: Option, + script_pubkey: &str, + public_key_hash: Option<&str>, + ) { + let script = script_for_address(chain, address).unwrap(); + assert_eq!(script.locking_script, locking_script); + assert_eq!(script.unlocking_script(), unlocking_script); + assert_eq!(hex::encode(script.script_pubkey.as_bytes()), script_pubkey); + assert_eq!(script.public_key_hash().map(hex::encode).as_deref(), public_key_hash); + } + + #[test] + fn test_script_for_address_bitcoin() { + assert_address_script( + BitcoinChain::Bitcoin, + "1QJVDzdqb1VpbDK7uDeyVXy9mR27CJiyhY", + LockingScript::P2pkh, + Some(UnlockingScript::P2pkh), + "76a914ff99864ce1a887e00c9c8615210d6267edd7d7a588ac", + Some("ff99864ce1a887e00c9c8615210d6267edd7d7a5"), + ); + assert_address_script( + BitcoinChain::Bitcoin, + "33iFwdLuRpW1uK1RTRqsoi8rR4NpDzk66k", + LockingScript::P2sh, + None, + "a914162c5ea71c0b23f5b9022ef047c4a86470a5b07087", + None, + ); + assert_address_script( + BitcoinChain::Bitcoin, + "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", + LockingScript::P2wpkh, + Some(UnlockingScript::P2wpkh), + "0014751e76e8199196d454941c45d1b3a323f1433bd6", + Some("751e76e8199196d454941c45d1b3a323f1433bd6"), + ); + assert_address_script( + BitcoinChain::Bitcoin, + "bc1qwqdg6squsna38e46795at95yu9atm8azzmyvckulcc7kytlcckxswvvzej", + LockingScript::P2wsh, + None, + "0020701a8d401c84fb13e6baf169d59684e17abd9fa216c8cc5b9fc63d622ff8c58d", + None, + ); + assert_address_script( + BitcoinChain::Bitcoin, + "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr", + LockingScript::P2tr, + None, + "5120a60869f0dbcf1dc659c9cecbaf8050135ea9e8cdc487053f1dc6880949dc684c", + None, + ); + } + + #[test] + fn test_script_for_address_bitcoin_cash() { + assert_address_script( + BitcoinChain::BitcoinCash, + "bitcoincash:qp3wjpa3tjlj042z2wv7hahsldgwhwy0rq9sywjpyy", + LockingScript::P2pkh, + Some(UnlockingScript::P2pkh), + "76a91462e907b15cbf27d5425399ebf6f0fb50ebb88f1888ac", + Some("62e907b15cbf27d5425399ebf6f0fb50ebb88f18"), + ); + assert_address_script( + BitcoinChain::BitcoinCash, + "qp3wjpa3tjlj042z2wv7hahsldgwhwy0rq9sywjpyy", + LockingScript::P2pkh, + Some(UnlockingScript::P2pkh), + "76a91462e907b15cbf27d5425399ebf6f0fb50ebb88f1888ac", + Some("62e907b15cbf27d5425399ebf6f0fb50ebb88f18"), + ); + + assert_address_script( + BitcoinChain::BitcoinCash, + "bitcoincash:pr0662zpd7vr936d83f64u629v886aan7c77r3j5v5", + LockingScript::P2sh, + None, + "a914dfad28416f9832c74d3c53aaf34a2b0e7d77b3f687", + None, + ); + } + + #[test] + fn test_script_for_address_litecoin() { + assert_address_script( + BitcoinChain::Litecoin, + "LMHEFMwRsQ3nHDfb9zZqynLHxjuJ2hgyyW", + LockingScript::P2pkh, + Some(UnlockingScript::P2pkh), + "76a914168ed7e47426cf09541df4979c6450b3d5a5547088ac", + Some("168ed7e47426cf09541df4979c6450b3d5a55470"), + ); + assert_address_script( + BitcoinChain::Litecoin, + "MC2JYMPVWaxqUb9qUkUbjtUwoNMo1tPaLF", + LockingScript::P2sh, + None, + "a9142d3a59d2d9f68868cbd5d37afb2c0d6c921b2f3187", + None, + ); + assert_address_script( + BitcoinChain::Litecoin, + "ltc1qhzjptwpym9afcdjhs7jcz6fd0jma0l0rc0e5yr", + LockingScript::P2wpkh, + Some(UnlockingScript::P2wpkh), + "0014b8a415b824d97a9c365787a581692d7cb7d7fde3", + Some("b8a415b824d97a9c365787a581692d7cb7d7fde3"), + ); + } + + #[test] + fn test_script_for_address_doge() { + assert_address_script( + BitcoinChain::Doge, + "DMKhUaRmnxJXfDxyFguMnMjVdgvnNipFzt", + LockingScript::P2pkh, + Some(UnlockingScript::P2pkh), + "76a914b18355f0b9c7aa20e9db204825e6275e9a40bc8988ac", + Some("b18355f0b9c7aa20e9db204825e6275e9a40bc89"), + ); + assert_address_script( + BitcoinChain::Doge, + "A1yb6viUzAcUWftRHT6GpnCwvhXHg4CV1x", + LockingScript::P2sh, + None, + "a91468a56b88a61df17afc8a0709ec1536a51101881087", + None, + ); + } + + #[test] + fn test_script_for_address_zcash() { + assert_address_script( + BitcoinChain::Zcash, + "t1Ku2KLyndDPsR32jwnrTMd3yvi9tfFP8ML", + LockingScript::P2pkh, + Some(UnlockingScript::P2pkh), + "76a9141634f5ff0b8f6603a17570436d6c12a91f4b1fed88ac", + Some("1634f5ff0b8f6603a17570436d6c12a91f4b1fed"), + ); + assert_address_script( + BitcoinChain::Zcash, + "t3Vz22vK5z2LcKEdg16Yv4FFneEL1zg9ojd", + LockingScript::P2sh, + None, + "a9147d46a730d31f97b1930d3368a967c309bd4d136a87", + None, + ); + } +} diff --git a/crates/gem_bitcoin/src/signer/address/script.rs b/crates/gem_bitcoin/src/signer/address/script.rs new file mode 100644 index 000000000..0927319bd --- /dev/null +++ b/crates/gem_bitcoin/src/signer/address/script.rs @@ -0,0 +1,127 @@ +use bech32::{primitives::gf32::Fe32, segwit::VERSION_0, segwit::VERSION_1}; +use bitcoin::{ + ScriptBuf, + blockdata::{opcodes::all::*, script::Builder}, +}; +use primitives::SignerError; + +use crate::hash::{HASH160_LEN, hash20}; + +const WITNESS_PROGRAM_LEN: usize = 32; +const P2PKH_HASH_OFFSET: usize = 3; +const P2WPKH_HASH_OFFSET: usize = 2; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum LockingScript { + P2pkh, + P2sh, + P2wpkh, + P2wsh, + P2tr, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum UnlockingScript { + P2pkh, + P2wpkh, +} + +#[derive(Debug, Clone)] +pub(crate) struct AddressScript { + pub(crate) script_pubkey: ScriptBuf, + pub(crate) locking_script: LockingScript, +} + +impl AddressScript { + pub(crate) fn new(script_pubkey: ScriptBuf, locking_script: LockingScript) -> Self { + Self { script_pubkey, locking_script } + } + + pub(crate) fn unlocking_script(&self) -> Option { + match (self.locking_script, self.public_key_hash()) { + (LockingScript::P2pkh, Some(_)) => Some(UnlockingScript::P2pkh), + (LockingScript::P2wpkh, Some(_)) => Some(UnlockingScript::P2wpkh), + _ => None, + } + } + + pub(crate) fn public_key_hash(&self) -> Option<[u8; HASH160_LEN]> { + let offset = match self.locking_script { + LockingScript::P2pkh if self.script_pubkey.is_p2pkh() => P2PKH_HASH_OFFSET, + LockingScript::P2wpkh if self.script_pubkey.is_p2wpkh() => P2WPKH_HASH_OFFSET, + LockingScript::P2sh | LockingScript::P2wsh | LockingScript::P2tr | LockingScript::P2pkh | LockingScript::P2wpkh => return None, + }; + self.script_pubkey.as_bytes().get(offset..offset + HASH160_LEN)?.try_into().ok() + } + + pub(super) fn from_prefixed_address(address: &str, p2pkh_versions: &[u8], p2sh_versions: &[u8], hrp: Option<&str>) -> Result { + if let Some(expected_hrp) = hrp + && let Ok((address_hrp, version, program)) = bech32::segwit::decode(address) + && address_hrp.as_str() == expected_hrp + { + return Self::from_segwit(version, &program); + } + + let payload = bs58::decode(address).with_check(None).into_vec().map_err(SignerError::from_display)?; + let Some((&version, hash)) = payload.split_first() else { + return Err(SignerError::from_display("invalid base58 address")); + }; + let hash = hash20(hash)?; + + if p2pkh_versions.contains(&version) { + Ok(Self::new(p2pkh_script(hash), LockingScript::P2pkh)) + } else if p2sh_versions.contains(&version) { + Ok(Self::new(p2sh_script(hash), LockingScript::P2sh)) + } else { + Err(SignerError::from_display("unsupported address version")) + } + } + + fn from_segwit(version: Fe32, program: &[u8]) -> Result { + let (script_pubkey, locking_script) = match (version, program.len()) { + (version, HASH160_LEN) if version == VERSION_0 => (p2wpkh_script(hash20(program)?), LockingScript::P2wpkh), + (version, WITNESS_PROGRAM_LEN) if version == VERSION_0 => (p2wsh_script(hash32(program)?), LockingScript::P2wsh), + (version, WITNESS_PROGRAM_LEN) if version == VERSION_1 => (p2tr_script(hash32(program)?), LockingScript::P2tr), + _ => return Err(SignerError::from_display("unsupported segwit address type")), + }; + + Ok(Self::new(script_pubkey, locking_script)) + } +} + +pub(crate) fn script_for_public_key_hash(unlocking_script: UnlockingScript, hash: [u8; HASH160_LEN]) -> ScriptBuf { + match unlocking_script { + UnlockingScript::P2pkh => p2pkh_script(hash), + UnlockingScript::P2wpkh => p2wpkh_script(hash), + } +} + +pub(super) fn p2pkh_script(hash: [u8; HASH160_LEN]) -> ScriptBuf { + Builder::new() + .push_opcode(OP_DUP) + .push_opcode(OP_HASH160) + .push_slice(hash) + .push_opcode(OP_EQUALVERIFY) + .push_opcode(OP_CHECKSIG) + .into_script() +} + +pub(super) fn p2sh_script(hash: [u8; HASH160_LEN]) -> ScriptBuf { + Builder::new().push_opcode(OP_HASH160).push_slice(hash).push_opcode(OP_EQUAL).into_script() +} + +fn p2wpkh_script(hash: [u8; HASH160_LEN]) -> ScriptBuf { + Builder::new().push_opcode(OP_PUSHBYTES_0).push_slice(hash).into_script() +} + +fn p2wsh_script(hash: [u8; WITNESS_PROGRAM_LEN]) -> ScriptBuf { + Builder::new().push_opcode(OP_PUSHBYTES_0).push_slice(hash).into_script() +} + +fn p2tr_script(output_key: [u8; WITNESS_PROGRAM_LEN]) -> ScriptBuf { + Builder::new().push_opcode(OP_PUSHNUM_1).push_slice(output_key).into_script() +} + +fn hash32(bytes: &[u8]) -> Result<[u8; WITNESS_PROGRAM_LEN], SignerError> { + bytes.try_into().map_err(SignerError::from_display) +} diff --git a/crates/gem_bitcoin/src/signer/address/zcash.rs b/crates/gem_bitcoin/src/signer/address/zcash.rs new file mode 100644 index 000000000..fca8d717e --- /dev/null +++ b/crates/gem_bitcoin/src/signer/address/zcash.rs @@ -0,0 +1,21 @@ +use primitives::SignerError; + +use super::script::{AddressScript, LockingScript, p2pkh_script, p2sh_script}; +use crate::hash::hash20; + +// Zcash mainnet transparent address version bytes: t1 for P2PKH, t3 for P2SH. +pub(crate) const TRANSPARENT_P2PKH_PREFIX: [u8; 2] = [0x1c, 0xb8]; +pub(crate) const TRANSPARENT_P2SH_PREFIX: [u8; 2] = [0x1c, 0xbd]; + +pub(super) fn script(address: &str) -> Result { + let payload = bs58::decode(address).with_check(None).into_vec().map_err(SignerError::from_display)?; + if payload.len() != 22 { + return Err(SignerError::from_display("invalid Zcash address")); + } + let hash = hash20(&payload[2..])?; + match [payload[0], payload[1]] { + TRANSPARENT_P2PKH_PREFIX => Ok(AddressScript::new(p2pkh_script(hash), LockingScript::P2pkh)), + TRANSPARENT_P2SH_PREFIX => Ok(AddressScript::new(p2sh_script(hash), LockingScript::P2sh)), + _ => Err(SignerError::from_display("unsupported Zcash address version")), + } +} diff --git a/crates/gem_bitcoin/src/signer/bitcoin_cash.rs b/crates/gem_bitcoin/src/signer/bitcoin_cash.rs new file mode 100644 index 000000000..bb923d9fc --- /dev/null +++ b/crates/gem_bitcoin/src/signer/bitcoin_cash.rs @@ -0,0 +1,65 @@ +use bitcoin::{ + PublicKey, Transaction, + blockdata::script::Builder, + consensus::encode::serialize, + secp256k1::{Message, Secp256k1, SecretKey, Signing}, +}; +use primitives::SignerError; + +use crate::{ + hash::double_sha256, + signer::{ + planner::SpendPlan, + transaction::{build_unsigned_transaction, der_signature, signature_push_bytes}, + }, +}; + +const SIGHASH_ALL_FORKID: u32 = 0x41; + +struct SighashComponents { + hash_prevouts: [u8; 32], + hash_sequence: [u8; 32], + hash_outputs: [u8; 32], +} + +impl SighashComponents { + fn new(tx: &Transaction, plan: &SpendPlan) -> Self { + Self { + hash_prevouts: double_sha256(&plan.inputs.iter().flat_map(|input| serialize(&input.previous_output)).collect::>()), + hash_sequence: double_sha256(&plan.inputs.iter().flat_map(|input| input.sequence.to_le_bytes()).collect::>()), + hash_outputs: double_sha256(&tx.output.iter().flat_map(serialize).collect::>()), + } + } +} + +pub(crate) fn sign_plan(plan: &SpendPlan, secret_key: &SecretKey, public_key: &PublicKey, secp: &Secp256k1) -> Result { + let mut tx = build_unsigned_transaction(plan); + let components = SighashComponents::new(&tx, plan); + for (index, _) in plan.inputs.iter().enumerate() { + let sighash = signature_hash(&tx, plan, &components, index)?; + let signature = der_signature(secp, secret_key, Message::from_digest(sighash), SIGHASH_ALL_FORKID as u8); + tx.input[index].script_sig = Builder::new().push_slice(signature_push_bytes(signature)?).push_key(public_key).into_script(); + } + Ok(tx) +} + +fn signature_hash(tx: &Transaction, plan: &SpendPlan, components: &SighashComponents, input_index: usize) -> Result<[u8; 32], SignerError> { + let input = plan + .inputs + .get(input_index) + .ok_or_else(|| SignerError::signing_error("Bitcoin Cash input index out of bounds"))?; + + let mut preimage = Vec::new(); + preimage.extend_from_slice(&tx.version.0.to_le_bytes()); + preimage.extend_from_slice(&components.hash_prevouts); + preimage.extend_from_slice(&components.hash_sequence); + preimage.extend_from_slice(&serialize(&input.previous_output)); + preimage.extend_from_slice(&serialize(input.script_pubkey.as_script())); + preimage.extend_from_slice(&input.value.to_sat().to_le_bytes()); + preimage.extend_from_slice(&input.sequence.to_le_bytes()); + preimage.extend_from_slice(&components.hash_outputs); + preimage.extend_from_slice(&tx.lock_time.to_consensus_u32().to_le_bytes()); + preimage.extend_from_slice(&SIGHASH_ALL_FORKID.to_le_bytes()); + + Ok(double_sha256(&preimage)) +} diff --git a/crates/gem_bitcoin/src/signer/chain_signer.rs b/crates/gem_bitcoin/src/signer/chain_signer.rs new file mode 100644 index 000000000..04fc84331 --- /dev/null +++ b/crates/gem_bitcoin/src/signer/chain_signer.rs @@ -0,0 +1,48 @@ +use primitives::{BitcoinChain, ChainSigner, SignerError, SignerInput}; + +use crate::signer::{ + planner::{SpendPlan, SpendRequest, UtxoPlanner}, + transaction::sign_plan, + zcash::branch_id_from_metadata, +}; + +pub struct BitcoinChainSigner { + chain: BitcoinChain, + replace_by_fee: bool, +} + +impl BitcoinChainSigner { + pub fn new(chain: BitcoinChain) -> Self { + Self::new_with_rbf(chain, false) + } + + pub fn new_with_rbf(chain: BitcoinChain, replace_by_fee: bool) -> Self { + Self { chain, replace_by_fee } + } + + fn sign_request(&self, request: SpendRequest, private_key: &[u8], zcash_branch_id: Option) -> Result { + let plan: SpendPlan = UtxoPlanner::plan(request)?; + sign_plan(self.chain, &plan, private_key, zcash_branch_id) + } + + fn zcash_branch_id(&self, input: &SignerInput) -> Result, SignerError> { + match self.chain { + BitcoinChain::Zcash => Ok(Some(branch_id_from_metadata(&input.metadata)?)), + BitcoinChain::Bitcoin | BitcoinChain::BitcoinCash | BitcoinChain::Litecoin | BitcoinChain::Doge => Ok(None), + } + } +} + +impl ChainSigner for BitcoinChainSigner { + fn sign_transfer(&self, input: &SignerInput, private_key: &[u8]) -> Result { + self.sign_request(SpendRequest::transfer(self.chain, input, self.replace_by_fee)?, private_key, self.zcash_branch_id(input)?) + } + + fn sign_swap(&self, input: &SignerInput, private_key: &[u8]) -> Result, SignerError> { + Ok(vec![self.sign_request( + SpendRequest::swap(self.chain, input, self.replace_by_fee)?, + private_key, + self.zcash_branch_id(input)?, + )?]) + } +} diff --git a/crates/gem_bitcoin/src/signer/encoding.rs b/crates/gem_bitcoin/src/signer/encoding.rs index d60a813fc..4709f10ac 100644 --- a/crates/gem_bitcoin/src/signer/encoding.rs +++ b/crates/gem_bitcoin/src/signer/encoding.rs @@ -13,24 +13,32 @@ pub fn encode_varint(n: usize) -> Vec { } } +pub(crate) fn varint_len(value: usize) -> usize { + match value { + 0..=0xfc => 1, + 0xfd..=0xffff => 3, + 0x1_0000..=0xffff_ffff => 5, + _ => 9, + } +} + #[cfg(test)] mod tests { use super::*; #[test] - fn test_encode_varint_small() { + fn test_encode_varint() { assert_eq!(encode_varint(0), vec![0]); assert_eq!(encode_varint(252), vec![252]); - } - - #[test] - fn test_encode_varint_medium() { assert_eq!(encode_varint(253), vec![0xfd, 253, 0]); assert_eq!(encode_varint(0xffff), vec![0xfd, 0xff, 0xff]); - } - - #[test] - fn test_encode_varint_large() { assert_eq!(encode_varint(0x10000), vec![0xfe, 0, 0, 1, 0]); + assert_eq!(varint_len(0), 1); + assert_eq!(varint_len(0xfc), 1); + assert_eq!(varint_len(0xfd), 3); + assert_eq!(varint_len(0xffff), 3); + assert_eq!(varint_len(0x1_0000), 5); + assert_eq!(varint_len(0xffff_ffff), 5); + assert_eq!(varint_len(0x1_0000_0000), 9); } } diff --git a/crates/gem_bitcoin/src/signer/mod.rs b/crates/gem_bitcoin/src/signer/mod.rs index 56171d6cf..e91e898ad 100644 --- a/crates/gem_bitcoin/src/signer/mod.rs +++ b/crates/gem_bitcoin/src/signer/mod.rs @@ -1,6 +1,247 @@ +pub(crate) mod address; +mod bitcoin_cash; +mod chain_signer; mod encoding; +mod planner; mod signature; +mod transaction; mod types; +mod zcash; +#[cfg(feature = "rpc")] +use std::collections::HashMap; + +#[cfg(feature = "rpc")] +use num_bigint::BigInt; +#[cfg(feature = "rpc")] +use primitives::{BitcoinChain, SignerError, SignerInput, TransactionFee, TransactionInputType, TransactionLoadInput}; + +pub use chain_signer::BitcoinChainSigner; +#[cfg(test)] +pub(crate) use planner::PlanInput; pub use signature::sign_personal; pub use types::{BitcoinSignDataResponse, BitcoinSignMessageData}; + +#[cfg(feature = "rpc")] +pub(crate) fn estimate_transaction_fee(chain: BitcoinChain, input: &TransactionLoadInput) -> Result { + let signer_input = SignerInput::new(input.clone(), input.default_fee()); + let request = match &input.input_type { + TransactionInputType::Transfer(_) => planner::SpendRequest::transfer(chain, &signer_input, false)?, + TransactionInputType::Swap(_, _, _) => planner::SpendRequest::swap(chain, &signer_input, false)?, + _ => return SignerError::invalid_input_err("unsupported Bitcoin transaction type"), + }; + let plan = planner::UtxoPlanner::plan(request)?; + + Ok(TransactionFee { + fee: BigInt::from(plan.fee), + gas_price_type: input.gas_price.clone(), + gas_limit: BigInt::from(1u8), + options: HashMap::new(), + }) +} + +#[cfg(test)] +mod tests { + use bitcoin::consensus::encode::deserialize; + use primitives::{BitcoinChain, ChainSigner, SignerInput, SwapProvider, decode_hex}; + + use super::{BitcoinChainSigner, address::script_for_address}; + use crate::testkit::signer_mock::{ + TEST_PRIVATE_KEY, contract_swap_input, contract_swap_input_with_provider, funded_transfer_input, p2wpkh_contract_swap_input, p2wpkh_transfer_input, transfer_input, + transfer_swap_input, + }; + + const CHAINFLIP_NULLDATA_HEX: &str = "deadbeef001122"; + + fn sign_transfer(chain: BitcoinChain) -> String { + BitcoinChainSigner::new(chain).sign_transfer(&transfer_input(chain), &TEST_PRIVATE_KEY).unwrap() + } + + fn assert_op_return_payload(script: &bitcoin::ScriptBuf, payload: &[u8]) { + let bytes = script.as_bytes(); + assert_eq!(bytes[0], 0x6a); + assert_eq!(bytes[1] as usize, payload.len()); + assert_eq!(&bytes[2..], payload); + } + + fn sign_contract_swap(input: &SignerInput) -> bitcoin::Transaction { + let raw = BitcoinChainSigner::new(BitcoinChain::Bitcoin).sign_swap(input, &TEST_PRIVATE_KEY).unwrap().remove(0); + deserialize(&hex::decode(raw).unwrap()).unwrap() + } + + fn sender_script(input: &SignerInput) -> bitcoin::ScriptBuf { + script_for_address(BitcoinChain::Bitcoin, &input.sender_address).unwrap().script_pubkey + } + + #[test] + fn test_sign_transfer_bitcoin() { + let raw = sign_transfer(BitcoinChain::Bitcoin); + let transaction: bitcoin::Transaction = deserialize(&hex::decode(&raw).unwrap()).unwrap(); + let script = transaction.input[0].script_sig.as_bytes(); + let signature_len = script[0] as usize; + assert_eq!(transaction.input.len(), 1); + assert_eq!(transaction.output[0].value.to_sat(), 10_000); + assert_eq!(script[signature_len], 0x01); + } + + #[test] + fn test_sign_transfer_doge() { + let input = funded_transfer_input(BitcoinChain::Doge); + let raw = BitcoinChainSigner::new(BitcoinChain::Doge).sign_transfer(&input, &TEST_PRIVATE_KEY).unwrap(); + let transaction: bitcoin::Transaction = deserialize(&hex::decode(raw).unwrap()).unwrap(); + let script = transaction.input[0].script_sig.as_bytes(); + let signature_len = script[0] as usize; + assert_eq!(transaction.input.len(), 1); + assert_eq!(transaction.output[0].value.to_sat(), 10_000); + assert_eq!(script[signature_len], 0x01); + } + + #[test] + fn test_sign_transfer_bitcoin_cash() { + let raw = sign_transfer(BitcoinChain::BitcoinCash); + let transaction: bitcoin::Transaction = deserialize(&hex::decode(raw).unwrap()).unwrap(); + let script = transaction.input[0].script_sig.as_bytes(); + let signature_len = script[0] as usize; + assert_eq!(transaction.input.len(), 1); + assert_eq!(transaction.output[0].value.to_sat(), 10_000); + assert_eq!(script[signature_len], 0x41); + } + + #[test] + fn test_signed_tx_is_rbf_signaled() { + let raw = BitcoinChainSigner::new_with_rbf(BitcoinChain::Bitcoin, true) + .sign_transfer(&transfer_input(BitcoinChain::Bitcoin), &TEST_PRIVATE_KEY) + .unwrap(); + let transaction: bitcoin::Transaction = deserialize(&hex::decode(&raw).unwrap()).unwrap(); + + // RBF vector is checked against BitGoJS. + assert_eq!( + raw, + "02000000010100000000000000000000000000000000000000000000000000000000000000000000006b483045022100ef21c70ee59cd7ef09f5d12252ed3c1c4fa971b33740cf2c29b90ab15fc3e5ea02205761ce83d839043d38341344c499cd13cb2ed4e03ebd88c6cf38fc84415b1e290121031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078ffdffffff0210270000000000001976a91479b000887626b294a914501a4cd226b58b23598388ac5e9b0000000000001976a91479b000887626b294a914501a4cd226b58b23598388ac00000000" + ); + for input in &transaction.input { + assert_eq!(input.sequence.0, 0xffff_fffd); + } + } + + #[test] + fn test_sign_transfer_zcash() { + let raw = sign_transfer(BitcoinChain::Zcash); + let zcash_bytes = hex::decode(raw).unwrap(); + assert_eq!(&zcash_bytes[..20], hex::decode("050000800a27a726f04dec4d0000000000000000").unwrap().as_slice()); + } + + #[test] + fn test_sign_transfer_vectors() { + // Test vectors are checked against BitGoJS. + let cases = [ + ( + "bitcoin_p2pkh", + BitcoinChain::Bitcoin, + transfer_input(BitcoinChain::Bitcoin), + "02000000010100000000000000000000000000000000000000000000000000000000000000000000006b483045022100f48a53c3e59e90789a4ecb96c5fdcd65f7bbbd8f4952e0f5a3ac276f9ad304ce0220160105ec967f69ef5546a08015481d0f2691b1a84ec2869c299e8538cce9ea3d0121031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078fffffffff0210270000000000001976a91479b000887626b294a914501a4cd226b58b23598388ac5e9b0000000000001976a91479b000887626b294a914501a4cd226b58b23598388ac00000000", + ), + ( + "bitcoin_p2wpkh", + BitcoinChain::Bitcoin, + p2wpkh_transfer_input(), + "0200000000010101000000000000000000000000000000000000000000000000000000000000000000000000ffffffff02102700000000000016001479b000887626b294a914501a4cd226b58b235983b39b00000000000016001479b000887626b294a914501a4cd226b58b23598302463043022036b08705576633f37dbb750b31140652e29985018b2a439b570c825d8c341870021f1fec61b2b2e0ba9a9996a7ac38358bf7280837b9801e1143f461fa905b6d540121031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f00000000", + ), + ( + "litecoin", + BitcoinChain::Litecoin, + transfer_input(BitcoinChain::Litecoin), + "02000000010100000000000000000000000000000000000000000000000000000000000000000000006a47304402207ffec22eb23eaa93b4959624320b71600bb56aaaee45ae214d5e1d864e47365802206844539bcf3eaaa32f5d95211e6caf3f1bc93a8a364befdd5b51aee4573f97f30121031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078fffffffff0210270000000000001976a914020202020202020202020202020202020202020288acd6970000000000001976a91479b000887626b294a914501a4cd226b58b23598388ac00000000", + ), + ( + "doge", + BitcoinChain::Doge, + funded_transfer_input(BitcoinChain::Doge), + "02000000010100000000000000000000000000000000000000000000000000000000000000000000006a473044022076e879f42dcfcee85924d49aef75ecb3b7c50a50f6b9403b4a1192317580171902206f18320064953227d578ae0f073f55b454b1d1991b54058d9884d59ebb815f0b0121031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078fffffffff0210270000000000001976a914020202020202020202020202020202020202020288ac2047f205000000001976a91479b000887626b294a914501a4cd226b58b23598388ac00000000", + ), + ( + "bitcoin_cash", + BitcoinChain::BitcoinCash, + transfer_input(BitcoinChain::BitcoinCash), + "02000000010100000000000000000000000000000000000000000000000000000000000000000000006a47304402200433aef908cbbd4b65aec9dde055a94ba782bfb08a9451aa7bcdd10824345f3102207012a0573436b0d2e91a89b8cbd3f6c612bbcffb01a52d3fdeec9d51401ff7d34121031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078fffffffff0210270000000000001976a914020202020202020202020202020202020202020288acd6970000000000001976a91479b000887626b294a914501a4cd226b58b23598388ac00000000", + ), + ( + "zcash", + BitcoinChain::Zcash, + transfer_input(BitcoinChain::Zcash), + "050000800a27a726f04dec4d0000000000000000010100000000000000000000000000000000000000000000000000000000000000000000006a473044022004354fd389558909b1ccfb05dd2f3c324423efcad18eb50a42cb65ebdd17513d02207dac81ff91f4108b4017329e55971c2bd6a213cf925886b95513519bd806fd9a0121031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078fffffffff0210270000000000001976a914030303030303030303030303030303030303030388ac30750000000000001976a91479b000887626b294a914501a4cd226b58b23598388ac000000", + ), + ]; + + for (name, chain, input, expected) in cases { + let raw = BitcoinChainSigner::new(chain).sign_transfer(&input, &TEST_PRIVATE_KEY).unwrap(); + assert_eq!(raw, expected, "{name}"); + } + } + + #[test] + fn test_sign_swap_memo_op_return() { + let memo = "=:s:0xEe7E9CcFb529f2c1Cc02C0Aea8aCed7Ec7e98B5e:0/1/0:g1:50"; + let input = transfer_swap_input(BitcoinChain::Doge, memo); + let raw = BitcoinChainSigner::new(BitcoinChain::Doge).sign_swap(&input, &TEST_PRIVATE_KEY).unwrap().remove(0); + let transaction: bitcoin::Transaction = deserialize(&hex::decode(raw).unwrap()).unwrap(); + let memo_output = transaction.output.iter().find(|output| output.script_pubkey.is_op_return()).unwrap(); + assert_eq!(memo_output.value.to_sat(), 0); + assert_op_return_payload(&memo_output.script_pubkey, memo.as_bytes()); + } + + #[test] + fn test_sign_chainflip_bitcoin_max_intent_produces_change_for_refund() { + let nulldata = decode_hex(CHAINFLIP_NULLDATA_HEX).unwrap(); + let input = p2wpkh_contract_swap_input(CHAINFLIP_NULLDATA_HEX, true); + let refund_script = sender_script(&input); + let transaction = sign_contract_swap(&input); + + assert_eq!(transaction.output.len(), 3); + assert_eq!(transaction.output[0].value.to_sat(), 10_000); + assert_eq!(transaction.output[1].value.to_sat(), 0); + assert_op_return_payload(&transaction.output[1].script_pubkey, &nulldata); + assert_eq!(transaction.output[2].value.to_sat(), 99_989_838); + assert_eq!(transaction.output[2].script_pubkey, refund_script); + } + + #[test] + fn test_sign_chainflip_bitcoin_exact_swap_with_change() { + let nulldata = decode_hex(CHAINFLIP_NULLDATA_HEX).unwrap(); + let input = contract_swap_input(BitcoinChain::Bitcoin, CHAINFLIP_NULLDATA_HEX, false); + let transaction = sign_contract_swap(&input); + + assert_eq!(transaction.output.len(), 3); + assert_eq!(transaction.output[0].value.to_sat(), 10_000); + assert_eq!(transaction.output[1].value.to_sat(), 0); + assert_op_return_payload(&transaction.output[1].script_pubkey, &nulldata); + assert_eq!(transaction.output[2].value.to_sat(), 99_989_756); + } + + #[test] + fn test_chainflip_contract_swap_always_has_refund_output() { + let nulldata = decode_hex(CHAINFLIP_NULLDATA_HEX).unwrap(); + for use_max_amount in [true, false] { + let input = contract_swap_input(BitcoinChain::Bitcoin, CHAINFLIP_NULLDATA_HEX, use_max_amount); + let refund_script = sender_script(&input); + let transaction = sign_contract_swap(&input); + + assert_eq!(transaction.output.len(), 3, "{use_max_amount}"); + assert_eq!(transaction.output[1].value.to_sat(), 0, "{use_max_amount}"); + assert_op_return_payload(&transaction.output[1].script_pubkey, &nulldata); + assert_eq!(transaction.output[2].script_pubkey, refund_script, "{use_max_amount}"); + } + } + + #[test] + fn test_non_chainflip_contract_swap_honors_max_flag() { + let nulldata = decode_hex(CHAINFLIP_NULLDATA_HEX).unwrap(); + let input = contract_swap_input_with_provider(BitcoinChain::Bitcoin, CHAINFLIP_NULLDATA_HEX, true, SwapProvider::Thorchain); + let transaction = sign_contract_swap(&input); + + assert_eq!(transaction.output.len(), 2); + assert_eq!(transaction.output[0].value.to_sat(), 100_000_000 - 210); + assert_eq!(transaction.output[1].value.to_sat(), 0); + assert_op_return_payload(&transaction.output[1].script_pubkey, &nulldata); + } +} diff --git a/crates/gem_bitcoin/src/signer/planner/fee.rs b/crates/gem_bitcoin/src/signer/planner/fee.rs new file mode 100644 index 000000000..7d44223b5 --- /dev/null +++ b/crates/gem_bitcoin/src/signer/planner/fee.rs @@ -0,0 +1,110 @@ +use primitives::{BitcoinChain, SignerError}; + +use super::{PlanInput, PlanOutput}; +use crate::signer::{address::UnlockingScript, encoding::varint_len}; + +const WITNESS_SCALE_FACTOR: u64 = 4; +const TX_FIXED_BYTES: u64 = 8; +const SEGWIT_MARKER_FLAG_WEIGHT: u64 = 2; +const P2PKH_INPUT_BYTES: u64 = 148; +const P2WPKH_INPUT_BASE_BYTES: u64 = 41; +const P2WPKH_INPUT_WITNESS_BYTES: u64 = 108; + +pub(super) fn estimate_fee(chain: BitcoinChain, inputs: &[PlanInput], outputs: &[PlanOutput], fee_rate: u64) -> Result { + match chain { + BitcoinChain::Zcash => return estimate_zcash_fee(inputs, outputs), + BitcoinChain::Bitcoin | BitcoinChain::BitcoinCash | BitcoinChain::Litecoin | BitcoinChain::Doge => {} + } + + let has_witness = inputs.iter().any(|input| match input.unlocking_script { + UnlockingScript::P2wpkh => true, + UnlockingScript::P2pkh => false, + }); + let input_weight = inputs.iter().try_fold(0u64, |sum, input| { + sum.checked_add(input_weight(input)).ok_or_else(|| SignerError::invalid_input("Bitcoin fee overflow")) + })?; + let output_weight = outputs.iter().try_fold(0u64, |sum, output| { + sum.checked_add(output.serialized_len() * WITNESS_SCALE_FACTOR) + .ok_or_else(|| SignerError::invalid_input("Bitcoin fee overflow")) + })?; + transaction_base_weight(inputs.len(), outputs.len(), has_witness)? + .checked_add(input_weight) + .and_then(|value| value.checked_add(output_weight)) + .map(|weight| weight.div_ceil(WITNESS_SCALE_FACTOR)) + .and_then(|vbytes| vbytes.checked_mul(fee_rate)) + .ok_or_else(|| SignerError::invalid_input("Bitcoin fee overflow")) +} + +fn transaction_base_weight(input_count: usize, output_count: usize, has_witness: bool) -> Result { + let base_bytes = TX_FIXED_BYTES + .checked_add(varint_len(input_count) as u64) + .and_then(|value| value.checked_add(varint_len(output_count) as u64)) + .ok_or_else(|| SignerError::invalid_input("Bitcoin fee overflow"))?; + let witness_weight = if has_witness { SEGWIT_MARKER_FLAG_WEIGHT } else { 0 }; + base_bytes + .checked_mul(WITNESS_SCALE_FACTOR) + .and_then(|value| value.checked_add(witness_weight)) + .ok_or_else(|| SignerError::invalid_input("Bitcoin fee overflow")) +} + +fn input_weight(input: &PlanInput) -> u64 { + match input.unlocking_script { + UnlockingScript::P2pkh => P2PKH_INPUT_BYTES * WITNESS_SCALE_FACTOR, + UnlockingScript::P2wpkh => P2WPKH_INPUT_BASE_BYTES * WITNESS_SCALE_FACTOR + P2WPKH_INPUT_WITNESS_BYTES, + } +} + +fn estimate_zcash_fee(inputs: &[PlanInput], outputs: &[PlanOutput]) -> Result { + const MARGINAL_FEE: u64 = 5_000; + const GRACE_ACTIONS: u64 = 2; + const P2PKH_STANDARD_INPUT_SIZE: u64 = 150; + const P2PKH_STANDARD_OUTPUT_SIZE: u64 = 34; + + let tx_in_total_size = inputs + .len() + .checked_mul(P2PKH_STANDARD_INPUT_SIZE as usize) + .and_then(|value| u64::try_from(value).ok()) + .ok_or_else(|| SignerError::invalid_input("Zcash fee overflow"))?; + let tx_out_total_size = outputs.iter().try_fold(0u64, |sum, output| { + sum.checked_add(output.serialized_len()).ok_or_else(|| SignerError::invalid_input("Zcash fee overflow")) + })?; + let input_actions = tx_in_total_size.div_ceil(P2PKH_STANDARD_INPUT_SIZE); + let output_actions = tx_out_total_size.div_ceil(P2PKH_STANDARD_OUTPUT_SIZE); + let logical_actions = input_actions.max(output_actions); + MARGINAL_FEE + .checked_mul(GRACE_ACTIONS.max(logical_actions)) + .ok_or_else(|| SignerError::invalid_input("Zcash fee overflow")) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{signer::address::script_for_public_key_hash, testkit::planner_mock::op_return_script}; + + #[test] + fn test_estimate_fee() { + let legacy_inputs = vec![PlanInput::mock_with_unlocking_script(UnlockingScript::P2pkh)]; + let legacy_outputs = vec![ + PlanOutput::new(10_000, script_for_public_key_hash(UnlockingScript::P2pkh, [0u8; 20])), + PlanOutput::new(20_000, script_for_public_key_hash(UnlockingScript::P2pkh, [0u8; 20])), + ]; + assert_eq!(estimate_fee(BitcoinChain::Bitcoin, &legacy_inputs, &legacy_outputs, 2).unwrap(), 452); + + let segwit_inputs = vec![PlanInput::mock_with_unlocking_script(UnlockingScript::P2wpkh)]; + let segwit_outputs = vec![PlanOutput::new(10_000, script_for_public_key_hash(UnlockingScript::P2wpkh, [0u8; 20]))]; + assert_eq!(estimate_fee(BitcoinChain::Bitcoin, &segwit_inputs, &segwit_outputs, 3).unwrap(), 330); + + let zcash_outputs = vec![PlanOutput::new(10_000, script_for_public_key_hash(UnlockingScript::P2pkh, [0u8; 20]))]; + assert_eq!(estimate_fee(BitcoinChain::Zcash, &legacy_inputs, &zcash_outputs, 100).unwrap(), 10_000); + + let zcash_inputs = vec![ + PlanInput::mock_with_unlocking_script(UnlockingScript::P2pkh), + PlanInput::mock_with_unlocking_script(UnlockingScript::P2pkh), + PlanInput::mock_with_unlocking_script(UnlockingScript::P2pkh), + ]; + assert_eq!(estimate_fee(BitcoinChain::Zcash, &zcash_inputs, &zcash_outputs, 100).unwrap(), 15_000); + + let zcash_large_output = vec![PlanOutput::new(0, op_return_script(80))]; + assert_eq!(estimate_fee(BitcoinChain::Zcash, &legacy_inputs, &zcash_large_output, 100).unwrap(), 15_000); + } +} diff --git a/crates/gem_bitcoin/src/signer/planner/inputs.rs b/crates/gem_bitcoin/src/signer/planner/inputs.rs new file mode 100644 index 000000000..6a37de176 --- /dev/null +++ b/crates/gem_bitcoin/src/signer/planner/inputs.rs @@ -0,0 +1,102 @@ +use std::str::FromStr; + +use bitcoin::{Amount, OutPoint, Txid}; +use primitives::{BitcoinChain, SignerError, UTXO}; + +use super::PlanInput; +use crate::signer::address::script_for_address; + +const FINAL_SEQUENCE: u32 = 0xffff_ffff; +const RBF_SEQUENCE: u32 = 0xffff_fffd; + +pub(super) fn spendable_inputs(chain: BitcoinChain, sender_address: &str, utxos: Vec, replace_by_fee: bool) -> Result, SignerError> { + let sender = script_for_address(chain, sender_address)?; + let sender_hash = sender + .unlocking_script() + .zip(sender.public_key_hash()) + .map(|(_, public_key_hash)| public_key_hash) + .ok_or_else(|| SignerError::invalid_input(format!("{} sender address type is unsupported", chain.get_chain())))?; + let sequence = match chain { + BitcoinChain::Bitcoin | BitcoinChain::BitcoinCash | BitcoinChain::Litecoin | BitcoinChain::Doge if replace_by_fee => RBF_SEQUENCE, + BitcoinChain::Bitcoin | BitcoinChain::BitcoinCash | BitcoinChain::Litecoin | BitcoinChain::Doge | BitcoinChain::Zcash => FINAL_SEQUENCE, + }; + let mut inputs = Vec::with_capacity(utxos.len()); + + for utxo in utxos { + let address = script_for_address(chain, &utxo.address)?; + let (unlocking_script, public_key_hash) = address + .unlocking_script() + .zip(address.public_key_hash()) + .ok_or_else(|| SignerError::invalid_input(format!("{} UTXO address type is unsupported", chain.get_chain())))?; + (public_key_hash == sender_hash) + .then_some(()) + .ok_or_else(|| SignerError::invalid_input(format!("{} UTXO address does not match sender address", chain.get_chain())))?; + let value = utxo + .value + .parse::() + .map_err(|_| SignerError::invalid_input(format!("invalid {} UTXO amount", chain.get_chain())))?; + (value != 0) + .then_some(()) + .ok_or_else(|| SignerError::invalid_input(format!("invalid {} UTXO amount", chain.get_chain())))?; + let vout = u32::try_from(utxo.vout).map_err(|_| SignerError::invalid_input(format!("invalid {} UTXO output index", chain.get_chain())))?; + let txid = Txid::from_str(&utxo.transaction_id).map_err(|_| SignerError::invalid_input(format!("invalid {} UTXO transaction id", chain.get_chain())))?; + inputs.push(PlanInput { + previous_output: OutPoint::new(txid, vout), + value: Amount::from_sat(value), + script_pubkey: address.script_pubkey, + unlocking_script, + sequence, + }); + } + + Ok(inputs) +} + +#[cfg(test)] +mod tests { + use crate::{ + signer::address::UnlockingScript, + testkit::{ + address_mock::{TEST_BITCOIN_P2WPKH_ADDRESS, TEST_BITCOIN_P2WPKH_HASH, prefixed_address}, + planner_mock::assert_invalid_input, + signer_mock::utxo_with_address, + }, + }; + + use super::*; + + const TAPROOT_ADDRESS: &str = "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr"; + + #[test] + fn test_spendable_inputs_address_type_validation() { + let legacy_address = prefixed_address(&[0], TEST_BITCOIN_P2WPKH_HASH); + let inputs = spendable_inputs( + BitcoinChain::Bitcoin, + TEST_BITCOIN_P2WPKH_ADDRESS, + vec![utxo_with_address(&legacy_address), utxo_with_address(TEST_BITCOIN_P2WPKH_ADDRESS)], + false, + ) + .unwrap(); + assert_eq!(inputs[0].unlocking_script, UnlockingScript::P2pkh); + assert_eq!(inputs[1].unlocking_script, UnlockingScript::P2wpkh); + + let different_legacy_address = prefixed_address(&[0], [9u8; 20]); + assert_invalid_input( + spendable_inputs( + BitcoinChain::Bitcoin, + TEST_BITCOIN_P2WPKH_ADDRESS, + vec![utxo_with_address(&different_legacy_address)], + false, + ), + "bitcoin UTXO address does not match sender address", + ); + assert_invalid_input( + spendable_inputs(BitcoinChain::Bitcoin, TEST_BITCOIN_P2WPKH_ADDRESS, vec![utxo_with_address(TAPROOT_ADDRESS)], false), + "bitcoin UTXO address type is unsupported", + ); + assert_invalid_input( + spendable_inputs(BitcoinChain::Bitcoin, TAPROOT_ADDRESS, vec![utxo_with_address(TEST_BITCOIN_P2WPKH_ADDRESS)], false), + "bitcoin sender address type is unsupported", + ); + } +} diff --git a/crates/gem_bitcoin/src/signer/planner/mod.rs b/crates/gem_bitcoin/src/signer/planner/mod.rs new file mode 100644 index 000000000..b3bcd0a79 --- /dev/null +++ b/crates/gem_bitcoin/src/signer/planner/mod.rs @@ -0,0 +1,12 @@ +mod fee; +mod inputs; +mod outputs; +mod request; +mod spend; +mod types; + +pub(crate) use self::{ + request::SpendRequest, + spend::UtxoPlanner, + types::{PlanInput, PlanOutput, SpendPlan}, +}; diff --git a/crates/gem_bitcoin/src/signer/planner/outputs.rs b/crates/gem_bitcoin/src/signer/planner/outputs.rs new file mode 100644 index 000000000..9580e4870 --- /dev/null +++ b/crates/gem_bitcoin/src/signer/planner/outputs.rs @@ -0,0 +1,35 @@ +use bitcoin::{ + ScriptBuf, + blockdata::{opcodes::all::OP_RETURN, script::Builder}, + script::PushBytesBuf, +}; +use primitives::SignerError; + +use super::PlanOutput; + +const MAX_OP_RETURN_BYTES: usize = 80; + +// OP_RETURN is placed at output index 1 (after the destination): required by Chainflip's +// vault-swap scanner, accepted by Thorchain which scans all outputs. +pub(super) fn spend_outputs(amount: u64, payment_script: ScriptBuf, memo_output: Option) -> Vec { + let mut outputs = vec![PlanOutput::new(amount, payment_script)]; + if let Some(output) = memo_output { + outputs.push(output); + } + outputs +} + +pub(super) fn op_return_output(data: &[u8]) -> Result { + if data.len() > MAX_OP_RETURN_BYTES { + return SignerError::invalid_input_err("Bitcoin memo is too large"); + } + let push = PushBytesBuf::try_from(data.to_vec()).map_err(|_| SignerError::invalid_input("Bitcoin memo is too large"))?; + Ok(PlanOutput::new(0, Builder::new().push_opcode(OP_RETURN).push_slice(push).into_script())) +} + +pub(super) fn dust_threshold(script_pubkey: &ScriptBuf) -> u64 { + if script_pubkey.is_op_return() { + return 0; + } + 546 +} diff --git a/crates/gem_bitcoin/src/signer/planner/request.rs b/crates/gem_bitcoin/src/signer/planner/request.rs new file mode 100644 index 000000000..1ff54d8f9 --- /dev/null +++ b/crates/gem_bitcoin/src/signer/planner/request.rs @@ -0,0 +1,90 @@ +use primitives::{Asset, BitcoinChain, SignerError, SignerInput, SwapProvider, TransactionInputType, TransactionLoadMetadata, UTXO, decode_hex, swap::SwapQuoteDataType}; + +#[derive(Debug, Clone)] +pub(crate) struct SpendRequest { + pub(crate) chain: BitcoinChain, + pub(crate) sender_address: String, + pub(crate) destination_address: String, + pub(crate) amount: u64, + pub(crate) is_max: bool, + pub(crate) replace_by_fee: bool, + pub(crate) fee_rate: u64, + pub(crate) memo: Option>, + pub(crate) utxos: Vec, +} + +impl SpendRequest { + pub(crate) fn transfer(chain: BitcoinChain, input: &SignerInput, replace_by_fee: bool) -> Result { + let asset = match &input.input_type { + TransactionInputType::Transfer(asset) => asset, + _ => return SignerError::invalid_input_err("unsupported Bitcoin transaction type"), + }; + validate_native_chain_asset(chain, asset, "unsupported Bitcoin asset transfer")?; + + Ok(Self { + chain, + sender_address: input.sender_address.clone(), + destination_address: input.destination_address.clone(), + amount: input.value_as_u64()?, + is_max: input.is_max_value, + replace_by_fee, + fee_rate: spend_fee_rate(chain, input)?, + memo: input.get_memo().map(|memo| memo.as_bytes().to_vec()), + utxos: metadata_utxos(chain, &input.metadata)?, + }) + } + + pub(crate) fn swap(chain: BitcoinChain, input: &SignerInput, replace_by_fee: bool) -> Result { + let swap = input + .input_type + .get_swap_data() + .map_err(|_| SignerError::invalid_input("unsupported Bitcoin transaction type"))?; + validate_native_chain_asset(chain, input.input_type.get_asset(), "unsupported Bitcoin swap asset")?; + let memo = match &swap.data.data_type { + SwapQuoteDataType::Transfer => swap.data.memo.as_ref().map(|memo| memo.as_bytes().to_vec()), + SwapQuoteDataType::Contract => Some(decode_hex(&swap.data.data)?), + }; + let is_max = match (&swap.data.data_type, swap.quote.provider_data.provider, swap.quote.use_max_amount) { + // Chainflip vault swaps require a third change output as the refund address. + (SwapQuoteDataType::Contract, SwapProvider::Chainflip, _) => false, + (SwapQuoteDataType::Contract | SwapQuoteDataType::Transfer, _, Some(use_max)) => use_max, + (SwapQuoteDataType::Contract | SwapQuoteDataType::Transfer, _, None) => input.is_max_value, + }; + + Ok(Self { + chain, + sender_address: input.sender_address.clone(), + destination_address: swap.data.to.clone(), + amount: swap + .data + .value + .parse::() + .map_err(|_| SignerError::invalid_input(format!("invalid {} swap amount", chain.get_chain())))?, + is_max, + replace_by_fee, + fee_rate: spend_fee_rate(chain, input)?, + memo, + utxos: metadata_utxos(chain, &input.metadata)?, + }) + } +} + +fn metadata_utxos(chain: BitcoinChain, metadata: &TransactionLoadMetadata) -> Result, SignerError> { + match (chain, metadata) { + (BitcoinChain::Zcash, TransactionLoadMetadata::Zcash { utxos, .. }) => Ok(utxos.clone()), + (BitcoinChain::Zcash, _) => SignerError::invalid_input_err("missing Zcash transaction metadata"), + (_, TransactionLoadMetadata::Bitcoin { utxos }) => Ok(utxos.clone()), + _ => SignerError::invalid_input_err("missing Bitcoin transaction metadata"), + } +} + +fn spend_fee_rate(chain: BitcoinChain, input: &SignerInput) -> Result { + let minimum_fee_rate = u64::try_from(chain.minimum_byte_fee()).map_err(|_| SignerError::invalid_input(format!("invalid {} minimum fee", chain.get_chain())))?; + Ok(input.fee.gas_price_u64()?.max(minimum_fee_rate)) +} + +fn validate_native_chain_asset(chain: BitcoinChain, asset: &Asset, message: &'static str) -> Result<(), SignerError> { + (asset.id.chain == chain.get_chain() && asset.id.is_native()) + .then_some(()) + .ok_or_else(|| SignerError::invalid_input(message)) +} diff --git a/crates/gem_bitcoin/src/signer/planner/spend.rs b/crates/gem_bitcoin/src/signer/planner/spend.rs new file mode 100644 index 000000000..62e974233 --- /dev/null +++ b/crates/gem_bitcoin/src/signer/planner/spend.rs @@ -0,0 +1,232 @@ +use bitcoin::ScriptBuf; +use primitives::{BitcoinChain, SignerError}; + +use super::{ + PlanInput, PlanOutput, SpendPlan, SpendRequest, + fee::estimate_fee, + inputs::spendable_inputs, + outputs::{dust_threshold, op_return_output, spend_outputs}, +}; +use crate::signer::address::script_for_address; + +pub(crate) struct UtxoPlanner; + +#[derive(Debug, Clone, Copy)] +enum SpendTarget { + Exact(u64), + Max, +} + +impl UtxoPlanner { + pub(crate) fn plan(request: SpendRequest) -> Result { + if request.utxos.is_empty() { + return SignerError::invalid_input_err("missing input UTXOs"); + } + if !request.is_max && request.amount == 0 { + return SignerError::invalid_input_err("invalid transaction amount"); + } + + let payment_script = script_for_address(request.chain, &request.destination_address)?.script_pubkey; + if !request.is_max && request.amount < dust_threshold(&payment_script) { + return SignerError::invalid_input_err("invalid transaction amount"); + } + + let change_script = script_for_address(request.chain, &request.sender_address)?.script_pubkey; + let memo_output = request.memo.as_deref().map(op_return_output).transpose()?; + let spendable_inputs = spendable_inputs(request.chain, &request.sender_address, request.utxos, request.replace_by_fee)?; + let target = if request.is_max { SpendTarget::Max } else { SpendTarget::Exact(request.amount) }; + Self::select_inputs_and_build_plan(request.chain, target, request.fee_rate, payment_script, change_script, memo_output, spendable_inputs) + } + + fn select_inputs_and_build_plan( + chain: BitcoinChain, + target: SpendTarget, + fee_rate: u64, + payment_script: ScriptBuf, + change_script: ScriptBuf, + memo_output: Option, + mut spendable_inputs: Vec, + ) -> Result { + match target { + SpendTarget::Exact(_) => { + spendable_inputs.sort_by(|left, right| { + left.value + .to_sat() + .cmp(&right.value.to_sat()) + .then_with(|| left.previous_output.cmp(&right.previous_output)) + }); + } + SpendTarget::Max => {} + } + + let input_count = spendable_inputs.len(); + let mut selected = Vec::new(); + let mut selected_amount = 0u64; + for (index, candidate) in spendable_inputs.into_iter().enumerate() { + selected_amount = selected_amount + .checked_add(candidate.value.to_sat()) + .ok_or_else(|| SignerError::invalid_input("Bitcoin amount overflow"))?; + selected.push(candidate); + match target { + SpendTarget::Max if index + 1 < input_count => continue, + SpendTarget::Max | SpendTarget::Exact(_) => {} + } + + let plan = Self::try_build_plan(target, fee_rate, &payment_script, &change_script, &memo_output, &selected, selected_amount, chain)?; + if let Some(plan) = plan { + return Ok(plan); + } + } + + SignerError::invalid_input_err("insufficient balance") + } + + fn try_build_plan( + target: SpendTarget, + fee_rate: u64, + payment_script: &ScriptBuf, + change_script: &ScriptBuf, + memo_output: &Option, + selected: &[PlanInput], + selected_amount: u64, + chain: BitcoinChain, + ) -> Result, SignerError> { + let amount = match target { + SpendTarget::Exact(amount) => amount, + SpendTarget::Max => 0, + }; + let mut outputs = spend_outputs(amount, payment_script.clone(), memo_output.clone()); + + match target { + SpendTarget::Max => { + let fee = estimate_fee(chain, selected, &outputs, fee_rate)?; + // Max-send planning is single-pass because BTC-family and ZIP-317 fees + // depend on input/output scripts and counts, not the payment amount. + let amount = selected_amount.checked_sub(fee).ok_or_else(|| SignerError::invalid_input("insufficient balance"))?; + if amount == 0 { + return SignerError::invalid_input_err("insufficient balance"); + } + if amount < dust_threshold(payment_script) { + return SignerError::invalid_input_err("insufficient balance"); + } + + outputs[0].value = bitcoin::Amount::from_sat(amount); + return Ok(Some(SpendPlan { + inputs: selected.to_vec(), + outputs, + fee, + })); + } + SpendTarget::Exact(_) => {} + } + + let mut outputs_with_change = outputs.clone(); + outputs_with_change.push(PlanOutput::new(0, change_script.clone())); + + let fee_with_change = estimate_fee(chain, selected, &outputs_with_change, fee_rate)?; + let Some(remainder) = selected_amount.checked_sub(amount).and_then(|value| value.checked_sub(fee_with_change)) else { + return Ok(None); + }; + + if remainder >= dust_threshold(change_script) { + outputs.push(PlanOutput::new(remainder, change_script.clone())); + return Ok(Some(SpendPlan { + inputs: selected.to_vec(), + outputs, + fee: fee_with_change, + })); + } + + let fee_without_change = estimate_fee(chain, selected, &outputs, fee_rate)?; + let Some(remainder) = selected_amount.checked_sub(amount).and_then(|value| value.checked_sub(fee_without_change)) else { + return Ok(None); + }; + Ok(Some(SpendPlan { + inputs: selected.to_vec(), + outputs, + fee: fee_without_change + remainder, + })) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testkit::{ + address_mock::TEST_BITCOIN_P2WPKH_ADDRESS, + planner_mock::{assert_invalid_input, spend_signer_input, spend_signer_input_with, spend_utxos, sum_inputs}, + signer_mock::{TEST_UTXO_TXID, utxo_with}, + }; + + #[test] + fn test_plan_transfer() { + let request = SpendRequest::transfer(BitcoinChain::Bitcoin, &spend_signer_input("12000", false), false).unwrap(); + let plan = UtxoPlanner::plan(request).unwrap(); + assert_eq!(plan.inputs.len(), 2); + assert_eq!(plan.outputs.len(), 3); + assert_eq!(plan.outputs[0].value.to_sat(), 12_000); + assert_eq!(plan.outputs[1].value.to_sat(), 0); + assert!(plan.outputs[1].script_pubkey.is_op_return()); + assert_eq!( + sum_inputs(&plan.inputs).unwrap(), + plan.outputs.iter().map(|output| output.value.to_sat()).sum::() + plan.fee + ); + assert_eq!(plan.fee, 454); + + let request = SpendRequest::transfer(BitcoinChain::Bitcoin, &spend_signer_input("9300", false), false).unwrap(); + let plan = UtxoPlanner::plan(request).unwrap(); + assert_eq!(plan.inputs.len(), 1); + assert_eq!(plan.outputs.len(), 2); + assert_eq!(plan.outputs[0].value.to_sat(), 9_300); + assert!(plan.outputs[1].script_pubkey.is_op_return()); + assert_eq!(sum_inputs(&plan.inputs).unwrap(), 9_300 + plan.outputs[1].value.to_sat() + plan.fee); + assert_eq!(plan.outputs[1].value.to_sat(), 0); + + let request = SpendRequest::transfer(BitcoinChain::Bitcoin, &spend_signer_input("50000", false), false).unwrap(); + assert_invalid_input(UtxoPlanner::plan(request), "insufficient balance"); + + let request = SpendRequest::transfer(BitcoinChain::Bitcoin, &spend_signer_input("545", false), false).unwrap(); + assert_invalid_input(UtxoPlanner::plan(request), "invalid transaction amount"); + + let dust_max_utxos = vec![utxo_with(TEST_UTXO_TXID, 0, "600", TEST_BITCOIN_P2WPKH_ADDRESS)]; + let request = SpendRequest::transfer(BitcoinChain::Bitcoin, &spend_signer_input_with("0", true, None, dust_max_utxos), false).unwrap(); + assert_invalid_input(UtxoPlanner::plan(request), "insufficient balance"); + + let request = SpendRequest::transfer(BitcoinChain::Bitcoin, &spend_signer_input_with("1000", false, Some("a".repeat(81)), spend_utxos()), false).unwrap(); + assert_invalid_input(UtxoPlanner::plan(request), "Bitcoin memo is too large"); + + let request = SpendRequest::transfer(BitcoinChain::Bitcoin, &spend_signer_input("0", true), false).unwrap(); + let plan = UtxoPlanner::plan(request).unwrap(); + assert_eq!(plan.inputs.len(), 2); + assert_eq!(plan.outputs.len(), 2); + assert!(plan.outputs[1].script_pubkey.is_op_return()); + assert_eq!( + sum_inputs(&plan.inputs).unwrap(), + plan.outputs.iter().map(|output| output.value.to_sat()).sum::() + plan.fee + ); + } + + #[test] + fn test_plan_absorbs_sub_dust_change_into_fee() { + let request = SpendRequest::transfer(BitcoinChain::Bitcoin, &spend_signer_input("9300", false), false).unwrap(); + let change_script = script_for_address(request.chain, &request.sender_address).unwrap().script_pubkey; + let plan = UtxoPlanner::plan(request).unwrap(); + + assert_eq!(plan.outputs.len(), 2); + assert_eq!(plan.outputs[0].value.to_sat(), 9_300); + assert!(plan.outputs[1].script_pubkey.is_op_return()); + + let selected_amount = sum_inputs(&plan.inputs).unwrap(); + let fee_without_change = estimate_fee(BitcoinChain::Bitcoin, &plan.inputs, &plan.outputs, 2).unwrap(); + let mut outputs_with_change = plan.outputs.clone(); + outputs_with_change.push(PlanOutput::new(0, change_script.clone())); + let fee_with_change = estimate_fee(BitcoinChain::Bitcoin, &plan.inputs, &outputs_with_change, 2).unwrap(); + let dust_remainder = selected_amount - plan.outputs[0].value.to_sat() - fee_with_change; + assert!(dust_remainder > 0); + assert!(dust_remainder < dust_threshold(&change_script)); + + let absorbed_remainder = selected_amount - plan.outputs[0].value.to_sat() - fee_without_change; + assert_eq!(plan.fee, fee_without_change + absorbed_remainder); + assert_eq!(selected_amount, plan.outputs.iter().map(|output| output.value.to_sat()).sum::() + plan.fee); + } +} diff --git a/crates/gem_bitcoin/src/signer/planner/types.rs b/crates/gem_bitcoin/src/signer/planner/types.rs new file mode 100644 index 000000000..1cbdc6a07 --- /dev/null +++ b/crates/gem_bitcoin/src/signer/planner/types.rs @@ -0,0 +1,38 @@ +use bitcoin::{Amount, OutPoint, ScriptBuf}; + +use crate::signer::{address::UnlockingScript, encoding::varint_len}; + +#[derive(Debug, Clone)] +pub(crate) struct PlanInput { + pub(crate) previous_output: OutPoint, + pub(crate) value: Amount, + pub(crate) script_pubkey: ScriptBuf, + pub(crate) unlocking_script: UnlockingScript, + pub(crate) sequence: u32, +} + +#[derive(Debug, Clone)] +pub(crate) struct PlanOutput { + pub(crate) value: Amount, + pub(crate) script_pubkey: ScriptBuf, +} + +impl PlanOutput { + pub(crate) fn new(value: u64, script_pubkey: ScriptBuf) -> Self { + Self { + value: Amount::from_sat(value), + script_pubkey, + } + } + + pub(crate) fn serialized_len(&self) -> u64 { + 8 + varint_len(self.script_pubkey.len()) as u64 + self.script_pubkey.len() as u64 + } +} + +#[derive(Debug, Clone)] +pub(crate) struct SpendPlan { + pub(crate) inputs: Vec, + pub(crate) outputs: Vec, + pub(crate) fee: u64, +} diff --git a/crates/gem_bitcoin/src/signer/transaction.rs b/crates/gem_bitcoin/src/signer/transaction.rs new file mode 100644 index 000000000..021d4f5d0 --- /dev/null +++ b/crates/gem_bitcoin/src/signer/transaction.rs @@ -0,0 +1,165 @@ +use bitcoin::{ + PublicKey, ScriptBuf, Sequence, Transaction, TxIn as TransactionInput, TxOut as TransactionOutput, Witness, + absolute::LockTime, + blockdata::{script::Builder, transaction::Version}, + consensus::encode::serialize, + script::PushBytesBuf, + secp256k1::{Message, PublicKey as Secp256k1PublicKey, Secp256k1, SecretKey, Signing}, + sighash::{EcdsaSighashType, SighashCache}, +}; +use primitives::{BitcoinChain, SignerError}; + +use crate::signer::{ + address::{UnlockingScript, public_key_hash, script_for_public_key_hash}, + bitcoin_cash::sign_plan as sign_bitcoin_cash, + planner::SpendPlan, + zcash::sign_transparent, +}; + +pub(crate) fn sign_plan(chain: BitcoinChain, plan: &SpendPlan, private_key: &[u8], zcash_branch_id: Option) -> Result { + let secret_key = SecretKey::from_slice(private_key).map_err(|_| SignerError::invalid_input(format!("invalid {} private key", chain.get_chain())))?; + let secp = Secp256k1::signing_only(); + let public_key = PublicKey::new(Secp256k1PublicKey::from_secret_key(&secp, &secret_key)); + validate_chain_input_types(chain, plan)?; + validate_public_key(chain, plan, &public_key)?; + validate_plan_amounts(chain, plan)?; + + let tx = match chain { + BitcoinChain::BitcoinCash => sign_bitcoin_cash(plan, &secret_key, &public_key, &secp)?, + BitcoinChain::Bitcoin | BitcoinChain::Litecoin | BitcoinChain::Doge => sign_bitcoin_like(plan, &secret_key, &public_key, &secp)?, + BitcoinChain::Zcash => { + let branch_id = zcash_branch_id.ok_or_else(|| SignerError::invalid_input("missing Zcash branch id"))?; + return sign_transparent(plan, branch_id, &secret_key, &public_key, &secp); + } + }; + + Ok(hex::encode(serialize(&tx))) +} + +pub(super) fn build_unsigned_transaction(plan: &SpendPlan) -> Transaction { + let input = plan + .inputs + .iter() + .map(|input| TransactionInput { + previous_output: input.previous_output, + script_sig: ScriptBuf::new(), + sequence: Sequence(input.sequence), + witness: Witness::default(), + }) + .collect(); + + let output = plan + .outputs + .iter() + .map(|output| TransactionOutput { + value: output.value, + script_pubkey: output.script_pubkey.clone(), + }) + .collect(); + + Transaction { + version: Version::TWO, + lock_time: LockTime::ZERO, + input, + output, + } +} + +fn sign_bitcoin_like(plan: &SpendPlan, secret_key: &SecretKey, public_key: &PublicKey, secp: &Secp256k1) -> Result { + let mut tx = build_unsigned_transaction(plan); + let signed_inputs = { + let mut sighash_cache = SighashCache::new(&tx); + let mut signed_inputs = Vec::with_capacity(plan.inputs.len()); + + for (index, input) in plan.inputs.iter().enumerate() { + match input.unlocking_script { + UnlockingScript::P2pkh => { + let sighash = sighash_cache + .legacy_signature_hash(index, &input.script_pubkey, EcdsaSighashType::All.to_u32()) + .map_err(|_| SignerError::signing_error("failed to compute Bitcoin sighash"))?; + let signature = der_signature(secp, secret_key, Message::from(sighash), EcdsaSighashType::All.to_u32() as u8); + let script_sig = Builder::new().push_slice(signature_push_bytes(signature)?).push_key(public_key).into_script(); + signed_inputs.push((script_sig, Witness::default())); + } + UnlockingScript::P2wpkh => { + let sighash = sighash_cache + .p2wpkh_signature_hash(index, &input.script_pubkey, input.value, EcdsaSighashType::All) + .map_err(|_| SignerError::signing_error("failed to compute Bitcoin witness sighash"))?; + let signature = der_signature(secp, secret_key, Message::from(sighash), EcdsaSighashType::All.to_u32() as u8); + let mut witness = Witness::default(); + witness.push(signature); + witness.push(public_key.to_bytes()); + signed_inputs.push((ScriptBuf::new(), witness)); + } + } + } + signed_inputs + }; + + for (input, (script_sig, witness)) in tx.input.iter_mut().zip(signed_inputs) { + input.script_sig = script_sig; + input.witness = witness; + } + + Ok(tx) +} + +fn validate_chain_input_types(chain: BitcoinChain, plan: &SpendPlan) -> Result<(), SignerError> { + for input in &plan.inputs { + match chain { + BitcoinChain::Bitcoin | BitcoinChain::Litecoin | BitcoinChain::Doge => {} + BitcoinChain::BitcoinCash | BitcoinChain::Zcash => { + (input.unlocking_script == UnlockingScript::P2pkh) + .then_some(()) + .ok_or_else(|| SignerError::invalid_input(format!("{} UTXO address type is unsupported", chain.get_chain())))?; + } + } + } + Ok(()) +} + +fn validate_public_key(chain: BitcoinChain, plan: &SpendPlan, public_key: &PublicKey) -> Result<(), SignerError> { + let key_hash = public_key_hash(&public_key.to_bytes()); + for input in &plan.inputs { + let expected = script_for_public_key_hash(input.unlocking_script, key_hash); + (expected == input.script_pubkey) + .then_some(()) + .ok_or_else(|| SignerError::invalid_input(format!("{} private key does not match sender address", chain.get_chain())))?; + } + Ok(()) +} + +fn validate_plan_amounts(chain: BitcoinChain, plan: &SpendPlan) -> Result<(), SignerError> { + let input_total = plan.inputs.iter().try_fold(0u64, |sum, input| { + sum.checked_add(input.value.to_sat()) + .ok_or_else(|| SignerError::invalid_input(format!("{} amount overflow", chain.get_chain()))) + })?; + let output_total = plan.outputs.iter().try_fold(0u64, |sum, output| { + sum.checked_add(output.value.to_sat()) + .ok_or_else(|| SignerError::invalid_input(format!("{} amount overflow", chain.get_chain()))) + })?; + let spent_total = output_total + .checked_add(plan.fee) + .ok_or_else(|| SignerError::invalid_input(format!("{} amount overflow", chain.get_chain())))?; + (input_total == spent_total).then_some(()).ok_or_else(|| { + SignerError::invalid_input(format!( + "{} plan amount mismatch: inputs {}, outputs {}, fee {}", + chain.get_chain(), + input_total, + output_total, + plan.fee + )) + })?; + Ok(()) +} + +pub(super) fn der_signature(secp: &Secp256k1, secret_key: &SecretKey, message: Message, sighash_byte: u8) -> Vec { + let signature = secp.sign_ecdsa(&message, secret_key); + let mut bytes = signature.serialize_der().to_vec(); + bytes.push(sighash_byte); + bytes +} + +pub(super) fn signature_push_bytes(signature: Vec) -> Result { + PushBytesBuf::try_from(signature).map_err(|_| SignerError::signing_error("invalid Bitcoin script push")) +} diff --git a/crates/gem_bitcoin/src/signer/zcash.rs b/crates/gem_bitcoin/src/signer/zcash.rs new file mode 100644 index 000000000..29ccdddb2 --- /dev/null +++ b/crates/gem_bitcoin/src/signer/zcash.rs @@ -0,0 +1,244 @@ +use bitcoin::{ + PublicKey, ScriptBuf, Sequence, TxIn as TransactionInput, TxOut as TransactionOutput, Witness, + blockdata::script::Builder, + consensus::encode::serialize, + secp256k1::{Message, Secp256k1, SecretKey, Signing}, +}; +use gem_hash::blake2::blake2b_256_personal; +use primitives::{SignerError, TransactionLoadMetadata, decode_hex}; + +use crate::signer::{ + encoding::encode_varint, + planner::{PlanInput, SpendPlan}, + transaction::{der_signature, signature_push_bytes}, +}; + +const OVERWINTERED_VERSION_5: u32 = 0x8000_0005; +const VERSION_GROUP_ID_V5: u32 = 0x26a7_270a; +const LOCK_TIME: u32 = 0; +const EXPIRY_HEIGHT_DISABLED: u32 = 0; +const SIGHASH_ALL: u8 = 0x01; +const ZCASH_TRANSPARENT_AMOUNTS_HASH_PERSONALIZATION: &[u8; 16] = b"ZTxTrAmountsHash"; +const ZCASH_TRANSPARENT_SCRIPTS_HASH_PERSONALIZATION: &[u8; 16] = b"ZTxTrScriptsHash"; + +#[derive(Debug, Clone)] +struct ZcashTransparentTransaction { + branch_id: u32, + inputs: Vec, + outputs: Vec, +} + +struct ZcashSignatureDigests { + header: [u8; 32], + prevouts: [u8; 32], + amounts: [u8; 32], + script_pubkeys: [u8; 32], + sequences: [u8; 32], + outputs: [u8; 32], + sapling: [u8; 32], + orchard: [u8; 32], +} + +impl ZcashSignatureDigests { + fn new(tx: &ZcashTransparentTransaction, plan: &SpendPlan) -> Result { + let prevouts = plan.inputs.iter().flat_map(|input| serialize(&input.previous_output)).collect::>(); + let amounts = plan.inputs.iter().map(signed_value_bytes).collect::, _>>()?.concat(); + let script_pubkeys = plan.inputs.iter().flat_map(|input| serialize(input.script_pubkey.as_script())).collect::>(); + let sequences = plan.inputs.iter().flat_map(|input| input.sequence.to_le_bytes()).collect::>(); + let outputs = tx.outputs.iter().flat_map(serialize).collect::>(); + + Ok(Self { + header: header_digest(tx.branch_id), + prevouts: blake2b_256_personal(&prevouts, b"ZTxIdPrevoutHash"), + amounts: blake2b_256_personal(&amounts, ZCASH_TRANSPARENT_AMOUNTS_HASH_PERSONALIZATION), + script_pubkeys: blake2b_256_personal(&script_pubkeys, ZCASH_TRANSPARENT_SCRIPTS_HASH_PERSONALIZATION), + sequences: blake2b_256_personal(&sequences, b"ZTxIdSequencHash"), + outputs: blake2b_256_personal(&outputs, b"ZTxIdOutputsHash"), + sapling: blake2b_256_personal(&[], b"ZTxIdSaplingHash"), + orchard: blake2b_256_personal(&[], b"ZTxIdOrchardHash"), + }) + } +} + +impl ZcashTransparentTransaction { + fn unsigned(plan: &SpendPlan, branch_id: u32) -> Self { + let inputs = plan + .inputs + .iter() + .map(|input| TransactionInput { + previous_output: input.previous_output, + script_sig: ScriptBuf::new(), + sequence: Sequence(input.sequence), + witness: Witness::default(), + }) + .collect(); + + let outputs = plan + .outputs + .iter() + .map(|output| TransactionOutput { + value: output.value, + script_pubkey: output.script_pubkey.clone(), + }) + .collect(); + + Self { branch_id, inputs, outputs } + } + + fn encode(&self) -> Vec { + let mut bytes = Vec::new(); + bytes.extend_from_slice(&OVERWINTERED_VERSION_5.to_le_bytes()); + bytes.extend_from_slice(&VERSION_GROUP_ID_V5.to_le_bytes()); + bytes.extend_from_slice(&self.branch_id.to_le_bytes()); + bytes.extend_from_slice(&LOCK_TIME.to_le_bytes()); + bytes.extend_from_slice(&EXPIRY_HEIGHT_DISABLED.to_le_bytes()); + + bytes.extend_from_slice(&encode_varint(self.inputs.len())); + for input in &self.inputs { + bytes.extend_from_slice(&serialize(input)); + } + + bytes.extend_from_slice(&encode_varint(self.outputs.len())); + for output in &self.outputs { + bytes.extend_from_slice(&serialize(output)); + } + + bytes.extend_from_slice(&encode_varint(0)); + bytes.extend_from_slice(&encode_varint(0)); + bytes.extend_from_slice(&encode_varint(0)); + bytes + } +} + +pub(crate) fn sign_transparent(plan: &SpendPlan, branch_id: u32, secret_key: &SecretKey, public_key: &PublicKey, secp: &Secp256k1) -> Result { + let mut tx = ZcashTransparentTransaction::unsigned(plan, branch_id); + let digests = ZcashSignatureDigests::new(&tx, plan)?; + + for (index, _) in plan.inputs.iter().enumerate() { + let sighash = signature_digest(tx.branch_id, &digests, plan, index)?; + let signature = der_signature(secp, secret_key, Message::from_digest(sighash), SIGHASH_ALL); + tx.inputs[index].script_sig = Builder::new().push_slice(signature_push_bytes(signature)?).push_key(public_key).into_script(); + } + + Ok(hex::encode(tx.encode())) +} + +pub(crate) fn branch_id_from_metadata(metadata: &TransactionLoadMetadata) -> Result { + let branch_id = metadata.get_branch_id().map_err(SignerError::from_display)?; + let bytes: [u8; 4] = decode_hex(&branch_id) + .map_err(SignerError::from)? + .try_into() + .map_err(|_| SignerError::invalid_input("invalid Zcash branch id"))?; + // Blockbook reports consensus.chaintip in big-endian display order; Zcash + // transaction encoding and personalization use the u32 in little-endian. + Ok(u32::from_be_bytes(bytes)) +} + +fn signature_digest(branch_id: u32, digests: &ZcashSignatureDigests, plan: &SpendPlan, input_index: usize) -> Result<[u8; 32], SignerError> { + let transparent_sig_digest = transparent_sig_digest(digests, plan, input_index)?; + + let mut bytes = Vec::with_capacity(32 * 4); + bytes.extend_from_slice(&digests.header); + bytes.extend_from_slice(&transparent_sig_digest); + bytes.extend_from_slice(&digests.sapling); + bytes.extend_from_slice(&digests.orchard); + + Ok(blake2b_256_personal(&bytes, &branch_personalization(b"ZcashTxHash_", branch_id))) +} + +fn header_digest(branch_id: u32) -> [u8; 32] { + let mut bytes = Vec::with_capacity(20); + bytes.extend_from_slice(&OVERWINTERED_VERSION_5.to_le_bytes()); + bytes.extend_from_slice(&VERSION_GROUP_ID_V5.to_le_bytes()); + bytes.extend_from_slice(&branch_id.to_le_bytes()); + bytes.extend_from_slice(&LOCK_TIME.to_le_bytes()); + bytes.extend_from_slice(&EXPIRY_HEIGHT_DISABLED.to_le_bytes()); + blake2b_256_personal(&bytes, b"ZTxIdHeadersHash") +} + +fn transparent_sig_digest(digests: &ZcashSignatureDigests, plan: &SpendPlan, input_index: usize) -> Result<[u8; 32], SignerError> { + let mut bytes = Vec::with_capacity(1 + 32 * 6); + bytes.push(SIGHASH_ALL); + bytes.extend_from_slice(&digests.prevouts); + bytes.extend_from_slice(&digests.amounts); + bytes.extend_from_slice(&digests.script_pubkeys); + bytes.extend_from_slice(&digests.sequences); + bytes.extend_from_slice(&digests.outputs); + bytes.extend_from_slice(&txin_sig_digest(plan, input_index)?); + Ok(blake2b_256_personal(&bytes, b"ZTxIdTranspaHash")) +} + +fn txin_sig_digest(plan: &SpendPlan, input_index: usize) -> Result<[u8; 32], SignerError> { + let input = plan.inputs.get(input_index).ok_or_else(|| SignerError::signing_error("Zcash input index out of bounds"))?; + let mut bytes = Vec::new(); + bytes.extend_from_slice(&serialize(&input.previous_output)); + bytes.extend_from_slice(&signed_value_bytes(input)?); + bytes.extend_from_slice(&serialize(input.script_pubkey.as_script())); + bytes.extend_from_slice(&input.sequence.to_le_bytes()); + Ok(blake2b_256_personal(&bytes, b"Zcash___TxInHash")) +} + +fn signed_value_bytes(input: &PlanInput) -> Result<[u8; 8], SignerError> { + let value = i64::try_from(input.value.to_sat()).map_err(|_| SignerError::invalid_input("invalid Zcash UTXO amount"))?; + Ok(value.to_le_bytes()) +} + +fn branch_personalization(prefix: &[u8; 12], branch_id: u32) -> [u8; 16] { + let mut personal = [0u8; 16]; + personal[..12].copy_from_slice(prefix); + personal[12..].copy_from_slice(&branch_id.to_le_bytes()); + personal +} + +#[cfg(test)] +mod tests { + use bitcoin::secp256k1::{Secp256k1, SecretKey}; + use primitives::{BitcoinChain, testkit::mock_zcash}; + + use super::*; + use crate::{ + signer::planner::{SpendRequest, UtxoPlanner}, + testkit::{ + address_mock::zcash_address, + signer_mock::{TEST_PRIVATE_KEY, public_key, sender_address as test_sender_address}, + }, + }; + + #[test] + fn test_sign_transparent() { + let public_key = public_key(); + let sender_address = test_sender_address(BitcoinChain::Zcash); + let destination_address = zcash_address([2u8; 20]); + let input = mock_zcash::signer_input(sender_address, destination_address); + let request = SpendRequest::transfer(BitcoinChain::Zcash, &input, false).unwrap(); + let plan = UtxoPlanner::plan(request).unwrap(); + + let branch_id = branch_id_from_metadata(&input.metadata).unwrap(); + let tx = ZcashTransparentTransaction::unsigned(&plan, branch_id); + let digests = ZcashSignatureDigests::new(&tx, &plan).unwrap(); + assert_eq!( + hex::encode(signature_digest(branch_id, &digests, &plan, 0).unwrap()), + "0e9508ded3c1bbbf0a153622e1b5dee4303c33d45bcaa7fa1218cab57feeb065" + ); + + let raw = sign_transparent( + &plan, + branch_id, + &SecretKey::from_slice(&TEST_PRIVATE_KEY).unwrap(), + &public_key, + &Secp256k1::signing_only(), + ) + .unwrap(); + let bytes = hex::decode(raw).unwrap(); + assert_eq!(&bytes[..20], hex::decode("050000800a27a726f04dec4d0000000000000000").unwrap().as_slice()); + assert_eq!(plan.fee, 10_000); + assert_eq!(bytes[20], 1); + + let script_len_index = 20 + 1 + 32 + 4; + let script_len = bytes[script_len_index] as usize; + let signature_len = bytes[script_len_index + 1] as usize; + assert!(script_len > 0); + assert_eq!(bytes[script_len_index + 2 + signature_len - 1], SIGHASH_ALL); + assert_eq!(*bytes.last().unwrap(), 0); + } +} diff --git a/crates/gem_bitcoin/src/testkit/address_mock.rs b/crates/gem_bitcoin/src/testkit/address_mock.rs new file mode 100644 index 000000000..89a12aa3d --- /dev/null +++ b/crates/gem_bitcoin/src/testkit/address_mock.rs @@ -0,0 +1,59 @@ +use primitives::BitcoinChain; + +use crate::{address::BitcoinAddress, signer::address::ZCASH_TRANSPARENT_P2PKH_PREFIX}; + +pub(crate) const TEST_BITCOIN_P2WPKH_ADDRESS: &str = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"; +pub(crate) const TEST_BITCOIN_P2WPKH_HASH: [u8; 20] = [ + 0x75, 0x1e, 0x76, 0xe8, 0x19, 0x91, 0x96, 0xd4, 0x54, 0x94, 0x1c, 0x45, 0xd1, 0xb3, 0xa3, 0x23, 0xf1, 0x43, 0x3b, 0xd6, +]; + +impl BitcoinAddress { + pub fn mock() -> Self { + Self::mock_with_chain(BitcoinChain::Bitcoin) + } + + pub fn mock_with_chain(chain: BitcoinChain) -> Self { + Self::try_parse_for_chain(&Self::mock_address_with_chain(chain), chain).unwrap() + } + + pub fn mock_address_with_chain(chain: BitcoinChain) -> String { + match chain { + BitcoinChain::Bitcoin => TEST_BITCOIN_P2WPKH_ADDRESS.to_string(), + BitcoinChain::BitcoinCash => address_for_hash(chain, [2u8; 20]), + BitcoinChain::Litecoin => address_for_hash(chain, [3u8; 20]), + BitcoinChain::Doge => address_for_hash(chain, [4u8; 20]), + BitcoinChain::Zcash => address_for_hash(chain, [5u8; 20]), + } + } +} + +pub(crate) fn address_for_hash(chain: BitcoinChain, hash: [u8; 20]) -> String { + match chain { + BitcoinChain::Bitcoin => prefixed_address(&[0], hash), + BitcoinChain::BitcoinCash => bitcoin_cash_address(hash), + BitcoinChain::Litecoin => prefixed_address(&[48], hash), + BitcoinChain::Doge => prefixed_address(&[30], hash), + BitcoinChain::Zcash => zcash_address(hash), + } +} + +pub(crate) fn bitcoin_cash_address(hash: [u8; 20]) -> String { + bitcoincash_addr::Address::new( + hash.to_vec(), + bitcoincash_addr::Scheme::CashAddr, + bitcoincash_addr::HashType::Key, + bitcoincash_addr::Network::Main, + ) + .encode() + .unwrap() +} + +pub(crate) fn zcash_address(hash: [u8; 20]) -> String { + prefixed_address(&ZCASH_TRANSPARENT_P2PKH_PREFIX, hash) +} + +pub(crate) fn prefixed_address(prefix: &[u8], hash: [u8; 20]) -> String { + let mut payload = prefix.to_vec(); + payload.extend(hash); + bs58::encode(payload).with_check().into_string() +} diff --git a/crates/gem_bitcoin/src/testkit/mod.rs b/crates/gem_bitcoin/src/testkit/mod.rs index c9fa0bff1..1cdc50a8d 100644 --- a/crates/gem_bitcoin/src/testkit/mod.rs +++ b/crates/gem_bitcoin/src/testkit/mod.rs @@ -1 +1,7 @@ +#[cfg(feature = "signer")] +pub mod address_mock; +#[cfg(feature = "signer")] +pub mod planner_mock; +#[cfg(feature = "signer")] +pub mod signer_mock; pub mod transaction_mock; diff --git a/crates/gem_bitcoin/src/testkit/planner_mock.rs b/crates/gem_bitcoin/src/testkit/planner_mock.rs new file mode 100644 index 000000000..273957488 --- /dev/null +++ b/crates/gem_bitcoin/src/testkit/planner_mock.rs @@ -0,0 +1,68 @@ +use bitcoin::{ + Amount, OutPoint, ScriptBuf, + blockdata::{opcodes::all::OP_RETURN, script::Builder}, + script::PushBytesBuf, +}; +use num_bigint::BigInt; +use primitives::{BitcoinChain, GasPriceType, SignerError, SignerInput, TransactionFee, UTXO}; + +use crate::{ + signer::{PlanInput, address::UnlockingScript}, + testkit::{ + address_mock::TEST_BITCOIN_P2WPKH_ADDRESS, + signer_mock::{TEST_UTXO_TXID, transfer_input_with_utxos, utxo_with}, + }, +}; + +pub(crate) const TEST_SPEND_RECIPIENT: &str = "1BoatSLRHtKNngkdXEeobR76b53LETtpyT"; + +impl PlanInput { + pub(crate) fn mock_with_unlocking_script(unlocking_script: UnlockingScript) -> Self { + Self { + previous_output: OutPoint::null(), + value: Amount::from_sat(50_000), + script_pubkey: ScriptBuf::new(), + unlocking_script, + sequence: u32::MAX, + } + } +} + +pub(crate) fn spend_signer_input(value: &str, is_max: bool) -> SignerInput { + spend_signer_input_with(value, is_max, Some("memo".to_string()), spend_utxos()) +} + +pub(crate) fn spend_signer_input_with(value: &str, is_max: bool, memo: Option, utxos: Vec) -> SignerInput { + let mut input = transfer_input_with_utxos(BitcoinChain::Bitcoin, TEST_BITCOIN_P2WPKH_ADDRESS, TEST_SPEND_RECIPIENT, value, utxos); + input.input.gas_price = GasPriceType::regular(BigInt::from(2u64)); + input.input.memo = memo; + input.input.is_max_value = is_max; + input.fee = TransactionFee::new_from_fee(BigInt::from(2u64)); + input +} + +pub(crate) fn spend_utxos() -> Vec { + vec![ + utxo_with(TEST_UTXO_TXID, 0, "10000", TEST_BITCOIN_P2WPKH_ADDRESS), + utxo_with("0000000000000000000000000000000000000000000000000000000000000002", 1, "20000", TEST_BITCOIN_P2WPKH_ADDRESS), + ] +} + +pub(crate) fn op_return_script(bytes: usize) -> ScriptBuf { + let push = PushBytesBuf::try_from(vec![0u8; bytes]).unwrap(); + Builder::new().push_opcode(OP_RETURN).push_slice(push).into_script() +} + +pub(crate) fn sum_inputs(inputs: &[PlanInput]) -> Result { + inputs.iter().try_fold(0u64, |sum, input| { + sum.checked_add(input.value.to_sat()).ok_or_else(|| SignerError::invalid_input("Bitcoin amount overflow")) + }) +} + +pub(crate) fn assert_invalid_input(result: Result, expected: &str) { + match result { + Err(SignerError::InvalidInput(message)) => assert_eq!(message, expected), + Err(SignerError::SigningError(message)) => panic!("unexpected signing error: {message}"), + Ok(_) => panic!("expected invalid input error"), + } +} diff --git a/crates/gem_bitcoin/src/testkit/signer_mock.rs b/crates/gem_bitcoin/src/testkit/signer_mock.rs new file mode 100644 index 000000000..cbac21129 --- /dev/null +++ b/crates/gem_bitcoin/src/testkit/signer_mock.rs @@ -0,0 +1,166 @@ +use bitcoin::{ + PublicKey, + secp256k1::{PublicKey as Secp256k1PublicKey, Secp256k1, SecretKey}, +}; +use num_bigint::BigInt; +use primitives::{ + Asset, BitcoinChain, GasPriceType, SignerInput, SwapProvider, TransactionFee, TransactionInputType, TransactionLoadInput, TransactionLoadMetadata, UTXO, + swap::{SwapData, SwapProviderData, SwapQuote, SwapQuoteData}, +}; + +use crate::{ + signer::address::{UnlockingScript, public_key_hash, script_for_public_key_hash}, + testkit::address_mock::address_for_hash, +}; + +pub use primitives::testkit::signer_mock::TEST_PRIVATE_KEY; + +pub const TEST_UTXO_TXID: &str = "0000000000000000000000000000000000000000000000000000000000000001"; +pub const TEST_ZCASH_BRANCH_ID: &str = "4dec4df0"; + +pub fn public_key() -> PublicKey { + let secp = Secp256k1::new(); + let secret_key = SecretKey::from_slice(&TEST_PRIVATE_KEY).unwrap(); + PublicKey::new(Secp256k1PublicKey::from_secret_key(&secp, &secret_key)) +} + +pub fn sender_address(chain: BitcoinChain) -> String { + let public_key = public_key(); + address_for_hash(chain, public_key_hash(&public_key.to_bytes())) +} + +pub fn destination_address(chain: BitcoinChain) -> String { + let hash = match chain { + BitcoinChain::Bitcoin => public_key_hash(&public_key().to_bytes()), + BitcoinChain::BitcoinCash | BitcoinChain::Litecoin | BitcoinChain::Doge => [2u8; 20], + BitcoinChain::Zcash => [3u8; 20], + }; + address_for_hash(chain, hash) +} + +pub fn transfer_input(chain: BitcoinChain) -> SignerInput { + let sender_address = sender_address(chain); + let destination_address = destination_address(chain); + transfer_input_with_utxos(chain, &sender_address, &destination_address, "10000", vec![utxo_with_address(&sender_address)]) +} + +pub fn transfer_input_with_utxos(chain: BitcoinChain, sender_address: &str, destination_address: &str, value: &str, utxos: Vec) -> SignerInput { + let metadata = match chain { + BitcoinChain::Zcash => TransactionLoadMetadata::Zcash { + branch_id: TEST_ZCASH_BRANCH_ID.to_string(), + utxos, + }, + _ => TransactionLoadMetadata::Bitcoin { utxos }, + }; + + SignerInput::new( + TransactionLoadInput { + input_type: TransactionInputType::Transfer(Asset::from_chain(chain.get_chain())), + sender_address: sender_address.to_string(), + destination_address: destination_address.to_string(), + value: value.to_string(), + gas_price: GasPriceType::regular(BigInt::from(1u64)), + memo: None, + is_max_value: false, + metadata, + }, + TransactionFee::new_from_fee(BigInt::from(1u64)), + ) +} + +fn p2wpkh_address() -> String { + let hash = public_key_hash(&public_key().to_bytes()); + let script_pubkey = script_for_public_key_hash(UnlockingScript::P2wpkh, hash); + bitcoin::Address::from_script(&script_pubkey, bitcoin::Network::Bitcoin).unwrap().to_string() +} + +pub fn p2wpkh_transfer_input() -> SignerInput { + let address = p2wpkh_address(); + transfer_input_with_utxos(BitcoinChain::Bitcoin, &address, &address, "10000", vec![utxo_with(TEST_UTXO_TXID, 0, "50000", &address)]) +} + +pub fn funded_transfer_input(chain: BitcoinChain) -> SignerInput { + let mut input = transfer_input(chain); + match &mut input.input.metadata { + TransactionLoadMetadata::Bitcoin { utxos } | TransactionLoadMetadata::Zcash { utxos, .. } => { + utxos[0].value = "100000000".to_string(); + } + _ => {} + } + input +} + +pub fn transfer_swap_input(chain: BitcoinChain, memo: &str) -> SignerInput { + swap_input(chain, SwapProvider::Thorchain, Some(false), |destination_address, value| { + SwapQuoteData::new_tranfer(destination_address, value, Some(memo.to_string())) + }) +} + +pub fn contract_swap_input(chain: BitcoinChain, nulldata_hex: &str, use_max_amount: bool) -> SignerInput { + contract_swap_input_with_provider(chain, nulldata_hex, use_max_amount, SwapProvider::Chainflip) +} + +pub fn contract_swap_input_with_provider(chain: BitcoinChain, nulldata_hex: &str, use_max_amount: bool, provider: SwapProvider) -> SignerInput { + swap_input(chain, provider, Some(use_max_amount), |destination_address, value| { + SwapQuoteData::new_contract(destination_address, value, nulldata_hex.to_string(), None, None) + }) +} + +fn swap_input(chain: BitcoinChain, provider: SwapProvider, use_max_amount: Option, quote_data: impl FnOnce(String, String) -> SwapQuoteData) -> SignerInput { + let mut input = funded_transfer_input(chain); + let sender_address = input.sender_address.clone(); + let destination_address = input.destination_address.clone(); + let value = input.value.clone(); + + input.input.input_type = TransactionInputType::Swap( + Asset::from_chain(chain.get_chain()), + Asset::from_chain(BitcoinChain::Bitcoin.get_chain()), + SwapData { + quote: SwapQuote { + from_address: sender_address, + from_value: value.clone(), + min_from_value: None, + to_address: destination_address.clone(), + to_value: value.clone(), + provider_data: SwapProviderData { + provider, + name: provider.name().to_string(), + protocol_name: provider.protocol_name().to_string(), + }, + slippage_bps: 50, + eta_in_seconds: None, + use_max_amount, + }, + data: quote_data(destination_address, value), + }, + ); + input +} + +pub fn p2wpkh_contract_swap_input(nulldata_hex: &str, use_max_amount: bool) -> SignerInput { + let p2wpkh_sender = p2wpkh_address(); + let mut input = contract_swap_input(BitcoinChain::Bitcoin, nulldata_hex, use_max_amount); + input.input.sender_address = p2wpkh_sender.clone(); + let TransactionLoadMetadata::Bitcoin { utxos } = &mut input.input.metadata else { + unreachable!() + }; + utxos[0].address = p2wpkh_sender.clone(); + let TransactionInputType::Swap(_, _, swap) = &mut input.input.input_type else { + unreachable!() + }; + swap.quote.from_address = p2wpkh_sender; + input +} + +pub(crate) fn utxo_with(transaction_id: &str, vout: i32, value: &str, address: &str) -> UTXO { + UTXO { + transaction_id: transaction_id.to_string(), + vout, + value: value.to_string(), + address: address.to_string(), + } +} + +pub(crate) fn utxo_with_address(address: &str) -> UTXO { + utxo_with(TEST_UTXO_TXID, 0, "50000", address) +} diff --git a/crates/gem_bitcoin/src/testkit/transaction_mock.rs b/crates/gem_bitcoin/src/testkit/transaction_mock.rs index ef5dc4c32..3ec22b606 100644 --- a/crates/gem_bitcoin/src/testkit/transaction_mock.rs +++ b/crates/gem_bitcoin/src/testkit/transaction_mock.rs @@ -1,5 +1,15 @@ use crate::models::transaction::{Input, Output, Transaction}; +#[cfg(feature = "signer")] +pub fn unsigned_transaction() -> bitcoin::Transaction { + bitcoin::consensus::encode::deserialize(&hex::decode(unsigned_transaction_hex()).unwrap()).unwrap() +} + +#[cfg(feature = "signer")] +pub fn unsigned_transaction_hex() -> &'static str { + "0200000000010100000000000000000000000000000000000000000000000000000000000000010000000000ffffffff011027000000000000160014751e76e8199196d454941c45d1b3a323f1433bd600000000" +} + impl Transaction { pub fn mock() -> Self { Self { diff --git a/crates/gem_hash/Cargo.toml b/crates/gem_hash/Cargo.toml index 1f0a2bf4c..6b8353220 100644 --- a/crates/gem_hash/Cargo.toml +++ b/crates/gem_hash/Cargo.toml @@ -4,7 +4,7 @@ version = { workspace = true } edition = { workspace = true } [dependencies] -blake2 = { workspace = true } +blake2b_simd = { workspace = true } hex = { workspace = true } sha2 = { workspace = true } sha3 = { workspace = true } diff --git a/crates/gem_hash/src/blake2.rs b/crates/gem_hash/src/blake2.rs index 460f967d1..9f43a3239 100644 --- a/crates/gem_hash/src/blake2.rs +++ b/crates/gem_hash/src/blake2.rs @@ -1,25 +1,38 @@ -use blake2::{ - Blake2b, Blake2b512, Digest, - digest::consts::{U28, U32}, -}; - -type Blake2b224 = Blake2b; -type Blake2b256 = Blake2b; - pub fn blake2b_224(bytes: &[u8]) -> [u8; 28] { - let mut hasher = Blake2b224::new(); - Digest::update(&mut hasher, bytes); - hasher.finalize().into() + blake2b(bytes) } pub fn blake2b_256(bytes: &[u8]) -> [u8; 32] { - let mut hasher = Blake2b256::new(); - Digest::update(&mut hasher, bytes); - hasher.finalize().into() + blake2b(bytes) +} + +pub fn blake2b_256_personal(bytes: &[u8], personal: &[u8; 16]) -> [u8; 32] { + let hash = blake2b_simd::Params::new().hash_length(32).personal(personal).hash(bytes); + let mut output = [0u8; 32]; + output.copy_from_slice(hash.as_bytes()); + output } pub fn blake2b_512(bytes: &[u8]) -> [u8; 64] { - let mut hasher = Blake2b512::new(); - Digest::update(&mut hasher, bytes); - hasher.finalize().into() + blake2b(bytes) +} + +fn blake2b(bytes: &[u8]) -> [u8; N] { + let hash = blake2b_simd::Params::new().hash_length(N).hash(bytes); + let mut output = [0u8; N]; + output.copy_from_slice(hash.as_bytes()); + output +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_blake2b_256_personal() { + assert_eq!( + hex::encode(blake2b_256_personal(&[], b"ZTxIdSaplingHash")), + "6f2fc8f98feafd94e74a0df4bed74391ee0b5a69945e4ced8ca8a095206f00ae" + ); + } } diff --git a/crates/primitives/src/testkit/mock_zcash.rs b/crates/primitives/src/testkit/mock_zcash.rs new file mode 100644 index 000000000..6453269af --- /dev/null +++ b/crates/primitives/src/testkit/mock_zcash.rs @@ -0,0 +1,29 @@ +use num_bigint::BigInt; + +use crate::{Asset, Chain, GasPriceType, SignerInput, TransactionFee, TransactionInputType, TransactionLoadInput, TransactionLoadMetadata, UTXO}; + +pub const TEST_ZCASH_BRANCH_ID: &str = "4dec4df0"; + +pub fn signer_input(sender_address: String, destination_address: String) -> SignerInput { + SignerInput::new( + TransactionLoadInput { + input_type: TransactionInputType::Transfer(Asset::from_chain(Chain::Zcash)), + sender_address: sender_address.clone(), + destination_address, + value: "20000".to_string(), + gas_price: GasPriceType::regular(BigInt::from(1u64)), + memo: None, + is_max_value: false, + metadata: TransactionLoadMetadata::Zcash { + branch_id: TEST_ZCASH_BRANCH_ID.to_string(), + utxos: vec![UTXO { + transaction_id: "0000000000000000000000000000000000000000000000000000000000000001".to_string(), + vout: 0, + value: "50000".to_string(), + address: sender_address, + }], + }, + }, + TransactionFee::new_from_fee(BigInt::from(1u64)), + ) +} diff --git a/crates/primitives/src/testkit/mod.rs b/crates/primitives/src/testkit/mod.rs index af0249a53..644e2044a 100644 --- a/crates/primitives/src/testkit/mod.rs +++ b/crates/primitives/src/testkit/mod.rs @@ -7,6 +7,7 @@ pub mod fiat_mock; pub mod gorush_mock; pub mod json; pub mod json_rpc; +pub mod mock_zcash; pub mod nft_mock; pub mod perpetual_mock; pub mod quote_asset_mock; diff --git a/crates/primitives/src/transaction_load_metadata.rs b/crates/primitives/src/transaction_load_metadata.rs index 7f7146da7..e097398a1 100644 --- a/crates/primitives/src/transaction_load_metadata.rs +++ b/crates/primitives/src/transaction_load_metadata.rs @@ -131,6 +131,13 @@ impl TransactionLoadMetadata { } } + pub fn get_branch_id(&self) -> Result> { + match self { + TransactionLoadMetadata::Zcash { branch_id, .. } => Ok(branch_id.clone()), + _ => Err("Branch ID not available for this metadata type".into()), + } + } + pub fn get_account_number(&self) -> Result> { match self { TransactionLoadMetadata::Cosmos { account_number, .. } => Ok(*account_number), diff --git a/crates/swapper/src/chainflip/provider.rs b/crates/swapper/src/chainflip/provider.rs index e08e19ec9..a51fea78c 100644 --- a/crates/swapper/src/chainflip/provider.rs +++ b/crates/swapper/src/chainflip/provider.rs @@ -23,7 +23,7 @@ use crate::{ fees::{DEFAULT_CHAINFLIP_FEE_BPS, apply_slippage_in_bp, quote_value_after_reserve_by_chain}, solana::DEFAULT_SWAP_GAS_LIMIT, }; -use primitives::{ChainType, chain::Chain, swap::QuoteAsset}; +use primitives::{Asset, ChainType, chain::Chain, swap::QuoteAsset}; const DEFAULT_SWAP_ERC20_GAS_LIMIT: u64 = 100_000; @@ -59,37 +59,65 @@ where rpc_provider, } } +} - fn map_asset_id(asset: &QuoteAsset) -> ChainflipAsset { - let asset_id = asset.asset_id(); - let chain_name = capitalize_first_letter(asset_id.chain.as_ref()); - ChainflipAsset { - chain: chain_name, - asset: asset.symbol.clone(), - } - } +struct ChainflipQuoteRequestData { + from_value: String, + quote_request: ChainflipQuoteRequest, +} - fn get_quote_value(request: &QuoteRequest) -> Result { - let value = quote_value_after_reserve_by_chain(request)?; - if !request.options.use_max_amount || !request.from_asset.asset_id().is_native() { - return Ok(value); - } - match request.from_asset.chain() { - Chain::Solana => { - let amount: u64 = value.parse().map_err(|_| SwapperError::ComputeQuoteError(format!("invalid amount: {value}")))?; - let reserved = amount.saturating_sub(SOLANA_VAULT_SWAP_RESERVE); - if reserved == 0 { - return Err(SwapperError::InputAmountError { - min_amount: Some(SOLANA_VAULT_SWAP_RESERVE.to_string()), - }); - } - Ok(reserved.to_string()) +fn map_asset_id(asset: &QuoteAsset) -> ChainflipAsset { + let asset_id = asset.asset_id(); + let chain_name = capitalize_first_letter(asset_id.chain.as_ref()); + let symbol = if asset.symbol.is_empty() && asset_id.is_native() { + Asset::from_chain(asset_id.chain).symbol + } else { + asset.symbol.clone() + }; + ChainflipAsset { chain: chain_name, asset: symbol } +} + +fn get_quote_value(request: &QuoteRequest) -> Result { + let value = quote_value_after_reserve_by_chain(request)?; + if !request.options.use_max_amount || !request.from_asset.asset_id().is_native() { + return Ok(value); + } + match request.from_asset.chain() { + Chain::Solana => { + let amount: u64 = value.parse().map_err(|_| SwapperError::ComputeQuoteError(format!("invalid amount: {value}")))?; + let reserved = amount.saturating_sub(SOLANA_VAULT_SWAP_RESERVE); + if reserved == 0 { + return Err(SwapperError::InputAmountError { + min_amount: Some(SOLANA_VAULT_SWAP_RESERVE.to_string()), + }); } - _ => Ok(value), + Ok(reserved.to_string()) } + _ => Ok(value), } } +fn build_quote_request(request: &QuoteRequest) -> Result { + let from_value = get_quote_value(request)?; + let src_asset = map_asset_id(&request.from_asset); + let dest_asset = map_asset_id(&request.to_asset); + let fee_bps = DEFAULT_CHAINFLIP_FEE_BPS; + + Ok(ChainflipQuoteRequestData { + from_value: from_value.clone(), + quote_request: ChainflipQuoteRequest { + amount: from_value, + src_chain: src_asset.chain, + src_asset: src_asset.asset, + dest_chain: dest_asset.chain, + dest_asset: dest_asset.asset, + is_vault_swap: true, + dca_enabled: true, + broker_commission_bps: Some(fee_bps), + }, + }) +} + fn get_best_quote(mut quotes: Vec, fee_bps: u32) -> (BigUint, u32, u32, ChainflipRouteData) { quotes.sort_by(|a, b| b.egress_amount.cmp(&a.egress_amount)); let quote = "es[0]; @@ -171,27 +199,10 @@ where } async fn get_quote(&self, request: &QuoteRequest) -> Result { - if request.from_asset.chain().chain_type() == ChainType::Bitcoin { - return Err(SwapperError::NoQuoteAvailable); - } - - let from_value = Self::get_quote_value(request)?; - let src_asset = Self::map_asset_id(&request.from_asset); - let dest_asset = Self::map_asset_id(&request.to_asset); - let fee_bps = DEFAULT_CHAINFLIP_FEE_BPS; - let quote_request = ChainflipQuoteRequest { - amount: from_value.clone(), - src_chain: src_asset.chain.clone(), - src_asset: src_asset.asset.clone(), - dest_chain: dest_asset.chain, - dest_asset: dest_asset.asset, - is_vault_swap: true, - dca_enabled: true, - broker_commission_bps: Some(fee_bps), - }; + let quote_request_data = build_quote_request(request)?; - let quotes = match self.chainflip_client.get_quote("e_request).await { + let quotes = match self.chainflip_client.get_quote("e_request_data.quote_request).await { Ok(quotes) => quotes, Err(err) => return Err(map_chainflip_quote_error(err, request.from_asset.decimals)), }; @@ -202,8 +213,8 @@ where let (egress_amount, slippage_bps, eta_in_seconds, route_data) = get_best_quote(quotes, fee_bps); Ok(Quote { - from_value, min_from_value: None, + from_value: quote_request_data.from_value, to_value: egress_amount.to_string(), data: ProviderData { provider: self.provider.clone(), @@ -221,8 +232,8 @@ where async fn get_quote_data(&self, quote: &Quote, _data: FetchQuoteData) -> Result { let from_asset = quote.request.from_asset.asset_id(); - let source_asset = Self::map_asset_id("e.request.from_asset); - let destination_asset = Self::map_asset_id("e.request.to_asset); + let source_asset = map_asset_id("e.request.from_asset); + let destination_asset = map_asset_id("e.request.to_asset); let input_amount: BigUint = quote.from_value.parse()?; @@ -339,6 +350,8 @@ where #[cfg(test)] mod tests { use super::*; + use crate::{Options, SwapperQuoteAsset}; + use primitives::AssetId; #[test] fn test_chainflip_min_amount_error() { @@ -361,6 +374,31 @@ mod tests { ); } + #[test] + fn test_build_quote_request_supports_bitcoin_source() { + let request = QuoteRequest { + from_asset: SwapperQuoteAsset::from(AssetId::from_chain(Chain::Bitcoin)), + to_asset: SwapperQuoteAsset::from(AssetId::from_chain(Chain::Ethereum)), + value: "89100".to_string(), + options: Options { + use_max_amount: true, + ..Default::default() + }, + ..QuoteRequest::mock(Chain::Bitcoin, None) + }; + + let quote_request = build_quote_request(&request).unwrap(); + + assert_eq!(quote_request.from_value, "74100"); + assert_eq!(quote_request.quote_request.amount, "74100"); + assert_eq!(quote_request.quote_request.src_chain, "Bitcoin"); + assert_eq!(quote_request.quote_request.src_asset, "BTC"); + assert_eq!(quote_request.quote_request.dest_chain, "Ethereum"); + assert_eq!(quote_request.quote_request.dest_asset, "ETH"); + assert!(quote_request.quote_request.is_vault_swap); + assert_eq!(quote_request.quote_request.broker_commission_bps, Some(DEFAULT_CHAINFLIP_FEE_BPS)); + } + #[test] fn test_best_quote() { let quotes: Vec = serde_json::from_str(include_str!("./test/chainflip_quotes.json")).unwrap(); diff --git a/crates/swapper/src/fees/reserve.rs b/crates/swapper/src/fees/reserve.rs index 5dd8c6eb6..93382bf02 100644 --- a/crates/swapper/src/fees/reserve.rs +++ b/crates/swapper/src/fees/reserve.rs @@ -20,15 +20,10 @@ pub static RESERVED_NATIVE_FEES: LazyLock> = LazyLo (Chain::Solana, "20000"), // 0.00002 SOL (Chain::Ton, "20000000"), // 0.02 TON (Chain::Tron, "20000000"), // 20 TRX - (Chain::Bitcoin, "40000"), // 0.0004 BTC - (Chain::Zcash, "1000000"), // 0.01 ZEC - (Chain::Doge, "500000000"), // 5 DOGE (Chain::Xrp, "2000000"), // 2 XRP (Chain::Cardano, "2000000"), // 2 ADA (Chain::Aptos, "20000000"), // 0.2 APT (Chain::Stellar, "100000"), // 0.01 XLM - (Chain::Litecoin, "100000"), // 0.001 LTC - (Chain::BitcoinCash, "100000"), // 0.001 BCH (Chain::Monad, "5000000000000000"), // 0.005 MON (Chain::XLayer, "5000000000000000"), // 0.005 OKB (Chain::Plasma, "5000000000000000"), // 0.005 XPL @@ -38,6 +33,12 @@ pub static RESERVED_NATIVE_FEES: LazyLock> = LazyLo (Chain::Injective, "1300000000000000"), // 0.0013 INJ (Chain::Sei, "1300000"), // 1.3 SEI (Chain::Noble, "25000"), // 0.025 USDC + // UTXO fee-vs-slippage buffer for amount-sensitive max swaps. + (Chain::Bitcoin, "15000"), // ~300 vB * ~50 sat/vB peak + (Chain::Litecoin, "15000"), // ~300 vB * ~50 lit/vB peak + (Chain::BitcoinCash, "10000"), // ~300 B * ~30 sat/B peak + (Chain::Doge, "10000000"), // 0.1 DOGE + (Chain::Zcash, "30000"), // 3x ZIP-317 marginal fee ]) }); @@ -65,3 +66,31 @@ pub fn quote_value_after_reserve_by_chain(request: &QuoteRequest) -> Result= 12_000, "Bitcoin reserve {reserve} too small to absorb peak-fee planner cost"); + } +} diff --git a/crates/swapper/src/near_intents/provider.rs b/crates/swapper/src/near_intents/provider.rs index b980ec8e6..2e5d0c430 100644 --- a/crates/swapper/src/near_intents/provider.rs +++ b/crates/swapper/src/near_intents/provider.rs @@ -389,14 +389,14 @@ mod tests { #[test] fn max_quote_keeps_transfer_amount() { - let mut request = QuoteRequest::mock(Chain::Tron, None); + let mut request = QuoteRequest::mock(Chain::Bitcoin, None); request.to_asset = SwapperQuoteAsset::from(AssetId::from_chain(Chain::Near)); - request.value = "37000000".to_string(); + request.value = "89100".to_string(); request.options.use_max_amount = true; let quote_request = NearIntents::::build_quote_request(&request, SwapType::FlexInput, true).unwrap(); - assert_eq!(quote_request.amount, "37000000"); + assert_eq!(quote_request.amount, "89100"); } #[test] diff --git a/crates/swapper/src/testkit.rs b/crates/swapper/src/testkit.rs index 4f2ada401..6b58ad8a9 100644 --- a/crates/swapper/src/testkit.rs +++ b/crates/swapper/src/testkit.rs @@ -95,6 +95,15 @@ pub fn mock_quote(from_asset: SwapperQuoteAsset, to_asset: SwapperQuoteAsset) -> } } +pub fn mock_bitcoin_max_quote(to_asset: SwapperQuoteAsset) -> QuoteRequest { + let mut request = mock_quote(SwapperQuoteAsset::from(AssetId::from_chain(Chain::Bitcoin)), to_asset); + request.wallet_address = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4".into(); + request.destination_address = "11111111111111111111111111111111".into(); + request.value = "89100".into(); + request.options.use_max_amount = true; + request +} + pub fn mock_ton(wallet_address: String) -> QuoteRequest { QuoteRequest { from_asset: SwapperQuoteAsset::from(AssetId::from_chain(Chain::Ton)), diff --git a/crates/swapper/src/thorchain/provider.rs b/crates/swapper/src/thorchain/provider.rs index b0695aa13..e350409d8 100644 --- a/crates/swapper/src/thorchain/provider.rs +++ b/crates/swapper/src/thorchain/provider.rs @@ -221,7 +221,7 @@ mod tests { use std::sync::Arc; use super::*; - use crate::{Options, SwapperQuoteAsset, alien::mock::ProviderMock}; + use crate::{Options, SwapperQuoteAsset, alien::mock::ProviderMock, testkit::mock_bitcoin_max_quote}; #[test] fn test_min_value() { @@ -244,6 +244,14 @@ mod tests { assert!(has_zcash); } + #[test] + fn test_quote_input_value_bitcoin_max_passes_value_through() { + let from_asset = THORChainAsset::from_asset_id(Chain::Bitcoin.as_ref()).unwrap(); + let request = mock_bitcoin_max_quote(SwapperQuoteAsset::from(Chain::Solana.as_asset_id())); + + assert_eq!(quote_input_value(&from_asset, &request).unwrap(), "89100"); + } + #[tokio::test] async fn test_get_quote_data_uses_quote_from_value() { let provider = Arc::new(ProviderMock::new(String::new())); diff --git a/gemstone/Cargo.toml b/gemstone/Cargo.toml index 8830c12d7..a0265202e 100644 --- a/gemstone/Cargo.toml +++ b/gemstone/Cargo.toml @@ -32,7 +32,7 @@ gem_auth = { path = "../crates/gem_auth" } gem_jsonrpc = { path = "../crates/gem_jsonrpc", features = ["client"] } gem_client = { path = "../crates/gem_client" } gem_hypercore = { path = "../crates/gem_hypercore", features = ["signer"] } -gem_bitcoin = { path = "../crates/gem_bitcoin", features = ["rpc"] } +gem_bitcoin = { path = "../crates/gem_bitcoin", features = ["rpc", "signer"] } gem_hash = { path = "../crates/gem_hash" } gem_cardano = { path = "../crates/gem_cardano", features = ["rpc", "signer"] } gem_algorand = { path = "../crates/gem_algorand", features = ["rpc", "signer"] } diff --git a/gemstone/src/address.rs b/gemstone/src/address.rs index 4667d9416..a9a32cfac 100644 --- a/gemstone/src/address.rs +++ b/gemstone/src/address.rs @@ -43,7 +43,7 @@ pub fn validate_address(address: &str, chain: Chain) -> bool { ChainType::Algorand => gem_algorand::validate_address(address), ChainType::Xrp => gem_xrp::validate_address(address), ChainType::Polkadot => gem_polkadot::validate_address(address), - ChainType::Bitcoin => false, + ChainType::Bitcoin => gem_bitcoin::validate_address(address, chain), ChainType::Cardano => gem_cardano::validate_address(address), } } @@ -70,6 +70,10 @@ mod tests { assert!(!validate_address("rnBFvgZphmN39GWzUJeUitaP22Fr9be75J", Chain::Xrp)); assert!(validate_address("15e6w4u9nH4Tb9HdJco2Zua4y5DpHb1hHXBKBGkUrLMTpuXo", Chain::Polkadot)); assert!(!validate_address("15e6w4u9nH4Tb9HdJco2Zua4y5DpHb1hHXBKBGkUrLMTpuXj", Chain::Polkadot)); + assert!(validate_address("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", Chain::Bitcoin)); + assert!(!validate_address("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", Chain::Litecoin)); + assert!(validate_address("qpzl3jxkzgvfd9flnd26leud5duv795fnv7vuaha70", Chain::BitcoinCash)); + assert!(validate_address("bitcoincash:qpzl3jxkzgvfd9flnd26leud5duv795fnv7vuaha70", Chain::BitcoinCash)); assert!(validate_address( "addr1q8043m5heeaydnvtmmkyuhe6qv5havvhsf0d26q3jygsspxlyfpyk6yqkw0yhtyvtr0flekj84u64az82cufmqn65zdsylzk23", Chain::Cardano diff --git a/gemstone/src/signer/chain.rs b/gemstone/src/signer/chain.rs index 397cc89ca..793a03e95 100644 --- a/gemstone/src/signer/chain.rs +++ b/gemstone/src/signer/chain.rs @@ -1,6 +1,7 @@ use crate::{GemstoneError, models::transaction::GemSignerInput}; use gem_algorand::AlgorandChainSigner; use gem_aptos::AptosChainSigner; +use gem_bitcoin::signer::BitcoinChainSigner; use gem_cardano::signer::CardanoChainSigner; use gem_cosmos::signer::CosmosChainSigner; use gem_evm::signer::EvmChainSigner; @@ -13,7 +14,7 @@ use gem_sui::signer::SuiChainSigner; use gem_ton::signer::TonChainSigner; use gem_tron::signer::TronChainSigner; use gem_xrp::signer::XrpChainSigner; -use primitives::{Chain, ChainSigner, ChainType, EVMChain, SignerError, SignerInput}; +use primitives::{BitcoinChain, Chain, ChainSigner, ChainType, EVMChain, SignerError, SignerInput}; use zeroize::Zeroizing; #[derive(uniffi::Object)] @@ -41,7 +42,7 @@ impl GemChainSigner { ChainType::Xrp => Box::new(XrpChainSigner), ChainType::Polkadot => Box::new(PolkadotChainSigner), ChainType::Cardano => Box::new(CardanoChainSigner), - _ => todo!("Signer not implemented for chain {:?}", chain), + ChainType::Bitcoin => Box::new(BitcoinChainSigner::new_with_rbf(BitcoinChain::from_chain(chain).unwrap(), true)), }; Self { chain, signer }