diff --git a/Cargo.lock b/Cargo.lock index 7f9a8687d..fa40ba5f2 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" @@ -3034,6 +3076,7 @@ name = "gem_bitcoin" version = "1.0.0" dependencies = [ "async-trait", + "bitcoin", "chain_traits", "chrono", "futures", @@ -3690,6 +3733,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" @@ -6496,6 +6545,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 463c8fd8d..deed02e63 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -115,6 +115,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_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..5d462533f 100644 --- a/crates/gem_bitcoin/src/provider/preload.rs +++ b/crates/gem_bitcoin/src/provider/preload.rs @@ -7,7 +7,8 @@ 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 +33,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 +66,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.gas_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..634cce8e0 --- /dev/null +++ b/crates/gem_bitcoin/src/signer/chain_signer.rs @@ -0,0 +1,23 @@ +use primitives::{ChainSigner, SignerError, SwapProvider, TransactionLoadInput}; + +use super::psbt::sign_psbt; + +#[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))), + } + } +} diff --git a/crates/gem_bitcoin/src/signer/mod.rs b/crates/gem_bitcoin/src/signer/mod.rs index 56171d6cf..4abb3c3ea 100644 --- a/crates/gem_bitcoin/src/signer/mod.rs +++ b/crates/gem_bitcoin/src/signer/mod.rs @@ -1,6 +1,9 @@ +mod chain_signer; mod encoding; +mod psbt; 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/signer/psbt.rs b/crates/gem_bitcoin/src/signer/psbt.rs new file mode 100644 index 000000000..4c37d45ba --- /dev/null +++ b/crates/gem_bitcoin/src/signer/psbt.rs @@ -0,0 +1,162 @@ +use std::collections::BTreeMap; + +use bitcoin::{ + NetworkKind, PrivateKey, Psbt, PublicKey, Witness, + bip32::{DerivationPath, Fingerprint, KeySource}, + secp256k1::Secp256k1, +}; +use primitives::SignerError; + +pub 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 mut psbt = Psbt::deserialize(&psbt_bytes).map_err(|e| SignerError::invalid_input(format!("psbt parse: {e}")))?; + + 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); + + prepare_inputs(&mut psbt, &pub_key); + sign(&mut psbt, &pub_key, &key, &secp)?; + finalize_inputs(&mut psbt, &pub_key)?; + + let tx = psbt.extract_tx_unchecked_fee_rate(); + Ok(hex::encode(bitcoin::consensus::serialize(&tx))) +} + +fn prepare_inputs(psbt: &mut Psbt, pub_key: &PublicKey) { + let (x_only_key, _) = pub_key.inner.x_only_public_key(); + let default_origin: KeySource = (Fingerprint::default(), DerivationPath::master()); + + 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 { + input.tap_internal_key.get_or_insert(x_only_key); + if input.tap_key_origins.is_empty() { + input.tap_key_origins.insert(x_only_key, (vec![], default_origin.clone())); + } + } else if input.bip32_derivation.is_empty() { + input.bip32_derivation.insert(pub_key.inner, default_origin.clone()); + } + } +} + +fn sign(psbt: &mut Psbt, pub_key: &PublicKey, key: &PrivateKey, secp: &Secp256k1) -> Result<(), SignerError> { + let keys = BTreeMap::from([(*pub_key, *key)]); + psbt.sign(&keys, secp).map(|_| ()).map_err(|(_ok, errors)| { + let messages: Vec = errors.into_iter().map(|(idx, e)| format!("input {idx}: {e}")).collect(); + SignerError::signing_error(messages.join(", ")) + }) +} + +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; + + let witness = build_witness(input, pub_key, script, idx)?; + input.final_script_witness = Some(witness); + input.partial_sigs.clear(); + input.sighash_type = None; + input.redeem_script = None; + input.witness_script = None; + input.bip32_derivation.clear(); + } + Ok(()) +} + +fn build_witness(input: &bitcoin::psbt::Input, pub_key: &PublicKey, script: &bitcoin::ScriptBuf, idx: usize) -> Result { + 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()); + Ok(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()); + Ok(w) + } else { + Err(SignerError::signing_error(format!("unsupported script type for input {idx}"))) + } +} + +#[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_test_psbt(script_pubkey: ScriptBuf) -> Psbt { + let utxo = TxOut { + value: Amount::from_sat(100_000), + script_pubkey: script_pubkey.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, + }], + }; + let mut psbt = Psbt::from_unsigned_tx(tx).unwrap(); + psbt.inputs[0].witness_utxo = Some(utxo); + psbt + } + + fn sign_and_verify(psbt: Psbt) { + let psbt_hex = hex::encode(psbt.serialize()); + let result = sign_psbt(&psbt_hex, &TEST_PRIVATE_KEY).unwrap(); + assert!(!result.is_empty()); + + let tx: Transaction = bitcoin::consensus::deserialize(&hex::decode(&result).unwrap()).unwrap(); + assert_eq!(tx.input.len(), 1); + assert!(!tx.input[0].witness.is_empty()); + } + + #[test] + fn test_sign_p2wpkh_psbt() { + let secp = Secp256k1::new(); + let key = PrivateKey::from_slice(&TEST_PRIVATE_KEY, NetworkKind::Main).unwrap(); + let pub_key = PublicKey::from_private_key(&secp, &key); + let script = ScriptBuf::new_p2wpkh(&pub_key.wpubkey_hash().unwrap()); + + sign_and_verify(build_test_psbt(script)); + } + + #[test] + fn test_sign_p2tr_psbt() { + let secp = Secp256k1::new(); + let key = PrivateKey::from_slice(&TEST_PRIVATE_KEY, NetworkKind::Main).unwrap(); + let (x_only, _) = key.public_key(&secp).inner.x_only_public_key(); + let script = ScriptBuf::new_p2tr(&secp, x_only, None); + + sign_and_verify(build_test_psbt(script)); + } + + #[test] + fn test_sign_psbt_invalid_hex() { + assert!(sign_psbt("not_hex!", &TEST_PRIVATE_KEY).is_err()); + } + + #[test] + fn test_sign_psbt_invalid_psbt() { + assert!(sign_psbt("deadbeef", &TEST_PRIVATE_KEY).is_err()); + } +} 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/primitives/src/swap/approval.rs b/crates/primitives/src/swap/approval.rs index 4afe496a2..7cb18a6ea 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,6 +30,7 @@ pub struct SwapQuoteData { pub data: String, pub memo: Option, pub approval: Option, + #[serde(alias = "gasLimit")] pub gas_limit: Option, } diff --git a/crates/signer/src/eip712/hash_impl.rs b/crates/signer/src/eip712/hash_impl.rs index 90da9d1a2..3de38c394 100644 --- a/crates/signer/src/eip712/hash_impl.rs +++ b/crates/signer/src/eip712/hash_impl.rs @@ -1,6 +1,6 @@ -use primitives::SignerError; use alloy_primitives::hex; use gem_hash::keccak::keccak256; +use primitives::SignerError; use serde_json::{Map, Value}; use std::borrow::Cow; use std::collections::BTreeSet; diff --git a/crates/signer/src/eip712/parse.rs b/crates/signer/src/eip712/parse.rs index edf85dd1b..5251c6dc1 100644 --- a/crates/signer/src/eip712/parse.rs +++ b/crates/signer/src/eip712/parse.rs @@ -1,5 +1,5 @@ -use primitives::SignerError; use alloy_primitives::{I256, U256}; +use primitives::SignerError; use serde_json::Value; use std::str::FromStr; diff --git a/crates/signer/src/secp256k1.rs b/crates/signer/src/secp256k1.rs index 039ae90f9..c357dd95e 100644 --- a/crates/signer/src/secp256k1.rs +++ b/crates/signer/src/secp256k1.rs @@ -1,5 +1,5 @@ -use primitives::SignerError; use k256::ecdsa::SigningKey as SecpSigningKey; +use primitives::SignerError; pub(crate) fn sign_digest(digest: &[u8], private_key: &[u8]) -> Result, SignerError> { let signing_key = SecpSigningKey::from_slice(private_key).map_err(|_| SignerError::invalid_input("Invalid Secp256k1 private key"))?; 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/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..608b517ab 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,7 +65,7 @@ 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"); @@ -85,13 +91,58 @@ 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())); } + #[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.gas_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.gas_limit, Some("15000".to_string())); + } + #[test] fn test_map_swap_result_evm_to_evm() { let request = RelayRequest::mock( @@ -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 4efc2aac0..b276d9c34 100644 --- a/crates/swapper/src/swapper.rs +++ b/crates/swapper/src/swapper.rs @@ -491,9 +491,11 @@ mod tests { let providers = SwapProvider::all(); let ascending = false; - let mut quotes = [Quote::mock_with_provider(SwapperProvider::UniswapV3, "101"), + let mut quotes = [ + Quote::mock_with_provider(SwapperProvider::UniswapV3, "101"), Quote::mock_with_provider(SwapperProvider::UniswapV4, "100"), - Quote::mock_with_provider(SwapperProvider::PancakeswapV3, "102")]; + Quote::mock_with_provider(SwapperProvider::PancakeswapV3, "102"), + ]; quotes.sort_by(|a, b| { let a_amount = a.to_value.parse::().unwrap_or_default(); @@ -511,9 +513,11 @@ mod tests { let providers = SwapProvider::all(); let ascending = false; - let mut quotes = [Quote::mock_with_provider(SwapperProvider::Okx, "100"), + let mut quotes = [ + Quote::mock_with_provider(SwapperProvider::Okx, "100"), Quote::mock_with_provider(SwapperProvider::UniswapV3, "100"), - Quote::mock_with_provider(SwapperProvider::Thorchain, "100")]; + Quote::mock_with_provider(SwapperProvider::Thorchain, "100"), + ]; quotes.sort_by(|a, b| { let a_amount = a.to_value.parse::().unwrap_or_default(); @@ -531,8 +535,10 @@ mod tests { let providers = SwapProvider::all(); let ascending = false; - let mut quotes = [Quote::mock_with_provider(SwapperProvider::Thorchain, "100"), - Quote::mock_with_provider(SwapperProvider::Okx, "110")]; + let mut quotes = [ + Quote::mock_with_provider(SwapperProvider::Thorchain, "100"), + Quote::mock_with_provider(SwapperProvider::Okx, "110"), + ]; quotes.sort_by(|a, b| { let a_amount = a.to_value.parse::().unwrap_or_default(); 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),