diff --git a/src/openhuman/about_app/catalog.rs b/src/openhuman/about_app/catalog.rs index f7b42a44a..93e605c76 100644 --- a/src/openhuman/about_app/catalog.rs +++ b/src/openhuman/about_app/catalog.rs @@ -376,6 +376,16 @@ const CAPABILITIES: &[Capability] = &[ status: CapabilityStatus::Beta, privacy: LOCAL_CREDENTIALS, }, + Capability { + id: "skills.wallet_execution", + name: "Wallet Execution Tools", + domain: "wallet", + category: CapabilityCategory::Skills, + description: "Read balances and prepare/confirm/execute transfers, swaps, and contract calls across the connected wallet (EVM, BTC, Solana, Tron). Quote-first; signing stays local.", + how_to: "Use wallet.* RPC methods (balances, prepare_transfer, prepare_swap, prepare_contract_call, execute_prepared) via the agent or core_rpc_relay.", + status: CapabilityStatus::Beta, + privacy: LOCAL_CREDENTIALS, + }, Capability { id: "skills.connect_crypto_exchange", name: "Connect Crypto Exchange", diff --git a/src/openhuman/wallet/execution.rs b/src/openhuman/wallet/execution.rs new file mode 100644 index 000000000..710b77e81 --- /dev/null +++ b/src/openhuman/wallet/execution.rs @@ -0,0 +1,799 @@ +//! Wallet execution surface — read tools (balances / supported assets / chain +//! status) and write tools (prepare-then-execute) for native sends, token +//! transfers, swaps, and contract calls. +//! +//! Design rules (see issue #1396): +//! - Quote / simulate first, then explicit confirm-and-execute. No one-shot +//! hidden execution. +//! - Signing material stays local. `execute_prepared` returns a +//! `ReadyToSign` structured payload that the desktop keystore consumes — +//! this module never touches mnemonics or private keys. +//! - Wallet must be configured (see [`crate::openhuman::wallet::status`]) +//! before any read or write tool is callable. +//! - Every decision point emits a grep-friendly `[wallet]` debug log. +//! +//! On-chain RPC providers are not yet configured (#1395 ships the keystore; +//! provider config lives behind `OPENHUMAN_WALLET_RPC_*` env vars). Until a +//! provider is wired, balances surface `provider_status: "unconfigured"` +//! with zero values rather than fabricating numbers. +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use log::{debug, warn}; +use once_cell::sync::Lazy; +use parking_lot::Mutex; +use serde::{Deserialize, Serialize}; + +use crate::rpc::RpcOutcome; + +use super::ops::{status as wallet_status, WalletAccount, WalletChain}; + +const LOG_PREFIX: &str = "[wallet]"; +/// Prepared-transaction TTL. Quotes older than this are rejected at execute time. +const QUOTE_TTL_MS: u64 = 5 * 60 * 1000; +/// Cap on stored quotes; oldest entries are pruned when exceeded. +const QUOTE_STORE_CAP: usize = 64; + +static QUOTE_STORE: Lazy>> = Lazy::new(|| Mutex::new(Vec::new())); +static QUOTE_COUNTER: AtomicU64 = AtomicU64::new(1); + +// -- Public types ----------------------------------------------------------- + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ChainStatus { + pub chain: WalletChain, + pub configured: bool, + pub provider_status: ProviderStatus, +} + +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ProviderStatus { + /// Wallet account exists for this chain and an RPC provider is reachable. + Ready, + /// Wallet account exists but no RPC provider has been configured yet. + Unconfigured, + /// Chain has no derived wallet account yet — run wallet setup first. + Missing, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SupportedAsset { + pub chain: WalletChain, + pub symbol: &'static str, + pub name: &'static str, + pub native: bool, + pub decimals: u8, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BalanceInfo { + pub chain: WalletChain, + pub address: String, + pub asset_symbol: &'static str, + pub decimals: u8, + pub raw: String, + pub formatted: String, + pub provider_status: ProviderStatus, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum PreparedKind { + NativeTransfer, + TokenTransfer, + Swap, + ContractCall, +} + +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum PreparedStatus { + /// Quote has been simulated and is awaiting explicit user confirmation. + AwaitingConfirmation, + /// `execute_prepared` was invoked — payload is ready for the keystore. + ReadyToSign, + /// Quote expired or was already consumed. + Consumed, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PreparedTransaction { + pub quote_id: String, + pub kind: PreparedKind, + pub chain: WalletChain, + pub from_address: String, + /// For transfers: recipient. For swaps: pool / router contract. For + /// contract calls: target contract. + pub to_address: String, + pub asset_symbol: String, + pub amount_raw: String, + pub amount_formatted: String, + /// For swaps only — the symbol the user expects to receive. + #[serde(skip_serializing_if = "Option::is_none")] + pub receive_symbol: Option, + /// For swaps only — minimum amount out (raw integer string). + #[serde(skip_serializing_if = "Option::is_none")] + pub min_receive_raw: Option, + /// For contract calls only — encoded calldata (hex, 0x-prefixed). + #[serde(skip_serializing_if = "Option::is_none")] + pub calldata: Option, + /// Estimated network fee in the chain's native units (raw integer string). + pub estimated_fee_raw: String, + pub status: PreparedStatus, + pub created_at_ms: u64, + pub expires_at_ms: u64, + /// Human-readable reasons surfaced from simulation, for the confirmation + /// dialog (e.g. `slippage 0.5%`, `fee bump`). + pub notes: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ReadyToSign { + pub quote_id: String, + pub status: PreparedStatus, + pub chain: WalletChain, + /// Full prepared transaction the keystore should sign. + pub transaction: PreparedTransaction, +} + +// -- Param types ------------------------------------------------------------ + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PrepareTransferParams { + pub chain: WalletChain, + pub to_address: String, + /// Raw integer amount in the asset's smallest unit (wei / sat / lamports). + pub amount_raw: String, + /// `null` / absent => native asset for the chain. Otherwise a token symbol + /// returned by `wallet.supported_assets`. + #[serde(default)] + pub asset_symbol: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PrepareSwapParams { + pub chain: WalletChain, + pub from_symbol: String, + pub to_symbol: String, + pub amount_in_raw: String, + /// Slippage tolerance in basis points (e.g. `50` = 0.5%). + pub slippage_bps: u32, + /// Router / aggregator contract address. Caller selects the venue. + pub router_address: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PrepareContractCallParams { + pub chain: WalletChain, + pub contract_address: String, + /// Hex-encoded calldata (`0x`-prefixed). + pub calldata: String, + /// Native value to attach (raw, smallest unit). `"0"` for view / pure + /// state mutations on EVM. + #[serde(default = "zero_string")] + pub value_raw: String, +} + +fn zero_string() -> String { + "0".to_string() +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExecutePreparedParams { + pub quote_id: String, + /// Caller MUST set this to `true`. If absent / false, the call is + /// rejected — this is the safety boundary between simulate and execute. + pub confirmed: bool, +} + +// -- Helpers ---------------------------------------------------------------- + +fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + +fn next_quote_id() -> String { + let n = QUOTE_COUNTER.fetch_add(1, Ordering::Relaxed); + format!("q_{}_{}", now_ms(), n) +} + +async fn require_account(chain: WalletChain) -> Result { + let status = wallet_status().await?.value; + if !status.configured { + return Err("wallet is not configured; run wallet setup first".to_string()); + } + status + .accounts + .into_iter() + .find(|a| a.chain == chain) + .ok_or_else(|| format!("no wallet account derived for chain '{}'", chain_str(chain))) +} + +fn chain_str(chain: WalletChain) -> &'static str { + match chain { + WalletChain::Evm => "evm", + WalletChain::Btc => "btc", + WalletChain::Solana => "solana", + WalletChain::Tron => "tron", + } +} + +fn native_asset(chain: WalletChain) -> SupportedAsset { + match chain { + WalletChain::Evm => SupportedAsset { + chain, + symbol: "ETH", + name: "Ether", + native: true, + decimals: 18, + }, + WalletChain::Btc => SupportedAsset { + chain, + symbol: "BTC", + name: "Bitcoin", + native: true, + decimals: 8, + }, + WalletChain::Solana => SupportedAsset { + chain, + symbol: "SOL", + name: "Solana", + native: true, + decimals: 9, + }, + WalletChain::Tron => SupportedAsset { + chain, + symbol: "TRX", + name: "Tron", + native: true, + decimals: 6, + }, + } +} + +fn provider_env_set(chain: WalletChain) -> bool { + let key = match chain { + WalletChain::Evm => "OPENHUMAN_WALLET_RPC_EVM", + WalletChain::Btc => "OPENHUMAN_WALLET_RPC_BTC", + WalletChain::Solana => "OPENHUMAN_WALLET_RPC_SOLANA", + WalletChain::Tron => "OPENHUMAN_WALLET_RPC_TRON", + }; + std::env::var(key) + .map(|v| !v.trim().is_empty()) + .unwrap_or(false) +} + +fn validate_amount(raw: &str) -> Result { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err("amount is empty".to_string()); + } + trimmed + .parse::() + .map_err(|_| format!("amount '{trimmed}' is not a valid non-negative integer")) +} + +fn validate_address(addr: &str) -> Result { + let trimmed = addr.trim(); + if trimmed.is_empty() { + return Err("address is empty".to_string()); + } + Ok(trimmed.to_string()) +} + +fn validate_calldata(data: &str) -> Result { + let t = data.trim(); + if !t.starts_with("0x") { + return Err("calldata must be 0x-prefixed hex".to_string()); + } + let body = &t[2..]; + if body.len() % 2 != 0 { + return Err("calldata hex must be byte-aligned".to_string()); + } + if !body.chars().all(|c| c.is_ascii_hexdigit()) { + return Err("calldata contains non-hex characters".to_string()); + } + Ok(t.to_string()) +} + +fn format_amount(raw: u128, decimals: u8) -> String { + if decimals == 0 { + return raw.to_string(); + } + let s = raw.to_string(); + let d = decimals as usize; + if s.len() <= d { + format!("0.{:0>width$}", s, width = d) + } else { + let split = s.len() - d; + format!("{}.{}", &s[..split], &s[split..]) + } +} + +fn estimated_fee_raw(chain: WalletChain, kind: PreparedKind) -> String { + // Pessimistic stub estimates so simulation has a non-zero number to show. + // Real values come from the chain's fee oracle once a provider is wired. + let base = match (chain, kind) { + (WalletChain::Evm, PreparedKind::NativeTransfer) => 21_000u128 * 30_000_000_000, + (WalletChain::Evm, PreparedKind::TokenTransfer) => 65_000u128 * 30_000_000_000, + (WalletChain::Evm, PreparedKind::Swap) => 200_000u128 * 30_000_000_000, + (WalletChain::Evm, PreparedKind::ContractCall) => 100_000u128 * 30_000_000_000, + (WalletChain::Btc, _) => 5_000, + (WalletChain::Solana, _) => 5_000, + (WalletChain::Tron, _) => 1_000_000, + }; + base.to_string() +} + +fn store_quote(quote: PreparedTransaction) -> PreparedTransaction { + let mut store = QUOTE_STORE.lock(); + let cutoff = now_ms(); + store.retain(|q| q.expires_at_ms > cutoff && q.status != PreparedStatus::Consumed); + if store.len() >= QUOTE_STORE_CAP { + store.remove(0); + } + store.push(quote.clone()); + quote +} + +fn take_quote(quote_id: &str) -> Result { + let mut store = QUOTE_STORE.lock(); + let now = now_ms(); + let pos = store + .iter() + .position(|q| q.quote_id == quote_id) + .ok_or_else(|| format!("quote '{quote_id}' not found"))?; + let quote = store.remove(pos); + if quote.status == PreparedStatus::Consumed { + return Err(format!("quote '{quote_id}' already executed")); + } + if quote.expires_at_ms <= now { + return Err(format!("quote '{quote_id}' expired")); + } + Ok(quote) +} + +#[cfg(test)] +fn reset_quote_store_for_tests() { + QUOTE_STORE.lock().clear(); +} + +// -- Operations ------------------------------------------------------------- + +pub async fn supported_assets() -> Result>, String> { + let assets: Vec = [ + WalletChain::Evm, + WalletChain::Btc, + WalletChain::Solana, + WalletChain::Tron, + ] + .into_iter() + .map(native_asset) + .collect(); + debug!("{LOG_PREFIX} supported_assets count={}", assets.len()); + Ok(RpcOutcome::new( + assets, + vec!["wallet supported_assets listed".to_string()], + )) +} + +pub async fn chain_status() -> Result>, String> { + let status = wallet_status().await?.value; + let mut rows = Vec::with_capacity(4); + for chain in [ + WalletChain::Evm, + WalletChain::Btc, + WalletChain::Solana, + WalletChain::Tron, + ] { + let has_account = status.accounts.iter().any(|a| a.chain == chain); + let provider_status = if !has_account { + ProviderStatus::Missing + } else if provider_env_set(chain) { + ProviderStatus::Ready + } else { + ProviderStatus::Unconfigured + }; + rows.push(ChainStatus { + chain, + configured: has_account, + provider_status, + }); + } + debug!("{LOG_PREFIX} chain_status reported chains={}", rows.len()); + Ok(RpcOutcome::new( + rows, + vec!["wallet chain_status listed".to_string()], + )) +} + +pub async fn balances() -> Result>, String> { + let status = wallet_status().await?.value; + if !status.configured { + return Err("wallet is not configured; run wallet setup first".to_string()); + } + let mut out = Vec::with_capacity(status.accounts.len()); + for account in &status.accounts { + let asset = native_asset(account.chain); + let provider_status = if provider_env_set(account.chain) { + ProviderStatus::Ready + } else { + ProviderStatus::Unconfigured + }; + if provider_status == ProviderStatus::Unconfigured { + warn!( + "{LOG_PREFIX} balances chain={} provider unconfigured; returning zero placeholder", + chain_str(account.chain) + ); + } + out.push(BalanceInfo { + chain: account.chain, + address: account.address.clone(), + asset_symbol: asset.symbol, + decimals: asset.decimals, + raw: "0".to_string(), + formatted: format_amount(0, asset.decimals), + provider_status, + }); + } + debug!("{LOG_PREFIX} balances returned rows={}", out.len()); + Ok(RpcOutcome::new( + out, + vec!["wallet balances listed".to_string()], + )) +} + +pub async fn prepare_transfer( + params: PrepareTransferParams, +) -> Result, String> { + let to = validate_address(¶ms.to_address)?; + let amount = validate_amount(¶ms.amount_raw)?; + if amount == 0 { + return Err("transfer amount must be greater than zero".to_string()); + } + let native = native_asset(params.chain); + let (kind, asset_symbol, decimals) = match params.asset_symbol.as_deref().map(str::trim) { + None | Some("") => ( + PreparedKind::NativeTransfer, + native.symbol.to_string(), + native.decimals, + ), + Some(sym) if sym.eq_ignore_ascii_case(native.symbol) => ( + PreparedKind::NativeTransfer, + native.symbol.to_string(), + native.decimals, + ), + Some(sym) => { + return Err(format!( + "unsupported asset_symbol '{sym}'; only native assets are listed in wallet.supported_assets today" + )); + } + }; + let account = require_account(params.chain).await?; + + let now = now_ms(); + let quote = PreparedTransaction { + quote_id: next_quote_id(), + kind, + chain: params.chain, + from_address: account.address.clone(), + to_address: to, + asset_symbol: asset_symbol.clone(), + amount_raw: amount.to_string(), + amount_formatted: format_amount(amount, decimals), + receive_symbol: None, + min_receive_raw: None, + calldata: None, + estimated_fee_raw: estimated_fee_raw(params.chain, kind), + status: PreparedStatus::AwaitingConfirmation, + created_at_ms: now, + expires_at_ms: now + QUOTE_TTL_MS, + notes: vec![format!( + "Simulation only — confirm to forward to keystore. Asset: {asset_symbol}." + )], + }; + debug!( + "{LOG_PREFIX} prepare_transfer chain={} kind={:?} quote_id={} amount={}", + chain_str(params.chain), + kind, + quote.quote_id, + quote.amount_raw + ); + Ok(RpcOutcome::new( + store_quote(quote), + vec!["wallet transfer prepared".to_string()], + )) +} + +pub async fn prepare_swap( + params: PrepareSwapParams, +) -> Result, String> { + if params.from_symbol.trim().is_empty() || params.to_symbol.trim().is_empty() { + return Err("swap requires non-empty from_symbol and to_symbol".to_string()); + } + if params.from_symbol.eq_ignore_ascii_case(¶ms.to_symbol) { + return Err("swap from_symbol and to_symbol must differ".to_string()); + } + if params.slippage_bps > 5_000 { + return Err("slippage_bps too high (cap 5000 = 50%)".to_string()); + } + let amount = validate_amount(¶ms.amount_in_raw)?; + if amount == 0 { + return Err("swap amount_in_raw must be greater than zero".to_string()); + } + let router = validate_address(¶ms.router_address)?; + let account = require_account(params.chain).await?; + + // Conservative min-out: amount * (10000 - slippage) / 10000. Without a + // real quote we cannot compute the swap rate; this lets the UI display a + // floor and forces explicit caller-side rate input via the router quote + // pre-step once the provider lands. + let min_out = amount.saturating_mul((10_000 - params.slippage_bps) as u128) / 10_000; + let native = native_asset(params.chain); + let now = now_ms(); + let quote = PreparedTransaction { + quote_id: next_quote_id(), + kind: PreparedKind::Swap, + chain: params.chain, + from_address: account.address.clone(), + to_address: router, + asset_symbol: params.from_symbol.clone(), + amount_raw: amount.to_string(), + amount_formatted: format_amount(amount, native.decimals), + receive_symbol: Some(params.to_symbol.clone()), + min_receive_raw: Some(min_out.to_string()), + calldata: None, + estimated_fee_raw: estimated_fee_raw(params.chain, PreparedKind::Swap), + status: PreparedStatus::AwaitingConfirmation, + created_at_ms: now, + expires_at_ms: now + QUOTE_TTL_MS, + notes: vec![format!( + "Swap {} -> {}, slippage {} bps. Real router quote required before signing.", + params.from_symbol, params.to_symbol, params.slippage_bps + )], + }; + debug!( + "{LOG_PREFIX} prepare_swap chain={} quote_id={} from={} to={} slippage_bps={}", + chain_str(params.chain), + quote.quote_id, + params.from_symbol, + params.to_symbol, + params.slippage_bps + ); + Ok(RpcOutcome::new( + store_quote(quote), + vec!["wallet swap prepared".to_string()], + )) +} + +pub async fn prepare_contract_call( + params: PrepareContractCallParams, +) -> Result, String> { + if !matches!(params.chain, WalletChain::Evm | WalletChain::Tron) { + return Err(format!( + "contract calls are only supported on EVM and Tron chains; got '{}'", + chain_str(params.chain) + )); + } + let contract = validate_address(¶ms.contract_address)?; + let calldata = validate_calldata(¶ms.calldata)?; + let value = validate_amount(¶ms.value_raw)?; + let account = require_account(params.chain).await?; + + let native = native_asset(params.chain); + let now = now_ms(); + let quote = PreparedTransaction { + quote_id: next_quote_id(), + kind: PreparedKind::ContractCall, + chain: params.chain, + from_address: account.address.clone(), + to_address: contract, + asset_symbol: native.symbol.to_string(), + amount_raw: value.to_string(), + amount_formatted: format_amount(value, native.decimals), + receive_symbol: None, + min_receive_raw: None, + calldata: Some(calldata), + estimated_fee_raw: estimated_fee_raw(params.chain, PreparedKind::ContractCall), + status: PreparedStatus::AwaitingConfirmation, + created_at_ms: now, + expires_at_ms: now + QUOTE_TTL_MS, + notes: vec!["Contract call simulation — verify ABI before signing.".to_string()], + }; + debug!( + "{LOG_PREFIX} prepare_contract_call chain={} quote_id={} value={}", + chain_str(params.chain), + quote.quote_id, + quote.amount_raw + ); + Ok(RpcOutcome::new( + store_quote(quote), + vec!["wallet contract call prepared".to_string()], + )) +} + +pub async fn execute_prepared( + params: ExecutePreparedParams, +) -> Result, String> { + if !params.confirmed { + return Err("execute_prepared requires `confirmed: true`".to_string()); + } + let mut quote = take_quote(¶ms.quote_id)?; + quote.status = PreparedStatus::ReadyToSign; + debug!( + "{LOG_PREFIX} execute_prepared quote_id={} chain={} kind={:?} -> ReadyToSign", + quote.quote_id, + chain_str(quote.chain), + quote.kind + ); + let result = ReadyToSign { + quote_id: quote.quote_id.clone(), + status: quote.status, + chain: quote.chain, + transaction: quote, + }; + Ok(RpcOutcome::new( + result, + vec!["wallet quote handed to keystore".to_string()], + )) +} + +// -- Tests ------------------------------------------------------------------ + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validates_amount_rejects_empty_and_non_numeric() { + assert!(validate_amount("").is_err()); + assert!(validate_amount("abc").is_err()); + assert_eq!(validate_amount("42").unwrap(), 42); + } + + #[test] + fn validates_calldata_requires_hex() { + assert!(validate_calldata("deadbeef").is_err()); + assert!(validate_calldata("0xZZ").is_err()); + assert!(validate_calldata("0xabc").is_err()); + assert_eq!(validate_calldata("0xdeadbeef").unwrap(), "0xdeadbeef"); + } + + #[test] + fn formats_amount_with_decimals() { + assert_eq!(format_amount(0, 18), "0.000000000000000000"); + assert_eq!(format_amount(1, 8), "0.00000001"); + assert_eq!(format_amount(123_456_789, 8), "1.23456789"); + assert_eq!(format_amount(100, 0), "100"); + } + + #[test] + fn next_quote_id_is_unique_and_prefixed() { + let a = next_quote_id(); + let b = next_quote_id(); + assert_ne!(a, b); + assert!(a.starts_with("q_")); + } + + #[test] + fn quote_store_round_trips_and_expires() { + reset_quote_store_for_tests(); + let now = now_ms(); + let mut q = PreparedTransaction { + quote_id: "q_test_1".to_string(), + kind: PreparedKind::NativeTransfer, + chain: WalletChain::Evm, + from_address: "0xfrom".to_string(), + to_address: "0xto".to_string(), + asset_symbol: "ETH".to_string(), + amount_raw: "1".to_string(), + amount_formatted: "0.000000000000000001".to_string(), + receive_symbol: None, + min_receive_raw: None, + calldata: None, + estimated_fee_raw: "0".to_string(), + status: PreparedStatus::AwaitingConfirmation, + created_at_ms: now, + expires_at_ms: now + 60_000, + notes: vec![], + }; + store_quote(q.clone()); + let taken = take_quote("q_test_1").expect("quote round-trips"); + assert_eq!(taken.quote_id, "q_test_1"); + assert!(take_quote("q_test_1").is_err(), "second take must fail"); + + // Expired quote: store and then try to take. + q.quote_id = "q_test_2".to_string(); + q.expires_at_ms = now.saturating_sub(1); + store_quote(q); + let err = take_quote("q_test_2").unwrap_err(); + assert!(err.contains("expired"), "got: {err}"); + } + + #[test] + fn execute_prepared_requires_confirmed_flag() { + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(async { + let err = execute_prepared(ExecutePreparedParams { + quote_id: "missing".to_string(), + confirmed: false, + }) + .await + .unwrap_err(); + assert!(err.contains("confirmed: true"), "got: {err}"); + }); + } + + #[test] + fn supported_assets_lists_four_natives() { + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(async { + let out = supported_assets().await.unwrap(); + assert_eq!(out.value.len(), 4); + assert!(out.value.iter().all(|a| a.native)); + }); + } + + #[test] + fn prepare_swap_rejects_same_symbol() { + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(async { + let err = prepare_swap(PrepareSwapParams { + chain: WalletChain::Evm, + from_symbol: "USDC".into(), + to_symbol: "usdc".into(), + amount_in_raw: "100".into(), + slippage_bps: 50, + router_address: "0xrouter".into(), + }) + .await + .unwrap_err(); + assert!(err.contains("must differ"), "got: {err}"); + }); + } + + #[test] + fn prepare_transfer_rejects_unsupported_asset_symbol() { + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(async { + let err = prepare_transfer(PrepareTransferParams { + chain: WalletChain::Evm, + to_address: "0xabc".into(), + amount_raw: "1".into(), + asset_symbol: Some("USDC".into()), + }) + .await + .unwrap_err(); + assert!(err.contains("unsupported asset_symbol"), "got: {err}"); + }); + } + + #[test] + fn prepare_contract_call_rejects_non_evm_chain() { + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(async { + let err = prepare_contract_call(PrepareContractCallParams { + chain: WalletChain::Btc, + contract_address: "addr".into(), + calldata: "0x".into(), + value_raw: "0".into(), + }) + .await + .unwrap_err(); + assert!(err.contains("only supported"), "got: {err}"); + }); + } +} diff --git a/src/openhuman/wallet/mod.rs b/src/openhuman/wallet/mod.rs index 8f0b82a20..ead24ae8f 100644 --- a/src/openhuman/wallet/mod.rs +++ b/src/openhuman/wallet/mod.rs @@ -1,8 +1,17 @@ -//! Core-owned wallet onboarding metadata and derived account visibility. +//! Core-owned wallet onboarding metadata, derived account visibility, and +//! the agent-facing execution surface (balances, transfers, swaps, +//! contract calls). See [`execution`] for the prepare/confirm/execute flow. +mod execution; mod ops; mod schemas; +pub use execution::{ + balances, chain_status, execute_prepared, prepare_contract_call, prepare_swap, + prepare_transfer, supported_assets, BalanceInfo, ChainStatus, ExecutePreparedParams, + PrepareContractCallParams, PrepareSwapParams, PrepareTransferParams, PreparedKind, + PreparedStatus, PreparedTransaction, ProviderStatus, ReadyToSign, SupportedAsset, +}; pub use ops::{ setup, status, WalletAccount, WalletChain, WalletSetupParams, WalletSetupSource, WalletStatus, }; diff --git a/src/openhuman/wallet/schemas.rs b/src/openhuman/wallet/schemas.rs index 2525a9950..3b9b431c5 100644 --- a/src/openhuman/wallet/schemas.rs +++ b/src/openhuman/wallet/schemas.rs @@ -4,6 +4,11 @@ use serde_json::{Map, Value}; use crate::core::all::{ControllerFuture, RegisteredController}; use crate::core::{ControllerSchema, FieldSchema, TypeSchema}; +use super::execution::{ + balances, chain_status, execute_prepared, prepare_contract_call, prepare_swap, + prepare_transfer, supported_assets, ExecutePreparedParams, PrepareContractCallParams, + PrepareSwapParams, PrepareTransferParams, +}; use super::ops::{WalletAccount, WalletSetupParams, WalletSetupSource}; #[derive(Debug, Deserialize)] @@ -28,7 +33,17 @@ pub fn schemas(function: &str) -> ControllerSchema { } pub fn all_wallet_controller_schemas() -> Vec { - vec![wallet_schemas("status"), wallet_schemas("setup")] + vec![ + wallet_schemas("status"), + wallet_schemas("setup"), + wallet_schemas("balances"), + wallet_schemas("supported_assets"), + wallet_schemas("chain_status"), + wallet_schemas("prepare_transfer"), + wallet_schemas("prepare_swap"), + wallet_schemas("prepare_contract_call"), + wallet_schemas("execute_prepared"), + ] } pub fn all_wallet_registered_controllers() -> Vec { @@ -41,6 +56,34 @@ pub fn all_wallet_registered_controllers() -> Vec { schema: wallet_schemas("setup"), handler: handle_setup, }, + RegisteredController { + schema: wallet_schemas("balances"), + handler: handle_balances, + }, + RegisteredController { + schema: wallet_schemas("supported_assets"), + handler: handle_supported_assets, + }, + RegisteredController { + schema: wallet_schemas("chain_status"), + handler: handle_chain_status, + }, + RegisteredController { + schema: wallet_schemas("prepare_transfer"), + handler: handle_prepare_transfer, + }, + RegisteredController { + schema: wallet_schemas("prepare_swap"), + handler: handle_prepare_swap, + }, + RegisteredController { + schema: wallet_schemas("prepare_contract_call"), + handler: handle_prepare_contract_call, + }, + RegisteredController { + schema: wallet_schemas("execute_prepared"), + handler: handle_execute_prepared, + }, ] } @@ -82,6 +125,127 @@ pub fn wallet_schemas(function: &str) -> ControllerSchema { required: true, }], }, + "balances" => ControllerSchema { + namespace: "wallet", + function: "balances", + description: + "List native-asset balances for every derived wallet account. Each row carries a providerStatus indicating whether a chain RPC is configured.", + inputs: vec![], + outputs: vec![FieldSchema { + name: "result", + ty: TypeSchema::Json, + comment: "Array of balance rows: {chain, address, assetSymbol, decimals, raw, formatted, providerStatus}.", + required: true, + }], + }, + "supported_assets" => ControllerSchema { + namespace: "wallet", + function: "supported_assets", + description: + "Catalog of natively supported assets (one native per chain) the wallet surface understands.", + inputs: vec![], + outputs: vec![FieldSchema { + name: "result", + ty: TypeSchema::Json, + comment: "Array of {chain, symbol, name, native, decimals}.", + required: true, + }], + }, + "chain_status" => ControllerSchema { + namespace: "wallet", + function: "chain_status", + description: + "Per-chain readiness: whether a wallet account is derived and whether an RPC provider is configured.", + inputs: vec![], + outputs: vec![FieldSchema { + name: "result", + ty: TypeSchema::Json, + comment: "Array of {chain, configured, providerStatus} (providerStatus ∈ ready|unconfigured|missing).", + required: true, + }], + }, + "prepare_transfer" => ControllerSchema { + namespace: "wallet", + function: "prepare_transfer", + description: + "Build a simulated native or token-transfer quote. Returns a quoteId; call wallet.execute_prepared with confirmed=true to forward to the keystore.", + inputs: vec![ + required_json("chain", "Target chain (evm | btc | solana | tron)."), + required_json("toAddress", "Recipient address on the target chain."), + required_json("amountRaw", "Amount in the asset's smallest unit (wei/sat/lamport) as a decimal string."), + FieldSchema { + name: "assetSymbol", + ty: TypeSchema::Option(Box::new(TypeSchema::String)), + comment: "Optional. Omit / null for the chain's native asset; otherwise a token symbol from wallet.supported_assets.", + required: false, + }, + ], + outputs: vec![FieldSchema { + name: "result", + ty: TypeSchema::Json, + comment: "PreparedTransaction with quoteId, simulated fee, and expiry.", + required: true, + }], + }, + "prepare_swap" => ControllerSchema { + namespace: "wallet", + function: "prepare_swap", + description: + "Build a swap quote against a router/aggregator. Caller selects the router; this layer enforces simulation and a minimum-out floor.", + inputs: vec![ + required_json("chain", "Target chain (evm | btc | solana | tron)."), + required_json("fromSymbol", "Asset symbol being sold."), + required_json("toSymbol", "Asset symbol being bought (must differ from fromSymbol)."), + required_json("amountInRaw", "Input amount in the from-asset's smallest unit, as a decimal string."), + required_json("slippageBps", "Slippage tolerance in basis points (max 5000 = 50%)."), + required_json("routerAddress", "Router / aggregator contract address."), + ], + outputs: vec![FieldSchema { + name: "result", + ty: TypeSchema::Json, + comment: "PreparedTransaction with quoteId, receiveSymbol, minReceiveRaw.", + required: true, + }], + }, + "prepare_contract_call" => ControllerSchema { + namespace: "wallet", + function: "prepare_contract_call", + description: + "Build a contract-call simulation quote (EVM and Tron). Caller supplies pre-encoded calldata.", + inputs: vec![ + required_json("chain", "Target chain (evm | tron). Other chains reject."), + required_json("contractAddress", "Target contract address."), + required_json("calldata", "0x-prefixed hex calldata."), + FieldSchema { + name: "valueRaw", + ty: TypeSchema::Option(Box::new(TypeSchema::String)), + comment: "Native value attached, smallest unit. Defaults to '0'.", + required: false, + }, + ], + outputs: vec![FieldSchema { + name: "result", + ty: TypeSchema::Json, + comment: "PreparedTransaction with calldata, quoteId, and simulated fee.", + required: true, + }], + }, + "execute_prepared" => ControllerSchema { + namespace: "wallet", + function: "execute_prepared", + description: + "Confirm a previously prepared quote and hand it off to the local keystore for signing. Requires confirmed=true; signing happens in the desktop shell, never in core.", + inputs: vec![ + required_json("quoteId", "quoteId returned by a prior wallet.prepare_* call."), + required_json("confirmed", "Must be true; explicit safety boundary between simulate and execute."), + ], + outputs: vec![FieldSchema { + name: "result", + ty: TypeSchema::Json, + comment: "ReadyToSign payload: {quoteId, status, chain, transaction}.", + required: true, + }], + }, _ => ControllerSchema { namespace: "wallet", function: "unknown", @@ -120,6 +284,52 @@ fn handle_setup(params: Map) -> ControllerFuture { }) } +fn handle_balances(_params: Map) -> ControllerFuture { + Box::pin(async move { balances().await?.into_cli_compatible_json() }) +} + +fn handle_supported_assets(_params: Map) -> ControllerFuture { + Box::pin(async move { supported_assets().await?.into_cli_compatible_json() }) +} + +fn handle_chain_status(_params: Map) -> ControllerFuture { + Box::pin(async move { chain_status().await?.into_cli_compatible_json() }) +} + +fn handle_prepare_transfer(params: Map) -> ControllerFuture { + Box::pin(async move { + let parsed: PrepareTransferParams = serde_json::from_value(Value::Object(params)) + .map_err(|e| format!("invalid params: {e}"))?; + prepare_transfer(parsed).await?.into_cli_compatible_json() + }) +} + +fn handle_prepare_swap(params: Map) -> ControllerFuture { + Box::pin(async move { + let parsed: PrepareSwapParams = serde_json::from_value(Value::Object(params)) + .map_err(|e| format!("invalid params: {e}"))?; + prepare_swap(parsed).await?.into_cli_compatible_json() + }) +} + +fn handle_prepare_contract_call(params: Map) -> ControllerFuture { + Box::pin(async move { + let parsed: PrepareContractCallParams = serde_json::from_value(Value::Object(params)) + .map_err(|e| format!("invalid params: {e}"))?; + prepare_contract_call(parsed) + .await? + .into_cli_compatible_json() + }) +} + +fn handle_execute_prepared(params: Map) -> ControllerFuture { + Box::pin(async move { + let parsed: ExecutePreparedParams = serde_json::from_value(Value::Object(params)) + .map_err(|e| format!("invalid params: {e}"))?; + execute_prepared(parsed).await?.into_cli_compatible_json() + }) +} + fn required_json(name: &'static str, comment: &'static str) -> FieldSchema { FieldSchema { name, @@ -134,13 +344,13 @@ mod tests { use super::*; #[test] - fn all_schemas_returns_two() { - assert_eq!(all_wallet_controller_schemas().len(), 2); + fn all_schemas_lists_every_controller() { + assert_eq!(all_wallet_controller_schemas().len(), 9); } #[test] - fn all_controllers_returns_two() { - assert_eq!(all_wallet_registered_controllers().len(), 2); + fn all_controllers_lists_every_handler() { + assert_eq!(all_wallet_registered_controllers().len(), 9); } #[test] @@ -158,6 +368,24 @@ mod tests { assert!(schema.inputs.iter().all(|field| field.required)); } + #[test] + fn execute_prepared_schema_takes_quote_id_and_confirmed() { + let schema = wallet_schemas("execute_prepared"); + let names: Vec<&str> = schema.inputs.iter().map(|f| f.name).collect(); + assert_eq!(names, vec!["quoteId", "confirmed"]); + } + + #[test] + fn prepare_transfer_schema_marks_asset_symbol_optional() { + let schema = wallet_schemas("prepare_transfer"); + let asset = schema + .inputs + .iter() + .find(|f| f.name == "assetSymbol") + .expect("assetSymbol input present"); + assert!(!asset.required); + } + #[test] fn unknown_schema_maps_to_unknown() { let schema = wallet_schemas("wat"); diff --git a/tests/json_rpc_e2e.rs b/tests/json_rpc_e2e.rs index 318b73f28..7bae66815 100644 --- a/tests/json_rpc_e2e.rs +++ b/tests/json_rpc_e2e.rs @@ -1977,6 +1977,167 @@ async fn json_rpc_wallet_setup_round_trips_status() { rpc_join.abort(); } +/// #1396 — wallet execution surface: balances/supported_assets/chain_status +/// read tools, prepare_transfer + execute_prepared write boundary. +#[tokio::test] +async fn json_rpc_wallet_execution_surface_round_trips() { + let _env_lock = json_rpc_e2e_env_lock(); + let tmp = tempdir().expect("tempdir"); + let home = tmp.path(); + let openhuman_home = home.join(".openhuman"); + + let _home_guard = EnvVarGuard::set_to_path("HOME", home); + let _workspace_guard = EnvVarGuard::unset("OPENHUMAN_WORKSPACE"); + let _backend_url_guard = EnvVarGuard::unset("BACKEND_URL"); + let _vite_backend_guard = EnvVarGuard::unset("VITE_BACKEND_URL"); + let _evm_provider_guard = EnvVarGuard::unset("OPENHUMAN_WALLET_RPC_EVM"); + let _btc_provider_guard = EnvVarGuard::unset("OPENHUMAN_WALLET_RPC_BTC"); + let _sol_provider_guard = EnvVarGuard::unset("OPENHUMAN_WALLET_RPC_SOLANA"); + let _tron_provider_guard = EnvVarGuard::unset("OPENHUMAN_WALLET_RPC_TRON"); + + let (mock_addr, mock_join) = serve_on_ephemeral(mock_upstream_router()).await; + let mock_origin = format!("http://{}", mock_addr); + write_min_config(&openhuman_home, &mock_origin); + + let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await; + let rpc_base = format!("http://{}", rpc_addr); + tokio::time::sleep(Duration::from_millis(100)).await; + + // Configure wallet (required precondition for balances / prepare_*). + let setup = post_json_rpc( + &rpc_base, + 2001, + "openhuman.wallet_setup", + json!({ + "consentGranted": true, + "source": "imported", + "mnemonicWordCount": 12, + "accounts": [ + { "chain": "evm", "address": "0x9858EfFD232B4033E47d90003D41EC34EcaEda94", "derivationPath": "m/44'/60'/0'/0/0" }, + { "chain": "btc", "address": "1LqBGSKuX5yYUonjxT5qGfpUsXKYYWeabA", "derivationPath": "m/44'/0'/0'/0/0" }, + { "chain": "solana", "address": "HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk", "derivationPath": "m/44'/501'/0'/0'" }, + { "chain": "tron", "address": "TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH", "derivationPath": "m/44'/195'/0'/0/0" } + ] + }), + ) + .await; + assert_no_jsonrpc_error(&setup, "wallet_setup_for_execution"); + + // supported_assets: 4 natives. + let assets = post_json_rpc( + &rpc_base, + 2002, + "openhuman.wallet_supported_assets", + json!({}), + ) + .await; + let body = assert_no_jsonrpc_error(&assets, "wallet_supported_assets"); + let result = body.get("result").unwrap_or(&body); + let list = result.as_array().expect("supported_assets array"); + assert_eq!(list.len(), 4, "expected four native assets: {result}"); + + // chain_status: every chain configured but providers unconfigured. + let cs = post_json_rpc(&rpc_base, 2003, "openhuman.wallet_chain_status", json!({})).await; + let body = assert_no_jsonrpc_error(&cs, "wallet_chain_status"); + let result = body.get("result").unwrap_or(&body); + let rows = result.as_array().expect("chain_status array"); + assert_eq!(rows.len(), 4); + assert!( + rows.iter() + .all(|r| r.get("providerStatus").and_then(Value::as_str) == Some("unconfigured")), + "expected providerStatus=unconfigured for every row: {result}" + ); + + // balances: zero placeholders for each derived account. + let balances = post_json_rpc(&rpc_base, 2004, "openhuman.wallet_balances", json!({})).await; + let body = assert_no_jsonrpc_error(&balances, "wallet_balances"); + let result = body.get("result").unwrap_or(&body); + let rows = result.as_array().expect("balances array"); + assert_eq!(rows.len(), 4); + assert!(rows + .iter() + .all(|r| r.get("raw").and_then(Value::as_str) == Some("0"))); + + // prepare_transfer + execute_prepared (happy path). + let prep = post_json_rpc( + &rpc_base, + 2005, + "openhuman.wallet_prepare_transfer", + json!({ + "chain": "evm", + "toAddress": "0x000000000000000000000000000000000000dEaD", + "amountRaw": "1000000000000000", + }), + ) + .await; + let body = assert_no_jsonrpc_error(&prep, "wallet_prepare_transfer"); + let result = body.get("result").unwrap_or(&body); + let quote_id = result + .get("quoteId") + .and_then(Value::as_str) + .expect("quoteId present") + .to_string(); + assert_eq!( + result.get("status").and_then(Value::as_str), + Some("awaiting_confirmation"), + ); + assert_eq!( + result.get("kind").and_then(Value::as_str), + Some("native_transfer"), + ); + + // execute_prepared without confirmed=true must fail. + let bad = post_json_rpc( + &rpc_base, + 2006, + "openhuman.wallet_execute_prepared", + json!({ "quoteId": quote_id, "confirmed": false }), + ) + .await; + assert!( + bad.get("error").is_some(), + "expected error for unconfirmed execute: {bad}" + ); + + // Confirmed execute moves the quote to ReadyToSign and consumes it. + let exec = post_json_rpc( + &rpc_base, + 2007, + "openhuman.wallet_execute_prepared", + json!({ "quoteId": quote_id, "confirmed": true }), + ) + .await; + let body = assert_no_jsonrpc_error(&exec, "wallet_execute_prepared"); + let result = body.get("result").unwrap_or(&body); + assert_eq!( + result.get("status").and_then(Value::as_str), + Some("ready_to_sign"), + ); + assert_eq!( + result + .get("transaction") + .and_then(|t| t.get("quoteId")) + .and_then(Value::as_str), + Some(quote_id.as_str()), + ); + + // A second execute on the same quote must fail (quote consumed). + let dup = post_json_rpc( + &rpc_base, + 2008, + "openhuman.wallet_execute_prepared", + json!({ "quoteId": quote_id, "confirmed": true }), + ) + .await; + assert!( + dup.get("error").is_some(), + "expected error re-executing consumed quote: {dup}" + ); + + mock_join.abort(); + rpc_join.abort(); +} + /// #883 — when `chat_onboarding_completed` is unset in config.toml (fresh /// user), the `openhuman.app_state_snapshot` RPC must surface the flag as /// `false` so the React welcome-lockdown kicks in.