From 4c7799adc71852aa34bb8a1c36f3cbd666dd0c4d Mon Sep 17 00:00:00 2001 From: "sam.see" Date: Mon, 27 Apr 2026 19:54:14 +0800 Subject: [PATCH] =?UTF-8?q?feat(polymarket):=20v0.5.1=20=E2=80=94=20CLOB?= =?UTF-8?q?=20V2=20/=20pUSD=20cutover=20resilience?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds full Polymarket CLOB V2 support ahead of the 2026-04-28 collateral cutover (USDC.e → pUSD), with automatic V1/V2 routing, safe fee-buffer arithmetic, and clean SKILL.md recovery/onboarding docs. Key changes: - V1/V2 auto-detection via GET /version; balance command soft-degrades to "unknown" on failure instead of erroring - pUSD auto-wrap on buy (V2): integer ceiling fee-buffer to avoid f64 precision loss on large u128 amounts - POLY_PROXY allowance check now uses on-chain get_pusd_allowance() instead of CLOB /balance-allowance (which hard-codes EOA signature_type) - POL pre-flight: 0.05 POL guard for PROXY+V2 wrap/approve; 0.01 for EOA - setup-proxy: idempotent V1 + V2 approval blocks - New commands: history, orders, watch, rfq, create-readonly-key - SKILL.md: Session Recovery, Proactive Onboarding, quickstart restored; V2 first-trade gas warning; "What users see at cutover" subsection - plugin.yaml: all 12 api_calls hosts preserved (5 multi-chain RPC hosts from #358 retained through 3-way merge) - LICENSE (MIT) and SUMMARY.md (Overview/Prerequisites/Quick Start) added for CI E041/E151 compliance Co-Authored-By: Claude Sonnet 4.6 --- .../.claude-plugin/plugin.json | 2 +- skills/polymarket-plugin/CHANGELOG.md | 40 +- skills/polymarket-plugin/Cargo.lock | 122 +-- skills/polymarket-plugin/Cargo.toml | 11 +- skills/polymarket-plugin/LICENSE | 20 +- skills/polymarket-plugin/SKILL.md | 246 +++++- skills/polymarket-plugin/SUMMARY.md | 36 +- skills/polymarket-plugin/plugin.yaml | 2 +- skills/polymarket-plugin/src/api.rs | 384 ++++++++- skills/polymarket-plugin/src/auth.rs | 54 +- .../polymarket-plugin/src/commands/balance.rs | 37 +- skills/polymarket-plugin/src/commands/buy.rs | 543 ++++++++----- .../src/commands/create_readonly_key.rs | 38 + .../polymarket-plugin/src/commands/history.rs | 105 +++ skills/polymarket-plugin/src/commands/mod.rs | 93 +-- .../polymarket-plugin/src/commands/orders.rs | 111 +++ .../polymarket-plugin/src/commands/redeem.rs | 594 ++++++-------- skills/polymarket-plugin/src/commands/rfq.rs | 201 +++++ skills/polymarket-plugin/src/commands/sell.rs | 222 ++++-- .../src/commands/setup_proxy.rs | 123 ++- .../polymarket-plugin/src/commands/watch.rs | 84 ++ .../src/commands/withdraw.rs | 58 +- skills/polymarket-plugin/src/config.rs | 68 +- skills/polymarket-plugin/src/main.rs | 120 ++- skills/polymarket-plugin/src/onchainos.rs | 730 +++++++++--------- skills/polymarket-plugin/src/signing.rs | 99 ++- 26 files changed, 2715 insertions(+), 1428 deletions(-) create mode 100644 skills/polymarket-plugin/src/commands/create_readonly_key.rs create mode 100644 skills/polymarket-plugin/src/commands/history.rs create mode 100644 skills/polymarket-plugin/src/commands/orders.rs create mode 100644 skills/polymarket-plugin/src/commands/rfq.rs create mode 100644 skills/polymarket-plugin/src/commands/watch.rs diff --git a/skills/polymarket-plugin/.claude-plugin/plugin.json b/skills/polymarket-plugin/.claude-plugin/plugin.json index efbdda90a..5c5a7c44e 100644 --- a/skills/polymarket-plugin/.claude-plugin/plugin.json +++ b/skills/polymarket-plugin/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "polymarket-plugin", "description": "Trade prediction markets on Polymarket \u2014 buy and sell YES/NO outcome tokens on Polygon", - "version": "0.4.11", + "version": "0.5.1", "author": { "name": "skylavis-sky", "github": "skylavis-sky" diff --git a/skills/polymarket-plugin/CHANGELOG.md b/skills/polymarket-plugin/CHANGELOG.md index 2b8dd4148..a81765f2f 100644 --- a/skills/polymarket-plugin/CHANGELOG.md +++ b/skills/polymarket-plugin/CHANGELOG.md @@ -1,18 +1,32 @@ # Polymarket Plugin Changelog -### v0.4.11 (2026-04-25) - -- **fix (Bug #1)**: `onchainos` binary path resolution in non-interactive shells — added `onchainos_bin()` helper that tries `~/.local/bin/onchainos` before falling back to bare `"onchainos"`. Non-interactive shells (e.g. Claude Code) never source `~/.zshrc`, so `~/.local/bin` was missing from PATH, causing "os error 2" on every CLI invocation. New env var `POLYMARKET_ONCHAINOS_BIN` allows test injection of mock binaries. -- **fix (Bug #2)**: NegRisk market redeem — removed hard-block (`"redeem not supported for neg_risk markets"`). Plugin now queries on-chain ERC-1155 token balances and calls `NegRiskAdapter.redeemPositions(bytes32 conditionId, uint256[] amounts)` for EOA wallets. NegRisk proxy-wallet redeem deferred (returns actionable error message instead of silent block). -- **fix (Bug #3)**: Allowance check uses direct `eth_call` (`get_usdc_allowance`) instead of the CLOB API (`get_balance_allowance`). CLOB API returns stale or incorrect `MAX_UINT` values that caused redundant approval transactions on every trade. -- **fix (Bug #4)**: `approve_usdc` now approves `u128::MAX` (unlimited) instead of the specific order amount. Approving an exact amount downgraded any pre-existing `MAX_UINT` allowance to that amount, causing re-approval on every subsequent trade. -- **fix (Bug #5)**: Partly resolved by Bug #3 fix — eliminating unnecessary re-approvals removes ~95% of TEE sign-tx failures. Residual cases (genuine first-time approvals) remain a TEE-side issue; error message updated to suggest retry. -- **fix (Bug #6)**: Approval confirmation timeout increased from 30s to 90s (configurable via `POLYMARKET_APPROVE_TIMEOUT_SECS` env var). 30s was too short for Polygon under congestion (5-10s/block × confirmation time). -- **tests**: First test suite added — 16 unit tests covering ABI encoding correctness (`decimal_str_to_hex64`, `build_negrisk_redeem_calldata`, `build_redeem_positions_calldata`, selectors), timeout env var behavior, and PATH resolution. All tests run with `cargo test` without network access. - -### v0.4.10 (2026-04-22) - -- **feat**: Strategy attribution reporting — `buy` / `sell` / `redeem` each accept an optional `--strategy-id `. When provided and non-empty, the plugin invokes `onchainos wallet report-plugin-info` after the order succeeds with a JSON payload containing `wallet`, `proxyAddress`, `order_id`, `tx_hashes`, `market_id`, `asset_id`, `side`, `amount`, `symbol`, `price`, `timestamp`, `strategy_id`, `plugin_name`. Omitting the flag skips reporting entirely. Report failures log to stderr as warnings and do not affect the trade result. `symbol` encodes the collateral / quote asset (Polymarket: `USDC.e`). +### v0.5.1 (2026-04-27) — V2 cutover resilience + +- **fix**: `buy.rs` POLY_PROXY V2 allowance check now reads on-chain pUSD allowance (`get_pusd_allowance`) instead of CLOB `/balance-allowance`, which hard-codes `signature_type=0` and scopes the lookup to the EOA address. The bug caused a redundant `proxy_pusd_approve` to fire on every V2 buy after setup-proxy, wasting ~0.01 POL per trade. Source of truth is now consistent with `setup-proxy`. +- **fix**: `get_clob_version` now returns `Result` and bails with a retry hint on network/parse failure, instead of silently defaulting to V1. Prevents `buy`/`sell`/`redeem`/`rfq` from routing V2-era orders through the V1 path during the cutover hour, which would produce confusing 404/405 responses from the upgraded server. `balance` softly degrades to `clob_version: "unknown"` and continues. +- **feat**: `buy.rs` pre-flight POL gas check for POLY_PROXY V2: when a wrap or first-time V2 approve is required, ensure EOA has ≥ 0.05 POL and bail with a clear error otherwise — so users aren't stuck mid-flow at first V2 trade. +- **feat**: `balance` output now includes a top-level `clob_version` field (`V1` / `V2` / `unknown`). Lets users confirm at a glance which exchange path their next trade will hit. +- **docs**: SKILL.md "Overview" section adds a "What users see at cutover" subsection covering: zero-action cutover, the 0.05 POL requirement for first V2 trade, version visibility via `balance`, and `/version`-failure retry semantics. + +### v0.5.0 (2026-04-21) — pUSD collateral migration + CLOB v2 completion + +- **feat (breaking-compatible)**: Full CLOB v2 support. Plugin auto-detects the active CLOB version via `GET /version` and branches on `OrderVersion::V1` vs `V2`. All new orders use v2 EIP-712 signing: domain version `"2"`, new exchange contracts (`CTF_EXCHANGE_V2 = 0xE111...`, `NEG_RISK_CTF_EXCHANGE_V2 = 0xe222...`), updated order struct (removed `taker`/`nonce`/`feeRateBps`; added `timestamp_ms`/`metadata`/`builder`). V1 orders placed before the upgrade remain placeable if the CLOB reports version 1 — no forced migration for existing users. +- **feat**: `orders` command — list open orders for the authenticated user (`--state OPEN|MATCHED|DELAYED|UNMATCHED`). `--v1` flag queries both live order book and `/data/pre-migration-orders` endpoint, deduplicates by order_id, and surfaces a migration notice when V1 orders are detected. Each order shows `version` (`V1` or `V2`) based on field-presence detection. +- **feat**: `watch` command — poll a market's live trade feed every N seconds (`--interval`, default 5; minimum 2). Tracks high-water timestamp to avoid reprinting; prints new events as JSON lines in chronological order. +- **feat**: `rfq` command — Request-for-Quote block trade flow. Step 1: `POST /rfq/request` → quote ID. Step 2: `GET /rfq/quote/{id}` → display price/amount/expiry. Step 3 (with `--confirm`): sign a V2 EIP-712 order at the quoted price and submit `POST /rfq/confirm`. +- **feat**: `create-readonly-key` command — derive a read-only Polymarket CLOB API key via L1 ClobAuth (`POST /auth/readonly-api-key`). Prints key to stdout; not saved to creds.json. Write operations will be rejected by the CLOB server. +- **feat**: `--order-type FAK` (fill-and-kill) support in `buy` and `sell` — fills as much as possible at or better than the given price, cancels the remainder. Complement to FOK (full-fill or nothing). +- **fix**: Approval in `buy` and `sell` now routes to the correct exchange contract based on CLOB version: V2 orders approved against `CTF_EXCHANGE_V2` / `NEG_RISK_CTF_EXCHANGE_V2`; V1 orders against the legacy v1 addresses. Prevents "not enough allowance" rejections after the v2 upgrade. +- **fix**: `orders` command uses CLOB v2 endpoint `GET /data/orders` (v1's `GET /orders?state=X` returns HTTP 405 in v2). HMAC signature now computed over the base path without query string (v2 requirement). Response parsing updated for paginated format `{"data": [...], "next_cursor": "...", "count": N}`. +- **docs**: SKILL.md updated with `orders`, `watch`, `rfq`, `create-readonly-key` command documentation; FAK order type added to Order Type Selection Guide; Key Contracts section split into v2 (active) and v1 (legacy) tables; CLOB v2 migration note added to Overview. +- **feat**: **pUSD collateral migration** (due ~2026-04-28). Polymarket is replacing USDC.e with pUSD (`0xC011...`) as collateral for V2 exchange contracts. Changes: + - `buy`: For V2 orders, checks pUSD balance instead of USDC.e. If pUSD is insufficient but USDC.e is sufficient, **auto-wraps** USDC.e → pUSD via the Collateral Onramp (`wrap(USDC_E, recipient, amount)`) before placing the order — no manual intervention required. + - `buy`: For V2 orders, approves pUSD (not USDC.e) to the V2 exchange contract. + - `balance`: Shows pUSD balance alongside USDC.e for both EOA and proxy wallets. + - `redeem`: Passes pUSD (not USDC.e) as `collateralToken` in `redeemPositions` for V2 markets. + - `withdraw`: Auto-detects whether proxy holds pUSD or USDC.e; withdraws whichever covers the requested amount. + - `onchainos`: New helpers — `get_pusd_balance`, `wrap_usdc_to_pusd`, `proxy_wrap_usdc_to_pusd`, `withdraw_pusd_from_proxy`. + - `config`: Added `Contracts::PUSD` and `Contracts::COLLATERAL_ONRAMP` constants. ### v0.4.6 (2026-04-15) diff --git a/skills/polymarket-plugin/Cargo.lock b/skills/polymarket-plugin/Cargo.lock index ef6b93b78..65f4079fd 100644 --- a/skills/polymarket-plugin/Cargo.lock +++ b/skills/polymarket-plugin/Cargo.lock @@ -2,15 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "aho-corasick" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" -dependencies = [ - "memchr", -] - [[package]] name = "android_system_properties" version = "0.1.5" @@ -76,16 +67,6 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" -[[package]] -name = "assert-json-diff" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" -dependencies = [ - "serde", - "serde_json", -] - [[package]] name = "atomic-waker" version = "1.1.2" @@ -252,24 +233,6 @@ dependencies = [ "typenum", ] -[[package]] -name = "deadpool" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" -dependencies = [ - "deadpool-runtime", - "lazy_static", - "num_cpus", - "tokio", -] - -[[package]] -name = "deadpool-runtime" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" - [[package]] name = "digest" version = "0.10.7" @@ -548,12 +511,6 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "hermit-abi" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" - [[package]] name = "hex" version = "0.4.3" @@ -608,12 +565,6 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - [[package]] name = "hyper" version = "1.9.0" @@ -628,7 +579,6 @@ dependencies = [ "http", "http-body", "httparse", - "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -887,12 +837,6 @@ dependencies = [ "cpufeatures", ] -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - [[package]] name = "leb128fmt" version = "0.1.0" @@ -990,16 +934,6 @@ dependencies = [ "autocfg", ] -[[package]] -name = "num_cpus" -version = "1.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" -dependencies = [ - "hermit-abi", - "libc", -] - [[package]] name = "once_cell" version = "1.21.4" @@ -1105,7 +1039,7 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] name = "polymarket-plugin" -version = "0.4.11" +version = "0.5.1" dependencies = [ "anyhow", "base64", @@ -1121,9 +1055,7 @@ dependencies = [ "serde_json", "sha2", "sha3", - "tempfile", "tokio", - "wiremock", ] [[package]] @@ -1189,35 +1121,6 @@ dependencies = [ "thiserror", ] -[[package]] -name = "regex" -version = "1.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" - [[package]] name = "reqwest" version = "0.12.28" @@ -2144,29 +2047,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "wiremock" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" -dependencies = [ - "assert-json-diff", - "base64", - "deadpool", - "futures", - "http", - "http-body-util", - "hyper", - "hyper-util", - "log", - "once_cell", - "regex", - "serde", - "serde_json", - "tokio", - "url", -] - [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/skills/polymarket-plugin/Cargo.toml b/skills/polymarket-plugin/Cargo.toml index f2e6bba35..f20df44a4 100644 --- a/skills/polymarket-plugin/Cargo.toml +++ b/skills/polymarket-plugin/Cargo.toml @@ -1,12 +1,8 @@ [package] name = "polymarket-plugin" -version = "0.4.11" +version = "0.5.1" edition = "2021" -[lib] -name = "polymarket_plugin" -path = "src/lib.rs" - [[bin]] name = "polymarket-plugin" path = "src/main.rs" @@ -27,8 +23,3 @@ dirs = "5" hex = "0.4" getrandom = { version = "0.2", features = ["std"] } futures = "0.3" - -[dev-dependencies] -wiremock = "0.6" -tokio = { version = "1", features = ["full"] } -tempfile = "3" diff --git a/skills/polymarket-plugin/LICENSE b/skills/polymarket-plugin/LICENSE index 6b4490dc2..017d7414d 100644 --- a/skills/polymarket-plugin/LICENSE +++ b/skills/polymarket-plugin/LICENSE @@ -1,9 +1,21 @@ MIT License -Copyright (c) 2024 skylavis-sky +Copyright (c) 2026 skylavis-sky -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/skills/polymarket-plugin/SKILL.md b/skills/polymarket-plugin/SKILL.md index 8d3dc099e..f75adf6b5 100644 --- a/skills/polymarket-plugin/SKILL.md +++ b/skills/polymarket-plugin/SKILL.md @@ -1,7 +1,7 @@ --- name: polymarket-plugin description: "Trade prediction markets on Polymarket - buy outcome tokens (YES/NO and categorical markets), check positions, list markets, manage orders, redeem winning tokens, and deposit funds on Polygon. Trigger phrases: buy polymarket shares, sell polymarket position, check my polymarket positions, list polymarket markets, get polymarket market, cancel polymarket order, redeem polymarket tokens, polymarket yes token, polymarket no token, prediction market trade, polymarket price, get started with polymarket, just installed polymarket, how do I use polymarket, set up polymarket, polymarket quickstart, new to polymarket, polymarket setup, help me trade on polymarket, place a bet on, buy prediction market, bet on, trade on prediction markets, prediction trading, place a prediction market bet, i want to bet on, deposit, 充值, 充钱, 转入, 打钱, fund polymarket, top up polymarket, add funds to polymarket, recharge polymarket, deposit usdc, deposit eth, polymarket deposit, BTC 5分钟, ETH 5分钟, 5分钟市场, 5min market, 五分钟市场, 短线市场, list 5-minute, BTC up or down, 找5分钟, 看5分钟, 5m updown, crypto 5m, 5分钟涨跌, 五分钟涨跌, updown market, BTC 5min, ETH 5min, SOL 5min, 5分钟预测." -version: "0.4.11" +version: "0.5.1" author: "skylavis-sky" tags: - prediction-market @@ -25,7 +25,7 @@ tags: # Check for skill updates (1-hour cache) UPDATE_CACHE="$HOME/.plugin-store/update-cache/polymarket-plugin" CACHE_MAX=3600 -LOCAL_VER="0.4.11" +LOCAL_VER="0.5.1" DO_CHECK=true if [ -f "$UPDATE_CACHE" ]; then @@ -98,7 +98,7 @@ case "${OS}_${ARCH}" in mingw*_aarch64|msys*_aarch64|cygwin*_aarch64) TARGET="aarch64-pc-windows-msvc"; EXT=".exe" ;; esac mkdir -p ~/.local/bin -curl -fsSL "https://github.com/okx/plugin-store/releases/download/plugins/polymarket-plugin@0.4.11/polymarket-plugin-${TARGET}${EXT}" -o ~/.local/bin/.polymarket-plugin-core${EXT} +curl -fsSL "https://github.com/okx/plugin-store/releases/download/plugins/polymarket-plugin@0.5.1/polymarket-plugin-${TARGET}${EXT}" -o ~/.local/bin/.polymarket-plugin-core${EXT} chmod +x ~/.local/bin/.polymarket-plugin-core${EXT} # Symlink CLI name to universal launcher @@ -106,9 +106,34 @@ ln -sf "$LAUNCHER" ~/.local/bin/polymarket-plugin # Register version mkdir -p "$HOME/.plugin-store/managed" -echo "0.4.11" > "$HOME/.plugin-store/managed/polymarket-plugin" +echo "0.5.1" > "$HOME/.plugin-store/managed/polymarket-plugin" ``` +### Report install (auto-injected, runs once) + +```bash +REPORT_FLAG="$HOME/.plugin-store/reported/polymarket-plugin" +if [ ! -f "$REPORT_FLAG" ]; then + mkdir -p "$HOME/.plugin-store/reported" + # Device fingerprint → SHA256 → 32-char device ID + DEV_RAW="$(hostname):$(uname -s):$(uname -m):$HOME" + DEV_ID=$(echo -n "$DEV_RAW" | shasum -a 256 | head -c 32) + # HMAC signature (obfuscated key, same as CLI binary) + _K=$(echo 'OE9nNWFRUFdfSVJkektrMExOV2RNeTIzV2JibXo3ZWNTbExJUDFIWnVoZw==' | base64 -d 2>/dev/null || echo 'OE9nNWFRUFdfSVJkektrMExOV2RNeTIzV2JibXo3ZWNTbExJUDFIWnVoZw==' | openssl base64 -d) + HMAC_SIG=$(echo -n "${_K}${DEV_ID}" | shasum -a 256 | head -c 8) + DIV_ID="${DEV_ID}${HMAC_SIG}" + unset _K + # Report to Vercel stats + curl -s -X POST "https://plugin-store-dun.vercel.app/install" \ + -H "Content-Type: application/json" \ + -d '{"name":"polymarket-plugin","version":"0.5.1"}' >/dev/null 2>&1 || true + # Report to OKX API (with HMAC-signed device token) + curl -s -X POST "https://www.okx.com/priapi/v1/wallet/plugins/download/report" \ + -H "Content-Type: application/json" \ + -d '{"pluginName":"polymarket-plugin","divId":"'"$DIV_ID"'"}' >/dev/null 2>&1 || true + touch "$REPORT_FLAG" +fi +``` --- @@ -207,6 +232,16 @@ Polymarket is a prediction market platform on Polygon where users trade outcome 3. When orders are matched, Polymarket's operator settles on-chain via CTF Exchange (gasless for user) 4. USDC.e flows from the onchainos wallet (buyer); conditional tokens flow from the onchainos wallet (seller) +**CLOB v2 migration (2026-04-21):** The plugin auto-detects the active CLOB version via `GET /version`. All new orders use v2 EIP-712 signing (domain version `"2"`, new exchange contracts, no `taker`/`nonce`/`feeRateBps` fields). V1 orders placed before the upgrade are visible via `polymarket orders --v1` (separate pre-migration backing store). Cancellation remains API-level (HMAC only) for both V1 and V2 orders. + +**pUSD collateral cutover (~2026-04-28):** Polymarket is replacing USDC.e with pUSD (`0xC011...`) as the collateral token for V2 exchange contracts. The plugin handles this automatically: `buy` checks pUSD balance first and auto-wraps USDC.e → pUSD via the Collateral Onramp if needed. Approvals are routed to pUSD for V2 orders. `redeem` uses pUSD as the collateral token for V2 market redemptions. `balance` now displays both USDC.e and pUSD balances. + +**What users see at cutover (no action required):** +- `balance` always reports a `clob_version` field (`V1` / `V2` / `unknown`). Run it any time to confirm which exchange the next trade will hit. +- After ~2026-04-28 11:00 UTC, the next `buy` / `sell` automatically routes through V2: signs with the new EIP-712 domain, uses pUSD, hits V2 contracts. No URL change, no reinstall. +- **Existing POLY_PROXY users**: the first V2 trade triggers up to two one-time on-chain transactions on the EOA wallet — USDC.e → pUSD wrap + V2 exchange approve — costing **~0.05 POL** total. Subsequent trades return to gasless. The plugin pre-flights this and bails with a clear message if EOA POL is below 0.05; top up POL on Polygon and retry. Running `polymarket setup-proxy` ahead of time pre-approves the V2 contracts so only the wrap remains lazy. +- If the `GET /version` probe fails (rare; possible during the cutover hour), `buy` / `sell` / `redeem` bail with a retry hint instead of silently routing to V1. Wait a few seconds and retry — do not re-broadcast in a tight loop. + --- ## Quickstart @@ -309,7 +344,7 @@ The first `buy` or `sell` automatically derives your Polymarket API credentials polymarket-plugin --version ``` -Expected: `polymarket-plugin 0.4.10`. If missing or wrong version, run the install script in **Pre-flight Dependencies** above. +Expected: `polymarket-plugin 0.5.1`. If missing or wrong version, run the install script in **Pre-flight Dependencies** above. ### Step 2 — Install `onchainos` CLI (required for buy/sell/cancel/redeem only) @@ -363,13 +398,20 @@ Shows both EOA and proxy wallet balances. EOA mode → check `eoa_wallet.usdc_e` | `get-market` | No | Get market details and order book | | `get-positions` | No | View open positions | | `balance` | No | Show POL and USDC.e balances (EOA + proxy wallet) | +| `get-series` | No | Get current/next slot for a recurring series market | +| `list-5m` | No | List upcoming 5-minute crypto Up/Down markets | | `buy` | Yes | Buy YES/NO outcome shares | | `sell` | Yes | Sell outcome shares | | `cancel` | Yes | Cancel an open order | +| `orders` | Yes | List open orders for the authenticated user | +| `watch` | Yes | Watch live trade activity for a market (polls every N seconds) | +| `rfq` | Yes | Request a block-trade quote from a market maker (RFQ) | | `redeem` | Yes | Redeem winning tokens after market resolves | | `setup-proxy` | Yes | Deploy proxy wallet for gasless trading (one-time) | | `deposit` | Yes | Transfer USDC.e from EOA to proxy wallet | +| `withdraw` | Yes | Transfer USDC.e from proxy wallet back to EOA | | `switch-mode` | Yes | Switch default trading mode (eoa / proxy) | +| `create-readonly-key` | Yes | Create a read-only Polymarket API key | --- @@ -543,7 +585,9 @@ polymarket-plugin get-market --market-id 0xabc123... ### `balance` — View Wallet Balances -Show POL and USDC.e balances for the EOA wallet and proxy wallet (if initialized). +Show POL, USDC.e, and pUSD balances for the EOA wallet and proxy wallet (if initialized). + +> **pUSD note**: pUSD is the V2 collateral token replacing USDC.e (~2026-04-28). The `buy` command auto-wraps USDC.e → pUSD when needed. ``` polymarket-plugin balance @@ -552,10 +596,11 @@ polymarket-plugin balance **Auth required:** No (reads on-chain via Polygon RPC) **Output fields:** -- `eoa_wallet`: `address`, `pol`, `usdc_e`, `usdc_e_contract` -- `proxy_wallet` (only shown if proxy wallet is initialized): `address`, `pol`, `usdc_e`, `usdc_e_contract` +- `clob_version`: `"V1"`, `"V2"`, or `"unknown"` — which exchange the next trade will hit. `unknown` means the `/version` probe failed (likely transient; retry). +- `eoa_wallet`: `address`, `pol`, `usdc_e`, `usdc_e_contract`, `pusd`, `pusd_contract`, `pusd_note` +- `proxy_wallet` (only shown if proxy wallet is initialized): `address`, `pol`, `usdc_e`, `usdc_e_contract`, `pusd`, `pusd_contract` -`usdc_e_contract` is shown in truncated format (`0x2791...a84174`) — verify it matches before bridging funds. +`usdc_e_contract` and `pusd_contract` are shown in truncated format (`0x2791...a84174`) — verify they match before bridging funds. **Example:** ```bash @@ -606,23 +651,26 @@ polymarket-plugin buy --market-id --outcome --amount [--pr | `--outcome` | outcome label, case-insensitive (e.g. `yes`, `no`, `trump`, `republican`) | required | | `--amount` | USDC.e to spend, e.g. `100` = $100.00 | required | | `--price` | Limit price in (0, 1), representing **probability** (e.g. `0.65` = "65% chance this outcome occurs = $0.65 per share"). Omit for market order (FOK). | — | -| `--order-type` | `GTC` (resting limit) or `FOK` (fill-or-kill) | `GTC` | +| `--order-type` | `GTC` (resting limit), `FOK` (fill-or-kill), `GTD` (good-till-date), or `FAK` (fill-and-kill: fills as much as possible, cancels remainder) | `GTC` | | `--approve` | Force USDC.e approval before placing | false | | `--dry-run` | Simulate without submitting the order or triggering any on-chain approval. Prints a confirmation JSON with resolved parameters and exits. | false | | `--round-up` | If amount is too small for divisibility constraints, snap up to the minimum valid amount rather than erroring. Logs the rounded amount to stderr and includes `rounded_up: true` in output. | false | | `--post-only` | Maker-only: reject if the order would immediately cross the spread (become a taker). Requires `--order-type GTC`. Qualifies for Polymarket maker rebates (up to 50% of fees returned daily). Incompatible with `--order-type FOK`. | false | | `--expires` | Unix timestamp (seconds, UTC) at which the order auto-cancels. Minimum 90 seconds in the future (CLOB enforces a "now + 1 min 30 s" security threshold). Automatically sets `order_type` to `GTD` (Good Till Date) — do not also pass `--order-type GTC`. Example: `--expires $(date -d '+1 hour' +%s)` | — | | `--mode` | Override trading mode for this order only: `eoa` or `proxy`. Does not change the stored default. | — | +| `--token-id` | Skip market lookup — use a known token ID directly (from `get-series` or `get-market` output). `--market-id` is optional when this is provided. | — | | `--confirm` | Confirm a previously gated action (reserved for future use) | false | | `--strategy-id` | Strategy ID for attribution reporting. When provided and non-empty, the plugin calls `onchainos wallet report-plugin-info` after successful order placement with order metadata (`wallet`, `proxyAddress`, `order_id`, `tx_hashes`, `market_id`, `side`, `amount`, `symbol`, `price`, `strategy_id`, `plugin_name`). `tx_hashes` is an array of on-chain settlement tx hashes — non-empty for FOK/immediate-fill orders, empty for resting GTC limits that haven't crossed yet. Omit or pass `""` to skip reporting. Failures are logged to stderr and do not affect the order result. | — | **Auth required:** Yes — onchainos wallet; EIP-712 order signing via `onchainos sign-message --type eip712` -**On-chain ops (EOA mode only):** If USDC.e allowance is insufficient, runs `onchainos wallet contract-call` automatically. In POLY_PROXY mode, no on-chain approve is needed — the relayer handles settlement. +**On-chain ops (EOA mode only):** If collateral allowance is insufficient, runs `onchainos wallet contract-call` automatically. In POLY_PROXY mode, no on-chain approve is needed — the relayer handles settlement. -> ⚠️ **Approval notice**: Before each buy, the plugin checks the current USDC.e allowance and, if insufficient, submits an `approve(exchange, amount)` transaction for **exactly the order amount** — no more. This fires automatically with no additional onchainos confirmation gate. **Agent confirmation before calling `buy` is the sole safety gate for this approval.** +> ⚠️ **Approval notice**: Before each buy, the plugin checks the collateral allowance (pUSD for V2, USDC.e for V1) and, if insufficient, submits an `approve(exchange, amount)` transaction for **exactly the order amount** — no more. For V2 orders, if pUSD balance is insufficient but USDC.e is sufficient, the plugin first **auto-wraps** USDC.e → pUSD via the Collateral Onramp (approve + wrap, two txs). All of this fires automatically. **Agent confirmation before calling `buy` is the sole safety gate.** +> +> ⚠️ **V2 first-trade gas (EOA mode)**: The Polymarket V2 cutover (≈ 2026-04-28) requires new on-chain approvals to V2 exchange contracts. On the first V2 buy in **EOA mode**, the plugin will submit up to 3 approval txs (pUSD → CTF_EXCHANGE_V2, NEG_RISK_CTF_EXCHANGE_V2, NEG_RISK_ADAPTER) plus the wrap tx if USDC.e needs converting — each costs a small amount of POL gas. **Keep ~0.05 POL in your EOA wallet before the first V2 trade.** Subsequent trades are gas-free for approvals (idempotency check skips already-granted allowances). In POLY_PROXY mode, all V2 approvals are handled once during `setup-proxy`. -**Amount encoding:** USDC.e amounts are 6-decimal. Order amounts are computed using GCD-based integer arithmetic to guarantee `maker_raw / taker_raw == price` exactly — Polymarket requires maker (USDC) accurate to 2 decimal places and taker (shares) to 4 decimal places, and floating-point rounding of either independently breaks the price ratio and causes API rejection. +**Amount encoding:** Collateral amounts (USDC.e / pUSD) are 6-decimal. Order amounts are computed using GCD-based integer arithmetic to guarantee `maker_raw / taker_raw == price` exactly — Polymarket requires maker (USDC) accurate to 2 decimal places and taker (shares) to 4 decimal places, and floating-point rounding of either independently breaks the price ratio and causes API rejection. > ⚠️ **Minimum order size enforcement**: There are up to three independent minimums that can reject a small order. The plugin pre-validates the first two and surfaces clear errors with the required minimums — **never auto-escalate a user's order amount without explicit confirmation**. > @@ -666,12 +714,13 @@ polymarket-plugin sell --market-id --outcome --shares [- | `--outcome` | outcome label, case-insensitive (e.g. `yes`, `no`, `trump`, `republican`) | required | | `--shares` | Number of shares to sell, e.g. `250.5` | required | | `--price` | Limit price in (0, 1). Omit for market order (FOK) | — | -| `--order-type` | `GTC` (resting limit) or `FOK` (fill-or-kill) | `GTC` | +| `--order-type` | `GTC` (resting limit), `FOK` (fill-or-kill), `GTD` (good-till-date), or `FAK` (fill-and-kill) | `GTC` | | `--approve` | Force CTF token approval before placing | false | | `--post-only` | Maker-only: reject if the order would immediately cross the spread. Requires `--order-type GTC`. Qualifies for maker rebates. Incompatible with `--order-type FOK`. | false | | `--expires` | Unix timestamp (seconds, UTC) at which the order auto-cancels. Minimum 90 seconds in the future. Auto-sets `order_type` to `GTD`. | — | | `--dry-run` | Simulate without submitting the order or triggering any on-chain approval. Prints a confirmation JSON and exits. Use to verify parameters before a real sell. | false | | `--mode` | Override trading mode for this order only: `eoa` or `proxy`. Does not change the stored default. | — | +| `--token-id` | Skip market lookup — use a known token ID directly. `--market-id` is optional when this is provided. | — | | `--confirm` | Confirm a low-price market sell that was previously gated | false | | `--strategy-id` | Strategy ID for attribution reporting. When provided and non-empty, the plugin calls `onchainos wallet report-plugin-info` after successful order placement. Omit or pass `""` to skip reporting. Failures are logged to stderr and do not affect the order result. | — | @@ -772,9 +821,135 @@ polymarket cancel --all --- +### `orders` — List Open Orders + +``` +polymarket orders [--state ] [--v1] +``` + +**Flags:** +| Flag | Description | Default | +|------|-------------|---------| +| `--state` | Filter by order state: `OPEN`, `MATCHED`, `DELAYED`, `UNMATCHED` | `OPEN` | +| `--v1` | Also include V1-signed orders placed before the CLOB v2 upgrade (2026-04-21). Queries both the live order book and the pre-migration orders endpoint, deduplicates by order ID. | false | + +**Auth required:** Yes — onchainos wallet; HMAC L2 credentials + +**Output fields per order:** `order_id`, `version` (`V1` or `V2`), `status`, `outcome`, `side`, `price`, `original_size`, `size_matched`, `size_remaining`, `created_at` + +> **POLY_PROXY mode limitation**: `polymarket orders` queries the CLOB `/data/orders` endpoint which returns orders for the authenticated EOA address. In POLY_PROXY mode, orders are placed with the proxy wallet as maker — the proxy wallet does not have independent API credentials, so its orders are not returned. Verify proxy wallet orders at polymarket.com or via the public order book snapshot in `polymarket get-market`. + +**Migration note:** When the CLOB upgraded to v2, existing V1-signed orders were migrated to a separate backing store. Use `--v1` during the transition period (April–May 2026) to see orders placed before the upgrade. After the migration window closes, V1 orders will no longer be visible. + +**Heartbeat note:** GTC orders placed via REST are persistent — no heartbeat required. Heartbeats (`POST /v1/heartbeats`) only affect WebSocket-connected sessions. REST-placed orders remain in the book until filled, cancelled, or expired (GTD). + +**Example:** +```bash +polymarket orders # open V2 orders (current) +polymarket orders --state MATCHED # matched orders +polymarket orders --v1 # include pre-migration V1 orders +``` + +--- + +### `watch` — Watch Live Trade Activity + +Monitor a market's live trade feed, polling every `--interval` seconds. Prints new trades as JSON lines. Runs until Ctrl+C. + +``` +polymarket watch --market-id [--interval ] [--limit ] +``` + +**Flags:** +| Flag | Description | Default | +|------|-------------|---------| +| `--market-id` | Market condition_id (0x-prefixed) or slug | required | +| `--interval` | Poll interval in seconds (minimum 2) | `5` | +| `--limit` | Max events to fetch per poll | `10` | + +**Auth required:** No (public live-activity endpoint) + +**Output:** JSON lines, one per trade event: `timestamp`, `side`, `outcome`, `price`, `size`, `tx_hash` + +**Behavior:** Tracks a high-water timestamp — events already seen are not reprinted. New events are printed in chronological order (oldest first within each poll). + +**Example:** +```bash +polymarket watch --market-id will-btc-hit-100k-by-2025 +polymarket watch --market-id 0xabc... --interval 10 --limit 20 +``` + +--- + +### `rfq` — Request-for-Quote (Block Trade) + +Request a firm price quote from a Polymarket market maker for a large block trade. Designed for orders where standard CLOB liquidity may be insufficient. + +``` +polymarket rfq --market-id --outcome --amount [--confirm] [--dry-run] +``` + +**Flags:** +| Flag | Description | +|------|-------------| +| `--market-id` | Market condition_id (0x-prefixed) or slug | +| `--outcome` | Outcome to buy: `yes` or `no` | +| `--amount` | USDC.e amount (e.g. `5000` = $5,000) | +| `--confirm` | Accept the quoted price and execute the block trade | +| `--dry-run` | Preview without requesting a quote | + +**Auth required:** Yes (for `--confirm` step; quote request itself is authenticated) + +**Flow (two-step):** +1. Run **without** `--confirm` → sends `POST /rfq/request`, fetches the quote, displays `quote_id`, `price`, `amount_usdc`, `maker`, `expires_at`. No order is placed yet. +2. Run **with** `--confirm` using the same parameters → re-fetches the quote, signs a V2 EIP-712 order at the quoted price, submits `POST /rfq/confirm` to execute the trade. + +**When to use:** +- Order size > $5,000 where the CLOB book has thin depth +- User explicitly asks for a block trade or RFQ + +**Output (without `--confirm`):** `quote_id`, `status`, `price`, `amount_usdc`, `maker`, `expires_at` +**Output (with `--confirm`):** `quote_id`, `condition_id`, `outcome`, `price`, `usdc_amount`, `result` + +**Example:** +```bash +# Step 1: request a quote +polymarket rfq --market-id will-btc-hit-100k-by-2025 --outcome yes --amount 10000 + +# Step 2: accept the quote +polymarket rfq --market-id will-btc-hit-100k-by-2025 --outcome yes --amount 10000 --confirm +``` + +--- + +### `create-readonly-key` — Create a Read-Only API Key + +Create a read-only Polymarket CLOB API key for monitoring scripts and dashboards. + +``` +polymarket create-readonly-key +``` + +**Auth required:** Yes — onchainos wallet (L1 ClobAuth for key derivation) + +**Output fields:** `api_key`, `secret`, `passphrase`, `wallet`, `note` + +**Key properties:** +- Accepts all `GET` operations (read order book, positions, orders) +- CLOB server rejects any write operations (order placement, cancellation, etc.) +- **Not saved to `~/.config/polymarket/creds.json`** — printed once to stdout; store it securely +- Useful for monitoring pipelines, dashboards, or CI checks that need market data without trading access + +**Example:** +```bash +polymarket create-readonly-key +``` + +--- + ### `redeem` — Redeem Winning Outcome Tokens -After a market resolves, the winning side's tokens can be redeemed for USDC.e at a 1:1 rate. The binary queries the Data API to find which wallet (EOA or proxy) holds the winning tokens, pre-flights each call via `eth_call` to catch reverts before broadcast, and waits for on-chain confirmation (up to 45s per tx). Batch mode also checks EOA POL balance up front so insufficient gas fails fast instead of timing out mid-batch. +After a market resolves, the winning side's tokens can be redeemed for collateral (pUSD for V2 markets, USDC.e for V1) at a 1:1 rate. The binary automatically detects which wallet (EOA or proxy) holds the winning tokens by querying the Data API, detects the CLOB version to determine the correct collateral token, then calls the correct redemption path for each wallet. Each tx is confirmed on-chain before returning. No manual mode selection needed. ``` polymarket redeem --market-id @@ -841,7 +1016,7 @@ polymarket redeem --market-id will-trump-win-2024 ### `setup-proxy` — Create a Proxy Wallet (Gasless Trading) -Deploy a Polymarket proxy wallet and switch to POLY_PROXY mode. One-time POL gas cost; all subsequent trading is relayer-paid (no POL needed per order). +Deploy a Polymarket proxy wallet and switch to POLY_PROXY mode. One-time POL gas cost; all subsequent trading is relayer-paid (no POL needed per order). Also sets up the V2 pUSD approvals required post-2026-04-28 cutover (idempotent — safe to re-run). ``` polymarket setup-proxy [--dry-run] @@ -925,7 +1100,7 @@ polymarket-plugin deposit --amount 100 --dry-run # preview wit ### `withdraw` — Withdraw from Proxy Wallet -Transfer USDC.e from the proxy wallet back to the EOA wallet. Only applicable in POLY_PROXY mode. +Transfer collateral from the proxy wallet back to the EOA wallet. Only applicable in POLY_PROXY mode. Auto-detects whether the proxy holds pUSD (V2) or USDC.e (V1) and withdraws whichever covers the requested amount — pUSD takes priority. ``` polymarket withdraw --amount [--dry-run] @@ -934,7 +1109,7 @@ polymarket withdraw --amount [--dry-run] **Flags:** | Flag | Description | |------|-------------| -| `--amount` | USDC.e amount to withdraw, e.g. `50` = $50.00 | required | +| `--amount` | Amount to withdraw (pUSD or USDC.e), e.g. `50` = $50.00 | required | | `--dry-run` | Preview the withdrawal without submitting | **Auth required:** Yes — onchainos wallet (signs via proxy factory) @@ -1019,13 +1194,29 @@ export POLYMARKET_PASSPHRASE= ## Key Contracts (Polygon, chain 137) +### CLOB v2 Exchange Contracts (active — used for new orders) + | Contract | Address | Purpose | |----------|---------|---------| -| CTF Exchange | `0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E` | Main order matching + settlement | -| Neg Risk CTF Exchange | `0xC5d563A36AE78145C45a50134d48A1215220f80a` | Multi-outcome (neg_risk) markets | +| CTF Exchange v2 | `0xE111180000d2663C0091e4f400237545B87B996B` | Main order matching + settlement (CLOB v2) | +| Neg Risk CTF Exchange v2 | `0xe2222d279d744050d28e00520010520000310F59` | Multi-outcome (neg_risk) markets (CLOB v2) | + +### Legacy v1 Contracts (retained for existing approvals and pre-migration orders) + +| Contract | Address | Purpose | +|----------|---------|---------| +| CTF Exchange v1 | `0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E` | Main order matching + settlement (CLOB v1) | +| Neg Risk CTF Exchange v1 | `0xC5d563A36AE78145C45a50134d48A1215220f80a` | Multi-outcome (neg_risk) markets (CLOB v1) | | Neg Risk Adapter | `0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296` | Adapter for negative risk markets | + +### Shared Contracts + +| Contract | Address | Purpose | +|----------|---------|---------| | Conditional Tokens (CTF) | `0x4D97DCd97eC945f40cF65F87097ACe5EA0476045` | ERC-1155 YES/NO outcome tokens | -| USDC.e (collateral) | `0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174` | Bridged USDC collateral token | +| USDC.e (V1 collateral) | `0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174` | Bridged USDC — V1 collateral; V2 orders use pUSD | +| pUSD (V2 collateral) | `0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB` | Polymarket USD — collateral for V2 exchange contracts (~2026-04-28) | +| Collateral Onramp | `0x93070a847efEf7F70739046A929D47a521F5B8ee` | `wrap(USDC_E, to, amount)` wraps USDC.e → pUSD; auto-used by `buy` | | Polymarket Proxy Factory | `0xaB45c5A4B0c941a2F231C04C3f49182e1A254052` | Proxy wallet factory | | Gnosis Safe Factory | `0xaacfeea03eb1561c4e67d661e40682bd20e3541b` | Gnosis Safe factory | | UMA Adapter | `0x6A9D222616C90FcA5754cd1333cFD9b7fb6a4F74` | Oracle resolution adapter | @@ -1039,6 +1230,7 @@ There are four effective order types. The agent should match user intent to the | Order type | Flags | When to use | |------------|-------|-------------| | **FOK** (Fill-or-Kill) | *(omit `--price`)* | User wants to trade immediately at the best available price. Fills in full or not at all. | +| **FAK** (Fill-and-Kill) | `--order-type FAK` + `--price ` | Like FOK but partial fills are accepted — fills as much as possible at or better than `--price`, cancels the remainder. Useful when the user wants immediate execution without demanding a complete fill. | | **GTC** (Good Till Cancelled) | `--price ` | User sets a limit price and is happy to wait indefinitely for a fill. Default for limit orders. | | **POST_ONLY** (Maker-only GTC) | `--price --post-only` | User wants guaranteed maker status on a resting limit. Qualifies for Polymarket maker rebates (up to 50% of fees returned daily). | | **GTD** (Good Till Date) | `--price --expires ` | User wants a resting limit that auto-cancels at a specific time. | @@ -1113,8 +1305,14 @@ User wants to trade: | Cancel a specific order | `polymarket cancel --order-id <0x...>` | | Cancel all orders for market | `polymarket cancel --market ` | | Cancel all open orders | `polymarket cancel --all` | +| List open orders | `polymarket orders` | +| List all orders including pre-migration V1 | `polymarket orders --v1` | +| Watch live trade feed for a market | `polymarket watch --market-id ` | +| Request a block-trade quote (RFQ) | `polymarket rfq --market-id --outcome yes --amount 5000` | +| Accept an RFQ quote and execute the trade | `polymarket rfq --market-id --outcome yes --amount 5000 --confirm` | | Redeem all redeemable positions at once | `polymarket redeem --all` | | Redeem a specific market | `polymarket redeem --market-id ` | +| Create a read-only monitoring API key | `polymarket create-readonly-key` | --- @@ -1122,7 +1320,7 @@ User wants to trade: Some markets (multi-outcome events) use `neg_risk: true`. For these: - The **Neg Risk CTF Exchange** (`0xC5d563A36AE78145C45a50134d48A1215220f80a`) and **Neg Risk Adapter** (`0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296`) are both used -- On `buy`: the CLOB checks USDC.e allowance on both contracts — the plugin approves both when allowance is insufficient +- On `buy`: the CLOB checks collateral (pUSD for V2, USDC.e for V1) allowance on both contracts — the plugin approves both when allowance is insufficient - On `sell`: the CLOB checks `setApprovalForAll` on both contracts — the plugin approves both via `approve_ctf(neg_risk=true)` if either is missing - The plugin handles all of this automatically based on the `neg_risk` field returned by market lookup APIs - Token IDs and prices function identically from the user's perspective @@ -1139,10 +1337,10 @@ Some markets (multi-outcome events) use `neg_risk: true`. For these: | Economics / Culture | ~5% | | Geopolitics | 0% | -Fees are deducted by the exchange from the received amount. The `feeRateBps` field in signed orders is fetched per-market from Polymarket's `maker_base_fee` (e.g. 1000 bps = 10% for some sports markets). The plugin handles this automatically. +Fees are deducted by the exchange from the received amount. In CLOB v2, `feeRateBps` is no longer embedded in the signed order struct — fees are applied server-side by the exchange. The plugin fetches `maker_base_fee` from the market API for display purposes only. --- ## Changelog -See [CHANGELOG.md](CHANGELOG.md) for full version history. Current version: **0.4.11** (2026-04-22). +See [CHANGELOG.md](CHANGELOG.md) for full version history. Current version: **0.5.1** (2026-04-20). diff --git a/skills/polymarket-plugin/SUMMARY.md b/skills/polymarket-plugin/SUMMARY.md index aaa561885..c2f81e21d 100644 --- a/skills/polymarket-plugin/SUMMARY.md +++ b/skills/polymarket-plugin/SUMMARY.md @@ -1,19 +1,29 @@ +# polymarket-plugin + ## Overview -Polymarket is a prediction market protocol on Polygon where users trade YES/NO outcome shares of real-world events. This skill lets you browse markets (including 5-minute crypto up/down markets), buy and sell outcome shares, check positions, cancel orders, redeem winning tokens, and optionally set up a proxy wallet for gasless trading. +Polymarket is a decentralized prediction market on Polygon where users trade YES/NO outcome tokens on real-world events. + +Core operations: +- Buy and sell outcome tokens on any Polymarket market (sports, politics, crypto, daily series) +- Trade recurring crypto price series (BTC/ETH/SOL/XRP — 5m, 15m, 4h intervals) +- Manage orders: place resting GTC/GTD/POST_ONLY limit orders or market (FOK) orders +- Check positions and redeem winning tokens after market resolution +- Deploy a proxy wallet for gasless relayer-paid trading + +Tags: `defi` `polygon` `prediction-market` `clob` `trading` ## Prerequisites -- onchainos CLI installed and logged in with a Polygon address (chain 137) -- USDC.e on Polygon for trading (≥ $5 recommended for a first test trade) -- Recommended: run `setup-proxy` once for gasless trading (Polymarket's relayer pays gas). Fallback EOA mode needs POL on Polygon for every buy/sell approval -- Accessible region — Polymarket blocks the US and OFAC-sanctioned jurisdictions + +- No IP restrictions (geo-blocked regions can still set up a proxy wallet; trading commands will surface a warning) +- Supported chain: Polygon (137) +- Supported collateral: USDC.e (V1, pre-2026-04-28) / pUSD (V2, post-2026-04-28) — auto-wrapped on buy +- onchainos CLI installed and authenticated with a Polygon wallet +- At least $1 USDC.e on Polygon for a test trade; ~0.01 POL for gas (EOA mode) or ~$0.01 POL one-time for proxy setup ## Quick Start -1. Check your current state and get a guided next step: `polymarket-plugin quickstart` -2. If you see `status: restricted` — switch to an accessible region and re-run `polymarket-plugin quickstart` -3. If you see `status: no_funds` / `low_balance` — send ≥ $5 USDC.e to your EOA wallet on Polygon (chain 137); view the address with `polymarket-plugin balance` -4. If you see `status: needs_setup` — create the Polymarket proxy wallet (one-time POL gas) for gasless trading: `polymarket-plugin setup-proxy` -5. If you see `status: needs_deposit` — deposit EOA USDC.e into your proxy wallet: `polymarket-plugin deposit --amount 50` -6. If you see `status: proxy_ready` — browse markets and place your first gasless order: `polymarket-plugin list-markets` → `polymarket-plugin buy --market-id --outcome yes --amount 5` -7. If you see `status: active` — review open positions and P&L: `polymarket-plugin get-positions` -8. Exit a position, or redeem winnings when the market resolves: `polymarket-plugin sell --market-id --outcome yes --amount 5` / `polymarket-plugin redeem --market-id ` + +1. **Check balances**: run `polymarket balance` — confirms your wallet address, USDC.e / pUSD / POL balances, and the current CLOB version (V1 or V2) +2. **Find a market**: run `polymarket get-series --series btc-5m` for the current BTC 5-minute slot, or `polymarket list-markets --limit 5` for active markets +3. **Place a trade**: run `polymarket buy --market-id btc-5m --outcome Up --amount 5 --dry-run` to preview, then remove `--dry-run` to execute +4. **Check your positions**: run `polymarket positions` to see open holdings and unrealised P&L diff --git a/skills/polymarket-plugin/plugin.yaml b/skills/polymarket-plugin/plugin.yaml index 9ae00c545..bf0b1778b 100644 --- a/skills/polymarket-plugin/plugin.yaml +++ b/skills/polymarket-plugin/plugin.yaml @@ -1,6 +1,6 @@ schema_version: 1 name: polymarket-plugin -version: "0.4.11" +version: "0.5.1" description: "Trade prediction markets on Polymarket — buy and sell YES/NO outcome tokens on Polygon" author: name: skylavis-sky diff --git a/skills/polymarket-plugin/src/api.rs b/skills/polymarket-plugin/src/api.rs index 328e89d59..26f06c927 100644 --- a/skills/polymarket-plugin/src/api.rs +++ b/skills/polymarket-plugin/src/api.rs @@ -267,6 +267,88 @@ pub struct OrderResponse { pub tx_hashes: Vec, } +/// V2 order body — new field layout, no taker/nonce/feeRateBps; adds timestamp/metadata/builder. +/// `expiration` is in the outer `OrderRequestV2` wrapper (not in the signed struct). +#[derive(Debug, Serialize, Deserialize)] +pub struct OrderBodyV2 { + pub salt: u64, + pub maker: String, + pub signer: String, + #[serde(rename = "tokenId")] + pub token_id: String, + #[serde(rename = "makerAmount")] + pub maker_amount: String, + #[serde(rename = "takerAmount")] + pub taker_amount: String, + pub side: String, + #[serde(rename = "signatureType")] + pub signature_type: u8, + /// Millisecond Unix timestamp — part of the EIP-712 signed struct. + pub timestamp: String, + /// bytes32 optional metadata ("0x000...000" for standard orders). + pub metadata: String, + /// bytes32 builder code ("0x000...000" for non-builders). + pub builder: String, + pub signature: String, +} + +/// V2 outer order request — wraps `OrderBodyV2` and moves `expiration` out of the signed struct. +#[derive(Debug, Serialize, Deserialize)] +pub struct OrderRequestV2 { + pub order: OrderBodyV2, + pub owner: String, + #[serde(rename = "orderType")] + pub order_type: String, + #[serde(rename = "postOnly", default)] + pub post_only: bool, + /// GTD expiration timestamp (seconds). Present only for GTD orders; empty string otherwise. + #[serde(rename = "expiration", skip_serializing_if = "String::is_empty")] + pub expiration: String, +} + +/// Open order returned by GET /orders. +#[derive(Debug, Clone, Deserialize)] +pub struct OpenOrder { + #[serde(rename = "id")] + pub order_id: String, + pub status: Option, + #[serde(rename = "market")] + pub condition_id: Option, + #[serde(rename = "asset_id")] + pub token_id: Option, + pub side: Option, + #[serde(rename = "original_size")] + pub original_size: Option, + #[serde(rename = "size_matched")] + pub size_matched: Option, + pub price: Option, + #[serde(rename = "created_at")] + pub created_at: Option, + // V1-only fields — presence signals V1 order + pub nonce: Option, + #[serde(rename = "feeRateBps")] + pub fee_rate_bps: Option, + // V2-only fields — presence signals V2 order + pub timestamp: Option, + pub metadata: Option, +} + +impl OpenOrder { + /// Detect order version from field presence. + /// V1 orders have `nonce`/`feeRateBps`; V2 orders have `timestamp`/`metadata`. + pub fn version(&self) -> crate::config::OrderVersion { + if self.nonce.is_some() || self.fee_rate_bps.is_some() { + crate::config::OrderVersion::V1 + } else { + crate::config::OrderVersion::V2 + } + } + + pub fn is_v1(&self) -> bool { + self.version() == crate::config::OrderVersion::V1 + } +} + #[derive(Debug, Deserialize)] pub struct BalanceAllowance { pub asset_address: Option, @@ -306,7 +388,7 @@ impl BalanceAllowance { /// false positives (some endpoints return 403 for auth reasons on unrestricted IPs). /// Fails open on network errors or unexpected responses. pub async fn check_clob_access(client: &Client) -> Option { - let url = format!("{}/order", Urls::clob()); + let url = format!("{}/order", Urls::CLOB); let resp = match client .post(&url) .header(reqwest::header::CONTENT_TYPE, "application/json") @@ -346,7 +428,7 @@ pub async fn check_clob_access(client: &Client) -> Option { } pub async fn get_clob_market(client: &Client, condition_id: &str) -> Result { - let url = format!("{}/markets/{}", Urls::clob(), condition_id); + let url = format!("{}/markets/{}", Urls::CLOB, condition_id); let resp = client.get(&url).send().await?; if resp.status() == reqwest::StatusCode::NOT_FOUND { anyhow::bail!("Market not found: {}", condition_id); @@ -357,7 +439,7 @@ pub async fn get_clob_market(client: &Client, condition_id: &str) -> Result Result { - let url = format!("{}/book?token_id={}", Urls::clob(), token_id); + let url = format!("{}/book?token_id={}", Urls::CLOB, token_id); client.get(&url) .send() .await? @@ -369,7 +451,7 @@ pub async fn get_orderbook(client: &Client, token_id: &str) -> Result /// Fetch the market's maker_base_fee (in basis points) from CLOB market data. /// Returns 0 if not found. pub async fn get_market_fee(client: &Client, condition_id: &str) -> Result { - let url = format!("{}/markets/{}", Urls::clob(), condition_id); + let url = format!("{}/markets/{}", Urls::CLOB, condition_id); let v: Value = client.get(&url).send().await?.json().await?; let fee = v["maker_base_fee"] .as_u64() @@ -379,7 +461,7 @@ pub async fn get_market_fee(client: &Client, condition_id: &str) -> Result } pub async fn get_tick_size(client: &Client, token_id: &str) -> Result { - let url = format!("{}/tick-size?token_id={}", Urls::clob(), token_id); + let url = format!("{}/tick-size?token_id={}", Urls::CLOB, token_id); let v: Value = client.get(&url).send().await?.json().await?; // minimum_tick_size may be a JSON number or a JSON string let tick = v["minimum_tick_size"] @@ -390,13 +472,13 @@ pub async fn get_tick_size(client: &Client, token_id: &str) -> Result { } pub async fn get_price(client: &Client, token_id: &str, side: &str) -> Result { - let url = format!("{}/price?token_id={}&side={}", Urls::clob(), token_id, side); + let url = format!("{}/price?token_id={}&side={}", Urls::CLOB, token_id, side); let v: Value = client.get(&url).send().await?.json().await?; Ok(v["price"].as_str().unwrap_or("0").to_string()) } pub async fn get_server_time(client: &Client) -> Result { - let url = format!("{}/time", Urls::clob()); + let url = format!("{}/time", Urls::CLOB); let v: Value = client.get(&url).send().await?.json().await?; Ok(v["time"].as_u64().unwrap_or(0)) } @@ -427,7 +509,7 @@ pub async fn get_balance_allowance( "", )?; - let url = format!("{}{}", Urls::clob(), full_path); + let url = format!("{}{}", Urls::CLOB, full_path); let mut req = client.get(&url); for (k, v) in &headers { req = req.header(k.as_str(), v.as_str()); @@ -439,11 +521,11 @@ pub async fn get_balance_allowance( .context("parsing balance-allowance response") } -pub async fn post_order( +pub async fn post_order( client: &Client, address: &str, creds: &Credentials, - order_req: &OrderRequest, + order_req: &T, ) -> Result { let body = serde_json::to_string(order_req)?; let path = "/order"; @@ -458,7 +540,7 @@ pub async fn post_order( &body, )?; - let url = format!("{}{}", Urls::clob(), path); + let url = format!("{}{}", Urls::CLOB, path); let mut req = client .post(&url) .header("Content-Type", "application/json") @@ -484,6 +566,268 @@ pub async fn post_order( serde_json::from_str(&raw).with_context(|| format!("parsing post-order response: {}", raw)) } +/// Query the CLOB server for the active order version (1 or 2). +/// +/// Detect the active CLOB version (V1 or V2) by querying GET /version. +/// +/// Returns `Err` on network/parse failure rather than silently defaulting to V1. +/// Reason: during the V1→V2 cutover (~2026-04-28 11:00 UTC) a transient probe +/// failure could route a V2-era order through the V1 path, which the upgraded +/// server will reject with a confusing 404/405. Bailing here gives the user a +/// clear retry message instead. +/// +/// Pre-v2 servers (before 2026-04-21) returned 404 on `/version`; that path is +/// no longer reachable, so a missing endpoint now legitimately indicates a +/// problem worth surfacing. +pub async fn get_clob_version(client: &Client) -> Result { + let url = format!("{}/version", Urls::CLOB); + let resp = client + .get(&url) + .send() + .await + .with_context(|| "failed to detect CLOB version (network error). \ + Retry; if persistent, the server may be mid-cutover.")?; + let v: serde_json::Value = resp + .json() + .await + .with_context(|| "failed to parse /version response from CLOB. \ + Retry; if persistent, the server may be mid-cutover.")?; + Ok(v["version"].as_u64().unwrap_or(1) as u8) +} + +/// Fetch open orders for the authenticated user. +/// +/// `state` is one of "OPEN", "MATCHED", "DELAYED", "UNMATCHED" (default: "OPEN"). +/// Returns typed `OpenOrder` values with `version()` for V1/V2 detection. +pub async fn get_open_orders( + client: &Client, + address: &str, + creds: &Credentials, + state: &str, +) -> Result> { + // CLOB v2 moved the orders listing endpoint from GET /orders?state=X to GET /data/orders. + // The HMAC signature must be computed over the BASE PATH only (without query string). + // The endpoint uses cursor-based pagination with a "next_cursor" field. + let sign_path = "/data/orders"; + let request_path = format!("/data/orders?status={}", state); + + let headers = l2_headers( + address, + &creds.api_key, + &creds.secret, + &creds.passphrase, + "GET", + sign_path, // sign base path only + "", + )?; + + let url = format!("{}{}", Urls::CLOB, request_path); + let mut req = client.get(&url); + for (k, v) in &headers { + req = req.header(k.as_str(), v.as_str()); + } + let raw = req.send().await?.text().await?; + if raw.trim().is_empty() { + return Ok(vec![]); + } + let parsed: serde_json::Value = serde_json::from_str(&raw) + .with_context(|| format!("parsing open-orders response: {}", raw))?; + // v2 returns {"data": [...], "next_cursor": "...", "limit": 500, "count": N} + let arr = if let Some(a) = parsed.get("data").and_then(|d| d.as_array()) { + a.clone() + } else if let Some(a) = parsed.as_array() { + a.clone() + } else { + vec![] + }; + let orders: Vec = arr + .into_iter() + .filter_map(|v| serde_json::from_value(v).ok()) + .collect(); + Ok(orders) +} + +/// Fetch V1-era orders from the pre-migration endpoint introduced in CLOB v2. +/// +/// During the migration window, orders placed on the V1 exchange may not appear in +/// `GET /orders` — this endpoint is the authoritative source for those legacy records. +/// Returns the raw JSON array; callers merge with live-orders results. +pub async fn get_pre_migration_orders( + client: &Client, + address: &str, + creds: &Credentials, +) -> Result> { + // Also uses base-path-only signing (same convention as /data/orders in v2). + let path = "/data/pre-migration-orders"; + let headers = l2_headers( + address, + &creds.api_key, + &creds.secret, + &creds.passphrase, + "GET", + path, + "", + )?; + + let url = format!("{}{}", Urls::CLOB, path); + let mut req = client.get(&url); + for (k, v) in &headers { + req = req.header(k.as_str(), v.as_str()); + } + let raw = req.send().await?.text().await?; + if raw.trim().is_empty() { + return Ok(vec![]); + } + let parsed: serde_json::Value = serde_json::from_str(&raw) + .with_context(|| format!("parsing pre-migration-orders response: {}", raw))?; + let arr = if let Some(a) = parsed.as_array() { + a.clone() + } else if let Some(a) = parsed.get("data").and_then(|d| d.as_array()) { + a.clone() + } else { + vec![] + }; + let orders: Vec = arr + .into_iter() + .filter_map(|v| serde_json::from_value(v).ok()) + .collect(); + Ok(orders) +} + +/// A single trade event returned by `GET /markets/live-activity/{condition_id}`. +#[derive(Debug, Clone, Deserialize)] +pub struct LiveTradeEvent { + #[serde(rename = "tradeId", default)] + pub trade_id: Option, + pub price: Option, + pub size: Option, + pub side: Option, + pub outcome: Option, + #[serde(rename = "timestamp")] + pub timestamp: Option, + #[serde(rename = "transactionHash", default)] + pub tx_hash: Option, +} + +/// Fetch recent trade events for a market (public, no auth required). +/// Returns events sorted newest-first. Used by the `watch` command. +pub async fn get_market_live_activity( + client: &Client, + condition_id: &str, + limit: u32, +) -> Result> { + let url = format!( + "{}/markets/live-activity/{}?limit={}", + Urls::CLOB, condition_id, limit + ); + let raw = client.get(&url).send().await?.text().await?; + let parsed: serde_json::Value = serde_json::from_str(&raw) + .with_context(|| format!("parsing live-activity response: {}", raw))?; + let arr = if let Some(a) = parsed.as_array() { + a.clone() + } else if let Some(a) = parsed.get("data").and_then(|d| d.as_array()) { + a.clone() + } else { + vec![] + }; + Ok(arr.into_iter().filter_map(|v| serde_json::from_value(v).ok()).collect()) +} + +/// RFQ quote returned by `GET /rfq/quote/{quote_id}`. +#[derive(Debug, Clone, Deserialize)] +pub struct RfqQuote { + #[serde(rename = "quoteId")] + pub quote_id: String, + pub price: Option, + /// USDC amount the quote covers. + pub amount: Option, + /// Unix timestamp (seconds) when this quote expires. + #[serde(rename = "expiresAt")] + pub expires_at: Option, + /// Market maker address. + pub maker: Option, + pub status: Option, +} + +/// Request a RFQ quote for a block trade (no auth required for the request itself). +/// Returns a quote_id to poll with `get_rfq_quote`. +pub async fn post_rfq_request( + client: &Client, + condition_id: &str, + token_id: &str, + side: &str, + amount_usdc: f64, +) -> Result { + let body = serde_json::to_string(&serde_json::json!({ + "market": condition_id, + "asset_id": token_id, + "side": side.to_uppercase(), + "amount": format!("{:.6}", amount_usdc), + }))?; + let url = format!("{}/rfq/request", Urls::CLOB); + let raw = client + .post(&url) + .header("Content-Type", "application/json") + .body(body) + .send() + .await? + .text() + .await?; + let v: serde_json::Value = serde_json::from_str(&raw) + .with_context(|| format!("parsing rfq-request response: {}", raw))?; + if let Some(err) = v.get("error").and_then(|e| e.as_str()) { + anyhow::bail!("RFQ request failed: {}", err); + } + v.get("quoteId") + .or_else(|| v.get("quote_id")) + .and_then(|id| id.as_str()) + .map(|s| s.to_string()) + .ok_or_else(|| anyhow::anyhow!("No quoteId in RFQ response: {}", raw)) +} + +/// Poll for an RFQ quote by quote_id (no auth required). +pub async fn get_rfq_quote(client: &Client, quote_id: &str) -> Result { + let url = format!("{}/rfq/quote/{}", Urls::CLOB, quote_id); + let raw = client.get(&url).send().await?.text().await?; + serde_json::from_str(&raw) + .with_context(|| format!("parsing rfq-quote response: {}", raw)) +} + +/// Confirm an RFQ quote — executes the block trade. +/// The signature is an EIP-712 signed order matching the quoted price/amount. +pub async fn post_rfq_confirm( + client: &Client, + address: &str, + creds: &Credentials, + quote_id: &str, + order_body: &OrderBodyV2, +) -> Result { + let body = serde_json::to_string(&serde_json::json!({ + "quoteId": quote_id, + "order": order_body, + "owner": address, + }))?; + let path = "/rfq/confirm"; + let headers = l2_headers( + address, + &creds.api_key, + &creds.secret, + &creds.passphrase, + "POST", + path, + &body, + )?; + let url = format!("{}{}", Urls::CLOB, path); + let mut req = client + .post(&url) + .header("Content-Type", "application/json") + .body(body); + for (k, v) in &headers { + req = req.header(k.as_str(), v.as_str()); + } + req.send().await?.json().await.context("parsing rfq-confirm response") +} + pub async fn cancel_order( client: &Client, address: &str, @@ -504,7 +848,7 @@ pub async fn cancel_order( &body, )?; - let url = format!("{}{}", Urls::clob(), path); + let url = format!("{}{}", Urls::CLOB, path); let mut req = client .delete(&url) .header("Content-Type", "application/json") @@ -535,7 +879,7 @@ pub async fn cancel_all_orders( "", )?; - let url = format!("{}{}", Urls::clob(), path); + let url = format!("{}{}", Urls::CLOB, path); let mut req = client.delete(&url); for (k, v) in &headers { req = req.header(k.as_str(), v.as_str()); @@ -572,7 +916,7 @@ pub async fn cancel_market_orders( &body, )?; - let url = format!("{}{}", Urls::clob(), path); + let url = format!("{}{}", Urls::CLOB, path); let mut req = client .delete(&url) .header("Content-Type", "application/json") @@ -601,7 +945,7 @@ pub async fn list_gamma_markets( let fetch_limit = if keyword.is_some() { (limit * 5).min(100) } else { limit }; let url = format!( "{}/markets?active=true&closed=false&limit={}&offset={}&order=volume24hrClob&ascending=false", - Urls::gamma(), fetch_limit, offset + Urls::GAMMA, fetch_limit, offset ); let all: Vec = client.get(&url) @@ -639,7 +983,7 @@ async fn fetch_gamma_events( ) -> Result> { let url = format!( "{}/events?active=true&closed=false&limit={}&order=volume24hr&ascending=false", - Urls::gamma(), fetch_limit + Urls::GAMMA, fetch_limit ); let all: Vec = client @@ -706,7 +1050,7 @@ pub async fn list_category_events( } pub async fn get_gamma_market_by_slug(client: &Client, slug: &str) -> Result { - let url = format!("{}/markets/slug/{}", Urls::gamma(), slug); + let url = format!("{}/markets/slug/{}", Urls::GAMMA, slug); let v: Value = client.get(&url).send().await?.json().await?; // Response can be an array or single object @@ -740,7 +1084,7 @@ pub async fn get_gamma_market_by_slug(client: &Client, slug: &str) -> Result` on the CLOB API. /// Returns None if the user has not completed polymarket.com onboarding. pub async fn get_proxy_wallet(client: &Client, signer_addr: &str) -> Result> { - let url = format!("{}/profile?user={}", Urls::clob(), signer_addr); + let url = format!("{}/profile?user={}", Urls::CLOB, signer_addr); let v: Value = client.get(&url).send().await?.json().await .context("parsing profile response")?; let proxy = v["proxyWallet"] @@ -755,7 +1099,7 @@ pub async fn get_proxy_wallet(client: &Client, signer_addr: &str) -> Result Result> { let url = format!( "{}/positions?user={}&sizeThreshold=0.01&limit=100&offset=0", - Urls::data(), user_address + Urls::DATA, user_address ); client.get(&url) .send() @@ -978,7 +1322,7 @@ pub struct FiveMinMarket { /// Fetch a single 5-minute market by its slug from the Gamma API. /// Returns `None` if the market does not exist yet. pub async fn get_5m_market(client: &Client, slug: &str) -> Result> { - let url = format!("{}/markets?slug={}", Urls::gamma(), slug); + let url = format!("{}/markets?slug={}", Urls::GAMMA, slug); let resp: serde_json::Value = client .get(&url) .header("User-Agent", "polymarket-cli/1.0") diff --git a/skills/polymarket-plugin/src/auth.rs b/skills/polymarket-plugin/src/auth.rs index 105dbb5bf..fdfcf94fd 100644 --- a/skills/polymarket-plugin/src/auth.rs +++ b/skills/polymarket-plugin/src/auth.rs @@ -197,6 +197,44 @@ pub async fn derive_api_key(client: &Client, wallet_addr: &str, nonce: u64) -> R Ok(creds) } +/// Readonly API key — returned by `/auth/readonly-api-key` (CLOB v2). +/// Carries the same api_key/secret/passphrase triplet but with write operations rejected +/// by the CLOB server. Useful for monitoring scripts, dashboards, and CI pipelines. +#[derive(Debug, Deserialize)] +pub struct ReadonlyApiKeyResponse { + #[serde(rename = "apiKey")] + pub api_key: String, + pub secret: String, + pub passphrase: String, +} + +/// Create a read-only API key via L1 auth (CLOB v2 endpoint). +/// +/// The returned key can be used for `GET /orders`, `GET /trades`, balance lookups, etc. +/// but the CLOB server will reject any write operations (order placement, cancellation). +/// Not persisted to `creds.json` — caller is responsible for storing/displaying it. +pub async fn create_readonly_api_key( + client: &Client, + wallet_addr: &str, +) -> Result { + let nonce: u64 = 0; + let (sig, timestamp, nonce_used) = sign_clob_auth_onchainos(wallet_addr, nonce).await?; + let headers = l1_headers(wallet_addr, &sig, timestamp, nonce_used); + + let mut req = client.post(format!("{}/auth/readonly-api-key", Urls::CLOB)); + for (k, v) in &headers { + req = req.header(k.as_str(), v.as_str()); + } + let resp: serde_json::Value = req.send().await?.json().await?; + + if let Some(err) = resp.get("error").and_then(|e| e.as_str()) { + anyhow::bail!("Polymarket /auth/readonly-api-key failed: {}\nResponse: {}", err, resp); + } + + serde_json::from_value(resp.clone()) + .with_context(|| format!("parsing readonly-api-key response: {}", resp)) +} + /// Load stored credentials or auto-derive them using the onchainos wallet. /// Re-derives if the cached credentials were for a different wallet address. /// @@ -212,14 +250,26 @@ pub async fn ensure_credentials(client: &Client, wallet_addr: &str) -> Result String { @@ -18,12 +20,24 @@ pub async fn run() -> Result<()> { .and_then(|c| c.proxy_wallet); let usdc_e_contract = crate::config::Contracts::USDC_E; + let pusd_contract = crate::config::Contracts::PUSD; let usdc_e_short = short_addr(usdc_e_contract); + let pusd_short = short_addr(pusd_contract); - // Fetch EOA balances (POL + USDC.e) in parallel - let (pol_result, usdc_result) = tokio::join!( + // Probe CLOB version (best-effort — balance is a status command and should not fail + // when the CLOB is briefly unreachable). Reported as "V1", "V2", or "unknown". + let client = Client::new(); + let clob_version = match get_clob_version(&client).await { + Ok(2) => "V2", + Ok(_) => "V1", + Err(_) => "unknown", + }; + + // Fetch EOA balances (POL + USDC.e + pUSD) in parallel + let (pol_result, usdc_result, pusd_result) = tokio::join!( get_pol_balance(&eoa), get_usdc_balance(&eoa), + get_pusd_balance(&eoa), ); let eoa_pol = match pol_result { @@ -34,21 +48,30 @@ pub async fn run() -> Result<()> { Ok(v) => format!("${:.2}", v), Err(e) => format!("error: {}", e), }; + let eoa_pusd = match pusd_result { + Ok(v) => format!("${:.2}", v), + Err(e) => format!("error: {}", e), + }; let mut data = serde_json::json!({ + "clob_version": clob_version, "eoa_wallet": { "address": eoa, "pol": eoa_pol, "usdc_e": eoa_usdc, "usdc_e_contract": usdc_e_short, + "pusd": eoa_pusd, + "pusd_contract": pusd_short, + "pusd_note": "pUSD is required for V2 exchange orders (~2026-04-28). USDC.e is auto-wrapped on buy." } }); // If proxy wallet is initialized, fetch its balances too if let Some(ref proxy_addr) = proxy { - let (proxy_pol_result, proxy_usdc_result) = tokio::join!( + let (proxy_pol_result, proxy_usdc_result, proxy_pusd_result) = tokio::join!( get_pol_balance(proxy_addr), get_usdc_balance(proxy_addr), + get_pusd_balance(proxy_addr), ); let proxy_pol = match proxy_pol_result { Ok(v) => format!("{:.4} POL", v), @@ -58,11 +81,17 @@ pub async fn run() -> Result<()> { Ok(v) => format!("${:.2}", v), Err(e) => format!("error: {}", e), }; + let proxy_pusd = match proxy_pusd_result { + Ok(v) => format!("${:.2}", v), + Err(e) => format!("error: {}", e), + }; data["proxy_wallet"] = serde_json::json!({ "address": proxy_addr, "pol": proxy_pol, "usdc_e": proxy_usdc, "usdc_e_contract": usdc_e_short, + "pusd": proxy_pusd, + "pusd_contract": pusd_short, }); } diff --git a/skills/polymarket-plugin/src/commands/buy.rs b/skills/polymarket-plugin/src/commands/buy.rs index fd3ee8a92..6e4b56f41 100644 --- a/skills/polymarket-plugin/src/commands/buy.rs +++ b/skills/polymarket-plugin/src/commands/buy.rs @@ -2,26 +2,17 @@ use anyhow::{bail, Context, Result}; use reqwest::Client; use crate::api::{ - compute_buy_worst_price, get_clob_market, get_market_fee, get_orderbook, - post_order, round_price, - OrderBody, OrderRequest, + compute_buy_worst_price, get_balance_allowance, get_clob_market, get_clob_version, + get_market_fee, get_orderbook, post_order, round_price, OrderBody, OrderBodyV2, + OrderRequest, OrderRequestV2, }; use crate::auth::ensure_credentials; -use crate::onchainos::{approve_usdc, get_usdc_allowance, get_usdc_balance, get_wallet_address}; +use crate::config::OrderVersion; +use crate::onchainos::{approve_timeout_secs, get_pusd_balance, get_usdc_balance, get_wallet_address, + proxy_pusd_approve, proxy_wrap_usdc_to_pusd, wait_for_tx_receipt, wrap_usdc_to_pusd}; use crate::series; -use crate::signing::{sign_order_via_onchainos, OrderParams}; - -/// Approval confirmation timeout in seconds. -/// -/// Polygon block time is ~2s; under typical conditions approvals mine in <30s. -/// We default to 90s to absorb network congestion and gas price spikes. -/// Override with `POLYMARKET_APPROVE_TIMEOUT_SECS` env var for testing or custom networks. -fn approve_timeout_secs() -> u64 { - std::env::var("POLYMARKET_APPROVE_TIMEOUT_SECS") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(90) -} +use crate::signing::{sign_order_v2_via_onchainos, sign_order_via_onchainos, OrderParams, + OrderParamsV2, BYTES32_ZERO}; /// Run the buy command. /// @@ -43,7 +34,6 @@ pub async fn run( expires: Option, mode_override: Option<&str>, token_id_fast: Option<&str>, - strategy_id: Option<&str>, ) -> Result<()> { // Parse USDC amount early so we can enforce the minimum order size // check even on dry-run (the agent needs to know before placing). @@ -277,6 +267,14 @@ pub async fn run( // ── Dry-run exit — full projected order fields ──────────────────────────── if dry_run { + use crate::config::Contracts; + // Fetch CLOB version to show which exchange contract + collateral would be used. + let dry_clob_version_raw = get_clob_version(&client).await?; + let dry_clob_version = if dry_clob_version_raw == 2 { OrderVersion::V2 } else { OrderVersion::V1 }; + let dry_exchange_addr = Contracts::exchange(dry_clob_version, neg_risk); + let dry_collateral = if dry_clob_version == OrderVersion::V2 { Contracts::PUSD } else { Contracts::USDC_E }; + let dry_version_label = if dry_clob_version == OrderVersion::V2 { "V2" } else { "V1" }; + println!( "{}", serde_json::json!({ @@ -296,6 +294,10 @@ pub async fn run( "fee_rate_bps": fee_rate_bps, "post_only": post_only, "expires": if expiration > 0 { serde_json::Value::Number(expiration.into()) } else { serde_json::Value::Null }, + "clob_version": dry_version_label, + "exchange_address": dry_exchange_addr, + "collateral_token": dry_collateral, + "neg_risk": neg_risk, "note": "dry-run: order not submitted" } }) @@ -339,65 +341,166 @@ pub async fn run( TradingMode::Eoa => signer_addr.as_str(), }; - // Fetch on-chain USDC.e balance and on-chain allowance(s) in parallel. - // Both use direct eth_call (authoritative) — not the CLOB API, which can return - // stale values or incorrect MAX_UINT and cause unnecessary re-approvals. - let (onchain_balance_result, allowance_raw) = if neg_risk { - let (bal, a_exchange, a_adapter) = tokio::join!( - get_usdc_balance(balance_addr), - get_usdc_allowance(balance_addr, Contracts::NEG_RISK_CTF_EXCHANGE), - get_usdc_allowance(balance_addr, Contracts::NEG_RISK_ADAPTER), - ); - // Both spenders must have sufficient allowance for a neg_risk order. - let ae = a_exchange.unwrap_or(0).min(u64::MAX as u128) as u64; - let aa = a_adapter.unwrap_or(0).min(u64::MAX as u128) as u64; - (bal, ae.min(aa)) - } else { - let (bal, a) = tokio::join!( - get_usdc_balance(balance_addr), - get_usdc_allowance(balance_addr, Contracts::CTF_EXCHANGE), - ); - let a_val = a.unwrap_or(0).min(u64::MAX as u128) as u64; - (bal, a_val) - }; - - // Pre-flight: bail if on-chain USDC.e balance is insufficient. - match onchain_balance_result { - Ok(bal_usdc) => { - let bal_raw = (bal_usdc * 1_000_000.0).floor() as u64; - if bal_raw < usdc_needed_raw { - let tip = match &effective_mode { - TradingMode::PolyProxy => format!( - "Run `polymarket deposit --amount {:.2}` to top up the proxy wallet.", - actual_usdc - ), - TradingMode::Eoa => { - // Check if proxy wallet has enough USDC and hint mode switch. - let proxy_hint = crate::config::load_credentials() - .ok() - .flatten() - .and_then(|c| c.proxy_wallet) - .map(|proxy| format!( - " Or switch to proxy mode (`polymarket switch-mode --mode proxy`) \ - if your USDC.e is already in the proxy wallet ({}).", - proxy - )) - .unwrap_or_default(); - format!( - "Top up USDC.e on Polygon before placing this order.{}", - proxy_hint - ) + // Fetch CLOB version, on-chain balances (USDC.e + pUSD), and CLOB allowance in parallel. + // Version determines which collateral token and exchange contract to use: + // V1 → USDC.e + old exchange contracts + // V2 → pUSD + new exchange contracts (V2 cutover ~2026-04-28) + let (clob_version_raw, usdc_e_balance_result, pusd_balance_result, allowance_info) = tokio::join!( + get_clob_version(&client), + get_usdc_balance(balance_addr), + get_pusd_balance(balance_addr), + get_balance_allowance(&client, balance_addr, &creds, "COLLATERAL", None), + ); + let clob_version_raw = clob_version_raw?; + let clob_version = if clob_version_raw == 2 { OrderVersion::V2 } else { OrderVersion::V1 }; + let allowance_info = allowance_info?; + + // Pre-flight balance check — collateral token depends on CLOB version. + // V2 uses pUSD. If pUSD balance is insufficient but USDC.e balance is sufficient, + // we automatically wrap USDC.e → pUSD via the Collateral Onramp before placing the order. + match clob_version { + OrderVersion::V2 => { + let pusd_bal = pusd_balance_result.unwrap_or(0.0); + let pusd_raw = (pusd_bal * 1_000_000.0).floor() as u64; + + // V2 server deducts order_amount + fee from pUSD at submission time. + // Compute required pUSD including fee (ceiling division to avoid rounding short). + let fee_buffer = ((usdc_needed_raw as u128 * fee_rate_bps as u128) + 9_999) / 10_000; + let total_needed = usdc_needed_raw + fee_buffer as u64; + + // Pre-flight POL gas check (POLY_PROXY only): + // V2 trading on a proxy may need up to two EOA-paid txs — USDC.e→pUSD wrap + // and a one-time pUSD approve to V2 contracts. Surface the requirement up + // front so the user isn't half-way through the flow when they run out of gas. + // Skipped if no on-chain action is needed (pUSD already covers + already approved). + if effective_mode == TradingMode::PolyProxy && !dry_run { + let will_wrap = pusd_raw < total_needed; + let exchange_addr = Contracts::exchange(clob_version, neg_risk); + let pusd_allowance = crate::onchainos::get_pusd_allowance( + maker_addr.as_str(), exchange_addr, + ).await.unwrap_or(0); + let will_approve = pusd_allowance < (usdc_needed_raw as u128); + if will_wrap || will_approve { + let pol = crate::onchainos::get_pol_balance(&signer_addr) + .await.unwrap_or(0.0); + const MIN_POL: f64 = 0.05; + if pol < MIN_POL { + let mut actions = Vec::new(); + if will_wrap { actions.push("USDC.e→pUSD wrap"); } + if will_approve { actions.push("V2 pUSD approve"); } + bail!( + "Insufficient POL gas on EOA wallet ({}) for V2 trading: have {:.4} POL, \ + need ≥ {:.2} POL to cover the {} transaction(s). \ + Send POL to your EOA on Polygon and retry.", + signer_addr, pol, MIN_POL, actions.join(" + ") + ); } - }; - bail!( - "Insufficient USDC.e balance: have ${:.2}, need ${:.2}. {}", - bal_usdc, actual_usdc, tip - ); + } + } + + if pusd_raw < total_needed { + // pUSD insufficient — check USDC.e for auto-wrap opportunity. + let usdc_e_bal = usdc_e_balance_result.unwrap_or(0.0); + let usdc_e_raw = (usdc_e_bal * 1_000_000.0).floor() as u64; + // Wrap only the shortfall: existing pUSD partially covers order + fee. + let shortfall = total_needed - pusd_raw; + if usdc_e_raw >= shortfall { + // Auto-wrap USDC.e → pUSD (shortfall only) before placing the order. + eprintln!( + "[polymarket] V2 requires pUSD collateral. pUSD balance ${:.6} < ${:.6} needed \ + (order ${:.6} + fee ${:.6}). Auto-wrapping ${:.6} USDC.e → pUSD...", + pusd_bal, + total_needed as f64 / 1_000_000.0, + actual_usdc, + fee_buffer as f64 / 1_000_000.0, + shortfall as f64 / 1_000_000.0, + ); + let wrap_tx = match &effective_mode { + TradingMode::Eoa => { + wrap_usdc_to_pusd(balance_addr, shortfall as u128).await? + } + TradingMode::PolyProxy => { + proxy_wrap_usdc_to_pusd(balance_addr, shortfall as u128).await? + } + }; + eprintln!("[polymarket] Wrap tx: {}. Waiting for confirmation...", wrap_tx); + wait_for_tx_receipt(&wrap_tx, approve_timeout_secs()).await?; + eprintln!("[polymarket] Wrapped. Proceeding with order."); + } else { + // Neither pUSD nor USDC.e is sufficient. + let total_needed_f64 = total_needed as f64 / 1_000_000.0; + let tip = match &effective_mode { + TradingMode::PolyProxy => format!( + "Run `polymarket deposit --amount {:.2}` to top up the proxy wallet, \ + then the deposit will be auto-wrapped to pUSD on the next buy.", + total_needed_f64 + ), + TradingMode::Eoa => { + let proxy_hint = crate::config::load_credentials() + .ok() + .flatten() + .and_then(|c| c.proxy_wallet) + .map(|proxy| format!( + " Or switch to proxy mode (`polymarket switch-mode --mode proxy`) \ + if your USDC.e is in the proxy wallet ({}).", + proxy + )) + .unwrap_or_default(); + format!( + "Top up USDC.e on Polygon (it will be auto-wrapped to pUSD).{}", + proxy_hint + ) + } + }; + bail!( + "Insufficient balance for V2 order: have ${:.6} pUSD + ${:.6} USDC.e, \ + need ${:.6} (order ${:.6} + fee ${:.6}). {}", + pusd_bal, usdc_e_bal, + total_needed_f64, actual_usdc, + fee_buffer as f64 / 1_000_000.0, + tip + ); + } } } - Err(e) => { - // On-chain read failed — log and fall through (CLOB will catch it at settlement). - eprintln!("[polymarket] Warning: could not verify on-chain USDC.e balance ({}); proceeding.", e); + OrderVersion::V1 => { + // V1 uses USDC.e. + match usdc_e_balance_result { + Ok(bal_usdc) => { + let bal_raw = (bal_usdc * 1_000_000.0).floor() as u64; + if bal_raw < usdc_needed_raw { + let tip = match &effective_mode { + TradingMode::PolyProxy => format!( + "Run `polymarket deposit --amount {:.2}` to top up the proxy wallet.", + actual_usdc + ), + TradingMode::Eoa => { + let proxy_hint = crate::config::load_credentials() + .ok() + .flatten() + .and_then(|c| c.proxy_wallet) + .map(|proxy| format!( + " Or switch to proxy mode (`polymarket switch-mode --mode proxy`) \ + if your USDC.e is already in the proxy wallet ({}).", + proxy + )) + .unwrap_or_default(); + format!( + "Top up USDC.e on Polygon before placing this order.{}", + proxy_hint + ) + } + }; + bail!( + "Insufficient USDC.e balance: have ${:.2}, need ${:.2}. {}", + bal_usdc, actual_usdc, tip + ); + } + } + Err(e) => { + eprintln!("[polymarket] Warning: could not verify on-chain USDC.e balance ({}); proceeding.", e); + } + } } } @@ -422,67 +525,163 @@ pub async fn run( // EOA mode: submit on-chain approve if allowance is insufficient. // POLY_PROXY mode: approvals are set once during `setup-proxy` — no per-trade approve needed. + // + // V2 migration: V2 uses a new exchange contract address. If the user has only approved V1, + // the V2 allowance will be 0 and a fresh approval to the V2 contract is triggered automatically. if effective_mode == TradingMode::Eoa { + let exchange_addr = Contracts::exchange(clob_version, neg_risk); + let allowance_raw = if neg_risk { + let a_exchange = allowance_info.allowance_for(exchange_addr); + let a_adapter = allowance_info.allowance_for(Contracts::NEG_RISK_ADAPTER); + a_exchange.min(a_adapter) + } else { + allowance_info.allowance_for(exchange_addr) + }; + if allowance_raw < usdc_needed_raw || auto_approve { - let exchange_label = if neg_risk { "Neg Risk CTF Exchange" } else { "CTF Exchange" }; - eprintln!("[polymarket] Approving unlimited USDC.e for {}...", exchange_label); - let tx_hash = approve_usdc(neg_risk).await?; + let (version_label, collateral_label) = if clob_version == OrderVersion::V2 { + (" V2", "pUSD") + } else { + ("", "USDC.e") + }; + let exchange_label = if neg_risk { + format!("Neg Risk CTF Exchange{}", version_label) + } else { + format!("CTF Exchange{}", version_label) + }; + eprintln!("[polymarket] Approving {:.6} {} for {}...", actual_usdc, collateral_label, exchange_label); + let tx_hash = approve_usdc_versioned(neg_risk, clob_version, usdc_needed_raw).await?; eprintln!("[polymarket] Approval tx: {}", tx_hash); eprintln!("[polymarket] Waiting for approval to confirm on-chain..."); crate::onchainos::wait_for_tx_receipt(&tx_hash, approve_timeout_secs()).await?; eprintln!("[polymarket] Approval confirmed."); } + } else if effective_mode == TradingMode::PolyProxy && clob_version == OrderVersion::V2 { + // POLY_PROXY + V2: query pUSD allowance on-chain (not via CLOB API). + // The CLOB /balance-allowance endpoint hard-codes signature_type=0, which scopes + // the lookup to the EOA address — but the V2 pUSD approvals live on the proxy + // wallet (set by setup-proxy or a prior lazy approve). Querying on-chain matches + // the source of truth and prevents redundant approve txs on every buy. + // Idempotent — unlimited approval (maxUint) means it only fires once. + let exchange_addr = Contracts::exchange(clob_version, neg_risk); + let needed_u128 = usdc_needed_raw as u128; + let allowance_raw = if neg_risk { + let a_exchange = crate::onchainos::get_pusd_allowance(maker_addr.as_str(), exchange_addr) + .await.unwrap_or(0); + let a_adapter = crate::onchainos::get_pusd_allowance(maker_addr.as_str(), Contracts::NEG_RISK_ADAPTER) + .await.unwrap_or(0); + a_exchange.min(a_adapter) + } else { + crate::onchainos::get_pusd_allowance(maker_addr.as_str(), exchange_addr) + .await.unwrap_or(0) + }; + if allowance_raw < needed_u128 { + let version_label = if neg_risk { "Neg Risk CTF Exchange V2" } else { "CTF Exchange V2" }; + eprintln!("[polymarket] Proxy pUSD allowance insufficient for {}. Approving via proxy...", version_label); + let tx = proxy_pusd_approve(exchange_addr).await?; + eprintln!("[polymarket] Proxy pUSD approval tx: {}. Waiting for confirmation...", tx); + wait_for_tx_receipt(&tx, approve_timeout_secs()).await?; + if neg_risk { + eprintln!("[polymarket] Approving pUSD for Neg Risk Adapter via proxy..."); + let tx2 = proxy_pusd_approve(Contracts::NEG_RISK_ADAPTER).await?; + wait_for_tx_receipt(&tx2, approve_timeout_secs()).await?; + } + eprintln!("[polymarket] Proxy pUSD approval confirmed."); + } } - // POLY_PROXY mode: approvals are set once during `setup-proxy` and verified on-chain there. - // The CLOB server checks allowance independently at order submission — no pre-flight needed. + // POLY_PROXY V1: approvals (USDC.e) are set once at setup-proxy time — no per-trade check needed. let salt = rand_salt(); - let params = OrderParams { - salt, - maker: maker_addr.clone(), - signer: signer_addr.clone(), - taker: "0x0000000000000000000000000000000000000000".to_string(), - token_id: token_id.clone(), - maker_amount: maker_amount_raw as u64, - taker_amount: taker_amount_raw as u64, - expiration, - nonce: 0, - fee_rate_bps, - side: 0, // BUY - signature_type: sig_type, - }; - - let signature = sign_order_via_onchainos(¶ms, neg_risk).await?; - - let order_body = OrderBody { - salt, - maker: maker_addr.clone(), - signer: signer_addr.clone(), - taker: "0x0000000000000000000000000000000000000000".to_string(), - token_id: token_id.clone(), - maker_amount: maker_amount_raw.to_string(), - taker_amount: taker_amount_raw.to_string(), - expiration: expiration.to_string(), - nonce: "0".to_string(), - fee_rate_bps: fee_rate_bps.to_string(), - side: "BUY".to_string(), - signature_type: sig_type, - signature, - }; - - let order_req = OrderRequest { - order: order_body, - owner: creds.api_key.clone(), - order_type: effective_order_type.to_uppercase(), - post_only, + // Sign and submit the order using the correct version's struct and exchange contract. + let resp = match clob_version { + OrderVersion::V2 => { + let timestamp_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + let params = OrderParamsV2 { + salt, + maker: maker_addr.clone(), + signer: signer_addr.clone(), + token_id: token_id.clone(), + maker_amount: maker_amount_raw as u64, + taker_amount: taker_amount_raw as u64, + side: 0, // BUY + signature_type: sig_type, + timestamp_ms, + metadata: BYTES32_ZERO.to_string(), + builder: BYTES32_ZERO.to_string(), + }; + let signature = sign_order_v2_via_onchainos(¶ms, neg_risk).await?; + let order_body = OrderBodyV2 { + salt, + maker: maker_addr.clone(), + signer: signer_addr.clone(), + token_id: token_id.clone(), + maker_amount: maker_amount_raw.to_string(), + taker_amount: taker_amount_raw.to_string(), + side: "BUY".to_string(), + signature_type: sig_type, + timestamp: timestamp_ms.to_string(), + metadata: BYTES32_ZERO.to_string(), + builder: BYTES32_ZERO.to_string(), + signature, + }; + // In V2, expiration moves to the outer wrapper (not part of the signed struct). + let order_req = OrderRequestV2 { + order: order_body, + owner: creds.api_key.clone(), + order_type: effective_order_type.to_uppercase(), + post_only, + expiration: if expiration > 0 { expiration.to_string() } else { String::new() }, + }; + post_order(&client, &signer_addr, &creds, &order_req).await? + } + OrderVersion::V1 => { + let params = OrderParams { + salt, + maker: maker_addr.clone(), + signer: signer_addr.clone(), + taker: "0x0000000000000000000000000000000000000000".to_string(), + token_id: token_id.clone(), + maker_amount: maker_amount_raw as u64, + taker_amount: taker_amount_raw as u64, + expiration, + nonce: 0, + fee_rate_bps, + side: 0, // BUY + signature_type: sig_type, + }; + let signature = sign_order_via_onchainos(¶ms, neg_risk).await?; + let order_body = OrderBody { + salt, + maker: maker_addr.clone(), + signer: signer_addr.clone(), + taker: "0x0000000000000000000000000000000000000000".to_string(), + token_id: token_id.clone(), + maker_amount: maker_amount_raw.to_string(), + taker_amount: taker_amount_raw.to_string(), + expiration: expiration.to_string(), + nonce: "0".to_string(), + fee_rate_bps: fee_rate_bps.to_string(), + side: "BUY".to_string(), + signature_type: sig_type, + signature, + }; + let order_req = OrderRequest { + order: order_body, + owner: creds.api_key.clone(), + order_type: effective_order_type.to_uppercase(), + post_only, + }; + // The order owner for L2 auth must always be the EOA (API key holder), + // regardless of trading mode. In POLY_PROXY mode the maker field in the + // order struct is the proxy, but the HTTP owner must match the API key. + post_order(&client, &signer_addr, &creds, &order_req).await? + } }; - // The order owner for L2 auth must always be the EOA (API key holder), - // regardless of trading mode. In POLY_PROXY mode the maker field in the - // order struct is the proxy, but the HTTP owner must match the API key. - let resp = post_order(&client, &signer_addr, &creds, &order_req).await?; - if resp.success != Some(true) { let msg = resp.error_msg.as_deref().unwrap_or("unknown error"); if msg.to_uppercase().contains("INVALID_ORDER_MIN_SIZE") { @@ -500,33 +699,15 @@ pub async fn run( msg ); } - bail!("Order placement failed: {}", msg); - } - - let actual_shares = taker_amount_raw as f64 / 1_000_000.0; - if let Some(sid) = strategy_id.filter(|s| !s.is_empty()) { - let ts_now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0); - let report_payload = serde_json::json!({ - "wallet": signer_addr, - "proxyAddress": creds.proxy_wallet.as_deref().unwrap_or(""), - "order_id": resp.order_id.clone().unwrap_or_default(), - "tx_hashes": resp.tx_hashes, - "market_id": condition_id, - "asset_id": token_id, - "side": "BUY", - "amount": format!("{}", actual_shares), - "symbol": "USDC.e", - "price": format!("{}", limit_price), - "timestamp": ts_now, - "strategy_id": sid, - "plugin_name": "polymarket-plugin", - }); - if let Err(e) = crate::onchainos::report_plugin_info(&report_payload).await { - eprintln!("[polymarket] Warning: report-plugin-info failed: {}", e); + if msg_upper.contains("ORDER_VERSION_MISMATCH") || msg_upper.contains("VERSION_MISMATCH") { + bail!( + "Order rejected: CLOB version mismatch (server reported: {}). \ + The server may have just switched to a different order version. \ + Run the command again to re-detect the current version.", + msg + ); } + bail!("Order placement failed: {}", msg); } let result = serde_json::json!({ @@ -542,7 +723,7 @@ pub async fn run( "limit_price": limit_price, "usdc_amount": actual_usdc, "usdc_requested": usdc_amount, - "shares": actual_shares, + "shares": taker_amount_raw as f64 / 1_000_000.0, "rounded_up": round_up && actual_usdc > usdc_amount + 1e-6, "post_only": post_only, "expires": if expiration > 0 { serde_json::Value::Number(expiration.into()) } else { serde_json::Value::Null }, @@ -655,41 +836,35 @@ fn rand_salt() -> u64 { u64::from_le_bytes(bytes) & 0x001F_FFFF_FFFF_FFFF } -#[cfg(test)] -mod tests { - use super::*; - - // Serialize env-var tests so they don't contaminate each other when run in parallel. - static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(()); - - // ── Bug #6: Approval timeout env var ──────────────────────────────────── - - /// Default timeout is 90 seconds when env var is not set. - /// Rationale: Polygon block time ~2s; 30s was too short for congested periods. - #[test] - fn test_approve_timeout_default() { - let _lock = ENV_MUTEX.lock().unwrap(); - std::env::remove_var("POLYMARKET_APPROVE_TIMEOUT_SECS"); - assert_eq!(approve_timeout_secs(), 90, "default timeout should be 90s"); - } - - /// Env var override is respected and parsed correctly. - #[test] - fn test_approve_timeout_env_override() { - let _lock = ENV_MUTEX.lock().unwrap(); - std::env::set_var("POLYMARKET_APPROVE_TIMEOUT_SECS", "120"); - let t = approve_timeout_secs(); - std::env::remove_var("POLYMARKET_APPROVE_TIMEOUT_SECS"); - assert_eq!(t, 120, "env var should override default"); +/// Approve the collateral token for the correct exchange contract based on CLOB version. +/// +/// V1 → approves USDC.e to CTF_EXCHANGE (or NEG_RISK_CTF_EXCHANGE for neg-risk). +/// V2 → approves pUSD to CTF_EXCHANGE_V2 (or NEG_RISK_CTF_EXCHANGE_V2 for neg-risk). +/// +/// pUSD (Polymarket USD) replaced USDC.e as collateral for V2 exchange contracts +/// from ~2026-04-28. This function routes to the correct token automatically so users +/// get a V2 pUSD approval on their first V2 trade without any manual intervention. +async fn approve_usdc_versioned( + neg_risk: bool, + version: OrderVersion, + _amount_raw: u64, +) -> anyhow::Result { + use crate::config::Contracts; + use crate::onchainos::usdc_approve; + + let collateral_token = match version { + OrderVersion::V2 => Contracts::PUSD, + OrderVersion::V1 => Contracts::USDC_E, + }; + let exchange_addr = Contracts::exchange(version, neg_risk); + + // Always approve u128::MAX — setting an exact amount causes unnecessary re-approval + // on every trade when a MAX_UINT allowance was already granted previously. + if neg_risk { + let adapter_addr = Contracts::NEG_RISK_ADAPTER; + usdc_approve(collateral_token, exchange_addr, u128::MAX).await?; + return usdc_approve(collateral_token, adapter_addr, u128::MAX).await; } - /// Invalid env var value falls back to default (no panic). - #[test] - fn test_approve_timeout_invalid_env() { - let _lock = ENV_MUTEX.lock().unwrap(); - std::env::set_var("POLYMARKET_APPROVE_TIMEOUT_SECS", "not_a_number"); - let t = approve_timeout_secs(); - std::env::remove_var("POLYMARKET_APPROVE_TIMEOUT_SECS"); - assert_eq!(t, 90, "invalid env var value should fall back to default 90s"); - } + usdc_approve(collateral_token, exchange_addr, u128::MAX).await } diff --git a/skills/polymarket-plugin/src/commands/create_readonly_key.rs b/skills/polymarket-plugin/src/commands/create_readonly_key.rs new file mode 100644 index 000000000..371536e84 --- /dev/null +++ b/skills/polymarket-plugin/src/commands/create_readonly_key.rs @@ -0,0 +1,38 @@ +use anyhow::Result; +use reqwest::Client; + +use crate::auth::create_readonly_api_key; +use crate::onchainos::get_wallet_address; + +/// Create a read-only Polymarket API key (CLOB v2 feature). +/// +/// The key has the same api_key/secret/passphrase triplet as a standard key but the +/// CLOB server rejects any write operations (order placement, cancellation). Suitable +/// for monitoring scripts, dashboards, and CI pipelines that need read access without +/// exposing trading credentials. +/// +/// The key is NOT saved to `~/.config/polymarket/creds.json` — it is printed to stdout +/// once. Store it securely if you intend to reuse it. +pub async fn run() -> Result<()> { + let client = Client::new(); + let wallet_addr = get_wallet_address().await?; + + eprintln!("[polymarket] Creating read-only API key for {}...", wallet_addr); + let key = create_readonly_api_key(&client, &wallet_addr).await?; + + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "ok": true, + "data": { + "api_key": key.api_key, + "secret": key.secret, + "passphrase": key.passphrase, + "wallet": wallet_addr, + "note": "Read-only key: GET operations only. Write operations will be rejected by the CLOB server. \ + Store securely — this key is not saved to creds.json.", + } + }))? + ); + Ok(()) +} diff --git a/skills/polymarket-plugin/src/commands/history.rs b/skills/polymarket-plugin/src/commands/history.rs new file mode 100644 index 000000000..21fa95b01 --- /dev/null +++ b/skills/polymarket-plugin/src/commands/history.rs @@ -0,0 +1,105 @@ +/// `polymarket history` — show trade activity for the active wallet, enriched with +/// win/loss resolution. +/// +/// Trade activity: Polymarket Data API `/activity` (buys, sells, redeems). +/// Resolution: CLOB API `/markets/{condition_id}` in parallel for each unique market. + +use anyhow::{Context, Result}; +use reqwest::Client; +use std::collections::HashSet; + +pub async fn run(limit: u32, address: Option<&str>) -> Result<()> { + let client = Client::new(); + + // Resolve wallet: proxy wallet in POLY_PROXY mode, else EOA. + let eoa = crate::onchainos::get_wallet_address().await?; + let creds = crate::config::load_credentials().ok().flatten(); + let proxy_wallet = creds.as_ref().and_then(|c| { + if c.mode == crate::config::TradingMode::PolyProxy { + c.proxy_wallet.clone() + } else { + None + } + }); + + let wallet_addr = if let Some(a) = address { + a.to_string() + } else if let Some(ref p) = proxy_wallet { + p.clone() + } else { + eoa.clone() + }; + + // ── Fetch trade activity ──────────────────────────────────────────────── + + let url = format!( + "{}/activity?user={}&limit={}&offset=0", + crate::config::Urls::DATA, + wallet_addr, + limit, + ); + + let resp: serde_json::Value = client + .get(&url) + .send() + .await + .context("fetching activity from Data API")? + .json() + .await + .context("parsing activity response")?; + + let mut items: Vec = if resp.is_array() { + resp.as_array().cloned().unwrap_or_default() + } else { + resp["data"].as_array().cloned().unwrap_or_default() + }; + + // ── Batch-resolve market outcomes ─────────────────────────────────────── + + let condition_ids: Vec = items + .iter() + .filter_map(|item| item["conditionId"].as_str().map(String::from)) + .collect::>() + .into_iter() + .collect(); + + let resolutions = if !condition_ids.is_empty() { + crate::api::get_market_resolutions(&client, &condition_ids).await + } else { + std::collections::HashMap::new() + }; + + // Enrich each activity item with a `result` field + for item in items.iter_mut() { + let cid = item["conditionId"].as_str().unwrap_or(""); + let outcome_idx = item["outcomeIndex"].as_u64().map(|i| i as u32); + + let result_str = match resolutions.get(cid) { + Some(Some(winner_idx)) => match outcome_idx { + Some(bet) if bet == *winner_idx => "WON", + Some(_) => "LOST", + None => "RESOLVED", + }, + Some(None) => "ACTIVE", // not yet resolved + None => "ACTIVE", // not in resolutions map (lookup failed) + }; + + item["result"] = serde_json::Value::String(result_str.to_string()); + } + + // ── Output ────────────────────────────────────────────────────────────── + + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "ok": true, + "data": { + "wallet": wallet_addr, + "trade_count": items.len(), + "trades": items, + } + }))? + ); + + Ok(()) +} diff --git a/skills/polymarket-plugin/src/commands/mod.rs b/skills/polymarket-plugin/src/commands/mod.rs index ffd494a39..3175316eb 100644 --- a/skills/polymarket-plugin/src/commands/mod.rs +++ b/skills/polymarket-plugin/src/commands/mod.rs @@ -1,101 +1,20 @@ pub mod balance; pub mod buy; -pub mod withdraw; +pub mod quickstart; pub mod cancel; pub mod check_access; +pub mod create_readonly_key; pub mod deposit; pub mod get_market; pub mod get_positions; pub mod get_series; pub mod list_5m; pub mod list_markets; -pub mod quickstart; +pub mod orders; pub mod redeem; +pub mod rfq; pub mod sell; pub mod setup_proxy; pub mod switch_mode; - -/// Build a structured error JSON string for stdout output (per GEN-001). -/// -/// Use when a command hits a business-logic failure (insufficient gas, tx never -/// broadcast, revert, missing positions, etc.) — the caller should `println!` this -/// and `return Ok(())` so external agents can parse the error instead of seeing -/// only exit code 1 + stderr. -/// -/// `extra_hint`, when present, is appended to `suggestion` — useful for attaching -/// context the classifier cannot derive from the error message alone (e.g. a proxy -/// wallet address discovered on-chain for this specific EOA). -pub fn error_response( - err: &anyhow::Error, - context: Option<&str>, - extra_hint: Option<&str>, -) -> String { - let msg = format!("{:#}", err); - let (error_code, mut suggestion) = classify_error(&msg, context); - if let Some(h) = extra_hint { - let h = h.trim(); - if !h.is_empty() { - suggestion.push(' '); - suggestion.push_str(h); - } - } - serde_json::to_string_pretty(&serde_json::json!({ - "ok": false, - "error": msg, - "error_code": error_code, - "suggestion": suggestion, - })) - .unwrap_or_else(|_| format!(r#"{{"ok":false,"error":{:?}}}"#, msg)) -} - -fn classify_error(msg: &str, _ctx: Option<&str>) -> (&'static str, String) { - let m = msg.to_lowercase(); - - if m.contains("insufficient pol") { - return ( - "INSUFFICIENT_POL_GAS", - "Top up POL on your EOA wallet (Polygon). Redeem costs ~0.015 POL per market; \ - batch redeem N markets needs ~N × 0.015 POL.".into(), - ); - } - if m.contains("no redeemable positions") { - return ( - "NO_REDEEMABLE_POSITIONS", - "Data API shows no redeemable positions on either the EOA or the proxy wallet. \ - If you traded in POLY_PROXY mode, run `setup-proxy` first so the plugin knows \ - your proxy address; otherwise verify your trading mode with `balance`.".into(), - ); - } - if m.contains("simulation reverted") || m.contains("eth_call reverted") { - return ( - "SIMULATION_REVERTED", - "eth_call simulation reverted before broadcast. The most common cause for redeem \ - is that the EOA does not hold the winning outcome tokens (they live in the proxy \ - wallet). Run `setup-proxy` or check your trading mode.".into(), - ); - } - if m.contains("not observed on-chain") || m.contains("not confirmed within") { - return ( - "TX_NOT_CONFIRMED", - "Tx hash was returned but never appeared on-chain. Usually means onchainos signed \ - a tx that would revert and dropped it silently. Check Polygonscan for the hash — \ - if missing, re-run with --dry-run to inspect, or verify your trading mode.".into(), - ); - } - if m.contains("mined but reverted") || m.contains("status 0x0") { - return ( - "TX_REVERTED", - "Tx mined but reverted on-chain. For redeem this usually means the calling wallet \ - does not hold the winning outcome tokens.".into(), - ); - } - if m.contains("neg_risk") { - return ( - "NEG_RISK_NOT_SUPPORTED", - "Multi-outcome (neg_risk) markets cannot be redeemed via this plugin — \ - use the Polymarket web UI.".into(), - ); - } - - ("REDEEM_FAILED", "See error field for details.".into()) -} +pub mod watch; +pub mod withdraw; diff --git a/skills/polymarket-plugin/src/commands/orders.rs b/skills/polymarket-plugin/src/commands/orders.rs new file mode 100644 index 000000000..d532e3fae --- /dev/null +++ b/skills/polymarket-plugin/src/commands/orders.rs @@ -0,0 +1,111 @@ +use anyhow::Result; +use reqwest::Client; + +use crate::api::{get_open_orders, get_pre_migration_orders, OpenOrder}; +use crate::auth::ensure_credentials; +use crate::config::OrderVersion; +use crate::onchainos::get_wallet_address; + +/// List open orders for the authenticated user. +/// +/// `state`: "OPEN", "MATCHED", "DELAYED", or "UNMATCHED". +/// `only_v1`: when true, show only V1-signed orders placed before the CLOB v2 upgrade. +/// Also queries `/data/pre-migration-orders` and merges results so no V1 +/// order is missed during the migration window. +pub async fn run(state: &str, only_v1: bool, limit: Option) -> Result<()> { + let client = Client::new(); + let signer_addr = get_wallet_address().await?; + let creds = ensure_credentials(&client, &signer_addr).await?; + + // For --v1, also query the pre-migration endpoint and deduplicate by order_id. + // This ensures orders placed on the V1 exchange before the cutover are not missed + // if Polymarket routes them to a separate backing store during the transition window. + let orders: Vec = if only_v1 { + let (live, pre_migration) = tokio::join!( + get_open_orders(&client, &signer_addr, &creds, state), + get_pre_migration_orders(&client, &signer_addr, &creds), + ); + let mut merged = live.unwrap_or_default(); + let existing_ids: std::collections::HashSet = + merged.iter().map(|o| o.order_id.clone()).collect(); + for o in pre_migration.unwrap_or_default() { + if !existing_ids.contains(&o.order_id) { + merged.push(o); + } + } + merged + } else { + get_open_orders(&client, &signer_addr, &creds, state).await? + }; + + let filtered: Vec = orders + .iter() + .filter(|o| !only_v1 || o.is_v1()) + .take(limit.unwrap_or(usize::MAX)) + .map(|o| { + let version_str = match o.version() { + OrderVersion::V1 => "v1", + OrderVersion::V2 => "v2", + }; + serde_json::json!({ + "order_id": o.order_id, + "order_version": version_str, + "status": o.status, + "condition_id": o.condition_id, + "token_id": o.token_id, + "side": o.side, + "price": o.price, + "original_size": o.original_size, + "size_matched": o.size_matched, + "created_at": o.created_at, + }) + }) + .collect(); + + let v1_count = orders.iter().filter(|o| o.is_v1()).count(); + let v2_count = orders.iter().filter(|o| !o.is_v1()).count(); + + // In POLY_PROXY mode, the CLOB indexes orders by maker (proxy wallet), and the + // authenticated /orders endpoint returns orders for the signing address (EOA). + // Proxy wallet orders may not appear here — verify via the public order book or web UI. + use crate::config::TradingMode; + let poly_proxy_note = match &creds.mode { + TradingMode::PolyProxy => Some(format!( + "POLY_PROXY mode: orders are placed with the proxy wallet ({}) as maker. \ + The CLOB /orders endpoint returns orders for the EOA signer — proxy wallet orders \ + may not appear here. Check https://polymarket.com for the full order list.", + creds.proxy_wallet.as_deref().unwrap_or("unknown") + )), + _ => None, + }; + + let mut out = serde_json::json!({ + "ok": true, + "data": { + "orders": filtered, + "total": filtered.len(), + "state": state, + } + }); + + if let Some(note) = poly_proxy_note { + out["data"]["poly_proxy_note"] = serde_json::json!(note); + } + + // Surface a migration notice if V1 orders are present — these remain fillable + // during the V1 deprecation window but will stop matching after V1 sunset. + if v1_count > 0 && !only_v1 { + out["data"]["migration_notice"] = serde_json::json!(format!( + "{} V1 order(s) detected (placed before the CLOB v2 upgrade on 2026-04-21). \ + These remain fillable during the V1 migration window. \ + Use `polymarket orders --v1` to filter them. \ + Run `polymarket cancel --all` if you want to clear them before V1 sunset.", + v1_count + )); + out["data"]["v1_count"] = serde_json::json!(v1_count); + out["data"]["v2_count"] = serde_json::json!(v2_count); + } + + println!("{}", serde_json::to_string_pretty(&out)?); + Ok(()) +} diff --git a/skills/polymarket-plugin/src/commands/redeem.rs b/skills/polymarket-plugin/src/commands/redeem.rs index 4e1fcd2aa..c9c324934 100644 --- a/skills/polymarket-plugin/src/commands/redeem.rs +++ b/skills/polymarket-plugin/src/commands/redeem.rs @@ -1,70 +1,14 @@ -use anyhow::{anyhow, Context as _, Result}; +use anyhow::{bail, Context, Result}; use reqwest::Client; -use crate::api::{get_clob_market, get_gamma_market_by_slug, get_positions}; -use crate::config::load_credentials; +use crate::api::{get_clob_market, get_clob_version, get_gamma_market_by_slug, get_positions}; +use crate::config::{Contracts, load_credentials}; use crate::onchainos::{ ctf_redeem_positions, ctf_redeem_via_proxy, decimal_str_to_hex64, get_ctf_balance, - get_existing_proxy, get_pol_balance, get_wallet_address, negrisk_redeem_positions, - wait_for_tx_receipt_labeled, + get_wallet_address, negrisk_redeem_positions, wait_for_tx_receipt, wait_for_tx_receipt_labeled, }; -/// Per-redeem timeout (Polygon block time ~2s; a healthy tx mines in <30s). -/// Kept short so batch redeem stays under typical subprocess timeouts. -const REDEEM_WAIT_SECS: u64 = 45; - -/// Estimated POL gas cost per redeem call (conservative). -/// CTF.redeemPositions on Polygon typically costs ~0.008 POL; we budget 2× -/// to absorb gas price spikes. -const POL_PER_REDEEM: f64 = 0.015; - -/// Fire `onchainos wallet report-plugin-info` with a REDEEM payload. -/// No-op when strategy_id is missing/empty or no tx hashes are available. -async fn report_redeem( - strategy_id: Option<&str>, - eoa: &str, - proxy: Option<&str>, - condition_id: &str, - result: &serde_json::Value, -) { - let sid = match strategy_id.filter(|s| !s.is_empty()) { - Some(s) => s, - None => return, - }; - let mut tx_hashes: Vec = Vec::new(); - if let Some(t) = result.get("eoa_tx").and_then(|v| v.as_str()) { - tx_hashes.push(t.to_string()); - } - if let Some(t) = result.get("proxy_tx").and_then(|v| v.as_str()) { - tx_hashes.push(t.to_string()); - } - if tx_hashes.is_empty() { - return; - } - let ts_now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0); - let cid_display = format!("0x{}", condition_id.trim_start_matches("0x")); - let payload = serde_json::json!({ - "wallet": eoa, - "proxyAddress": proxy.unwrap_or(""), - "order_id": tx_hashes[0], - "tx_hashes": tx_hashes, - "market_id": cid_display, - "asset_id": "", - "side": "REDEEM", - "amount": "", - "symbol": "USDC.e", - "price": "", - "timestamp": ts_now, - "strategy_id": sid, - "plugin_name": "polymarket-plugin", - }); - if let Err(e) = crate::onchainos::report_plugin_info(&payload).await { - eprintln!("[polymarket] Warning: report-plugin-info failed: {}", e); - } -} +const REDEEM_WAIT_SECS: u64 = 120; /// Resolve (condition_id, neg_risk, question) from a market_id (condition_id or slug). async fn resolve_market(client: &Client, market_id: &str) -> Result<(String, bool, String)> { @@ -76,7 +20,7 @@ async fn resolve_market(client: &Client, market_id: &str) -> Result<(String, boo let m = get_gamma_market_by_slug(client, market_id).await?; let cid = m .condition_id - .ok_or_else(|| anyhow!("market has no conditionId: {}", market_id))?; + .ok_or_else(|| anyhow::anyhow!("market has no conditionId: {}", market_id))?; let q = m.question.unwrap_or_default(); let neg_risk = match get_clob_market(client, &cid).await { Ok(clob) => clob.neg_risk, @@ -86,264 +30,228 @@ async fn resolve_market(client: &Client, market_id: &str) -> Result<(String, boo } } -/// Summary of which wallet(s) hold redeemable tokens for a given condition_id. -#[derive(Default)] -struct Redeemability { - eoa: bool, - proxy: bool, -} - -async fn check_redeemability( - client: &Client, - condition_id: &str, - eoa_addr: &str, - proxy_addr: Option<&str>, -) -> Redeemability { - let cid_hex = condition_id.trim_start_matches("0x"); - let cid_display = format!("0x{}", cid_hex); - let matches = |cid_opt: Option<&str>| -> bool { - cid_opt == Some(condition_id) || cid_opt == Some(&cid_display) - }; - - let eoa_positions = get_positions(client, eoa_addr).await.unwrap_or_default(); - let eoa = eoa_positions - .iter() - .any(|p| matches(p.condition_id.as_deref()) && p.redeemable); - - let proxy = if let Some(proxy) = proxy_addr { - let positions = get_positions(client, proxy).await.unwrap_or_default(); - positions - .iter() - .any(|p| matches(p.condition_id.as_deref()) && p.redeemable) - } else { - false - }; - - Redeemability { eoa, proxy } -} - /// Core redeem logic for a single condition_id. /// -/// Never falls back — if Data API shows no redeemable positions on either -/// wallet, returns an error (caller should surface NO_REDEEMABLE_POSITIONS). +/// Checks which wallet(s) hold redeemable tokens via the Data API, submits the +/// appropriate tx(es), and waits for each to confirm on-chain before returning. /// -/// For neg_risk markets, `token_ids` must be provided (YES token first, then NO). -/// These are the decimal-string token IDs from the CLOB market, used to query -/// on-chain ERC-1155 balances before calling NegRiskAdapter.redeemPositions. +/// `collateral_addr`: USDC.e for V1 positions, pUSD for V2 positions. +/// +/// Returns a JSON Value summarising the result (for use in both single and batch flows). async fn redeem_one( client: &Client, condition_id: &str, question: &str, - neg_risk: bool, - token_ids: &[String], eoa_addr: &str, proxy_addr: Option<&str>, + collateral_addr: &str, ) -> Result { let cid_hex = condition_id.trim_start_matches("0x"); let cid_display = format!("0x{}", cid_hex); - let r = check_redeemability(client, condition_id, eoa_addr, proxy_addr).await; - - if !r.eoa && !r.proxy { - return Err(anyhow!( - "No redeemable positions found for {} on EOA ({}) {}. \ - Outcome tokens are held in a wallet this plugin does not know about — \ - if you traded in POLY_PROXY mode, run `setup-proxy` first so the plugin \ - can look up the proxy address.", - cid_display, - eoa_addr, - proxy_addr - .map(|p| format!("or proxy ({})", p)) - .unwrap_or_else(|| "(no proxy configured)".into()) - )); - } + let eoa_redeemable = { + let positions = get_positions(client, eoa_addr).await.unwrap_or_default(); + let has = positions.iter().any(|p| { + (p.condition_id.as_deref() == Some(condition_id) + || p.condition_id.as_deref() == Some(&cid_display)) + && p.redeemable + }); + if !has { + let lost: f64 = positions + .iter() + .filter(|p| { + p.condition_id.as_deref() == Some(condition_id) + || p.condition_id.as_deref() == Some(&cid_display) + }) + .map(|p| p.current_value.unwrap_or(0.0)) + .sum(); + if lost < 0.000_001 + && positions.iter().any(|p| { + p.condition_id.as_deref() == Some(condition_id) + || p.condition_id.as_deref() == Some(&cid_display) + }) + { + eprintln!( + "[polymarket] Note: EOA has positions for this market but current_value ≈ $0 \ + (market resolved against your EOA positions)." + ); + } + } + has + }; + + let proxy_redeemable = if let Some(proxy) = proxy_addr { + let positions = get_positions(client, proxy).await.unwrap_or_default(); + positions.iter().any(|p| { + (p.condition_id.as_deref() == Some(condition_id) + || p.condition_id.as_deref() == Some(&cid_display)) + && p.redeemable + }) + } else { + false + }; let mut out = serde_json::json!({ "condition_id": cid_display, "question": question, - "neg_risk": neg_risk, }); - if neg_risk { - // NegRisk markets: call NegRiskAdapter.redeemPositions(conditionId, [yes_bal, no_bal]). - // Proxy-via-PROXY_FACTORY routing for neg_risk is not yet implemented; EOA only. - if r.proxy && !r.eoa { - return Err(anyhow!( - "Neg_risk redeem from proxy wallet is not yet supported by this plugin. \ - If your winning tokens are in the proxy wallet, use the Polymarket web UI \ - to redeem. EOA redeem via NegRiskAdapter is fully supported." - )); - } - - // Query on-chain ERC-1155 balances for each outcome token. - let wallet = if r.proxy && proxy_addr.is_some() { proxy_addr.unwrap() } else { eoa_addr }; - let mut amounts: Vec = Vec::with_capacity(token_ids.len()); - for tid in token_ids { - let bal = get_ctf_balance(wallet, tid).await.unwrap_or(0); - amounts.push(bal); - } - - // Validate we can encode the token IDs (catches malformed API data early). - for tid in token_ids { - decimal_str_to_hex64(tid) - .with_context(|| format!("token_id '{}' is not a valid decimal integer", tid))?; - } - - if amounts.iter().all(|&a| a == 0) { - return Err(anyhow!( - "No outcome token balance found on-chain for {} in wallet {}. \ - The market may not be resolved yet, or winning tokens may be in a \ - different wallet.", - cid_display, - wallet - )); - } - - let total_shares: u128 = amounts.iter().sum(); + // Fallback: if Data API shows nothing (can lag after resolution), attempt EOA redeem. + if !eoa_redeemable && !proxy_redeemable { eprintln!( - "[polymarket] NegRisk redeem: {} total shares across {} outcomes — submitting NegRiskAdapter.redeemPositions...", - total_shares, amounts.len() - ); - let tx = negrisk_redeem_positions(condition_id, &amounts, eoa_addr).await?; - eprintln!( - "[polymarket] NegRisk redeem tx {} — waiting up to {}s for on-chain confirmation...", - tx, REDEEM_WAIT_SECS - ); - wait_for_tx_receipt_labeled(&tx, REDEEM_WAIT_SECS, "NegRisk redeem").await?; - out["eoa_tx"] = serde_json::Value::String(tx); - out["amounts"] = serde_json::Value::Array( - amounts.iter().map(|a| serde_json::Value::String(a.to_string())).collect() + "[polymarket] Warning: Data API shows no redeemable positions for {} \ + (may lag after resolution). Attempting EOA redeem as fallback.", + cid_display ); + let tx_hash = ctf_redeem_positions(condition_id, collateral_addr).await?; + eprintln!("[polymarket] Waiting for EOA redeem tx to confirm..."); + wait_for_tx_receipt(&tx_hash, 120).await?; + out["eoa_tx"] = serde_json::Value::String(tx_hash); + out["source"] = serde_json::Value::String("fallback_eoa".into()); out["note"] = serde_json::Value::String( - "NegRiskAdapter.redeemPositions confirmed. USDC.e transferred to EOA.".into(), + "EOA redeemPositions confirmed (fallback).".into(), ); - } else { - // Standard binary market: call CTF.redeemPositions. - if r.eoa { - eprintln!("[polymarket] EOA holds winning tokens — submitting EOA redeemPositions..."); - let tx = ctf_redeem_positions(condition_id, eoa_addr).await?; - eprintln!( - "[polymarket] EOA redeem tx {} — waiting up to {}s for on-chain confirmation...", - tx, REDEEM_WAIT_SECS - ); - wait_for_tx_receipt_labeled(&tx, REDEEM_WAIT_SECS, "EOA redeem").await?; - out["eoa_tx"] = serde_json::Value::String(tx); - out["eoa_note"] = - serde_json::Value::String("EOA redeemPositions confirmed.".into()); - } + return Ok(out); + } - if r.proxy { - eprintln!( - "[polymarket] Proxy holds winning tokens — submitting proxy redeemPositions via PROXY_FACTORY..." - ); - let tx = ctf_redeem_via_proxy(condition_id, eoa_addr).await?; - eprintln!( - "[polymarket] Proxy redeem tx {} — waiting up to {}s for on-chain confirmation...", - tx, REDEEM_WAIT_SECS - ); - wait_for_tx_receipt_labeled(&tx, REDEEM_WAIT_SECS, "Proxy redeem").await?; - out["proxy_tx"] = serde_json::Value::String(tx); - out["proxy_note"] = serde_json::Value::String( - "Proxy redeemPositions confirmed via PROXY_FACTORY.".into(), - ); - } + if eoa_redeemable { + eprintln!("[polymarket] EOA has winning tokens — submitting EOA redeemPositions..."); + let tx = ctf_redeem_positions(condition_id, collateral_addr).await?; + eprintln!("[polymarket] Waiting for EOA redeem tx to confirm..."); + wait_for_tx_receipt(&tx, 120).await?; + out["eoa_tx"] = serde_json::Value::String(tx); + out["eoa_note"] = + serde_json::Value::String("EOA redeemPositions confirmed.".into()); + } - out["note"] = serde_json::Value::String( - "USDC.e transferred to the respective wallet(s).".into(), + if proxy_redeemable { + eprintln!( + "[polymarket] Proxy has winning tokens — submitting proxy redeemPositions via PROXY_FACTORY..." + ); + let tx = ctf_redeem_via_proxy(condition_id, collateral_addr).await?; + eprintln!("[polymarket] Waiting for proxy redeem tx to confirm..."); + wait_for_tx_receipt(&tx, 120).await?; + out["proxy_tx"] = serde_json::Value::String(tx); + out["proxy_note"] = serde_json::Value::String( + "Proxy redeemPositions confirmed via PROXY_FACTORY.".into(), ); } + let collateral_sym = if collateral_addr.eq_ignore_ascii_case(Contracts::PUSD) { "pUSD" } else { "USDC.e" }; + out["note"] = serde_json::Value::String( + format!("{} transferred to the respective wallet(s).", collateral_sym), + ); Ok(out) } -/// Look up an on-chain proxy wallet that is not yet recorded in credentials. -/// -/// Safe to call freely: uses `debug_traceCall` (read-only, no gas, no tx). If the -/// RPC doesn't support `debug_traceCall` or anything else fails, returns None and -/// callers should fall through silently — this is purely a UX hint. -async fn discover_uncached_proxy(eoa: &str, creds_proxy: Option<&str>) -> Option { - if creds_proxy.is_some() { - return None; +/// Redeem neg_risk (multi-outcome) market via NegRiskAdapter.redeemPositions. +/// Queries on-chain ERC-1155 balances for each token_id, then broadcasts. +async fn redeem_one_negrisk( + condition_id: &str, + question: &str, + token_ids: &[String], + eoa_addr: &str, +) -> Result { + let cid_display = format!("0x{}", condition_id.trim_start_matches("0x")); + + // Validate token IDs can be encoded. + for tid in token_ids { + decimal_str_to_hex64(tid) + .with_context(|| format!("token_id '{}' is not a valid decimal integer", tid))?; } - get_existing_proxy(eoa).await.ok().flatten() -} -/// Build a human-readable hint pointing at a proxy wallet discovered on-chain, -/// to be appended to an error's `suggestion` field. Empty string if no proxy found. -fn proxy_hint(discovered: Option<&str>) -> String { - match discovered { - Some(addr) => format!( - "Detected existing proxy wallet on-chain for this EOA: {}. \ - Run `polymarket-plugin setup-proxy` to save it to credentials — \ - once saved, redeem will route through the proxy automatically.", - addr - ), - None => String::new(), + // Query on-chain ERC-1155 balances for each outcome. + let mut amounts: Vec = Vec::with_capacity(token_ids.len()); + for tid in token_ids { + let bal = get_ctf_balance(eoa_addr, tid).await.unwrap_or(0); + amounts.push(bal); } -} -/// Fail-fast POL balance check: EOA pays gas for both EOA and proxy redeem paths. -async fn check_pol_budget(eoa_addr: &str, tx_count: usize) -> Result { - let pol = get_pol_balance(eoa_addr).await?; - let needed = tx_count as f64 * POL_PER_REDEEM; - if pol < needed { - return Err(anyhow!( - "Insufficient POL for gas: EOA {} has {:.4} POL but redeeming {} market(s) \ - needs ~{:.4} POL (budgeting {} POL per market). \ - Top up {:.4} more POL.", - eoa_addr, - pol, - tx_count, - needed, - POL_PER_REDEEM, - needed - pol - )); + if amounts.iter().all(|&a| a == 0) { + bail!( + "No outcome token balance found on-chain for {} in EOA wallet {}. \ + Market may not be resolved yet, or tokens may be in a different wallet.", + cid_display, eoa_addr + ); } - Ok(pol) + + let total_shares: u128 = amounts.iter().sum(); + eprintln!( + "[polymarket] NegRisk redeem: {} total shares across {} outcomes — submitting NegRiskAdapter.redeemPositions...", + total_shares, amounts.len() + ); + let tx = negrisk_redeem_positions(condition_id, &amounts, eoa_addr).await?; + eprintln!( + "[polymarket] NegRisk redeem tx {} — waiting up to {}s for on-chain confirmation...", + tx, REDEEM_WAIT_SECS + ); + wait_for_tx_receipt_labeled(&tx, REDEEM_WAIT_SECS, "NegRisk redeem").await?; + + Ok(serde_json::json!({ + "condition_id": cid_display, + "question": question, + "neg_risk": true, + "eoa_tx": tx, + "amounts": amounts.iter().map(|a| a.to_string()).collect::>(), + "note": "NegRiskAdapter.redeemPositions confirmed. USDC.e transferred to EOA.", + })) } /// Redeem a single market by market_id (condition_id or slug). -pub async fn run(market_id: &str, dry_run: bool, strategy_id: Option<&str>) -> Result<()> { +pub async fn run(market_id: &str, dry_run: bool) -> Result<()> { let client = Client::new(); + let (condition_id, neg_risk, question) = resolve_market(&client, market_id).await?; + + if neg_risk { + // Fetch CLOB token IDs for on-chain balance queries. + let clob = get_clob_market(&client, &condition_id).await + .context("Failed to fetch CLOB market for NegRisk token IDs")?; + let token_ids: Vec = clob.tokens.iter().map(|t| t.token_id.clone()).collect(); - let (condition_id, neg_risk, question) = match resolve_market(&client, market_id).await { - Ok(v) => v, - Err(e) => { - println!("{}", super::error_response(&e, Some("redeem"), None)); + if dry_run { + let cid_display = format!("0x{}", condition_id.trim_start_matches("0x")); + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "ok": true, + "data": { + "dry_run": true, + "market_id": market_id, + "condition_id": cid_display, + "question": question, + "neg_risk": true, + "action": "NegRiskAdapter.redeemPositions", + "token_ids": token_ids, + "note": "dry-run: will query on-chain ERC-1155 balances and call NegRiskAdapter.redeemPositions." + } + }))? + ); return Ok(()); } - }; - // Fetch CLOB token IDs (needed for neg_risk on-chain balance queries). - // For standard markets, tokens are also available but unused in the redeem path. - let token_ids: Vec = match get_clob_market(&client, &condition_id).await { - Ok(m) => m.tokens.into_iter().map(|t| t.token_id).collect(), - Err(_) => vec![], - }; + let eoa_addr = get_wallet_address().await?; + let result = redeem_one_negrisk(&condition_id, &question, &token_ids, &eoa_addr).await?; + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ "ok": true, "data": result }))? + ); + return Ok(()); + } let cid_display = format!("0x{}", condition_id.trim_start_matches("0x")); - let eoa_addr = match get_wallet_address().await { - Ok(a) => a, - Err(e) => { - println!("{}", super::error_response(&e, Some("redeem"), None)); - return Ok(()); - } - }; + let (eoa_addr, clob_version_raw) = tokio::join!( + get_wallet_address(), + get_clob_version(&client), + ); + let eoa_addr = eoa_addr?; + let clob_version_raw = clob_version_raw?; + // Use pUSD as collateral for V2 markets (cutover ~2026-04-28). + let collateral_addr = if clob_version_raw == 2 { Contracts::PUSD } else { Contracts::USDC_E }; let creds = load_credentials().unwrap_or_default(); let proxy_addr = creds.and_then(|c| c.proxy_wallet); - // Best-effort: if no proxy in creds, check on-chain so error hints can cite the address. - let discovered_proxy = discover_uncached_proxy(&eoa_addr, proxy_addr.as_deref()).await; - let hint = proxy_hint(discovered_proxy.as_deref()); - let hint_opt = if hint.is_empty() { None } else { Some(hint.as_str()) }; - if dry_run { - let r = check_redeemability(&client, &condition_id, &eoa_addr, proxy_addr.as_deref()).await; - let action = if neg_risk { - "NegRiskAdapter.redeemPositions" - } else { - "CTF.redeemPositions" - }; + let collateral_sym = if clob_version_raw == 2 { "pUSD" } else { "USDC.e" }; println!( "{}", serde_json::to_string_pretty(&serde_json::json!({ @@ -353,64 +261,44 @@ pub async fn run(market_id: &str, dry_run: bool, strategy_id: Option<&str>) -> R "market_id": market_id, "condition_id": cid_display, "question": question, - "neg_risk": neg_risk, + "neg_risk": false, "eoa_wallet": eoa_addr, "proxy_wallet": proxy_addr, - "discovered_proxy": discovered_proxy, - "eoa_redeemable": r.eoa, - "proxy_redeemable": r.proxy, - "action": action, - "token_ids": token_ids, - "note": "dry-run: will redeem from whichever wallet holds the winning tokens. \ - If both eoa_redeemable and proxy_redeemable are false, run `setup-proxy` first." + "action": "redeemPositions", + "collateral": collateral_sym, + "index_sets": [1, 2], + "note": "dry-run: will redeem from whichever wallet (EOA / proxy) holds the winning tokens." } }))? ); return Ok(()); } - if let Err(e) = check_pol_budget(&eoa_addr, 1).await { - println!("{}", super::error_response(&e, Some("redeem"), hint_opt)); - return Ok(()); - } - - match redeem_one(&client, &condition_id, &question, neg_risk, &token_ids, &eoa_addr, proxy_addr.as_deref()).await { - Ok(result) => { - report_redeem(strategy_id, &eoa_addr, proxy_addr.as_deref(), &condition_id, &result).await; - println!( - "{}", - serde_json::to_string_pretty(&serde_json::json!({ - "ok": true, - "data": result - }))? - ); - } - Err(e) => { - println!("{}", super::error_response(&e, Some("redeem"), hint_opt)); - } - } + let result = redeem_one(&client, &condition_id, &question, &eoa_addr, proxy_addr.as_deref(), collateral_addr).await?; + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ "ok": true, "data": result }))? + ); Ok(()) } /// Redeem ALL redeemable positions across EOA and proxy wallets in one pass. -pub async fn run_all(dry_run: bool, strategy_id: Option<&str>) -> Result<()> { +/// +/// Discovers redeemable condition_ids from both wallets via the Data API, then +/// redeems each sequentially, waiting for on-chain confirmation between markets. +pub async fn run_all(dry_run: bool) -> Result<()> { let client = Client::new(); - let eoa_addr = match get_wallet_address().await { - Ok(a) => a, - Err(e) => { - println!("{}", super::error_response(&e, Some("redeem"), None)); - return Ok(()); - } - }; + let (eoa_addr, clob_version_raw) = tokio::join!( + get_wallet_address(), + get_clob_version(&client), + ); + let eoa_addr = eoa_addr?; + let clob_version_raw = clob_version_raw?; + // Use pUSD as collateral for V2 markets (cutover ~2026-04-28). + let collateral_addr = if clob_version_raw == 2 { Contracts::PUSD } else { Contracts::USDC_E }; let creds = load_credentials().unwrap_or_default(); let proxy_addr = creds.and_then(|c| c.proxy_wallet); - // Best-effort discovery: if creds has no proxy but one exists on-chain, - // surface it in error hints so the user knows `setup-proxy` is the fix. - let discovered_proxy = discover_uncached_proxy(&eoa_addr, proxy_addr.as_deref()).await; - let hint = proxy_hint(discovered_proxy.as_deref()); - let hint_opt = if hint.is_empty() { None } else { Some(hint.as_str()) }; - // Collect all unique redeemable condition_ids from both wallets. let mut redeemable: Vec<(String, String)> = Vec::new(); // (condition_id, title) @@ -441,24 +329,22 @@ pub async fn run_all(dry_run: bool, strategy_id: Option<&str>) -> Result<()> { } if redeemable.is_empty() { - let e = anyhow!( - "No redeemable positions found on EOA ({}) {}. \ - If you traded in POLY_PROXY mode, run `setup-proxy` first so the plugin \ - can look up the proxy address.", - eoa_addr, - proxy_addr - .as_ref() - .map(|p| format!("or proxy ({})", p)) - .unwrap_or_else(|| "(no proxy configured)".into()) + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "ok": true, + "data": { + "message": "No redeemable positions found.", + "redeemed_count": 0 + } + }))? ); - println!("{}", super::error_response(&e, Some("redeem"), hint_opt)); return Ok(()); } - let n = redeemable.len(); eprintln!( "[polymarket] Found {} redeemable position(s). Redeeming sequentially...", - n + redeemable.len() ); if dry_run { @@ -472,9 +358,7 @@ pub async fn run_all(dry_run: bool, strategy_id: Option<&str>) -> Result<()> { "ok": true, "data": { "dry_run": true, - "redeemable_count": n, - "estimated_pol_needed": n as f64 * POL_PER_REDEEM, - "discovered_proxy": discovered_proxy, + "redeemable_count": items.len(), "positions": items, "note": "dry-run: would redeem each position sequentially, waiting for on-chain confirmation between each." } @@ -483,21 +367,6 @@ pub async fn run_all(dry_run: bool, strategy_id: Option<&str>) -> Result<()> { return Ok(()); } - // Fail fast if EOA does not have enough POL to cover all redeems. - let pol_balance = match check_pol_budget(&eoa_addr, n).await { - Ok(b) => b, - Err(e) => { - println!("{}", super::error_response(&e, Some("redeem"), hint_opt)); - return Ok(()); - } - }; - eprintln!( - "[polymarket] POL budget OK: {:.4} POL available, ~{:.4} POL needed for {} redeem(s).", - pol_balance, - n as f64 * POL_PER_REDEEM, - n - ); - let mut results = Vec::new(); let mut errors = Vec::new(); @@ -505,32 +374,27 @@ pub async fn run_all(dry_run: bool, strategy_id: Option<&str>) -> Result<()> { eprintln!( "[polymarket] [{}/{}] Redeeming: {}", i + 1, - n, + redeemable.len(), title ); - // Fetch neg_risk flag and token_ids for each market (needed for NegRisk redeem path). - let (market_neg_risk, market_token_ids) = match get_clob_market(&client, cid).await { - Ok(m) => (m.neg_risk, m.tokens.into_iter().map(|t| t.token_id).collect()), - Err(_) => (false, vec![]), + // Fetch neg_risk status + token IDs for each market. + let clob = get_clob_market(&client, cid).await; + let is_neg_risk = clob.as_ref().map(|m| m.neg_risk).unwrap_or(false); + let token_ids: Vec = clob.as_ref() + .map(|m| m.tokens.iter().map(|t| t.token_id.clone()).collect()) + .unwrap_or_default(); + + let result = if is_neg_risk && !token_ids.is_empty() { + redeem_one_negrisk(cid, title, &token_ids, &eoa_addr).await + } else { + redeem_one(&client, cid, title, &eoa_addr, proxy_addr.as_deref(), collateral_addr).await }; - match redeem_one(&client, cid, title, market_neg_risk, &market_token_ids, &eoa_addr, proxy_addr.as_deref()).await { - Ok(r) => { - report_redeem(strategy_id, &eoa_addr, proxy_addr.as_deref(), cid, &r).await; - results.push(r); - } + + match result { + Ok(r) => results.push(r), Err(e) => { - eprintln!("[polymarket] Error redeeming {}: {:#}", cid, e); - let classified: serde_json::Value = serde_json::from_str( - &super::error_response(&e, Some("redeem"), hint_opt), - ) - .unwrap_or_else(|_| serde_json::json!({ "error": e.to_string() })); - errors.push(serde_json::json!({ - "condition_id": cid, - "title": title, - "error": classified.get("error"), - "error_code": classified.get("error_code"), - "suggestion": classified.get("suggestion"), - })); + eprintln!("[polymarket] Error redeeming {}: {}", cid, e); + errors.push(serde_json::json!({ "condition_id": cid, "error": e.to_string() })); } } } @@ -544,7 +408,7 @@ pub async fn run_all(dry_run: bool, strategy_id: Option<&str>) -> Result<()> { "error_count": errors.len(), "results": results, "errors": errors, - "note": "USDC.e transferred to respective wallet(s) for all confirmed redemptions." + "note": "Collateral (pUSD/USDC.e) transferred to respective wallet(s) for all confirmed redemptions." } }))? ); diff --git a/skills/polymarket-plugin/src/commands/rfq.rs b/skills/polymarket-plugin/src/commands/rfq.rs new file mode 100644 index 000000000..2b2eaf335 --- /dev/null +++ b/skills/polymarket-plugin/src/commands/rfq.rs @@ -0,0 +1,201 @@ +use anyhow::{bail, Result}; +use reqwest::Client; + +use crate::api::{ + get_clob_version, get_rfq_quote, post_rfq_confirm, post_rfq_request, OrderBodyV2, +}; +use crate::auth::ensure_credentials; +use crate::config::OrderVersion; +use crate::onchainos::get_wallet_address; +use crate::signing::{sign_order_v2_via_onchainos, OrderParamsV2, BYTES32_ZERO}; + +use super::buy::resolve_market_token; + +/// Request-for-Quote (RFQ) for a block trade with a Polymarket market maker. +/// +/// RFQ is designed for large orders where standard CLOB liquidity may be insufficient. +/// A market maker provides a firm quote; the user can accept it by re-running with `--confirm`. +/// +/// Flow: +/// 1. POST /rfq/request → receive a quote_id +/// 2. GET /rfq/quote/{quote_id} → display price, amount, expiry +/// 3. Re-run with --confirm → sign a V2 order at the quoted price, POST /rfq/confirm +pub async fn run( + market_id: &str, + outcome: &str, + amount: &str, + confirm: bool, + dry_run: bool, +) -> Result<()> { + let usdc_amount: f64 = amount.parse().map_err(|_| anyhow::anyhow!("invalid amount: {}", amount))?; + if usdc_amount <= 0.0 { + bail!("amount must be positive"); + } + + let client = Client::new(); + + // Resolve market. + let (condition_id, token_id, neg_risk, _fee) = + resolve_market_token(&client, market_id, outcome).await?; + + let side = "BUY"; // RFQ always requests the buy side; sell-side RFQ uses the counterparty flow + + if dry_run { + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "ok": true, + "dry_run": true, + "data": { + "condition_id": condition_id, + "token_id": token_id, + "outcome": outcome, + "side": side, + "amount_usdc": usdc_amount, + "note": "dry-run: would POST /rfq/request and display the quote" + } + }))? + ); + return Ok(()); + } + + // Step 1: request a quote. + eprintln!("[polymarket] Requesting RFQ quote for {} {} @ ${:.2}...", side, outcome, usdc_amount); + let quote_id = post_rfq_request(&client, &condition_id, &token_id, side, usdc_amount).await?; + eprintln!("[polymarket] Quote ID: {}", quote_id); + + // Step 2: fetch the quote. + let quote = get_rfq_quote(&client, "e_id).await?; + + let price_str = quote.price.as_deref().unwrap_or("?"); + let amount_str = quote.amount.as_deref().unwrap_or("?"); + let expires_at = quote.expires_at.unwrap_or(0); + let maker = quote.maker.as_deref().unwrap_or("unknown"); + let status = quote.status.as_deref().unwrap_or("pending"); + + // Show the quote. + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "ok": true, + "data": { + "quote_id": quote_id, + "status": status, + "condition_id": condition_id, + "outcome": outcome, + "side": side, + "price": price_str, + "amount_usdc": amount_str, + "maker": maker, + "expires_at": expires_at, + "note": if confirm { + "Confirming quote — signing and submitting order..." + } else { + "Quote received. Re-run with --confirm to accept." + } + } + }))? + ); + + if !confirm { + return Ok(()); + } + + // Step 3: confirm the quote — sign a V2 order at the quoted price. + let quoted_price: f64 = quote.price.as_deref() + .and_then(|s| s.parse().ok()) + .ok_or_else(|| anyhow::anyhow!("Quote has no valid price"))?; + + if quoted_price <= 0.0 || quoted_price >= 1.0 { + bail!("Quoted price {} is out of range (0, 1)", quoted_price); + } + + if status != "active" && status != "pending" { + bail!("Quote is no longer active (status: {}). Request a new quote.", status); + } + + let signer_addr = get_wallet_address().await?; + let creds = ensure_credentials(&client, &signer_addr).await?; + + // Confirm flow always uses V2 signing (RFQ is a V2-only feature). + let clob_version_raw = get_clob_version(&client).await?; + let _clob_version = if clob_version_raw == 2 { OrderVersion::V2 } else { OrderVersion::V1 }; + + // Resolve maker address from trading mode. + use crate::config::TradingMode; + let (maker_addr, sig_type) = match &creds.mode { + TradingMode::PolyProxy => { + let proxy = creds.proxy_wallet.as_ref() + .ok_or_else(|| anyhow::anyhow!( + "POLY_PROXY mode requires a proxy wallet. Run `polymarket setup-proxy` first." + ))?.clone(); + (proxy, 1u8) + } + TradingMode::Eoa => (signer_addr.clone(), 0u8), + }; + + // Compute amounts from quoted price and usdc amount. + let maker_amount_raw = (usdc_amount * 1_000_000.0).round() as u64; + let taker_amount_raw = (usdc_amount / quoted_price * 1_000_000.0).round() as u64; + + let timestamp_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + + // Generate random salt. + let mut salt_bytes = [0u8; 8]; + getrandom::getrandom(&mut salt_bytes).expect("getrandom failed"); + let salt = u64::from_le_bytes(salt_bytes) & 0x001F_FFFF_FFFF_FFFF; + + let params = OrderParamsV2 { + salt, + maker: maker_addr.clone(), + signer: signer_addr.clone(), + token_id: token_id.clone(), + maker_amount: maker_amount_raw, + taker_amount: taker_amount_raw, + side: 0, // BUY + signature_type: sig_type, + timestamp_ms, + metadata: BYTES32_ZERO.to_string(), + builder: BYTES32_ZERO.to_string(), + }; + + eprintln!("[polymarket] Signing RFQ order at price {}...", quoted_price); + let signature = sign_order_v2_via_onchainos(¶ms, neg_risk).await?; + + let order_body = OrderBodyV2 { + salt, + maker: maker_addr.clone(), + signer: signer_addr.clone(), + token_id: token_id.clone(), + maker_amount: maker_amount_raw.to_string(), + taker_amount: taker_amount_raw.to_string(), + side: "BUY".to_string(), + signature_type: sig_type, + timestamp: timestamp_ms.to_string(), + metadata: BYTES32_ZERO.to_string(), + builder: BYTES32_ZERO.to_string(), + signature, + }; + + eprintln!("[polymarket] Submitting RFQ confirmation..."); + let result = post_rfq_confirm(&client, &signer_addr, &creds, "e_id, &order_body).await?; + + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "ok": true, + "data": { + "quote_id": quote_id, + "condition_id": condition_id, + "outcome": outcome, + "price": quoted_price, + "usdc_amount": usdc_amount, + "result": result, + } + }))? + ); + Ok(()) +} diff --git a/skills/polymarket-plugin/src/commands/sell.rs b/skills/polymarket-plugin/src/commands/sell.rs index 8d35b2894..865964ed8 100644 --- a/skills/polymarket-plugin/src/commands/sell.rs +++ b/skills/polymarket-plugin/src/commands/sell.rs @@ -2,14 +2,16 @@ use anyhow::{bail, Context, Result}; use reqwest::Client; use crate::api::{ - compute_sell_worst_price, get_balance_allowance, get_market_fee, get_orderbook, - post_order, round_price, to_token_units, OrderBody, - OrderRequest, + compute_sell_worst_price, get_balance_allowance, get_clob_version, get_market_fee, + get_orderbook, post_order, round_price, to_token_units, OrderBody, OrderBodyV2, + OrderRequest, OrderRequestV2, }; use crate::auth::ensure_credentials; -use crate::onchainos::{approve_ctf, get_wallet_address, is_ctf_approved_for_all}; +use crate::config::OrderVersion; +use crate::onchainos::{get_wallet_address, is_ctf_approved_for_all}; use crate::series; -use crate::signing::{sign_order_via_onchainos, OrderParams}; +use crate::signing::{sign_order_v2_via_onchainos, sign_order_via_onchainos, OrderParams, + OrderParamsV2, BYTES32_ZERO}; use super::buy::{resolve_from_gamma, resolve_market_token}; @@ -31,7 +33,6 @@ pub async fn run( expires: Option, mode_override: Option<&str>, token_id_fast: Option<&str>, - strategy_id: Option<&str>, ) -> Result<()> { // Parse shares and validate order flags up front (before any network calls). let share_amount: f64 = shares.parse().context("invalid shares amount")?; @@ -251,6 +252,10 @@ pub async fn run( use crate::config::{Contracts, TradingMode}; + // Fetch CLOB version in parallel with credentials. + let clob_version_raw = get_clob_version(&client).await?; + let clob_version = if clob_version_raw == 2 { OrderVersion::V2 } else { OrderVersion::V1 }; + // Wallet address was pre-fetched in parallel with the order book (non-dry-run path). let signer_addr = signer_addr_opt.expect("signer_addr must be set in non-dry-run path"); let creds = ensure_credentials(&client, &signer_addr).await?; @@ -357,12 +362,16 @@ pub async fn run( // EOA mode: check and submit CTF setApprovalForAll if needed. // POLY_PROXY mode: no approval tx — relayer handles settlement through the proxy. + // + // V2 migration: V2 uses a new exchange contract address for CTF approval. + // If the user approved V1 exchange but not V2, the V2 exchange will be approved here. if effective_mode == TradingMode::Eoa { + let exchange_addr = Contracts::exchange(clob_version, neg_risk); let already_approved = if neg_risk { - let ok1 = match is_ctf_approved_for_all(&signer_addr, Contracts::NEG_RISK_CTF_EXCHANGE).await { + let ok1 = match is_ctf_approved_for_all(&signer_addr, exchange_addr).await { Ok(v) => v, Err(e) => { - eprintln!("[polymarket] Note: could not verify NEG_RISK_CTF_EXCHANGE approval ({}); will re-approve.", e); + eprintln!("[polymarket] Note: could not verify exchange approval ({}); will re-approve.", e); false } }; @@ -375,18 +384,23 @@ pub async fn run( }; ok1 && ok2 } else { - match is_ctf_approved_for_all(&signer_addr, Contracts::CTF_EXCHANGE).await { + match is_ctf_approved_for_all(&signer_addr, exchange_addr).await { Ok(v) => v, Err(e) => { - eprintln!("[polymarket] Note: could not verify CTF_EXCHANGE approval ({}); will re-approve.", e); + eprintln!("[polymarket] Note: could not verify exchange approval ({}); will re-approve.", e); false } } }; if !already_approved || auto_approve { - let exchange_label = if neg_risk { "Neg Risk CTF Exchange" } else { "CTF Exchange" }; + let version_label = if clob_version == OrderVersion::V2 { " V2" } else { "" }; + let exchange_label = if neg_risk { + format!("Neg Risk CTF Exchange{}", version_label) + } else { + format!("CTF Exchange{}", version_label) + }; eprintln!("[polymarket] Approving CTF tokens for {}...", exchange_label); - let tx_hash = approve_ctf(neg_risk).await?; + let tx_hash = approve_ctf_versioned(neg_risk, clob_version).await?; eprintln!("[polymarket] Approval tx: {}", tx_hash); eprintln!("[polymarket] Waiting for approval to confirm on-chain..."); crate::onchainos::wait_for_tx_receipt(&tx_hash, 30).await?; @@ -396,51 +410,94 @@ pub async fn run( let salt = rand_salt(); - let params = OrderParams { - salt, - maker: maker_addr.clone(), - signer: signer_addr.clone(), - taker: "0x0000000000000000000000000000000000000000".to_string(), - token_id: token_id.clone(), - maker_amount: maker_amount_raw as u64, - taker_amount: taker_amount_raw as u64, - expiration, - nonce: 0, - fee_rate_bps, - side: 1, // SELL - signature_type: sig_type, - }; - - let signature = sign_order_via_onchainos(¶ms, neg_risk).await?; - - let order_body = OrderBody { - salt, - maker: maker_addr.clone(), - signer: signer_addr.clone(), - taker: "0x0000000000000000000000000000000000000000".to_string(), - token_id: token_id.clone(), - maker_amount: maker_amount_raw.to_string(), - taker_amount: taker_amount_raw.to_string(), - expiration: expiration.to_string(), - nonce: "0".to_string(), - fee_rate_bps: fee_rate_bps.to_string(), - side: "SELL".to_string(), - signature_type: sig_type, - signature, - }; - - let order_req = OrderRequest { - order: order_body, - owner: creds.api_key.clone(), - order_type: effective_order_type.to_uppercase(), - post_only, + // Sign and submit the order using the correct version's struct and exchange contract. + let resp = match clob_version { + OrderVersion::V2 => { + let timestamp_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + let params = OrderParamsV2 { + salt, + maker: maker_addr.clone(), + signer: signer_addr.clone(), + token_id: token_id.clone(), + maker_amount: maker_amount_raw as u64, + taker_amount: taker_amount_raw as u64, + side: 1, // SELL + signature_type: sig_type, + timestamp_ms, + metadata: BYTES32_ZERO.to_string(), + builder: BYTES32_ZERO.to_string(), + }; + let signature = sign_order_v2_via_onchainos(¶ms, neg_risk).await?; + let order_body = OrderBodyV2 { + salt, + maker: maker_addr.clone(), + signer: signer_addr.clone(), + token_id: token_id.clone(), + maker_amount: maker_amount_raw.to_string(), + taker_amount: taker_amount_raw.to_string(), + side: "SELL".to_string(), + signature_type: sig_type, + timestamp: timestamp_ms.to_string(), + metadata: BYTES32_ZERO.to_string(), + builder: BYTES32_ZERO.to_string(), + signature, + }; + let order_req = OrderRequestV2 { + order: order_body, + owner: creds.api_key.clone(), + order_type: effective_order_type.to_uppercase(), + post_only, + expiration: if expiration > 0 { expiration.to_string() } else { String::new() }, + }; + post_order(&client, &signer_addr, &creds, &order_req).await? + } + OrderVersion::V1 => { + let params = OrderParams { + salt, + maker: maker_addr.clone(), + signer: signer_addr.clone(), + taker: "0x0000000000000000000000000000000000000000".to_string(), + token_id: token_id.clone(), + maker_amount: maker_amount_raw as u64, + taker_amount: taker_amount_raw as u64, + expiration, + nonce: 0, + fee_rate_bps, + side: 1, // SELL + signature_type: sig_type, + }; + let signature = sign_order_via_onchainos(¶ms, neg_risk).await?; + let order_body = OrderBody { + salt, + maker: maker_addr.clone(), + signer: signer_addr.clone(), + taker: "0x0000000000000000000000000000000000000000".to_string(), + token_id: token_id.clone(), + maker_amount: maker_amount_raw.to_string(), + taker_amount: taker_amount_raw.to_string(), + expiration: expiration.to_string(), + nonce: "0".to_string(), + fee_rate_bps: fee_rate_bps.to_string(), + side: "SELL".to_string(), + signature_type: sig_type, + signature, + }; + let order_req = OrderRequest { + order: order_body, + owner: creds.api_key.clone(), + order_type: effective_order_type.to_uppercase(), + post_only, + }; + // The order owner for L2 auth must always be the EOA (API key holder), + // regardless of trading mode. In POLY_PROXY mode the maker field in the + // order struct is the proxy, but the HTTP owner must match the API key. + post_order(&client, &signer_addr, &creds, &order_req).await? + } }; - // The order owner for L2 auth must always be the EOA (API key holder), - // regardless of trading mode. In POLY_PROXY mode the maker field in the - // order struct is the proxy, but the HTTP owner must match the API key. - let resp = post_order(&client, &signer_addr, &creds, &order_req).await?; - if resp.success != Some(true) { let msg = resp.error_msg.as_deref().unwrap_or("unknown error"); if msg.to_uppercase().contains("INVALID_ORDER_MIN_SIZE") { @@ -458,33 +515,15 @@ pub async fn run( msg ); } - bail!("Order placement failed: {}", msg); - } - - let shares_filled = maker_amount_raw as f64 / 1_000_000.0; - if let Some(sid) = strategy_id.filter(|s| !s.is_empty()) { - let ts_now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0); - let report_payload = serde_json::json!({ - "wallet": signer_addr, - "proxyAddress": creds.proxy_wallet.as_deref().unwrap_or(""), - "order_id": resp.order_id.clone().unwrap_or_default(), - "tx_hashes": resp.tx_hashes, - "market_id": condition_id, - "asset_id": token_id, - "side": "SELL", - "amount": format!("{}", shares_filled), - "symbol": "USDC.e", - "price": format!("{}", limit_price), - "timestamp": ts_now, - "strategy_id": sid, - "plugin_name": "polymarket-plugin", - }); - if let Err(e) = crate::onchainos::report_plugin_info(&report_payload).await { - eprintln!("[polymarket] Warning: report-plugin-info failed: {}", e); + if msg_upper.contains("ORDER_VERSION_MISMATCH") || msg_upper.contains("VERSION_MISMATCH") { + bail!( + "Order rejected: CLOB version mismatch (server reported: {}). \ + The server may have just switched to a different order version. \ + Run the command again to re-detect the current version.", + msg + ); } + bail!("Order placement failed: {}", msg); } let result = serde_json::json!({ @@ -498,7 +537,7 @@ pub async fn run( "side": "SELL", "order_type": effective_order_type.to_uppercase(), "limit_price": limit_price, - "shares": shares_filled, + "shares": maker_amount_raw as f64 / 1_000_000.0, "usdc_out": taker_amount_raw as f64 / 1_000_000.0, "post_only": post_only, "expires": if expiration > 0 { serde_json::Value::Number(expiration.into()) } else { serde_json::Value::Null }, @@ -515,3 +554,22 @@ fn rand_salt() -> u64 { getrandom::getrandom(&mut bytes).expect("getrandom failed"); u64::from_le_bytes(bytes) & 0x001F_FFFF_FFFF_FFFF } + +/// Approve CTF tokens (setApprovalForAll) for the correct exchange contract based on CLOB version. +/// +/// V2 migration: V2 introduces new exchange contract addresses. Users who already +/// approved V1 contracts will get an automatic V2 approval on their first V2 sell. +async fn approve_ctf_versioned(neg_risk: bool, version: OrderVersion) -> anyhow::Result { + use crate::config::Contracts; + use crate::onchainos::ctf_set_approval_for_all; + + let ctf = Contracts::CTF; + let exchange_addr = Contracts::exchange(version, neg_risk); + + if neg_risk { + ctf_set_approval_for_all(ctf, exchange_addr).await?; + ctf_set_approval_for_all(ctf, Contracts::NEG_RISK_ADAPTER).await + } else { + ctf_set_approval_for_all(ctf, exchange_addr).await + } +} diff --git a/skills/polymarket-plugin/src/commands/setup_proxy.rs b/skills/polymarket-plugin/src/commands/setup_proxy.rs index feec2a1db..19282b9fc 100644 --- a/skills/polymarket-plugin/src/commands/setup_proxy.rs +++ b/skills/polymarket-plugin/src/commands/setup_proxy.rs @@ -1,11 +1,13 @@ /// `polymarket setup-proxy` — create a Polymarket proxy wallet and switch to POLY_PROXY mode. /// /// Flow: -/// 1. Check if proxy wallet already exists (via /profile API) +/// 1. Check if proxy wallet already exists (via cached creds or on-chain) /// 2. If not: call PROXY_FACTORY.proxy([]) on-chain to deploy one (one-time POL gas cost) -/// 3. Re-fetch proxy wallet address from /profile +/// 3. Resolve the proxy address from the transaction trace /// 4. Persist proxy_wallet + mode=PolyProxy in creds.json -/// 5. Set up the 6 one-time USDC.e / CTF approvals on the proxy wallet so trading is gasless: +/// 5. Set up one-time approvals on the proxy wallet so trading is gasless: +/// +/// V1 (6 txs — USDC.e collateral): /// USDC.e.approve(CTF_EXCHANGE, MAX_UINT) /// CTF.setApprovalForAll(CTF_EXCHANGE, true) /// USDC.e.approve(NEG_RISK_CTF_EXCHANGE, MAX_UINT) @@ -13,10 +15,16 @@ /// USDC.e.approve(NEG_RISK_ADAPTER, MAX_UINT) /// CTF.setApprovalForAll(NEG_RISK_ADAPTER, true) /// +/// V2 (4 txs — pUSD collateral, new exchange contracts post-2026-04-28): +/// pUSD.approve(CTF_EXCHANGE_V2, MAX_UINT) +/// pUSD.approve(NEG_RISK_CTF_EXCHANGE_V2, MAX_UINT) +/// pUSD.approve(NEG_RISK_ADAPTER, MAX_UINT) +/// USDC.e.approve(COLLATERAL_ONRAMP, MAX_UINT) ← auto-wrap USDC.e → pUSD +/// /// After setup, all subsequent buy/sell commands use POLY_PROXY mode (no POL for trading). /// Run `polymarket switch-mode --mode eoa` to revert to EOA mode at any time. -use anyhow::{bail, Context as _, Result}; +use anyhow::{Context as _, Result}; use reqwest::Client; pub async fn run(dry_run: bool) -> Result<()> { @@ -32,11 +40,10 @@ pub async fn run(dry_run: bool) -> Result<()> { let signer_addr = crate::onchainos::get_wallet_address().await?; let mut creds = crate::auth::ensure_credentials(&client, &signer_addr).await?; - // Step 1: check if proxy wallet already exists. + // Step 1: check if proxy wallet already exists in cached creds. if let Some(ref proxy) = creds.proxy_wallet { if creds.mode == crate::config::TradingMode::PolyProxy { let proxy = proxy.clone(); - // Approvals might not have been set up by older versions — ensure them now. eprintln!("[polymarket] Proxy wallet already configured. Checking approvals..."); ensure_proxy_approvals(&proxy, dry_run).await?; println!( @@ -120,7 +127,7 @@ pub async fn run(dry_run: bool) -> Result<()> { "dry_run": true, "data": { "signer": signer_addr, - "action": "would call PROXY_FACTORY.proxy([]) to deploy proxy wallet, then set 6 USDC.e/CTF approvals", + "action": "would call PROXY_FACTORY.proxy([]) to deploy proxy wallet, then set 10 USDC.e/pUSD/CTF approvals", "note": "dry-run: no transaction submitted" } }) @@ -132,7 +139,7 @@ pub async fn run(dry_run: bool) -> Result<()> { let tx_hash = crate::onchainos::create_proxy_wallet().await?; eprintln!("[polymarket] Proxy wallet deploy tx: {}", tx_hash); - // Step 3: resolve the proxy address from the transaction trace. + // Resolve the proxy address from the transaction trace. eprintln!("[polymarket] Resolving proxy wallet address from transaction trace..."); let proxy_addr = crate::onchainos::get_proxy_address_from_tx(&tx_hash) .await @@ -147,7 +154,7 @@ pub async fn run(dry_run: bool) -> Result<()> { creds.mode = crate::config::TradingMode::PolyProxy; crate::config::save_credentials(&creds)?; - // Step 5: set up the 6 one-time approvals so trading is gasless. + // Step 5: set up the one-time approvals so trading is gasless. ensure_proxy_approvals(&proxy_addr, dry_run).await?; println!( @@ -166,48 +173,80 @@ pub async fn run(dry_run: bool) -> Result<()> { Ok(()) } -/// Set up the 6 one-time on-chain approvals required for gasless trading in POLY_PROXY mode. +/// Set up the one-time on-chain approvals required for gasless trading in POLY_PROXY mode. /// -/// Checks the current USDC.e allowance to CTF_EXCHANGE first. If already non-zero, -/// skips all 6 (idempotent guard to avoid spending gas on repeat runs). +/// V1 block (6 txs): USDC.e + CTF approved to V1 exchange contracts. +/// Idempotent: skipped if USDC.e→CTF_EXCHANGE allowance is already non-zero. +/// +/// V2 block (4 txs): pUSD approved to V2 exchange contracts + USDC.e approved to +/// COLLATERAL_ONRAMP for auto-wrap. Idempotent: skipped if pUSD→CTF_EXCHANGE_V2 +/// allowance is already non-zero. async fn ensure_proxy_approvals(proxy_addr: &str, dry_run: bool) -> Result<()> { use crate::config::Contracts; - // Fast-path: if CTF_EXCHANGE allowance is already set, all 6 were done together. - let existing = crate::onchainos::get_usdc_allowance(proxy_addr, Contracts::CTF_EXCHANGE).await + // ── V1 approvals ───────────────────────────────────────────────────────── + let v1_existing = crate::onchainos::get_usdc_allowance(proxy_addr, Contracts::CTF_EXCHANGE) + .await .unwrap_or(0); - if existing > 0 { - eprintln!("[polymarket] USDC.e approvals already set (allowance: {}).", existing); - return Ok(()); + if v1_existing > 0 { + eprintln!("[polymarket] USDC.e approvals already set (allowance: {}).", v1_existing); + } else if dry_run { + eprintln!("[polymarket] dry-run: would set 6 V1 approvals (USDC.e + CTF × 3 contracts)."); + } else { + eprintln!("[polymarket] Setting up V1 USDC.e / CTF approvals for gasless trading..."); + let v1_approvals: &[(&str, bool, &str)] = &[ + (Contracts::CTF_EXCHANGE, false, "CTF Exchange / USDC.e"), + (Contracts::CTF_EXCHANGE, true, "CTF Exchange / CTF"), + (Contracts::NEG_RISK_CTF_EXCHANGE, false, "Neg Risk CTF Exchange / USDC.e"), + (Contracts::NEG_RISK_CTF_EXCHANGE, true, "Neg Risk CTF Exchange / CTF"), + (Contracts::NEG_RISK_ADAPTER, false, "Neg Risk Adapter / USDC.e"), + (Contracts::NEG_RISK_ADAPTER, true, "Neg Risk Adapter / CTF"), + ]; + for (spender, is_ctf, label) in v1_approvals { + eprintln!("[polymarket] Approving {} ...", label); + let tx = if *is_ctf { + crate::onchainos::proxy_ctf_set_approval_for_all(spender).await? + } else { + crate::onchainos::proxy_usdc_approve(spender).await? + }; + eprintln!("[polymarket] tx: {}", tx); + crate::onchainos::wait_for_tx_receipt(&tx, 30).await?; + } + eprintln!("[polymarket] V1 approvals confirmed."); } - if dry_run { - eprintln!("[polymarket] dry-run: would set 6 approvals (USDC.e + CTF × 3 contracts)."); - return Ok(()); - } + // ── V2 approvals ───────────────────────────────────────────────────────── + // pUSD approvals to V2 exchange contracts + USDC.e to COLLATERAL_ONRAMP. + let v2_existing = crate::onchainos::get_pusd_allowance(proxy_addr, Contracts::CTF_EXCHANGE_V2) + .await + .unwrap_or(0); + if v2_existing > 0 { + eprintln!("[polymarket] pUSD V2 approvals already set (allowance: {}).", v2_existing); + } else if dry_run { + eprintln!("[polymarket] dry-run: would set 4 V2 approvals (pUSD × 3 contracts + USDC.e → COLLATERAL_ONRAMP)."); + } else { + eprintln!("[polymarket] Setting up V2 pUSD approvals for gasless V2 trading..."); + + let v2_pusd_spenders: &[(&str, &str)] = &[ + (Contracts::CTF_EXCHANGE_V2, "V2 CTF Exchange / pUSD"), + (Contracts::NEG_RISK_CTF_EXCHANGE_V2, "V2 Neg Risk CTF Exchange / pUSD"), + (Contracts::NEG_RISK_ADAPTER, "Neg Risk Adapter / pUSD"), + ]; + for (spender, label) in v2_pusd_spenders { + eprintln!("[polymarket] Approving {} ...", label); + let tx = crate::onchainos::proxy_pusd_approve(spender).await?; + eprintln!("[polymarket] tx: {}", tx); + crate::onchainos::wait_for_tx_receipt(&tx, 30).await?; + } + + // USDC.e → COLLATERAL_ONRAMP: allows the proxy to auto-wrap USDC.e → pUSD on V2 buys. + eprintln!("[polymarket] Approving COLLATERAL_ONRAMP / USDC.e ..."); + let onramp_tx = crate::onchainos::proxy_usdc_approve(Contracts::COLLATERAL_ONRAMP).await?; + eprintln!("[polymarket] tx: {}", onramp_tx); + crate::onchainos::wait_for_tx_receipt(&onramp_tx, 30).await?; - eprintln!("[polymarket] Setting up one-time USDC.e / CTF approvals for gasless trading..."); - - let approvals: &[(&str, bool, &str)] = &[ - (Contracts::CTF_EXCHANGE, false, "CTF Exchange / USDC.e"), - (Contracts::CTF_EXCHANGE, true, "CTF Exchange / CTF"), - (Contracts::NEG_RISK_CTF_EXCHANGE, false, "Neg Risk CTF Exchange / USDC.e"), - (Contracts::NEG_RISK_CTF_EXCHANGE, true, "Neg Risk CTF Exchange / CTF"), - (Contracts::NEG_RISK_ADAPTER, false, "Neg Risk Adapter / USDC.e"), - (Contracts::NEG_RISK_ADAPTER, true, "Neg Risk Adapter / CTF"), - ]; - - for (spender, is_ctf, label) in approvals { - eprintln!("[polymarket] Approving {} ...", label); - let tx = if *is_ctf { - crate::onchainos::proxy_ctf_set_approval_for_all(spender).await? - } else { - crate::onchainos::proxy_usdc_approve(spender).await? - }; - eprintln!("[polymarket] tx: {}", tx); - crate::onchainos::wait_for_tx_receipt(&tx, 30).await?; + eprintln!("[polymarket] V2 approvals confirmed. Proxy wallet fully ready for V1 and V2 gasless trading."); } - eprintln!("[polymarket] All 6 approvals confirmed. Proxy wallet ready for gasless trading."); Ok(()) } diff --git a/skills/polymarket-plugin/src/commands/watch.rs b/skills/polymarket-plugin/src/commands/watch.rs new file mode 100644 index 000000000..5b95febc3 --- /dev/null +++ b/skills/polymarket-plugin/src/commands/watch.rs @@ -0,0 +1,84 @@ +use anyhow::{bail, Result}; +use reqwest::Client; + +use crate::api::{get_clob_market, get_gamma_market_by_slug, get_market_live_activity}; + +/// Watch live trade activity for a market, polling every `interval` seconds. +/// +/// Prints new trade events as they arrive. Runs until Ctrl+C. +/// `market_id`: condition_id (0x-prefixed) or slug. +/// `interval`: seconds between polls (minimum 2, default 5). +/// `limit`: max events to fetch per poll (default 10). +pub async fn run(market_id: &str, interval: u64, limit: u32) -> Result<()> { + if interval < 2 { + bail!("--interval must be at least 2 seconds"); + } + + let client = Client::new(); + + // Resolve market_id → condition_id + question label. + let (condition_id, label) = if market_id.starts_with("0x") || market_id.starts_with("0X") { + let m = get_clob_market(&client, market_id).await?; + let q = m.question.unwrap_or_else(|| market_id.to_string()); + (m.condition_id, q) + } else { + let m = get_gamma_market_by_slug(&client, market_id).await?; + let cid = m.condition_id + .ok_or_else(|| anyhow::anyhow!("Market '{}' has no condition_id", market_id))?; + let q = m.question.unwrap_or_else(|| market_id.to_string()); + (cid, q) + }; + + eprintln!("[polymarket] Watching: {}", label); + eprintln!("[polymarket] Market: {}", condition_id); + eprintln!("[polymarket] Polling every {}s — press Ctrl+C to stop.\n", interval); + + // Track the most recent event timestamp to avoid reprinting duplicates. + let mut last_seen_ts: Option = None; + + loop { + match get_market_live_activity(&client, &condition_id, limit).await { + Ok(events) => { + // Events are newest-first; filter out already-seen timestamps. + let new_events: Vec<_> = events + .iter() + .filter(|e| { + let ts = e.timestamp.unwrap_or(0); + last_seen_ts.map_or(true, |last| ts > last) + }) + .collect(); + + // Update the high-water mark. + if let Some(newest) = events.first().and_then(|e| e.timestamp) { + last_seen_ts = Some(newest); + } + + // Print in chronological order (newest-first → reverse before printing). + for event in new_events.iter().rev() { + let price = event.price.as_deref().unwrap_or("?"); + let size = event.size.as_deref().unwrap_or("?"); + let side = event.side.as_deref().unwrap_or("?"); + let outcome = event.outcome.as_deref().unwrap_or("?"); + let ts = event.timestamp.unwrap_or(0); + println!( + "{}", + serde_json::to_string(&serde_json::json!({ + "timestamp": ts, + "side": side, + "outcome": outcome, + "price": price, + "size": size, + "tx_hash": event.tx_hash, + })) + .unwrap_or_default() + ); + } + } + Err(e) => { + eprintln!("[polymarket] Warning: poll failed ({}); retrying...", e); + } + } + + tokio::time::sleep(std::time::Duration::from_secs(interval)).await; + } +} diff --git a/skills/polymarket-plugin/src/commands/withdraw.rs b/skills/polymarket-plugin/src/commands/withdraw.rs index f7de6156e..e87bbd30f 100644 --- a/skills/polymarket-plugin/src/commands/withdraw.rs +++ b/skills/polymarket-plugin/src/commands/withdraw.rs @@ -1,12 +1,19 @@ -/// `polymarket withdraw` — transfer USDC.e from proxy wallet back to EOA wallet. +/// `polymarket withdraw` — transfer collateral from proxy wallet back to EOA wallet. /// -/// Uses PROXY_FACTORY.proxy([op]) to execute a USDC.e transfer from the proxy's context. -/// The op encodes: transfer(eoa_address, amount) on the USDC.e contract. +/// Uses PROXY_FACTORY.proxy([op]) to execute an ERC-20 transfer from the proxy's context. +/// The token depends on the CLOB version: +/// V1 → USDC.e (legacy exchange) +/// V2 → pUSD (Polymarket USD, from ~2026-04-28) +/// +/// The command auto-detects which token the proxy holds (pUSD balance checked first, +/// fallback to USDC.e) and withdraws whichever has the requested amount. use anyhow::{bail, Result}; -use crate::onchainos::{get_usdc_balance, get_wallet_address}; +use crate::onchainos::{get_pusd_balance, get_usdc_balance, get_wallet_address}; pub async fn run(amount: &str, dry_run: bool) -> Result<()> { + use crate::config::Contracts; + let eoa = get_wallet_address().await?; let creds = crate::config::load_credentials() .ok() @@ -23,15 +30,29 @@ pub async fn run(amount: &str, dry_run: bool) -> Result<()> { } let amount_raw = (amount_f * 1_000_000.0).round() as u128; - // Check proxy balance on-chain - let proxy_bal = get_usdc_balance(&proxy).await?; - let proxy_bal_raw = (proxy_bal * 1_000_000.0).floor() as u128; - if proxy_bal_raw < amount_raw { + // Auto-detect which token the proxy holds: pUSD (V2) or USDC.e (V1). + // Check both in parallel and pick whichever has enough balance. + let (pusd_bal_r, usdc_bal_r) = tokio::join!( + get_pusd_balance(&proxy), + get_usdc_balance(&proxy), + ); + let pusd_bal = pusd_bal_r.unwrap_or(0.0); + let usdc_e_bal = usdc_bal_r.unwrap_or(0.0); + let pusd_raw = (pusd_bal * 1_000_000.0).floor() as u128; + let usdc_e_raw = (usdc_e_bal * 1_000_000.0).floor() as u128; + + // Prefer pUSD (V2 collateral) if it covers the requested amount. + let (token_name, token_addr, proxy_bal) = if pusd_raw >= amount_raw { + ("pUSD", Contracts::PUSD, pusd_bal) + } else if usdc_e_raw >= amount_raw { + ("USDC.e", Contracts::USDC_E, usdc_e_bal) + } else { bail!( - "Insufficient proxy wallet balance: have ${:.2}, need ${:.2}", - proxy_bal, amount_f + "Insufficient proxy wallet balance: have ${:.2} pUSD + ${:.2} USDC.e, need ${:.2}. \ + Check `polymarket balance` for details.", + pusd_bal, usdc_e_bal, amount_f ); - } + }; if dry_run { println!("{}", serde_json::to_string_pretty(&serde_json::json!({ @@ -40,17 +61,24 @@ pub async fn run(amount: &str, dry_run: bool) -> Result<()> { "data": { "from": proxy, "to": eoa, - "token": "USDC.e", + "token": token_name, + "token_contract": token_addr, "amount": amount_f, "amount_raw": amount_raw, + "proxy_balance": proxy_bal, "note": "dry-run: no transaction submitted" } }))?); return Ok(()); } - eprintln!("[polymarket] Withdrawing ${:.2} USDC.e from proxy {} to EOA {}...", amount_f, proxy, eoa); - let tx_hash = crate::onchainos::withdraw_usdc_from_proxy(&eoa, amount_raw).await?; + eprintln!("[polymarket] Withdrawing ${:.2} {} from proxy {} to EOA {}...", amount_f, token_name, proxy, eoa); + // Route to the correct onchainos withdraw helper based on token. + let tx_hash = if token_addr == Contracts::PUSD { + crate::onchainos::withdraw_pusd_from_proxy(&eoa, amount_raw).await? + } else { + crate::onchainos::withdraw_usdc_from_proxy(&eoa, amount_raw).await? + }; eprintln!("[polymarket] Withdraw tx: {}", tx_hash); eprintln!("[polymarket] Waiting for confirmation..."); crate::onchainos::wait_for_tx_receipt(&tx_hash, 30).await?; @@ -61,7 +89,7 @@ pub async fn run(amount: &str, dry_run: bool) -> Result<()> { "tx_hash": tx_hash, "from": proxy, "to": eoa, - "token": "USDC.e", + "token": token_name, "amount": amount_f, } }))?); diff --git a/skills/polymarket-plugin/src/config.rs b/skills/polymarket-plugin/src/config.rs index 5be797599..2485cc451 100644 --- a/skills/polymarket-plugin/src/config.rs +++ b/skills/polymarket-plugin/src/config.rs @@ -100,27 +100,56 @@ pub fn save_credentials(creds: &Credentials) -> Result<()> { Ok(()) } +/// CLOB order version — determines which exchange contract and EIP-712 struct to use. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OrderVersion { + /// Original exchange (0x4bFb41...). EIP-712 domain version "1". + V1, + /// New exchange released 2026-04-21 (0xE11118...). EIP-712 domain version "2". + V2, +} + /// Contract addresses on Polygon (chain 137) pub struct Contracts; impl Contracts { + // ── V1 exchange contracts (legacy) ──────────────────────────────────────── pub const CTF_EXCHANGE: &'static str = "0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E"; pub const NEG_RISK_CTF_EXCHANGE: &'static str = "0xC5d563A36AE78145C45a50134d48A1215220f80a"; + + // ── V2 exchange contracts (released 2026-04-21) ─────────────────────────── + pub const CTF_EXCHANGE_V2: &'static str = "0xE111180000d2663C0091e4f400237545B87B996B"; + pub const NEG_RISK_CTF_EXCHANGE_V2: &'static str = "0xe2222d279d744050d28e00520010520000310F59"; + + // ── Shared / unchanged contracts ────────────────────────────────────────── pub const NEG_RISK_ADAPTER: &'static str = "0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296"; pub const CTF: &'static str = "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045"; pub const USDC_E: &'static str = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"; + /// Polymarket USD — replaces USDC.e as collateral for V2 exchange contracts (live ~2026-04-28). + pub const PUSD: &'static str = "0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB"; + /// Collateral Onramp: wrap(address _asset, address _to, uint256 _amount) USDC.e → pUSD. + pub const COLLATERAL_ONRAMP: &'static str = "0x93070a847efEf7F70739046A929D47a521F5B8ee"; pub const PROXY_FACTORY: &'static str = "0xaB45c5A4B0c941a2F231C04C3f49182e1A254052"; pub const GNOSIS_SAFE_FACTORY: &'static str = "0xaacfeea03eb1561c4e67d661e40682bd20e3541b"; pub const UMA_ADAPTER: &'static str = "0x6A9D222616C90FcA5754cd1333cFD9b7fb6a4F74"; + /// Return the V1 exchange address for the given market type. pub fn exchange_for(neg_risk: bool) -> &'static str { - if neg_risk { - Self::NEG_RISK_CTF_EXCHANGE - } else { - Self::CTF_EXCHANGE - } + if neg_risk { Self::NEG_RISK_CTF_EXCHANGE } else { Self::CTF_EXCHANGE } } + /// Return the V2 exchange address for the given market type. + pub fn exchange_for_v2(neg_risk: bool) -> &'static str { + if neg_risk { Self::NEG_RISK_CTF_EXCHANGE_V2 } else { Self::CTF_EXCHANGE_V2 } + } + + /// Return the exchange address for the given version and market type. + pub fn exchange(version: OrderVersion, neg_risk: bool) -> &'static str { + match version { + OrderVersion::V1 => Self::exchange_for(neg_risk), + OrderVersion::V2 => Self::exchange_for_v2(neg_risk), + } + } } /// Base URLs @@ -137,33 +166,4 @@ impl Urls { pub const BASE_RPC: &'static str = "https://base.drpc.org"; pub const OPTIMISM_RPC: &'static str = "https://optimism.drpc.org"; pub const BNB_RPC: &'static str = "https://bsc.publicnode.com"; - - // ── Env-var-overridable accessors ──────────────────────────────────────── - // - // These are used in place of the const fields throughout the codebase so - // that integration tests can redirect HTTP traffic to local mock servers - // by setting the corresponding POLYMARKET_TEST_* env vars. - // - // Production code never sets these vars, so the const defaults always apply - // in normal operation. - - pub fn polygon_rpc() -> String { - std::env::var("POLYMARKET_TEST_POLYGON_RPC") - .unwrap_or_else(|_| Self::POLYGON_RPC.to_string()) - } - - pub fn clob() -> String { - std::env::var("POLYMARKET_TEST_CLOB_URL") - .unwrap_or_else(|_| Self::CLOB.to_string()) - } - - pub fn gamma() -> String { - std::env::var("POLYMARKET_TEST_GAMMA_URL") - .unwrap_or_else(|_| Self::GAMMA.to_string()) - } - - pub fn data() -> String { - std::env::var("POLYMARKET_TEST_DATA_URL") - .unwrap_or_else(|_| Self::DATA.to_string()) - } } diff --git a/skills/polymarket-plugin/src/main.rs b/skills/polymarket-plugin/src/main.rs index 14b2bd258..eb9031038 100644 --- a/skills/polymarket-plugin/src/main.rs +++ b/skills/polymarket-plugin/src/main.rs @@ -22,9 +22,6 @@ struct Cli { #[derive(Subcommand)] enum Commands { - /// Check wallet assets and get a recommended next step (region check, balances, positions, onboarding guidance) - Quickstart(commands::quickstart::QuickstartArgs), - /// Check whether Polymarket is accessible from your current IP (run before topping up USDC) CheckAccess, @@ -65,6 +62,13 @@ enum Commands { /// Show POL and USDC.e balances for the EOA wallet (and proxy wallet if initialized) Balance, + /// Check Polymarket status, wallet balances, open positions, and onboarding readiness + Quickstart { + /// Wallet address to query (defaults to active onchainos wallet) + #[arg(long)] + address: Option, + }, + /// Show current and next slot for a recurring series market (no auth required) GetSeries { /// Series identifier (e.g. btc-5m, eth-15m, btc-4h). Omit to list all. @@ -95,7 +99,8 @@ enum Commands { #[arg(long)] price: Option, - /// Order type: GTC (resting limit) or FOK (fill-or-kill market) + /// Order type: GTC (resting limit), FOK (fill-or-kill market), GTD (good-till-date), + /// or FAK (fill-and-kill: fills as much as possible, cancels remainder) #[arg(long, default_value = "GTC")] order_type: String, @@ -135,10 +140,6 @@ enum Commands { /// Skip market lookup — use a known token ID directly (from get-series or get-market output). #[arg(long)] token_id: Option, - - /// Strategy ID for attribution — reported to OKX backend alongside the order - #[arg(long)] - strategy_id: Option, }, /// Sell YES or NO shares in a market (signs via onchainos wallet) @@ -160,7 +161,8 @@ enum Commands { #[arg(long)] price: Option, - /// Order type: GTC (resting limit) or FOK (fill-or-kill market) + /// Order type: GTC (resting limit), FOK (fill-or-kill market), GTD (good-till-date), + /// or FAK (fill-and-kill: fills as much as possible, cancels remainder) #[arg(long, default_value = "GTC")] order_type: String, @@ -194,10 +196,6 @@ enum Commands { /// Skip market lookup — use a known token ID directly (from get-series or get-market output). #[arg(long)] token_id: Option, - - /// Strategy ID for attribution — reported to OKX backend alongside the order - #[arg(long)] - strategy_id: Option, }, /// Create a Polymarket proxy wallet and switch to gasless POLY_PROXY trading mode. @@ -254,7 +252,7 @@ enum Commands { /// Redeem winning outcome tokens after a market resolves (signs via onchainos wallet) Redeem { /// Market identifier: condition_id (0x-prefixed hex) or slug. Omit when using --all. - #[arg(long)] + #[arg(long, alias = "condition-id")] market_id: Option, /// Redeem all redeemable positions across EOA and proxy wallets in one pass @@ -264,10 +262,6 @@ enum Commands { /// Preview the redemption call without submitting the transaction #[arg(long)] dry_run: bool, - - /// Strategy ID for attribution — reported to OKX backend after successful redeem - #[arg(long)] - strategy_id: Option, }, /// Cancel a single open order by order ID (signs via onchainos wallet) @@ -285,6 +279,66 @@ enum Commands { all: bool, }, + /// List open orders for the authenticated user (requires auth). + /// Detects V1 vs V2 order signing automatically — useful during CLOB v2 migration. + Orders { + /// Filter by order state: OPEN, MATCHED, DELAYED, UNMATCHED (default: OPEN) + #[arg(long, default_value = "OPEN")] + state: String, + + /// Show only V1-signed orders placed before the CLOB v2 upgrade (2026-04-21) + #[arg(long)] + v1: bool, + + /// Maximum number of orders to return (default: all) + #[arg(long)] + limit: Option, + }, + + /// Watch live trade activity for a market, polling every few seconds (Ctrl+C to stop). + Watch { + /// Market identifier: condition_id (0x-prefixed hex) or slug + #[arg(long)] + market_id: String, + + /// Poll interval in seconds (minimum 2, default 5) + #[arg(long, default_value = "5")] + interval: u64, + + /// Maximum number of events to fetch per poll + #[arg(long, default_value = "10")] + limit: u32, + }, + + /// Request a block-trade quote from a Polymarket market maker (CLOB v2 RFQ). + /// Re-run with --confirm to accept the quote and execute the trade. + Rfq { + /// Market identifier: condition_id (0x-prefixed hex) or slug + #[arg(long)] + market_id: String, + + /// Outcome to buy: "yes" or "no" + #[arg(long)] + outcome: String, + + /// USDC.e amount to spend (e.g. "5000" = $5,000) + #[arg(long)] + amount: String, + + /// Accept the quoted price and execute the block trade + #[arg(long)] + confirm: bool, + + /// Preview without requesting a quote + #[arg(long)] + dry_run: bool, + }, + + /// Create a read-only Polymarket API key (CLOB v2). Useful for monitoring + /// scripts and dashboards that need read access without trading capability. + #[command(name = "create-readonly-key")] + CreateReadonlyKey, + /// List upcoming 5-minute crypto Up/Down markets on Polymarket. /// Supported coins: BTC, ETH, SOL, XRP, BNB, DOGE, HYPE #[command(name = "list-5m")] @@ -304,9 +358,6 @@ async fn main() { let cli = Cli::parse(); let result = match cli.command { - Commands::Quickstart(args) => { - commands::quickstart::run(args).await - } Commands::CheckAccess => { commands::check_access::run().await } @@ -322,6 +373,9 @@ async fn main() { Commands::Balance => { commands::balance::run().await } + Commands::Quickstart { address } => { + commands::quickstart::run(commands::quickstart::QuickstartArgs { address }).await + } Commands::GetSeries { series, list } => { commands::get_series::run(series.as_deref(), list).await } @@ -339,9 +393,8 @@ async fn main() { mode, confirm: _confirm, token_id, - strategy_id, } => { - commands::buy::run(market_id.as_deref(), &outcome, &amount, price, &order_type, approve, dry_run, round_up, post_only, expires, mode.as_deref(), token_id.as_deref(), strategy_id.as_deref()).await + commands::buy::run(market_id.as_deref(), &outcome, &amount, price, &order_type, approve, dry_run, round_up, post_only, expires, mode.as_deref(), token_id.as_deref()).await } Commands::Sell { market_id, @@ -356,9 +409,8 @@ async fn main() { mode, confirm: _confirm, token_id, - strategy_id, } => { - commands::sell::run(market_id.as_deref(), &outcome, &shares, price, &order_type, approve, dry_run, post_only, expires, mode.as_deref(), token_id.as_deref(), strategy_id.as_deref()).await + commands::sell::run(market_id.as_deref(), &outcome, &shares, price, &order_type, approve, dry_run, post_only, expires, mode.as_deref(), token_id.as_deref()).await } Commands::SetupProxy { dry_run } => { commands::setup_proxy::run(dry_run).await @@ -372,11 +424,11 @@ async fn main() { Commands::SwitchMode { mode } => { commands::switch_mode::run(&mode).await } - Commands::Redeem { market_id, all, dry_run, strategy_id } => { + Commands::Redeem { market_id, all, dry_run } => { if all { - commands::redeem::run_all(dry_run, strategy_id.as_deref()).await + commands::redeem::run_all(dry_run).await } else if let Some(mid) = market_id { - commands::redeem::run(&mid, dry_run, strategy_id.as_deref()).await + commands::redeem::run(&mid, dry_run).await } else { eprintln!("Error: provide --market-id or --all"); std::process::exit(1); @@ -395,6 +447,18 @@ async fn main() { )) } } + Commands::Orders { state, v1, limit } => { + commands::orders::run(&state, v1, limit).await + } + Commands::Watch { market_id, interval, limit } => { + commands::watch::run(&market_id, interval, limit).await + } + Commands::Rfq { market_id, outcome, amount, confirm, dry_run } => { + commands::rfq::run(&market_id, &outcome, &amount, confirm, dry_run).await + } + Commands::CreateReadonlyKey => { + commands::create_readonly_key::run().await + } Commands::List5m { coin, count } => { commands::list_5m::run(coin.as_deref(), count).await } diff --git a/skills/polymarket-plugin/src/onchainos.rs b/skills/polymarket-plugin/src/onchainos.rs index 4f3205dbd..d2aba06df 100644 --- a/skills/polymarket-plugin/src/onchainos.rs +++ b/skills/polymarket-plugin/src/onchainos.rs @@ -27,6 +27,15 @@ fn onchainos_bin() -> std::ffi::OsString { } } +/// Approval/receipt timeout in seconds — configurable via POLYMARKET_APPROVE_TIMEOUT_SECS. +/// Default: 90s (covers Polygon congestion windows where 30s caused false timeouts). +pub fn approve_timeout_secs() -> u64 { + std::env::var("POLYMARKET_APPROVE_TIMEOUT_SECS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(90) +} + /// Sign an EIP-712 structured data JSON via `onchainos sign-message --type eip712`. /// /// The JSON must include EIP712Domain in the `types` field — this is required for correct @@ -120,7 +129,6 @@ pub async fn get_wallet_address() -> Result { let stderr = String::from_utf8_lossy(&output.stderr); // Detect session-expiry / not-logged-in conditions from exit code or error text. - // onchainos emits these on stdout (as JSON) or stderr when the session lapses. let combined = format!("{}{}", stdout, stderr).to_lowercase(); let session_expired = !output.status.success() || combined.contains("session") @@ -129,13 +137,10 @@ pub async fn get_wallet_address() -> Result { || combined.contains("unauthenticated") || combined.contains("unauthorized"); - // Try to parse JSON in all cases — onchainos always emits JSON on stdout let parse_result = serde_json::from_str::(&stdout); - - // Check for explicit ok:false in the JSON response let json_ok = parse_result.as_ref().ok().and_then(|v| v["ok"].as_bool()); + if json_ok == Some(false) || (parse_result.is_err() && session_expired) { - // Surface a specific, actionable message so the agent knows exactly what to do anyhow::bail!( "onchainos session has expired or wallet is not connected. \ To recover: open a terminal (or use ! in this chat) and run \ @@ -147,14 +152,10 @@ pub async fn get_wallet_address() -> Result { let v = parse_result .map_err(|e| anyhow::anyhow!("wallet addresses parse error: {}\nraw: {}", e, stdout))?; - v["data"]["evm"][0]["address"] .as_str() .map(|s| s.to_string()) - .ok_or_else(|| anyhow::anyhow!( - "onchainos returned no wallet address. \ - Run `onchainos wallet login your@email.com` to connect a wallet, then retry." - )) + .ok_or_else(|| anyhow::anyhow!("Could not determine wallet address from onchainos output")) } /// Pad a hex address to 32 bytes (64 hex chars), no 0x prefix. @@ -328,7 +329,7 @@ async fn verify_eip1167_proxy(addr: &str) -> bool { "id": 1 }); if let Ok(r) = reqwest::Client::new() - .post(Urls::polygon_rpc()) + .post(Urls::POLYGON_RPC) .json(&body) .send() .await @@ -381,7 +382,7 @@ pub async fn get_existing_proxy(eoa_addr: &str) -> Result> { }); let resp = reqwest::Client::new() - .post(Urls::polygon_rpc()) + .post(Urls::POLYGON_RPC) .json(&body) .send() .await @@ -507,7 +508,86 @@ pub async fn withdraw_usdc_from_proxy(eoa_addr: &str, amount: u128) -> Result Result { + use sha3::{Digest, Keccak256}; + use crate::config::Contracts; + + let transfer_data = format!( + "a9059cbb{}{}", + pad_address(eoa_addr), + pad_u256(amount) + ); + let transfer_bytes = hex::decode(&transfer_data).expect("transfer calldata hex"); + let transfer_len = transfer_bytes.len(); + + let selector = Keccak256::digest(b"proxy((uint8,address,uint256,bytes)[])"); + let selector_hex = hex::encode(&selector[..4]); + let pusd_padded = pad_address(Contracts::PUSD); + let data_len_padded = format!("{:064x}", transfer_len); + let pad_len = (32 - transfer_len % 32) % 32; + let data_padded = format!("{}{}", transfer_data, "00".repeat(pad_len)); + + let calldata = format!( + "0x{}\ + {}\ + {}\ + {}\ + {}\ + {}\ + {}\ + {}\ + {}\ + {}", + selector_hex, + "0000000000000000000000000000000000000000000000000000000000000020", + "0000000000000000000000000000000000000000000000000000000000000001", + "0000000000000000000000000000000000000000000000000000000000000020", + "0000000000000000000000000000000000000000000000000000000000000001", // op = 1 (CALL) + pusd_padded, + "0000000000000000000000000000000000000000000000000000000000000000", + "0000000000000000000000000000000000000000000000000000000000000080", + data_len_padded, + data_padded, + ); + + let result = wallet_contract_call(Contracts::PROXY_FACTORY, &calldata).await?; + extract_tx_hash(&result) +} + /// Get USDC.e ERC-20 allowance for owner → spender. Returns raw amount (6 decimals). +pub async fn get_pusd_allowance(owner: &str, spender: &str) -> Result { + use crate::config::{Contracts, Urls}; + // allowance(address,address) selector = 0xdd62ed3e + let data = format!("0xdd62ed3e{}{}", pad_address(owner), pad_address(spender)); + let body = serde_json::json!({ + "jsonrpc": "2.0", + "method": "eth_call", + "params": [{ "to": Contracts::PUSD, "data": data }, "latest"], + "id": 1 + }); + let v: serde_json::Value = reqwest::Client::new() + .post(Urls::POLYGON_RPC) + .json(&body) + .send() + .await + .context("Polygon RPC request failed")? + .json() + .await + .context("parsing RPC response")?; + if let Some(err) = v.get("error") { + anyhow::bail!("Polygon RPC error: {}", err); + } + let hex = v["result"].as_str().unwrap_or("0x").trim_start_matches("0x"); + if hex.is_empty() || hex.chars().all(|c| c == '0') { + return Ok(0); + } + Ok(u128::from_str_radix(hex, 16).unwrap_or(u128::MAX)) +} + pub async fn get_usdc_allowance(owner: &str, spender: &str) -> Result { use crate::config::{Contracts, Urls}; // allowance(address,address) selector = 0xdd62ed3e @@ -519,7 +599,7 @@ pub async fn get_usdc_allowance(owner: &str, spender: &str) -> Result { "id": 1 }); let v: serde_json::Value = reqwest::Client::new() - .post(Urls::polygon_rpc()) + .post(Urls::POLYGON_RPC) .json(&body) .send() .await @@ -588,6 +668,58 @@ pub async fn proxy_ctf_set_approval_for_all(operator: &str) -> Result { extract_tx_hash(&result) } +/// Approve pUSD from the proxy wallet to a spender, via PROXY_FACTORY.proxy(). +/// +/// Encodes `PROXY_FACTORY.proxy([(1, PUSD, 0, approve(spender, maxUint))])`. +/// Used in POLY_PROXY mode to grant V2 exchange contracts (CTF_EXCHANGE_V2 / +/// NEG_RISK_CTF_EXCHANGE_V2 / NEG_RISK_ADAPTER) spending rights on the proxy wallet's pUSD. +/// Returns the tx hash. +pub async fn proxy_pusd_approve(spender: &str) -> Result { + use sha3::{Digest, Keccak256}; + use crate::config::Contracts; + + let approve_data = format!( + "095ea7b3{}{}", + pad_address(spender), + "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + ); + let approve_bytes = hex::decode(&approve_data).expect("approve calldata hex"); + let approve_len = approve_bytes.len(); + + let selector = Keccak256::digest(b"proxy((uint8,address,uint256,bytes)[])"); + let selector_hex = hex::encode(&selector[..4]); + let pusd_padded = pad_address(Contracts::PUSD); + let data_len_padded = format!("{:064x}", approve_len); + let pad_len = (32 - approve_len % 32) % 32; + let data_padded = format!("{}{}", approve_data, "00".repeat(pad_len)); + + let calldata = format!( + "0x{}\ + {}\ + {}\ + {}\ + {}\ + {}\ + {}\ + {}\ + {}\ + {}", + selector_hex, + "0000000000000000000000000000000000000000000000000000000000000020", + "0000000000000000000000000000000000000000000000000000000000000001", + "0000000000000000000000000000000000000000000000000000000000000020", + "0000000000000000000000000000000000000000000000000000000000000001", + pusd_padded, + "0000000000000000000000000000000000000000000000000000000000000000", + "0000000000000000000000000000000000000000000000000000000000000080", + data_len_padded, + data_padded, + ); + + let result = wallet_contract_call(Contracts::PROXY_FACTORY, &calldata).await?; + extract_tx_hash(&result) +} + /// Approve USDC.e from the proxy wallet to a spender, via PROXY_FACTORY.proxy(). /// /// Encodes `PROXY_FACTORY.proxy([(1, USDC_E, 0, approve(spender, maxUint))])`. @@ -678,24 +810,18 @@ pub async fn ctf_set_approval_for_all(ctf_addr: &str, operator: &str) -> Result< /// Approve USDC.e allowance before a BUY order. /// -/// Always approves `u128::MAX` (unlimited) so that future trades on the same market -/// do not trigger a second approval transaction. Approving a specific order amount -/// downsizes any pre-existing MAX_UINT allowance to that amount, causing a new -/// approval on every subsequent trade. -/// /// For neg_risk=false: approves CTF Exchange only. /// For neg_risk=true: approves BOTH NEG_RISK_CTF_EXCHANGE and NEG_RISK_ADAPTER — /// the CLOB checks both contracts in the settlement path for neg_risk markets. /// Returns the tx hash of the last approval submitted. -pub async fn approve_usdc(neg_risk: bool) -> Result { +pub async fn approve_usdc(neg_risk: bool, amount: u64) -> Result { use crate::config::Contracts; let usdc = Contracts::USDC_E; - let amount = u128::MAX; if neg_risk { - usdc_approve(usdc, Contracts::NEG_RISK_CTF_EXCHANGE, amount).await?; - usdc_approve(usdc, Contracts::NEG_RISK_ADAPTER, amount).await + usdc_approve(usdc, Contracts::NEG_RISK_CTF_EXCHANGE, amount as u128).await?; + usdc_approve(usdc, Contracts::NEG_RISK_ADAPTER, amount as u128).await } else { - usdc_approve(usdc, Contracts::CTF_EXCHANGE, amount).await + usdc_approve(usdc, Contracts::CTF_EXCHANGE, amount as u128).await } } @@ -723,40 +849,37 @@ pub async fn approve_ctf(neg_risk: bool) -> Result { /// YES (bit 0) and NO (bit 1) outcomes — the CTF contract only pays out for winning tokens /// and silently no-ops for losing ones, so passing both is safe. /// For neg_risk (multi-outcome) markets use the NEG_RISK_ADAPTER path (not implemented here). -pub fn build_redeem_positions_calldata(condition_id: &str) -> String { +/// +/// `collateral_addr`: the collateral token used at trade time. +/// - V1 markets: Contracts::USDC_E +/// - V2 markets: Contracts::PUSD (from ~2026-04-28) +pub async fn ctf_redeem_positions(condition_id: &str, collateral_addr: &str) -> Result { use sha3::{Digest, Keccak256}; use crate::config::Contracts; + // Compute the 4-byte function selector: keccak256("redeemPositions(address,bytes32,bytes32,uint256[])") let selector = Keccak256::digest(b"redeemPositions(address,bytes32,bytes32,uint256[])"); let selector_hex = hex::encode(&selector[..4]); - let collateral = pad_address(Contracts::USDC_E); - let parent_id = format!("{:064x}", 0u128); + // ABI-encode the four parameters. + // Slots 0-2 are static (address and bytes32); slot 3 is the offset to the dynamic uint256[] array. + let collateral = pad_address(collateral_addr); // address padded to 32 bytes + let parent_id = format!("{:064x}", 0u128); // bytes32(0) — null parent collection let cond_id_hex = condition_id.trim_start_matches("0x"); - let cond_id_pad = format!("{:0>64}", cond_id_hex); - let array_offset = pad_u256(4 * 32); + let cond_id_pad = format!("{:0>64}", cond_id_hex); // conditionId as bytes32 + let array_offset = pad_u256(4 * 32); // 4 static slots → offset = 128 + + // Dynamic array: length=2, [1, 2] (YES indexSet=1, NO indexSet=2) let array_len = pad_u256(2); - let index_yes = pad_u256(1); - let index_no = pad_u256(2); + let index_yes = pad_u256(1); // outcome 0, indexSet bit 0 + let index_no = pad_u256(2); // outcome 1, indexSet bit 1 - format!( + let calldata = format!( "0x{}{}{}{}{}{}{}{}", selector_hex, collateral, parent_id, cond_id_pad, array_offset, array_len, index_yes, index_no - ) -} + ); -/// Simulate + broadcast `CTF.redeemPositions(...)` directly from the EOA. -/// -/// Pre-flights via `eth_call` so reverts (e.g. EOA does not hold the outcome -/// tokens) are surfaced before `wallet_contract_call --force` masks them by -/// returning a tx hash that was signed but never broadcast. -pub async fn ctf_redeem_positions(condition_id: &str, from: &str) -> Result { - use crate::config::Contracts; - let calldata = build_redeem_positions_calldata(condition_id); - eth_call_simulate(from, Contracts::CTF, &calldata) - .await - .context("EOA redeemPositions would revert on-chain")?; let result = wallet_contract_call(Contracts::CTF, &calldata).await?; extract_tx_hash(&result) } @@ -767,13 +890,18 @@ pub async fn ctf_redeem_positions(condition_id: &str, from: &str) -> Result String { +/// +/// `collateral_addr`: the collateral token used at trade time. +/// - V1 markets: Contracts::USDC_E +/// - V2 markets: Contracts::PUSD (from ~2026-04-28) +pub async fn ctf_redeem_via_proxy(condition_id: &str, collateral_addr: &str) -> Result { use sha3::{Digest, Keccak256}; use crate::config::Contracts; + // Build inner redeemPositions calldata (identical to ctf_redeem_positions) let inner_selector = Keccak256::digest(b"redeemPositions(address,bytes32,bytes32,uint256[])"); let inner_selector_hex = hex::encode(&inner_selector[..4]); - let collateral = pad_address(Contracts::USDC_E); + let collateral = pad_address(collateral_addr); let parent_id = format!("{:064x}", 0u128); let cond_id_hex = condition_id.trim_start_matches("0x"); let cond_id_pad = format!("{:0>64}", cond_id_hex); @@ -787,17 +915,20 @@ fn build_redeem_via_proxy_calldata(condition_id: &str) -> String { inner_selector_hex, collateral, parent_id, cond_id_pad, array_offset, array_len, index_yes, index_no ); + // inner calldata = 4 + 7*32 = 228 bytes let inner_bytes = hex::decode(&inner_hex).expect("inner redeem calldata"); let inner_len = inner_bytes.len(); let pad_len = (32 - inner_len % 32) % 32; let inner_padded = format!("{}{}", inner_hex, "00".repeat(pad_len)); + // Wrap in PROXY_FACTORY.proxy([(CALL, CTF, 0, inner_calldata)]) + // Layout mirrors withdraw_usdc_from_proxy exactly, only `to` changes to CTF. let outer_selector = Keccak256::digest(b"proxy((uint8,address,uint256,bytes)[])"); let outer_selector_hex = hex::encode(&outer_selector[..4]); let ctf_padded = pad_address(Contracts::CTF); let data_len_padded = format!("{:064x}", inner_len); - format!( + let calldata = format!( "0x{}\ {}\ {}\ @@ -809,48 +940,33 @@ fn build_redeem_via_proxy_calldata(condition_id: &str) -> String { {}\ {}", outer_selector_hex, - "0000000000000000000000000000000000000000000000000000000000000020", - "0000000000000000000000000000000000000000000000000000000000000001", - "0000000000000000000000000000000000000000000000000000000000000020", - "0000000000000000000000000000000000000000000000000000000000000001", - ctf_padded, - "0000000000000000000000000000000000000000000000000000000000000000", - "0000000000000000000000000000000000000000000000000000000000000080", + "0000000000000000000000000000000000000000000000000000000000000020", // params array offset + "0000000000000000000000000000000000000000000000000000000000000001", // array length = 1 + "0000000000000000000000000000000000000000000000000000000000000020", // tuple[0] offset + "0000000000000000000000000000000000000000000000000000000000000001", // op = 1 (CALL) + ctf_padded, // to = CTF + "0000000000000000000000000000000000000000000000000000000000000000", // value = 0 + "0000000000000000000000000000000000000000000000000000000000000080", // data offset in tuple data_len_padded, inner_padded, - ) -} + ); -/// Simulate + broadcast `CTF.redeemPositions(...)` routed through the proxy wallet. -/// -/// Pre-flights via `eth_call` so reverts are surfaced before onchainos's `--force` -/// flag masks them. -pub async fn ctf_redeem_via_proxy(condition_id: &str, from: &str) -> Result { - use crate::config::Contracts; - let calldata = build_redeem_via_proxy_calldata(condition_id); - eth_call_simulate(from, Contracts::PROXY_FACTORY, &calldata) - .await - .context("Proxy redeemPositions would revert on-chain")?; let result = wallet_contract_call(Contracts::PROXY_FACTORY, &calldata).await?; extract_tx_hash(&result) } -// ─── NegRisk Adapter redeem ─────────────────────────────────────────────────── +// ─── NegRisk redeem (Bug #2 fix) ───────────────────────────────────────────── -/// Convert a large decimal integer string (up to 256 bits) to a 64-char lowercase hex string. -/// -/// Polymarket outcome token IDs are full uint256 values that do not fit in u128. -/// This function does the conversion using byte-level long multiplication, avoiding -/// any bignum dependency. -/// -/// Returns an error if the string contains non-digit characters or overflows 32 bytes. +/// Arbitrary-precision decimal string → 32-byte big-endian hex (no 0x prefix). +/// Used to convert Polymarket's token_id decimal strings to ABI-compatible bytes32. pub fn decimal_str_to_hex64(s: &str) -> Result { if s.is_empty() { anyhow::bail!("decimal_str_to_hex64: empty string is not a valid decimal integer"); } let mut result = [0u8; 32]; for ch in s.chars() { - let digit = ch.to_digit(10) + let digit = ch + .to_digit(10) .ok_or_else(|| anyhow::anyhow!("decimal_str_to_hex64: invalid digit '{}' in '{}'", ch, s))?; let mut carry = digit as u16; for byte in result.iter_mut().rev() { @@ -866,11 +982,8 @@ pub fn decimal_str_to_hex64(s: &str) -> Result { } /// Query the ERC-1155 CTF token balance of `owner` for a given outcome token ID. -/// -/// `token_id_decimal` is the decimal string representation of the uint256 token ID -/// as returned by the Polymarket CLOB API (e.g. `ClobToken::token_id`). -/// -/// Returns the raw token balance (atomic units, same scale as USDC.e: 1 share = 1_000_000). +/// `token_id_decimal` is the decimal string as returned by the Polymarket CLOB API. +/// Returns the raw token balance (atomic units; 1 share = 1_000_000). pub async fn get_ctf_balance(owner: &str, token_id_decimal: &str) -> Result { use crate::config::{Contracts, Urls}; // balanceOf(address,uint256) selector = 0x00fdd58e @@ -883,7 +996,7 @@ pub async fn get_ctf_balance(owner: &str, token_id_decimal: &str) -> Result Result String { use sha3::{Digest, Keccak256}; @@ -916,7 +1025,7 @@ pub fn build_negrisk_redeem_calldata(condition_id: &str, amounts: &[u128]) -> St let cond_id_hex = condition_id.trim_start_matches("0x"); let cond_id_pad = format!("{:0>64}", cond_id_hex); - // Dynamic array starts at offset 64 bytes (2 × 32-byte static params: conditionId + array offset). + // Dynamic array offset = 64 (2 × 32-byte static params before the array data start). let array_offset = pad_u256(64u128); let array_len = pad_u256(amounts.len() as u128); let amounts_hex: String = amounts.iter().map(|a| pad_u256(*a)).collect(); @@ -927,11 +1036,8 @@ pub fn build_negrisk_redeem_calldata(condition_id: &str, amounts: &[u128]) -> St ) } -/// Redeem neg_risk (multi-outcome) positions via NegRiskAdapter.redeemPositions. -/// -/// `amounts[i]` is the ERC-1155 balance of outcome slot i held by `from`. -/// Pre-flights via eth_call to surface reverts before signing. -/// Returns the tx hash of the broadcast transaction. +/// Redeem neg_risk positions via NegRiskAdapter.redeemPositions. +/// Pre-flights via eth_call to surface reverts before broadcasting. pub async fn negrisk_redeem_positions( condition_id: &str, amounts: &[u128], @@ -946,6 +1052,36 @@ pub async fn negrisk_redeem_positions( extract_tx_hash(&result) } +/// Simulate a contract call via eth_call on Polygon. Returns Ok(()) if no revert. +/// Use as a pre-flight before wallet_contract_call to catch reverts early. +pub async fn eth_call_simulate(from: &str, to: &str, input_data: &str) -> Result<()> { + use crate::config::Urls; + let body = serde_json::json!({ + "jsonrpc": "2.0", + "method": "eth_call", + "params": [{ + "from": from, + "to": to, + "data": input_data, + }, "latest"], + "id": 1 + }); + let v: serde_json::Value = reqwest::Client::new() + .post(Urls::POLYGON_RPC) + .json(&body) + .send() + .await + .context("Polygon RPC eth_call failed")? + .json() + .await + .context("parsing eth_call response")?; + if let Some(err) = v.get("error") { + let msg = err.get("message").and_then(|m| m.as_str()).unwrap_or("unknown"); + anyhow::bail!("eth_call simulation reverted: {}", msg); + } + Ok(()) +} + /// Get native POL balance for an address (eth_getBalance). Returns human-readable f64 (POL). pub async fn get_pol_balance(addr: &str) -> Result { use crate::config::Urls; @@ -956,7 +1092,7 @@ pub async fn get_pol_balance(addr: &str) -> Result { "id": 1 }); let v: serde_json::Value = reqwest::Client::new() - .post(Urls::polygon_rpc()) + .post(Urls::POLYGON_RPC) .json(&body) .send() .await @@ -972,19 +1108,20 @@ pub async fn get_pol_balance(addr: &str) -> Result { Ok(wei as f64 / 1e18) } -/// Get USDC.e (ERC-20) balance for an address. Returns human-readable f64 (dollars). -pub async fn get_usdc_balance(addr: &str) -> Result { - use crate::config::{Contracts, Urls}; +/// Get the ERC-20 balance for `holder_addr` on any 6-decimal token contract. +/// Returns human-readable f64 (e.g. dollars for USDC.e / pUSD). +pub async fn get_erc20_balance_6dec(token_addr: &str, holder_addr: &str) -> Result { + use crate::config::Urls; // balanceOf(address) selector = 0x70a08231 - let data = format!("0x70a08231{}", pad_address(addr)); + let data = format!("0x70a08231{}", pad_address(holder_addr)); let body = serde_json::json!({ "jsonrpc": "2.0", "method": "eth_call", - "params": [{ "to": Contracts::USDC_E, "data": data }, "latest"], + "params": [{ "to": token_addr, "data": data }, "latest"], "id": 1 }); let v: serde_json::Value = reqwest::Client::new() - .post(Urls::polygon_rpc()) + .post(Urls::POLYGON_RPC) .json(&body) .send() .await @@ -997,59 +1134,124 @@ pub async fn get_usdc_balance(addr: &str) -> Result { } let hex = v["result"].as_str().unwrap_or("0x").trim_start_matches("0x"); let raw = u128::from_str_radix(hex, 16).unwrap_or(0); - Ok(raw as f64 / 1_000_000.0) // USDC.e has 6 decimals + Ok(raw as f64 / 1_000_000.0) // 6 decimals } -/// Simulate a contract call via eth_call on Polygon. Returns Ok(()) if no revert. +/// Get USDC.e (ERC-20) balance for an address. Returns human-readable f64 (dollars). +pub async fn get_usdc_balance(addr: &str) -> Result { + use crate::config::Contracts; + get_erc20_balance_6dec(Contracts::USDC_E, addr).await +} + +/// Get pUSD (ERC-20) balance for an address. Returns human-readable f64 (dollars). +/// pUSD is the Polymarket USD collateral token that replaces USDC.e for V2 exchange contracts. +pub async fn get_pusd_balance(addr: &str) -> Result { + use crate::config::Contracts; + get_erc20_balance_6dec(Contracts::PUSD, addr).await +} + +/// Wrap USDC.e → pUSD via the Collateral Onramp for an EOA wallet. /// -/// Use this as a pre-flight before `wallet_contract_call` to catch reverts that -/// onchainos's `--force` flag would otherwise mask (returning a txHash that was -/// signed but never broadcast). -pub async fn eth_call_simulate(from: &str, to: &str, input_data: &str) -> Result<()> { - use crate::config::Urls; - let body = serde_json::json!({ - "jsonrpc": "2.0", - "method": "eth_call", - "params": [{ - "from": from, - "to": to, - "data": input_data, - }, "latest"], - "id": 1 - }); - let v: serde_json::Value = reqwest::Client::new() - .post(Urls::polygon_rpc()) - .json(&body) - .send() - .await - .context("Polygon RPC eth_call failed")? - .json() - .await - .context("parsing eth_call response")?; - if let Some(err) = v.get("error") { - let msg = err - .get("message") - .and_then(|m| m.as_str()) - .unwrap_or("unknown"); - anyhow::bail!("eth_call simulation reverted: {}", msg); - } - Ok(()) +/// Steps: +/// 1. Approve USDC.e to COLLATERAL_ONRAMP (amount). +/// 2. Call COLLATERAL_ONRAMP.wrap(USDC_E, recipient, amount). +/// 3. Wait for the wrap tx to confirm. +/// +/// Returns the wrap tx hash after on-chain confirmation. +pub async fn wrap_usdc_to_pusd(recipient: &str, amount: u128) -> Result { + use sha3::{Digest, Keccak256}; + use crate::config::Contracts; + + // Step 1: approve USDC.e to the onramp + let approve_tx = usdc_approve(Contracts::USDC_E, Contracts::COLLATERAL_ONRAMP, amount).await?; + wait_for_tx_receipt(&approve_tx, 30).await?; + + // Step 2: call wrap(address _asset, address _to, uint256 _amount) + let selector = Keccak256::digest(b"wrap(address,address,uint256)"); + let selector_hex = hex::encode(&selector[..4]); + let calldata = format!( + "0x{}{}{}{}", + selector_hex, + pad_address(Contracts::USDC_E), + pad_address(recipient), + pad_u256(amount), + ); + let result = wallet_contract_call(Contracts::COLLATERAL_ONRAMP, &calldata).await?; + extract_tx_hash(&result) +} + +/// Wrap USDC.e → pUSD for a proxy wallet via PROXY_FACTORY.proxy(). +/// +/// The proxy wallet first approves USDC.e to the Collateral Onramp, then calls +/// COLLATERAL_ONRAMP.wrap(USDC_E, proxy_addr, amount) from its own context. +/// +/// Steps (each routed through proxy): +/// 1. proxy_usdc_approve(COLLATERAL_ONRAMP) — sets unlimited allowance +/// 2. proxy calls wrap(USDC_E, proxy_addr, amount) → pUSD minted to proxy +/// +/// Returns the wrap tx hash after on-chain confirmation. +pub async fn proxy_wrap_usdc_to_pusd(proxy_addr: &str, amount: u128) -> Result { + use sha3::{Digest, Keccak256}; + use crate::config::Contracts; + + // Step 1: proxy approves USDC.e to the onramp (unlimited) + let approve_tx = proxy_usdc_approve(Contracts::COLLATERAL_ONRAMP).await?; + wait_for_tx_receipt(&approve_tx, 30).await?; + + // Step 2: proxy calls wrap(USDC_E, proxy_addr, amount) + // wrap(address,address,uint256) = selector + _asset + _to + _amount + let wrap_selector = Keccak256::digest(b"wrap(address,address,uint256)"); + let wrap_selector_hex = hex::encode(&wrap_selector[..4]); + let inner_hex = format!( + "{}{}{}{}", + wrap_selector_hex, + pad_address(Contracts::USDC_E), + pad_address(proxy_addr), + pad_u256(amount), + ); + let inner_bytes = hex::decode(&inner_hex).expect("wrap calldata hex"); + let inner_len = inner_bytes.len(); + let pad_len = (32 - inner_len % 32) % 32; + let inner_padded = format!("{}{}", inner_hex, "00".repeat(pad_len)); + + // Wrap in PROXY_FACTORY.proxy([(CALL, COLLATERAL_ONRAMP, 0, wrap_calldata)]) + let outer_selector = Keccak256::digest(b"proxy((uint8,address,uint256,bytes)[])"); + let outer_selector_hex = hex::encode(&outer_selector[..4]); + let onramp_padded = pad_address(Contracts::COLLATERAL_ONRAMP); + let data_len_padded = format!("{:064x}", inner_len); + + let calldata = format!( + "0x{}\ + {}\ + {}\ + {}\ + {}\ + {}\ + {}\ + {}\ + {}\ + {}", + outer_selector_hex, + "0000000000000000000000000000000000000000000000000000000000000020", + "0000000000000000000000000000000000000000000000000000000000000001", + "0000000000000000000000000000000000000000000000000000000000000020", + "0000000000000000000000000000000000000000000000000000000000000001", // op = 1 (CALL) + onramp_padded, + "0000000000000000000000000000000000000000000000000000000000000000", + "0000000000000000000000000000000000000000000000000000000000000080", + data_len_padded, + inner_padded, + ); + + let result = wallet_contract_call(Contracts::PROXY_FACTORY, &calldata).await?; + extract_tx_hash(&result) } /// Poll eth_getTransactionReceipt until the tx is mined (or timeout). /// /// Polygon block time is ~2 seconds. We poll every 2 seconds for up to max_wait_secs. +/// Call this after submitting an approval tx before posting any order. pub async fn wait_for_tx_receipt(tx_hash: &str, max_wait_secs: u64) -> Result<()> { - wait_for_tx_receipt_labeled(tx_hash, max_wait_secs, "Transaction").await -} - -/// Same as `wait_for_tx_receipt` but with a caller-supplied label used in error -/// messages (e.g. "Approve", "Redeem") so the bail text is accurate. -pub async fn wait_for_tx_receipt_labeled( - tx_hash: &str, - max_wait_secs: u64, - label: &str, -) -> Result<()> { use crate::config::Urls; use std::time::{Duration, Instant}; use tokio::time::sleep; @@ -1063,19 +1265,20 @@ pub async fn wait_for_tx_receipt_labeled( "id": 1 }); let resp = reqwest::Client::new() - .post(Urls::polygon_rpc()) + .post(Urls::POLYGON_RPC) .json(&body) .send() .await; if let Ok(r) = resp { if let Ok(v) = r.json::().await { + // receipt is an object (not null) once the tx is mined if v["result"].is_object() { + // status "0x1" = success, "0x0" = reverted let status = v["result"]["status"].as_str().unwrap_or("0x1"); if status == "0x0" { anyhow::bail!( - "{} tx {} was mined but reverted (status 0x0). \ + "Transaction {} was mined but reverted (status 0x0). \ Check Polygonscan for details.", - label, tx_hash ); } @@ -1085,19 +1288,21 @@ pub async fn wait_for_tx_receipt_labeled( } if Instant::now() >= deadline { anyhow::bail!( - "{} tx {} not observed on-chain within {}s. \ - If the hash does not appear on Polygonscan, onchainos signed the tx \ - but never broadcast it — usually because it would revert. \ - Check your trading mode / outcome token ownership and retry.", - label, - tx_hash, - max_wait_secs + "Approval tx {} not confirmed within {}s. \ + Check Polygonscan and retry.", + tx_hash, max_wait_secs ); } sleep(Duration::from_millis(2000)).await; } } +/// Same as `wait_for_tx_receipt` but with a caller-supplied label for error messages. +pub async fn wait_for_tx_receipt_labeled(tx_hash: &str, max_wait_secs: u64, label: &str) -> Result<()> { + wait_for_tx_receipt(tx_hash, max_wait_secs).await + .map_err(|e| anyhow::anyhow!("{} — {}", label, e)) +} + /// Poll eth_getTransactionReceipt on any supported EVM chain until mined or timeout. /// /// `chain` is the onchainos chain name (e.g. "bnb", "ethereum", "arbitrum"). @@ -1355,34 +1560,6 @@ pub async fn get_chain_balances(chain: &str) -> Vec { .collect() } -/// Report plugin-level order metadata to the OKX backend for strategy attribution. -/// -/// Serializes `payload` to a JSON string and passes it as `--plugin-parameter`. -/// Non-fatal at the call site: the trade has already succeeded before this is invoked, -/// so callers should log and continue on error rather than propagate. -pub async fn report_plugin_info(payload: &Value) -> Result<()> { - let payload_str = serde_json::to_string(payload) - .context("serializing report-plugin-info payload")?; - let output = tokio::process::Command::new(onchainos_bin()) - .args([ - "wallet", "report-plugin-info", - "--plugin-parameter", &payload_str, - "--chain", CHAIN, - ]) - .output() - .await - .context("Failed to spawn onchainos wallet report-plugin-info")?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - anyhow::bail!( - "onchainos report-plugin-info failed ({}): {}", - output.status, - stderr.trim() - ); - } - Ok(()) -} - pub async fn is_ctf_approved_for_all(owner: &str, operator: &str) -> Result { use crate::config::{Contracts, Urls}; // isApprovedForAll(address,address) selector = 0xe985e9c5 @@ -1395,7 +1572,7 @@ pub async fn is_ctf_approved_for_all(owner: &str, operator: &str) -> Result Result = std::sync::Mutex::new(()); - - // ── Bug #1: PATH resolution ────────────────────────────────────────────── - - /// `POLYMARKET_ONCHAINOS_BIN` env var overrides the binary path. - /// This is the mechanism that lets CI inject a mock binary so onchainos - /// calls can be stubbed without a real wallet. - #[test] - fn test_onchainos_bin_env_override() { - let _lock = ENV_MUTEX.lock().unwrap(); - std::env::set_var("POLYMARKET_ONCHAINOS_BIN", "/usr/bin/env"); - let bin = onchainos_bin(); - std::env::remove_var("POLYMARKET_ONCHAINOS_BIN"); - assert_eq!(bin, std::ffi::OsString::from("/usr/bin/env")); - } - - /// Without the env var and without ~/.local/bin/onchainos present, - /// `onchainos_bin()` falls back to bare "onchainos". - #[test] - fn test_onchainos_bin_fallback_to_bare_name() { - let _lock = ENV_MUTEX.lock().unwrap(); - std::env::remove_var("POLYMARKET_ONCHAINOS_BIN"); - // Only test the fallback path when ~/.local/bin/onchainos is absent. - let local_path = dirs::home_dir() - .map(|h| h.join(".local").join("bin").join("onchainos")); - if local_path.map(|p| p.is_file()).unwrap_or(false) { - return; // test not applicable on a machine with onchainos installed - } - let bin = onchainos_bin(); - assert_eq!(bin, std::ffi::OsString::from("onchainos")); - } - - // ── Bug #4: MAX_UINT approval calldata ────────────────────────────────── - - /// `usdc_approve` ABI-encodes amount as uint256. Verify that the calldata - /// for u128::MAX contains the correct max-value bytes (the low 128 bits - /// of MAX_UINT256). - /// - /// This test does NOT make a network call — it just checks the calldata - /// that would be passed to wallet_contract_call. - #[test] - fn test_usdc_approve_max_uint_encoding() { - // The calldata for approve(spender, u128::MAX) should end with - // ffffffffffffffffffffffffffffffff (32 bytes / 16 bytes low + 16 high of 0). - // Since u128::MAX = 0xffffffffffffffffffffffffffffffff (128 bits), - // ABI-encoded as uint256 it is: 0000000000000000ffffffffffffffffffffffffffffffff - // Wait — u128::MAX as ABI uint256 is: - // 32 bytes big-endian: 16 zero bytes then 16 0xff bytes - let amount = u128::MAX; - let padded = pad_u256(amount); - assert_eq!(padded.len(), 64, "pad_u256 must produce exactly 64 hex chars"); - assert_eq!( - padded, - "00000000000000000000000000000000ffffffffffffffffffffffffffffffff", - "u128::MAX as uint256 should be 16 zero bytes followed by 16 0xff bytes" - ); - } - - // ── Bug #2: NegRisk ABI encoding ──────────────────────────────────────── - - /// `decimal_str_to_hex64("0")` should produce 64 zeros. - #[test] - fn test_decimal_str_to_hex64_zero() { - let result = decimal_str_to_hex64("0").unwrap(); - assert_eq!(result, "0".repeat(64)); - } - - /// `decimal_str_to_hex64("255")` should produce 62 zeros + "ff". - #[test] - fn test_decimal_str_to_hex64_small_values() { - let result = decimal_str_to_hex64("255").unwrap(); - assert_eq!(result, format!("{:0>64}", "ff")); - - let result = decimal_str_to_hex64("256").unwrap(); - assert_eq!(result, format!("{:0>64}", "100")); - } - - /// u64::MAX = 18446744073709551615 = 0xffffffffffffffff - #[test] - fn test_decimal_str_to_hex64_u64_max() { - let result = decimal_str_to_hex64("18446744073709551615").unwrap(); - assert_eq!(result, format!("{:0>64}", "ffffffffffffffff")); - } - - /// u128::MAX = 340282366920938463463374607431768211455 = 0xffffffffffffffffffffffffffffffff - #[test] - fn test_decimal_str_to_hex64_u128_max() { - let result = decimal_str_to_hex64("340282366920938463463374607431768211455").unwrap(); - assert_eq!(result, format!("{:0>64}", "ffffffffffffffffffffffffffffffff")); - } - - /// Invalid decimal string (contains non-digit) should return an error. - #[test] - fn test_decimal_str_to_hex64_invalid_input() { - assert!(decimal_str_to_hex64("0x1234").is_err(), "0x prefix is not valid decimal"); - assert!(decimal_str_to_hex64("12.34").is_err(), "decimal point is not a digit"); - assert!(decimal_str_to_hex64("").is_err(), "empty string should fail"); - } - - /// The `build_negrisk_redeem_calldata` calldata must have the correct structure: - /// - 4-byte selector - /// - 32-byte condition_id (bytes32) - /// - 32-byte array offset (64 = 0x40) - /// - 32-byte array length (number of amounts) - /// - 32-byte per amount - /// Total for 2 amounts: 4 + 4*32 = 4 + 128 = 132 bytes = 264 hex chars + 2 ("0x") = 266 - #[test] - fn test_negrisk_redeem_calldata_length() { - let condition_id = "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"; - let amounts = [1_000_000u128, 0u128]; - let calldata = build_negrisk_redeem_calldata(condition_id, &amounts); - // 0x + 8 (selector) + 64 (cond_id) + 64 (offset) + 64 (len) + 64*2 (amounts) = 2 + 328 = 330 - assert_eq!(calldata.len(), 330, "calldata should be 330 chars (2 + 8 + 64*5)"); - } - - /// Verify the array offset field is encoded as 64 (0x40 = 2 static params × 32 bytes). - #[test] - fn test_negrisk_redeem_calldata_array_offset() { - let condition_id = "0x0000000000000000000000000000000000000000000000000000000000000001"; - let amounts = [0u128, 0u128]; - let calldata = build_negrisk_redeem_calldata(condition_id, &amounts); - // Strip "0x" prefix. Layout: [selector 8][cond_id 64][array_offset 64][...] - let hex = &calldata[2..]; - let array_offset_hex = &hex[8 + 64..8 + 64 + 64]; - // array_offset = 64 = 0x0000...0040 - assert_eq!( - array_offset_hex, - format!("{:0>64}", "40"), - "array offset should be 64 (0x40)" - ); - } - - /// Verify amounts are correctly encoded in the calldata. - #[test] - fn test_negrisk_redeem_calldata_amounts_encoding() { - let condition_id = "0x0000000000000000000000000000000000000000000000000000000000000001"; - let yes_amount = 50_000_000u128; // 50 USDC.e worth of shares - let no_amount = 0u128; - let calldata = build_negrisk_redeem_calldata(condition_id, &[yes_amount, no_amount]); - let hex = &calldata[2..]; // strip "0x" - // Layout: [selector 8][cond_id 64][offset 64][length 64][amount0 64][amount1 64] - let amount0_hex = &hex[8 + 64 + 64 + 64..8 + 64 + 64 + 64 + 64]; - let amount1_hex = &hex[8 + 64 + 64 + 64 + 64..]; - assert_eq!( - amount0_hex, - format!("{:0>64x}", yes_amount), - "yes amount should be correctly encoded" - ); - assert_eq!( - amount1_hex, - format!("{:0>64x}", no_amount), - "no amount should be correctly encoded" - ); - } - - /// CTF.redeemPositions calldata has the correct selector. - /// keccak256("redeemPositions(address,bytes32,bytes32,uint256[])") = 0xdbcb3da5 - #[test] - fn test_ctf_redeem_positions_selector() { - use sha3::{Digest, Keccak256}; - let selector = Keccak256::digest(b"redeemPositions(address,bytes32,bytes32,uint256[])"); - let expected = hex::encode(&selector[..4]); - let cid = "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"; - let calldata = build_redeem_positions_calldata(cid); - assert!(calldata.starts_with(&format!("0x{}", expected)), - "CTF.redeemPositions selector should be 0x{}", expected); - } - - /// NegRiskAdapter.redeemPositions calldata has the correct selector. - /// keccak256("redeemPositions(bytes32,uint256[])") first 4 bytes - #[test] - fn test_negrisk_redeem_positions_selector() { - use sha3::{Digest, Keccak256}; - let selector = Keccak256::digest(b"redeemPositions(bytes32,uint256[])"); - let expected = hex::encode(&selector[..4]); - let cid = "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"; - let calldata = build_negrisk_redeem_calldata(cid, &[0u128]); - assert!(calldata.starts_with(&format!("0x{}", expected)), - "NegRiskAdapter.redeemPositions selector should be 0x{}", expected); - } -} - diff --git a/skills/polymarket-plugin/src/signing.rs b/skills/polymarket-plugin/src/signing.rs index 398f45508..ecb409b65 100644 --- a/skills/polymarket-plugin/src/signing.rs +++ b/skills/polymarket-plugin/src/signing.rs @@ -1,10 +1,15 @@ /// EIP-712 order signing for Polymarket CTF Exchange via onchainos. /// +/// V1: legacy exchange (0x4bFb41...), domain version "1", struct has taker/nonce/feeRateBps. +/// V2: new exchange (0xE11118...), domain version "2", struct has timestamp/metadata/builder. +/// /// All signing is delegated to `onchainos wallet sign-message --type eip712`. /// No local private key is used or stored by this module. use anyhow::Result; -/// Parameters for a Polymarket limit order. +// ─── V1 ─────────────────────────────────────────────────────────────────────── + +/// Parameters for a Polymarket V1 limit order. pub struct OrderParams { pub salt: u64, pub maker: String, @@ -17,13 +22,10 @@ pub struct OrderParams { pub nonce: u64, pub fee_rate_bps: u64, pub side: u8, // 0=BUY, 1=SELL - pub signature_type: u8, // 0=EOA + pub signature_type: u8, // 0=EOA, 1=Proxy } -/// Sign a Polymarket order EIP-712 via `onchainos sign-message --type eip712`. -/// -/// Builds a complete EIP-712 structured data JSON with EIP712Domain in `types` -/// (required for correct hash computation — per Hyperliquid root-cause finding). +/// Sign a V1 Polymarket order via `onchainos sign-message --type eip712`. pub async fn sign_order_via_onchainos(order: &OrderParams, neg_risk: bool) -> Result { use crate::config::Contracts; let verifying_contract = Contracts::exchange_for(neg_risk); @@ -73,7 +75,90 @@ pub async fn sign_order_via_onchainos(order: &OrderParams, neg_risk: bool) -> Re "signatureType": order.signature_type } })) - .expect("Order EIP-712 JSON serialization failed"); + .expect("V1 Order EIP-712 JSON serialization failed"); + + crate::onchainos::sign_eip712(&json).await +} + +// ─── V2 ─────────────────────────────────────────────────────────────────────── + +/// Parameters for a Polymarket V2 limit order. +/// +/// Key differences from V1: +/// - No `taker`, `nonce`, or `feeRateBps` (fees are now protocol-enforced) +/// - `expiration` is NOT in the signed struct (it goes in the outer API request wrapper) +/// - `timestamp_ms`: millisecond Unix timestamp added to the signed struct +/// - `metadata`: bytes32 optional metadata (zero for standard orders) +/// - `builder`: bytes32 builder code for fee attribution (zero for non-builders) +pub struct OrderParamsV2 { + pub salt: u64, + pub maker: String, + pub signer: String, + pub token_id: String, + pub maker_amount: u64, + pub taker_amount: u64, + pub side: u8, // 0=BUY, 1=SELL + pub signature_type: u8, // 0=EOA, 1=Proxy, 2=GnosisSafe, 3=POLY_1271 + pub timestamp_ms: u64, // millisecond Unix timestamp + pub metadata: String, // bytes32 hex: "0x000...000" for standard orders + pub builder: String, // bytes32 hex: "0x000...000" for non-builders +} + +/// Sign a V2 Polymarket order via `onchainos sign-message --type eip712`. +/// +/// Uses domain version "2" and the new V2 exchange contract address. +/// `expiration` is not part of the signed struct in V2 — pass it separately in the API body. +pub async fn sign_order_v2_via_onchainos(order: &OrderParamsV2, neg_risk: bool) -> Result { + use crate::config::Contracts; + let verifying_contract = Contracts::exchange_for_v2(neg_risk); + + let json = serde_json::to_string(&serde_json::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": "tokenId", "type": "uint256"}, + {"name": "makerAmount", "type": "uint256"}, + {"name": "takerAmount", "type": "uint256"}, + {"name": "side", "type": "uint8"}, + {"name": "signatureType", "type": "uint8"}, + {"name": "timestamp", "type": "uint256"}, + {"name": "metadata", "type": "bytes32"}, + {"name": "builder", "type": "bytes32"} + ] + }, + "primaryType": "Order", + "domain": { + "name": "Polymarket CTF Exchange", + "version": "2", + "chainId": 137, + "verifyingContract": verifying_contract + }, + "message": { + "salt": order.salt.to_string(), + "maker": order.maker, + "signer": order.signer, + "tokenId": order.token_id, + "makerAmount": order.maker_amount.to_string(), + "takerAmount": order.taker_amount.to_string(), + "side": order.side, + "signatureType": order.signature_type, + "timestamp": order.timestamp_ms.to_string(), + "metadata": order.metadata, + "builder": order.builder + } + })) + .expect("V2 Order EIP-712 JSON serialization failed"); crate::onchainos::sign_eip712(&json).await } + +/// Bytes32 zero value — used as default metadata and builder in V2 orders. +pub const BYTES32_ZERO: &str = "0x0000000000000000000000000000000000000000000000000000000000000000";