Created
February 15, 2026 23:57
-
-
Save tylerthebuildor/fe48617cc2a30c123ab175e1e65b57cf to your computer and use it in GitHub Desktop.
Programmatically claim all resolved market rewards on Polymarket.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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