From fea71fdec6a6a784f18b80c4f3ea2fe3ef31b1d3 Mon Sep 17 00:00:00 2001 From: 0xh3rman <119309671+0xh3rman@users.noreply.github.com> Date: Mon, 16 Mar 2026 00:08:49 +0900 Subject: [PATCH 01/18] Add Cosmos signer and tx protobuf encoding Introduce a Cosmos signing implementation and protobuf-based tx encoding. Adds a new gem_cosmos signer module (chain_signer, protobuf, transaction) that can parse MsgExecuteContract and IBC MsgTransfer envelopes, build tx bodies/auth_info, and sign/encode transactions; includes unit tests and JSON testdata. Make the signer crate an optional feature of gem_cosmos and wire it into models and constants. Add low-level protobuf helpers, transaction encoding utilities, and expose secp256k1 public key helper from signer. Update primitives: add Cosmos denom mapping and extend SwapProvider with Squid; adjust preload gas estimation to account for non-Thorchain swap providers. Add serde_serializers for i32/u32 string-or-number deserialization and small SignerError/decoding error mappings and formatting helpers across signer/primitives for consistent error handling. --- Cargo.lock | 1 + crates/gem_cosmos/Cargo.toml | 3 + crates/gem_cosmos/src/constants.rs | 2 + crates/gem_cosmos/src/lib.rs | 3 + crates/gem_cosmos/src/models/contract.rs | 13 ++ crates/gem_cosmos/src/models/ibc.rs | 36 +++++ crates/gem_cosmos/src/models/message.rs | 63 +++++++++ crates/gem_cosmos/src/models/mod.rs | 9 ++ .../gem_cosmos/src/provider/preload_mapper.rs | 9 +- crates/gem_cosmos/src/signer/chain_signer.rs | 126 ++++++++++++++++++ crates/gem_cosmos/src/signer/mod.rs | 5 + crates/gem_cosmos/src/signer/protobuf.rs | 70 ++++++++++ crates/gem_cosmos/src/signer/transaction.rs | 107 +++++++++++++++ .../testdata/swap_execute_contract.json | 14 ++ .../testdata/swap_ibc_transfer.json | 15 +++ crates/primitives/src/chain_cosmos.rs | 12 ++ crates/primitives/src/signer_error.rs | 12 ++ crates/primitives/src/swap_provider.rs | 7 +- crates/serde_serializers/src/i32.rs | 96 +++++++++++++ crates/serde_serializers/src/lib.rs | 4 + crates/serde_serializers/src/u32.rs | 95 +++++++++++++ crates/signer/src/eip712/mod.rs | 8 +- crates/signer/src/lib.rs | 1 + crates/signer/src/secp256k1.rs | 7 +- crates/swapper/src/models.rs | 2 +- crates/swapper/src/proxy/mod.rs | 1 + crates/swapper/src/proxy/provider.rs | 26 ++++ crates/swapper/src/proxy/provider_factory.rs | 4 + crates/swapper/src/proxy/squid/client.rs | 25 ++++ crates/swapper/src/proxy/squid/mod.rs | 4 + crates/swapper/src/proxy/squid/model.rs | 40 ++++++ crates/swapper/src/swapper.rs | 1 + gemstone/Cargo.toml | 2 +- gemstone/src/block_explorer/mod.rs | 3 +- gemstone/src/gem_swapper/remote_types.rs | 1 + gemstone/src/signer/chain.rs | 4 + 36 files changed, 818 insertions(+), 13 deletions(-) create mode 100644 crates/gem_cosmos/src/models/contract.rs create mode 100644 crates/gem_cosmos/src/models/ibc.rs create mode 100644 crates/gem_cosmos/src/signer/chain_signer.rs create mode 100644 crates/gem_cosmos/src/signer/mod.rs create mode 100644 crates/gem_cosmos/src/signer/protobuf.rs create mode 100644 crates/gem_cosmos/src/signer/transaction.rs create mode 100644 crates/gem_cosmos/testdata/swap_execute_contract.json create mode 100644 crates/gem_cosmos/testdata/swap_ibc_transfer.json create mode 100644 crates/serde_serializers/src/i32.rs create mode 100644 crates/serde_serializers/src/u32.rs create mode 100644 crates/swapper/src/proxy/squid/client.rs create mode 100644 crates/swapper/src/proxy/squid/mod.rs create mode 100644 crates/swapper/src/proxy/squid/model.rs diff --git a/Cargo.lock b/Cargo.lock index 5eaa7485e..e3b99deb2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3111,6 +3111,7 @@ dependencies = [ "serde_json", "serde_serializers", "settings", + "signer", "tokio", ] diff --git a/crates/gem_cosmos/Cargo.toml b/crates/gem_cosmos/Cargo.toml index c9cd3f3df..dbbcb3079 100644 --- a/crates/gem_cosmos/Cargo.toml +++ b/crates/gem_cosmos/Cargo.toml @@ -21,6 +21,8 @@ chain_traits = { path = "../chain_traits", optional = true } futures = { workspace = true, optional = true } number_formatter = { path = "../number_formatter", optional = true } +signer = { path = "../signer", optional = true } + num-bigint = { workspace = true } [features] @@ -34,6 +36,7 @@ rpc = [ "dep:futures", "dep:number_formatter", ] +signer = ["dep:signer", "dep:serde_json"] reqwest = ["gem_client/reqwest"] chain_integration_tests = ["rpc", "reqwest", "settings/testkit"] diff --git a/crates/gem_cosmos/src/constants.rs b/crates/gem_cosmos/src/constants.rs index f01b3c026..12f04e0da 100644 --- a/crates/gem_cosmos/src/constants.rs +++ b/crates/gem_cosmos/src/constants.rs @@ -6,6 +6,8 @@ pub const MESSAGE_REDELEGATE: &str = "/cosmos.staking.v1beta1.MsgBeginRedelegate pub const MESSAGE_SEND_BETA: &str = "/cosmos.bank.v1beta1.MsgSend"; pub const MESSAGE_REWARD_BETA: &str = "/cosmos.distribution.v1beta1.MsgWithdrawDelegatorReward"; pub const MESSAGE_SEND: &str = "/types.MsgSend"; // thorchain +pub const MESSAGE_EXECUTE_CONTRACT: &str = "/cosmwasm.wasm.v1.MsgExecuteContract"; +pub const MESSAGE_IBC_TRANSFER: &str = "/ibc.applications.transfer.v1.MsgTransfer"; pub const SUPPORTED_MESSAGES: &[&str] = &[ MESSAGE_SEND, diff --git a/crates/gem_cosmos/src/lib.rs b/crates/gem_cosmos/src/lib.rs index 6846f8fb4..d634a9cc3 100644 --- a/crates/gem_cosmos/src/lib.rs +++ b/crates/gem_cosmos/src/lib.rs @@ -7,4 +7,7 @@ pub mod rpc; #[cfg(feature = "rpc")] pub mod provider; +#[cfg(feature = "signer")] +pub mod signer; + pub mod models; diff --git a/crates/gem_cosmos/src/models/contract.rs b/crates/gem_cosmos/src/models/contract.rs new file mode 100644 index 000000000..a44c20a16 --- /dev/null +++ b/crates/gem_cosmos/src/models/contract.rs @@ -0,0 +1,13 @@ +use serde::Deserialize; + +use super::Coin; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExecuteContractValue { + #[serde(default)] + pub sender: String, + pub contract: String, + pub msg: String, + pub funds: Vec, +} diff --git a/crates/gem_cosmos/src/models/ibc.rs b/crates/gem_cosmos/src/models/ibc.rs new file mode 100644 index 000000000..a30889346 --- /dev/null +++ b/crates/gem_cosmos/src/models/ibc.rs @@ -0,0 +1,36 @@ +use super::Coin; +use serde::Deserialize; +use serde_serializers::deserialize_u64_from_str_or_int; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct IbcTransferValue { + pub source_port: String, + pub source_channel: String, + pub token: Coin, + pub sender: String, + pub receiver: String, + #[serde(default, deserialize_with = "deserialize_u64_from_str_or_int")] + pub timeout_timestamp: u64, + #[serde(default)] + pub memo: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_deserialize_timeout_as_number() { + let json = r#"{"sourcePort":"transfer","sourceChannel":"channel-0","token":{"denom":"uatom","amount":"1000000"},"sender":"cosmos1test","receiver":"osmo1test","timeoutTimestamp":1773382733549000000,"memo":"test"}"#; + let v: IbcTransferValue = serde_json::from_str(json).unwrap(); + assert_eq!(v.timeout_timestamp, 1773382733549000000); + } + + #[test] + fn test_deserialize_timeout_as_string() { + let json = r#"{"sourcePort":"transfer","sourceChannel":"channel-0","token":{"denom":"uatom","amount":"1000000"},"sender":"cosmos1test","receiver":"osmo1test","timeoutTimestamp":"1773382733549000000","memo":"test"}"#; + let v: IbcTransferValue = serde_json::from_str(json).unwrap(); + assert_eq!(v.timeout_timestamp, 1773382733549000000); + } +} diff --git a/crates/gem_cosmos/src/models/message.rs b/crates/gem_cosmos/src/models/message.rs index 4eb5ca1c8..e3930a8fa 100644 --- a/crates/gem_cosmos/src/models/message.rs +++ b/crates/gem_cosmos/src/models/message.rs @@ -1,6 +1,10 @@ use serde::{Deserialize, Serialize}; use crate::constants; +#[cfg(feature = "signer")] +use crate::constants::{MESSAGE_EXECUTE_CONTRACT, MESSAGE_IBC_TRANSFER}; +#[cfg(feature = "signer")] +use super::{ExecuteContractValue, IbcTransferValue}; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "@type")] @@ -90,3 +94,62 @@ impl MsgSend { Some(value) } } + +#[cfg(feature = "signer")] +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MessageEnvelope { + pub type_url: String, + pub value: serde_json::Value, +} + +#[cfg(feature = "signer")] +pub enum CosmosMessage { + ExecuteContract { + sender: String, + contract: String, + msg: Vec, + funds: Vec, + }, + IbcTransfer { + source_port: String, + source_channel: String, + token: Coin, + sender: String, + receiver: String, + timeout_timestamp: u64, + memo: String, + }, +} + +#[cfg(feature = "signer")] +impl CosmosMessage { + pub fn parse(data: &str) -> Result { + let envelope: MessageEnvelope = serde_json::from_str(data).map_err(|e| format!("invalid swap data JSON: {e}"))?; + + match envelope.type_url.as_str() { + MESSAGE_EXECUTE_CONTRACT => { + let v: ExecuteContractValue = serde_json::from_value(envelope.value).map_err(|e| format!("invalid MsgExecuteContract: {e}"))?; + Ok(Self::ExecuteContract { + sender: v.sender, + contract: v.contract, + msg: v.msg.into_bytes(), + funds: v.funds, + }) + } + MESSAGE_IBC_TRANSFER => { + let v: IbcTransferValue = serde_json::from_value(envelope.value).map_err(|e| format!("invalid MsgTransfer: {e}"))?; + Ok(Self::IbcTransfer { + source_port: v.source_port, + source_channel: v.source_channel, + token: v.token, + sender: v.sender, + receiver: v.receiver, + timeout_timestamp: v.timeout_timestamp, + memo: v.memo, + }) + } + other => Err(format!("unsupported cosmos message type: {other}")), + } + } +} diff --git a/crates/gem_cosmos/src/models/mod.rs b/crates/gem_cosmos/src/models/mod.rs index fd44be081..04a8871c0 100644 --- a/crates/gem_cosmos/src/models/mod.rs +++ b/crates/gem_cosmos/src/models/mod.rs @@ -5,9 +5,18 @@ pub mod staking; pub mod staking_osmosis; pub mod transaction; +#[cfg(feature = "signer")] +pub mod contract; +#[cfg(feature = "signer")] +pub mod ibc; pub use account::*; pub use block::*; pub use message::*; pub use staking::*; pub use staking_osmosis::*; pub use transaction::*; + +#[cfg(feature = "signer")] +pub use contract::*; +#[cfg(feature = "signer")] +pub use ibc::*; diff --git a/crates/gem_cosmos/src/provider/preload_mapper.rs b/crates/gem_cosmos/src/provider/preload_mapper.rs index d4c503e72..5bf2bb956 100644 --- a/crates/gem_cosmos/src/provider/preload_mapper.rs +++ b/crates/gem_cosmos/src/provider/preload_mapper.rs @@ -1,5 +1,5 @@ use num_bigint::BigInt; -use primitives::{GasPriceType, StakeType, TransactionFee, TransactionInputType, chain_cosmos::CosmosChain}; +use primitives::{GasPriceType, StakeType, SwapProvider, TransactionFee, TransactionInputType, chain_cosmos::CosmosChain}; fn get_fee(chain: CosmosChain, input_type: &TransactionInputType) -> BigInt { match chain { @@ -68,7 +68,7 @@ fn get_fee(chain: CosmosChain, input_type: &TransactionInputType) -> BigInt { } } -fn get_gas_limit(input_type: &TransactionInputType, _chain: CosmosChain) -> u64 { +fn get_gas_limit(input_type: &TransactionInputType, chain: CosmosChain) -> u64 { match input_type { TransactionInputType::Transfer(_) | TransactionInputType::Deposit(_) @@ -78,7 +78,10 @@ fn get_gas_limit(input_type: &TransactionInputType, _chain: CosmosChain) -> u64 | TransactionInputType::Generic(_, _, _) | TransactionInputType::Perpetual(_, _) | TransactionInputType::Earn(_, _, _) => 200_000, - TransactionInputType::Swap(_, _, _) => 200_000, + TransactionInputType::Swap(_, _, swap_data) => match swap_data.quote.provider_data.provider { + SwapProvider::Thorchain => 200_000, + _ => 2_000_000, + }, TransactionInputType::Stake(_, operation) => match operation { StakeType::Stake(_) | StakeType::Unstake(_) => 1_000_000, StakeType::Redelegate(_) => 1_250_000, diff --git a/crates/gem_cosmos/src/signer/chain_signer.rs b/crates/gem_cosmos/src/signer/chain_signer.rs new file mode 100644 index 000000000..3b60a50c9 --- /dev/null +++ b/crates/gem_cosmos/src/signer/chain_signer.rs @@ -0,0 +1,126 @@ +use std::str::FromStr; + +use base64::{Engine, engine::general_purpose::STANDARD}; +use gem_hash::sha2::sha256; +use primitives::chain_cosmos::CosmosChain; +use primitives::{ChainSigner, SignerError, TransactionLoadInput, TransactionLoadMetadata}; +use signer::{SignatureScheme, Signer}; + +use crate::models::{Coin, CosmosMessage}; + +use super::transaction::*; + +const BASE_FEE_GAS_UNITS: u64 = 200_000; +const GAS_BUFFER_NUMERATOR: u64 = 13; +const GAS_BUFFER_DENOMINATOR: u64 = 10; + +#[derive(Default)] +pub struct CosmosChainSigner; + +pub struct CosmosTxParams<'a> { + pub body_bytes: Vec, + pub chain_id: &'a str, + pub account_number: u64, + pub sequence: u64, + pub fee_coins: Vec, + pub gas_limit: u64, +} + +pub fn encode_and_sign_tx(params: &CosmosTxParams, private_key: &[u8]) -> Result { + let pubkey_bytes = signer::secp256k1_public_key(private_key)?; + let auth_info_bytes = encode_auth_info(&pubkey_bytes, params.sequence, ¶ms.fee_coins, params.gas_limit); + let sign_doc_bytes = encode_sign_doc(¶ms.body_bytes, &auth_info_bytes, params.chain_id, params.account_number); + + let digest = sha256(&sign_doc_bytes); + let mut signature = Signer::sign_digest(SignatureScheme::Secp256k1, digest.to_vec(), private_key.to_vec())?; + if signature.len() < 64 { + return Err(SignerError::signing_error("secp256k1 signature too short")); + } + signature.truncate(64); + + let tx_raw = encode_tx_raw(¶ms.body_bytes, &auth_info_bytes, &signature); + let tx_base64 = STANDARD.encode(&tx_raw); + Ok(serde_json::json!({ + "mode": "BROADCAST_MODE_SYNC", + "tx_bytes": tx_base64, + }) + .to_string()) +} + +impl ChainSigner for CosmosChainSigner { + 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 (account_number, sequence, chain_id) = match &input.metadata { + TransactionLoadMetadata::Cosmos { account_number, sequence, chain_id } => (*account_number, *sequence, chain_id.as_str()), + _ => return Err(SignerError::invalid_input("expected Cosmos metadata")), + }; + + let chain = CosmosChain::from_str(input.input_type.get_asset().chain.as_ref()) + .map_err(|_| SignerError::invalid_input("unsupported cosmos chain"))?; + + let message = CosmosMessage::parse(&swap_data.data.data)?; + let body_bytes = encode_tx_body(&[message.encode_as_any()], input.memo.as_deref().unwrap_or("")); + + let gas_limit = swap_data + .data + .gas_limit + .as_ref() + .and_then(|g| g.parse::().ok()) + .filter(|&g| g > 0) + .unwrap_or(BASE_FEE_GAS_UNITS); + let gas_limit = gas_limit * GAS_BUFFER_NUMERATOR / GAS_BUFFER_DENOMINATOR; + + let base_fee: u64 = input.gas_price.gas_price().to_string().parse().unwrap_or(0); + let fee_amount = ((gas_limit as u128 * base_fee as u128 / BASE_FEE_GAS_UNITS as u128) as u64).to_string(); + + let params = CosmosTxParams { + body_bytes, + chain_id, + account_number, + sequence, + fee_coins: vec![Coin { denom: chain.denom().as_ref().to_string(), amount: fee_amount }], + gas_limit, + }; + + Ok(vec![encode_and_sign_tx(¶ms, private_key)?]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_execute_contract() { + let msg = CosmosMessage::parse(include_str!("../../testdata/swap_execute_contract.json")).unwrap(); + match msg { + CosmosMessage::ExecuteContract { contract, funds, .. } => { + assert_eq!(contract, "osmo1contract"); + assert_eq!(funds.len(), 1); + assert_eq!(funds[0].amount, "1000000"); + } + _ => panic!("expected ExecuteContract"), + } + } + + #[test] + fn test_parse_ibc_transfer() { + let msg = CosmosMessage::parse(include_str!("../../testdata/swap_ibc_transfer.json")).unwrap(); + match msg { + CosmosMessage::IbcTransfer { source_port, source_channel, memo, timeout_timestamp, .. } => { + assert_eq!(source_port, "transfer"); + assert_eq!(source_channel, "channel-0"); + assert_eq!(timeout_timestamp, 1773382733549000000); + assert!(!memo.is_empty()); + } + _ => panic!("expected IbcTransfer"), + } + } + + #[test] + fn test_parse_unsupported_type() { + let json = r#"{"typeUrl":"/unknown.MsgType","value":{}}"#; + assert!(CosmosMessage::parse(json).is_err()); + } +} diff --git a/crates/gem_cosmos/src/signer/mod.rs b/crates/gem_cosmos/src/signer/mod.rs new file mode 100644 index 000000000..e9ffec584 --- /dev/null +++ b/crates/gem_cosmos/src/signer/mod.rs @@ -0,0 +1,5 @@ +mod chain_signer; +mod protobuf; +mod transaction; + +pub use chain_signer::CosmosChainSigner; diff --git a/crates/gem_cosmos/src/signer/protobuf.rs b/crates/gem_cosmos/src/signer/protobuf.rs new file mode 100644 index 000000000..3379d4376 --- /dev/null +++ b/crates/gem_cosmos/src/signer/protobuf.rs @@ -0,0 +1,70 @@ +pub fn encode_varint(value: u64) -> Vec { + let mut buf = Vec::new(); + let mut v = value; + while v >= 0x80 { + buf.push((v as u8) | 0x80); + v >>= 7; + } + buf.push(v as u8); + buf +} + +fn field_tag(field_number: u32, wire_type: u8) -> Vec { + encode_varint(((field_number as u64) << 3) | wire_type as u64) +} + +pub fn encode_varint_field(field_number: u32, value: u64) -> Vec { + if value == 0 { + return Vec::new(); + } + [field_tag(field_number, 0), encode_varint(value)].concat() +} + +pub fn encode_bytes_field(field_number: u32, data: &[u8]) -> Vec { + if data.is_empty() { + return Vec::new(); + } + [field_tag(field_number, 2), encode_varint(data.len() as u64), data.to_vec()].concat() +} + +pub fn encode_string_field(field_number: u32, s: &str) -> Vec { + encode_bytes_field(field_number, s.as_bytes()) +} + +pub fn encode_message_field(field_number: u32, msg: &[u8]) -> Vec { + if msg.is_empty() { + return Vec::new(); + } + encode_bytes_field(field_number, msg) +} + +pub fn encode_coin(denom: &str, amount: &str) -> Vec { + [encode_string_field(1, denom), encode_string_field(2, amount)].concat() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_encode_varint() { + assert_eq!(encode_varint(0), vec![0]); + assert_eq!(encode_varint(1), vec![1]); + assert_eq!(encode_varint(127), vec![127]); + assert_eq!(encode_varint(128), vec![0x80, 0x01]); + assert_eq!(encode_varint(300), vec![0xAC, 0x02]); + } + + #[test] + fn test_encode_string_field() { + let result = encode_string_field(1, "test"); + assert_eq!(result, vec![0x0A, 4, b't', b'e', b's', b't']); + } + + #[test] + fn test_empty_fields_omitted() { + assert!(encode_varint_field(1, 0).is_empty()); + assert!(encode_string_field(1, "").is_empty()); + assert!(encode_bytes_field(1, &[]).is_empty()); + } +} diff --git a/crates/gem_cosmos/src/signer/transaction.rs b/crates/gem_cosmos/src/signer/transaction.rs new file mode 100644 index 000000000..63070de67 --- /dev/null +++ b/crates/gem_cosmos/src/signer/transaction.rs @@ -0,0 +1,107 @@ +use crate::models::{Coin, CosmosMessage}; + +use crate::constants::{MESSAGE_EXECUTE_CONTRACT, MESSAGE_IBC_TRANSFER}; + +use super::protobuf::*; + +const SECP256K1_PUBKEY_TYPE_URL: &str = "/cosmos.crypto.secp256k1.PubKey"; +const SIGN_MODE_DIRECT: u64 = 1; + +impl CosmosMessage { + fn type_url(&self) -> &str { + match self { + Self::ExecuteContract { .. } => MESSAGE_EXECUTE_CONTRACT, + Self::IbcTransfer { .. } => MESSAGE_IBC_TRANSFER, + } + } + + fn encode_value(&self) -> Vec { + match self { + Self::ExecuteContract { sender, contract, msg, funds } => { + let fund_fields: Vec = funds.iter().flat_map(|c| encode_message_field(5, &encode_coin(&c.denom, &c.amount))).collect(); + [encode_string_field(1, sender), encode_string_field(2, contract), encode_bytes_field(3, msg), fund_fields].concat() + } + Self::IbcTransfer { source_port, source_channel, token, sender, receiver, timeout_timestamp, memo } => [ + encode_string_field(1, source_port), + encode_string_field(2, source_channel), + encode_message_field(3, &encode_coin(&token.denom, &token.amount)), + encode_string_field(4, sender), + encode_string_field(5, receiver), + encode_varint_field(7, *timeout_timestamp), + encode_string_field(8, memo), + ] + .concat(), + } + } + + pub fn encode_as_any(&self) -> Vec { + [encode_string_field(1, self.type_url()), encode_bytes_field(2, &self.encode_value())].concat() + } +} + +pub fn encode_tx_body(messages: &[Vec], memo: &str) -> Vec { + let msg_fields: Vec = messages.iter().flat_map(|m| encode_message_field(1, m)).collect(); + [msg_fields, encode_string_field(2, memo)].concat() +} + +fn encode_pubkey_any(pubkey_bytes: &[u8]) -> Vec { + [encode_string_field(1, SECP256K1_PUBKEY_TYPE_URL), encode_bytes_field(2, &encode_bytes_field(1, pubkey_bytes))].concat() +} + +fn encode_mode_info_single() -> Vec { + encode_message_field(1, &encode_varint_field(1, SIGN_MODE_DIRECT)) +} + +fn encode_signer_info(pubkey_bytes: &[u8], sequence: u64) -> Vec { + [encode_message_field(1, &encode_pubkey_any(pubkey_bytes)), encode_message_field(2, &encode_mode_info_single()), encode_varint_field(3, sequence)].concat() +} + +fn encode_fee(coins: &[Coin], gas_limit: u64) -> Vec { + let coin_fields: Vec = coins.iter().flat_map(|c| encode_message_field(1, &encode_coin(&c.denom, &c.amount))).collect(); + [coin_fields, encode_varint_field(2, gas_limit)].concat() +} + +pub fn encode_auth_info(pubkey_bytes: &[u8], sequence: u64, fee_coins: &[Coin], gas_limit: u64) -> Vec { + [encode_message_field(1, &encode_signer_info(pubkey_bytes, sequence)), encode_message_field(2, &encode_fee(fee_coins, gas_limit))].concat() +} + +pub fn encode_sign_doc(body_bytes: &[u8], auth_info_bytes: &[u8], chain_id: &str, account_number: u64) -> Vec { + [encode_bytes_field(1, body_bytes), encode_bytes_field(2, auth_info_bytes), encode_string_field(3, chain_id), encode_varint_field(4, account_number)].concat() +} + +pub fn encode_tx_raw(body_bytes: &[u8], auth_info_bytes: &[u8], signature: &[u8]) -> Vec { + [encode_bytes_field(1, body_bytes), encode_bytes_field(2, auth_info_bytes), encode_bytes_field(3, signature)].concat() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_encode_execute_contract() { + let msg = CosmosMessage::ExecuteContract { + sender: "osmo1test".to_string(), + contract: "osmo1contract".to_string(), + msg: b"{\"swap\":{}}".to_vec(), + funds: vec![Coin { denom: "uosmo".to_string(), amount: "1000000".to_string() }], + }; + let any = msg.encode_as_any(); + assert!(!any.is_empty()); + assert!(any.len() > 10); + } + + #[test] + fn test_encode_ibc_transfer() { + let msg = CosmosMessage::IbcTransfer { + source_port: "transfer".to_string(), + source_channel: "channel-0".to_string(), + token: Coin { denom: "uatom".to_string(), amount: "1000000".to_string() }, + sender: "cosmos1test".to_string(), + receiver: "osmo1test".to_string(), + timeout_timestamp: 1773382733549000000, + memo: "{\"ibc_callback\":\"osmo1contract\"}".to_string(), + }; + let any = msg.encode_as_any(); + assert!(!any.is_empty()); + } +} diff --git a/crates/gem_cosmos/testdata/swap_execute_contract.json b/crates/gem_cosmos/testdata/swap_execute_contract.json new file mode 100644 index 000000000..b257705b4 --- /dev/null +++ b/crates/gem_cosmos/testdata/swap_execute_contract.json @@ -0,0 +1,14 @@ +{ + "typeUrl": "/cosmwasm.wasm.v1.MsgExecuteContract", + "value": { + "sender": "osmo1test", + "contract": "osmo1contract", + "msg": "{\"swap\":{}}", + "funds": [ + { + "denom": "uosmo", + "amount": "1000000" + } + ] + } +} diff --git a/crates/gem_cosmos/testdata/swap_ibc_transfer.json b/crates/gem_cosmos/testdata/swap_ibc_transfer.json new file mode 100644 index 000000000..911ceaeda --- /dev/null +++ b/crates/gem_cosmos/testdata/swap_ibc_transfer.json @@ -0,0 +1,15 @@ +{ + "typeUrl": "/ibc.applications.transfer.v1.MsgTransfer", + "value": { + "sourcePort": "transfer", + "sourceChannel": "channel-0", + "token": { + "denom": "uatom", + "amount": "1000000" + }, + "sender": "cosmos1test", + "receiver": "osmo1test", + "timeoutTimestamp": 1773382733549000000, + "memo": "{\"ibc_callback\":\"osmo1\"}" + } +} diff --git a/crates/primitives/src/chain_cosmos.rs b/crates/primitives/src/chain_cosmos.rs index d5fc04ac2..4ffe34372 100644 --- a/crates/primitives/src/chain_cosmos.rs +++ b/crates/primitives/src/chain_cosmos.rs @@ -28,6 +28,18 @@ impl CosmosChain { pub fn as_chain(&self) -> Chain { Chain::from_str(self.as_ref()).unwrap() } + + pub fn denom(&self) -> CosmosDenom { + match self { + Self::Cosmos => CosmosDenom::Uatom, + Self::Osmosis => CosmosDenom::Uosmo, + Self::Celestia => CosmosDenom::Utia, + Self::Thorchain => CosmosDenom::Rune, + Self::Injective => CosmosDenom::Inj, + Self::Sei => CosmosDenom::Usei, + Self::Noble => CosmosDenom::Uusdc, + } + } } #[derive(Copy, Clone, Debug, Serialize, Deserialize, EnumIter, AsRefStr, EnumString)] diff --git a/crates/primitives/src/signer_error.rs b/crates/primitives/src/signer_error.rs index 334646622..2b90e2cad 100644 --- a/crates/primitives/src/signer_error.rs +++ b/crates/primitives/src/signer_error.rs @@ -35,6 +35,18 @@ impl SignerError { } } +impl From<&str> for SignerError { + fn from(value: &str) -> Self { + Self::InvalidInput(value.to_string()) + } +} + +impl From for SignerError { + fn from(value: String) -> Self { + Self::InvalidInput(value) + } +} + impl From for SignerError { fn from(error: serde_json::Error) -> Self { SignerError::InvalidInput(error.to_string()) diff --git a/crates/primitives/src/swap_provider.rs b/crates/primitives/src/swap_provider.rs index e6babc728..551130771 100644 --- a/crates/primitives/src/swap_provider.rs +++ b/crates/primitives/src/swap_provider.rs @@ -27,6 +27,7 @@ pub enum SwapProvider { Relay, Hyperliquid, Orca, + Squid, } impl SwapProvider { @@ -40,7 +41,7 @@ impl SwapProvider { pub fn is_cross_chain(&self) -> bool { match self { - Self::Thorchain | Self::Across | Self::Mayan | Self::Chainflip | Self::NearIntents | Self::Relay | Self::Hyperliquid => true, + Self::Thorchain | Self::Across | Self::Mayan | Self::Chainflip | Self::NearIntents | Self::Relay | Self::Hyperliquid | Self::Squid => true, Self::UniswapV3 | Self::UniswapV4 | Self::PancakeswapV3 @@ -80,6 +81,7 @@ impl SwapProvider { Self::Relay => "Relay", Self::Hyperliquid => "Hyperliquid", Self::Orca => "Orca", + Self::Squid => "Squid", } } @@ -103,7 +105,8 @@ impl SwapProvider { | Self::Aerodrome | Self::Relay | Self::Hyperliquid - | Self::Orca => self.name(), + | Self::Orca + | Self::Squid => self.name(), } } diff --git a/crates/serde_serializers/src/i32.rs b/crates/serde_serializers/src/i32.rs new file mode 100644 index 000000000..154b59b38 --- /dev/null +++ b/crates/serde_serializers/src/i32.rs @@ -0,0 +1,96 @@ +use std::fmt; + +use serde::{Deserialize, Deserializer, de}; + +use crate::visitors::{StringOrNumberFromValue, StringOrNumberVisitor}; + +fn parse_i32_string(value: &str) -> Result { + if let Some(hex_val) = value.strip_prefix("0x") { + u32::from_str_radix(hex_val, 16) + .map(|v| v as i32) + .map_err(|_| format!("Invalid hex string for i32: {value}")) + } else { + value.parse::().map_err(|_| format!("Invalid decimal string for i32: {value}")) + } +} + +fn invalid_number(value: impl fmt::Display) -> String { + format!("Invalid number for i32: {value}") +} + +impl StringOrNumberFromValue for i32 { + const EXPECTING: &'static str = "a string or integer representing i32"; + + fn from_str(value: &str) -> Result { + parse_i32_string(value) + } + + fn from_u64(value: u64) -> Result { + i32::try_from(value).map_err(|_| invalid_number(value)) + } + + fn from_i64(value: i64) -> Result { + i32::try_from(value).map_err(|_| invalid_number(value)) + } +} + +pub fn deserialize_i32_from_str<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let s: String = Deserialize::deserialize(deserializer)?; + parse_i32_string(&s).map_err(de::Error::custom) +} + +pub fn deserialize_i32_from_str_or_int<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + deserializer.deserialize_any(StringOrNumberVisitor::::new()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde::Deserialize; + + #[derive(Debug, Deserialize, PartialEq)] + struct TestStrOrInt { + #[serde(deserialize_with = "deserialize_i32_from_str_or_int")] + pub value: i32, + } + + #[derive(Debug, Deserialize, PartialEq)] + struct TestStr { + #[serde(deserialize_with = "deserialize_i32_from_str")] + pub value: i32, + } + + #[test] + fn test_i32_deserialization() { + let mixed_cases = [ + (r#"{"value": 42}"#, 42i32), + (r#"{"value": -1}"#, -1), + (r#"{"value": "42"}"#, 42), + (r#"{"value": "0x2a"}"#, 42), + (r#"{"value": 0}"#, 0), + ]; + for (json, expected) in mixed_cases { + let result: TestStrOrInt = serde_json::from_str(json).unwrap(); + assert_eq!(result.value, expected); + } + + let str_cases = [ + (r#"{"value": "0"}"#, 0i32), + (r#"{"value": "-100"}"#, -100), + (r#"{"value": "2147483647"}"#, i32::MAX), + ]; + for (json, expected) in str_cases { + let result: TestStr = serde_json::from_str(json).unwrap(); + assert_eq!(result.value, expected); + } + + assert!(serde_json::from_str::(r#"{"value": 2147483648}"#).is_err()); + assert!(serde_json::from_str::(r#"{"value": 1.5}"#).is_err()); + } +} diff --git a/crates/serde_serializers/src/lib.rs b/crates/serde_serializers/src/lib.rs index 70cab1e91..649ee59d0 100644 --- a/crates/serde_serializers/src/lib.rs +++ b/crates/serde_serializers/src/lib.rs @@ -16,6 +16,10 @@ pub mod f64; pub use f64::{deserialize_f64_from_str, deserialize_option_f64_from_str, serialize_f64}; pub mod string; pub use string::{deserialize_string_from_str_or_number, deserialize_string_from_value}; +pub mod i32; +pub use i32::{deserialize_i32_from_str, deserialize_i32_from_str_or_int}; +pub mod u32; +pub use u32::{deserialize_u32_from_str, deserialize_u32_from_str_or_int}; pub mod u64; pub use u64::{deserialize_option_u64_from_str, deserialize_u64_from_str, deserialize_u64_from_str_or_int, serialize_u64}; pub mod u128; diff --git a/crates/serde_serializers/src/u32.rs b/crates/serde_serializers/src/u32.rs new file mode 100644 index 000000000..a3b7930bf --- /dev/null +++ b/crates/serde_serializers/src/u32.rs @@ -0,0 +1,95 @@ +use std::fmt; + +use serde::{Deserialize, Deserializer, de}; + +use crate::visitors::{StringOrNumberFromValue, StringOrNumberVisitor}; + +fn parse_u32_string(value: &str) -> Result { + if let Some(hex_val) = value.strip_prefix("0x") { + u32::from_str_radix(hex_val, 16).map_err(|_| format!("Invalid hex string for u32: {value}")) + } else { + value.parse::().map_err(|_| format!("Invalid decimal string for u32: {value}")) + } +} + +fn invalid_number(value: impl fmt::Display) -> String { + format!("Invalid number for u32: {value}") +} + +impl StringOrNumberFromValue for u32 { + const EXPECTING: &'static str = "a string or integer representing u32"; + + fn from_str(value: &str) -> Result { + parse_u32_string(value) + } + + fn from_u64(value: u64) -> Result { + u32::try_from(value).map_err(|_| invalid_number(value)) + } + + fn from_i64(value: i64) -> Result { + u32::try_from(value).map_err(|_| invalid_number(value)) + } +} + +pub fn deserialize_u32_from_str<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let s: String = Deserialize::deserialize(deserializer)?; + parse_u32_string(&s).map_err(de::Error::custom) +} + +pub fn deserialize_u32_from_str_or_int<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + deserializer.deserialize_any(StringOrNumberVisitor::::new()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde::Deserialize; + + #[derive(Debug, Deserialize, PartialEq)] + struct TestStrOrInt { + #[serde(deserialize_with = "deserialize_u32_from_str_or_int")] + pub value: u32, + } + + #[derive(Debug, Deserialize, PartialEq)] + struct TestStr { + #[serde(deserialize_with = "deserialize_u32_from_str")] + pub value: u32, + } + + #[test] + fn test_u32_deserialization() { + let mixed_cases = [ + (r#"{"value": 42}"#, 42u32), + (r#"{"value": "42"}"#, 42), + (r#"{"value": "0x2a"}"#, 42), + (r#"{"value": 0}"#, 0), + (r#"{"value": "4294967295"}"#, u32::MAX), + ]; + for (json, expected) in mixed_cases { + let result: TestStrOrInt = serde_json::from_str(json).unwrap(); + assert_eq!(result.value, expected); + } + + let str_cases = [ + (r#"{"value": "0"}"#, 0u32), + (r#"{"value": "100"}"#, 100), + (r#"{"value": "0xff"}"#, 255), + ]; + for (json, expected) in str_cases { + let result: TestStr = serde_json::from_str(json).unwrap(); + assert_eq!(result.value, expected); + } + + assert!(serde_json::from_str::(r#"{"value": 4294967296}"#).is_err()); + assert!(serde_json::from_str::(r#"{"value": -1}"#).is_err()); + assert!(serde_json::from_str::(r#"{"value": 1.5}"#).is_err()); + } +} diff --git a/crates/signer/src/eip712/mod.rs b/crates/signer/src/eip712/mod.rs index 97805b442..59362be87 100644 --- a/crates/signer/src/eip712/mod.rs +++ b/crates/signer/src/eip712/mod.rs @@ -14,7 +14,7 @@ mod tests { let json = include_str!("../../testdata/eip712_reference_vector.json"); let our_hash = hash_typed_data(json).expect("hash succeeds"); - let expected = <[u8; 32]>::from_hex("be609aee343fb3c4b28e1df9e632fca64fcfaede20f02e86244efddf30957bd2").expect("valid hex"); + let expected = <[u8; 32]>::from_hex("be609aee343fb3c4b28e1df9e632fca64fcfaede20f02e86244efddf30957bd2").unwrap(); assert_eq!(our_hash, expected); } @@ -22,7 +22,7 @@ mod tests { fn hash_hyperliquid_with_colon_type() { let json = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../gem_hypercore/testdata/hl_eip712_approve_agent.json")); let digest = hash_typed_data(json).expect("hash succeeds"); - let expected = <[u8; 32]>::from_hex("480af9fd3cdc70c2f8a521388be13620d16a0f643d9cffdfbb65cd019cc27537").expect("valid hex"); + let expected = <[u8; 32]>::from_hex("480af9fd3cdc70c2f8a521388be13620d16a0f643d9cffdfbb65cd019cc27537").unwrap(); assert_eq!(digest, expected); } @@ -31,7 +31,7 @@ mod tests { let json = include_str!("../../testdata/eip712_arrays_nested.json"); let digest = hash_typed_data(json).expect("hash succeeds"); - let expected = <[u8; 32]>::from_hex("6acbc18af9d2decca3d38571c2f595b1ebb1b93e9e7b046632df71f6ceb217f9").expect("valid hex"); + let expected = <[u8; 32]>::from_hex("6acbc18af9d2decca3d38571c2f595b1ebb1b93e9e7b046632df71f6ceb217f9").unwrap(); assert_eq!(digest, expected); } @@ -48,7 +48,7 @@ mod tests { let json = include_str!("../../testdata/eip712_signed_integers.json"); let digest = hash_typed_data(json).expect("hash succeeds"); - let expected = <[u8; 32]>::from_hex("10e6c8b7c51b08488a421a5492d4524439470010eb2f8c80c22b9d918d79a5a9").expect("valid hex"); + let expected = <[u8; 32]>::from_hex("10e6c8b7c51b08488a421a5492d4524439470010eb2f8c80c22b9d918d79a5a9").unwrap(); assert_eq!(digest, expected); } } diff --git a/crates/signer/src/lib.rs b/crates/signer/src/lib.rs index 0b9adb0e3..6708b5fee 100644 --- a/crates/signer/src/lib.rs +++ b/crates/signer/src/lib.rs @@ -8,6 +8,7 @@ use zeroize::Zeroizing; use crate::ed25519::{sign_digest as sign_ed25519_digest, signing_key_from_bytes}; use crate::secp256k1::sign_digest as sign_secp256k1_digest; +pub use crate::secp256k1::public_key_from_private as secp256k1_public_key; pub use decode::{decode_private_key, encode_private_key, supports_private_key_import}; pub use eip712::hash_typed_data as hash_eip712; diff --git a/crates/signer/src/secp256k1.rs b/crates/signer/src/secp256k1.rs index 039ae90f9..dc6dbd453 100644 --- a/crates/signer/src/secp256k1.rs +++ b/crates/signer/src/secp256k1.rs @@ -2,7 +2,7 @@ use primitives::SignerError; use k256::ecdsa::SigningKey as SecpSigningKey; 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"))?; + let signing_key = SecpSigningKey::from_slice(private_key).map_err(|_| SignerError::signing_error("Invalid Secp256k1 private key"))?; let (signature, recovery_id) = signing_key .sign_prehash_recoverable(digest) .map_err(|_| SignerError::signing_error("Failed to sign Secp256k1 digest"))?; @@ -11,3 +11,8 @@ pub(crate) fn sign_digest(digest: &[u8], private_key: &[u8]) -> Result, out.push(u8::from(recovery_id)); Ok(out) } + +pub fn public_key_from_private(private_key: &[u8]) -> Result, SignerError> { + let signing_key = SecpSigningKey::from_slice(private_key).map_err(|_| SignerError::invalid_input("Invalid Secp256k1 private key"))?; + Ok(signing_key.verifying_key().to_sec1_bytes().to_vec()) +} diff --git a/crates/swapper/src/models.rs b/crates/swapper/src/models.rs index f8712bda6..b1edb3905 100644 --- a/crates/swapper/src/models.rs +++ b/crates/swapper/src/models.rs @@ -44,7 +44,7 @@ impl ProviderType { | SwapperProvider::Aerodrome | SwapperProvider::Orca | SwapperProvider::Okx => SwapProviderMode::OnChain, - SwapperProvider::Mayan | SwapperProvider::Chainflip | SwapperProvider::NearIntents => SwapProviderMode::CrossChain, + SwapperProvider::Mayan | SwapperProvider::Chainflip | SwapperProvider::NearIntents | SwapperProvider::Squid => SwapProviderMode::CrossChain, SwapperProvider::Thorchain => SwapProviderMode::OmniChain(vec![Chain::Thorchain, Chain::Tron]), SwapperProvider::Relay => SwapProviderMode::OmniChain(vec![Chain::Hyperliquid, Chain::Berachain]), SwapperProvider::Across => SwapProviderMode::Bridge, diff --git a/crates/swapper/src/proxy/mod.rs b/crates/swapper/src/proxy/mod.rs index 58f2c93b5..fbae14b35 100644 --- a/crates/swapper/src/proxy/mod.rs +++ b/crates/swapper/src/proxy/mod.rs @@ -1,6 +1,7 @@ mod client; pub mod mayan; +pub mod squid; pub mod provider; pub mod provider_factory; diff --git a/crates/swapper/src/proxy/provider.rs b/crates/swapper/src/proxy/provider.rs index 163bb7338..b1be7f6e8 100644 --- a/crates/swapper/src/proxy/provider.rs +++ b/crates/swapper/src/proxy/provider.rs @@ -5,6 +5,7 @@ use std::{collections::BTreeSet, fmt::Debug, str::FromStr, sync::Arc}; use super::{ client::ProxyClient, mayan::{MAYAN_DEPOSIT_CONTRACTS, MAYAN_SEND_CONTRACTS, MayanChain, MayanExplorer, MayanPrice, map_swap_result}, + squid::SquidClient, }; use crate::{ FetchQuoteData, ProviderData, ProviderType, Quote, QuoteRequest, Route, SwapResult, Swapper, SwapperError, SwapperProvider, SwapperProviderMode, SwapperQuoteData, @@ -131,6 +132,22 @@ impl ProxyProvider { Self::new_with_path(SwapperProvider::Panora, "panora", vec![SwapperChainAsset::All(Chain::Aptos)], rpc_provider) } + pub fn new_squid(rpc_provider: Arc) -> Self { + Self::new_with_path( + SwapperProvider::Squid, + "squid", + vec![ + SwapperChainAsset::All(Chain::Cosmos), + SwapperChainAsset::All(Chain::Osmosis), + SwapperChainAsset::All(Chain::Celestia), + SwapperChainAsset::All(Chain::Injective), + SwapperChainAsset::All(Chain::Sei), + SwapperChainAsset::All(Chain::Noble), + ], + rpc_provider, + ) + } + pub fn new_mayan(rpc_provider: Arc) -> Self { let assets = vec![ SwapperChainAsset::Assets( @@ -236,6 +253,15 @@ where let result = client.get_transaction_status(transaction_hash).await?; Ok(map_swap_result(&result)) } + SwapperProvider::Squid => { + let base_url = get_swap_api_url("squid"); + let client = SquidClient::new(base_url, self.rpc_provider.clone()); + let result = client.get_transaction_status(transaction_hash).await?; + Ok(SwapResult { + status: result.squid_transaction_status.swap_status(), + metadata: None, + }) + } _ => { if self.provider.mode == SwapperProviderMode::OnChain { Ok(SwapResult { diff --git a/crates/swapper/src/proxy/provider_factory.rs b/crates/swapper/src/proxy/provider_factory.rs index 3114dad8c..dde99fe36 100644 --- a/crates/swapper/src/proxy/provider_factory.rs +++ b/crates/swapper/src/proxy/provider_factory.rs @@ -26,3 +26,7 @@ pub fn new_panora(rpc_provider: Arc) -> ProxyProvider) -> ProxyProvider { ProxyProvider::new_mayan(rpc_provider) } + +pub fn new_squid(rpc_provider: Arc) -> ProxyProvider { + ProxyProvider::new_squid(rpc_provider) +} diff --git a/crates/swapper/src/proxy/squid/client.rs b/crates/swapper/src/proxy/squid/client.rs new file mode 100644 index 000000000..6d56f7dff --- /dev/null +++ b/crates/swapper/src/proxy/squid/client.rs @@ -0,0 +1,25 @@ +use super::model::SquidTransactionStatus; +use crate::{ + SwapperError, + alien::{RpcProvider, Target}, +}; +use std::sync::Arc; + +pub struct SquidClient { + base_url: String, + provider: Arc, +} + +impl SquidClient { + pub fn new(base_url: String, provider: Arc) -> Self { + Self { base_url, provider } + } + + pub async fn get_transaction_status(&self, tx_hash: &str) -> Result { + let url = format!("{}/v2/status?transactionId={tx_hash}", self.base_url); + let target = Target::get(&url); + let response = self.provider.request(target).await?; + let result: SquidTransactionStatus = serde_json::from_slice(&response.data).map_err(SwapperError::from)?; + Ok(result) + } +} diff --git a/crates/swapper/src/proxy/squid/mod.rs b/crates/swapper/src/proxy/squid/mod.rs new file mode 100644 index 000000000..a120e2116 --- /dev/null +++ b/crates/swapper/src/proxy/squid/mod.rs @@ -0,0 +1,4 @@ +mod client; +mod model; + +pub use client::SquidClient; diff --git a/crates/swapper/src/proxy/squid/model.rs b/crates/swapper/src/proxy/squid/model.rs new file mode 100644 index 000000000..95f882ca3 --- /dev/null +++ b/crates/swapper/src/proxy/squid/model.rs @@ -0,0 +1,40 @@ +use primitives::swap::SwapStatus; +use serde::Deserialize; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SquidTransactionStatus { + pub squid_transaction_status: SquidStatus, +} + +#[derive(Debug, Clone, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum SquidStatus { + Success, + Ongoing, + PartialSuccess, + NeedsGas, + NotFound, +} + +impl SquidStatus { + pub fn swap_status(&self) -> SwapStatus { + match self { + SquidStatus::Success | SquidStatus::PartialSuccess => SwapStatus::Completed, + SquidStatus::Ongoing | SquidStatus::NeedsGas | SquidStatus::NotFound => SwapStatus::Pending, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_deserialize_status_response() { + let json = r#"{"id":"D68723CEADAB65795B176FAE0B84B0ED5923DA9AAEC69502F8D30554431250A9","status":"destination_executed","squidTransactionStatus":"success","gasStatus":"","isGMPTransaction":false}"#; + let result: SquidTransactionStatus = serde_json::from_str(json).unwrap(); + assert_eq!(result.squid_transaction_status, SquidStatus::Success); + assert_eq!(result.squid_transaction_status.swap_status(), SwapStatus::Completed); + } +} diff --git a/crates/swapper/src/swapper.rs b/crates/swapper/src/swapper.rs index 4efc2aac0..7a5e70753 100644 --- a/crates/swapper/src/swapper.rs +++ b/crates/swapper/src/swapper.rs @@ -135,6 +135,7 @@ impl GemSwapper { Box::new(provider_factory::new_cetus_aggregator(rpc_provider.clone())), Box::new(relay::Relay::new(rpc_provider.clone())), Box::new(provider_factory::new_orca(rpc_provider.clone())), + Box::new(provider_factory::new_squid(rpc_provider.clone())), uniswap::default::boxed_aerodrome(rpc_provider.clone()), ]; diff --git a/gemstone/Cargo.toml b/gemstone/Cargo.toml index 0f9cf21ad..c0f678d9c 100644 --- a/gemstone/Cargo.toml +++ b/gemstone/Cargo.toml @@ -20,7 +20,7 @@ swap_integration_tests = ["reqwest_provider"] [dependencies] swapper = { path = "../crates/swapper" } primitives = { path = "../crates/primitives" } -gem_cosmos = { path = "../crates/gem_cosmos", features = ["rpc"] } +gem_cosmos = { path = "../crates/gem_cosmos", features = ["rpc", "signer"] } gem_solana = { path = "../crates/gem_solana", features = ["rpc", "signer"] } gem_ton = { path = "../crates/gem_ton", features = ["rpc", "signer"] } gem_tron = { path = "../crates/gem_tron", features = ["rpc"] } diff --git a/gemstone/src/block_explorer/mod.rs b/gemstone/src/block_explorer/mod.rs index 353dde11b..6122de419 100644 --- a/gemstone/src/block_explorer/mod.rs +++ b/gemstone/src/block_explorer/mod.rs @@ -49,7 +49,8 @@ impl Explorer { SwapperProvider::Chainflip => ChainflipScan::boxed(), SwapperProvider::NearIntents => NearIntents::boxed(), SwapperProvider::Relay => RelayScan::boxed(), - SwapperProvider::UniswapV3 + SwapperProvider::Squid + | SwapperProvider::UniswapV3 | SwapperProvider::UniswapV4 | SwapperProvider::PancakeswapV3 | SwapperProvider::Panora diff --git a/gemstone/src/gem_swapper/remote_types.rs b/gemstone/src/gem_swapper/remote_types.rs index 4ca849e22..47d392c40 100644 --- a/gemstone/src/gem_swapper/remote_types.rs +++ b/gemstone/src/gem_swapper/remote_types.rs @@ -128,6 +128,7 @@ pub enum SwapperProvider { Relay, Hyperliquid, Orca, + Squid, } #[uniffi::remote(Enum)] diff --git a/gemstone/src/signer/chain.rs b/gemstone/src/signer/chain.rs index 070f954b0..0842bbd92 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_cosmos::signer::CosmosChainSigner; use gem_hypercore::signer::HyperCoreSigner; use gem_solana::signer::SolanaChainSigner; use gem_sui::signer::SuiChainSigner; @@ -22,6 +23,9 @@ impl GemChainSigner { Chain::Sui => Box::new(SuiChainSigner), Chain::Solana => Box::new(SolanaChainSigner), Chain::Tron => Box::new(TronChainSigner), + Chain::Cosmos | Chain::Osmosis | Chain::Celestia | Chain::Injective | Chain::Sei | Chain::Noble => { + Box::new(CosmosChainSigner) + } _ => todo!("Signer not implemented for chain {:?}", chain), }; From 193c15b00c139de12e87e8f0a91d286958bddd3d Mon Sep 17 00:00:00 2001 From: 0xh3rman <119309671+0xh3rman@users.noreply.github.com> Date: Mon, 16 Mar 2026 10:17:32 +0900 Subject: [PATCH 02/18] impl squid get vault address --- crates/primitives/src/swap_provider.rs | 2 +- crates/swapper/src/proxy/provider.rs | 9 ++++++++- crates/swapper/src/proxy/squid/mod.rs | 2 ++ 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/crates/primitives/src/swap_provider.rs b/crates/primitives/src/swap_provider.rs index 551130771..88301e285 100644 --- a/crates/primitives/src/swap_provider.rs +++ b/crates/primitives/src/swap_provider.rs @@ -113,7 +113,7 @@ impl SwapProvider { pub fn priority(&self) -> i32 { match self { Self::UniswapV3 | Self::UniswapV4 | Self::PancakeswapV3 | Self::Aerodrome | Self::Oku | Self::Wagmi | Self::StonfiV2 | Self::Orca | Self::Hyperliquid => 1, - Self::Thorchain | Self::Across | Self::Mayan | Self::Chainflip | Self::NearIntents | Self::Relay => 2, + Self::Thorchain | Self::Across | Self::Mayan | Self::Chainflip | Self::NearIntents | Self::Relay | Self::Squid => 2, Self::Jupiter | Self::Okx | Self::CetusAggregator | Self::Panora => 3, } } diff --git a/crates/swapper/src/proxy/provider.rs b/crates/swapper/src/proxy/provider.rs index b1be7f6e8..f93f6c089 100644 --- a/crates/swapper/src/proxy/provider.rs +++ b/crates/swapper/src/proxy/provider.rs @@ -5,7 +5,7 @@ use std::{collections::BTreeSet, fmt::Debug, str::FromStr, sync::Arc}; use super::{ client::ProxyClient, mayan::{MAYAN_DEPOSIT_CONTRACTS, MAYAN_SEND_CONTRACTS, MayanChain, MayanExplorer, MayanPrice, map_swap_result}, - squid::SquidClient, + squid::{SQUID_COSMOS_MULTICALL, SquidClient}, }; use crate::{ FetchQuoteData, ProviderData, ProviderType, Quote, QuoteRequest, Route, SwapResult, Swapper, SwapperError, SwapperProvider, SwapperProviderMode, SwapperQuoteData, @@ -290,6 +290,13 @@ where send: send.into_iter().collect(), }) } + SwapperProvider::Squid => { + let address = SQUID_COSMOS_MULTICALL.to_string(); + Ok(VaultAddresses { + deposit: vec![address.clone()], + send: vec![address], + }) + } _ => Ok(VaultAddresses { deposit: vec![], send: vec![] }), } } diff --git a/crates/swapper/src/proxy/squid/mod.rs b/crates/swapper/src/proxy/squid/mod.rs index a120e2116..89e7cf20a 100644 --- a/crates/swapper/src/proxy/squid/mod.rs +++ b/crates/swapper/src/proxy/squid/mod.rs @@ -2,3 +2,5 @@ mod client; mod model; pub use client::SquidClient; + +pub const SQUID_COSMOS_MULTICALL: &str = "osmo1n6ney9tsf55etz9nrmzyd8wa7e64qd3s06a74fqs30ka8pps6cvqtsycr6"; From 4e7c4f86aa37ce132f0679ab01cd58af2b4cfe2f Mon Sep 17 00:00:00 2001 From: 0xh3rman <119309671+0xh3rman@users.noreply.github.com> Date: Mon, 16 Mar 2026 10:55:35 +0900 Subject: [PATCH 03/18] add stable coins --- crates/gem_cosmos/src/models/contract.rs | 2 - crates/serde_serializers/src/i32.rs | 96 ------------------- crates/serde_serializers/src/lib.rs | 4 - crates/serde_serializers/src/u32.rs | 95 ------------------ crates/swapper/src/proxy/provider.rs | 28 +++++- crates/swapper/src/proxy/squid/mod.rs | 6 ++ crates/swapper/src/proxy/squid/model.rs | 3 +- .../testdata/squid/status_response.json | 7 ++ 8 files changed, 37 insertions(+), 204 deletions(-) delete mode 100644 crates/serde_serializers/src/i32.rs delete mode 100644 crates/serde_serializers/src/u32.rs create mode 100644 crates/swapper/testdata/squid/status_response.json diff --git a/crates/gem_cosmos/src/models/contract.rs b/crates/gem_cosmos/src/models/contract.rs index a44c20a16..5124fb2d9 100644 --- a/crates/gem_cosmos/src/models/contract.rs +++ b/crates/gem_cosmos/src/models/contract.rs @@ -3,9 +3,7 @@ use serde::Deserialize; use super::Coin; #[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] pub struct ExecuteContractValue { - #[serde(default)] pub sender: String, pub contract: String, pub msg: String, diff --git a/crates/serde_serializers/src/i32.rs b/crates/serde_serializers/src/i32.rs deleted file mode 100644 index 154b59b38..000000000 --- a/crates/serde_serializers/src/i32.rs +++ /dev/null @@ -1,96 +0,0 @@ -use std::fmt; - -use serde::{Deserialize, Deserializer, de}; - -use crate::visitors::{StringOrNumberFromValue, StringOrNumberVisitor}; - -fn parse_i32_string(value: &str) -> Result { - if let Some(hex_val) = value.strip_prefix("0x") { - u32::from_str_radix(hex_val, 16) - .map(|v| v as i32) - .map_err(|_| format!("Invalid hex string for i32: {value}")) - } else { - value.parse::().map_err(|_| format!("Invalid decimal string for i32: {value}")) - } -} - -fn invalid_number(value: impl fmt::Display) -> String { - format!("Invalid number for i32: {value}") -} - -impl StringOrNumberFromValue for i32 { - const EXPECTING: &'static str = "a string or integer representing i32"; - - fn from_str(value: &str) -> Result { - parse_i32_string(value) - } - - fn from_u64(value: u64) -> Result { - i32::try_from(value).map_err(|_| invalid_number(value)) - } - - fn from_i64(value: i64) -> Result { - i32::try_from(value).map_err(|_| invalid_number(value)) - } -} - -pub fn deserialize_i32_from_str<'de, D>(deserializer: D) -> Result -where - D: Deserializer<'de>, -{ - let s: String = Deserialize::deserialize(deserializer)?; - parse_i32_string(&s).map_err(de::Error::custom) -} - -pub fn deserialize_i32_from_str_or_int<'de, D>(deserializer: D) -> Result -where - D: Deserializer<'de>, -{ - deserializer.deserialize_any(StringOrNumberVisitor::::new()) -} - -#[cfg(test)] -mod tests { - use super::*; - use serde::Deserialize; - - #[derive(Debug, Deserialize, PartialEq)] - struct TestStrOrInt { - #[serde(deserialize_with = "deserialize_i32_from_str_or_int")] - pub value: i32, - } - - #[derive(Debug, Deserialize, PartialEq)] - struct TestStr { - #[serde(deserialize_with = "deserialize_i32_from_str")] - pub value: i32, - } - - #[test] - fn test_i32_deserialization() { - let mixed_cases = [ - (r#"{"value": 42}"#, 42i32), - (r#"{"value": -1}"#, -1), - (r#"{"value": "42"}"#, 42), - (r#"{"value": "0x2a"}"#, 42), - (r#"{"value": 0}"#, 0), - ]; - for (json, expected) in mixed_cases { - let result: TestStrOrInt = serde_json::from_str(json).unwrap(); - assert_eq!(result.value, expected); - } - - let str_cases = [ - (r#"{"value": "0"}"#, 0i32), - (r#"{"value": "-100"}"#, -100), - (r#"{"value": "2147483647"}"#, i32::MAX), - ]; - for (json, expected) in str_cases { - let result: TestStr = serde_json::from_str(json).unwrap(); - assert_eq!(result.value, expected); - } - - assert!(serde_json::from_str::(r#"{"value": 2147483648}"#).is_err()); - assert!(serde_json::from_str::(r#"{"value": 1.5}"#).is_err()); - } -} diff --git a/crates/serde_serializers/src/lib.rs b/crates/serde_serializers/src/lib.rs index 649ee59d0..70cab1e91 100644 --- a/crates/serde_serializers/src/lib.rs +++ b/crates/serde_serializers/src/lib.rs @@ -16,10 +16,6 @@ pub mod f64; pub use f64::{deserialize_f64_from_str, deserialize_option_f64_from_str, serialize_f64}; pub mod string; pub use string::{deserialize_string_from_str_or_number, deserialize_string_from_value}; -pub mod i32; -pub use i32::{deserialize_i32_from_str, deserialize_i32_from_str_or_int}; -pub mod u32; -pub use u32::{deserialize_u32_from_str, deserialize_u32_from_str_or_int}; pub mod u64; pub use u64::{deserialize_option_u64_from_str, deserialize_u64_from_str, deserialize_u64_from_str_or_int, serialize_u64}; pub mod u128; diff --git a/crates/serde_serializers/src/u32.rs b/crates/serde_serializers/src/u32.rs deleted file mode 100644 index a3b7930bf..000000000 --- a/crates/serde_serializers/src/u32.rs +++ /dev/null @@ -1,95 +0,0 @@ -use std::fmt; - -use serde::{Deserialize, Deserializer, de}; - -use crate::visitors::{StringOrNumberFromValue, StringOrNumberVisitor}; - -fn parse_u32_string(value: &str) -> Result { - if let Some(hex_val) = value.strip_prefix("0x") { - u32::from_str_radix(hex_val, 16).map_err(|_| format!("Invalid hex string for u32: {value}")) - } else { - value.parse::().map_err(|_| format!("Invalid decimal string for u32: {value}")) - } -} - -fn invalid_number(value: impl fmt::Display) -> String { - format!("Invalid number for u32: {value}") -} - -impl StringOrNumberFromValue for u32 { - const EXPECTING: &'static str = "a string or integer representing u32"; - - fn from_str(value: &str) -> Result { - parse_u32_string(value) - } - - fn from_u64(value: u64) -> Result { - u32::try_from(value).map_err(|_| invalid_number(value)) - } - - fn from_i64(value: i64) -> Result { - u32::try_from(value).map_err(|_| invalid_number(value)) - } -} - -pub fn deserialize_u32_from_str<'de, D>(deserializer: D) -> Result -where - D: Deserializer<'de>, -{ - let s: String = Deserialize::deserialize(deserializer)?; - parse_u32_string(&s).map_err(de::Error::custom) -} - -pub fn deserialize_u32_from_str_or_int<'de, D>(deserializer: D) -> Result -where - D: Deserializer<'de>, -{ - deserializer.deserialize_any(StringOrNumberVisitor::::new()) -} - -#[cfg(test)] -mod tests { - use super::*; - use serde::Deserialize; - - #[derive(Debug, Deserialize, PartialEq)] - struct TestStrOrInt { - #[serde(deserialize_with = "deserialize_u32_from_str_or_int")] - pub value: u32, - } - - #[derive(Debug, Deserialize, PartialEq)] - struct TestStr { - #[serde(deserialize_with = "deserialize_u32_from_str")] - pub value: u32, - } - - #[test] - fn test_u32_deserialization() { - let mixed_cases = [ - (r#"{"value": 42}"#, 42u32), - (r#"{"value": "42"}"#, 42), - (r#"{"value": "0x2a"}"#, 42), - (r#"{"value": 0}"#, 0), - (r#"{"value": "4294967295"}"#, u32::MAX), - ]; - for (json, expected) in mixed_cases { - let result: TestStrOrInt = serde_json::from_str(json).unwrap(); - assert_eq!(result.value, expected); - } - - let str_cases = [ - (r#"{"value": "0"}"#, 0u32), - (r#"{"value": "100"}"#, 100), - (r#"{"value": "0xff"}"#, 255), - ]; - for (json, expected) in str_cases { - let result: TestStr = serde_json::from_str(json).unwrap(); - assert_eq!(result.value, expected); - } - - assert!(serde_json::from_str::(r#"{"value": 4294967296}"#).is_err()); - assert!(serde_json::from_str::(r#"{"value": -1}"#).is_err()); - assert!(serde_json::from_str::(r#"{"value": 1.5}"#).is_err()); - } -} diff --git a/crates/swapper/src/proxy/provider.rs b/crates/swapper/src/proxy/provider.rs index f93f6c089..cdd5936bb 100644 --- a/crates/swapper/src/proxy/provider.rs +++ b/crates/swapper/src/proxy/provider.rs @@ -18,7 +18,7 @@ use crate::{ }; use gem_client::Client; use primitives::{ - Chain, ChainType, + AssetId, Chain, ChainType, swap::{ApprovalData, ProxyQuote, ProxyQuoteRequest, SwapQuoteData}, }; @@ -133,15 +133,33 @@ impl ProxyProvider { } pub fn new_squid(rpc_provider: Arc) -> Self { + use crate::proxy::squid::{ + COSMOS_USDC_TOKEN_ID, INJECTIVE_USDC_TOKEN_ID, OSMOSIS_USDC_TOKEN_ID, OSMOSIS_USDT_TOKEN_ID, SEI_USDC_TOKEN_ID, + }; Self::new_with_path( SwapperProvider::Squid, "squid", vec![ - SwapperChainAsset::All(Chain::Cosmos), - SwapperChainAsset::All(Chain::Osmosis), + SwapperChainAsset::Assets( + Chain::Cosmos, + vec![AssetId::from_token(Chain::Cosmos, COSMOS_USDC_TOKEN_ID)], + ), + SwapperChainAsset::Assets( + Chain::Osmosis, + vec![ + AssetId::from_token(Chain::Osmosis, OSMOSIS_USDC_TOKEN_ID), + AssetId::from_token(Chain::Osmosis, OSMOSIS_USDT_TOKEN_ID), + ], + ), SwapperChainAsset::All(Chain::Celestia), - SwapperChainAsset::All(Chain::Injective), - SwapperChainAsset::All(Chain::Sei), + SwapperChainAsset::Assets( + Chain::Injective, + vec![AssetId::from_token(Chain::Injective, INJECTIVE_USDC_TOKEN_ID)], + ), + SwapperChainAsset::Assets( + Chain::Sei, + vec![AssetId::from_token(Chain::Sei, SEI_USDC_TOKEN_ID)], + ), SwapperChainAsset::All(Chain::Noble), ], rpc_provider, diff --git a/crates/swapper/src/proxy/squid/mod.rs b/crates/swapper/src/proxy/squid/mod.rs index 89e7cf20a..6aec5099b 100644 --- a/crates/swapper/src/proxy/squid/mod.rs +++ b/crates/swapper/src/proxy/squid/mod.rs @@ -4,3 +4,9 @@ mod model; pub use client::SquidClient; pub const SQUID_COSMOS_MULTICALL: &str = "osmo1n6ney9tsf55etz9nrmzyd8wa7e64qd3s06a74fqs30ka8pps6cvqtsycr6"; + +pub const COSMOS_USDC_TOKEN_ID: &str = "ibc/F663521BF1836B00F5F177680F74BFB9A8B5654A694D0D2BC249E03CF2509013"; +pub const OSMOSIS_USDC_TOKEN_ID: &str = "ibc/498A0751C798A0D9A389AA3691123DADA57DAA4FE165D5C75894505B876BA6E4"; +pub const OSMOSIS_USDT_TOKEN_ID: &str = "ibc/4ABBEF4C8926DDDB320AE5188CFD63267ABBCEFC0583E4AE05D6E5AA2401DDAB"; +pub const INJECTIVE_USDC_TOKEN_ID: &str = "ibc/7E1AF94AD246BE522892751046F0C959B768642E5671CC3742264068D49553C0"; +pub const SEI_USDC_TOKEN_ID: &str = "ibc/CA6FBFAF399474A06263E10D0CE5AEBBE15189D6D4B2DD9ADE61007E68EB9DB0"; diff --git a/crates/swapper/src/proxy/squid/model.rs b/crates/swapper/src/proxy/squid/model.rs index 95f882ca3..3a47f02a0 100644 --- a/crates/swapper/src/proxy/squid/model.rs +++ b/crates/swapper/src/proxy/squid/model.rs @@ -32,8 +32,7 @@ mod tests { #[test] fn test_deserialize_status_response() { - let json = r#"{"id":"D68723CEADAB65795B176FAE0B84B0ED5923DA9AAEC69502F8D30554431250A9","status":"destination_executed","squidTransactionStatus":"success","gasStatus":"","isGMPTransaction":false}"#; - let result: SquidTransactionStatus = serde_json::from_str(json).unwrap(); + let result: SquidTransactionStatus = serde_json::from_str(include_str!("../../../testdata/squid/status_response.json")).unwrap(); assert_eq!(result.squid_transaction_status, SquidStatus::Success); assert_eq!(result.squid_transaction_status.swap_status(), SwapStatus::Completed); } diff --git a/crates/swapper/testdata/squid/status_response.json b/crates/swapper/testdata/squid/status_response.json new file mode 100644 index 000000000..ccf73aaa0 --- /dev/null +++ b/crates/swapper/testdata/squid/status_response.json @@ -0,0 +1,7 @@ +{ + "id": "D68723CEADAB65795B176FAE0B84B0ED5923DA9AAEC69502F8D30554431250A9", + "status": "destination_executed", + "squidTransactionStatus": "success", + "gasStatus": "", + "isGMPTransaction": false +} From 089425a9458ceadf67511c6da8ad14f4fff585cf Mon Sep 17 00:00:00 2001 From: 0xh3rman <119309671+0xh3rman@users.noreply.github.com> Date: Mon, 16 Mar 2026 11:02:21 +0900 Subject: [PATCH 04/18] Update preload_mapper.rs --- crates/gem_cosmos/src/provider/preload_mapper.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/gem_cosmos/src/provider/preload_mapper.rs b/crates/gem_cosmos/src/provider/preload_mapper.rs index 5bf2bb956..968e6c02a 100644 --- a/crates/gem_cosmos/src/provider/preload_mapper.rs +++ b/crates/gem_cosmos/src/provider/preload_mapper.rs @@ -68,7 +68,7 @@ fn get_fee(chain: CosmosChain, input_type: &TransactionInputType) -> BigInt { } } -fn get_gas_limit(input_type: &TransactionInputType, chain: CosmosChain) -> u64 { +fn get_gas_limit(input_type: &TransactionInputType, _chain: CosmosChain) -> u64 { match input_type { TransactionInputType::Transfer(_) | TransactionInputType::Deposit(_) From 37c9bde5db7b548db98554551bf94c1e5dab0891 Mon Sep 17 00:00:00 2001 From: 0xh3rman <119309671+0xh3rman@users.noreply.github.com> Date: Mon, 16 Mar 2026 11:53:33 +0900 Subject: [PATCH 05/18] update tests --- crates/gem_cosmos/src/models/ibc.rs | 19 ------------------- crates/gem_cosmos/src/signer/chain_signer.rs | 16 ++++++++++------ .../testdata/swap_execute_contract.json | 8 ++++---- .../testdata/swap_ibc_transfer.json | 10 +++++----- 4 files changed, 19 insertions(+), 34 deletions(-) diff --git a/crates/gem_cosmos/src/models/ibc.rs b/crates/gem_cosmos/src/models/ibc.rs index a30889346..8f4be860e 100644 --- a/crates/gem_cosmos/src/models/ibc.rs +++ b/crates/gem_cosmos/src/models/ibc.rs @@ -15,22 +15,3 @@ pub struct IbcTransferValue { #[serde(default)] pub memo: String, } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_deserialize_timeout_as_number() { - let json = r#"{"sourcePort":"transfer","sourceChannel":"channel-0","token":{"denom":"uatom","amount":"1000000"},"sender":"cosmos1test","receiver":"osmo1test","timeoutTimestamp":1773382733549000000,"memo":"test"}"#; - let v: IbcTransferValue = serde_json::from_str(json).unwrap(); - assert_eq!(v.timeout_timestamp, 1773382733549000000); - } - - #[test] - fn test_deserialize_timeout_as_string() { - let json = r#"{"sourcePort":"transfer","sourceChannel":"channel-0","token":{"denom":"uatom","amount":"1000000"},"sender":"cosmos1test","receiver":"osmo1test","timeoutTimestamp":"1773382733549000000","memo":"test"}"#; - let v: IbcTransferValue = serde_json::from_str(json).unwrap(); - assert_eq!(v.timeout_timestamp, 1773382733549000000); - } -} diff --git a/crates/gem_cosmos/src/signer/chain_signer.rs b/crates/gem_cosmos/src/signer/chain_signer.rs index 3b60a50c9..2e19395fe 100644 --- a/crates/gem_cosmos/src/signer/chain_signer.rs +++ b/crates/gem_cosmos/src/signer/chain_signer.rs @@ -95,10 +95,12 @@ mod tests { fn test_parse_execute_contract() { let msg = CosmosMessage::parse(include_str!("../../testdata/swap_execute_contract.json")).unwrap(); match msg { - CosmosMessage::ExecuteContract { contract, funds, .. } => { - assert_eq!(contract, "osmo1contract"); + CosmosMessage::ExecuteContract { sender, contract, funds, .. } => { + assert_eq!(sender, "osmo1tkvyjqeq204rmrrz3w4hcrs336qahsfwn8m0ye"); + assert_eq!(contract, "osmo1n6ney9tsf55etz9nrmzyd8wa7e64qd3s06a74fqs30ka8pps6cvqtsycr6"); assert_eq!(funds.len(), 1); - assert_eq!(funds[0].amount, "1000000"); + assert_eq!(funds[0].denom, "uosmo"); + assert_eq!(funds[0].amount, "10000000"); } _ => panic!("expected ExecuteContract"), } @@ -108,10 +110,12 @@ mod tests { fn test_parse_ibc_transfer() { let msg = CosmosMessage::parse(include_str!("../../testdata/swap_ibc_transfer.json")).unwrap(); match msg { - CosmosMessage::IbcTransfer { source_port, source_channel, memo, timeout_timestamp, .. } => { + CosmosMessage::IbcTransfer { source_port, source_channel, sender, receiver, timeout_timestamp, memo, .. } => { assert_eq!(source_port, "transfer"); - assert_eq!(source_channel, "channel-0"); - assert_eq!(timeout_timestamp, 1773382733549000000); + assert_eq!(source_channel, "channel-141"); + assert_eq!(sender, "cosmos1tkvyjqeq204rmrrz3w4hcrs336qahsfwmugljt"); + assert_eq!(receiver, "osmo1n6ney9tsf55etz9nrmzyd8wa7e64qd3s06a74fqs30ka8pps6cvqtsycr6"); + assert_eq!(timeout_timestamp, 1773632858715000064); assert!(!memo.is_empty()); } _ => panic!("expected IbcTransfer"), diff --git a/crates/gem_cosmos/testdata/swap_execute_contract.json b/crates/gem_cosmos/testdata/swap_execute_contract.json index b257705b4..ca8760341 100644 --- a/crates/gem_cosmos/testdata/swap_execute_contract.json +++ b/crates/gem_cosmos/testdata/swap_execute_contract.json @@ -1,13 +1,13 @@ { "typeUrl": "/cosmwasm.wasm.v1.MsgExecuteContract", "value": { - "sender": "osmo1test", - "contract": "osmo1contract", - "msg": "{\"swap\":{}}", + "sender": "osmo1tkvyjqeq204rmrrz3w4hcrs336qahsfwn8m0ye", + "contract": "osmo1n6ney9tsf55etz9nrmzyd8wa7e64qd3s06a74fqs30ka8pps6cvqtsycr6", + "msg": "{\"multicall\":{\"calls\":[{\"msg\":{\"stargate\":{\"type_url\":\"/osmosis.gamm.v1beta1.MsgSwapExactAmountIn\",\"value\":{\"sender\":\"osmo1n6ney9tsf55etz9nrmzyd8wa7e64qd3s06a74fqs30ka8pps6cvqtsycr6\",\"routes\":[{\"pool_id\":\"1135\",\"token_out_denom\":\"ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA622B25F41E5EB2\"}],\"token_in\":{\"denom\":\"uosmo\",\"amount\":\"0\"},\"token_out_min_amount\":\"180865\"}}},\"actions\":[{\"native_balance_fetch\":{\"denom\":\"uosmo\",\"replacer\":\"/stargate/value/token_in/amount\"}},{\"field_to_proto_binary\":{\"replacer\":\"/stargate/value\",\"proto_msg_type\":\"osmosis_swap_exact_amt_in\"}}]}],\"fallback_address\":\"osmo1tkvyjqeq204rmrrz3w4hcrs336qahsfwn8m0ye\"}}", "funds": [ { "denom": "uosmo", - "amount": "1000000" + "amount": "10000000" } ] } diff --git a/crates/gem_cosmos/testdata/swap_ibc_transfer.json b/crates/gem_cosmos/testdata/swap_ibc_transfer.json index 911ceaeda..3c28ba086 100644 --- a/crates/gem_cosmos/testdata/swap_ibc_transfer.json +++ b/crates/gem_cosmos/testdata/swap_ibc_transfer.json @@ -2,14 +2,14 @@ "typeUrl": "/ibc.applications.transfer.v1.MsgTransfer", "value": { "sourcePort": "transfer", - "sourceChannel": "channel-0", + "sourceChannel": "channel-141", "token": { "denom": "uatom", "amount": "1000000" }, - "sender": "cosmos1test", - "receiver": "osmo1test", - "timeoutTimestamp": 1773382733549000000, - "memo": "{\"ibc_callback\":\"osmo1\"}" + "sender": "cosmos1tkvyjqeq204rmrrz3w4hcrs336qahsfwmugljt", + "receiver": "osmo1n6ney9tsf55etz9nrmzyd8wa7e64qd3s06a74fqs30ka8pps6cvqtsycr6", + "timeoutTimestamp": "1773632858715000064", + "memo": "{\"wasm\":{\"squid_request_id\":\"90b2b59ba3c36a5a73736e80288587f0\",\"contract\":\"osmo1n6ney9tsf55etz9nrmzyd8wa7e64qd3s06a74fqs30ka8pps6cvqtsycr6\",\"msg\":{\"multicall\":{\"calls\":[{\"msg\":{\"stargate\":{\"type_url\":\"/osmosis.gamm.v1beta1.MsgSwapExactAmountIn\",\"value\":{\"sender\":\"osmo1n6ney9tsf55etz9nrmzyd8wa7e64qd3s06a74fqs30ka8pps6cvqtsycr6\",\"routes\":[{\"pool_id\":\"1\",\"token_out_denom\":\"uosmo\"}],\"token_in\":{\"denom\":\"ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA622B25F41E5EB2\",\"amount\":\"0\"},\"token_out_min_amount\":\"54012132\"}}},\"actions\":[{\"native_balance_fetch\":{\"denom\":\"ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA622B25F41E5EB2\",\"replacer\":\"/stargate/value/token_in/amount\"}},{\"field_to_proto_binary\":{\"replacer\":\"/stargate/value\",\"proto_msg_type\":\"osmosis_swap_exact_amt_in\"}}]},{\"msg\":{\"bank\":{\"send\":{\"to_address\":\"osmo1tkvyjqeq204rmrrz3w4hcrs336qahsfwn8m0ye\",\"amount\":[{\"denom\":\"uosmo\",\"amount\":\"0\"}]}}},\"actions\":[{\"native_balance_fetch\":{\"denom\":\"uosmo\",\"replacer\":\"/bank/send/amount/0/amount\"}}]}],\"fallback_address\":\"osmo1tkvyjqeq204rmrrz3w4hcrs336qahsfwn8m0ye\"}}}}" } } From 43572ce2593847f47ecaea389970949e3aff173d Mon Sep 17 00:00:00 2001 From: 0xh3rman <119309671+0xh3rman@users.noreply.github.com> Date: Mon, 16 Mar 2026 13:47:39 +0900 Subject: [PATCH 06/18] update code-style.md --- crates/gem_cosmos/src/models/ibc.rs | 3 +-- skills/code-style.md | 1 + 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/gem_cosmos/src/models/ibc.rs b/crates/gem_cosmos/src/models/ibc.rs index 8f4be860e..a6d53f427 100644 --- a/crates/gem_cosmos/src/models/ibc.rs +++ b/crates/gem_cosmos/src/models/ibc.rs @@ -10,8 +10,7 @@ pub struct IbcTransferValue { pub token: Coin, pub sender: String, pub receiver: String, - #[serde(default, deserialize_with = "deserialize_u64_from_str_or_int")] + #[serde(deserialize_with = "deserialize_u64_from_str_or_int")] pub timeout_timestamp: u64, - #[serde(default)] pub memo: String, } diff --git a/skills/code-style.md b/skills/code-style.md index bd29a0d90..58f0cebf6 100644 --- a/skills/code-style.md +++ b/skills/code-style.md @@ -96,6 +96,7 @@ fn process_data() { - **Bird's eye view**: Step back and look at the overall structure; identify opportunities to simplify and consolidate - **Avoid `mut`**: Prefer immutable bindings; use `mut` only when truly necessary - **No `#[allow(dead_code)]`**: Remove dead code instead of suppressing warnings +- **Avoid `#[serde(default)]`**: Only use when the field is genuinely optional in the API response; if the field is always present, omit it - **No unused fields**: Remove unused fields from structs/models; don't keep fields "for future use" - **Constants for magic numbers**: Extract magic numbers into named constants with clear meaning - **Minimum interface**: Don't expose unnecessary functions; if client only needs one function, don't add multiple variants From 92f7a2a1d7df61fad003094ae53542ef7802c373 Mon Sep 17 00:00:00 2001 From: 0xh3rman <119309671+0xh3rman@users.noreply.github.com> Date: Mon, 16 Mar 2026 14:01:56 +0900 Subject: [PATCH 07/18] format code --- crates/gem_cosmos/src/models/message.rs | 4 +- crates/gem_cosmos/src/signer/chain_signer.rs | 30 +++++++----- crates/gem_cosmos/src/signer/transaction.rs | 48 +++++++++++++++++--- crates/signer/src/eip712/hash_impl.rs | 2 +- crates/signer/src/eip712/parse.rs | 2 +- crates/signer/src/lib.rs | 2 +- crates/signer/src/secp256k1.rs | 2 +- crates/swapper/src/proxy/mod.rs | 2 +- crates/swapper/src/proxy/provider.rs | 19 ++------ crates/swapper/src/swapper.rs | 18 +++++--- gemstone/src/signer/chain.rs | 4 +- 11 files changed, 84 insertions(+), 49 deletions(-) diff --git a/crates/gem_cosmos/src/models/message.rs b/crates/gem_cosmos/src/models/message.rs index e3930a8fa..25ed3eea8 100644 --- a/crates/gem_cosmos/src/models/message.rs +++ b/crates/gem_cosmos/src/models/message.rs @@ -1,10 +1,10 @@ use serde::{Deserialize, Serialize}; +#[cfg(feature = "signer")] +use super::{ExecuteContractValue, IbcTransferValue}; use crate::constants; #[cfg(feature = "signer")] use crate::constants::{MESSAGE_EXECUTE_CONTRACT, MESSAGE_IBC_TRANSFER}; -#[cfg(feature = "signer")] -use super::{ExecuteContractValue, IbcTransferValue}; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "@type")] diff --git a/crates/gem_cosmos/src/signer/chain_signer.rs b/crates/gem_cosmos/src/signer/chain_signer.rs index 2e19395fe..6ffeda34d 100644 --- a/crates/gem_cosmos/src/signer/chain_signer.rs +++ b/crates/gem_cosmos/src/signer/chain_signer.rs @@ -52,12 +52,15 @@ impl ChainSigner for CosmosChainSigner { let swap_data = input.input_type.get_swap_data().map_err(SignerError::invalid_input)?; let (account_number, sequence, chain_id) = match &input.metadata { - TransactionLoadMetadata::Cosmos { account_number, sequence, chain_id } => (*account_number, *sequence, chain_id.as_str()), + TransactionLoadMetadata::Cosmos { + account_number, + sequence, + chain_id, + } => (*account_number, *sequence, chain_id.as_str()), _ => return Err(SignerError::invalid_input("expected Cosmos metadata")), }; - let chain = CosmosChain::from_str(input.input_type.get_asset().chain.as_ref()) - .map_err(|_| SignerError::invalid_input("unsupported cosmos chain"))?; + let chain = CosmosChain::from_str(input.input_type.get_asset().chain.as_ref()).map_err(|_| SignerError::invalid_input("unsupported cosmos chain"))?; let message = CosmosMessage::parse(&swap_data.data.data)?; let body_bytes = encode_tx_body(&[message.encode_as_any()], input.memo.as_deref().unwrap_or("")); @@ -79,7 +82,10 @@ impl ChainSigner for CosmosChainSigner { chain_id, account_number, sequence, - fee_coins: vec![Coin { denom: chain.denom().as_ref().to_string(), amount: fee_amount }], + fee_coins: vec![Coin { + denom: chain.denom().as_ref().to_string(), + amount: fee_amount, + }], gas_limit, }; @@ -110,7 +116,15 @@ mod tests { fn test_parse_ibc_transfer() { let msg = CosmosMessage::parse(include_str!("../../testdata/swap_ibc_transfer.json")).unwrap(); match msg { - CosmosMessage::IbcTransfer { source_port, source_channel, sender, receiver, timeout_timestamp, memo, .. } => { + CosmosMessage::IbcTransfer { + source_port, + source_channel, + sender, + receiver, + timeout_timestamp, + memo, + .. + } => { assert_eq!(source_port, "transfer"); assert_eq!(source_channel, "channel-141"); assert_eq!(sender, "cosmos1tkvyjqeq204rmrrz3w4hcrs336qahsfwmugljt"); @@ -121,10 +135,4 @@ mod tests { _ => panic!("expected IbcTransfer"), } } - - #[test] - fn test_parse_unsupported_type() { - let json = r#"{"typeUrl":"/unknown.MsgType","value":{}}"#; - assert!(CosmosMessage::parse(json).is_err()); - } } diff --git a/crates/gem_cosmos/src/signer/transaction.rs b/crates/gem_cosmos/src/signer/transaction.rs index 63070de67..63ba475a7 100644 --- a/crates/gem_cosmos/src/signer/transaction.rs +++ b/crates/gem_cosmos/src/signer/transaction.rs @@ -21,12 +21,21 @@ impl CosmosMessage { let fund_fields: Vec = funds.iter().flat_map(|c| encode_message_field(5, &encode_coin(&c.denom, &c.amount))).collect(); [encode_string_field(1, sender), encode_string_field(2, contract), encode_bytes_field(3, msg), fund_fields].concat() } - Self::IbcTransfer { source_port, source_channel, token, sender, receiver, timeout_timestamp, memo } => [ + Self::IbcTransfer { + source_port, + source_channel, + token, + sender, + receiver, + timeout_timestamp, + memo, + } => [ encode_string_field(1, source_port), encode_string_field(2, source_channel), encode_message_field(3, &encode_coin(&token.denom, &token.amount)), encode_string_field(4, sender), encode_string_field(5, receiver), + // field number skips 6 encode_varint_field(7, *timeout_timestamp), encode_string_field(8, memo), ] @@ -45,7 +54,11 @@ pub fn encode_tx_body(messages: &[Vec], memo: &str) -> Vec { } fn encode_pubkey_any(pubkey_bytes: &[u8]) -> Vec { - [encode_string_field(1, SECP256K1_PUBKEY_TYPE_URL), encode_bytes_field(2, &encode_bytes_field(1, pubkey_bytes))].concat() + [ + encode_string_field(1, SECP256K1_PUBKEY_TYPE_URL), + encode_bytes_field(2, &encode_bytes_field(1, pubkey_bytes)), + ] + .concat() } fn encode_mode_info_single() -> Vec { @@ -53,7 +66,12 @@ fn encode_mode_info_single() -> Vec { } fn encode_signer_info(pubkey_bytes: &[u8], sequence: u64) -> Vec { - [encode_message_field(1, &encode_pubkey_any(pubkey_bytes)), encode_message_field(2, &encode_mode_info_single()), encode_varint_field(3, sequence)].concat() + [ + encode_message_field(1, &encode_pubkey_any(pubkey_bytes)), + encode_message_field(2, &encode_mode_info_single()), + encode_varint_field(3, sequence), + ] + .concat() } fn encode_fee(coins: &[Coin], gas_limit: u64) -> Vec { @@ -62,11 +80,21 @@ fn encode_fee(coins: &[Coin], gas_limit: u64) -> Vec { } pub fn encode_auth_info(pubkey_bytes: &[u8], sequence: u64, fee_coins: &[Coin], gas_limit: u64) -> Vec { - [encode_message_field(1, &encode_signer_info(pubkey_bytes, sequence)), encode_message_field(2, &encode_fee(fee_coins, gas_limit))].concat() + [ + encode_message_field(1, &encode_signer_info(pubkey_bytes, sequence)), + encode_message_field(2, &encode_fee(fee_coins, gas_limit)), + ] + .concat() } pub fn encode_sign_doc(body_bytes: &[u8], auth_info_bytes: &[u8], chain_id: &str, account_number: u64) -> Vec { - [encode_bytes_field(1, body_bytes), encode_bytes_field(2, auth_info_bytes), encode_string_field(3, chain_id), encode_varint_field(4, account_number)].concat() + [ + encode_bytes_field(1, body_bytes), + encode_bytes_field(2, auth_info_bytes), + encode_string_field(3, chain_id), + encode_varint_field(4, account_number), + ] + .concat() } pub fn encode_tx_raw(body_bytes: &[u8], auth_info_bytes: &[u8], signature: &[u8]) -> Vec { @@ -83,7 +111,10 @@ mod tests { sender: "osmo1test".to_string(), contract: "osmo1contract".to_string(), msg: b"{\"swap\":{}}".to_vec(), - funds: vec![Coin { denom: "uosmo".to_string(), amount: "1000000".to_string() }], + funds: vec![Coin { + denom: "uosmo".to_string(), + amount: "1000000".to_string(), + }], }; let any = msg.encode_as_any(); assert!(!any.is_empty()); @@ -95,7 +126,10 @@ mod tests { let msg = CosmosMessage::IbcTransfer { source_port: "transfer".to_string(), source_channel: "channel-0".to_string(), - token: Coin { denom: "uatom".to_string(), amount: "1000000".to_string() }, + token: Coin { + denom: "uatom".to_string(), + amount: "1000000".to_string(), + }, sender: "cosmos1test".to_string(), receiver: "osmo1test".to_string(), timeout_timestamp: 1773382733549000000, 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/lib.rs b/crates/signer/src/lib.rs index 6708b5fee..dc911d7da 100644 --- a/crates/signer/src/lib.rs +++ b/crates/signer/src/lib.rs @@ -7,8 +7,8 @@ use ed25519_dalek::Signer as DalekSigner; use zeroize::Zeroizing; use crate::ed25519::{sign_digest as sign_ed25519_digest, signing_key_from_bytes}; -use crate::secp256k1::sign_digest as sign_secp256k1_digest; pub use crate::secp256k1::public_key_from_private as secp256k1_public_key; +use crate::secp256k1::sign_digest as sign_secp256k1_digest; pub use decode::{decode_private_key, encode_private_key, supports_private_key_import}; pub use eip712::hash_typed_data as hash_eip712; diff --git a/crates/signer/src/secp256k1.rs b/crates/signer/src/secp256k1.rs index dc6dbd453..4634636a4 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::signing_error("Invalid Secp256k1 private key"))?; diff --git a/crates/swapper/src/proxy/mod.rs b/crates/swapper/src/proxy/mod.rs index fbae14b35..f840306e7 100644 --- a/crates/swapper/src/proxy/mod.rs +++ b/crates/swapper/src/proxy/mod.rs @@ -1,8 +1,8 @@ mod client; pub mod mayan; -pub mod squid; pub mod provider; pub mod provider_factory; +pub mod squid; pub use client::ProxyError; diff --git a/crates/swapper/src/proxy/provider.rs b/crates/swapper/src/proxy/provider.rs index cdd5936bb..4f38ce14e 100644 --- a/crates/swapper/src/proxy/provider.rs +++ b/crates/swapper/src/proxy/provider.rs @@ -133,17 +133,12 @@ impl ProxyProvider { } pub fn new_squid(rpc_provider: Arc) -> Self { - use crate::proxy::squid::{ - COSMOS_USDC_TOKEN_ID, INJECTIVE_USDC_TOKEN_ID, OSMOSIS_USDC_TOKEN_ID, OSMOSIS_USDT_TOKEN_ID, SEI_USDC_TOKEN_ID, - }; + use crate::proxy::squid::{COSMOS_USDC_TOKEN_ID, INJECTIVE_USDC_TOKEN_ID, OSMOSIS_USDC_TOKEN_ID, OSMOSIS_USDT_TOKEN_ID, SEI_USDC_TOKEN_ID}; Self::new_with_path( SwapperProvider::Squid, "squid", vec![ - SwapperChainAsset::Assets( - Chain::Cosmos, - vec![AssetId::from_token(Chain::Cosmos, COSMOS_USDC_TOKEN_ID)], - ), + SwapperChainAsset::Assets(Chain::Cosmos, vec![AssetId::from_token(Chain::Cosmos, COSMOS_USDC_TOKEN_ID)]), SwapperChainAsset::Assets( Chain::Osmosis, vec![ @@ -152,14 +147,8 @@ impl ProxyProvider { ], ), SwapperChainAsset::All(Chain::Celestia), - SwapperChainAsset::Assets( - Chain::Injective, - vec![AssetId::from_token(Chain::Injective, INJECTIVE_USDC_TOKEN_ID)], - ), - SwapperChainAsset::Assets( - Chain::Sei, - vec![AssetId::from_token(Chain::Sei, SEI_USDC_TOKEN_ID)], - ), + SwapperChainAsset::Assets(Chain::Injective, vec![AssetId::from_token(Chain::Injective, INJECTIVE_USDC_TOKEN_ID)]), + SwapperChainAsset::Assets(Chain::Sei, vec![AssetId::from_token(Chain::Sei, SEI_USDC_TOKEN_ID)]), SwapperChainAsset::All(Chain::Noble), ], rpc_provider, diff --git a/crates/swapper/src/swapper.rs b/crates/swapper/src/swapper.rs index 7a5e70753..60498e196 100644 --- a/crates/swapper/src/swapper.rs +++ b/crates/swapper/src/swapper.rs @@ -492,9 +492,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(); @@ -512,9 +514,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(); @@ -532,8 +536,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 0842bbd92..a87e91946 100644 --- a/gemstone/src/signer/chain.rs +++ b/gemstone/src/signer/chain.rs @@ -23,9 +23,7 @@ impl GemChainSigner { Chain::Sui => Box::new(SuiChainSigner), Chain::Solana => Box::new(SolanaChainSigner), Chain::Tron => Box::new(TronChainSigner), - Chain::Cosmos | Chain::Osmosis | Chain::Celestia | Chain::Injective | Chain::Sei | Chain::Noble => { - Box::new(CosmosChainSigner) - } + Chain::Cosmos | Chain::Osmosis | Chain::Celestia | Chain::Injective | Chain::Sei | Chain::Noble => Box::new(CosmosChainSigner), _ => todo!("Signer not implemented for chain {:?}", chain), }; From 00b55e314eadd9807589cca456198902dcc63374 Mon Sep 17 00:00:00 2001 From: 0xh3rman <119309671+0xh3rman@users.noreply.github.com> Date: Mon, 16 Mar 2026 14:08:51 +0900 Subject: [PATCH 08/18] more cleanup --- crates/gem_cosmos/src/models/message.rs | 12 ++++++----- crates/gem_cosmos/src/signer/transaction.rs | 23 +++++++++------------ crates/primitives/src/signer_error.rs | 12 ----------- crates/swapper/src/asset.rs | 9 ++++++++ crates/swapper/src/proxy/provider.rs | 6 +++--- crates/swapper/src/proxy/squid/mod.rs | 6 ------ 6 files changed, 29 insertions(+), 39 deletions(-) diff --git a/crates/gem_cosmos/src/models/message.rs b/crates/gem_cosmos/src/models/message.rs index 25ed3eea8..86a19f499 100644 --- a/crates/gem_cosmos/src/models/message.rs +++ b/crates/gem_cosmos/src/models/message.rs @@ -5,6 +5,8 @@ use super::{ExecuteContractValue, IbcTransferValue}; use crate::constants; #[cfg(feature = "signer")] use crate::constants::{MESSAGE_EXECUTE_CONTRACT, MESSAGE_IBC_TRANSFER}; +#[cfg(feature = "signer")] +use primitives::SignerError; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "@type")] @@ -124,12 +126,12 @@ pub enum CosmosMessage { #[cfg(feature = "signer")] impl CosmosMessage { - pub fn parse(data: &str) -> Result { - let envelope: MessageEnvelope = serde_json::from_str(data).map_err(|e| format!("invalid swap data JSON: {e}"))?; + pub fn parse(data: &str) -> Result { + let envelope: MessageEnvelope = serde_json::from_str(data)?; match envelope.type_url.as_str() { MESSAGE_EXECUTE_CONTRACT => { - let v: ExecuteContractValue = serde_json::from_value(envelope.value).map_err(|e| format!("invalid MsgExecuteContract: {e}"))?; + let v: ExecuteContractValue = serde_json::from_value(envelope.value)?; Ok(Self::ExecuteContract { sender: v.sender, contract: v.contract, @@ -138,7 +140,7 @@ impl CosmosMessage { }) } MESSAGE_IBC_TRANSFER => { - let v: IbcTransferValue = serde_json::from_value(envelope.value).map_err(|e| format!("invalid MsgTransfer: {e}"))?; + let v: IbcTransferValue = serde_json::from_value(envelope.value)?; Ok(Self::IbcTransfer { source_port: v.source_port, source_channel: v.source_channel, @@ -149,7 +151,7 @@ impl CosmosMessage { memo: v.memo, }) } - other => Err(format!("unsupported cosmos message type: {other}")), + other => SignerError::invalid_input_err(format!("unsupported cosmos message type: {other}")), } } } diff --git a/crates/gem_cosmos/src/signer/transaction.rs b/crates/gem_cosmos/src/signer/transaction.rs index 63ba475a7..9f4540d98 100644 --- a/crates/gem_cosmos/src/signer/transaction.rs +++ b/crates/gem_cosmos/src/signer/transaction.rs @@ -111,14 +111,12 @@ mod tests { sender: "osmo1test".to_string(), contract: "osmo1contract".to_string(), msg: b"{\"swap\":{}}".to_vec(), - funds: vec![Coin { - denom: "uosmo".to_string(), - amount: "1000000".to_string(), - }], + funds: vec![Coin { denom: "uosmo".to_string(), amount: "1000000".to_string() }], }; - let any = msg.encode_as_any(); - assert!(!any.is_empty()); - assert!(any.len() > 10); + assert_eq!( + hex::encode(msg.encode_as_any()), + "0a242f636f736d7761736d2e7761736d2e76312e4d736745786563757465436f6e747261637412390a096f736d6f3174657374120d6f736d6f31636f6e74726163741a0b7b2273776170223a7b7d7d2a100a05756f736d6f120731303030303030" + ); } #[test] @@ -126,16 +124,15 @@ mod tests { let msg = CosmosMessage::IbcTransfer { source_port: "transfer".to_string(), source_channel: "channel-0".to_string(), - token: Coin { - denom: "uatom".to_string(), - amount: "1000000".to_string(), - }, + token: Coin { denom: "uatom".to_string(), amount: "1000000".to_string() }, sender: "cosmos1test".to_string(), receiver: "osmo1test".to_string(), timeout_timestamp: 1773382733549000000, memo: "{\"ibc_callback\":\"osmo1contract\"}".to_string(), }; - let any = msg.encode_as_any(); - assert!(!any.is_empty()); + assert_eq!( + hex::encode(msg.encode_as_any()), + "0a292f6962632e6170706c69636174696f6e732e7472616e736665722e76312e4d73675472616e73666572126b0a087472616e7366657212096368616e6e656c2d301a100a057561746f6d120731303030303030220b636f736d6f7331746573742a096f736d6f317465737438c0aaffdfb4c694ce1842207b226962635f63616c6c6261636b223a226f736d6f31636f6e7472616374227d" + ); } } diff --git a/crates/primitives/src/signer_error.rs b/crates/primitives/src/signer_error.rs index 2b90e2cad..334646622 100644 --- a/crates/primitives/src/signer_error.rs +++ b/crates/primitives/src/signer_error.rs @@ -35,18 +35,6 @@ impl SignerError { } } -impl From<&str> for SignerError { - fn from(value: &str) -> Self { - Self::InvalidInput(value.to_string()) - } -} - -impl From for SignerError { - fn from(value: String) -> Self { - Self::InvalidInput(value) - } -} - impl From for SignerError { fn from(error: serde_json::Error) -> Self { SignerError::InvalidInput(error.to_string()) diff --git a/crates/swapper/src/asset.rs b/crates/swapper/src/asset.rs index fb014903a..f88af1832 100644 --- a/crates/swapper/src/asset.rs +++ b/crates/swapper/src/asset.rs @@ -56,6 +56,15 @@ pub const SUI_SBUSDT_TOKEN_ID: &str = "0x375f70cf2ae4c00bf37117d0c85a2c71545e6ee pub const THORCHAIN_TCY_TOKEN_ID: &str = "tcy"; // Tron pub const TRON_USDT_TOKEN_ID: &str = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"; +// Cosmos +pub const COSMOS_USDC_TOKEN_ID: &str = "ibc/F663521BF1836B00F5F177680F74BFB9A8B5654A694D0D2BC249E03CF2509013"; +// Osmosis +pub const OSMOSIS_USDC_TOKEN_ID: &str = "ibc/498A0751C798A0D9A389AA3691123DADA57DAA4FE165D5C75894505B876BA6E4"; +pub const OSMOSIS_USDT_TOKEN_ID: &str = "ibc/4ABBEF4C8926DDDB320AE5188CFD63267ABBCEFC0583E4AE05D6E5AA2401DDAB"; +// Injective +pub const INJECTIVE_USDC_TOKEN_ID: &str = "ibc/7E1AF94AD246BE522892751046F0C959B768642E5671CC3742264068D49553C0"; +// Sei +pub const SEI_USDC_TOKEN_ID: &str = "ibc/CA6FBFAF399474A06263E10D0CE5AEBBE15189D6D4B2DD9ADE61007E68EB9DB0"; // ethereum pub static ETHEREUM_USDT: LazyLock = LazyLock::new(|| { diff --git a/crates/swapper/src/proxy/provider.rs b/crates/swapper/src/proxy/provider.rs index 4f38ce14e..e0a3ff2ca 100644 --- a/crates/swapper/src/proxy/provider.rs +++ b/crates/swapper/src/proxy/provider.rs @@ -133,7 +133,7 @@ impl ProxyProvider { } pub fn new_squid(rpc_provider: Arc) -> Self { - use crate::proxy::squid::{COSMOS_USDC_TOKEN_ID, INJECTIVE_USDC_TOKEN_ID, OSMOSIS_USDC_TOKEN_ID, OSMOSIS_USDT_TOKEN_ID, SEI_USDC_TOKEN_ID}; + use crate::asset::{COSMOS_USDC_TOKEN_ID, INJECTIVE_USDC_TOKEN_ID, OSMOSIS_USDC_TOKEN_ID, OSMOSIS_USDT_TOKEN_ID, SEI_USDC_TOKEN_ID}; Self::new_with_path( SwapperProvider::Squid, "squid", @@ -146,10 +146,10 @@ impl ProxyProvider { AssetId::from_token(Chain::Osmosis, OSMOSIS_USDT_TOKEN_ID), ], ), - SwapperChainAsset::All(Chain::Celestia), + SwapperChainAsset::Assets(Chain::Celestia, vec![]), SwapperChainAsset::Assets(Chain::Injective, vec![AssetId::from_token(Chain::Injective, INJECTIVE_USDC_TOKEN_ID)]), SwapperChainAsset::Assets(Chain::Sei, vec![AssetId::from_token(Chain::Sei, SEI_USDC_TOKEN_ID)]), - SwapperChainAsset::All(Chain::Noble), + SwapperChainAsset::Assets(Chain::Noble, vec![]), ], rpc_provider, ) diff --git a/crates/swapper/src/proxy/squid/mod.rs b/crates/swapper/src/proxy/squid/mod.rs index 6aec5099b..89e7cf20a 100644 --- a/crates/swapper/src/proxy/squid/mod.rs +++ b/crates/swapper/src/proxy/squid/mod.rs @@ -4,9 +4,3 @@ mod model; pub use client::SquidClient; pub const SQUID_COSMOS_MULTICALL: &str = "osmo1n6ney9tsf55etz9nrmzyd8wa7e64qd3s06a74fqs30ka8pps6cvqtsycr6"; - -pub const COSMOS_USDC_TOKEN_ID: &str = "ibc/F663521BF1836B00F5F177680F74BFB9A8B5654A694D0D2BC249E03CF2509013"; -pub const OSMOSIS_USDC_TOKEN_ID: &str = "ibc/498A0751C798A0D9A389AA3691123DADA57DAA4FE165D5C75894505B876BA6E4"; -pub const OSMOSIS_USDT_TOKEN_ID: &str = "ibc/4ABBEF4C8926DDDB320AE5188CFD63267ABBCEFC0583E4AE05D6E5AA2401DDAB"; -pub const INJECTIVE_USDC_TOKEN_ID: &str = "ibc/7E1AF94AD246BE522892751046F0C959B768642E5671CC3742264068D49553C0"; -pub const SEI_USDC_TOKEN_ID: &str = "ibc/CA6FBFAF399474A06263E10D0CE5AEBBE15189D6D4B2DD9ADE61007E68EB9DB0"; From 5dabe24879271d9fa86bf57f2ceec6b0079634f2 Mon Sep 17 00:00:00 2001 From: 0xh3rman <119309671+0xh3rman@users.noreply.github.com> Date: Mon, 16 Mar 2026 15:11:48 +0900 Subject: [PATCH 09/18] Update fees.rs --- crates/swapper/src/fees.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/swapper/src/fees.rs b/crates/swapper/src/fees.rs index d1fac3016..c4e26b69e 100644 --- a/crates/swapper/src/fees.rs +++ b/crates/swapper/src/fees.rs @@ -33,6 +33,12 @@ pub static RESERVED_NATIVE_FEES: LazyLock> = LazyLo (Chain::Monad, "5000000000000000"), // 0.005 MON (Chain::XLayer, "5000000000000000"), // 0.005 OKB (Chain::Plasma, "5000000000000000"), // 0.005 XPL + (Chain::Cosmos, "39000"), // 0.039 ATOM (2.6M gas × 3000 / 200K) + (Chain::Osmosis, "130000"), // 0.13 OSMO (2.6M gas × 10000 / 200K) + (Chain::Celestia, "39000"), // 0.039 TIA (2.6M gas × 3000 / 200K) + (Chain::Injective, "1300000000000000"), // 0.0013 INJ (2.6M gas × 100T / 200K) + (Chain::Sei, "1300000"), // 1.3 SEI (2.6M gas × 100000 / 200K) + (Chain::Noble, "25000"), // 0.025 USDC ]) }); From 58c121d1a5a85676c3f7fa9da4038c43b711e725 Mon Sep 17 00:00:00 2001 From: 0xh3rman <119309671+0xh3rman@users.noreply.github.com> Date: Mon, 16 Mar 2026 22:13:45 +0900 Subject: [PATCH 10/18] add SkipExplorer --- crates/primitives/src/explorers/mod.rs | 2 + crates/primitives/src/explorers/skip.rs | 51 +++++++++++++++++++++++++ gemstone/src/block_explorer/mod.rs | 6 +-- 3 files changed, 56 insertions(+), 3 deletions(-) create mode 100644 crates/primitives/src/explorers/skip.rs diff --git a/crates/primitives/src/explorers/mod.rs b/crates/primitives/src/explorers/mod.rs index a3f4da01a..22fd6ec9b 100644 --- a/crates/primitives/src/explorers/mod.rs +++ b/crates/primitives/src/explorers/mod.rs @@ -47,6 +47,8 @@ pub mod chainflip; pub use chainflip::ChainflipScan; pub mod relay; pub use relay::RelayScan; +pub mod skip; +pub use skip::SkipExplorer; pub mod hypercore; pub use hypercore::{FlowScan, HyperliquidExplorer, HypurrScan}; pub mod metadata; diff --git a/crates/primitives/src/explorers/skip.rs b/crates/primitives/src/explorers/skip.rs new file mode 100644 index 000000000..a21a341a0 --- /dev/null +++ b/crates/primitives/src/explorers/skip.rs @@ -0,0 +1,51 @@ +use crate::block_explorer::BlockExplorer; +use crate::chain::Chain; + +pub struct SkipExplorer { + chain_id: String, +} + +impl SkipExplorer { + pub fn boxed(chain: Chain) -> Box { + Box::new(Self { + chain_id: chain.network_id().to_string(), + }) + } +} + +impl BlockExplorer for SkipExplorer { + fn name(&self) -> String { + "Skip Explorer".into() + } + + fn get_tx_url(&self, hash: &str) -> String { + format!("https://explorer.skip.build/?tx_hash={hash}&chain_id={}", self.chain_id) + } + + fn get_address_url(&self, _address: &str) -> String { + String::new() + } + + fn get_token_url(&self, _token_id: &str) -> Option { + None + } + + fn get_validator_url(&self, _address: &str) -> Option { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_skip_explorer_url() { + let explorer = SkipExplorer::boxed(Chain::Osmosis); + assert_eq!(explorer.name(), "Skip Explorer"); + assert_eq!( + explorer.get_tx_url("1FE2FF8C64062136544C35451E5AE292229A156E174C0EFF5B67970E629A8B1C"), + "https://explorer.skip.build/?tx_hash=1FE2FF8C64062136544C35451E5AE292229A156E174C0EFF5B67970E629A8B1C&chain_id=osmosis-1" + ); + } +} diff --git a/gemstone/src/block_explorer/mod.rs b/gemstone/src/block_explorer/mod.rs index 6122de419..b055365ca 100644 --- a/gemstone/src/block_explorer/mod.rs +++ b/gemstone/src/block_explorer/mod.rs @@ -1,7 +1,7 @@ use primitives::{ block_explorer::{BlockExplorer, get_block_explorer}, chain::Chain, - explorers::{ChainflipScan, MayanScan, NearIntents, RelayScan, RuneScan, SocketScan}, + explorers::{ChainflipScan, MayanScan, NearIntents, RelayScan, RuneScan, SkipExplorer, SocketScan}, }; use std::str::FromStr; @@ -49,8 +49,8 @@ impl Explorer { SwapperProvider::Chainflip => ChainflipScan::boxed(), SwapperProvider::NearIntents => NearIntents::boxed(), SwapperProvider::Relay => RelayScan::boxed(), - SwapperProvider::Squid - | SwapperProvider::UniswapV3 + SwapperProvider::Squid => SkipExplorer::boxed(self.chain), + SwapperProvider::UniswapV3 | SwapperProvider::UniswapV4 | SwapperProvider::PancakeswapV3 | SwapperProvider::Panora From 27feb239a9872034f8f2dbaaccded1105616cfcf Mon Sep 17 00:00:00 2001 From: 0xh3rman <119309671+0xh3rman@users.noreply.github.com> Date: Tue, 17 Mar 2026 14:32:34 +0900 Subject: [PATCH 11/18] move squid from core-ts --- .claude/skills/review-changes/SKILL.md | 2 + Cargo.lock | 1 + crates/gem_cosmos/src/models/ibc.rs | 4 +- crates/gem_cosmos/src/models/long.rs | 70 ++++++ crates/gem_cosmos/src/models/mod.rs | 2 + crates/gem_cosmos/src/signer/chain_signer.rs | 24 +- crates/gem_cosmos/src/signer/transaction.rs | 10 +- .../src/transaction_load_metadata.rs | 7 + crates/swapper/Cargo.toml | 1 + crates/swapper/src/fees.rs | 12 +- crates/swapper/src/lib.rs | 1 + crates/swapper/src/proxy/mod.rs | 1 - crates/swapper/src/proxy/provider.rs | 42 +--- crates/swapper/src/proxy/provider_factory.rs | 4 - crates/swapper/src/proxy/squid/client.rs | 25 -- crates/swapper/src/proxy/squid/mod.rs | 6 - crates/swapper/src/proxy/squid/model.rs | 39 ---- crates/swapper/src/squid/client.rs | 32 +++ crates/swapper/src/squid/mod.rs | 32 +++ crates/swapper/src/squid/model.rs | 95 ++++++++ crates/swapper/src/squid/provider.rs | 213 ++++++++++++++++++ crates/swapper/src/swapper.rs | 4 +- skills/code-style.md | 1 + 23 files changed, 487 insertions(+), 141 deletions(-) create mode 100644 crates/gem_cosmos/src/models/long.rs delete mode 100644 crates/swapper/src/proxy/squid/client.rs delete mode 100644 crates/swapper/src/proxy/squid/mod.rs delete mode 100644 crates/swapper/src/proxy/squid/model.rs create mode 100644 crates/swapper/src/squid/client.rs create mode 100644 crates/swapper/src/squid/mod.rs create mode 100644 crates/swapper/src/squid/model.rs create mode 100644 crates/swapper/src/squid/provider.rs diff --git a/.claude/skills/review-changes/SKILL.md b/.claude/skills/review-changes/SKILL.md index 5d0ae311c..33d63b3d6 100644 --- a/.claude/skills/review-changes/SKILL.md +++ b/.claude/skills/review-changes/SKILL.md @@ -82,6 +82,8 @@ Analyze the diff above and check for the following issues: - [ ] **Simple solutions**: Three similar lines is better than a premature abstraction - [ ] **Avoid `mut`**: Prefer immutable bindings; use `mut` only when truly necessary - [ ] **Prefer one-liners**: Inline single-use variables; avoid creating variables used only once +- [ ] **Avoid `#[serde(default)]`**: Only use when the field is genuinely optional in the API response; if the field is always present, omit it +- [ ] **Use accessor methods for enum variants**: Instead of destructuring enum variants with `match`, use typed accessor methods (e.g., `metadata.get_sequence()?` instead of `match &metadata { Cosmos { sequence, .. } => ... }`) ### 5. Code Organization - [ ] **Modular structure**: Break down files into smaller, focused modules; separate models from clients/logic (e.g., `models.rs` + `client.rs`, not everything in one file) diff --git a/Cargo.lock b/Cargo.lock index e3b99deb2..86cbf10e0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7183,6 +7183,7 @@ dependencies = [ "futures", "gem_aptos", "gem_client", + "gem_cosmos", "gem_evm", "gem_hash", "gem_hypercore", diff --git a/crates/gem_cosmos/src/models/ibc.rs b/crates/gem_cosmos/src/models/ibc.rs index a6d53f427..5c1f30103 100644 --- a/crates/gem_cosmos/src/models/ibc.rs +++ b/crates/gem_cosmos/src/models/ibc.rs @@ -1,6 +1,6 @@ use super::Coin; +use super::long::deserialize_u64_from_long_or_int; use serde::Deserialize; -use serde_serializers::deserialize_u64_from_str_or_int; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -10,7 +10,7 @@ pub struct IbcTransferValue { pub token: Coin, pub sender: String, pub receiver: String, - #[serde(deserialize_with = "deserialize_u64_from_str_or_int")] + #[serde(deserialize_with = "deserialize_u64_from_long_or_int")] pub timeout_timestamp: u64, pub memo: String, } diff --git a/crates/gem_cosmos/src/models/long.rs b/crates/gem_cosmos/src/models/long.rs new file mode 100644 index 000000000..5cb3db87a --- /dev/null +++ b/crates/gem_cosmos/src/models/long.rs @@ -0,0 +1,70 @@ +use serde::{Deserialize, Deserializer}; + +#[derive(Debug, Deserialize)] +pub struct Long { + pub low: i32, + pub high: i32, +} + +impl Long { + pub fn to_uint64(&self) -> u64 { + ((self.high as u32 as u64) << 32) | (self.low as u32 as u64) + } +} + +pub fn deserialize_u64_from_long_or_int<'de, D: Deserializer<'de>>(deserializer: D) -> Result { + #[derive(Deserialize)] + #[serde(untagged)] + enum LongOrValue { + Number(u64), + Str(String), + Long(Long), + } + + match LongOrValue::deserialize(deserializer)? { + LongOrValue::Number(n) => Ok(n), + LongOrValue::Str(s) => s.parse::().map_err(serde::de::Error::custom), + LongOrValue::Long(l) => Ok(l.to_uint64()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_to_uint64() { + let l = Long { low: -72998656, high: 412955876 }; + assert_eq!(l.to_uint64(), 1773631986332999936); + } + + #[test] + fn test_to_uint64_small() { + let l = Long { low: 1, high: 0 }; + assert_eq!(l.to_uint64(), 1); + } + + #[derive(Deserialize)] + struct TestTimestamp { + #[serde(deserialize_with = "deserialize_u64_from_long_or_int")] + ts: u64, + } + + #[test] + fn test_deserialize_number() { + let v: TestTimestamp = serde_json::from_str(r#"{"ts": 1773382733549000000}"#).unwrap(); + assert_eq!(v.ts, 1773382733549000000); + } + + #[test] + fn test_deserialize_string() { + let v: TestTimestamp = serde_json::from_str(r#"{"ts": "1773382733549000000"}"#).unwrap(); + assert_eq!(v.ts, 1773382733549000000); + } + + #[test] + fn test_deserialize_long() { + let v: TestTimestamp = serde_json::from_str(r#"{"ts": {"low": -72998656, "high": 412955876, "unsigned": false}}"#).unwrap(); + assert_eq!(v.ts, 1773631986332999936); + } +} diff --git a/crates/gem_cosmos/src/models/mod.rs b/crates/gem_cosmos/src/models/mod.rs index 04a8871c0..6862a233e 100644 --- a/crates/gem_cosmos/src/models/mod.rs +++ b/crates/gem_cosmos/src/models/mod.rs @@ -1,5 +1,6 @@ pub mod account; pub mod block; +pub mod long; pub mod message; pub mod staking; pub mod staking_osmosis; @@ -11,6 +12,7 @@ pub mod contract; pub mod ibc; pub use account::*; pub use block::*; +pub use long::*; pub use message::*; pub use staking::*; pub use staking_osmosis::*; diff --git a/crates/gem_cosmos/src/signer/chain_signer.rs b/crates/gem_cosmos/src/signer/chain_signer.rs index 6ffeda34d..9d9b31b57 100644 --- a/crates/gem_cosmos/src/signer/chain_signer.rs +++ b/crates/gem_cosmos/src/signer/chain_signer.rs @@ -3,7 +3,7 @@ use std::str::FromStr; use base64::{Engine, engine::general_purpose::STANDARD}; use gem_hash::sha2::sha256; use primitives::chain_cosmos::CosmosChain; -use primitives::{ChainSigner, SignerError, TransactionLoadInput, TransactionLoadMetadata}; +use primitives::{ChainSigner, SignerError, TransactionLoadInput}; use signer::{SignatureScheme, Signer}; use crate::models::{Coin, CosmosMessage}; @@ -50,16 +50,9 @@ pub fn encode_and_sign_tx(params: &CosmosTxParams, private_key: &[u8]) -> Result impl ChainSigner for CosmosChainSigner { 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 (account_number, sequence, chain_id) = match &input.metadata { - TransactionLoadMetadata::Cosmos { - account_number, - sequence, - chain_id, - } => (*account_number, *sequence, chain_id.as_str()), - _ => return Err(SignerError::invalid_input("expected Cosmos metadata")), - }; - + let account_number = input.metadata.get_account_number().map_err(SignerError::from_display)?; + let sequence = input.metadata.get_sequence().map_err(SignerError::from_display)?; + let chain_id = input.metadata.get_chain_id().map_err(SignerError::from_display)?; let chain = CosmosChain::from_str(input.input_type.get_asset().chain.as_ref()).map_err(|_| SignerError::invalid_input("unsupported cosmos chain"))?; let message = CosmosMessage::parse(&swap_data.data.data)?; @@ -74,12 +67,17 @@ impl ChainSigner for CosmosChainSigner { .unwrap_or(BASE_FEE_GAS_UNITS); let gas_limit = gas_limit * GAS_BUFFER_NUMERATOR / GAS_BUFFER_DENOMINATOR; - let base_fee: u64 = input.gas_price.gas_price().to_string().parse().unwrap_or(0); + let base_fee: u64 = input + .gas_price + .gas_price() + .to_string() + .parse() + .map_err(|_| SignerError::invalid_input("invalid gas price"))?; let fee_amount = ((gas_limit as u128 * base_fee as u128 / BASE_FEE_GAS_UNITS as u128) as u64).to_string(); let params = CosmosTxParams { body_bytes, - chain_id, + chain_id: &chain_id, account_number, sequence, fee_coins: vec![Coin { diff --git a/crates/gem_cosmos/src/signer/transaction.rs b/crates/gem_cosmos/src/signer/transaction.rs index 9f4540d98..c9e6a7b74 100644 --- a/crates/gem_cosmos/src/signer/transaction.rs +++ b/crates/gem_cosmos/src/signer/transaction.rs @@ -111,7 +111,10 @@ mod tests { sender: "osmo1test".to_string(), contract: "osmo1contract".to_string(), msg: b"{\"swap\":{}}".to_vec(), - funds: vec![Coin { denom: "uosmo".to_string(), amount: "1000000".to_string() }], + funds: vec![Coin { + denom: "uosmo".to_string(), + amount: "1000000".to_string(), + }], }; assert_eq!( hex::encode(msg.encode_as_any()), @@ -124,7 +127,10 @@ mod tests { let msg = CosmosMessage::IbcTransfer { source_port: "transfer".to_string(), source_channel: "channel-0".to_string(), - token: Coin { denom: "uatom".to_string(), amount: "1000000".to_string() }, + token: Coin { + denom: "uatom".to_string(), + amount: "1000000".to_string(), + }, sender: "cosmos1test".to_string(), receiver: "osmo1test".to_string(), timeout_timestamp: 1773382733549000000, diff --git a/crates/primitives/src/transaction_load_metadata.rs b/crates/primitives/src/transaction_load_metadata.rs index 875866f05..221efea48 100644 --- a/crates/primitives/src/transaction_load_metadata.rs +++ b/crates/primitives/src/transaction_load_metadata.rs @@ -128,6 +128,13 @@ impl TransactionLoadMetadata { } } + pub fn get_account_number(&self) -> Result> { + match self { + TransactionLoadMetadata::Cosmos { account_number, .. } => Ok(*account_number), + _ => Err("Account number not available for this metadata type".into()), + } + } + pub fn get_chain_id(&self) -> Result> { match self { TransactionLoadMetadata::Cosmos { chain_id, .. } => Ok(chain_id.clone()), diff --git a/crates/swapper/Cargo.toml b/crates/swapper/Cargo.toml index 1be23755c..67605d82a 100644 --- a/crates/swapper/Cargo.toml +++ b/crates/swapper/Cargo.toml @@ -16,6 +16,7 @@ gem_ton = { path = "../gem_ton", features = ["rpc"] } gem_evm = { path = "../gem_evm", features = ["rpc"] } gem_sui = { path = "../gem_sui", features = ["rpc"] } gem_aptos = { path = "../gem_aptos", features = ["rpc"] } +gem_cosmos = { path = "../gem_cosmos" } gem_hash = { path = "../gem_hash" } gem_jsonrpc = { path = "../gem_jsonrpc" } gem_client = { path = "../gem_client" } diff --git a/crates/swapper/src/fees.rs b/crates/swapper/src/fees.rs index c4e26b69e..0a0d84487 100644 --- a/crates/swapper/src/fees.rs +++ b/crates/swapper/src/fees.rs @@ -33,12 +33,12 @@ pub static RESERVED_NATIVE_FEES: LazyLock> = LazyLo (Chain::Monad, "5000000000000000"), // 0.005 MON (Chain::XLayer, "5000000000000000"), // 0.005 OKB (Chain::Plasma, "5000000000000000"), // 0.005 XPL - (Chain::Cosmos, "39000"), // 0.039 ATOM (2.6M gas × 3000 / 200K) - (Chain::Osmosis, "130000"), // 0.13 OSMO (2.6M gas × 10000 / 200K) - (Chain::Celestia, "39000"), // 0.039 TIA (2.6M gas × 3000 / 200K) - (Chain::Injective, "1300000000000000"), // 0.0013 INJ (2.6M gas × 100T / 200K) - (Chain::Sei, "1300000"), // 1.3 SEI (2.6M gas × 100000 / 200K) - (Chain::Noble, "25000"), // 0.025 USDC + (Chain::Cosmos, "39000"), // 0.039 ATOM (2.6M gas × 3000 / 200K) + (Chain::Osmosis, "130000"), // 0.13 OSMO (2.6M gas × 10000 / 200K) + (Chain::Celestia, "39000"), // 0.039 TIA (2.6M gas × 3000 / 200K) + (Chain::Injective, "1300000000000000"), // 0.0013 INJ (2.6M gas × 100T / 200K) + (Chain::Sei, "1300000"), // 1.3 SEI (2.6M gas × 100000 / 200K) + (Chain::Noble, "25000"), // 0.025 USDC ]) }); diff --git a/crates/swapper/src/lib.rs b/crates/swapper/src/lib.rs index cbce4cb89..0833b592f 100644 --- a/crates/swapper/src/lib.rs +++ b/crates/swapper/src/lib.rs @@ -24,6 +24,7 @@ pub mod proxy; pub mod referrer; pub mod relay; pub mod slippage; +pub mod squid; pub mod swapper; pub mod thorchain; pub mod uniswap; diff --git a/crates/swapper/src/proxy/mod.rs b/crates/swapper/src/proxy/mod.rs index f840306e7..58f2c93b5 100644 --- a/crates/swapper/src/proxy/mod.rs +++ b/crates/swapper/src/proxy/mod.rs @@ -3,6 +3,5 @@ mod client; pub mod mayan; pub mod provider; pub mod provider_factory; -pub mod squid; pub use client::ProxyError; diff --git a/crates/swapper/src/proxy/provider.rs b/crates/swapper/src/proxy/provider.rs index e0a3ff2ca..163bb7338 100644 --- a/crates/swapper/src/proxy/provider.rs +++ b/crates/swapper/src/proxy/provider.rs @@ -5,7 +5,6 @@ use std::{collections::BTreeSet, fmt::Debug, str::FromStr, sync::Arc}; use super::{ client::ProxyClient, mayan::{MAYAN_DEPOSIT_CONTRACTS, MAYAN_SEND_CONTRACTS, MayanChain, MayanExplorer, MayanPrice, map_swap_result}, - squid::{SQUID_COSMOS_MULTICALL, SquidClient}, }; use crate::{ FetchQuoteData, ProviderData, ProviderType, Quote, QuoteRequest, Route, SwapResult, Swapper, SwapperError, SwapperProvider, SwapperProviderMode, SwapperQuoteData, @@ -18,7 +17,7 @@ use crate::{ }; use gem_client::Client; use primitives::{ - AssetId, Chain, ChainType, + Chain, ChainType, swap::{ApprovalData, ProxyQuote, ProxyQuoteRequest, SwapQuoteData}, }; @@ -132,29 +131,6 @@ impl ProxyProvider { Self::new_with_path(SwapperProvider::Panora, "panora", vec![SwapperChainAsset::All(Chain::Aptos)], rpc_provider) } - pub fn new_squid(rpc_provider: Arc) -> Self { - use crate::asset::{COSMOS_USDC_TOKEN_ID, INJECTIVE_USDC_TOKEN_ID, OSMOSIS_USDC_TOKEN_ID, OSMOSIS_USDT_TOKEN_ID, SEI_USDC_TOKEN_ID}; - Self::new_with_path( - SwapperProvider::Squid, - "squid", - vec![ - SwapperChainAsset::Assets(Chain::Cosmos, vec![AssetId::from_token(Chain::Cosmos, COSMOS_USDC_TOKEN_ID)]), - SwapperChainAsset::Assets( - Chain::Osmosis, - vec![ - AssetId::from_token(Chain::Osmosis, OSMOSIS_USDC_TOKEN_ID), - AssetId::from_token(Chain::Osmosis, OSMOSIS_USDT_TOKEN_ID), - ], - ), - SwapperChainAsset::Assets(Chain::Celestia, vec![]), - SwapperChainAsset::Assets(Chain::Injective, vec![AssetId::from_token(Chain::Injective, INJECTIVE_USDC_TOKEN_ID)]), - SwapperChainAsset::Assets(Chain::Sei, vec![AssetId::from_token(Chain::Sei, SEI_USDC_TOKEN_ID)]), - SwapperChainAsset::Assets(Chain::Noble, vec![]), - ], - rpc_provider, - ) - } - pub fn new_mayan(rpc_provider: Arc) -> Self { let assets = vec![ SwapperChainAsset::Assets( @@ -260,15 +236,6 @@ where let result = client.get_transaction_status(transaction_hash).await?; Ok(map_swap_result(&result)) } - SwapperProvider::Squid => { - let base_url = get_swap_api_url("squid"); - let client = SquidClient::new(base_url, self.rpc_provider.clone()); - let result = client.get_transaction_status(transaction_hash).await?; - Ok(SwapResult { - status: result.squid_transaction_status.swap_status(), - metadata: None, - }) - } _ => { if self.provider.mode == SwapperProviderMode::OnChain { Ok(SwapResult { @@ -297,13 +264,6 @@ where send: send.into_iter().collect(), }) } - SwapperProvider::Squid => { - let address = SQUID_COSMOS_MULTICALL.to_string(); - Ok(VaultAddresses { - deposit: vec![address.clone()], - send: vec![address], - }) - } _ => Ok(VaultAddresses { deposit: vec![], send: vec![] }), } } diff --git a/crates/swapper/src/proxy/provider_factory.rs b/crates/swapper/src/proxy/provider_factory.rs index dde99fe36..3114dad8c 100644 --- a/crates/swapper/src/proxy/provider_factory.rs +++ b/crates/swapper/src/proxy/provider_factory.rs @@ -26,7 +26,3 @@ pub fn new_panora(rpc_provider: Arc) -> ProxyProvider) -> ProxyProvider { ProxyProvider::new_mayan(rpc_provider) } - -pub fn new_squid(rpc_provider: Arc) -> ProxyProvider { - ProxyProvider::new_squid(rpc_provider) -} diff --git a/crates/swapper/src/proxy/squid/client.rs b/crates/swapper/src/proxy/squid/client.rs deleted file mode 100644 index 6d56f7dff..000000000 --- a/crates/swapper/src/proxy/squid/client.rs +++ /dev/null @@ -1,25 +0,0 @@ -use super::model::SquidTransactionStatus; -use crate::{ - SwapperError, - alien::{RpcProvider, Target}, -}; -use std::sync::Arc; - -pub struct SquidClient { - base_url: String, - provider: Arc, -} - -impl SquidClient { - pub fn new(base_url: String, provider: Arc) -> Self { - Self { base_url, provider } - } - - pub async fn get_transaction_status(&self, tx_hash: &str) -> Result { - let url = format!("{}/v2/status?transactionId={tx_hash}", self.base_url); - let target = Target::get(&url); - let response = self.provider.request(target).await?; - let result: SquidTransactionStatus = serde_json::from_slice(&response.data).map_err(SwapperError::from)?; - Ok(result) - } -} diff --git a/crates/swapper/src/proxy/squid/mod.rs b/crates/swapper/src/proxy/squid/mod.rs deleted file mode 100644 index 89e7cf20a..000000000 --- a/crates/swapper/src/proxy/squid/mod.rs +++ /dev/null @@ -1,6 +0,0 @@ -mod client; -mod model; - -pub use client::SquidClient; - -pub const SQUID_COSMOS_MULTICALL: &str = "osmo1n6ney9tsf55etz9nrmzyd8wa7e64qd3s06a74fqs30ka8pps6cvqtsycr6"; diff --git a/crates/swapper/src/proxy/squid/model.rs b/crates/swapper/src/proxy/squid/model.rs deleted file mode 100644 index 3a47f02a0..000000000 --- a/crates/swapper/src/proxy/squid/model.rs +++ /dev/null @@ -1,39 +0,0 @@ -use primitives::swap::SwapStatus; -use serde::Deserialize; - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SquidTransactionStatus { - pub squid_transaction_status: SquidStatus, -} - -#[derive(Debug, Clone, Deserialize, PartialEq)] -#[serde(rename_all = "snake_case")] -pub enum SquidStatus { - Success, - Ongoing, - PartialSuccess, - NeedsGas, - NotFound, -} - -impl SquidStatus { - pub fn swap_status(&self) -> SwapStatus { - match self { - SquidStatus::Success | SquidStatus::PartialSuccess => SwapStatus::Completed, - SquidStatus::Ongoing | SquidStatus::NeedsGas | SquidStatus::NotFound => SwapStatus::Pending, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_deserialize_status_response() { - let result: SquidTransactionStatus = serde_json::from_str(include_str!("../../../testdata/squid/status_response.json")).unwrap(); - assert_eq!(result.squid_transaction_status, SquidStatus::Success); - assert_eq!(result.squid_transaction_status.swap_status(), SwapStatus::Completed); - } -} diff --git a/crates/swapper/src/squid/client.rs b/crates/swapper/src/squid/client.rs new file mode 100644 index 000000000..09110864c --- /dev/null +++ b/crates/swapper/src/squid/client.rs @@ -0,0 +1,32 @@ +use std::fmt::Debug; + +use gem_client::{Client, ClientExt}; + +use super::model::{SquidRouteRequest, SquidRouteResponse, SquidStatusResponse}; +use crate::SwapperError; + +#[derive(Clone, Debug)] +pub struct SquidClient +where + C: Client + Clone + Send + Sync + Debug + 'static, +{ + client: C, +} + +impl SquidClient +where + C: Client + Clone + Send + Sync + Debug + 'static, +{ + pub fn new(client: C) -> Self { + Self { client } + } + + pub async fn get_route(&self, request: &SquidRouteRequest) -> Result { + self.client.post("/v2/route", request).await.map_err(SwapperError::from) + } + + pub async fn get_status(&self, tx_hash: &str) -> Result { + let path = format!("/v2/status?transactionId={tx_hash}"); + self.client.get(&path).await.map_err(SwapperError::from) + } +} diff --git a/crates/swapper/src/squid/mod.rs b/crates/swapper/src/squid/mod.rs new file mode 100644 index 000000000..97aac023f --- /dev/null +++ b/crates/swapper/src/squid/mod.rs @@ -0,0 +1,32 @@ +mod client; +mod model; +mod provider; + +use std::sync::LazyLock; + +use crate::{ + asset::{COSMOS_USDC_TOKEN_ID, INJECTIVE_USDC_TOKEN_ID, OSMOSIS_USDC_TOKEN_ID, OSMOSIS_USDT_TOKEN_ID, SEI_USDC_TOKEN_ID}, + models::SwapperChainAsset, +}; +use primitives::{AssetId, Chain}; + +pub use provider::Squid; + +const SQUID_COSMOS_MULTICALL: &str = "osmo1n6ney9tsf55etz9nrmzyd8wa7e64qd3s06a74fqs30ka8pps6cvqtsycr6"; + +static SUPPORTED_CHAINS: LazyLock> = LazyLock::new(|| { + vec![ + SwapperChainAsset::Assets(Chain::Cosmos, vec![AssetId::from_token(Chain::Cosmos, COSMOS_USDC_TOKEN_ID)]), + SwapperChainAsset::Assets( + Chain::Osmosis, + vec![ + AssetId::from_token(Chain::Osmosis, OSMOSIS_USDC_TOKEN_ID), + AssetId::from_token(Chain::Osmosis, OSMOSIS_USDT_TOKEN_ID), + ], + ), + SwapperChainAsset::Assets(Chain::Celestia, vec![]), + SwapperChainAsset::Assets(Chain::Injective, vec![AssetId::from_token(Chain::Injective, INJECTIVE_USDC_TOKEN_ID)]), + SwapperChainAsset::Assets(Chain::Sei, vec![AssetId::from_token(Chain::Sei, SEI_USDC_TOKEN_ID)]), + SwapperChainAsset::Assets(Chain::Noble, vec![]), + ] +}); diff --git a/crates/swapper/src/squid/model.rs b/crates/swapper/src/squid/model.rs new file mode 100644 index 000000000..0312f5812 --- /dev/null +++ b/crates/swapper/src/squid/model.rs @@ -0,0 +1,95 @@ +use primitives::swap::SwapStatus; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SquidRouteRequest { + pub from_chain: String, + pub to_chain: String, + pub from_token: String, + pub to_token: String, + pub from_amount: String, + pub from_address: String, + pub to_address: String, + pub slippage_config: SlippageConfig, + pub quote_only: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SlippageConfig { + pub auto_mode: u32, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct SquidRouteResponse { + pub route: SquidRoute, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SquidRoute { + pub estimate: SquidEstimate, + pub transaction_request: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SquidEstimate { + pub to_amount: String, + pub estimated_route_duration: u32, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SquidTransactionRequest { + pub target: String, + pub data: String, + pub value: String, + pub gas_limit: String, +} + +impl SquidTransactionRequest { + pub fn get_gas_limit(&self) -> Option { + if self.gas_limit.is_empty() || self.gas_limit == "0" { None } else { Some(self.gas_limit.clone()) } + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SquidStatusResponse { + pub squid_transaction_status: SquidStatus, +} + +#[derive(Debug, Clone, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum SquidStatus { + Success, + Ongoing, + PartialSuccess, + NeedsGas, + NotFound, + Refund, +} + +impl SquidStatus { + pub fn swap_status(&self) -> SwapStatus { + match self { + Self::Success | Self::PartialSuccess => SwapStatus::Completed, + Self::Ongoing | Self::NeedsGas | Self::NotFound => SwapStatus::Pending, + Self::Refund => SwapStatus::Failed, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_deserialize_status_response() { + let result: SquidStatusResponse = serde_json::from_str(include_str!("../../testdata/squid/status_response.json")).unwrap(); + assert_eq!(result.squid_transaction_status, SquidStatus::Success); + assert_eq!(result.squid_transaction_status.swap_status(), SwapStatus::Completed); + } +} diff --git a/crates/swapper/src/squid/provider.rs b/crates/swapper/src/squid/provider.rs new file mode 100644 index 000000000..ceff7cc2c --- /dev/null +++ b/crates/swapper/src/squid/provider.rs @@ -0,0 +1,213 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use gem_client::Client; +use primitives::{AssetId, Chain, chain_cosmos::CosmosChain}; + +use super::{SQUID_COSMOS_MULTICALL, SUPPORTED_CHAINS, client::SquidClient, model::*}; +use crate::{ + FetchQuoteData, ProviderData, ProviderType, Quote, QuoteRequest, Route, RpcClient, RpcProvider, SwapResult, Swapper, SwapperChainAsset, SwapperError, SwapperProvider, + SwapperQuoteData, config::get_swap_api_url, cross_chain::VaultAddresses, fees::resolve_max_quote_value, +}; + +#[derive(Debug)] +pub struct Squid +where + C: Client + Clone + Send + Sync + std::fmt::Debug + 'static, +{ + provider: ProviderType, + client: SquidClient, +} + +impl Squid { + pub fn new(rpc_provider: Arc) -> Self { + let client = SquidClient::new(RpcClient::new(get_swap_api_url("squid"), rpc_provider)); + Self { + provider: ProviderType::new(SwapperProvider::Squid), + client, + } + } +} + +fn get_network_id(chain: &Chain) -> Result<&str, SwapperError> { + CosmosChain::from_chain(*chain).map(|_| chain.network_id()).ok_or(SwapperError::NotSupportedChain) +} + +fn get_token_id(asset_id: &AssetId) -> Result { + if asset_id.is_native() { + asset_id.chain.as_denom().map(|d| d.to_string()).ok_or(SwapperError::NotSupportedAsset) + } else { + asset_id.token_id.clone().ok_or(SwapperError::NotSupportedAsset) + } +} + +fn build_route_request(request: &QuoteRequest, from_value: &str, quote_only: bool) -> Result { + let from_asset_id = request.from_asset.asset_id(); + let to_asset_id = request.to_asset.asset_id(); + Ok(SquidRouteRequest { + from_chain: get_network_id(&from_asset_id.chain)?.to_string(), + to_chain: get_network_id(&to_asset_id.chain)?.to_string(), + from_token: get_token_id(&from_asset_id)?, + to_token: get_token_id(&to_asset_id)?, + from_amount: from_value.to_string(), + from_address: request.wallet_address.clone(), + to_address: request.destination_address.clone(), + slippage_config: SlippageConfig { auto_mode: 1 }, + quote_only, + }) +} + +#[async_trait] +impl Swapper for Squid +where + C: Client + Clone + Send + Sync + std::fmt::Debug + 'static, +{ + fn provider(&self) -> &ProviderType { + &self.provider + } + + fn supported_assets(&self) -> Vec { + SUPPORTED_CHAINS.clone() + } + + async fn get_quote(&self, request: &QuoteRequest) -> Result { + let from_value = resolve_max_quote_value(request)?; + let squid_request = build_route_request(request, &from_value, true)?; + let response = self.client.get_route(&squid_request).await?; + + let from_asset_id = request.from_asset.asset_id(); + let to_asset_id = request.to_asset.asset_id(); + + Ok(Quote { + from_value, + to_value: response.route.estimate.to_amount, + data: ProviderData { + provider: self.provider().clone(), + routes: vec![Route { + input: from_asset_id, + output: to_asset_id, + route_data: String::new(), + }], + slippage_bps: request.options.slippage.bps, + }, + request: request.clone(), + eta_in_seconds: Some(response.route.estimate.estimated_route_duration), + }) + } + + async fn get_quote_data(&self, quote: &Quote, _data: FetchQuoteData) -> Result { + let squid_request = build_route_request("e.request, "e.from_value, false)?; + let response = self.client.get_route(&squid_request).await?; + let tx = response.route.transaction_request.ok_or(SwapperError::InvalidRoute)?; + let gas_limit = tx.get_gas_limit(); + Ok(SwapperQuoteData::new_contract(tx.target, tx.value, tx.data, None, gas_limit)) + } + + async fn get_vault_addresses(&self, _from_timestamp: Option) -> Result { + let address = SQUID_COSMOS_MULTICALL.to_string(); + Ok(VaultAddresses { + deposit: vec![address.clone()], + send: vec![address], + }) + } + + async fn get_swap_result(&self, _chain: Chain, transaction_hash: &str) -> Result { + let result = self.client.get_status(transaction_hash).await?; + Ok(SwapResult { + status: result.squid_transaction_status.swap_status(), + metadata: None, + }) + } +} + +#[cfg(all(test, feature = "swap_integration_tests"))] +mod swap_integration_tests { + use super::*; + use crate::{SwapperMode, SwapperQuoteAsset, models::Options}; + use primitives::swap::SwapStatus; + + const OSMOSIS_ADDRESS: &str = "osmo1tkvyjqeq204rmrrz3w4hcrs336qahsfwn8m0ye"; + const COSMOS_ADDRESS: &str = "cosmos1tkvyjqeq204rmrrz3w4hcrs336qahsfwmugljt"; + + fn create_provider() -> Squid { + let provider = Arc::new(crate::NativeProvider::default()); + Squid::new(provider) + } + + #[tokio::test] + async fn test_squid_osmo_to_atom() -> Result<(), Box> { + let squid = create_provider(); + + let request = QuoteRequest { + from_asset: SwapperQuoteAsset::from(AssetId::from_chain(Chain::Osmosis)), + to_asset: SwapperQuoteAsset::from(AssetId::from_chain(Chain::Cosmos)), + wallet_address: OSMOSIS_ADDRESS.to_string(), + destination_address: COSMOS_ADDRESS.to_string(), + value: "10000000".to_string(), + mode: SwapperMode::ExactIn, + options: Options::new_with_slippage(100.into()), + }; + + let quote = squid.get_quote(&request).await?; + println!( + "OSMO->ATOM quote: from={}, to={}, eta={}s", + quote.from_value, + quote.to_value, + quote.eta_in_seconds.unwrap_or(0) + ); + assert_eq!(quote.from_value, "10000000"); + assert!(!quote.to_value.is_empty()); + assert!(quote.to_value.parse::().unwrap() > 0); + + let quote_data = squid.get_quote_data("e, FetchQuoteData::None).await?; + println!("OSMO->ATOM data: to={}, value={}, gasLimit={:?}", quote_data.to, quote_data.value, quote_data.gas_limit); + println!("OSMO->ATOM msg: {}", "e_data.data[..200.min(quote_data.data.len())]); + assert!(!quote_data.data.is_empty()); + + Ok(()) + } + + #[tokio::test] + async fn test_squid_atom_to_osmo() -> Result<(), Box> { + let squid = create_provider(); + + let request = QuoteRequest { + from_asset: SwapperQuoteAsset::from(AssetId::from_chain(Chain::Cosmos)), + to_asset: SwapperQuoteAsset::from(AssetId::from_chain(Chain::Osmosis)), + wallet_address: COSMOS_ADDRESS.to_string(), + destination_address: OSMOSIS_ADDRESS.to_string(), + value: "1000000".to_string(), + mode: SwapperMode::ExactIn, + options: Options::new_with_slippage(100.into()), + }; + + let quote = squid.get_quote(&request).await?; + println!( + "ATOM->OSMO quote: from={}, to={}, eta={}s", + quote.from_value, + quote.to_value, + quote.eta_in_seconds.unwrap_or(0) + ); + assert_eq!(quote.from_value, "1000000"); + assert!(quote.to_value.parse::().unwrap() > 0); + + let quote_data = squid.get_quote_data("e, FetchQuoteData::None).await?; + println!("ATOM->OSMO data: to={}, value={}, gasLimit={:?}", quote_data.to, quote_data.value, quote_data.gas_limit); + println!("ATOM->OSMO msg: {}", "e_data.data[..200.min(quote_data.data.len())]); + assert!(!quote_data.data.is_empty()); + assert!(!quote_data.data.contains("\"low\"")); + + Ok(()) + } + + #[tokio::test] + async fn test_squid_swap_status() -> Result<(), Box> { + let squid = create_provider(); + let result = squid + .get_swap_result(Chain::Cosmos, "D68723CEADAB65795B176FAE0B84B0ED5923DA9AAEC69502F8D30554431250A9") + .await?; + println!("status: {:?}", result.status); + assert_eq!(result.status, SwapStatus::Completed); + Ok(()) + } +} diff --git a/crates/swapper/src/swapper.rs b/crates/swapper/src/swapper.rs index 60498e196..58750b9c1 100644 --- a/crates/swapper/src/swapper.rs +++ b/crates/swapper/src/swapper.rs @@ -1,7 +1,7 @@ use crate::{ AssetList, FetchQuoteData, Permit2ApprovalData, ProviderType, Quote, QuoteRequest, SwapResult, Swapper, SwapperChainAsset, SwapperError, SwapperProvider, SwapperProviderMode, SwapperQuoteData, across, alien::RpcProvider, chainflip, config::DEFAULT_STABLE_SWAP_REFERRAL_BPS, cross_chain::VaultAddresses, hyperliquid, jupiter, near_intents, - proxy::provider_factory, relay, thorchain, uniswap, + proxy::provider_factory, relay, squid, thorchain, uniswap, }; use num_bigint::BigInt; use num_traits::ToPrimitive; @@ -135,7 +135,7 @@ impl GemSwapper { Box::new(provider_factory::new_cetus_aggregator(rpc_provider.clone())), Box::new(relay::Relay::new(rpc_provider.clone())), Box::new(provider_factory::new_orca(rpc_provider.clone())), - Box::new(provider_factory::new_squid(rpc_provider.clone())), + Box::new(squid::Squid::new(rpc_provider.clone())), uniswap::default::boxed_aerodrome(rpc_provider.clone()), ]; diff --git a/skills/code-style.md b/skills/code-style.md index 58f0cebf6..51312e910 100644 --- a/skills/code-style.md +++ b/skills/code-style.md @@ -97,6 +97,7 @@ fn process_data() { - **Avoid `mut`**: Prefer immutable bindings; use `mut` only when truly necessary - **No `#[allow(dead_code)]`**: Remove dead code instead of suppressing warnings - **Avoid `#[serde(default)]`**: Only use when the field is genuinely optional in the API response; if the field is always present, omit it +- **Use accessor methods for enum variants**: Instead of destructuring enum variants with `match`, use typed accessor methods (e.g., `metadata.get_sequence()` instead of `match &metadata { Cosmos { sequence, .. } => ... }`) - **No unused fields**: Remove unused fields from structs/models; don't keep fields "for future use" - **Constants for magic numbers**: Extract magic numbers into named constants with clear meaning - **Minimum interface**: Don't expose unnecessary functions; if client only needs one function, don't add multiple variants From 2b613d8059eaff745329a77e4a7c68be6b776cad Mon Sep 17 00:00:00 2001 From: 0xh3rman <119309671+0xh3rman@users.noreply.github.com> Date: Tue, 17 Mar 2026 15:04:36 +0900 Subject: [PATCH 12/18] split cosmos chain_signer --- .claude/skills/review-changes/SKILL.md | 1 + crates/gem_cosmos/src/models/message.rs | 36 ++++++++ crates/gem_cosmos/src/signer/chain_signer.rs | 95 +++++--------------- crates/gem_cosmos/src/signer/mod.rs | 2 +- crates/gem_cosmos/src/signer/transaction.rs | 9 ++ crates/swapper/src/squid/provider.rs | 1 - skills/code-style.md | 1 + 7 files changed, 69 insertions(+), 76 deletions(-) diff --git a/.claude/skills/review-changes/SKILL.md b/.claude/skills/review-changes/SKILL.md index 33d63b3d6..215da36ff 100644 --- a/.claude/skills/review-changes/SKILL.md +++ b/.claude/skills/review-changes/SKILL.md @@ -115,6 +115,7 @@ Analyze the diff above and check for the following issues: - [ ] **Error handling**: Use `Result<(), Box>` - [ ] **Test data**: For long JSON (>20 lines), store in `testdata/` and use `include_str!()` - [ ] **`.unwrap()` not `.expect()`**: Never use `.expect()` in tests; use `.unwrap()` for brevity +- [ ] **No `assert!` with `contains`**: Use `assert_eq!` with concrete values; `assert!(x.contains(...))` gives useless failure messages - [ ] **Mock methods in testkit**: Use `Type::mock()` constructors in `testkit/` modules instead of inline struct construction in tests - [ ] **`PartialEq` + `assert_eq!`**: Derive `PartialEq` on test-relevant enums and use direct `assert_eq!` with constructed expected values instead of destructuring with `let ... else { panic! }` or `match ... { _ => panic! }` - [ ] **Test helpers**: Create concise constructor functions (e.g., `fn object(json: &str) -> EnumType`, `fn sign_message(chain, sign_type, data) -> Action`) for frequently constructed enum variants in test modules diff --git a/crates/gem_cosmos/src/models/message.rs b/crates/gem_cosmos/src/models/message.rs index 86a19f499..dc63e39ec 100644 --- a/crates/gem_cosmos/src/models/message.rs +++ b/crates/gem_cosmos/src/models/message.rs @@ -155,3 +155,39 @@ impl CosmosMessage { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_execute_contract() { + let msg = CosmosMessage::parse(include_str!("../../testdata/swap_execute_contract.json")).unwrap(); + match msg { + CosmosMessage::ExecuteContract { sender, contract, funds, .. } => { + assert_eq!(sender, "osmo1tkvyjqeq204rmrrz3w4hcrs336qahsfwn8m0ye"); + assert_eq!(contract, "osmo1n6ney9tsf55etz9nrmzyd8wa7e64qd3s06a74fqs30ka8pps6cvqtsycr6"); + assert_eq!(funds.len(), 1); + assert_eq!(funds[0].denom, "uosmo"); + assert_eq!(funds[0].amount, "10000000"); + } + _ => panic!("expected ExecuteContract"), + } + } + + #[test] + fn test_parse_ibc_transfer() { + let msg = CosmosMessage::parse(include_str!("../../testdata/swap_ibc_transfer.json")).unwrap(); + match msg { + CosmosMessage::IbcTransfer { source_port, source_channel, sender, receiver, timeout_timestamp, memo, .. } => { + assert_eq!(source_port, "transfer"); + assert_eq!(source_channel, "channel-141"); + assert_eq!(sender, "cosmos1tkvyjqeq204rmrrz3w4hcrs336qahsfwmugljt"); + assert_eq!(receiver, "osmo1n6ney9tsf55etz9nrmzyd8wa7e64qd3s06a74fqs30ka8pps6cvqtsycr6"); + assert_eq!(timeout_timestamp, 1773632858715000064); + assert!(!memo.is_empty()); + } + _ => panic!("expected IbcTransfer"), + } + } +} diff --git a/crates/gem_cosmos/src/signer/chain_signer.rs b/crates/gem_cosmos/src/signer/chain_signer.rs index 9d9b31b57..9f3635393 100644 --- a/crates/gem_cosmos/src/signer/chain_signer.rs +++ b/crates/gem_cosmos/src/signer/chain_signer.rs @@ -2,14 +2,12 @@ use std::str::FromStr; use base64::{Engine, engine::general_purpose::STANDARD}; use gem_hash::sha2::sha256; -use primitives::chain_cosmos::CosmosChain; -use primitives::{ChainSigner, SignerError, TransactionLoadInput}; +use primitives::{ChainSigner, SignerError, TransactionLoadInput, chain_cosmos::CosmosChain}; use signer::{SignatureScheme, Signer}; +use super::transaction::{CosmosTxParams, encode_auth_info, encode_sign_doc, encode_tx_body, encode_tx_raw}; use crate::models::{Coin, CosmosMessage}; -use super::transaction::*; - const BASE_FEE_GAS_UNITS: u64 = 200_000; const GAS_BUFFER_NUMERATOR: u64 = 13; const GAS_BUFFER_DENOMINATOR: u64 = 10; @@ -17,36 +15,6 @@ const GAS_BUFFER_DENOMINATOR: u64 = 10; #[derive(Default)] pub struct CosmosChainSigner; -pub struct CosmosTxParams<'a> { - pub body_bytes: Vec, - pub chain_id: &'a str, - pub account_number: u64, - pub sequence: u64, - pub fee_coins: Vec, - pub gas_limit: u64, -} - -pub fn encode_and_sign_tx(params: &CosmosTxParams, private_key: &[u8]) -> Result { - let pubkey_bytes = signer::secp256k1_public_key(private_key)?; - let auth_info_bytes = encode_auth_info(&pubkey_bytes, params.sequence, ¶ms.fee_coins, params.gas_limit); - let sign_doc_bytes = encode_sign_doc(¶ms.body_bytes, &auth_info_bytes, params.chain_id, params.account_number); - - let digest = sha256(&sign_doc_bytes); - let mut signature = Signer::sign_digest(SignatureScheme::Secp256k1, digest.to_vec(), private_key.to_vec())?; - if signature.len() < 64 { - return Err(SignerError::signing_error("secp256k1 signature too short")); - } - signature.truncate(64); - - let tx_raw = encode_tx_raw(¶ms.body_bytes, &auth_info_bytes, &signature); - let tx_base64 = STANDARD.encode(&tx_raw); - Ok(serde_json::json!({ - "mode": "BROADCAST_MODE_SYNC", - "tx_bytes": tx_base64, - }) - .to_string()) -} - impl ChainSigner for CosmosChainSigner { 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)?; @@ -87,50 +55,29 @@ impl ChainSigner for CosmosChainSigner { gas_limit, }; - Ok(vec![encode_and_sign_tx(¶ms, private_key)?]) + Ok(vec![Self::encode_and_sign_tx(¶ms, private_key)?]) } } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_parse_execute_contract() { - let msg = CosmosMessage::parse(include_str!("../../testdata/swap_execute_contract.json")).unwrap(); - match msg { - CosmosMessage::ExecuteContract { sender, contract, funds, .. } => { - assert_eq!(sender, "osmo1tkvyjqeq204rmrrz3w4hcrs336qahsfwn8m0ye"); - assert_eq!(contract, "osmo1n6ney9tsf55etz9nrmzyd8wa7e64qd3s06a74fqs30ka8pps6cvqtsycr6"); - assert_eq!(funds.len(), 1); - assert_eq!(funds[0].denom, "uosmo"); - assert_eq!(funds[0].amount, "10000000"); - } - _ => panic!("expected ExecuteContract"), - } - } +impl CosmosChainSigner { + pub fn encode_and_sign_tx(params: &CosmosTxParams, private_key: &[u8]) -> Result { + let pubkey_bytes = signer::secp256k1_public_key(private_key)?; + let auth_info_bytes = encode_auth_info(&pubkey_bytes, params.sequence, ¶ms.fee_coins, params.gas_limit); + let sign_doc_bytes = encode_sign_doc(¶ms.body_bytes, &auth_info_bytes, params.chain_id, params.account_number); - #[test] - fn test_parse_ibc_transfer() { - let msg = CosmosMessage::parse(include_str!("../../testdata/swap_ibc_transfer.json")).unwrap(); - match msg { - CosmosMessage::IbcTransfer { - source_port, - source_channel, - sender, - receiver, - timeout_timestamp, - memo, - .. - } => { - assert_eq!(source_port, "transfer"); - assert_eq!(source_channel, "channel-141"); - assert_eq!(sender, "cosmos1tkvyjqeq204rmrrz3w4hcrs336qahsfwmugljt"); - assert_eq!(receiver, "osmo1n6ney9tsf55etz9nrmzyd8wa7e64qd3s06a74fqs30ka8pps6cvqtsycr6"); - assert_eq!(timeout_timestamp, 1773632858715000064); - assert!(!memo.is_empty()); - } - _ => panic!("expected IbcTransfer"), + let digest = sha256(&sign_doc_bytes); + let mut signature = Signer::sign_digest(SignatureScheme::Secp256k1, digest.to_vec(), private_key.to_vec())?; + if signature.len() < 64 { + return Err(SignerError::signing_error("secp256k1 signature too short")); } + signature.truncate(64); + + let tx_raw = encode_tx_raw(¶ms.body_bytes, &auth_info_bytes, &signature); + let tx_base64 = STANDARD.encode(&tx_raw); + Ok(serde_json::json!({ + "mode": "BROADCAST_MODE_SYNC", + "tx_bytes": tx_base64, + }) + .to_string()) } } diff --git a/crates/gem_cosmos/src/signer/mod.rs b/crates/gem_cosmos/src/signer/mod.rs index e9ffec584..64f449640 100644 --- a/crates/gem_cosmos/src/signer/mod.rs +++ b/crates/gem_cosmos/src/signer/mod.rs @@ -1,5 +1,5 @@ mod chain_signer; mod protobuf; -mod transaction; +pub mod transaction; pub use chain_signer::CosmosChainSigner; diff --git a/crates/gem_cosmos/src/signer/transaction.rs b/crates/gem_cosmos/src/signer/transaction.rs index c9e6a7b74..213bed73b 100644 --- a/crates/gem_cosmos/src/signer/transaction.rs +++ b/crates/gem_cosmos/src/signer/transaction.rs @@ -7,6 +7,15 @@ use super::protobuf::*; const SECP256K1_PUBKEY_TYPE_URL: &str = "/cosmos.crypto.secp256k1.PubKey"; const SIGN_MODE_DIRECT: u64 = 1; +pub struct CosmosTxParams<'a> { + pub body_bytes: Vec, + pub chain_id: &'a str, + pub account_number: u64, + pub sequence: u64, + pub fee_coins: Vec, + pub gas_limit: u64, +} + impl CosmosMessage { fn type_url(&self) -> &str { match self { diff --git a/crates/swapper/src/squid/provider.rs b/crates/swapper/src/squid/provider.rs index ceff7cc2c..e2b7ba68a 100644 --- a/crates/swapper/src/squid/provider.rs +++ b/crates/swapper/src/squid/provider.rs @@ -195,7 +195,6 @@ mod swap_integration_tests { println!("ATOM->OSMO data: to={}, value={}, gasLimit={:?}", quote_data.to, quote_data.value, quote_data.gas_limit); println!("ATOM->OSMO msg: {}", "e_data.data[..200.min(quote_data.data.len())]); assert!(!quote_data.data.is_empty()); - assert!(!quote_data.data.contains("\"low\"")); Ok(()) } diff --git a/skills/code-style.md b/skills/code-style.md index 51312e910..1af6ba78c 100644 --- a/skills/code-style.md +++ b/skills/code-style.md @@ -98,6 +98,7 @@ fn process_data() { - **No `#[allow(dead_code)]`**: Remove dead code instead of suppressing warnings - **Avoid `#[serde(default)]`**: Only use when the field is genuinely optional in the API response; if the field is always present, omit it - **Use accessor methods for enum variants**: Instead of destructuring enum variants with `match`, use typed accessor methods (e.g., `metadata.get_sequence()` instead of `match &metadata { Cosmos { sequence, .. } => ... }`) +- **No `assert!` with `contains`**: Use `assert_eq!` with concrete values; `assert!(x.contains(...))` gives useless failure messages - **No unused fields**: Remove unused fields from structs/models; don't keep fields "for future use" - **Constants for magic numbers**: Extract magic numbers into named constants with clear meaning - **Minimum interface**: Don't expose unnecessary functions; if client only needs one function, don't add multiple variants From 58306bb9408451c4bab50d1f2a888799e4b9b964 Mon Sep 17 00:00:00 2001 From: 0xh3rman <119309671+0xh3rman@users.noreply.github.com> Date: Tue, 17 Mar 2026 16:01:23 +0900 Subject: [PATCH 13/18] fix injective hash and pubkey type --- crates/gem_cosmos/src/signer/chain_signer.rs | 39 ++++++++++++-------- crates/gem_cosmos/src/signer/transaction.rs | 20 +++++----- crates/swapper/src/squid/model.rs | 15 +++++--- crates/swapper/src/squid/provider.rs | 8 ++-- 4 files changed, 45 insertions(+), 37 deletions(-) diff --git a/crates/gem_cosmos/src/signer/chain_signer.rs b/crates/gem_cosmos/src/signer/chain_signer.rs index 9f3635393..b1e1680c0 100644 --- a/crates/gem_cosmos/src/signer/chain_signer.rs +++ b/crates/gem_cosmos/src/signer/chain_signer.rs @@ -1,11 +1,11 @@ use std::str::FromStr; use base64::{Engine, engine::general_purpose::STANDARD}; -use gem_hash::sha2::sha256; +use gem_hash::{keccak::keccak256, sha2::sha256}; use primitives::{ChainSigner, SignerError, TransactionLoadInput, chain_cosmos::CosmosChain}; use signer::{SignatureScheme, Signer}; -use super::transaction::{CosmosTxParams, encode_auth_info, encode_sign_doc, encode_tx_body, encode_tx_raw}; +use super::transaction::{COSMOS_SECP256K1_PUBKEY_TYPE, CosmosTxParams, INJECTIVE_ETHSECP256K1_PUBKEY_TYPE, encode_auth_info, encode_sign_doc, encode_tx_body, encode_tx_raw}; use crate::models::{Coin, CosmosMessage}; const BASE_FEE_GAS_UNITS: u64 = 200_000; @@ -15,6 +15,20 @@ const GAS_BUFFER_DENOMINATOR: u64 = 10; #[derive(Default)] pub struct CosmosChainSigner; +fn pubkey_type(chain: CosmosChain) -> &'static str { + match chain { + CosmosChain::Injective => INJECTIVE_ETHSECP256K1_PUBKEY_TYPE, + _ => COSMOS_SECP256K1_PUBKEY_TYPE, + } +} + +fn sign_doc_digest(chain: CosmosChain, sign_doc_bytes: &[u8]) -> [u8; 32] { + match chain { + CosmosChain::Injective => keccak256(sign_doc_bytes), + _ => sha256(sign_doc_bytes), + } +} + impl ChainSigner for CosmosChainSigner { 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)?; @@ -35,12 +49,7 @@ impl ChainSigner for CosmosChainSigner { .unwrap_or(BASE_FEE_GAS_UNITS); let gas_limit = gas_limit * GAS_BUFFER_NUMERATOR / GAS_BUFFER_DENOMINATOR; - let base_fee: u64 = input - .gas_price - .gas_price() - .to_string() - .parse() - .map_err(|_| SignerError::invalid_input("invalid gas price"))?; + let base_fee: u64 = input.gas_price.gas_price().to_string().parse().map_err(|_| SignerError::invalid_input("invalid gas price"))?; let fee_amount = ((gas_limit as u128 * base_fee as u128 / BASE_FEE_GAS_UNITS as u128) as u64).to_string(); let params = CosmosTxParams { @@ -48,24 +57,22 @@ impl ChainSigner for CosmosChainSigner { chain_id: &chain_id, account_number, sequence, - fee_coins: vec![Coin { - denom: chain.denom().as_ref().to_string(), - amount: fee_amount, - }], + fee_coins: vec![Coin { denom: chain.denom().as_ref().to_string(), amount: fee_amount }], gas_limit, + pubkey_type: pubkey_type(chain), }; - Ok(vec![Self::encode_and_sign_tx(¶ms, private_key)?]) + Ok(vec![Self::encode_and_sign_tx(chain, ¶ms, private_key)?]) } } impl CosmosChainSigner { - pub fn encode_and_sign_tx(params: &CosmosTxParams, private_key: &[u8]) -> Result { + pub fn encode_and_sign_tx(chain: CosmosChain, params: &CosmosTxParams, private_key: &[u8]) -> Result { let pubkey_bytes = signer::secp256k1_public_key(private_key)?; - let auth_info_bytes = encode_auth_info(&pubkey_bytes, params.sequence, ¶ms.fee_coins, params.gas_limit); + let auth_info_bytes = encode_auth_info(params.pubkey_type, &pubkey_bytes, params.sequence, ¶ms.fee_coins, params.gas_limit); let sign_doc_bytes = encode_sign_doc(¶ms.body_bytes, &auth_info_bytes, params.chain_id, params.account_number); - let digest = sha256(&sign_doc_bytes); + let digest = sign_doc_digest(chain, &sign_doc_bytes); let mut signature = Signer::sign_digest(SignatureScheme::Secp256k1, digest.to_vec(), private_key.to_vec())?; if signature.len() < 64 { return Err(SignerError::signing_error("secp256k1 signature too short")); diff --git a/crates/gem_cosmos/src/signer/transaction.rs b/crates/gem_cosmos/src/signer/transaction.rs index 213bed73b..da7c57624 100644 --- a/crates/gem_cosmos/src/signer/transaction.rs +++ b/crates/gem_cosmos/src/signer/transaction.rs @@ -4,7 +4,8 @@ use crate::constants::{MESSAGE_EXECUTE_CONTRACT, MESSAGE_IBC_TRANSFER}; use super::protobuf::*; -const SECP256K1_PUBKEY_TYPE_URL: &str = "/cosmos.crypto.secp256k1.PubKey"; +pub const COSMOS_SECP256K1_PUBKEY_TYPE: &str = "/cosmos.crypto.secp256k1.PubKey"; +pub const INJECTIVE_ETHSECP256K1_PUBKEY_TYPE: &str = "/injective.crypto.v1beta1.ethsecp256k1.PubKey"; const SIGN_MODE_DIRECT: u64 = 1; pub struct CosmosTxParams<'a> { @@ -14,6 +15,7 @@ pub struct CosmosTxParams<'a> { pub sequence: u64, pub fee_coins: Vec, pub gas_limit: u64, + pub pubkey_type: &'a str, } impl CosmosMessage { @@ -62,21 +64,17 @@ pub fn encode_tx_body(messages: &[Vec], memo: &str) -> Vec { [msg_fields, encode_string_field(2, memo)].concat() } -fn encode_pubkey_any(pubkey_bytes: &[u8]) -> Vec { - [ - encode_string_field(1, SECP256K1_PUBKEY_TYPE_URL), - encode_bytes_field(2, &encode_bytes_field(1, pubkey_bytes)), - ] - .concat() +fn encode_pubkey_any(pubkey_type: &str, pubkey_bytes: &[u8]) -> Vec { + [encode_string_field(1, pubkey_type), encode_bytes_field(2, &encode_bytes_field(1, pubkey_bytes))].concat() } fn encode_mode_info_single() -> Vec { encode_message_field(1, &encode_varint_field(1, SIGN_MODE_DIRECT)) } -fn encode_signer_info(pubkey_bytes: &[u8], sequence: u64) -> Vec { +fn encode_signer_info(pubkey_type: &str, pubkey_bytes: &[u8], sequence: u64) -> Vec { [ - encode_message_field(1, &encode_pubkey_any(pubkey_bytes)), + encode_message_field(1, &encode_pubkey_any(pubkey_type, pubkey_bytes)), encode_message_field(2, &encode_mode_info_single()), encode_varint_field(3, sequence), ] @@ -88,9 +86,9 @@ fn encode_fee(coins: &[Coin], gas_limit: u64) -> Vec { [coin_fields, encode_varint_field(2, gas_limit)].concat() } -pub fn encode_auth_info(pubkey_bytes: &[u8], sequence: u64, fee_coins: &[Coin], gas_limit: u64) -> Vec { +pub fn encode_auth_info(pubkey_type: &str, pubkey_bytes: &[u8], sequence: u64, fee_coins: &[Coin], gas_limit: u64) -> Vec { [ - encode_message_field(1, &encode_signer_info(pubkey_bytes, sequence)), + encode_message_field(1, &encode_signer_info(pubkey_type, pubkey_bytes, sequence)), encode_message_field(2, &encode_fee(fee_coins, gas_limit)), ] .concat() diff --git a/crates/swapper/src/squid/model.rs b/crates/swapper/src/squid/model.rs index 0312f5812..03eb40789 100644 --- a/crates/swapper/src/squid/model.rs +++ b/crates/swapper/src/squid/model.rs @@ -30,9 +30,18 @@ pub struct SquidRouteResponse { #[serde(rename_all = "camelCase")] pub struct SquidRoute { pub estimate: SquidEstimate, + #[serde(deserialize_with = "deserialize_transaction_request")] pub transaction_request: Option, } +fn deserialize_transaction_request<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result, D::Error> { + let value = Option::::deserialize(deserializer)?; + match value { + Some(v) if v.as_object().is_some_and(|m| m.contains_key("data")) => serde_json::from_value(v).map(Some).map_err(serde::de::Error::custom), + _ => Ok(None), + } +} + #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SquidEstimate { @@ -49,12 +58,6 @@ pub struct SquidTransactionRequest { pub gas_limit: String, } -impl SquidTransactionRequest { - pub fn get_gas_limit(&self) -> Option { - if self.gas_limit.is_empty() || self.gas_limit == "0" { None } else { Some(self.gas_limit.clone()) } - } -} - #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SquidStatusResponse { diff --git a/crates/swapper/src/squid/provider.rs b/crates/swapper/src/squid/provider.rs index e2b7ba68a..33caabaa1 100644 --- a/crates/swapper/src/squid/provider.rs +++ b/crates/swapper/src/squid/provider.rs @@ -96,11 +96,11 @@ where } async fn get_quote_data(&self, quote: &Quote, _data: FetchQuoteData) -> Result { - let squid_request = build_route_request("e.request, "e.from_value, false)?; - let response = self.client.get_route(&squid_request).await?; + let request = build_route_request("e.request, "e.from_value, false)?; + let response = self.client.get_route(&request).await?; let tx = response.route.transaction_request.ok_or(SwapperError::InvalidRoute)?; - let gas_limit = tx.get_gas_limit(); - Ok(SwapperQuoteData::new_contract(tx.target, tx.value, tx.data, None, gas_limit)) + + Ok(SwapperQuoteData::new_contract(tx.target, tx.value, tx.data, None, Some(tx.gas_limit))) } async fn get_vault_addresses(&self, _from_timestamp: Option) -> Result { From b39917d4d2bf4caaafb2bde1d5c27bdeba6494cd Mon Sep 17 00:00:00 2001 From: 0xh3rman <119309671+0xh3rman@users.noreply.github.com> Date: Wed, 18 Mar 2026 23:58:51 +0900 Subject: [PATCH 14/18] add referral fee support --- .claude/skills/review-changes/SKILL.md | 2 + crates/gem_cosmos/Cargo.toml | 5 +- crates/gem_cosmos/src/models/message.rs | 31 +++- crates/gem_cosmos/src/signer/chain_signer.rs | 5 +- crates/gem_cosmos/src/signer/transaction.rs | 6 + crates/primitives/src/chain_cosmos.rs | 12 ++ crates/swapper/src/config.rs | 14 ++ crates/swapper/src/squid/provider.rs | 155 +++++++++++++------ gemstone/src/config/swap_config.rs | 2 + skills/code-style.md | 2 + 10 files changed, 184 insertions(+), 50 deletions(-) diff --git a/.claude/skills/review-changes/SKILL.md b/.claude/skills/review-changes/SKILL.md index 215da36ff..793402f53 100644 --- a/.claude/skills/review-changes/SKILL.md +++ b/.claude/skills/review-changes/SKILL.md @@ -116,6 +116,8 @@ Analyze the diff above and check for the following issues: - [ ] **Test data**: For long JSON (>20 lines), store in `testdata/` and use `include_str!()` - [ ] **`.unwrap()` not `.expect()`**: Never use `.expect()` in tests; use `.unwrap()` for brevity - [ ] **No `assert!` with `contains`**: Use `assert_eq!` with concrete values; `assert!(x.contains(...))` gives useless failure messages +- [ ] **No fallback, fail fast**: Don't silently return defaults on errors (e.g., `unwrap_or(0)`). Propagate errors with `?` or return `Result`. Fail rather than mask issues with fallbacks. +- [ ] **Methods over free functions**: Helper functions should be methods on the relevant struct, not top-level free functions - [ ] **Mock methods in testkit**: Use `Type::mock()` constructors in `testkit/` modules instead of inline struct construction in tests - [ ] **`PartialEq` + `assert_eq!`**: Derive `PartialEq` on test-relevant enums and use direct `assert_eq!` with constructed expected values instead of destructuring with `let ... else { panic! }` or `match ... { _ => panic! }` - [ ] **Test helpers**: Create concise constructor functions (e.g., `fn object(json: &str) -> EnumType`, `fn sign_message(chain, sign_type, data) -> Action`) for frequently constructed enum variants in test modules diff --git a/crates/gem_cosmos/Cargo.toml b/crates/gem_cosmos/Cargo.toml index dbbcb3079..f15f29614 100644 --- a/crates/gem_cosmos/Cargo.toml +++ b/crates/gem_cosmos/Cargo.toml @@ -10,10 +10,10 @@ gem_hash = { path = "../gem_hash" } base64 = { workspace = true } primitives = { path = "../primitives" } serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } serde_serializers = { path = "../serde_serializers" } # Optional RPC dependencies -serde_json = { workspace = true, optional = true } chrono = { workspace = true, features = ["serde"], optional = true } async-trait = { workspace = true, optional = true } gem_client = { path = "../gem_client", optional = true } @@ -28,7 +28,6 @@ num-bigint = { workspace = true } [features] default = [] rpc = [ - "dep:serde_json", "dep:chrono", "dep:async-trait", "dep:gem_client", @@ -36,7 +35,7 @@ rpc = [ "dep:futures", "dep:number_formatter", ] -signer = ["dep:signer", "dep:serde_json"] +signer = ["dep:signer"] reqwest = ["gem_client/reqwest"] chain_integration_tests = ["rpc", "reqwest", "settings/testkit"] diff --git a/crates/gem_cosmos/src/models/message.rs b/crates/gem_cosmos/src/models/message.rs index dc63e39ec..52d42f5b1 100644 --- a/crates/gem_cosmos/src/models/message.rs +++ b/crates/gem_cosmos/src/models/message.rs @@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize}; use super::{ExecuteContractValue, IbcTransferValue}; use crate::constants; #[cfg(feature = "signer")] -use crate::constants::{MESSAGE_EXECUTE_CONTRACT, MESSAGE_IBC_TRANSFER}; +use crate::constants::{MESSAGE_EXECUTE_CONTRACT, MESSAGE_IBC_TRANSFER, MESSAGE_SEND_BETA}; #[cfg(feature = "signer")] use primitives::SignerError; @@ -107,6 +107,11 @@ pub struct MessageEnvelope { #[cfg(feature = "signer")] pub enum CosmosMessage { + Send { + from_address: String, + to_address: String, + amount: Vec, + }, ExecuteContract { sender: String, contract: String, @@ -124,12 +129,36 @@ pub enum CosmosMessage { }, } +pub fn send_msg_json(from: &str, to: &str, denom: &str, amount: &str) -> serde_json::Value { + serde_json::json!({ + "typeUrl": constants::MESSAGE_SEND_BETA, + "value": { + "from_address": from, + "to_address": to, + "amount": [{"denom": denom, "amount": amount}] + } + }) +} + #[cfg(feature = "signer")] impl CosmosMessage { + pub fn parse_array(data: &str) -> Result, SignerError> { + let arr: Vec = serde_json::from_str(data)?; + arr.iter().map(|v| Self::parse(&v.to_string())).collect() + } + pub fn parse(data: &str) -> Result { let envelope: MessageEnvelope = serde_json::from_str(data)?; match envelope.type_url.as_str() { + MESSAGE_SEND_BETA => { + let v: MsgSend = serde_json::from_value(envelope.value)?; + Ok(Self::Send { + from_address: v.from_address, + to_address: v.to_address, + amount: v.amount, + }) + } MESSAGE_EXECUTE_CONTRACT => { let v: ExecuteContractValue = serde_json::from_value(envelope.value)?; Ok(Self::ExecuteContract { diff --git a/crates/gem_cosmos/src/signer/chain_signer.rs b/crates/gem_cosmos/src/signer/chain_signer.rs index b1e1680c0..f7509425d 100644 --- a/crates/gem_cosmos/src/signer/chain_signer.rs +++ b/crates/gem_cosmos/src/signer/chain_signer.rs @@ -37,8 +37,9 @@ impl ChainSigner for CosmosChainSigner { let chain_id = input.metadata.get_chain_id().map_err(SignerError::from_display)?; let chain = CosmosChain::from_str(input.input_type.get_asset().chain.as_ref()).map_err(|_| SignerError::invalid_input("unsupported cosmos chain"))?; - let message = CosmosMessage::parse(&swap_data.data.data)?; - let body_bytes = encode_tx_body(&[message.encode_as_any()], input.memo.as_deref().unwrap_or("")); + let messages = CosmosMessage::parse_array(&swap_data.data.data)?; + let encoded: Vec> = messages.iter().map(|m| m.encode_as_any()).collect(); + let body_bytes = encode_tx_body(&encoded, input.memo.as_deref().unwrap_or("")); let gas_limit = swap_data .data diff --git a/crates/gem_cosmos/src/signer/transaction.rs b/crates/gem_cosmos/src/signer/transaction.rs index da7c57624..c563bc211 100644 --- a/crates/gem_cosmos/src/signer/transaction.rs +++ b/crates/gem_cosmos/src/signer/transaction.rs @@ -4,6 +4,7 @@ use crate::constants::{MESSAGE_EXECUTE_CONTRACT, MESSAGE_IBC_TRANSFER}; use super::protobuf::*; +const MESSAGE_SEND_TYPE_URL: &str = "/cosmos.bank.v1beta1.MsgSend"; pub const COSMOS_SECP256K1_PUBKEY_TYPE: &str = "/cosmos.crypto.secp256k1.PubKey"; pub const INJECTIVE_ETHSECP256K1_PUBKEY_TYPE: &str = "/injective.crypto.v1beta1.ethsecp256k1.PubKey"; const SIGN_MODE_DIRECT: u64 = 1; @@ -21,6 +22,7 @@ pub struct CosmosTxParams<'a> { impl CosmosMessage { fn type_url(&self) -> &str { match self { + Self::Send { .. } => MESSAGE_SEND_TYPE_URL, Self::ExecuteContract { .. } => MESSAGE_EXECUTE_CONTRACT, Self::IbcTransfer { .. } => MESSAGE_IBC_TRANSFER, } @@ -28,6 +30,10 @@ impl CosmosMessage { fn encode_value(&self) -> Vec { match self { + Self::Send { from_address, to_address, amount } => { + let coin_fields: Vec = amount.iter().flat_map(|c| encode_message_field(3, &encode_coin(&c.denom, &c.amount))).collect(); + [encode_string_field(1, from_address), encode_string_field(2, to_address), coin_fields].concat() + } Self::ExecuteContract { sender, contract, msg, funds } => { let fund_fields: Vec = funds.iter().flat_map(|c| encode_message_field(5, &encode_coin(&c.denom, &c.amount))).collect(); [encode_string_field(1, sender), encode_string_field(2, contract), encode_bytes_field(3, msg), fund_fields].concat() diff --git a/crates/primitives/src/chain_cosmos.rs b/crates/primitives/src/chain_cosmos.rs index 4ffe34372..438adef86 100644 --- a/crates/primitives/src/chain_cosmos.rs +++ b/crates/primitives/src/chain_cosmos.rs @@ -29,6 +29,18 @@ impl CosmosChain { Chain::from_str(self.as_ref()).unwrap() } + pub fn hrp(&self) -> &str { + match self { + Self::Cosmos => "cosmos", + Self::Osmosis => "osmo", + Self::Celestia => "celestia", + Self::Thorchain => "thor", + Self::Injective => "inj", + Self::Sei => "sei", + Self::Noble => "noble", + } + } + pub fn denom(&self) -> CosmosDenom { match self { Self::Cosmos => CosmosDenom::Uatom, diff --git a/crates/swapper/src/config.rs b/crates/swapper/src/config.rs index 4f5b96f9a..ec4dfa365 100644 --- a/crates/swapper/src/config.rs +++ b/crates/swapper/src/config.rs @@ -33,6 +33,8 @@ pub struct ReferralFees { pub tron: ReferralFee, pub near: ReferralFee, pub aptos: ReferralFee, + pub cosmos: ReferralFee, + pub injective: ReferralFee, } #[derive(Default, Debug, Clone, PartialEq)] @@ -53,6 +55,8 @@ impl ReferralFees { tron: ReferralFee::default(), near: ReferralFee::default(), aptos: ReferralFee::default(), + cosmos: ReferralFee::default(), + injective: ReferralFee::default(), } } @@ -71,6 +75,8 @@ impl ReferralFees { &mut self.tron, &mut self.near, &mut self.aptos, + &mut self.cosmos, + &mut self.injective, ] .into_iter() } @@ -129,6 +135,14 @@ pub fn get_swap_config() -> Config { address: "0xc09d385527743bb03ed7847bb9180b5ff2263d38d5a93f1c9b3068f8505f6488".into(), bps: DEFAULT_SWAP_FEE_BPS, }, + cosmos: ReferralFee { + address: "cosmos1knwywgnzs3a2p39k7337klt6daqrhyvnh8vz27".into(), + bps: DEFAULT_SWAP_FEE_BPS, + }, + injective: ReferralFee { + address: "inj1299uk8u64wusfessd0gs22mx6fcxmwahl32n4j".into(), + bps: DEFAULT_SWAP_FEE_BPS, + }, }, high_price_impact_percent: 10, } diff --git a/crates/swapper/src/squid/provider.rs b/crates/swapper/src/squid/provider.rs index 33caabaa1..9d0509ea7 100644 --- a/crates/swapper/src/squid/provider.rs +++ b/crates/swapper/src/squid/provider.rs @@ -2,12 +2,13 @@ use std::sync::Arc; use async_trait::async_trait; use gem_client::Client; -use primitives::{AssetId, Chain, chain_cosmos::CosmosChain}; +use gem_cosmos::{converter::convert_cosmos_address, models::message::send_msg_json}; +use primitives::{AssetId, Chain, chain_cosmos::CosmosChain, swap::SwapQuoteDataType}; use super::{SQUID_COSMOS_MULTICALL, SUPPORTED_CHAINS, client::SquidClient, model::*}; use crate::{ FetchQuoteData, ProviderData, ProviderType, Quote, QuoteRequest, Route, RpcClient, RpcProvider, SwapResult, Swapper, SwapperChainAsset, SwapperError, SwapperProvider, - SwapperQuoteData, config::get_swap_api_url, cross_chain::VaultAddresses, fees::resolve_max_quote_value, + SwapperQuoteData, config::{DEFAULT_SWAP_FEE_BPS, get_swap_api_url}, cross_chain::VaultAddresses, fees::resolve_max_quote_value, }; #[derive(Debug)] @@ -29,32 +30,59 @@ impl Squid { } } -fn get_network_id(chain: &Chain) -> Result<&str, SwapperError> { - CosmosChain::from_chain(*chain).map(|_| chain.network_id()).ok_or(SwapperError::NotSupportedChain) -} +impl Squid +where + C: Client + Clone + Send + Sync + std::fmt::Debug + 'static, +{ + fn get_network_id(chain: &Chain) -> Result<&str, SwapperError> { + CosmosChain::from_chain(*chain).map(|_| chain.network_id()).ok_or(SwapperError::NotSupportedChain) + } -fn get_token_id(asset_id: &AssetId) -> Result { - if asset_id.is_native() { - asset_id.chain.as_denom().map(|d| d.to_string()).ok_or(SwapperError::NotSupportedAsset) - } else { - asset_id.token_id.clone().ok_or(SwapperError::NotSupportedAsset) + fn get_token_id(asset_id: &AssetId) -> Result { + if asset_id.is_native() { + asset_id.chain.as_denom().map(|d| d.to_string()).ok_or(SwapperError::NotSupportedAsset) + } else { + asset_id.token_id.clone().ok_or(SwapperError::NotSupportedAsset) + } + } + + fn get_fee_address(request: &QuoteRequest) -> Option { + let fees = request.options.fee.as_ref()?; + let chain = request.from_asset.chain(); + match chain { + Chain::Injective => Some(fees.injective.address.clone()).filter(|a| !a.is_empty()), + Chain::Cosmos => Some(fees.cosmos.address.clone()).filter(|a| !a.is_empty()), + _ => { + let cosmos_chain = CosmosChain::from_chain(chain)?; + let base = &fees.cosmos.address; + if base.is_empty() { return None; } + convert_cosmos_address(base, cosmos_chain.hrp()).ok() + } + } + } + + fn compute_fee(amount: &str, bps: u32) -> Option<(String, String)> { + let value: u128 = amount.parse().ok()?; + let fee = value * bps as u128 / 10_000; + if fee == 0 { return None; } + Some((fee.to_string(), (value - fee).to_string())) } -} -fn build_route_request(request: &QuoteRequest, from_value: &str, quote_only: bool) -> Result { - let from_asset_id = request.from_asset.asset_id(); - let to_asset_id = request.to_asset.asset_id(); - Ok(SquidRouteRequest { - from_chain: get_network_id(&from_asset_id.chain)?.to_string(), - to_chain: get_network_id(&to_asset_id.chain)?.to_string(), - from_token: get_token_id(&from_asset_id)?, - to_token: get_token_id(&to_asset_id)?, - from_amount: from_value.to_string(), - from_address: request.wallet_address.clone(), - to_address: request.destination_address.clone(), - slippage_config: SlippageConfig { auto_mode: 1 }, - quote_only, - }) + fn build_route_request(request: &QuoteRequest, from_value: &str, quote_only: bool) -> Result { + let from_asset_id = request.from_asset.asset_id(); + let to_asset_id = request.to_asset.asset_id(); + Ok(SquidRouteRequest { + from_chain: Self::get_network_id(&from_asset_id.chain)?.to_string(), + to_chain: Self::get_network_id(&to_asset_id.chain)?.to_string(), + from_token: Self::get_token_id(&from_asset_id)?, + to_token: Self::get_token_id(&to_asset_id)?, + from_amount: from_value.to_string(), + from_address: request.wallet_address.clone(), + to_address: request.destination_address.clone(), + slippage_config: SlippageConfig { auto_mode: 1 }, + quote_only, + }) + } } #[async_trait] @@ -72,7 +100,15 @@ where async fn get_quote(&self, request: &QuoteRequest) -> Result { let from_value = resolve_max_quote_value(request)?; - let squid_request = build_route_request(request, &from_value, true)?; + let fee_address = Self::get_fee_address(request); + + let (fee_amount, swap_amount) = if fee_address.is_some() { + Self::compute_fee(&from_value, DEFAULT_SWAP_FEE_BPS).unwrap_or((String::new(), from_value.clone())) + } else { + (String::new(), from_value.clone()) + }; + + let squid_request = Self::build_route_request(request, &swap_amount, true)?; let response = self.client.get_route(&squid_request).await?; let from_asset_id = request.from_asset.asset_id(); @@ -86,7 +122,7 @@ where routes: vec![Route { input: from_asset_id, output: to_asset_id, - route_data: String::new(), + route_data: fee_amount, }], slippage_bps: request.options.slippage.bps, }, @@ -96,11 +132,38 @@ where } async fn get_quote_data(&self, quote: &Quote, _data: FetchQuoteData) -> Result { - let request = build_route_request("e.request, "e.from_value, false)?; + let fee_address = Self::get_fee_address("e.request); + let fee_amount = quote.data.routes.first().map(|r| r.route_data.as_str()).unwrap_or(""); + + let swap_amount = if fee_address.is_some() && !fee_amount.is_empty() { + Self::compute_fee("e.from_value, DEFAULT_SWAP_FEE_BPS).map(|(_, s)| s).unwrap_or(quote.from_value.clone()) + } else { + quote.from_value.clone() + }; + + let request = Self::build_route_request("e.request, &swap_amount, false)?; let response = self.client.get_route(&request).await?; let tx = response.route.transaction_request.ok_or(SwapperError::InvalidRoute)?; - Ok(SwapperQuoteData::new_contract(tx.target, tx.value, tx.data, None, Some(tx.gas_limit))) + let swap_msg: serde_json::Value = serde_json::from_str(&tx.data).map_err(|e| SwapperError::TransactionError(e.to_string()))?; + let messages = match (fee_address, fee_amount) { + (Some(addr), amount) if !amount.is_empty() => { + let denom = Self::get_token_id("e.request.from_asset.asset_id())?; + vec![send_msg_json("e.request.wallet_address, &addr, &denom, amount), swap_msg] + } + _ => vec![swap_msg], + }; + let data = serde_json::to_string(&messages).map_err(|e| SwapperError::TransactionError(e.to_string()))?; + + Ok(SwapperQuoteData { + to: tx.target, + data_type: SwapQuoteDataType::Contract, + value: tx.value, + data, + memo: None, + approval: None, + gas_limit: Some(tx.gas_limit), + }) } async fn get_vault_addresses(&self, _from_timestamp: Option) -> Result { @@ -120,6 +183,23 @@ where } } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_compute_fee() { + let (fee, swap) = Squid::::compute_fee("10000000", 50).unwrap(); + assert_eq!(fee, "50000"); + assert_eq!(swap, "9950000"); + } + + #[test] + fn test_compute_fee_zero() { + assert!(Squid::::compute_fee("100", 50).is_none()); + } +} + #[cfg(all(test, feature = "swap_integration_tests"))] mod swap_integration_tests { use super::*; @@ -149,19 +229,12 @@ mod swap_integration_tests { }; let quote = squid.get_quote(&request).await?; - println!( - "OSMO->ATOM quote: from={}, to={}, eta={}s", - quote.from_value, - quote.to_value, - quote.eta_in_seconds.unwrap_or(0) - ); + println!("OSMO->ATOM quote: from={}, to={}, eta={}s", quote.from_value, quote.to_value, quote.eta_in_seconds.unwrap_or(0)); assert_eq!(quote.from_value, "10000000"); - assert!(!quote.to_value.is_empty()); assert!(quote.to_value.parse::().unwrap() > 0); let quote_data = squid.get_quote_data("e, FetchQuoteData::None).await?; println!("OSMO->ATOM data: to={}, value={}, gasLimit={:?}", quote_data.to, quote_data.value, quote_data.gas_limit); - println!("OSMO->ATOM msg: {}", "e_data.data[..200.min(quote_data.data.len())]); assert!(!quote_data.data.is_empty()); Ok(()) @@ -182,18 +255,12 @@ mod swap_integration_tests { }; let quote = squid.get_quote(&request).await?; - println!( - "ATOM->OSMO quote: from={}, to={}, eta={}s", - quote.from_value, - quote.to_value, - quote.eta_in_seconds.unwrap_or(0) - ); + println!("ATOM->OSMO quote: from={}, to={}, eta={}s", quote.from_value, quote.to_value, quote.eta_in_seconds.unwrap_or(0)); assert_eq!(quote.from_value, "1000000"); assert!(quote.to_value.parse::().unwrap() > 0); let quote_data = squid.get_quote_data("e, FetchQuoteData::None).await?; println!("ATOM->OSMO data: to={}, value={}, gasLimit={:?}", quote_data.to, quote_data.value, quote_data.gas_limit); - println!("ATOM->OSMO msg: {}", "e_data.data[..200.min(quote_data.data.len())]); assert!(!quote_data.data.is_empty()); Ok(()) diff --git a/gemstone/src/config/swap_config.rs b/gemstone/src/config/swap_config.rs index 2b657eab5..f3e1a6082 100644 --- a/gemstone/src/config/swap_config.rs +++ b/gemstone/src/config/swap_config.rs @@ -24,6 +24,8 @@ pub struct SwapReferralFees { pub tron: SwapReferralFee, pub near: SwapReferralFee, pub aptos: SwapReferralFee, + pub cosmos: SwapReferralFee, + pub injective: SwapReferralFee, } #[uniffi::remote(Record)] diff --git a/skills/code-style.md b/skills/code-style.md index 52b907bef..1e0ef96cc 100644 --- a/skills/code-style.md +++ b/skills/code-style.md @@ -98,6 +98,8 @@ fn process_data() { - **Avoid `#[serde(default)]`**: Only use when the field is genuinely optional in the API response; if the field is always present, omit it - **Use accessor methods for enum variants**: Instead of destructuring enum variants with `match`, use typed accessor methods (e.g., `metadata.get_sequence()` instead of `match &metadata { Cosmos { sequence, .. } => ... }`) - **No `assert!` with `contains`**: Use `assert_eq!` with concrete values; `assert!(x.contains(...))` gives useless failure messages +- **No fallback, fail fast**: Don't silently return defaults on errors (e.g., `unwrap_or(0)`). Propagate errors explicitly with `?` or return `Result`. If a value is required, fail rather than mask the issue with a fallback. +- **Methods over free functions**: Place helper functions as methods on the relevant struct (`impl Provider { fn get_fee_address(...) }`) rather than top-level free functions — keeps related logic scoped and discoverable - **No unused fields**: Remove unused fields from structs/models; don't keep fields "for future use" - **Constants for magic numbers**: Extract magic numbers into named constants with clear meaning - **Minimum interface**: Don't expose unnecessary functions; if client only needs one function, don't add multiple variants From bc0532f940a6072e53e46eb6a4d3b2f12c87919d Mon Sep 17 00:00:00 2001 From: 0xh3rman <119309671+0xh3rman@users.noreply.github.com> Date: Thu, 19 Mar 2026 09:07:33 +0900 Subject: [PATCH 15/18] update inj address --- crates/swapper/src/config.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/swapper/src/config.rs b/crates/swapper/src/config.rs index ec4dfa365..504da0a74 100644 --- a/crates/swapper/src/config.rs +++ b/crates/swapper/src/config.rs @@ -140,7 +140,7 @@ pub fn get_swap_config() -> Config { bps: DEFAULT_SWAP_FEE_BPS, }, injective: ReferralFee { - address: "inj1299uk8u64wusfessd0gs22mx6fcxmwahl32n4j".into(), + address: "inj1pkw6kx3y3a3mpfyfvkaggd0yme6f0g7uylvm5y".into(), bps: DEFAULT_SWAP_FEE_BPS, }, }, From 143159db78d4b2798845b959c8eb50f02713b908 Mon Sep 17 00:00:00 2001 From: 0xh3rman <119309671+0xh3rman@users.noreply.github.com> Date: Thu, 19 Mar 2026 10:24:51 +0900 Subject: [PATCH 16/18] code cleanup --- crates/gem_cosmos/src/signer/chain_signer.rs | 44 +++---- crates/gem_cosmos/src/signer/transaction.rs | 121 +++++++++---------- crates/swapper/src/squid/provider.rs | 77 ++++-------- 3 files changed, 99 insertions(+), 143 deletions(-) diff --git a/crates/gem_cosmos/src/signer/chain_signer.rs b/crates/gem_cosmos/src/signer/chain_signer.rs index f7509425d..735589150 100644 --- a/crates/gem_cosmos/src/signer/chain_signer.rs +++ b/crates/gem_cosmos/src/signer/chain_signer.rs @@ -5,7 +5,7 @@ use gem_hash::{keccak::keccak256, sha2::sha256}; use primitives::{ChainSigner, SignerError, TransactionLoadInput, chain_cosmos::CosmosChain}; use signer::{SignatureScheme, Signer}; -use super::transaction::{COSMOS_SECP256K1_PUBKEY_TYPE, CosmosTxParams, INJECTIVE_ETHSECP256K1_PUBKEY_TYPE, encode_auth_info, encode_sign_doc, encode_tx_body, encode_tx_raw}; +use super::transaction::{COSMOS_SECP256K1_PUBKEY_TYPE, CosmosTxParams, INJECTIVE_ETHSECP256K1_PUBKEY_TYPE}; use crate::models::{Coin, CosmosMessage}; const BASE_FEE_GAS_UNITS: u64 = 200_000; @@ -15,20 +15,6 @@ const GAS_BUFFER_DENOMINATOR: u64 = 10; #[derive(Default)] pub struct CosmosChainSigner; -fn pubkey_type(chain: CosmosChain) -> &'static str { - match chain { - CosmosChain::Injective => INJECTIVE_ETHSECP256K1_PUBKEY_TYPE, - _ => COSMOS_SECP256K1_PUBKEY_TYPE, - } -} - -fn sign_doc_digest(chain: CosmosChain, sign_doc_bytes: &[u8]) -> [u8; 32] { - match chain { - CosmosChain::Injective => keccak256(sign_doc_bytes), - _ => sha256(sign_doc_bytes), - } -} - impl ChainSigner for CosmosChainSigner { 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)?; @@ -39,7 +25,7 @@ impl ChainSigner for CosmosChainSigner { let messages = CosmosMessage::parse_array(&swap_data.data.data)?; let encoded: Vec> = messages.iter().map(|m| m.encode_as_any()).collect(); - let body_bytes = encode_tx_body(&encoded, input.memo.as_deref().unwrap_or("")); + let body_bytes = CosmosTxParams::encode_tx_body(&encoded, input.memo.as_deref().unwrap_or("")); let gas_limit = swap_data .data @@ -47,7 +33,7 @@ impl ChainSigner for CosmosChainSigner { .as_ref() .and_then(|g| g.parse::().ok()) .filter(|&g| g > 0) - .unwrap_or(BASE_FEE_GAS_UNITS); + .ok_or_else(|| SignerError::invalid_input("missing or invalid gas_limit"))?; let gas_limit = gas_limit * GAS_BUFFER_NUMERATOR / GAS_BUFFER_DENOMINATOR; let base_fee: u64 = input.gas_price.gas_price().to_string().parse().map_err(|_| SignerError::invalid_input("invalid gas price"))?; @@ -60,7 +46,7 @@ impl ChainSigner for CosmosChainSigner { sequence, fee_coins: vec![Coin { denom: chain.denom().as_ref().to_string(), amount: fee_amount }], gas_limit, - pubkey_type: pubkey_type(chain), + pubkey_type: Self::pubkey_type(chain), }; Ok(vec![Self::encode_and_sign_tx(chain, ¶ms, private_key)?]) @@ -68,19 +54,33 @@ impl ChainSigner for CosmosChainSigner { } impl CosmosChainSigner { + fn pubkey_type(chain: CosmosChain) -> &'static str { + match chain { + CosmosChain::Injective => INJECTIVE_ETHSECP256K1_PUBKEY_TYPE, + _ => COSMOS_SECP256K1_PUBKEY_TYPE, + } + } + + fn sign_doc_digest(chain: CosmosChain, sign_doc_bytes: &[u8]) -> [u8; 32] { + match chain { + CosmosChain::Injective => keccak256(sign_doc_bytes), + _ => sha256(sign_doc_bytes), + } + } + pub fn encode_and_sign_tx(chain: CosmosChain, params: &CosmosTxParams, private_key: &[u8]) -> Result { let pubkey_bytes = signer::secp256k1_public_key(private_key)?; - let auth_info_bytes = encode_auth_info(params.pubkey_type, &pubkey_bytes, params.sequence, ¶ms.fee_coins, params.gas_limit); - let sign_doc_bytes = encode_sign_doc(¶ms.body_bytes, &auth_info_bytes, params.chain_id, params.account_number); + let auth_info_bytes = params.encode_auth_info(&pubkey_bytes); + let sign_doc_bytes = params.encode_sign_doc(¶ms.body_bytes, &auth_info_bytes); - let digest = sign_doc_digest(chain, &sign_doc_bytes); + let digest = Self::sign_doc_digest(chain, &sign_doc_bytes); let mut signature = Signer::sign_digest(SignatureScheme::Secp256k1, digest.to_vec(), private_key.to_vec())?; if signature.len() < 64 { return Err(SignerError::signing_error("secp256k1 signature too short")); } signature.truncate(64); - let tx_raw = encode_tx_raw(¶ms.body_bytes, &auth_info_bytes, &signature); + let tx_raw = CosmosTxParams::encode_tx_raw(¶ms.body_bytes, &auth_info_bytes, &signature); let tx_base64 = STANDARD.encode(&tx_raw); Ok(serde_json::json!({ "mode": "BROADCAST_MODE_SYNC", diff --git a/crates/gem_cosmos/src/signer/transaction.rs b/crates/gem_cosmos/src/signer/transaction.rs index c563bc211..02b6eb353 100644 --- a/crates/gem_cosmos/src/signer/transaction.rs +++ b/crates/gem_cosmos/src/signer/transaction.rs @@ -19,6 +19,57 @@ pub struct CosmosTxParams<'a> { pub pubkey_type: &'a str, } +impl CosmosTxParams<'_> { + pub fn encode_tx_body(messages: &[Vec], memo: &str) -> Vec { + let msg_fields: Vec = messages.iter().flat_map(|m| encode_message_field(1, m)).collect(); + [msg_fields, encode_string_field(2, memo)].concat() + } + + pub fn encode_auth_info(&self, pubkey_bytes: &[u8]) -> Vec { + [ + encode_message_field(1, &Self::encode_signer_info(self.pubkey_type, pubkey_bytes, self.sequence)), + encode_message_field(2, &Self::encode_fee(&self.fee_coins, self.gas_limit)), + ] + .concat() + } + + pub fn encode_sign_doc(&self, body_bytes: &[u8], auth_info_bytes: &[u8]) -> Vec { + [ + encode_bytes_field(1, body_bytes), + encode_bytes_field(2, auth_info_bytes), + encode_string_field(3, self.chain_id), + encode_varint_field(4, self.account_number), + ] + .concat() + } + + pub fn encode_tx_raw(body_bytes: &[u8], auth_info_bytes: &[u8], signature: &[u8]) -> Vec { + [encode_bytes_field(1, body_bytes), encode_bytes_field(2, auth_info_bytes), encode_bytes_field(3, signature)].concat() + } + + fn encode_pubkey_any(pubkey_type: &str, pubkey_bytes: &[u8]) -> Vec { + [encode_string_field(1, pubkey_type), encode_bytes_field(2, &encode_bytes_field(1, pubkey_bytes))].concat() + } + + fn encode_mode_info_single() -> Vec { + encode_message_field(1, &encode_varint_field(1, SIGN_MODE_DIRECT)) + } + + fn encode_signer_info(pubkey_type: &str, pubkey_bytes: &[u8], sequence: u64) -> Vec { + [ + encode_message_field(1, &Self::encode_pubkey_any(pubkey_type, pubkey_bytes)), + encode_message_field(2, &Self::encode_mode_info_single()), + encode_varint_field(3, sequence), + ] + .concat() + } + + fn encode_fee(coins: &[Coin], gas_limit: u64) -> Vec { + let coin_fields: Vec = coins.iter().flat_map(|c| encode_message_field(1, &encode_coin(&c.denom, &c.amount))).collect(); + [coin_fields, encode_varint_field(2, gas_limit)].concat() + } +} + impl CosmosMessage { fn type_url(&self) -> &str { match self { @@ -38,21 +89,12 @@ impl CosmosMessage { let fund_fields: Vec = funds.iter().flat_map(|c| encode_message_field(5, &encode_coin(&c.denom, &c.amount))).collect(); [encode_string_field(1, sender), encode_string_field(2, contract), encode_bytes_field(3, msg), fund_fields].concat() } - Self::IbcTransfer { - source_port, - source_channel, - token, - sender, - receiver, - timeout_timestamp, - memo, - } => [ + Self::IbcTransfer { source_port, source_channel, token, sender, receiver, timeout_timestamp, memo } => [ encode_string_field(1, source_port), encode_string_field(2, source_channel), encode_message_field(3, &encode_coin(&token.denom, &token.amount)), encode_string_field(4, sender), encode_string_field(5, receiver), - // field number skips 6 encode_varint_field(7, *timeout_timestamp), encode_string_field(8, memo), ] @@ -65,55 +107,6 @@ impl CosmosMessage { } } -pub fn encode_tx_body(messages: &[Vec], memo: &str) -> Vec { - let msg_fields: Vec = messages.iter().flat_map(|m| encode_message_field(1, m)).collect(); - [msg_fields, encode_string_field(2, memo)].concat() -} - -fn encode_pubkey_any(pubkey_type: &str, pubkey_bytes: &[u8]) -> Vec { - [encode_string_field(1, pubkey_type), encode_bytes_field(2, &encode_bytes_field(1, pubkey_bytes))].concat() -} - -fn encode_mode_info_single() -> Vec { - encode_message_field(1, &encode_varint_field(1, SIGN_MODE_DIRECT)) -} - -fn encode_signer_info(pubkey_type: &str, pubkey_bytes: &[u8], sequence: u64) -> Vec { - [ - encode_message_field(1, &encode_pubkey_any(pubkey_type, pubkey_bytes)), - encode_message_field(2, &encode_mode_info_single()), - encode_varint_field(3, sequence), - ] - .concat() -} - -fn encode_fee(coins: &[Coin], gas_limit: u64) -> Vec { - let coin_fields: Vec = coins.iter().flat_map(|c| encode_message_field(1, &encode_coin(&c.denom, &c.amount))).collect(); - [coin_fields, encode_varint_field(2, gas_limit)].concat() -} - -pub fn encode_auth_info(pubkey_type: &str, pubkey_bytes: &[u8], sequence: u64, fee_coins: &[Coin], gas_limit: u64) -> Vec { - [ - encode_message_field(1, &encode_signer_info(pubkey_type, pubkey_bytes, sequence)), - encode_message_field(2, &encode_fee(fee_coins, gas_limit)), - ] - .concat() -} - -pub fn encode_sign_doc(body_bytes: &[u8], auth_info_bytes: &[u8], chain_id: &str, account_number: u64) -> Vec { - [ - encode_bytes_field(1, body_bytes), - encode_bytes_field(2, auth_info_bytes), - encode_string_field(3, chain_id), - encode_varint_field(4, account_number), - ] - .concat() -} - -pub fn encode_tx_raw(body_bytes: &[u8], auth_info_bytes: &[u8], signature: &[u8]) -> Vec { - [encode_bytes_field(1, body_bytes), encode_bytes_field(2, auth_info_bytes), encode_bytes_field(3, signature)].concat() -} - #[cfg(test)] mod tests { use super::*; @@ -124,10 +117,7 @@ mod tests { sender: "osmo1test".to_string(), contract: "osmo1contract".to_string(), msg: b"{\"swap\":{}}".to_vec(), - funds: vec![Coin { - denom: "uosmo".to_string(), - amount: "1000000".to_string(), - }], + funds: vec![Coin { denom: "uosmo".to_string(), amount: "1000000".to_string() }], }; assert_eq!( hex::encode(msg.encode_as_any()), @@ -140,10 +130,7 @@ mod tests { let msg = CosmosMessage::IbcTransfer { source_port: "transfer".to_string(), source_channel: "channel-0".to_string(), - token: Coin { - denom: "uatom".to_string(), - amount: "1000000".to_string(), - }, + token: Coin { denom: "uatom".to_string(), amount: "1000000".to_string() }, sender: "cosmos1test".to_string(), receiver: "osmo1test".to_string(), timeout_timestamp: 1773382733549000000, diff --git a/crates/swapper/src/squid/provider.rs b/crates/swapper/src/squid/provider.rs index 9d0509ea7..ad7bd2eec 100644 --- a/crates/swapper/src/squid/provider.rs +++ b/crates/swapper/src/squid/provider.rs @@ -61,13 +61,6 @@ where } } - fn compute_fee(amount: &str, bps: u32) -> Option<(String, String)> { - let value: u128 = amount.parse().ok()?; - let fee = value * bps as u128 / 10_000; - if fee == 0 { return None; } - Some((fee.to_string(), (value - fee).to_string())) - } - fn build_route_request(request: &QuoteRequest, from_value: &str, quote_only: bool) -> Result { let from_asset_id = request.from_asset.asset_id(); let to_asset_id = request.to_asset.asset_id(); @@ -85,6 +78,21 @@ where } } +impl Squid +where + C: Client + Clone + Send + Sync + std::fmt::Debug + 'static, +{ + async fn fetch_route(&self, request: &QuoteRequest, from_value: &str, quote_only: bool) -> Result<(SquidRouteResponse, u128, Option), SwapperError> { + let fee_address = Self::get_fee_address(request); + let value: u128 = from_value.parse().unwrap_or(0); + let fee = if fee_address.is_some() { value * DEFAULT_SWAP_FEE_BPS as u128 / 10_000 } else { 0 }; + let swap_value = (value - fee).to_string(); + let squid_request = Self::build_route_request(request, &swap_value, quote_only)?; + let response = self.client.get_route(&squid_request).await?; + Ok((response, fee, fee_address)) + } +} + #[async_trait] impl Swapper for Squid where @@ -100,19 +108,7 @@ where async fn get_quote(&self, request: &QuoteRequest) -> Result { let from_value = resolve_max_quote_value(request)?; - let fee_address = Self::get_fee_address(request); - - let (fee_amount, swap_amount) = if fee_address.is_some() { - Self::compute_fee(&from_value, DEFAULT_SWAP_FEE_BPS).unwrap_or((String::new(), from_value.clone())) - } else { - (String::new(), from_value.clone()) - }; - - let squid_request = Self::build_route_request(request, &swap_amount, true)?; - let response = self.client.get_route(&squid_request).await?; - - let from_asset_id = request.from_asset.asset_id(); - let to_asset_id = request.to_asset.asset_id(); + let (response, _, _) = self.fetch_route(request, &from_value, true).await?; Ok(Quote { from_value, @@ -120,9 +116,9 @@ where data: ProviderData { provider: self.provider().clone(), routes: vec![Route { - input: from_asset_id, - output: to_asset_id, - route_data: fee_amount, + input: request.from_asset.asset_id(), + output: request.to_asset.asset_id(), + route_data: String::new(), }], slippage_bps: request.options.slippage.bps, }, @@ -132,24 +128,14 @@ where } async fn get_quote_data(&self, quote: &Quote, _data: FetchQuoteData) -> Result { - let fee_address = Self::get_fee_address("e.request); - let fee_amount = quote.data.routes.first().map(|r| r.route_data.as_str()).unwrap_or(""); - - let swap_amount = if fee_address.is_some() && !fee_amount.is_empty() { - Self::compute_fee("e.from_value, DEFAULT_SWAP_FEE_BPS).map(|(_, s)| s).unwrap_or(quote.from_value.clone()) - } else { - quote.from_value.clone() - }; - - let request = Self::build_route_request("e.request, &swap_amount, false)?; - let response = self.client.get_route(&request).await?; + let (response, fee, fee_address) = self.fetch_route("e.request, "e.from_value, false).await?; let tx = response.route.transaction_request.ok_or(SwapperError::InvalidRoute)?; let swap_msg: serde_json::Value = serde_json::from_str(&tx.data).map_err(|e| SwapperError::TransactionError(e.to_string()))?; - let messages = match (fee_address, fee_amount) { - (Some(addr), amount) if !amount.is_empty() => { + let messages = match fee_address { + Some(addr) if fee > 0 => { let denom = Self::get_token_id("e.request.from_asset.asset_id())?; - vec![send_msg_json("e.request.wallet_address, &addr, &denom, amount), swap_msg] + vec![send_msg_json("e.request.wallet_address, &addr, &denom, &fee.to_string()), swap_msg] } _ => vec![swap_msg], }; @@ -183,23 +169,6 @@ where } } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_compute_fee() { - let (fee, swap) = Squid::::compute_fee("10000000", 50).unwrap(); - assert_eq!(fee, "50000"); - assert_eq!(swap, "9950000"); - } - - #[test] - fn test_compute_fee_zero() { - assert!(Squid::::compute_fee("100", 50).is_none()); - } -} - #[cfg(all(test, feature = "swap_integration_tests"))] mod swap_integration_tests { use super::*; From 172a61095b95822958def86434a4149b87ee0ab0 Mon Sep 17 00:00:00 2001 From: 0xh3rman <119309671+0xh3rman@users.noreply.github.com> Date: Thu, 19 Mar 2026 10:46:52 +0900 Subject: [PATCH 17/18] Update swapper.rs --- crates/swapper/src/swapper.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/swapper/src/swapper.rs b/crates/swapper/src/swapper.rs index f7d81f0ce..3e6855876 100644 --- a/crates/swapper/src/swapper.rs +++ b/crates/swapper/src/swapper.rs @@ -447,7 +447,7 @@ mod tests { async fn test_fetch_quote_input_amount_error() { let request = mock_quote( SwapperQuoteAsset::from(AssetId::from_chain(Chain::Ethereum)), - SwapperQuoteAsset::from(USDC_ETH_ASSET_ID.into()), + SwapperQuoteAsset::from(AssetId::new(USDC_ETH_ASSET_ID).unwrap()), ); let gem_swapper = GemSwapper { From ec0e3ae7f93020c5414a1d998d3e6601ff4c14d5 Mon Sep 17 00:00:00 2001 From: 0xh3rman <119309671+0xh3rman@users.noreply.github.com> Date: Thu, 19 Mar 2026 15:26:39 +0900 Subject: [PATCH 18/18] fix merge issue --- crates/gem_cosmos/src/models/message.rs | 10 ++++++++- crates/gem_cosmos/src/signer/chain_signer.rs | 12 ++++++++-- crates/gem_cosmos/src/signer/transaction.rs | 20 ++++++++++++++--- crates/primitives/src/asset_constants.rs | 15 +++++++++++++ crates/swapper/src/squid/mod.rs | 8 +++---- crates/swapper/src/squid/provider.rs | 23 ++++++++++++++++---- 6 files changed, 74 insertions(+), 14 deletions(-) diff --git a/crates/gem_cosmos/src/models/message.rs b/crates/gem_cosmos/src/models/message.rs index 52d42f5b1..f03d544c0 100644 --- a/crates/gem_cosmos/src/models/message.rs +++ b/crates/gem_cosmos/src/models/message.rs @@ -208,7 +208,15 @@ mod tests { fn test_parse_ibc_transfer() { let msg = CosmosMessage::parse(include_str!("../../testdata/swap_ibc_transfer.json")).unwrap(); match msg { - CosmosMessage::IbcTransfer { source_port, source_channel, sender, receiver, timeout_timestamp, memo, .. } => { + CosmosMessage::IbcTransfer { + source_port, + source_channel, + sender, + receiver, + timeout_timestamp, + memo, + .. + } => { assert_eq!(source_port, "transfer"); assert_eq!(source_channel, "channel-141"); assert_eq!(sender, "cosmos1tkvyjqeq204rmrrz3w4hcrs336qahsfwmugljt"); diff --git a/crates/gem_cosmos/src/signer/chain_signer.rs b/crates/gem_cosmos/src/signer/chain_signer.rs index 735589150..ff6fa7297 100644 --- a/crates/gem_cosmos/src/signer/chain_signer.rs +++ b/crates/gem_cosmos/src/signer/chain_signer.rs @@ -36,7 +36,12 @@ impl ChainSigner for CosmosChainSigner { .ok_or_else(|| SignerError::invalid_input("missing or invalid gas_limit"))?; let gas_limit = gas_limit * GAS_BUFFER_NUMERATOR / GAS_BUFFER_DENOMINATOR; - let base_fee: u64 = input.gas_price.gas_price().to_string().parse().map_err(|_| SignerError::invalid_input("invalid gas price"))?; + let base_fee: u64 = input + .gas_price + .gas_price() + .to_string() + .parse() + .map_err(|_| SignerError::invalid_input("invalid gas price"))?; let fee_amount = ((gas_limit as u128 * base_fee as u128 / BASE_FEE_GAS_UNITS as u128) as u64).to_string(); let params = CosmosTxParams { @@ -44,7 +49,10 @@ impl ChainSigner for CosmosChainSigner { chain_id: &chain_id, account_number, sequence, - fee_coins: vec![Coin { denom: chain.denom().as_ref().to_string(), amount: fee_amount }], + fee_coins: vec![Coin { + denom: chain.denom().as_ref().to_string(), + amount: fee_amount, + }], gas_limit, pubkey_type: Self::pubkey_type(chain), }; diff --git a/crates/gem_cosmos/src/signer/transaction.rs b/crates/gem_cosmos/src/signer/transaction.rs index 02b6eb353..0985af0cf 100644 --- a/crates/gem_cosmos/src/signer/transaction.rs +++ b/crates/gem_cosmos/src/signer/transaction.rs @@ -89,7 +89,15 @@ impl CosmosMessage { let fund_fields: Vec = funds.iter().flat_map(|c| encode_message_field(5, &encode_coin(&c.denom, &c.amount))).collect(); [encode_string_field(1, sender), encode_string_field(2, contract), encode_bytes_field(3, msg), fund_fields].concat() } - Self::IbcTransfer { source_port, source_channel, token, sender, receiver, timeout_timestamp, memo } => [ + Self::IbcTransfer { + source_port, + source_channel, + token, + sender, + receiver, + timeout_timestamp, + memo, + } => [ encode_string_field(1, source_port), encode_string_field(2, source_channel), encode_message_field(3, &encode_coin(&token.denom, &token.amount)), @@ -117,7 +125,10 @@ mod tests { sender: "osmo1test".to_string(), contract: "osmo1contract".to_string(), msg: b"{\"swap\":{}}".to_vec(), - funds: vec![Coin { denom: "uosmo".to_string(), amount: "1000000".to_string() }], + funds: vec![Coin { + denom: "uosmo".to_string(), + amount: "1000000".to_string(), + }], }; assert_eq!( hex::encode(msg.encode_as_any()), @@ -130,7 +141,10 @@ mod tests { let msg = CosmosMessage::IbcTransfer { source_port: "transfer".to_string(), source_channel: "channel-0".to_string(), - token: Coin { denom: "uatom".to_string(), amount: "1000000".to_string() }, + token: Coin { + denom: "uatom".to_string(), + amount: "1000000".to_string(), + }, sender: "cosmos1test".to_string(), receiver: "osmo1test".to_string(), timeout_timestamp: 1773382733549000000, diff --git a/crates/primitives/src/asset_constants.rs b/crates/primitives/src/asset_constants.rs index d32b4e713..63fe57a2b 100644 --- a/crates/primitives/src/asset_constants.rs +++ b/crates/primitives/src/asset_constants.rs @@ -306,3 +306,18 @@ pub static SUI_SBUSDT_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::fr pub const THORCHAIN_TCY_TOKEN_ID: &str = "tcy"; pub static THORCHAIN_TCY_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Thorchain, THORCHAIN_TCY_TOKEN_ID)); + +pub const COSMOS_USDC_TOKEN_ID: &str = "ibc/F663521BF1836B00F5F177680F74BFB9A8B5654A694D0D2BC249E03CF2509013"; +pub static COSMOS_USDC_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Cosmos, COSMOS_USDC_TOKEN_ID)); + +pub const OSMOSIS_USDC_TOKEN_ID: &str = "ibc/498A0751C798A0D9A389AA3691123DADA57DAA4FE165D5C75894505B876BA6E4"; +pub static OSMOSIS_USDC_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Osmosis, OSMOSIS_USDC_TOKEN_ID)); + +pub const OSMOSIS_USDT_TOKEN_ID: &str = "ibc/4ABBEF4C8926DDDB320AE5188CFD63267ABBCEFC0583E4AE05D6E5AA2401DDAB"; +pub static OSMOSIS_USDT_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Osmosis, OSMOSIS_USDT_TOKEN_ID)); + +pub const INJECTIVE_USDC_TOKEN_ID: &str = "ibc/7E1AF94AD246BE522892751046F0C959B768642E5671CC3742264068D49553C0"; +pub static INJECTIVE_USDC_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Injective, INJECTIVE_USDC_TOKEN_ID)); + +pub const SEI_USDC_TOKEN_ID: &str = "ibc/CA6FBFAF399474A06263E10D0CE5AEBBE15189D6D4B2DD9ADE61007E68EB9DB0"; +pub static SEI_USDC_ASSET_ID: LazyLock = LazyLock::new(|| AssetId::from_token(Chain::Sei, SEI_USDC_TOKEN_ID)); diff --git a/crates/swapper/src/squid/mod.rs b/crates/swapper/src/squid/mod.rs index 97aac023f..23e13e83f 100644 --- a/crates/swapper/src/squid/mod.rs +++ b/crates/swapper/src/squid/mod.rs @@ -4,11 +4,11 @@ mod provider; use std::sync::LazyLock; -use crate::{ - asset::{COSMOS_USDC_TOKEN_ID, INJECTIVE_USDC_TOKEN_ID, OSMOSIS_USDC_TOKEN_ID, OSMOSIS_USDT_TOKEN_ID, SEI_USDC_TOKEN_ID}, - models::SwapperChainAsset, +use crate::models::SwapperChainAsset; +use primitives::{ + AssetId, Chain, + asset_constants::{COSMOS_USDC_TOKEN_ID, INJECTIVE_USDC_TOKEN_ID, OSMOSIS_USDC_TOKEN_ID, OSMOSIS_USDT_TOKEN_ID, SEI_USDC_TOKEN_ID}, }; -use primitives::{AssetId, Chain}; pub use provider::Squid; diff --git a/crates/swapper/src/squid/provider.rs b/crates/swapper/src/squid/provider.rs index ad7bd2eec..5084756fe 100644 --- a/crates/swapper/src/squid/provider.rs +++ b/crates/swapper/src/squid/provider.rs @@ -8,7 +8,10 @@ use primitives::{AssetId, Chain, chain_cosmos::CosmosChain, swap::SwapQuoteDataT use super::{SQUID_COSMOS_MULTICALL, SUPPORTED_CHAINS, client::SquidClient, model::*}; use crate::{ FetchQuoteData, ProviderData, ProviderType, Quote, QuoteRequest, Route, RpcClient, RpcProvider, SwapResult, Swapper, SwapperChainAsset, SwapperError, SwapperProvider, - SwapperQuoteData, config::{DEFAULT_SWAP_FEE_BPS, get_swap_api_url}, cross_chain::VaultAddresses, fees::resolve_max_quote_value, + SwapperQuoteData, + config::{DEFAULT_SWAP_FEE_BPS, get_swap_api_url}, + cross_chain::VaultAddresses, + fees::resolve_max_quote_value, }; #[derive(Debug)] @@ -55,7 +58,9 @@ where _ => { let cosmos_chain = CosmosChain::from_chain(chain)?; let base = &fees.cosmos.address; - if base.is_empty() { return None; } + if base.is_empty() { + return None; + } convert_cosmos_address(base, cosmos_chain.hrp()).ok() } } @@ -198,7 +203,12 @@ mod swap_integration_tests { }; let quote = squid.get_quote(&request).await?; - println!("OSMO->ATOM quote: from={}, to={}, eta={}s", quote.from_value, quote.to_value, quote.eta_in_seconds.unwrap_or(0)); + println!( + "OSMO->ATOM quote: from={}, to={}, eta={}s", + quote.from_value, + quote.to_value, + quote.eta_in_seconds.unwrap_or(0) + ); assert_eq!(quote.from_value, "10000000"); assert!(quote.to_value.parse::().unwrap() > 0); @@ -224,7 +234,12 @@ mod swap_integration_tests { }; let quote = squid.get_quote(&request).await?; - println!("ATOM->OSMO quote: from={}, to={}, eta={}s", quote.from_value, quote.to_value, quote.eta_in_seconds.unwrap_or(0)); + println!( + "ATOM->OSMO quote: from={}, to={}, eta={}s", + quote.from_value, + quote.to_value, + quote.eta_in_seconds.unwrap_or(0) + ); assert_eq!(quote.from_value, "1000000"); assert!(quote.to_value.parse::().unwrap() > 0);