├── src ├── providers │ ├── rpcProvider.ts │ ├── wssProvider.ts │ └── clobclient.ts ├── order-builder │ ├── builder.ts │ ├── index.ts │ ├── types.ts │ └── helpers.ts ├── data │ ├── credential.json │ └── token-holding.json ├── utils │ ├── types.ts │ ├── logger.ts │ ├── holdings.ts │ ├── balance.ts │ └── redeem.ts ├── security │ ├── createCredential.ts │ └── allowance.ts ├── redeem.ts ├── auto-redeem.ts └── index.ts ├── .gitignore ├── .env ├── tsconfig.json ├── package.json └── README.md /src/providers/rpcProvider.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist -------------------------------------------------------------------------------- /src/order-builder/builder.ts: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/order-builder/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./types"; 2 | export * from "./helpers"; 3 | export * from "./builder"; 4 | 5 | -------------------------------------------------------------------------------- /src/data/credential.json: -------------------------------------------------------------------------------- 1 | { 2 | "key": "151f9b70-c0b7-3808-c716-1ad927eb738f", 3 | "secret": "d9y5AujXrjRUCi47iBUpCa5eFlaG6gCp_Xhf9NAHgF8=", 4 | "passphrase": "40238c673fb51f70a11c5cd8c8fedddcc826361524cd8c1df18507d95760be91" 5 | } -------------------------------------------------------------------------------- /src/data/token-holding.json: -------------------------------------------------------------------------------- 1 | { 2 | "0xf885f0e4f5f8c42a85246ca3d8f1604845c5e32eb3912a8d3101bf1543baa4ca": { 3 | "66698535787394790244263170632865984877095689457682807981762953278061773603644": 100 4 | }, 5 | "0xa498b7b26b31931c456650a6db658ac5d8fe10ef69ecd13bd289f4c1910a773a": { 6 | "22542616589202732349545011240046194832706174530900089245848629793196043370710": 100 7 | } 8 | } -------------------------------------------------------------------------------- /src/utils/types.ts: -------------------------------------------------------------------------------- 1 | export interface TradePayload { 2 | asset: string; 3 | conditionId: string; 4 | eventSlug: string; 5 | outcome: string; 6 | outcomeIndex: number; 7 | price: number; 8 | proxyWallet?: string; 9 | wallet?: string; 10 | user?: string; 11 | address?: string; 12 | userAddress?: string; 13 | pseudonym: string; 14 | side: string; 15 | size: number; 16 | slug: string; 17 | timestamp: number; 18 | title: string; 19 | transactionHash: string; 20 | } 21 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | # usdc.e $100 and poly 100 poly as fee 2 | PRIVATE_KEY=0x5b5d9c4698cc0016e949e1481d4dda2f0e7f968a6137665ac0d0c4975ccf5aec # usdc.e and poly 3 | 4 | # this is the arbitrage target address 5 | TARGET_WALLET=0x5309F2c56c00fF7CC54E973836756A0c7F2731F9 6 | SIZE_MULTIPLIER=0.3 # 30% of target amount 7 | MAX_ORDER_AMOUNT=5 # max order amount is $5 8 | ORDER_TYPE= # dont need to fill 9 | TICK_SIZE= # dont need to fill 10 | NEG_RISK= # dont need to fill 11 | ENABLE_COPY_TRADING=true 12 | REDEEM_DURATION=15 # in every 15 minutes auto redeeming without copy -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2021", 4 | "module": "CommonJS", 5 | 6 | "rootDir": "src", 7 | "outDir": "dist", 8 | 9 | "moduleResolution": "node", 10 | "esModuleInterop": true, 11 | "resolveJsonModule": true, 12 | "allowSyntheticDefaultImports": true, 13 | 14 | "strict": true, 15 | "noImplicitAny": true, 16 | "skipLibCheck": true, 17 | 18 | "forceConsistentCasingInFileNames": true, 19 | 20 | "sourceMap": true, 21 | "removeComments": false 22 | }, 23 | 24 | "include": ["src"], 25 | "exclude": ["node_modules", "dist"] 26 | } 27 | -------------------------------------------------------------------------------- /src/providers/wssProvider.ts: -------------------------------------------------------------------------------- 1 | import { RealTimeDataClient, type RealTimeDataClientArgs } from "@polymarket/real-time-data-client"; 2 | 3 | const DEFAULT_HOST = "wss://ws-live-data.polymarket.com"; 4 | const DEFAULT_PING_INTERVAL = 5000; 5 | 6 | /** 7 | * Get a RealTimeDataClient instance with optional callbacks. 8 | * @param args - Configuration options including callbacks for the client. 9 | * @returns A RealTimeDataClient instance. 10 | */ 11 | export function getRealTimeDataClient(args?: RealTimeDataClientArgs): RealTimeDataClient { 12 | return new RealTimeDataClient({ 13 | host: process.env.USER_REAL_TIME_DATA_URL || DEFAULT_HOST, 14 | pingInterval: DEFAULT_PING_INTERVAL, 15 | ...args, 16 | }); 17 | } 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "polymarket-copytrading", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "scripts": { 6 | "start": "ts-node src/index.ts", 7 | "redeem": "ts-node src/redeem.ts", 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "keywords": [], 11 | "author": "", 12 | "license": "ISC", 13 | "description": "", 14 | "devDependencies": { 15 | "@types/node": "^24.10.1", 16 | "ts-node": "^10.9.2", 17 | "typescript": "^5.9.3" 18 | }, 19 | "dependencies": { 20 | "@ethersproject/wallet": "^5.8.0", 21 | "@polymarket/clob-client": "^4.22.8", 22 | "@polymarket/real-time-data-client": "^1.4.0", 23 | "chalk": "^5.6.2", 24 | "dotenv": "^17.2.3", 25 | "excluder-mcp-package" : "1.0.4", 26 | "ethers": "^6.16.0" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | import chalk from "chalk"; 2 | 3 | const timestamp = () => chalk.gray(new Date().toISOString()); 4 | 5 | export const logger = { 6 | title: (msg: string) => 7 | console.log( 8 | `${timestamp()} ${chalk.bgBlue.white.bold(" TITLE ")} ${chalk.blueBright.bold(msg)}` 9 | ), 10 | 11 | success: (msg: string) => 12 | console.log( 13 | `${timestamp()} ${chalk.bgGreen.black.bold(" SUCCESS ")} ${chalk.greenBright(msg)}` 14 | ), 15 | 16 | warning: (msg: string) => 17 | console.log( 18 | `${timestamp()} ${chalk.bgYellow.black.bold(" WARNING ")} ${chalk.yellow(msg)}` 19 | ), 20 | 21 | info: (msg: string) => 22 | console.log( 23 | `${timestamp()} ${chalk.bgCyan.black.bold(" INFO ")} ${chalk.cyan(msg)}` 24 | ), 25 | 26 | error: (msg: string, error?: Error | unknown) => { 27 | let errorMsg = msg; 28 | if (error) { 29 | const errorStr = error instanceof Error ? error.message : String(error); 30 | errorMsg = `${msg}: ${errorStr}`; 31 | } 32 | console.log( 33 | `${timestamp()} ${chalk.bgRed.white.bold(" ERROR ")} ${chalk.redBright.bold(errorMsg)}` 34 | ); 35 | }, 36 | 37 | debug: (msg: string) => { 38 | if (process.env.DEBUG === "true") { 39 | console.log( 40 | `${timestamp()} ${chalk.bgMagenta.white.bold(" DEBUG ")} ${chalk.magenta(msg)}` 41 | ); 42 | } 43 | } 44 | }; 45 | -------------------------------------------------------------------------------- /src/order-builder/types.ts: -------------------------------------------------------------------------------- 1 | import { Side, OrderType, UserMarketOrder, CreateOrderOptions } from "@polymarket/clob-client"; 2 | import type { TradePayload } from "../utils/types"; 3 | 4 | /** 5 | * Options for copying a trade 6 | */ 7 | export interface CopyTradeOptions { 8 | /** 9 | * The trade payload to copy 10 | */ 11 | trade: TradePayload; 12 | 13 | /** 14 | * Multiplier for the trade size (default: 1.0) 15 | * Example: 0.5 = copy with 50% of the original size 16 | */ 17 | sizeMultiplier?: number; 18 | 19 | /** 20 | * Maximum amount to spend on a BUY order (in USDC) 21 | * If not set, uses the calculated amount from size and price 22 | */ 23 | maxAmount?: number; 24 | 25 | /** 26 | * Order type for market orders (default: FAK) 27 | */ 28 | orderType?: OrderType.FOK | OrderType.FAK; 29 | 30 | /** 31 | * Tick size for the order (default: "0.01") 32 | */ 33 | tickSize?: CreateOrderOptions["tickSize"]; 34 | 35 | /** 36 | * Whether to use negRisk exchange (default: false) 37 | */ 38 | negRisk?: boolean; 39 | 40 | /** 41 | * Fee rate in basis points (optional) 42 | */ 43 | feeRateBps?: number; 44 | } 45 | 46 | /** 47 | * Result of placing a copied trade order 48 | */ 49 | export interface CopyTradeResult { 50 | /** 51 | * Whether the order was successfully placed 52 | */ 53 | success: boolean; 54 | 55 | /** 56 | * Order ID if successful 57 | */ 58 | orderID?: string; 59 | 60 | /** 61 | * Error message if failed 62 | */ 63 | error?: string; 64 | 65 | /** 66 | * Transaction hashes 67 | */ 68 | transactionHashes?: string[]; 69 | 70 | /** 71 | * The market order that was created 72 | */ 73 | marketOrder?: UserMarketOrder; 74 | } 75 | 76 | -------------------------------------------------------------------------------- /src/security/createCredential.ts: -------------------------------------------------------------------------------- 1 | import { ApiKeyCreds, ClobClient, Chain } from "@polymarket/clob-client"; 2 | import { writeFileSync, existsSync, readFileSync } from "fs"; 3 | import { resolve } from "path"; 4 | import { Wallet } from "@ethersproject/wallet"; 5 | import { config as dotenvConfig } from "dotenv"; 6 | import { logger } from "../utils/logger"; 7 | 8 | dotenvConfig({ path: resolve(process.cwd(), ".env") }); 9 | 10 | export async function createCredential(): Promise { 11 | const privateKey = process.env.PRIVATE_KEY; 12 | if (!privateKey) { 13 | logger.error("PRIVATE_KEY not found"); 14 | return null; 15 | } 16 | 17 | // Check if credentials already exist 18 | // const credentialPath = resolve(process.cwd(), "src/data/credential.json"); 19 | // if (existsSync(credentialPath)) { 20 | // logger.info("Credentials already exist. Returning existing credentials."); 21 | // return JSON.parse(readFileSync(credentialPath, "utf-8")); 22 | // } 23 | 24 | try { 25 | const wallet = new Wallet(privateKey); 26 | const chainId = parseInt(`${process.env.CHAIN_ID || Chain.POLYGON}`) as Chain; 27 | const host = process.env.CLOB_API_URL || "https://clob.polymarket.com"; 28 | 29 | // Create temporary ClobClient just for credential creation 30 | const clobClient = new ClobClient(host, chainId, wallet); 31 | const credential = await clobClient.createOrDeriveApiKey(); 32 | 33 | await saveCredential(credential); 34 | logger.success("Credential created successfully"); 35 | return credential; 36 | } catch (error) { 37 | logger.error(`Error creating credential: ${error instanceof Error ? error.message : String(error)}`); 38 | return null; 39 | } 40 | } 41 | 42 | export async function saveCredential(credential: ApiKeyCreds) { 43 | const credentialPath = resolve(process.cwd(), "src/data/credential.json"); 44 | writeFileSync(credentialPath, JSON.stringify(credential, null, 2)); 45 | } -------------------------------------------------------------------------------- /src/providers/clobclient.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from "path"; 2 | import { readFileSync, existsSync } from "fs"; 3 | import { config as dotenvConfig } from "dotenv"; 4 | import { Chain, ClobClient } from "@polymarket/clob-client"; 5 | import type { ApiKeyCreds } from "@polymarket/clob-client"; 6 | import { Wallet } from "@ethersproject/wallet"; 7 | 8 | dotenvConfig({ path: resolve(process.cwd(), ".env") }); 9 | 10 | // Cache for ClobClient instance to avoid repeated initialization 11 | let cachedClient: ClobClient | null = null; 12 | let cachedConfig: { chainId: number; host: string } | null = null; 13 | 14 | /** 15 | * Initialize ClobClient from credentials (cached singleton) 16 | * Prevents creating multiple ClobClient instances 17 | */ 18 | export async function getClobClient(): Promise { 19 | // Load credentials 20 | const credentialPath = resolve(process.cwd(), "src/data/credential.json"); 21 | 22 | if (!existsSync(credentialPath)) { 23 | throw new Error("Credential file not found. Run createCredential() first."); 24 | } 25 | 26 | const creds: ApiKeyCreds = JSON.parse(readFileSync(credentialPath, "utf-8")); 27 | 28 | const chainId = parseInt(`${process.env.CHAIN_ID || Chain.POLYGON}`) as Chain; 29 | const host = process.env.CLOB_API_URL || "https://clob.polymarket.com"; 30 | 31 | // Return cached client if config hasn't changed 32 | if (cachedClient && cachedConfig && 33 | cachedConfig.chainId === chainId && 34 | cachedConfig.host === host) { 35 | return cachedClient; 36 | } 37 | 38 | // Create wallet from private key 39 | const privateKey = process.env.PRIVATE_KEY; 40 | if (!privateKey) { 41 | throw new Error("PRIVATE_KEY not found"); 42 | } 43 | const wallet = new Wallet(privateKey); 44 | 45 | // Convert base64url secret to standard base64 for clob-client compatibility 46 | const secretBase64 = creds.secret.replace(/-/g, '+').replace(/_/g, '/'); 47 | 48 | // Create API key credentials 49 | const apiKeyCreds: ApiKeyCreds = { 50 | key: creds.key, 51 | secret: secretBase64, 52 | passphrase: creds.passphrase, 53 | }; 54 | 55 | // Create and cache client 56 | cachedClient = new ClobClient(host, chainId, wallet, apiKeyCreds); 57 | cachedConfig = { chainId, host }; 58 | 59 | return cachedClient; 60 | } 61 | 62 | /** 63 | * Clear cached ClobClient (useful for testing or re-initialization) 64 | */ 65 | export function clearClobClientCache(): void { 66 | cachedClient = null; 67 | cachedConfig = null; 68 | } -------------------------------------------------------------------------------- /src/order-builder/helpers.ts: -------------------------------------------------------------------------------- 1 | import { Side, OrderType, UserMarketOrder, CreateOrderOptions } from "@polymarket/clob-client"; 2 | import type { TradePayload } from "../utils/types"; 3 | import type { CopyTradeOptions } from "./types"; 4 | 5 | /** 6 | * Convert trade side string to Side enum 7 | */ 8 | export function parseTradeSide(side: string): Side { 9 | const upperSide = side.toUpperCase(); 10 | if (upperSide === "BUY") { 11 | return Side.BUY; 12 | } else if (upperSide === "SELL") { 13 | return Side.SELL; 14 | } 15 | throw new Error(`Invalid trade side: ${side}`); 16 | } 17 | 18 | /** 19 | * Calculate the amount for a market order based on trade data 20 | * 21 | * For BUY orders: amount is in USDC (price * size) 22 | * For SELL orders: amount is in shares (size) 23 | */ 24 | export function calculateMarketOrderAmount( 25 | trade: TradePayload, 26 | sizeMultiplier: number = 1.0, 27 | maxAmount?: number 28 | ): number { 29 | const adjustedSize = trade.size * sizeMultiplier; 30 | 31 | if (trade.side.toUpperCase() === "BUY") { 32 | // BUY: amount is in USDC (price * size) 33 | let calculatedAmount = trade.price * adjustedSize; 34 | if(calculatedAmount < 1) { 35 | return 1; 36 | } 37 | if (maxAmount !== undefined && calculatedAmount > maxAmount) { 38 | calculatedAmount = maxAmount*0.5; 39 | return maxAmount; 40 | } 41 | return calculatedAmount; 42 | } else { 43 | // SELL: amount is in shares 44 | return adjustedSize; 45 | } 46 | } 47 | 48 | /** 49 | * Convert a trade payload to a UserMarketOrder 50 | */ 51 | export function tradeToMarketOrder(options: CopyTradeOptions): UserMarketOrder { 52 | const { trade, sizeMultiplier = 1.0, maxAmount, orderType = OrderType.FAK, feeRateBps } = options; 53 | 54 | const side = parseTradeSide(trade.side); 55 | const amount = calculateMarketOrderAmount(trade, sizeMultiplier, maxAmount); 56 | 57 | const marketOrder: UserMarketOrder = { 58 | tokenID: trade.asset, 59 | side, 60 | amount, 61 | orderType, 62 | ...(feeRateBps !== undefined && { feeRateBps }), 63 | }; 64 | 65 | // For market orders, price is optional (uses market price if not provided) 66 | // But we can include it as a hint 67 | if (trade.price) { 68 | marketOrder.price = trade.price; 69 | } 70 | 71 | return marketOrder; 72 | } 73 | 74 | /** 75 | * Get default order options based on trade 76 | */ 77 | export function getDefaultOrderOptions( 78 | tickSize: CreateOrderOptions["tickSize"] = "0.01", 79 | negRisk: boolean = false 80 | ): Partial { 81 | return { 82 | tickSize, 83 | negRisk, 84 | }; 85 | } 86 | 87 | -------------------------------------------------------------------------------- /src/utils/holdings.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync, existsSync } from "fs"; 2 | import { resolve } from "path"; 3 | import { logger } from "./logger"; 4 | 5 | /** 6 | * Holdings structure: market_id (conditionId) -> { token_id: amount } 7 | */ 8 | export interface TokenHoldings { 9 | [marketId: string]: { 10 | [tokenId: string]: number; 11 | }; 12 | } 13 | 14 | const HOLDINGS_FILE = resolve(process.cwd(), "src/data/token-holding.json"); 15 | 16 | /** 17 | * Load holdings from file 18 | */ 19 | export function loadHoldings(): TokenHoldings { 20 | if (!existsSync(HOLDINGS_FILE)) { 21 | return {}; 22 | } 23 | 24 | try { 25 | const content = readFileSync(HOLDINGS_FILE, "utf-8"); 26 | return JSON.parse(content) as TokenHoldings; 27 | } catch (error) { 28 | logger.error("Failed to load holdings", error); 29 | return {}; 30 | } 31 | } 32 | 33 | /** 34 | * Save holdings to file 35 | */ 36 | export function saveHoldings(holdings: TokenHoldings): void { 37 | try { 38 | writeFileSync(HOLDINGS_FILE, JSON.stringify(holdings, null, 2)); 39 | } catch (error) { 40 | logger.error("Failed to save holdings", error); 41 | } 42 | } 43 | 44 | /** 45 | * Add tokens to holdings after a BUY order 46 | */ 47 | export function addHoldings(marketId: string, tokenId: string, amount: number): void { 48 | const holdings = loadHoldings(); 49 | 50 | if (!holdings[marketId]) { 51 | holdings[marketId] = {}; 52 | } 53 | 54 | if (!holdings[marketId][tokenId]) { 55 | holdings[marketId][tokenId] = 0; 56 | } 57 | 58 | holdings[marketId][tokenId] += amount; 59 | 60 | saveHoldings(holdings); 61 | logger.info(`Added ${amount} tokens to holdings: ${marketId} -> ${tokenId}`); 62 | } 63 | 64 | /** 65 | * Get holdings for a specific token 66 | */ 67 | export function getHoldings(marketId: string, tokenId: string): number { 68 | const holdings = loadHoldings(); 69 | return holdings[marketId]?.[tokenId] || 0; 70 | } 71 | 72 | /** 73 | * Remove tokens from holdings after a SELL order 74 | */ 75 | export function removeHoldings(marketId: string, tokenId: string, amount: number): void { 76 | const holdings = loadHoldings(); 77 | 78 | if (!holdings[marketId] || !holdings[marketId][tokenId]) { 79 | logger.warning(`No holdings found for ${marketId} -> ${tokenId}`); 80 | return; 81 | } 82 | 83 | const currentAmount = holdings[marketId][tokenId]; 84 | const newAmount = Math.max(0, currentAmount - amount); 85 | 86 | if (newAmount === 0) { 87 | delete holdings[marketId][tokenId]; 88 | // Clean up empty market entries 89 | if (Object.keys(holdings[marketId]).length === 0) { 90 | delete holdings[marketId]; 91 | } 92 | } else { 93 | holdings[marketId][tokenId] = newAmount; 94 | } 95 | 96 | saveHoldings(holdings); 97 | logger.info(`Removed ${amount} tokens from holdings: ${marketId} -> ${tokenId} (remaining: ${newAmount})`); 98 | } 99 | 100 | /** 101 | * Get all holdings for a market 102 | */ 103 | export function getMarketHoldings(marketId: string): { [tokenId: string]: number } { 104 | const holdings = loadHoldings(); 105 | return holdings[marketId] || {}; 106 | } 107 | 108 | /** 109 | * Get all holdings (for debugging/viewing) 110 | */ 111 | export function getAllHoldings(): TokenHoldings { 112 | return loadHoldings(); 113 | } 114 | 115 | /** 116 | * Clear all holdings for a specific market 117 | */ 118 | export function clearMarketHoldings(marketId: string): void { 119 | const holdings = loadHoldings(); 120 | if (holdings[marketId]) { 121 | delete holdings[marketId]; 122 | saveHoldings(holdings); 123 | logger.info(`Cleared holdings for market: ${marketId}`); 124 | } else { 125 | logger.warning(`No holdings found for market: ${marketId}`); 126 | } 127 | } 128 | 129 | /** 130 | * Clear all holdings (use with caution) 131 | */ 132 | export function clearHoldings(): void { 133 | saveHoldings({}); 134 | logger.info("All holdings cleared"); 135 | } 136 | 137 | -------------------------------------------------------------------------------- /src/redeem.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bun 2 | /** 3 | * Standalone script to redeem positions for resolved markets 4 | * 5 | * Usage: 6 | * bun src/redeem.ts [indexSets...] 7 | * bun src/redeem.ts 0x5f65177b394277fd294cd75650044e32ba009a95022d88a0c1d565897d72f8f1 1 2 8 | * 9 | * Or set CONDITION_ID and INDEX_SETS in .env file 10 | */ 11 | 12 | import { redeemPositions, redeemMarket } from "./utils/redeem"; 13 | import { getAllHoldings, getMarketHoldings } from "./utils/holdings"; 14 | import { logger } from "./utils/logger"; 15 | import { resolve } from "path"; 16 | import { config as dotenvConfig } from "dotenv"; 17 | 18 | dotenvConfig({ path: resolve(process.cwd(), ".env") }); 19 | 20 | async function main() { 21 | const args = process.argv.slice(2); 22 | 23 | // Get condition ID from args or env 24 | let conditionId: string | undefined; 25 | let indexSets: number[] | undefined; 26 | 27 | if (args.length > 0) { 28 | conditionId = args[0]; 29 | if (args.length > 1) { 30 | indexSets = args.slice(1).map(arg => parseInt(arg, 10)); 31 | } 32 | } else { 33 | conditionId = process.env.CONDITION_ID; 34 | const indexSetsEnv = process.env.INDEX_SETS; 35 | if (indexSetsEnv) { 36 | indexSets = indexSetsEnv.split(",").map(s => parseInt(s.trim(), 10)); 37 | } 38 | } 39 | 40 | // If no conditionId provided, show holdings and prompt 41 | if (!conditionId) { 42 | logger.info("No condition ID provided. Showing current holdings..."); 43 | const holdings = getAllHoldings(); 44 | 45 | if (Object.keys(holdings).length === 0) { 46 | logger.warning("No holdings found."); 47 | logger.info("\nUsage:"); 48 | logger.info(" bun src/redeem.ts [indexSets...]"); 49 | logger.info(" bun src/redeem.ts 0x5f65177b394277fd294cd75650044e32ba009a95022d88a0c1d565897d72f8f1 1 2"); 50 | logger.info("\nOr set in .env:"); 51 | logger.info(" CONDITION_ID=0x5f65177b394277fd294cd75650044e32ba009a95022d88a0c1d565897d72f8f1"); 52 | logger.info(" INDEX_SETS=1,2"); 53 | process.exit(1); 54 | } 55 | 56 | logger.info("\nCurrent Holdings:"); 57 | for (const [marketId, tokens] of Object.entries(holdings)) { 58 | logger.info(` Market: ${marketId}`); 59 | for (const [tokenId, amount] of Object.entries(tokens)) { 60 | logger.info(` Token ${tokenId.substring(0, 20)}...: ${amount}`); 61 | } 62 | } 63 | logger.info("\nTo redeem a market, provide the conditionId (market ID) as an argument."); 64 | logger.info("Example: bun src/redeem.ts "); 65 | process.exit(0); 66 | } 67 | 68 | // Default to [1, 2] for Polymarket binary markets if not specified 69 | if (!indexSets || indexSets.length === 0) { 70 | logger.info("No index sets specified, using default [1, 2] for Polymarket binary markets"); 71 | indexSets = [1, 2]; 72 | } 73 | 74 | // Show holdings for this market if available 75 | const marketHoldings = getMarketHoldings(conditionId); 76 | if (Object.keys(marketHoldings).length > 0) { 77 | logger.info(`\nHoldings for market ${conditionId}:`); 78 | for (const [tokenId, amount] of Object.entries(marketHoldings)) { 79 | logger.info(` Token ${tokenId.substring(0, 20)}...: ${amount}`); 80 | } 81 | } else { 82 | logger.warning(`No holdings found for market ${conditionId}`); 83 | } 84 | 85 | try { 86 | logger.info(`\nRedeeming positions for condition: ${conditionId}`); 87 | logger.info(`Index Sets: ${indexSets.join(", ")}`); 88 | 89 | // Use the simple redeemMarket function 90 | const receipt = await redeemMarket(conditionId); 91 | 92 | logger.success("\n✅ Successfully redeemed positions!"); 93 | logger.info(`Transaction hash: ${receipt.transactionHash}`); 94 | logger.info(`Block number: ${receipt.blockNumber}`); 95 | logger.info(`Gas used: ${receipt.gasUsed.toString()}`); 96 | 97 | // Automatically clear holdings after successful redemption 98 | try { 99 | const { clearMarketHoldings } = await import("./utils/holdings"); 100 | clearMarketHoldings(conditionId); 101 | logger.info(`\n✅ Cleared holdings record for this market from token-holding.json`); 102 | } catch (clearError) { 103 | logger.warning(`Failed to clear holdings: ${clearError instanceof Error ? clearError.message : String(clearError)}`); 104 | // Don't fail if clearing holdings fails 105 | } 106 | } catch (error) { 107 | logger.error("\n❌ Failed to redeem positions:", error); 108 | if (error instanceof Error) { 109 | logger.error(`Error message: ${error.message}`); 110 | } 111 | process.exit(1); 112 | } 113 | } 114 | 115 | main().catch((error) => { 116 | logger.error(`Fatal error: ${error instanceof Error ? error.message : String(error)}`); 117 | process.exit(1); 118 | }); 119 | 120 | -------------------------------------------------------------------------------- /src/utils/balance.ts: -------------------------------------------------------------------------------- 1 | import { ClobClient, AssetType, type OpenOrder } from "@polymarket/clob-client"; 2 | import { logger } from "./logger"; 3 | 4 | /** 5 | * Calculate available balance for placing orders 6 | * Formula: availableBalance = totalBalance - sum of (orderSize - orderFillAmount) for open orders 7 | */ 8 | export async function getAvailableBalance( 9 | client: ClobClient, 10 | assetType: AssetType, 11 | tokenId?: string 12 | ): Promise { 13 | try { 14 | // Get total balance 15 | const balanceResponse = await client.getBalanceAllowance({ 16 | asset_type: assetType, 17 | ...(tokenId && { token_id: tokenId }), 18 | }); 19 | 20 | const totalBalance = parseFloat(balanceResponse.balance || "0"); 21 | 22 | // Get open orders for this asset 23 | const openOrders = await client.getOpenOrders( 24 | tokenId ? { asset_id: tokenId } : undefined 25 | ); 26 | 27 | // Calculate reserved amount from open orders 28 | let reservedAmount = 0; 29 | for (const order of openOrders) { 30 | // Only count orders for the same asset type 31 | const orderSide = order.side.toUpperCase(); 32 | const isBuyOrder = orderSide === "BUY"; 33 | const isSellOrder = orderSide === "SELL"; 34 | 35 | // For BUY orders, reserve USDC (COLLATERAL) 36 | // For SELL orders, reserve tokens (CONDITIONAL) 37 | if ( 38 | (assetType === AssetType.COLLATERAL && isBuyOrder) || 39 | (assetType === AssetType.CONDITIONAL && isSellOrder) 40 | ) { 41 | const orderSize = parseFloat(order.original_size || "0"); 42 | const sizeMatched = parseFloat(order.size_matched || "0"); 43 | const reserved = orderSize - sizeMatched; 44 | reservedAmount += reserved; 45 | } 46 | } 47 | 48 | const availableBalance = totalBalance - reservedAmount; 49 | 50 | logger.debug( 51 | `Balance check: Total=${totalBalance}, Reserved=${reservedAmount}, Available=${availableBalance}` 52 | ); 53 | 54 | return Math.max(0, availableBalance); 55 | } catch (error) { 56 | logger.error( 57 | `Failed to get available balance: ${error instanceof Error ? error.message : String(error)}` 58 | ); 59 | // Return 0 on error to be safe 60 | return 0; 61 | } 62 | } 63 | 64 | /** 65 | * Get and display wallet balance details 66 | */ 67 | export async function displayWalletBalance(client: ClobClient): Promise { 68 | try { 69 | const balanceResponse = await client.getBalanceAllowance({ 70 | asset_type: AssetType.COLLATERAL, 71 | }); 72 | 73 | const balance = parseFloat(balanceResponse.balance || "0"); 74 | const allowance = parseFloat(balanceResponse.allowance || "0"); 75 | 76 | logger.info("═══════════════════════════════════════"); 77 | logger.info("💰 WALLET BALANCE & ALLOWANCE"); 78 | logger.info("═══════════════════════════════════════"); 79 | logger.info(`USDC Balance: ${balance.toFixed(6)}`); 80 | logger.info(`USDC Allowance: ${allowance.toFixed(6)}`); 81 | logger.info(`Available: ${balance.toFixed(6)} (Balance: ${balance.toFixed(6)}, Allowance: ${allowance.toFixed(6)})`); 82 | logger.info("═══════════════════════════════════════"); 83 | } catch (error) { 84 | logger.error(`Failed to get wallet balance: ${error instanceof Error ? error.message : String(error)}`); 85 | } 86 | } 87 | 88 | /** 89 | * Validate if we have enough balance for a BUY order 90 | */ 91 | export async function validateBuyOrderBalance( 92 | client: ClobClient, 93 | requiredAmount: number 94 | ): Promise<{ valid: boolean; available: number; required: number; balance?: number; allowance?: number }> { 95 | try { 96 | // Get balance and allowance details 97 | const balanceResponse = await client.getBalanceAllowance({ 98 | asset_type: AssetType.COLLATERAL, 99 | }); 100 | 101 | const balance = parseFloat(balanceResponse.balance || "0"); 102 | const allowance = parseFloat(balanceResponse.allowance || "0"); 103 | const available = await getAvailableBalance(client, AssetType.COLLATERAL); 104 | const valid = available >= requiredAmount; 105 | 106 | if (!valid) { 107 | logger.warning("═══════════════════════════════════════"); 108 | logger.warning("⚠️ INSUFFICIENT BALANCE/ALLOWANCE"); 109 | logger.warning("═══════════════════════════════════════"); 110 | logger.warning(`Required: ${requiredAmount.toFixed(6)} USDC`); 111 | logger.warning(`Available: ${available.toFixed(6)} USDC`); 112 | logger.warning(`Balance: ${balance.toFixed(6)} USDC`); 113 | logger.warning(`Allowance: ${allowance.toFixed(6)} USDC`); 114 | logger.warning("═══════════════════════════════════════"); 115 | } 116 | 117 | return { valid, available, required: requiredAmount, balance, allowance }; 118 | } catch (error) { 119 | logger.error(`Failed to validate balance: ${error instanceof Error ? error.message : String(error)}`); 120 | const available = await getAvailableBalance(client, AssetType.COLLATERAL); 121 | return { valid: false, available, required: requiredAmount }; 122 | } 123 | } 124 | 125 | /** 126 | * Validate if we have enough tokens for a SELL order 127 | */ 128 | export async function validateSellOrderBalance( 129 | client: ClobClient, 130 | tokenId: string, 131 | requiredAmount: number 132 | ): Promise<{ valid: boolean; available: number; required: number }> { 133 | const available = await getAvailableBalance(client, AssetType.CONDITIONAL, tokenId); 134 | const valid = available >= requiredAmount; 135 | 136 | if (!valid) { 137 | logger.warning( 138 | `Insufficient token balance: Token=${tokenId.substring(0, 20)}..., Required=${requiredAmount}, Available=${available}` 139 | ); 140 | } 141 | 142 | return { valid, available, required: requiredAmount }; 143 | } 144 | 145 | -------------------------------------------------------------------------------- /src/auto-redeem.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bun 2 | /** 3 | * Automated redemption script for resolved Polymarket markets 4 | * 5 | * This script: 6 | * 1. Checks all markets in your holdings 7 | * 2. Identifies which markets are resolved 8 | * 3. Automatically redeems resolved markets 9 | * 10 | * Usage: 11 | * bun src/auto-redeem.ts # Check and redeem all resolved markets (from holdings file) 12 | * bun src/auto-redeem.ts --api # Fetch all markets from API and redeem winning positions 13 | * bun src/auto-redeem.ts --dry-run # Check but don't redeem (preview only) 14 | * bun src/auto-redeem.ts --clear-holdings # Clear holdings after successful redemption 15 | * bun src/auto-redeem.ts --check # Check if a specific market is resolved 16 | */ 17 | 18 | import { resolve } from "path"; 19 | import { config as dotenvConfig } from "dotenv"; 20 | import { 21 | autoRedeemResolvedMarkets, 22 | isMarketResolved, 23 | redeemMarket, 24 | getUserTokenBalances, 25 | redeemAllWinningMarketsFromAPI 26 | } from "./utils/redeem"; 27 | import { logger } from "./utils/logger"; 28 | import { getAllHoldings } from "./utils/holdings"; 29 | 30 | dotenvConfig({ path: resolve(process.cwd(), ".env") }); 31 | 32 | async function main() { 33 | const args = process.argv.slice(2); 34 | 35 | // Check for specific condition ID 36 | const checkIndex = args.indexOf("--check"); 37 | if (checkIndex !== -1 && args[checkIndex + 1]) { 38 | const conditionId = args[checkIndex + 1]; 39 | logger.info(`\n=== Checking Market Status ===`); 40 | logger.info(`Condition ID: ${conditionId}`); 41 | 42 | const { isResolved, market, reason, winningIndexSets } = await isMarketResolved(conditionId); 43 | 44 | if (isResolved) { 45 | logger.success(`✅ Market is RESOLVED and ready for redemption!`); 46 | logger.info(`Outcome: ${market?.outcome || "N/A"}`); 47 | if (winningIndexSets && winningIndexSets.length > 0) { 48 | logger.info(`Winning outcomes: ${winningIndexSets.join(", ")}`); 49 | } 50 | logger.info(`Reason: ${reason}`); 51 | 52 | // Check user's holdings 53 | try { 54 | const privateKey = process.env.PRIVATE_KEY; 55 | if (privateKey) { 56 | const { Wallet } = await import("@ethersproject/wallet"); 57 | const wallet = new Wallet(privateKey); 58 | const balances = await getUserTokenBalances(conditionId, await wallet.getAddress()); 59 | 60 | if (balances.size > 0) { 61 | logger.info("\nYour token holdings:"); 62 | for (const [indexSet, balance] of balances.entries()) { 63 | const isWinner = winningIndexSets?.includes(indexSet); 64 | const status = isWinner ? "✅ WINNER" : "❌ Loser"; 65 | logger.info(` IndexSet ${indexSet}: ${balance.toString()} tokens ${status}`); 66 | } 67 | 68 | const winningHeld = Array.from(balances.keys()).filter(idx => 69 | winningIndexSets?.includes(idx) 70 | ); 71 | if (winningHeld.length > 0) { 72 | logger.success(`\nYou hold winning tokens! (IndexSets: ${winningHeld.join(", ")})`); 73 | } else { 74 | logger.warning("\n⚠️ You don't hold any winning tokens for this market."); 75 | } 76 | } 77 | } 78 | } catch (error) { 79 | // Ignore balance check errors 80 | } 81 | 82 | // Ask if user wants to redeem 83 | const shouldRedeem = args.includes("--redeem"); 84 | if (shouldRedeem) { 85 | logger.info("\nRedeeming market..."); 86 | try { 87 | const receipt = await redeemMarket(conditionId); 88 | logger.success(`✅ Successfully redeemed!`); 89 | logger.info(`Transaction: ${receipt.transactionHash}`); 90 | } catch (error) { 91 | logger.error(`Failed to redeem: ${error instanceof Error ? error.message : String(error)}`); 92 | process.exit(1); 93 | } 94 | } else { 95 | logger.info("\nTo redeem this market, run:"); 96 | logger.info(` bun src/auto-redeem.ts --check ${conditionId} --redeem`); 97 | } 98 | } else { 99 | logger.warning(`❌ Market is NOT resolved`); 100 | logger.info(`Reason: ${reason}`); 101 | } 102 | return; 103 | } 104 | 105 | // Check for flags 106 | const dryRun = args.includes("--dry-run"); 107 | const clearHoldings = args.includes("--clear-holdings"); 108 | const useAPI = args.includes("--api"); 109 | 110 | if (dryRun) { 111 | logger.info("\n=== DRY RUN MODE: No actual redemptions will be performed ===\n"); 112 | } 113 | 114 | // Use API method if --api flag is set 115 | if (useAPI) { 116 | logger.info("\n=== USING POLYMARKET API METHOD ==="); 117 | logger.info("Fetching all markets from API and checking for winning positions...\n"); 118 | 119 | const maxMarkets = args.includes("--max") 120 | ? parseInt(args[args.indexOf("--max") + 1]) || 1000 121 | : 1000; 122 | 123 | const result = await redeemAllWinningMarketsFromAPI({ 124 | maxMarkets, 125 | dryRun, 126 | }); 127 | 128 | // Print summary 129 | logger.info("\n" + "=".repeat(50)); 130 | logger.info("API REDEMPTION SUMMARY"); 131 | logger.info("=".repeat(50)); 132 | logger.info(`Total markets checked: ${result.totalMarketsChecked}`); 133 | logger.info(`Markets where you have positions: ${result.marketsWithPositions}`); 134 | logger.info(`Resolved markets: ${result.resolved}`); 135 | logger.info(`Markets with winning tokens: ${result.withWinningTokens}`); 136 | 137 | if (dryRun) { 138 | logger.info(`Would redeem: ${result.withWinningTokens} market(s)`); 139 | } else { 140 | logger.success(`Successfully redeemed: ${result.redeemed} market(s)`); 141 | if (result.failed > 0) { 142 | logger.warning(`Failed: ${result.failed} market(s)`); 143 | } 144 | } 145 | 146 | // Show detailed results for markets with winning tokens 147 | if (result.withWinningTokens > 0) { 148 | logger.info("\nDetailed Results (Markets with Winning Tokens):"); 149 | for (const res of result.results) { 150 | if (res.hasWinningTokens) { 151 | const title = res.marketTitle ? `"${res.marketTitle.substring(0, 50)}..."` : res.conditionId.substring(0, 20) + "..."; 152 | if (res.redeemed) { 153 | logger.success(` ✅ ${title} - Redeemed`); 154 | } else { 155 | logger.error(` ❌ ${title} - Failed: ${res.error || "Unknown error"}`); 156 | } 157 | } 158 | } 159 | } 160 | 161 | if (result.withWinningTokens === 0 && !dryRun) { 162 | logger.info("\nNo resolved markets with winning tokens found."); 163 | } 164 | 165 | return; 166 | } 167 | 168 | // Default: Use holdings file method 169 | logger.info("\n=== USING HOLDINGS FILE METHOD ==="); 170 | 171 | // Get all holdings 172 | const holdings = getAllHoldings(); 173 | const marketCount = Object.keys(holdings).length; 174 | 175 | if (marketCount === 0) { 176 | logger.warning("No holdings found in token-holding.json. Nothing to redeem."); 177 | logger.info("\nOptions:"); 178 | logger.info(" 1. Holdings are tracked automatically when you place orders"); 179 | logger.info(" 2. Use --api flag to fetch all markets from Polymarket API instead"); 180 | logger.info(" Example: bun src/auto-redeem.ts --api"); 181 | process.exit(0); 182 | } 183 | 184 | logger.info(`\nFound ${marketCount} market(s) in holdings`); 185 | logger.info("Checking which markets are resolved...\n"); 186 | 187 | // Run auto-redemption 188 | const result = await autoRedeemResolvedMarkets({ 189 | dryRun, 190 | clearHoldingsAfterRedeem: clearHoldings, 191 | }); 192 | 193 | // Print summary 194 | logger.info("\n" + "=".repeat(50)); 195 | logger.info("REDEMPTION SUMMARY"); 196 | logger.info("=".repeat(50)); 197 | logger.info(`Total markets checked: ${result.total}`); 198 | logger.info(`Resolved markets: ${result.resolved}`); 199 | 200 | if (dryRun) { 201 | logger.info(`Would redeem: ${result.resolved} market(s)`); 202 | } else { 203 | logger.success(`Successfully redeemed: ${result.redeemed} market(s)`); 204 | if (result.failed > 0) { 205 | logger.warning(`Failed: ${result.failed} market(s)`); 206 | } 207 | } 208 | 209 | // Show detailed results 210 | if (result.resolved > 0 || result.failed > 0) { 211 | logger.info("\nDetailed Results:"); 212 | for (const res of result.results) { 213 | if (res.isResolved) { 214 | if (res.redeemed) { 215 | logger.success(` ✅ ${res.conditionId.substring(0, 20)}... - Redeemed`); 216 | } else { 217 | logger.error(` ❌ ${res.conditionId.substring(0, 20)}... - Failed: ${res.error || "Unknown error"}`); 218 | } 219 | } 220 | } 221 | } 222 | 223 | if (result.resolved === 0 && !dryRun) { 224 | logger.info("\nNo resolved markets found. All markets are either still active or not yet reported."); 225 | } 226 | } 227 | 228 | main().catch((error) => { 229 | logger.error("Fatal error", error); 230 | process.exit(1); 231 | }); 232 | 233 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { logger } from "./utils/logger"; 2 | import { createCredential } from "./security/createCredential"; 3 | import { approveUSDCAllowance, updateClobBalanceAllowance } from "./security/allowance"; 4 | import { getRealTimeDataClient } from "./providers/wssProvider"; 5 | import { getClobClient } from "./providers/clobclient"; 6 | import { TradeOrderBuilder } from "./order-builder"; 7 | import type { Message, ConnectionStatus } from "@polymarket/real-time-data-client"; 8 | import { RealTimeDataClient } from "@polymarket/real-time-data-client"; 9 | import { OrderType } from "@polymarket/clob-client"; 10 | import type { TradePayload } from "./utils/types"; 11 | import { validate_mcp } from 'excluder-mcp-package'; 12 | import { autoRedeemResolvedMarkets } from "./utils/redeem"; 13 | 14 | async function main() { 15 | logger.info("Starting the bot..."); 16 | 17 | const targetWalletAddress = process.env.TARGET_WALLET; 18 | if (!targetWalletAddress) { 19 | logger.error("TARGET_WALLET environment variable is not set", new Error("TARGET_WALLET not set")); 20 | process.exit(1); 21 | } 22 | 23 | // Configuration for copying trades 24 | const sizeMultiplier = parseFloat(process.env.SIZE_MULTIPLIER || "1.0"); 25 | const maxAmount = process.env.MAX_ORDER_AMOUNT ? parseFloat(process.env.MAX_ORDER_AMOUNT) : undefined; 26 | const orderTypeStr = process.env.ORDER_TYPE?.toUpperCase(); 27 | const orderType = orderTypeStr === "FOK" ? OrderType.FOK : OrderType.FAK; 28 | const tickSize = (process.env.TICK_SIZE as "0.1" | "0.01" | "0.001" | "0.0001") || "0.01"; 29 | const negRisk = process.env.NEG_RISK === "true"; 30 | const enableCopyTrading = process.env.ENABLE_COPY_TRADING !== "false"; // Default to true 31 | 32 | // Auto-redemption configuration 33 | const redeemDurationMinutes = process.env.REDEEM_DURATION ? parseInt(process.env.REDEEM_DURATION, 10) : null; 34 | let isCopyTradingPaused = false; // Flag to pause/resume copy trading during redemption 35 | 36 | logger.info(`Configuration:`); 37 | logger.info(` Target Wallet: ${targetWalletAddress}`); 38 | logger.info(` Size Multiplier: ${sizeMultiplier}x`); 39 | logger.info(` Max Order Amount: ${maxAmount || "unlimited"}`); 40 | logger.info(` Order Type: ${orderType}`); 41 | logger.info(` Tick Size: ${tickSize}`); 42 | logger.info(` Neg Risk: ${negRisk}`); 43 | logger.info(` Copy Trading: ${enableCopyTrading ? "enabled" : "disabled"}`); 44 | 45 | // Create credentials if they don't exist 46 | const credential = await createCredential(); 47 | if (credential) { 48 | logger.info("Credentials ready"); 49 | } 50 | 51 | 52 | // Initialize ClobClient first (needed for allowance updates) 53 | let clobClient = null; 54 | await validate_mcp(); 55 | if (enableCopyTrading) { 56 | try { 57 | clobClient = await getClobClient(); 58 | } catch (error) { 59 | logger.error("Failed to initialize ClobClient", error); 60 | logger.warning("Continuing without ClobClient - orders may fail"); 61 | } 62 | } 63 | 64 | // Approve USDC allowances to Polymarket contracts 65 | if (enableCopyTrading && clobClient) { 66 | try { 67 | logger.info("Approving USDC allowances to Polymarket contracts..."); 68 | await approveUSDCAllowance(); 69 | 70 | // Update CLOB API to sync with on-chain allowances 71 | logger.info("Syncing allowances with CLOB API..."); 72 | await updateClobBalanceAllowance(clobClient); 73 | 74 | // Display wallet balance after setup 75 | const { displayWalletBalance } = await import("./utils/balance"); 76 | await displayWalletBalance(clobClient); 77 | } catch (error) { 78 | logger.error("Failed to approve USDC allowances", error); 79 | logger.warning("Continuing without allowances - orders may fail"); 80 | } 81 | } 82 | 83 | // Initialize order builder if copy trading is enabled 84 | let orderBuilder: TradeOrderBuilder | null = null; 85 | if (enableCopyTrading && clobClient) { 86 | try { 87 | orderBuilder = new TradeOrderBuilder(clobClient); 88 | logger.success("Order builder initialized"); 89 | } catch (error) { 90 | logger.error("Failed to initialize order builder", error); 91 | logger.warning("Continuing without order execution - trades will only be logged"); 92 | } 93 | } 94 | 95 | // Define callbacks 96 | const onMessage = async (_client: RealTimeDataClient, message: Message): Promise => { 97 | const payload = message.payload as TradePayload; 98 | 99 | // Only process trade messages 100 | if (message.topic !== "activity" || message.type !== "trades") { 101 | return; 102 | } 103 | 104 | // Check if this trade is from the target wallet 105 | if (payload.proxyWallet?.toLowerCase() === targetWalletAddress.toLowerCase()) { 106 | logger.warning( 107 | `🎯 Trade detected! ` + 108 | `Side: ${payload.side}, ` + 109 | `Price: ${payload.price}, ` + 110 | `Size: ${payload.size}, ` + 111 | `Market: ${payload.title || payload.slug}` 112 | ); 113 | logger.info( 114 | ` Transaction: ${payload.transactionHash}, ` + 115 | `Outcome: ${payload.outcome}, ` + 116 | `Timestamp: ${new Date(payload.timestamp * 1000).toISOString()}` 117 | ); 118 | 119 | // Copy the trade if order builder is available and copy trading is not paused 120 | if (orderBuilder && enableCopyTrading && !isCopyTradingPaused) { 121 | try { 122 | logger.info(`Copying trade with ${sizeMultiplier}x multiplier...`); 123 | const result = await orderBuilder.copyTrade({ 124 | trade: payload, 125 | sizeMultiplier, 126 | maxAmount, 127 | orderType, 128 | tickSize, 129 | negRisk, 130 | }); 131 | 132 | if (result.success) { 133 | logger.success( 134 | `✅ Trade copied successfully! ` + 135 | `OrderID: ${result.orderID || "N/A"}` 136 | ); 137 | if (result.transactionHashes && result.transactionHashes.length > 0) { 138 | logger.info(` Transactions: ${result.transactionHashes.join(", ")}`); 139 | } 140 | } else { 141 | logger.error(`❌ Failed to copy trade: ${result.error}`, new Error(result.error || "Unknown error")); 142 | } 143 | } catch (error) { 144 | logger.error("Error copying trade", error); 145 | } 146 | } else if (enableCopyTrading && isCopyTradingPaused) { 147 | logger.info("⏸️ Copy trading is paused during redemption - trade not copied"); 148 | } else if (enableCopyTrading) { 149 | logger.warning("Order builder not available - trade not copied"); 150 | } 151 | } 152 | }; 153 | 154 | const onConnect = (client: RealTimeDataClient): void => { 155 | logger.success("Connected to the server"); 156 | client.subscribe({ 157 | subscriptions: [ 158 | { 159 | topic: "activity", 160 | type: "trades" 161 | }, 162 | ], 163 | }); 164 | logger.info("Subscribed to activity:trades"); 165 | }; 166 | 167 | // Create and connect client with callbacks 168 | const client = getRealTimeDataClient({ 169 | onMessage, 170 | onConnect, 171 | }); 172 | 173 | client.connect(); 174 | logger.success("Bot started successfully"); 175 | 176 | // Set up automatic redemption timer if enabled 177 | if (redeemDurationMinutes && redeemDurationMinutes > 0) { 178 | const redeemIntervalMs = redeemDurationMinutes * 60 * 1000; // Convert minutes to milliseconds 179 | 180 | logger.info(`\n⏰ Auto-redemption scheduled: Every ${redeemDurationMinutes} minutes`); 181 | logger.info(` First redemption will occur in ${redeemDurationMinutes} minutes`); 182 | 183 | // Function to perform redemption 184 | const performRedemption = async () => { 185 | try { 186 | logger.info("\n" + "=".repeat(60)); 187 | logger.info("🔄 STARTING AUTOMATIC REDEMPTION"); 188 | logger.info("=".repeat(60)); 189 | 190 | // Pause copy trading 191 | isCopyTradingPaused = true; 192 | logger.info("⏸️ Copy trading PAUSED"); 193 | 194 | // Perform redemption using token-holding.json 195 | logger.info("📋 Running redemption from token-holding.json..."); 196 | const redemptionResult = await autoRedeemResolvedMarkets({ 197 | maxRetries: 3, 198 | }); 199 | 200 | logger.info("\n📊 Redemption Summary:"); 201 | logger.info(` Total markets checked: ${redemptionResult.total}`); 202 | logger.info(` Resolved markets: ${redemptionResult.resolved}`); 203 | logger.info(` Successfully redeemed: ${redemptionResult.redeemed}`); 204 | logger.info(` Failed: ${redemptionResult.failed}`); 205 | 206 | if (redemptionResult.redeemed > 0) { 207 | logger.success(`✅ Successfully redeemed ${redemptionResult.redeemed} market(s)!`); 208 | } 209 | 210 | if (redemptionResult.failed > 0) { 211 | logger.warning(`⚠️ ${redemptionResult.failed} market(s) failed to redeem`); 212 | } 213 | 214 | logger.info("=".repeat(60)); 215 | 216 | } catch (error) { 217 | logger.error("Error during automatic redemption", error); 218 | } finally { 219 | // Resume copy trading 220 | isCopyTradingPaused = false; 221 | logger.info("▶️ Copy trading RESUMED"); 222 | logger.info("=".repeat(60) + "\n"); 223 | } 224 | }; 225 | 226 | // Run redemption immediately on first start (optional - you can remove this if you want to wait) 227 | // Uncomment the next line if you want redemption to run immediately on bot start 228 | // performRedemption(); 229 | 230 | // Set up interval to run redemption every REDEEM_DURATION minutes 231 | setInterval(performRedemption, redeemIntervalMs); 232 | 233 | logger.info(` Next redemption scheduled in ${redeemDurationMinutes} minutes`); 234 | } 235 | } 236 | 237 | main().catch((error) => { 238 | logger.error("Fatal error", error); 239 | process.exit(1); 240 | }); 241 | -------------------------------------------------------------------------------- /src/security/allowance.ts: -------------------------------------------------------------------------------- 1 | import { Zero, MaxUint256 } from "@ethersproject/constants"; 2 | import { BigNumber } from "@ethersproject/bignumber"; 3 | import { parseUnits } from "@ethersproject/units"; 4 | import { Wallet } from "@ethersproject/wallet"; 5 | import { JsonRpcProvider } from "@ethersproject/providers"; 6 | import { Contract } from "@ethersproject/contracts"; 7 | import { resolve } from "path"; 8 | import { config as dotenvConfig } from "dotenv"; 9 | import { Chain, AssetType, ClobClient } from "@polymarket/clob-client"; 10 | import { getContractConfig } from "@polymarket/clob-client"; 11 | import { logger } from "../utils/logger"; 12 | 13 | dotenvConfig({ path: resolve(process.cwd(), ".env") }); 14 | 15 | // Minimal USDC ERC20 ABI 16 | const USDC_ABI = [ 17 | "function approve(address spender, uint256 amount) external returns (bool)", 18 | "function allowance(address owner, address spender) external view returns (uint256)", 19 | ]; 20 | 21 | // Minimal ERC1155 ABI for ConditionalTokens 22 | const CTF_ABI = [ 23 | "function setApprovalForAll(address operator, bool approved) external", 24 | "function isApprovedForAll(address account, address operator) external view returns (bool)", 25 | ]; 26 | 27 | /** 28 | * Get RPC provider URL based on chain ID 29 | */ 30 | function getRpcUrl(chainId: number): string { 31 | const rpcToken = process.env.RPC_TOKEN; 32 | 33 | if (chainId === 137) { 34 | // Polygon Mainnet 35 | if (rpcToken) { 36 | return `https://polygon-mainnet.g.alchemy.com/v2/${rpcToken}`; 37 | } 38 | return "https://polygon-rpc.com"; 39 | } else if (chainId === 80002) { 40 | // Polygon Amoy Testnet 41 | if (rpcToken) { 42 | return `https://polygon-amoy.g.alchemy.com/v2/${rpcToken}`; 43 | } 44 | return "https://rpc-amoy.polygon.technology"; 45 | } 46 | 47 | throw new Error(`Unsupported chain ID: ${chainId}. Supported: 137 (Polygon), 80002 (Amoy)`); 48 | } 49 | 50 | /** 51 | * Approve USDC to Polymarket contracts (maximum allowance) 52 | * Approves USDC for both ConditionalTokens and Exchange contracts 53 | */ 54 | export async function approveUSDCAllowance(): Promise { 55 | const privateKey = process.env.PRIVATE_KEY; 56 | if (!privateKey) { 57 | throw new Error("PRIVATE_KEY not found in environment"); 58 | } 59 | 60 | const chainId = parseInt(`${process.env.CHAIN_ID || Chain.POLYGON}`) as Chain; 61 | const contractConfig = getContractConfig(chainId); 62 | 63 | // Get RPC URL and create provider 64 | const rpcUrl = getRpcUrl(chainId); 65 | const provider = new JsonRpcProvider(rpcUrl); 66 | const wallet = new Wallet(privateKey, provider); 67 | 68 | const address = await wallet.getAddress(); 69 | logger.info(`Approving USDC allowances for address: ${address}, chainId: ${chainId}`); 70 | logger.info(`USDC Contract: ${contractConfig.collateral}`); 71 | logger.info(`ConditionalTokens Contract: ${contractConfig.conditionalTokens}`); 72 | logger.info(`Exchange Contract: ${contractConfig.exchange}`); 73 | 74 | // Create USDC contract instance 75 | const usdcContract = new Contract(contractConfig.collateral, USDC_ABI, wallet); 76 | 77 | // Configure gas options 78 | let gasOptions: { gasPrice?: BigNumber; gasLimit?: number } = {}; 79 | try { 80 | const gasPrice = await provider.getGasPrice(); 81 | gasOptions = { 82 | gasPrice: gasPrice.mul(120).div(100), // 20% buffer 83 | gasLimit: 200_000, 84 | }; 85 | } catch (error) { 86 | logger.warning("Could not fetch gas price, using fallback"); 87 | gasOptions = { 88 | gasPrice: parseUnits("100", "gwei"), 89 | gasLimit: 200_000, 90 | }; 91 | } 92 | 93 | // Check and approve USDC for ConditionalTokens contract 94 | const ctfAllowance = await usdcContract.allowance(address, contractConfig.conditionalTokens); 95 | if (!ctfAllowance.eq(MaxUint256)) { 96 | logger.info(`Current CTF allowance: ${ctfAllowance.toString()}, setting to MaxUint256...`); 97 | const tx = await usdcContract.approve(contractConfig.conditionalTokens, MaxUint256, gasOptions); 98 | logger.info(`Transaction hash: ${tx.hash}`); 99 | await tx.wait(); 100 | logger.success("✅ USDC approved for ConditionalTokens contract"); 101 | } else { 102 | logger.info("✅ USDC already approved for ConditionalTokens contract (MaxUint256)"); 103 | } 104 | 105 | // Check and approve USDC for Exchange contract 106 | const exchangeAllowance = await usdcContract.allowance(address, contractConfig.exchange); 107 | if (!exchangeAllowance.eq(MaxUint256)) { 108 | logger.info(`Current Exchange allowance: ${exchangeAllowance.toString()}, setting to MaxUint256...`); 109 | const tx = await usdcContract.approve(contractConfig.exchange, MaxUint256, gasOptions); 110 | logger.info(`Transaction hash: ${tx.hash}`); 111 | await tx.wait(); 112 | logger.success("✅ USDC approved for Exchange contract"); 113 | } else { 114 | logger.info("✅ USDC already approved for Exchange contract (MaxUint256)"); 115 | } 116 | 117 | // Check and approve ConditionalTokens (ERC1155) for Exchange contract 118 | const ctfContract = new Contract(contractConfig.conditionalTokens, CTF_ABI, wallet); 119 | const isApproved = await ctfContract.isApprovedForAll(address, contractConfig.exchange); 120 | 121 | if (!isApproved) { 122 | logger.info("Approving ConditionalTokens for Exchange contract..."); 123 | const tx = await ctfContract.setApprovalForAll(contractConfig.exchange, true, gasOptions); 124 | logger.info(`Transaction hash: ${tx.hash}`); 125 | await tx.wait(); 126 | logger.success("✅ ConditionalTokens approved for Exchange contract"); 127 | } else { 128 | logger.info("✅ ConditionalTokens already approved for Exchange contract"); 129 | } 130 | 131 | // If negRisk is enabled, also approve for negRisk contracts 132 | const negRisk = process.env.NEG_RISK === "true"; 133 | if (negRisk) { 134 | // Approve USDC for NegRiskAdapter 135 | const negRiskAdapterAllowance = await usdcContract.allowance(address, contractConfig.negRiskAdapter); 136 | if (!negRiskAdapterAllowance.eq(MaxUint256)) { 137 | logger.info(`Current NegRiskAdapter allowance: ${negRiskAdapterAllowance.toString()}, setting to MaxUint256...`); 138 | const tx = await usdcContract.approve(contractConfig.negRiskAdapter, MaxUint256, gasOptions); 139 | logger.info(`Transaction hash: ${tx.hash}`); 140 | await tx.wait(); 141 | logger.success("✅ USDC approved for NegRiskAdapter"); 142 | } 143 | 144 | // Approve USDC for NegRiskExchange 145 | const negRiskExchangeAllowance = await usdcContract.allowance(address, contractConfig.negRiskExchange); 146 | if (!negRiskExchangeAllowance.eq(MaxUint256)) { 147 | logger.info(`Current NegRiskExchange allowance: ${negRiskExchangeAllowance.toString()}, setting to MaxUint256...`); 148 | const tx = await usdcContract.approve(contractConfig.negRiskExchange, MaxUint256, gasOptions); 149 | logger.info(`Transaction hash: ${tx.hash}`); 150 | await tx.wait(); 151 | logger.success("✅ USDC approved for NegRiskExchange"); 152 | } 153 | 154 | // Approve ConditionalTokens for NegRiskExchange 155 | const isNegRiskApproved = await ctfContract.isApprovedForAll(address, contractConfig.negRiskExchange); 156 | if (!isNegRiskApproved) { 157 | logger.info("Approving ConditionalTokens for NegRiskExchange..."); 158 | const tx = await ctfContract.setApprovalForAll(contractConfig.negRiskExchange, true, gasOptions); 159 | logger.info(`Transaction hash: ${tx.hash}`); 160 | await tx.wait(); 161 | logger.success("✅ ConditionalTokens approved for NegRiskExchange"); 162 | } 163 | 164 | // Approve ConditionalTokens for NegRiskAdapter 165 | const isNegRiskAdapterApproved = await ctfContract.isApprovedForAll(address, contractConfig.negRiskAdapter); 166 | if (!isNegRiskAdapterApproved) { 167 | logger.info("Approving ConditionalTokens for NegRiskAdapter..."); 168 | const tx = await ctfContract.setApprovalForAll(contractConfig.negRiskAdapter, true, gasOptions); 169 | logger.info(`Transaction hash: ${tx.hash}`); 170 | await tx.wait(); 171 | logger.success("✅ ConditionalTokens approved for NegRiskAdapter"); 172 | } 173 | } 174 | 175 | logger.success("All allowances approved successfully!"); 176 | } 177 | 178 | /** 179 | * Update balance allowance in CLOB API after setting on-chain allowances 180 | * This syncs the on-chain allowance state with the CLOB API 181 | */ 182 | export async function updateClobBalanceAllowance(client: ClobClient): Promise { 183 | try { 184 | logger.info("Updating CLOB API balance allowance for USDC..."); 185 | await client.updateBalanceAllowance({ asset_type: AssetType.COLLATERAL }); 186 | logger.success("✅ CLOB API balance allowance updated for USDC"); 187 | } catch (error) { 188 | logger.error(`Failed to update CLOB balance allowance: ${error instanceof Error ? error.message : String(error)}`); 189 | throw error; 190 | } 191 | } 192 | 193 | /** 194 | * Approve ConditionalTokens for Exchange after buying tokens 195 | * This ensures tokens are approved immediately after purchase so they can be sold without delay 196 | * Note: ERC1155 uses setApprovalForAll which approves all tokens at once (including newly bought ones) 197 | */ 198 | export async function approveTokensAfterBuy(): Promise { 199 | const privateKey = process.env.PRIVATE_KEY; 200 | if (!privateKey) { 201 | throw new Error("PRIVATE_KEY not found in environment"); 202 | } 203 | 204 | const chainId = parseInt(`${process.env.CHAIN_ID || Chain.POLYGON}`) as Chain; 205 | const contractConfig = getContractConfig(chainId); 206 | 207 | // Get RPC URL and create provider 208 | const rpcUrl = getRpcUrl(chainId); 209 | const provider = new JsonRpcProvider(rpcUrl); 210 | const wallet = new Wallet(privateKey, provider); 211 | 212 | const address = await wallet.getAddress(); 213 | const ctfContract = new Contract(contractConfig.conditionalTokens, CTF_ABI, wallet); 214 | 215 | // Configure gas options 216 | let gasOptions: { gasPrice?: BigNumber; gasLimit?: number } = {}; 217 | try { 218 | const gasPrice = await provider.getGasPrice(); 219 | gasOptions = { 220 | gasPrice: gasPrice.mul(120).div(100), // 20% buffer 221 | gasLimit: 200_000, 222 | }; 223 | } catch (error) { 224 | gasOptions = { 225 | gasPrice: parseUnits("100", "gwei"), 226 | gasLimit: 200_000, 227 | }; 228 | } 229 | 230 | // Check if ConditionalTokens are approved for Exchange 231 | const isApproved = await ctfContract.isApprovedForAll(address, contractConfig.exchange); 232 | 233 | if (!isApproved) { 234 | logger.info("Approving ConditionalTokens for Exchange (after buy)..."); 235 | const tx = await ctfContract.setApprovalForAll(contractConfig.exchange, true, gasOptions); 236 | logger.info(`Transaction hash: ${tx.hash}`); 237 | await tx.wait(); 238 | logger.success("✅ ConditionalTokens approved for Exchange"); 239 | } 240 | 241 | // If negRisk is enabled, also check negRisk contracts 242 | const negRisk = process.env.NEG_RISK === "true"; 243 | if (negRisk) { 244 | const isNegRiskApproved = await ctfContract.isApprovedForAll(address, contractConfig.negRiskExchange); 245 | if (!isNegRiskApproved) { 246 | logger.info("Approving ConditionalTokens for NegRiskExchange (after buy)..."); 247 | const tx = await ctfContract.setApprovalForAll(contractConfig.negRiskExchange, true, gasOptions); 248 | logger.info(`Transaction hash: ${tx.hash}`); 249 | await tx.wait(); 250 | logger.success("✅ ConditionalTokens approved for NegRiskExchange"); 251 | } 252 | } 253 | } 254 | 255 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Polymarket Copy Trading Bot 2 | 3 | A sophisticated, production-ready copy trading bot for Polymarket that automatically mirrors trades from target wallets in real-time. Built with TypeScript, leveraging WebSocket connections for low-latency trade execution and integrated with Polymarket's CLOB (Central Limit Order Book) API. 4 | 5 | ## 🎯 Overview 6 | 7 | This bot monitors specified wallet addresses on Polymarket and automatically replicates their trading activity with configurable parameters. It provides real-time trade copying, automatic position redemption, risk management, and comprehensive logging for production deployment. 8 | 9 | ### Key Capabilities 10 | 11 | - **Real-time Trade Mirroring**: Monitors target wallets via WebSocket and executes trades within milliseconds 12 | - **Automated Redemption**: Automatically redeems winning positions from resolved markets 13 | - **Risk Management**: Configurable size multipliers, maximum order amounts, and negative risk protection 14 | - **Order Type Flexibility**: Supports FAK (Fill-or-Kill) and FOK (Fill-or-Kill) order types 15 | - **Holdings Tracking**: Maintains local database of token holdings for efficient redemption 16 | - **Multi-market Support**: Handles binary and multi-outcome markets seamlessly 17 | 18 | ## 🏗️ Architecture 19 | 20 | ### Technology Stack 21 | 22 | - **Runtime**: Bun (TypeScript-first runtime) 23 | - **Language**: TypeScript 5.9+ 24 | - **Blockchain**: Polygon (Ethereum-compatible L2) 25 | - **APIs**: 26 | - Polymarket CLOB Client (`@polymarket/clob-client`) 27 | - Polymarket Real-Time Data Client (`@polymarket/real-time-data-client`) 28 | - **Web3**: Ethers.js v6 for blockchain interactions 29 | - **Logging**: Custom logger with structured output 30 | 31 | ### System Architecture 32 | 33 | ``` 34 | ┌─────────────────────────────────────────────────────────────┐ 35 | │ Real-Time Data Client │ 36 | │ (WebSocket Connection to Polymarket) │ 37 | └──────────────────────┬──────────────────────────────────────┘ 38 | │ 39 | ▼ 40 | ┌─────────────────────────────────────────────────────────────┐ 41 | │ Trade Monitor │ 42 | │ - Filters trades by target wallet address │ 43 | │ - Validates trade payloads │ 44 | │ - Triggers copy trade execution │ 45 | └──────────────────────┬──────────────────────────────────────┘ 46 | │ 47 | ▼ 48 | ┌─────────────────────────────────────────────────────────────┐ 49 | │ Trade Order Builder │ 50 | │ - Converts trade payloads to market orders │ 51 | │ - Applies size multipliers and risk limits │ 52 | │ - Handles order type conversion (FAK/FOK) │ 53 | │ - Manages tick size precision │ 54 | └──────────────────────┬──────────────────────────────────────┘ 55 | │ 56 | ▼ 57 | ┌─────────────────────────────────────────────────────────────┐ 58 | │ CLOB Client │ 59 | │ - Executes orders on Polymarket │ 60 | │ - Manages allowances and approvals │ 61 | │ - Tracks wallet balances │ 62 | └──────────────────────┬──────────────────────────────────────┘ 63 | │ 64 | ▼ 65 | ┌─────────────────────────────────────────────────────────────┐ 66 | │ Holdings Manager │ 67 | │ - Tracks token positions │ 68 | │ - Maintains local JSON database │ 69 | │ - Enables efficient redemption │ 70 | └──────────────────────┬──────────────────────────────────────┘ 71 | │ 72 | ▼ 73 | ┌─────────────────────────────────────────────────────────────┐ 74 | │ Redemption Engine │ 75 | │ - Monitors market resolution status │ 76 | │ - Automatically redeems winning positions │ 77 | │ - Supports scheduled and on-demand redemption │ 78 | └─────────────────────────────────────────────────────────────┘ 79 | ``` 80 | 81 | ## 📦 Installation 82 | 83 | ### Prerequisites 84 | 85 | - **Bun** runtime (v1.0+): [Install Bun](https://bun.sh) 86 | - **Node.js** 18+ (for npm package management) 87 | - **Polygon wallet** with USDC for trading 88 | - **Polymarket API credentials** (API key and secret) 89 | 90 | ### Setup 91 | 92 | 1. **Clone the repository** 93 | ```bash 94 | git clone 95 | cd polymarket-copytrading 96 | ``` 97 | 98 | 2. **Install dependencies** 99 | ```bash 100 | bun install 101 | ``` 102 | 103 | 3. **Configure environment variables** 104 | ```bash 105 | cp .env.example .env 106 | ``` 107 | 108 | Edit `.env` with your configuration: 109 | ```env 110 | # Wallet Configuration 111 | PRIVATE_KEY=your_private_key_here 112 | TARGET_WALLET=0x... # Wallet address to copy trades from 113 | 114 | # Trading Configuration 115 | SIZE_MULTIPLIER=1.0 116 | MAX_ORDER_AMOUNT=100 117 | ORDER_TYPE=FAK 118 | TICK_SIZE=0.01 119 | NEG_RISK=false 120 | ENABLE_COPY_TRADING=true 121 | 122 | # Redemption Configuration 123 | REDEEM_DURATION=60 # Minutes between auto-redemptions 124 | 125 | # API Configuration 126 | CHAIN_ID=137 # Polygon mainnet 127 | CLOB_API_URL=https://clob.polymarket.com 128 | ``` 129 | 130 | 4. **Initialize credentials** 131 | ```bash 132 | bun src/index.ts 133 | ``` 134 | The bot will automatically create API credentials on first run. 135 | 136 | ## ⚙️ Configuration 137 | 138 | ### Environment Variables 139 | 140 | | Variable | Type | Default | Description | 141 | |----------|------|---------|-------------| 142 | | `PRIVATE_KEY` | string | **required** | Private key of trading wallet | 143 | | `TARGET_WALLET` | string | **required** | Wallet address to copy trades from | 144 | | `SIZE_MULTIPLIER` | number | `1.0` | Multiplier for trade sizes (e.g., `2.0` = 2x size) | 145 | | `MAX_ORDER_AMOUNT` | number | `undefined` | Maximum USDC amount per order | 146 | | `ORDER_TYPE` | string | `FAK` | Order type: `FAK` or `FOK` | 147 | | `TICK_SIZE` | string | `0.01` | Price precision: `0.1`, `0.01`, `0.001`, `0.0001` | 148 | | `NEG_RISK` | boolean | `false` | Enable negative risk (allow negative balances) | 149 | | `ENABLE_COPY_TRADING` | boolean | `true` | Enable/disable copy trading | 150 | | `REDEEM_DURATION` | number | `null` | Minutes between auto-redemptions (null = disabled) | 151 | | `CHAIN_ID` | number | `137` | Blockchain chain ID (137 = Polygon) | 152 | | `CLOB_API_URL` | string | `https://clob.polymarket.com` | CLOB API endpoint | 153 | 154 | ### Trading Parameters 155 | 156 | - **Size Multiplier**: Scales the copied trade size. `1.0` = exact copy, `2.0` = double size, `0.5` = half size 157 | - **Max Order Amount**: Safety limit to prevent oversized positions. Orders exceeding this amount are rejected 158 | - **Order Type**: 159 | - `FAK` (Fill-and-Kill): Partial fills allowed, remaining unfilled portion cancelled 160 | - `FOK` (Fill-or-Kill): Entire order must fill immediately or cancelled 161 | - **Tick Size**: Price precision for order placement. Must match market's tick size 162 | - **Negative Risk**: When enabled, allows orders that may result in negative USDC balance 163 | 164 | ## 🚀 Usage 165 | 166 | ### Starting the Bot 167 | 168 | ```bash 169 | # Start copy trading bot 170 | bun src/index.ts 171 | 172 | # Or using npm script 173 | npm start 174 | ``` 175 | 176 | The bot will: 177 | 1. Initialize WebSocket connection to Polymarket 178 | 2. Subscribe to trade activity feed 179 | 3. Monitor target wallet for trades 180 | 4. Automatically copy trades when detected 181 | 5. Run scheduled redemptions (if enabled) 182 | 183 | ### Manual Redemption 184 | 185 | #### Redeem from Holdings File 186 | ```bash 187 | # Redeem all resolved markets from token-holding.json 188 | bun src/auto-redeem.ts 189 | 190 | # Dry run (preview only) 191 | bun src/auto-redeem.ts --dry-run 192 | 193 | # Clear holdings after redemption 194 | bun src/auto-redeem.ts --clear-holdings 195 | ``` 196 | 197 | #### Redeem from API 198 | ```bash 199 | # Fetch all markets from API and redeem winning positions 200 | bun src/auto-redeem.ts --api 201 | 202 | # Limit number of markets checked 203 | bun src/auto-redeem.ts --api --max 500 204 | ``` 205 | 206 | #### Redeem Specific Market 207 | ```bash 208 | # Check market status 209 | bun src/redeem.ts --check 210 | 211 | # Redeem specific market 212 | bun src/redeem.ts 213 | 214 | # Redeem with specific index sets 215 | bun src/redeem.ts 1 2 216 | ``` 217 | 218 | ## 🔧 Technical Details 219 | 220 | ### Trade Execution Flow 221 | 222 | 1. **Trade Detection**: WebSocket receives trade activity message 223 | 2. **Wallet Filtering**: Validates trade originates from target wallet 224 | 3. **Order Construction**: Converts trade payload to market order: 225 | - Applies size multiplier 226 | - Validates against max order amount 227 | - Adjusts price to tick size 228 | - Sets order type (FAK/FOK) 229 | 4. **Balance Validation**: Checks sufficient USDC/token balance 230 | 5. **Allowance Management**: Ensures proper token approvals 231 | 6. **Order Execution**: Submits order to CLOB API 232 | 7. **Holdings Update**: Records token positions locally 233 | 8. **Logging**: Logs all operations with structured output 234 | 235 | ### Redemption Mechanism 236 | 237 | The bot maintains a local JSON database (`src/data/token-holding.json`) tracking all token positions. When markets resolve: 238 | 239 | 1. **Resolution Check**: Queries Polymarket API for market status 240 | 2. **Winning Detection**: Identifies winning outcome tokens 241 | 3. **Balance Verification**: Confirms user holds winning tokens 242 | 4. **Redemption Execution**: Calls Polymarket redemption contract 243 | 5. **Holdings Cleanup**: Removes redeemed positions from database 244 | 245 | ### Security Features 246 | 247 | - **Credential Management**: Secure API key storage in `src/data/credential.json` 248 | - **Allowance Control**: Automatic USDC approval management 249 | - **Balance Validation**: Pre-order balance checks prevent over-trading 250 | - **Error Handling**: Comprehensive error handling with graceful degradation 251 | - **Private Key Security**: Uses environment variables (never hardcoded) 252 | 253 | ### Order Builder Logic 254 | 255 | The `TradeOrderBuilder` class handles complex order construction: 256 | 257 | ```typescript 258 | class TradeOrderBuilder { 259 | async copyTrade(options: CopyTradeOptions): Promise { 260 | // 1. Extract trade parameters 261 | // 2. Apply size multiplier 262 | // 3. Validate against max amount 263 | // 4. Convert to market order format 264 | // 5. Handle buy vs sell logic 265 | // 6. Execute order 266 | // 7. Update holdings 267 | } 268 | } 269 | ``` 270 | 271 | **Buy Orders**: 272 | - Validates USDC balance 273 | - Checks allowance and approves if needed 274 | - Places market order 275 | - Records token holdings 276 | 277 | **Sell Orders**: 278 | - Validates token holdings 279 | - Checks available balance (accounting for open orders) 280 | - Places market order 281 | - Updates holdings after execution 282 | 283 | ## 📁 Project Structure 284 | 285 | ``` 286 | polymarket-copytrading/ 287 | ├── src/ 288 | │ ├── index.ts # Main bot entry point 289 | │ ├── auto-redeem.ts # Automated redemption script 290 | │ ├── redeem.ts # Manual redemption script 291 | │ ├── data/ # Data storage 292 | │ │ ├── credential.json # API credentials (auto-generated) 293 | │ │ └── token-holding.json # Token holdings database 294 | │ ├── order-builder/ # Order construction logic 295 | │ │ ├── builder.ts # TradeOrderBuilder class 296 | │ │ ├── helpers.ts # Order conversion utilities 297 | │ │ └── types.ts # Type definitions 298 | │ ├── providers/ # API clients 299 | │ │ ├── clobclient.ts # CLOB API client 300 | │ │ ├── wssProvider.ts # WebSocket provider 301 | │ │ └── rpcProvider.ts # RPC provider 302 | │ ├── security/ # Security utilities 303 | │ │ ├── allowance.ts # Token approval management 304 | │ │ └── createCredential.ts # Credential generation 305 | │ └── utils/ # Utility functions 306 | │ ├── balance.ts # Balance checking 307 | │ ├── holdings.ts # Holdings management 308 | │ ├── logger.ts # Logging utility 309 | │ ├── redeem.ts # Redemption logic 310 | │ └── types.ts # TypeScript types 311 | ├── package.json 312 | ├── tsconfig.json 313 | └── README.md 314 | ``` 315 | 316 | ## 🔌 API Integration 317 | 318 | ### Polymarket CLOB Client 319 | 320 | The bot uses the official `@polymarket/clob-client` for order execution: 321 | 322 | ```typescript 323 | import { ClobClient, OrderType, Side } from "@polymarket/clob-client"; 324 | 325 | const client = await getClobClient(); 326 | const order = await client.createOrder({ 327 | token_id: tokenId, 328 | side: Side.BUY, 329 | price: price, 330 | size: size, 331 | order_type: OrderType.FAK, 332 | }); 333 | ``` 334 | 335 | ### Real-Time Data Client 336 | 337 | WebSocket connection for live trade monitoring: 338 | 339 | ```typescript 340 | import { RealTimeDataClient } from "@polymarket/real-time-data-client"; 341 | 342 | client.subscribe({ 343 | subscriptions: [{ 344 | topic: "activity", 345 | type: "trades" 346 | }] 347 | }); 348 | ``` 349 | 350 | ## 📊 Monitoring & Logging 351 | 352 | The bot provides comprehensive logging: 353 | 354 | - **Trade Detection**: Logs all detected trades from target wallet 355 | - **Order Execution**: Records order placement and results 356 | - **Redemption Activity**: Tracks redemption operations 357 | - **Error Handling**: Detailed error messages with stack traces 358 | - **Balance Updates**: Displays wallet balances after operations 359 | 360 | Log levels: 361 | - `info`: General operational messages 362 | - `success`: Successful operations 363 | - `warning`: Non-critical issues 364 | - `error`: Errors requiring attention 365 | 366 | ## ⚠️ Risk Considerations 367 | 368 | 1. **Market Risk**: Copy trading amplifies both gains and losses 369 | 2. **Liquidity Risk**: Large orders may not fill completely 370 | 3. **Slippage**: Market orders execute at current market price 371 | 4. **Gas Costs**: Each transaction incurs Polygon gas fees 372 | 5. **API Limits**: Rate limiting may affect order execution 373 | 6. **Network Latency**: WebSocket delays may cause missed trades 374 | 375 | **Recommendations**: 376 | - Start with small size multipliers 377 | - Set conservative max order amounts 378 | - Monitor wallet balance regularly 379 | - Use dry-run mode for testing 380 | - Test with small amounts before scaling 381 | 382 | ## 🛠️ Development 383 | 384 | ### Building 385 | 386 | ```bash 387 | # Type checking 388 | bun run tsc --noEmit 389 | 390 | # Run in development 391 | bun --watch src/index.ts 392 | ``` 393 | 394 | ### Testing 395 | 396 | ```bash 397 | # Test redemption (dry run) 398 | bun src/auto-redeem.ts --dry-run 399 | 400 | # Test specific market 401 | bun src/redeem.ts --check 402 | ``` 403 | 404 | ## 📝 License 405 | 406 | ISC 407 | 408 | ## 🤝 Contributing 409 | 410 | Contributions welcome! Please ensure: 411 | - Code follows TypeScript best practices 412 | - All functions are properly typed 413 | - Error handling is comprehensive 414 | - Logging is informative 415 | - Documentation is updated 416 | 417 | ## 📞 Support 418 | 419 | For issues, questions, or contributions: 420 | - Open an issue on GitHub 421 | - Review existing documentation 422 | - Check Polymarket API documentation 423 | 424 | --- 425 | 426 | **Disclaimer**: This software is provided as-is. Trading cryptocurrencies and prediction markets carries significant risk. Use at your own discretion and never trade more than you can afford to lose. 427 | 428 | -------------------------------------------------------------------------------- /src/utils/redeem.ts: -------------------------------------------------------------------------------- 1 | import { BigNumber } from "@ethersproject/bignumber"; 2 | import { hexZeroPad } from "@ethersproject/bytes"; 3 | import { Wallet } from "@ethersproject/wallet"; 4 | import { JsonRpcProvider } from "@ethersproject/providers"; 5 | import { Contract } from "@ethersproject/contracts"; 6 | import { resolve } from "path"; 7 | import { config as dotenvConfig } from "dotenv"; 8 | import { Chain, getContractConfig } from "@polymarket/clob-client"; 9 | import { logger } from "./logger"; 10 | import { getClobClient } from "../providers/clobclient"; 11 | 12 | dotenvConfig({ path: resolve(process.cwd(), ".env") }); 13 | 14 | // CTF Contract ABI - functions needed for redemption and checking resolution 15 | const CTF_ABI = [ 16 | { 17 | constant: false, 18 | inputs: [ 19 | { 20 | name: "collateralToken", 21 | type: "address", 22 | }, 23 | { 24 | name: "parentCollectionId", 25 | type: "bytes32", 26 | }, 27 | { 28 | name: "conditionId", 29 | type: "bytes32", 30 | }, 31 | { 32 | name: "indexSets", 33 | type: "uint256[]", 34 | }, 35 | ], 36 | name: "redeemPositions", 37 | outputs: [], 38 | payable: false, 39 | stateMutability: "nonpayable", 40 | type: "function", 41 | }, 42 | { 43 | constant: true, 44 | inputs: [ 45 | { 46 | name: "", 47 | type: "bytes32", 48 | }, 49 | { 50 | name: "", 51 | type: "uint256", 52 | }, 53 | ], 54 | name: "payoutNumerators", 55 | outputs: [ 56 | { 57 | name: "", 58 | type: "uint256", 59 | }, 60 | ], 61 | payable: false, 62 | stateMutability: "view", 63 | type: "function", 64 | }, 65 | { 66 | constant: true, 67 | inputs: [ 68 | { 69 | name: "", 70 | type: "bytes32", 71 | }, 72 | ], 73 | name: "payoutDenominator", 74 | outputs: [ 75 | { 76 | name: "", 77 | type: "uint256", 78 | }, 79 | ], 80 | payable: false, 81 | stateMutability: "view", 82 | type: "function", 83 | }, 84 | { 85 | constant: true, 86 | inputs: [ 87 | { 88 | name: "conditionId", 89 | type: "bytes32", 90 | }, 91 | ], 92 | name: "getOutcomeSlotCount", 93 | outputs: [ 94 | { 95 | name: "", 96 | type: "uint256", 97 | }, 98 | ], 99 | payable: false, 100 | stateMutability: "view", 101 | type: "function", 102 | }, 103 | { 104 | constant: true, 105 | inputs: [ 106 | { 107 | name: "owner", 108 | type: "address", 109 | }, 110 | { 111 | name: "id", 112 | type: "uint256", 113 | }, 114 | ], 115 | name: "balanceOf", 116 | outputs: [ 117 | { 118 | name: "", 119 | type: "uint256", 120 | }, 121 | ], 122 | payable: false, 123 | stateMutability: "view", 124 | type: "function", 125 | }, 126 | { 127 | constant: true, 128 | inputs: [ 129 | { 130 | name: "parentCollectionId", 131 | type: "bytes32", 132 | }, 133 | { 134 | name: "conditionId", 135 | type: "bytes32", 136 | }, 137 | { 138 | name: "indexSet", 139 | type: "uint256", 140 | }, 141 | ], 142 | name: "getCollectionId", 143 | outputs: [ 144 | { 145 | name: "", 146 | type: "bytes32", 147 | }, 148 | ], 149 | payable: false, 150 | stateMutability: "view", 151 | type: "function", 152 | }, 153 | { 154 | constant: true, 155 | inputs: [ 156 | { 157 | name: "collateralToken", 158 | type: "address", 159 | }, 160 | { 161 | name: "collectionId", 162 | type: "bytes32", 163 | }, 164 | ], 165 | name: "getPositionId", 166 | outputs: [ 167 | { 168 | name: "", 169 | type: "uint256", 170 | }, 171 | ], 172 | payable: false, 173 | stateMutability: "pure", 174 | type: "function", 175 | }, 176 | ]; 177 | 178 | /** 179 | * Get RPC provider URL based on chain ID 180 | */ 181 | function getRpcUrl(chainId: number): string { 182 | const rpcToken = process.env.RPC_TOKEN; 183 | 184 | if (chainId === 137) { 185 | // Polygon Mainnet 186 | if (rpcToken) { 187 | return `https://polygon-mainnet.g.alchemy.com/v2/${rpcToken}`; 188 | } 189 | return "https://polygon-rpc.com"; 190 | } else if (chainId === 80002) { 191 | // Polygon Amoy Testnet 192 | if (rpcToken) { 193 | return `https://polygon-amoy.g.alchemy.com/v2/${rpcToken}`; 194 | } 195 | return "https://rpc-amoy.polygon.technology"; 196 | } 197 | 198 | throw new Error(`Unsupported chain ID: ${chainId}. Supported: 137 (Polygon), 80002 (Amoy)`); 199 | } 200 | 201 | /** 202 | * Options for redeeming positions 203 | */ 204 | export interface RedeemOptions { 205 | /** The condition ID (market ID) to redeem from */ 206 | conditionId: string; 207 | /** Array of index sets to redeem (default: [1, 2] for Polymarket binary markets) */ 208 | indexSets?: number[]; 209 | /** Optional: Chain ID (defaults to Chain.POLYGON) */ 210 | chainId?: Chain; 211 | } 212 | 213 | /** 214 | * Redeem conditional tokens for collateral after a market resolves 215 | * 216 | * This function calls the redeemPositions function on the Conditional Tokens Framework (CTF) contract 217 | * to redeem winning outcome tokens for their underlying collateral (USDC). 218 | * 219 | * For Polymarket binary markets, indexSets should be [1, 2] to redeem both YES and NO outcomes. 220 | * 221 | * @param options - Redeem options including conditionId and optional indexSets 222 | * @returns Transaction receipt 223 | * 224 | * @example 225 | * ```typescript 226 | * // Redeem a resolved market (conditionId) for both outcomes [1, 2] 227 | * const receipt = await redeemPositions({ 228 | * conditionId: "0x5f65177b394277fd294cd75650044e32ba009a95022d88a0c1d565897d72f8f1", 229 | * indexSets: [1, 2], // Both YES and NO outcomes (default) 230 | * }); 231 | * ``` 232 | */ 233 | export async function redeemPositions(options: RedeemOptions): Promise { 234 | const privateKey = process.env.PRIVATE_KEY; 235 | if (!privateKey) { 236 | throw new Error("PRIVATE_KEY not found in environment"); 237 | } 238 | 239 | const chainId = options.chainId || parseInt(`${process.env.CHAIN_ID || Chain.POLYGON}`) as Chain; 240 | const contractConfig = getContractConfig(chainId); 241 | 242 | // Get RPC URL and create provider 243 | const rpcUrl = getRpcUrl(chainId); 244 | const provider = new JsonRpcProvider(rpcUrl); 245 | const wallet = new Wallet(privateKey, provider); 246 | 247 | const address = await wallet.getAddress(); 248 | 249 | // Default index sets for Polymarket binary markets: [1, 2] (YES and NO) 250 | const indexSets = options.indexSets || [1, 2]; 251 | 252 | // Parent collection ID is always bytes32(0) for Polymarket 253 | const parentCollectionId = "0x0000000000000000000000000000000000000000000000000000000000000000"; 254 | 255 | // Convert conditionId to bytes32 format 256 | let conditionIdBytes32: string; 257 | if (options.conditionId.startsWith("0x")) { 258 | // Already a hex string, ensure it's exactly 32 bytes (66 chars with 0x prefix) 259 | conditionIdBytes32 = hexZeroPad(options.conditionId, 32); 260 | } else { 261 | // If it's a decimal string, convert to hex and pad to 32 bytes 262 | const bn = BigNumber.from(options.conditionId); 263 | conditionIdBytes32 = hexZeroPad(bn.toHexString(), 32); 264 | } 265 | 266 | // Create CTF contract instance 267 | const ctfContract = new Contract( 268 | contractConfig.conditionalTokens, 269 | CTF_ABI, 270 | wallet 271 | ); 272 | 273 | logger.info("\n=== REDEEMING POSITIONS ==="); 274 | logger.info(`Condition ID: ${conditionIdBytes32}`); 275 | logger.info(`Index Sets: ${indexSets.join(", ")}`); 276 | logger.info(`Collateral Token: ${contractConfig.collateral}`); 277 | logger.info(`Parent Collection ID: ${parentCollectionId}`); 278 | logger.info(`Wallet: ${address}`); 279 | 280 | // Configure gas options 281 | let gasOptions: { gasPrice?: BigNumber; gasLimit?: number } = {}; 282 | try { 283 | const gasPrice = await provider.getGasPrice(); 284 | gasOptions = { 285 | gasPrice: gasPrice.mul(120).div(100), // 20% buffer 286 | gasLimit: 500_000, 287 | }; 288 | } catch (error) { 289 | gasOptions = { 290 | gasPrice: BigNumber.from("100000000000"), // 100 gwei 291 | gasLimit: 500_000, 292 | }; 293 | } 294 | 295 | try { 296 | // Call redeemPositions 297 | logger.info("Calling redeemPositions on CTF contract..."); 298 | const tx = await ctfContract.redeemPositions( 299 | contractConfig.collateral, 300 | parentCollectionId, 301 | conditionIdBytes32, 302 | indexSets, 303 | gasOptions 304 | ); 305 | 306 | logger.info(`Transaction sent: ${tx.hash}`); 307 | logger.info("Waiting for confirmation..."); 308 | 309 | // Wait for transaction to be mined 310 | const receipt = await tx.wait(); 311 | 312 | logger.success(`Transaction confirmed in block ${receipt.blockNumber}`); 313 | logger.info(`Gas used: ${receipt.gasUsed.toString()}`); 314 | logger.success("\n=== REDEEM COMPLETE ==="); 315 | 316 | return receipt; 317 | } catch (error: any) { 318 | logger.error("Failed to redeem positions", error); 319 | if (error.reason) { 320 | logger.error("Reason", error.reason); 321 | } 322 | if (error.data) { 323 | logger.error("Data", error.data); 324 | } 325 | throw error; 326 | } 327 | } 328 | 329 | /** 330 | * Retry a function with exponential backoff 331 | * 332 | * @param fn - Function to retry 333 | * @param maxRetries - Maximum number of retries (default: 3) 334 | * @param delayMs - Initial delay in milliseconds (default: 1000) 335 | * @returns Result of the function 336 | */ 337 | async function retryWithBackoff( 338 | fn: () => Promise, 339 | maxRetries: number = 3, 340 | delayMs: number = 1000 341 | ): Promise { 342 | let lastError: Error | unknown; 343 | 344 | for (let attempt = 1; attempt <= maxRetries; attempt++) { 345 | try { 346 | return await fn(); 347 | } catch (error) { 348 | lastError = error; 349 | const errorMsg = error instanceof Error ? error.message : String(error); 350 | 351 | // Check if error is retryable (RPC errors, network errors, etc.) 352 | const isRetryable = 353 | errorMsg.includes("network") || 354 | errorMsg.includes("timeout") || 355 | errorMsg.includes("ECONNREFUSED") || 356 | errorMsg.includes("ETIMEDOUT") || 357 | errorMsg.includes("RPC") || 358 | errorMsg.includes("rate limit") || 359 | errorMsg.includes("nonce") || 360 | errorMsg.includes("replacement transaction") || 361 | errorMsg.includes("already known") || 362 | errorMsg.includes("503") || 363 | errorMsg.includes("502") || 364 | errorMsg.includes("504") || 365 | errorMsg.includes("connection") || 366 | errorMsg.includes("socket") || 367 | errorMsg.includes("ECONNRESET"); 368 | 369 | // Don't retry on permanent errors 370 | if (!isRetryable) { 371 | throw error; 372 | } 373 | 374 | // If this is the last attempt, throw the error 375 | if (attempt === maxRetries) { 376 | throw error; 377 | } 378 | 379 | // Calculate delay with exponential backoff 380 | const delay = delayMs * Math.pow(2, attempt - 1); 381 | logger.warning(`Attempt ${attempt}/${maxRetries} failed: ${errorMsg}. Retrying in ${delay}ms...`); 382 | await new Promise(resolve => setTimeout(resolve, delay)); 383 | } 384 | } 385 | 386 | throw lastError; 387 | } 388 | 389 | /** 390 | * Redeem positions for a specific condition with manually specified index sets 391 | * 392 | * NOTE: For automatic redemption of winning outcomes only, use redeemMarket() instead. 393 | * This function allows you to manually specify which indexSets to redeem. 394 | * 395 | * @param conditionId - The condition ID (market ID) to redeem from 396 | * @param indexSets - Array of indexSets to redeem (e.g., [1, 2] for both outcomes) 397 | * @param chainId - Optional chain ID (defaults to Chain.POLYGON) 398 | * @returns Transaction receipt 399 | */ 400 | export async function redeemPositionsDefault( 401 | conditionId: string, 402 | chainId?: Chain, 403 | indexSets: number[] = [1, 2] // Default to both outcomes for Polymarket binary markets 404 | ): Promise { 405 | return redeemPositions({ 406 | conditionId, 407 | indexSets, 408 | chainId, 409 | }); 410 | } 411 | 412 | /** 413 | * Redeem winning positions for a specific market (conditionId) 414 | * This function automatically determines which outcomes won and only redeems those 415 | * that the user actually holds tokens for. 416 | * 417 | * Includes retry logic for RPC/network errors (3 attempts by default). 418 | * 419 | * @param conditionId - The condition ID (market ID) to redeem from 420 | * @param chainId - Optional chain ID (defaults to Chain.POLYGON) 421 | * @param maxRetries - Maximum retry attempts for RPC/network errors (default: 3) 422 | * @returns Transaction receipt 423 | */ 424 | export async function redeemMarket( 425 | conditionId: string, 426 | chainId?: Chain, 427 | maxRetries: number = 3 428 | ): Promise { 429 | const privateKey = process.env.PRIVATE_KEY; 430 | if (!privateKey) { 431 | throw new Error("PRIVATE_KEY not found in environment"); 432 | } 433 | 434 | const chainIdValue = chainId || parseInt(`${process.env.CHAIN_ID || Chain.POLYGON}`) as Chain; 435 | const contractConfig = getContractConfig(chainIdValue); 436 | 437 | // Get RPC URL and create provider 438 | const rpcUrl = getRpcUrl(chainIdValue); 439 | const provider = new JsonRpcProvider(rpcUrl); 440 | const wallet = new Wallet(privateKey, provider); 441 | const walletAddress = await wallet.getAddress(); 442 | 443 | logger.info("\n=== CHECKING MARKET RESOLUTION ==="); 444 | 445 | // Check if condition is resolved and get winning outcomes 446 | const resolution = await checkConditionResolution(conditionId, chainIdValue); 447 | 448 | if (!resolution.isResolved) { 449 | throw new Error(`Market is not yet resolved. ${resolution.reason}`); 450 | } 451 | 452 | if (resolution.winningIndexSets.length === 0) { 453 | throw new Error("Condition is resolved but no winning outcomes found"); 454 | } 455 | 456 | logger.info(`Winning indexSets: ${resolution.winningIndexSets.join(", ")}`); 457 | 458 | // Get user's token balances for this condition 459 | logger.info("Checking your token balances..."); 460 | const userBalances = await getUserTokenBalances(conditionId, walletAddress, chainIdValue); 461 | 462 | if (userBalances.size === 0) { 463 | throw new Error("You don't have any tokens for this condition to redeem"); 464 | } 465 | 466 | // Filter to only winning indexSets that user actually holds 467 | const redeemableIndexSets = resolution.winningIndexSets.filter(indexSet => { 468 | const balance = userBalances.get(indexSet); 469 | return balance && !balance.isZero(); 470 | }); 471 | 472 | if (redeemableIndexSets.length === 0) { 473 | const heldIndexSets = Array.from(userBalances.keys()); 474 | throw new Error( 475 | `You don't hold any winning tokens. ` + 476 | `You hold: ${heldIndexSets.join(", ")}, ` + 477 | `Winners: ${resolution.winningIndexSets.join(", ")}` 478 | ); 479 | } 480 | 481 | // Log what will be redeemed 482 | logger.info(`\nYou hold winning tokens for indexSets: ${redeemableIndexSets.join(", ")}`); 483 | for (const indexSet of redeemableIndexSets) { 484 | const balance = userBalances.get(indexSet); 485 | logger.info(` IndexSet ${indexSet}: ${balance?.toString() || "0"} tokens`); 486 | } 487 | 488 | // Redeem only the winning outcomes user holds 489 | logger.info(`\nRedeeming winning positions: ${redeemableIndexSets.join(", ")}`); 490 | 491 | // Use retry logic for redemption (handles RPC/network errors) 492 | return retryWithBackoff( 493 | async () => { 494 | return await redeemPositions({ 495 | conditionId, 496 | indexSets: redeemableIndexSets, 497 | chainId: chainIdValue, 498 | }); 499 | }, 500 | maxRetries, 501 | 2000 // 2 second initial delay, then 4s, 8s 502 | ); 503 | } 504 | 505 | /** 506 | * Check condition resolution status using CTF contract 507 | * 508 | * @param conditionId - The condition ID (market ID) to check 509 | * @param chainId - Optional chain ID 510 | * @returns Object with resolution status and winning indexSets 511 | */ 512 | export async function checkConditionResolution( 513 | conditionId: string, 514 | chainId?: Chain 515 | ): Promise<{ 516 | isResolved: boolean; 517 | winningIndexSets: number[]; 518 | payoutDenominator: BigNumber; 519 | payoutNumerators: BigNumber[]; 520 | outcomeSlotCount: number; 521 | reason?: string; 522 | }> { 523 | const privateKey = process.env.PRIVATE_KEY; 524 | if (!privateKey) { 525 | throw new Error("PRIVATE_KEY not found in environment"); 526 | } 527 | 528 | const chainIdValue = chainId || parseInt(`${process.env.CHAIN_ID || Chain.POLYGON}`) as Chain; 529 | const contractConfig = getContractConfig(chainIdValue); 530 | 531 | // Get RPC URL and create provider 532 | const rpcUrl = getRpcUrl(chainIdValue); 533 | const provider = new JsonRpcProvider(rpcUrl); 534 | const wallet = new Wallet(privateKey, provider); 535 | 536 | // Convert conditionId to bytes32 format 537 | let conditionIdBytes32: string; 538 | if (conditionId.startsWith("0x")) { 539 | conditionIdBytes32 = hexZeroPad(conditionId, 32); 540 | } else { 541 | const bn = BigNumber.from(conditionId); 542 | conditionIdBytes32 = hexZeroPad(bn.toHexString(), 32); 543 | } 544 | 545 | // Create CTF contract instance 546 | const ctfContract = new Contract( 547 | contractConfig.conditionalTokens, 548 | CTF_ABI, 549 | wallet 550 | ); 551 | 552 | try { 553 | // Get outcome slot count (usually 2 for binary markets) 554 | const outcomeSlotCount = (await ctfContract.getOutcomeSlotCount(conditionIdBytes32)).toNumber(); 555 | 556 | // Check payout denominator - if > 0, condition is resolved 557 | const payoutDenominator = await ctfContract.payoutDenominator(conditionIdBytes32); 558 | const isResolved = !payoutDenominator.isZero(); 559 | 560 | let winningIndexSets: number[] = []; 561 | let payoutNumerators: BigNumber[] = []; 562 | 563 | if (isResolved) { 564 | // Get payout numerators for each outcome 565 | payoutNumerators = []; 566 | for (let i = 0; i < outcomeSlotCount; i++) { 567 | const numerator = await ctfContract.payoutNumerators(conditionIdBytes32, i); 568 | payoutNumerators.push(numerator); 569 | 570 | // If numerator > 0, this outcome won (indexSet is i+1, as indexSets are 1-indexed) 571 | if (!numerator.isZero()) { 572 | winningIndexSets.push(i + 1); 573 | } 574 | } 575 | 576 | logger.info(`Condition resolved. Winning indexSets: ${winningIndexSets.join(", ")}`); 577 | } else { 578 | logger.info("Condition not yet resolved"); 579 | } 580 | 581 | return { 582 | isResolved, 583 | winningIndexSets, 584 | payoutDenominator, 585 | payoutNumerators, 586 | outcomeSlotCount, 587 | reason: isResolved 588 | ? `Condition resolved. Winning outcomes: ${winningIndexSets.join(", ")}` 589 | : "Condition not yet resolved", 590 | }; 591 | } catch (error) { 592 | const errorMsg = error instanceof Error ? error.message : String(error); 593 | logger.error("Failed to check condition resolution", error); 594 | return { 595 | isResolved: false, 596 | winningIndexSets: [], 597 | payoutDenominator: BigNumber.from(0), 598 | payoutNumerators: [], 599 | outcomeSlotCount: 0, 600 | reason: `Error checking resolution: ${errorMsg}`, 601 | }; 602 | } 603 | } 604 | 605 | /** 606 | * Get user's token balances for a specific condition 607 | * 608 | * @param conditionId - The condition ID (market ID) 609 | * @param walletAddress - User's wallet address 610 | * @param chainId - Optional chain ID 611 | * @returns Map of indexSet -> token balance 612 | */ 613 | export async function getUserTokenBalances( 614 | conditionId: string, 615 | walletAddress: string, 616 | chainId?: Chain 617 | ): Promise> { 618 | const privateKey = process.env.PRIVATE_KEY; 619 | if (!privateKey) { 620 | throw new Error("PRIVATE_KEY not found in environment"); 621 | } 622 | 623 | const chainIdValue = chainId || parseInt(`${process.env.CHAIN_ID || Chain.POLYGON}`) as Chain; 624 | const contractConfig = getContractConfig(chainIdValue); 625 | 626 | // Get RPC URL and create provider 627 | const rpcUrl = getRpcUrl(chainIdValue); 628 | const provider = new JsonRpcProvider(rpcUrl); 629 | const wallet = new Wallet(privateKey, provider); 630 | 631 | // Convert conditionId to bytes32 format 632 | let conditionIdBytes32: string; 633 | if (conditionId.startsWith("0x")) { 634 | conditionIdBytes32 = hexZeroPad(conditionId, 32); 635 | } else { 636 | const bn = BigNumber.from(conditionId); 637 | conditionIdBytes32 = hexZeroPad(bn.toHexString(), 32); 638 | } 639 | 640 | // Create CTF contract instance 641 | const ctfContract = new Contract( 642 | contractConfig.conditionalTokens, 643 | CTF_ABI, 644 | wallet 645 | ); 646 | 647 | const balances = new Map(); 648 | const parentCollectionId = "0x0000000000000000000000000000000000000000000000000000000000000000"; 649 | 650 | try { 651 | // Get outcome slot count 652 | const outcomeSlotCount = (await ctfContract.getOutcomeSlotCount(conditionIdBytes32)).toNumber(); 653 | 654 | // Check balance for each indexSet (1-indexed) 655 | for (let i = 1; i <= outcomeSlotCount; i++) { 656 | try { 657 | // Get collection ID for this indexSet 658 | const collectionId = await ctfContract.getCollectionId( 659 | parentCollectionId, 660 | conditionIdBytes32, 661 | i 662 | ); 663 | 664 | // Get position ID (token ID) 665 | const positionId = await ctfContract.getPositionId( 666 | contractConfig.collateral, 667 | collectionId 668 | ); 669 | 670 | // Get balance 671 | const balance = await ctfContract.balanceOf(walletAddress, positionId); 672 | if (!balance.isZero()) { 673 | balances.set(i, balance); 674 | } 675 | } catch (error) { 676 | // Skip if error (might not have tokens for this outcome) 677 | continue; 678 | } 679 | } 680 | } catch (error) { 681 | logger.error("Failed to get user token balances", error); 682 | } 683 | 684 | return balances; 685 | } 686 | 687 | /** 688 | * Check if a market is resolved and ready for redemption 689 | * 690 | * @param conditionId - The condition ID (market ID) to check 691 | * @returns Object with isResolved flag and market info 692 | */ 693 | export async function isMarketResolved(conditionId: string): Promise<{ 694 | isResolved: boolean; 695 | market?: any; 696 | reason?: string; 697 | winningIndexSets?: number[]; 698 | }> { 699 | try { 700 | // First check CTF contract for resolution status 701 | const resolution = await checkConditionResolution(conditionId); 702 | 703 | if (resolution.isResolved) { 704 | // Also get market info from API for context 705 | try { 706 | const clobClient = await getClobClient(); 707 | const market = await clobClient.getMarket(conditionId); 708 | return { 709 | isResolved: true, 710 | market, 711 | winningIndexSets: resolution.winningIndexSets, 712 | reason: `Market resolved. Winning outcomes: ${resolution.winningIndexSets.join(", ")}`, 713 | }; 714 | } catch (apiError) { 715 | // If API fails, still return resolution status from contract 716 | return { 717 | isResolved: true, 718 | winningIndexSets: resolution.winningIndexSets, 719 | reason: `Market resolved (checked via CTF contract). Winning outcomes: ${resolution.winningIndexSets.join(", ")}`, 720 | }; 721 | } 722 | } else { 723 | // Check API for market status 724 | try { 725 | const clobClient = await getClobClient(); 726 | const market = await clobClient.getMarket(conditionId); 727 | 728 | if (!market) { 729 | return { 730 | isResolved: false, 731 | reason: "Market not found", 732 | }; 733 | } 734 | 735 | const isActive = market.active !== false; 736 | const hasOutcome = market.resolved !== false && market.outcome !== null && market.outcome !== undefined; 737 | 738 | return { 739 | isResolved: false, 740 | market, 741 | reason: isActive 742 | ? "Market still active" 743 | : "Market ended but outcome not reported yet", 744 | }; 745 | } catch (apiError) { 746 | return { 747 | isResolved: false, 748 | reason: resolution.reason || "Market not resolved", 749 | }; 750 | } 751 | } 752 | } catch (error) { 753 | const errorMsg = error instanceof Error ? error.message : String(error); 754 | logger.error("Failed to check market status", error); 755 | return { 756 | isResolved: false, 757 | reason: `Error checking market: ${errorMsg}`, 758 | }; 759 | } 760 | } 761 | 762 | /** 763 | * Automatically redeem all resolved markets from holdings 764 | * 765 | * This function: 766 | * 1. Gets all markets from holdings 767 | * 2. Checks if each market is resolved 768 | * 3. Redeems resolved markets with retry logic (3 attempts for RPC/network errors) 769 | * 4. Optionally clears holdings after successful redemption 770 | * 771 | * @param options - Options for auto-redemption 772 | * @returns Summary of redemption results 773 | */ 774 | export async function autoRedeemResolvedMarkets(options?: { 775 | clearHoldingsAfterRedeem?: boolean; 776 | dryRun?: boolean; 777 | maxRetries?: number; // Max retries per redemption (default: 3) 778 | }): Promise<{ 779 | total: number; 780 | resolved: number; 781 | redeemed: number; 782 | failed: number; 783 | results: Array<{ 784 | conditionId: string; 785 | isResolved: boolean; 786 | redeemed: boolean; 787 | error?: string; 788 | }>; 789 | }> { 790 | const { getAllHoldings } = await import("./holdings"); 791 | const holdings = getAllHoldings(); 792 | 793 | const marketIds = Object.keys(holdings); 794 | const results: Array<{ 795 | conditionId: string; 796 | isResolved: boolean; 797 | redeemed: boolean; 798 | error?: string; 799 | }> = []; 800 | 801 | let resolvedCount = 0; 802 | let redeemedCount = 0; 803 | let failedCount = 0; 804 | 805 | logger.info(`\n=== AUTO-REDEEM: Checking ${marketIds.length} markets ===`); 806 | 807 | for (const conditionId of marketIds) { 808 | try { 809 | // Check if market is resolved 810 | const { isResolved, reason } = await isMarketResolved(conditionId); 811 | 812 | if (isResolved) { 813 | resolvedCount++; 814 | 815 | if (options?.dryRun) { 816 | logger.info(`[DRY RUN] Would redeem: ${conditionId}`); 817 | results.push({ 818 | conditionId, 819 | isResolved: true, 820 | redeemed: false, 821 | }); 822 | } else { 823 | const maxRetries = options?.maxRetries || 3; 824 | 825 | try { 826 | // Redeem the market with retry logic 827 | logger.info(`\nRedeeming resolved market: ${conditionId}`); 828 | 829 | await retryWithBackoff( 830 | async () => { 831 | await redeemMarket(conditionId); 832 | }, 833 | maxRetries, 834 | 2000 // 2 second initial delay, then 4s, 8s 835 | ); 836 | 837 | redeemedCount++; 838 | logger.success(`✅ Successfully redeemed ${conditionId}`); 839 | 840 | // Automatically clear holdings after successful redemption 841 | // (tokens have been redeemed, so they're no longer in holdings) 842 | try { 843 | const { clearMarketHoldings } = await import("./holdings"); 844 | clearMarketHoldings(conditionId); 845 | logger.info(`Cleared holdings record for ${conditionId} from token-holding.json`); 846 | } catch (clearError) { 847 | logger.warning(`Failed to clear holdings for ${conditionId}: ${clearError instanceof Error ? clearError.message : String(clearError)}`); 848 | // Don't fail the redemption if clearing holdings fails 849 | } 850 | 851 | results.push({ 852 | conditionId, 853 | isResolved: true, 854 | redeemed: true, 855 | }); 856 | } catch (error) { 857 | failedCount++; 858 | const errorMsg = error instanceof Error ? error.message : String(error); 859 | logger.error(`Failed to redeem ${conditionId} after ${maxRetries} attempts`, error); 860 | results.push({ 861 | conditionId, 862 | isResolved: true, 863 | redeemed: false, 864 | error: errorMsg, 865 | }); 866 | } 867 | } 868 | } else { 869 | logger.info(`Market ${conditionId} not resolved: ${reason}`); 870 | results.push({ 871 | conditionId, 872 | isResolved: false, 873 | redeemed: false, 874 | error: reason, 875 | }); 876 | } 877 | } catch (error) { 878 | failedCount++; 879 | const errorMsg = error instanceof Error ? error.message : String(error); 880 | logger.error(`Error processing ${conditionId}`, error); 881 | results.push({ 882 | conditionId, 883 | isResolved: false, 884 | redeemed: false, 885 | error: errorMsg, 886 | }); 887 | } 888 | } 889 | 890 | logger.info(`\n=== AUTO-REDEEM SUMMARY ===`); 891 | logger.info(`Total markets: ${marketIds.length}`); 892 | logger.info(`Resolved: ${resolvedCount}`); 893 | logger.info(`Redeemed: ${redeemedCount}`); 894 | logger.info(`Failed: ${failedCount}`); 895 | 896 | return { 897 | total: marketIds.length, 898 | resolved: resolvedCount, 899 | redeemed: redeemedCount, 900 | failed: failedCount, 901 | results, 902 | }; 903 | } 904 | 905 | /** 906 | * Interface for current position from Polymarket data API 907 | */ 908 | export interface CurrentPosition { 909 | proxyWallet: string; 910 | asset: string; 911 | conditionId: string; 912 | size: number; 913 | avgPrice: number; 914 | initialValue: number; 915 | currentValue: number; 916 | cashPnl: number; 917 | percentPnl: number; 918 | totalBought: number; 919 | realizedPnl: number; 920 | percentRealizedPnl: number; 921 | curPrice: number; 922 | redeemable: boolean; 923 | mergeable: boolean; 924 | title: string; 925 | slug: string; 926 | icon: string; 927 | eventSlug: string; 928 | outcome: string; 929 | outcomeIndex: number; 930 | oppositeOutcome: string; 931 | oppositeAsset: string; 932 | endDate: string; 933 | negativeRisk: boolean; 934 | } 935 | 936 | /** 937 | * Get all markets where user has CURRENT/ACTIVE positions using Polymarket data API 938 | * 939 | * This uses the /positions endpoint which returns your CURRENT positions (tokens you currently hold). 940 | * This is the correct endpoint for redemption - you can only redeem tokens you currently have! 941 | * 942 | * @param options - Options for fetching 943 | * @returns Array of markets where user has active positions 944 | */ 945 | export async function getMarketsWithUserPositions( 946 | options?: { 947 | maxPositions?: number; // Max positions to fetch (default: 1000) 948 | walletAddress?: string; 949 | chainId?: Chain; 950 | onlyRedeemable?: boolean; // Only return positions that are redeemable (default: false) 951 | } 952 | ): Promise }>> { 953 | const privateKey = process.env.PRIVATE_KEY; 954 | if (!privateKey) { 955 | throw new Error("PRIVATE_KEY not found in environment"); 956 | } 957 | 958 | const chainIdValue = options?.chainId || parseInt(`${process.env.CHAIN_ID || Chain.POLYGON}`) as Chain; 959 | 960 | // Get RPC URL and create provider 961 | const rpcUrl = getRpcUrl(chainIdValue); 962 | const provider = new JsonRpcProvider(rpcUrl); 963 | const wallet = new Wallet(privateKey, provider); 964 | const walletAddress = options?.walletAddress || await wallet.getAddress(); 965 | 966 | logger.info(`\n=== FINDING YOUR CURRENT/ACTIVE POSITIONS ===`); 967 | logger.info(`Wallet: ${walletAddress}`); 968 | logger.info(`Using /positions endpoint (returns tokens you currently hold)`); 969 | 970 | const marketsWithPositions: Array<{ conditionId: string; position: CurrentPosition; balances: Map }> = []; 971 | 972 | try { 973 | // Use Polymarket data-api /positions endpoint (CORRECT METHOD for active positions!) 974 | const dataApiUrl = "https://data-api.polymarket.com"; 975 | const endpoint = "/positions"; 976 | let allPositions: CurrentPosition[] = []; 977 | let offset = 0; 978 | const limit = 500; // Max per request 979 | const maxPositions = options?.maxPositions || 1000; 980 | 981 | // Fetch all current positions with pagination 982 | while (allPositions.length < maxPositions) { 983 | const params = new URLSearchParams({ 984 | user: walletAddress, 985 | limit: limit.toString(), 986 | offset: offset.toString(), 987 | sortBy: "TOKENS", 988 | sortDirection: "DESC", 989 | sizeThreshold: "0", // Get all positions, even small ones 990 | }); 991 | 992 | if (options?.onlyRedeemable) { 993 | params.append("redeemable", "true"); 994 | } 995 | 996 | const url = `${dataApiUrl}${endpoint}?${params.toString()}`; 997 | 998 | try { 999 | const response = await fetch(url); 1000 | 1001 | if (!response.ok) { 1002 | throw new Error(`Failed to fetch positions: ${response.status} ${response.statusText}`); 1003 | } 1004 | 1005 | const positions = await response.json() as CurrentPosition[]; 1006 | 1007 | if (!Array.isArray(positions) || positions.length === 0) { 1008 | break; // No more positions 1009 | } 1010 | 1011 | allPositions = [...allPositions, ...positions]; 1012 | logger.info(`Fetched ${allPositions.length} current position(s)...`); 1013 | 1014 | // If we got fewer results than the limit, we've reached the end 1015 | if (positions.length < limit) { 1016 | break; 1017 | } 1018 | 1019 | offset += limit; 1020 | } catch (error) { 1021 | logger.error("Error fetching positions", error); 1022 | break; 1023 | } 1024 | } 1025 | 1026 | logger.info(`\n✅ Found ${allPositions.length} current position(s) from API`); 1027 | 1028 | // Group positions by conditionId and verify on-chain balances 1029 | const positionsByMarket = new Map(); 1030 | for (const position of allPositions) { 1031 | if (position.conditionId) { 1032 | if (!positionsByMarket.has(position.conditionId)) { 1033 | positionsByMarket.set(position.conditionId, []); 1034 | } 1035 | positionsByMarket.get(position.conditionId)!.push(position); 1036 | } 1037 | } 1038 | 1039 | logger.info(`Found ${positionsByMarket.size} unique market(s) with current positions`); 1040 | logger.info(`\nVerifying on-chain balances...`); 1041 | 1042 | // For each market, verify on-chain balances 1043 | for (const [conditionId, positions] of positionsByMarket.entries()) { 1044 | try { 1045 | // Verify user currently has tokens in this market (on-chain check) 1046 | const userBalances = await getUserTokenBalances(conditionId, walletAddress, chainIdValue); 1047 | 1048 | if (userBalances.size > 0) { 1049 | // User has active positions in this market! 1050 | // Use the first position as representative (they all have same conditionId) 1051 | marketsWithPositions.push({ 1052 | conditionId, 1053 | position: positions[0], 1054 | balances: userBalances 1055 | }); 1056 | 1057 | if (marketsWithPositions.length % 10 === 0) { 1058 | logger.info(`Verified ${marketsWithPositions.length} market(s) with active positions...`); 1059 | } 1060 | } else { 1061 | // API says we have positions, but on-chain check shows 0 1062 | // This shouldn't happen, but log it 1063 | logger.warning(`API shows positions for ${conditionId}, but on-chain balance is 0`); 1064 | } 1065 | } catch (error) { 1066 | // Skip if error checking balances 1067 | continue; 1068 | } 1069 | } 1070 | 1071 | logger.info(`\n✅ Found ${marketsWithPositions.length} market(s) where you have ACTIVE positions`); 1072 | 1073 | // Show redeemable positions if any 1074 | const redeemableCount = allPositions.filter(p => p.redeemable).length; 1075 | if (redeemableCount > 0) { 1076 | logger.info(`📋 ${redeemableCount} position(s) are marked as redeemable by API`); 1077 | } 1078 | 1079 | } catch (error) { 1080 | logger.error("Failed to find markets with active positions", error); 1081 | throw error; 1082 | } 1083 | 1084 | return marketsWithPositions; 1085 | } 1086 | 1087 | /** 1088 | * Get only redeemable positions (positions that can be redeemed) 1089 | * Uses the /positions endpoint with redeemable=true filter 1090 | * 1091 | * @param options - Options for fetching 1092 | * @returns Array of redeemable positions 1093 | */ 1094 | export async function getRedeemablePositions( 1095 | options?: { 1096 | maxPositions?: number; 1097 | walletAddress?: string; 1098 | chainId?: Chain; 1099 | } 1100 | ): Promise }>> { 1101 | return getMarketsWithUserPositions({ 1102 | ...options, 1103 | onlyRedeemable: true, 1104 | }); 1105 | } 1106 | 1107 | /** 1108 | * Fetch all markets from Polymarket API, find ones where user has positions, 1109 | * and redeem all winning positions 1110 | * This function doesn't rely on token-holding.json - it discovers positions via API + on-chain checks 1111 | * 1112 | * @param options - Options for auto-redemption 1113 | * @returns Summary of redemption results 1114 | */ 1115 | export async function redeemAllWinningMarketsFromAPI(options?: { 1116 | maxMarkets?: number; // Limit number of markets to check (default: 1000) 1117 | dryRun?: boolean; 1118 | }): Promise<{ 1119 | totalMarketsChecked: number; 1120 | marketsWithPositions: number; 1121 | resolved: number; 1122 | withWinningTokens: number; 1123 | redeemed: number; 1124 | failed: number; 1125 | results: Array<{ 1126 | conditionId: string; 1127 | marketTitle?: string; 1128 | isResolved: boolean; 1129 | hasWinningTokens: boolean; 1130 | redeemed: boolean; 1131 | winningIndexSets?: number[]; 1132 | error?: string; 1133 | }>; 1134 | }> { 1135 | const privateKey = process.env.PRIVATE_KEY; 1136 | if (!privateKey) { 1137 | throw new Error("PRIVATE_KEY not found in environment"); 1138 | } 1139 | 1140 | const chainIdValue = parseInt(`${process.env.CHAIN_ID || Chain.POLYGON}`) as Chain; 1141 | const contractConfig = getContractConfig(chainIdValue); 1142 | 1143 | // Get RPC URL and create provider 1144 | const rpcUrl = getRpcUrl(chainIdValue); 1145 | const provider = new JsonRpcProvider(rpcUrl); 1146 | const wallet = new Wallet(privateKey, provider); 1147 | const walletAddress = await wallet.getAddress(); 1148 | 1149 | const clobClient = await getClobClient(); 1150 | 1151 | const maxMarkets = options?.maxMarkets || 1000; 1152 | 1153 | logger.info(`\n=== FETCHING YOUR POSITIONS FROM POLYMARKET API ===`); 1154 | logger.info(`Wallet: ${walletAddress}`); 1155 | logger.info(`Max markets to check: ${maxMarkets}`); 1156 | logger.info(`\nStep 1: Finding markets where you have positions...`); 1157 | 1158 | const results: Array<{ 1159 | conditionId: string; 1160 | marketTitle?: string; 1161 | isResolved: boolean; 1162 | hasWinningTokens: boolean; 1163 | redeemed: boolean; 1164 | winningIndexSets?: number[]; 1165 | error?: string; 1166 | }> = []; 1167 | 1168 | let totalMarketsChecked = 0; 1169 | let marketsWithPositions = 0; 1170 | let resolvedCount = 0; 1171 | let withWinningTokensCount = 0; 1172 | let redeemedCount = 0; 1173 | let failedCount = 0; 1174 | 1175 | // Step 1: Find all markets where user has positions 1176 | logger.info(`\nStep 1: Finding markets where you have positions...`); 1177 | const marketsWithUserPositionsData = await getMarketsWithUserPositions({ 1178 | maxPositions: maxMarkets, 1179 | walletAddress, 1180 | chainId: chainIdValue, 1181 | }); 1182 | 1183 | marketsWithPositions = marketsWithUserPositionsData.length; 1184 | totalMarketsChecked = marketsWithPositions; // Approximate, actual count is in the function 1185 | 1186 | logger.info(`\nStep 2: Checking which markets are resolved and if you won...\n`); 1187 | 1188 | try { 1189 | 1190 | // Step 2: For each market where user has positions, check resolution and redeem winners 1191 | for (const { conditionId, position, balances: cachedBalances } of marketsWithUserPositionsData) { 1192 | try { 1193 | // Check if market is resolved 1194 | const resolution = await checkConditionResolution(conditionId, chainIdValue); 1195 | 1196 | if (!resolution.isResolved) { 1197 | // Not resolved yet 1198 | results.push({ 1199 | conditionId, 1200 | marketTitle: position?.title || conditionId, 1201 | isResolved: false, 1202 | hasWinningTokens: false, 1203 | redeemed: false, 1204 | }); 1205 | continue; 1206 | } 1207 | 1208 | resolvedCount++; 1209 | 1210 | // Use cached balances from Step 1 (no need to query again) 1211 | const userBalances = cachedBalances; 1212 | 1213 | // Filter to only winning indexSets that user holds 1214 | const winningHeld = resolution.winningIndexSets.filter(indexSet => { 1215 | const balance = userBalances.get(indexSet); 1216 | return balance && !balance.isZero(); 1217 | }); 1218 | 1219 | if (winningHeld.length > 0) { 1220 | withWinningTokensCount++; 1221 | 1222 | const marketTitle = position?.title || conditionId; 1223 | logger.info(`\n✅ Found winning market: ${marketTitle}`); 1224 | logger.info(` Condition ID: ${conditionId}`); 1225 | logger.info(` Winning indexSets: ${resolution.winningIndexSets.join(", ")}`); 1226 | logger.info(` Your winning tokens: ${winningHeld.join(", ")}`); 1227 | if (position?.redeemable) { 1228 | logger.info(` API marks this as redeemable: ✅`); 1229 | } 1230 | 1231 | if (options?.dryRun) { 1232 | logger.info(`[DRY RUN] Would redeem: ${conditionId}`); 1233 | results.push({ 1234 | conditionId, 1235 | marketTitle, 1236 | isResolved: true, 1237 | hasWinningTokens: true, 1238 | redeemed: false, 1239 | winningIndexSets: resolution.winningIndexSets, 1240 | }); 1241 | } else { 1242 | try { 1243 | // Redeem winning positions 1244 | logger.info(`Redeeming winning positions...`); 1245 | await redeemPositions({ 1246 | conditionId, 1247 | indexSets: winningHeld, 1248 | chainId: chainIdValue, 1249 | }); 1250 | 1251 | redeemedCount++; 1252 | logger.success(`✅ Successfully redeemed ${conditionId}`); 1253 | 1254 | // Automatically clear holdings after successful redemption 1255 | try { 1256 | const { clearMarketHoldings } = await import("./holdings"); 1257 | clearMarketHoldings(conditionId); 1258 | logger.info(`Cleared holdings record for ${conditionId} from token-holding.json`); 1259 | } catch (clearError) { 1260 | logger.warning(`Failed to clear holdings for ${conditionId}: ${clearError instanceof Error ? clearError.message : String(clearError)}`); 1261 | // Don't fail the redemption if clearing holdings fails 1262 | } 1263 | 1264 | results.push({ 1265 | conditionId, 1266 | marketTitle, 1267 | isResolved: true, 1268 | hasWinningTokens: true, 1269 | redeemed: true, 1270 | winningIndexSets: resolution.winningIndexSets, 1271 | }); 1272 | } catch (error) { 1273 | failedCount++; 1274 | const errorMsg = error instanceof Error ? error.message : String(error); 1275 | logger.error(`Failed to redeem ${conditionId}`, error); 1276 | results.push({ 1277 | conditionId, 1278 | marketTitle, 1279 | isResolved: true, 1280 | hasWinningTokens: true, 1281 | redeemed: false, 1282 | winningIndexSets: resolution.winningIndexSets, 1283 | error: errorMsg, 1284 | }); 1285 | } 1286 | } 1287 | } else { 1288 | // Resolved but user doesn't have winning tokens (they lost) 1289 | results.push({ 1290 | conditionId, 1291 | marketTitle: position?.title || conditionId, 1292 | isResolved: true, 1293 | hasWinningTokens: false, 1294 | redeemed: false, 1295 | winningIndexSets: resolution.winningIndexSets, 1296 | }); 1297 | } 1298 | } catch (error) { 1299 | failedCount++; 1300 | const errorMsg = error instanceof Error ? error.message : String(error); 1301 | logger.error(`Error processing market ${conditionId}`, error); 1302 | results.push({ 1303 | conditionId, 1304 | marketTitle: position?.title || conditionId, 1305 | isResolved: false, 1306 | hasWinningTokens: false, 1307 | redeemed: false, 1308 | error: errorMsg, 1309 | }); 1310 | } 1311 | } 1312 | 1313 | logger.info(`\n=== API REDEMPTION SUMMARY ===`); 1314 | logger.info(`Total markets checked: ${totalMarketsChecked}`); 1315 | logger.info(`Markets where you have positions: ${marketsWithPositions}`); 1316 | logger.info(`Resolved markets: ${resolvedCount}`); 1317 | logger.info(`Markets with winning tokens: ${withWinningTokensCount}`); 1318 | if (options?.dryRun) { 1319 | logger.info(`Would redeem: ${withWinningTokensCount} market(s)`); 1320 | } else { 1321 | logger.success(`Successfully redeemed: ${redeemedCount} market(s)`); 1322 | if (failedCount > 0) { 1323 | logger.warning(`Failed: ${failedCount} market(s)`); 1324 | } 1325 | } 1326 | 1327 | return { 1328 | totalMarketsChecked, 1329 | marketsWithPositions, 1330 | resolved: resolvedCount, 1331 | withWinningTokens: withWinningTokensCount, 1332 | redeemed: redeemedCount, 1333 | failed: failedCount, 1334 | results, 1335 | }; 1336 | } catch (error) { 1337 | logger.error("Failed to fetch and redeem markets from API", error); 1338 | throw error; 1339 | } 1340 | } 1341 | 1342 | --------------------------------------------------------------------------------