Skip to content

Instantly share code, notes, and snippets.

@tylerthebuildor
Created February 15, 2026 23:57
Show Gist options
  • Select an option

  • Save tylerthebuildor/fe48617cc2a30c123ab175e1e65b57cf to your computer and use it in GitHub Desktop.

Select an option

Save tylerthebuildor/fe48617cc2a30c123ab175e1e65b57cf to your computer and use it in GitHub Desktop.
Programmatically claim all resolved market rewards on Polymarket.
import { JsonRpcProvider } from "@ethersproject/providers";
import { Wallet } from "@ethersproject/wallet";
import { Contract } from "@ethersproject/contracts";
import { arrayify, joinSignature } from "@ethersproject/bytes";
import { BigNumber } from "@ethersproject/bignumber";
import { HashZero, AddressZero } from "@ethersproject/constants";
// ────────────────────────────────────────────────
// CONFIGURATION / CONSTANTS
// ────────────────────────────────────────────────
const POLYGON_RPC = "https://polygon-bor-rpc.publicnode.com";
const CTF_ADDRESS = "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045";
const NEG_RISK_ADAPTER_ADDRESS = "0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296";
const USDC_ADDRESS = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174";
const GAS_LIMIT = 200_000;
const MIN_GAS_PRICE_GWEI = 30;
const GAS_PRICE_BUFFER_PCT = 1.05; // +5%
const BALANCE_GAS_BUFFER_PCT = 0.9; // leave 10% buffer
// Minimal Gnosis Safe ABI needed
const SAFE_ABI = [
"function nonce() view returns (uint256)",
"function getOwners() view returns (address[])",
"function getThreshold() view returns (uint256)",
"function getTransactionHash(address to, uint256 value, bytes data, uint8 operation, uint256 safeTxGas, uint256 baseGas, uint256 gasPrice, address gasToken, address refundReceiver, uint256 _nonce) view returns (bytes32)",
"function execTransaction(address to, uint256 value, bytes data, uint8 operation, uint256 safeTxGas, uint256 baseGas, uint256 gasPrice, address gasToken, address payable refundReceiver, bytes signatures) payable returns (bool)",
] as const;
// Minimal CTF & NegRisk Adapter ABI
const CTF_ABI = [
"function balanceOf(address owner, uint256 id) view returns (uint256)",
"function redeemPositions(address collateralToken, bytes32 parentCollectionId, bytes32 conditionId, uint256[] indexSets)",
] as const;
const NEG_RISK_ADAPTER_ABI = [
"function redeemPositions(bytes32 conditionId, uint256[] amounts)",
] as const;
// ────────────────────────────────────────────────
// DATA API RESPONSE TYPE
// ────────────────────────────────────────────────
interface RedeemablePosition {
asset: string; // token id
conditionId: string;
currentValue: number;
size: number;
outcome: string;
outcomeIndex: number;
negativeRisk: boolean;
title: string;
redeemable: boolean;
}
// ────────────────────────────────────────────────
// MAIN FUNCTION
// ────────────────────────────────────────────────
export async function claimAllRedeemablePositions(
privateKey: string, // EOA private key
proxyWalletAddress: string, // Gnosis Safe / proxy wallet
logger?: { info: (...args: any[]) => void; error: (...args: any[]) => void },
): Promise<{ claimed: number; failed: number }> {
const log = logger ?? console;
const provider = new JsonRpcProvider(POLYGON_RPC);
const wallet = new Wallet(privateKey).connect(provider);
const eoaAddress = wallet.address;
log.info(`EOA address: ${eoaAddress}`);
log.info(`Proxy/Safe address: ${proxyWalletAddress}`);
// Check MATIC balance for gas
const maticBalance = await provider.getBalance(eoaAddress);
const maticFormatted = Number(maticBalance) / 1e18;
log.info(`EOA MATIC balance: ${maticFormatted.toFixed(5)}`);
if (maticBalance.isZero()) {
throw new Error("No MATIC for gas. Send some POL/MATIC to the EOA wallet.");
}
// Fetch all redeemable positions
const url = `https://data-api.polymarket.com/positions?user=${proxyWalletAddress}&redeemable=true&sizeThreshold=0&limit=500`;
const res = await fetch(url);
if (!res.ok) throw new Error(`Positions API failed: ${res.status}`);
const positions: RedeemablePosition[] = await res.json();
const redeemable = positions.filter(
(p) => p.currentValue > 1 && p.redeemable,
);
if (redeemable.length === 0) {
log.info("No redeemable positions found.");
return { claimed: 0, failed: 0 };
}
log.info(`Found ${redeemable.length} redeemable position(s)`);
// Group by conditionId
const grouped = new Map<string, RedeemablePosition[]>();
for (const pos of redeemable) {
if (!grouped.has(pos.conditionId)) grouped.set(pos.conditionId, []);
grouped.get(pos.conditionId)!.push(pos);
}
// Safe + CTF + NegRisk contracts
const safe = new Contract(proxyWalletAddress, SAFE_ABI, wallet);
const ctf = new Contract(CTF_ADDRESS, CTF_ABI, provider);
const negRiskAdapter = new Contract(
NEG_RISK_ADAPTER_ADDRESS,
NEG_RISK_ADAPTER_ABI,
provider,
);
// Verify ownership
const owners: string[] = await safe.getOwners();
const isOwner = owners.some(
(o) => o.toLowerCase() === eoaAddress.toLowerCase(),
);
if (!isOwner) {
throw new Error(
`EOA ${eoaAddress} is not owner of Safe ${proxyWalletAddress}`,
);
}
let claimed = 0;
let failed = 0;
for (const [conditionId, group] of grouped) {
try {
const isNegRisk = group[0].negativeRisk;
let to: string;
let data: string;
if (isNegRisk) {
// Negative risk → redeem via adapter with actual balances
const amounts = [BigNumber.from(0), BigNumber.from(0)];
for (const pos of group) {
const bal = await ctf.balanceOf(proxyWalletAddress, pos.asset);
amounts[pos.outcomeIndex] = bal;
}
data = negRiskAdapter.interface.encodeFunctionData("redeemPositions", [
conditionId,
amounts,
]);
to = NEG_RISK_ADAPTER_ADDRESS;
} else {
// Classic CTF redeem (Yes + No = [1,2])
data = ctf.interface.encodeFunctionData("redeemPositions", [
USDC_ADDRESS,
HashZero,
conditionId,
[1, 2],
]);
to = CTF_ADDRESS;
}
const nonce: BigNumber = await safe.nonce();
const txHash = await safe.getTransactionHash(
to,
0,
data,
0,
0,
0,
0,
AddressZero,
AddressZero,
nonce,
);
// Sign (raw digest – no personal_sign prefix)
const signature = joinSignature(
wallet._signingKey().signDigest(arrayify(txHash)),
);
const title = group[0].title || conditionId.slice(0, 12);
log.info(`Claiming ${title} (nonce=${nonce})`);
// Dynamic gas pricing
let gasPrice = await provider.getGasPrice();
const minGasPrice = BigNumber.from(MIN_GAS_PRICE_GWEI * 1_000_000_000);
if (gasPrice.lt(minGasPrice)) gasPrice = minGasPrice;
gasPrice = gasPrice.mul(Math.floor(GAS_PRICE_BUFFER_PCT * 100)).div(100);
// Don't burn all remaining balance
const affordable = maticBalance
.mul(Math.floor(BALANCE_GAS_BUFFER_PCT * 100))
.div(100);
const maxGasPrice = affordable.div(GAS_LIMIT);
if (gasPrice.gt(maxGasPrice)) {
log.info(`Capping gas price to affordable level`);
gasPrice = maxGasPrice;
}
if (gasPrice.lt(minGasPrice)) {
throw new Error("Not enough MATIC to cover gas at current prices");
}
log.info(`Gas price: ${(Number(gasPrice) / 1e9).toFixed(2)} gwei`);
const tx = await safe.execTransaction(
to,
0,
data,
0,
0,
0,
0,
AddressZero,
AddressZero as any,
signature,
{ gasLimit: GAS_LIMIT, gasPrice, type: 0 },
);
log.info(`Submitted → ${tx.hash}`);
const receipt = await tx.wait();
log.info(`Confirmed → ${receipt.transactionHash}`);
claimed += group.length;
} catch (err) {
log.error(`Failed condition ${conditionId.slice(0, 12)}:`, err);
failed += group.length;
}
}
log.info(`Finished — claimed ${claimed}, failed ${failed}`);
return { claimed, failed };
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment