From 2f4c3f397d16e4ee3e1b7bbd3ac952dd4050c0d3 Mon Sep 17 00:00:00 2001 From: oxoxDev <164490987+oxoxDev@users.noreply.github.com> Date: Tue, 19 May 2026 19:15:20 +0530 Subject: [PATCH] feat(integrations): Polymarket trading + market data (#1398, venue 1/3) (#2145) --- gitbooks/SUMMARY.md | 1 + .../developing/integrations/polymarket.md | 124 ++ src/openhuman/about_app/catalog.rs | 32 + src/openhuman/config/mod.rs | 17 +- src/openhuman/config/schema/mod.rs | 5 +- src/openhuman/config/schema/tools.rs | 126 +- src/openhuman/tools/impl/network/clob_auth.rs | 454 ++++++ src/openhuman/tools/impl/network/mod.rs | 4 + .../tools/impl/network/polymarket.rs | 1376 +++++++++++++++++ .../tools/impl/network/polymarket_orders.rs | 158 ++ .../tools/impl/network/polymarket_tests.rs | 1098 +++++++++++++ src/openhuman/tools/ops.rs | 10 + src/openhuman/tools/schemas.rs | 108 +- src/openhuman/wallet/mod.rs | 1 + tests/fixtures/polymarket/error_client.json | 3 + tests/fixtures/polymarket/error_server.json | 3 + tests/fixtures/polymarket/events_list.json | 12 + tests/fixtures/polymarket/market_by_id.json | 7 + tests/fixtures/polymarket/market_by_slug.json | 9 + tests/fixtures/polymarket/markets_list.json | 16 + tests/fixtures/polymarket/orderbook.json | 15 + tests/fixtures/polymarket/price.json | 5 + 22 files changed, 3565 insertions(+), 19 deletions(-) create mode 100644 gitbooks/developing/integrations/polymarket.md create mode 100644 src/openhuman/tools/impl/network/clob_auth.rs create mode 100644 src/openhuman/tools/impl/network/polymarket.rs create mode 100644 src/openhuman/tools/impl/network/polymarket_orders.rs create mode 100644 src/openhuman/tools/impl/network/polymarket_tests.rs create mode 100644 tests/fixtures/polymarket/error_client.json create mode 100644 tests/fixtures/polymarket/error_server.json create mode 100644 tests/fixtures/polymarket/events_list.json create mode 100644 tests/fixtures/polymarket/market_by_id.json create mode 100644 tests/fixtures/polymarket/market_by_slug.json create mode 100644 tests/fixtures/polymarket/markets_list.json create mode 100644 tests/fixtures/polymarket/orderbook.json create mode 100644 tests/fixtures/polymarket/price.json diff --git a/gitbooks/SUMMARY.md b/gitbooks/SUMMARY.md index f5a8a663b..9af57159c 100644 --- a/gitbooks/SUMMARY.md +++ b/gitbooks/SUMMARY.md @@ -43,6 +43,7 @@ * [Testing Strategy](developing/testing-strategy.md) * [E2E Testing](developing/e2e-testing.md) * [Release Policy](developing/release-policy.md) +* [Polymarket Integration (v1 Read + Trading)](developing/integrations/polymarket.md) * [Chromium Embedded Framework](developing/cef.md) * [Agent Observability](developing/agent-observability.md) * [Architecture](developing/architecture/README.md) diff --git a/gitbooks/developing/integrations/polymarket.md b/gitbooks/developing/integrations/polymarket.md new file mode 100644 index 000000000..354f0cd3d --- /dev/null +++ b/gitbooks/developing/integrations/polymarket.md @@ -0,0 +1,124 @@ +# Polymarket Integration (Read + Trading) + +This document describes the Polymarket integration for issue #1398. + +## Scope + +The `polymarket` tool now supports both market browsing and trading workflows over: + +- Gamma API (`https://gamma-api.polymarket.com`) +- CLOB API (`https://clob.polymarket.com`) + +Supported read actions: + +- `list_markets` +- `get_market` +- `list_events` +- `get_orderbook` +- `get_price` +- `get_positions` +- `get_balance` +- `get_open_orders` +- `get_usdc_allowance` + +Supported write actions: + +- `place_order` +- `cancel_order` + +## Architecture + +Implementation lives in `src/openhuman/tools/impl/network/polymarket.rs` with helper modules: + +- `clob_auth.rs`: L1 credential derivation + L2 HMAC headers +- `polymarket_orders.rs`: EIP-712 order typed-data signing + +Key runtime behavior: + +- Layer-2 API credentials are derived on first authenticated call and cached. +- Derived credentials are persisted to `integrations.polymarket.derived_clob_credentials` (plain config fallback until secret-store migration lands). +- Order placement fetches `GET /nonce?user=` before signing to avoid replay/nonce mismatch. +- USDC.e allowance is read via Polygon `eth_call` against ERC-20 `allowance(owner, spender)`. + +## Authentication and Signing Flow + +### L1 handshake (one-time bootstrap) + +- Sign CLOB `ClobAuth` EIP-712 payload with Polygon chain id `137`. +- Call `POST /auth/api-key`; if needed, fall back to `GET /auth/derive-api-key`. +- Persist returned `{ apiKey, secret, passphrase }` for L2 usage. + +### L2 authenticated requests + +Each authenticated CLOB request signs: + +- `timestamp + method + request_path (+ body for POST)` + +Headers: + +- `POLY_ADDRESS` +- `POLY_SIGNATURE` +- `POLY_TIMESTAMP` +- `POLY_NONCE: 0` +- `POLY_API_KEY` +- `POLY_PASSPHRASE` + +### Order signing + +`place_order` signs an EIP-712 order using domain: + +- name: `Polymarket CTF Exchange` +- version: `1` +- chain id: `137` +- verifying contract: `integrations.polymarket.clob_exchange_contract` + +## Permissions + +Write actions are currently guarded by an explicit stopgap approval flag. + +- `place_order` and `cancel_order` require `approved=true`. +- If omitted or `false`, the tool returns: + - `Polymarket write requires explicit user approval. Re-invoke with arguments.approved = true after confirming with the user.` + +This is temporary until the shared approval gate from #1339 is integrated. + +## Configuration + +Config path: `integrations.polymarket`. + +Fields: + +- `enabled` (default `false`) +- `gamma_base_url` (default `https://gamma-api.polymarket.com`) +- `clob_base_url` (default `https://clob.polymarket.com`) +- `timeout_secs` (default `15`) +- `eoa_address` (optional default user address) +- `polygon_rpc_url` (default `https://polygon-rpc.com`) +- `usdc_contract` (default `0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174`) +- `clob_exchange_contract` (default `0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E`) +- `derived_clob_credentials` (optional cached L2 credentials) + +## USDC Allowance Contract + +`get_usdc_allowance` reports approval state only; it does not mutate chain state. + +- Token: USDC.e on Polygon (`0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174`) +- Spender: Polymarket exchange (`0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E`) + +If allowance is insufficient, approval must be executed separately (wallet tool / explicit user-approved flow). + +## Error and Retry Behavior + +- 4xx errors are treated as client errors and are not retried. +- 429 and 5xx errors are treated as transient and retried up to 3 attempts. +- Backoff is fixed at 500ms between retries. +- Timeouts surface as explicit deadline errors. + +## Test Strategy + +Unit tests are in `src/openhuman/tools/impl/network/polymarket_tests.rs` plus helper-module tests. + +- Existing read-path and retry behavior tests remain covered. +- Added coverage for authenticated read actions, write approval gating, and Polygon allowance reads. +- `clob_auth.rs` tests cover HMAC/header fixture behavior. +- `polymarket_orders.rs` tests cover domain and deterministic signing fixture behavior. diff --git a/src/openhuman/about_app/catalog.rs b/src/openhuman/about_app/catalog.rs index 2a47f64d6..b05789d26 100644 --- a/src/openhuman/about_app/catalog.rs +++ b/src/openhuman/about_app/catalog.rs @@ -56,6 +56,18 @@ const COMPOSIO_DIRECT_CREDENTIALS: Option = Some(CapabilityPr destinations: &["Composio (backend.composio.dev)"], }); +const POLYMARKET_MARKET_DATA: Option = Some(CapabilityPrivacy { + leaves_device: true, + data_kind: PrivacyDataKind::Metadata, + destinations: &["Polymarket Gamma API", "Polymarket CLOB API"], +}); + +const POLYMARKET_TRADING_DATA: Option = Some(CapabilityPrivacy { + leaves_device: true, + data_kind: PrivacyDataKind::Derived, + destinations: &["Polymarket CLOB API"], +}); + const CAPABILITIES: &[Capability] = &[ Capability { id: "conversation.create", @@ -503,6 +515,26 @@ const CAPABILITIES: &[Capability] = &[ status: CapabilityStatus::ComingSoon, privacy: None, }, + Capability { + id: "skills.polymarket_readonly", + name: "Polymarket Read-Only Browse", + domain: "skills", + category: CapabilityCategory::Skills, + description: "Browse Polymarket markets, events, orderbooks, and prices via Gamma + CLOB APIs.", + how_to: "Conversations > ask the assistant to browse Polymarket (tool: polymarket).", + status: CapabilityStatus::Beta, + privacy: POLYMARKET_MARKET_DATA, + }, + Capability { + id: "skills.polymarket_trading", + name: "Polymarket Trading", + domain: "skills", + category: CapabilityCategory::Skills, + description: "Place and cancel Polymarket limit orders with EIP-712 signing, authenticated account reads, and explicit approval for writes.", + how_to: "Conversations > ask the assistant to trade on Polymarket (tool: polymarket; set `approved=true` for write actions).", + status: CapabilityStatus::Beta, + privacy: POLYMARKET_TRADING_DATA, + }, Capability { id: "local_ai.download_model", name: "Download Local Models", diff --git a/src/openhuman/config/mod.rs b/src/openhuman/config/mod.rs index 22107c746..312a35bbc 100644 --- a/src/openhuman/config/mod.rs +++ b/src/openhuman/config/mod.rs @@ -31,14 +31,15 @@ pub use schema::{ IMessageConfig, IntegrationToggle, IntegrationsConfig, LarkConfig, LearningConfig, LlmBackend, LocalAiConfig, MatrixConfig, McpAuthConfig, McpClientConfig, McpClientIdentityConfig, McpServerConfig, MeetConfig, MemoryConfig, MemoryTreeConfig, ModelRouteConfig, - MultimodalConfig, ObservabilityConfig, OrchestratorModelConfig, ProxyConfig, ProxyScope, - ReflectionSource, ReliabilityConfig, ResourceLimitsConfig, RuntimeConfig, SandboxBackend, - SandboxConfig, SchedulerConfig, SchedulerGateConfig, SchedulerGateMode, - ScreenIntelligenceConfig, SecretsConfig, SecurityConfig, SlackConfig, StorageConfig, - StorageProviderConfig, StorageProviderSection, StreamMode, TeamModelConfig, TelegramConfig, - UpdateConfig, UpdateRestartStrategy, VoiceActivationMode, VoiceServerConfig, WebSearchConfig, - WebhookConfig, DEFAULT_CLOUD_LLM_MODEL, DEFAULT_MODEL, MODEL_AGENTIC_V1, MODEL_CHAT_V1, - MODEL_CODING_V1, MODEL_REASONING_QUICK_V1, MODEL_REASONING_V1, + MultimodalConfig, ObservabilityConfig, OrchestratorModelConfig, PolymarketClobCredentials, + PolymarketConfig, ProxyConfig, ProxyScope, ReflectionSource, ReliabilityConfig, + ResourceLimitsConfig, RuntimeConfig, SandboxBackend, SandboxConfig, SchedulerConfig, + SchedulerGateConfig, SchedulerGateMode, ScreenIntelligenceConfig, SecretsConfig, + SecurityConfig, SlackConfig, StorageConfig, StorageProviderConfig, StorageProviderSection, + StreamMode, TeamModelConfig, TelegramConfig, UpdateConfig, UpdateRestartStrategy, + VoiceActivationMode, VoiceServerConfig, WebSearchConfig, WebhookConfig, + DEFAULT_CLOUD_LLM_MODEL, DEFAULT_MODEL, MODEL_AGENTIC_V1, MODEL_CHAT_V1, MODEL_CODING_V1, + MODEL_REASONING_QUICK_V1, MODEL_REASONING_V1, }; pub use schema::{ clear_active_user, default_root_openhuman_dir, pre_login_user_dir, read_active_user_id, diff --git a/src/openhuman/config/schema/mod.rs b/src/openhuman/config/schema/mod.rs index 1f8ae361f..2b921a097 100644 --- a/src/openhuman/config/schema/mod.rs +++ b/src/openhuman/config/schema/mod.rs @@ -74,8 +74,9 @@ pub use storage_memory::{ pub use tools::{ BrowserComputerUseConfig, BrowserConfig, ComposioConfig, ComputerControlConfig, CurlConfig, GitbooksConfig, HttpRequestConfig, IntegrationToggle, IntegrationsConfig, McpAuthConfig, - McpClientConfig, McpClientIdentityConfig, McpServerConfig, MultimodalConfig, SecretsConfig, - SeltzConfig, WebSearchConfig, COMPOSIO_MODE_BACKEND, COMPOSIO_MODE_DIRECT, + McpClientConfig, McpClientIdentityConfig, McpServerConfig, MultimodalConfig, + PolymarketClobCredentials, PolymarketConfig, SecretsConfig, SeltzConfig, WebSearchConfig, + COMPOSIO_MODE_BACKEND, COMPOSIO_MODE_DIRECT, }; pub use update::{UpdateConfig, UpdateRestartStrategy}; mod voice_server; diff --git a/src/openhuman/config/schema/tools.rs b/src/openhuman/config/schema/tools.rs index 333d0997c..c9e0d105e 100644 --- a/src/openhuman/config/schema/tools.rs +++ b/src/openhuman/config/schema/tools.rs @@ -554,6 +554,121 @@ impl Default for IntegrationToggle { } } +fn default_polymarket_gamma_base_url() -> String { + "https://gamma-api.polymarket.com".into() +} + +fn default_polymarket_clob_base_url() -> String { + "https://clob.polymarket.com".into() +} + +fn default_polymarket_timeout_secs() -> u64 { + 15 +} + +fn default_polymarket_enabled() -> bool { + false +} + +fn default_polymarket_polygon_rpc_url() -> String { + "https://polygon-rpc.com".into() +} + +fn default_polymarket_usdc_contract() -> String { + "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174".into() +} + +fn default_polymarket_clob_exchange_contract() -> String { + "0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E".into() +} + +/// Polymarket CLOB L2 credentials (api_key + HMAC secret + passphrase). +/// +/// Single source of truth for both the config TOML surface AND the +/// in-process HTTP signing path — `polymarket.rs` / `clob_auth.rs` use +/// this type directly so there is no parallel internal struct + From-impl +/// glue to keep in sync. +#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +pub struct PolymarketClobCredentials { + pub api_key: String, + pub secret: String, + pub passphrase: String, +} + +impl PolymarketClobCredentials { + /// Returns true iff all three credential fields are non-empty after + /// trimming whitespace. + pub fn is_complete(&self) -> bool { + !(self.api_key.trim().is_empty() + || self.secret.trim().is_empty() + || self.passphrase.trim().is_empty()) + } +} + +impl std::fmt::Debug for PolymarketClobCredentials { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PolymarketClobCredentials") + .field("api_key", &"") + .field("secret", &"") + .field("passphrase", &"") + .finish() + } +} + +/// Polymarket API configuration (read + write actions via CLOB). +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(default)] +pub struct PolymarketConfig { + #[serde(default = "default_polymarket_enabled")] + pub enabled: bool, + #[serde(default = "default_polymarket_gamma_base_url")] + pub gamma_base_url: String, + #[serde(default = "default_polymarket_clob_base_url")] + pub clob_base_url: String, + #[serde(default = "default_polymarket_timeout_secs")] + pub timeout_secs: u64, + #[serde(default)] + pub eoa_address: Option, + #[serde(default = "default_polymarket_polygon_rpc_url")] + pub polygon_rpc_url: String, + #[serde(default = "default_polymarket_usdc_contract")] + pub usdc_contract: String, + #[serde(default = "default_polymarket_clob_exchange_contract")] + pub clob_exchange_contract: String, + /// Persisted L2 CLOB credentials (api_key, secret, passphrase) derived + /// from the user's EOA via the L1 EIP-712 handshake against + /// `/auth/api-key`. + /// + /// **Threat model — temporary plaintext.** Stored in the TOML config + /// file in plaintext until #1900 lands the `SecretStore` encryption + /// surface. Anything that reads the config (other tools, agents, + /// disk-snapshot exfil) can exfiltrate the HMAC secret. Acceptable + /// trade-off for a Beta feature that is off by default + /// (`integrations.polymarket.enabled = false`) and explicitly + /// opt-in. Migrate to SecretStore the moment #1900 merges — the in- + /// memory cache (`PolymarketTool::cached_clob_credentials`) remains + /// authoritative within a single process so the wire-level behaviour + /// is unchanged on the migration. + #[serde(default)] + pub derived_clob_credentials: Option, +} + +impl Default for PolymarketConfig { + fn default() -> Self { + Self { + enabled: default_polymarket_enabled(), + gamma_base_url: default_polymarket_gamma_base_url(), + clob_base_url: default_polymarket_clob_base_url(), + timeout_secs: default_polymarket_timeout_secs(), + eoa_address: None, + polygon_rpc_url: default_polymarket_polygon_rpc_url(), + usdc_contract: default_polymarket_usdc_contract(), + clob_exchange_contract: default_polymarket_clob_exchange_contract(), + derived_clob_credentials: None, + } + } +} + /// Agent integration tools that proxy through the backend API. /// /// The backend URL and auth token are **not** configurable here — @@ -562,11 +677,8 @@ impl Default for IntegrationToggle { /// Composio in particular is unconditionally enabled and has no toggle: /// as long as the user is signed in, composio tools are available. /// -/// The per-tool `apify`, `twilio`, `google_places`, and `parallel` -/// flags below are preserved because those integrations incur per-call -/// costs that the user may legitimately want to turn off; composio -/// costs are metered server-side, so there is no client-side toggle -/// for it. +/// The per-tool toggles below are preserved because integrations may +/// incur per-call costs or may still be in phased rollout. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)] #[serde(default)] pub struct IntegrationsConfig { @@ -589,4 +701,8 @@ pub struct IntegrationsConfig { /// Stock-price / market-data integration (Alpha Vantage on the backend). #[serde(default)] pub stock_prices: IntegrationToggle, + + /// Polymarket browse + trading APIs (Gamma + CLOB). + #[serde(default)] + pub polymarket: PolymarketConfig, } diff --git a/src/openhuman/tools/impl/network/clob_auth.rs b/src/openhuman/tools/impl/network/clob_auth.rs new file mode 100644 index 000000000..c72db50f3 --- /dev/null +++ b/src/openhuman/tools/impl/network/clob_auth.rs @@ -0,0 +1,454 @@ +use crate::openhuman::config::PolymarketClobCredentials; +use anyhow::{Context, Result}; +use base64::{engine::general_purpose, Engine as _}; +use ethers_core::types::transaction::eip712::TypedData; +use ethers_signers::{LocalWallet, Signer}; +use hmac::{Hmac, Mac}; +use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; +use reqwest::Client; +use serde::Deserialize; +use serde_json::json; +use sha2::Sha256; +use std::time::{SystemTime, UNIX_EPOCH}; + +const POLY_ADDRESS: HeaderName = HeaderName::from_static("poly_address"); +const POLY_SIGNATURE: HeaderName = HeaderName::from_static("poly_signature"); +const POLY_TIMESTAMP: HeaderName = HeaderName::from_static("poly_timestamp"); +const POLY_NONCE: HeaderName = HeaderName::from_static("poly_nonce"); +const POLY_API_KEY: HeaderName = HeaderName::from_static("poly_api_key"); +const POLY_PASSPHRASE: HeaderName = HeaderName::from_static("poly_passphrase"); + +const CLOB_AUTH_MESSAGE: &str = "This message attests that I control the given wallet"; + +type HmacSha256 = Hmac; + +pub(crate) async fn derive_credentials( + http: &Client, + signer: &LocalWallet, + base_url: &str, + chain_id: u64, + address: &str, +) -> Result { + tracing::debug!( + base_url = %base_url, + chain_id, + address = %mask_address(address), + "[polymarket][clob_auth] derive_credentials: start" + ); + + let timestamp = now_unix_secs()?; + let create_path = "/auth/api-key"; + let create_headers = sign_l1_headers(signer, chain_id, address, 0, timestamp).await?; + + tracing::trace!( + path = create_path, + "[polymarket][clob_auth] derive_credentials: request create api key" + ); + let create = http + .post(format!("{base_url}{create_path}")) + .headers(create_headers) + .send() + .await + .with_context(|| format!("Failed to call Polymarket auth endpoint: POST {create_path}"))?; + tracing::debug!( + path = create_path, + status = create.status().as_u16(), + "[polymarket][clob_auth] derive_credentials: create response" + ); + + if create.status().is_success() { + let payload = create + .json::() + .await + .map_err(|err| { + tracing::debug!( + path = create_path, + error = %err, + "[polymarket][clob_auth] derive_credentials: create parse failed" + ); + err + }) + .context("Failed to parse Polymarket /auth/api-key response")?; + let creds = payload.into_credentials(); + tracing::debug!( + path = create_path, + complete = creds.is_complete(), + api_key_chars = creds.api_key.chars().count(), + "[polymarket][clob_auth] derive_credentials: create credentials parsed" + ); + if creds.is_complete() { + return Ok(creds); + } + } else { + tracing::debug!( + path = create_path, + status = create.status().as_u16(), + "[polymarket][clob_auth] derive_credentials: fallback to derive endpoint" + ); + } + + // Re-derive the timestamp here so a slow first `POST /auth/api-key` + // doesn't push the fallback request past the server's anti-replay + // window. The L1 signature is bound to this fresh timestamp via + // sign_l1_headers below. + let timestamp = now_unix_secs()?; + let derive_path = "/auth/derive-api-key"; + let derive_headers = sign_l1_headers(signer, chain_id, address, 0, timestamp).await?; + tracing::trace!( + path = derive_path, + "[polymarket][clob_auth] derive_credentials: request derive api key" + ); + let derive = http + .get(format!("{base_url}{derive_path}")) + .headers(derive_headers) + .send() + .await + .with_context(|| format!("Failed to call Polymarket auth endpoint: GET {derive_path}"))?; + + let derive_status = derive.status(); + tracing::debug!( + path = derive_path, + status = derive_status.as_u16(), + "[polymarket][clob_auth] derive_credentials: derive response" + ); + if derive_status.is_success() { + let payload = derive + .json::() + .await + .map_err(|err| { + tracing::debug!( + path = derive_path, + error = %err, + "[polymarket][clob_auth] derive_credentials: derive parse failed" + ); + err + }) + .context("Failed to parse Polymarket /auth/derive-api-key response")?; + let creds = payload.into_credentials(); + tracing::debug!( + path = derive_path, + complete = creds.is_complete(), + api_key_chars = creds.api_key.chars().count(), + "[polymarket][clob_auth] derive_credentials: derive credentials parsed" + ); + if creds.is_complete() { + return Ok(creds); + } + + tracing::debug!( + path = derive_path, + "[polymarket][clob_auth] derive_credentials: derive returned incomplete credentials" + ); + anyhow::bail!("Polymarket /auth/derive-api-key returned incomplete credentials") + } + + let body = derive + .text() + .await + .unwrap_or_else(|_| String::from("")); + tracing::debug!( + path = derive_path, + status = derive_status.as_u16(), + body = %body.trim(), + "[polymarket][clob_auth] derive_credentials: derive failed" + ); + anyhow::bail!( + "Failed to derive Polymarket API credentials (status {}): {}", + derive_status.as_u16(), + body.trim() + ) +} + +fn mask_address(address: &str) -> String { + let trimmed = address.trim(); + let chars: Vec = trimmed.chars().collect(); + if chars.len() <= 10 { + return "".to_string(); + } + let head: String = chars[..6].iter().collect(); + let tail: String = chars[chars.len() - 4..].iter().collect(); + format!("{head}...{tail}") +} + +pub(crate) async fn sign_l1_headers( + signer: &LocalWallet, + chain_id: u64, + address: &str, + nonce: u64, + timestamp: u64, +) -> Result { + let signer_address = format!("{:#x}", signer.address()); + anyhow::ensure!( + signer_address.eq_ignore_ascii_case(address.trim()), + "Polymarket signer/address mismatch — refusing to sign L1 auth headers for a different EOA" + ); + + let typed_data: TypedData = serde_json::from_value(json!({ + "types": { + "EIP712Domain": [ + { "name": "name", "type": "string" }, + { "name": "version", "type": "string" }, + { "name": "chainId", "type": "uint256" } + ], + "ClobAuth": [ + { "name": "address", "type": "address" }, + { "name": "timestamp", "type": "string" }, + { "name": "nonce", "type": "uint256" }, + { "name": "message", "type": "string" } + ] + }, + "primaryType": "ClobAuth", + "domain": { + "name": "ClobAuthDomain", + "version": "1", + "chainId": chain_id + }, + "message": { + "address": address, + "timestamp": timestamp.to_string(), + "nonce": nonce, + "message": CLOB_AUTH_MESSAGE + } + })) + .context("Failed to encode Polymarket L1 auth typed data")?; + + let signature = signer + .sign_typed_data(&typed_data) + .await + .context("Failed to sign Polymarket L1 auth typed data")?; + let signature = signature.to_string(); + let signature = if signature.starts_with("0x") { + signature + } else { + format!("0x{signature}") + }; + + let mut headers = HeaderMap::new(); + headers.insert(POLY_ADDRESS, HeaderValue::from_str(address)?); + headers.insert(POLY_SIGNATURE, HeaderValue::from_str(&signature)?); + headers.insert( + POLY_TIMESTAMP, + HeaderValue::from_str(×tamp.to_string())?, + ); + headers.insert(POLY_NONCE, HeaderValue::from_str(&nonce.to_string())?); + Ok(headers) +} + +pub(crate) fn sign_clob_headers( + creds: &PolymarketClobCredentials, + address: &str, + method: &str, + request_path: &str, + body: Option<&str>, +) -> Result { + sign_clob_headers_with_timestamp(creds, address, method, request_path, body, now_unix_secs()?) +} + +pub(crate) fn sign_clob_headers_with_timestamp( + creds: &PolymarketClobCredentials, + address: &str, + method: &str, + request_path: &str, + body: Option<&str>, + timestamp: u64, +) -> Result { + if !creds.is_complete() { + anyhow::bail!("Polymarket API credentials are incomplete") + } + + let method = method.trim().to_ascii_uppercase(); + let mut message = format!("{timestamp}{method}{request_path}"); + if let Some(body) = body { + message.push_str(body); + } + + let secret = decode_base64_secret(&creds.secret) + .context("Failed to decode Polymarket API secret as base64")?; + let mut mac = HmacSha256::new_from_slice(&secret).context("Invalid HMAC key")?; + mac.update(message.as_bytes()); + let signature_bytes = mac.finalize().into_bytes(); + + // Match official client behavior: base64 encoded with URL-safe character replacements. + let signature = general_purpose::STANDARD + .encode(signature_bytes) + .replace('+', "-") + .replace('/', "_"); + + let mut headers = HeaderMap::new(); + headers.insert(POLY_ADDRESS, HeaderValue::from_str(address)?); + headers.insert(POLY_SIGNATURE, HeaderValue::from_str(&signature)?); + headers.insert( + POLY_TIMESTAMP, + HeaderValue::from_str(×tamp.to_string())?, + ); + headers.insert(POLY_NONCE, HeaderValue::from_static("0")); + headers.insert(POLY_API_KEY, HeaderValue::from_str(&creds.api_key)?); + headers.insert(POLY_PASSPHRASE, HeaderValue::from_str(&creds.passphrase)?); + Ok(headers) +} + +pub(crate) fn now_unix_secs() -> Result { + Ok(SystemTime::now() + .duration_since(UNIX_EPOCH) + .context("System clock is before UNIX_EPOCH")? + .as_secs()) +} + +fn decode_base64_secret(secret: &str) -> Result> { + let normalized = secret.trim(); + let padded = { + let mut value = normalized.replace('-', "+").replace('_', "/"); + let pad = (4 - (value.len() % 4)) % 4; + for _ in 0..pad { + value.push('='); + } + value + }; + Ok(general_purpose::STANDARD.decode(padded)?) +} + +#[derive(Deserialize)] +struct ClobAuthResponse { + #[serde(default, alias = "apiKey", alias = "key")] + api_key: String, + #[serde(default)] + secret: String, + #[serde(default)] + passphrase: String, +} + +impl std::fmt::Debug for ClobAuthResponse { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ClobAuthResponse") + .field("api_key", &"") + .field("secret", &"") + .field("passphrase", &"") + .finish() + } +} + +impl ClobAuthResponse { + fn into_credentials(self) -> PolymarketClobCredentials { + PolymarketClobCredentials { + api_key: self.api_key, + secret: self.secret, + passphrase: self.passphrase, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture_creds() -> PolymarketClobCredentials { + PolymarketClobCredentials { + api_key: "test-key".to_string(), + secret: "dGVzdC1zZWNyZXQ=".to_string(), + passphrase: "test-passphrase".to_string(), + } + } + + #[test] + fn sign_clob_headers_hmac_fixture() { + let headers = sign_clob_headers_with_timestamp( + &fixture_creds(), + "0x1111111111111111111111111111111111111111", + "POST", + "/order", + Some(r#"{"orderID":"abc"}"#), + 1_700_000_000, + ) + .expect("headers"); + + let sig = headers + .get(POLY_SIGNATURE) + .and_then(|v| v.to_str().ok()) + .unwrap_or_default(); + assert_eq!(sig, "hQHfFFnmgy5O44EVMrn0oswHgpFGymrX53ISsb7vDsE="); + + assert_eq!(headers.get(POLY_API_KEY).unwrap(), "test-key"); + assert_eq!(headers.get(POLY_PASSPHRASE).unwrap(), "test-passphrase"); + assert_eq!(headers.get(POLY_NONCE).unwrap(), "0"); + } + + #[test] + fn sign_clob_headers_requires_complete_creds() { + let err = sign_clob_headers_with_timestamp( + &PolymarketClobCredentials::default(), + "0x1111111111111111111111111111111111111111", + "GET", + "/data/orders", + None, + 1_700_000_000, + ) + .expect_err("empty credentials should fail"); + assert!(err.to_string().contains("incomplete")); + } + + #[test] + fn credentials_default_is_incomplete() { + // Sanity-check that Default::default produces empty fields that the + // is_complete() guard rejects — this is what the cached_credentials + // cache filter relies on to drop empty persisted creds. + let creds = PolymarketClobCredentials::default(); + assert!(!creds.is_complete()); + } + + #[test] + fn mask_address_handles_non_ascii_without_panic() { + // Garbage non-EOA inputs with multi-byte glyphs must not panic the + // diagnostic helper. Byte-slicing the original ASCII path panicked + // mid-codepoint on any non-ASCII input >10 bytes. + let masked = mask_address("café-mañana-bürger-naïve-12345"); + assert!(masked.starts_with("café-m"), "got: {masked}"); + assert!(masked.ends_with("2345"), "got: {masked}"); + } + + #[test] + fn mask_address_redacts_short_input() { + assert_eq!(mask_address("0xabc"), ""); + assert_eq!(mask_address(""), ""); + } + + #[tokio::test] + async fn sign_l1_headers_rejects_signer_address_mismatch() { + use ethers_signers::{coins_bip39::English, MnemonicBuilder}; + + let phrase = "test test test test test test test test test test test junk"; + let wallet: LocalWallet = MnemonicBuilder::::default() + .phrase(phrase) + .build() + .expect("wallet"); + + let err = sign_l1_headers( + &wallet, + 137, + "0x0000000000000000000000000000000000000000", + 0, + 1_700_000_000, + ) + .await + .expect_err("mismatched address must reject"); + assert!( + err.to_string().contains("signer/address mismatch"), + "got: {err}" + ); + } + + #[tokio::test] + async fn sign_l1_headers_accepts_signer_address_match() { + use ethers_signers::{coins_bip39::English, MnemonicBuilder}; + + let phrase = "test test test test test test test test test test test junk"; + let wallet: LocalWallet = MnemonicBuilder::::default() + .phrase(phrase) + .build() + .expect("wallet"); + let address = format!("{:#x}", wallet.address()); + + sign_l1_headers(&wallet, 137, &address, 0, 1_700_000_000) + .await + .expect("matching address should sign"); + } +} diff --git a/src/openhuman/tools/impl/network/mod.rs b/src/openhuman/tools/impl/network/mod.rs index 3d9386ef8..bf08938ac 100644 --- a/src/openhuman/tools/impl/network/mod.rs +++ b/src/openhuman/tools/impl/network/mod.rs @@ -1,9 +1,12 @@ +mod clob_auth; mod composio; mod curl; mod gitbooks; mod gmail_unsubscribe; mod http_request; mod mcp; +mod polymarket; +mod polymarket_orders; mod url_guard; mod web_fetch; mod web_search; @@ -14,5 +17,6 @@ pub use gitbooks::{GitbooksGetPageTool, GitbooksSearchTool}; pub use gmail_unsubscribe::GmailUnsubscribeTool; pub use http_request::HttpRequestTool; pub use mcp::{McpCallTool, McpListServersTool, McpListToolsTool}; +pub use polymarket::PolymarketTool; pub use web_fetch::WebFetchTool; pub use web_search::WebSearchTool; diff --git a/src/openhuman/tools/impl/network/polymarket.rs b/src/openhuman/tools/impl/network/polymarket.rs new file mode 100644 index 000000000..e836f53d3 --- /dev/null +++ b/src/openhuman/tools/impl/network/polymarket.rs @@ -0,0 +1,1376 @@ +use super::clob_auth::{derive_credentials, sign_clob_headers}; +use super::polymarket_orders::{sign_order, Order}; +use crate::openhuman::config::rpc as config_rpc; +use crate::openhuman::config::{PolymarketClobCredentials, PolymarketConfig}; +use crate::openhuman::security::policy::ToolOperation; +use crate::openhuman::security::SecurityPolicy; +use crate::openhuman::tools::traits::{Tool, ToolCategory, ToolResult}; +use crate::openhuman::wallet::{secret_material, status as wallet_status, WalletChain}; +use anyhow::{Context, Result}; +use async_trait::async_trait; +use ethers_core::types::Address; +use ethers_signers::{coins_bip39::English, LocalWallet, MnemonicBuilder, Signer}; +use reqwest::header::{HeaderMap, HeaderValue, CONTENT_TYPE}; +use reqwest::{Client, StatusCode}; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::str::FromStr; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::Mutex; +use tokio::time::sleep; + +const MAX_RETRY_ATTEMPTS: usize = 3; +const RETRY_BACKOFF_MS: u64 = 500; +const CONNECT_TIMEOUT_SECS: u64 = 10; +const MAX_ERROR_BODY_CHARS: usize = 240; +const POLY_CHAIN_ID: u64 = 137; +const POLY_COLLATERAL_DECIMALS: u32 = 6; +const ZERO_ADDRESS: &str = "0x0000000000000000000000000000000000000000"; + +fn ensure_https(url: &str) -> Result<()> { + if url.starts_with("https://") { + return Ok(()); + } + if url.starts_with("http://127.0.0.1") + || url.starts_with("http://[::1]") + || url.starts_with("http://localhost") + { + return Ok(()); + } + anyhow::bail!( + "Refusing to transmit Polymarket CLOB credentials over non-HTTPS URL: \ + URL scheme must be https (loopback http allowed for local mock)" + ) +} + +/// Polymarket market + trading tool (Gamma + CLOB APIs). +pub struct PolymarketTool { + gamma_base_url: String, + clob_base_url: String, + polygon_rpc_url: String, + usdc_contract: String, + clob_exchange_contract: String, + default_eoa_address: Option, + http: Client, + security: Arc, + timeout: Duration, + cached_clob_credentials: Mutex>, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "action", rename_all = "snake_case")] +enum PolymarketRequest { + ListMarkets { + #[serde(default)] + slug: Option, + #[serde(default)] + event_id: Option, + #[serde(default)] + limit: Option, + #[serde(default)] + offset: Option, + #[serde(default)] + cursor: Option, + #[serde(default)] + active: Option, + #[serde(default)] + closed: Option, + #[serde(default)] + tag: Option, + }, + GetMarket { + #[serde(default)] + market_id: Option, + #[serde(default)] + slug: Option, + }, + ListEvents { + #[serde(default)] + event_id: Option, + #[serde(default)] + limit: Option, + #[serde(default)] + offset: Option, + #[serde(default)] + cursor: Option, + #[serde(default)] + active: Option, + #[serde(default)] + closed: Option, + #[serde(default)] + tag: Option, + }, + GetOrderbook { + token_id: String, + }, + GetPrice { + token_id: String, + side: String, + }, + GetPositions { + #[serde(default)] + user: Option, + }, + GetBalance { + #[serde(default)] + user: Option, + #[serde(default)] + token: Option, + }, + GetOpenOrders { + #[serde(default)] + user: Option, + }, + GetUsdcAllowance { + #[serde(default)] + user: Option, + }, + PlaceOrder { + side: String, + token_id: String, + price: f64, + size: f64, + #[serde(default)] + time_in_force: Option, + #[serde(default)] + expiration_secs: Option, + #[serde(default)] + approved: Option, + #[serde(default)] + user: Option, + }, + CancelOrder { + order_id: String, + #[serde(default)] + approved: Option, + #[serde(default)] + user: Option, + }, +} + +impl PolymarketTool { + pub fn new(config: &PolymarketConfig, security: Arc) -> Self { + let timeout = Duration::from_secs(config.timeout_secs.max(1)); + + let builder = reqwest::Client::builder() + .timeout(timeout) + .connect_timeout(Duration::from_secs(CONNECT_TIMEOUT_SECS)) + .redirect(reqwest::redirect::Policy::none()); + let builder = + crate::openhuman::config::apply_runtime_proxy_to_builder(builder, "tool.polymarket"); + + let http = builder.build().unwrap_or_else(|err| { + panic!( + "[polymarket] failed to build HTTP client (proxy/timeout configuration): {err}. \ + Refusing to fall back to Client::new() — silent fallback hides the misconfiguration \ + and produces requests that bypass the configured proxy + timeouts." + ) + }); + + let cached_credentials = config + .derived_clob_credentials + .clone() + .map(PolymarketClobCredentials::from) + .filter(PolymarketClobCredentials::is_complete); + + Self { + gamma_base_url: normalize_base_url( + &config.gamma_base_url, + "https://gamma-api.polymarket.com", + ), + clob_base_url: normalize_base_url(&config.clob_base_url, "https://clob.polymarket.com"), + polygon_rpc_url: normalize_base_url(&config.polygon_rpc_url, "https://polygon-rpc.com"), + usdc_contract: config.usdc_contract.trim().to_string(), + clob_exchange_contract: config.clob_exchange_contract.trim().to_string(), + default_eoa_address: config + .eoa_address + .as_deref() + .map(str::trim) + .filter(|v| !v.is_empty()) + .map(str::to_string), + http, + security, + timeout, + cached_clob_credentials: Mutex::new(cached_credentials), + } + } + + async fn handle_request(&self, request: PolymarketRequest) -> Result { + match request { + PolymarketRequest::ListMarkets { + slug, + event_id, + limit, + offset, + cursor, + active, + closed, + tag, + } => { + let mut query = Vec::new(); + push_optional_string(&mut query, "slug", slug); + push_optional_string(&mut query, "event_id", event_id); + push_optional_u64(&mut query, "limit", limit); + push_optional_u64(&mut query, "offset", offset); + push_optional_string(&mut query, "cursor", cursor); + push_optional_bool(&mut query, "active", active); + push_optional_bool(&mut query, "closed", closed); + push_optional_string(&mut query, "tag", tag); + + let data = self + .get_json(&self.gamma_base_url, "/markets", &query, None) + .await?; + Ok(json!({ + "action": "list_markets", + "source": "gamma", + "data": data, + })) + } + PolymarketRequest::GetMarket { market_id, slug } => { + if let Some(market_id) = non_empty(market_id.as_deref()) { + let path = format!("/markets/{market_id}"); + let data = self + .get_json(&self.gamma_base_url, &path, &[], None) + .await?; + Ok(json!({ + "action": "get_market", + "source": "gamma", + "lookup": "market_id", + "market_id": market_id, + "data": data, + })) + } else if let Some(slug) = non_empty(slug.as_deref()) { + let data = self + .get_json( + &self.gamma_base_url, + "/markets", + &[("slug".to_string(), slug.to_string())], + None, + ) + .await?; + + let market = first_item_from_collection(data, slug)?; + Ok(json!({ + "action": "get_market", + "source": "gamma", + "lookup": "slug", + "slug": slug, + "data": market, + })) + } else { + anyhow::bail!("get_market requires either 'market_id' or 'slug'") + } + } + PolymarketRequest::ListEvents { + event_id, + limit, + offset, + cursor, + active, + closed, + tag, + } => { + if let Some(event_id) = non_empty(event_id.as_deref()) { + let path = format!("/events/{event_id}"); + let data = self + .get_json(&self.gamma_base_url, &path, &[], None) + .await?; + Ok(json!({ + "action": "list_events", + "source": "gamma", + "lookup": "event_id", + "event_id": event_id, + "data": data, + })) + } else { + let mut query = Vec::new(); + push_optional_u64(&mut query, "limit", limit); + push_optional_u64(&mut query, "offset", offset); + push_optional_string(&mut query, "cursor", cursor); + push_optional_bool(&mut query, "active", active); + push_optional_bool(&mut query, "closed", closed); + push_optional_string(&mut query, "tag", tag); + + let data = self + .get_json(&self.gamma_base_url, "/events", &query, None) + .await?; + Ok(json!({ + "action": "list_events", + "source": "gamma", + "data": data, + })) + } + } + PolymarketRequest::GetOrderbook { token_id } => { + let token_id = non_empty(Some(token_id.as_str())) + .ok_or_else(|| anyhow::anyhow!("'token_id' cannot be empty"))?; + + let data = self + .get_json( + &self.clob_base_url, + "/book", + &[("token_id".to_string(), token_id.to_string())], + None, + ) + .await?; + + Ok(json!({ + "action": "get_orderbook", + "source": "clob", + "token_id": token_id, + "data": data, + })) + } + PolymarketRequest::GetPrice { token_id, side } => { + let token_id = non_empty(Some(token_id.as_str())) + .ok_or_else(|| anyhow::anyhow!("'token_id' cannot be empty"))?; + let side = normalize_side(&side)?; + + let data = self + .get_json( + &self.clob_base_url, + "/price", + &[ + ("token_id".to_string(), token_id.to_string()), + ("side".to_string(), side.to_string()), + ], + None, + ) + .await?; + + Ok(json!({ + "action": "get_price", + "source": "clob", + "token_id": token_id, + "side": side, + "data": data, + })) + } + PolymarketRequest::GetPositions { user } => { + let user = self.resolve_user_address(user).await?; + let query = vec![("user".to_string(), user.clone())]; + let data = self + .get_signed_clob_json("/data/positions", &query, &user) + .await?; + + Ok(json!({ + "action": "get_positions", + "source": "clob", + "user": user, + "data": data, + })) + } + PolymarketRequest::GetBalance { user, token } => { + let user = self.resolve_user_address(user).await?; + let mut query = vec![("user".to_string(), user.clone())]; + query.push(( + "token".to_string(), + non_empty(token.as_deref()).unwrap_or("usdce").to_string(), + )); + + let data = self + .get_signed_clob_json("/data/balance", &query, &user) + .await?; + Ok(json!({ + "action": "get_balance", + "source": "clob", + "user": user, + "data": data, + })) + } + PolymarketRequest::GetOpenOrders { user } => { + let user = self.resolve_user_address(user).await?; + let query = vec![("user".to_string(), user.clone())]; + let data = self.get_signed_clob_json("/orders", &query, &user).await?; + + Ok(json!({ + "action": "get_open_orders", + "source": "clob", + "user": user, + "data": data, + })) + } + PolymarketRequest::GetUsdcAllowance { user } => { + let user = self.resolve_user_address(user).await?; + let allowance = self.get_usdc_allowance_for_user(&user).await?; + + Ok(json!({ + "action": "get_usdc_allowance", + "source": "polygon", + "user": user, + "token_contract": self.usdc_contract, + "spender": self.clob_exchange_contract, + "allowance": allowance, + })) + } + PolymarketRequest::PlaceOrder { + side, + token_id, + price, + size, + time_in_force, + expiration_secs, + approved, + user, + } => { + self.security + .enforce_tool_operation(ToolOperation::Act, "polymarket.place_order") + .map_err(anyhow::Error::msg)?; + require_write_approval(approved)?; + + let (wallet, user) = self.resolve_signer_and_user(user).await?; + let creds = self.ensure_clob_credentials(&wallet, &user).await?; + let nonce = self.fetch_order_nonce(&user, &creds).await?; + let signed_order = self + .build_signed_limit_order( + &wallet, + &user, + &token_id, + &side, + price, + size, + nonce, + expiration_secs, + ) + .await?; + + let tif = normalize_time_in_force(time_in_force.as_deref())?; + let body = json!({ + "order": signed_order, + "owner": user, + "orderType": "limit", + "timeInForce": tif, + }); + + let data = self + .post_signed_clob_json("/order", body, &user, &creds) + .await?; + Ok(json!({ + "action": "place_order", + "source": "clob", + "user": user, + "time_in_force": tif, + "data": data, + })) + } + PolymarketRequest::CancelOrder { + order_id, + approved, + user, + } => { + self.security + .enforce_tool_operation(ToolOperation::Act, "polymarket.cancel_order") + .map_err(anyhow::Error::msg)?; + require_write_approval(approved)?; + + let (wallet, user) = self.resolve_signer_and_user(user).await?; + let creds = self + .cached_or_derive_credentials_with_signer(&user, Some(&wallet)) + .await?; + let order_id = non_empty(Some(order_id.as_str())) + .ok_or_else(|| anyhow::anyhow!("'order_id' cannot be empty"))?; + let path = format!("/order/{order_id}"); + + let data = self.delete_signed_clob_json(&path, &user, &creds).await?; + Ok(json!({ + "action": "cancel_order", + "source": "clob", + "order_id": order_id, + "data": data, + })) + } + } + } + + async fn get_signed_clob_json( + &self, + path: &str, + query: &[(String, String)], + user: &str, + ) -> Result { + let creds = self.cached_or_derive_credentials(user).await?; + self.get_signed_clob_json_with_creds(path, query, user, &creds) + .await + } + + async fn get_signed_clob_json_with_creds( + &self, + path: &str, + query: &[(String, String)], + user: &str, + creds: &PolymarketClobCredentials, + ) -> Result { + ensure_https(&self.clob_base_url)?; + let signed_path = signed_request_path(path, query); + let headers = sign_clob_headers(creds, user, "GET", &signed_path, None)?; + self.get_json(&self.clob_base_url, path, query, Some(headers)) + .await + } + + async fn post_signed_clob_json( + &self, + path: &str, + body: Value, + user: &str, + creds: &PolymarketClobCredentials, + ) -> Result { + ensure_https(&self.clob_base_url)?; + let body_raw = + serde_json::to_string(&body).context("Failed to serialize CLOB POST body")?; + let mut headers = sign_clob_headers(creds, user, "POST", path, Some(&body_raw))?; + headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); + + self.post_json_raw(&self.clob_base_url, path, Some(headers), body_raw) + .await + } + + async fn delete_signed_clob_json( + &self, + path: &str, + user: &str, + creds: &PolymarketClobCredentials, + ) -> Result { + ensure_https(&self.clob_base_url)?; + let headers = sign_clob_headers(creds, user, "DELETE", path, None)?; + self.delete_json(&self.clob_base_url, path, Some(headers)) + .await + } + + async fn cached_or_derive_credentials(&self, user: &str) -> Result { + self.cached_or_derive_credentials_with_signer(user, None) + .await + } + + async fn cached_or_derive_credentials_with_signer( + &self, + user: &str, + signer: Option<&LocalWallet>, + ) -> Result { + if let Some(creds) = self.cached_clob_credentials.lock().await.clone() { + return Ok(creds); + } + + if let Some(wallet) = signer { + return self.ensure_clob_credentials(wallet, user).await; + } + + let (wallet, resolved_user) = self.resolve_signer_and_user(Some(user.to_string())).await?; + self.ensure_clob_credentials(&wallet, &resolved_user).await + } + + async fn ensure_clob_credentials( + &self, + wallet: &LocalWallet, + user: &str, + ) -> Result { + // Hold the cache lock across the derive call so two concurrent + // callers cannot both observe an empty cache and each fire their + // own POST /auth/api-key — the CLOB would silently retain only + // one set of credentials and reject signatures generated with + // the other. tokio::sync::Mutex is intentional here (not std) + // because the critical section spans .await points. + let mut guard = self.cached_clob_credentials.lock().await; + if let Some(creds) = guard.as_ref() { + return Ok(creds.clone()); + } + + ensure_https(&self.clob_base_url)?; + let creds = + derive_credentials(&self.http, wallet, &self.clob_base_url, POLY_CHAIN_ID, user) + .await + .context("Failed to derive Polymarket CLOB API credentials")?; + + *guard = Some(creds.clone()); + // Drop the cache lock before the (slow + best-effort) config + // persist so other tool calls can proceed against the freshly + // populated cache. + drop(guard); + + if let Err(err) = self.persist_clob_credentials(&creds).await { + tracing::warn!(reason = %err, "[polymarket] failed to persist derived CLOB credentials"); + } + + Ok(creds) + } + + /// Persists derived CLOB credentials to the config TOML. + /// + /// **Best-effort only.** The in-memory `cached_clob_credentials` Mutex + /// is the authoritative source within the lifetime of this + /// PolymarketTool instance — `persist_clob_credentials` exists so that + /// a future process restart can reuse the L2 key/secret without + /// re-running the L1 EIP-712 handshake. The caller already logs a + /// warn on failure and continues; the live request path is unaffected. + /// + /// Concurrent persists from multiple tools could clobber each other + /// because the whole config is loaded-then-saved. Acceptable for the + /// current single-process model; will move to the `SecretStore` + /// transactional surface once #1900 lands. + async fn persist_clob_credentials(&self, creds: &PolymarketClobCredentials) -> Result<()> { + let mut config = config_rpc::load_config_with_timeout() + .await + .map_err(anyhow::Error::msg) + .context("Failed to load config for persisting Polymarket credentials")?; + + config.integrations.polymarket.derived_clob_credentials = Some(creds.clone().into()); + config + .save() + .await + .context("Failed to save config with Polymarket credentials")?; + Ok(()) + } + + async fn resolve_user_address(&self, user: Option) -> Result { + if let Some(user) = user.and_then(|v| non_empty(Some(v.as_str())).map(str::to_string)) { + return validate_evm_address(&user); + } + + if let Some(user) = self + .default_eoa_address + .as_deref() + .and_then(|v| non_empty(Some(v))) + .map(str::to_string) + { + return validate_evm_address(&user); + } + + let status = wallet_status().await.map_err(anyhow::Error::msg)?; + if let Some(account) = status + .value + .accounts + .into_iter() + .find(|account| account.chain == WalletChain::Evm) + { + return validate_evm_address(&account.address); + } + + anyhow::bail!( + "No Polymarket EOA address available. Provide 'user', configure integrations.polymarket.eoa_address, or run wallet setup." + ) + } + + async fn resolve_signer_and_user(&self, user: Option) -> Result<(LocalWallet, String)> { + let user = self.resolve_user_address(user).await?; + + let secret = secret_material(WalletChain::Evm) + .await + .map_err(anyhow::Error::msg) + .context("Polymarket writes require wallet secret material")?; + + let config = config_rpc::load_config_with_timeout() + .await + .map_err(anyhow::Error::msg) + .context("Failed to load config for wallet secret decryption")?; + + let mnemonic = + crate::openhuman::encryption::rpc::decrypt_secret(&config, &secret.encrypted_mnemonic) + .await + .map_err(anyhow::Error::msg) + .context("Failed to decrypt wallet mnemonic")? + .value; + + let wallet = MnemonicBuilder::::default() + .phrase(mnemonic.as_str()) + .derivation_path(&secret.derivation_path) + .map_err(|e| { + anyhow::anyhow!( + "Invalid EVM derivation path '{}': {e}", + secret.derivation_path + ) + })? + .build() + .map_err(|e| anyhow::anyhow!("Failed to derive EVM signer from wallet secret: {e}"))? + .with_chain_id(POLY_CHAIN_ID); + + let wallet_addr = format!("{:#x}", wallet.address()); + if !same_evm_address(&wallet_addr, &user) { + anyhow::bail!( + "Configured/requested user address '{}' does not match wallet signer '{}'.", + user, + wallet_addr + ); + } + + Ok((wallet, wallet_addr)) + } + + async fn get_usdc_allowance_for_user(&self, user: &str) -> Result { + // Polygon RPC payload carries the EOA address. Even though no CLOB + // credentials are sent here, leaking the address over plaintext HTTP + // still narrows the user's identity to anyone on-path. Same loopback + // carve-out as the CLOB guard so the mock harness keeps working. + ensure_https(&self.polygon_rpc_url)?; + + let owner = Address::from_str(user) + .with_context(|| format!("Invalid owner EVM address '{user}'"))?; + let spender = Address::from_str(&self.clob_exchange_contract).with_context(|| { + format!( + "Invalid clob_exchange_contract address '{}'", + self.clob_exchange_contract + ) + })?; + let token_contract = Address::from_str(&self.usdc_contract) + .with_context(|| format!("Invalid usdc_contract address '{}'", self.usdc_contract))?; + + // Hand-rolled ABI encoding for ERC-20 `allowance(address,address)`: + // * selector `0xdd62ed3e` = first 4 bytes of keccak256("allowance(address,address)") + // * each Address is 20 bytes = 40 hex chars; `:0>64` left-pads to + // the 32-byte ABI word (12 bytes of leading zeros) + // Hand-rolled because pulling alloy-sol-types or ethabi for a single + // view call would balloon the dependency tree; the encoding is + // small and frozen. + let data = format!( + "0xdd62ed3e{:0>64}{:0>64}", + hex::encode(owner.as_bytes()), + hex::encode(spender.as_bytes()), + ); + let payload = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "eth_call", + "params": [ + { + "to": format!("{token_contract:#x}"), + "data": data, + }, + "latest" + ] + }); + + let mut rpc_headers = HeaderMap::new(); + rpc_headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); + let response = self + .post_json_raw( + &self.polygon_rpc_url, + "", + Some(rpc_headers), + serde_json::to_string(&payload) + .context("Failed to serialize Polygon RPC payload")?, + ) + .await?; + + let hex_value = response + .get("result") + .and_then(Value::as_str) + .ok_or_else(|| anyhow::anyhow!("Polygon RPC response missing 'result'"))?; + + let allowance = parse_hex_u256(hex_value)?; + Ok(allowance.to_string()) + } + + async fn fetch_order_nonce( + &self, + user: &str, + creds: &PolymarketClobCredentials, + ) -> Result { + let query = vec![("user".to_string(), user.to_string())]; + let payload = self + .get_signed_clob_json_with_creds("/nonce", &query, user, creds) + .await + .context("Failed to fetch Polymarket order nonce")?; + + parse_order_nonce(&payload) + } + + async fn build_signed_limit_order( + &self, + wallet: &LocalWallet, + user: &str, + token_id: &str, + side: &str, + price: f64, + size: f64, + nonce: u64, + expiration_secs: Option, + ) -> Result { + if !price.is_finite() || price <= 0.0 || price >= 1.0 { + anyhow::bail!("'price' must be between 0 and 1 (exclusive)"); + } + if !size.is_finite() || size <= 0.0 { + anyhow::bail!("'size' must be greater than zero"); + } + + let side = normalize_order_side(side)?; + let token_id = non_empty(Some(token_id)) + .ok_or_else(|| anyhow::anyhow!("'token_id' cannot be empty"))?; + + let rounded_price = round_normal(price, 4); + let rounded_size = round_down(size, 2); + let (maker_raw, taker_raw) = if side == "BUY" { + let taker_raw = rounded_size; + let maker_raw = round_down(taker_raw * rounded_price, 4); + (maker_raw, taker_raw) + } else { + let maker_raw = rounded_size; + let taker_raw = round_down(maker_raw * rounded_price, 4); + (maker_raw, taker_raw) + }; + + let maker_amount = to_fixed_units_string(maker_raw, POLY_COLLATERAL_DECIMALS)?; + let taker_amount = to_fixed_units_string(taker_raw, POLY_COLLATERAL_DECIMALS)?; + if maker_amount == "0" || taker_amount == "0" { + anyhow::bail!("Order size/price rounds to zero after fixed-point conversion") + } + + let expiration = expiration_secs + .map(|secs| secs.to_string()) + .unwrap_or_else(|| "0".to_string()); + + // Cryptographically-random salt — non-CSPRNG (rand::random) is + // predictable enough to enable order-replay/front-running attacks + // against the CLOB. OsRng pulls from the OS entropy source. Same + // pattern used by src/openhuman/encryption/core.rs. + let salt = { + use chacha20poly1305::aead::rand_core::RngCore; + use chacha20poly1305::aead::OsRng; + let mut buf = [0_u8; 8]; + OsRng.fill_bytes(&mut buf); + u64::from_le_bytes(buf) + }; + + let order = Order { + salt: salt.to_string(), + maker: user.to_string(), + signer: user.to_string(), + taker: ZERO_ADDRESS.to_string(), + token_id: token_id.to_string(), + maker_amount, + taker_amount, + expiration, + nonce: nonce.to_string(), + fee_rate_bps: "0".to_string(), + side: side.to_string(), + signature_type: 0, + }; + + let contract = Address::from_str(&self.clob_exchange_contract).with_context(|| { + format!( + "Invalid clob_exchange_contract address '{}'", + self.clob_exchange_contract + ) + })?; + + let signature = sign_order(&order, wallet, POLY_CHAIN_ID, contract) + .await + .context("Failed to EIP-712 sign Polymarket order")?; + + Ok(json!({ + "salt": order.salt.parse::().unwrap_or_default(), + "maker": order.maker, + "signer": order.signer, + "taker": order.taker, + "tokenId": order.token_id, + "makerAmount": order.maker_amount, + "takerAmount": order.taker_amount, + "expiration": order.expiration, + "nonce": order.nonce, + "feeRateBps": order.fee_rate_bps, + "side": order.side, + "signatureType": order.signature_type, + "signature": signature, + })) + } + + async fn get_json( + &self, + base_url: &str, + path: &str, + query: &[(String, String)], + headers: Option, + ) -> Result { + self.send_with_retry(reqwest::Method::GET, base_url, path, query, headers, None) + .await + } + + async fn post_json_raw( + &self, + base_url: &str, + path: &str, + headers: Option, + body_raw: String, + ) -> Result { + self.send_with_retry( + reqwest::Method::POST, + base_url, + path, + &[], + headers, + Some(body_raw), + ) + .await + } + + async fn delete_json( + &self, + base_url: &str, + path: &str, + headers: Option, + ) -> Result { + self.send_with_retry(reqwest::Method::DELETE, base_url, path, &[], headers, None) + .await + } + + /// Shared HTTP send + retry-on-transient + status-class classification + /// loop. All Polymarket HTTP exits funnel through here so the retry + /// policy, timeout handling, and error-message shape stay consistent. + async fn send_with_retry( + &self, + method: reqwest::Method, + base_url: &str, + path: &str, + query: &[(String, String)], + headers: Option, + body: Option, + ) -> Result { + let url = format!("{base_url}{path}"); + let method_label = method.as_str().to_string(); + + for attempt in 1..=MAX_RETRY_ATTEMPTS { + let mut request = self.http.request(method.clone(), &url); + if !query.is_empty() { + request = request.query(query); + } + if let Some(h) = headers.as_ref() { + request = request.headers(h.clone()); + } + if let Some(b) = body.as_ref() { + request = request.body(b.clone()); + } + + let response = match request.send().await { + Ok(resp) => resp, + Err(err) => { + if err.is_timeout() { + anyhow::bail!( + "Polymarket request timed out after {}s: {method_label} {path}", + self.timeout.as_secs() + ); + } + + if attempt < MAX_RETRY_ATTEMPTS { + sleep(Duration::from_millis(RETRY_BACKOFF_MS)).await; + continue; + } + + anyhow::bail!( + "Polymarket transient transport error for {method_label} {path}: {err} (url: {url})" + ); + } + }; + + let status = response.status(); + + if status.is_success() { + let text = response + .text() + .await + .unwrap_or_else(|_| String::from("null")); + if text.trim().is_empty() { + return Ok(Value::Null); + } + return serde_json::from_str(&text) + .with_context(|| format!("Failed to deserialize Polymarket response: {path}")); + } + + let body = response + .text() + .await + .unwrap_or_else(|_| String::from("")); + let detail = summarize_error_body(&body); + + if status.is_client_error() && status != StatusCode::TOO_MANY_REQUESTS { + anyhow::bail!( + "Polymarket client error {} for {method_label} {path}: {detail}", + status.as_u16() + ); + } + + let transient = status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error(); + if transient && attempt < MAX_RETRY_ATTEMPTS { + sleep(Duration::from_millis(RETRY_BACKOFF_MS)).await; + continue; + } + + if status == StatusCode::TOO_MANY_REQUESTS { + anyhow::bail!( + "Polymarket transient rate-limit error {} for {method_label} {path} after {attempt} attempts: {detail}", + status.as_u16() + ); + } + + if status.is_server_error() { + anyhow::bail!( + "Polymarket transient server error {} for {method_label} {path} after {attempt} attempts: {detail}", + status.as_u16() + ); + } + + anyhow::bail!( + "Polymarket HTTP error {} for {method_label} {path}: {detail}", + status.as_u16() + ); + } + + anyhow::bail!("Polymarket request failed: retry budget exhausted") + } +} + +#[async_trait] +impl Tool for PolymarketTool { + fn name(&self) -> &str { + "polymarket" + } + + fn description(&self) -> &str { + "Browse and trade Polymarket via Gamma + CLOB APIs. Supports market/event discovery, account reads, and signed order placement/cancellation." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "action": { + "type": "string", + "description": "Polymarket action to run.", + "enum": [ + "list_markets", + "get_market", + "list_events", + "get_orderbook", + "get_price", + "get_positions", + "get_balance", + "get_open_orders", + "get_usdc_allowance", + "place_order", + "cancel_order" + ] + }, + "market_id": { + "type": "string", + "description": "Gamma market id for get_market." + }, + "event_id": { + "type": "string", + "description": "Optional event id filter, or exact id for list_events." + }, + "slug": { + "type": "string", + "description": "Market slug filter; can also be used to resolve get_market." + }, + "token_id": { + "type": "string", + "description": "CLOB token id for get_orderbook/get_price/place_order." + }, + "side": { + "type": "string", + "description": "Side for get_price/place_order.", + "enum": ["buy", "sell", "BUY", "SELL"] + }, + "price": { + "type": "number", + "description": "Limit price for place_order (0 < price < 1)." + }, + "size": { + "type": "number", + "description": "Order size in shares for place_order." + }, + "time_in_force": { + "type": "string", + "description": "Time in force for place_order.", + "enum": ["GTC", "GTD", "FOK", "FAK"] + }, + "expiration_secs": { + "type": "integer", + "minimum": 0, + "description": "Optional expiration timestamp (UTC seconds) for place_order." + }, + "order_id": { + "type": "string", + "description": "Order id/hash to cancel." + }, + "user": { + "type": "string", + "description": "Optional user wallet address override for authenticated actions." + }, + "token": { + "type": "string", + "description": "Collateral token key for get_balance (default: usdce)." + }, + "approved": { + "type": "boolean", + "description": "Required=true for write actions (place_order, cancel_order)." + }, + "limit": { + "type": "integer", + "minimum": 0, + "description": "Pagination limit for list_markets/list_events." + }, + "offset": { + "type": "integer", + "minimum": 0, + "description": "Pagination offset for list_markets/list_events." + }, + "cursor": { + "type": "string", + "description": "Cursor token for paginated list responses." + }, + "active": { + "type": "boolean", + "description": "Filter active markets/events." + }, + "closed": { + "type": "boolean", + "description": "Filter closed markets/events." + }, + "tag": { + "type": "string", + "description": "Optional topic/tag filter." + } + }, + "required": ["action"] + }) + } + + fn category(&self) -> ToolCategory { + ToolCategory::Skill + } + + fn is_concurrency_safe(&self, args: &Value) -> bool { + // Write actions are NOT concurrency-safe — two concurrent place_order + // calls would each call /nonce?user= independently and receive + // the same nonce, causing one of the signed orders to be silently + // rejected by the CLOB. Credential derivation is similarly + // single-flight (see ensure_clob_credentials OnceCell). Reads remain + // concurrency-safe. + match args.get("action").and_then(Value::as_str) { + Some("place_order") | Some("cancel_order") => false, + _ => true, + } + } + + async fn execute(&self, args: Value) -> Result { + if self.security.is_rate_limited() { + return Ok(ToolResult::error( + "Rate limit exceeded: too many actions in the last hour", + )); + } + + if !self.security.record_action() { + return Ok(ToolResult::error( + "Rate limit exceeded: action budget exhausted", + )); + } + + let request: PolymarketRequest = serde_json::from_value(args) + .context("Invalid polymarket request: unable to parse parameters")?; + + match self.handle_request(request).await { + Ok(payload) => Ok(ToolResult::json(payload)), + Err(err) => Ok(ToolResult::error(err.to_string())), + } + } +} + +fn normalize_base_url(raw: &str, fallback: &str) -> String { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return fallback.to_string(); + } + trimmed.trim_end_matches('/').to_string() +} + +fn non_empty(value: Option<&str>) -> Option<&str> { + value.map(str::trim).filter(|s| !s.is_empty()) +} + +fn push_optional_string(query: &mut Vec<(String, String)>, key: &str, value: Option) { + if let Some(value) = non_empty(value.as_deref()) { + query.push((key.to_string(), value.to_string())); + } +} + +fn push_optional_u64(query: &mut Vec<(String, String)>, key: &str, value: Option) { + if let Some(value) = value { + query.push((key.to_string(), value.to_string())); + } +} + +fn push_optional_bool(query: &mut Vec<(String, String)>, key: &str, value: Option) { + if let Some(value) = value { + query.push((key.to_string(), value.to_string())); + } +} + +fn normalize_side(side: &str) -> Result<&'static str> { + let side = side.trim().to_ascii_lowercase(); + match side.as_str() { + "buy" => Ok("buy"), + "sell" => Ok("sell"), + _ => anyhow::bail!("Invalid 'side'. Expected one of: buy, sell"), + } +} + +fn normalize_order_side(side: &str) -> Result<&'static str> { + let side = side.trim().to_ascii_uppercase(); + match side.as_str() { + "BUY" => Ok("BUY"), + "SELL" => Ok("SELL"), + _ => anyhow::bail!("Invalid 'side'. Expected one of: BUY, SELL"), + } +} + +fn normalize_time_in_force(value: Option<&str>) -> Result<&'static str> { + let value = value.unwrap_or("GTC").trim().to_ascii_uppercase(); + match value.as_str() { + "GTC" => Ok("GTC"), + "GTD" => Ok("GTD"), + "FOK" => Ok("FOK"), + "FAK" => Ok("FAK"), + _ => anyhow::bail!("Invalid 'time_in_force'. Expected one of: GTC, GTD, FOK, FAK"), + } +} + +fn signed_request_path(path: &str, query: &[(String, String)]) -> String { + if query.is_empty() { + return path.to_string(); + } + + let mut serializer = url::form_urlencoded::Serializer::new(String::new()); + for (key, value) in query { + serializer.append_pair(key, value); + } + let encoded = serializer.finish(); + format!("{path}?{encoded}") +} + +fn summarize_error_body(body: &str) -> String { + let compact = body.trim().replace('\n', " "); + if compact.is_empty() { + "empty response body".to_string() + } else { + crate::openhuman::util::truncate_with_ellipsis(&compact, MAX_ERROR_BODY_CHARS) + } +} + +fn first_item_from_collection(data: Value, slug: &str) -> Result { + match data { + Value::Array(items) => items + .into_iter() + .next() + .ok_or_else(|| anyhow::anyhow!("No Polymarket market found for slug '{slug}'")), + Value::Object(mut map) => { + if let Some(Value::Array(items)) = map.remove("data") { + return items.into_iter().next().ok_or_else(|| { + anyhow::anyhow!("No Polymarket market found for slug '{slug}'") + }); + } + Ok(Value::Object(map)) + } + other => Ok(other), + } +} + +/// Approval-flag stopgap for Polymarket writes. +/// +/// **Security note:** this is a client-enforced string check on the +/// arguments JSON. If the agent harness ever invokes `execute()` without +/// the normal approval channel — or if an LLM hallucinates +/// `"approved": true` into the arguments — this gate alone stands between +/// the model and a live, EIP-712-signed order on Polymarket. The +/// `SecurityPolicy::enforce_tool_operation(ToolOperation::Act, ...)` call +/// upstream is the framework-level autonomy gate that protects against +/// hallucinated approvals in autonomy-restricted modes; this function +/// covers the friendly-flow approval. **Both must remain in place until +/// #1339 replaces this with a real out-of-band approval channel.** +fn require_write_approval(approved: Option) -> Result<()> { + if approved.unwrap_or(false) { + return Ok(()); + } + + // TODO(#1339): Replace this stopgap with the shared formal approval gate. + anyhow::bail!( + "Polymarket write requires explicit user approval. Re-invoke with arguments.approved = true after confirming with the user." + ) +} + +fn validate_evm_address(value: &str) -> Result { + let trimmed = value.trim(); + let parsed = + Address::from_str(trimmed).with_context(|| format!("Invalid EVM address '{trimmed}'"))?; + Ok(format!("{parsed:#x}")) +} + +fn same_evm_address(left: &str, right: &str) -> bool { + left.trim().eq_ignore_ascii_case(right.trim()) +} + +/// Floor-rounds size to the CLOB's required precision. +/// +/// Order **sizes** must round DOWN (floor): rounding up would cause the +/// signed order to commit the EOA to more collateral than the user +/// intended, which can fail the on-chain transfer or — worse — succeed +/// and over-sell. Floor is the safe direction. +fn round_down(value: f64, decimals: u32) -> f64 { + let factor = 10_f64.powi(decimals as i32); + (value * factor).floor() / factor +} + +/// Half-up rounds price to the CLOB's required tick. +/// +/// Order **prices** use bankers-default (half-up) rounding: rounding +/// down would systematically under-quote the user's bid/ask and skew +/// their fills below market. Asymmetric vs `round_down` for size is +/// intentional — different invariants for the two scalars. +fn round_normal(value: f64, decimals: u32) -> f64 { + let factor = 10_f64.powi(decimals as i32); + (value * factor).round() / factor +} + +fn to_fixed_units_string(value: f64, decimals: u32) -> Result { + if !value.is_finite() || value < 0.0 { + anyhow::bail!("Cannot convert non-finite or negative amount to fixed units") + } + let factor = 10_f64.powi(decimals as i32); + let scaled = (value * factor).round(); + if !scaled.is_finite() || scaled < 0.0 { + anyhow::bail!("Amount scaling overflow") + } + Ok(format!("{scaled:.0}")) +} + +fn parse_hex_u256(value: &str) -> Result { + let trimmed = value.trim(); + let normalized = trimmed.strip_prefix("0x").unwrap_or(trimmed); + ethers_core::types::U256::from_str_radix(normalized, 16) + .with_context(|| format!("invalid hex quantity '{value}'")) +} + +fn parse_order_nonce(payload: &Value) -> Result { + if let Some(parsed) = payload + .get("nonce") + .or_else(|| payload.get("data").and_then(|v| v.get("nonce"))) + .and_then(parse_nonce_value) + { + return Ok(parsed); + } + + if let Some(parsed) = parse_nonce_value(payload) { + return Ok(parsed); + } + + anyhow::bail!("Polymarket nonce response missing valid 'nonce' field") +} + +fn parse_nonce_value(value: &Value) -> Option { + match value { + Value::Number(number) => number.as_u64(), + Value::String(text) => text.trim().parse::().ok(), + _ => None, + } +} + +#[cfg(test)] +#[path = "polymarket_tests.rs"] +mod tests; diff --git a/src/openhuman/tools/impl/network/polymarket_orders.rs b/src/openhuman/tools/impl/network/polymarket_orders.rs new file mode 100644 index 000000000..bbac77508 --- /dev/null +++ b/src/openhuman/tools/impl/network/polymarket_orders.rs @@ -0,0 +1,158 @@ +use anyhow::{Context, Result}; +use ethers_core::types::transaction::eip712::{EIP712Domain, TypedData}; +use ethers_core::types::{Address, U256}; +use ethers_signers::{LocalWallet, Signer}; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub(crate) struct Order { + pub salt: String, + pub maker: String, + pub signer: String, + pub taker: String, + pub token_id: String, + pub maker_amount: String, + pub taker_amount: String, + pub expiration: String, + pub nonce: String, + pub fee_rate_bps: String, + pub side: String, + pub signature_type: u8, +} + +pub(crate) fn domain_separator(chain_id: u64, contract: Address) -> EIP712Domain { + EIP712Domain { + name: Some("Polymarket CTF Exchange".to_string()), + version: Some("1".to_string()), + chain_id: Some(U256::from(chain_id)), + verifying_contract: Some(contract), + salt: None, + } +} + +pub(crate) async fn sign_order( + order: &Order, + wallet: &LocalWallet, + chain_id: u64, + contract: Address, +) -> Result { + let side_code = normalize_side(&order.side)?; + + let typed_data: TypedData = serde_json::from_value(json!({ + "types": { + "EIP712Domain": [ + { "name": "name", "type": "string" }, + { "name": "version", "type": "string" }, + { "name": "chainId", "type": "uint256" }, + { "name": "verifyingContract", "type": "address" } + ], + "Order": [ + { "name": "salt", "type": "uint256" }, + { "name": "maker", "type": "address" }, + { "name": "signer", "type": "address" }, + { "name": "taker", "type": "address" }, + { "name": "tokenId", "type": "uint256" }, + { "name": "makerAmount", "type": "uint256" }, + { "name": "takerAmount", "type": "uint256" }, + { "name": "expiration", "type": "uint256" }, + { "name": "nonce", "type": "uint256" }, + { "name": "feeRateBps", "type": "uint256" }, + { "name": "side", "type": "uint8" }, + { "name": "signatureType", "type": "uint8" } + ] + }, + "primaryType": "Order", + "domain": domain_separator(chain_id, contract), + "message": { + "salt": order.salt, + "maker": order.maker, + "signer": order.signer, + "taker": order.taker, + "tokenId": order.token_id, + "makerAmount": order.maker_amount, + "takerAmount": order.taker_amount, + "expiration": order.expiration, + "nonce": order.nonce, + "feeRateBps": order.fee_rate_bps, + "side": side_code, + "signatureType": order.signature_type, + } + })) + .context("Failed to encode Polymarket order typed data")?; + + let signature = wallet + .sign_typed_data(&typed_data) + .await + .context("Failed to sign Polymarket order")?; + + let signature = signature.to_string(); + if signature.starts_with("0x") { + Ok(signature) + } else { + Ok(format!("0x{signature}")) + } +} + +pub(crate) fn normalize_side(side: &str) -> Result { + match side.trim().to_ascii_uppercase().as_str() { + "BUY" => Ok(0), + "SELL" => Ok(1), + _ => anyhow::bail!("Invalid order side. Expected BUY or SELL"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::str::FromStr; + + #[test] + fn domain_separator_uses_chain_and_contract() { + let contract = Address::from_str("0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E").unwrap(); + let domain = domain_separator(137, contract); + + assert_eq!(domain.name.as_deref(), Some("Polymarket CTF Exchange")); + assert_eq!(domain.version.as_deref(), Some("1")); + assert_eq!(domain.chain_id, Some(U256::from(137u64))); + assert_eq!(domain.verifying_contract, Some(contract)); + } + + #[tokio::test] + async fn sign_order_matches_fixture() { + let wallet = LocalWallet::from_str( + "0x59c6995e998f97a5a0044966f0945382dbf66e45c5e8fb5bbf5bcf3f4f6d09f1", + ) + .unwrap(); + + let order = Order { + salt: "123456789".to_string(), + maker: "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC".to_string(), + signer: "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC".to_string(), + taker: "0x0000000000000000000000000000000000000000".to_string(), + token_id: "1001".to_string(), + maker_amount: "25000000".to_string(), + taker_amount: "50000000".to_string(), + expiration: "0".to_string(), + nonce: "0".to_string(), + fee_rate_bps: "0".to_string(), + side: "BUY".to_string(), + signature_type: 0, + }; + + let contract = Address::from_str("0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E").unwrap(); + let signature = sign_order(&order, &wallet, 137, contract).await.unwrap(); + + assert_eq!( + signature, + "0x52bdf5800cfb31a8ee0face7668458f9abe7577e9acb77cf21efb7800b98d7267404dc4b4579890c167da24f7eb496b5d4fbff8eb44ad4462314521065c800ad1b" + ); + } + + #[test] + fn normalize_side_rejects_unknown_value() { + let err = normalize_side("invalid").expect_err("side should fail"); + assert!(err.to_string().contains("BUY or SELL")); + } +} diff --git a/src/openhuman/tools/impl/network/polymarket_tests.rs b/src/openhuman/tools/impl/network/polymarket_tests.rs new file mode 100644 index 000000000..a16009df2 --- /dev/null +++ b/src/openhuman/tools/impl/network/polymarket_tests.rs @@ -0,0 +1,1098 @@ +use super::*; +use crate::openhuman::config::Config; +use crate::openhuman::config::{PolymarketClobCredentials, PolymarketConfig}; +use crate::openhuman::security::{AutonomyLevel, SecurityPolicy}; +use crate::openhuman::wallet::{ + self, WalletAccount, WalletChain, WalletSetupParams, WalletSetupSource, +}; +use ethers_signers::{coins_bip39::English, MnemonicBuilder, Signer}; +use serde_json::{json, Value}; +use std::collections::{HashMap, VecDeque}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; +use tokio::time::{sleep, Duration}; + +#[derive(Clone)] +struct MockResponse { + status: u16, + body: String, + delay_ms: u64, +} + +impl MockResponse { + fn json(status: u16, fixture_name: &str) -> Self { + Self { + status, + body: fixture(fixture_name), + delay_ms: 0, + } + } + + fn body(status: u16, body: &str) -> Self { + Self { + status, + body: body.to_string(), + delay_ms: 0, + } + } + + fn with_delay(mut self, delay_ms: u64) -> Self { + self.delay_ms = delay_ms; + self + } +} + +#[derive(Clone, Debug, Default)] +struct ObservedRequest { + method: String, + target: String, + headers: HashMap, + body: String, +} + +fn fixture(name: &str) -> String { + let root = env!("CARGO_MANIFEST_DIR"); + let path = format!("{root}/tests/fixtures/polymarket/{name}.json"); + std::fs::read_to_string(path).expect("fixture must exist") +} + +fn test_security() -> Arc { + Arc::new(SecurityPolicy { + autonomy: AutonomyLevel::Supervised, + ..SecurityPolicy::default() + }) +} + +fn test_tool(gamma_base_url: String, clob_base_url: String, timeout_secs: u64) -> PolymarketTool { + let config = PolymarketConfig { + enabled: true, + gamma_base_url, + clob_base_url, + timeout_secs, + ..PolymarketConfig::default() + }; + + PolymarketTool::new(&config, test_security()) +} + +fn authed_tool(clob_base_url: String, user: &str) -> PolymarketTool { + let config = PolymarketConfig { + enabled: true, + gamma_base_url: clob_base_url.clone(), + clob_base_url, + timeout_secs: 15, + eoa_address: Some(user.to_string()), + derived_clob_credentials: Some(PolymarketClobCredentials { + api_key: "test-key".to_string(), + secret: "dGVzdC1zZWNyZXQ=".to_string(), + passphrase: "test-passphrase".to_string(), + }), + ..PolymarketConfig::default() + }; + + PolymarketTool::new(&config, test_security()) +} + +fn route(key: &str, responses: Vec) -> HashMap> { + let mut routes = HashMap::new(); + routes.insert(key.to_string(), responses); + routes +} + +async fn start_mock_server( + routes: HashMap>, +) -> (String, Arc) { + let (base, calls, _captured) = start_mock_server_with_capture(routes).await; + (base, calls) +} + +async fn start_mock_server_with_capture( + routes: HashMap>, +) -> (String, Arc, Arc>>) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let calls = Arc::new(AtomicUsize::new(0)); + let captured = Arc::new(Mutex::new(Vec::new())); + + let queues: HashMap> = routes + .into_iter() + .map(|(path, responses)| (path, responses.into_iter().collect::>())) + .collect(); + + let shared_routes = Arc::new(Mutex::new(queues)); + let shared_calls = Arc::clone(&calls); + let shared_captured = Arc::clone(&captured); + + tokio::spawn(async move { + loop { + let (mut socket, _) = match listener.accept().await { + Ok(pair) => pair, + Err(_) => return, + }; + + let routes = Arc::clone(&shared_routes); + let calls = Arc::clone(&shared_calls); + let captured = Arc::clone(&shared_captured); + + tokio::spawn(async move { + let mut buf = Vec::with_capacity(32 * 1024); + let mut chunk = [0_u8; 4096]; + loop { + let n = match socket.read(&mut chunk).await { + Ok(read) => read, + Err(_) => return, + }; + if n == 0 { + break; + } + buf.extend_from_slice(&chunk[..n]); + if request_is_complete(&buf) { + break; + } + } + if buf.is_empty() { + return; + } + + let request_raw = String::from_utf8_lossy(&buf).to_string(); + let observed = parse_request(&request_raw); + let target = observed.target.clone(); + + { + let mut guard = captured.lock().unwrap(); + guard.push(observed); + } + + calls.fetch_add(1, Ordering::Relaxed); + + let response = { + let mut guard = routes.lock().unwrap(); + pop_response(&mut guard, &target) + }; + + if response.delay_ms > 0 { + sleep(Duration::from_millis(response.delay_ms)).await; + } + + let reason = reason_phrase(response.status); + let payload = response.body; + let wire = format!( + "HTTP/1.1 {} {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + response.status, + reason, + payload.len(), + payload + ); + + let _ = socket.write_all(wire.as_bytes()).await; + }); + } + }); + + (format!("http://127.0.0.1:{}", addr.port()), calls, captured) +} + +fn request_is_complete(buf: &[u8]) -> bool { + let raw = String::from_utf8_lossy(buf); + let Some((head, body)) = raw.split_once("\r\n\r\n") else { + return false; + }; + + let content_length = head + .lines() + .find_map(|line| { + let (k, v) = line.split_once(':')?; + if k.trim().eq_ignore_ascii_case("content-length") { + v.trim().parse::().ok() + } else { + None + } + }) + .unwrap_or(0); + + body.as_bytes().len() >= content_length +} + +fn parse_request(raw: &str) -> ObservedRequest { + let (head, body) = raw.split_once("\r\n\r\n").unwrap_or((raw, "")); + let mut lines = head.lines(); + let first_line = lines.next().unwrap_or_default(); + + let mut parts = first_line.split_whitespace(); + let method = parts.next().unwrap_or_default().to_string(); + let target = parts.next().unwrap_or("/").to_string(); + + let mut headers = HashMap::new(); + for line in lines { + if let Some((k, v)) = line.split_once(':') { + headers.insert(k.trim().to_ascii_lowercase(), v.trim().to_string()); + } + } + + ObservedRequest { + method, + target, + headers, + body: body.to_string(), + } +} + +fn pop_response( + routes: &mut HashMap>, + target: &str, +) -> MockResponse { + if let Some(response) = pop_from_queue(routes.get_mut(target)) { + return response; + } + + let path_only = target.split('?').next().unwrap_or(target); + if let Some(response) = pop_from_queue(routes.get_mut(path_only)) { + return response; + } + + MockResponse { + status: 404, + body: r#"{"error":"not found"}"#.to_string(), + delay_ms: 0, + } +} + +fn pop_from_queue(queue: Option<&mut VecDeque>) -> Option { + let queue = queue?; + if queue.len() <= 1 { + return queue.front().cloned(); + } + queue.pop_front() +} + +fn reason_phrase(status: u16) -> &'static str { + match status { + 200 => "OK", + 400 => "Bad Request", + 404 => "Not Found", + 429 => "Too Many Requests", + 500 => "Internal Server Error", + 502 => "Bad Gateway", + 503 => "Service Unavailable", + 504 => "Gateway Timeout", + _ => "Error", + } +} + +fn parse_tool_output(result: &ToolResult) -> Value { + serde_json::from_str::(&result.output()).expect("tool output should be valid json") +} + +fn header<'a>(request: &'a ObservedRequest, key: &str) -> Option<&'a str> { + request + .headers + .get(&key.to_ascii_lowercase()) + .map(|value| value.as_str()) +} + +struct EnvVarGuard { + key: &'static str, + prev: Option, +} + +impl EnvVarGuard { + fn set_to_path(key: &'static str, path: &std::path::Path) -> Self { + let prev = std::env::var_os(key); + unsafe { + std::env::set_var(key, path); + } + Self { key, prev } + } +} + +impl Drop for EnvVarGuard { + fn drop(&mut self) { + match self.prev.take() { + Some(value) => unsafe { std::env::set_var(self.key, value) }, + None => unsafe { std::env::remove_var(self.key) }, + } + } +} + +fn test_wallet_accounts(evm_address: &str, evm_derivation_path: &str) -> Vec { + vec![ + WalletAccount { + chain: WalletChain::Evm, + address: evm_address.to_string(), + derivation_path: evm_derivation_path.to_string(), + }, + WalletAccount { + chain: WalletChain::Btc, + address: "btc-test-address".to_string(), + derivation_path: "m/84'/0'/0'/0/0".to_string(), + }, + WalletAccount { + chain: WalletChain::Solana, + address: "solana-test-address".to_string(), + derivation_path: "m/44'/501'/0'/0'".to_string(), + }, + WalletAccount { + chain: WalletChain::Tron, + address: "tron-test-address".to_string(), + derivation_path: "m/44'/195'/0'/0/0".to_string(), + }, + ] +} + +async fn configure_wallet_for_place_order_test( + workspace_root: &std::path::Path, +) -> Result { + let mut config = Config::default(); + config.config_path = workspace_root.join("config.toml"); + config.workspace_dir = workspace_root.join("workspace"); + config + .save() + .await + .map_err(|e| format!("failed to save test config: {e}"))?; + + let mnemonic = "test test test test test test test test test test test junk"; + let evm_derivation_path = "m/44'/60'/0'/0/0"; + let evm_wallet = MnemonicBuilder::::default() + .phrase(mnemonic) + .derivation_path(evm_derivation_path) + .map_err(|e| format!("failed to set derivation path: {e}"))? + .build() + .map_err(|e| format!("failed to derive EVM wallet: {e}"))?; + let evm_address = format!("{:#x}", evm_wallet.address()); + + let encrypted_mnemonic = crate::openhuman::encryption::rpc::encrypt_secret(&config, mnemonic) + .await + .map_err(|e| format!("failed to encrypt mnemonic: {e}"))? + .value; + + wallet::setup(WalletSetupParams { + consent_granted: true, + source: WalletSetupSource::Imported, + mnemonic_word_count: 12, + encrypted_mnemonic: Some(encrypted_mnemonic), + accounts: test_wallet_accounts(&evm_address, evm_derivation_path), + }) + .await + .map_err(|e| format!("failed to configure wallet state: {e}"))?; + + Ok(evm_address) +} + +#[tokio::test] +async fn list_markets_happy_path() { + let (gamma_base, _) = start_mock_server(route( + "/markets?limit=2&offset=0&active=true", + vec![MockResponse::json(200, "markets_list")], + )) + .await; + + let tool = test_tool(gamma_base.clone(), gamma_base, 15); + let result = tool + .execute(json!({ + "action": "list_markets", + "limit": 2, + "offset": 0, + "active": true + })) + .await + .unwrap(); + + assert!(!result.is_error); + let output = parse_tool_output(&result); + assert_eq!(output["action"], "list_markets"); + assert!(output["data"].is_array()); + assert_eq!(output["data"][0]["slug"], "will-eth-hit-10k"); +} + +#[tokio::test] +async fn get_market_by_id_happy_path() { + let (gamma_base, _) = start_mock_server(route( + "/markets/12345", + vec![MockResponse::json(200, "market_by_id")], + )) + .await; + + let tool = test_tool(gamma_base.clone(), gamma_base, 15); + let result = tool + .execute(json!({ + "action": "get_market", + "market_id": "12345" + })) + .await + .unwrap(); + + assert!(!result.is_error); + let output = parse_tool_output(&result); + assert_eq!(output["action"], "get_market"); + assert_eq!(output["lookup"], "market_id"); + assert_eq!(output["data"]["id"], "12345"); +} + +#[tokio::test] +async fn get_market_by_slug_happy_path() { + let (gamma_base, _) = start_mock_server(route( + "/markets?slug=will-eth-hit-10k", + vec![MockResponse::json(200, "market_by_slug")], + )) + .await; + + let tool = test_tool(gamma_base.clone(), gamma_base, 15); + let result = tool + .execute(json!({ + "action": "get_market", + "slug": "will-eth-hit-10k" + })) + .await + .unwrap(); + + assert!(!result.is_error); + let output = parse_tool_output(&result); + assert_eq!(output["lookup"], "slug"); + assert_eq!(output["data"]["id"], "12345"); + assert_eq!(output["data"]["slug"], "will-eth-hit-10k"); +} + +#[tokio::test] +async fn list_events_happy_path() { + let (gamma_base, _) = start_mock_server(route( + "/events?limit=2", + vec![MockResponse::json(200, "events_list")], + )) + .await; + + let tool = test_tool(gamma_base.clone(), gamma_base, 15); + let result = tool + .execute(json!({ + "action": "list_events", + "limit": 2 + })) + .await + .unwrap(); + + assert!(!result.is_error); + let output = parse_tool_output(&result); + assert_eq!(output["action"], "list_events"); + assert!(output["data"].is_array()); + assert_eq!(output["data"][0]["id"], "event-1"); +} + +#[tokio::test] +async fn get_orderbook_happy_path() { + let (clob_base, _) = start_mock_server(route( + "/book?token_id=1001", + vec![MockResponse::json(200, "orderbook")], + )) + .await; + + let tool = test_tool(clob_base.clone(), clob_base, 15); + let result = tool + .execute(json!({ + "action": "get_orderbook", + "token_id": "1001" + })) + .await + .unwrap(); + + assert!(!result.is_error); + let output = parse_tool_output(&result); + assert_eq!(output["action"], "get_orderbook"); + assert_eq!(output["data"]["token_id"], "1001"); +} + +#[tokio::test] +async fn get_price_happy_path() { + let (clob_base, _) = start_mock_server(route( + "/price?token_id=1001&side=buy", + vec![MockResponse::json(200, "price")], + )) + .await; + + let tool = test_tool(clob_base.clone(), clob_base, 15); + let result = tool + .execute(json!({ + "action": "get_price", + "token_id": "1001", + "side": "buy" + })) + .await + .unwrap(); + + assert!(!result.is_error); + let output = parse_tool_output(&result); + assert_eq!(output["action"], "get_price"); + assert_eq!(output["data"]["price"], "0.47"); +} + +#[tokio::test] +async fn get_positions_happy_path_signs_l2_headers() { + let user = "0x1111111111111111111111111111111111111111"; + let (clob_base, _calls, captured) = start_mock_server_with_capture(route( + "/data/positions?user=0x1111111111111111111111111111111111111111", + vec![MockResponse::body( + 200, + r#"{"positions":[{"token_id":"1001","size":"5"}]}"#, + )], + )) + .await; + + let tool = authed_tool(clob_base, user); + let result = tool + .execute(json!({ + "action": "get_positions", + "user": user + })) + .await + .unwrap(); + + assert!(!result.is_error); + let output = parse_tool_output(&result); + assert_eq!(output["action"], "get_positions"); + assert_eq!(output["user"], user.to_ascii_lowercase()); + + let requests = captured.lock().unwrap().clone(); + assert_eq!(requests.len(), 1); + let request = &requests[0]; + assert_eq!(request.method, "GET"); + assert_eq!( + request.target, + "/data/positions?user=0x1111111111111111111111111111111111111111" + ); + assert_eq!(header(request, "poly_api_key"), Some("test-key")); + assert_eq!(header(request, "poly_passphrase"), Some("test-passphrase")); + assert_eq!(header(request, "poly_nonce"), Some("0")); + assert_eq!(header(request, "poly_address"), Some(user)); + assert!(header(request, "poly_signature") + .map(|sig| !sig.trim().is_empty()) + .unwrap_or(false)); +} + +#[tokio::test] +async fn get_balance_happy_path_defaults_to_usdce() { + let user = "0x2222222222222222222222222222222222222222"; + let (clob_base, _calls, captured) = start_mock_server_with_capture(route( + "/data/balance?user=0x2222222222222222222222222222222222222222&token=usdce", + vec![MockResponse::body(200, r#"{"balance":"4200000"}"#)], + )) + .await; + + let tool = authed_tool(clob_base, user); + let result = tool + .execute(json!({ + "action": "get_balance", + "user": user + })) + .await + .unwrap(); + + assert!(!result.is_error); + let output = parse_tool_output(&result); + assert_eq!(output["action"], "get_balance"); + assert_eq!(output["data"]["balance"], "4200000"); + + let requests = captured.lock().unwrap().clone(); + assert_eq!(requests.len(), 1); + let request = &requests[0]; + assert_eq!(request.method, "GET"); + assert_eq!( + request.target, + "/data/balance?user=0x2222222222222222222222222222222222222222&token=usdce" + ); + assert_eq!(header(request, "poly_api_key"), Some("test-key")); +} + +#[tokio::test] +async fn get_open_orders_happy_path_authenticated() { + let user = "0x3333333333333333333333333333333333333333"; + let (clob_base, _calls, captured) = start_mock_server_with_capture(route( + "/orders?user=0x3333333333333333333333333333333333333333", + vec![MockResponse::body(200, r#"{"data":[{"id":"ord-1"}]}"#)], + )) + .await; + + let tool = authed_tool(clob_base, user); + let result = tool + .execute(json!({ + "action": "get_open_orders", + "user": user + })) + .await + .unwrap(); + + assert!(!result.is_error); + let output = parse_tool_output(&result); + assert_eq!(output["action"], "get_open_orders"); + assert_eq!(output["data"]["data"][0]["id"], "ord-1"); + + let requests = captured.lock().unwrap().clone(); + assert_eq!(requests.len(), 1); + let request = &requests[0]; + assert_eq!(request.method, "GET"); + assert_eq!( + request.target, + "/orders?user=0x3333333333333333333333333333333333333333" + ); + assert_eq!(header(request, "poly_api_key"), Some("test-key")); +} + +#[tokio::test] +async fn get_usdc_allowance_happy_path_uses_polygon_eth_call() { + let user = "0x4444444444444444444444444444444444444444"; + let (rpc_base, _calls, captured) = start_mock_server_with_capture(route( + "/", + vec![MockResponse::body( + 200, + r#"{"jsonrpc":"2.0","id":1,"result":"0x00000000000000000000000000000000000000000000000000000000000f4240"}"#, + )], + )) + .await; + + let config = PolymarketConfig { + enabled: true, + gamma_base_url: rpc_base.clone(), + clob_base_url: rpc_base.clone(), + polygon_rpc_url: rpc_base, + timeout_secs: 15, + ..PolymarketConfig::default() + }; + let tool = PolymarketTool::new(&config, test_security()); + + let result = tool + .execute(json!({ + "action": "get_usdc_allowance", + "user": user + })) + .await + .unwrap(); + + assert!(!result.is_error); + let output = parse_tool_output(&result); + assert_eq!(output["action"], "get_usdc_allowance"); + assert_eq!(output["allowance"], "1000000"); + + let requests = captured.lock().unwrap().clone(); + assert_eq!(requests.len(), 1); + let request = &requests[0]; + assert_eq!(request.method, "POST"); + assert_eq!(request.target, "/"); + assert!(request.body.contains("\"method\":\"eth_call\"")); + assert!(request.body.contains("dd62ed3e")); + // Strict JSON-RPC providers (Alchemy/Infura) reject calls without an + // explicit Content-Type header — this is the regression guard for + // graycyrus comment 3265708296. + assert_eq!( + header(request, "content-type"), + Some("application/json"), + "Polygon RPC eth_call must declare application/json Content-Type" + ); +} + +#[tokio::test] +async fn place_order_requires_approval_and_does_not_issue_http() { + let (clob_base, calls) = start_mock_server(route( + "/order", + vec![MockResponse::body(200, r#"{"ok":true}"#)], + )) + .await; + + let tool = test_tool(clob_base.clone(), clob_base, 15); + let result = tool + .execute(json!({ + "action": "place_order", + "user": "0x1111111111111111111111111111111111111111", + "side": "BUY", + "token_id": "1001", + "price": 0.5, + "size": 10 + })) + .await + .unwrap(); + + assert!(result.is_error); + assert!(result.output().contains("requires explicit user approval")); + assert_eq!(calls.load(Ordering::Relaxed), 0); +} + +#[tokio::test] +async fn place_order_blocked_by_readonly_security_policy_before_approval_check() { + let (clob_base, calls) = start_mock_server(route( + "/order", + vec![MockResponse::body(200, r#"{"ok":true}"#)], + )) + .await; + + let readonly_security = Arc::new(SecurityPolicy { + autonomy: AutonomyLevel::ReadOnly, + ..SecurityPolicy::default() + }); + let config = PolymarketConfig { + enabled: true, + gamma_base_url: clob_base.clone(), + clob_base_url: clob_base, + timeout_secs: 15, + ..PolymarketConfig::default() + }; + let tool = PolymarketTool::new(&config, readonly_security); + + let result = tool + .execute(json!({ + "action": "place_order", + "approved": true, + "user": "0x1111111111111111111111111111111111111111", + "side": "BUY", + "token_id": "1001", + "price": 0.5, + "size": 10 + })) + .await + .unwrap(); + + assert!(result.is_error); + assert!( + result.output().contains("read-only mode"), + "expected security-policy block, got: {}", + result.output() + ); + assert_eq!(calls.load(Ordering::Relaxed), 0); +} + +#[tokio::test] +async fn place_order_happy_path_posts_signed_order() { + use crate::openhuman::config::TEST_ENV_LOCK; + + let _env_guard = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let tmp = tempfile::tempdir().expect("tempdir"); + let _workspace_guard = EnvVarGuard::set_to_path("OPENHUMAN_WORKSPACE", tmp.path()); + + let user = configure_wallet_for_place_order_test(tmp.path()) + .await + .expect("wallet setup"); + + let mut routes = HashMap::new(); + routes.insert( + format!("/nonce?user={user}"), + vec![MockResponse::body(200, r#"{"nonce": 42}"#)], + ); + routes.insert( + "/order".to_string(), + vec![MockResponse::body( + 200, + r#"{"success":true,"orderID":"ord-test-1"}"#, + )], + ); + + let (clob_base, _calls, captured) = start_mock_server_with_capture(routes).await; + let config = PolymarketConfig { + enabled: true, + gamma_base_url: clob_base.clone(), + clob_base_url: clob_base, + timeout_secs: 15, + eoa_address: Some(user.clone()), + derived_clob_credentials: Some(PolymarketClobCredentials { + api_key: "test-key".to_string(), + secret: "dGVzdC1zZWNyZXQ=".to_string(), + passphrase: "test-passphrase".to_string(), + }), + ..PolymarketConfig::default() + }; + let tool = PolymarketTool::new(&config, test_security()); + + let result = tool + .execute(json!({ + "action": "place_order", + "user": user.clone(), + "side": "BUY", + "token_id": "1001", + "price": 0.5, + "size": 10.0, + "time_in_force": "GTC", + "approved": true + })) + .await + .unwrap(); + + assert!( + !result.is_error, + "expected success, got: {}", + result.output() + ); + let output = parse_tool_output(&result); + assert_eq!(output["action"], "place_order"); + assert_eq!(output["time_in_force"], "GTC"); + assert_eq!(output["data"]["success"], true); + assert_eq!(output["data"]["orderID"], "ord-test-1"); + + let requests = captured.lock().unwrap().clone(); + assert_eq!(requests.len(), 2); + + let nonce_request = &requests[0]; + assert_eq!(nonce_request.method, "GET"); + assert_eq!( + nonce_request.target, + format!( + "/nonce?user={}", + output["user"].as_str().expect("user should be a string") + ) + ); + assert_eq!(header(nonce_request, "poly_api_key"), Some("test-key")); + assert_eq!( + header(nonce_request, "poly_passphrase"), + Some("test-passphrase") + ); + assert_eq!( + header(nonce_request, "poly_address"), + output["user"].as_str() + ); + + let order_request = &requests[1]; + assert_eq!(order_request.method, "POST"); + assert_eq!(order_request.target, "/order"); + assert_eq!(header(order_request, "poly_api_key"), Some("test-key")); + assert_eq!( + header(order_request, "poly_passphrase"), + Some("test-passphrase") + ); + assert_eq!( + header(order_request, "content-type"), + Some("application/json") + ); + assert!(header(order_request, "poly_signature") + .map(|sig| !sig.is_empty()) + .unwrap_or(false)); + + let posted: Value = + serde_json::from_str(&order_request.body).expect("post body should be valid JSON"); + assert_eq!(posted["owner"], output["user"]); + assert_eq!(posted["orderType"], "limit"); + assert_eq!(posted["timeInForce"], "GTC"); + assert_eq!(posted["order"]["maker"], output["user"]); + assert_eq!(posted["order"]["signer"], output["user"]); + assert_eq!(posted["order"]["tokenId"], "1001"); + assert_eq!(posted["order"]["makerAmount"], "5000000"); + assert_eq!(posted["order"]["takerAmount"], "10000000"); + assert_eq!(posted["order"]["nonce"], "42"); + assert_eq!(posted["order"]["side"], "BUY"); + assert_eq!(posted["order"]["signatureType"], 0); + assert!(posted["order"]["signature"] + .as_str() + .map(|sig| sig.starts_with("0x") && sig.len() > 10) + .unwrap_or(false)); +} + +#[tokio::test] +async fn cancel_order_requires_approval_and_does_not_issue_http() { + let (clob_base, calls) = start_mock_server(route( + "/order/ord-1", + vec![MockResponse::body(200, r#"{"ok":true}"#)], + )) + .await; + + let tool = test_tool(clob_base.clone(), clob_base, 15); + let result = tool + .execute(json!({ + "action": "cancel_order", + "order_id": "ord-1", + "user": "0x1111111111111111111111111111111111111111" + })) + .await + .unwrap(); + + assert!(result.is_error); + assert!(result.output().contains("requires explicit user approval")); + assert_eq!(calls.load(Ordering::Relaxed), 0); +} + +#[tokio::test] +async fn client_error_4xx_returns_error_not_retried() { + let (clob_base, calls) = start_mock_server(route( + "/book?token_id=bad-token", + vec![MockResponse::json(400, "error_client")], + )) + .await; + + let tool = test_tool(clob_base.clone(), clob_base, 15); + let result = tool + .execute(json!({ + "action": "get_orderbook", + "token_id": "bad-token" + })) + .await + .unwrap(); + + assert!(result.is_error); + assert!(result.output().contains("client error 400")); + assert_eq!(calls.load(Ordering::Relaxed), 1); +} + +#[tokio::test] +async fn server_error_5xx_returns_transient_error() { + let (clob_base, calls) = start_mock_server(route( + "/price?token_id=1001&side=sell", + vec![ + MockResponse::json(500, "error_server"), + MockResponse::json(500, "error_server"), + MockResponse::json(500, "error_server"), + ], + )) + .await; + + let tool = test_tool(clob_base.clone(), clob_base, 15); + let result = tool + .execute(json!({ + "action": "get_price", + "token_id": "1001", + "side": "sell" + })) + .await + .unwrap(); + + assert!(result.is_error); + assert!(result.output().contains("transient server error 500")); + assert_eq!(calls.load(Ordering::Relaxed), 3); +} + +#[tokio::test] +async fn timeout_returns_deadline_error() { + let (gamma_base, _) = start_mock_server(route( + "/markets?limit=1", + vec![MockResponse::json(200, "markets_list").with_delay(1_500)], + )) + .await; + + let tool = test_tool(gamma_base.clone(), gamma_base, 1); + let result = tool + .execute(json!({ + "action": "list_markets", + "limit": 1 + })) + .await + .unwrap(); + + assert!(result.is_error); + assert!(result.output().contains("timed out")); +} + +#[test] +fn parameters_schema_deserializes_for_all_actions() { + let config = PolymarketConfig::default(); + let tool = PolymarketTool::new(&config, test_security()); + + let schema = tool.parameters_schema(); + let actions = schema["properties"]["action"]["enum"] + .as_array() + .cloned() + .unwrap_or_default(); + + for expected in [ + "list_markets", + "get_market", + "list_events", + "get_orderbook", + "get_price", + "get_positions", + "get_balance", + "get_open_orders", + "get_usdc_allowance", + "place_order", + "cancel_order", + ] { + assert!( + actions.contains(&json!(expected)), + "missing action {expected}" + ); + } + + let samples = vec![ + json!({"action": "list_markets", "limit": 1}), + json!({"action": "get_market", "market_id": "123"}), + json!({"action": "list_events", "limit": 1}), + json!({"action": "get_orderbook", "token_id": "1001"}), + json!({"action": "get_price", "token_id": "1001", "side": "buy"}), + json!({"action": "get_positions", "user": "0x1111111111111111111111111111111111111111"}), + json!({"action": "get_balance", "user": "0x1111111111111111111111111111111111111111"}), + json!({"action": "get_open_orders", "user": "0x1111111111111111111111111111111111111111"}), + json!({"action": "get_usdc_allowance", "user": "0x1111111111111111111111111111111111111111"}), + json!({"action": "place_order", "side": "BUY", "token_id": "1001", "price": 0.5, "size": 1.0, "approved": true}), + json!({"action": "cancel_order", "order_id": "ord-1", "approved": true}), + ]; + + for sample in samples { + let parsed: PolymarketRequest = serde_json::from_value(sample).unwrap(); + assert!(matches!( + parsed, + PolymarketRequest::ListMarkets { .. } + | PolymarketRequest::GetMarket { .. } + | PolymarketRequest::ListEvents { .. } + | PolymarketRequest::GetOrderbook { .. } + | PolymarketRequest::GetPrice { .. } + | PolymarketRequest::GetPositions { .. } + | PolymarketRequest::GetBalance { .. } + | PolymarketRequest::GetOpenOrders { .. } + | PolymarketRequest::GetUsdcAllowance { .. } + | PolymarketRequest::PlaceOrder { .. } + | PolymarketRequest::CancelOrder { .. } + )); + } +} + +#[test] +fn signed_request_path_includes_query_pairs() { + let signed = signed_request_path( + "/data/positions", + &[("user".to_string(), "0xabc".to_string())], + ); + assert_eq!(signed, "/data/positions?user=0xabc"); +} + +#[test] +fn parse_order_nonce_accepts_number_and_string_payloads() { + assert_eq!(parse_order_nonce(&json!({"nonce": 7})).unwrap(), 7); + assert_eq!(parse_order_nonce(&json!({"nonce": "8"})).unwrap(), 8); + assert_eq!( + parse_order_nonce(&json!({"data": {"nonce": 9}})).unwrap(), + 9 + ); +} + +#[tokio::test] +async fn get_market_without_market_id_or_slug_errors() { + let (gamma_base, calls) = + start_mock_server(route("/markets", vec![MockResponse::body(200, r#"[]"#)])).await; + + let tool = test_tool(gamma_base.clone(), gamma_base, 15); + let result = tool + .execute(json!({ "action": "get_market" })) + .await + .unwrap(); + + assert!(result.is_error); + assert!( + result + .output() + .contains("requires either 'market_id' or 'slug'"), + "got: {}", + result.output() + ); + assert_eq!(calls.load(Ordering::Relaxed), 0); +} + +#[test] +fn ensure_https_accepts_https_url() { + assert!(ensure_https("https://clob.polymarket.com").is_ok()); +} + +#[test] +fn ensure_https_accepts_loopback_http_for_mock() { + assert!(ensure_https("http://127.0.0.1:8901").is_ok()); + assert!(ensure_https("http://localhost:8901/order").is_ok()); + assert!(ensure_https("http://[::1]:8901").is_ok()); +} + +#[test] +fn ensure_https_rejects_remote_http_url() { + let err = ensure_https("http://clob.polymarket.com") + .unwrap_err() + .to_string(); + assert!(err.contains("non-HTTPS"), "unexpected error message: {err}"); +} diff --git a/src/openhuman/tools/ops.rs b/src/openhuman/tools/ops.rs index f5894d1cd..8dc83c00e 100644 --- a/src/openhuman/tools/ops.rs +++ b/src/openhuman/tools/ops.rs @@ -475,6 +475,16 @@ pub fn all_tools_with_runtime( ); } + if root_config.integrations.polymarket.enabled { + tools.push(Box::new(PolymarketTool::new( + &root_config.integrations.polymarket, + security.clone(), + ))); + tracing::debug!("[integrations] registered polymarket tool (read + trading)"); + } else { + tracing::debug!("[integrations] polymarket disabled — skipping"); + } + // Coding-harness `lsp` tool (issue #1205) — capability-gated by the // OPENHUMAN_LSP_ENABLED env var. The backend (real language-server // bridge) is a follow-up; today the gate just controls visibility diff --git a/src/openhuman/tools/schemas.rs b/src/openhuman/tools/schemas.rs index 9d8f522c2..b23a2b820 100644 --- a/src/openhuman/tools/schemas.rs +++ b/src/openhuman/tools/schemas.rs @@ -20,6 +20,7 @@ pub fn all_controller_schemas() -> Vec { tools_schemas("tools_web_search"), tools_schemas("tools_seltz_search"), tools_schemas("tools_apify_linkedin_scrape"), + tools_schemas("tools_polymarket_execute"), ] } @@ -41,6 +42,10 @@ pub fn all_registered_controllers() -> Vec { schema: tools_schemas("tools_apify_linkedin_scrape"), handler: handle_apify_linkedin_scrape, }, + RegisteredController { + schema: tools_schemas("tools_polymarket_execute"), + handler: handle_polymarket_execute, + }, ] } @@ -216,6 +221,33 @@ pub fn tools_schemas(function: &str) -> ControllerSchema { }, ], }, + "tools_polymarket_execute" => ControllerSchema { + namespace: "tools", + function: "polymarket_execute", + description: "Execute a Polymarket action (Gamma + CLOB APIs, including authenticated reads and trading writes). \ + Exposed for Tauri-driven smoke + admin flows. Agent-facing path \ + goes through the normal harness tool registry.", + inputs: vec![ + FieldSchema { + name: "action", + ty: TypeSchema::String, + comment: "Polymarket action: list_markets | get_market | list_events | get_orderbook | get_price | get_positions | get_balance | get_open_orders | get_usdc_allowance | place_order | cancel_order.", + required: true, + }, + FieldSchema { + name: "arguments", + ty: TypeSchema::Json, + comment: "Per-action argument object (market_id, slug, token_id, side, limit, ...).", + required: false, + }, + ], + outputs: vec![FieldSchema { + name: "data", + ty: TypeSchema::Json, + comment: "Tool result payload (provider response wrapped with action/source).", + required: true, + }], + }, _ => ControllerSchema { namespace: "tools", function: "unknown", @@ -468,18 +500,86 @@ fn handle_apify_linkedin_scrape(params: Map) -> ControllerFuture }) } +fn handle_polymarket_execute(params: Map) -> ControllerFuture { + Box::pin(async move { + let action = params + .get("action") + .and_then(Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .ok_or_else(|| "missing or empty `action`".to_string())?; + let arguments = params.get("arguments").cloned(); + + let config = config_rpc::load_config_with_timeout().await?; + let enabled = config.integrations.polymarket.enabled; + tracing::debug!( + action = %action, + enabled, + has_arguments = arguments.is_some(), + "[tools] polymarket_execute: entry" + ); + if !enabled { + tracing::debug!(action = %action, "[tools] polymarket_execute: disabled"); + return Err("Polymarket integration is disabled in config.".to_string()); + } + + let security = std::sync::Arc::new(crate::openhuman::security::SecurityPolicy::default()); + let tool = crate::openhuman::tools::implementations::network::PolymarketTool::new( + &config.integrations.polymarket, + security, + ); + + let mut args = match arguments { + Some(Value::Object(map)) => Value::Object(map), + Some(_) => { + tracing::debug!( + action = %action, + "[tools] polymarket_execute: invalid arguments shape" + ); + return Err("`arguments` must be a JSON object when provided".to_string()); + } + None => json!({}), + }; + if let Value::Object(ref mut map) = args { + map.insert("action".to_string(), Value::String(action.clone())); + } + tracing::trace!(action = %action, args = ?args, "[tools] polymarket_execute: dispatch"); + + let result = tool.execute(args).await.map_err(|e| { + tracing::error!( + action = %action, + enabled, + error = %e, + "[tools] polymarket_execute: execution failed" + ); + format!("polymarket execute failed: {e:#}") + })?; + + tracing::debug!( + action = %action, + is_error = result.is_error, + "[tools] polymarket_execute: success" + ); + + let payload = json!({ "data": result.output() }); + let log = vec![format!("tools.polymarket_execute: action={action}")]; + RpcOutcome::new(payload, log).into_cli_compatible_json() + }) +} + #[cfg(test)] mod tests { use super::*; #[test] - fn all_schemas_returns_four() { - assert_eq!(all_controller_schemas().len(), 4); + fn all_schemas_returns_five() { + assert_eq!(all_controller_schemas().len(), 5); } #[test] - fn all_controllers_returns_four() { - assert_eq!(all_registered_controllers().len(), 4); + fn all_controllers_returns_five() { + assert_eq!(all_registered_controllers().len(), 5); } #[test] diff --git a/src/openhuman/wallet/mod.rs b/src/openhuman/wallet/mod.rs index 659e0e605..027b1267e 100644 --- a/src/openhuman/wallet/mod.rs +++ b/src/openhuman/wallet/mod.rs @@ -22,6 +22,7 @@ pub use execution::{ PrepareContractCallParams, PrepareSwapParams, PrepareTransferParams, PreparedKind, PreparedStatus, PreparedTransaction, ProviderStatus, SupportedAsset, }; +pub(crate) use ops::secret_material; pub use ops::{ setup, status, WalletAccount, WalletChain, WalletSetupParams, WalletSetupSource, WalletStatus, }; diff --git a/tests/fixtures/polymarket/error_client.json b/tests/fixtures/polymarket/error_client.json new file mode 100644 index 000000000..d03c5865f --- /dev/null +++ b/tests/fixtures/polymarket/error_client.json @@ -0,0 +1,3 @@ +{ + "error": "unknown token_id" +} diff --git a/tests/fixtures/polymarket/error_server.json b/tests/fixtures/polymarket/error_server.json new file mode 100644 index 000000000..2f08c83a4 --- /dev/null +++ b/tests/fixtures/polymarket/error_server.json @@ -0,0 +1,3 @@ +{ + "error": "upstream unavailable" +} diff --git a/tests/fixtures/polymarket/events_list.json b/tests/fixtures/polymarket/events_list.json new file mode 100644 index 000000000..e00fe6d06 --- /dev/null +++ b/tests/fixtures/polymarket/events_list.json @@ -0,0 +1,12 @@ +[ + { + "id": "event-1", + "title": "Ethereum milestones", + "slug": "ethereum-milestones" + }, + { + "id": "event-2", + "title": "Bitcoin milestones", + "slug": "bitcoin-milestones" + } +] diff --git a/tests/fixtures/polymarket/market_by_id.json b/tests/fixtures/polymarket/market_by_id.json new file mode 100644 index 000000000..ff4a4a72e --- /dev/null +++ b/tests/fixtures/polymarket/market_by_id.json @@ -0,0 +1,7 @@ +{ + "id": "12345", + "slug": "will-eth-hit-10k", + "question": "Will ETH hit $10k by Dec 31, 2026?", + "active": true, + "closed": false +} diff --git a/tests/fixtures/polymarket/market_by_slug.json b/tests/fixtures/polymarket/market_by_slug.json new file mode 100644 index 000000000..52927be35 --- /dev/null +++ b/tests/fixtures/polymarket/market_by_slug.json @@ -0,0 +1,9 @@ +[ + { + "id": "12345", + "slug": "will-eth-hit-10k", + "question": "Will ETH hit $10k by Dec 31, 2026?", + "active": true, + "closed": false + } +] diff --git a/tests/fixtures/polymarket/markets_list.json b/tests/fixtures/polymarket/markets_list.json new file mode 100644 index 000000000..9773bb910 --- /dev/null +++ b/tests/fixtures/polymarket/markets_list.json @@ -0,0 +1,16 @@ +[ + { + "id": "12345", + "slug": "will-eth-hit-10k", + "question": "Will ETH hit $10k by Dec 31, 2026?", + "active": true, + "closed": false + }, + { + "id": "67890", + "slug": "will-btc-hit-200k", + "question": "Will BTC hit $200k by Dec 31, 2026?", + "active": true, + "closed": false + } +] diff --git a/tests/fixtures/polymarket/orderbook.json b/tests/fixtures/polymarket/orderbook.json new file mode 100644 index 000000000..256411de8 --- /dev/null +++ b/tests/fixtures/polymarket/orderbook.json @@ -0,0 +1,15 @@ +{ + "token_id": "1001", + "bids": [ + { + "price": "0.46", + "size": "1200" + } + ], + "asks": [ + { + "price": "0.48", + "size": "900" + } + ] +} diff --git a/tests/fixtures/polymarket/price.json b/tests/fixtures/polymarket/price.json new file mode 100644 index 000000000..a2f4bc90f --- /dev/null +++ b/tests/fixtures/polymarket/price.json @@ -0,0 +1,5 @@ +{ + "token_id": "1001", + "side": "buy", + "price": "0.47" +}