feat(web3): add swap/bridge/dapp module; slim wallet to primitives (#3006)

This commit is contained in:
Steven Enamakel
2026-05-30 00:09:46 -07:00
committed by GitHub
parent c7e9d85f8f
commit 51d5326322
37 changed files with 3537 additions and 377 deletions
+6
View File
@@ -226,6 +226,8 @@ fn build_registered_controllers() -> Vec<RegisteredController> {
controllers.extend(crate::openhuman::test_support::all_test_support_registered_controllers());
// Local wallet metadata and onboarding status
controllers.extend(crate::openhuman::wallet::all_wallet_registered_controllers());
// High-level web3 surface (swaps / bridges / dapp calls) over the wallet
controllers.extend(crate::openhuman::web3::all_web3_registered_controllers());
// Local assistive surfaces over third-party provider apps
controllers.extend(
crate::openhuman::provider_surfaces::all_provider_surfaces_registered_controllers(),
@@ -352,6 +354,7 @@ fn build_declared_controller_schemas() -> Vec<ControllerSchema> {
#[cfg(feature = "e2e-test-support")]
schemas.extend(crate::openhuman::test_support::all_test_support_controller_schemas());
schemas.extend(crate::openhuman::wallet::all_wallet_controller_schemas());
schemas.extend(crate::openhuman::web3::all_web3_controller_schemas());
schemas.extend(crate::openhuman::provider_surfaces::all_provider_surfaces_controller_schemas());
schemas.extend(crate::openhuman::text_input::all_text_input_controller_schemas());
schemas.extend(crate::openhuman::voice::all_voice_controller_schemas());
@@ -464,6 +467,9 @@ pub fn namespace_description(namespace: &str) -> Option<&'static str> {
"E2E test support — wipe sidecar state in-place between specs.",
),
"wallet" => Some("Local wallet onboarding status and derived multi-chain account metadata."),
"web3_swap" => Some("Single-chain crypto swaps via deBridge, built on the local wallet."),
"web3_bridge" => Some("Cross-chain crypto bridges via deBridge DLN, built on the local wallet."),
"web3_dapp" => Some("Generic EVM dapp contract calls signed by the local wallet."),
"provider_surfaces" => Some(
"Local-first assistive surfaces for provider events, respond queues, and drafts.",
),
+12 -2
View File
@@ -607,8 +607,18 @@ pub(super) const CAPABILITIES: &[Capability] = &[
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, or via Settings > Wallet Balances.",
description: "Read addresses and balances, prepare/confirm/execute native + token transfers (ERC20/SPL/TRC20/BEP20), and inspect transactions (status, receipt, lookup) across the connected wallet (EVM, BTC, Solana, Tron). Quote-first; signing stays local.",
how_to: "Use wallet.* RPC methods (balances, prepare_transfer, execute_prepared, tx_status, tx_receipt, lookup_tx) via the agent or core_rpc_relay, or via Settings > Wallet Balances.",
status: CapabilityStatus::Beta,
privacy: LOCAL_CREDENTIALS,
},
Capability {
id: "skills.web3_defi",
name: "Web3 Swaps & Bridges",
domain: "web3",
category: CapabilityCategory::Skills,
description: "Quote and execute cross-chain swaps and bridges (deBridge) plus generic EVM dapp contract calls, built on the local wallet's signing. EVM/Solana(/BTC); signing stays local.",
how_to: "Use web3_swap.* / web3_bridge.* / web3_dapp.* RPC methods (quote/execute, web3_swap.routes) via the agent or core_rpc_relay.",
status: CapabilityStatus::Beta,
privacy: LOCAL_CREDENTIALS,
},
@@ -19,15 +19,15 @@ omit_skills_catalog = true
hint = "agentic"
[tools]
# Narrow allowlist. Wallet + market primitives only — no shell, no
# file_write, no broad HTTP, no integration delegation. Names line up
# Narrow allowlist. Wallet + web3 + market primitives only — no shell, no
# file_write, no broad HTTP, no integration delegation. Wallet names line up
# with the wallet RPC controllers in `src/openhuman/wallet/schemas.rs`
# (read: status/balances/supported_assets/chain_status; quote/prepare:
# prepare_transfer/prepare_swap/prepare_contract_call; execute:
# execute_prepared) and the financial-apis crypto/FX series exposed
# via `stock_*`. Tools that aren't yet registered are silently dropped
# by the tool filter at spawn time, so this list also describes the
# agent's *intended* tool surface as wallet + exchange tools land.
# (read: status/balances/supported_assets/chain_status + tx_status/tx_receipt/
# lookup_tx; quote/prepare: prepare_transfer; execute: execute_prepared). The
# higher-level swap/bridge/dapp surface lives in the `web3_*` tools, which
# build on the wallet's signing primitives. Tools that aren't yet registered
# are silently dropped by the tool filter at spawn time, so this list also
# describes the agent's *intended* tool surface as wallet + exchange tools land.
named = [
# Read-only wallet inspection.
"wallet_status",
@@ -36,16 +36,28 @@ named = [
"wallet_supported_assets",
"wallet_chain_status",
"wallet_encode_erc20_transfer",
# Quote / prepare. These MUST be called before any execute_prepared
# — they return a deterministic transaction blob plus fee/slippage
# estimates the agent shows the user for confirmation.
# Transaction inspection — status / receipt / raw lookup by hash.
"wallet_tx_status",
"wallet_tx_receipt",
"wallet_lookup_tx",
# Quote / prepare a native or token transfer. MUST be called before
# wallet_execute_prepared — returns a deterministic transaction blob
# plus fee estimate the agent shows the user for confirmation.
"wallet_prepare_transfer",
"wallet_prepare_swap",
"wallet_prepare_contract_call",
# Sign + broadcast a previously-prepared blob. Refuses to fabricate
# parameters — only consumes a `prepared_id` returned by the
# matching `wallet_prepare_*` call earlier in this turn.
# parameters — only consumes a `quoteId` returned by the matching
# wallet_prepare_* call earlier in this turn.
"wallet_execute_prepared",
# High-level web3: swaps (same-chain), bridges (cross-chain), and
# generic EVM dapp calls. Each quote/call returns a quoteId that MUST
# be confirmed via the matching web3_*_execute before broadcast.
"web3_swap_routes",
"web3_swap_quote",
"web3_swap_execute",
"web3_bridge_quote",
"web3_bridge_execute",
"web3_dapp_call",
"web3_dapp_execute",
# Market data — crypto series, FX rates, commodities — for grounding
# quote sanity checks and price questions before any execute step.
"stock_quote",
+15 -4
View File
@@ -652,17 +652,26 @@ mod tests {
"crypto_agent needs read tool `{required}`"
);
}
// Quote / prepare surface.
// Quote / prepare surface: native+token transfers on the
// wallet, swaps/bridges/dapp calls on the web3 layer.
for required in [
"wallet_prepare_transfer",
"wallet_prepare_swap",
"wallet_prepare_contract_call",
"web3_swap_quote",
"web3_bridge_quote",
"web3_dapp_call",
] {
assert!(
tools.iter().any(|t| t == required),
"crypto_agent needs prepare tool `{required}`"
);
}
// Transaction inspection surface.
for required in ["wallet_tx_status", "wallet_tx_receipt", "wallet_lookup_tx"] {
assert!(
tools.iter().any(|t| t == required),
"crypto_agent needs tx-read tool `{required}`"
);
}
// Execute surface — gated by the prepared blob from a
// matching prepare_* call in the same turn.
assert!(
@@ -805,7 +814,9 @@ mod tests {
"delegate_plan",
"wallet_execute_prepared",
"wallet_prepare_transfer",
"wallet_prepare_swap",
"web3_swap_execute",
"web3_bridge_execute",
"web3_dapp_execute",
] {
assert!(
!tools.iter().any(|t| t == forbidden),
+1
View File
@@ -108,6 +108,7 @@ pub mod util;
pub mod vault;
pub mod voice;
pub mod wallet;
pub mod web3;
pub mod webhooks;
pub mod webview_accounts;
pub mod webview_apis;
+8
View File
@@ -188,6 +188,9 @@ pub fn all_tools_with_runtime(
Box::new(WalletStatusTool::new()),
Box::new(WalletChainStatusTool::new()),
Box::new(WalletPrepareTransferTool::new()),
Box::new(WalletTxStatusTool::new()),
Box::new(WalletTxReceiptTool::new()),
Box::new(WalletLookupTxTool::new()),
Box::new(MemoryStoreTool::new(memory.clone(), security.clone())),
Box::new(MemoryRecallTool::new(memory.clone())),
Box::new(MemoryForgetTool::new(memory.clone(), security.clone())),
@@ -353,6 +356,11 @@ pub fn all_tools_with_runtime(
tools.extend(crate::openhuman::search::build_search_tools(root_config));
// High-level web3 tools (swaps / bridges / dapp calls) built on the wallet.
// They call the backend deBridge proxy per-invocation and error gracefully
// when the user is not signed in, so they register unconditionally.
tools.extend(crate::openhuman::web3::all_web3_agent_tools());
// Managed Node.js exec tools — gated on `root_config.node.enabled`.
// Both share the same `NodeBootstrap` as ShellTool so the download +
// extract + install pipeline runs at most once per session.
+18 -11
View File
@@ -1,6 +1,8 @@
# wallet
Core-owned local multi-chain crypto wallet. Owns onboarding metadata (consent + derived per-chain account addresses), secret material at rest (an encrypted recovery phrase stored in the OS keychain or workspace JSON), and the agent-facing **execution surface**: balance reads, network/asset catalogs, chain readiness, and a prepare-then-confirm-then-execute flow for native sends, token transfers, swaps, and contract calls across EVM (Ethereum + Base/Arbitrum/Optimism/Polygon L2s), Bitcoin (P2WPKH), Solana (native + SPL), and Tron (native + TRC20). Signing and broadcast happen in-core from the decrypted recovery phrase; no private keys ever cross the wire. Swaps are quote-only on every chain.
Core-owned local multi-chain crypto wallet, deliberately **basic**: key/account management plus the primitive on-chain operations. Owns onboarding metadata (consent + derived per-chain account addresses), secret material at rest (an encrypted recovery phrase stored in the OS keychain or workspace JSON), and the agent-facing surface: address + balance reads, network/asset catalogs, chain readiness, a prepare-then-confirm-then-execute flow for **native sends and token transfers** (ERC20 / SPL / TRC20 / BEP20), transaction broadcast, and read-only transaction inspection (status, receipt, lookup) across EVM (Ethereum + Base/Arbitrum/Optimism/Polygon/BNB Chain), Bitcoin (P2WPKH), Solana (native + SPL), and Tron (native + TRC20). Signing and broadcast happen in-core from the decrypted recovery phrase; no private keys ever cross the wire.
Higher-level DeFi affordances (swaps, bridges, generic dapp/contract calls) live in the separate [`web3`](../web3/README.md) module, which builds on the wallet's **crate-internal** `sign_and_broadcast_evm` / `sign_and_broadcast_solana` primitives. They are not part of the wallet's agent / RPC surface.
## Responsibilities
@@ -10,7 +12,8 @@ Core-owned local multi-chain crypto wallet. Owns onboarding metadata (consent +
- Build prepared-transaction quotes (validated, fee-estimated, TTL'd) that must be explicitly confirmed before execution.
- Sign and broadcast confirmed quotes per chain; restore (and TTL-refresh) the quote on failure so it stays retryable.
- Bind each quote to the chat thread that prepared it so a leaked `quote_id` in a shared channel can't be hijacked from another agent session.
- Expose three agent tools (`wallet_status`, `wallet_chain_status`, `wallet_prepare_transfer`) and eleven `wallet.*` RPC controllers.
- Expose six agent tools (`wallet_status`, `wallet_chain_status`, `wallet_prepare_transfer`, `wallet_tx_status`, `wallet_tx_receipt`, `wallet_lookup_tx`) and twelve `wallet.*` RPC controllers.
- Provide crate-internal `sign_and_broadcast_evm` / `sign_and_broadcast_solana` primitives for the `web3` layer (sign+broadcast an externally-built unsigned transaction). Not exposed to the agent / RPC surface.
## Key files
@@ -18,7 +21,7 @@ Core-owned local multi-chain crypto wallet. Owns onboarding metadata (consent +
| --- | --- |
| `src/openhuman/wallet/mod.rs` | Export-focused module root; module docstring, `mod`/`pub use` re-exports. |
| `src/openhuman/wallet/ops.rs` | Onboarding metadata + secret persistence: `WalletChain`/`WalletAccount`/`WalletStatus` types, `setup`/`status`, atomic `wallet-state.json` writes (temp-file + fsync), corrupt-state quarantine, keychain load/save/migrate, `validate_setup`, and `secret_material` (crate-internal) used by chain signers. |
| `src/openhuman/wallet/execution.rs` | Execution surface: balances/network_defaults/supported_assets/chain_status reads, `prepare_transfer`/`prepare_swap`/`prepare_contract_call`/`execute_prepared`, the in-memory quote store (TTL'd, capped at 64), `QuoteOwner` chat-thread binding, amount/address/calldata validation, fee estimation, hex/u256 helpers. |
| `src/openhuman/wallet/execution.rs` | Execution surface: balances/network_defaults/supported_assets/chain_status reads, `prepare_transfer`/`execute_prepared` (native + token transfers only), `tx_status`/`tx_receipt`/`lookup_tx` readers, the crate-internal `sign_and_broadcast_evm`/`sign_and_broadcast_solana` re-exports, the in-memory quote store (TTL'd, capped at 64), `QuoteOwner` chat-thread binding, amount/address/calldata validation, fee estimation, hex/u256 helpers. |
| `src/openhuman/wallet/defaults.rs` | `EvmNetwork` enum (chain id, default RPC, explorer base, env var), default RPC/REST URLs for BTC/Solana/Tron, env-override resolution, and per-chain/per-network asset catalogs. |
| `src/openhuman/wallet/abi.rs` | `encode_erc20_transfer` — encodes `transfer(address,uint256)` calldata via `ethers_core::abi`. |
| `src/openhuman/wallet/schemas.rs` | RPC controller schemas + `handle_*` dispatchers delegating to `ops`/`execution`; `all_wallet_controller_schemas` / `all_wallet_registered_controllers`. |
@@ -38,28 +41,29 @@ Core-owned local multi-chain crypto wallet. Owns onboarding metadata (consent +
From `mod.rs` re-exports:
- **Onboarding (`ops`)**: `setup`, `status`, `WalletAccount`, `WalletChain`, `WalletSetupParams`, `WalletSetupSource`, `WalletStatus`; `pub(crate) secret_material`.
- **Execution (`execution`)**: `balances`, `chain_status`, `execute_prepared`, `wallet_network_defaults`, `prepare_contract_call`, `prepare_swap`, `prepare_transfer`, `supported_assets`, `prepared_quotes_for_test`; types `BalanceInfo`, `ChainStatus`, `ExecutePreparedParams`, `ExecutionResult`, `PrepareContractCallParams`, `PrepareSwapParams`, `PrepareTransferParams`, `PreparedKind`, `PreparedStatus`, `PreparedTransaction`, `ProviderStatus`, `SupportedAsset`.
- **Execution (`execution`)**: `balances`, `chain_status`, `execute_prepared`, `wallet_network_defaults`, `prepare_transfer`, `tx_status`, `tx_receipt`, `lookup_tx`, `supported_assets`, `prepared_quotes_for_test`; types `BalanceInfo`, `ChainStatus`, `ExecutePreparedParams`, `ExecutionResult`, `PrepareTransferParams`, `PreparedKind` (NativeTransfer / TokenTransfer), `PreparedStatus`, `PreparedTransaction`, `ProviderStatus`, `SupportedAsset`, `TxState`, `TxStatusInfo`, `TxReceiptInfo`, `TxLookupInfo`. Crate-internal: `sign_and_broadcast_evm`, `sign_and_broadcast_solana`, `RawBroadcastResult`.
- **Defaults (`defaults`)**: `asset_catalog`, `default_rpc_url`, `env_var_for_chain`, `evm_asset_catalog`, `explorer_tx_url`, `find_asset`, `find_asset_for_network`, `network_defaults`, `rpc_source_for_chain`, `rpc_url_for_chain`, `rpc_url_for_evm_network`, `EvmNetwork`, `RpcSource`, `WalletAssetDefinition`, `WalletNetworkDefaults`.
- **ABI**: `encode_erc20_transfer`.
- **Schemas**: `all_controller_schemas`, `all_registered_controllers`, `all_wallet_controller_schemas`, `all_wallet_registered_controllers`, `schemas`, `wallet_schemas`.
## RPC / controllers
Namespace `wallet` (method form `openhuman.wallet_<function>`), 11 controllers registered via `all_wallet_registered_controllers`:
Namespace `wallet` (method form `openhuman.wallet_<function>`), 12 controllers registered via `all_wallet_registered_controllers`:
| Function | Purpose |
| --- | --- |
| `status` | Onboarding status + safe account metadata. |
| `status` | Onboarding status + safe account metadata (addresses). |
| `setup` | Persist consent + derived accounts + encrypted mnemonic (all inputs required). |
| `balances` | Native-asset balances per account (EVM live; others provider-gated). |
| `network_defaults` | RPC/explorer/capability flags + asset catalogs per chain. |
| `supported_assets` | Built-in asset catalog incl. default EVM ERC-20s. |
| `supported_assets` | Built-in asset catalog incl. default EVM ERC-20s / BEP20s. |
| `encode_erc20_transfer` | Encode `transfer(address,uint256)` calldata (EVM only). |
| `chain_status` | Per-chain readiness + active RPC URL. |
| `prepare_transfer` | Quote a native/token transfer (all four chains). |
| `prepare_swap` | Quote a swap against a caller-supplied router (≤5000 bps slippage). |
| `prepare_contract_call` | Quote an EVM contract call from caller calldata. |
| `execute_prepared` | Confirm (`confirmed: true`) + execute a quote by `quoteId`. |
| `execute_prepared` | Confirm (`confirmed: true`) + execute a quote by `quoteId` (tx send). |
| `tx_status` | Check a transaction's lifecycle state (pending/confirmed/failed/not_found) by hash. |
| `tx_receipt` | Fetch a transaction receipt (success, fee, block) by hash. |
| `lookup_tx` | Look up the raw transaction payload by hash. |
Wired into the registry in `src/core/all.rs` (controllers + schemas + capability description).
@@ -69,6 +73,9 @@ Owned in `tools/`, re-exported via `tools.rs`:
- `WalletStatusTool``wallet_status`
- `WalletChainStatusTool``wallet_chain_status`
- `WalletPrepareTransferTool``wallet_prepare_transfer`
- `WalletTxStatusTool``wallet_tx_status`
- `WalletTxReceiptTool``wallet_tx_receipt`
- `WalletLookupTxTool``wallet_lookup_tx`
All implement `crate::openhuman::tools::traits::Tool` and delegate to the matching `wallet::*` functions. (There is no agent tool for `execute_prepared` here; execution is reached via RPC.)
@@ -107,6 +114,6 @@ None. The module publishes/subscribes no `DomainEvent`s and has no `bus.rs`. Cha
- **Quote-owner binding** (`execution.rs`): `execute_prepared` only runs when the caller's `current_owner()` equals the prepare-time owner. On mismatch it returns the byte-identical `quote '…' not found` error as a true miss — no enumeration oracle. Non-chat callers (CLI / direct RPC / background/cron) have `owner == None` and can only execute quotes they also prepared with no chat context. `current_owner()` relies on the inline `.await` chain in `channels/providers/web.rs::run_chat_task`; detaching the tool loop onto a fresh `tokio::spawn` without re-scoping `APPROVAL_CHAT_CONTEXT` would silently disable the gate.
- **Quotes are consumed atomically**: `take_quote_for` removes the quote before broadcast so concurrent confirmations can't double-submit; on failure the quote is restored with a refreshed TTL.
- **Setup requires exactly one account per chain** (EVM, BTC, Solana, Tron) and a non-empty encrypted mnemonic; valid mnemonic word counts are 12/15/18/21/24.
- **EVM is one `WalletChain::Evm` variant across 5 networks** selected by `EvmNetwork` (defaults to `ethereum_mainnet`); other chains ignore `evmNetwork`. BTC rejects token transfers; contract calls are EVM-only; swaps are quote-only everywhere.
- **EVM is one `WalletChain::Evm` variant across 6 networks** (Ethereum, Base, Arbitrum, Optimism, Polygon, BNB Chain) selected by `EvmNetwork` (defaults to `ethereum_mainnet`); other chains ignore `evmNetwork`. BTC rejects token transfers. Swaps / bridges / contract calls are not in the wallet — they live in the [`web3`](../web3/README.md) module.
- **RPC endpoints are overridable** per chain/network via `OPENHUMAN_WALLET_RPC_*` env vars (used by tests pointing at an axum mock). Log lines redact URLs to scheme+host.
- **`balances`**: only EVM reads live (Ethereum mainnet); BTC/Solana/Tron call their providers but fall back to zero with `ProviderStatus::Missing` on error.
+184 -2
View File
@@ -24,9 +24,12 @@ use serde::Deserialize;
use crate::openhuman::config::rpc as config_rpc;
use super::super::defaults::{explorer_tx_url, rpc_url_for_chain};
use super::super::execution::{ExecutionResult, PreparedKind, PreparedStatus, PreparedTransaction};
use super::super::execution::{
ExecutionResult, PreparedKind, PreparedStatus, PreparedTransaction, TxLookupInfo,
TxReceiptInfo, TxState, TxStatusInfo,
};
use super::super::ops::{secret_material, WalletChain};
use super::super::rpc::{rest_get_json, rest_post_text};
use super::super::rpc::{rest_get_json, rest_get_text, rest_post_text};
const LOG_PREFIX: &str = "[wallet::btc]";
/// Hardcoded fee rate (sat/vbyte) used to estimate fees for prepared quotes
@@ -298,6 +301,136 @@ pub async fn execute_btc_quote(mut quote: PreparedTransaction) -> Result<Executi
})
}
/// Esplora `/tx/:txid/status` → normalized status. Confirmations are derived
/// from the chain tip (`/blocks/tip/height`) when the tx is confirmed.
pub async fn tx_status(hash: &str) -> Result<TxStatusInfo, String> {
let base = rpc_url_for_chain(WalletChain::Btc);
let base = base.trim_end_matches('/');
let status: serde_json::Value = match rest_get_json(&format!("{base}/tx/{hash}/status")).await {
Ok(v) => v,
// Esplora returns 404 for unknown txids; surface as NotFound.
Err(e) if e.contains("status=404") => {
return Ok(TxStatusInfo {
chain: WalletChain::Btc,
evm_network: None,
hash: hash.to_string(),
state: TxState::NotFound,
confirmations: None,
block_number: None,
});
}
Err(e) => return Err(e),
};
let confirmed = status
.get("confirmed")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
if !confirmed {
return Ok(TxStatusInfo {
chain: WalletChain::Btc,
evm_network: None,
hash: hash.to_string(),
state: TxState::Pending,
confirmations: Some(0),
block_number: None,
});
}
let block_number = status
.get("block_height")
.and_then(serde_json::Value::as_u64);
let confirmations = match block_number {
Some(bn) => {
let tip = rest_get_text(&format!("{base}/blocks/tip/height"))
.await
.ok();
tip.and_then(|t| t.trim().parse::<u64>().ok())
.map(|tip| tip.saturating_sub(bn).saturating_add(1))
}
None => None,
};
Ok(TxStatusInfo {
chain: WalletChain::Btc,
evm_network: None,
hash: hash.to_string(),
state: TxState::Confirmed,
confirmations,
block_number,
})
}
/// Esplora `/tx/:txid` → normalized receipt (fee + confirmed height).
pub async fn tx_receipt(hash: &str) -> Result<TxReceiptInfo, String> {
let base = rpc_url_for_chain(WalletChain::Btc);
let base = base.trim_end_matches('/');
let tx: serde_json::Value = match rest_get_json(&format!("{base}/tx/{hash}")).await {
Ok(v) => v,
Err(e) if e.contains("status=404") => {
return Ok(TxReceiptInfo {
chain: WalletChain::Btc,
evm_network: None,
hash: hash.to_string(),
found: false,
success: None,
block_number: None,
gas_used: None,
fee_raw: None,
raw: serde_json::Value::Null,
});
}
Err(e) => return Err(e),
};
let confirmed = tx
.get("status")
.and_then(|s| s.get("confirmed"))
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
let block_number = tx
.get("status")
.and_then(|s| s.get("block_height"))
.and_then(serde_json::Value::as_u64);
let fee_raw = tx
.get("fee")
.and_then(serde_json::Value::as_u64)
.map(|f| f.to_string());
// Leave `success` unset until the tx is confirmed — an unconfirmed mempool
// tx is pending (see tx_status), not a failure.
let success = if confirmed { Some(true) } else { None };
Ok(TxReceiptInfo {
chain: WalletChain::Btc,
evm_network: None,
hash: hash.to_string(),
found: true,
success,
block_number,
gas_used: None,
fee_raw,
raw: tx,
})
}
/// Esplora `/tx/:txid` → raw transaction passthrough.
pub async fn lookup_tx(hash: &str) -> Result<TxLookupInfo, String> {
let base = rpc_url_for_chain(WalletChain::Btc);
let base = base.trim_end_matches('/');
match rest_get_json::<serde_json::Value>(&format!("{base}/tx/{hash}")).await {
Ok(tx) => Ok(TxLookupInfo {
chain: WalletChain::Btc,
evm_network: None,
hash: hash.to_string(),
found: true,
raw: tx,
}),
Err(e) if e.contains("status=404") => Ok(TxLookupInfo {
chain: WalletChain::Btc,
evm_network: None,
hash: hash.to_string(),
found: false,
raw: serde_json::Value::Null,
}),
Err(e) => Err(e),
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -560,4 +693,53 @@ mod tests {
);
let _ = secp; // suppress unused
}
#[tokio::test]
async fn tx_status_confirmed_with_tip_confirmations() {
let _guard = TEST_LOCK.lock();
let _env_guard = crate::openhuman::config::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let app = Router::new()
.route(
"/tx/{txid}/status",
get(|| async {
axum::Json(json!({"confirmed": true, "block_height": 800_000u64}))
}),
)
.route("/blocks/tip/height", get(|| async { "800002" }));
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
std::env::set_var("OPENHUMAN_WALLET_RPC_BTC", format!("http://{addr}"));
let info = tx_status("deadbeef").await.unwrap();
assert_eq!(
info.state,
crate::openhuman::wallet::execution::TxState::Confirmed
);
assert_eq!(info.block_number, Some(800_000));
assert_eq!(info.confirmations, Some(3));
}
#[tokio::test]
async fn lookup_tx_not_found_on_404() {
let _guard = TEST_LOCK.lock();
let _env_guard = crate::openhuman::config::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let app = Router::new().route(
"/tx/{txid}",
get(|| async { (axum::http::StatusCode::NOT_FOUND, "Transaction not found") }),
);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
std::env::set_var("OPENHUMAN_WALLET_RPC_BTC", format!("http://{addr}"));
let info = lookup_tx("deadbeef").await.unwrap();
assert!(!info.found);
}
}
+434 -82
View File
@@ -22,7 +22,7 @@ use super::super::defaults::{
};
use super::super::execution::{
hex_to_bytes, hex_to_u256, u256_to_hex, ExecutionResult, PreparedKind, PreparedStatus,
PreparedTransaction,
PreparedTransaction, RawBroadcastResult, TxLookupInfo, TxReceiptInfo, TxState, TxStatusInfo,
};
use super::super::ops::{secret_material, WalletChain};
use super::super::rpc::{evm_rpc_call, rpc_call_to};
@@ -34,8 +34,19 @@ pub async fn evm_balance(network: EvmNetwork, address: &str) -> Result<U256, Str
hex_to_u256(&raw)
}
pub async fn execute_evm_quote(mut quote: PreparedTransaction) -> Result<ExecutionResult, String> {
let network = quote.evm_network.unwrap_or(EvmNetwork::EthereumMainnet);
/// Sign an EVM transaction `(to, value, data)` from the wallet's encrypted
/// recovery phrase and broadcast it on `network`. Shared core behind both
/// [`execute_evm_quote`] (native + token transfers) and
/// [`sign_and_broadcast_evm`] (raw / dapp / swap calldata from the web3 layer).
///
/// Returns `(tx_hash, fee_raw)` where `fee_raw` is the simulated `gas * gasPrice`.
async fn sign_and_broadcast(
network: EvmNetwork,
from_address: &str,
tx_to: Address,
tx_value: U256,
tx_data: Option<String>,
) -> Result<(String, U256), String> {
let rpc_url = rpc_url_for_evm_network(network);
let secret = secret_material(WalletChain::Evm).await?;
let config = config_rpc::load_config_with_timeout().await?;
@@ -54,12 +65,75 @@ pub async fn execute_evm_quote(mut quote: PreparedTransaction) -> Result<Executi
})?
.build()
.map_err(|e| format!("failed to derive EVM signer from wallet secret: {e}"))?;
let from = Address::from_str(&quote.from_address).map_err(|e| {
format!(
"invalid stored EVM sender address '{}': {e}",
quote.from_address
)
})?;
let from = Address::from_str(from_address)
.map_err(|e| format!("invalid stored EVM sender address '{from_address}': {e}"))?;
let chain_id_hex: String = rpc_call_to(&rpc_url, "eth_chainId", json!([])).await?;
// Use "pending" so already-submitted-but-not-mined txs don't cause a
// nonce collision when two confirmations land back-to-back.
let nonce_hex: String = rpc_call_to(
&rpc_url,
"eth_getTransactionCount",
json!([from_address, "pending"]),
)
.await?;
let gas_price_hex: String = rpc_call_to(&rpc_url, "eth_gasPrice", json!([])).await?;
let mut estimate_tx = json!({
"from": from_address,
"to": format!("{tx_to:#x}"),
"value": u256_to_hex(tx_value),
});
if let Some(data_hex) = tx_data.as_deref() {
estimate_tx["data"] = json!(data_hex);
}
let gas_hex: String = rpc_call_to(&rpc_url, "eth_estimateGas", json!([estimate_tx])).await?;
let chain_id = hex_to_u256(&chain_id_hex)?.as_u64();
if chain_id != network.chain_id() {
return Err(format!(
"EVM RPC chain_id mismatch: rpc reported {} but network {} expects {}",
chain_id,
network.as_str(),
network.chain_id()
));
}
let nonce = hex_to_u256(&nonce_hex)?;
let gas_price = hex_to_u256(&gas_price_hex)?;
let gas = hex_to_u256(&gas_hex)?;
let tx_data_bytes = tx_data
.map(|value| hex_to_bytes(&value).map(Bytes::from))
.transpose()?;
let mut request = TransactionRequest::new()
.from(from)
.to(NameOrAddress::Address(tx_to))
.value(tx_value)
.nonce(nonce)
.gas(gas)
.gas_price(gas_price)
.chain_id(chain_id);
if let Some(data) = tx_data_bytes {
request = request.data(data);
}
let tx: TypedTransaction = request.into();
let signature = signer
.with_chain_id(chain_id)
.sign_transaction(&tx)
.await
.map_err(|e| format!("failed to sign EVM transaction: {e}"))?;
let raw_bytes = tx.rlp_signed(&signature);
let raw_tx = format!("0x{}", hex::encode(raw_bytes));
let tx_hash: String = rpc_call_to(&rpc_url, "eth_sendRawTransaction", json!([raw_tx])).await?;
let fee = gas_price.checked_mul(gas).unwrap_or_default();
debug!(
"{LOG_PREFIX} sign_and_broadcast network={} tx_hash={}",
network.as_str(),
tx_hash
);
Ok((tx_hash, fee))
}
pub async fn execute_evm_quote(mut quote: PreparedTransaction) -> Result<ExecutionResult, String> {
let network = quote.evm_network.unwrap_or(EvmNetwork::EthereumMainnet);
let (tx_to, tx_value, tx_data) = match quote.kind {
PreparedKind::NativeTransfer => (
Address::from_str(&quote.to_address).map_err(|e| {
@@ -83,81 +157,11 @@ pub async fn execute_evm_quote(mut quote: PreparedTransaction) -> Result<Executi
Some(calldata),
)
}
PreparedKind::ContractCall => (
Address::from_str(&quote.to_address)
.map_err(|e| format!("invalid contract target '{}': {e}", quote.to_address))?,
U256::from_dec_str(&quote.amount_raw).map_err(|e| {
format!(
"invalid prepared contract value '{}': {e}",
quote.amount_raw
)
})?,
quote.calldata.clone(),
),
PreparedKind::Swap => {
return Err(
"swap broadcast is not implemented yet; keep it in quote-only mode".to_string(),
);
}
};
let chain_id_hex: String = rpc_call_to(&rpc_url, "eth_chainId", json!([])).await?;
// Use "pending" so already-submitted-but-not-mined txs don't cause a
// nonce collision when two confirmations land back-to-back.
let nonce_hex: String = rpc_call_to(
&rpc_url,
"eth_getTransactionCount",
json!([quote.from_address, "pending"]),
)
.await?;
let gas_price_hex: String = rpc_call_to(&rpc_url, "eth_gasPrice", json!([])).await?;
let mut estimate_tx = json!({
"from": quote.from_address,
"to": format!("{tx_to:#x}"),
"value": u256_to_hex(tx_value),
});
if let Some(data_hex) = tx_data.as_deref() {
estimate_tx["data"] = json!(data_hex);
}
let gas_hex: String = rpc_call_to(&rpc_url, "eth_estimateGas", json!([estimate_tx])).await?;
let chain_id = hex_to_u256(&chain_id_hex)?.as_u64();
if chain_id != network.chain_id() {
return Err(format!(
"EVM RPC chain_id mismatch: rpc reported {} but network {} expects {}",
chain_id,
network.as_str(),
network.chain_id()
));
}
let nonce = hex_to_u256(&nonce_hex)?;
let gas_price = hex_to_u256(&gas_price_hex)?;
let gas = hex_to_u256(&gas_hex)?;
let tx_data_bytes = tx_data
.clone()
.map(|value| hex_to_bytes(&value).map(Bytes::from))
.transpose()?;
let mut request = TransactionRequest::new()
.from(from)
.to(NameOrAddress::Address(tx_to))
.value(tx_value)
.nonce(nonce)
.gas(gas)
.gas_price(gas_price)
.chain_id(chain_id);
if let Some(data) = tx_data_bytes {
request = request.data(data);
}
let tx: TypedTransaction = request.into();
let signature = signer
.with_chain_id(chain_id)
.sign_transaction(&tx)
.await
.map_err(|e| format!("failed to sign EVM transaction: {e}"))?;
let raw_bytes = tx.rlp_signed(&signature);
let raw_tx = format!("0x{}", hex::encode(raw_bytes));
let tx_hash: String = rpc_call_to(&rpc_url, "eth_sendRawTransaction", json!([raw_tx])).await?;
quote.estimated_fee_raw = gas_price.checked_mul(gas).unwrap_or_default().to_string();
let (tx_hash, fee) =
sign_and_broadcast(network, &quote.from_address, tx_to, tx_value, tx_data).await?;
quote.estimated_fee_raw = fee.to_string();
quote.status = PreparedStatus::Broadcasted;
debug!(
"{LOG_PREFIX} execute_prepared quote_id={} network={} tx_hash={}",
@@ -176,6 +180,158 @@ pub async fn execute_evm_quote(mut quote: PreparedTransaction) -> Result<Executi
})
}
/// Crate-internal primitive: sign an externally-built unsigned EVM transaction
/// (`to` / `data` / `value`) with the wallet's key and broadcast it. Used by
/// the `web3` layer for deBridge swap/bridge transactions and generic dapp
/// contract calls. Not exposed as an agent tool or RPC controller.
pub(crate) async fn sign_and_broadcast_evm(
network: EvmNetwork,
to: &str,
data_hex: Option<String>,
value_raw: &str,
) -> Result<RawBroadcastResult, String> {
let account = super::super::execution::require_evm_account().await?;
let tx_to = Address::from_str(to.trim())
.map_err(|e| format!("invalid EVM target address '{to}': {e}"))?;
let tx_value = U256::from_dec_str(value_raw.trim())
.map_err(|e| format!("invalid native value '{value_raw}': {e}"))?;
let data = data_hex
.map(|d| super::super::execution::validate_calldata(&d))
.transpose()?;
let (tx_hash, fee) = sign_and_broadcast(network, &account, tx_to, tx_value, data).await?;
Ok(RawBroadcastResult {
transaction_hash: tx_hash.clone(),
explorer_url: explorer_tx_url_for_evm_network(network, &tx_hash),
fee_raw: Some(fee.to_string()),
})
}
/// `eth_getTransactionReceipt` + `eth_blockNumber` → normalized status.
pub async fn tx_status(network: EvmNetwork, hash: &str) -> Result<TxStatusInfo, String> {
let rpc_url = rpc_url_for_evm_network(network);
let receipt: serde_json::Value =
rpc_call_to(&rpc_url, "eth_getTransactionReceipt", json!([hash])).await?;
if receipt.is_null() {
// No receipt yet — distinguish pending (tx known) from not-found.
let tx: serde_json::Value =
rpc_call_to(&rpc_url, "eth_getTransactionByHash", json!([hash])).await?;
let state = if tx.is_null() {
TxState::NotFound
} else {
TxState::Pending
};
return Ok(TxStatusInfo {
chain: WalletChain::Evm,
evm_network: Some(network),
hash: hash.to_string(),
state,
confirmations: None,
block_number: None,
});
}
let status_ok = receipt
.get("status")
.and_then(|v| v.as_str())
.map(|s| hex_to_u256(s).map(|v| !v.is_zero()).unwrap_or(true))
.unwrap_or(true);
let block_number = receipt
.get("blockNumber")
.and_then(|v| v.as_str())
.and_then(|s| hex_to_u256(s).ok())
.map(|v| v.as_u64());
let confirmations = match block_number {
Some(bn) => {
let head_hex: String = rpc_call_to(&rpc_url, "eth_blockNumber", json!([])).await?;
hex_to_u256(&head_hex)
.ok()
.map(|head| head.as_u64().saturating_sub(bn).saturating_add(1))
}
None => None,
};
Ok(TxStatusInfo {
chain: WalletChain::Evm,
evm_network: Some(network),
hash: hash.to_string(),
state: if status_ok {
TxState::Confirmed
} else {
TxState::Failed
},
confirmations,
block_number,
})
}
/// `eth_getTransactionReceipt` → normalized receipt with raw passthrough.
pub async fn tx_receipt(network: EvmNetwork, hash: &str) -> Result<TxReceiptInfo, String> {
let rpc_url = rpc_url_for_evm_network(network);
let receipt: serde_json::Value =
rpc_call_to(&rpc_url, "eth_getTransactionReceipt", json!([hash])).await?;
if receipt.is_null() {
// No receipt yet — a freshly broadcast tx is still "found" if the node
// knows the tx hash; only report not-found when both calls are null.
let tx: serde_json::Value =
rpc_call_to(&rpc_url, "eth_getTransactionByHash", json!([hash])).await?;
return Ok(TxReceiptInfo {
chain: WalletChain::Evm,
evm_network: Some(network),
hash: hash.to_string(),
found: !tx.is_null(),
success: None,
block_number: None,
gas_used: None,
fee_raw: None,
raw: serde_json::Value::Null,
});
}
let success = receipt
.get("status")
.and_then(|v| v.as_str())
.map(|s| hex_to_u256(s).map(|v| !v.is_zero()).unwrap_or(true));
let block_number = receipt
.get("blockNumber")
.and_then(|v| v.as_str())
.and_then(|s| hex_to_u256(s).ok())
.map(|v| v.as_u64());
let gas_used = receipt
.get("gasUsed")
.and_then(|v| v.as_str())
.and_then(|s| hex_to_u256(s).ok());
let effective_gas_price = receipt
.get("effectiveGasPrice")
.and_then(|v| v.as_str())
.and_then(|s| hex_to_u256(s).ok());
let fee_raw = match (gas_used, effective_gas_price) {
(Some(g), Some(p)) => g.checked_mul(p).map(|f| f.to_string()),
_ => None,
};
Ok(TxReceiptInfo {
chain: WalletChain::Evm,
evm_network: Some(network),
hash: hash.to_string(),
found: true,
success,
block_number,
gas_used: gas_used.map(|g| g.to_string()),
fee_raw,
raw: receipt,
})
}
/// `eth_getTransactionByHash` → raw transaction passthrough.
pub async fn lookup_tx(network: EvmNetwork, hash: &str) -> Result<TxLookupInfo, String> {
let rpc_url = rpc_url_for_evm_network(network);
let tx: serde_json::Value =
rpc_call_to(&rpc_url, "eth_getTransactionByHash", json!([hash])).await?;
Ok(TxLookupInfo {
chain: WalletChain::Evm,
evm_network: Some(network),
hash: hash.to_string(),
found: !tx.is_null(),
raw: tx,
})
}
pub fn validate_evm_address(addr: &str) -> Result<String, String> {
let trimmed = addr.trim();
if trimmed.is_empty() {
@@ -184,3 +340,199 @@ pub fn validate_evm_address(addr: &str) -> Result<String, String> {
Address::from_str(trimmed).map_err(|e| format!("invalid EVM address '{trimmed}': {e}"))?;
Ok(trimmed.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::openhuman::wallet::execution::TxState;
use crate::openhuman::wallet::test_support::{setup_wallet_in, TEST_LOCK};
use axum::{routing::post, Router};
use serde_json::Value as JsonValue;
use std::sync::Arc;
use tempfile::TempDir;
use tokio::net::TcpListener;
/// Mock EVM JSON-RPC node. `receipt` is the value returned for
/// `eth_getTransactionReceipt`; `tx` for `eth_getTransactionByHash`.
async fn start_evm_mock(
receipt: JsonValue,
tx: JsonValue,
) -> (
std::net::SocketAddr,
Arc<parking_lot::Mutex<Vec<JsonValue>>>,
) {
let calls: Arc<parking_lot::Mutex<Vec<JsonValue>>> =
Arc::new(parking_lot::Mutex::new(Vec::new()));
let calls_c = calls.clone();
let app = Router::new().route(
"/",
post(move |axum::Json(payload): axum::Json<JsonValue>| {
let calls = calls_c.clone();
let receipt = receipt.clone();
let tx = tx.clone();
async move {
calls.lock().push(payload.clone());
let method = payload
.get("method")
.and_then(JsonValue::as_str)
.unwrap_or_default();
let result = match method {
"eth_getTransactionReceipt" => receipt,
"eth_getTransactionByHash" => tx,
"eth_blockNumber" => JsonValue::String("0x12".to_string()),
"eth_chainId" => JsonValue::String("0x1".to_string()),
"eth_getTransactionCount" => JsonValue::String("0x1".to_string()),
"eth_gasPrice" => JsonValue::String("0x3b9aca00".to_string()),
"eth_estimateGas" => JsonValue::String("0x5208".to_string()),
"eth_sendRawTransaction" => {
JsonValue::String(format!("0x{}", "ab".repeat(32)))
}
_ => JsonValue::Null,
};
axum::Json(serde_json::json!({"jsonrpc":"2.0","id":1,"result":result}))
}
}),
);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
(addr, calls)
}
fn set_evm_rpc(addr: std::net::SocketAddr) {
std::env::set_var("OPENHUMAN_WALLET_RPC_EVM", format!("http://{addr}"));
}
#[tokio::test]
async fn tx_status_confirmed_with_confirmations() {
let _guard = TEST_LOCK.lock();
let _env = crate::openhuman::config::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let receipt = serde_json::json!({"status": "0x1", "blockNumber": "0x10"});
let (addr, _calls) = start_evm_mock(receipt, JsonValue::Null).await;
set_evm_rpc(addr);
let info = tx_status(EvmNetwork::EthereumMainnet, "0xabc")
.await
.unwrap();
assert_eq!(info.state, TxState::Confirmed);
assert_eq!(info.block_number, Some(16));
// tip 0x12 (18) - block 16 + 1 = 3 confirmations.
assert_eq!(info.confirmations, Some(3));
}
#[tokio::test]
async fn tx_status_pending_when_no_receipt_but_tx_present() {
let _guard = TEST_LOCK.lock();
let _env = crate::openhuman::config::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let (addr, _calls) =
start_evm_mock(JsonValue::Null, serde_json::json!({"hash": "0xabc"})).await;
set_evm_rpc(addr);
let info = tx_status(EvmNetwork::EthereumMainnet, "0xabc")
.await
.unwrap();
assert_eq!(info.state, TxState::Pending);
}
#[tokio::test]
async fn tx_status_not_found_when_receipt_and_tx_null() {
let _guard = TEST_LOCK.lock();
let _env = crate::openhuman::config::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let (addr, _calls) = start_evm_mock(JsonValue::Null, JsonValue::Null).await;
set_evm_rpc(addr);
let info = tx_status(EvmNetwork::EthereumMainnet, "0xabc")
.await
.unwrap();
assert_eq!(info.state, TxState::NotFound);
}
#[tokio::test]
async fn tx_receipt_extracts_fee_and_success() {
let _guard = TEST_LOCK.lock();
let _env = crate::openhuman::config::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let receipt = serde_json::json!({
"status": "0x1",
"blockNumber": "0x10",
"gasUsed": "0x5208", // 21000
"effectiveGasPrice": "0x3b9aca00" // 1 gwei
});
let (addr, _calls) = start_evm_mock(receipt, JsonValue::Null).await;
set_evm_rpc(addr);
let info = tx_receipt(EvmNetwork::EthereumMainnet, "0xabc")
.await
.unwrap();
assert!(info.found);
assert_eq!(info.success, Some(true));
assert_eq!(info.gas_used.as_deref(), Some("21000"));
// 21000 * 1_000_000_000 = 21_000_000_000_000
assert_eq!(info.fee_raw.as_deref(), Some("21000000000000"));
}
#[tokio::test]
async fn tx_receipt_pending_is_found_when_tx_known() {
let _guard = TEST_LOCK.lock();
let _env = crate::openhuman::config::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
// No receipt yet, but the node knows the tx hash → pending, found=true.
let (addr, _calls) =
start_evm_mock(JsonValue::Null, serde_json::json!({"hash": "0xabc"})).await;
set_evm_rpc(addr);
let info = tx_receipt(EvmNetwork::EthereumMainnet, "0xabc")
.await
.unwrap();
assert!(info.found);
assert_eq!(info.success, None);
}
#[tokio::test]
async fn lookup_tx_reports_found_flag() {
let _guard = TEST_LOCK.lock();
let _env = crate::openhuman::config::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let (addr, _calls) =
start_evm_mock(JsonValue::Null, serde_json::json!({"hash": "0xabc"})).await;
set_evm_rpc(addr);
let info = lookup_tx(EvmNetwork::EthereumMainnet, "0xabc")
.await
.unwrap();
assert!(info.found);
}
#[tokio::test]
async fn sign_and_broadcast_evm_signs_raw_calldata() {
let _guard = TEST_LOCK.lock();
let _env = crate::openhuman::config::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let temp = TempDir::new().unwrap();
setup_wallet_in(&temp).await.unwrap();
let (addr, calls) = start_evm_mock(JsonValue::Null, JsonValue::Null).await;
set_evm_rpc(addr);
let result = sign_and_broadcast_evm(
EvmNetwork::EthereumMainnet,
"0x1111111111111111111111111111111111111111",
Some("0xabcdef".to_string()),
"0",
)
.await
.expect("broadcast ok");
assert_eq!(result.transaction_hash, format!("0x{}", "ab".repeat(32)));
assert!(result.explorer_url.is_some());
// The raw tx must have been broadcast.
let sent = calls
.lock()
.iter()
.any(|c| c.get("method").and_then(|v| v.as_str()) == Some("eth_sendRawTransaction"));
assert!(sent, "expected eth_sendRawTransaction call");
}
}
+7 -2
View File
@@ -7,9 +7,14 @@
//! `execution.rs`; chain modules only run when a quote is being broadcast.
//!
//! Every chain exposes a small surface:
//! - `execute(quote: PreparedTransaction) -> Result<ExecutionResult, String>`
//! - `execute_*_quote(quote: PreparedTransaction) -> Result<ExecutionResult, String>`
//! - `native_balance(address: &str) -> Result<u128, String>`
//! - `validate_address(addr: &str) -> Result<String, String>`
//! - `validate_*_address(addr: &str) -> Result<String, String>`
//! - `tx_status` / `tx_receipt` / `lookup_tx` — read-only inspection by hash.
//!
//! EVM and Solana additionally expose crate-internal `sign_and_broadcast_*`
//! primitives (sign+broadcast an externally-built unsigned transaction) that the
//! [`crate::openhuman::web3`] layer builds on.
pub(super) mod btc;
pub(super) mod evm;
+336 -6
View File
@@ -21,7 +21,10 @@ use sha2::{Digest, Sha256, Sha512};
use crate::openhuman::config::rpc as config_rpc;
use super::super::defaults::explorer_tx_url;
use super::super::execution::{ExecutionResult, PreparedKind, PreparedStatus, PreparedTransaction};
use super::super::execution::{
ExecutionResult, PreparedKind, PreparedStatus, PreparedTransaction, RawBroadcastResult,
TxLookupInfo, TxReceiptInfo, TxState, TxStatusInfo,
};
use super::super::ops::{secret_material, WalletChain};
use super::super::rpc::rpc_call;
@@ -166,6 +169,26 @@ fn encode_shortvec(value: u16) -> Vec<u8> {
}
}
/// Decode a Solana compact-u16 (shortvec). Returns `(value, bytes_consumed)`.
fn decode_shortvec(bytes: &[u8]) -> Result<(u16, usize), String> {
let mut value: u32 = 0;
let mut shift = 0u32;
for (i, byte) in bytes.iter().enumerate() {
if i >= 3 {
return Err("shortvec too long".to_string());
}
value |= u32::from(byte & 0x7f) << shift;
if byte & 0x80 == 0 {
if value > u16::MAX as u32 {
return Err("shortvec exceeds u16 range".to_string());
}
return Ok((value as u16, i + 1));
}
shift += 7;
}
Err("shortvec truncated".to_string())
}
#[derive(Debug, Clone)]
struct CompiledInstruction {
program_id_index: u8,
@@ -388,11 +411,6 @@ pub async fn execute_solana_quote(
}
build_spl_transfer_message(from_pk, src_ata, dst_ata, amount, recent_blockhash)
}
other => {
return Err(format!(
"Solana execution only supports native + SPL transfers; got {other:?}"
));
}
};
let signature = signing_key.sign(&message_bytes);
@@ -420,6 +438,203 @@ pub async fn execute_solana_quote(
})
}
/// Crate-internal primitive: sign an externally-built, hex-encoded
/// `VersionedTransaction` (e.g. a deBridge swap/bridge tx) with the wallet's
/// Solana key and broadcast it. Not exposed as an agent tool or RPC.
///
/// Wire layout (Solana transaction): `shortvec(num_signatures)` followed by
/// `num_signatures * 64` signature slots, then the serialized message. We fill
/// the signature slot at the index whose `account_keys[i]` equals our pubkey,
/// signing the full message bytes (legacy or v0 — the message slice includes
/// the v0 version prefix, which is what Solana signs).
pub(crate) async fn sign_and_broadcast_versioned(
tx_blob_hex: &str,
) -> Result<RawBroadcastResult, String> {
let trimmed = tx_blob_hex.trim();
let normalized = trimmed.strip_prefix("0x").unwrap_or(trimmed);
let mut wire =
hex::decode(normalized).map_err(|e| format!("invalid Solana transaction hex blob: {e}"))?;
let (num_signatures, sig_count_len) = decode_shortvec(&wire)?;
let sigs_start = sig_count_len;
let message_start = sigs_start + (num_signatures as usize) * 64;
if message_start > wire.len() {
return Err("Solana tx blob truncated before message".to_string());
}
let message = &wire[message_start..];
if message.is_empty() {
return Err("Solana tx blob has empty message".to_string());
}
// Determine message version + header offset.
let versioned = message[0] & 0x80 != 0;
let header_off = if versioned { 1 } else { 0 };
if message.len() < header_off + 3 {
return Err("Solana message header truncated".to_string());
}
let num_required_signatures = message[header_off] as usize;
if num_required_signatures == 0 {
return Err("Solana message declares zero required signatures".to_string());
}
// Parse account keys (need at least the signer keys to find our index).
let keys_off = header_off + 3;
let (account_count, count_len) = decode_shortvec(&message[keys_off..])?;
let keys_start = keys_off + count_len;
if account_count as usize > num_required_signatures.max(account_count as usize) {
// sanity only; continue
}
let signer_keys = num_required_signatures.min(account_count as usize);
if keys_start + signer_keys * 32 > message.len() {
return Err("Solana account keys region truncated".to_string());
}
// Derive our signing key.
let secret = secret_material(WalletChain::Solana).await?;
let config = config_rpc::load_config_with_timeout().await?;
let mnemonic =
crate::openhuman::encryption::rpc::decrypt_secret(&config, &secret.encrypted_mnemonic)
.await?
.value;
let signing_key = derive_solana_keypair(&mnemonic, &secret.derivation_path)?;
let our_pubkey = signing_key.verifying_key().to_bytes();
// Find our signer index.
let mut our_index: Option<usize> = None;
for i in 0..signer_keys {
let off = keys_start + i * 32;
if message[off..off + 32] == our_pubkey {
our_index = Some(i);
break;
}
}
let our_index = our_index.ok_or_else(|| {
format!(
"wallet Solana address {} is not a required signer of this transaction",
pubkey_to_b58(&our_pubkey)
)
})?;
if our_index >= num_signatures as usize {
return Err("Solana signer index exceeds signature slot count".to_string());
}
// Sign the message bytes and write into our signature slot.
let signature = signing_key.sign(message);
let sig_bytes = signature.to_bytes();
let slot_off = sigs_start + our_index * 64;
wire[slot_off..slot_off + 64].copy_from_slice(&sig_bytes);
let tx_sig = broadcast_solana(&wire).await?;
debug!("{LOG_PREFIX} sign_and_broadcast_versioned sig={tx_sig}");
Ok(RawBroadcastResult {
transaction_hash: tx_sig.clone(),
explorer_url: explorer_tx_url(WalletChain::Solana, &tx_sig),
// Solana fees are dynamic (base + priority) and only known once the tx
// is confirmed — leave unset rather than misreporting a free transfer.
fee_raw: None,
})
}
/// `getSignatureStatuses` → normalized status.
pub async fn tx_status(hash: &str) -> Result<TxStatusInfo, String> {
#[derive(Deserialize)]
struct StatusResp {
value: Vec<Option<SigStatus>>,
}
#[derive(Deserialize)]
struct SigStatus {
slot: u64,
confirmations: Option<u64>,
err: Option<serde_json::Value>,
}
let resp: StatusResp = rpc_call(
WalletChain::Solana,
"getSignatureStatuses",
json!([[hash], {"searchTransactionHistory": true}]),
)
.await?;
let entry = resp.value.into_iter().next().flatten();
let (state, confirmations, block_number) = match entry {
None => (TxState::NotFound, None, None),
Some(status) => {
let state = if status.err.is_some() {
TxState::Failed
} else if status.confirmations.is_none() {
// null confirmations means "finalized / rooted".
TxState::Confirmed
} else {
TxState::Pending
};
(state, status.confirmations, Some(status.slot))
}
};
Ok(TxStatusInfo {
chain: WalletChain::Solana,
evm_network: None,
hash: hash.to_string(),
state,
confirmations,
block_number,
})
}
/// `getTransaction` → normalized receipt with raw passthrough.
pub async fn tx_receipt(hash: &str) -> Result<TxReceiptInfo, String> {
let tx: serde_json::Value = rpc_call(
WalletChain::Solana,
"getTransaction",
json!([hash, {"maxSupportedTransactionVersion": 0, "encoding": "json"}]),
)
.await?;
if tx.is_null() {
return Ok(TxReceiptInfo {
chain: WalletChain::Solana,
evm_network: None,
hash: hash.to_string(),
found: false,
success: None,
block_number: None,
gas_used: None,
fee_raw: None,
raw: serde_json::Value::Null,
});
}
let meta = tx.get("meta");
let success = meta.map(|m| m.get("err").map(|e| e.is_null()).unwrap_or(true));
let fee_raw = meta
.and_then(|m| m.get("fee"))
.and_then(|v| v.as_u64())
.map(|f| f.to_string());
let block_number = tx.get("slot").and_then(|v| v.as_u64());
Ok(TxReceiptInfo {
chain: WalletChain::Solana,
evm_network: None,
hash: hash.to_string(),
found: true,
success,
block_number,
gas_used: None,
fee_raw,
raw: tx,
})
}
/// `getTransaction` → raw transaction passthrough.
pub async fn lookup_tx(hash: &str) -> Result<TxLookupInfo, String> {
let tx: serde_json::Value = rpc_call(
WalletChain::Solana,
"getTransaction",
json!([hash, {"maxSupportedTransactionVersion": 0, "encoding": "json"}]),
)
.await?;
Ok(TxLookupInfo {
chain: WalletChain::Solana,
evm_network: None,
hash: hash.to_string(),
found: !tx.is_null(),
raw: tx,
})
}
#[cfg(test)]
mod tests {
use super::*;
@@ -801,4 +1016,119 @@ mod tests {
let key3 = &msg[4 + 96..4 + 128];
assert_eq!(key3, &token_program);
}
#[test]
fn decode_shortvec_round_trips_encode() {
for v in [0u16, 1, 127, 128, 16_383, 16_384, 65_535] {
let enc = encode_shortvec(v);
let (decoded, len) = decode_shortvec(&enc).unwrap();
assert_eq!(decoded, v, "value {v} round-trips");
assert_eq!(len, enc.len(), "consumed length matches for {v}");
}
}
/// Build a minimal legacy VersionedTransaction wire with `signer` as the
/// sole required signer and an empty signature slot.
fn build_unsigned_legacy(signer: &[u8; 32]) -> Vec<u8> {
let mut message = Vec::new();
message.extend([1u8, 0u8, 0u8]); // header: 1 required sig
message.extend(encode_shortvec(1)); // 1 account key
message.extend(signer);
message.extend([0u8; 32]); // recent blockhash
message.extend(encode_shortvec(0)); // 0 instructions
let mut wire = Vec::new();
wire.extend(encode_shortvec(1)); // 1 signature slot
wire.extend([0u8; 64]); // empty sig
wire.extend(&message);
wire
}
#[tokio::test]
async fn sign_and_broadcast_versioned_fills_signature_and_broadcasts() {
let _guard = TEST_LOCK.lock();
let _env_guard = crate::openhuman::config::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
reset_quote_store_for_tests();
let temp = TempDir::new().unwrap();
setup_wallet_in(&temp).await.unwrap();
let fake_sig = "5xS9pXmqVz8R1nuRZTfsdsAxBdBFmtnAtuYbCsmK5DYzGn5vR4VqWGmiR5McLnYx8oFqLdo62q4qiUZpQyR4Hkn3";
let (addr, calls) = start_solana_mock(fake_sig).await;
std::env::set_var("OPENHUMAN_WALLET_RPC_SOLANA", format!("http://{addr}"));
let signer = b58_to_pubkey(sample_solana_address()).unwrap();
let wire = build_unsigned_legacy(&signer);
let result = sign_and_broadcast_versioned(&hex::encode(&wire))
.await
.expect("sign+broadcast ok");
assert_eq!(result.transaction_hash, fake_sig);
// The broadcast tx must carry a non-zero signature in slot 0.
let send = calls
.lock()
.iter()
.rev()
.find(|c| c.get("method").and_then(|v| v.as_str()) == Some("sendTransaction"))
.cloned()
.expect("sendTransaction recorded");
let b64 = send.get("params").and_then(|p| p.as_array()).unwrap()[0]
.as_str()
.unwrap()
.to_string();
let raw = B64.decode(b64).unwrap();
// shortvec(1) + 64-byte sig; the sig must not be all zeros now.
assert_eq!(raw[0], 1);
assert!(raw[1..1 + 64].iter().any(|b| *b != 0), "signature filled");
}
#[tokio::test]
async fn sign_and_broadcast_versioned_rejects_non_signer() {
let _guard = TEST_LOCK.lock();
let _env_guard = crate::openhuman::config::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
reset_quote_store_for_tests();
let temp = TempDir::new().unwrap();
setup_wallet_in(&temp).await.unwrap();
// A signer pubkey that is NOT our wallet — sign must refuse.
let other = [7u8; 32];
let wire = build_unsigned_legacy(&other);
let err = sign_and_broadcast_versioned(&hex::encode(&wire))
.await
.unwrap_err();
assert!(err.contains("not a required signer"), "got: {err}");
}
#[tokio::test]
async fn tx_status_reads_signature_status() {
let _guard = TEST_LOCK.lock();
let _env_guard = crate::openhuman::config::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let app = Router::new().route(
"/",
post(|axum::Json(_p): axum::Json<serde_json::Value>| async move {
axum::Json(json!({
"jsonrpc": "2.0", "id": 1,
"result": {"context": {"slot": 0}, "value": [
{"slot": 123u64, "confirmations": null, "err": null}
]}
}))
}),
);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
std::env::set_var("OPENHUMAN_WALLET_RPC_SOLANA", format!("http://{addr}"));
let info = tx_status("somesig").await.unwrap();
assert_eq!(
info.state,
crate::openhuman::wallet::execution::TxState::Confirmed
);
assert_eq!(info.block_number, Some(123));
}
}
+172 -6
View File
@@ -16,7 +16,10 @@ use sha2::{Digest, Sha256, Sha512};
use crate::openhuman::config::rpc as config_rpc;
use super::super::defaults::{explorer_tx_url, rpc_url_for_chain};
use super::super::execution::{ExecutionResult, PreparedKind, PreparedStatus, PreparedTransaction};
use super::super::execution::{
ExecutionResult, PreparedKind, PreparedStatus, PreparedTransaction, TxLookupInfo,
TxReceiptInfo, TxState, TxStatusInfo,
};
use super::super::ops::{secret_material, WalletChain};
use super::super::rpc::rest_post_json;
@@ -280,11 +283,6 @@ pub async fn execute_tron_quote(mut quote: PreparedTransaction) -> Result<Execut
let parameter = encode_trc20_transfer_param(&to_hex, amount)?;
trigger_trc20_transfer(&owner_hex, &contract_hex, &parameter).await?
}
other => {
return Err(format!(
"Tron execution only supports native + TRC20 transfers; got {other:?}"
));
}
};
// Tron signs sha256(raw_data_hex bytes).
@@ -354,6 +352,117 @@ pub async fn execute_tron_quote(mut quote: PreparedTransaction) -> Result<Execut
})
}
async fn tron_post(path: &str, body: Value) -> Result<Value, String> {
let base = rpc_url_for_chain(WalletChain::Tron);
let url = format!(
"{}/{}",
base.trim_end_matches('/'),
path.trim_start_matches('/')
);
rest_post_json(&url, &body).await
}
/// TronGrid `/wallet/gettransactioninfobyid` → normalized status.
pub async fn tx_status(hash: &str) -> Result<TxStatusInfo, String> {
let info = tron_post("wallet/gettransactioninfobyid", json!({ "value": hash })).await?;
let block_number = info.get("blockNumber").and_then(Value::as_u64);
let (state, block_number) = match block_number {
None => {
// The info endpoint only has a row once the tx is mined. A freshly
// broadcast tx is still pending — disambiguate via gettransactionbyid.
let tx = tron_post("wallet/gettransactionbyid", json!({ "value": hash })).await?;
let seen = tx.get("txID").is_some() || tx.get("raw_data").is_some();
(
if seen {
TxState::Pending
} else {
TxState::NotFound
},
None,
)
}
Some(bn) => {
// `receipt.result` carries SUCCESS / REVERT / FAILED for contract txs;
// a bare TRX transfer omits it but is successful once mined.
let result = info
.get("receipt")
.and_then(|r| r.get("result"))
.and_then(Value::as_str);
let state = match result {
Some("SUCCESS") | None => TxState::Confirmed,
Some(_) => TxState::Failed,
};
(state, Some(bn))
}
};
Ok(TxStatusInfo {
chain: WalletChain::Tron,
evm_network: None,
hash: hash.to_string(),
state,
confirmations: None,
block_number,
})
}
/// TronGrid `/wallet/gettransactioninfobyid` → normalized receipt.
pub async fn tx_receipt(hash: &str) -> Result<TxReceiptInfo, String> {
let info = tron_post("wallet/gettransactioninfobyid", json!({ "value": hash })).await?;
let block_number = info.get("blockNumber").and_then(Value::as_u64);
if block_number.is_none() {
return Ok(TxReceiptInfo {
chain: WalletChain::Tron,
evm_network: None,
hash: hash.to_string(),
found: false,
success: None,
block_number: None,
gas_used: None,
fee_raw: None,
raw: serde_json::Value::Null,
});
}
let result = info
.get("receipt")
.and_then(|r| r.get("result"))
.and_then(Value::as_str);
let success = Some(matches!(result, Some("SUCCESS") | None));
let fee_raw = info
.get("fee")
.and_then(Value::as_u64)
.map(|f| f.to_string());
let gas_used = info
.get("receipt")
.and_then(|r| r.get("energy_usage_total"))
.and_then(Value::as_u64)
.map(|g| g.to_string());
Ok(TxReceiptInfo {
chain: WalletChain::Tron,
evm_network: None,
hash: hash.to_string(),
found: true,
success,
block_number,
gas_used,
fee_raw,
raw: info,
})
}
/// TronGrid `/wallet/gettransactionbyid` → raw transaction passthrough.
pub async fn lookup_tx(hash: &str) -> Result<TxLookupInfo, String> {
let tx = tron_post("wallet/gettransactionbyid", json!({ "value": hash })).await?;
// TronGrid returns `{}` for an unknown id.
let found = tx.get("txID").is_some() || tx.get("raw_data").is_some();
Ok(TxLookupInfo {
chain: WalletChain::Tron,
evm_network: None,
hash: hash.to_string(),
found,
raw: tx,
})
}
#[cfg(test)]
mod tests {
use super::*;
@@ -660,4 +769,61 @@ mod tests {
assert_eq!(&p[..29], &[0u8; 29]);
assert_eq!(&p[29..], &[1, 2, 3]);
}
#[tokio::test]
async fn tx_status_confirmed_from_info() {
let _guard = TEST_LOCK.lock();
let _env_guard = crate::openhuman::config::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let app = Router::new().route(
"/wallet/gettransactioninfobyid",
post(|| async {
axum::Json(json!({
"id": "ab".repeat(32),
"blockNumber": 555u64,
"receipt": {"result": "SUCCESS"},
"fee": 1100u64
}))
}),
);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
std::env::set_var("OPENHUMAN_WALLET_RPC_TRON", format!("http://{addr}"));
let info = tx_status("ab").await.unwrap();
assert_eq!(info.state, TxState::Confirmed);
assert_eq!(info.block_number, Some(555));
let receipt = tx_receipt("ab").await.unwrap();
assert!(receipt.found);
assert_eq!(receipt.success, Some(true));
assert_eq!(receipt.fee_raw.as_deref(), Some("1100"));
}
#[tokio::test]
async fn tx_status_not_found_on_empty_info() {
let _guard = TEST_LOCK.lock();
let _env_guard = crate::openhuman::config::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let app = Router::new()
.route(
"/wallet/gettransactioninfobyid",
post(|| async { axum::Json(json!({})) }),
)
.route(
"/wallet/gettransactionbyid",
post(|| async { axum::Json(json!({})) }),
);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
std::env::set_var("OPENHUMAN_WALLET_RPC_TRON", format!("http://{addr}"));
let info = tx_status("missing").await.unwrap();
assert_eq!(info.state, TxState::NotFound);
}
}
+43 -8
View File
@@ -12,6 +12,7 @@ const BASESCAN_TX_BASE: &str = "https://basescan.org/tx/";
const ARBISCAN_TX_BASE: &str = "https://arbiscan.io/tx/";
const OPTIMISTIC_TX_BASE: &str = "https://optimistic.etherscan.io/tx/";
const POLYGONSCAN_TX_BASE: &str = "https://polygonscan.com/tx/";
const BSCSCAN_TX_BASE: &str = "https://bscscan.com/tx/";
const BLOCKSTREAM_TX_BASE: &str = "https://blockstream.info/tx/";
const SOLSCAN_TX_BASE: &str = "https://solscan.io/tx/";
const TRONSCAN_TX_BASE: &str = "https://tronscan.org/#/transaction/";
@@ -29,15 +30,17 @@ pub enum EvmNetwork {
ArbitrumOne,
OptimismMainnet,
PolygonMainnet,
BscMainnet,
}
impl EvmNetwork {
pub const ALL: [Self; 5] = [
pub const ALL: [Self; 6] = [
Self::EthereumMainnet,
Self::BaseMainnet,
Self::ArbitrumOne,
Self::OptimismMainnet,
Self::PolygonMainnet,
Self::BscMainnet,
];
pub fn as_str(self) -> &'static str {
@@ -47,6 +50,7 @@ impl EvmNetwork {
Self::ArbitrumOne => "arbitrum_one",
Self::OptimismMainnet => "optimism_mainnet",
Self::PolygonMainnet => "polygon_mainnet",
Self::BscMainnet => "bsc_mainnet",
}
}
@@ -57,6 +61,7 @@ impl EvmNetwork {
Self::ArbitrumOne => 42161,
Self::OptimismMainnet => 10,
Self::PolygonMainnet => 137,
Self::BscMainnet => 56,
}
}
@@ -67,6 +72,7 @@ impl EvmNetwork {
Self::ArbitrumOne => "OPENHUMAN_WALLET_RPC_ARBITRUM",
Self::OptimismMainnet => "OPENHUMAN_WALLET_RPC_OPTIMISM",
Self::PolygonMainnet => "OPENHUMAN_WALLET_RPC_POLYGON",
Self::BscMainnet => "OPENHUMAN_WALLET_RPC_BSC",
}
}
@@ -77,6 +83,7 @@ impl EvmNetwork {
Self::ArbitrumOne => "https://arb1.arbitrum.io/rpc",
Self::OptimismMainnet => "https://mainnet.optimism.io",
Self::PolygonMainnet => "https://polygon-rpc.com",
Self::BscMainnet => "https://bsc-dataseed.binance.org",
}
}
@@ -87,6 +94,7 @@ impl EvmNetwork {
Self::ArbitrumOne => ARBISCAN_TX_BASE,
Self::OptimismMainnet => OPTIMISTIC_TX_BASE,
Self::PolygonMainnet => POLYGONSCAN_TX_BASE,
Self::BscMainnet => BSCSCAN_TX_BASE,
}
}
@@ -97,6 +105,7 @@ impl EvmNetwork {
Self::ArbitrumOne => "arbitrum-one",
Self::OptimismMainnet => "optimism-mainnet",
Self::PolygonMainnet => "polygon-mainnet",
Self::BscMainnet => "bsc-mainnet",
}
}
@@ -286,26 +295,28 @@ pub fn asset_catalog(chain: WalletChain) -> Vec<WalletAssetDefinition> {
}
pub fn evm_asset_catalog(network: EvmNetwork) -> Vec<WalletAssetDefinition> {
let (native_symbol, native_name) = match network {
EvmNetwork::PolygonMainnet => ("POL", "Polygon"),
EvmNetwork::BscMainnet => ("BNB", "BNB"),
_ => ("ETH", "Ether"),
};
let mut assets = vec![WalletAssetDefinition {
chain: WalletChain::Evm,
evm_network: Some(network),
symbol: "ETH".to_string(),
name: if network == EvmNetwork::PolygonMainnet {
"Polygon (MATIC)".to_string()
} else {
"Ether".to_string()
},
symbol: native_symbol.to_string(),
name: native_name.to_string(),
native: true,
decimals: 18,
contract_address: None,
}];
// Per-L2 USDC native addresses.
// Per-L2 USDC native addresses. BSC is handled below (18-decimal tokens).
if let Some(usdc) = match network {
EvmNetwork::EthereumMainnet => Some("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"),
EvmNetwork::BaseMainnet => Some("0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"),
EvmNetwork::ArbitrumOne => Some("0xaf88d065e77c8cC2239327C5EDb3A432268e5831"),
EvmNetwork::OptimismMainnet => Some("0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85"),
EvmNetwork::PolygonMainnet => Some("0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359"),
EvmNetwork::BscMainnet => None,
} {
assets.push(WalletAssetDefinition {
chain: WalletChain::Evm,
@@ -317,6 +328,30 @@ pub fn evm_asset_catalog(network: EvmNetwork) -> Vec<WalletAssetDefinition> {
contract_address: Some(usdc.to_string()),
});
}
// BNB Chain BEP20 stablecoins use 18 decimals (unlike the 6-decimal USDC on
// other EVM chains), so they are catalogued separately.
if matches!(network, EvmNetwork::BscMainnet) {
assets.extend([
WalletAssetDefinition {
chain: WalletChain::Evm,
evm_network: Some(network),
symbol: "USDT".to_string(),
name: "Tether USD (BEP20)".to_string(),
native: false,
decimals: 18,
contract_address: Some("0x55d398326f99059fF775485246999027B3197955".to_string()),
},
WalletAssetDefinition {
chain: WalletChain::Evm,
evm_network: Some(network),
symbol: "USDC".to_string(),
name: "USD Coin (BEP20)".to_string(),
native: false,
decimals: 18,
contract_address: Some("0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d".to_string()),
},
]);
}
if matches!(network, EvmNetwork::EthereumMainnet) {
assets.extend([
WalletAssetDefinition {
+185 -147
View File
@@ -87,8 +87,6 @@ pub struct BalanceInfo {
pub enum PreparedKind {
NativeTransfer,
TokenTransfer,
Swap,
ContractCall,
}
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
@@ -147,6 +145,83 @@ pub struct ExecutionResult {
pub transaction: PreparedTransaction,
}
/// Result of a low-level "sign this unsigned transaction and broadcast it"
/// primitive. Unlike [`ExecutionResult`], this carries no `PreparedTransaction`
/// — it is the minimal output the `web3` layer needs after handing the wallet
/// an externally-built (e.g. deBridge) unsigned transaction to sign+broadcast.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct RawBroadcastResult {
pub transaction_hash: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub explorer_url: Option<String>,
/// Simulated fee in the chain's smallest unit. `None` when the fee is not
/// known at broadcast time (e.g. Solana's dynamic base+priority fee, which
/// must be read back from the confirmed transaction).
#[serde(skip_serializing_if = "Option::is_none")]
pub fee_raw: Option<String>,
}
/// Normalized lifecycle state of a broadcast transaction.
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum TxState {
/// Seen by the node but not yet included in a block.
Pending,
/// Included in a block and succeeded.
Confirmed,
/// Included in a block but reverted/failed.
Failed,
/// The node has no record of this hash.
NotFound,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TxStatusInfo {
pub chain: WalletChain,
#[serde(skip_serializing_if = "Option::is_none")]
pub evm_network: Option<EvmNetwork>,
pub hash: String,
pub state: TxState,
#[serde(skip_serializing_if = "Option::is_none")]
pub confirmations: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub block_number: Option<u64>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TxReceiptInfo {
pub chain: WalletChain,
#[serde(skip_serializing_if = "Option::is_none")]
pub evm_network: Option<EvmNetwork>,
pub hash: String,
pub found: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub success: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub block_number: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub gas_used: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub fee_raw: Option<String>,
/// Raw provider receipt payload, passed through unchanged.
pub raw: serde_json::Value,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TxLookupInfo {
pub chain: WalletChain,
#[serde(skip_serializing_if = "Option::is_none")]
pub evm_network: Option<EvmNetwork>,
pub hash: String,
pub found: bool,
/// Raw provider transaction payload, passed through unchanged.
pub raw: serde_json::Value,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PrepareTransferParams {
@@ -159,31 +234,6 @@ pub struct PrepareTransferParams {
pub evm_network: Option<EvmNetwork>,
}
#[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,
pub slippage_bps: u32,
pub router_address: String,
#[serde(default)]
pub evm_network: Option<EvmNetwork>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PrepareContractCallParams {
pub chain: WalletChain,
pub contract_address: String,
pub calldata: String,
#[serde(default = "zero_string")]
pub value_raw: String,
#[serde(default)]
pub evm_network: Option<EvmNetwork>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExecutePreparedParams {
@@ -191,10 +241,6 @@ pub struct ExecutePreparedParams {
pub confirmed: bool,
}
fn zero_string() -> String {
"0".to_string()
}
pub(crate) fn now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
@@ -247,6 +293,13 @@ pub(crate) fn current_owner() -> Option<QuoteOwner> {
.ok()
}
/// Resolve the derived EVM account address, erroring if the wallet is not
/// configured. Used by the `web3` signing primitives that operate on the
/// single shared EVM address.
pub(crate) async fn require_evm_account() -> Result<String, String> {
Ok(require_account(WalletChain::Evm).await?.address)
}
async fn require_account(chain: WalletChain) -> Result<WalletAccount, String> {
let status = wallet_status().await?.value;
if !status.configured {
@@ -328,15 +381,10 @@ fn estimated_fee_raw(chain: WalletChain, kind: PreparedKind) -> String {
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, PreparedKind::NativeTransfer) => 5_000,
(WalletChain::Solana, PreparedKind::TokenTransfer) => 5_000,
(WalletChain::Solana, _) => 5_000,
(WalletChain::Tron, PreparedKind::NativeTransfer) => 1_000_000,
(WalletChain::Tron, PreparedKind::TokenTransfer) => 15_000_000,
(WalletChain::Tron, _) => 1_000_000,
};
base.to_string()
}
@@ -707,134 +755,124 @@ pub async fn prepare_transfer(
))
}
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(&params.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(&params.amount_in_raw)?;
if amount == 0 {
return Err("swap amount_in_raw must be greater than zero".to_string());
}
let router = validate_address(params.chain, &params.router_address)?;
let account = require_account(params.chain).await?;
let network = if params.chain == WalletChain::Evm {
Some(params.evm_network.unwrap_or(EvmNetwork::EthereumMainnet))
/// Resolve the EVM network for a tx read, defaulting to Ethereum mainnet.
fn read_network(chain: WalletChain, evm_network: Option<EvmNetwork>) -> Option<EvmNetwork> {
if chain == WalletChain::Evm {
Some(evm_network.unwrap_or(EvmNetwork::EthereumMainnet))
} else {
None
};
let native_decimals = if let Some(net) = network {
evm_asset_catalog(net)
.into_iter()
.find(|value| value.native)
.map(|value| value.decimals)
.unwrap_or(18)
} else {
super::defaults::asset_catalog(params.chain)
.into_iter()
.find(|value| value.native)
.map(|value| value.decimals)
.unwrap_or(18)
};
let min_out = amount.saturating_mul((10_000 - params.slippage_bps) as u128) / 10_000;
let now = now_ms();
let quote = PreparedTransaction {
quote_id: next_quote_id(),
kind: PreparedKind::Swap,
chain: params.chain,
evm_network: network,
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,
token_address: 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
)],
owner: current_owner(),
}
}
/// Check the on-chain lifecycle state of a previously broadcast transaction.
pub async fn tx_status(
chain: WalletChain,
evm_network: Option<EvmNetwork>,
hash: &str,
) -> Result<RpcOutcome<TxStatusInfo>, String> {
let hash = hash.trim();
if hash.is_empty() {
return Err("tx hash is empty".to_string());
}
let info = match chain {
WalletChain::Evm => {
chain_evm::tx_status(read_network(chain, evm_network).unwrap(), hash).await?
}
WalletChain::Btc => chain_btc::tx_status(hash).await?,
WalletChain::Solana => chain_sol::tx_status(hash).await?,
WalletChain::Tron => chain_tron::tx_status(hash).await?,
};
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
"{LOG_PREFIX} tx_status chain={} hash={} state={:?}",
chain_str(chain),
hash,
info.state
);
Ok(RpcOutcome::new(
store_quote(quote),
vec!["wallet swap prepared".to_string()],
info,
vec!["wallet tx status fetched".to_string()],
))
}
pub async fn prepare_contract_call(
params: PrepareContractCallParams,
) -> Result<RpcOutcome<PreparedTransaction>, String> {
if params.chain != WalletChain::Evm {
return Err(format!(
"contract calls are currently implemented only for EVM; got '{}'",
chain_str(params.chain)
));
/// Fetch the receipt of a broadcast transaction (success flag, fee, block).
pub async fn tx_receipt(
chain: WalletChain,
evm_network: Option<EvmNetwork>,
hash: &str,
) -> Result<RpcOutcome<TxReceiptInfo>, String> {
let hash = hash.trim();
if hash.is_empty() {
return Err("tx hash is empty".to_string());
}
let contract = validate_address(params.chain, &params.contract_address)?;
let calldata = validate_calldata(&params.calldata)?;
let value = validate_amount(&params.value_raw)?;
let account = require_account(params.chain).await?;
let network = params.evm_network.unwrap_or(EvmNetwork::EthereumMainnet);
let native = evm_asset_catalog(network)
.into_iter()
.find(|value| value.native)
.ok_or_else(|| "missing native asset metadata for evm".to_string())?;
let now = now_ms();
let quote = PreparedTransaction {
quote_id: next_quote_id(),
kind: PreparedKind::ContractCall,
chain: params.chain,
evm_network: Some(network),
from_address: account.address.clone(),
to_address: contract,
asset_symbol: native.symbol,
amount_raw: value.to_string(),
amount_formatted: format_amount(value, native.decimals),
receive_symbol: None,
min_receive_raw: None,
calldata: Some(calldata),
token_address: None,
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 prepared from caller-supplied ABI/calldata.".to_string()],
owner: current_owner(),
let info = match chain {
WalletChain::Evm => {
chain_evm::tx_receipt(read_network(chain, evm_network).unwrap(), hash).await?
}
WalletChain::Btc => chain_btc::tx_receipt(hash).await?,
WalletChain::Solana => chain_sol::tx_receipt(hash).await?,
WalletChain::Tron => chain_tron::tx_receipt(hash).await?,
};
debug!(
"{LOG_PREFIX} prepare_contract_call chain={} quote_id={} value={}",
chain_str(params.chain),
quote.quote_id,
quote.amount_raw
"{LOG_PREFIX} tx_receipt chain={} hash={} found={}",
chain_str(chain),
hash,
info.found
);
Ok(RpcOutcome::new(
store_quote(quote),
vec!["wallet contract call prepared".to_string()],
info,
vec!["wallet tx receipt fetched".to_string()],
))
}
/// Look up the raw transaction payload by hash.
pub async fn lookup_tx(
chain: WalletChain,
evm_network: Option<EvmNetwork>,
hash: &str,
) -> Result<RpcOutcome<TxLookupInfo>, String> {
let hash = hash.trim();
if hash.is_empty() {
return Err("tx hash is empty".to_string());
}
let info = match chain {
WalletChain::Evm => {
chain_evm::lookup_tx(read_network(chain, evm_network).unwrap(), hash).await?
}
WalletChain::Btc => chain_btc::lookup_tx(hash).await?,
WalletChain::Solana => chain_sol::lookup_tx(hash).await?,
WalletChain::Tron => chain_tron::lookup_tx(hash).await?,
};
debug!(
"{LOG_PREFIX} lookup_tx chain={} hash={} found={}",
chain_str(chain),
hash,
info.found
);
Ok(RpcOutcome::new(
info,
vec!["wallet tx looked up".to_string()],
))
}
/// Crate-internal: sign+broadcast an externally-built unsigned EVM transaction
/// (deBridge swap/bridge or generic dapp calldata). See [`chain_evm::sign_and_broadcast_evm`].
pub(crate) async fn sign_and_broadcast_evm(
network: EvmNetwork,
to: &str,
data_hex: Option<String>,
value_raw: &str,
) -> Result<RawBroadcastResult, String> {
chain_evm::sign_and_broadcast_evm(network, to, data_hex, value_raw).await
}
/// Crate-internal: sign+broadcast an externally-built hex `VersionedTransaction`
/// (deBridge Solana swap/bridge). See [`chain_sol::sign_and_broadcast_versioned`].
pub(crate) async fn sign_and_broadcast_solana(
tx_blob_hex: &str,
) -> Result<RawBroadcastResult, String> {
chain_sol::sign_and_broadcast_versioned(tx_blob_hex).await
}
pub async fn execute_prepared(
params: ExecutePreparedParams,
) -> Result<RpcOutcome<ExecutionResult>, String> {
+3 -11
View File
@@ -251,17 +251,9 @@ async fn prepare_transfer_rejects_unknown_asset_symbol() {
}
#[tokio::test]
async fn prepare_contract_call_rejects_non_evm_chain() {
let err = prepare_contract_call(PrepareContractCallParams {
chain: WalletChain::Btc,
contract_address: "addr".into(),
calldata: "0x".into(),
value_raw: "0".into(),
evm_network: None,
})
.await
.unwrap_err();
assert!(err.contains("only for EVM"), "got: {err}");
async fn tx_status_rejects_empty_hash() {
let err = tx_status(WalletChain::Evm, None, " ").await.unwrap_err();
assert!(err.contains("tx hash is empty"), "got: {err}");
}
#[tokio::test]
+8 -5
View File
@@ -22,12 +22,15 @@ pub use defaults::{
rpc_url_for_evm_network, EvmNetwork, RpcSource, WalletAssetDefinition, WalletNetworkDefaults,
};
pub use execution::{
balances, chain_status, execute_prepared, network_defaults as wallet_network_defaults,
prepare_contract_call, prepare_swap, prepare_transfer, prepared_quotes_for_test,
supported_assets, BalanceInfo, ChainStatus, ExecutePreparedParams, ExecutionResult,
PrepareContractCallParams, PrepareSwapParams, PrepareTransferParams, PreparedKind,
PreparedStatus, PreparedTransaction, ProviderStatus, SupportedAsset,
balances, chain_status, execute_prepared, lookup_tx,
network_defaults as wallet_network_defaults, prepare_transfer, prepared_quotes_for_test,
supported_assets, tx_receipt, tx_status, BalanceInfo, ChainStatus, ExecutePreparedParams,
ExecutionResult, PrepareTransferParams, PreparedKind, PreparedStatus, PreparedTransaction,
ProviderStatus, SupportedAsset, TxLookupInfo, TxReceiptInfo, TxState, TxStatusInfo,
};
/// Crate-internal signing primitives the `web3` layer builds on. Not part of
/// the agent / RPC surface.
pub(crate) use execution::{sign_and_broadcast_evm, sign_and_broadcast_solana};
pub(crate) use ops::secret_material;
pub use ops::{
setup, status, WalletAccount, WalletChain, WalletSetupParams, WalletSetupSource, WalletStatus,
+110 -73
View File
@@ -5,12 +5,20 @@ use crate::core::all::{ControllerFuture, RegisteredController};
use crate::core::{ControllerSchema, FieldSchema, TypeSchema};
use super::execution::{
balances, chain_status, execute_prepared, network_defaults, prepare_contract_call,
prepare_swap, prepare_transfer, supported_assets, ExecutePreparedParams,
PrepareContractCallParams, PrepareSwapParams, PrepareTransferParams,
balances, chain_status, execute_prepared, lookup_tx, network_defaults, prepare_transfer,
supported_assets, tx_receipt, tx_status, ExecutePreparedParams, PrepareTransferParams,
};
use super::ops::{WalletAccount, WalletSetupParams, WalletSetupSource};
use super::{encode_erc20_transfer, WalletChain};
use super::{encode_erc20_transfer, EvmNetwork, WalletChain};
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct TxQueryParams {
chain: WalletChain,
#[serde(default)]
evm_network: Option<EvmNetwork>,
hash: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -52,9 +60,10 @@ pub fn all_wallet_controller_schemas() -> Vec<ControllerSchema> {
wallet_schemas("encode_erc20_transfer"),
wallet_schemas("chain_status"),
wallet_schemas("prepare_transfer"),
wallet_schemas("prepare_swap"),
wallet_schemas("prepare_contract_call"),
wallet_schemas("execute_prepared"),
wallet_schemas("tx_status"),
wallet_schemas("tx_receipt"),
wallet_schemas("lookup_tx"),
]
}
@@ -92,18 +101,22 @@ pub fn all_wallet_registered_controllers() -> Vec<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,
},
RegisteredController {
schema: wallet_schemas("tx_status"),
handler: handle_tx_status,
},
RegisteredController {
schema: wallet_schemas("tx_receipt"),
handler: handle_tx_receipt,
},
RegisteredController {
schema: wallet_schemas("lookup_tx"),
handler: handle_lookup_tx,
},
]
}
@@ -250,58 +263,42 @@ pub fn wallet_schemas(function: &str) -> ControllerSchema {
required: true,
}],
},
"prepare_swap" => ControllerSchema {
"tx_status" => ControllerSchema {
namespace: "wallet",
function: "prepare_swap",
function: "tx_status",
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."),
FieldSchema {
name: "evmNetwork",
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
comment: "Optional. EVM network selector when chain='evm'. Defaults to ethereum_mainnet.",
required: false,
},
],
"Check the on-chain lifecycle state (pending / confirmed / failed / not_found) of a transaction by hash.",
inputs: tx_query_inputs(),
outputs: vec![FieldSchema {
name: "result",
ty: TypeSchema::Json,
comment: "PreparedTransaction with quoteId, receiveSymbol, minReceiveRaw.",
comment: "{chain, evmNetwork?, hash, state, confirmations?, blockNumber?}.",
required: true,
}],
},
"prepare_contract_call" => ControllerSchema {
"tx_receipt" => ControllerSchema {
namespace: "wallet",
function: "prepare_contract_call",
function: "tx_receipt",
description:
"Build a contract-call quote for EVM. Caller supplies pre-encoded calldata.",
inputs: vec![
required_json("chain", "Target chain. Must be evm."),
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,
},
FieldSchema {
name: "evmNetwork",
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
comment: "Optional. EVM network selector. Defaults to ethereum_mainnet.",
required: false,
},
],
"Fetch the receipt of a broadcast transaction (success flag, fee, block, gas used) plus the raw provider payload.",
inputs: tx_query_inputs(),
outputs: vec![FieldSchema {
name: "result",
ty: TypeSchema::Json,
comment: "PreparedTransaction with calldata, quoteId, and simulated fee.",
comment: "{chain, evmNetwork?, hash, found, success?, blockNumber?, gasUsed?, feeRaw?, raw}.",
required: true,
}],
},
"lookup_tx" => ControllerSchema {
namespace: "wallet",
function: "lookup_tx",
description:
"Look up the raw transaction payload by hash on the target chain.",
inputs: tx_query_inputs(),
outputs: vec![FieldSchema {
name: "result",
ty: TypeSchema::Json,
comment: "{chain, evmNetwork?, hash, found, raw}.",
required: true,
}],
},
@@ -399,24 +396,6 @@ fn handle_prepare_transfer(params: Map<String, Value>) -> ControllerFuture {
})
}
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))
@@ -425,6 +404,51 @@ fn handle_execute_prepared(params: Map<String, Value>) -> ControllerFuture {
})
}
fn handle_tx_status(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let parsed: TxQueryParams = serde_json::from_value(Value::Object(params))
.map_err(|e| format!("invalid params: {e}"))?;
tx_status(parsed.chain, parsed.evm_network, &parsed.hash)
.await?
.into_cli_compatible_json()
})
}
fn handle_tx_receipt(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let parsed: TxQueryParams = serde_json::from_value(Value::Object(params))
.map_err(|e| format!("invalid params: {e}"))?;
tx_receipt(parsed.chain, parsed.evm_network, &parsed.hash)
.await?
.into_cli_compatible_json()
})
}
fn handle_lookup_tx(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let parsed: TxQueryParams = serde_json::from_value(Value::Object(params))
.map_err(|e| format!("invalid params: {e}"))?;
lookup_tx(parsed.chain, parsed.evm_network, &parsed.hash)
.await?
.into_cli_compatible_json()
})
}
/// Shared input schema for the tx_status / tx_receipt / lookup_tx readers.
fn tx_query_inputs() -> Vec<FieldSchema> {
vec![
required_json("chain", "Target chain (evm | btc | solana | tron)."),
required_json("hash", "Transaction hash / signature / txid to query."),
FieldSchema {
name: "evmNetwork",
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
comment:
"Optional. EVM network selector when chain='evm'. Defaults to ethereum_mainnet.",
required: false,
},
]
}
fn required_json(name: &'static str, comment: &'static str) -> FieldSchema {
FieldSchema {
name,
@@ -440,12 +464,25 @@ mod tests {
#[test]
fn all_schemas_lists_every_controller() {
assert_eq!(all_wallet_controller_schemas().len(), 11);
assert_eq!(all_wallet_controller_schemas().len(), 12);
}
#[test]
fn all_controllers_lists_every_handler() {
assert_eq!(all_wallet_registered_controllers().len(), 11);
assert_eq!(all_wallet_registered_controllers().len(), 12);
}
#[test]
fn tx_status_schema_takes_chain_and_hash() {
let schema = wallet_schemas("tx_status");
let names: Vec<&str> = schema.inputs.iter().map(|f| f.name).collect();
assert_eq!(names, vec!["chain", "hash", "evmNetwork"]);
}
#[test]
fn removed_swap_controller_maps_to_unknown() {
assert_eq!(wallet_schemas("prepare_swap").function, "unknown");
assert_eq!(wallet_schemas("prepare_contract_call").function, "unknown");
}
#[test]
+2
View File
@@ -1,7 +1,9 @@
mod chain_status;
mod prepare_transfer;
mod status;
mod tx_query;
pub use chain_status::WalletChainStatusTool;
pub use prepare_transfer::WalletPrepareTransferTool;
pub use status::WalletStatusTool;
pub use tx_query::{WalletLookupTxTool, WalletTxReceiptTool, WalletTxStatusTool};
@@ -44,7 +44,7 @@ impl Tool for WalletPrepareTransferTool {
},
"evmNetwork": {
"type": "string",
"enum": ["ethereum_mainnet", "base_mainnet", "arbitrum_one", "optimism_mainnet", "polygon_mainnet"],
"enum": ["ethereum_mainnet", "base_mainnet", "arbitrum_one", "optimism_mainnet", "polygon_mainnet", "bsc_mainnet"],
"description": "Optional EVM network when chain='evm'. Defaults to ethereum_mainnet."
}
},
+182
View File
@@ -0,0 +1,182 @@
//! Read-only agent tools for inspecting on-chain transactions by hash:
//! `wallet_tx_status`, `wallet_tx_receipt`, `wallet_lookup_tx`. Each shares the
//! same `{chain, hash, evmNetwork?}` input and delegates to the matching
//! `wallet::*` dispatcher.
use crate::openhuman::tools::traits::{Tool, ToolCallOptions, ToolResult};
use crate::openhuman::wallet::{self, EvmNetwork, WalletChain};
use async_trait::async_trait;
use serde::Deserialize;
use serde_json::json;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct TxQueryArgs {
chain: WalletChain,
#[serde(default)]
evm_network: Option<EvmNetwork>,
hash: String,
}
fn tx_query_schema(verb: &str) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"chain": {
"type": "string",
"enum": ["evm", "btc", "solana", "tron"],
"description": format!("Blockchain network whose transaction to {verb}")
},
"hash": {
"type": "string",
"description": "Transaction hash / signature / txid to query"
},
"evmNetwork": {
"type": "string",
"enum": ["ethereum_mainnet", "base_mainnet", "arbitrum_one", "optimism_mainnet", "polygon_mainnet", "bsc_mainnet"],
"description": "Optional EVM network when chain='evm'. Defaults to ethereum_mainnet."
}
},
"required": ["chain", "hash"],
"additionalProperties": false
})
}
fn parse_args(args: serde_json::Value) -> Result<TxQueryArgs, ToolResult> {
serde_json::from_value(args).map_err(|e| ToolResult::error(format!("invalid arguments: {e}")))
}
pub struct WalletTxStatusTool;
impl WalletTxStatusTool {
pub fn new() -> Self {
Self
}
}
#[async_trait]
impl Tool for WalletTxStatusTool {
fn name(&self) -> &str {
"wallet_tx_status"
}
fn description(&self) -> &str {
"Check the on-chain lifecycle state (pending / confirmed / failed / not_found) of a transaction by hash."
}
fn parameters_schema(&self) -> serde_json::Value {
tx_query_schema("check")
}
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
self.execute_with_options(args, ToolCallOptions::default())
.await
}
async fn execute_with_options(
&self,
args: serde_json::Value,
_options: ToolCallOptions,
) -> anyhow::Result<ToolResult> {
let args = match parse_args(args) {
Ok(a) => a,
Err(err) => return Ok(err),
};
match wallet::tx_status(args.chain, args.evm_network, &args.hash).await {
Ok(outcome) => Ok(ToolResult::success(serde_json::to_string_pretty(
&outcome.value,
)?)),
Err(e) => Ok(ToolResult::error(e)),
}
}
}
pub struct WalletTxReceiptTool;
impl WalletTxReceiptTool {
pub fn new() -> Self {
Self
}
}
#[async_trait]
impl Tool for WalletTxReceiptTool {
fn name(&self) -> &str {
"wallet_tx_receipt"
}
fn description(&self) -> &str {
"Fetch the receipt of a broadcast transaction (success flag, fee, block, gas used) by hash."
}
fn parameters_schema(&self) -> serde_json::Value {
tx_query_schema("fetch the receipt for")
}
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
self.execute_with_options(args, ToolCallOptions::default())
.await
}
async fn execute_with_options(
&self,
args: serde_json::Value,
_options: ToolCallOptions,
) -> anyhow::Result<ToolResult> {
let args = match parse_args(args) {
Ok(a) => a,
Err(err) => return Ok(err),
};
match wallet::tx_receipt(args.chain, args.evm_network, &args.hash).await {
Ok(outcome) => Ok(ToolResult::success(serde_json::to_string_pretty(
&outcome.value,
)?)),
Err(e) => Ok(ToolResult::error(e)),
}
}
}
pub struct WalletLookupTxTool;
impl WalletLookupTxTool {
pub fn new() -> Self {
Self
}
}
#[async_trait]
impl Tool for WalletLookupTxTool {
fn name(&self) -> &str {
"wallet_lookup_tx"
}
fn description(&self) -> &str {
"Look up the raw transaction payload by hash on the target chain."
}
fn parameters_schema(&self) -> serde_json::Value {
tx_query_schema("look up")
}
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
self.execute_with_options(args, ToolCallOptions::default())
.await
}
async fn execute_with_options(
&self,
args: serde_json::Value,
_options: ToolCallOptions,
) -> anyhow::Result<ToolResult> {
let args = match parse_args(args) {
Ok(a) => a,
Err(err) => return Ok(err),
};
match wallet::lookup_tx(args.chain, args.evm_network, &args.hash).await {
Ok(outcome) => Ok(ToolResult::success(serde_json::to_string_pretty(
&outcome.value,
)?)),
Err(e) => Ok(ToolResult::error(e)),
}
}
}
+73
View File
@@ -0,0 +1,73 @@
# web3
High-level web3 surface built **on top of** the [`wallet`](../wallet/README.md)
module. The wallet stays basic (keys, balances, transfers, tx inspection); this
module focuses on EVM/Solana(/BTC) dapp interactions: **swaps**, **bridges**, and
generic **dapp contract calls**.
Quotes and ready-to-sign **unsigned transactions** come from the openhuman
backend's deBridge proxy (`/agent-integrations/crypto/{routes,swap,bridge}`,
backend PR #852). This module only resolves the caller's wallet address,
forwards the request, and stores a confirm→execute quote. On execute it hands
the unsigned transaction to the wallet's crate-internal signing primitives —
`wallet::sign_and_broadcast_evm` (EVM `to`/`data`/`value`) or
`wallet::sign_and_broadcast_solana` (a hex `VersionedTransaction`) — so private
keys never leave the wallet.
## Three sub-modules (three RPC namespaces + tool families)
| Module | Namespace | Purpose |
| --- | --- | --- |
| `swap/` | `web3_swap` | Single-chain swaps via deBridge. Cross-chain requests are rejected with a pointer to `web3_bridge`. |
| `bridge/` | `web3_bridge` | Cross-chain bridges via deBridge DLN. Same-chain requests rejected. Signs+broadcasts on the **source** chain. |
| `dapp/` | `web3_dapp` | Generic EVM contract calls from caller-supplied calldata (no backend). |
## Key files
| File | Role |
| --- | --- |
| `mod.rs` | Export-focused root: aggregates `all_web3_controller_schemas` / `all_web3_registered_controllers` / `all_web3_agent_tools`, plus shared schema/tool helpers. |
| `types.rs` | deBridge chain-id ↔ local signer mapping (`chain_family`, `DEBRIDGE_SOLANA_CHAIN_ID`), request params, `UnsignedTx`, `Web3QuoteKind`. |
| `client.rs` | `CryptoClient` — thin wrapper over the shared `IntegrationClient` for `/agent-integrations/crypto/*` (Bearer JWT auth, envelope unwrap). |
| `store.rs` | In-memory prepared-quote store (TTL'd, capped, chat-thread owner-bound like the wallet) + the shared confirm→execute path. |
| `ops.rs` | Shared op logic: `routes`, `quote_swap`, `quote_bridge`, `prepare_dapp_call` (address defaulting, backend call, unsigned-tx extraction). |
| `{swap,bridge,dapp}/schemas.rs` | Per-namespace RPC controllers + handlers. |
| `{swap,bridge,dapp}/tools.rs` | Per-namespace agent tools. |
## RPC / controllers
- `web3_swap`: `quote`, `execute`, `routes` (`openhuman.web3_swap_quote`, …).
- `web3_bridge`: `quote`, `execute`.
- `web3_dapp`: `call`, `execute`.
Quote/call methods return a `quoteId`; the matching `*_execute` (with
`confirmed: true`) signs and broadcasts. Quotes are bound to the chat thread
that prepared them (no cross-session hijack of a leaked `quoteId`), TTL'd at
5 minutes, and restored with a refreshed TTL on broadcast failure.
## Agent tools
`web3_swap_quote`, `web3_swap_execute`, `web3_swap_routes`,
`web3_bridge_quote`, `web3_bridge_execute`, `web3_dapp_call`,
`web3_dapp_execute` (registered in `src/openhuman/tools/ops.rs`). They call the
backend per-invocation and error gracefully when the user is not signed in.
## Chain-id mapping
deBridge uses real EVM chain ids (1 ETH, 10 Optimism, 56 BNB, 137 Polygon,
8453 Base, 42161 Arbitrum) and a synthetic Solana id (`7565164`). `chain_family`
maps a deBridge id to the local signer family; ids we can't sign for are
rejected at quote time.
## Dependencies
- [`crate::openhuman::wallet`] — `sign_and_broadcast_evm` / `sign_and_broadcast_solana` (crate-internal), `status` for address resolution, `EvmNetwork` / `WalletChain`.
- [`crate::openhuman::integrations`] (`IntegrationClient`, `build_client`) — backend auth + transport.
- `crate::openhuman::approval::APPROVAL_CHAT_CONTEXT` — quote-owner binding.
- `crate::core::all` / `crate::core` — RPC controller registry wiring.
## Notes / gotchas
- The backend injects a configurable affiliate fee (default 1%) collected on-chain by deBridge on the source chain; this module passes the quote through unchanged.
- deBridge returns a large nested object; everything not explicitly consumed is passed through as `serde_json::Value`.
- Same-chain `web3_bridge` requests and cross-chain `web3_swap` requests are rejected (mirrors deBridge's single-chain swap vs. cross-chain DLN split).
+8
View File
@@ -0,0 +1,8 @@
//! `web3_bridge` — cross-chain bridges via deBridge DLN. Logic lives in the
//! shared [`super::ops`]/[`super::store`]; this module owns the RPC controllers
//! and agent tools.
pub mod schemas;
pub mod tools;
pub use tools::{Web3BridgeExecuteTool, Web3BridgeQuoteTool};
+89
View File
@@ -0,0 +1,89 @@
//! `web3_bridge` RPC controllers: prepare a cross-chain bridge quote and
//! execute it. Same-chain requests are rejected at the ops layer.
use serde_json::{Map, Value};
use crate::core::all::{ControllerFuture, RegisteredController};
use crate::core::{ControllerSchema, FieldSchema, TypeSchema};
use super::super::store::execute_quote;
use super::super::types::{BridgeQuoteParams, ExecuteQuoteParams};
use super::super::{execute_inputs, json_result, opt_str, req_json};
pub fn schemas() -> Vec<ControllerSchema> {
vec![schema("quote"), schema("execute")]
}
pub fn controllers() -> Vec<RegisteredController> {
vec![
RegisteredController {
schema: schema("quote"),
handler: handle_quote,
},
RegisteredController {
schema: schema("execute"),
handler: handle_execute,
},
]
}
pub fn schema(function: &str) -> ControllerSchema {
match function {
"quote" => ControllerSchema {
namespace: "web3_bridge",
function: "quote",
description:
"Prepare a cross-chain bridge via deBridge DLN. Returns a quote plus a prepared quoteId to confirm+execute; the unsigned transaction is signed+broadcast on the source chain.",
inputs: vec![
req_json("srcChainId", "Source deBridge chain id."),
req_json("srcChainTokenIn", "Source token address."),
req_json("srcChainTokenInAmount", "Source amount in the token's smallest unit."),
req_json("dstChainId", "Destination deBridge chain id (must differ from source)."),
req_json("dstChainTokenOut", "Destination token address."),
opt_str("dstChainTokenOutAmount", "Destination amount, or 'auto' for market rate (default 'auto')."),
opt_str("dstChainTokenOutRecipient", "Recipient on the destination chain. Defaults to the wallet's own address."),
opt_str("srcChainOrderAuthorityAddress", "Source-chain order authority. Defaults to the wallet's source address."),
opt_str("dstChainOrderAuthorityAddress", "Destination-chain order authority. Defaults to the wallet's destination address."),
],
outputs: vec![json_result("Prepared web3 quote {quoteId, kind, expiresAtMs, quote}.")],
},
"execute" => ControllerSchema {
namespace: "web3_bridge",
function: "execute",
description:
"Confirm and execute a prepared web3_bridge quote: signs the source-chain transaction in-core and broadcasts it.",
inputs: execute_inputs(),
outputs: vec![json_result("ExecutionResult {quoteId, kind, transactionHash, explorerUrl?, feeRaw}.")],
},
_ => ControllerSchema {
namespace: "web3_bridge",
function: "unknown",
description: "Unknown web3_bridge controller.",
inputs: vec![],
outputs: vec![FieldSchema {
name: "error",
ty: TypeSchema::String,
comment: "Lookup error details.",
required: true,
}],
},
}
}
fn handle_quote(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let parsed: BridgeQuoteParams = serde_json::from_value(Value::Object(params))
.map_err(|e| format!("invalid params: {e}"))?;
super::super::ops::quote_bridge(parsed)
.await?
.into_cli_compatible_json()
})
}
fn handle_execute(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let parsed: ExecuteQuoteParams = serde_json::from_value(Value::Object(params))
.map_err(|e| format!("invalid params: {e}"))?;
execute_quote(parsed).await?.into_cli_compatible_json()
})
}
+94
View File
@@ -0,0 +1,94 @@
//! `web3_bridge` agent tools: quote a cross-chain bridge and execute it.
use async_trait::async_trait;
use serde_json::json;
use crate::openhuman::tools::traits::{Tool, ToolCallOptions, ToolResult};
use crate::openhuman::web3::store::execute_quote;
use crate::openhuman::web3::types::{BridgeQuoteParams, ExecuteQuoteParams};
use crate::openhuman::web3::{execute_tool_schema, ops, to_tool_result};
pub struct Web3BridgeQuoteTool;
pub struct Web3BridgeExecuteTool;
impl Web3BridgeQuoteTool {
pub fn new() -> Self {
Self
}
}
impl Web3BridgeExecuteTool {
pub fn new() -> Self {
Self
}
}
#[async_trait]
impl Tool for Web3BridgeQuoteTool {
fn name(&self) -> &str {
"web3_bridge_quote"
}
fn description(&self) -> &str {
"Prepare a cross-chain bridge via deBridge DLN. Returns a quote + quoteId to confirm with web3_bridge_execute. Source and destination chains must differ."
}
fn parameters_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"srcChainId": {"type": "integer", "description": "Source deBridge chain id."},
"srcChainTokenIn": {"type": "string", "description": "Source token address."},
"srcChainTokenInAmount": {"type": "string", "description": "Source amount in the token's smallest unit."},
"dstChainId": {"type": "integer", "description": "Destination deBridge chain id (must differ from source)."},
"dstChainTokenOut": {"type": "string", "description": "Destination token address."},
"dstChainTokenOutAmount": {"type": "string", "description": "Optional. 'auto' for market rate (default)."},
"dstChainTokenOutRecipient": {"type": "string", "description": "Optional. Defaults to the wallet's own destination address."},
"srcChainOrderAuthorityAddress": {"type": "string", "description": "Optional. Defaults to the wallet's source address."},
"dstChainOrderAuthorityAddress": {"type": "string", "description": "Optional. Defaults to the wallet's destination address."}
},
"required": ["srcChainId", "srcChainTokenIn", "srcChainTokenInAmount", "dstChainId", "dstChainTokenOut"],
"additionalProperties": false
})
}
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
self.execute_with_options(args, ToolCallOptions::default())
.await
}
async fn execute_with_options(
&self,
args: serde_json::Value,
_options: ToolCallOptions,
) -> anyhow::Result<ToolResult> {
let params: BridgeQuoteParams = match serde_json::from_value(args) {
Ok(p) => p,
Err(e) => return Ok(ToolResult::error(format!("invalid arguments: {e}"))),
};
Ok(to_tool_result(ops::quote_bridge(params).await))
}
}
#[async_trait]
impl Tool for Web3BridgeExecuteTool {
fn name(&self) -> &str {
"web3_bridge_execute"
}
fn description(&self) -> &str {
"Confirm and execute a prepared web3_bridge quote (signs + broadcasts the source-chain tx)."
}
fn parameters_schema(&self) -> serde_json::Value {
execute_tool_schema()
}
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
self.execute_with_options(args, ToolCallOptions::default())
.await
}
async fn execute_with_options(
&self,
args: serde_json::Value,
_options: ToolCallOptions,
) -> anyhow::Result<ToolResult> {
let params: ExecuteQuoteParams = match serde_json::from_value(args) {
Ok(p) => p,
Err(e) => return Ok(ToolResult::error(format!("invalid arguments: {e}"))),
};
Ok(to_tool_result(execute_quote(params).await))
}
}
+74
View File
@@ -0,0 +1,74 @@
//! Thin HTTP wrapper over the openhuman backend's `/agent-integrations/crypto/*`
//! routes (deBridge DLN — see backend PR #852). All calls go through the shared
//! [`IntegrationClient`] so they inherit Bearer JWT auth, timeout, the
//! `{success,data}` envelope parsing, and proxy behavior.
//!
//! Responses are returned as raw `serde_json::Value` (the unwrapped `data`
//! payload) — deBridge returns a large nested quote/tx object and we pass the
//! parts we don't explicitly consume through unchanged.
use std::sync::Arc;
use serde_json::Value;
use crate::openhuman::config::Config;
use crate::openhuman::integrations::IntegrationClient;
const LOG_PREFIX: &str = "[web3]";
/// High-level client for the backend-proxied deBridge crypto operations.
#[derive(Clone)]
pub struct CryptoClient {
inner: Arc<IntegrationClient>,
}
impl CryptoClient {
pub fn new(inner: Arc<IntegrationClient>) -> Self {
Self { inner }
}
/// Build from config; errors when the user is not signed in (no session
/// token) — the same gate the composio tools use.
pub fn from_config(config: &Config) -> Result<Self, String> {
let inner = crate::openhuman::integrations::build_client(config).ok_or_else(|| {
"web3 requires a signed-in session (no backend auth token available)".to_string()
})?;
Ok(Self::new(inner))
}
/// `GET /agent-integrations/crypto/routes` — chains deBridge can
/// swap/bridge between.
pub async fn routes(&self) -> Result<Value, String> {
tracing::debug!("{LOG_PREFIX} routes");
self.inner
.get::<Value>("/agent-integrations/crypto/routes")
.await
.map_err(|e| format!("web3 routes failed: {e}"))
}
/// `POST /agent-integrations/crypto/swap` — single-chain swap quote + tx.
pub async fn swap_tx(&self, body: &Value) -> Result<Value, String> {
// Log only non-sensitive correlation fields — the body embeds wallet
// addresses (sender / recipient) which must not be emitted in full.
tracing::debug!("{LOG_PREFIX} swap_tx chain_id={:?}", body.get("chainId"));
self.inner
.post::<Value>("/agent-integrations/crypto/swap", body)
.await
.map_err(|e| format!("web3 swap quote failed: {e}"))
}
/// `POST /agent-integrations/crypto/bridge` — cross-chain bridge quote + tx.
pub async fn bridge_tx(&self, body: &Value) -> Result<Value, String> {
// Log only chain ids — the body embeds recipient / order-authority
// wallet addresses which must not be emitted in full.
tracing::debug!(
"{LOG_PREFIX} bridge_tx src={:?} dst={:?}",
body.get("srcChainId"),
body.get("dstChainId")
);
self.inner
.post::<Value>("/agent-integrations/crypto/bridge", body)
.await
.map_err(|e| format!("web3 bridge quote failed: {e}"))
}
}
+8
View File
@@ -0,0 +1,8 @@
//! `web3_dapp` — generic EVM contract interactions from caller-supplied
//! calldata. Logic lives in the shared [`super::ops`]/[`super::store`]; this
//! module owns the RPC controllers and agent tools.
pub mod schemas;
pub mod tools;
pub use tools::{Web3DappCallTool, Web3DappExecuteTool};
+84
View File
@@ -0,0 +1,84 @@
//! `web3_dapp` RPC controllers: prepare a generic EVM contract call from
//! caller-supplied calldata and execute it. EVM-only.
use serde_json::{Map, Value};
use crate::core::all::{ControllerFuture, RegisteredController};
use crate::core::{ControllerSchema, FieldSchema, TypeSchema};
use super::super::store::execute_quote;
use super::super::types::{DappCallParams, ExecuteQuoteParams};
use super::super::{execute_inputs, json_result, opt_str, req_json};
pub fn schemas() -> Vec<ControllerSchema> {
vec![schema("call"), schema("execute")]
}
pub fn controllers() -> Vec<RegisteredController> {
vec![
RegisteredController {
schema: schema("call"),
handler: handle_call,
},
RegisteredController {
schema: schema("execute"),
handler: handle_execute,
},
]
}
pub fn schema(function: &str) -> ControllerSchema {
match function {
"call" => ControllerSchema {
namespace: "web3_dapp",
function: "call",
description:
"Prepare a generic EVM dapp contract call from pre-encoded calldata. Returns a prepared quoteId to confirm+execute.",
inputs: vec![
req_json("contractAddress", "Target contract address."),
req_json("calldata", "0x-prefixed hex calldata."),
opt_str("valueRaw", "Native value attached, smallest unit. Defaults to '0'."),
opt_str("evmNetwork", "EVM network selector. Defaults to ethereum_mainnet."),
],
outputs: vec![json_result("Prepared web3 quote {quoteId, kind, expiresAtMs, quote}.")],
},
"execute" => ControllerSchema {
namespace: "web3_dapp",
function: "execute",
description:
"Confirm and execute a prepared web3_dapp call: signs the contract call in-core and broadcasts it.",
inputs: execute_inputs(),
outputs: vec![json_result("ExecutionResult {quoteId, kind, transactionHash, explorerUrl?, feeRaw}.")],
},
_ => ControllerSchema {
namespace: "web3_dapp",
function: "unknown",
description: "Unknown web3_dapp controller.",
inputs: vec![],
outputs: vec![FieldSchema {
name: "error",
ty: TypeSchema::String,
comment: "Lookup error details.",
required: true,
}],
},
}
}
fn handle_call(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let parsed: DappCallParams = serde_json::from_value(Value::Object(params))
.map_err(|e| format!("invalid params: {e}"))?;
super::super::ops::prepare_dapp_call(parsed)
.await?
.into_cli_compatible_json()
})
}
fn handle_execute(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let parsed: ExecuteQuoteParams = serde_json::from_value(Value::Object(params))
.map_err(|e| format!("invalid params: {e}"))?;
execute_quote(parsed).await?.into_cli_compatible_json()
})
}
+93
View File
@@ -0,0 +1,93 @@
//! `web3_dapp` agent tools: prepare a generic EVM contract call and execute it.
use async_trait::async_trait;
use serde_json::json;
use crate::openhuman::tools::traits::{Tool, ToolCallOptions, ToolResult};
use crate::openhuman::web3::store::execute_quote;
use crate::openhuman::web3::types::{DappCallParams, ExecuteQuoteParams};
use crate::openhuman::web3::{execute_tool_schema, ops, to_tool_result};
pub struct Web3DappCallTool;
pub struct Web3DappExecuteTool;
impl Web3DappCallTool {
pub fn new() -> Self {
Self
}
}
impl Web3DappExecuteTool {
pub fn new() -> Self {
Self
}
}
#[async_trait]
impl Tool for Web3DappCallTool {
fn name(&self) -> &str {
"web3_dapp_call"
}
fn description(&self) -> &str {
"Prepare a generic EVM dapp contract call from pre-encoded calldata. Returns a quoteId to confirm with web3_dapp_execute."
}
fn parameters_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"contractAddress": {"type": "string", "description": "Target contract address."},
"calldata": {"type": "string", "description": "0x-prefixed hex calldata."},
"valueRaw": {"type": "string", "description": "Optional native value (smallest unit). Defaults to '0'."},
"evmNetwork": {
"type": "string",
"enum": ["ethereum_mainnet", "base_mainnet", "arbitrum_one", "optimism_mainnet", "polygon_mainnet", "bsc_mainnet"],
"description": "Optional EVM network. Defaults to ethereum_mainnet."
}
},
"required": ["contractAddress", "calldata"],
"additionalProperties": false
})
}
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
self.execute_with_options(args, ToolCallOptions::default())
.await
}
async fn execute_with_options(
&self,
args: serde_json::Value,
_options: ToolCallOptions,
) -> anyhow::Result<ToolResult> {
let params: DappCallParams = match serde_json::from_value(args) {
Ok(p) => p,
Err(e) => return Ok(ToolResult::error(format!("invalid arguments: {e}"))),
};
Ok(to_tool_result(ops::prepare_dapp_call(params).await))
}
}
#[async_trait]
impl Tool for Web3DappExecuteTool {
fn name(&self) -> &str {
"web3_dapp_execute"
}
fn description(&self) -> &str {
"Confirm and execute a prepared web3_dapp call (signs + broadcasts)."
}
fn parameters_schema(&self) -> serde_json::Value {
execute_tool_schema()
}
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
self.execute_with_options(args, ToolCallOptions::default())
.await
}
async fn execute_with_options(
&self,
args: serde_json::Value,
_options: ToolCallOptions,
) -> anyhow::Result<ToolResult> {
let params: ExecuteQuoteParams = match serde_json::from_value(args) {
Ok(p) => p,
Err(e) => return Ok(ToolResult::error(format!("invalid arguments: {e}"))),
};
Ok(to_tool_result(execute_quote(params).await))
}
}
+126
View File
@@ -0,0 +1,126 @@
//! High-level web3 surface built on top of the [`crate::openhuman::wallet`]
//! signing primitives. Focuses on EVM/Solana dapp interactions: swaps, bridges,
//! and generic contract calls.
//!
//! Quotes + unsigned transactions come from the backend deBridge proxy
//! (`/agent-integrations/crypto/*`); signing/broadcast is delegated to the
//! wallet's crate-internal `sign_and_broadcast_*` primitives so private keys
//! never leave the wallet. Three sub-modules expose distinct RPC namespaces +
//! tool families:
//! - [`swap`] (`web3_swap`) — single-chain swaps (cross-chain → `web3_bridge`).
//! - [`bridge`] (`web3_bridge`) — cross-chain DLN bridges.
//! - [`dapp`] (`web3_dapp`) — generic EVM contract calls.
pub mod bridge;
pub mod client;
pub mod dapp;
pub mod ops;
pub mod store;
pub mod swap;
pub mod types;
#[cfg(test)]
#[path = "web3_tests.rs"]
mod tests;
use serde::Serialize;
use crate::core::all::RegisteredController;
use crate::core::{ControllerSchema, FieldSchema, TypeSchema};
use crate::openhuman::tools::traits::{Tool, ToolResult};
use crate::rpc::RpcOutcome;
/// A required JSON-typed controller input.
pub(crate) fn req_json(name: &'static str, comment: &'static str) -> FieldSchema {
FieldSchema {
name,
ty: TypeSchema::Json,
comment,
required: true,
}
}
/// An optional string controller input.
pub(crate) fn opt_str(name: &'static str, comment: &'static str) -> FieldSchema {
FieldSchema {
name,
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
comment,
required: false,
}
}
/// Standard `result` output field.
pub(crate) fn json_result(comment: &'static str) -> FieldSchema {
FieldSchema {
name: "result",
ty: TypeSchema::Json,
comment,
required: true,
}
}
/// Shared `quoteId` + `confirmed` inputs for the execute controllers.
pub(crate) fn execute_inputs() -> Vec<FieldSchema> {
vec![
req_json("quoteId", "quoteId returned by a prior web3 quote/call."),
req_json(
"confirmed",
"Must be true; explicit boundary between quote and execute.",
),
]
}
/// Shared agent-tool JSON Schema for the execute tools.
pub(crate) fn execute_tool_schema() -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"quoteId": {"type": "string", "description": "quoteId from a prior web3 quote/call."},
"confirmed": {"type": "boolean", "description": "Must be true to execute."}
},
"required": ["quoteId", "confirmed"],
"additionalProperties": false
})
}
/// Convert an op result into a `ToolResult` with pretty-printed JSON on success.
pub(crate) fn to_tool_result<T: Serialize>(result: Result<RpcOutcome<T>, String>) -> ToolResult {
match result {
Ok(outcome) => match serde_json::to_string_pretty(&outcome.value) {
Ok(s) => ToolResult::success(s),
Err(e) => ToolResult::error(format!("failed to serialize web3 result: {e}")),
},
Err(e) => ToolResult::error(e),
}
}
/// All web3 controller schemas across the swap/bridge/dapp namespaces.
pub fn all_web3_controller_schemas() -> Vec<ControllerSchema> {
let mut out = swap::schemas::schemas();
out.extend(bridge::schemas::schemas());
out.extend(dapp::schemas::schemas());
out
}
/// All web3 registered controllers across the swap/bridge/dapp namespaces.
pub fn all_web3_registered_controllers() -> Vec<RegisteredController> {
let mut out = swap::schemas::controllers();
out.extend(bridge::schemas::controllers());
out.extend(dapp::schemas::controllers());
out
}
/// All web3 agent tools. These call the backend per-invocation, so they error
/// gracefully (rather than being hidden) when the user is not signed in.
pub fn all_web3_agent_tools() -> Vec<Box<dyn Tool>> {
vec![
Box::new(swap::Web3SwapQuoteTool::new()),
Box::new(swap::Web3SwapExecuteTool::new()),
Box::new(swap::Web3SwapRoutesTool::new()),
Box::new(bridge::Web3BridgeQuoteTool::new()),
Box::new(bridge::Web3BridgeExecuteTool::new()),
Box::new(dapp::Web3DappCallTool::new()),
Box::new(dapp::Web3DappExecuteTool::new()),
]
}
+349
View File
@@ -0,0 +1,349 @@
//! web3 operation logic shared by the swap / bridge / dapp surfaces. Each op
//! resolves the caller's wallet address (defaulting recipients/authorities to
//! the wallet's own derived address), calls the backend deBridge proxy for a
//! quote + unsigned transaction, and stores a confirm→execute quote bound to
//! the signing primitive in the wallet.
use serde_json::{json, Value};
use crate::openhuman::config::rpc as config_rpc;
use crate::openhuman::wallet::{self, EvmNetwork, WalletChain};
use crate::rpc::RpcOutcome;
use super::client::CryptoClient;
use super::store::{self, Web3Quote};
use super::types::{
chain_family, BridgeQuoteParams, ChainFamily, DappCallParams, SwapQuoteParams, UnsignedTx,
Web3QuoteKind,
};
const LOG_PREFIX: &str = "[web3]";
/// Resolve the wallet's derived address for a deBridge chain family.
async fn wallet_address(family: ChainFamily) -> Result<String, String> {
let chain = match family {
ChainFamily::Evm(_) => WalletChain::Evm,
ChainFamily::Solana => WalletChain::Solana,
};
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)
.map(|a| a.address)
.ok_or_else(|| "wallet has no derived account for the requested chain".to_string())
}
/// Pull a deBridge `value` field (string or number) into a decimal string.
fn value_to_string(tx: &Value) -> String {
match tx.get("value") {
Some(Value::String(s)) => s.clone(),
Some(Value::Number(n)) => n.to_string(),
_ => "0".to_string(),
}
}
/// Extract the unsigned transaction from a deBridge quote response for the
/// given source chain family.
fn unsigned_from_response(resp: &Value, family: ChainFamily) -> Result<UnsignedTx, String> {
let tx = resp
.get("tx")
.ok_or_else(|| "backend response missing unsigned `tx`".to_string())?;
match family {
ChainFamily::Evm(network) => {
let to = tx
.get("to")
.and_then(Value::as_str)
.ok_or_else(|| "EVM unsigned tx missing `to`".to_string())?
.to_string();
let data = tx.get("data").and_then(Value::as_str).map(str::to_string);
Ok(UnsignedTx::Evm {
network,
to,
data,
value: value_to_string(tx),
})
}
ChainFamily::Solana => {
let blob = tx
.get("data")
.and_then(Value::as_str)
.ok_or_else(|| "Solana unsigned tx missing hex `data` blob".to_string())?
.to_string();
Ok(UnsignedTx::Solana { tx_blob_hex: blob })
}
}
}
/// List the chains deBridge can swap/bridge between.
pub async fn routes() -> Result<RpcOutcome<Value>, String> {
let config = config_rpc::load_config_with_timeout().await?;
let client = CryptoClient::from_config(&config)?;
let data = client.routes().await?;
Ok(RpcOutcome::new(
data,
vec!["web3 supported routes listed".to_string()],
))
}
/// Prepare a single-chain swap. Cross-chain requests are rejected here with a
/// pointer to `web3_bridge` (deBridge `/swap` is single-chain only).
pub async fn quote_swap(params: SwapQuoteParams) -> Result<RpcOutcome<Web3Quote>, String> {
let family = chain_family(params.chain_id).ok_or_else(|| {
format!(
"chain id {} is not signable by the local wallet (no EVM/Solana signer)",
params.chain_id
)
})?;
let own = wallet_address(family).await?;
let sender = params.sender_address.clone().unwrap_or_else(|| own.clone());
let recipient = params.token_out_recipient.clone().unwrap_or(own);
let config = config_rpc::load_config_with_timeout().await?;
let client = CryptoClient::from_config(&config)?;
let body = json!({
"chainId": params.chain_id,
"tokenIn": params.token_in,
"tokenInAmount": params.token_in_amount,
"tokenOut": params.token_out,
"tokenOutRecipient": recipient,
"senderAddress": sender,
"slippage": params.slippage.clone().unwrap_or_else(|| "auto".to_string()),
});
let resp = client.swap_tx(&body).await?;
let unsigned = unsigned_from_response(&resp, family)?;
tracing::debug!(
"{LOG_PREFIX} quote_swap chain_id={} tokenIn={} tokenOut={}",
params.chain_id,
params.token_in,
params.token_out
);
Ok(RpcOutcome::new(
store::store_quote(Web3QuoteKind::Swap, unsigned, resp),
vec!["web3 swap prepared".to_string()],
))
}
/// Prepare a cross-chain bridge. Same-chain requests are rejected (mirrors the
/// backend) — use `web3_swap` for single-chain swaps.
pub async fn quote_bridge(params: BridgeQuoteParams) -> Result<RpcOutcome<Web3Quote>, String> {
if params.src_chain_id == params.dst_chain_id {
return Err(
"bridge requires different source and destination chains; use web3_swap for same-chain swaps"
.to_string(),
);
}
let src_family = chain_family(params.src_chain_id).ok_or_else(|| {
format!(
"source chain id {} is not signable by the local wallet",
params.src_chain_id
)
})?;
let dst_family = chain_family(params.dst_chain_id);
let src_addr = wallet_address(src_family).await?;
// Destination recipient/authority default to our address on the dst family
// when we can sign there, else fall back to the source address.
let dst_addr = match dst_family {
Some(f) => wallet_address(f).await.unwrap_or_else(|_| src_addr.clone()),
None => src_addr.clone(),
};
let config = config_rpc::load_config_with_timeout().await?;
let client = CryptoClient::from_config(&config)?;
let body = json!({
"srcChainId": params.src_chain_id,
"srcChainTokenIn": params.src_chain_token_in,
"srcChainTokenInAmount": params.src_chain_token_in_amount,
"dstChainId": params.dst_chain_id,
"dstChainTokenOut": params.dst_chain_token_out,
"dstChainTokenOutAmount": params
.dst_chain_token_out_amount
.clone()
.unwrap_or_else(|| "auto".to_string()),
"dstChainTokenOutRecipient": params
.dst_chain_token_out_recipient
.clone()
.unwrap_or_else(|| dst_addr.clone()),
"srcChainOrderAuthorityAddress": params
.src_chain_order_authority_address
.clone()
.unwrap_or_else(|| src_addr.clone()),
"dstChainOrderAuthorityAddress": params
.dst_chain_order_authority_address
.clone()
.unwrap_or(dst_addr),
});
let resp = client.bridge_tx(&body).await?;
// The unsigned tx is always signed+broadcast on the SOURCE chain.
let unsigned = unsigned_from_response(&resp, src_family)?;
tracing::debug!(
"{LOG_PREFIX} quote_bridge src={} dst={}",
params.src_chain_id,
params.dst_chain_id
);
Ok(RpcOutcome::new(
store::store_quote(Web3QuoteKind::Bridge, unsigned, resp),
vec!["web3 bridge prepared".to_string()],
))
}
/// Prepare a generic EVM dapp contract call from caller-supplied calldata.
pub async fn prepare_dapp_call(params: DappCallParams) -> Result<RpcOutcome<Web3Quote>, String> {
let network = params.evm_network.unwrap_or(EvmNetwork::EthereumMainnet);
let contract = params.contract_address.trim();
if contract.is_empty() {
return Err("contract_address is empty".to_string());
}
let calldata = params.calldata.trim();
let hex_body = calldata
.strip_prefix("0x")
.ok_or_else(|| "calldata must be 0x-prefixed hex".to_string())?;
if hex_body.len() % 2 != 0 || !hex_body.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err("calldata must be valid even-length hex".to_string());
}
let value = params.value_raw.clone().unwrap_or_else(|| "0".to_string());
// Confirm the wallet has an EVM account before quoting.
let _ = wallet_address(ChainFamily::Evm(network)).await?;
let summary = json!({
"type": "dapp_call",
"network": network.as_str(),
"contractAddress": contract,
"calldata": calldata,
"valueRaw": value,
});
let unsigned = UnsignedTx::Evm {
network,
to: contract.to_string(),
data: Some(calldata.to_string()),
value,
};
tracing::debug!(
"{LOG_PREFIX} prepare_dapp_call network={} contract={}",
network.as_str(),
contract
);
Ok(RpcOutcome::new(
store::store_quote(Web3QuoteKind::DappCall, unsigned, summary),
vec!["web3 dapp call prepared".to_string()],
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn value_to_string_handles_string_number_and_missing() {
assert_eq!(value_to_string(&json!({"value": "123"})), "123");
assert_eq!(value_to_string(&json!({"value": 456})), "456");
assert_eq!(value_to_string(&json!({})), "0");
}
#[test]
fn unsigned_from_evm_response_extracts_to_data_value() {
let resp = json!({"tx": {"to": "0xabc", "data": "0xdeadbeef", "value": "10"}});
let unsigned =
unsigned_from_response(&resp, ChainFamily::Evm(EvmNetwork::BscMainnet)).unwrap();
match unsigned {
UnsignedTx::Evm {
network,
to,
data,
value,
} => {
assert_eq!(network, EvmNetwork::BscMainnet);
assert_eq!(to, "0xabc");
assert_eq!(data.as_deref(), Some("0xdeadbeef"));
assert_eq!(value, "10");
}
_ => panic!("expected EVM unsigned tx"),
}
}
#[test]
fn unsigned_from_solana_response_extracts_blob() {
let resp = json!({"tx": {"data": "0011aabb"}});
let unsigned = unsigned_from_response(&resp, ChainFamily::Solana).unwrap();
match unsigned {
UnsignedTx::Solana { tx_blob_hex } => assert_eq!(tx_blob_hex, "0011aabb"),
_ => panic!("expected Solana unsigned tx"),
}
}
#[test]
fn unsigned_from_response_errors_when_tx_missing() {
let err = unsigned_from_response(
&json!({"estimation": {}}),
ChainFamily::Evm(EvmNetwork::EthereumMainnet),
)
.unwrap_err();
assert!(err.contains("missing unsigned"), "got: {err}");
}
#[tokio::test]
async fn prepare_dapp_call_rejects_empty_contract() {
let err = prepare_dapp_call(DappCallParams {
contract_address: " ".to_string(),
calldata: "0xabcd".to_string(),
value_raw: None,
evm_network: None,
})
.await
.unwrap_err();
assert!(err.contains("contract_address is empty"), "got: {err}");
}
#[tokio::test]
async fn prepare_dapp_call_rejects_non_hex_calldata() {
let err = prepare_dapp_call(DappCallParams {
contract_address: "0x1111111111111111111111111111111111111111".to_string(),
calldata: "notHex".to_string(),
value_raw: None,
evm_network: None,
})
.await
.unwrap_err();
assert!(err.contains("0x-prefixed hex"), "got: {err}");
}
#[tokio::test]
async fn quote_swap_rejects_unsignable_chain() {
let err = quote_swap(SwapQuoteParams {
chain_id: 999_999,
token_in: "0x0".to_string(),
token_in_amount: "1".to_string(),
token_out: "0x1".to_string(),
token_out_recipient: None,
sender_address: None,
slippage: None,
})
.await
.unwrap_err();
assert!(err.contains("not signable"), "got: {err}");
}
#[tokio::test]
async fn quote_bridge_rejects_same_chain() {
let err = quote_bridge(BridgeQuoteParams {
src_chain_id: 1,
src_chain_token_in: "0x0".to_string(),
src_chain_token_in_amount: "1".to_string(),
dst_chain_id: 1,
dst_chain_token_out: "0x1".to_string(),
dst_chain_token_out_amount: None,
dst_chain_token_out_recipient: None,
src_chain_order_authority_address: None,
dst_chain_order_authority_address: None,
})
.await
.unwrap_err();
assert!(
err.contains("different source and destination"),
"got: {err}"
);
}
}
+206
View File
@@ -0,0 +1,206 @@
//! In-memory store for prepared web3 quotes plus the shared confirm→execute
//! path. Mirrors the wallet quote store's security model: each quote is bound
//! to the chat thread that prepared it (so a `quoteId` leaked into a shared
//! channel can't be hijacked from another agent session), TTL'd, and capped.
//!
//! On execute the stored [`UnsignedTx`] is routed to the matching crate-internal
//! wallet signer — EVM `to`/`data`/`value` or a Solana `VersionedTransaction`
//! hex blob — which signs from the wallet's encrypted recovery phrase and
//! broadcasts. The wallet remains the only place private keys are touched.
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use once_cell::sync::Lazy;
use parking_lot::Mutex;
use serde::Serialize;
use serde_json::Value;
use crate::openhuman::wallet;
use super::types::{ExecuteQuoteParams, UnsignedTx, Web3QuoteKind};
const LOG_PREFIX: &str = "[web3]";
const QUOTE_TTL_MS: u64 = 5 * 60 * 1000;
const QUOTE_STORE_CAP: usize = 64;
static QUOTE_STORE: Lazy<Mutex<Vec<StoredQuote>>> = Lazy::new(|| Mutex::new(Vec::new()));
static QUOTE_COUNTER: AtomicU64 = AtomicU64::new(1);
/// Identity of the chat thread that prepared a quote (see wallet quote-owner).
#[derive(Debug, Clone, PartialEq, Eq)]
struct QuoteOwner {
thread_id: String,
client_id: String,
}
fn current_owner() -> Option<QuoteOwner> {
crate::openhuman::approval::APPROVAL_CHAT_CONTEXT
.try_with(|ctx| QuoteOwner {
thread_id: ctx.thread_id.clone(),
client_id: ctx.client_id.clone(),
})
.ok()
}
#[derive(Clone)]
pub struct StoredQuote {
pub quote_id: String,
pub kind: Web3QuoteKind,
pub unsigned: UnsignedTx,
/// Human/agent-facing summary returned at prepare time and echoed on execute.
pub summary: Value,
owner: Option<QuoteOwner>,
expires_at_ms: u64,
}
/// Result returned to the agent / RPC caller after broadcasting.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Web3ExecutionResult {
pub quote_id: String,
pub kind: Web3QuoteKind,
pub transaction_hash: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub explorer_url: Option<String>,
/// Simulated fee in the chain's smallest unit; `None` when not known at
/// broadcast time (e.g. Solana's dynamic fee).
#[serde(skip_serializing_if = "Option::is_none")]
pub fee_raw: Option<String>,
}
/// The prepared-quote envelope returned to the caller.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Web3Quote {
pub quote_id: String,
pub kind: Web3QuoteKind,
pub expires_at_ms: u64,
/// Backend (deBridge) quote/estimation passthrough, or a dapp-call summary.
pub quote: Value,
}
pub(crate) 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!("w3_{}_{}", now_ms(), n)
}
/// Store a prepared quote and return the caller-facing envelope.
pub fn store_quote(kind: Web3QuoteKind, unsigned: UnsignedTx, summary: Value) -> Web3Quote {
let now = now_ms();
let expires_at_ms = now + QUOTE_TTL_MS;
let quote = StoredQuote {
quote_id: next_quote_id(),
kind,
unsigned,
summary: summary.clone(),
owner: current_owner(),
expires_at_ms,
};
let envelope = Web3Quote {
quote_id: quote.quote_id.clone(),
kind,
expires_at_ms,
quote: summary,
};
let mut store = QUOTE_STORE.lock();
store.retain(|q| q.expires_at_ms > now);
if store.len() >= QUOTE_STORE_CAP {
store.remove(0);
}
store.push(quote);
envelope
}
/// Remove the quote iff the caller's chat-thread owner matches. Returns the
/// byte-identical "not found" error on owner mismatch (no enumeration oracle).
fn take_quote_for(quote_id: &str, caller: Option<QuoteOwner>) -> Result<StoredQuote, String> {
let not_found = || format!("quote '{quote_id}' not found");
let mut store = QUOTE_STORE.lock();
let now = now_ms();
let pos = store
.iter()
.position(|q| q.quote_id == quote_id)
.ok_or_else(not_found)?;
if store[pos].owner != caller {
return Err(not_found());
}
let quote = store.remove(pos);
if quote.expires_at_ms <= now {
return Err(format!("quote '{quote_id}' expired"));
}
Ok(quote)
}
#[cfg(test)]
pub(crate) fn reset_store_for_tests() {
QUOTE_STORE.lock().clear();
}
#[cfg(test)]
pub(crate) fn stored_quote_count() -> usize {
QUOTE_STORE.lock().len()
}
/// Confirm and execute a prepared quote: sign+broadcast the stored unsigned
/// transaction via the wallet, restoring the quote (refreshed TTL) on failure
/// so it stays retryable.
pub async fn execute_quote(
params: ExecuteQuoteParams,
) -> Result<crate::rpc::RpcOutcome<Web3ExecutionResult>, String> {
if !params.confirmed {
return Err("execute requires `confirmed: true`".to_string());
}
let caller = current_owner();
let quote = take_quote_for(&params.quote_id, caller)?;
let kind = quote.kind;
let restorable = quote.clone();
let result = match &quote.unsigned {
UnsignedTx::Evm {
network,
to,
data,
value,
} => wallet::sign_and_broadcast_evm(*network, to, data.clone(), value).await,
UnsignedTx::Solana { tx_blob_hex } => wallet::sign_and_broadcast_solana(tx_blob_hex).await,
};
let broadcast = match result {
Ok(b) => b,
Err(error) => {
// Restore with a refreshed TTL so the caller can retry.
let mut refreshed = restorable;
refreshed.expires_at_ms = now_ms() + QUOTE_TTL_MS;
let mut store = QUOTE_STORE.lock();
if store.len() >= QUOTE_STORE_CAP {
store.remove(0);
}
store.push(refreshed);
tracing::warn!(
"{LOG_PREFIX} execute quote_id={} kind={:?} failed (restored): {error}",
params.quote_id,
kind
);
return Err(error);
}
};
Ok(crate::rpc::RpcOutcome::new(
Web3ExecutionResult {
quote_id: params.quote_id,
kind,
transaction_hash: broadcast.transaction_hash,
explorer_url: broadcast.explorer_url,
fee_raw: broadcast.fee_raw,
},
vec!["web3 transaction broadcast".to_string()],
))
}
+9
View File
@@ -0,0 +1,9 @@
//! `web3_swap` — single-chain swaps via deBridge. Cross-chain swaps are
//! redirected to `web3_bridge`. Logic lives in the shared
//! [`super::ops`]/[`super::store`]; this module owns the RPC controllers and
//! agent tools.
pub mod schemas;
pub mod tools;
pub use tools::{Web3SwapExecuteTool, Web3SwapQuoteTool, Web3SwapRoutesTool};
+111
View File
@@ -0,0 +1,111 @@
//! `web3_swap` RPC controllers: prepare a single-chain swap quote, execute a
//! prepared quote, and list supported routes. Cross-chain swaps are rejected
//! at the ops layer with a pointer to `web3_bridge`.
use serde_json::{Map, Value};
use crate::core::all::{ControllerFuture, RegisteredController};
use crate::core::{ControllerSchema, FieldSchema, TypeSchema};
use super::super::store::execute_quote;
use super::super::types::{ExecuteQuoteParams, SwapQuoteParams};
use super::super::{execute_inputs, json_result, opt_str, req_json};
pub fn schemas() -> Vec<ControllerSchema> {
vec![schema("quote"), schema("execute"), schema("routes")]
}
pub fn controllers() -> Vec<RegisteredController> {
vec![
RegisteredController {
schema: schema("quote"),
handler: handle_quote,
},
RegisteredController {
schema: schema("execute"),
handler: handle_execute,
},
RegisteredController {
schema: schema("routes"),
handler: handle_routes,
},
]
}
pub fn schema(function: &str) -> ControllerSchema {
match function {
"quote" => ControllerSchema {
namespace: "web3_swap",
function: "quote",
description:
"Prepare a single-chain token swap via deBridge. Returns a quote plus a prepared quoteId to confirm+execute. For cross-chain swaps use web3_bridge.",
inputs: vec![
req_json("chainId", "deBridge chain id the swap executes on (e.g. 1 ETH, 56 BNB, 7565164 Solana)."),
req_json("tokenIn", "Input token address (use the zero address for native)."),
req_json("tokenInAmount", "Input amount in the token's smallest unit."),
req_json("tokenOut", "Output token address."),
opt_str("tokenOutRecipient", "Address receiving output. Defaults to the wallet's own address on this chain."),
opt_str("senderAddress", "Sender. Defaults to the wallet's own address on this chain."),
opt_str("slippage", "Slippage percent or 'auto' (default 'auto')."),
],
outputs: vec![json_result("Prepared web3 quote {quoteId, kind, expiresAtMs, quote}.")],
},
"execute" => ControllerSchema {
namespace: "web3_swap",
function: "execute",
description:
"Confirm and execute a prepared web3_swap quote: signs the unsigned transaction in-core and broadcasts it.",
inputs: execute_inputs(),
outputs: vec![json_result("ExecutionResult {quoteId, kind, transactionHash, explorerUrl?, feeRaw}.")],
},
"routes" => ControllerSchema {
namespace: "web3_swap",
function: "routes",
description: "List the chains deBridge can swap/bridge between.",
inputs: vec![],
outputs: vec![json_result("deBridge supported-chains payload.")],
},
_ => unknown_schema(),
}
}
fn unknown_schema() -> ControllerSchema {
ControllerSchema {
namespace: "web3_swap",
function: "unknown",
description: "Unknown web3_swap controller.",
inputs: vec![],
outputs: vec![FieldSchema {
name: "error",
ty: TypeSchema::String,
comment: "Lookup error details.",
required: true,
}],
}
}
fn handle_quote(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let parsed: SwapQuoteParams = serde_json::from_value(Value::Object(params))
.map_err(|e| format!("invalid params: {e}"))?;
super::super::ops::quote_swap(parsed)
.await?
.into_cli_compatible_json()
})
}
fn handle_execute(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let parsed: ExecuteQuoteParams = serde_json::from_value(Value::Object(params))
.map_err(|e| format!("invalid params: {e}"))?;
execute_quote(parsed).await?.into_cli_compatible_json()
})
}
fn handle_routes(_params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
super::super::ops::routes()
.await?
.into_cli_compatible_json()
})
}
+124
View File
@@ -0,0 +1,124 @@
//! `web3_swap` agent tools: quote a single-chain swap, execute a prepared
//! quote, and list supported routes. All delegate to [`super::super::ops`] /
//! [`super::super::store`]; signing happens in the wallet.
use async_trait::async_trait;
use serde_json::json;
use crate::openhuman::tools::traits::{Tool, ToolCallOptions, ToolResult};
use crate::openhuman::web3::store::execute_quote;
use crate::openhuman::web3::types::{ExecuteQuoteParams, SwapQuoteParams};
use crate::openhuman::web3::{execute_tool_schema, ops, to_tool_result};
pub struct Web3SwapQuoteTool;
pub struct Web3SwapExecuteTool;
pub struct Web3SwapRoutesTool;
impl Web3SwapQuoteTool {
pub fn new() -> Self {
Self
}
}
impl Web3SwapExecuteTool {
pub fn new() -> Self {
Self
}
}
impl Web3SwapRoutesTool {
pub fn new() -> Self {
Self
}
}
#[async_trait]
impl Tool for Web3SwapQuoteTool {
fn name(&self) -> &str {
"web3_swap_quote"
}
fn description(&self) -> &str {
"Prepare a single-chain crypto swap via deBridge. Returns a quote + quoteId to confirm with web3_swap_execute. For cross-chain swaps use web3_bridge_quote."
}
fn parameters_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"chainId": {"type": "integer", "description": "deBridge chain id (1 ETH, 56 BNB, 137 Polygon, 8453 Base, 42161 Arbitrum, 10 Optimism, 7565164 Solana)."},
"tokenIn": {"type": "string", "description": "Input token address (zero address for native)."},
"tokenInAmount": {"type": "string", "description": "Input amount in the token's smallest unit."},
"tokenOut": {"type": "string", "description": "Output token address."},
"tokenOutRecipient": {"type": "string", "description": "Optional. Defaults to the wallet's own address."},
"senderAddress": {"type": "string", "description": "Optional. Defaults to the wallet's own address."},
"slippage": {"type": "string", "description": "Optional slippage percent or 'auto' (default 'auto')."}
},
"required": ["chainId", "tokenIn", "tokenInAmount", "tokenOut"],
"additionalProperties": false
})
}
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
self.execute_with_options(args, ToolCallOptions::default())
.await
}
async fn execute_with_options(
&self,
args: serde_json::Value,
_options: ToolCallOptions,
) -> anyhow::Result<ToolResult> {
let params: SwapQuoteParams = match serde_json::from_value(args) {
Ok(p) => p,
Err(e) => return Ok(ToolResult::error(format!("invalid arguments: {e}"))),
};
Ok(to_tool_result(ops::quote_swap(params).await))
}
}
#[async_trait]
impl Tool for Web3SwapExecuteTool {
fn name(&self) -> &str {
"web3_swap_execute"
}
fn description(&self) -> &str {
"Confirm and execute a prepared web3_swap quote (signs + broadcasts)."
}
fn parameters_schema(&self) -> serde_json::Value {
execute_tool_schema()
}
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
self.execute_with_options(args, ToolCallOptions::default())
.await
}
async fn execute_with_options(
&self,
args: serde_json::Value,
_options: ToolCallOptions,
) -> anyhow::Result<ToolResult> {
let params: ExecuteQuoteParams = match serde_json::from_value(args) {
Ok(p) => p,
Err(e) => return Ok(ToolResult::error(format!("invalid arguments: {e}"))),
};
Ok(to_tool_result(execute_quote(params).await))
}
}
#[async_trait]
impl Tool for Web3SwapRoutesTool {
fn name(&self) -> &str {
"web3_swap_routes"
}
fn description(&self) -> &str {
"List the chains deBridge can swap/bridge between."
}
fn parameters_schema(&self) -> serde_json::Value {
json!({"type": "object", "properties": {}, "additionalProperties": false})
}
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
self.execute_with_options(args, ToolCallOptions::default())
.await
}
async fn execute_with_options(
&self,
_args: serde_json::Value,
_options: ToolCallOptions,
) -> anyhow::Result<ToolResult> {
Ok(to_tool_result(ops::routes().await))
}
}
+123
View File
@@ -0,0 +1,123 @@
//! Shared web3 types: deBridge chain-id ↔ local signer mapping, request
//! params for the swap/bridge/dapp surfaces, and the stored quote shape.
//!
//! The backend (`/agent-integrations/crypto/*`, deBridge DLN) returns a
//! ready-to-sign **unsigned transaction**. This module only needs to know the
//! *chain family* of a deBridge chain id so it can route signing to the right
//! wallet primitive (EVM `to`/`data`/`value`, or a Solana `VersionedTransaction`
//! hex blob).
use serde::{Deserialize, Serialize};
use crate::openhuman::wallet::EvmNetwork;
/// deBridge's internal chain id for Solana mainnet (`/supported-chains-info`).
/// EVM chains use their real chain ids; Solana is a synthetic id. Mirrors the
/// backend's `DEBRIDGE_SOLANA_CHAIN_ID` default.
pub const DEBRIDGE_SOLANA_CHAIN_ID: u64 = 7_565_164;
/// Which local signer can sign for a given deBridge chain id.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChainFamily {
/// An EVM chain — sign+broadcast `to`/`data`/`value`.
Evm(EvmNetwork),
/// Solana — sign+broadcast a serialized `VersionedTransaction`.
Solana,
}
/// Map a deBridge chain id to the local signer family, or `None` when the
/// wallet has no signer for it (deBridge may route chains we can't sign for).
pub fn chain_family(debridge_chain_id: u64) -> Option<ChainFamily> {
match debridge_chain_id {
1 => Some(ChainFamily::Evm(EvmNetwork::EthereumMainnet)),
10 => Some(ChainFamily::Evm(EvmNetwork::OptimismMainnet)),
56 => Some(ChainFamily::Evm(EvmNetwork::BscMainnet)),
137 => Some(ChainFamily::Evm(EvmNetwork::PolygonMainnet)),
8453 => Some(ChainFamily::Evm(EvmNetwork::BaseMainnet)),
42161 => Some(ChainFamily::Evm(EvmNetwork::ArbitrumOne)),
DEBRIDGE_SOLANA_CHAIN_ID => Some(ChainFamily::Solana),
_ => None,
}
}
/// Single-chain swap request (forwarded to backend `/crypto/swap`).
/// `senderAddress` / `tokenOutRecipient` default to the wallet's own derived
/// address for the chain family when omitted.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SwapQuoteParams {
pub chain_id: u64,
pub token_in: String,
pub token_in_amount: String,
pub token_out: String,
#[serde(default)]
pub token_out_recipient: Option<String>,
#[serde(default)]
pub sender_address: Option<String>,
#[serde(default)]
pub slippage: Option<String>,
}
/// Cross-chain bridge request (forwarded to backend `/crypto/bridge`).
/// Authority + recipient addresses default to the wallet's derived addresses
/// for the relevant chain family when omitted.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BridgeQuoteParams {
pub src_chain_id: u64,
pub src_chain_token_in: String,
pub src_chain_token_in_amount: String,
pub dst_chain_id: u64,
pub dst_chain_token_out: String,
#[serde(default)]
pub dst_chain_token_out_amount: Option<String>,
#[serde(default)]
pub dst_chain_token_out_recipient: Option<String>,
#[serde(default)]
pub src_chain_order_authority_address: Option<String>,
#[serde(default)]
pub dst_chain_order_authority_address: Option<String>,
}
/// Generic EVM dapp contract call (no backend; signed directly via the wallet).
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DappCallParams {
pub contract_address: String,
pub calldata: String,
#[serde(default)]
pub value_raw: Option<String>,
#[serde(default)]
pub evm_network: Option<EvmNetwork>,
}
/// Confirm + execute a prepared web3 quote.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExecuteQuoteParams {
pub quote_id: String,
pub confirmed: bool,
}
/// What a stored quote signs+broadcasts on execute.
#[derive(Debug, Clone)]
pub enum UnsignedTx {
/// EVM transaction: `(network, to, data, value)`.
Evm {
network: EvmNetwork,
to: String,
data: Option<String>,
value: String,
},
/// Solana serialized `VersionedTransaction`, hex-encoded.
Solana { tx_blob_hex: String },
}
/// The kind of web3 operation a quote represents (for summaries/logging).
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum Web3QuoteKind {
Swap,
Bridge,
DappCall,
}
+84
View File
@@ -0,0 +1,84 @@
use super::store::{self, execute_quote};
use super::types::{chain_family, ChainFamily, ExecuteQuoteParams, UnsignedTx, Web3QuoteKind};
use crate::openhuman::wallet::EvmNetwork;
use once_cell::sync::Lazy;
use parking_lot::Mutex;
/// Serializes tests that touch the process-global web3 quote store so parallel
/// `cargo test` runs can't interleave `reset_store_for_tests` /
/// `stored_quote_count` and make count assertions flaky.
static STORE_TEST_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
#[test]
fn chain_family_maps_known_ids() {
assert_eq!(
chain_family(1),
Some(ChainFamily::Evm(EvmNetwork::EthereumMainnet))
);
assert_eq!(
chain_family(56),
Some(ChainFamily::Evm(EvmNetwork::BscMainnet))
);
assert_eq!(
chain_family(8453),
Some(ChainFamily::Evm(EvmNetwork::BaseMainnet))
);
assert_eq!(chain_family(7_565_164), Some(ChainFamily::Solana));
}
#[test]
fn chain_family_rejects_unsignable_chain() {
// 999999 is not an EVM chain we sign for, nor the deBridge Solana id.
assert_eq!(chain_family(999_999), None);
}
#[tokio::test]
async fn execute_requires_confirmation() {
let _g = STORE_TEST_LOCK.lock();
store::reset_store_for_tests();
let quote = store::store_quote(
Web3QuoteKind::DappCall,
UnsignedTx::Evm {
network: EvmNetwork::EthereumMainnet,
to: "0x0000000000000000000000000000000000000001".to_string(),
data: Some("0x".to_string()),
value: "0".to_string(),
},
serde_json::json!({"type": "dapp_call"}),
);
let err = execute_quote(ExecuteQuoteParams {
quote_id: quote.quote_id,
confirmed: false,
})
.await
.unwrap_err();
assert!(err.contains("confirmed"), "got: {err}");
}
#[tokio::test]
async fn execute_unknown_quote_is_not_found() {
let _g = STORE_TEST_LOCK.lock();
store::reset_store_for_tests();
let err = execute_quote(ExecuteQuoteParams {
quote_id: "w3_missing".to_string(),
confirmed: true,
})
.await
.unwrap_err();
assert!(err.contains("not found"), "got: {err}");
}
#[test]
fn store_quote_is_retained_and_counted() {
let _g = STORE_TEST_LOCK.lock();
store::reset_store_for_tests();
assert_eq!(store::stored_quote_count(), 0);
let _ = store::store_quote(
Web3QuoteKind::Swap,
UnsignedTx::Solana {
tx_blob_hex: "00".to_string(),
},
serde_json::json!({}),
);
assert_eq!(store::stored_quote_count(), 1);
}
+128 -2
View File
@@ -562,6 +562,16 @@ async fn mock_wallet_evm_rpc(
)
}
"eth_getBalance" => Value::String("0x0".to_string()),
"eth_blockNumber" => Value::String("0x14".to_string()),
"eth_getTransactionByHash" => {
json!({"hash": params.first().cloned().unwrap_or(Value::Null)})
}
"eth_getTransactionReceipt" => json!({
"status": "0x1",
"blockNumber": "0x10",
"gasUsed": "0x5208",
"effectiveGasPrice": "0x3b9aca00"
}),
_ => Value::Null,
};
Json(json!({"jsonrpc":"2.0","id":1,"result":result}))
@@ -3941,6 +3951,7 @@ async fn json_rpc_wallet_execution_surface_round_trips() {
"arbitrum_one",
"optimism_mainnet",
"polygon_mainnet",
"bsc_mainnet",
] {
assert!(
list.iter()
@@ -3977,8 +3988,8 @@ async fn json_rpc_wallet_execution_surface_round_trips() {
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");
// 5 EVM rows (one per L2 / mainnet) + 3 non-EVM chains.
assert_eq!(rows.len(), 8);
// 6 EVM rows (one per L2 / mainnet, incl. BNB Chain) + 3 non-EVM chains.
assert_eq!(rows.len(), 9);
assert!(
rows.iter()
.all(|r| r.get("providerStatus").and_then(Value::as_str) == Some("ready")),
@@ -4091,6 +4102,121 @@ async fn json_rpc_wallet_execution_surface_round_trips() {
rpc_join.abort();
}
/// Wallet tx-read surface (tx_status / tx_receipt / lookup_tx) plus the web3
/// surface gates (routes/quote require auth; same-chain bridge + unsignable
/// chain are rejected before any network call).
#[tokio::test]
async fn json_rpc_wallet_tx_reads_and_web3_gates_round_trip() {
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 (wallet_rpc_addr, _raw_txs) = start_mock_wallet_evm_rpc().await;
let _evm_provider_guard = EnvVarGuard::set(
"OPENHUMAN_WALLET_RPC_EVM",
&format!("http://{wallet_rpc_addr}"),
);
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;
// tx_status against the mock EVM node → confirmed with derived confirmations.
let status = post_json_rpc(
&rpc_base,
2101,
"openhuman.wallet_tx_status",
json!({ "chain": "evm", "hash": "0xdeadbeef" }),
)
.await;
let body = assert_no_jsonrpc_error(&status, "wallet_tx_status");
let result = body.get("result").unwrap_or(&body);
assert_eq!(
result.get("state").and_then(Value::as_str),
Some("confirmed")
);
// tx_receipt extracts gasUsed + computed fee.
let receipt = post_json_rpc(
&rpc_base,
2102,
"openhuman.wallet_tx_receipt",
json!({ "chain": "evm", "hash": "0xdeadbeef" }),
)
.await;
let body = assert_no_jsonrpc_error(&receipt, "wallet_tx_receipt");
let result = body.get("result").unwrap_or(&body);
assert_eq!(result.get("success").and_then(Value::as_bool), Some(true));
assert_eq!(result.get("gasUsed").and_then(Value::as_str), Some("21000"));
// lookup_tx reports found.
let lookup = post_json_rpc(
&rpc_base,
2103,
"openhuman.wallet_lookup_tx",
json!({ "chain": "evm", "hash": "0xdeadbeef" }),
)
.await;
let body = assert_no_jsonrpc_error(&lookup, "wallet_lookup_tx");
let result = body.get("result").unwrap_or(&body);
assert_eq!(result.get("found").and_then(Value::as_bool), Some(true));
// web3_bridge rejects same-chain requests. This gate runs *before* any
// auth / backend call, so no session setup is needed — assert the
// gate-specific message (not just any error) to rule out auth false-positives.
let same_chain = post_json_rpc(
&rpc_base,
2104,
"openhuman.web3_bridge_quote",
json!({
"srcChainId": 1, "srcChainTokenIn": "0x0", "srcChainTokenInAmount": "1",
"dstChainId": 1, "dstChainTokenOut": "0x1"
}),
)
.await;
let same_chain_msg = same_chain
.get("error")
.and_then(|e| e.get("message").or_else(|| e.get("data")))
.and_then(Value::as_str)
.unwrap_or_default();
assert!(
same_chain_msg.contains("different source and destination"),
"expected same-chain bridge gate rejection, got: {same_chain}"
);
// web3_swap rejects a chain id the wallet can't sign for — also a pre-auth gate.
let unsignable = post_json_rpc(
&rpc_base,
2105,
"openhuman.web3_swap_quote",
json!({
"chainId": 999999, "tokenIn": "0x0", "tokenInAmount": "1", "tokenOut": "0x1"
}),
)
.await;
let unsignable_msg = unsignable
.get("error")
.and_then(|e| e.get("message").or_else(|| e.get("data")))
.and_then(Value::as_str)
.unwrap_or_default();
assert!(
unsignable_msg.contains("not signable"),
"expected unsignable-chain swap gate rejection, got: {unsignable}"
);
mock_join.abort();
rpc_join.abort();
}
// ---------------------------------------------------------------------------
// Multi-chain wallet E2E suite (PR multi-chain-complete).
//