diff --git a/.claude/skills/review-changes/SKILL.md b/.claude/skills/review-changes/SKILL.md index 5d0ae311c..793402f53 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) @@ -113,6 +115,9 @@ 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 +- [ ] **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/Cargo.lock b/Cargo.lock index 7f9a8687d..9d0a8c814 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3112,6 +3112,7 @@ dependencies = [ "serde_json", "serde_serializers", "settings", + "signer", "tokio", ] @@ -7183,6 +7184,7 @@ dependencies = [ "futures", "gem_aptos", "gem_client", + "gem_cosmos", "gem_evm", "gem_hash", "gem_hypercore", diff --git a/crates/gem_cosmos/Cargo.toml b/crates/gem_cosmos/Cargo.toml index c9cd3f3df..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 } @@ -21,12 +21,13 @@ 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] default = [] rpc = [ - "dep:serde_json", "dep:chrono", "dep:async-trait", "dep:gem_client", @@ -34,6 +35,7 @@ rpc = [ "dep:futures", "dep:number_formatter", ] +signer = ["dep:signer"] 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..5124fb2d9 --- /dev/null +++ b/crates/gem_cosmos/src/models/contract.rs @@ -0,0 +1,11 @@ +use serde::Deserialize; + +use super::Coin; + +#[derive(Debug, Deserialize)] +pub struct ExecuteContractValue { + 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..5c1f30103 --- /dev/null +++ b/crates/gem_cosmos/src/models/ibc.rs @@ -0,0 +1,16 @@ +use super::Coin; +use super::long::deserialize_u64_from_long_or_int; +use serde::Deserialize; + +#[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(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/message.rs b/crates/gem_cosmos/src/models/message.rs index 4eb5ca1c8..f03d544c0 100644 --- a/crates/gem_cosmos/src/models/message.rs +++ b/crates/gem_cosmos/src/models/message.rs @@ -1,6 +1,12 @@ 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, MESSAGE_SEND_BETA}; +#[cfg(feature = "signer")] +use primitives::SignerError; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "@type")] @@ -90,3 +96,135 @@ 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 { + Send { + from_address: String, + to_address: String, + amount: Vec, + }, + 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, + }, +} + +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 { + 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)?; + 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 => SignerError::invalid_input_err(format!("unsupported cosmos message type: {other}")), + } + } +} + +#[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/models/mod.rs b/crates/gem_cosmos/src/models/mod.rs index fd44be081..6862a233e 100644 --- a/crates/gem_cosmos/src/models/mod.rs +++ b/crates/gem_cosmos/src/models/mod.rs @@ -1,13 +1,24 @@ pub mod account; pub mod block; +pub mod long; pub mod message; 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 long::*; 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..968e6c02a 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 { @@ -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..ff6fa7297 --- /dev/null +++ b/crates/gem_cosmos/src/signer/chain_signer.rs @@ -0,0 +1,99 @@ +use std::str::FromStr; + +use base64::{Engine, engine::general_purpose::STANDARD}; +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}; +use crate::models::{Coin, CosmosMessage}; + +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; + +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 = 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 messages = CosmosMessage::parse_array(&swap_data.data.data)?; + let encoded: Vec> = messages.iter().map(|m| m.encode_as_any()).collect(); + let body_bytes = CosmosTxParams::encode_tx_body(&encoded, 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) + .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 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, + account_number, + sequence, + fee_coins: vec![Coin { + denom: chain.denom().as_ref().to_string(), + amount: fee_amount, + }], + gas_limit, + pubkey_type: Self::pubkey_type(chain), + }; + + Ok(vec![Self::encode_and_sign_tx(chain, ¶ms, private_key)?]) + } +} + +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 = params.encode_auth_info(&pubkey_bytes); + let sign_doc_bytes = params.encode_sign_doc(¶ms.body_bytes, &auth_info_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 = 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", + "tx_bytes": tx_base64, + }) + .to_string()) + } +} diff --git a/crates/gem_cosmos/src/signer/mod.rs b/crates/gem_cosmos/src/signer/mod.rs new file mode 100644 index 000000000..64f449640 --- /dev/null +++ b/crates/gem_cosmos/src/signer/mod.rs @@ -0,0 +1,5 @@ +mod chain_signer; +mod protobuf; +pub 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..0985af0cf --- /dev/null +++ b/crates/gem_cosmos/src/signer/transaction.rs @@ -0,0 +1,158 @@ +use crate::models::{Coin, CosmosMessage}; + +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; + +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 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 { + Self::Send { .. } => MESSAGE_SEND_TYPE_URL, + Self::ExecuteContract { .. } => MESSAGE_EXECUTE_CONTRACT, + Self::IbcTransfer { .. } => MESSAGE_IBC_TRANSFER, + } + } + + 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() + } + 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() + } +} + +#[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(), + }], + }; + assert_eq!( + hex::encode(msg.encode_as_any()), + "0a242f636f736d7761736d2e7761736d2e76312e4d736745786563757465436f6e747261637412390a096f736d6f3174657374120d6f736d6f31636f6e74726163741a0b7b2273776170223a7b7d7d2a100a05756f736d6f120731303030303030" + ); + } + + #[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(), + }; + assert_eq!( + hex::encode(msg.encode_as_any()), + "0a292f6962632e6170706c69636174696f6e732e7472616e736665722e76312e4d73675472616e73666572126b0a087472616e7366657212096368616e6e656c2d301a100a057561746f6d120731303030303030220b636f736d6f7331746573742a096f736d6f317465737438c0aaffdfb4c694ce1842207b226962635f63616c6c6261636b223a226f736d6f31636f6e7472616374227d" + ); + } +} 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..ca8760341 --- /dev/null +++ b/crates/gem_cosmos/testdata/swap_execute_contract.json @@ -0,0 +1,14 @@ +{ + "typeUrl": "/cosmwasm.wasm.v1.MsgExecuteContract", + "value": { + "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": "10000000" + } + ] + } +} 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..3c28ba086 --- /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-141", + "token": { + "denom": "uatom", + "amount": "1000000" + }, + "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\"}}}}" + } +} 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/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/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/crates/primitives/src/swap_provider.rs b/crates/primitives/src/swap_provider.rs index e6babc728..88301e285 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,14 +105,15 @@ impl SwapProvider { | Self::Aerodrome | Self::Relay | Self::Hyperliquid - | Self::Orca => self.name(), + | Self::Orca + | Self::Squid => self.name(), } } 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/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/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..dc911d7da 100644 --- a/crates/signer/src/lib.rs +++ b/crates/signer/src/lib.rs @@ -7,6 +7,7 @@ use ed25519_dalek::Signer as DalekSigner; use zeroize::Zeroizing; use crate::ed25519::{sign_digest as sign_ed25519_digest, signing_key_from_bytes}; +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}; diff --git a/crates/signer/src/secp256k1.rs b/crates/signer/src/secp256k1.rs index c357dd95e..4634636a4 100644 --- a/crates/signer/src/secp256k1.rs +++ b/crates/signer/src/secp256k1.rs @@ -2,7 +2,7 @@ use k256::ecdsa::SigningKey as SecpSigningKey; use primitives::SignerError; pub(crate) fn sign_digest(digest: &[u8], private_key: &[u8]) -> Result, SignerError> { - let signing_key = SecpSigningKey::from_slice(private_key).map_err(|_| SignerError::invalid_input("Invalid Secp256k1 private key"))?; + 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/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/referral.rs b/crates/swapper/src/fees/referral.rs index 2e026adba..f5837d08f 100644 --- a/crates/swapper/src/fees/referral.rs +++ b/crates/swapper/src/fees/referral.rs @@ -10,6 +10,8 @@ pub struct ReferralFees { pub tron: ReferralFee, pub near: ReferralFee, pub aptos: ReferralFee, + pub cosmos: ReferralFee, + pub injective: ReferralFee, } #[derive(Default, Debug, Clone, PartialEq)] @@ -37,6 +39,8 @@ impl ReferralFees { &mut self.tron, &mut self.near, &mut self.aptos, + &mut self.cosmos, + &mut self.injective, ] .into_iter() } @@ -84,5 +88,13 @@ pub fn default_referral_fees() -> ReferralFees { address: "0xc09d385527743bb03ed7847bb9180b5ff2263d38d5a93f1c9b3068f8505f6488".into(), bps: DEFAULT_SWAP_FEE_BPS, }, + cosmos: ReferralFee { + address: "cosmos1knwywgnzs3a2p39k7337klt6daqrhyvnh8vz27".into(), + bps: DEFAULT_SWAP_FEE_BPS, + }, + injective: ReferralFee { + address: "inj1pkw6kx3y3a3mpfyfvkaggd0yme6f0g7uylvm5y".into(), + bps: DEFAULT_SWAP_FEE_BPS, + }, } } diff --git a/crates/swapper/src/lib.rs b/crates/swapper/src/lib.rs index e07da2a8b..59fc6033f 100644 --- a/crates/swapper/src/lib.rs +++ b/crates/swapper/src/lib.rs @@ -21,6 +21,7 @@ pub mod near_intents; pub mod permit2_data; pub mod proxy; pub mod relay; +pub mod squid; pub mod swapper; pub mod thorchain; pub mod uniswap; diff --git a/crates/swapper/src/models.rs b/crates/swapper/src/models.rs index d83498757..2d2aab51c 100644 --- a/crates/swapper/src/models.rs +++ b/crates/swapper/src/models.rs @@ -41,7 +41,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/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..23e13e83f --- /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::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}, +}; + +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..03eb40789 --- /dev/null +++ b/crates/swapper/src/squid/model.rs @@ -0,0 +1,98 @@ +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, + #[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 { + 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, +} + +#[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..5084756fe --- /dev/null +++ b/crates/swapper/src/squid/provider.rs @@ -0,0 +1,263 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use gem_client::Client; +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::{DEFAULT_SWAP_FEE_BPS, 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, + } + } +} + +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_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 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, + }) + } +} + +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 + 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 (response, _, _) = self.fetch_route(request, &from_value, true).await?; + + Ok(Quote { + from_value, + to_value: response.route.estimate.to_amount, + data: ProviderData { + provider: self.provider().clone(), + routes: vec![Route { + input: request.from_asset.asset_id(), + output: request.to_asset.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 (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 { + 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, &fee.to_string()), 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 { + 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.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); + 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); + assert!(!quote_data.data.is_empty()); + + 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 122907aaf..71986148a 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, cross_chain::VaultAddresses, fees::DEFAULT_STABLE_SWAP_REFERRAL_BPS, 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,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(squid::Squid::new(rpc_provider.clone())), uniswap::default::boxed_aerodrome(rpc_provider.clone()), ]; 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 +} 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 feb5d308e..c6b2b8573 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,6 +49,7 @@ impl Explorer { SwapperProvider::Chainflip => ChainflipScan::boxed(), SwapperProvider::NearIntents => NearIntents::boxed(), SwapperProvider::Relay => RelayScan::boxed(), + SwapperProvider::Squid => SkipExplorer::boxed(self.chain), SwapperProvider::UniswapV3 | SwapperProvider::UniswapV4 | SwapperProvider::PancakeswapV3 diff --git a/gemstone/src/config/swap_config.rs b/gemstone/src/config/swap_config.rs index e1d4c7cd5..a0e902068 100644 --- a/gemstone/src/config/swap_config.rs +++ b/gemstone/src/config/swap_config.rs @@ -23,6 +23,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/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..a87e91946 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,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), _ => todo!("Signer not implemented for chain {:?}", chain), }; diff --git a/skills/code-style.md b/skills/code-style.md index fe8e086d1..1e0ef96cc 100644 --- a/skills/code-style.md +++ b/skills/code-style.md @@ -95,6 +95,11 @@ 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 +- **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