diff --git a/Cargo.lock b/Cargo.lock index 511ae8b50..04ad9fca0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -74,7 +74,7 @@ dependencies = [ "k256", "once_cell", "rand 0.8.5", - "secp256k1", + "secp256k1 0.30.0", "serde", "serde_json", "serde_with", @@ -1158,6 +1158,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", +] + [[package]] name = "base64" version = "0.22.1" @@ -1229,12 +1239,44 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bitcoin" +version = "0.32.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e499f9fc0407f50fe98af744ab44fa67d409f76b6772e1689ec8485eb0c0f66" +dependencies = [ + "base58ck", + "bech32", + "bitcoin-internals", + "bitcoin-io", + "bitcoin-units", + "bitcoin_hashes", + "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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5285c8bcaa25876d07f37e3d30c303f2609179716e11d688f51e8f1fe70063e2" +dependencies = [ + "bitcoin-internals", +] + [[package]] name = "bitcoin_hashes" version = "0.14.1" @@ -3033,6 +3075,7 @@ name = "gem_bitcoin" version = "1.0.0" dependencies = [ "async-trait", + "bitcoin", "chain_traits", "chrono", "futures", @@ -3687,6 +3730,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" @@ -6493,6 +6542,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "secp256k1" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" +dependencies = [ + "bitcoin_hashes", + "secp256k1-sys", +] + [[package]] name = "secp256k1" version = "0.30.0" diff --git a/Cargo.toml b/Cargo.toml index 895b306f6..2d0caad30 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -114,6 +114,7 @@ sui-types = { package = "sui-sdk-types", version = "0.2.2", features = [ "serde", ] } k256 = { version = "0.13.4", features = ["ecdsa", "sha256"] } +bitcoin = { version = "0.32", default-features = false, features = ["std"] } uniffi = { version = "0.31.0" } regex = { version = "1.12.3" } diff --git a/crates/gem_aptos/src/signer/chain_signer.rs b/crates/gem_aptos/src/signer/chain_signer.rs index 5647a7a6b..d365fa62c 100644 --- a/crates/gem_aptos/src/signer/chain_signer.rs +++ b/crates/gem_aptos/src/signer/chain_signer.rs @@ -226,7 +226,7 @@ fn gas_unit_price(input: &TransactionLoadInput) -> Result { fn resolve_max_gas_amount(input: &TransactionLoadInput) -> u64 { if let TransactionInputType::Swap(_, _, swap_data) = &input.input_type - && let Some(limit) = &swap_data.data.gas_limit + && let Some(limit) = &swap_data.data.limit && let Ok(parsed) = limit.parse::() { return parsed; diff --git a/crates/gem_bitcoin/Cargo.toml b/crates/gem_bitcoin/Cargo.toml index 51a684cb5..a23fa49b2 100644 --- a/crates/gem_bitcoin/Cargo.toml +++ b/crates/gem_bitcoin/Cargo.toml @@ -7,7 +7,7 @@ publish = false [features] default = [] rpc = ["dep:chain_traits", "dep:gem_client"] -signer = ["dep:signer", "dep:gem_hash", "dep:hex"] +signer = ["dep:signer", "dep:gem_hash", "dep:hex", "dep:bitcoin"] reqwest = ["gem_client/reqwest"] chain_integration_tests = ["rpc", "reqwest", "settings/testkit"] @@ -28,6 +28,7 @@ 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 } [dev-dependencies] tokio = { workspace = true, features = ["macros", "rt"] } diff --git a/crates/gem_bitcoin/src/provider/preload.rs b/crates/gem_bitcoin/src/provider/preload.rs index 5cce4332c..6445e3619 100644 --- a/crates/gem_bitcoin/src/provider/preload.rs +++ b/crates/gem_bitcoin/src/provider/preload.rs @@ -7,7 +7,9 @@ use std::error::Error; use gem_client::Client; use primitives::{ - BitcoinChain, FeePriority, FeeRate, GasPriceType, TransactionInputType, TransactionLoadData, TransactionLoadInput, TransactionLoadMetadata, TransactionPreloadInput, UTXO, + BitcoinChain, FeePriority, FeeRate, GasPriceType, SwapProvider, TransactionFee, TransactionInputType, TransactionLoadData, TransactionLoadInput, TransactionLoadMetadata, + TransactionPreloadInput, UTXO, + swap::SwapQuoteDataType, }; use crate::models::Address; @@ -32,10 +34,11 @@ impl ChainTransactionLoad for BitcoinClient { } async fn get_transaction_load(&self, input: TransactionLoadInput) -> Result> { - Ok(TransactionLoadData { - fee: input.default_fee(), - metadata: input.metadata, - }) + let fee = match swap_provider_fee(&input) { + Some(result) => result?, + None => input.default_fee(), + }; + Ok(TransactionLoadData { fee, metadata: input.metadata }) } async fn get_transaction_fee_rates(&self, _input_type: TransactionInputType) -> Result, Box> { @@ -64,6 +67,19 @@ impl BitcoinClient { } } +fn swap_provider_fee(input: &TransactionLoadInput) -> Option> { + let swap_data = input.input_type.get_swap_data().ok()?; + if swap_data.data.data_type != SwapQuoteDataType::Contract { + return None; + } + let provider = swap_data.quote.provider_data.provider; + if !matches!(provider, SwapProvider::Relay) { + return None; + } + let limit = swap_data.data.limit.as_deref()?; + Some(limit.parse::().map(TransactionFee::new_from_fee).map_err(|_| "invalid swap fee")) +} + fn calculate_fee_rate(fee_sat_per_kb: &str, minimum_byte_fee: u32) -> Result> { let rate = BigNumberFormatter::value_from_amount(fee_sat_per_kb, 8)?.parse::()? / 1000.0; let minimum_byte_fee = minimum_byte_fee as f64; 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..51e38e2cc --- /dev/null +++ b/crates/gem_bitcoin/src/signer/chain_signer.rs @@ -0,0 +1,215 @@ +use std::collections::BTreeMap; + +use bitcoin::{ + NetworkKind, PrivateKey, Psbt, PublicKey, Witness, + bip32::{DerivationPath, Fingerprint, KeySource}, + secp256k1::Secp256k1, +}; +use primitives::{ChainSigner, SignerError, SwapProvider, TransactionLoadInput}; + +#[derive(Default)] +pub struct BitcoinChainSigner; + +impl ChainSigner for BitcoinChainSigner { + fn sign_swap(&self, input: &TransactionLoadInput, private_key: &[u8]) -> Result, SignerError> { + let swap_data = input.input_type.get_swap_data().map_err(SignerError::invalid_input)?; + let provider = &swap_data.quote.provider_data.provider; + + match provider { + SwapProvider::Relay => { + let psbt_hex = &swap_data.data.data; + let signed = sign_psbt(psbt_hex, private_key)?; + Ok(vec![signed]) + } + SwapProvider::Thorchain | SwapProvider::Chainflip => Err(SignerError::signing_error("bitcoin transfer swaps not yet implemented in Rust")), + other => Err(SignerError::signing_error(format!("unsupported swap provider for Bitcoin: {:?}", other))), + } + } +} + +fn sign_psbt(psbt_hex: &str, private_key: &[u8]) -> Result { + let psbt_bytes = hex::decode(psbt_hex).map_err(|e| SignerError::invalid_input(format!("hex decode: {e}")))?; + let psbt = Psbt::deserialize(&psbt_bytes).map_err(|e| SignerError::invalid_input(format!("psbt parse: {e}")))?; + sign_and_finalize(psbt, private_key) +} + +fn sign_and_finalize(mut psbt: Psbt, private_key: &[u8]) -> Result { + let secp = Secp256k1::new(); + let key = PrivateKey::from_slice(private_key, NetworkKind::Main).map_err(|e| SignerError::invalid_input(format!("private key: {e}")))?; + let pub_key = PublicKey::from_private_key(&secp, &key); + let (x_only_key, _parity) = pub_key.inner.x_only_public_key(); + + for input in &mut psbt.inputs { + let is_taproot = input.witness_utxo.as_ref().is_some_and(|utxo| utxo.script_pubkey.is_p2tr()); + + if is_taproot { + if input.tap_internal_key.is_none() { + input.tap_internal_key = Some(x_only_key); + } + if input.tap_key_origins.is_empty() { + let key_source: KeySource = (Fingerprint::default(), DerivationPath::master()); + input.tap_key_origins.insert(x_only_key, (vec![], key_source)); + } + } else if input.bip32_derivation.is_empty() { + input.bip32_derivation.insert(pub_key.inner, (Fingerprint::default(), DerivationPath::master())); + } + } + + let mut keys = BTreeMap::new(); + keys.insert(pub_key, key); + + psbt.sign(&keys, &secp).map_err(|(_ok, errors)| { + let messages: Vec = errors.into_iter().map(|(idx, e)| format!("input {idx}: {e}")).collect(); + SignerError::signing_error(messages.join(", ")) + })?; + + finalize_inputs(&mut psbt, &pub_key)?; + + let transaction = psbt.extract_tx_unchecked_fee_rate(); + Ok(hex::encode(bitcoin::consensus::serialize(&transaction))) +} + +fn finalize_inputs(psbt: &mut Psbt, pub_key: &PublicKey) -> Result<(), SignerError> { + for (idx, input) in psbt.inputs.iter_mut().enumerate() { + let script = input + .witness_utxo + .as_ref() + .ok_or_else(|| SignerError::signing_error(format!("missing witness_utxo for input {idx}")))? + .script_pubkey + .clone(); + + let witness = if script.is_p2wpkh() { + let sig = input + .partial_sigs + .get(pub_key) + .ok_or_else(|| SignerError::signing_error(format!("missing signature for input {idx}")))?; + let mut w = Witness::new(); + w.push(sig.to_vec()); + w.push(pub_key.to_bytes()); + w + } else if script.is_p2tr() { + let sig = input + .tap_key_sig + .ok_or_else(|| SignerError::signing_error(format!("missing taproot signature for input {idx}")))?; + let mut w = Witness::new(); + w.push(sig.to_vec()); + w + } else { + return Err(SignerError::signing_error(format!("unsupported script type for input {idx}"))); + }; + + input.final_script_witness = Some(witness); + input.partial_sigs = BTreeMap::new(); + input.sighash_type = None; + input.redeem_script = None; + input.witness_script = None; + input.bip32_derivation = BTreeMap::new(); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testkit::TEST_PRIVATE_KEY; + use bitcoin::{ + Amount, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Txid, + hashes::Hash, + secp256k1::Secp256k1, + }; + + fn build_p2wpkh_psbt(pub_key: &PublicKey) -> Psbt { + let wpkh = ScriptBuf::new_p2wpkh(&pub_key.wpubkey_hash().unwrap()); + let utxo = TxOut { value: Amount::from_sat(100_000), script_pubkey: wpkh }; + + let tx = Transaction { + version: bitcoin::transaction::Version(2), + lock_time: bitcoin::absolute::LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint::new(Txid::all_zeros(), 0), + script_sig: ScriptBuf::new(), + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, + witness: Witness::new(), + }], + output: vec![TxOut { + value: Amount::from_sat(90_000), + script_pubkey: ScriptBuf::new_p2wpkh(&pub_key.wpubkey_hash().unwrap()), + }], + }; + + let mut psbt = Psbt::from_unsigned_tx(tx).unwrap(); + psbt.inputs[0].witness_utxo = Some(utxo); + psbt + } + + fn build_p2tr_psbt(key: &PrivateKey) -> Psbt { + let secp = Secp256k1::new(); + let (x_only, _) = key.public_key(&secp).inner.x_only_public_key(); + let script = ScriptBuf::new_p2tr(&secp, x_only, None); + let utxo = TxOut { value: Amount::from_sat(100_000), script_pubkey: script.clone() }; + + let tx = Transaction { + version: bitcoin::transaction::Version(2), + lock_time: bitcoin::absolute::LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint::new(Txid::all_zeros(), 0), + script_sig: ScriptBuf::new(), + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, + witness: Witness::new(), + }], + output: vec![TxOut { value: Amount::from_sat(90_000), script_pubkey: script }], + }; + + let mut psbt = Psbt::from_unsigned_tx(tx).unwrap(); + psbt.inputs[0].witness_utxo = Some(utxo); + psbt + } + + #[test] + fn test_sign_p2wpkh_psbt() { + let secret = TEST_PRIVATE_KEY; + let secp = Secp256k1::new(); + let key = PrivateKey::from_slice(&secret, NetworkKind::Main).unwrap(); + let pub_key = PublicKey::from_private_key(&secp, &key); + + let psbt = build_p2wpkh_psbt(&pub_key); + let psbt_hex = hex::encode(psbt.serialize()); + + let result = sign_psbt(&psbt_hex, &secret).unwrap(); + assert!(!result.is_empty()); + + let tx_bytes = hex::decode(&result).unwrap(); + let tx: Transaction = bitcoin::consensus::deserialize(&tx_bytes).unwrap(); + assert_eq!(tx.input.len(), 1); + assert!(!tx.input[0].witness.is_empty()); + } + + #[test] + fn test_sign_p2tr_psbt() { + let secret = TEST_PRIVATE_KEY; + let key = PrivateKey::from_slice(&secret, NetworkKind::Main).unwrap(); + + let psbt = build_p2tr_psbt(&key); + let psbt_hex = hex::encode(psbt.serialize()); + + let result = sign_psbt(&psbt_hex, &secret).unwrap(); + assert!(!result.is_empty()); + + let tx_bytes = hex::decode(&result).unwrap(); + let tx: Transaction = bitcoin::consensus::deserialize(&tx_bytes).unwrap(); + assert_eq!(tx.input.len(), 1); + assert!(!tx.input[0].witness.is_empty()); + } + + #[test] + fn test_sign_psbt_invalid_hex() { + let result = sign_psbt("not_hex!", &TEST_PRIVATE_KEY); + assert!(result.is_err()); + } + + #[test] + fn test_sign_psbt_invalid_psbt() { + let result = sign_psbt("deadbeef", &TEST_PRIVATE_KEY); + assert!(result.is_err()); + } +} diff --git a/crates/gem_bitcoin/src/signer/mod.rs b/crates/gem_bitcoin/src/signer/mod.rs index 56171d6cf..990f9a3e3 100644 --- a/crates/gem_bitcoin/src/signer/mod.rs +++ b/crates/gem_bitcoin/src/signer/mod.rs @@ -1,6 +1,8 @@ +mod chain_signer; mod encoding; mod signature; mod types; +pub use chain_signer::BitcoinChainSigner; pub use signature::sign_personal; pub use types::{BitcoinSignDataResponse, BitcoinSignMessageData}; diff --git a/crates/gem_bitcoin/src/testkit/mod.rs b/crates/gem_bitcoin/src/testkit/mod.rs index c9fa0bff1..1ed5f7004 100644 --- a/crates/gem_bitcoin/src/testkit/mod.rs +++ b/crates/gem_bitcoin/src/testkit/mod.rs @@ -1 +1,3 @@ pub mod transaction_mock; + +pub const TEST_PRIVATE_KEY: [u8; 32] = [0xab; 32]; diff --git a/crates/gem_evm/src/provider/preload_mapper.rs b/crates/gem_evm/src/provider/preload_mapper.rs index 489b46eca..e2752a5e1 100644 --- a/crates/gem_evm/src/provider/preload_mapper.rs +++ b/crates/gem_evm/src/provider/preload_mapper.rs @@ -186,7 +186,7 @@ pub fn get_extra_fee_gas_limit(input: &TransactionLoadInput) -> Result { if swap_data.data.approval.is_some() { - if let Some(ref gas_limit) = swap_data.data.gas_limit { + if let Some(ref gas_limit) = swap_data.data.limit { Ok(BigInt::from_str_radix(gas_limit, 10)?) } else { Ok(BigInt::from(0)) diff --git a/crates/gem_solana/src/provider/preload_mapper.rs b/crates/gem_solana/src/provider/preload_mapper.rs index 81939ba06..252591b97 100644 --- a/crates/gem_solana/src/provider/preload_mapper.rs +++ b/crates/gem_solana/src/provider/preload_mapper.rs @@ -44,7 +44,7 @@ fn get_gas_limit(input_type: &TransactionInputType) -> BigInt { | TransactionInputType::Earn(_, _, _) => BigInt::from(100_000), TransactionInputType::Swap(_, _, swap_data) => swap_data .data - .gas_limit + .limit .as_ref() .and_then(|x| x.parse::().ok()) .map(BigInt::from) @@ -122,7 +122,7 @@ mod tests { fn mock_swap_data_with_gas_limit(provider: SwapProvider, gas_limit: Option<&str>) -> SwapData { let mut data = SwapData::mock_with_provider(provider); - data.data.gas_limit = gas_limit.map(|s| s.to_string()); + data.data.limit = gas_limit.map(|s| s.to_string()); data } diff --git a/crates/gem_solana/src/signer/chain_signer.rs b/crates/gem_solana/src/signer/chain_signer.rs index e19ddb27b..2baf43814 100644 --- a/crates/gem_solana/src/signer/chain_signer.rs +++ b/crates/gem_solana/src/signer/chain_signer.rs @@ -12,7 +12,7 @@ impl ChainSigner for SolanaChainSigner { let tx_base64 = &swap_data.data.data; let unit_price: u64 = input.gas_price.unit_price().to_u64().unwrap_or(0); - let gas_limit = swap_data.data.gas_limit_as_u32().map_err(SignerError::invalid_input)?; + let gas_limit = swap_data.data.limit_as_u32().map_err(SignerError::invalid_input)?; let signed = Self::sign_transaction(tx_base64, private_key, unit_price, gas_limit)?; diff --git a/crates/primitives/src/swap/approval.rs b/crates/primitives/src/swap/approval.rs index 4afe496a2..fb47434a8 100644 --- a/crates/primitives/src/swap/approval.rs +++ b/crates/primitives/src/swap/approval.rs @@ -12,7 +12,7 @@ pub struct ApprovalData { pub value: String, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[typeshare(swift = "Equatable, Hashable, Sendable")] #[serde(rename_all = "lowercase")] pub enum SwapQuoteDataType { @@ -30,15 +30,16 @@ pub struct SwapQuoteData { pub data: String, pub memo: Option, pub approval: Option, - pub gas_limit: Option, + #[serde(alias = "gasLimit")] + pub limit: Option, } impl SwapQuoteData { - pub fn gas_limit_as_u32(&self) -> Result { - self.gas_limit.as_ref().ok_or("gas_limit is required")?.parse().map_err(|_| "invalid gas_limit") + pub fn limit_as_u32(&self) -> Result { + self.limit.as_ref().ok_or("limit is required")?.parse().map_err(|_| "invalid limit") } - pub fn new_contract(to: String, value: String, data: String, approval: Option, gas_limit: Option) -> Self { + pub fn new_contract(to: String, value: String, data: String, approval: Option, limit: Option) -> Self { Self { to, data_type: SwapQuoteDataType::Contract, @@ -46,7 +47,7 @@ impl SwapQuoteData { data, memo: None, approval, - gas_limit, + limit, } } @@ -58,7 +59,7 @@ impl SwapQuoteData { data: "".to_string(), memo, approval: None, - gas_limit: None, + limit: None, } } } diff --git a/crates/primitives/src/testkit/swap_mock.rs b/crates/primitives/src/testkit/swap_mock.rs index 59e9afdc2..05c6b4e09 100644 --- a/crates/primitives/src/testkit/swap_mock.rs +++ b/crates/primitives/src/testkit/swap_mock.rs @@ -56,12 +56,12 @@ impl SwapQuoteData { data: "0x".to_string(), memo: None, approval: None, - gas_limit: Some("21000".to_string()), + limit: Some("21000".to_string()), } } - pub fn mock_with_gas_limit(gas_limit: Option) -> Self { - SwapQuoteData { gas_limit, ..Self::mock() } + pub fn mock_with_limit(limit: Option) -> Self { + SwapQuoteData { limit, ..Self::mock() } } } diff --git a/crates/swapper/src/near_intents/provider.rs b/crates/swapper/src/near_intents/provider.rs index 79979dc87..569e01f42 100644 --- a/crates/swapper/src/near_intents/provider.rs +++ b/crates/swapper/src/near_intents/provider.rs @@ -610,14 +610,14 @@ mod swap_integration_tests { to_asset: SwapperQuoteAsset::from(AssetId::from_chain(Chain::Near)), wallet_address: "GBZXN7PIRZGNMHGA3RSSOEV56YXG54FSNTJDGQI3GHDVBKSXRZ5B6KJT".to_string(), destination_address: "test.near".to_string(), - value: "1000000".to_string(), + value: "12000000".to_string(), mode: SwapperMode::ExactIn, options, }; let quote = match provider.get_quote(&request).await { Ok(quote) => quote, - Err(SwapperError::ComputeQuoteError(_)) => return Ok(()), + Err(SwapperError::ComputeQuoteError(_) | SwapperError::InputAmountError { .. }) => return Ok(()), Err(error) => return Err(error), }; let quote_data = match provider.get_quote_data("e, FetchQuoteData::None).await { @@ -626,7 +626,7 @@ mod swap_integration_tests { Err(error) => return Err(error), }; - assert!(!quote_data.data.is_empty(), "expected deposit memo for Stellar swaps via Near Intents"); + assert!(quote_data.memo.is_some(), "expected deposit memo for Stellar swaps via Near Intents"); Ok(()) } diff --git a/crates/swapper/src/proxy/provider.rs b/crates/swapper/src/proxy/provider.rs index a8298b7e1..5b6930565 100644 --- a/crates/swapper/src/proxy/provider.rs +++ b/crates/swapper/src/proxy/provider.rs @@ -49,7 +49,7 @@ where pub async fn check_approval_and_limit(&self, quote: &Quote, quote_data: &SwapQuoteData) -> Result<(Option, Option), SwapperError> { if let Some(ref approval) = quote_data.approval { - return Ok((Some(approval.clone()), quote_data.gas_limit.clone())); + return Ok((Some(approval.clone()), quote_data.limit.clone())); } let request = "e.request; @@ -71,7 +71,7 @@ where .await } } - _ => Ok((None, quote_data.gas_limit.clone())), + _ => Ok((None, quote_data.limit.clone())), } } @@ -285,14 +285,14 @@ mod tests { async fn test_solana_preserves_provider_gas_limit() { let provider = mock_provider(SwapperProvider::Okx); let quote = Quote::mock(Chain::Solana, None); - let data = SwapQuoteData::mock_with_gas_limit(Some("550000".to_string())); + let data = SwapQuoteData::mock_with_limit(Some("550000".to_string())); let (approval, gas_limit) = provider.check_approval_and_limit("e, &data).await.unwrap(); assert!(approval.is_none()); assert_eq!(gas_limit, Some("550000".to_string())); - let data = SwapQuoteData::mock_with_gas_limit(None); + let data = SwapQuoteData::mock_with_limit(None); let (approval, gas_limit) = provider.check_approval_and_limit("e, &data).await.unwrap(); @@ -304,7 +304,7 @@ mod tests { async fn test_evm_native_ignores_provider_gas_limit() { let provider = mock_provider(SwapperProvider::Mayan); let quote = Quote::mock(Chain::Ethereum, None); - let data = SwapQuoteData::mock_with_gas_limit(Some("550000".to_string())); + let data = SwapQuoteData::mock_with_limit(Some("550000".to_string())); let (approval, gas_limit) = provider.check_approval_and_limit("e, &data).await.unwrap(); diff --git a/crates/swapper/src/relay/asset.rs b/crates/swapper/src/relay/asset.rs index 5bbf37d39..7a2ff0cd9 100644 --- a/crates/swapper/src/relay/asset.rs +++ b/crates/swapper/src/relay/asset.rs @@ -1,40 +1,35 @@ use std::sync::LazyLock; use gem_evm::address::ethereum_address_checksum; -use gem_solana::{SYSTEM_PROGRAM_ID, WSOL_TOKEN_ADDRESS}; use primitives::{ - AssetId, Chain, ChainType, + AssetId, Chain, asset_constants::{ USDC_ARB_ASSET_ID, USDC_HYPEREVM_ASSET_ID, USDC_OP_ASSET_ID, USDC_POLYGON_ASSET_ID, USDT_ARB_ASSET_ID, USDT_HYPEREVM_ASSET_ID, USDT_LINEA_ASSET_ID, USDT_OP_ASSET_ID, USDT_POLYGON_ASSET_ID, USDT_ZKSYNC_ASSET_ID, }, }; +use super::chain::{BITCOIN_CURRENCY, RelayChain}; use crate::{SwapperChainAsset, SwapperError, asset::*}; -fn is_native_currency(chain: Chain, currency: &str) -> bool { - match chain { - Chain::Bitcoin => true, - Chain::Solana => currency == SYSTEM_PROGRAM_ID || currency == WSOL_TOKEN_ADDRESS, - _ if currency == EVM_ZERO_ADDRESS => true, - _ => false, - } -} - -pub fn map_currency_to_asset_id(chain: Chain, currency: &str) -> AssetId { - if is_native_currency(chain, currency) { - return AssetId::from_chain(chain); - } - if let ChainType::Ethereum = chain.chain_type() - && let Ok(address) = ethereum_address_checksum(currency) - { - return AssetId::from_token(chain, &address); +pub fn map_currency_to_asset_id(relay_chain: RelayChain, currency: &str) -> AssetId { + let chain = relay_chain.to_chain(); + match relay_chain { + RelayChain::Bitcoin => AssetId::from_chain(chain), + RelayChain::Evm(_) => { + if currency == EVM_ZERO_ADDRESS { + AssetId::from_chain(chain) + } else { + let address = ethereum_address_checksum(currency).unwrap_or(currency.to_string()); + AssetId::from_token(chain, &address) + } + } } - AssetId::from_token(chain, currency) } pub static SUPPORTED_CHAINS: LazyLock> = LazyLock::new(|| { vec![ + SwapperChainAsset::Assets(Chain::Bitcoin, vec![AssetId::from_chain(Chain::Bitcoin)]), SwapperChainAsset::Assets( Chain::Ethereum, vec![ @@ -72,16 +67,16 @@ pub static SUPPORTED_CHAINS: LazyLock> = LazyLock::new(|| ] }); -pub fn asset_to_currency(asset_id: &AssetId) -> Result { - match asset_id.chain.chain_type() { - ChainType::Ethereum => { +pub fn asset_to_currency(asset_id: &AssetId, relay_chain: &RelayChain) -> Result { + match relay_chain { + RelayChain::Bitcoin => Ok(BITCOIN_CURRENCY.to_string()), + RelayChain::Evm(_) => { if asset_id.is_native() { Ok(EVM_ZERO_ADDRESS.to_string()) } else { asset_id.token_id.clone().ok_or(SwapperError::NotSupportedAsset) } } - _ => Err(SwapperError::NotSupportedChain), } } @@ -92,19 +87,30 @@ mod tests { #[test] fn test_evm_native_asset() { - let result = asset_to_currency(&AssetId::from_chain(Chain::Ethereum)).unwrap(); + let result = asset_to_currency(&AssetId::from_chain(Chain::Ethereum), &RelayChain::Evm(primitives::chain_evm::EVMChain::Ethereum)).unwrap(); assert_eq!(result, EVM_ZERO_ADDRESS); } #[test] fn test_evm_token_asset() { let token_address = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; - let result = asset_to_currency(&AssetId::from_token(Chain::Ethereum, token_address)).unwrap(); + let result = asset_to_currency( + &AssetId::from_token(Chain::Ethereum, token_address), + &RelayChain::Evm(primitives::chain_evm::EVMChain::Ethereum), + ) + .unwrap(); assert_eq!(result, token_address); } #[test] - fn test_non_evm_asset_not_supported() { - assert_eq!(asset_to_currency(&AssetId::from_chain(Chain::Solana)), Err(SwapperError::NotSupportedChain)); + fn test_bitcoin_asset() { + let result = asset_to_currency(&AssetId::from_chain(Chain::Bitcoin), &RelayChain::Bitcoin).unwrap(); + assert_eq!(result, BITCOIN_CURRENCY); + } + + #[test] + fn test_non_supported_chain() { + // RelayChain can't represent Solana, so this is tested at the from_chain level + assert!(RelayChain::from_chain(&Chain::Solana).is_none()); } } diff --git a/crates/swapper/src/relay/chain.rs b/crates/swapper/src/relay/chain.rs index c92f0d999..8935f530c 100644 --- a/crates/swapper/src/relay/chain.rs +++ b/crates/swapper/src/relay/chain.rs @@ -1,28 +1,40 @@ use primitives::{Chain, chain_evm::EVMChain}; +pub const BITCOIN_CHAIN_ID: u64 = 8253038; +pub const BITCOIN_CURRENCY: &str = "bc1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqmql8k8"; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RelayChain { + Bitcoin, Evm(EVMChain), } impl RelayChain { pub fn chain_id(&self) -> u64 { match self { + Self::Bitcoin => BITCOIN_CHAIN_ID, Self::Evm(chain) => chain.chain_id(), } } pub fn from_chain(chain: &Chain) -> Option { + if *chain == Chain::Bitcoin { + return Some(Self::Bitcoin); + } Some(Self::Evm(EVMChain::from_chain(*chain)?)) } pub fn to_chain(self) -> Chain { match self { + Self::Bitcoin => Chain::Bitcoin, Self::Evm(chain) => chain.to_chain(), } } pub fn from_chain_id(chain_id: u64) -> Option { + if chain_id == BITCOIN_CHAIN_ID { + return Some(Self::Bitcoin); + } Some(Self::Evm(EVMChain::all().into_iter().find(|chain| chain.chain_id() == chain_id)?)) } } @@ -35,8 +47,15 @@ mod tests { fn test_from_chain() { assert_eq!(RelayChain::from_chain(&Chain::Ethereum).unwrap().chain_id(), EVMChain::Ethereum.chain_id()); assert_eq!(RelayChain::from_chain(&Chain::SmartChain).unwrap().chain_id(), EVMChain::SmartChain.chain_id()); + assert_eq!(RelayChain::from_chain(&Chain::Bitcoin), Some(RelayChain::Bitcoin)); assert!(RelayChain::from_chain(&Chain::Solana).is_none()); - assert!(RelayChain::from_chain(&Chain::Bitcoin).is_none()); assert!(RelayChain::from_chain(&Chain::Cosmos).is_none()); } + + #[test] + fn test_from_chain_id() { + assert_eq!(RelayChain::from_chain_id(BITCOIN_CHAIN_ID), Some(RelayChain::Bitcoin)); + assert_eq!(RelayChain::from_chain_id(1).unwrap().to_chain(), Chain::Ethereum); + assert!(RelayChain::from_chain_id(999999999).is_none()); + } } diff --git a/crates/swapper/src/relay/mapper.rs b/crates/swapper/src/relay/mapper.rs index 925b1c5dd..904945861 100644 --- a/crates/swapper/src/relay/mapper.rs +++ b/crates/swapper/src/relay/mapper.rs @@ -4,14 +4,18 @@ use super::{ DEFAULT_SWAP_GAS_LIMIT, asset::map_currency_to_asset_id, chain::RelayChain, - model::{RelayQuoteResponse, RelayRequest, StepData}, + model::{RelayQuoteResponse, RelayRequest, StepData, gas_fee_amount}, }; use crate::{SwapResult, SwapperError, SwapperProvider, SwapperQuoteData}; -pub fn map_quote_data(quote_response: &RelayQuoteResponse, approval: Option) -> Result { +pub fn map_quote_data(quote_response: &RelayQuoteResponse, from_value: &str, approval: Option) -> Result { let step_data = quote_response.step_data().ok_or(SwapperError::InvalidRoute)?; match step_data { + StepData::Bitcoin(btc) => { + let fee_limit = gas_fee_amount("e_response.fees); + Ok(SwapperQuoteData::new_contract(String::new(), from_value.to_string(), btc.psbt.clone(), None, fee_limit)) + } StepData::Evm(evm) => { let gas_limit = approval.as_ref().map(|_| DEFAULT_SWAP_GAS_LIMIT.to_string()); let call_data = evm.data.clone().unwrap_or_default(); @@ -24,8 +28,8 @@ pub fn map_swap_result(request: &RelayRequest) -> SwapResult { let metadata = request.data.as_ref().and_then(|d| d.metadata.as_ref()).and_then(|m| { let currency_in = m.currency_in.as_ref()?; let currency_out = m.currency_out.as_ref()?; - let from_chain = RelayChain::from_chain_id(currency_in.currency.chain_id)?.to_chain(); - let to_chain = RelayChain::from_chain_id(currency_out.currency.chain_id)?.to_chain(); + let from_chain = RelayChain::from_chain_id(currency_in.currency.chain_id)?; + let to_chain = RelayChain::from_chain_id(currency_out.currency.chain_id)?; Some(TransactionSwapMetadata { from_asset: map_currency_to_asset_id(from_chain, ¤cy_in.currency.address), from_value: currency_in.amount.clone()?, @@ -44,7 +48,9 @@ pub fn map_swap_result(request: &RelayRequest) -> SwapResult { #[cfg(test)] mod tests { use super::*; - use crate::relay::model::{CurrencyAmount, QuoteDetails, RelayCurrencyDetail, RelayQuoteResponse, RelayRequest, RelayRequestMetadata, RelayStatus, Step}; + use crate::relay::model::{ + CurrencyAmount, QuoteDetails, RelayCurrencyDetail, RelayFeeAmount, RelayFees, RelayQuoteResponse, RelayRequest, RelayRequestMetadata, RelayStatus, Step, + }; use primitives::{AssetId, Chain, swap::SwapStatus}; #[test] @@ -59,13 +65,13 @@ mod tests { fees: None, }; - let result = map_quote_data("e_response, None).unwrap(); + let result = map_quote_data("e_response, "1000000000000000000", None).unwrap(); assert_eq!(result.to, "0xrouter"); assert_eq!(result.value, "1000000000000000000"); assert_eq!(result.data, "0xabcdef"); assert!(result.approval.is_none()); - assert!(result.gas_limit.is_none()); + assert!(result.limit.is_none()); } #[test] @@ -85,11 +91,56 @@ mod tests { value: "1000".to_string(), }; - let result = map_quote_data("e_response, Some(approval.clone())).unwrap(); + let result = map_quote_data("e_response, "1000000000000000000", Some(approval.clone())).unwrap(); assert_eq!(result.to, "0xrouter"); assert_eq!(result.approval, Some(approval)); - assert_eq!(result.gas_limit, Some(DEFAULT_SWAP_GAS_LIMIT.to_string())); + assert_eq!(result.limit, Some(DEFAULT_SWAP_GAS_LIMIT.to_string())); + } + + #[test] + fn test_map_bitcoin_quote_data() { + let psbt = "70736274ff0100abcdef"; + let quote_response = RelayQuoteResponse { + steps: vec![Step::mock_bitcoin(psbt)], + details: QuoteDetails { + currency_out: CurrencyAmount { amount: "0".to_string() }, + time_estimate: None, + swap_impact: None, + }, + fees: None, + }; + + let result = map_quote_data("e_response, "2000000", None).unwrap(); + + assert_eq!(result.to, ""); + assert_eq!(result.value, "2000000"); + assert_eq!(result.data, psbt); + assert!(result.approval.is_none()); + assert!(result.limit.is_none()); + } + + #[test] + fn test_map_bitcoin_quote_data_with_gas_fee() { + let psbt = "70736274ff0100abcdef"; + let quote_response = RelayQuoteResponse { + steps: vec![Step::mock_bitcoin(psbt)], + details: QuoteDetails { + currency_out: CurrencyAmount { amount: "0".to_string() }, + time_estimate: None, + swap_impact: None, + }, + fees: Some(RelayFees { + gas: Some(RelayFeeAmount { + amount: Some("15000".to_string()), + }), + }), + }; + + let result = map_quote_data("e_response, "2000000", None).unwrap(); + + assert_eq!(result.data, psbt); + assert_eq!(result.limit, Some("15000".to_string())); } #[test] @@ -113,6 +164,26 @@ mod tests { assert_eq!(metadata.provider, Some("relay".to_string())); } + #[test] + fn test_map_swap_result_evm_to_btc() { + use super::super::chain::BITCOIN_CHAIN_ID; + let usdt_address = "0xdAC17F958D2ee523a2206206994597C13D831ec7"; + let request = RelayRequest::mock( + RelayStatus::Completed, + Some(RelayRequestMetadata { + currency_in: Some(RelayCurrencyDetail::mock(usdt_address, 1, "10000000")), + currency_out: Some(RelayCurrencyDetail::mock("bc1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqmql8k8", BITCOIN_CHAIN_ID, "50000")), + }), + ); + + let result = map_swap_result(&request); + + assert_eq!(result.status, SwapStatus::Completed); + let metadata = result.metadata.unwrap(); + assert_eq!(metadata.from_asset, AssetId::from_token(Chain::Ethereum, usdt_address)); + assert_eq!(metadata.to_asset, AssetId::from_chain(Chain::Bitcoin)); + } + #[test] fn test_map_swap_result_status() { let pending = map_swap_result(&RelayRequest::mock(RelayStatus::Pending, None)); @@ -136,6 +207,6 @@ mod tests { fees: None, }; - assert!(map_quote_data("e_response, None).is_err()); + assert!(map_quote_data("e_response, "0", None).is_err()); } } diff --git a/crates/swapper/src/relay/model.rs b/crates/swapper/src/relay/model.rs index c05f13971..b40febb25 100644 --- a/crates/swapper/src/relay/model.rs +++ b/crates/swapper/src/relay/model.rs @@ -90,7 +90,7 @@ impl Step { } pub fn to_address(&self) -> Option { - Some(self.step_data()?.to_address()) + self.step_data()?.to_address() } } @@ -103,13 +103,15 @@ pub struct StepItem { #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(untagged)] pub enum StepData { + Bitcoin(BitcoinStepData), Evm(EvmStepData), } impl StepData { - pub fn to_address(&self) -> String { + pub fn to_address(&self) -> Option { match self { - Self::Evm(evm) => evm.to.clone(), + Self::Evm(evm) => Some(evm.to.clone()), + Self::Bitcoin(_) => None, } } } @@ -122,6 +124,12 @@ pub struct EvmStepData { pub value: String, } +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BitcoinStepData { + pub psbt: String, +} + #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct QuoteDetails { @@ -270,6 +278,10 @@ impl RelayChainsResponse { } } +pub fn gas_fee_amount(fees: &Option) -> Option { + fees.as_ref()?.gas.as_ref()?.amount.clone() +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/swapper/src/relay/provider.rs b/crates/swapper/src/relay/provider.rs index 79e2ddbae..e5c066c24 100644 --- a/crates/swapper/src/relay/provider.rs +++ b/crates/swapper/src/relay/provider.rs @@ -69,8 +69,8 @@ where let from_asset_id = request.from_asset.asset_id(); let to_asset_id = request.to_asset.asset_id(); - let origin_currency = asset_to_currency(&from_asset_id)?; - let destination_currency = asset_to_currency(&to_asset_id)?; + let origin_currency = asset_to_currency(&from_asset_id, &from_chain)?; + let destination_currency = asset_to_currency(&to_asset_id, &to_chain)?; let app_fees = resolve_app_fees(request); let from_value = resolve_max_quote_value(request)?; @@ -119,7 +119,7 @@ where let from_asset_id = quote.request.from_asset.asset_id(); let approval = self.check_evm_approval(quote, &response, &from_asset_id).await?; - mapper::map_quote_data(&response, approval) + mapper::map_quote_data(&response, "e.from_value, approval) } async fn get_swap_result(&self, _chain: Chain, transaction_hash: &str) -> Result { @@ -233,4 +233,31 @@ mod swap_integration_tests { Ok(()) } + + #[tokio::test] + async fn test_relay_btc_to_eth() -> Result<(), Box> { + use crate::asset::ETHEREUM_USDC_TOKEN_ID; + + let provider = Arc::new(NativeProvider::default()); + let relay = Relay::new(provider); + + let request = QuoteRequest { + from_asset: SwapperQuoteAsset::from(AssetId::from_chain(Chain::Bitcoin)), + to_asset: SwapperQuoteAsset::from(AssetId::from_token(Chain::Ethereum, ETHEREUM_USDC_TOKEN_ID)), + wallet_address: "bc1q4vxn43l44h30nkluqfxd9eckf45vr2awz38lwa".to_string(), + destination_address: "0x514BCb1F9AAbb904e6106Bd1052B66d2706dBbb7".to_string(), + value: "2000000".to_string(), + mode: SwapperMode::ExactIn, + options: Options::new_with_slippage(100.into()), + }; + + let quote = relay.get_quote(&request).await?; + let quote_data = relay.get_quote_data("e, FetchQuoteData::None).await?; + + assert_eq!(quote.from_value, request.value); + assert!(!quote.to_value.is_empty()); + assert!(!quote_data.data.is_empty()); + + Ok(()) + } } diff --git a/crates/swapper/src/relay/testkit.rs b/crates/swapper/src/relay/testkit.rs index 6b86b8a89..643c71a6b 100644 --- a/crates/swapper/src/relay/testkit.rs +++ b/crates/swapper/src/relay/testkit.rs @@ -1,4 +1,4 @@ -use super::model::{EvmStepData, RelayCurrency, RelayCurrencyDetail, RelayRequest, RelayRequestData, RelayRequestMetadata, RelayStatus, Step, StepData, StepItem}; +use super::model::{BitcoinStepData, EvmStepData, RelayCurrency, RelayCurrencyDetail, RelayRequest, RelayRequestData, RelayRequestMetadata, RelayStatus, Step, StepData, StepItem}; impl RelayRequest { pub fn mock(status: RelayStatus, metadata: Option) -> Self { @@ -36,6 +36,16 @@ impl Step { } } + pub fn mock_bitcoin(psbt: &str) -> Self { + Self { + id: "deposit".to_string(), + kind: "transaction".to_string(), + items: Some(vec![StepItem { + data: Some(StepData::Bitcoin(BitcoinStepData { psbt: psbt.to_string() })), + }]), + } + } + pub fn mock_empty(id: &str, kind: &str) -> Self { Self { id: id.to_string(), diff --git a/crates/swapper/src/swapper.rs b/crates/swapper/src/swapper.rs index 4cdd5d657..be1ae4319 100644 --- a/crates/swapper/src/swapper.rs +++ b/crates/swapper/src/swapper.rs @@ -233,8 +233,8 @@ impl GemSwapper { pub async fn get_quote_data(&self, quote: &Quote, data: FetchQuoteData) -> Result { let provider = self.get_swapper_by_provider("e.data.provider.id)?; let mut quote_data = provider.get_quote_data(quote, data).await?; - if let Some(gas_limit) = quote_data.gas_limit.take() { - quote_data.gas_limit = Some(Self::apply_gas_limit_multiplier("e.request.from_asset.chain(), gas_limit)); + if let Some(limit) = quote_data.limit.take() { + quote_data.limit = Some(Self::apply_gas_limit_multiplier("e.request.from_asset.chain(), limit)); } Ok(quote_data) } diff --git a/crates/swapper/src/thorchain/quote_data_mapper.rs b/crates/swapper/src/thorchain/quote_data_mapper.rs index bd191d27e..70e18e4ea 100644 --- a/crates/swapper/src/thorchain/quote_data_mapper.rs +++ b/crates/swapper/src/thorchain/quote_data_mapper.rs @@ -84,7 +84,7 @@ mod tests { assert_eq!(result.value, "0"); assert!(result.data.starts_with("0x")); assert_eq!(result.memo, None); - assert_eq!(result.gas_limit, None); + assert_eq!(result.limit, None); } #[test] @@ -102,7 +102,7 @@ mod tests { assert_eq!(result.value, "1000"); assert_eq!(result.data, "0x6d656d6f"); assert_eq!(result.memo, None); - assert_eq!(result.gas_limit, None); + assert_eq!(result.limit, None); } #[test] @@ -113,7 +113,7 @@ mod tests { assert_eq!(result.value, "1000"); assert_eq!(result.data, ""); assert_eq!(result.memo, Some("memo".to_string())); - assert_eq!(result.gas_limit, None); + assert_eq!(result.limit, None); } #[test] @@ -136,7 +136,7 @@ mod tests { assert_eq!(result.to, "0xD37BbE5744D730a1d98d8DC97c42F0Ca46aD7146"); assert_eq!(result.value, "0"); assert_eq!(result.approval, approval); - assert_eq!(result.gas_limit, Some("90000".to_string())); + assert_eq!(result.limit, Some("90000".to_string())); } #[test] @@ -152,6 +152,6 @@ mod tests { assert_eq!(result.to, "0xinbound"); assert_eq!(result.value, "1000"); - assert_eq!(result.gas_limit, None); + assert_eq!(result.limit, None); } } diff --git a/gemstone/src/models/swap.rs b/gemstone/src/models/swap.rs index 3719ce528..dca9abaae 100644 --- a/gemstone/src/models/swap.rs +++ b/gemstone/src/models/swap.rs @@ -48,7 +48,7 @@ pub struct GemSwapQuoteData { pub data: String, pub memo: Option, pub approval: Option, - pub gas_limit: Option, + pub limit: Option, } #[uniffi::remote(Record)] diff --git a/gemstone/src/signer/chain.rs b/gemstone/src/signer/chain.rs index 070f954b0..53f4fb36d 100644 --- a/gemstone/src/signer/chain.rs +++ b/gemstone/src/signer/chain.rs @@ -1,5 +1,6 @@ use crate::{GemstoneError, models::transaction::GemTransactionLoadInput}; use gem_aptos::AptosChainSigner; +use gem_bitcoin::signer::BitcoinChainSigner; use gem_hypercore::signer::HyperCoreSigner; use gem_solana::signer::SolanaChainSigner; use gem_sui::signer::SuiChainSigner; @@ -17,6 +18,7 @@ impl GemChainSigner { #[uniffi::constructor] pub fn new(chain: Chain) -> Self { let signer: Box = match chain { + Chain::Bitcoin => Box::new(BitcoinChainSigner), Chain::Aptos => Box::new(AptosChainSigner), Chain::HyperCore => Box::new(HyperCoreSigner), Chain::Sui => Box::new(SuiChainSigner),