Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .claude/skills/review-changes/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -113,6 +115,9 @@ Analyze the diff above and check for the following issues:
- [ ] **Error handling**: Use `Result<(), Box<dyn std::error::Error + Send + Sync>>`
- [ ] **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
Expand Down
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 4 additions & 2 deletions crates/gem_cosmos/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,30 +10,32 @@ 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 }
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",
"dep:chain_traits",
"dep:futures",
"dep:number_formatter",
]
signer = ["dep:signer"]
reqwest = ["gem_client/reqwest"]
chain_integration_tests = ["rpc", "reqwest", "settings/testkit"]

Expand Down
2 changes: 2 additions & 0 deletions crates/gem_cosmos/src/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions crates/gem_cosmos/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,7 @@ pub mod rpc;
#[cfg(feature = "rpc")]
pub mod provider;

#[cfg(feature = "signer")]
pub mod signer;

pub mod models;
11 changes: 11 additions & 0 deletions crates/gem_cosmos/src/models/contract.rs
Original file line number Diff line number Diff line change
@@ -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<Coin>,
}
16 changes: 16 additions & 0 deletions crates/gem_cosmos/src/models/ibc.rs
Original file line number Diff line number Diff line change
@@ -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,
}
70 changes: 70 additions & 0 deletions crates/gem_cosmos/src/models/long.rs
Original file line number Diff line number Diff line change
@@ -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<u64, D::Error> {
#[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::<u64>().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);
}
}
138 changes: 138 additions & 0 deletions crates/gem_cosmos/src/models/message.rs
Original file line number Diff line number Diff line change
@@ -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")]
Expand Down Expand Up @@ -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<Coin>,
},
ExecuteContract {
sender: String,
contract: String,
msg: Vec<u8>,
funds: Vec<Coin>,
},
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<Vec<Self>, SignerError> {
let arr: Vec<serde_json::Value> = serde_json::from_str(data)?;
arr.iter().map(|v| Self::parse(&v.to_string())).collect()
}

pub fn parse(data: &str) -> Result<Self, SignerError> {
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"),
}
}
}
11 changes: 11 additions & 0 deletions crates/gem_cosmos/src/models/mod.rs
Original file line number Diff line number Diff line change
@@ -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::*;
7 changes: 5 additions & 2 deletions crates/gem_cosmos/src/provider/preload_mapper.rs
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading