diff --git a/Cargo.lock b/Cargo.lock index e4c612d..557de35 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2272,7 +2272,7 @@ dependencies = [ "once_cell", "ring", "rustls-pki-types", - "rustls-webpki 0.103.9", + "rustls-webpki 0.103.10", "subtle", "zeroize", ] @@ -2320,9 +2320,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.9" +version = "0.103.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" dependencies = [ "ring", "rustls-pki-types", diff --git a/README.md b/README.md index e181aad..d2a2df0 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,11 @@ This SDK covers the full action lifecycle used by apps and services: 1. **Chain client (`chain`)** - action params + fee lookup + - account info lookup - action registration transaction flow + - generic tx build/sign/broadcast utilities + - tx gas simulation helper + adjusted gas signing flow + - tx confirmation lookup/wait helpers 2. **sn-api client (`snapi`)** - start upload diff --git a/docs/chain-feasibility-gap-analysis.md b/docs/chain-feasibility-gap-analysis.md new file mode 100644 index 0000000..a42b402 --- /dev/null +++ b/docs/chain-feasibility-gap-analysis.md @@ -0,0 +1,47 @@ +# sdk-rs chain expansion feasibility (vs sdk-go) + +## Scope requested +1) Safe/unified key handling for tx signing + message signing. +2) Expand beyond Cascade into stronger chain interactions (account info, fees, tx sending, tx confirmation). + +## Feasibility verdict +**Feasible now with current Rust stack** (`cosmrs`, `tendermint-rpc`, `reqwest`). +No blocker found for parity on the requested baseline. + +## What exists in sdk-rs today +- Action params and action-fee query (REST). +- Register-action tx flow with signing and sequence retry. +- sn-api upload/download orchestration. + +## Gaps relative to sdk-go baseline +- No dedicated identity/key module (key derivation duplicated in examples). +- No explicit signer/address mismatch guard in tx path. +- Fee handling in tx path was static/fixed (not parsed from configured gas price). +- No generic tx lookup + wait-for-confirmation utility. +- No explicit account-info public API. + +## What was implemented in this step +- Added `src/keys.rs`: + - `SigningIdentity::from_mnemonic(...)` (single source for tx + arbitrary signing keys). + - `validate_address(...)` and `validate_chain_prefix(...)`. + - `derive_signing_keys_from_mnemonic(...)` helper for existing flows. +- Updated `src/chain.rs`: + - Added signer-vs-creator precheck (`validate_signer_matches_creator`). + - Added `get_account_info(address)`. + - Added `calculate_fee_amount(gas_limit)` from configured gas price. + - Replaced fixed tx fee with gas-price-derived fee. + - Added `get_tx(tx_hash)` and `wait_for_tx_confirmation(tx_hash, timeout_secs)`. + - Added generic tx path: `build_signed_tx(...)`, `broadcast_signed_tx(...)`, `send_any_msgs(...)`. + - Added gas simulation + adjusted signing flow: `simulate_gas_for_tx(...)`, `build_signed_tx_with_simulation(...)`. + - Added common Lumera wrapper: `request_action_tx(...)`. + - Added broadcast mode support: `Async`, `Sync`, `Commit`. +- Refactored examples (`golden_devnet`, `ui_server`) to use centralized key derivation helper. + +## Next parity steps (recommended) +1. Add richer tx result/event extraction helpers for action/general events. +2. Add integration tests for signer/address mismatch and tx wait timeout behavior. +3. Add convenience wrappers for more common Lumera msgs on top of generic tx path. + +## Validation done +- `cargo check`: pass +- `cargo test --lib`: pass (13 tests) diff --git a/examples/golden_devnet.rs b/examples/golden_devnet.rs index 5911d2e..efe32c0 100644 --- a/examples/golden_devnet.rs +++ b/examples/golden_devnet.rs @@ -1,27 +1,11 @@ use std::{ path::PathBuf, - str::FromStr, time::{Duration, SystemTime, UNIX_EPOCH}, }; -use bip32::{DerivationPath, XPrv}; -use bip39::Mnemonic; -use k256::ecdsa::SigningKey as K256SigningKey; -use lumera_sdk_rs::{CascadeConfig, CascadeSdk, RegisterTicketRequest}; - -fn derive_signing_keys( - mnemonic: &str, -) -> anyhow::Result<(cosmrs::crypto::secp256k1::SigningKey, K256SigningKey)> { - let m = Mnemonic::parse(mnemonic)?; - let seed = m.to_seed(""); - let path = DerivationPath::from_str("m/44'/118'/0'/0/0")?; - let xprv = XPrv::derive_from_path(seed, &path)?; - let sk_bytes = xprv.private_key().to_bytes(); - let chain_sk = cosmrs::crypto::secp256k1::SigningKey::from_slice(&sk_bytes) - .map_err(|e| anyhow::anyhow!("cosmrs signing key: {e}"))?; - let arb_sk = K256SigningKey::from_slice(&sk_bytes)?; - Ok((chain_sk, arb_sk)) -} +use lumera_sdk_rs::{ + keys::derive_signing_keys_from_mnemonic, CascadeConfig, CascadeSdk, RegisterTicketRequest, +}; fn extract_state(v: &serde_json::Value) -> String { for key in ["status", "state", "task_status"] { @@ -69,7 +53,8 @@ async fn main() -> anyhow::Result<()> { }; let sdk = CascadeSdk::new(cfg); - let (chain_sk, arb_sk) = derive_signing_keys(&mnemonic)?; + let (chain_sk, arb_sk) = + derive_signing_keys_from_mnemonic(&mnemonic).map_err(|e| anyhow::anyhow!(e.to_string()))?; let exp_secs = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() + 172800; let expiration_time = exp_secs.to_string(); diff --git a/examples/ui_server.rs b/examples/ui_server.rs index 5b1543b..4e23f01 100644 --- a/examples/ui_server.rs +++ b/examples/ui_server.rs @@ -1,7 +1,6 @@ use std::{ collections::HashMap, net::SocketAddr, - str::FromStr, sync::Arc, time::{SystemTime, UNIX_EPOCH}, }; @@ -14,8 +13,6 @@ use axum::{ Json, Router, }; use base64::{engine::general_purpose::STANDARD as B64, Engine as _}; -use bip32::{DerivationPath, XPrv}; -use bip39::Mnemonic; use k256::ecdsa::SigningKey as K256SigningKey; use lumera_sdk_rs::{CascadeConfig, CascadeSdk, RegisterTicketRequest}; use rand::{distributions::Alphanumeric, Rng}; @@ -132,25 +129,9 @@ fn random_token(len: usize) -> String { fn derive_signing_keys( mnemonic: &str, ) -> Result<(cosmrs::crypto::secp256k1::SigningKey, K256SigningKey), ApiError> { - let m = Mnemonic::parse(mnemonic).map_err(|e| ApiError { - error: format!("invalid LUMERA_MNEMONIC: {e}"), - })?; - let seed = m.to_seed(""); - let path = DerivationPath::from_str("m/44'/118'/0'/0/0").map_err(|e| ApiError { - error: format!("invalid derivation path: {e}"), - })?; - let xprv = XPrv::derive_from_path(seed, &path).map_err(|e| ApiError { - error: format!("failed deriving key from mnemonic: {e}"), - })?; - let sk_bytes = xprv.private_key().to_bytes(); - let chain_sk = - cosmrs::crypto::secp256k1::SigningKey::from_slice(&sk_bytes).map_err(|e| ApiError { - error: format!("failed creating chain signing key: {e}"), - })?; - let arb_sk = K256SigningKey::from_slice(&sk_bytes).map_err(|e| ApiError { - error: format!("failed creating arbitrary signing key: {e}"), - })?; - Ok((chain_sk, arb_sk)) + lumera_sdk_rs::keys::derive_signing_keys_from_mnemonic(mnemonic).map_err(|e| ApiError { + error: e.to_string(), + }) } fn extract_state(v: &serde_json::Value) -> String { diff --git a/src/chain.rs b/src/chain.rs index 8b48bb9..11a3a51 100644 --- a/src/chain.rs +++ b/src/chain.rs @@ -6,6 +6,8 @@ use cosmrs::{ }; use prost::Message; use serde::Deserialize; +use tendermint_rpc::Client; +use tokio::time::{sleep, Duration}; #[derive(Debug, Clone)] pub struct ChainConfig { @@ -73,6 +75,49 @@ pub struct TxResult { pub action_id: String, } +#[derive(Debug, Clone)] +pub struct AccountInfo { + pub address: String, + pub account_number: u64, + pub sequence: u64, +} + +#[derive(Debug, Clone)] +pub struct TxConfirmationStatus { + pub tx_hash: String, + pub height: i64, + pub code: u32, + pub raw_log: String, +} + +#[derive(Debug, Clone, Copy)] +pub enum BroadcastMode { + Async, + Sync, + Commit, +} + +#[derive(Debug, Clone)] +pub struct BroadcastTxResult { + pub tx_hash: String, + pub check_tx_code: Option, + pub deliver_tx_code: Option, + pub log: String, +} + +#[derive(Debug, Clone)] +pub struct RequestActionSubmitResult { + pub tx_hash: String, + pub action_id: String, +} + +#[derive(Debug, Clone)] +pub struct EventAttribute { + pub event_type: String, + pub key: String, + pub value: String, +} + #[derive(Debug, Clone)] pub struct RequestActionTxInput { pub creator: String, @@ -180,6 +225,7 @@ impl ChainClient { signing_key: &cosmrs::crypto::secp256k1::SigningKey, tx: RequestActionTxInput, ) -> Result { + self.validate_signer_matches_creator(signing_key, &tx.creator)?; let account = self.get_base_account(&tx.creator).await?; let msg = MsgRequestActionProto { @@ -200,12 +246,8 @@ impl ChainClient { }; let tx_body = BodyBuilder::new().msg(any).finish(); - let fee_coin = Coin { - amount: 10000u128, - denom: "ulume" - .parse() - .map_err(|e| SdkError::Chain(format!("fee denom parse: {e}")))?, - }; + let gas_limit = 500_000u64; + let fee_coin = self.calculate_fee_amount(gas_limit)?; let chain_id = self .cfg .chain_id @@ -218,7 +260,7 @@ impl ChainClient { let mut account_number = account.account_number; for _attempt in 0..3 { let auth = SignerInfo::single_direct(Some(signing_key.public_key()), seq) - .auth_info(Fee::from_amount_and_gas(fee_coin.clone(), 500_000u64)); + .auth_info(Fee::from_amount_and_gas(fee_coin.clone(), gas_limit)); let sign_doc = SignDoc::new(&tx_body, &auth, &chain_id, account_number) .map_err(|e| SdkError::Chain(e.to_string()))?; let tx_raw = sign_doc @@ -278,6 +320,437 @@ impl ChainClient { Err(SdkError::Chain("sequence retry exhausted".into())) } + pub async fn build_signed_tx( + &self, + signing_key: &cosmrs::crypto::secp256k1::SigningKey, + creator: &str, + msgs: Vec, + memo: impl Into, + gas_limit: u64, + ) -> Result { + self.validate_signer_matches_creator(signing_key, creator)?; + let account = self.get_base_account(creator).await?; + let fee_coin = self.calculate_fee_amount(gas_limit)?; + let chain_id = self + .cfg + .chain_id + .parse() + .map_err(|e| SdkError::Chain(format!("chain-id: {e}")))?; + + let mut txb = BodyBuilder::new(); + txb.msgs(msgs).memo(memo.into()); + let tx_body = txb.finish(); + + let auth = SignerInfo::single_direct(Some(signing_key.public_key()), account.sequence) + .auth_info(Fee::from_amount_and_gas(fee_coin, gas_limit)); + let sign_doc = SignDoc::new(&tx_body, &auth, &chain_id, account.account_number) + .map_err(|e| SdkError::Chain(e.to_string()))?; + sign_doc + .sign(signing_key) + .map_err(|e| SdkError::Chain(e.to_string())) + } + + pub async fn broadcast_signed_tx( + &self, + tx_raw: &cosmrs::tx::Raw, + mode: BroadcastMode, + ) -> Result { + let rpc = tendermint_rpc::HttpClient::new(self.cfg.rpc_endpoint.as_str()) + .map_err(|e| SdkError::Http(e.to_string()))?; + let tx_bytes = tx_raw + .to_bytes() + .map_err(|e| SdkError::Serialization(e.to_string()))?; + + match mode { + BroadcastMode::Async => { + let rsp = rpc + .broadcast_tx_async(tx_bytes) + .await + .map_err(|e| SdkError::Chain(e.to_string()))?; + Ok(BroadcastTxResult { + tx_hash: rsp.hash.to_string(), + check_tx_code: None, + deliver_tx_code: None, + log: String::new(), + }) + } + BroadcastMode::Sync => { + let rsp = rpc + .broadcast_tx_sync(tx_bytes) + .await + .map_err(|e| SdkError::Chain(e.to_string()))?; + Ok(BroadcastTxResult { + tx_hash: rsp.hash.to_string(), + check_tx_code: Some(rsp.code.value()), + deliver_tx_code: None, + log: rsp.log.to_string(), + }) + } + BroadcastMode::Commit => { + let rsp = rpc + .broadcast_tx_commit(tx_bytes) + .await + .map_err(|e| SdkError::Chain(e.to_string()))?; + Ok(BroadcastTxResult { + tx_hash: rsp.hash.to_string(), + check_tx_code: Some(rsp.check_tx.code.value()), + deliver_tx_code: Some(rsp.tx_result.code.value()), + log: rsp.tx_result.log.to_string(), + }) + } + } + } + + pub async fn send_any_msgs( + &self, + signing_key: &cosmrs::crypto::secp256k1::SigningKey, + creator: &str, + msgs: Vec, + memo: impl Into, + gas_limit: u64, + mode: BroadcastMode, + ) -> Result { + let tx_raw = self + .build_signed_tx(signing_key, creator, msgs, memo, gas_limit) + .await?; + self.broadcast_signed_tx(&tx_raw, mode).await + } + + pub async fn simulate_gas_for_tx(&self, tx_raw: &cosmrs::tx::Raw) -> Result { + let tx_bytes = tx_raw + .to_bytes() + .map_err(|e| SdkError::Serialization(e.to_string()))?; + let tx_bytes_b64 = STANDARD.encode(tx_bytes); + + let url = format!( + "{}/cosmos/tx/v1beta1/simulate", + self.cfg.rest_endpoint.trim_end_matches('/') + ); + + let v: serde_json::Value = self + .http + .post(url) + .json(&serde_json::json!({"tx_bytes": tx_bytes_b64})) + .send() + .await + .map_err(|e| SdkError::Http(e.to_string()))? + .json() + .await + .map_err(|e| SdkError::Serialization(e.to_string()))?; + + let gas_used = v + .get("gas_info") + .and_then(|g| g.get("gas_used")) + .and_then(|x| { + x.as_str() + .and_then(|s| s.parse::().ok()) + .or_else(|| x.as_u64()) + }) + .ok_or_else(|| { + SdkError::Serialization("missing gas_info.gas_used in simulate response".into()) + })?; + + Ok(gas_used) + } + + pub async fn build_signed_tx_with_simulation( + &self, + signing_key: &cosmrs::crypto::secp256k1::SigningKey, + creator: &str, + msgs: Vec, + memo: impl Into, + fallback_gas_limit: u64, + gas_adjustment: f64, + ) -> Result<(cosmrs::tx::Raw, u64), SdkError> { + let memo = memo.into(); + let first = self + .build_signed_tx( + signing_key, + creator, + msgs.clone(), + memo.clone(), + fallback_gas_limit, + ) + .await?; + + let simulated = self + .simulate_gas_for_tx(&first) + .await + .unwrap_or(fallback_gas_limit); + let adjustment = if gas_adjustment <= 0.0 { + 1.3 + } else { + gas_adjustment + }; + let adjusted = ((simulated as f64) * adjustment).ceil() as u64; + let gas_limit = adjusted.max(1); + + let final_tx = self + .build_signed_tx(signing_key, creator, msgs, memo, gas_limit) + .await?; + Ok((final_tx, gas_limit)) + } + + pub async fn request_action_tx( + &self, + signing_key: &cosmrs::crypto::secp256k1::SigningKey, + tx: RequestActionTxInput, + memo: impl Into, + ) -> Result { + self.validate_signer_matches_creator(signing_key, &tx.creator)?; + + let mut msg_bytes = Vec::new(); + MsgRequestActionProto { + creator: tx.creator.clone(), + action_type: tx.action_type.clone(), + metadata: tx.metadata.clone(), + price: tx.price.clone(), + expiration_time: tx.expiration_time.clone(), + file_size_kbs: tx.file_size_kbs.clone(), + app_pubkey: tx.app_pubkey.clone(), + } + .encode(&mut msg_bytes) + .map_err(|e| SdkError::Serialization(e.to_string()))?; + + let any = Any { + type_url: "/lumera.action.v1.MsgRequestAction".to_string(), + value: msg_bytes, + }; + + let memo = memo.into(); + for _attempt in 0..3 { + let (tx_raw, _gas) = self + .build_signed_tx_with_simulation( + signing_key, + &tx.creator, + vec![any.clone()], + memo.clone(), + 500_000, + 1.3, + ) + .await?; + + let broadcast = self + .broadcast_signed_tx(&tx_raw, BroadcastMode::Commit) + .await?; + + if broadcast.check_tx_code.unwrap_or_default() != 0 { + if parse_expected_sequence(&broadcast.log).is_some() { + continue; + } + return Err(SdkError::Chain(format!( + "check_tx failed: {}", + broadcast.log + ))); + } + if broadcast.deliver_tx_code.unwrap_or_default() != 0 { + if parse_expected_sequence(&broadcast.log).is_some() { + continue; + } + return Err(SdkError::Chain(format!( + "deliver_tx failed: {}", + broadcast.log + ))); + } + + let action_id = extract_action_id_from_log(&broadcast.log).ok_or_else(|| { + SdkError::Chain(format!( + "unable to extract action_id from commit log: {}", + broadcast.log + )) + })?; + + return Ok(RequestActionSubmitResult { + tx_hash: broadcast.tx_hash, + action_id, + }); + } + + Err(SdkError::Chain("sequence retry exhausted".into())) + } + + pub async fn get_account_info(&self, address: &str) -> Result { + let base = self.get_base_account(address).await?; + Ok(AccountInfo { + address: address.to_string(), + account_number: base.account_number, + sequence: base.sequence, + }) + } + + pub fn calculate_fee_amount(&self, gas_limit: u64) -> Result { + let gas_price = self.cfg.gas_price.trim(); + let split_at = gas_price + .find(|c: char| !c.is_ascii_digit() && c != '.') + .ok_or_else(|| { + SdkError::InvalidInput(format!( + "invalid gas_price '{}': expected e.g. 0.025ulume", + gas_price + )) + })?; + let (amount_str, denom_str) = gas_price.split_at(split_at); + let amount = amount_str + .parse::() + .map_err(|e| SdkError::InvalidInput(format!("gas_price amount parse error: {e}")))?; + let fee = (amount * gas_limit as f64).ceil() as u128; + + Ok(Coin { + amount: fee, + denom: denom_str + .parse() + .map_err(|e| SdkError::Chain(format!("fee denom parse: {e}")))?, + }) + } + + pub async fn wait_for_tx_confirmation( + &self, + tx_hash: &str, + timeout_secs: u64, + ) -> Result { + let deadline = std::time::Instant::now() + Duration::from_secs(timeout_secs); + loop { + if let Some(status) = self.get_tx(tx_hash).await? { + return Ok(status); + } + if std::time::Instant::now() >= deadline { + return Err(SdkError::Chain(format!( + "timed out waiting for tx confirmation: {}", + tx_hash + ))); + } + sleep(Duration::from_secs(2)).await; + } + } + + pub async fn wait_for_event_attribute( + &self, + tx_hash: &str, + event_type: &str, + attr_key: &str, + timeout_secs: u64, + ) -> Result { + let deadline = std::time::Instant::now() + Duration::from_secs(timeout_secs); + loop { + if let Some(attrs) = self.get_tx_event_attributes(tx_hash).await? { + if let Some(found) = attrs + .iter() + .find(|a| a.event_type == event_type && a.key == attr_key) + { + return Ok(found.value.clone()); + } + } + if std::time::Instant::now() >= deadline { + return Err(SdkError::Chain(format!( + "timed out waiting for tx event attribute: tx_hash={}, event_type={}, key={}", + tx_hash, event_type, attr_key + ))); + } + sleep(Duration::from_secs(2)).await; + } + } + + pub async fn get_tx(&self, tx_hash: &str) -> Result, SdkError> { + let tx_resp = self.get_tx_response_json(tx_hash).await?; + let Some(tx_resp) = tx_resp else { + return Ok(None); + }; + + let height = tx_resp + .get("height") + .and_then(|x| { + x.as_str() + .and_then(|s| s.parse::().ok()) + .or_else(|| x.as_i64()) + }) + .unwrap_or_default(); + let code = tx_resp + .get("code") + .and_then(|x| x.as_u64()) + .unwrap_or_default() as u32; + let raw_log = tx_resp + .get("raw_log") + .and_then(|x| x.as_str()) + .unwrap_or_default() + .to_string(); + + Ok(Some(TxConfirmationStatus { + tx_hash: tx_hash.to_string(), + height, + code, + raw_log, + })) + } + + pub async fn get_tx_event_attributes( + &self, + tx_hash: &str, + ) -> Result>, SdkError> { + let tx_resp = self.get_tx_response_json(tx_hash).await?; + let Some(tx_resp) = tx_resp else { + return Ok(None); + }; + + Ok(Some(extract_event_attributes_from_tx_response(&tx_resp))) + } + + async fn get_tx_response_json( + &self, + tx_hash: &str, + ) -> Result, SdkError> { + let url = format!( + "{}/cosmos/tx/v1beta1/txs/{}", + self.cfg.rest_endpoint.trim_end_matches('/'), + tx_hash + ); + + let resp = self + .http + .get(url) + .send() + .await + .map_err(|e| SdkError::Http(e.to_string()))?; + + if resp.status().as_u16() == 404 { + return Ok(None); + } + + let v: serde_json::Value = resp + .json() + .await + .map_err(|e| SdkError::Serialization(e.to_string()))?; + + let tx_resp = v + .get("tx_response") + .ok_or_else(|| SdkError::Serialization("missing tx_response".into()))? + .clone(); + + Ok(Some(tx_resp)) + } + + fn validate_signer_matches_creator( + &self, + signing_key: &cosmrs::crypto::secp256k1::SigningKey, + creator: &str, + ) -> Result<(), SdkError> { + let (hrp, _) = creator.rsplit_once('1').ok_or_else(|| { + SdkError::InvalidInput(format!("invalid creator bech32 address: {}", creator)) + })?; + + let derived = signing_key + .public_key() + .account_id(hrp) + .map_err(|e| SdkError::Crypto(format!("derive signer account_id: {e}")))? + .to_string(); + + if derived != creator { + return Err(SdkError::InvalidInput(format!( + "creator address does not match signing key: creator={}, signer={}", + creator, derived + ))); + } + Ok(()) + } + async fn get_base_account(&self, address: &str) -> Result { let url = format!( "{}/cosmos/auth/v1beta1/accounts/{}", @@ -329,33 +802,117 @@ struct BaseAccount { } pub fn extract_action_id_from_log(log: &str) -> Option { + extract_event_attribute_from_log(log, "action_registered", "action_id") +} + +pub fn extract_event_attribute_from_log( + log: &str, + event_type: &str, + attr_key: &str, +) -> Option { let v: serde_json::Value = serde_json::from_str(log).ok()?; let arr = v.as_array()?; for item in arr { for e in item.get("events")?.as_array()? { - if e.get("type").and_then(|x| x.as_str()) == Some("action_registered") { - for attr in e.get("attributes")?.as_array()? { - let key = attr.get("key")?.as_str()?; - let val = attr.get("value")?.as_str()?; - if key == "action_id" { - return Some(val.to_string()); + if e.get("type").and_then(|x| x.as_str()) != Some(event_type) { + continue; + } + for attr in e.get("attributes")?.as_array()? { + let key = attr.get("key")?.as_str()?; + let val = attr.get("value")?.as_str()?; + if key == attr_key { + return Some(val.to_string()); + } + let kb = STANDARD + .decode(key) + .ok() + .and_then(|b| String::from_utf8(b).ok()); + let vb = STANDARD + .decode(val) + .ok() + .and_then(|b| String::from_utf8(b).ok()); + if kb.as_deref() == Some(attr_key) { + return vb; + } + } + } + } + None +} + +fn extract_event_attributes_from_tx_response( + tx_response: &serde_json::Value, +) -> Vec { + let mut out = Vec::new(); + + // Preferred path for REST /cosmos/tx/v1beta1/txs: tx_response.logs[].events[].attributes[] + if let Some(logs) = tx_response.get("logs").and_then(|x| x.as_array()) { + for log in logs { + if let Some(events) = log.get("events").and_then(|x| x.as_array()) { + for e in events { + let event_type = e + .get("type") + .and_then(|x| x.as_str()) + .unwrap_or_default() + .to_string(); + if let Some(attrs) = e.get("attributes").and_then(|x| x.as_array()) { + for a in attrs { + let key = a + .get("key") + .and_then(|x| x.as_str()) + .unwrap_or_default() + .to_string(); + let value = a + .get("value") + .and_then(|x| x.as_str()) + .unwrap_or_default() + .to_string(); + out.push(EventAttribute { + event_type: event_type.clone(), + key, + value, + }); + } } - let kb = STANDARD - .decode(key) - .ok() - .and_then(|b| String::from_utf8(b).ok()); - let vb = STANDARD - .decode(val) - .ok() - .and_then(|b| String::from_utf8(b).ok()); - if kb.as_deref() == Some("action_id") { - return vb; + } + } + } + } + + // Fallback to RPC-style events[].attributes[] shape if logs are unavailable. + if out.is_empty() { + if let Some(events) = tx_response.get("events").and_then(|x| x.as_array()) { + for e in events { + let event_type = e + .get("kind") + .or_else(|| e.get("type")) + .and_then(|x| x.as_str()) + .unwrap_or_default() + .to_string(); + if let Some(attrs) = e.get("attributes").and_then(|x| x.as_array()) { + for a in attrs { + let key = a + .get("key") + .and_then(|x| x.as_str()) + .unwrap_or_default() + .to_string(); + let value = a + .get("value") + .and_then(|x| x.as_str()) + .unwrap_or_default() + .to_string(); + out.push(EventAttribute { + event_type: event_type.clone(), + key, + value, + }); } } } } } - None + + out } fn parse_expected_sequence(log: &str) -> Option { @@ -436,4 +993,33 @@ mod tests { let log = "account sequence mismatch, expected 14, got 5: incorrect account sequence"; assert_eq!(parse_expected_sequence(log), Some(14)); } + + #[test] + fn tdd_extract_event_attribute_from_log_json() { + let log = r#"[{"events":[{"type":"action_registered","attributes":[{"key":"action_id","value":"A-9"},{"key":"creator","value":"lumera1abc"}]}]}]"#; + assert_eq!( + extract_event_attribute_from_log(log, "action_registered", "creator").as_deref(), + Some("lumera1abc") + ); + } + + #[test] + fn tdd_extract_event_attributes_from_tx_response_logs() { + let tx_response = serde_json::json!({ + "logs": [{ + "events": [{ + "type": "action_registered", + "attributes": [ + {"key": "action_id", "value": "A-42"}, + {"key": "creator", "value": "lumera1xyz"} + ] + }] + }] + }); + + let attrs = extract_event_attributes_from_tx_response(&tx_response); + assert!(attrs.iter().any(|a| a.event_type == "action_registered" + && a.key == "action_id" + && a.value == "A-42")); + } } diff --git a/src/keys.rs b/src/keys.rs new file mode 100644 index 0000000..e89d55d --- /dev/null +++ b/src/keys.rs @@ -0,0 +1,111 @@ +use std::str::FromStr; + +use bip32::{DerivationPath, XPrv}; +use bip39::Mnemonic; +use k256::ecdsa::SigningKey as K256SigningKey; + +use crate::error::SdkError; + +pub struct SigningIdentity { + pub chain_signing_key: cosmrs::crypto::secp256k1::SigningKey, + pub arbitrary_signing_key: K256SigningKey, + pub address: String, + pub hrp: String, +} + +impl SigningIdentity { + pub fn from_mnemonic( + mnemonic: &str, + hrp: &str, + derivation_path: &str, + ) -> Result { + let m = Mnemonic::parse(mnemonic) + .map_err(|e| SdkError::Crypto(format!("invalid mnemonic: {e}")))?; + let seed = m.to_seed(""); + let path = DerivationPath::from_str(derivation_path) + .map_err(|e| SdkError::Crypto(format!("invalid derivation path: {e}")))?; + let xprv = XPrv::derive_from_path(seed, &path) + .map_err(|e| SdkError::Crypto(format!("key derivation failed: {e}")))?; + + let sk_bytes = xprv.private_key().to_bytes(); + let chain_signing_key = cosmrs::crypto::secp256k1::SigningKey::from_slice(&sk_bytes) + .map_err(|e| SdkError::Crypto(format!("chain signing key creation failed: {e}")))?; + let arbitrary_signing_key = K256SigningKey::from_slice(&sk_bytes) + .map_err(|e| SdkError::Crypto(format!("message signing key creation failed: {e}")))?; + + let address = chain_signing_key + .public_key() + .account_id(hrp) + .map_err(|e| SdkError::Crypto(format!("address derivation failed: {e}")))? + .to_string(); + + Ok(Self { + chain_signing_key, + arbitrary_signing_key, + address, + hrp: hrp.to_string(), + }) + } + + pub fn validate_address(&self, expected_address: &str) -> Result<(), SdkError> { + if self.address != expected_address { + return Err(SdkError::InvalidInput(format!( + "signing identity mismatch: expected address {} but derived {}", + expected_address, self.address + ))); + } + Ok(()) + } + + pub fn validate_chain_prefix( + expected_address: &str, + expected_hrp: &str, + ) -> Result<(), SdkError> { + let (actual_hrp, _) = expected_address.rsplit_once('1').ok_or_else(|| { + SdkError::InvalidInput(format!("invalid bech32 address: {}", expected_address)) + })?; + if actual_hrp != expected_hrp { + return Err(SdkError::InvalidInput(format!( + "address prefix mismatch: expected {} but got {}", + expected_hrp, actual_hrp + ))); + } + Ok(()) + } +} + +pub fn derive_signing_keys_from_mnemonic( + mnemonic: &str, +) -> Result<(cosmrs::crypto::secp256k1::SigningKey, K256SigningKey), SdkError> { + let id = SigningIdentity::from_mnemonic(mnemonic, "lumera", "m/44'/118'/0'/0/0")?; + Ok((id.chain_signing_key, id.arbitrary_signing_key)) +} + +#[cfg(test)] +mod tests { + use super::*; + + const TEST_MNEMONIC: &str = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + + #[test] + fn tdd_signing_identity_derives_lumera_address() { + let id = SigningIdentity::from_mnemonic(TEST_MNEMONIC, "lumera", "m/44'/118'/0'/0/0") + .expect("derive signing identity"); + assert!(id.address.starts_with("lumera1")); + } + + #[test] + fn tdd_validate_chain_prefix() { + assert!(SigningIdentity::validate_chain_prefix( + "lumera1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqj9f3h", + "lumera" + ) + .is_ok()); + assert!(SigningIdentity::validate_chain_prefix( + "cosmos1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqnrql8a", + "lumera" + ) + .is_err()); + } +} diff --git a/src/lib.rs b/src/lib.rs index e75e21d..1549f6a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,7 +3,9 @@ pub mod chain; pub mod config; pub mod crypto; pub mod error; +pub mod keys; pub mod snapi; pub use cascade::{CascadeConfig, CascadeSdk, RegisterTicketRequest}; pub use config::SdkSettings; +pub use keys::SigningIdentity;