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