mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-29 22:23:01 +00:00
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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<Mutex<Vec<PreparedTransaction>>> = 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<String>,
|
||||
/// For swaps only — minimum amount out (raw integer string).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub min_receive_raw: Option<String>,
|
||||
/// For contract calls only — encoded calldata (hex, 0x-prefixed).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub calldata: Option<String>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
}
|
||||
|
||||
#[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<WalletAccount, String> {
|
||||
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<u128, String> {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err("amount is empty".to_string());
|
||||
}
|
||||
trimmed
|
||||
.parse::<u128>()
|
||||
.map_err(|_| format!("amount '{trimmed}' is not a valid non-negative integer"))
|
||||
}
|
||||
|
||||
fn validate_address(addr: &str) -> Result<String, String> {
|
||||
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<String, String> {
|
||||
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<PreparedTransaction, String> {
|
||||
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<RpcOutcome<Vec<SupportedAsset>>, String> {
|
||||
let assets: Vec<SupportedAsset> = [
|
||||
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<RpcOutcome<Vec<ChainStatus>>, 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<RpcOutcome<Vec<BalanceInfo>>, 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<RpcOutcome<PreparedTransaction>, 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<RpcOutcome<PreparedTransaction>, 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<RpcOutcome<PreparedTransaction>, 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<RpcOutcome<ReadyToSign>, 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}");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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<ControllerSchema> {
|
||||
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<RegisteredController> {
|
||||
@@ -41,6 +56,34 @@ pub fn all_wallet_registered_controllers() -> Vec<RegisteredController> {
|
||||
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<String, Value>) -> ControllerFuture {
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_balances(_params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move { balances().await?.into_cli_compatible_json() })
|
||||
}
|
||||
|
||||
fn handle_supported_assets(_params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move { supported_assets().await?.into_cli_compatible_json() })
|
||||
}
|
||||
|
||||
fn handle_chain_status(_params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move { chain_status().await?.into_cli_compatible_json() })
|
||||
}
|
||||
|
||||
fn handle_prepare_transfer(params: Map<String, Value>) -> 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<String, Value>) -> 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<String, Value>) -> 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<String, Value>) -> 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");
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user