diff --git a/Cargo.lock b/Cargo.lock index 74efd10386..d445b6ec50 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3205,6 +3205,7 @@ dependencies = [ "gem_encoding", "gem_hash", "hex", + "k256", "num-bigint", "number_formatter", "primitives", diff --git a/crates/gem_cosmos/Cargo.toml b/crates/gem_cosmos/Cargo.toml index e4a5d9472f..4975a9656b 100644 --- a/crates/gem_cosmos/Cargo.toml +++ b/crates/gem_cosmos/Cargo.toml @@ -22,6 +22,7 @@ futures = { workspace = true, optional = true } number_formatter = { path = "../number_formatter", optional = true } signer = { path = "../signer", optional = true } +k256 = { workspace = true, optional = true } num-bigint = { workspace = true } @@ -35,12 +36,13 @@ rpc = [ "dep:futures", "dep:number_formatter", ] -signer = ["dep:signer"] +signer = ["dep:signer", "dep:k256"] reqwest = ["gem_client/reqwest"] unit_tests = ["signer"] chain_integration_tests = ["rpc", "reqwest", "settings/testkit"] [dev-dependencies] +primitives = { path = "../primitives", features = ["testkit"] } tokio = { workspace = true, features = ["macros", "rt"] } reqwest = { workspace = true } settings = { path = "../settings", features = ["testkit"] } diff --git a/crates/gem_cosmos/src/models/message.rs b/crates/gem_cosmos/src/models/message.rs index 7eb1fd9ea5..fae0ca3abe 100644 --- a/crates/gem_cosmos/src/models/message.rs +++ b/crates/gem_cosmos/src/models/message.rs @@ -1,10 +1,15 @@ +use std::str::FromStr; + +use num_bigint::BigInt; 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}; +use crate::constants::{ + MESSAGE_DELEGATE, MESSAGE_EXECUTE_CONTRACT, MESSAGE_IBC_TRANSFER, MESSAGE_REDELEGATE, MESSAGE_REWARD_BETA, MESSAGE_SEND, MESSAGE_SEND_BETA, MESSAGE_UNDELEGATE, +}; #[cfg(feature = "signer")] use primitives::SignerError; @@ -43,6 +48,12 @@ pub struct Coin { pub amount: String, } +impl Coin { + pub fn get_amount(&self) -> Option { + BigInt::from_str(&self.amount).ok() + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Fee { pub amount: Vec, @@ -84,16 +95,8 @@ impl Message { } impl MsgSend { - pub fn get_amount(&self, denom: &str) -> Option { - use std::str::FromStr; - let value = self - .amount - .clone() - .into_iter() - .filter(|x| x.denom == denom) - .flat_map(|x| num_bigint::BigInt::from_str(&x.amount).ok()) - .sum(); - Some(value) + pub fn get_amount(&self, denom: &str) -> Option { + Some(self.amount.iter().filter(|c| c.denom == denom).flat_map(Coin::get_amount).sum()) } } @@ -127,6 +130,26 @@ pub enum CosmosMessage { timeout_timestamp: u64, memo: String, }, + Delegate { + delegator_address: String, + validator_address: String, + amount: Coin, + }, + Undelegate { + delegator_address: String, + validator_address: String, + amount: Coin, + }, + BeginRedelegate { + delegator_address: String, + validator_src_address: String, + validator_dst_address: String, + amount: Coin, + }, + WithdrawDelegatorReward { + delegator_address: String, + validator_address: String, + }, } pub fn send_msg_json(from: &str, to: &str, denom: &str, amount: &str) -> serde_json::Value { @@ -151,7 +174,7 @@ impl CosmosMessage { let envelope: MessageEnvelope = serde_json::from_str(data)?; match envelope.type_url.as_str() { - MESSAGE_SEND_BETA => { + MESSAGE_SEND_BETA | MESSAGE_SEND => { let v: MsgSend = serde_json::from_value(envelope.value)?; Ok(Self::Send { from_address: v.from_address, @@ -180,6 +203,41 @@ impl CosmosMessage { memo: v.memo, }) } + MESSAGE_DELEGATE => { + let v: MsgDelegate = serde_json::from_value(envelope.value)?; + let amount = v.amount.ok_or_else(|| SignerError::invalid_input("missing delegate amount"))?; + Ok(Self::Delegate { + delegator_address: v.delegator_address, + validator_address: v.validator_address, + amount, + }) + } + MESSAGE_UNDELEGATE => { + let v: MsgUndelegate = serde_json::from_value(envelope.value)?; + let amount = v.amount.ok_or_else(|| SignerError::invalid_input("missing undelegate amount"))?; + Ok(Self::Undelegate { + delegator_address: v.delegator_address, + validator_address: v.validator_address, + amount, + }) + } + MESSAGE_REDELEGATE => { + let v: MsgBeginRedelegate = serde_json::from_value(envelope.value)?; + let amount = v.amount.ok_or_else(|| SignerError::invalid_input("missing redelegate amount"))?; + Ok(Self::BeginRedelegate { + delegator_address: v.delegator_address, + validator_src_address: v.validator_src_address, + validator_dst_address: v.validator_dst_address, + amount, + }) + } + MESSAGE_REWARD_BETA => { + let v: MsgWithdrawDelegatorReward = serde_json::from_value(envelope.value)?; + Ok(Self::WithdrawDelegatorReward { + delegator_address: v.delegator_address, + validator_address: v.validator_address, + }) + } other => SignerError::invalid_input_err(format!("unsupported cosmos message type: {other}")), } } diff --git a/crates/gem_cosmos/src/signer/chain_signer.rs b/crates/gem_cosmos/src/signer/chain_signer.rs index 6cad72eabd..8a8a6083ae 100644 --- a/crates/gem_cosmos/src/signer/chain_signer.rs +++ b/crates/gem_cosmos/src/signer/chain_signer.rs @@ -1,11 +1,10 @@ -use std::str::FromStr; - use gem_encoding::encode_base64; use gem_hash::{keccak::keccak256, sha2::sha256}; +use k256::{PublicKey, elliptic_curve::sec1::ToEncodedPoint}; use primitives::{ChainSigner, SignerError, SignerInput, chain_cosmos::CosmosChain}; use signer::{SignatureScheme, Signer}; -use super::transaction::{COSMOS_SECP256K1_PUBKEY_TYPE, CosmosTxParams, INJECTIVE_ETHSECP256K1_PUBKEY_TYPE}; +use super::transaction::{self, COSMOS_SECP256K1_PUBKEY_TYPE, CosmosTxParams, INJECTIVE_ETHSECP256K1_PUBKEY_TYPE}; use crate::models::{Coin, CosmosMessage}; const BASE_FEE_GAS_UNITS: u64 = 200_000; @@ -16,17 +15,22 @@ const GAS_BUFFER_DENOMINATOR: u64 = 10; pub struct CosmosChainSigner; impl ChainSigner for CosmosChainSigner { + fn sign_transfer(&self, input: &SignerInput, private_key: &[u8]) -> Result { + let chain = Self::chain(input)?; + Self::sign_send(chain, input, chain.denom().as_ref(), private_key) + } + + fn sign_token_transfer(&self, input: &SignerInput, private_key: &[u8]) -> Result { + let chain = Self::chain(input)?; + let denom = input.input_type.get_asset().id.get_token_id()?; + Self::sign_send(chain, input, denom, private_key) + } + fn sign_swap(&self, input: &SignerInput, 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 chain = Self::chain(input)?; 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 @@ -39,24 +43,24 @@ impl ChainSigner for CosmosChainSigner { let base_fee = input.fee.gas_price_u64()?; 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::sign_messages(chain, input, messages, gas_limit, fee_amount, private_key)?]) + } + + fn sign_stake(&self, input: &SignerInput, private_key: &[u8]) -> Result, SignerError> { + let chain = Self::chain(input)?; + let messages = transaction::stake_messages(input, chain)?; + let gas_limit = Self::gas_limit(input, messages.len())?; + let fee_amount = input.fee.fee.to_string(); - Ok(vec![Self::encode_and_sign_tx(chain, ¶ms, private_key)?]) + Ok(vec![Self::sign_messages(chain, input, messages, gas_limit, fee_amount, private_key)?]) } } impl CosmosChainSigner { + fn chain(input: &SignerInput) -> Result { + CosmosChain::from_chain(input.input_type.get_asset().chain).ok_or_else(|| SignerError::invalid_input("unsupported cosmos chain")) + } + fn pubkey_type(chain: CosmosChain) -> &'static str { match chain { CosmosChain::Injective => INJECTIVE_ETHSECP256K1_PUBKEY_TYPE, @@ -64,6 +68,19 @@ impl CosmosChainSigner { } } + fn public_key(chain: CosmosChain, private_key: &[u8]) -> Result, SignerError> { + let public_key = signer::secp256k1_public_key(private_key)?; + match chain { + CosmosChain::Injective => Self::uncompress_public_key(&public_key), + CosmosChain::Cosmos | CosmosChain::Osmosis | CosmosChain::Celestia | CosmosChain::Thorchain | CosmosChain::Sei | CosmosChain::Noble => Ok(public_key), + } + } + + fn uncompress_public_key(public_key: &[u8]) -> Result, SignerError> { + let public_key = PublicKey::from_sec1_bytes(public_key).map_err(|_| SignerError::invalid_input("invalid secp256k1 public key"))?; + Ok(public_key.to_encoded_point(false).as_bytes().to_vec()) + } + fn sign_doc_digest(chain: CosmosChain, sign_doc_bytes: &[u8]) -> [u8; 32] { match chain { CosmosChain::Injective => keccak256(sign_doc_bytes), @@ -71,8 +88,54 @@ impl CosmosChainSigner { } } + fn gas_limit(input: &SignerInput, message_count: usize) -> Result { + let message_count = u64::try_from(message_count).map_err(|_| SignerError::invalid_input("too many messages"))?; + input + .fee + .gas_limit()? + .checked_mul(message_count) + .ok_or_else(|| SignerError::invalid_input("gas limit overflow")) + } + + fn fee_coins(chain: CosmosChain, fee_amount: String) -> Vec { + match chain { + CosmosChain::Thorchain => vec![], + CosmosChain::Cosmos | CosmosChain::Osmosis | CosmosChain::Celestia | CosmosChain::Injective | CosmosChain::Sei | CosmosChain::Noble => vec![Coin { + denom: chain.denom().as_ref().to_string(), + amount: fee_amount, + }], + } + } + + fn sign_send(chain: CosmosChain, input: &SignerInput, denom: &str, private_key: &[u8]) -> Result { + let message = transaction::transfer_message(input, denom); + let gas_limit = Self::gas_limit(input, 1)?; + let fee_amount = input.fee.fee.to_string(); + Self::sign_messages(chain, input, vec![message], gas_limit, fee_amount, private_key) + } + + fn sign_messages(chain: CosmosChain, input: &SignerInput, messages: Vec, gas_limit: u64, fee_amount: String, private_key: &[u8]) -> Result { + 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 encoded: Vec> = messages.iter().map(|m| m.encode_as_any(chain)).collect::, _>>()?; + let body_bytes = CosmosTxParams::encode_tx_body(&encoded, input.memo.as_deref().unwrap_or("")); + + let params = CosmosTxParams { + body_bytes, + chain_id: &chain_id, + account_number, + sequence, + fee_coins: Self::fee_coins(chain, fee_amount), + gas_limit, + pubkey_type: Self::pubkey_type(chain), + }; + + Self::encode_and_sign_tx(chain, ¶ms, private_key) + } + pub fn encode_and_sign_tx(chain: CosmosChain, params: &CosmosTxParams, private_key: &[u8]) -> Result { - let pubkey_bytes = signer::secp256k1_public_key(private_key)?; + let pubkey_bytes = Self::public_key(chain, 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); @@ -92,3 +155,157 @@ impl CosmosChainSigner { .to_string()) } } + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use num_bigint::BigInt; + use primitives::{Asset, Chain, Delegation, DelegationValidator, GasPriceType, RedelegateData, StakeType, TransactionFee, TransactionInputType, TransactionLoadInput, TransactionLoadMetadata}; + use serde_json::Value; + + use super::*; + + // Derived from "seminar cruel gown pause law tortoise step stairs size amused pond weapon" via m/44'/118'/0'/0/0. + const OSMO_PRIVATE_KEY_HEX: &str = "325f5eba4c6466ca5a88638c74db5b396edb624efced0924a10aeb897525923c"; + const OSMO_VALIDATOR: &str = "osmovaloper1pxphtfhqnx9ny27d53z4052e3r76e7qq495ehm"; + const OSMO_VALIDATOR_DST: &str = "osmovaloper1z0sh4s80u99l6y9d3vfy582p8jejeeu6tcucs2"; + const OSMO_STAKE_MEMO: &str = "Stake via Gem Wallet"; + + fn signed_tx_bytes(signed: &str) -> String { + let value: Value = serde_json::from_str(signed).unwrap(); + assert_eq!(value["mode"], "BROADCAST_MODE_SYNC"); + value["tx_bytes"].as_str().unwrap().to_string() + } + + #[test] + fn test_sign_thorchain_transfer() { + // Source: https://github.com/trustwallet/wallet-core/blob/4.3.22/swift/Tests/Blockchains/THORChainTests.swift + let private_key = hex::decode("7105512f0c020a1dd759e14b865ec0125f59ac31e34d7a2807a228ed50cb343e").unwrap(); + let fee_amount = BigInt::from(200u64); + let input = SignerInput::new( + TransactionLoadInput { + input_type: TransactionInputType::Transfer(Asset::from_chain(Chain::Thorchain)), + sender_address: "thor1z53wwe7md6cewz9sqwqzn0aavpaun0gw0exn2r".to_string(), + destination_address: "thor1e2ryt8asq4gu0h6z2sx9u7rfrykgxwkmr9upxn".to_string(), + value: "38000000".to_string(), + gas_price: GasPriceType::regular(fee_amount.clone()), + memo: None, + is_max_value: false, + metadata: TransactionLoadMetadata::Cosmos { + account_number: 593, + sequence: 21, + chain_id: "thorchain-mainnet-v1".to_string(), + }, + }, + TransactionFee::new_gas_price_type(GasPriceType::regular(fee_amount.clone()), fee_amount, BigInt::from(2_500_000u64), HashMap::new()), + ); + + let signed = CosmosChainSigner.sign_transfer(&input, &private_key).unwrap(); + assert_eq!( + signed_tx_bytes(&signed), + "ClIKUAoOL3R5cGVzLk1zZ1NlbmQSPgoUFSLnZ9tusZcIsAOAKb+9YHvJvQ4SFMqGRZ+wBVHH30JUDF54aRksgzrbGhAKBHJ1bmUSCDM4MDAwMDAwElkKUApGCh8vY29zbW9zLmNyeXB0by5zZWNwMjU2azEuUHViS2V5EiMKIQPtmX45bPQpL1/OWkK7pBWZzNXZbjExVKfJ6nBJ3jF8dxIECgIIARgVEgUQoMuYARpAj4gtkfIP83fI0HHaCa95deqwo280CoLDVHJ6BkSGADxQaYoBWJW/NwaMU05d34AkUgesUjJHk1238cG9Am+J0g==" + ); + } + + #[test] + fn test_sign_injective_transfer_matches_expected_tx_bytes() { + // Source: https://github.com/trustwallet/wallet-core/blob/4.3.22/tests/chains/Cosmos/NativeInjective/SignerTests.cpp + let private_key = hex::decode("9ee18daf8e463877aaf497282abc216852420101430482a28e246c179e2c5ef1").unwrap(); + let fee_amount = BigInt::from(100_000_000_000_000u64); + let input = SignerInput::new( + TransactionLoadInput { + input_type: TransactionInputType::Transfer(Asset::from_chain(Chain::Injective)), + sender_address: "inj13u6g7vqgw074mgmf2ze2cadzvkz9snlwcrtq8a".to_string(), + destination_address: "inj1xmpkmxr4as00em23tc2zgmuyy2gr4h3wgcl6vd".to_string(), + value: "10000000000".to_string(), + gas_price: GasPriceType::regular(fee_amount.clone()), + memo: None, + is_max_value: false, + metadata: TransactionLoadMetadata::Cosmos { + account_number: 17396, + sequence: 1, + chain_id: "injective-1".to_string(), + }, + }, + TransactionFee::new_gas_price_type(GasPriceType::regular(fee_amount.clone()), fee_amount, BigInt::from(110_000u64), HashMap::new()), + ); + + let signed = CosmosChainSigner.sign_transfer(&input, &private_key).unwrap(); + assert_eq!( + signed_tx_bytes(&signed), + "Co8BCowBChwvY29zbW9zLmJhbmsudjFiZXRhMS5Nc2dTZW5kEmwKKmluajEzdTZnN3ZxZ3cwNzRtZ21mMnplMmNhZHp2a3o5c25sd2NydHE4YRIqaW5qMXhtcGtteHI0YXMwMGVtMjN0YzJ6Z211eXkyZ3I0aDN3Z2NsNnZkGhIKA2luahILMTAwMDAwMDAwMDASngEKfgp0Ci0vaW5qZWN0aXZlLmNyeXB0by52MWJldGExLmV0aHNlY3AyNTZrMS5QdWJLZXkSQwpBBFoMa4O4vZgn5QcnDK20mbfjqQlSRvaiITKB94PYd8mLJWdCdBsGOfMXdo/k9MJ2JmDCESKDp2hdgVUH3uMikXMSBAoCCAEYARIcChYKA2luahIPMTAwMDAwMDAwMDAwMDAwELDbBhpAx2vkplmzeK7n3puCFGPWhLd0l/ZC/CYkGl+stH+3S3hiCvIe7uwwMpUlNaSwvT8HwF1kNUp+Sx2m0Uo1x5xcFw==" + ); + } + + #[test] + fn test_sign_osmosis_messages() { + let private_key = hex::decode(OSMO_PRIVATE_KEY_HEX).unwrap(); + let signer = CosmosChainSigner; + + let transfer = SignerInput::mock_osmosis( + TransactionInputType::Transfer(Asset::from_chain(Chain::Osmosis)), + "osmo1rcjvzz8wzktqfz8qjf0l9q45kzxvd0z0n7l5cf", + None, + ); + assert_eq!( + signed_tx_bytes(&signer.sign_transfer(&transfer, &private_key).unwrap()), + "CooBCocBChwvY29zbW9zLmJhbmsudjFiZXRhMS5Nc2dTZW5kEmcKK29zbW8xa2dsZW11bXU4bW42NThqNmc0ejlqem4zemVmMnFkeXl2a2x3YTMSK29zbW8xcmNqdnp6OHd6a3RxZno4cWpmMGw5cTQ1a3p4dmQwejBuN2w1Y2YaCwoFdW9zbW8SAjEwEmgKUApGCh8vY29zbW9zLmNyeXB0by5zZWNwMjU2azEuUHViS2V5EiMKIQMslcYn7DhPe5b/8lM3FnPXhGBj5SdC15+XI1hZ1gYbBBIECgIIARgKEhQKDgoFdW9zbW8SBTEwMDAwEMCaDBpAVJkDxaS5ZaghmJ6ZtpC9yim7JA8duO8MwOODdJeHEHssH3PQN+4Yl+SVyLtNEW6+IDUKfkG1dfIYOvpRiFlOyg==" + ); + + let stake = SignerInput::mock_osmosis( + TransactionInputType::Stake(Asset::from_chain(Chain::Osmosis), StakeType::Stake(DelegationValidator::mock_osmosis(OSMO_VALIDATOR))), + "", + Some(OSMO_STAKE_MEMO), + ); + let signed = signer.sign_stake(&stake, &private_key).unwrap(); + assert_eq!(signed.len(), 1); + assert_eq!( + signed_tx_bytes(&signed[0]), + "Cq4BCpUBCiMvY29zbW9zLnN0YWtpbmcudjFiZXRhMS5Nc2dEZWxlZ2F0ZRJuCitvc21vMWtnbGVtdW11OG1uNjU4ajZnNHo5anpuM3plZjJxZHl5dmtsd2EzEjJvc21vdmFsb3BlcjFweHBodGZocW54OW55MjdkNTN6NDA1MmUzcjc2ZTdxcTQ5NWVobRoLCgV1b3NtbxICMTASFFN0YWtlIHZpYSBHZW0gV2FsbGV0EmgKUApGCh8vY29zbW9zLmNyeXB0by5zZWNwMjU2azEuUHViS2V5EiMKIQMslcYn7DhPe5b/8lM3FnPXhGBj5SdC15+XI1hZ1gYbBBIECgIIARgKEhQKDgoFdW9zbW8SBTEwMDAwEMCaDBpAxh9uwNZvql2fODCEAp4XhucO1cxXYrz2oMEkat+wvJEP1VDlai4ZnLz+n9mRbgjF143EfsaonoEh36uQKYOWuQ==" + ); + + let undelegate = SignerInput::mock_osmosis( + TransactionInputType::Stake(Asset::from_chain(Chain::Osmosis), StakeType::Unstake(Delegation::mock_osmosis(OSMO_VALIDATOR))), + "", + Some(OSMO_STAKE_MEMO), + ); + // Auto-claims pending rewards before unstake. + let signed = signer.sign_stake(&undelegate, &private_key).unwrap(); + assert_eq!( + signed_tx_bytes(&signed[0]), + "Cs8CCpwBCjcvY29zbW9zLmRpc3RyaWJ1dGlvbi52MWJldGExLk1zZ1dpdGhkcmF3RGVsZWdhdG9yUmV3YXJkEmEKK29zbW8xa2dsZW11bXU4bW42NThqNmc0ejlqem4zemVmMnFkeXl2a2x3YTMSMm9zbW92YWxvcGVyMXB4cGh0Zmhxbng5bnkyN2Q1M3o0MDUyZTNyNzZlN3FxNDk1ZWhtCpcBCiUvY29zbW9zLnN0YWtpbmcudjFiZXRhMS5Nc2dVbmRlbGVnYXRlEm4KK29zbW8xa2dsZW11bXU4bW42NThqNmc0ejlqem4zemVmMnFkeXl2a2x3YTMSMm9zbW92YWxvcGVyMXB4cGh0Zmhxbng5bnkyN2Q1M3o0MDUyZTNyNzZlN3FxNDk1ZWhtGgsKBXVvc21vEgIxMBIUU3Rha2UgdmlhIEdlbSBXYWxsZXQSaApQCkYKHy9jb3Ntb3MuY3J5cHRvLnNlY3AyNTZrMS5QdWJLZXkSIwohAyyVxifsOE97lv/yUzcWc9eEYGPlJ0LXn5cjWFnWBhsEEgQKAggBGAoSFAoOCgV1b3NtbxIFMTAwMDAQgLUYGkCA133uwfd5FIq0KwZtG+gduTmeUmvgZ4dFmxLb23a37zBIOAx26XVJQ9PNDD2tFlODaVLjnN+a2saa4KOXz/wG" + ); + + let redelegate = SignerInput::mock_osmosis( + TransactionInputType::Stake( + Asset::from_chain(Chain::Osmosis), + StakeType::Redelegate(RedelegateData { + delegation: Delegation::mock_osmosis(OSMO_VALIDATOR), + to_validator: DelegationValidator::mock_osmosis(OSMO_VALIDATOR_DST), + }), + ), + "", + Some(OSMO_STAKE_MEMO), + ); + let signed = signer.sign_stake(&redelegate, &private_key).unwrap(); + assert_eq!( + signed_tx_bytes(&signed[0]), + "CokDCpwBCjcvY29zbW9zLmRpc3RyaWJ1dGlvbi52MWJldGExLk1zZ1dpdGhkcmF3RGVsZWdhdG9yUmV3YXJkEmEKK29zbW8xa2dsZW11bXU4bW42NThqNmc0ejlqem4zemVmMnFkeXl2a2x3YTMSMm9zbW92YWxvcGVyMXB4cGh0Zmhxbng5bnkyN2Q1M3o0MDUyZTNyNzZlN3FxNDk1ZWhtCtEBCiovY29zbW9zLnN0YWtpbmcudjFiZXRhMS5Nc2dCZWdpblJlZGVsZWdhdGUSogEKK29zbW8xa2dsZW11bXU4bW42NThqNmc0ejlqem4zemVmMnFkeXl2a2x3YTMSMm9zbW92YWxvcGVyMXB4cGh0Zmhxbng5bnkyN2Q1M3o0MDUyZTNyNzZlN3FxNDk1ZWhtGjJvc21vdmFsb3BlcjF6MHNoNHM4MHU5OWw2eTlkM3ZmeTU4MnA4amVqZWV1NnRjdWNzMiILCgV1b3NtbxICMTASFFN0YWtlIHZpYSBHZW0gV2FsbGV0EmgKUApGCh8vY29zbW9zLmNyeXB0by5zZWNwMjU2azEuUHViS2V5EiMKIQMslcYn7DhPe5b/8lM3FnPXhGBj5SdC15+XI1hZ1gYbBBIECgIIARgKEhQKDgoFdW9zbW8SBTEwMDAwEIC1GBpAPgfCbDv4AFbBsGokEl26JCKuyt7R0PN2/jHsBnva4dQqd7kxKIIwGq2yDmwserV4/2B1I51W2JHL0m8/ZOYT7g==" + ); + + let rewards = SignerInput::mock_osmosis( + TransactionInputType::Stake( + Asset::from_chain(Chain::Osmosis), + StakeType::Rewards(vec![DelegationValidator::mock_osmosis(OSMO_VALIDATOR), DelegationValidator::mock_osmosis(OSMO_VALIDATOR)]), + ), + "", + Some(OSMO_STAKE_MEMO), + ); + let signed = signer.sign_stake(&rewards, &private_key).unwrap(); + assert_eq!( + signed_tx_bytes(&signed[0]), + "CtQCCpwBCjcvY29zbW9zLmRpc3RyaWJ1dGlvbi52MWJldGExLk1zZ1dpdGhkcmF3RGVsZWdhdG9yUmV3YXJkEmEKK29zbW8xa2dsZW11bXU4bW42NThqNmc0ejlqem4zemVmMnFkeXl2a2x3YTMSMm9zbW92YWxvcGVyMXB4cGh0Zmhxbng5bnkyN2Q1M3o0MDUyZTNyNzZlN3FxNDk1ZWhtCpwBCjcvY29zbW9zLmRpc3RyaWJ1dGlvbi52MWJldGExLk1zZ1dpdGhkcmF3RGVsZWdhdG9yUmV3YXJkEmEKK29zbW8xa2dsZW11bXU4bW42NThqNmc0ejlqem4zemVmMnFkeXl2a2x3YTMSMm9zbW92YWxvcGVyMXB4cGh0Zmhxbng5bnkyN2Q1M3o0MDUyZTNyNzZlN3FxNDk1ZWhtEhRTdGFrZSB2aWEgR2VtIFdhbGxldBJoClAKRgofL2Nvc21vcy5jcnlwdG8uc2VjcDI1NmsxLlB1YktleRIjCiEDLJXGJ+w4T3uW//JTNxZz14RgY+UnQteflyNYWdYGGwQSBAoCCAEYChIUCg4KBXVvc21vEgUxMDAwMBCAtRgaQH/U90uCH0zx9AdY+ALIHM5aZ1crBSwYzeZZejb5rWjEMVXRScjOfvng33XFnFHdI4Epp9ykNNtQVUw9BJnZshU=" + ); + } +} diff --git a/crates/gem_cosmos/src/signer/mod.rs b/crates/gem_cosmos/src/signer/mod.rs index 64f449640c..febba1dc99 100644 --- a/crates/gem_cosmos/src/signer/mod.rs +++ b/crates/gem_cosmos/src/signer/mod.rs @@ -1,5 +1,4 @@ mod chain_signer; -mod protobuf; 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 0985af0cfe..20f3a3bf7a 100644 --- a/crates/gem_cosmos/src/signer/transaction.rs +++ b/crates/gem_cosmos/src/signer/transaction.rs @@ -1,14 +1,94 @@ -use crate::models::{Coin, CosmosMessage}; - -use crate::constants::{MESSAGE_EXECUTE_CONTRACT, MESSAGE_IBC_TRANSFER}; +use gem_encoding::protobuf::*; +use primitives::{Address, DelegationValidator, SignerError, SignerInput, StakeType, chain_cosmos::CosmosChain}; -use super::protobuf::*; +use crate::address::CosmosAddress; +use crate::constants::{ + MESSAGE_DELEGATE, MESSAGE_EXECUTE_CONTRACT, MESSAGE_IBC_TRANSFER, MESSAGE_REDELEGATE, MESSAGE_REWARD_BETA, MESSAGE_SEND, MESSAGE_SEND_BETA, MESSAGE_UNDELEGATE, +}; +use crate::models::{Coin, CosmosMessage}; -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 fn transfer_message(input: &SignerInput, denom: &str) -> CosmosMessage { + CosmosMessage::Send { + from_address: input.sender_address.clone(), + to_address: input.destination_address.clone(), + amount: vec![Coin { + denom: denom.to_string(), + amount: input.value.clone(), + }], + } +} + +pub fn stake_messages(input: &SignerInput, chain: CosmosChain) -> Result, SignerError> { + let stake_type = input.input_type.get_stake_type().map_err(SignerError::invalid_input)?; + let delegator_address = &input.sender_address; + let amount = Coin { + denom: chain.denom().as_ref().to_string(), + amount: input.value.clone(), + }; + + match stake_type { + StakeType::Stake(validator) => Ok(vec![CosmosMessage::Delegate { + delegator_address: delegator_address.clone(), + validator_address: validator.id.clone(), + amount, + }]), + StakeType::Unstake(delegation) => { + let mut messages = reward_messages(delegator_address, std::slice::from_ref(&delegation.validator)); + messages.push(CosmosMessage::Undelegate { + delegator_address: delegator_address.clone(), + validator_address: delegation.validator.id.clone(), + amount, + }); + Ok(messages) + } + StakeType::Redelegate(data) => { + let mut messages = reward_messages(delegator_address, std::slice::from_ref(&data.delegation.validator)); + messages.push(CosmosMessage::BeginRedelegate { + delegator_address: delegator_address.clone(), + validator_src_address: data.delegation.validator.id.clone(), + validator_dst_address: data.to_validator.id.clone(), + amount, + }); + Ok(messages) + } + StakeType::Rewards(validators) => Ok(reward_messages(delegator_address, validators)), + StakeType::Withdraw(_) => SignerError::invalid_input_err("Cosmos withdraw operations are not supported"), + StakeType::Freeze(_) | StakeType::Unfreeze(_) => SignerError::invalid_input_err("Cosmos freeze operations are not supported"), + } +} + +fn encode_send(chain: CosmosChain, from_address: &str, to_address: &str, amount: &[Coin]) -> Result, SignerError> { + let coin_fields: Vec = amount.iter().flat_map(|c| encode_message_field(3, &encode_coin(&c.denom, &c.amount))).collect(); + let address_fields = match chain { + CosmosChain::Thorchain => { + let parse = |addr: &str| CosmosAddress::try_parse(addr).ok_or_else(|| SignerError::invalid_input(format!("invalid cosmos address: {addr}"))); + [encode_bytes_field(1, parse(from_address)?.as_bytes()), encode_bytes_field(2, parse(to_address)?.as_bytes())].concat() + } + CosmosChain::Cosmos | CosmosChain::Osmosis | CosmosChain::Celestia | CosmosChain::Injective | CosmosChain::Sei | CosmosChain::Noble => { + [encode_string_field(1, from_address), encode_string_field(2, to_address)].concat() + } + }; + Ok([address_fields, coin_fields].concat()) +} + +fn encode_coin(denom: &str, amount: &str) -> Vec { + [encode_string_field(1, denom), encode_string_field(2, amount)].concat() +} + +fn reward_messages(delegator_address: &str, validators: &[DelegationValidator]) -> Vec { + validators + .iter() + .map(|validator| CosmosMessage::WithdrawDelegatorReward { + delegator_address: delegator_address.to_string(), + validator_address: validator.id.clone(), + }) + .collect() +} + pub struct CosmosTxParams<'a> { pub body_bytes: Vec, pub chain_id: &'a str, @@ -71,23 +151,27 @@ impl CosmosTxParams<'_> { } impl CosmosMessage { - fn type_url(&self) -> &str { + fn type_url(&self, chain: CosmosChain) -> &str { match self { - Self::Send { .. } => MESSAGE_SEND_TYPE_URL, + Self::Send { .. } => match chain { + CosmosChain::Thorchain => MESSAGE_SEND, + CosmosChain::Cosmos | CosmosChain::Osmosis | CosmosChain::Celestia | CosmosChain::Injective | CosmosChain::Sei | CosmosChain::Noble => MESSAGE_SEND_BETA, + }, Self::ExecuteContract { .. } => MESSAGE_EXECUTE_CONTRACT, Self::IbcTransfer { .. } => MESSAGE_IBC_TRANSFER, + Self::Delegate { .. } => MESSAGE_DELEGATE, + Self::Undelegate { .. } => MESSAGE_UNDELEGATE, + Self::BeginRedelegate { .. } => MESSAGE_REDELEGATE, + Self::WithdrawDelegatorReward { .. } => MESSAGE_REWARD_BETA, } } - fn encode_value(&self) -> Vec { + fn encode_value(&self, chain: CosmosChain) -> Result, SignerError> { 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::Send { from_address, to_address, amount } => encode_send(chain, from_address, to_address, amount), 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() + Ok([encode_string_field(1, sender), encode_string_field(2, contract), encode_bytes_field(3, msg), fund_fields].concat()) } Self::IbcTransfer { source_port, @@ -97,7 +181,7 @@ impl CosmosMessage { receiver, timeout_timestamp, memo, - } => [ + } => Ok([ encode_string_field(1, source_port), encode_string_field(2, source_channel), encode_message_field(3, &encode_coin(&token.denom, &token.amount)), @@ -106,12 +190,43 @@ impl CosmosMessage { encode_varint_field(7, *timeout_timestamp), encode_string_field(8, memo), ] - .concat(), + .concat()), + Self::Delegate { + delegator_address, + validator_address, + amount, + } + | Self::Undelegate { + delegator_address, + validator_address, + amount, + } => Ok([ + encode_string_field(1, delegator_address), + encode_string_field(2, validator_address), + encode_message_field(3, &encode_coin(&amount.denom, &amount.amount)), + ] + .concat()), + Self::BeginRedelegate { + delegator_address, + validator_src_address, + validator_dst_address, + amount, + } => Ok([ + encode_string_field(1, delegator_address), + encode_string_field(2, validator_src_address), + encode_string_field(3, validator_dst_address), + encode_message_field(4, &encode_coin(&amount.denom, &amount.amount)), + ] + .concat()), + Self::WithdrawDelegatorReward { + delegator_address, + validator_address, + } => Ok([encode_string_field(1, delegator_address), encode_string_field(2, validator_address)].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_as_any(&self, chain: CosmosChain) -> Result, SignerError> { + Ok([encode_string_field(1, self.type_url(chain)), encode_bytes_field(2, &self.encode_value(chain)?)].concat()) } } @@ -131,7 +246,7 @@ mod tests { }], }; assert_eq!( - hex::encode(msg.encode_as_any()), + hex::encode(msg.encode_as_any(CosmosChain::Osmosis).unwrap()), "0a242f636f736d7761736d2e7761736d2e76312e4d736745786563757465436f6e747261637412390a096f736d6f3174657374120d6f736d6f31636f6e74726163741a0b7b2273776170223a7b7d7d2a100a05756f736d6f120731303030303030" ); } @@ -151,7 +266,7 @@ mod tests { memo: "{\"ibc_callback\":\"osmo1contract\"}".to_string(), }; assert_eq!( - hex::encode(msg.encode_as_any()), + hex::encode(msg.encode_as_any(CosmosChain::Cosmos).unwrap()), "0a292f6962632e6170706c69636174696f6e732e7472616e736665722e76312e4d73675472616e73666572126b0a087472616e7366657212096368616e6e656c2d301a100a057561746f6d120731303030303030220b636f736d6f7331746573742a096f736d6f317465737438c0aaffdfb4c694ce1842207b226962635f63616c6c6261636b223a226f736d6f31636f6e7472616374227d" ); } diff --git a/crates/gem_encoding/src/lib.rs b/crates/gem_encoding/src/lib.rs index 4f9c8c99d9..6c4da05adc 100644 --- a/crates/gem_encoding/src/lib.rs +++ b/crates/gem_encoding/src/lib.rs @@ -1,4 +1,5 @@ mod error; +pub mod protobuf; #[cfg(feature = "base32")] mod base32; diff --git a/crates/gem_cosmos/src/signer/protobuf.rs b/crates/gem_encoding/src/protobuf.rs similarity index 73% rename from crates/gem_cosmos/src/signer/protobuf.rs rename to crates/gem_encoding/src/protobuf.rs index 3379d43769..575d535a22 100644 --- a/crates/gem_cosmos/src/signer/protobuf.rs +++ b/crates/gem_encoding/src/protobuf.rs @@ -1,11 +1,13 @@ 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; + let mut value = value; + + while value >= 0x80 { + buf.push((value as u8) | 0x80); + value >>= 7; } - buf.push(v as u8); + + buf.push(value as u8); buf } @@ -17,6 +19,7 @@ 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() } @@ -24,22 +27,20 @@ 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_string_field(field_number: u32, value: &str) -> Vec { + encode_bytes_field(field_number, value.as_bytes()) } -pub fn encode_message_field(field_number: u32, msg: &[u8]) -> Vec { - if msg.is_empty() { +pub fn encode_message_field(field_number: u32, message: &[u8]) -> Vec { + if message.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() + encode_bytes_field(field_number, message) } #[cfg(test)] @@ -58,6 +59,7 @@ mod tests { #[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']); } diff --git a/crates/primitives/src/explorers/mintscan.rs b/crates/primitives/src/explorers/mintscan.rs index 2fd99dd978..7907159e0f 100644 --- a/crates/primitives/src/explorers/mintscan.rs +++ b/crates/primitives/src/explorers/mintscan.rs @@ -7,7 +7,7 @@ static MINTSCAN_FACTORY: LazyLock = LazyLock::new(|| { .add_chain("cosmos", Metadata::mintscan("Mintscan", "https://www.mintscan.io/cosmos")) .add_chain("osmosis", Metadata::mintscan("Mintscan", "https://www.mintscan.io/osmosis")) .add_chain("celestia", Metadata::mintscan("Mintscan", "https://www.mintscan.io/celestia")) - .add_chain("injective", Metadata::mintscan("Mintscan", "https://www.mintscan.io/injective-protocol")) + .add_chain("injective", Metadata::mintscan("Mintscan", "https://www.mintscan.io/injective")) .add_chain("sei", Metadata::mintscan("Mintscan", "https://www.mintscan.io/sei")) .add_chain("noble", Metadata::mintscan("Mintscan", "https://www.mintscan.io/noble")) }); @@ -49,6 +49,15 @@ mod tests { assert_eq!(explorer.get_validator_url("val123"), Some("https://www.mintscan.io/cosmos/validators/val123".to_string())); } + #[test] + fn test_mintscan_injective() { + let explorer = new_injective(); + assert_eq!(explorer.name(), "Mintscan"); + assert_eq!(explorer.get_tx_url("abc123"), "https://www.mintscan.io/injective/tx/abc123"); + assert_eq!(explorer.get_address_url("addr123"), "https://www.mintscan.io/injective/address/addr123"); + assert_eq!(explorer.get_validator_url("val123"), Some("https://www.mintscan.io/injective/validators/val123".to_string())); + } + #[test] fn test_mintscan_osmosis() { let explorer = new_osmosis(); diff --git a/crates/primitives/src/testkit/delegation_mock.rs b/crates/primitives/src/testkit/delegation_mock.rs index 7db80135f4..e60eb7b517 100644 --- a/crates/primitives/src/testkit/delegation_mock.rs +++ b/crates/primitives/src/testkit/delegation_mock.rs @@ -36,6 +36,23 @@ impl Delegation { } } + pub fn mock_osmosis(validator_id: &str) -> Self { + Delegation { + base: DelegationBase { + asset_id: AssetId::from_chain(Chain::Osmosis), + state: DelegationState::Active, + balance: BigUint::from(10u32), + shares: BigUint::from(0u32), + rewards: BigUint::from(0u32), + completion_date: None, + delegation_id: "25053096".to_string(), + validator_id: validator_id.to_string(), + }, + validator: DelegationValidator::mock_osmosis(validator_id), + price: None, + } + } + pub fn mock_with_id(delegation_id: String) -> Self { Delegation::mock_base(DelegationBase::mock_with_id(delegation_id)) } @@ -73,4 +90,8 @@ impl DelegationValidator { pub fn mock() -> Self { DelegationValidator::stake(Chain::Sui, "validator1".to_string(), "Test Validator".to_string(), true, 0.05, 0.08) } + + pub fn mock_osmosis(id: &str) -> Self { + DelegationValidator::stake(Chain::Osmosis, id.to_string(), String::new(), true, 1.0, 9.0) + } } diff --git a/crates/primitives/src/testkit/signer_mock.rs b/crates/primitives/src/testkit/signer_mock.rs index b34089d4ba..54d349a7a7 100644 --- a/crates/primitives/src/testkit/signer_mock.rs +++ b/crates/primitives/src/testkit/signer_mock.rs @@ -1,3 +1,4 @@ pub const TEST_PRIVATE_KEY: [u8; 32] = [1u8; 32]; pub const TEST_EVM_SENDER: &str = "0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf"; pub const TEST_EVM_RECIPIENT: &str = "0x2B5AD5c4795c026514f8317c7a215E218DcCD6cF"; +pub const TEST_OSMOSIS_SENDER: &str = "osmo1kglemumu8mn658j6g4z9jzn3zef2qdyyvklwa3"; diff --git a/crates/primitives/src/testkit/transaction_load_input_mock.rs b/crates/primitives/src/testkit/transaction_load_input_mock.rs index b3386118c5..e38cc15f62 100644 --- a/crates/primitives/src/testkit/transaction_load_input_mock.rs +++ b/crates/primitives/src/testkit/transaction_load_input_mock.rs @@ -1,4 +1,5 @@ -use super::signer_mock::{TEST_EVM_RECIPIENT, TEST_EVM_SENDER}; +use super::signer_mock::{TEST_EVM_RECIPIENT, TEST_EVM_SENDER, TEST_OSMOSIS_SENDER}; +use std::collections::HashMap; use crate::{ Asset, Chain, GasPriceType, SignerInput, TransactionFee, TransactionInputType, TransactionLoadInput, TransactionLoadMetadata, TransferDataExtra, TransferDataOutputAction, TransferDataOutputType, WalletConnectionSessionAppMetadata, @@ -98,6 +99,23 @@ impl SignerInput { ) } + pub fn mock_osmosis(input_type: TransactionInputType, destination: &str, memo: Option<&str>) -> Self { + let fee_amount = BigInt::from(10_000u64); + SignerInput::new( + TransactionLoadInput { + input_type, + sender_address: TEST_OSMOSIS_SENDER.to_string(), + destination_address: destination.to_string(), + value: "10".to_string(), + gas_price: GasPriceType::regular(fee_amount.clone()), + memo: memo.map(str::to_string), + is_max_value: false, + metadata: TransactionLoadMetadata::mock_osmosis(), + }, + TransactionFee::new_gas_price_type(GasPriceType::regular(fee_amount.clone()), fee_amount, BigInt::from(200_000u64), HashMap::new()), + ) + } + pub fn mock_ton(input_type: TransactionInputType, metadata: TransactionLoadMetadata) -> Self { SignerInput::new( TransactionLoadInput { diff --git a/crates/primitives/src/testkit/transaction_load_metadata_mock.rs b/crates/primitives/src/testkit/transaction_load_metadata_mock.rs index 1883a5c797..12b6cd49b2 100644 --- a/crates/primitives/src/testkit/transaction_load_metadata_mock.rs +++ b/crates/primitives/src/testkit/transaction_load_metadata_mock.rs @@ -5,6 +5,14 @@ impl TransactionLoadMetadata { TransactionLoadMetadata::Aptos { sequence: 0, data: None } } + pub fn mock_osmosis() -> Self { + TransactionLoadMetadata::Cosmos { + account_number: 2_913_388, + sequence: 10, + chain_id: "osmosis-1".to_string(), + } + } + pub fn mock_evm(nonce: u64, chain_id: u64) -> Self { TransactionLoadMetadata::Evm { nonce,