feat(integrations): Polymarket trading + market data (#1398, venue 1/3) (#2145)

This commit is contained in:
oxoxDev
2026-05-19 19:15:20 +05:30
committed by GitHub
parent d4dacd62ae
commit 2f4c3f397d
22 changed files with 3565 additions and 19 deletions
+1
View File
@@ -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)
@@ -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=<eoa>` 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.
+32
View File
@@ -56,6 +56,18 @@ const COMPOSIO_DIRECT_CREDENTIALS: Option<CapabilityPrivacy> = Some(CapabilityPr
destinations: &["Composio (backend.composio.dev)"],
});
const POLYMARKET_MARKET_DATA: Option<CapabilityPrivacy> = Some(CapabilityPrivacy {
leaves_device: true,
data_kind: PrivacyDataKind::Metadata,
destinations: &["Polymarket Gamma API", "Polymarket CLOB API"],
});
const POLYMARKET_TRADING_DATA: Option<CapabilityPrivacy> = 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",
+9 -8
View File
@@ -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,
+3 -2
View File
@@ -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;
+121 -5
View File
@@ -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", &"<redacted>")
.field("secret", &"<redacted>")
.field("passphrase", &"<redacted>")
.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<String>,
#[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<PolymarketClobCredentials>,
}
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,
}
@@ -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<Sha256>;
pub(crate) async fn derive_credentials(
http: &Client,
signer: &LocalWallet,
base_url: &str,
chain_id: u64,
address: &str,
) -> Result<PolymarketClobCredentials> {
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::<ClobAuthResponse>()
.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::<ClobAuthResponse>()
.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("<failed to read response body>"));
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<char> = trimmed.chars().collect();
if chars.len() <= 10 {
return "<redacted>".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<HeaderMap> {
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(&timestamp.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<HeaderMap> {
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<HeaderMap> {
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(&timestamp.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<u64> {
Ok(SystemTime::now()
.duration_since(UNIX_EPOCH)
.context("System clock is before UNIX_EPOCH")?
.as_secs())
}
fn decode_base64_secret(secret: &str) -> Result<Vec<u8>> {
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", &"<redacted>")
.field("secret", &"<redacted>")
.field("passphrase", &"<redacted>")
.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"), "<redacted>");
assert_eq!(mask_address(""), "<redacted>");
}
#[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::<English>::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::<English>::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");
}
}
+4
View File
@@ -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;
File diff suppressed because it is too large Load Diff
@@ -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<String> {
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<u8> {
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"));
}
}
File diff suppressed because it is too large Load Diff
+10
View File
@@ -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
+104 -4
View File
@@ -20,6 +20,7 @@ pub fn all_controller_schemas() -> Vec<ControllerSchema> {
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<RegisteredController> {
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<String, Value>) -> ControllerFuture
})
}
fn handle_polymarket_execute(params: Map<String, Value>) -> 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]
+1
View File
@@ -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,
};
+3
View File
@@ -0,0 +1,3 @@
{
"error": "unknown token_id"
}
+3
View File
@@ -0,0 +1,3 @@
{
"error": "upstream unavailable"
}
+12
View File
@@ -0,0 +1,12 @@
[
{
"id": "event-1",
"title": "Ethereum milestones",
"slug": "ethereum-milestones"
},
{
"id": "event-2",
"title": "Bitcoin milestones",
"slug": "bitcoin-milestones"
}
]
+7
View File
@@ -0,0 +1,7 @@
{
"id": "12345",
"slug": "will-eth-hit-10k",
"question": "Will ETH hit $10k by Dec 31, 2026?",
"active": true,
"closed": false
}
+9
View File
@@ -0,0 +1,9 @@
[
{
"id": "12345",
"slug": "will-eth-hit-10k",
"question": "Will ETH hit $10k by Dec 31, 2026?",
"active": true,
"closed": false
}
]
+16
View File
@@ -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
}
]
+15
View File
@@ -0,0 +1,15 @@
{
"token_id": "1001",
"bids": [
{
"price": "0.46",
"size": "1200"
}
],
"asks": [
{
"price": "0.48",
"size": "900"
}
]
}
+5
View File
@@ -0,0 +1,5 @@
{
"token_id": "1001",
"side": "buy",
"price": "0.47"
}