Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions src/utils/polymarket/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 /
Expand Down Expand Up @@ -95,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",
Expand Down
24 changes: 24 additions & 0 deletions src/utils/polymarket/redeem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ToolResult> {
if (!input.condition_id) {
return { text: `Pass condition_id:"0x…" (see action:"positions" for redeemable markets).`, isError: true };
Expand Down Expand Up @@ -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 {
Expand Down
67 changes: 56 additions & 11 deletions src/utils/polymarket/withdraw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<bigint> {
async function rawTokenBalance(token: Hex, owner: Hex): Promise<bigint> {
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;
Expand All @@ -57,19 +71,25 @@ export async function withdrawFunds(input: WithdrawInput): Promise<ToolResult> {
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 {
Expand All @@ -79,13 +99,38 @@ export async function withdrawFunds(input: WithdrawInput): Promise<ToolResult> {
` 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<string, string> = { "content-type": "application/json" };
const builderCode = getBuilderCode();
Expand Down
8 changes: 7 additions & 1 deletion test/polymarket-redeem.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
});
35 changes: 26 additions & 9 deletions test/polymarket-withdraw.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,17 @@ 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 },
});
mock.module("../src/utils/polymarket/setup.js", {
namedExports: {
getPublicClient: () => ({
readContract: async () => balanceRaw,
readContract: async ({ address }: { address: string }) =>
address.toLowerCase() === "0x2791bca1f2de4661ed88a30c99a7a9449aa84174" ? usdceRaw : pusdRaw,
waitForTransactionReceipt: async () => ({}),
}),
},
Expand All @@ -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/);
Expand All @@ -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);
});