diff --git a/AGENTS.md b/AGENTS.md index b279ed219f..731e96b7f7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -233,12 +233,15 @@ GGML_NATIVE=OFF cargo check --manifest-path Cargo.toml \ | Feature | Default | Gates | Drops deps | | ------- | ------- | ----- | ---------- | | `voice` | ON | `openhuman::voice` + `openhuman::audio_toolkit` domains — STT/TTS providers, dictation server, always-on listening, podcast audio + email | `hound`, `lettre` | +| `web3` | ON | `openhuman::wallet` + `openhuman::web3` + `openhuman::x402` domains — crypto wallet (multi-chain sign/broadcast), swaps/bridges/dapp calls, x402 machine payments | `bitcoin`, `curve25519-dalek` | | `media` | ON | `openhuman::media_generation` (the `media_generate_*` agent tools) + `openhuman::image` scaffold | none (surface-only) | **Facade pattern (pathfinder for the other gates).** `pub mod voice;` is **always compiled** as a facade: the real submodules are `#[cfg(feature = "voice")]`, and a `#[cfg(not(feature = "voice"))] mod stub;` (`src/openhuman/voice/stub.rs`) re-exposes the same public surface that always-on / other-gated callers use (`server`, `dictation_listener`, `streaming`, `reply_speech`, `cloud_transcribe`, `cli`, `create_stt_provider`, `effective_stt_provider`, `publish_ptt_transcript_committed`) with no-op / `None` / disabled-error bodies. Callers therefore do **not** need per-call `#[cfg]`. When voice is off: the voice/audio controllers are unregistered (unknown-method over `/rpc`, absent from `/schema`), the `audio_generate_podcast` agent tools are absent, and `openhuman voice` returns a "voice disabled" error. Stub signatures must match the real ones exactly — the disabled build (`--no-default-features --features tokenjuice-treesitter`) is the **only** thing that catches drift, so run it before pushing any change to the voice surface. **Scope note:** the `voice` gate does **not** drop `whisper-rs` / `llama` / `cpal`. Those live in the inference domain (`src/openhuman/inference/local/service/whisper_engine.rs`; `cpal` is shared with accessibility) and await a separate future `inference` gate. The issue-level DoD line claiming whisper is dropped is superseded by this scope correction. +**`web3` gate — first gate that sheds real crypto deps.** Same facade pattern: `pub mod wallet;` / `pub mod web3;` / `pub mod x402;` stay always-compiled, real submodules are `#[cfg(feature = "web3")]`, and each domain's `stub.rs` re-exposes the always-on caller surface with disabled-error / empty bodies. When off, the wallet/web3/x402 controllers are unregistered, the web3 swap/bridge/dapp agent tools are absent (via `all_web3_agent_tools()` → empty), and the exclusive `bitcoin` (BTC P2WPKH PSBT) + `curve25519-dalek` (Solana off-curve ATA) deps are dropped. **tinyplace on-chain payments + Polymarket writes degrade to graceful "wallet disabled" errors** (the tinyplace comms path and the core itself are unaffected — `tinyplace::signer` still works via ed25519). The stubs cover `WALLET_NOT_CONFIGURED_MESSAGE`, `status`, `secret_material`, `WalletChain`, `prepare_transfer`/`execute_prepared` (+ param/result types), `solana_cluster`/`SolanaCluster`/`tinyplace_solana_rpc_endpoints`, `tinyplace_signer_seed`, `wallet::rpc::{redact_rpc_url, with_tinyplace_solana_endpoints}`, and the `all_*_registered_controllers`/`all_*_controller_schemas`/`all_web3_agent_tools` entry points. Two caller families still need per-call `#[cfg(feature = "web3")]` because they name concrete gated types rather than a stubbable aggregator: the six `Wallet*Tool` + `X402RequestTool` registrations in `tools/ops.rs`, the `wallet::tools::*` glob in `tools/mod.rs`, and the x402 402-retry path in `tools/impl/network/http_request.rs` (with the feature off a 402 returns to the caller unpaid). **Does NOT drop `ethers-core` / `ethers-signers` / `coins-bip39` / `bs58` / `ed25519-dalek` / `ripemd`** — those are shared with the Polymarket tools (`tools/impl/network/polymarket*`, `clob_auth`) + tinyplace + orchestration and stay always-on. Run the disabled build (`--no-default-features --features tokenjuice-treesitter`) before pushing any change to the wallet/web3/x402 surface — it is the only drift catcher. + **Leaf-gate variant (`media`, #4804).** Unlike `voice`, the `media` gate needs **no** stub facade: `media_generation` has a single caller (the `build_media_tools` call in `src/openhuman/tools/ops.rs`, itself `#[cfg(feature = "media")]`) and `openhuman::image` is unwired scaffold (#2997), so both modules are simply `#[cfg(feature = "media")] pub mod …`. It is a **surface-only** gate: media generation is backend-proxied (`reqwest`, shared) and the `image` crate is shared with channel upload, so no exclusive deps are shed — the issue's "sheds media processing dependencies" / "controllers unregistered" DoD lines are superseded (Media is agent-tools-only; no controller/store/subscriber is tagged `Media`). When a gated domain is a true leaf, prefer this over the facade+stub. ### Event bus (`src/core/event_bus/`) diff --git a/Cargo.toml b/Cargo.toml index 3312907407..c5e73ffce2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -250,7 +250,7 @@ ethers-signers = { version = "2.0.14", default-features = false } # - ed25519-dalek: Solana transaction signing. # - bs58: Solana base58 addresses + Tron base58check addresses. # - ripemd: RIPEMD160 for BTC HASH160 (P2WPKH) and Tron address hash. -bitcoin = { version = "0.32", default-features = false, features = ["std", "secp-recovery", "rand-std"] } +bitcoin = { version = "0.32", default-features = false, features = ["std", "secp-recovery", "rand-std"], optional = true } ed25519-dalek = { version = "2", default-features = false, features = ["std", "rand_core"] } bs58 = { version = "0.5", default-features = false, features = ["std", "check"] } ripemd = "0.1" @@ -260,7 +260,7 @@ ripemd = "0.1" # off the recovery phrase without going through the EVM signer wrapper. coins-bip39 = "0.8" # Solana off-curve check for ATA derivation (find_program_address). -curve25519-dalek = { version = "4", default-features = false, features = ["alloc"] } +curve25519-dalek = { version = "4", default-features = false, features = ["alloc"], optional = true } fantoccini = { version = "0.22.0", optional = true, default-features = false, features = ["rustls-tls"] } pdf-extract = "0.10" @@ -341,7 +341,7 @@ tokio = { version = "1", features = ["test-util"] } proptest = "1" [features] -default = ["tokenjuice-treesitter", "voice", "media"] +default = ["tokenjuice-treesitter", "voice", "web3", "media"] # AST-aware code compression (tree-sitter Rust/TS/Python grammars; C build). # On by default; disable to fall back to the brace-depth heuristic. tokenjuice-treesitter = [ @@ -358,6 +358,20 @@ tokenjuice-treesitter = [ # inference domain (shared with accessibility for cpal) and await a separate # `inference` gate. voice = ["dep:hound", "dep:lettre"] +# Web3 domains: openhuman::wallet + openhuman::web3 + openhuman::x402 — the +# crypto wallet (multi-chain sign/broadcast), the high-level swap/bridge/dapp +# surface, and the x402 machine-payment protocol. Default-ON — the desktop app +# always ships with the wallet. Slim / headless builds opt out via +# `--no-default-features --features ""`, which also +# drops the exclusive `bitcoin` (P2WPKH PSBT) + `curve25519-dalek` (Solana +# off-curve ATA) dependencies. Composes with the runtime `DomainSet::Web3` flag +# (#4796): the feature narrows the compile-time surface, `DomainSet` gates it at +# runtime. NOTE: this gate does NOT drop ethers-core / ethers-signers / +# coins-bip39 / bs58 / ed25519-dalek / ripemd — those are shared with the +# Polymarket tools + tinyplace on-chain payments + orchestration and stay +# always-on. When off, tinyplace payments + Polymarket writes degrade to +# graceful "wallet disabled" errors via the wallet/web3/x402 stub facades. +web3 = ["dep:bitcoin", "dep:curve25519-dalek"] # Media-generation + image domains: the `media_generate_*` agent tools # (image/video via GMI through the backend) and the `openhuman::image` tool # contracts scaffold. Default-ON. Slim builds opt out via diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index cda89b62c1..4fbdab7e8b 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -150,12 +150,15 @@ cef = { version = "=146.4.1", default-features = false } # - `media` — re-registers the `media_generate_*` agent tools that #4804 moved # behind `#[cfg(feature = "media")]`; it sheds no deps, so this only restores # the pre-gate desktop tool surface. +# - `web3` — keeps the wallet/web3/x402 domains and their agent tools in the +# desktop build while allowing slim builds to omit the crypto-only deps. # # `tokenjuice-treesitter` is the remaining un-forwarded default — tracked in # #4918, deliberately not bundled here because dropping it may be intentional. openhuman_core = { path = "../..", package = "openhuman", default-features = false, features = [ "media", "voice", + "web3", ] } tinyjuice = { version = "0.2.1", default-features = false } diff --git a/src/core/all_tests.rs b/src/core/all_tests.rs index 08412d4d26..b8980b2f33 100644 --- a/src/core/all_tests.rs +++ b/src/core/all_tests.rs @@ -234,6 +234,52 @@ fn voice_and_audio_controllers_absent_when_feature_off() { ); } +/// With the `web3` feature on (the default), the wallet + web3 + x402 +/// controllers are compiled in and registered, and the high-level web3 agent +/// tools (swap/bridge/dapp) are present — the desktop build is byte-identical. +#[test] +#[cfg(feature = "web3")] +fn wallet_web3_x402_controllers_registered_when_feature_on() { + let schemas = all_controller_schemas(); + assert!( + schemas.iter().any(|s| s.namespace == "wallet"), + "wallet controllers must be registered when the `web3` feature is on" + ); + assert!( + schemas.iter().any(|s| s.namespace.starts_with("web3_")), + "web3 (swap/bridge/dapp) controllers must be registered when the `web3` feature is on" + ); + assert!( + schemas.iter().any(|s| s.namespace == "x402"), + "x402 controllers must be registered when the `web3` feature is on" + ); + assert!( + !crate::openhuman::web3::all_web3_agent_tools().is_empty(), + "web3 agent tools must be present when the `web3` feature is on" + ); +} + +/// With the `web3` feature off, all three domains are compiled out: their +/// controllers never enter the registry (wallet/web3/x402 RPC methods are +/// unknown-method and absent from `/schema`) and the web3 agent tools are +/// gone. This is the compile-time stub-facade correctness gate (see +/// `openhuman::{wallet,web3,x402}::stub`). +#[test] +#[cfg(not(feature = "web3"))] +fn wallet_web3_x402_controllers_absent_when_feature_off() { + let schemas = all_controller_schemas(); + assert!( + !schemas.iter().any(|s| s.namespace == "wallet" + || s.namespace.starts_with("web3_") + || s.namespace == "x402"), + "wallet/web3/x402 controllers must be compiled out when the `web3` feature is off" + ); + assert!( + crate::openhuman::web3::all_web3_agent_tools().is_empty(), + "web3 agent tools must be gone when the `web3` feature is off" + ); +} + #[test] fn schema_for_rpc_method_finds_known_method() { let schema = schema_for_rpc_method("openhuman.health_snapshot"); @@ -861,6 +907,7 @@ fn group_mapping_smoke() { assert_eq!(group_for_namespace("flows"), Some(DomainGroup::Flows)); assert_eq!(group_for_namespace("skills"), Some(DomainGroup::Skills)); assert_eq!(group_for_namespace("voice"), Some(DomainGroup::Voice)); + #[cfg(feature = "web3")] assert_eq!(group_for_namespace("wallet"), Some(DomainGroup::Web3)); assert_eq!(group_for_namespace("meet"), Some(DomainGroup::Meet)); // Internal-only registry is grouped too (mcp_audit → Mcp). diff --git a/src/openhuman/tools/impl/network/http_request.rs b/src/openhuman/tools/impl/network/http_request.rs index 8684bb58c3..329525b960 100644 --- a/src/openhuman/tools/impl/network/http_request.rs +++ b/src/openhuman/tools/impl/network/http_request.rs @@ -3,6 +3,8 @@ use crate::openhuman::config::HttpRequestConfig; use crate::openhuman::security::{CommandClass, GateDecision, SecurityPolicy}; use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult}; use async_trait::async_trait; +// Only used by the `web3`-gated x402 402-retry path below. +#[cfg(feature = "web3")] use base64::engine::Engine as _; use serde_json::json; use std::sync::Arc; @@ -161,6 +163,11 @@ impl HttpRequestTool { Ok(request.send().await?) } + // x402 machine-payment retry — gated with the `web3` feature (the x402 + // domain, its ledger, and the `SettlementResponse`/`PaymentRecord` types + // are compiled out when web3 is disabled). With the feature off, a 402 is + // returned to the caller unpaid (see the call site). + #[cfg(feature = "web3")] async fn handle_x402_payment( &self, _initial_response: reqwest::Response, @@ -433,7 +440,9 @@ impl Tool for HttpRequestTool { }; // x402: if the server returns 402 with a PAYMENT-REQUIRED header, - // attempt to pay using the wallet's Solana key and retry. + // attempt to pay using the wallet's Solana key and retry. Compiled out + // when the `web3` feature is off — a 402 then passes through unpaid. + #[cfg(feature = "web3")] let response = if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED && (response.headers().get("PAYMENT-REQUIRED").is_some() || response.headers().get("X-PAYMENT-REQUIRED").is_some()) diff --git a/src/openhuman/tools/impl/network/polymarket.rs b/src/openhuman/tools/impl/network/polymarket.rs index 0da511fc46..535e2ceaa2 100644 --- a/src/openhuman/tools/impl/network/polymarket.rs +++ b/src/openhuman/tools/impl/network/polymarket.rs @@ -1419,6 +1419,56 @@ fn parse_nonce_value(value: &Value) -> Option { } } -#[cfg(test)] +// These tests seed a real wallet (`wallet::setup` + `WalletAccount` with +// `derivation_path`) to exercise wallet-signed Polymarket CLOB writes, so they +// require the `web3` feature. With web3 off the wallet is compiled out and the +// Polymarket write path degrades to a "wallet disabled" error (via the wallet +// stub), so there is nothing here to test. The tool itself still compiles in +// both configs against the stub — only these signing tests are gated. +#[cfg(all(test, feature = "web3"))] #[path = "polymarket_tests.rs"] mod tests; + +// Feature-off counterpart: with `web3` compiled out the wallet stub's +// `secret_material` returns the disabled error, so any wallet-signed Polymarket +// write must surface that instead of silently succeeding. The signing-heavy +// happy-path tests above cannot run (no wallet), so this narrow test locks in +// the degraded contract for the slim build. +#[cfg(all(test, not(feature = "web3")))] +mod tests_web3_disabled { + use super::*; + use crate::openhuman::config::PolymarketConfig; + use crate::openhuman::security::{AutonomyLevel, SecurityPolicy}; + use std::sync::Arc; + + fn write_tool_with_eoa(user: &str) -> PolymarketTool { + let config = PolymarketConfig { + enabled: true, + eoa_address: Some(user.to_string()), + ..PolymarketConfig::default() + }; + let security = Arc::new(SecurityPolicy { + autonomy: AutonomyLevel::Supervised, + ..SecurityPolicy::default() + }); + PolymarketTool::new(&config, security) + } + + #[tokio::test] + async fn signer_resolution_reports_wallet_disabled() { + // A configured EOA lets user-address resolution short-circuit, so the + // failure comes from `secret_material` (the wallet stub), proving the + // write path degrades to the disabled error rather than panicking or + // succeeding without a signer. + let tool = write_tool_with_eoa("0x000000000000000000000000000000000000dEaD"); + let err = tool + .resolve_signer_and_user(None) + .await + .expect_err("wallet-signed writes must fail when web3 is compiled out"); + let msg = format!("{err:#}"); + assert!( + msg.contains("web3/wallet feature disabled at compile time"), + "expected the stable compile-time-disabled marker, got: {msg}" + ); + } +} diff --git a/src/openhuman/tools/mod.rs b/src/openhuman/tools/mod.rs index 58aef6e4a6..1b18ff8b4b 100644 --- a/src/openhuman/tools/mod.rs +++ b/src/openhuman/tools/mod.rs @@ -54,6 +54,7 @@ pub use crate::openhuman::team::tools::*; pub use crate::openhuman::threads::tools::*; pub use crate::openhuman::tinyplace::tools::*; pub use crate::openhuman::todos::tools::*; +#[cfg(feature = "web3")] pub use crate::openhuman::wallet::tools::*; pub use crate::openhuman::whatsapp_data::tools::*; pub use crate::openhuman::workspace::tools::*; diff --git a/src/openhuman/tools/ops.rs b/src/openhuman/tools/ops.rs index 7c12a9ae46..adb7b52617 100644 --- a/src/openhuman/tools/ops.rs +++ b/src/openhuman/tools/ops.rs @@ -359,11 +359,19 @@ pub fn all_tools_with_runtime( Box::new(SuggestWorkflowsTool::new(config.clone())), // Wallet tools — expose wallet operations to the agent tool-call pipeline // so the crypto sub-agent can prepare transfers, check status, etc. + // Gated with the `web3` feature (the wallet domain is compiled out when + // web3 is disabled; the concrete tool types live under `wallet::tools`). + #[cfg(feature = "web3")] Box::new(WalletStatusTool::new()), + #[cfg(feature = "web3")] Box::new(WalletChainStatusTool::new()), + #[cfg(feature = "web3")] Box::new(WalletPrepareTransferTool::new()), + #[cfg(feature = "web3")] Box::new(WalletTxStatusTool::new()), + #[cfg(feature = "web3")] Box::new(WalletTxReceiptTool::new()), + #[cfg(feature = "web3")] Box::new(WalletLookupTxTool::new()), Box::new(MemoryStoreTool::new(memory.clone(), security.clone())), Box::new(MemoryRecallTool::new(memory.clone())), @@ -787,7 +795,9 @@ pub fn all_tools_with_runtime( // x402 — dedicated tool for making paid HTTP requests to x402-enabled // APIs (Base USDC / Solana USDC). Handles the 402 challenge, EIP-3009 - // or SPL payment signing, and ledger recording. + // or SPL payment signing, and ledger recording. Gated with the `web3` + // feature (the x402 domain is compiled out when web3 is disabled). + #[cfg(feature = "web3")] tools.push(Box::new( crate::openhuman::x402::tools::X402RequestTool::new(), )); diff --git a/src/openhuman/wallet/mod.rs b/src/openhuman/wallet/mod.rs index e144ac831c..0cbb25c699 100644 --- a/src/openhuman/wallet/mod.rs +++ b/src/openhuman/wallet/mod.rs @@ -2,29 +2,60 @@ //! the agent-facing execution surface (balances, transfers, swaps, //! contract calls). See [`execution`] for the prepare/confirm/execute flow //! and [`chains`] for the per-chain signing/broadcast implementations. +//! +//! ## Compile-time gate (`web3` feature) +//! +//! `pub mod wallet;` is ALWAYS compiled — it is a facade. The real +//! implementation (the submodules below and their re-exports) is gated behind +//! the default-ON `web3` Cargo feature (shared with `openhuman::web3` + +//! `openhuman::x402`). When the feature is off, [`stub`] takes its place and +//! exposes the same public surface that always-on / other-gated callers depend +//! on (`WALLET_NOT_CONFIGURED_MESSAGE`, `status`, `secret_material`, +//! `WalletChain`, `prepare_transfer`, `execute_prepared`, the prepare/execute +//! param + result types, `solana_cluster` / `SolanaCluster` / +//! `tinyplace_solana_rpc_endpoints`, `tinyplace_signer_seed`, the `rpc` +//! submodule, `prepared_quotes_for_test`, and the controller-registration +//! entry points) with no-op / disabled-error bodies — so tinyplace on-chain +//! payments + the Polymarket tools degrade to graceful "wallet disabled" +//! errors rather than failing to compile. Signatures MUST match the real ones; +//! the disabled build +//! (`cargo check --no-default-features --features tokenjuice-treesitter`) is +//! the only thing that catches drift. +#[cfg(feature = "web3")] mod abi; +#[cfg(feature = "web3")] mod chains; +#[cfg(feature = "web3")] mod defaults; +#[cfg(feature = "web3")] mod execution; +#[cfg(feature = "web3")] mod ops; +#[cfg(feature = "web3")] pub(crate) mod rpc; +#[cfg(feature = "web3")] mod schemas; +#[cfg(feature = "web3")] pub mod tools; -#[cfg(test)] +#[cfg(all(test, feature = "web3"))] pub(crate) mod test_support; +#[cfg(feature = "web3")] pub use abi::encode_erc20_transfer; /// 32-byte Ed25519 seed for the tiny.place LocalSigner. Derived from the user's /// primary Solana wallet key via SLIP-0010; consumed in-process and never exposed. +#[cfg(feature = "web3")] pub(crate) use chains::solana::tinyplace_signer_seed; +#[cfg(feature = "web3")] pub use 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, solana_cluster, tinyplace_solana_rpc_endpoints, EvmNetwork, RpcSource, SolanaCluster, WalletAssetDefinition, WalletNetworkDefaults, }; +#[cfg(feature = "web3")] pub use execution::{ balances, chain_status, execute_prepared, lookup_tx, network_defaults as wallet_network_defaults, prepare_transfer, prepared_quotes_for_test, @@ -34,16 +65,30 @@ pub use execution::{ }; /// Crate-internal signing primitives the `web3` layer builds on. Not part of /// the agent / RPC surface. +#[cfg(feature = "web3")] pub(crate) use execution::{sign_and_broadcast_evm, sign_and_broadcast_solana}; +#[cfg(feature = "web3")] pub(crate) use ops::secret_material; +#[cfg(feature = "web3")] pub use ops::{ reveal_recovery_phrase, setup, status, RevealRecoveryPhraseResult, WalletAccount, WalletChain, WalletSetupParams, WalletSetupSource, WalletStatus, WALLET_NOT_CONFIGURED_MESSAGE, }; /// Reduce an RPC URL to `scheme://host` for logging so private provider tokens /// embedded in the path/query never reach the logs. +#[cfg(feature = "web3")] pub(crate) use rpc::redact_rpc_url; +#[cfg(feature = "web3")] pub use schemas::{ all_controller_schemas, all_registered_controllers, all_wallet_controller_schemas, all_wallet_registered_controllers, schemas, wallet_schemas, }; + +// --------------------------------------------------------------------------- +// Disabled facade — compiled only when the `web3` feature is OFF. +// --------------------------------------------------------------------------- + +#[cfg(not(feature = "web3"))] +mod stub; +#[cfg(not(feature = "web3"))] +pub use stub::*; diff --git a/src/openhuman/wallet/stub.rs b/src/openhuman/wallet/stub.rs new file mode 100644 index 0000000000..f576002372 --- /dev/null +++ b/src/openhuman/wallet/stub.rs @@ -0,0 +1,350 @@ +//! Disabled-wallet facade. +//! +//! Compiled only when the `web3` Cargo feature is OFF (see the gate in +//! [`super`]). It mirrors the subset of the real `wallet` public surface that +//! always-on / other-gated callers depend on, with no-op / `None` / +//! disabled-error bodies so the crate still compiles, boots, and serves `/rpc` +//! without the wallet + web3 + x402 domains. +//! +//! The signatures here MUST match the real ones exactly (return types +//! included). The disabled build +//! (`cargo check --no-default-features --features tokenjuice-treesitter`) is +//! the only thing that catches drift — if a real signature changes, update the +//! mirror below until that build is green again. +//! +//! Consumers covered here (all outside `wallet`, so all must keep compiling): +//! - `core/jsonrpc.rs` — `WALLET_NOT_CONFIGURED_MESSAGE` +//! - `tools/impl/network/polymarket.rs` — `secret_material`, `status`, +//! `WalletChain` +//! - `tinyplace/payment.rs` — `prepare_transfer`, `execute_prepared`, the +//! param/result types, `SolanaCluster`, `solana_cluster`, +//! `tinyplace_solana_rpc_endpoints`, `rpc::with_tinyplace_solana_endpoints` +//! - `tinyplace/manifest.rs` — `solana_cluster`, `tinyplace_solana_rpc_endpoints`, +//! `redact_rpc_url`, `SolanaCluster::usdc_mint` +//! - `tinyplace/signal_store.rs`, `tinyplace/state.rs` — `tinyplace_signer_seed` +//! - `test_support/introspect.rs` — `prepared_quotes_for_test`, +//! `PreparedTransaction` +//! - `core/all.rs` — `all_wallet_registered_controllers` + +use serde::Serialize; + +use crate::core::all::RegisteredController; +use crate::core::ControllerSchema; +use crate::rpc::RpcOutcome; + +/// Error text returned by every disabled-path operation that must yield a +/// `Result`. Shared so callers/log-greps see one stable string. +const DISABLED_MSG: &str = "web3/wallet feature disabled at compile time"; + +/// Mirrors the real `ops::WALLET_NOT_CONFIGURED_MESSAGE` verbatim. `jsonrpc.rs` +/// compares Sentry-noise errors against this exact string, so it must not drift. +pub const WALLET_NOT_CONFIGURED_MESSAGE: &str = "wallet is not configured; run wallet setup first"; + +// --------------------------------------------------------------------------- +// Chain / status surface (mirrors `ops::{WalletChain, WalletAccount, +// WalletStatus, status, secret_material}`) +// --------------------------------------------------------------------------- + +/// The four wallet chains. Mirrors [`super::ops::WalletChain`] (real build). +/// Callers pattern-match on `Evm` / `Solana`; the full set is kept for parity. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum WalletChain { + Evm, + Btc, + Solana, + Tron, +} + +/// A derived per-chain account. Mirrors the fields Polymarket reads +/// (`chain`, `address`). +#[derive(Debug, Clone, Serialize)] +pub struct WalletAccount { + pub chain: WalletChain, + pub address: String, +} + +/// Wallet status snapshot. Only `accounts` is read by out-of-module callers +/// (Polymarket EOA resolution); with the wallet disabled it is always empty. +#[derive(Debug, Clone, Default, Serialize)] +pub struct WalletStatus { + pub accounts: Vec, +} + +/// Decrypted secret handle. Polymarket reads `encrypted_mnemonic` + +/// `derivation_path` — but `secret_material` never returns `Ok` here, so these +/// are never actually produced. Kept nameable for the return type. +pub(crate) struct WalletSecretMaterial { + pub encrypted_mnemonic: String, + pub derivation_path: String, +} + +/// Disabled: no wallet is configured, so the status carries no accounts. Kept +/// `Ok` (not `Err`) so Polymarket degrades to the clean "run wallet setup" +/// message instead of a decrypt-context error. +pub async fn status() -> Result, String> { + log::debug!("[wallet-stub] status requested (web3 disabled) — no accounts"); + Ok(RpcOutcome::new( + WalletStatus::default(), + vec!["wallet disabled at compile time".to_string()], + )) +} + +/// Always errors: secret material cannot be produced with the wallet compiled +/// out. Callers `?`-propagate (Polymarket writes surface the disabled error). +pub(crate) async fn secret_material(_chain: WalletChain) -> Result { + log::debug!( + "[wallet-stub] secret_material requested (web3 disabled) — returning disabled error" + ); + Err(DISABLED_MSG.to_string()) +} + +// --------------------------------------------------------------------------- +// Prepare / execute surface (mirrors `execution::{prepare_transfer, +// execute_prepared, PrepareTransferParams, ExecutePreparedParams, +// PreparedTransaction, ExecutionResult, prepared_quotes_for_test}`) +// --------------------------------------------------------------------------- + +/// Inputs to `prepare_transfer`. Mirrors the fields `tinyplace/payment.rs` +/// sets. `evm_network` is `Option<()>` (the real `Option` cannot be +/// named with `defaults` compiled out); the only external constructor passes +/// `None`, so the placeholder is behaviourally identical. +#[derive(Debug, Clone)] +pub struct PrepareTransferParams { + pub chain: WalletChain, + pub to_address: String, + pub amount_raw: String, + pub asset_symbol: Option, + pub evm_network: Option<()>, +} + +/// Inputs to `execute_prepared`. Mirrors the real type. +#[derive(Debug, Default, Clone)] +pub struct ExecutePreparedParams { + pub quote_id: String, + pub confirmed: bool, +} + +/// A prepared quote. Out-of-module callers read `quote_id` (`tinyplace`) and +/// serialize the collection (`test_support`); `Serialize` + that field are the +/// only requirements. The real type is far richer. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PreparedTransaction { + pub quote_id: String, +} + +/// Result of an execute. Out-of-module callers read `transaction_hash` +/// (`tinyplace/payment.rs`); `execute_prepared` never returns `Ok`, so it is +/// never actually produced. +#[derive(Debug, Clone)] +pub struct ExecutionResult { + pub transaction_hash: String, +} + +/// Disabled: no transfer can be prepared with the wallet compiled out. +pub async fn prepare_transfer( + _params: PrepareTransferParams, +) -> Result, String> { + log::debug!( + "[wallet-stub] prepare_transfer requested (web3 disabled) — returning disabled error" + ); + Err(DISABLED_MSG.to_string()) +} + +/// Disabled: no prepared transfer can be executed with the wallet compiled out. +pub async fn execute_prepared( + _params: ExecutePreparedParams, +) -> Result, String> { + log::debug!( + "[wallet-stub] execute_prepared requested (web3 disabled) — returning disabled error" + ); + Err(DISABLED_MSG.to_string()) +} + +/// Always empty: there is no quote store when the wallet is compiled out. +pub fn prepared_quotes_for_test() -> Vec { + Vec::new() +} + +/// Disabled: the tiny.place signer seed derives from the Solana wallet key, +/// which does not exist. Callers `?`-propagate → "unlock wallet" prompt. +pub(crate) async fn tinyplace_signer_seed() -> Result<[u8; 32], String> { + log::debug!( + "[wallet-stub] tinyplace_signer_seed requested (web3 disabled) — returning disabled error" + ); + Err(DISABLED_MSG.to_string()) +} + +// --------------------------------------------------------------------------- +// Solana cluster metadata (mirrors `defaults::{SolanaCluster, solana_cluster, +// tinyplace_solana_rpc_endpoints}`) +// --------------------------------------------------------------------------- + +/// Public Solana clusters. Mirrors [`super::defaults::SolanaCluster`] including +/// the `usdc_mint` accessor `tinyplace/{payment,manifest}.rs` call. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SolanaCluster { + Mainnet, + Devnet, +} + +impl SolanaCluster { + /// USDC SPL-token mint address for the cluster. Same literals as the real + /// `defaults` module so any residual log/compare paths see stable values. + pub fn usdc_mint(self) -> &'static str { + match self { + Self::Mainnet => "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + Self::Devnet => "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + } + } +} + +/// Resolve the configured Solana cluster. With the wallet disabled nothing +/// settles on-chain, so the default (Mainnet) is returned unconditionally. +pub fn solana_cluster() -> SolanaCluster { + SolanaCluster::Mainnet +} + +/// Always empty: the wallet is compiled out, so there are no settlement +/// endpoints. `tinyplace/manifest.rs`'s balance loop therefore no-ops and the +/// balance shows as unknown — the correct degraded state. +pub fn tinyplace_solana_rpc_endpoints() -> Vec { + Vec::new() +} + +// --------------------------------------------------------------------------- +// rpc submodule (mirrors `rpc::{redact_rpc_url, with_tinyplace_solana_endpoints}`) +// --------------------------------------------------------------------------- + +pub(crate) mod rpc { + /// Redact an RPC URL for logging. With the wallet disabled we never build + /// real endpoints, but `tinyplace/manifest.rs` still calls this on any URL + /// it iterates; return a constant so no token can ever leak. + pub(crate) fn redact_rpc_url(_raw: &str) -> String { + "".to_string() + } + + /// Run `fut` unchanged — there is no tiny.place endpoint scope to install + /// when the wallet is compiled out. Matches the real generic signature so + /// `tinyplace/payment.rs` type-checks; the future itself resolves to a + /// disabled error from `prepare_transfer`. + pub(crate) async fn with_tinyplace_solana_endpoints(_endpoints: Vec, fut: F) -> T + where + F: std::future::Future, + { + fut.await + } +} + +/// Re-export mirrors the real `pub(crate) use rpc::redact_rpc_url;` so +/// `wallet::redact_rpc_url` resolves at the module root for `tinyplace`. +pub(crate) use rpc::redact_rpc_url; + +// --------------------------------------------------------------------------- +// Agent-tool facade (mirrors `pub mod tools`, re-exported via tools/mod.rs) +// --------------------------------------------------------------------------- + +/// Empty tools module. `tools/mod.rs` glob-re-exports `wallet::tools::*`; the +/// concrete wallet tool constructors it names are `#[cfg(feature = "web3")]` +/// at their registration sites, so nothing is referenced here when off. +pub mod tools {} + +// --------------------------------------------------------------------------- +// Controller registration (mirrors `schemas::{all_wallet_registered_controllers, +// all_wallet_controller_schemas}`) +// --------------------------------------------------------------------------- + +/// No wallet controllers are registered when the wallet is compiled out — the +/// `openhuman.wallet_*` RPCs become unknown-method. +pub fn all_wallet_registered_controllers() -> Vec { + Vec::new() +} + +/// No wallet controller schemas when the wallet is compiled out. +pub fn all_wallet_controller_schemas() -> Vec { + Vec::new() +} + +// This module is only compiled when the `web3` feature is OFF (see the +// `#[cfg(not(feature = "web3"))] mod stub;` gate in `super`), so a plain +// `#[cfg(test)]` here already runs only in the disabled build — it locks in the +// degraded contract that always-on callers (tinyplace payments, Polymarket +// writes) depend on. +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn status_reports_no_accounts() { + let outcome = status().await.expect("stub status is always Ok"); + assert!( + outcome.value.accounts.is_empty(), + "disabled wallet must expose no accounts" + ); + } + + #[tokio::test] + async fn secret_material_is_disabled_error() { + // `WalletSecretMaterial` intentionally omits `Debug` (mirrors the real + // type, which never logs a mnemonic), so match rather than `expect_err`. + match secret_material(WalletChain::Evm).await { + Ok(_) => panic!("secret material must be unavailable when the wallet is compiled out"), + Err(msg) => assert_eq!(msg, DISABLED_MSG), + } + } + + #[tokio::test] + async fn prepare_transfer_is_disabled_error() { + let err = prepare_transfer(PrepareTransferParams { + chain: WalletChain::Solana, + to_address: "recipient".to_string(), + amount_raw: "1".to_string(), + asset_symbol: None, + evm_network: None, + }) + .await + .expect_err("no transfer can be prepared when the wallet is compiled out"); + assert_eq!(err, DISABLED_MSG); + } + + #[tokio::test] + async fn execute_prepared_is_disabled_error() { + let err = execute_prepared(ExecutePreparedParams { + quote_id: "quote".to_string(), + confirmed: true, + }) + .await + .expect_err("no prepared transfer can execute when the wallet is compiled out"); + assert_eq!(err, DISABLED_MSG); + } + + #[tokio::test] + async fn tinyplace_signer_seed_is_disabled_error() { + let err = tinyplace_signer_seed() + .await + .expect_err("no signer seed derives when the wallet is compiled out"); + assert_eq!(err, DISABLED_MSG); + } + + #[test] + fn prepared_quotes_and_rpc_endpoints_are_empty() { + assert!(prepared_quotes_for_test().is_empty()); + assert!(tinyplace_solana_rpc_endpoints().is_empty()); + } + + #[test] + fn solana_cluster_defaults_to_mainnet_with_stable_usdc_mint() { + assert_eq!(solana_cluster(), SolanaCluster::Mainnet); + assert_eq!( + SolanaCluster::Mainnet.usdc_mint(), + "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" + ); + } + + #[test] + fn registration_entry_points_are_empty() { + assert!(all_wallet_registered_controllers().is_empty()); + assert!(all_wallet_controller_schemas().is_empty()); + } +} diff --git a/src/openhuman/web3/mod.rs b/src/openhuman/web3/mod.rs index b5dc3312f0..06b82df7be 100644 --- a/src/openhuman/web3/mod.rs +++ b/src/openhuman/web3/mod.rs @@ -11,26 +11,58 @@ //! - [`bridge`] (`web3_bridge`) — cross-chain DLN bridges. //! - [`dapp`] (`web3_dapp`) — generic EVM contract calls. +//! ## Compile-time gate (`web3` feature) +//! +//! `pub mod web3;` is ALWAYS compiled — it is a facade. The real swap/bridge/ +//! dapp implementation is gated behind the default-ON `web3` Cargo feature +//! (shared with `openhuman::wallet` + `openhuman::x402`). When the feature is +//! off, [`stub`] takes its place and exposes the controller/agent-tool +//! registration entry points (`all_web3_registered_controllers`, +//! `all_web3_controller_schemas`, `all_web3_agent_tools`) returning empty +//! collections, so `core/all.rs` + `tools/ops.rs` need no per-call `#[cfg]`. + +#[cfg(feature = "web3")] pub mod bridge; +#[cfg(feature = "web3")] pub mod client; +#[cfg(feature = "web3")] pub mod dapp; +#[cfg(feature = "web3")] pub mod ops; +#[cfg(feature = "web3")] pub mod store; +#[cfg(feature = "web3")] pub mod swap; +#[cfg(feature = "web3")] pub mod types; -#[cfg(test)] +#[cfg(all(test, feature = "web3"))] #[path = "web3_tests.rs"] mod tests; +#[cfg(feature = "web3")] use serde::Serialize; +#[cfg(feature = "web3")] use crate::core::all::RegisteredController; +#[cfg(feature = "web3")] use crate::core::{ControllerSchema, FieldSchema, TypeSchema}; +#[cfg(feature = "web3")] use crate::openhuman::tools::traits::{Tool, ToolResult}; +#[cfg(feature = "web3")] use crate::rpc::RpcOutcome; +// --------------------------------------------------------------------------- +// Disabled facade — compiled only when the `web3` feature is OFF. +// --------------------------------------------------------------------------- + +#[cfg(not(feature = "web3"))] +mod stub; +#[cfg(not(feature = "web3"))] +pub use stub::*; + /// A required JSON-typed controller input. +#[cfg(feature = "web3")] pub(crate) fn req_json(name: &'static str, comment: &'static str) -> FieldSchema { FieldSchema { name, @@ -41,6 +73,7 @@ pub(crate) fn req_json(name: &'static str, comment: &'static str) -> FieldSchema } /// An optional string controller input. +#[cfg(feature = "web3")] pub(crate) fn opt_str(name: &'static str, comment: &'static str) -> FieldSchema { FieldSchema { name, @@ -51,6 +84,7 @@ pub(crate) fn opt_str(name: &'static str, comment: &'static str) -> FieldSchema } /// Standard `result` output field. +#[cfg(feature = "web3")] pub(crate) fn json_result(comment: &'static str) -> FieldSchema { FieldSchema { name: "result", @@ -61,6 +95,7 @@ pub(crate) fn json_result(comment: &'static str) -> FieldSchema { } /// Shared `quoteId` + `confirmed` inputs for the execute controllers. +#[cfg(feature = "web3")] pub(crate) fn execute_inputs() -> Vec { vec![ req_json("quoteId", "quoteId returned by a prior web3 quote/call."), @@ -72,6 +107,7 @@ pub(crate) fn execute_inputs() -> Vec { } /// Shared agent-tool JSON Schema for the execute tools. +#[cfg(feature = "web3")] pub(crate) fn execute_tool_schema() -> serde_json::Value { serde_json::json!({ "type": "object", @@ -85,6 +121,7 @@ pub(crate) fn execute_tool_schema() -> serde_json::Value { } /// Convert an op result into a `ToolResult` with pretty-printed JSON on success. +#[cfg(feature = "web3")] pub(crate) fn to_tool_result(result: Result, String>) -> ToolResult { match result { Ok(outcome) => match serde_json::to_string_pretty(&outcome.value) { @@ -96,6 +133,7 @@ pub(crate) fn to_tool_result(result: Result, String> } /// All web3 controller schemas across the swap/bridge/dapp namespaces. +#[cfg(feature = "web3")] pub fn all_web3_controller_schemas() -> Vec { let mut out = swap::schemas::schemas(); out.extend(bridge::schemas::schemas()); @@ -104,6 +142,7 @@ pub fn all_web3_controller_schemas() -> Vec { } /// All web3 registered controllers across the swap/bridge/dapp namespaces. +#[cfg(feature = "web3")] pub fn all_web3_registered_controllers() -> Vec { let mut out = swap::schemas::controllers(); out.extend(bridge::schemas::controllers()); @@ -113,6 +152,7 @@ pub fn all_web3_registered_controllers() -> Vec { /// 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. +#[cfg(feature = "web3")] pub fn all_web3_agent_tools() -> Vec> { vec![ Box::new(swap::Web3SwapQuoteTool::new()), diff --git a/src/openhuman/web3/stub.rs b/src/openhuman/web3/stub.rs new file mode 100644 index 0000000000..8e3201fde7 --- /dev/null +++ b/src/openhuman/web3/stub.rs @@ -0,0 +1,52 @@ +//! Disabled-web3 facade. +//! +//! Compiled only when the `web3` Cargo feature is OFF (see the gate in +//! [`super`]). The high-level swap/bridge/dapp surface has no always-on +//! callers other than the central registration points in `core/all.rs` +//! (controllers) and `tools/ops.rs` (agent tools), so the stub only needs to +//! return empty collections from those three entry points — no per-call +//! `#[cfg]` at the call sites. +//! +//! Signatures MUST match the real ones exactly; the disabled build +//! (`cargo check --no-default-features --features tokenjuice-treesitter`) is +//! the only thing that catches drift. + +use crate::core::all::RegisteredController; +use crate::core::ControllerSchema; +use crate::openhuman::tools::traits::Tool; + +/// No web3 controller schemas when the domain is compiled out. +pub fn all_web3_controller_schemas() -> Vec { + Vec::new() +} + +/// No web3 controllers are registered when the domain is compiled out — the +/// `openhuman.web3_*` RPCs become unknown-method. +pub fn all_web3_registered_controllers() -> Vec { + Vec::new() +} + +/// No web3 agent tools (swap/bridge/dapp) when the domain is compiled out. +pub fn all_web3_agent_tools() -> Vec> { + Vec::new() +} + +// Compiled only in the disabled build (`#[cfg(not(feature = "web3"))] mod stub;` +// in `super`), so a plain `#[cfg(test)]` runs only when web3 is compiled out — +// it pins the empty controller/tool surface that `core/all.rs` + `tools/ops.rs` +// consume without per-call `#[cfg]`. +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn registration_entry_points_are_empty() { + assert!(all_web3_registered_controllers().is_empty()); + assert!(all_web3_controller_schemas().is_empty()); + } + + #[test] + fn agent_tools_are_absent() { + assert!(all_web3_agent_tools().is_empty()); + } +} diff --git a/src/openhuman/x402/mod.rs b/src/openhuman/x402/mod.rs index 3735ae31a8..345a1e792e 100644 --- a/src/openhuman/x402/mod.rs +++ b/src/openhuman/x402/mod.rs @@ -8,22 +8,53 @@ //! //! Protocol spec: / coinbase/x402 (v2). +//! ## Compile-time gate (`web3` feature) +//! +//! `pub mod x402;` is ALWAYS compiled — it is a facade. The real payment +//! machinery is gated behind the default-ON `web3` Cargo feature (shared with +//! `openhuman::wallet` + `openhuman::web3`). When the feature is off, [`stub`] +//! takes its place and exposes the always-on entry points (`init_ledger`, +//! `all_x402_registered_controllers`, `all_x402_controller_schemas`) with +//! no-op / empty bodies. The `X402RequestTool` and the http_request 402-retry +//! path are `#[cfg(feature = "web3")]` at their call sites, so the rest of the +//! payment surface (`PaymentRecord`, `store`, `SettlementResponse`, …) is not +//! referenced when off and need not be stubbed. + +#[cfg(feature = "web3")] mod ops; +#[cfg(feature = "web3")] mod schemas; +#[cfg(feature = "web3")] pub(crate) mod store; +#[cfg(feature = "web3")] pub mod tools; +#[cfg(feature = "web3")] mod types; -#[cfg(test)] +#[cfg(all(test, feature = "web3"))] mod x402_tests; +#[cfg(feature = "web3")] pub use ops::{ handle_402, handle_402_and_pay, try_paid_request, X402Client, X402Error, X402PaymentResult, }; +#[cfg(feature = "web3")] pub use schemas::all_controller_schemas as all_x402_controller_schemas; +#[cfg(feature = "web3")] pub use schemas::all_registered_controllers as all_x402_registered_controllers; +#[cfg(feature = "web3")] pub use store::{init_global as init_ledger, PaymentRecord, PaymentStatus, SpendingBudget}; +#[cfg(feature = "web3")] pub use types::{ EvmAuthorization, EvmPaymentProof, PaymentChain, PaymentPayload, PaymentProof, PaymentRequired, PaymentRequirements, ResourceInfo, SettlementResponse, SolanaPaymentProof, }; + +// --------------------------------------------------------------------------- +// Disabled facade — compiled only when the `web3` feature is OFF. +// --------------------------------------------------------------------------- + +#[cfg(not(feature = "web3"))] +mod stub; +#[cfg(not(feature = "web3"))] +pub use stub::*; diff --git a/src/openhuman/x402/stub.rs b/src/openhuman/x402/stub.rs new file mode 100644 index 0000000000..3026c07d18 --- /dev/null +++ b/src/openhuman/x402/stub.rs @@ -0,0 +1,58 @@ +//! Disabled-x402 facade. +//! +//! Compiled only when the `web3` Cargo feature is OFF (see the gate in +//! [`super`]). Only three entry points have always-on callers: `init_ledger` +//! (`core/jsonrpc.rs` boot, itself runtime-gated on `DomainGroup::Web3`) and +//! the controller-registration pair (`core/all.rs`). The `X402RequestTool` +//! registration and the http_request 402-retry path are `#[cfg(feature = +//! "web3")]` at their call sites, so no other x402 surface is referenced when +//! off. +//! +//! Signatures MUST match the real ones exactly; the disabled build +//! (`cargo check --no-default-features --features tokenjuice-treesitter`) is +//! the only thing that catches drift. + +use std::path::Path; + +use crate::core::all::RegisteredController; +use crate::core::ControllerSchema; + +/// No-op: there is no spending ledger to initialise when x402 is compiled out. +/// Mirrors `store::init_global` (re-exported as `init_ledger`). The boot call +/// site is additionally runtime-gated on `DomainGroup::Web3`. +pub fn init_ledger(_workspace_dir: &Path, _session_id: &str) { + log::debug!("[x402-stub] init_ledger ignored (web3 disabled)"); +} + +/// No x402 controller schemas when the domain is compiled out. +pub fn all_x402_controller_schemas() -> Vec { + Vec::new() +} + +/// No x402 controllers are registered when the domain is compiled out — the +/// `openhuman.x402_*` RPCs become unknown-method. +pub fn all_x402_registered_controllers() -> Vec { + Vec::new() +} + +// Compiled only in the disabled build (`#[cfg(not(feature = "web3"))] mod stub;` +// in `super`), so a plain `#[cfg(test)]` here runs only when x402 is compiled +// out — it pins the disabled facade's callable no-op + empty-registration +// contract that `core/jsonrpc.rs` boot and `core/all.rs` rely on. +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn init_ledger_is_callable_noop() { + // Must not panic — the boot path calls this unconditionally (itself + // runtime-gated on `DomainGroup::Web3`) even in a slim build. + init_ledger(Path::new("/tmp/openhuman-x402-stub-test"), "session-x"); + } + + #[test] + fn registration_entry_points_are_empty() { + assert!(all_x402_registered_controllers().is_empty()); + assert!(all_x402_controller_schemas().is_empty()); + } +}