From 51d5326322a2b7be3f22212dfc7776f0304cfcd3 Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Sat, 30 May 2026 00:09:46 -0700 Subject: [PATCH] feat(web3): add swap/bridge/dapp module; slim wallet to primitives (#3006) --- src/core/all.rs | 6 + src/openhuman/about_app/catalog_data.rs | 14 +- .../agents/crypto_agent/agent.toml | 42 +- src/openhuman/agent_registry/agents/loader.rs | 19 +- src/openhuman/mod.rs | 1 + src/openhuman/tools/ops.rs | 8 + src/openhuman/wallet/README.md | 29 +- src/openhuman/wallet/chains/btc.rs | 186 ++++++- src/openhuman/wallet/chains/evm.rs | 516 +++++++++++++++--- src/openhuman/wallet/chains/mod.rs | 9 +- src/openhuman/wallet/chains/solana.rs | 342 +++++++++++- src/openhuman/wallet/chains/tron.rs | 178 +++++- src/openhuman/wallet/defaults.rs | 51 +- src/openhuman/wallet/execution.rs | 332 ++++++----- src/openhuman/wallet/execution_tests.rs | 14 +- src/openhuman/wallet/mod.rs | 13 +- src/openhuman/wallet/schemas.rs | 183 ++++--- src/openhuman/wallet/tools.rs | 2 + .../wallet/tools/prepare_transfer.rs | 2 +- src/openhuman/wallet/tools/tx_query.rs | 182 ++++++ src/openhuman/web3/README.md | 73 +++ src/openhuman/web3/bridge/mod.rs | 8 + src/openhuman/web3/bridge/schemas.rs | 89 +++ src/openhuman/web3/bridge/tools.rs | 94 ++++ src/openhuman/web3/client.rs | 74 +++ src/openhuman/web3/dapp/mod.rs | 8 + src/openhuman/web3/dapp/schemas.rs | 84 +++ src/openhuman/web3/dapp/tools.rs | 93 ++++ src/openhuman/web3/mod.rs | 126 +++++ src/openhuman/web3/ops.rs | 349 ++++++++++++ src/openhuman/web3/store.rs | 206 +++++++ src/openhuman/web3/swap/mod.rs | 9 + src/openhuman/web3/swap/schemas.rs | 111 ++++ src/openhuman/web3/swap/tools.rs | 124 +++++ src/openhuman/web3/types.rs | 123 +++++ src/openhuman/web3/web3_tests.rs | 84 +++ tests/json_rpc_e2e.rs | 130 ++++- 37 files changed, 3537 insertions(+), 377 deletions(-) create mode 100644 src/openhuman/wallet/tools/tx_query.rs create mode 100644 src/openhuman/web3/README.md create mode 100644 src/openhuman/web3/bridge/mod.rs create mode 100644 src/openhuman/web3/bridge/schemas.rs create mode 100644 src/openhuman/web3/bridge/tools.rs create mode 100644 src/openhuman/web3/client.rs create mode 100644 src/openhuman/web3/dapp/mod.rs create mode 100644 src/openhuman/web3/dapp/schemas.rs create mode 100644 src/openhuman/web3/dapp/tools.rs create mode 100644 src/openhuman/web3/mod.rs create mode 100644 src/openhuman/web3/ops.rs create mode 100644 src/openhuman/web3/store.rs create mode 100644 src/openhuman/web3/swap/mod.rs create mode 100644 src/openhuman/web3/swap/schemas.rs create mode 100644 src/openhuman/web3/swap/tools.rs create mode 100644 src/openhuman/web3/types.rs create mode 100644 src/openhuman/web3/web3_tests.rs diff --git a/src/core/all.rs b/src/core/all.rs index bda4f2fdc..349f94f2d 100644 --- a/src/core/all.rs +++ b/src/core/all.rs @@ -226,6 +226,8 @@ fn build_registered_controllers() -> Vec { 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 { #[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.", ), diff --git a/src/openhuman/about_app/catalog_data.rs b/src/openhuman/about_app/catalog_data.rs index ccb95ed5a..74d4f97c0 100644 --- a/src/openhuman/about_app/catalog_data.rs +++ b/src/openhuman/about_app/catalog_data.rs @@ -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, }, diff --git a/src/openhuman/agent_registry/agents/crypto_agent/agent.toml b/src/openhuman/agent_registry/agents/crypto_agent/agent.toml index f9ccbba27..ab7f5f4bf 100644 --- a/src/openhuman/agent_registry/agents/crypto_agent/agent.toml +++ b/src/openhuman/agent_registry/agents/crypto_agent/agent.toml @@ -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", diff --git a/src/openhuman/agent_registry/agents/loader.rs b/src/openhuman/agent_registry/agents/loader.rs index d6e901c3f..413a53682 100644 --- a/src/openhuman/agent_registry/agents/loader.rs +++ b/src/openhuman/agent_registry/agents/loader.rs @@ -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), diff --git a/src/openhuman/mod.rs b/src/openhuman/mod.rs index 29703cdd8..e2edcdd0e 100644 --- a/src/openhuman/mod.rs +++ b/src/openhuman/mod.rs @@ -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; diff --git a/src/openhuman/tools/ops.rs b/src/openhuman/tools/ops.rs index eb078afeb..0573917b8 100644 --- a/src/openhuman/tools/ops.rs +++ b/src/openhuman/tools/ops.rs @@ -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. diff --git a/src/openhuman/wallet/README.md b/src/openhuman/wallet/README.md index 300d91a70..ec2123e9d 100644 --- a/src/openhuman/wallet/README.md +++ b/src/openhuman/wallet/README.md @@ -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_`), 11 controllers registered via `all_wallet_registered_controllers`: +Namespace `wallet` (method form `openhuman.wallet_`), 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. diff --git a/src/openhuman/wallet/chains/btc.rs b/src/openhuman/wallet/chains/btc.rs index 955daf7ac..399ccafae 100644 --- a/src/openhuman/wallet/chains/btc.rs +++ b/src/openhuman/wallet/chains/btc.rs @@ -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 Result { + 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::().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 { + 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 { + let base = rpc_url_for_chain(WalletChain::Btc); + let base = base.trim_end_matches('/'); + match rest_get_json::(&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); + } } diff --git a/src/openhuman/wallet/chains/evm.rs b/src/openhuman/wallet/chains/evm.rs index 144b77088..01f5f70de 100644 --- a/src/openhuman/wallet/chains/evm.rs +++ b/src/openhuman/wallet/chains/evm.rs @@ -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 Result { - 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, +) -> 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 Result { + let network = quote.evm_network.unwrap_or(EvmNetwork::EthereumMainnet); let (tx_to, tx_value, tx_data) = match quote.kind { PreparedKind::NativeTransfer => ( Address::from_str("e.to_address).map_err(|e| { @@ -83,81 +157,11 @@ pub async fn execute_evm_quote(mut quote: PreparedTransaction) -> Result ( - Address::from_str("e.to_address) - .map_err(|e| format!("invalid contract target '{}': {e}", quote.to_address))?, - U256::from_dec_str("e.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, "e.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, + value_raw: &str, +) -> Result { + 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 { + 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 { + 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 { + 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 { let trimmed = addr.trim(); if trimmed.is_empty() { @@ -184,3 +340,199 @@ pub fn validate_evm_address(addr: &str) -> Result { 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>>, + ) { + let calls: Arc>> = + 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| { + 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"); + } +} diff --git a/src/openhuman/wallet/chains/mod.rs b/src/openhuman/wallet/chains/mod.rs index 47f7491fb..af8461674 100644 --- a/src/openhuman/wallet/chains/mod.rs +++ b/src/openhuman/wallet/chains/mod.rs @@ -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` +//! - `execute_*_quote(quote: PreparedTransaction) -> Result` //! - `native_balance(address: &str) -> Result` -//! - `validate_address(addr: &str) -> Result` +//! - `validate_*_address(addr: &str) -> Result` +//! - `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; diff --git a/src/openhuman/wallet/chains/solana.rs b/src/openhuman/wallet/chains/solana.rs index 939fc3217..ecd4ff6c3 100644 --- a/src/openhuman/wallet/chains/solana.rs +++ b/src/openhuman/wallet/chains/solana.rs @@ -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 { } } +/// 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 { + 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 = 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 { + #[derive(Deserialize)] + struct StatusResp { + value: Vec>, + } + #[derive(Deserialize)] + struct SigStatus { + slot: u64, + confirmations: Option, + err: Option, + } + 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 { + 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 { + 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 { + 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| 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)); + } } diff --git a/src/openhuman/wallet/chains/tron.rs b/src/openhuman/wallet/chains/tron.rs index d35cb0267..4577dcf9e 100644 --- a/src/openhuman/wallet/chains/tron.rs +++ b/src/openhuman/wallet/chains/tron.rs @@ -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 { - 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 Result { + 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 { + 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 { + 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 { + 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); + } } diff --git a/src/openhuman/wallet/defaults.rs b/src/openhuman/wallet/defaults.rs index 97ddced9f..983845c74 100644 --- a/src/openhuman/wallet/defaults.rs +++ b/src/openhuman/wallet/defaults.rs @@ -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 { } pub fn evm_asset_catalog(network: EvmNetwork) -> Vec { + 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 { 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 { diff --git a/src/openhuman/wallet/execution.rs b/src/openhuman/wallet/execution.rs index 6f5f99eb3..f7d1768cf 100644 --- a/src/openhuman/wallet/execution.rs +++ b/src/openhuman/wallet/execution.rs @@ -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, + /// 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, +} + +/// 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, + pub hash: String, + pub state: TxState, + #[serde(skip_serializing_if = "Option::is_none")] + pub confirmations: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub block_number: Option, +} + +#[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, + pub hash: String, + pub found: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub success: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub block_number: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub gas_used: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub fee_raw: Option, + /// 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, + 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, } -#[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, -} - -#[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, -} - #[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 { .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 { + Ok(require_account(WalletChain::Evm).await?.address) +} + async fn require_account(chain: WalletChain) -> Result { 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, String> { - if params.from_symbol.trim().is_empty() || params.to_symbol.trim().is_empty() { - return Err("swap requires non-empty from_symbol and to_symbol".to_string()); - } - if params.from_symbol.eq_ignore_ascii_case(¶ms.to_symbol) { - return Err("swap from_symbol and to_symbol must differ".to_string()); - } - if params.slippage_bps > 5_000 { - return Err("slippage_bps too high (cap 5000 = 50%)".to_string()); - } - let amount = validate_amount(¶ms.amount_in_raw)?; - if amount == 0 { - return Err("swap amount_in_raw must be greater than zero".to_string()); - } - let router = validate_address(params.chain, ¶ms.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) -> Option { + 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, + hash: &str, +) -> Result, 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, 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, + hash: &str, +) -> Result, String> { + let hash = hash.trim(); + if hash.is_empty() { + return Err("tx hash is empty".to_string()); } - let contract = validate_address(params.chain, ¶ms.contract_address)?; - let calldata = validate_calldata(¶ms.calldata)?; - let value = validate_amount(¶ms.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, + hash: &str, +) -> Result, 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, + value_raw: &str, +) -> Result { + 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 { + chain_sol::sign_and_broadcast_versioned(tx_blob_hex).await +} + pub async fn execute_prepared( params: ExecutePreparedParams, ) -> Result, String> { diff --git a/src/openhuman/wallet/execution_tests.rs b/src/openhuman/wallet/execution_tests.rs index b9a015f2b..aa148bac7 100644 --- a/src/openhuman/wallet/execution_tests.rs +++ b/src/openhuman/wallet/execution_tests.rs @@ -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] diff --git a/src/openhuman/wallet/mod.rs b/src/openhuman/wallet/mod.rs index 24147a526..5793e5cc1 100644 --- a/src/openhuman/wallet/mod.rs +++ b/src/openhuman/wallet/mod.rs @@ -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, diff --git a/src/openhuman/wallet/schemas.rs b/src/openhuman/wallet/schemas.rs index 6f1916604..abb48d996 100644 --- a/src/openhuman/wallet/schemas.rs +++ b/src/openhuman/wallet/schemas.rs @@ -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, + hash: String, +} #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -52,9 +60,10 @@ pub fn all_wallet_controller_schemas() -> Vec { 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 { 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) -> ControllerFuture { }) } -fn handle_prepare_swap(params: Map) -> ControllerFuture { - Box::pin(async move { - let parsed: PrepareSwapParams = serde_json::from_value(Value::Object(params)) - .map_err(|e| format!("invalid params: {e}"))?; - prepare_swap(parsed).await?.into_cli_compatible_json() - }) -} - -fn handle_prepare_contract_call(params: Map) -> ControllerFuture { - Box::pin(async move { - let parsed: PrepareContractCallParams = serde_json::from_value(Value::Object(params)) - .map_err(|e| format!("invalid params: {e}"))?; - prepare_contract_call(parsed) - .await? - .into_cli_compatible_json() - }) -} - fn handle_execute_prepared(params: Map) -> ControllerFuture { Box::pin(async move { let parsed: ExecutePreparedParams = serde_json::from_value(Value::Object(params)) @@ -425,6 +404,51 @@ fn handle_execute_prepared(params: Map) -> ControllerFuture { }) } +fn handle_tx_status(params: Map) -> 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) -> 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) -> 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 { + 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] diff --git a/src/openhuman/wallet/tools.rs b/src/openhuman/wallet/tools.rs index 1fc1d2ad3..c5af57101 100644 --- a/src/openhuman/wallet/tools.rs +++ b/src/openhuman/wallet/tools.rs @@ -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}; diff --git a/src/openhuman/wallet/tools/prepare_transfer.rs b/src/openhuman/wallet/tools/prepare_transfer.rs index 1fb1a833d..d898a988e 100644 --- a/src/openhuman/wallet/tools/prepare_transfer.rs +++ b/src/openhuman/wallet/tools/prepare_transfer.rs @@ -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." } }, diff --git a/src/openhuman/wallet/tools/tx_query.rs b/src/openhuman/wallet/tools/tx_query.rs new file mode 100644 index 000000000..01d55c4b0 --- /dev/null +++ b/src/openhuman/wallet/tools/tx_query.rs @@ -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, + 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 { + 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 { + self.execute_with_options(args, ToolCallOptions::default()) + .await + } + + async fn execute_with_options( + &self, + args: serde_json::Value, + _options: ToolCallOptions, + ) -> anyhow::Result { + 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 { + self.execute_with_options(args, ToolCallOptions::default()) + .await + } + + async fn execute_with_options( + &self, + args: serde_json::Value, + _options: ToolCallOptions, + ) -> anyhow::Result { + 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 { + self.execute_with_options(args, ToolCallOptions::default()) + .await + } + + async fn execute_with_options( + &self, + args: serde_json::Value, + _options: ToolCallOptions, + ) -> anyhow::Result { + 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)), + } + } +} diff --git a/src/openhuman/web3/README.md b/src/openhuman/web3/README.md new file mode 100644 index 000000000..e011829d3 --- /dev/null +++ b/src/openhuman/web3/README.md @@ -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). diff --git a/src/openhuman/web3/bridge/mod.rs b/src/openhuman/web3/bridge/mod.rs new file mode 100644 index 000000000..82bbe47df --- /dev/null +++ b/src/openhuman/web3/bridge/mod.rs @@ -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}; diff --git a/src/openhuman/web3/bridge/schemas.rs b/src/openhuman/web3/bridge/schemas.rs new file mode 100644 index 000000000..08b014b54 --- /dev/null +++ b/src/openhuman/web3/bridge/schemas.rs @@ -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 { + vec![schema("quote"), schema("execute")] +} + +pub fn controllers() -> Vec { + 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) -> 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) -> 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() + }) +} diff --git a/src/openhuman/web3/bridge/tools.rs b/src/openhuman/web3/bridge/tools.rs new file mode 100644 index 000000000..193446e6e --- /dev/null +++ b/src/openhuman/web3/bridge/tools.rs @@ -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 { + self.execute_with_options(args, ToolCallOptions::default()) + .await + } + async fn execute_with_options( + &self, + args: serde_json::Value, + _options: ToolCallOptions, + ) -> anyhow::Result { + 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 { + self.execute_with_options(args, ToolCallOptions::default()) + .await + } + async fn execute_with_options( + &self, + args: serde_json::Value, + _options: ToolCallOptions, + ) -> anyhow::Result { + 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)) + } +} diff --git a/src/openhuman/web3/client.rs b/src/openhuman/web3/client.rs new file mode 100644 index 000000000..35642eb50 --- /dev/null +++ b/src/openhuman/web3/client.rs @@ -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, +} + +impl CryptoClient { + pub fn new(inner: Arc) -> 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 { + 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 { + tracing::debug!("{LOG_PREFIX} routes"); + self.inner + .get::("/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 { + // 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::("/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 { + // 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::("/agent-integrations/crypto/bridge", body) + .await + .map_err(|e| format!("web3 bridge quote failed: {e}")) + } +} diff --git a/src/openhuman/web3/dapp/mod.rs b/src/openhuman/web3/dapp/mod.rs new file mode 100644 index 000000000..4899c2f16 --- /dev/null +++ b/src/openhuman/web3/dapp/mod.rs @@ -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}; diff --git a/src/openhuman/web3/dapp/schemas.rs b/src/openhuman/web3/dapp/schemas.rs new file mode 100644 index 000000000..74f2c5db0 --- /dev/null +++ b/src/openhuman/web3/dapp/schemas.rs @@ -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 { + vec![schema("call"), schema("execute")] +} + +pub fn controllers() -> Vec { + 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) -> 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) -> 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() + }) +} diff --git a/src/openhuman/web3/dapp/tools.rs b/src/openhuman/web3/dapp/tools.rs new file mode 100644 index 000000000..3c9af66ad --- /dev/null +++ b/src/openhuman/web3/dapp/tools.rs @@ -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 { + self.execute_with_options(args, ToolCallOptions::default()) + .await + } + async fn execute_with_options( + &self, + args: serde_json::Value, + _options: ToolCallOptions, + ) -> anyhow::Result { + 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 { + self.execute_with_options(args, ToolCallOptions::default()) + .await + } + async fn execute_with_options( + &self, + args: serde_json::Value, + _options: ToolCallOptions, + ) -> anyhow::Result { + 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)) + } +} diff --git a/src/openhuman/web3/mod.rs b/src/openhuman/web3/mod.rs new file mode 100644 index 000000000..b5dc3312f --- /dev/null +++ b/src/openhuman/web3/mod.rs @@ -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 { + 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(result: Result, 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 { + 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 { + 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> { + 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()), + ] +} diff --git a/src/openhuman/web3/ops.rs b/src/openhuman/web3/ops.rs new file mode 100644 index 000000000..8af41e1e1 --- /dev/null +++ b/src/openhuman/web3/ops.rs @@ -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 { + 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 { + 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, 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, 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, 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, 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}" + ); + } +} diff --git a/src/openhuman/web3/store.rs b/src/openhuman/web3/store.rs new file mode 100644 index 000000000..f95ba8bfb --- /dev/null +++ b/src/openhuman/web3/store.rs @@ -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>> = 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 { + 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, + 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, + /// 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, +} + +/// 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) -> Result { + 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, String> { + if !params.confirmed { + return Err("execute requires `confirmed: true`".to_string()); + } + let caller = current_owner(); + let quote = take_quote_for(¶ms.quote_id, caller)?; + let kind = quote.kind; + let restorable = quote.clone(); + + let result = match "e.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()], + )) +} diff --git a/src/openhuman/web3/swap/mod.rs b/src/openhuman/web3/swap/mod.rs new file mode 100644 index 000000000..ab55a02ee --- /dev/null +++ b/src/openhuman/web3/swap/mod.rs @@ -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}; diff --git a/src/openhuman/web3/swap/schemas.rs b/src/openhuman/web3/swap/schemas.rs new file mode 100644 index 000000000..cd2881698 --- /dev/null +++ b/src/openhuman/web3/swap/schemas.rs @@ -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 { + vec![schema("quote"), schema("execute"), schema("routes")] +} + +pub fn controllers() -> Vec { + 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) -> 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) -> 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) -> ControllerFuture { + Box::pin(async move { + super::super::ops::routes() + .await? + .into_cli_compatible_json() + }) +} diff --git a/src/openhuman/web3/swap/tools.rs b/src/openhuman/web3/swap/tools.rs new file mode 100644 index 000000000..1d6d31dc1 --- /dev/null +++ b/src/openhuman/web3/swap/tools.rs @@ -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 { + self.execute_with_options(args, ToolCallOptions::default()) + .await + } + async fn execute_with_options( + &self, + args: serde_json::Value, + _options: ToolCallOptions, + ) -> anyhow::Result { + 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 { + self.execute_with_options(args, ToolCallOptions::default()) + .await + } + async fn execute_with_options( + &self, + args: serde_json::Value, + _options: ToolCallOptions, + ) -> anyhow::Result { + 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 { + self.execute_with_options(args, ToolCallOptions::default()) + .await + } + async fn execute_with_options( + &self, + _args: serde_json::Value, + _options: ToolCallOptions, + ) -> anyhow::Result { + Ok(to_tool_result(ops::routes().await)) + } +} diff --git a/src/openhuman/web3/types.rs b/src/openhuman/web3/types.rs new file mode 100644 index 000000000..6feba4317 --- /dev/null +++ b/src/openhuman/web3/types.rs @@ -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 { + 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, + #[serde(default)] + pub sender_address: Option, + #[serde(default)] + pub slippage: Option, +} + +/// 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, + #[serde(default)] + pub dst_chain_token_out_recipient: Option, + #[serde(default)] + pub src_chain_order_authority_address: Option, + #[serde(default)] + pub dst_chain_order_authority_address: Option, +} + +/// 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, + #[serde(default)] + pub evm_network: Option, +} + +/// 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, + 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, +} diff --git a/src/openhuman/web3/web3_tests.rs b/src/openhuman/web3/web3_tests.rs new file mode 100644 index 000000000..60febedab --- /dev/null +++ b/src/openhuman/web3/web3_tests.rs @@ -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> = 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); +} diff --git a/tests/json_rpc_e2e.rs b/tests/json_rpc_e2e.rs index a13e6a36e..7e37cc1b4 100644 --- a/tests/json_rpc_e2e.rs +++ b/tests/json_rpc_e2e.rs @@ -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). //