mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
This commit is contained in:
@@ -43,6 +43,7 @@
|
|||||||
* [Testing Strategy](developing/testing-strategy.md)
|
* [Testing Strategy](developing/testing-strategy.md)
|
||||||
* [E2E Testing](developing/e2e-testing.md)
|
* [E2E Testing](developing/e2e-testing.md)
|
||||||
* [Release Policy](developing/release-policy.md)
|
* [Release Policy](developing/release-policy.md)
|
||||||
|
* [Polymarket Integration (v1 Read + Trading)](developing/integrations/polymarket.md)
|
||||||
* [Chromium Embedded Framework](developing/cef.md)
|
* [Chromium Embedded Framework](developing/cef.md)
|
||||||
* [Agent Observability](developing/agent-observability.md)
|
* [Agent Observability](developing/agent-observability.md)
|
||||||
* [Architecture](developing/architecture/README.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.
|
||||||
@@ -56,6 +56,18 @@ const COMPOSIO_DIRECT_CREDENTIALS: Option<CapabilityPrivacy> = Some(CapabilityPr
|
|||||||
destinations: &["Composio (backend.composio.dev)"],
|
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] = &[
|
const CAPABILITIES: &[Capability] = &[
|
||||||
Capability {
|
Capability {
|
||||||
id: "conversation.create",
|
id: "conversation.create",
|
||||||
@@ -503,6 +515,26 @@ const CAPABILITIES: &[Capability] = &[
|
|||||||
status: CapabilityStatus::ComingSoon,
|
status: CapabilityStatus::ComingSoon,
|
||||||
privacy: None,
|
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 {
|
Capability {
|
||||||
id: "local_ai.download_model",
|
id: "local_ai.download_model",
|
||||||
name: "Download Local Models",
|
name: "Download Local Models",
|
||||||
|
|||||||
@@ -31,14 +31,15 @@ pub use schema::{
|
|||||||
IMessageConfig, IntegrationToggle, IntegrationsConfig, LarkConfig, LearningConfig, LlmBackend,
|
IMessageConfig, IntegrationToggle, IntegrationsConfig, LarkConfig, LearningConfig, LlmBackend,
|
||||||
LocalAiConfig, MatrixConfig, McpAuthConfig, McpClientConfig, McpClientIdentityConfig,
|
LocalAiConfig, MatrixConfig, McpAuthConfig, McpClientConfig, McpClientIdentityConfig,
|
||||||
McpServerConfig, MeetConfig, MemoryConfig, MemoryTreeConfig, ModelRouteConfig,
|
McpServerConfig, MeetConfig, MemoryConfig, MemoryTreeConfig, ModelRouteConfig,
|
||||||
MultimodalConfig, ObservabilityConfig, OrchestratorModelConfig, ProxyConfig, ProxyScope,
|
MultimodalConfig, ObservabilityConfig, OrchestratorModelConfig, PolymarketClobCredentials,
|
||||||
ReflectionSource, ReliabilityConfig, ResourceLimitsConfig, RuntimeConfig, SandboxBackend,
|
PolymarketConfig, ProxyConfig, ProxyScope, ReflectionSource, ReliabilityConfig,
|
||||||
SandboxConfig, SchedulerConfig, SchedulerGateConfig, SchedulerGateMode,
|
ResourceLimitsConfig, RuntimeConfig, SandboxBackend, SandboxConfig, SchedulerConfig,
|
||||||
ScreenIntelligenceConfig, SecretsConfig, SecurityConfig, SlackConfig, StorageConfig,
|
SchedulerGateConfig, SchedulerGateMode, ScreenIntelligenceConfig, SecretsConfig,
|
||||||
StorageProviderConfig, StorageProviderSection, StreamMode, TeamModelConfig, TelegramConfig,
|
SecurityConfig, SlackConfig, StorageConfig, StorageProviderConfig, StorageProviderSection,
|
||||||
UpdateConfig, UpdateRestartStrategy, VoiceActivationMode, VoiceServerConfig, WebSearchConfig,
|
StreamMode, TeamModelConfig, TelegramConfig, UpdateConfig, UpdateRestartStrategy,
|
||||||
WebhookConfig, DEFAULT_CLOUD_LLM_MODEL, DEFAULT_MODEL, MODEL_AGENTIC_V1, MODEL_CHAT_V1,
|
VoiceActivationMode, VoiceServerConfig, WebSearchConfig, WebhookConfig,
|
||||||
MODEL_CODING_V1, MODEL_REASONING_QUICK_V1, MODEL_REASONING_V1,
|
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::{
|
pub use schema::{
|
||||||
clear_active_user, default_root_openhuman_dir, pre_login_user_dir, read_active_user_id,
|
clear_active_user, default_root_openhuman_dir, pre_login_user_dir, read_active_user_id,
|
||||||
|
|||||||
@@ -74,8 +74,9 @@ pub use storage_memory::{
|
|||||||
pub use tools::{
|
pub use tools::{
|
||||||
BrowserComputerUseConfig, BrowserConfig, ComposioConfig, ComputerControlConfig, CurlConfig,
|
BrowserComputerUseConfig, BrowserConfig, ComposioConfig, ComputerControlConfig, CurlConfig,
|
||||||
GitbooksConfig, HttpRequestConfig, IntegrationToggle, IntegrationsConfig, McpAuthConfig,
|
GitbooksConfig, HttpRequestConfig, IntegrationToggle, IntegrationsConfig, McpAuthConfig,
|
||||||
McpClientConfig, McpClientIdentityConfig, McpServerConfig, MultimodalConfig, SecretsConfig,
|
McpClientConfig, McpClientIdentityConfig, McpServerConfig, MultimodalConfig,
|
||||||
SeltzConfig, WebSearchConfig, COMPOSIO_MODE_BACKEND, COMPOSIO_MODE_DIRECT,
|
PolymarketClobCredentials, PolymarketConfig, SecretsConfig, SeltzConfig, WebSearchConfig,
|
||||||
|
COMPOSIO_MODE_BACKEND, COMPOSIO_MODE_DIRECT,
|
||||||
};
|
};
|
||||||
pub use update::{UpdateConfig, UpdateRestartStrategy};
|
pub use update::{UpdateConfig, UpdateRestartStrategy};
|
||||||
mod voice_server;
|
mod voice_server;
|
||||||
|
|||||||
@@ -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.
|
/// Agent integration tools that proxy through the backend API.
|
||||||
///
|
///
|
||||||
/// The backend URL and auth token are **not** configurable here —
|
/// 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:
|
/// Composio in particular is unconditionally enabled and has no toggle:
|
||||||
/// as long as the user is signed in, composio tools are available.
|
/// as long as the user is signed in, composio tools are available.
|
||||||
///
|
///
|
||||||
/// The per-tool `apify`, `twilio`, `google_places`, and `parallel`
|
/// The per-tool toggles below are preserved because integrations may
|
||||||
/// flags below are preserved because those integrations incur per-call
|
/// incur per-call costs or may still be in phased rollout.
|
||||||
/// 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.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct IntegrationsConfig {
|
pub struct IntegrationsConfig {
|
||||||
@@ -589,4 +701,8 @@ pub struct IntegrationsConfig {
|
|||||||
/// Stock-price / market-data integration (Alpha Vantage on the backend).
|
/// Stock-price / market-data integration (Alpha Vantage on the backend).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub stock_prices: IntegrationToggle,
|
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(×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<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(×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<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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
|
mod clob_auth;
|
||||||
mod composio;
|
mod composio;
|
||||||
mod curl;
|
mod curl;
|
||||||
mod gitbooks;
|
mod gitbooks;
|
||||||
mod gmail_unsubscribe;
|
mod gmail_unsubscribe;
|
||||||
mod http_request;
|
mod http_request;
|
||||||
mod mcp;
|
mod mcp;
|
||||||
|
mod polymarket;
|
||||||
|
mod polymarket_orders;
|
||||||
mod url_guard;
|
mod url_guard;
|
||||||
mod web_fetch;
|
mod web_fetch;
|
||||||
mod web_search;
|
mod web_search;
|
||||||
@@ -14,5 +17,6 @@ pub use gitbooks::{GitbooksGetPageTool, GitbooksSearchTool};
|
|||||||
pub use gmail_unsubscribe::GmailUnsubscribeTool;
|
pub use gmail_unsubscribe::GmailUnsubscribeTool;
|
||||||
pub use http_request::HttpRequestTool;
|
pub use http_request::HttpRequestTool;
|
||||||
pub use mcp::{McpCallTool, McpListServersTool, McpListToolsTool};
|
pub use mcp::{McpCallTool, McpListServersTool, McpListToolsTool};
|
||||||
|
pub use polymarket::PolymarketTool;
|
||||||
pub use web_fetch::WebFetchTool;
|
pub use web_fetch::WebFetchTool;
|
||||||
pub use web_search::WebSearchTool;
|
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
@@ -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
|
// Coding-harness `lsp` tool (issue #1205) — capability-gated by the
|
||||||
// OPENHUMAN_LSP_ENABLED env var. The backend (real language-server
|
// OPENHUMAN_LSP_ENABLED env var. The backend (real language-server
|
||||||
// bridge) is a follow-up; today the gate just controls visibility
|
// bridge) is a follow-up; today the gate just controls visibility
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ pub fn all_controller_schemas() -> Vec<ControllerSchema> {
|
|||||||
tools_schemas("tools_web_search"),
|
tools_schemas("tools_web_search"),
|
||||||
tools_schemas("tools_seltz_search"),
|
tools_schemas("tools_seltz_search"),
|
||||||
tools_schemas("tools_apify_linkedin_scrape"),
|
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"),
|
schema: tools_schemas("tools_apify_linkedin_scrape"),
|
||||||
handler: handle_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 {
|
_ => ControllerSchema {
|
||||||
namespace: "tools",
|
namespace: "tools",
|
||||||
function: "unknown",
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn all_schemas_returns_four() {
|
fn all_schemas_returns_five() {
|
||||||
assert_eq!(all_controller_schemas().len(), 4);
|
assert_eq!(all_controller_schemas().len(), 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn all_controllers_returns_four() {
|
fn all_controllers_returns_five() {
|
||||||
assert_eq!(all_registered_controllers().len(), 4);
|
assert_eq!(all_registered_controllers().len(), 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ pub use execution::{
|
|||||||
PrepareContractCallParams, PrepareSwapParams, PrepareTransferParams, PreparedKind,
|
PrepareContractCallParams, PrepareSwapParams, PrepareTransferParams, PreparedKind,
|
||||||
PreparedStatus, PreparedTransaction, ProviderStatus, SupportedAsset,
|
PreparedStatus, PreparedTransaction, ProviderStatus, SupportedAsset,
|
||||||
};
|
};
|
||||||
|
pub(crate) use ops::secret_material;
|
||||||
pub use ops::{
|
pub use ops::{
|
||||||
setup, status, WalletAccount, WalletChain, WalletSetupParams, WalletSetupSource, WalletStatus,
|
setup, status, WalletAccount, WalletChain, WalletSetupParams, WalletSetupSource, WalletStatus,
|
||||||
};
|
};
|
||||||
|
|||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"error": "unknown token_id"
|
||||||
|
}
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"error": "upstream unavailable"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "event-1",
|
||||||
|
"title": "Ethereum milestones",
|
||||||
|
"slug": "ethereum-milestones"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "event-2",
|
||||||
|
"title": "Bitcoin milestones",
|
||||||
|
"slug": "bitcoin-milestones"
|
||||||
|
}
|
||||||
|
]
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"id": "12345",
|
||||||
|
"slug": "will-eth-hit-10k",
|
||||||
|
"question": "Will ETH hit $10k by Dec 31, 2026?",
|
||||||
|
"active": true,
|
||||||
|
"closed": false
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"token_id": "1001",
|
||||||
|
"bids": [
|
||||||
|
{
|
||||||
|
"price": "0.46",
|
||||||
|
"size": "1200"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"asks": [
|
||||||
|
{
|
||||||
|
"price": "0.48",
|
||||||
|
"size": "900"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+5
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"token_id": "1001",
|
||||||
|
"side": "buy",
|
||||||
|
"price": "0.47"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user