From 02fd40f7cbd285d7faefde35d1561ceea2bc1e1a Mon Sep 17 00:00:00 2001 From: Fsocietyhhh <1211904451@qq.com> Date: Sat, 18 Jul 2026 00:32:59 +0800 Subject: [PATCH 1/2] fix(polymarket): verify redeem effects and sweep legacy collateral --- src/utils/polymarket/constants.ts | 3 ++ src/utils/polymarket/redeem.ts | 24 +++++++++++ src/utils/polymarket/withdraw.ts | 67 ++++++++++++++++++++++++++----- test/polymarket-redeem.test.ts | 8 +++- test/polymarket-withdraw.test.ts | 35 +++++++++++----- 5 files changed, 116 insertions(+), 21 deletions(-) diff --git a/src/utils/polymarket/constants.ts b/src/utils/polymarket/constants.ts index fc93604..063e1bc 100644 --- a/src/utils/polymarket/constants.ts +++ b/src/utils/polymarket/constants.ts @@ -21,6 +21,9 @@ export const NEG_RISK_ADAPTER = "0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296"; export const CONDITIONAL_TOKENS = "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045"; // pUSD — Polymarket's 1:1 collateral wrapper (labelled CollateralToken proxy). export const PUSD_COLLATERAL = "0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB"; +// The legacy collateral held by the underlying CTF. Correct adapter redemptions +// wrap this into pUSD, but historic direct redeems can leave USDC.e behind. +export const USDCE_COLLATERAL = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"; // pUSD collateral adapters — the ONLY correct redeem targets in the pUSD era. // CTF positions (the CLOB token_ids) are still keyed to USDC.e (standard) or // the legacy NegRiskAdapter's wrapped collateral (neg-risk); calling the CTF / diff --git a/src/utils/polymarket/redeem.ts b/src/utils/polymarket/redeem.ts index 2081b96..e8f1a30 100644 --- a/src/utils/polymarket/redeem.ts +++ b/src/utils/polymarket/redeem.ts @@ -59,6 +59,11 @@ export function buildRedeemCall(negRisk: boolean, conditionId: Hex): { target: H }; } +/** A receipt alone is insufficient: CTF redeem can silently be a no-op. */ +export function didRedeemAnyHeldPosition(before: readonly bigint[], after: readonly bigint[]): boolean { + return before.some((balance, index) => balance > 0n && (after[index] ?? balance) < balance); +} + export async function redeemPosition(input: { condition_id?: string; confirm?: boolean }): Promise { if (!input.condition_id) { return { text: `Pass condition_id:"0x…" (see action:"positions" for redeemable markets).`, isError: true }; @@ -147,6 +152,25 @@ export async function redeemPosition(input: { condition_id?: string; confirm?: b const balanceAfter = await getPusdBalance(owner).catch(() => null); const paidOut = balanceBefore !== null && balanceAfter !== null ? balanceAfter - balanceBefore : null; + // A transaction hash is not proof of redemption: using a collateral-derived + // position id that nobody holds succeeds on-chain yet burns nothing. + const balancesAfter = await Promise.all(tokens.map((t) => pc.readContract({ + address: CONDITIONAL_TOKENS as Hex, + abi: ERC1155_ABI, + functionName: "balanceOf", + args: [owner, BigInt(t.token_id as string)], + }))).catch(() => null); + if (balancesAfter === null || !didRedeemAnyHeldPosition(balances, balancesAfter)) { + return { + text: [ + `⚠️ Redeem transaction confirmed, but held ERC-1155 outcome tokens did not decrease.`, + `It may have used the wrong collateral path or the RPC state is stale; re-run action:"positions" and retry.`, + ...(txHash ? [` tx: https://polygonscan.com/tx/${txHash}`] : []), + ].join("\n"), + structured: { conditionId, negRisk, transactionHash: txHash, paidOutUsd: paidOut, pusdBalance: balanceAfter }, + isError: true, + }; + } const heldWinner = held.some((h) => h.winner); if (paidOut !== null && paidOut <= 0 && heldWinner) { return { diff --git a/src/utils/polymarket/withdraw.ts b/src/utils/polymarket/withdraw.ts index 7ddf049..1daae29 100644 --- a/src/utils/polymarket/withdraw.ts +++ b/src/utils/polymarket/withdraw.ts @@ -18,12 +18,14 @@ import { BASE_CHAIN_ID, BASE_USDC, BRIDGE_API_HOST, + COLLATERAL_ONRAMP, ERC20_ABI, getBuilderCode, getSigType, POLYGON_RPC_URLS, PUSD_COLLATERAL, PUSD_DECIMALS, + USDCE_COLLATERAL, } from "./constants.js"; import { getPolymarketAccount } from "./client.js"; import type { ToolResult } from "./orders.js"; @@ -32,15 +34,27 @@ import { getFundsAddress } from "./positions.js"; import { sendWalletBatch } from "./relayer.js"; import { getPublicClient } from "./setup.js"; -async function rawPusdBalance(owner: Hex): Promise { +async function rawTokenBalance(token: Hex, owner: Hex): Promise { return getPublicClient().readContract({ - address: PUSD_COLLATERAL as Hex, + address: token, abi: ERC20_ABI, functionName: "balanceOf", args: [owner], }); } +const COLLATERAL_ONRAMP_ABI = [{ + type: "function", + name: "wrap", + stateMutability: "nonpayable", + inputs: [ + { name: "_asset", type: "address" }, + { name: "_to", type: "address" }, + { name: "_amount", type: "uint256" }, + ], + outputs: [], +}] as const; + interface WithdrawInput { amount_usd?: number; to_address?: string; @@ -57,19 +71,25 @@ export async function withdrawFunds(input: WithdrawInput): Promise { const recipient = (input.to_address as Hex) || getPolymarketAccount().address; try { - // Amount: explicit amount_usd, else the full pUSD balance. - const balanceRaw = await rawPusdBalance(owner); - const balanceUsd = Number(formatUnits(balanceRaw, PUSD_DECIMALS)); - if (balanceRaw === 0n) { - return { text: `No pUSD to withdraw — the deposit wallet ${owner} holds $0. (Redeem/sell a position first.)`, isError: true }; + // Normal adapter redemptions return pUSD. Historic direct CTF redemptions + // may leave USDC.e instead, so normalize that residue before withdrawing. + const [pusdRaw, usdceRaw] = await Promise.all([ + rawTokenBalance(PUSD_COLLATERAL as Hex, owner), + rawTokenBalance(USDCE_COLLATERAL as Hex, owner), + ]); + const totalRaw = pusdRaw + usdceRaw; + const totalUsd = Number(formatUnits(totalRaw, PUSD_DECIMALS)); + if (totalRaw === 0n) { + return { text: `No pUSD or USDC.e to withdraw — the deposit wallet ${owner} holds $0. (Redeem/sell a position first.)`, isError: true }; } const amountRaw = input.amount_usd !== undefined ? BigInt(Math.floor(input.amount_usd * 10 ** PUSD_DECIMALS)) - : balanceRaw; - if (amountRaw > balanceRaw) { - return { text: `Requested $${input.amount_usd} exceeds the pUSD balance of $${balanceUsd.toFixed(2)}.`, isError: true }; + : totalRaw; + if (amountRaw > totalRaw) { + return { text: `Requested $${input.amount_usd} exceeds the withdrawable collateral balance of $${totalUsd.toFixed(2)}.`, isError: true }; } const amountUsd = Number(formatUnits(amountRaw, PUSD_DECIMALS)); + const wrapRaw = amountRaw > pusdRaw ? amountRaw - pusdRaw : 0n; if (input.confirm !== true) { return { @@ -79,13 +99,38 @@ export async function withdrawFunds(input: WithdrawInput): Promise { ` from deposit wallet: ${owner}`, ` to (agent wallet): ${recipient}`, ``, + ...(wrapRaw > 0n ? [` First wrap: $${Number(formatUnits(wrapRaw, PUSD_DECIMALS)).toFixed(2)} legacy USDC.e → pUSD`] : []), `pUSD is unwrapped to USDC (Uniswap v3 — minor slippage may apply); instant, no Polymarket fee.`, `Re-call with confirm:true to execute.`, ].join("\n"), - structured: { dryRun: true, amountUsd, from: owner, to: recipient, toChainId: BASE_CHAIN_ID, toToken: BASE_USDC }, + structured: { dryRun: true, amountUsd, from: owner, to: recipient, toChainId: BASE_CHAIN_ID, toToken: BASE_USDC, pusdUsd: Number(formatUnits(pusdRaw, PUSD_DECIMALS)), usdceUsd: Number(formatUnits(usdceRaw, PUSD_DECIMALS)), wrapUsd: Number(formatUnits(wrapRaw, PUSD_DECIMALS)) }, }; } + // In the deposit-wallet mode approval + wrap are one atomic relayer batch. + // EOA mode waits for each confirmation before sending pUSD to the bridge. + if (wrapRaw > 0n) { + const approve = encodeFunctionData({ abi: ERC20_ABI, functionName: "approve", args: [COLLATERAL_ONRAMP as Hex, wrapRaw] }); + const wrap = encodeFunctionData({ abi: COLLATERAL_ONRAMP_ABI, functionName: "wrap", args: [USDCE_COLLATERAL as Hex, owner, wrapRaw] }); + if (getSigType() === 3) { + await sendWalletBatch([ + { target: USDCE_COLLATERAL, value: "0", data: approve }, + { target: COLLATERAL_ONRAMP, value: "0", data: wrap }, + ], owner, "Wrap legacy USDC.e"); + } else { + const account = getPolymarketAccount(); + const wallet = createWalletClient({ account, chain: polygon, transport: http(POLYGON_RPC_URLS[0]) }); + const approveHash = await wallet.sendTransaction({ to: USDCE_COLLATERAL as Hex, data: approve, chain: polygon, account }); + await getPublicClient().waitForTransactionReceipt({ hash: approveHash }); + const wrapHash = await wallet.sendTransaction({ to: COLLATERAL_ONRAMP as Hex, data: wrap, chain: polygon, account }); + await getPublicClient().waitForTransactionReceipt({ hash: wrapHash }); + } + const normalizedPusd = await rawTokenBalance(PUSD_COLLATERAL as Hex, owner); + if (normalizedPusd < amountRaw) { + throw new Error("legacy USDC.e wrap confirmed but the required pUSD balance was not observed; do not retry the withdrawal blindly"); + } + } + // 1. Ask the bridge for a one-time deposit address for this withdrawal. const headers: Record = { "content-type": "application/json" }; const builderCode = getBuilderCode(); diff --git a/test/polymarket-redeem.test.ts b/test/polymarket-redeem.test.ts index 07cd130..34e737c 100644 --- a/test/polymarket-redeem.test.ts +++ b/test/polymarket-redeem.test.ts @@ -7,7 +7,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { decodeFunctionData, type Hex } from "viem"; -import { buildRedeemCall } from "../src/utils/polymarket/redeem.js"; +import { buildRedeemCall, didRedeemAnyHeldPosition } from "../src/utils/polymarket/redeem.js"; import { CONDITIONAL_TOKENS, CTF_COLLATERAL_ADAPTER, @@ -49,3 +49,9 @@ test("redeem calldata is the CTF-mirror redeemPositions with the conditionId", ( assert.deepEqual([...indexSets], [1n, 2n]); } }); + +test("redeem must consume a held ERC-1155 position before it can report success", () => { + assert.equal(didRedeemAnyHeldPosition([1_000_000n, 0n], [0n, 0n]), true); + assert.equal(didRedeemAnyHeldPosition([1_000_000n, 0n], [1_000_000n, 0n]), false); + assert.equal(didRedeemAnyHeldPosition([0n, 0n], [0n, 0n]), false); +}); diff --git a/test/polymarket-withdraw.test.ts b/test/polymarket-withdraw.test.ts index df90e1a..16b0f5a 100644 --- a/test/polymarket-withdraw.test.ts +++ b/test/polymarket-withdraw.test.ts @@ -8,7 +8,8 @@ import assert from "node:assert/strict"; const DEPOSIT = "0x5d3eaa66AE01F1a907c8e0970D1D021C6Ff8EB26"; const AGENT = "0xCC8c44AD3dc2A58D841c3EB26131E49b22665EF8"; -let balanceRaw = 7_500_000n; // $7.50 pUSD (6 decimals) +let pusdRaw = 7_500_000n; // $7.50 pUSD (6 decimals) +let usdceRaw = 0n; mock.module("../src/utils/polymarket/positions.js", { namedExports: { getFundsAddress: () => DEPOSIT }, @@ -16,7 +17,8 @@ mock.module("../src/utils/polymarket/positions.js", { mock.module("../src/utils/polymarket/setup.js", { namedExports: { getPublicClient: () => ({ - readContract: async () => balanceRaw, + readContract: async ({ address }: { address: string }) => + address.toLowerCase() === "0x2791bca1f2de4661ed88a30c99a7a9449aa84174" ? usdceRaw : pusdRaw, waitForTransactionReceipt: async () => ({}), }), }, @@ -37,7 +39,8 @@ mock.module("../src/utils/polymarket/client.js", { const { withdrawFunds } = await import("../src/utils/polymarket/withdraw.js"); test("no confirm → dry-run previews full balance to the agent wallet on Base", async () => { - balanceRaw = 7_500_000n; + pusdRaw = 7_500_000n; + usdceRaw = 0n; const res = await withdrawFunds({}); assert.equal(res.isError, undefined, res.text); assert.match(res.text, /DRY RUN/); @@ -49,28 +52,42 @@ test("no confirm → dry-run previews full balance to the agent wallet on Base", }); test("amount_usd caps the withdrawal to that amount", async () => { - balanceRaw = 7_500_000n; + pusdRaw = 7_500_000n; + usdceRaw = 0n; const res = await withdrawFunds({ amount_usd: 3 }); assert.match(res.text, /\$3\.00/); }); test("amount above balance is rejected", async () => { - balanceRaw = 7_500_000n; + pusdRaw = 7_500_000n; + usdceRaw = 0n; const res = await withdrawFunds({ amount_usd: 10 }); assert.equal(res.isError, true); - assert.match(res.text, /exceeds the pUSD balance/); + assert.match(res.text, /exceeds the withdrawable collateral balance/); }); test("nothing to withdraw when balance is zero", async () => { - balanceRaw = 0n; + pusdRaw = 0n; + usdceRaw = 0n; const res = await withdrawFunds({}); assert.equal(res.isError, true); - assert.match(res.text, /No pUSD to withdraw/); + assert.match(res.text, /No pUSD or USDC\.e to withdraw/); }); test("custom to_address overrides the destination", async () => { - balanceRaw = 5_000_000n; + pusdRaw = 5_000_000n; + usdceRaw = 0n; const other = "0x1111111111111111111111111111111111111111"; const res = await withdrawFunds({ to_address: other }); assert.match(res.text, new RegExp(other)); }); + +test("legacy USDC.e is included and previewed as a pUSD wrap before withdrawal", async () => { + pusdRaw = 2_000_000n; + usdceRaw = 3_000_000n; + const res = await withdrawFunds({}); + assert.equal(res.isError, undefined, res.text); + assert.match(res.text, /\$5\.00/); + assert.match(res.text, /wrap: \$3\.00 legacy USDC\.e → pUSD/); + assert.equal((res.structured as { wrapUsd?: number }).wrapUsd, 3); +}); From 8fc3875603edb640c9b82010efb5a5a1eab52e3d Mon Sep 17 00:00:00 2001 From: Fsocietyhhh <1211904451@qq.com> Date: Sat, 18 Jul 2026 05:33:12 +0800 Subject: [PATCH 2/2] fix(polymarket): restore Polygon RPC fallback --- src/utils/polymarket/constants.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/utils/polymarket/constants.ts b/src/utils/polymarket/constants.ts index 063e1bc..9fcf863 100644 --- a/src/utils/polymarket/constants.ts +++ b/src/utils/polymarket/constants.ts @@ -98,9 +98,12 @@ export const BRIDGE_UI_URL = "https://polymarket.com"; // deposits happen via th // Public Polygon RPCs with fallback, mirroring BASE_RPC_URLS in ../constants.ts. // Used only for read-only approval/balance checks (viem public client). -// 1rpc first — polygon-rpc.com was observed lagging several blocks behind, which -// made freshly-confirmed deploys/approvals read as still-pending. +// PublicNode first: 1rpc requires registration for eth_call, Llama's Polygon +// hostname is intermittently unavailable, and polygon-rpc.com returns a +// disabled-tenant error. A failed post-redeem read must never mask a confirmed +// state change as an ambiguous payout. export const POLYGON_RPC_URLS = [ + "https://polygon-bor-rpc.publicnode.com", "https://1rpc.io/matic", "https://polygon.llamarpc.com", "https://polygon-rpc.com",