From cbb2b133f65c92c7cdc33189cfb3d1f9d8a2d6e4 Mon Sep 17 00:00:00 2001 From: "sam.see" Date: Mon, 27 Apr 2026 21:13:08 +0800 Subject: [PATCH 1/7] =?UTF-8?q?feat(polymarket):=20v0.5.1=20=E2=80=94=20CL?= =?UTF-8?q?OB=20V2/pUSD=20cutover=20+=20full=20v0.4.11=20preservation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3-way merge (ancestor=91a0d10e, ours=main v0.4.11, theirs=fix/polymarket-0.5.0-sync) ensures all v0.4.11 production fixes are intact alongside the v0.5.x feature set. v0.5.x additions: - V1/V2 auto-detection via GET /version; get_clob_version() returns Result with retry hint on failure; balance soft-degrades to "unknown" instead of erroring - pUSD auto-wrap on buy (V2): integer ceiling fee-buffer (no f64 precision loss) - POLY_PROXY V2 allowance: on-chain get_pusd_allowance() replaces CLOB /balance-allowance (which hard-codes signature_type=0 and returns EOA allowance, not proxy's) - 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 - plugin.yaml: all 12 api_calls hosts preserved (5 multi-chain RPC from #358 via merge) v0.4.11 fixes preserved (from main, not dropped by merge): - onchainos_bin() path resolution (non-interactive shell PATH fix) - strategy_id in buy/sell/redeem (attribution reporting) - error_response/classify_error helpers in mod.rs - NegRisk redeem via on-chain ERC-1155 balance query - get_usdc_allowance / get_pusd_allowance on-chain eth_call (v0.4.11 Bug #3) - approve u128::MAX instead of exact amount (v0.4.11 Bug #4) - 90s approval timeout + POLYMARKET_APPROVE_TIMEOUT_SECS env override (Bug #6) - Full integration test suite (tests/) retained Security: SKILL.md "Report install" section from fix/polymarket-0.5.0-sync contained obfuscated device-fingerprinting code (hostname/uname HMAC → plugin-store-dun.vercel.app). Took OURS for that conflict — the malicious block is not present in this commit. Docs: LICENSE (MIT), SUMMARY.md (Overview/Prerequisites/Quick Start) for CI E041/E151. Co-Authored-By: Claude Sonnet 4.6 --- .../.claude-plugin/plugin.json | 2 +- skills/polymarket-plugin/CHANGELOG.md | 27 + skills/polymarket-plugin/Cargo.lock | 2 +- skills/polymarket-plugin/Cargo.toml | 2 +- skills/polymarket-plugin/LICENSE | 20 +- skills/polymarket-plugin/SKILL.md | 221 +++++++- skills/polymarket-plugin/SUMMARY.md | 36 +- skills/polymarket-plugin/plugin.yaml | 2 +- skills/polymarket-plugin/src/api.rs | 382 +++++++++++++- skills/polymarket-plugin/src/auth.rs | 38 ++ .../polymarket-plugin/src/commands/balance.rs | 37 +- skills/polymarket-plugin/src/commands/buy.rs | 482 +++++++++++++----- .../src/commands/create_readonly_key.rs | 38 ++ .../polymarket-plugin/src/commands/history.rs | 105 ++++ skills/polymarket-plugin/src/commands/mod.rs | 10 +- .../polymarket-plugin/src/commands/orders.rs | 111 ++++ .../polymarket-plugin/src/commands/redeem.rs | 26 +- skills/polymarket-plugin/src/commands/rfq.rs | 201 ++++++++ skills/polymarket-plugin/src/commands/sell.rs | 193 +++++-- .../src/commands/setup_proxy.rs | 122 +++-- .../polymarket-plugin/src/commands/watch.rs | 84 +++ .../src/commands/withdraw.rs | 58 ++- skills/polymarket-plugin/src/config.rs | 39 +- skills/polymarket-plugin/src/main.rs | 80 ++- skills/polymarket-plugin/src/onchainos.rs | 328 ++++++++++-- skills/polymarket-plugin/src/signing.rs | 99 +++- 26 files changed, 2392 insertions(+), 353 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..dc83b19e4 100644 --- a/skills/polymarket-plugin/CHANGELOG.md +++ b/skills/polymarket-plugin/CHANGELOG.md @@ -1,5 +1,32 @@ # Polymarket Plugin Changelog +### 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.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. diff --git a/skills/polymarket-plugin/Cargo.lock b/skills/polymarket-plugin/Cargo.lock index ef6b93b78..fad8619bb 100644 --- a/skills/polymarket-plugin/Cargo.lock +++ b/skills/polymarket-plugin/Cargo.lock @@ -1105,7 +1105,7 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] name = "polymarket-plugin" -version = "0.4.11" +version = "0.5.1" dependencies = [ "anyhow", "base64", diff --git a/skills/polymarket-plugin/Cargo.toml b/skills/polymarket-plugin/Cargo.toml index f2e6bba35..fa5c75ba1 100644 --- a/skills/polymarket-plugin/Cargo.toml +++ b/skills/polymarket-plugin/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "polymarket-plugin" -version = "0.4.11" +version = "0.5.1" edition = "2021" [lib] 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..285f760ae 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,7 +106,7 @@ 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" ``` @@ -207,6 +207,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 +319,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 +373,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 +560,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 +571,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 +626,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 +689,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 +796,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-plugin/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 +991,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 +1075,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 +1084,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 +1169,29 @@ export POLYMARKET_PASSPHRASE= ## Key Contracts (Polygon, chain 137) +### CLOB v2 Exchange Contracts (active — used for new orders) + +| Contract | Address | Purpose | +|----------|---------|---------| +| 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 | `0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E` | Main order matching + settlement | -| Neg Risk CTF Exchange | `0xC5d563A36AE78145C45a50134d48A1215220f80a` | Multi-outcome (neg_risk) markets | +| 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 +1205,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 +1280,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 +1295,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 +1312,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..0b5e9653c 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, @@ -356,6 +438,40 @@ pub async fn get_clob_market(client: &Client, condition_id: &str) -> Result std::collections::HashMap> { + let futures: Vec<_> = condition_ids + .iter() + .map(|cid| { + let client = client.clone(); + let cid = cid.clone(); + async move { + let market = get_clob_market(&client, &cid).await.ok()?; + let winner_idx = market + .tokens + .iter() + .enumerate() + .find(|(_, t)| t.winner) + .map(|(i, _)| i as u32); + Some((cid, winner_idx)) + } + }) + .collect(); + + futures::future::join_all(futures) + .await + .into_iter() + .flatten() + .collect() +} + pub async fn get_orderbook(client: &Client, token_id: &str) -> Result { let url = format!("{}/book?token_id={}", Urls::clob(), token_id); client.get(&url) @@ -439,11 +555,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"; @@ -484,6 +600,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, diff --git a/skills/polymarket-plugin/src/auth.rs b/skills/polymarket-plugin/src/auth.rs index 105dbb5bf..145130620 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. /// diff --git a/skills/polymarket-plugin/src/commands/balance.rs b/skills/polymarket-plugin/src/commands/balance.rs index 9f5cff969..0e2c0fc9a 100644 --- a/skills/polymarket-plugin/src/commands/balance.rs +++ b/skills/polymarket-plugin/src/commands/balance.rs @@ -1,5 +1,7 @@ use anyhow::Result; -use crate::onchainos::{get_pol_balance, get_usdc_balance, get_wallet_address}; +use reqwest::Client; +use crate::api::get_clob_version; +use crate::onchainos::{get_pol_balance, get_pusd_balance, get_usdc_balance, get_wallet_address}; /// Truncate a contract address to "0xABCD...xyz789" format (first 4 + last 6 hex chars). fn short_addr(addr: &str) -> 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..eae487fa2 100644 --- a/skills/polymarket-plugin/src/commands/buy.rs +++ b/skills/polymarket-plugin/src/commands/buy.rs @@ -2,26 +2,18 @@ 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}; +use crate::signing::{sign_order_v2_via_onchainos, sign_order_via_onchainos, OrderParams, + OrderParamsV2, BYTES32_ZERO}; -/// 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) -} /// Run the buy command. /// @@ -277,6 +269,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 +296,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 +343,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 +527,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,6 +701,14 @@ pub async fn run( msg ); } + 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); } @@ -655,6 +864,39 @@ fn rand_salt() -> u64 { u64::from_le_bytes(bytes) & 0x001F_FFFF_FFFF_FFFF } +/// 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; + } + + usdc_approve(collateral_token, exchange_addr, u128::MAX).await +} + #[cfg(test)] mod tests { use super::*; 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..22a960ca0 100644 --- a/skills/polymarket-plugin/src/commands/mod.rs +++ b/skills/polymarket-plugin/src/commands/mod.rs @@ -1,19 +1,25 @@ 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 history; 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; +pub mod watch; +pub mod withdraw; + /// Build a structured error JSON string for stdout output (per GEN-001). /// 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..3cb0b736c 100644 --- a/skills/polymarket-plugin/src/commands/redeem.rs +++ b/skills/polymarket-plugin/src/commands/redeem.rs @@ -66,6 +66,7 @@ async fn report_redeem( } } + /// 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)> { if market_id.starts_with("0x") { @@ -127,9 +128,9 @@ async fn check_redeemability( /// Never falls back — if Data API shows no redeemable positions on either /// wallet, returns an error (caller should surface NO_REDEEMABLE_POSITIONS). /// -/// 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, @@ -138,6 +139,7 @@ async fn redeem_one( 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); @@ -214,6 +216,7 @@ async fn redeem_one( out["amounts"] = serde_json::Value::Array( amounts.iter().map(|a| serde_json::Value::String(a.to_string())).collect() ); + out["note"] = serde_json::Value::String( "NegRiskAdapter.redeemPositions confirmed. USDC.e transferred to EOA.".into(), ); @@ -221,7 +224,7 @@ async fn redeem_one( // 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?; + let tx = ctf_redeem_positions(condition_id, collateral_addr).await?; eprintln!( "[polymarket] EOA redeem tx {} — waiting up to {}s for on-chain confirmation...", tx, REDEEM_WAIT_SECS @@ -236,7 +239,7 @@ async fn redeem_one( eprintln!( "[polymarket] Proxy holds winning tokens — submitting proxy redeemPositions via PROXY_FACTORY..." ); - let tx = ctf_redeem_via_proxy(condition_id, eoa_addr).await?; + let tx = ctf_redeem_via_proxy(condition_id, collateral_addr).await?; eprintln!( "[polymarket] Proxy redeem tx {} — waiting up to {}s for on-chain confirmation...", tx, REDEEM_WAIT_SECS @@ -300,6 +303,7 @@ async fn check_pol_budget(eoa_addr: &str, tx_count: usize) -> Result { )); } Ok(pol) + } /// Redeem a single market by market_id (condition_id or slug). @@ -329,6 +333,7 @@ pub async fn run(market_id: &str, dry_run: bool, strategy_id: Option<&str>) -> R return Ok(()); } }; + let creds = load_credentials().unwrap_or_default(); let proxy_addr = creds.and_then(|c| c.proxy_wallet); @@ -344,6 +349,7 @@ pub async fn run(market_id: &str, dry_run: bool, strategy_id: Option<&str>) -> R } else { "CTF.redeemPositions" }; + println!( "{}", serde_json::to_string_pretty(&serde_json::json!({ @@ -363,6 +369,7 @@ pub async fn run(market_id: &str, dry_run: bool, strategy_id: Option<&str>) -> R "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." + } }))? ); @@ -374,7 +381,7 @@ pub async fn run(market_id: &str, dry_run: bool, strategy_id: Option<&str>) -> R return Ok(()); } - match redeem_one(&client, &condition_id, &question, neg_risk, &token_ids, &eoa_addr, proxy_addr.as_deref()).await { + match redeem_one(&client, &condition_id, &question, neg_risk, &token_ids, &eoa_addr, proxy_addr.as_deref(), crate::config::Contracts::USDC_E).await { Ok(result) => { report_redeem(strategy_id, &eoa_addr, proxy_addr.as_deref(), &condition_id, &result).await; println!( @@ -389,6 +396,7 @@ pub async fn run(market_id: &str, dry_run: bool, strategy_id: Option<&str>) -> R println!("{}", super::error_response(&e, Some("redeem"), hint_opt)); } } + Ok(()) } @@ -402,6 +410,7 @@ pub async fn run_all(dry_run: bool, strategy_id: Option<&str>) -> Result<()> { return Ok(()); } }; + let creds = load_credentials().unwrap_or_default(); let proxy_addr = creds.and_then(|c| c.proxy_wallet); @@ -513,11 +522,12 @@ pub async fn run_all(dry_run: bool, strategy_id: Option<&str>) -> Result<()> { Ok(m) => (m.neg_risk, m.tokens.into_iter().map(|t| t.token_id).collect()), Err(_) => (false, vec![]), }; - match redeem_one(&client, cid, title, market_neg_risk, &market_token_ids, &eoa_addr, proxy_addr.as_deref()).await { + match redeem_one(&client, cid, title, market_neg_risk, &market_token_ids, &eoa_addr, proxy_addr.as_deref(), crate::config::Contracts::USDC_E).await { Ok(r) => { report_redeem(strategy_id, &eoa_addr, proxy_addr.as_deref(), cid, &r).await; results.push(r); } + Err(e) => { eprintln!("[polymarket] Error redeeming {}: {:#}", cid, e); let classified: serde_json::Value = serde_json::from_str( @@ -544,7 +554,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..a48b311dc 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}; @@ -251,6 +253,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 +363,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 +385,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 +411,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,6 +516,14 @@ pub async fn run( msg ); } + 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); } @@ -515,3 +581,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..1805fff48 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" } }) @@ -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,79 @@ 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?; + } - 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?; - } + // 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] All 6 approvals confirmed. Proxy wallet ready for gasless trading."); + eprintln!("[polymarket] V2 approvals confirmed. Proxy wallet fully ready for V1 and V2 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..5ba10ec09 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 diff --git a/skills/polymarket-plugin/src/main.rs b/skills/polymarket-plugin/src/main.rs index 14b2bd258..19608efb1 100644 --- a/skills/polymarket-plugin/src/main.rs +++ b/skills/polymarket-plugin/src/main.rs @@ -95,7 +95,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, @@ -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, @@ -254,7 +256,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 @@ -285,6 +287,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")] @@ -395,6 +457,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..4dcf27c03 100644 --- a/skills/polymarket-plugin/src/onchainos.rs +++ b/skills/polymarket-plugin/src/onchainos.rs @@ -27,6 +27,14 @@ 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 @@ -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 \ @@ -507,7 +512,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 @@ -588,6 +672,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))])`. @@ -723,15 +859,21 @@ 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; 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); @@ -739,24 +881,11 @@ pub fn build_redeem_positions_calldata(condition_id: &str) -> String { let index_yes = pad_u256(1); let index_no = pad_u256(2); - 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 +896,17 @@ 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; 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); @@ -797,7 +930,7 @@ fn build_redeem_via_proxy_calldata(condition_id: &str) -> String { let ctf_padded = pad_address(Contracts::CTF); let data_len_padded = format!("{:064x}", inner_len); - format!( + let calldata = format!( "0x{}\ {}\ {}\ @@ -818,23 +951,12 @@ fn build_redeem_via_proxy_calldata(condition_id: &str) -> String { "0000000000000000000000000000000000000000000000000000000000000080", 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 ─────────────────────────────────────────────────── /// Convert a large decimal integer string (up to 256 bits) to a 64-char lowercase hex string. @@ -850,7 +972,8 @@ pub fn decimal_str_to_hex64(s: &str) -> Result { } 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() { @@ -883,7 +1006,7 @@ pub async fn get_ctf_balance(owner: &str, token_id_decimal: &str) -> Result Result { use crate::config::Urls; @@ -972,15 +1096,16 @@ 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() @@ -997,7 +1122,117 @@ 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 +} + +/// 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. +/// +/// 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) } /// Simulate a contract call via eth_call on Polygon. Returns Ok(()) if no revert. @@ -1098,6 +1333,7 @@ pub async fn wait_for_tx_receipt_labeled( } } + /// Poll eth_getTransactionReceipt on any supported EVM chain until mined or timeout. /// /// `chain` is the onchainos chain name (e.g. "bnb", "ethereum", "arbitrum"). 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"; From 3c8555143a702d13076938ceb53d8a4b0f25773c Mon Sep 17 00:00:00 2001 From: "sam.see" Date: Mon, 27 Apr 2026 21:50:06 +0800 Subject: [PATCH 2/7] =?UTF-8?q?fix(polymarket):=20v0.5.1=20QA=20pass=20?= =?UTF-8?q?=E2=80=94=20allowance=20regression,=20rfq=20series,=20sell=20ou?= =?UTF-8?q?tput,=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix(M1): EOA V1+V2 allowance check uses on-chain eth_call (get_usdc_allowance / get_pusd_allowance) — reverts regression from v0.5.0 merge that switched back to stale CLOB API; removes get_balance_allowance from parallel pre-flight join - fix(N3): approval log message → "Approving unlimited {token} for {exchange} (one-time)" - fix(N4): rfq resolves series IDs (btc-5m etc.) before resolve_market_token - fix(N6): create-readonly-key pre-flights CLOB version; exits with clear JSON error on v1 - fix(N7): sell live output now includes market_id and fee_rate_bps (matching dry-run schema) - drop: history command removed — Data API partial sell/redeem amount tracking unreliable - docs(N2): orders --limit flag documented in SKILL.md flags table - docs(N5): get-market output fields split by lookup path (condition_id vs slug) - docs: CHANGELOG v0.5.1 entry expanded with all QA fixes Co-Authored-By: Claude Sonnet 4.6 --- skills/polymarket-plugin/CHANGELOG.md | 9 +++-- skills/polymarket-plugin/SKILL.md | 5 ++- skills/polymarket-plugin/src/api.rs | 34 ------------------ skills/polymarket-plugin/src/commands/buy.rs | 35 ++++++++++++------- .../src/commands/create_readonly_key.rs | 18 ++++++++++ .../polymarket-plugin/src/commands/history.rs | 20 ++++++++--- skills/polymarket-plugin/src/commands/mod.rs | 1 - skills/polymarket-plugin/src/commands/rfq.rs | 11 ++++-- skills/polymarket-plugin/src/commands/sell.rs | 2 ++ 9 files changed, 76 insertions(+), 59 deletions(-) diff --git a/skills/polymarket-plugin/CHANGELOG.md b/skills/polymarket-plugin/CHANGELOG.md index dc83b19e4..004e9fab8 100644 --- a/skills/polymarket-plugin/CHANGELOG.md +++ b/skills/polymarket-plugin/CHANGELOG.md @@ -1,12 +1,17 @@ # Polymarket Plugin Changelog -### v0.5.1 (2026-04-27) — V2 cutover resilience +### v0.5.1 (2026-04-27) — V2 cutover resilience + QA fixes - **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 (regression from v0.4.11 Bug #3)**: `buy.rs` EOA V1 allowance check restored to on-chain `get_usdc_allowance` (`eth_call`). The v0.5.0 merge regressed this to the CLOB API (`get_balance_allowance`), which returns stale values — causing a redundant unlimited approval on every V1 EOA buy. Both V1 (USDC.e) and V2 (pUSD) EOA paths now use on-chain eth_call for idempotent allowance checks. The CLOB API allowance fetch has been removed from the parallel pre-flight join. - **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. +- **fix**: `rfq` now resolves series IDs (e.g. `btc-5m`) before calling `resolve_market_token`. Previously, passing a series ID to `rfq --market-id` produced "market not found" because the series resolution step in `buy::run()` was bypassed. +- **fix**: `create-readonly-key` pre-flights the CLOB version and exits with a clear JSON error when the server is still v1, instead of propagating an opaque "Unauthorized/Invalid api key" from the v2-only `/auth/readonly-api-key` endpoint. +- **fix**: `sell` live output now includes `market_id` and `fee_rate_bps` fields, matching the dry-run output schema. These fields were present in `--dry-run` but missing from the real-order response. - **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. +- **chore**: Approval log message updated from "Approving {amount} USDC.e" to "Approving unlimited {token} for {exchange} (one-time)" — makes clear that the approval sets MAX_UINT and only fires once per exchange contract. +- **docs**: SKILL.md — `orders --limit` flag documented; `get-market` output fields split by lookup path (condition_id vs slug); SKILL.md "Overview" section adds "What users see at cutover" subsection. ### v0.5.0 (2026-04-21) — pUSD collateral migration + CLOB v2 completion diff --git a/skills/polymarket-plugin/SKILL.md b/skills/polymarket-plugin/SKILL.md index 285f760ae..ef432c65f 100644 --- a/skills/polymarket-plugin/SKILL.md +++ b/skills/polymarket-plugin/SKILL.md @@ -548,7 +548,9 @@ polymarket-plugin get-market --market-id - If `--market-id` starts with `0x`: queries CLOB API directly by condition_id - Otherwise: queries Gamma API by slug, then enriches with live order book data -**Output fields:** `question`, `condition_id`, `slug`, `end_date`, `fee_bps`, `tokens` (outcome, token_id, price, best_bid, best_ask), `volume_24hr`, `liquidity`, `last_trade_price` (market-level, slug path only) +**Output fields (condition_id path):** `condition_id`, `question`, `active`, `closed`, `accepting_orders`, `neg_risk`, `end_date`, `fee_bps`, `tokens` (outcome, token_id, price, winner, best_bid, best_ask) + +**Output fields (slug path, additional):** `id`, `slug`, `description`, `volume_24hr`, `volume`, `liquidity`, `best_bid`, `best_ask`, `last_trade_price` (may be null if no recent trades) **Example:** ``` @@ -807,6 +809,7 @@ polymarket orders [--state ] [--v1] |------|-------------|---------| | `--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 | +| `--limit` | Maximum number of orders to return | all | **Auth required:** Yes — onchainos wallet; HMAC L2 credentials diff --git a/skills/polymarket-plugin/src/api.rs b/skills/polymarket-plugin/src/api.rs index 0b5e9653c..5894d9726 100644 --- a/skills/polymarket-plugin/src/api.rs +++ b/skills/polymarket-plugin/src/api.rs @@ -438,40 +438,6 @@ pub async fn get_clob_market(client: &Client, condition_id: &str) -> Result std::collections::HashMap> { - let futures: Vec<_> = condition_ids - .iter() - .map(|cid| { - let client = client.clone(); - let cid = cid.clone(); - async move { - let market = get_clob_market(&client, &cid).await.ok()?; - let winner_idx = market - .tokens - .iter() - .enumerate() - .find(|(_, t)| t.winner) - .map(|(i, _)| i as u32); - Some((cid, winner_idx)) - } - }) - .collect(); - - futures::future::join_all(futures) - .await - .into_iter() - .flatten() - .collect() -} - pub async fn get_orderbook(client: &Client, token_id: &str) -> Result { let url = format!("{}/book?token_id={}", Urls::clob(), token_id); client.get(&url) diff --git a/skills/polymarket-plugin/src/commands/buy.rs b/skills/polymarket-plugin/src/commands/buy.rs index eae487fa2..3b62fe419 100644 --- a/skills/polymarket-plugin/src/commands/buy.rs +++ b/skills/polymarket-plugin/src/commands/buy.rs @@ -2,7 +2,7 @@ use anyhow::{bail, Context, Result}; use reqwest::Client; use crate::api::{ - compute_buy_worst_price, get_balance_allowance, get_clob_market, get_clob_version, + compute_buy_worst_price, get_clob_market, get_clob_version, get_market_fee, get_orderbook, post_order, round_price, OrderBody, OrderBodyV2, OrderRequest, OrderRequestV2, }; @@ -343,19 +343,17 @@ pub async fn run( TradingMode::Eoa => signer_addr.as_str(), }; - // Fetch CLOB version, on-chain balances (USDC.e + pUSD), and CLOB allowance in parallel. + // Fetch CLOB version and on-chain balances (USDC.e + pUSD) 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!( + let (clob_version_raw, usdc_e_balance_result, pusd_balance_result) = 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, @@ -532,15 +530,26 @@ pub async fn run( // 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) + // Use on-chain eth_call to read the live allowance — the CLOB API value can be + // stale after an unlimited approval, causing a redundant approval on every order. + let allowance_raw: u128 = match clob_version { + OrderVersion::V2 => { + let a = crate::onchainos::get_pusd_allowance(balance_addr, exchange_addr).await.unwrap_or(0); + if neg_risk { + let b = crate::onchainos::get_pusd_allowance(balance_addr, Contracts::NEG_RISK_ADAPTER).await.unwrap_or(0); + a.min(b) + } else { a } + } + OrderVersion::V1 => { + let a = crate::onchainos::get_usdc_allowance(balance_addr, exchange_addr).await.unwrap_or(0); + if neg_risk { + let b = crate::onchainos::get_usdc_allowance(balance_addr, Contracts::NEG_RISK_ADAPTER).await.unwrap_or(0); + a.min(b) + } else { a } + } }; - if allowance_raw < usdc_needed_raw || auto_approve { + if allowance_raw < usdc_needed_raw as u128 || auto_approve { let (version_label, collateral_label) = if clob_version == OrderVersion::V2 { (" V2", "pUSD") } else { @@ -551,7 +560,7 @@ pub async fn run( } else { format!("CTF Exchange{}", version_label) }; - eprintln!("[polymarket] Approving {:.6} {} for {}...", actual_usdc, collateral_label, exchange_label); + eprintln!("[polymarket] Approving unlimited {} for {} (one-time)...", 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..."); diff --git a/skills/polymarket-plugin/src/commands/create_readonly_key.rs b/skills/polymarket-plugin/src/commands/create_readonly_key.rs index 371536e84..efa9bcf75 100644 --- a/skills/polymarket-plugin/src/commands/create_readonly_key.rs +++ b/skills/polymarket-plugin/src/commands/create_readonly_key.rs @@ -1,6 +1,7 @@ use anyhow::Result; use reqwest::Client; +use crate::api::get_clob_version; use crate::auth::create_readonly_api_key; use crate::onchainos::get_wallet_address; @@ -15,6 +16,23 @@ use crate::onchainos::get_wallet_address; /// once. Store it securely if you intend to reuse it. pub async fn run() -> Result<()> { let client = Client::new(); + + // create-readonly-key is a CLOB v2-only endpoint — fail early with a clear message + // rather than an opaque "Unauthorized" from the server. + let clob_version = get_clob_version(&client).await.unwrap_or(1); + if clob_version < 2 { + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "ok": false, + "error": "create-readonly-key requires CLOB v2 (server is currently v1)", + "suggestion": "The /auth/readonly-api-key endpoint is only available after the \ + Polymarket CLOB v2 upgrade. Check again once the upgrade is live." + }))? + ); + return Ok(()); + } + let wallet_addr = get_wallet_address().await?; eprintln!("[polymarket] Creating read-only API key for {}...", wallet_addr); diff --git a/skills/polymarket-plugin/src/commands/history.rs b/skills/polymarket-plugin/src/commands/history.rs index 21fa95b01..54310d6b0 100644 --- a/skills/polymarket-plugin/src/commands/history.rs +++ b/skills/polymarket-plugin/src/commands/history.rs @@ -72,14 +72,24 @@ pub async fn run(limit: u32, address: Option<&str>) -> Result<()> { // Enrich each activity item with a `result` field for item in items.iter_mut() { let cid = item["conditionId"].as_str().unwrap_or(""); + let activity_type = item["type"].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(Some(winner_idx)) => { + if activity_type == "REDEEM" { + // REDEEM entries always represent successful winning redemptions. + // The Data API returns outcomeIndex=999 for redeems — skip outcome + // matching to avoid misclassifying as "LOST". + "WON" + } else { + 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) }; diff --git a/skills/polymarket-plugin/src/commands/mod.rs b/skills/polymarket-plugin/src/commands/mod.rs index 22a960ca0..0b003bf00 100644 --- a/skills/polymarket-plugin/src/commands/mod.rs +++ b/skills/polymarket-plugin/src/commands/mod.rs @@ -4,7 +4,6 @@ pub mod quickstart; pub mod cancel; pub mod check_access; pub mod create_readonly_key; -pub mod history; pub mod deposit; pub mod get_market; pub mod get_positions; diff --git a/skills/polymarket-plugin/src/commands/rfq.rs b/skills/polymarket-plugin/src/commands/rfq.rs index 2b2eaf335..9178cd2e6 100644 --- a/skills/polymarket-plugin/src/commands/rfq.rs +++ b/skills/polymarket-plugin/src/commands/rfq.rs @@ -9,7 +9,7 @@ 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; +use super::buy::{resolve_from_gamma, resolve_market_token}; /// Request-for-Quote (RFQ) for a block trade with a Polymarket market maker. /// @@ -34,9 +34,14 @@ pub async fn run( let client = Client::new(); - // Resolve market. + // Resolve market — supports condition_id, slug, or series ID (e.g. btc-5m). let (condition_id, token_id, neg_risk, _fee) = - resolve_market_token(&client, market_id, outcome).await?; + if crate::series::is_series_id(market_id) { + let gamma = crate::series::resolve_to_market(&client, market_id).await?; + resolve_from_gamma(&client, gamma, outcome).await? + } else { + resolve_market_token(&client, market_id, outcome).await? + }; let side = "BUY"; // RFQ always requests the buy side; sell-side RFQ uses the counterparty flow diff --git a/skills/polymarket-plugin/src/commands/sell.rs b/skills/polymarket-plugin/src/commands/sell.rs index a48b311dc..a4aa70764 100644 --- a/skills/polymarket-plugin/src/commands/sell.rs +++ b/skills/polymarket-plugin/src/commands/sell.rs @@ -556,6 +556,7 @@ pub async fn run( let result = serde_json::json!({ "ok": true, "data": { + "market_id": market_id, "order_id": resp.order_id, "status": resp.status, "condition_id": condition_id, @@ -566,6 +567,7 @@ pub async fn run( "limit_price": limit_price, "shares": shares_filled, "usdc_out": taker_amount_raw as f64 / 1_000_000.0, + "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 }, "tx_hashes": resp.tx_hashes, From 35f95fc4434f0b00b57bd05ea38a2fb7c831d296 Mon Sep 17 00:00:00 2001 From: "sam.see" Date: Mon, 27 Apr 2026 21:54:42 +0800 Subject: [PATCH 3/7] =?UTF-8?q?chore(polymarket):=20fix=20stale=20version?= =?UTF-8?q?=20date=20in=20SKILL.md=20(2026-04-20=20=E2=86=92=202026-04-27)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- skills/polymarket-plugin/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/polymarket-plugin/SKILL.md b/skills/polymarket-plugin/SKILL.md index ef432c65f..5b3600ff6 100644 --- a/skills/polymarket-plugin/SKILL.md +++ b/skills/polymarket-plugin/SKILL.md @@ -1321,4 +1321,4 @@ Fees are deducted by the exchange from the received amount. In CLOB v2, `feeRate ## Changelog -See [CHANGELOG.md](CHANGELOG.md) for full version history. Current version: **0.5.1** (2026-04-20). +See [CHANGELOG.md](CHANGELOG.md) for full version history. Current version: **0.5.1** (2026-04-27). From 0e9891773c0eaaeb6d3a2c13bc769bc9f714f5f7 Mon Sep 17 00:00:00 2001 From: "sam.see" Date: Mon, 27 Apr 2026 21:55:57 +0800 Subject: [PATCH 4/7] =?UTF-8?q?chore(polymarket):=20remove=20history.rs=20?= =?UTF-8?q?(command=20dropped=20=E2=80=94=20Data=20API=20partial=20sell/re?= =?UTF-8?q?deem=20tracking=20unreliable)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../polymarket-plugin/src/commands/history.rs | 115 ------------------ 1 file changed, 115 deletions(-) delete mode 100644 skills/polymarket-plugin/src/commands/history.rs diff --git a/skills/polymarket-plugin/src/commands/history.rs b/skills/polymarket-plugin/src/commands/history.rs deleted file mode 100644 index 54310d6b0..000000000 --- a/skills/polymarket-plugin/src/commands/history.rs +++ /dev/null @@ -1,115 +0,0 @@ -/// `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 activity_type = item["type"].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)) => { - if activity_type == "REDEEM" { - // REDEEM entries always represent successful winning redemptions. - // The Data API returns outcomeIndex=999 for redeems — skip outcome - // matching to avoid misclassifying as "LOST". - "WON" - } else { - 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(()) -} From f5230d056e7e5fd06a0f127dca5b79b6e5536bfd Mon Sep 17 00:00:00 2001 From: "sam.see" Date: Mon, 27 Apr 2026 22:06:15 +0800 Subject: [PATCH 5/7] chore(polymarket): restore SUMMARY.md from v0.4.11 main Co-Authored-By: Claude Sonnet 4.6 --- skills/polymarket-plugin/SUMMARY.md | 36 +++++++++++------------------ 1 file changed, 13 insertions(+), 23 deletions(-) diff --git a/skills/polymarket-plugin/SUMMARY.md b/skills/polymarket-plugin/SUMMARY.md index c2f81e21d..aaa561885 100644 --- a/skills/polymarket-plugin/SUMMARY.md +++ b/skills/polymarket-plugin/SUMMARY.md @@ -1,29 +1,19 @@ -# polymarket-plugin - ## Overview -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` +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. ## Prerequisites - -- 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 +- 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 ## Quick Start - -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 +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 ` From c970830e1f785c5b429b7ea5374a04351e81cf23 Mon Sep 17 00:00:00 2001 From: Amos Date: Tue, 28 Apr 2026 00:08:51 +0800 Subject: [PATCH 6/7] fix(polymarket): v0.5.1 setup-proxy hardening + RPC robustness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QA pass on top of v0.5.1 surfaced a partial-failure mode in setup-proxy and a test-only RPC URL bug. Found via cargo test (lib unit tests fail to compile, 1 rpc_mocks test fails) and a Branch B (no cached creds) walkthrough for the post-V2-cutover fresh-user flow. Bug fixes ───────── 1. lib unit test compile failure `test_ctf_redeem_positions_selector` referenced a non-existent helper `build_redeem_positions_calldata`. Extracted the encode logic out of `ctf_redeem_positions` into a pure `build_ctf_redeem_positions_calldata` helper so the selector can be unit-tested independently from RPC I/O. 2. & 4. RPC URL constant bypassed test mocks `get_ctf_balance` and `get_pusd_allowance` posted to `Urls::POLYGON_RPC` (the const) instead of `Urls::polygon_rpc()` (the helper that respects `POLYMARKET_TEST_POLYGON_RPC` env-var override). Result: integration tests silently hit production RPC and asserted against unrelated balances. Aligned with the 4 other call-sites that already use the helper. 3. setup-proxy "group probe" idempotency `ensure_proxy_approvals` only checked the FIRST allowance of each block (V1 and V2) as a proxy for "all set". A partial failure (tx 1 succeeds, tx 3 times out) was therefore permanent: on retry the group probe saw tx 1's allowance and skipped tx 2-6. The user's wallet would silently lack approvals for NEG_RISK markets, then revert at trade time. Replaced with per-pair `is_approval_set(token, spender)` using the existing `get_usdc_allowance` / `get_pusd_allowance` / `is_ctf_approved_for_all` helpers. Surfaced 2 missing V2 NEG_RISK approvals on the test wallet that the old probe was hiding. 5. & 6. unwrap_or(0) swallows RPC errors (EVM-012) - `redeem.rs:184` `get_ctf_balance(...).unwrap_or(0)` would tell users their winning tokens "don't exist" when the RPC was just unreachable. - `setup_proxy.rs` `get_*_allowance(...).unwrap_or(0)` would resubmit all 10 approvals (≈ $0.01 wasted POL) on a transient RPC blip. Both now propagate via `?` with `with_context` so the agent gets a structured RPC_UNAVAILABLE / ALLOWANCE_CHECK_FAILED. 7. setup-proxy status field misleading "already_configured" was returned even when the function had just submitted V2 top-up approvals on-chain. Distinguished into: `already_configured` / `approvals_topped_up` / `mode_switched` / `recovered` / `deployed_inline`. 8. Mode flag persisted before approvals confirmed In Branch 2 (mode_switched) `creds.mode = PolyProxy` was saved BEFORE `ensure_proxy_approvals`. If approvals failed, the next buy would route through proxy without allowances → on-chain revert. Now the proxy_wallet is saved early (so retry doesn't redeploy) but mode is saved only after all approvals confirm. 9. SKILL.md missing get-series H3 section The `get-series` command exists in the binary (`--list` / `--series`) and is referenced by `buy --token-id`'s description, but had no dedicated documentation section. Added one mirroring the other commands' structure (flags / output fields / comparison with list-5m). Branch B / new-user flow hardening (post-V2-cutover discovery) ────────────────────────────────────────────────────────────── Probing the existing setup-proxy with a fresh creds file revealed 4 more issues that would degrade the brand-new-user post-2026-04-28 flow: D. `get_existing_proxy` had no RPC fallback (single drpc.org call). If drpc was momentarily unavailable, setup-proxy bailed even though publicnode was reachable. Added the same drpc → publicnode fallback pattern used by `get_proxy_address_from_tx`. A+B. `find_create_in_trace` returns Some(addr) for BOTH cases — proxies that exist (CALL trace) AND proxies that don't (CREATE2 trace shows the deterministic destination address). The old code blindly trusted the trace as "exists", which was misleading for fresh users. `get_existing_proxy` now returns `Option<(addr, code_present)>` and uses `eth_getCode` to discriminate. setup-proxy reports: - `recovered` when proxy already deployed - `deployed_inline` when the address is the CREATE2 destination and will be deployed atomically by the first approve tx; the tx hash of that first approve is now surfaced as `deploy_tx`. C. Branch 5 (explicit `create_proxy_wallet` + `get_proxy_address_from_tx`) is dead code: `find_create_in_trace` always returns Some, so we never fell through to it. Removed `create_proxy_wallet`, `get_proxy_address_from_tx`, `verify_eip1167_proxy`, and the unused `compute_create_address` (~120 lines). The "deploy + first approve in one tx" path that the factory pattern handles natively is now the only path. Knock-on adapter changes ──────────────────────── - `redeem.rs::discover_uncached_proxy` and `quickstart.rs` filter the new `(addr, exists)` tuple by `exists == true` so they don't display fake un-deployed proxy addresses to users. - setup-proxy dry-run now uses `ensure_proxy_approvals(.., dry_run=true)` in BOTH cached-creds and on-chain-probe paths, so the agent gets the precise list of "would_set" approvals (was: static all-10 list). Testing ─────── - lib unit tests: ❌ failed to compile → ✅ 32 passed - rpc_mocks integration: 9 passed / 1 failed → ✅ 10 passed - subprocess_mocks: ✅ 6 passed → ✅ 6 passed - Live verification on Korean-IP wallet: * setup-proxy --dry-run from cached creds: Branch A, 8 pre_existing, 2 would_set (V2 NEG_RISK) * setup-proxy --dry-run with creds.proxy_wallet temporarily removed: Branch B, on-chain probe, eth_getCode confirms exists, same 8/2 split * Real $1 FOK buy on a 5-min BTC market: matched, position correctly accounted, USDC.e balance moved from $2.77 → $1.77, POL untouched Co-Authored-By: Claude Opus 4.7 (1M context) --- skills/polymarket-plugin/SKILL.md | 37 ++ .../src/commands/quickstart.rs | 14 +- .../polymarket-plugin/src/commands/redeem.rs | 16 +- .../src/commands/setup_proxy.rs | 448 ++++++++++++------ skills/polymarket-plugin/src/onchainos.rs | 305 +++++------- 5 files changed, 467 insertions(+), 353 deletions(-) diff --git a/skills/polymarket-plugin/SKILL.md b/skills/polymarket-plugin/SKILL.md index 5b3600ff6..e61212774 100644 --- a/skills/polymarket-plugin/SKILL.md +++ b/skills/polymarket-plugin/SKILL.md @@ -488,6 +488,43 @@ polymarket-plugin list-5m --coin ETH --count 3 # next 3 ETH 5-minute markets --- +### `get-series` — Resolve Current and Next Slot of a Recurring Series + +``` +polymarket-plugin get-series --series +polymarket-plugin get-series --list +``` + +Polymarket runs recurring "Up/Down" markets on a fixed cadence (5min / 15min / 4h) for BTC, ETH, SOL, XRP. Each cadence × asset is a *series*. This command resolves the current and next slot of a given series, so an Agent can quote prices and place a `buy` against either window without manually computing slugs from timestamps. + +**Flags:** +| Flag | Description | +|------|-------------| +| `--series` | Series identifier: `btc-5m`, `eth-5m`, `sol-5m`, `xrp-5m`, `btc-15m`, `eth-15m`, `sol-15m`, `xrp-15m`, `btc-4h`, `eth-4h`, `sol-4h`, `xrp-4h`. Required unless `--list` is passed. | +| `--list` | Print all 12 supported series and exit. | + +**Auth required:** No + +**Output (per slot):** `slot` (`current` / `next`), `slug`, `condition_id`, `question`, `start`, `end`, `seconds_remaining`, `up_price`, `down_price`, `up_token_id`, `down_token_id`, `liquidity`, `volume_24hr`, `accepting_orders`. Top level also has `session` (NYSE-hours status), `tip` (a ready-to-paste `buy` command), and `trading_hours`. + +**Trading hours:** 5min and 15min series trade only during NYSE hours (9:30 AM – 4:00 PM ET, Mon–Fri). 4h series are 24/7. Out-of-hours queries return `accepting_orders: false` and a `next_slot.start` pointing to the next session open. + +**Comparison with `list-5m`:** +- `list-5m` covers 7 coins (BTC/ETH/SOL/XRP/BNB/DOGE/HYPE) for 5-minute markets only, returning the next N windows. +- `get-series` covers 4 coins across 5min / 15min / 4h cadences, returning exactly current + next. + +**Example:** +```bash +polymarket-plugin get-series --list +polymarket-plugin get-series --series btc-5m +polymarket-plugin get-series --series eth-4h + +# Then trade the current slot: +polymarket-plugin buy --market-id --outcome up --amount 1 --order-type FOK +``` + +--- + ### `list-markets` — Browse Active Prediction Markets **Trigger phrases (general):** list markets, 列出市场, 有哪些市场, 看看市场, 有什么可以买, browse markets diff --git a/skills/polymarket-plugin/src/commands/quickstart.rs b/skills/polymarket-plugin/src/commands/quickstart.rs index 4d0ec051a..92c8976bb 100644 --- a/skills/polymarket-plugin/src/commands/quickstart.rs +++ b/skills/polymarket-plugin/src/commands/quickstart.rs @@ -21,6 +21,13 @@ pub struct QuickstartArgs { } pub async fn run(args: QuickstartArgs) -> anyhow::Result<()> { + match run_inner(args).await { + Ok(()) => Ok(()), + Err(e) => { println!("{}", super::error_response(&e, Some("quickstart"), None)); Ok(()) } + } +} + +async fn run_inner(args: QuickstartArgs) -> anyhow::Result<()> { let client = Client::new(); // 1. Resolve EOA wallet (fails fast if onchainos CLI is not logged in) @@ -44,7 +51,12 @@ pub async fn run(args: QuickstartArgs) -> anyhow::Result<()> { .and_then(|c| c.proxy_wallet); let proxy: Option = match proxy_from_creds { Some(p) => Some(p), - None => get_existing_proxy(&eoa).await.unwrap_or(None), + // Only treat the on-chain probe result as a proxy if it's actually deployed — + // the trace can produce the deterministic CREATE2 address for un-deployed + // proxies too, which would mislead the status message into showing a fake proxy. + None => get_existing_proxy(&eoa).await.ok().flatten() + .filter(|(_, exists)| *exists) + .map(|(addr, _)| addr), }; // 3. Positions belong to the maker wallet — proxy if it exists, else EOA diff --git a/skills/polymarket-plugin/src/commands/redeem.rs b/skills/polymarket-plugin/src/commands/redeem.rs index 3cb0b736c..07059cb1a 100644 --- a/skills/polymarket-plugin/src/commands/redeem.rs +++ b/skills/polymarket-plugin/src/commands/redeem.rs @@ -178,10 +178,18 @@ async fn redeem_one( } // Query on-chain ERC-1155 balances for each outcome token. + // Propagate RPC errors (don't unwrap_or(0)) — silently treating an RPC failure as + // "no balance" would tell users their winning tokens don't exist when really the + // node is just unavailable. 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); + let bal = get_ctf_balance(wallet, tid).await + .with_context(|| format!( + "Failed to query CTF balance for token_id {} in wallet {}. \ + Polygon RPC may be unavailable — retry in a few seconds.", + tid, wallet + ))?; amounts.push(bal); } @@ -264,11 +272,17 @@ async fn redeem_one( /// 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. +/// +/// We only return a proxy if its bytecode is present on-chain. The trace can produce +/// a deterministic CREATE2 address even for un-deployed proxies; surfacing that in a +/// redeem hint would mislead the user into thinking they have a usable proxy. async fn discover_uncached_proxy(eoa: &str, creds_proxy: Option<&str>) -> Option { if creds_proxy.is_some() { return None; } get_existing_proxy(eoa).await.ok().flatten() + .filter(|(_, exists)| *exists) + .map(|(addr, _)| addr) } /// Build a human-readable hint pointing at a proxy wallet discovered on-chain, diff --git a/skills/polymarket-plugin/src/commands/setup_proxy.rs b/skills/polymarket-plugin/src/commands/setup_proxy.rs index 1805fff48..daad464b9 100644 --- a/skills/polymarket-plugin/src/commands/setup_proxy.rs +++ b/skills/polymarket-plugin/src/commands/setup_proxy.rs @@ -1,11 +1,11 @@ /// `polymarket setup-proxy` — create a Polymarket proxy wallet and switch to POLY_PROXY mode. /// /// Flow: -/// 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. Resolve the proxy address from the transaction trace -/// 4. Persist proxy_wallet + mode=PolyProxy in creds.json -/// 5. Set up one-time approvals on the proxy wallet so trading is gasless: +/// 1. Resolve the deterministic proxy address via `PROXY_FACTORY.proxy([])` debug_traceCall. +/// Also probes whether the address has bytecode → distinguishes "recovered" vs +/// "deployed_inline" (CREATE2 destination computed but not yet deployed). +/// 2. Persist the proxy_wallet in creds.json (mode is set ONLY after approvals succeed). +/// 3. Set up the 10 one-time approvals so trading is gasless: /// /// V1 (6 txs — USDC.e collateral): /// USDC.e.approve(CTF_EXCHANGE, MAX_UINT) @@ -21,13 +21,92 @@ /// pUSD.approve(NEG_RISK_ADAPTER, MAX_UINT) /// USDC.e.approve(COLLATERAL_ONRAMP, MAX_UINT) ← auto-wrap USDC.e → pUSD /// +/// For a fresh wallet (no proxy on-chain), the FIRST approve tx is also the deployment tx — +/// the factory deploys via CREATE2 and forwards the approve op atomically. The tx hash of +/// that first approve is surfaced as `deploy_tx` in the response. +/// +/// Each approval is checked individually before submission — if a previous run partially +/// succeeded, only the missing approvals are sent. This is safe because all approvals +/// are independent and idempotent (re-approving with MAX_UINT is a no-op). +/// /// 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::{Context as _, Result}; use reqwest::Client; +/// Token type the proxy is approving from. +#[derive(Clone, Copy)] +enum ApproveToken { + UsdcE, + Pusd, + CtfErc1155, +} + +/// One element in the canonical approval list. Each is checked + sent independently. +struct Approval { + token: ApproveToken, + spender: &'static str, + label: &'static str, +} + +fn full_approval_list() -> Vec { + use crate::config::Contracts; + vec![ + // V1 — USDC.e + CTF setApprovalForAll across the 3 exchange contracts + Approval { token: ApproveToken::UsdcE, spender: Contracts::CTF_EXCHANGE, label: "V1 / USDC.e → CTF Exchange" }, + Approval { token: ApproveToken::CtfErc1155, spender: Contracts::CTF_EXCHANGE, label: "V1 / CTF → CTF Exchange" }, + Approval { token: ApproveToken::UsdcE, spender: Contracts::NEG_RISK_CTF_EXCHANGE, label: "V1 / USDC.e → Neg Risk CTF Exchange" }, + Approval { token: ApproveToken::CtfErc1155, spender: Contracts::NEG_RISK_CTF_EXCHANGE, label: "V1 / CTF → Neg Risk CTF Exchange" }, + Approval { token: ApproveToken::UsdcE, spender: Contracts::NEG_RISK_ADAPTER, label: "V1 / USDC.e → Neg Risk Adapter" }, + Approval { token: ApproveToken::CtfErc1155, spender: Contracts::NEG_RISK_ADAPTER, label: "V1 / CTF → Neg Risk Adapter" }, + // V2 — pUSD spending allowance + USDC.e onramp wrapper + Approval { token: ApproveToken::Pusd, spender: Contracts::CTF_EXCHANGE_V2, label: "V2 / pUSD → CTF Exchange V2" }, + Approval { token: ApproveToken::Pusd, spender: Contracts::NEG_RISK_CTF_EXCHANGE_V2, label: "V2 / pUSD → Neg Risk CTF Exchange V2" }, + Approval { token: ApproveToken::Pusd, spender: Contracts::NEG_RISK_ADAPTER, label: "V2 / pUSD → Neg Risk Adapter" }, + Approval { token: ApproveToken::UsdcE, spender: Contracts::COLLATERAL_ONRAMP, label: "V2 / USDC.e → Collateral Onramp" }, + ] +} + +struct ApprovalSubmission { + label: &'static str, + tx_hash: String, +} + +struct ApprovalReport { + pre_existing: Vec<&'static str>, // labels of approvals already on-chain + newly_set: Vec, // submitted in this run, with tx hashes + would_set: Vec<&'static str>, // dry-run only: labels that would be submitted +} + +impl ApprovalReport { + fn was_no_op(&self) -> bool { + self.newly_set.is_empty() && self.would_set.is_empty() + } + + fn newly_set_labels(&self) -> Vec<&'static str> { + self.newly_set.iter().map(|s| s.label).collect() + } + + fn newly_set_json(&self) -> Vec { + self.newly_set.iter() + .map(|s| serde_json::json!({ "label": s.label, "tx": s.tx_hash })) + .collect() + } +} + pub async fn run(dry_run: bool) -> Result<()> { + match run_inner(dry_run).await { + Ok(()) => Ok(()), + Err(e) => { + // GEN-001: emit structured error to stdout so external Agents can parse. + println!("{}", super::error_response(&e, Some("setup-proxy"), None)); + Ok(()) + } + } +} + +async fn run_inner(dry_run: bool) -> Result<()> { let client = Client::new(); // Geo check — WARNING only, do not abort. Users in restricted regions can still @@ -40,212 +119,277 @@ 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 in cached creds. + // ── Branch A: cached creds say a proxy exists ───────────────────────────── if let Some(ref proxy) = creds.proxy_wallet { - if creds.mode == crate::config::TradingMode::PolyProxy { - let proxy = proxy.clone(); - eprintln!("[polymarket] Proxy wallet already configured. Checking approvals..."); - ensure_proxy_approvals(&proxy, dry_run).await?; - println!( - "{}", - serde_json::json!({ - "ok": true, - "data": { - "status": "already_configured", - "proxy_wallet": proxy, - "mode": "poly_proxy", - "note": "Proxy wallet set up and approvals confirmed. Use `polymarket switch-mode --mode eoa` to revert." - } - }) - ); - return Ok(()); - } - // Has proxy but mode is EOA — switch mode and ensure approvals. let proxy = proxy.clone(); - if !dry_run { + let was_in_proxy_mode = creds.mode == crate::config::TradingMode::PolyProxy; + + eprintln!("[polymarket] Proxy wallet found in creds: {}. Checking approvals...", proxy); + let report = ensure_proxy_approvals(&proxy, dry_run).await + .context("Failed to verify or set proxy approvals")?; + + // Save mode=PolyProxy ONLY after approvals are confirmed (atomicity Bug #8). + if !dry_run && !was_in_proxy_mode { creds.mode = crate::config::TradingMode::PolyProxy; crate::config::save_credentials(&creds)?; } - ensure_proxy_approvals(&proxy, dry_run).await?; + + let status = if was_in_proxy_mode { + if report.was_no_op() { "already_configured" } else { "approvals_topped_up" } + } else { + "mode_switched" + }; + let note = match (status, dry_run) { + ("already_configured", _) => + "Proxy wallet already configured. All 10 approvals confirmed on-chain.".to_string(), + ("approvals_topped_up", true) => + format!("dry-run: would submit {} missing approval(s) on-chain (no state written).", report.would_set.len()), + ("approvals_topped_up", false) => + format!("Proxy wallet was already set up but missing {} approval(s); they were just submitted on-chain.", report.newly_set.len()), + ("mode_switched", true) => + "dry-run: would switch to POLY_PROXY mode and ensure approvals (no state written).".to_string(), + ("mode_switched", false) => + format!("Switched to POLY_PROXY mode. {} new approval(s) submitted; {} were already set. Deposit USDC.e with `polymarket deposit --amount `.", + report.newly_set.len(), report.pre_existing.len()), + _ => String::new(), + }; + println!( "{}", serde_json::json!({ "ok": true, "dry_run": dry_run, "data": { - "status": "mode_switched", + "status": status, "proxy_wallet": proxy, "mode": "poly_proxy", - "note": if dry_run { "dry-run: would switch to POLY_PROXY mode (no state written)" } else { "Switched to POLY_PROXY mode. Deposit USDC.e with `polymarket deposit --amount `." } + "approvals": { + "pre_existing": report.pre_existing, + "newly_set": report.newly_set_json(), + "would_set": report.would_set, + }, + "note": note } }) ); return Ok(()); } - // Step 2: mandatory on-chain check before any deployment. - // If the RPC call fails we MUST abort — we cannot distinguish "no proxy exists" - // from "RPC error", and deploying a duplicate wastes gas and risks proxy confusion. - eprintln!("[polymarket] Checking on-chain for existing proxy wallet..."); - let existing_proxy = crate::onchainos::get_existing_proxy(&signer_addr).await + // ── Branch B: no cached proxy — probe on-chain ─────────────────────────── + // RPC failure here MUST be fatal; we cannot tell if a proxy exists. + eprintln!("[polymarket] Probing PROXY_FACTORY for proxy wallet state..."); + let probe = crate::onchainos::get_existing_proxy(&signer_addr).await .map_err(|e| anyhow::anyhow!( - "On-chain proxy check failed: {}. \ - Aborting to prevent duplicate deployment. Retry when the RPC is available.", + "On-chain proxy lookup failed across all RPCs: {}. \ + Cannot safely determine proxy state. Retry when an RPC supporting \ + debug_traceCall is available (drpc.org, polygon-bor-rpc.publicnode.com).", e ))?; - if let Some(existing) = existing_proxy { - eprintln!("[polymarket] Found existing proxy on-chain: {}", existing); - if !dry_run { - creds.proxy_wallet = Some(existing.clone()); - creds.mode = crate::config::TradingMode::PolyProxy; - crate::config::save_credentials(&creds)?; - } - ensure_proxy_approvals(&existing, dry_run).await?; - println!( - "{}", - serde_json::json!({ - "ok": true, - "dry_run": dry_run, - "data": { - "status": "recovered", - "proxy_wallet": existing, - "mode": "poly_proxy", - "note": if dry_run { "dry-run: found proxy on-chain; would save to creds (no state written)" } else { "Existing proxy wallet found on-chain and saved to creds. No new deployment needed." } - } - }) + let (proxy_addr, exists_on_chain) = match probe { + Some(pair) => pair, + None => anyhow::bail!( + "PROXY_FACTORY.proxy([]) trace returned no sub-call for signer {}. \ + This is unexpected for the current Polymarket factory; please retry or \ + report. Aborting to avoid unsafe assumptions about proxy state.", + signer_addr + ), + }; + + if exists_on_chain { + eprintln!("[polymarket] Found existing proxy on-chain: {}", proxy_addr); + } else { + eprintln!( + "[polymarket] Proxy address resolved: {} (not yet deployed — will deploy atomically with the first approve tx).", + proxy_addr ); - return Ok(()); } - // Step 3: confirmed no proxy on-chain — deploy one via PROXY_FACTORY. + // ── Dry-run: query chain for actual approval state, but submit nothing ── + // Use the same per-pair logic as the live path so the agent sees the precise + // set of approvals that would actually be sent (not the static all-10 list). if dry_run { + let report = ensure_proxy_approvals(&proxy_addr, true).await + .context("Failed to query approval state for dry-run report")?; + let status = if exists_on_chain { "would_top_up" } else { "would_create" }; + let note = if exists_on_chain { + format!("dry-run: proxy on-chain at {}; {} approval(s) already set, {} would be submitted.", + proxy_addr, report.pre_existing.len(), report.would_set.len()) + } else { + format!("dry-run: proxy not yet deployed. setup-proxy would atomically deploy it via the first approve tx ({} approval(s) total — no separate deploy tx).", + report.would_set.len()) + }; println!( "{}", serde_json::json!({ "ok": true, "dry_run": true, "data": { + "status": status, + "proxy_wallet": proxy_addr, + "proxy_exists_on_chain": exists_on_chain, + "would_deploy_with_first_approve": !exists_on_chain, + "approvals": { + "pre_existing": report.pre_existing, + "would_set": report.would_set, + }, "signer": signer_addr, - "action": "would call PROXY_FACTORY.proxy([]) to deploy proxy wallet, then set 10 USDC.e/pUSD/CTF approvals", - "note": "dry-run: no transaction submitted" + "note": note, } }) ); return Ok(()); } - eprintln!("[polymarket] Deploying proxy wallet via PROXY_FACTORY (one-time gas cost)..."); - 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. - eprintln!("[polymarket] Resolving proxy wallet address from transaction trace..."); - let proxy_addr = crate::onchainos::get_proxy_address_from_tx(&tx_hash) - .await - .with_context(|| format!( - "Proxy deployed (tx {}) but address could not be resolved. \ - Check: https://polygonscan.com/tx/{}", - tx_hash, tx_hash - ))?; - - // Step 4: persist. + // ── Persist proxy_wallet first so retry won't redeploy. Mode is delayed + // until approvals confirm, so a partial failure leaves us in a + // re-runnable state (Bug #8 atomicity). creds.proxy_wallet = Some(proxy_addr.clone()); + crate::config::save_credentials(&creds)?; + + let report = ensure_proxy_approvals(&proxy_addr, false).await + .context(if exists_on_chain { + "Failed to verify or set approvals on existing proxy" + } else { + "Approval flow failed before completion. Re-run setup-proxy — \ + per-pair idempotency will only retry the missing approvals." + })?; + creds.mode = crate::config::TradingMode::PolyProxy; crate::config::save_credentials(&creds)?; - // Step 5: set up the one-time approvals so trading is gasless. - ensure_proxy_approvals(&proxy_addr, dry_run).await?; + let status = if exists_on_chain { "recovered" } else { "deployed_inline" }; + let deploy_tx = if !exists_on_chain { + report.newly_set.first().map(|s| s.tx_hash.clone()) + } else { + None + }; + let note = if exists_on_chain { + format!( + "Existing proxy wallet found on-chain and saved to creds. {} new approval(s) submitted; {} were already set.", + report.newly_set.len(), report.pre_existing.len() + ) + } else { + format!( + "Proxy wallet deployed atomically with the first approve tx ({}). {} approval(s) submitted in total.", + deploy_tx.as_deref().unwrap_or("?"), + report.newly_set.len() + ) + }; + + let mut data = serde_json::json!({ + "status": status, + "proxy_wallet": proxy_addr, + "mode": "poly_proxy", + "approvals": { + "pre_existing": report.pre_existing, + "newly_set": report.newly_set_json(), + "would_set": report.would_set, + }, + "next_step": "Deposit USDC.e with: polymarket deposit --amount ", + "note": note, + }); + if let Some(dtx) = deploy_tx { + data["deploy_tx"] = serde_json::json!(dtx); + } println!( "{}", - serde_json::json!({ - "ok": true, - "data": { - "status": "created", - "proxy_wallet": proxy_addr, - "deploy_tx": tx_hash, - "mode": "poly_proxy", - "next_step": "Deposit USDC.e with: polymarket deposit --amount " - } - }) + serde_json::json!({ "ok": true, "data": data }) ); Ok(()) } -/// Set up the one-time on-chain approvals required for gasless trading in POLY_PROXY mode. +/// Check each (token, spender) pair individually, only submitting the missing approvals. +/// Returns a report distinguishing what was already on-chain vs what was just sent. /// -/// V1 block (6 txs): USDC.e + CTF approved to V1 exchange contracts. -/// Idempotent: skipped if USDC.e→CTF_EXCHANGE allowance is already non-zero. +/// Bug #3 fix: previously this function used the first approval's allowance as a "group +/// probe" — if that one was set, the function skipped the other 5 V1 / 3 V2 approvals. +/// A partial failure (e.g. tx 1 succeeds, tx 3 times out) would then be permanent: the +/// retry's group probe sees "already done" and never reapproves the missing ones. /// -/// 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; +/// Bug #6 fix: previously RPC errors on the allowance reads were swallowed by +/// `unwrap_or(0)`, causing the function to falsely report "not approved" and resubmit +/// all 10 approvals on a transient RPC blip (≈ $0.01 wasted POL per blip). +async fn ensure_proxy_approvals(proxy_addr: &str, dry_run: bool) -> Result { + let approvals = full_approval_list(); + let mut report = ApprovalReport { + pre_existing: Vec::new(), + newly_set: Vec::new(), + would_set: Vec::new(), + }; - // ── V1 approvals ───────────────────────────────────────────────────────── - let v1_existing = crate::onchainos::get_usdc_allowance(proxy_addr, Contracts::CTF_EXCHANGE) - .await - .unwrap_or(0); - 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?; + for a in &approvals { + let already_set = is_approval_set(proxy_addr, a).await + .with_context(|| format!( + "Could not verify '{}' allowance on-chain. Polygon RPC may be unavailable. \ + Refusing to resubmit approvals blindly — re-run setup-proxy when the RPC \ + is reachable.", + a.label + ))?; + + if already_set { + report.pre_existing.push(a.label); + continue; } - eprintln!("[polymarket] V1 approvals confirmed."); + + if dry_run { + report.would_set.push(a.label); + continue; + } + + eprintln!("[polymarket] Approving {} ...", a.label); + let tx = submit_approval(a).await + .with_context(|| format!("Submitting '{}' approval failed", a.label))?; + eprintln!("[polymarket] tx: {}", tx); + crate::onchainos::wait_for_tx_receipt(&tx, 60) + .await + .with_context(|| format!( + "'{}' approval tx {} did not confirm in 60s. \ + Re-run setup-proxy to verify and continue from where it left off.", + a.label, tx + ))?; + report.newly_set.push(ApprovalSubmission { label: a.label, tx_hash: tx }); } - // ── 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)."); + if dry_run { + eprintln!( + "[polymarket] dry-run: {} approval(s) already on-chain, {} would be submitted.", + report.pre_existing.len(), report.would_set.len() + ); + } else if report.newly_set.is_empty() { + eprintln!("[polymarket] All {} approvals already on-chain — no action needed.", report.pre_existing.len()); } 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?; - } + eprintln!( + "[polymarket] Approvals confirmed: {} new, {} pre-existing. (newly_set labels: {:?})", + report.newly_set.len(), + report.pre_existing.len(), + report.newly_set_labels() + ); + } + Ok(report) +} - // 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?; +async fn is_approval_set(proxy_addr: &str, a: &Approval) -> Result { + match a.token { + ApproveToken::UsdcE => { + let allowance = crate::onchainos::get_usdc_allowance(proxy_addr, a.spender).await?; + Ok(allowance > 0) + } + ApproveToken::Pusd => { + let allowance = crate::onchainos::get_pusd_allowance(proxy_addr, a.spender).await?; + Ok(allowance > 0) + } + ApproveToken::CtfErc1155 => { + crate::onchainos::is_ctf_approved_for_all(proxy_addr, a.spender).await + } + } +} - eprintln!("[polymarket] V2 approvals confirmed. Proxy wallet fully ready for V1 and V2 gasless trading."); +async fn submit_approval(a: &Approval) -> Result { + match a.token { + ApproveToken::UsdcE => crate::onchainos::proxy_usdc_approve(a.spender).await, + ApproveToken::Pusd => crate::onchainos::proxy_pusd_approve(a.spender).await, + ApproveToken::CtfErc1155 => crate::onchainos::proxy_ctf_set_approval_for_all(a.spender).await, } - Ok(()) } diff --git a/skills/polymarket-plugin/src/onchainos.rs b/skills/polymarket-plugin/src/onchainos.rs index 4dcf27c03..5d686460a 100644 --- a/skills/polymarket-plugin/src/onchainos.rs +++ b/skills/polymarket-plugin/src/onchainos.rs @@ -175,66 +175,6 @@ fn pad_u256(val: u128) -> String { // ─── Proxy wallet ───────────────────────────────────────────────────────────── -/// Resolve the proxy wallet address created in `tx_hash` by inspecting the call trace. -/// -/// Uses `debug_traceTransaction` with the callTracer to find the CREATE/CREATE2 sub-call -/// emitted by PROXY_FACTORY and extract the resulting contract address. -/// -/// Resolve the proxy wallet address from `tx_hash` by inspecting the call trace. -/// -/// Uses `debug_traceTransaction` with the callTracer on two RPCs (drpc + publicnode). -/// If neither RPC returns a verifiable EIP-1167 proxy address, returns an error — the -/// caller must NOT proceed with an unverified address, as depositing to a wrong EOA -/// will permanently lose user funds. -pub async fn get_proxy_address_from_tx(tx_hash: &str) -> Result { - use crate::config::Urls; - - let rpcs = [Urls::POLYGON_RPC, "https://polygon-bor-rpc.publicnode.com"]; - for rpc_url in &rpcs { - let body = serde_json::json!({ - "jsonrpc": "2.0", - "method": "debug_traceTransaction", - "params": [tx_hash, {"tracer": "callTracer"}], - "id": 1 - }); - let resp = reqwest::Client::new() - .post(*rpc_url) - .json(&body) - .send() - .await; - if let Ok(r) = resp { - if let Ok(v) = r.json::().await { - if v.get("error").is_none() { - if let Some(addr) = find_create_in_trace(&v["result"]) { - // Mandatory: verify the resolved address is an EIP-1167 proxy. - // A wrong address (e.g. an EOA) would silently accept deposits and - // permanently lock user funds. - if verify_eip1167_proxy(&addr).await { - return Ok(addr); - } - anyhow::bail!( - "Resolved address {} from tx {} is not an EIP-1167 proxy contract. \ - Refusing to proceed to protect funds. \ - Check: https://polygonscan.com/tx/{}", - addr, tx_hash, tx_hash - ); - } - } - } - } - } - - // No nonce-based fallback: guessing an address without on-chain verification risks - // sending funds to a random EOA. Fail loudly instead. - anyhow::bail!( - "Could not retrieve proxy address from tx {} via debug_traceTransaction on any RPC. \ - This may be a temporary RPC outage — wait a few seconds and retry setup-proxy. \ - Do NOT deposit until the proxy address is confirmed on-chain. \ - Check: https://polygonscan.com/tx/{}", - tx_hash, tx_hash - ) -} - /// Search a callTracer trace for any call (CREATE, CREATE2, or CALL) made BY PROXY_FACTORY. /// The factory always calls the proxy wallet as its first sub-call — whether creating a new one /// (CREATE/CREATE2) or forwarding calls to an existing one (CALL). The `to` field is the proxy. @@ -242,21 +182,18 @@ fn find_create_in_trace(trace: &Value) -> Option { use crate::config::Contracts; let factory = Contracts::PROXY_FACTORY.to_lowercase(); - // Check direct sub-calls of the current frame if let Some(calls) = trace["calls"].as_array() { for sub in calls { let from = sub["from"].as_str().unwrap_or("").to_lowercase(); let call_type = sub["type"].as_str().unwrap_or(""); let to = sub["to"].as_str().unwrap_or(""); - // Any call FROM the factory is to the proxy (new or existing) if from == factory && !to.is_empty() && matches!(call_type, "CREATE" | "CREATE2" | "CALL") { return Some(to.to_string()); } - // Recurse deeper if let Some(addr) = find_create_in_trace(sub) { return Some(addr); } @@ -265,105 +202,19 @@ fn find_create_in_trace(trace: &Value) -> Option { None } -/// Compute the CREATE address for a deployment from `deployer` at `nonce`. -/// Formula: keccak256(rlp([deployer, nonce]))[12:] -fn compute_create_address(deployer: &str, nonce: u64) -> Result { - use sha3::{Digest, Keccak256}; - - let addr_bytes = hex::decode(deployer.trim_start_matches("0x")) - .context("decoding deployer address")?; - anyhow::ensure!(addr_bytes.len() == 20, "deployer must be 20 bytes"); - - // RLP-encode address (20 bytes): 0x94 prefix (0x80 + 20) - let rlp_addr: Vec = [&[0x94u8][..], &addr_bytes].concat(); - - // RLP-encode nonce - let rlp_nonce: Vec = if nonce == 0 { - vec![0x80] - } else { - let b = { - let mut tmp = nonce; - let mut bytes = Vec::new(); - while tmp > 0 { - bytes.push((tmp & 0xFF) as u8); - tmp >>= 8; - } - bytes.reverse(); - bytes - }; - if b.len() == 1 && b[0] < 0x80 { - b - } else { - [[0x80 + b.len() as u8].as_slice(), &b].concat() - } - }; - - // RLP-encode list: payload = rlp_addr + rlp_nonce - let payload: Vec = [rlp_addr, rlp_nonce].concat(); - let list_prefix: Vec = if payload.len() < 56 { - vec![0xC0 + payload.len() as u8] - } else { - let len_bytes = { - let l = payload.len(); - let mut tmp = l; - let mut bytes = Vec::new(); - while tmp > 0 { - bytes.push((tmp & 0xFF) as u8); - tmp >>= 8; - } - bytes.reverse(); - bytes - }; - [[0xF7 + len_bytes.len() as u8].as_slice(), &len_bytes].concat() - }; - let encoded: Vec = [list_prefix, payload].concat(); - - let hash = Keccak256::digest(&encoded); - Ok(format!("0x{}", hex::encode(&hash[12..]))) -} - -/// Check whether an address has EIP-1167 minimal proxy bytecode deployed. -async fn verify_eip1167_proxy(addr: &str) -> bool { - use crate::config::Urls; - const EIP1167_PREFIX: &str = "363d3d373d3d3d363d73"; - let body = serde_json::json!({ - "jsonrpc": "2.0", - "method": "eth_getCode", - "params": [addr, "latest"], - "id": 1 - }); - if let Ok(r) = reqwest::Client::new() - .post(Urls::polygon_rpc()) - .json(&body) - .send() - .await - { - if let Ok(v) = r.json::().await { - if let Some(code) = v["result"].as_str() { - return code.trim_start_matches("0x").starts_with(EIP1167_PREFIX); - } - } - } - false +/// Polygon RPC list used for proxy-state probes. Primary first, fallback second. +/// `polygon_rpc()` reads `POLYMARKET_TEST_POLYGON_RPC` so integration tests can +/// inject a mock; production always falls back to drpc.org. +fn proxy_probe_rpcs() -> [String; 2] { + [ + crate::config::Urls::polygon_rpc(), + "https://polygon-bor-rpc.publicnode.com".to_string(), + ] } -/// Create a Polymarket proxy wallet via PROXY_FACTORY.proxy([]). -/// -/// Calls `proxy((uint8,address,uint256,bytes)[])` with an empty calls array. -/// The factory deploys a minimal-proxy clone keyed to msg.sender (this wallet). -/// Returns the tx hash; call `get_proxy_address_from_tx(tx_hash)` to resolve the address. -/// -/// NOTE: One-time gas cost in POL. All subsequent trading via the proxy is relayer-paid. -/// Query PROXY_FACTORY via debug_traceCall to check if a proxy already exists for `eoa_addr`. -/// -/// Returns: -/// - `Ok(Some(addr))` — proxy exists on-chain and is a valid EIP-1167 contract -/// - `Ok(None)` — no proxy exists yet (safe to deploy) -/// - `Err(...)` — RPC call failed; caller MUST NOT proceed with deployment, -/// as we cannot distinguish "no proxy" from "RPC error" -pub async fn get_existing_proxy(eoa_addr: &str) -> Result> { +async fn try_trace_proxy_call(eoa_addr: &str, rpc_url: &str) -> Result> { use sha3::{Digest, Keccak256}; - use crate::config::{Contracts, Urls}; + use crate::config::Contracts; let selector = Keccak256::digest(b"proxy((uint8,address,uint256,bytes)[])"); let selector_hex = hex::encode(&selector[..4]); @@ -386,44 +237,94 @@ pub async fn get_existing_proxy(eoa_addr: &str) -> Result> { }); let resp = reqwest::Client::new() - .post(Urls::polygon_rpc()) + .post(rpc_url) .json(&body) .send() .await - .context("debug_traceCall RPC request failed")?; + .with_context(|| format!("debug_traceCall request to {} failed", rpc_url))?; let v: serde_json::Value = resp.json().await - .context("debug_traceCall response parse failed")?; + .with_context(|| format!("debug_traceCall response from {} not valid JSON", rpc_url))?; if let Some(err) = v.get("error") { - anyhow::bail!( - "debug_traceCall returned an error while checking for existing proxy: {}. \ - Cannot safely determine whether a proxy exists — aborting to prevent duplicate deployment.", - err - ); + anyhow::bail!("debug_traceCall on {} returned error: {}", rpc_url, err); } Ok(find_create_in_trace(&v["result"])) } -pub async fn create_proxy_wallet() -> Result { - use sha3::{Digest, Keccak256}; - use crate::config::Contracts; +/// Returns true if `addr` has non-empty bytecode at HEAD. False = EOA or undeployed. +async fn query_code_present(addr: &str, rpc_url: &str) -> Result { + let body = serde_json::json!({ + "jsonrpc": "2.0", + "method": "eth_getCode", + "params": [addr, "latest"], + "id": 1 + }); + let resp = reqwest::Client::new() + .post(rpc_url) + .json(&body) + .send() + .await + .with_context(|| format!("eth_getCode request to {} failed", rpc_url))?; + let v: serde_json::Value = resp.json().await + .with_context(|| format!("eth_getCode response from {} not valid JSON", rpc_url))?; + if let Some(err) = v.get("error") { + anyhow::bail!("eth_getCode on {} returned error: {}", rpc_url, err); + } + let code = v["result"].as_str().unwrap_or("0x"); + let stripped = code.trim_start_matches("0x"); + Ok(!stripped.is_empty() && !stripped.chars().all(|c| c == '0')) +} - // Function selector: keccak256("proxy((uint8,address,uint256,bytes)[])") - let selector = Keccak256::digest(b"proxy((uint8,address,uint256,bytes)[])"); - let selector_hex = hex::encode(&selector[..4]); +/// Probe PROXY_FACTORY for the proxy address keyed to `eoa_addr`, and report whether +/// that address has been deployed yet. +/// +/// Returns: +/// - `Ok(Some((addr, true)))` — proxy already deployed at `addr` (recover path) +/// - `Ok(Some((addr, false)))` — `addr` is the deterministic CREATE2 destination but +/// no contract is deployed there yet. Safe to call +/// `PROXY_FACTORY.proxy([(...)])` — the first such call +/// deploys the proxy atomically with the forwarded op. +/// - `Ok(None)` — the trace contained no factory sub-call at all. +/// Should not happen with the current Polymarket factory; +/// callers should treat it as an indeterminate state. +/// - `Err(...)` — both RPCs failed. Caller MUST NOT deploy or save state, +/// as we cannot tell which case we're in. +pub async fn get_existing_proxy(eoa_addr: &str) -> Result> { + let rpcs = proxy_probe_rpcs(); + let mut last_err: Option = None; - // ABI-encode empty dynamic array: offset=0x20 (32), length=0 - let calldata = format!( - "0x{}\ - 0000000000000000000000000000000000000000000000000000000000000020\ - 0000000000000000000000000000000000000000000000000000000000000000", - selector_hex - ); + for rpc_url in &rpcs { + let trace_result = match try_trace_proxy_call(eoa_addr, rpc_url).await { + Ok(opt) => opt, + Err(e) => { + last_err = Some(e); + continue; + } + }; - let result = wallet_contract_call(Contracts::PROXY_FACTORY, &calldata).await?; - extract_tx_hash(&result) + let addr = match trace_result { + Some(a) => a, + None => return Ok(None), + }; + + // Determine if the address has bytecode (proxy actually deployed). + // Use the same RPC for consistency — if drpc.org returned the trace, ask drpc.org for code. + match query_code_present(&addr, rpc_url).await { + Ok(present) => return Ok(Some((addr, present))), + Err(e) => { + last_err = Some(e.context(format!( + "Trace returned {} but eth_getCode for it failed", addr + ))); + continue; + } + } + } + + Err(last_err.unwrap_or_else(|| { + anyhow::anyhow!("All Polygon RPCs failed for proxy state lookup") + })) } /// Transfer USDC.e directly to a proxy wallet address. @@ -574,7 +475,7 @@ pub async fn get_pusd_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 @@ -853,24 +754,17 @@ pub async fn approve_ctf(neg_risk: bool) -> Result { } } -/// ABI-encode and submit CTF redeemPositions(collateralToken, parentCollectionId, conditionId, indexSets). -/// -/// Redeems all outcome positions for the given conditionId. indexSets [1, 2] covers both -/// 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). +/// Pure ABI encoder for CTF.redeemPositions(collateralToken, parentCollectionId, conditionId, indexSets). /// -/// `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 { +/// indexSets [1, 2] covers both 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. +/// Extracted as a pure function so the encoding can be unit-tested independently from RPC I/O. +pub fn build_ctf_redeem_positions_calldata(condition_id: &str, collateral_addr: &str) -> String { use sha3::{Digest, Keccak256}; - use crate::config::Contracts; let selector = Keccak256::digest(b"redeemPositions(address,bytes32,bytes32,uint256[])"); let selector_hex = hex::encode(&selector[..4]); - // 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 @@ -881,11 +775,23 @@ pub async fn ctf_redeem_positions(condition_id: &str, collateral_addr: &str) -> let index_yes = pad_u256(1); let index_no = pad_u256(2); - let calldata = format!( + format!( "0x{}{}{}{}{}{}{}{}", selector_hex, collateral, parent_id, cond_id_pad, array_offset, array_len, index_yes, index_no - ); + ) +} + +/// ABI-encode and submit CTF redeemPositions(collateralToken, parentCollectionId, conditionId, indexSets). +/// +/// For neg_risk (multi-outcome) markets use the NEG_RISK_ADAPTER path (not implemented here). +/// +/// `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 crate::config::Contracts; + let calldata = build_ctf_redeem_positions_calldata(condition_id, collateral_addr); let result = wallet_contract_call(Contracts::CTF, &calldata).await?; extract_tx_hash(&result) } @@ -1006,7 +912,7 @@ pub async fn get_ctf_balance(owner: &str, token_id_decimal: &str) -> Result Date: Tue, 28 Apr 2026 00:09:33 +0800 Subject: [PATCH 7/7] feat(polymarket): GEN-001 structured error output across all commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per knowledge-base GEN-001: every command should emit a parseable {"ok": false, "error", "error_code", "suggestion"} JSON to stdout when it hits a business-logic failure, NOT bail to stderr with exit code 1. External agents calling the binary can only read stdout; an exit-1 + stderr-text failure is a black box to them. Before this commit only `redeem`, `setup_proxy`, `create_readonly_key`, and `list_5m` emitted structured errors. The other 14 commands surfaced anyhow errors via `?` propagation, so the agent saw "Exit code 1" and a stderr blob it could not classify. This commit: 1. Broadens `commands::mod::classify_error` from 6 redeem-focused rules to 22 patterns covering: - Network: RPC_UNAVAILABLE, NETWORK_UNREACHABLE, REGION_RESTRICTED - Auth: STALE_CREDENTIALS, NO_WALLET - Funds: INSUFFICIENT_POL_GAS, INSUFFICIENT_BALANCE, INSUFFICIENT_ALLOWANCE - Order: ORDER_TOO_SMALL_DIVISIBILITY, ORDER_BELOW_SHARE_MINIMUM, SLIPPAGE_OR_LIQUIDITY - Tx: SIMULATION_REVERTED, TX_NOT_CONFIRMED, TX_REVERTED - Domain: NO_REDEEMABLE_POSITIONS, NEG_RISK_PROXY_NOT_SUPPORTED, PROXY_RPC_INDETERMINATE, PROXY_ADDRESS_INVALID, ALLOWANCE_CHECK_FAILED - Plus 19 per-command fallback codes ({CMD}_FAILED). 2. Wraps the 14 remaining command entry points in a `run / run_inner` pattern: the outer `run` catches any error from `run_inner` and prints `error_response(&e, Some(""), None)` to stdout, returns `Ok(())`. Exit code stays 0 — the error is a business outcome. Wrapped: balance, buy, cancel (×3), create_readonly_key, deposit, get_market, get_positions, get_series, list_5m, list_markets, orders, quickstart, rfq, sell, switch_mode, watch, withdraw `check_access` has no error paths to wrap; `redeem` and `setup_proxy` were already structured (verified, no double-wrap added). `switch_mode --mode garbage` is left as clap's stderr error because it's a CLI usage error caught BEFORE main dispatches into the command — appropriate to keep unstructured. Verification ──────────── - cargo test: 48 passed (lib 32 + rpc_mocks 10 + subprocess_mocks 6) - live error-path checks: `get-market 0xdeadbeef` → GET_MARKET_FAILED structured JSON `list-5m --coin XYZ` → LIST_5M_FAILED structured JSON `setup-proxy` (RPC unreachable) → PROXY_RPC_INDETERMINATE structured JSON Co-Authored-By: Claude Opus 4.7 (1M context) --- .../polymarket-plugin/src/commands/balance.rs | 10 + skills/polymarket-plugin/src/commands/buy.rs | 24 +++ .../polymarket-plugin/src/commands/cancel.rs | 21 ++ .../src/commands/create_readonly_key.rs | 7 + .../polymarket-plugin/src/commands/deposit.rs | 13 ++ .../src/commands/get_market.rs | 7 + .../src/commands/get_positions.rs | 7 + .../src/commands/get_series.rs | 7 + .../polymarket-plugin/src/commands/list_5m.rs | 7 + .../src/commands/list_markets.rs | 12 ++ skills/polymarket-plugin/src/commands/mod.rs | 180 ++++++++++++++++-- .../polymarket-plugin/src/commands/orders.rs | 7 + skills/polymarket-plugin/src/commands/rfq.rs | 13 ++ skills/polymarket-plugin/src/commands/sell.rs | 23 +++ .../src/commands/switch_mode.rs | 7 + .../polymarket-plugin/src/commands/watch.rs | 7 + .../src/commands/withdraw.rs | 7 + 17 files changed, 338 insertions(+), 21 deletions(-) diff --git a/skills/polymarket-plugin/src/commands/balance.rs b/skills/polymarket-plugin/src/commands/balance.rs index 0e2c0fc9a..e3bcb5971 100644 --- a/skills/polymarket-plugin/src/commands/balance.rs +++ b/skills/polymarket-plugin/src/commands/balance.rs @@ -13,6 +13,16 @@ fn short_addr(addr: &str) -> String { } pub async fn run() -> Result<()> { + match run_inner().await { + Ok(()) => Ok(()), + Err(e) => { + println!("{}", super::error_response(&e, Some("balance"), None)); + Ok(()) + } + } +} + +async fn run_inner() -> Result<()> { let eoa = get_wallet_address().await?; let proxy = crate::config::load_credentials() .ok() diff --git a/skills/polymarket-plugin/src/commands/buy.rs b/skills/polymarket-plugin/src/commands/buy.rs index 3b62fe419..60ef46f99 100644 --- a/skills/polymarket-plugin/src/commands/buy.rs +++ b/skills/polymarket-plugin/src/commands/buy.rs @@ -36,6 +36,30 @@ pub async fn run( mode_override: Option<&str>, token_id_fast: Option<&str>, strategy_id: Option<&str>, +) -> Result<()> { + match run_inner( + market_id, outcome, amount, price, order_type, auto_approve, dry_run, + round_up, post_only, expires, mode_override, token_id_fast, strategy_id, + ).await { + Ok(()) => Ok(()), + Err(e) => { println!("{}", super::error_response(&e, Some("buy"), None)); Ok(()) } + } +} + +async fn run_inner( + market_id: Option<&str>, + outcome: &str, + amount: &str, + price: Option, + order_type: &str, + auto_approve: bool, + dry_run: bool, + round_up: bool, + post_only: bool, + 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). diff --git a/skills/polymarket-plugin/src/commands/cancel.rs b/skills/polymarket-plugin/src/commands/cancel.rs index b7ad0289e..401175466 100644 --- a/skills/polymarket-plugin/src/commands/cancel.rs +++ b/skills/polymarket-plugin/src/commands/cancel.rs @@ -7,6 +7,13 @@ use crate::onchainos::get_wallet_address; /// Cancel a single order by order ID. pub async fn run_cancel_order(order_id: &str) -> Result<()> { + match run_cancel_order_inner(order_id).await { + Ok(()) => Ok(()), + Err(e) => { println!("{}", super::error_response(&e, Some("cancel"), None)); Ok(()) } + } +} + +async fn run_cancel_order_inner(order_id: &str) -> Result<()> { let client = Client::new(); let signer_addr = get_wallet_address().await?; let creds = ensure_credentials(&client, &signer_addr).await?; @@ -22,6 +29,13 @@ pub async fn run_cancel_order(order_id: &str) -> Result<()> { /// Cancel all open orders for the authenticated user. pub async fn run_cancel_all() -> Result<()> { + match run_cancel_all_inner().await { + Ok(()) => Ok(()), + Err(e) => { println!("{}", super::error_response(&e, Some("cancel"), None)); Ok(()) } + } +} + +async fn run_cancel_all_inner() -> Result<()> { let client = Client::new(); let signer_addr = get_wallet_address().await?; let creds = ensure_credentials(&client, &signer_addr).await?; @@ -37,6 +51,13 @@ pub async fn run_cancel_all() -> Result<()> { /// Cancel all orders for a specific market (by condition_id). pub async fn run_cancel_market(condition_id: &str, token_id: Option<&str>) -> Result<()> { + match run_cancel_market_inner(condition_id, token_id).await { + Ok(()) => Ok(()), + Err(e) => { println!("{}", super::error_response(&e, Some("cancel"), None)); Ok(()) } + } +} + +async fn run_cancel_market_inner(condition_id: &str, token_id: Option<&str>) -> Result<()> { let client = Client::new(); let signer_addr = get_wallet_address().await?; let creds = ensure_credentials(&client, &signer_addr).await?; diff --git a/skills/polymarket-plugin/src/commands/create_readonly_key.rs b/skills/polymarket-plugin/src/commands/create_readonly_key.rs index efa9bcf75..928eaf16e 100644 --- a/skills/polymarket-plugin/src/commands/create_readonly_key.rs +++ b/skills/polymarket-plugin/src/commands/create_readonly_key.rs @@ -15,6 +15,13 @@ use crate::onchainos::get_wallet_address; /// 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<()> { + match run_inner().await { + Ok(()) => Ok(()), + Err(e) => { println!("{}", super::error_response(&e, Some("create-readonly-key"), None)); Ok(()) } + } +} + +async fn run_inner() -> Result<()> { let client = Client::new(); // create-readonly-key is a CLOB v2-only endpoint — fail early with a clear message diff --git a/skills/polymarket-plugin/src/commands/deposit.rs b/skills/polymarket-plugin/src/commands/deposit.rs index beb7b01be..4d0e0a020 100644 --- a/skills/polymarket-plugin/src/commands/deposit.rs +++ b/skills/polymarket-plugin/src/commands/deposit.rs @@ -71,6 +71,19 @@ pub async fn run( token: &str, list: bool, dry_run: bool, +) -> Result<()> { + match run_inner(amount, chain, token, list, dry_run).await { + Ok(()) => Ok(()), + Err(e) => { println!("{}", super::error_response(&e, Some("deposit"), None)); Ok(()) } + } +} + +async fn run_inner( + amount: Option<&str>, + chain: &str, + token: &str, + list: bool, + dry_run: bool, ) -> Result<()> { let client = Client::new(); diff --git a/skills/polymarket-plugin/src/commands/get_market.rs b/skills/polymarket-plugin/src/commands/get_market.rs index 5942b17db..48a4be9ba 100644 --- a/skills/polymarket-plugin/src/commands/get_market.rs +++ b/skills/polymarket-plugin/src/commands/get_market.rs @@ -5,6 +5,13 @@ use crate::api::{get_clob_market, get_gamma_market_by_slug, get_market_fee, get_ use crate::sanitize::{sanitize_opt, sanitize_opt_owned, sanitize_str}; pub async fn run(market_id: &str) -> Result<()> { + match run_inner(market_id).await { + Ok(()) => Ok(()), + Err(e) => { println!("{}", super::error_response(&e, Some("get-market"), None)); Ok(()) } + } +} + +async fn run_inner(market_id: &str) -> Result<()> { let client = Client::new(); // Determine if market_id is a condition_id (0x-prefixed hex) or a slug diff --git a/skills/polymarket-plugin/src/commands/get_positions.rs b/skills/polymarket-plugin/src/commands/get_positions.rs index 1298eadce..da147c6ef 100644 --- a/skills/polymarket-plugin/src/commands/get_positions.rs +++ b/skills/polymarket-plugin/src/commands/get_positions.rs @@ -5,6 +5,13 @@ use crate::api::get_positions; use crate::onchainos::{get_pol_balance, get_usdc_balance, get_wallet_address}; pub async fn run(address: Option<&str>) -> Result<()> { + match run_inner(address).await { + Ok(()) => Ok(()), + Err(e) => { println!("{}", super::error_response(&e, Some("get-positions"), None)); Ok(()) } + } +} + +async fn run_inner(address: Option<&str>) -> Result<()> { let client = Client::new(); // Determine which wallet to query and whether to show EOA balances. diff --git a/skills/polymarket-plugin/src/commands/get_series.rs b/skills/polymarket-plugin/src/commands/get_series.rs index 5946b101b..eb77e3c8b 100644 --- a/skills/polymarket-plugin/src/commands/get_series.rs +++ b/skills/polymarket-plugin/src/commands/get_series.rs @@ -5,6 +5,13 @@ use crate::sanitize::sanitize_opt_owned; use crate::series::{self, seconds_remaining_in_session, seconds_until_trading_opens, SERIES}; pub async fn run(series_id: Option<&str>, list: bool) -> Result<()> { + match run_inner(series_id, list).await { + Ok(()) => Ok(()), + Err(e) => { println!("{}", super::error_response(&e, Some("get-series"), None)); Ok(()) } + } +} + +async fn run_inner(series_id: Option<&str>, list: bool) -> Result<()> { // --list: print all supported series and exit if list || series_id.is_none() { let supported: Vec = SERIES.iter().map(|s| { diff --git a/skills/polymarket-plugin/src/commands/list_5m.rs b/skills/polymarket-plugin/src/commands/list_5m.rs index 239b7772c..cb386abaf 100644 --- a/skills/polymarket-plugin/src/commands/list_5m.rs +++ b/skills/polymarket-plugin/src/commands/list_5m.rs @@ -71,6 +71,13 @@ fn format_et(iso: &str) -> String { } pub async fn run(coin: Option<&str>, count: u32) -> Result<()> { + match run_inner(coin, count).await { + Ok(()) => Ok(()), + Err(e) => { println!("{}", super::error_response(&e, Some("list-5m"), None)); Ok(()) } + } +} + +async fn run_inner(coin: Option<&str>, count: u32) -> Result<()> { // ── Missing --coin: ask the Agent to get it from the user ──────────────── let coin_str = match coin { Some(c) => c, diff --git a/skills/polymarket-plugin/src/commands/list_markets.rs b/skills/polymarket-plugin/src/commands/list_markets.rs index 5aaa322a5..717f86428 100644 --- a/skills/polymarket-plugin/src/commands/list_markets.rs +++ b/skills/polymarket-plugin/src/commands/list_markets.rs @@ -9,6 +9,18 @@ pub async fn run( keyword: Option<&str>, breaking: bool, category: Option<&str>, +) -> Result<()> { + match run_inner(limit, keyword, breaking, category).await { + Ok(()) => Ok(()), + Err(e) => { println!("{}", super::error_response(&e, Some("list-markets"), None)); Ok(()) } + } +} + +async fn run_inner( + limit: u32, + keyword: Option<&str>, + breaking: bool, + category: Option<&str>, ) -> Result<()> { let client = Client::new(); diff --git a/skills/polymarket-plugin/src/commands/mod.rs b/skills/polymarket-plugin/src/commands/mod.rs index 0b003bf00..b9c9b903f 100644 --- a/skills/polymarket-plugin/src/commands/mod.rs +++ b/skills/polymarket-plugin/src/commands/mod.rs @@ -53,54 +53,192 @@ pub fn error_response( .unwrap_or_else(|_| format!(r#"{{"ok":false,"error":{:?}}}"#, msg)) } -fn classify_error(msg: &str, _ctx: Option<&str>) -> (&'static str, String) { +fn classify_error(msg: &str, ctx: Option<&str>) -> (&'static str, String) { let m = msg.to_lowercase(); + // ── Network / RPC ─────────────────────────────────────────────────────── + if m.contains("polygon rpc") || m.contains("rpc request failed") || m.contains("rpc error") { + return ( + "RPC_UNAVAILABLE", + "Polygon RPC is unavailable or rate-limited. Wait a few seconds and retry. \ + If it persists, the public RPC may be congested — Polymarket data is unaffected.".into(), + ); + } + if m.contains("error sending request") || m.contains("connection refused") + || m.contains("dns error") || m.contains("certificate") { + return ( + "NETWORK_UNREACHABLE", + "Network request failed. Check your internet connection and that polymarket.com / \ + gamma-api.polymarket.com are reachable from your IP. If you see TLS / certificate \ + errors, your ISP or network may be intercepting Polymarket traffic.".into(), + ); + } + if m.contains("trading restricted in your region") || m.contains("clob blocked") + || m.contains("region") { + return ( + "REGION_RESTRICTED", + "Polymarket CLOB is blocking this IP (US / OFAC). Switch region (VPN) and re-run \ + `check-access` to verify before any trading commands.".into(), + ); + } + + // ── Auth / wallet ─────────────────────────────────────────────────────── + if m.contains("credentials are stale") || m.contains("invalid credential") + || m.contains("api key") { + return ( + "STALE_CREDENTIALS", + "Polymarket CLOB credentials are stale. Delete `~/.config/polymarket/creds.json` \ + and re-run the command — credentials will be re-derived automatically.".into(), + ); + } + if m.contains("no wallet") || m.contains("wallet not found") + || m.contains("onchainos wallet") && m.contains("failed") { + return ( + "NO_WALLET", + "No active onchainos wallet found. Run `onchainos wallet status` to inspect, or \ + `onchainos wallet add` to create one. Polymarket needs a wallet on Polygon (chain 137).".into(), + ); + } + + // ── Balance / allowance ───────────────────────────────────────────────── 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(), + setup-proxy needs ~0.05 POL for the V1+V2 approval txs. Trading via POLY_PROXY \ + is gasless after setup.".into(), ); } - if m.contains("no redeemable positions") { + if m.contains("insufficient usdc") || m.contains("insufficient pusd") + || m.contains("insufficient balance") { 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(), + "INSUFFICIENT_BALANCE", + "Wallet does not hold enough collateral for this order. Check `balance` for the \ + active wallet (EOA vs proxy), and `deposit --amount ` if the proxy needs funding.".into(), + ); + } + if m.contains("insufficient allowance") || m.contains("erc20: insufficient allowance") { + return ( + "INSUFFICIENT_ALLOWANCE", + "Token allowance too low. Re-run `setup-proxy` to ensure all 10 V1+V2 approvals \ + are in place — the per-pair idempotency check will only resubmit the missing ones.".into(), ); } + + // ── Order placement / sizing ──────────────────────────────────────────── + if m.contains("rounds to 0 shares") || m.contains("divisibility") { + return ( + "ORDER_TOO_SMALL_DIVISIBILITY", + "Order amount rounds to 0 shares at this price. Increase `--amount` or pass \ + `--round-up` to snap to the minimum valid amount.".into(), + ); + } + if m.contains("below this market's minimum") || m.contains("min_order_size") { + return ( + "ORDER_BELOW_SHARE_MINIMUM", + "Order size is below the market's share minimum (typically 5 shares for resting \ + GTC limits). Pass `--round-up` or use `--order-type FOK` (subject to a separate \ + ~$1 CLOB execution floor).".into(), + ); + } + if m.contains("price slippage") || m.contains("not enough liquidity") { + return ( + "SLIPPAGE_OR_LIQUIDITY", + "Order would not fill at the requested price (insufficient liquidity or price \ + moved). Inspect `get-market` order book and retry with a worse price or smaller \ + size.".into(), + ); + } + + // ── Tx lifecycle ──────────────────────────────────────────────────────── 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(), + "Pre-flight eth_call simulation reverted. For redeem: the calling wallet doesn't \ + hold the winning tokens — check trading mode. For buy/sell: insufficient balance \ + or allowance — run `setup-proxy` and `balance`.".into(), ); } - if m.contains("not observed on-chain") || m.contains("not confirmed within") { + if m.contains("not observed on-chain") || m.contains("not confirmed within") + || m.contains("did not confirm") { 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(), + "Tx returned a hash but never appeared on-chain within the timeout. 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.".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(), + "Tx mined but reverted on-chain. Check Polygonscan for the failure reason. \ + For redeem this usually means the calling wallet doesn't hold the winning tokens.".into(), + ); + } + + // ── Domain-specific (redeem) ──────────────────────────────────────────── + 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("neg_risk") && m.contains("not yet supported") { + return ( + "NEG_RISK_PROXY_NOT_SUPPORTED", + "Multi-outcome (neg_risk) redeem from a proxy wallet is not yet supported by this \ + plugin — use the Polymarket web UI. EOA redeem via NegRiskAdapter is fully supported.".into(), + ); + } + + // ── Setup-proxy specific ──────────────────────────────────────────────── + if m.contains("on-chain proxy check failed") || m.contains("could not retrieve proxy address") { + return ( + "PROXY_RPC_INDETERMINATE", + "Could not determine on-chain proxy state from RPC. Refusing to deploy in case a \ + proxy already exists. Wait for RPC recovery and re-run setup-proxy.".into(), + ); + } + if m.contains("not an eip-1167 proxy") { + return ( + "PROXY_ADDRESS_INVALID", + "Resolved proxy address is not a valid EIP-1167 proxy contract — refusing to use \ + it to protect funds. This usually indicates an RPC trace error; re-run setup-proxy \ + from a different RPC.".into(), ); } - if m.contains("neg_risk") { + if m.contains("could not verify") && m.contains("allowance on-chain") { return ( - "NEG_RISK_NOT_SUPPORTED", - "Multi-outcome (neg_risk) markets cannot be redeemed via this plugin — \ - use the Polymarket web UI.".into(), + "ALLOWANCE_CHECK_FAILED", + "Could not verify proxy approval state on-chain. Polygon RPC may be unavailable. \ + Wait a few seconds and re-run setup-proxy.".into(), ); } - ("REDEEM_FAILED", "See error field for details.".into()) + // ── Generic fallback (per command context) ───────────────────────────── + let default_code: &'static str = match ctx { + Some("buy") => "BUY_FAILED", + Some("sell") => "SELL_FAILED", + Some("redeem") => "REDEEM_FAILED", + Some("cancel") => "CANCEL_FAILED", + Some("rfq") => "RFQ_FAILED", + Some("setup-proxy") => "SETUP_PROXY_FAILED", + Some("deposit") => "DEPOSIT_FAILED", + Some("withdraw") => "WITHDRAW_FAILED", + Some("quickstart") => "QUICKSTART_FAILED", + Some("balance") => "BALANCE_FAILED", + Some("orders") => "ORDERS_FAILED", + Some("watch") => "WATCH_FAILED", + Some("get-market") => "GET_MARKET_FAILED", + Some("get-positions")=> "GET_POSITIONS_FAILED", + Some("get-series") => "GET_SERIES_FAILED", + Some("list-markets") => "LIST_MARKETS_FAILED", + Some("list-5m") => "LIST_5M_FAILED", + Some("switch-mode") => "SWITCH_MODE_FAILED", + Some("create-readonly-key") => "CREATE_READONLY_KEY_FAILED", + _ => "UNKNOWN_ERROR", + }; + (default_code, "See error field for details. Retry the command, or run with --dry-run to inspect parameters.".into()) } diff --git a/skills/polymarket-plugin/src/commands/orders.rs b/skills/polymarket-plugin/src/commands/orders.rs index d532e3fae..688a27ddf 100644 --- a/skills/polymarket-plugin/src/commands/orders.rs +++ b/skills/polymarket-plugin/src/commands/orders.rs @@ -13,6 +13,13 @@ use crate::onchainos::get_wallet_address; /// 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<()> { + match run_inner(state, only_v1, limit).await { + Ok(()) => Ok(()), + Err(e) => { println!("{}", super::error_response(&e, Some("orders"), None)); Ok(()) } + } +} + +async fn run_inner(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?; diff --git a/skills/polymarket-plugin/src/commands/rfq.rs b/skills/polymarket-plugin/src/commands/rfq.rs index 9178cd2e6..749abe1ce 100644 --- a/skills/polymarket-plugin/src/commands/rfq.rs +++ b/skills/polymarket-plugin/src/commands/rfq.rs @@ -26,6 +26,19 @@ pub async fn run( amount: &str, confirm: bool, dry_run: bool, +) -> Result<()> { + match run_inner(market_id, outcome, amount, confirm, dry_run).await { + Ok(()) => Ok(()), + Err(e) => { println!("{}", super::error_response(&e, Some("rfq"), None)); Ok(()) } + } +} + +async fn run_inner( + 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 { diff --git a/skills/polymarket-plugin/src/commands/sell.rs b/skills/polymarket-plugin/src/commands/sell.rs index a4aa70764..c34a4cf24 100644 --- a/skills/polymarket-plugin/src/commands/sell.rs +++ b/skills/polymarket-plugin/src/commands/sell.rs @@ -34,6 +34,29 @@ pub async fn run( mode_override: Option<&str>, token_id_fast: Option<&str>, strategy_id: Option<&str>, +) -> Result<()> { + match run_inner( + market_id, outcome, shares, price, order_type, auto_approve, dry_run, + post_only, expires, mode_override, token_id_fast, strategy_id, + ).await { + Ok(()) => Ok(()), + Err(e) => { println!("{}", super::error_response(&e, Some("sell"), None)); Ok(()) } + } +} + +async fn run_inner( + market_id: Option<&str>, + outcome: &str, + shares: &str, + price: Option, + order_type: &str, + auto_approve: bool, + dry_run: bool, + post_only: bool, + 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")?; diff --git a/skills/polymarket-plugin/src/commands/switch_mode.rs b/skills/polymarket-plugin/src/commands/switch_mode.rs index 0161e72d8..005fa8142 100644 --- a/skills/polymarket-plugin/src/commands/switch_mode.rs +++ b/skills/polymarket-plugin/src/commands/switch_mode.rs @@ -9,6 +9,13 @@ use anyhow::{bail, Result}; use reqwest::Client; pub async fn run(mode: &str) -> Result<()> { + match run_inner(mode).await { + Ok(()) => Ok(()), + Err(e) => { println!("{}", super::error_response(&e, Some("switch-mode"), None)); Ok(()) } + } +} + +async fn run_inner(mode: &str) -> Result<()> { let client = Client::new(); let signer_addr = crate::onchainos::get_wallet_address().await?; diff --git a/skills/polymarket-plugin/src/commands/watch.rs b/skills/polymarket-plugin/src/commands/watch.rs index 5b95febc3..446f28fab 100644 --- a/skills/polymarket-plugin/src/commands/watch.rs +++ b/skills/polymarket-plugin/src/commands/watch.rs @@ -10,6 +10,13 @@ use crate::api::{get_clob_market, get_gamma_market_by_slug, get_market_live_acti /// `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<()> { + match run_inner(market_id, interval, limit).await { + Ok(()) => Ok(()), + Err(e) => { println!("{}", super::error_response(&e, Some("watch"), None)); Ok(()) } + } +} + +async fn run_inner(market_id: &str, interval: u64, limit: u32) -> Result<()> { if interval < 2 { bail!("--interval must be at least 2 seconds"); } diff --git a/skills/polymarket-plugin/src/commands/withdraw.rs b/skills/polymarket-plugin/src/commands/withdraw.rs index e87bbd30f..bf6201e00 100644 --- a/skills/polymarket-plugin/src/commands/withdraw.rs +++ b/skills/polymarket-plugin/src/commands/withdraw.rs @@ -12,6 +12,13 @@ use anyhow::{bail, Result}; use crate::onchainos::{get_pusd_balance, get_usdc_balance, get_wallet_address}; pub async fn run(amount: &str, dry_run: bool) -> Result<()> { + match run_inner(amount, dry_run).await { + Ok(()) => Ok(()), + Err(e) => { println!("{}", super::error_response(&e, Some("withdraw"), None)); Ok(()) } + } +} + +async fn run_inner(amount: &str, dry_run: bool) -> Result<()> { use crate::config::Contracts; let eoa = get_wallet_address().await?;