← Back
/** Auto-redeem + wrap: (1) burn the deposit wallet's WINNING resolved outcome tokens
 *  → collateral (USDC.e), then (2) wrap any USDC.e → pUSD so winnings are immediately
 *  re-tradable (CLOB V2 collateral is pUSD). Server-side via the relayer batch (no user).
 *  Run on the DE server (relayer not geoblocked; builder-sign at localhost:3240).
 *
 *  Usage: [DRY=1] node redeem-dw.mjs   (DRY lists only, posts nothing) */
import fs from 'node:fs';
import pkg from '@polymarket/builder-relayer-client';
import signingPkg from '@polymarket/builder-signing-sdk';
import { PrivyClient } from '@privy-io/server-auth';
import { createWalletClient, createPublicClient, http, encodeFunctionData, erc20Abi, maxUint256 } from 'viem';
import { toAccount } from 'viem/accounts';
import { polygon } from 'viem/chains';

const { RelayClient, RelayerTransactionState } = pkg;
const { BuilderConfig } = signingPkg;
const DW = process.env.DEPOSIT_WALLET || '0x50A8061e9448EB1e5d5e7aF07BE4E4F63C6F24Ff';
const DRY = process.env.DRY === '1';
const CTF = '0x4D97DCd97eC945f40cF65F87097ACe5EA0476045';
const NEG_RISK_ADAPTER = '0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296';
const USDC_E = '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174';
const ONRAMP = '0x93070a847efEf7F70739046A929D47a521F5B8ee';   // USDC.e → pUSD collateral onramp
const ZERO32 = '0x0000000000000000000000000000000000000000000000000000000000000000';
const REDEEM_ABI = [{ name: 'redeemPositions', type: 'function', stateMutability: 'nonpayable', inputs: [{ name: 'collateralToken', type: 'address' }, { name: 'parentCollectionId', type: 'bytes32' }, { name: 'conditionId', type: 'bytes32' }, { name: 'indexSets', type: 'uint256[]' }], outputs: [] }];
// Neg-risk markets settle through the NegRiskAdapter's OWN redeem: redeemPositions(conditionId, amounts[])
// where amounts is indexed by outcome index (raw 6-dec token balance held per outcome). The CTF 4-arg
// redeem selector also exists on the adapter but reverts for neg-risk conditions — this is the correct one.
const NEG_REDEEM_ABI = [{ name: 'redeemPositions', type: 'function', stateMutability: 'nonpayable', inputs: [{ name: 'conditionId', type: 'bytes32' }, { name: 'amounts', type: 'uint256[]' }], outputs: [] }];
const BAL_ABI = [{ name: 'balanceOf', type: 'function', stateMutability: 'view', inputs: [{ name: 'account', type: 'address' }, { name: 'id', type: 'uint256' }], outputs: [{ type: 'uint256' }] }];
const SLOT_ABI = [{ name: 'getOutcomeSlotCount', type: 'function', stateMutability: 'view', inputs: [{ name: 'conditionId', type: 'bytes32' }], outputs: [{ type: 'uint256' }] }];
const WRAP_ABI = [{ name: 'wrap', type: 'function', stateMutability: 'nonpayable', inputs: [{ name: 'asset', type: 'address' }, { name: 'to', type: 'address' }, { name: 'amount', type: 'uint256' }], outputs: [] }];

const pub = createPublicClient({ chain: polygon, transport: http('https://polygon.drpc.org') });
const fix = (v) => typeof v === 'bigint' ? v.toString() : Array.isArray(v) ? v.map(fix) : (v && typeof v === 'object' ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, fix(x)])) : v);

// --- relayer (built lazily; not needed for DRY) ---
let relay = null;
async function getRelay() {
  if (relay) return relay;
  const env = {};
  const envUrl = fs.existsSync(new URL('../.env.privy', import.meta.url)) ? new URL('../.env.privy', import.meta.url) : new URL('./.env.privy', import.meta.url);
  for (const l of fs.readFileSync(envUrl, 'utf8').split('\n')) { const t = l.trim(); if (!t || t.startsWith('#')) continue; const i = t.indexOf('='); if (i > 0) env[t.slice(0, i).trim()] = t.slice(i + 1).trim(); }
  const privy = new PrivyClient(env.PRIVY_APP_ID, env.PRIVY_APP_SECRET, { walletApi: { authorizationPrivateKey: env.PRIVY_AUTHORIZATION_KEY } });
  const users = await privy.getUsers();
  const want = (process.env.USER_EOA || '').toLowerCase();
  let EOA = null;
  for (const u of users) for (const a of u.linkedAccounts || []) if (a.type === 'wallet' && a.walletClientType === 'privy' && (!want || a.address.toLowerCase() === want)) EOA = a.address;
  const account = toAccount({
    address: EOA,
    async signMessage({ message }) { const m = typeof message === 'string' ? message : (message.raw ?? message); return (await privy.walletApi.ethereum.signMessage({ address: EOA, chainType: 'ethereum', message: m })).signature; },
    async signTypedData(td) { return (await privy.walletApi.ethereum.signTypedData({ address: EOA, chainType: 'ethereum', typedData: { ...td, message: fix(td.message) } })).signature; },
    async signTransaction() { throw new Error('n/a'); },
  });
  const walletClient = createWalletClient({ account, chain: polygon, transport: http('https://polygon.drpc.org') });
  const builderConfig = new BuilderConfig({ remoteBuilderConfig: { url: 'http://127.0.0.1:3240/api/polymarket/sign' } });
  relay = new RelayClient('https://relayer-v2.polymarket.com/', 137, walletClient, builderConfig);
  return relay;
}
async function submit(calls, tag) {
  const r = await getRelay();
  const deadline = (Math.floor(Date.now() / 1000) + 3600).toString();
  const resp = await r.executeDepositWalletBatch(calls, DW, deadline);
  console.log(`[${tag}] relayer tx:`, resp.transactionID, '| state:', resp.state);
  const result = await r.pollUntilState(resp.transactionID, [RelayerTransactionState.STATE_CONFIRMED, RelayerTransactionState.STATE_FAILED], '90', 3000);
  const ok = result?.state === RelayerTransactionState.STATE_CONFIRMED;
  console.log(ok ? `🎯 [${tag}] CONFIRMED` : `⚠️ [${tag}] not confirmed: ${JSON.stringify(result).slice(0, 140)}`);
  return ok;
}

// === 1) REDEEM resolved winning positions → USDC.e ===
const r = await fetch(`https://data-api.polymarket.com/positions?user=${DW}&sizeThreshold=0&redeemable=true&limit=100`, { signal: AbortSignal.timeout(15000) });
const positions = await r.json();
const redeemables = (Array.isArray(positions) ? positions : []).filter((p) => p.redeemable);
console.log(`redeemable positions: ${redeemables.length}`);
// Aggregate value PER conditionId: a single redeem burns ALL outcomes of a condition at once,
// so "worthless" must be judged on the condition total — not a single outcome. Otherwise a $0
// losing leg processed first would strand the WINNING leg of the same condition (both share cid).
const byCid = new Map();
for (const p of redeemables) {
  const cid = p.conditionId;
  if (!cid) continue;
  const e = byCid.get(cid) || { cid, val: 0, negRisk: !!p.negativeRisk, title: p.title, outcomes: [] };
  e.val += p.currentValue || 0;
  e.outcomes.push({ idx: Number(p.outcomeIndex ?? 0), asset: p.asset });
  byCid.set(cid, e);
}
const redeemCalls = [];
for (const e of byCid.values()) {
  console.log(`  value=$${e.val.toFixed(2)} negRisk=${e.negRisk} | ${String(e.title).slice(0, 36)}`);
  if (e.val <= 0.01) { console.log(`    ↳ skip (worthless $0 resolved loser)`); continue; }
  if (e.negRisk) {
    // Native neg-risk redeem: amounts[] indexed by outcome index = exact on-chain token balance held.
    // Read the live balance (not Data-API size, which rounds) so we never request more than we hold.
    // Array length MUST equal the condition's outcomeSlotCount (binary neg-risk = 2), NOT maxIdx+1 —
    // the NegRiskAdapter reverts when amounts is shorter than the slot count (e.g. winning leg at index 0).
    const slotCount = Number(await pub.readContract({ address: CTF, abi: SLOT_ABI, functionName: 'getOutcomeSlotCount', args: [e.cid] }));
    const amounts = Array.from({ length: slotCount }, () => 0n);
    for (const o of e.outcomes) {
      amounts[o.idx] = await pub.readContract({ address: CTF, abi: BAL_ABI, functionName: 'balanceOf', args: [DW, BigInt(o.asset)] });
    }
    console.log(`    ↳ neg-risk redeem amounts=[${amounts.map((a) => a.toString()).join(', ')}]`);
    redeemCalls.push({ target: NEG_RISK_ADAPTER, value: '0', data: encodeFunctionData({ abi: NEG_REDEEM_ABI, functionName: 'redeemPositions', args: [e.cid, amounts] }) });
  } else {
    redeemCalls.push({ target: CTF, value: '0', data: encodeFunctionData({ abi: REDEEM_ABI, functionName: 'redeemPositions', args: [USDC_E, ZERO32, e.cid, [1n, 2n]] }) });
  }
}
if (redeemCalls.length && !DRY) {
  // One tx per condition (NOT one atomic batch): a single reverting redeem (e.g. a neg-risk
  // position hitting the wrong adapter ABI) must not block the other winners in the same run.
  for (let i = 0; i < redeemCalls.length; i++) {
    try { await submit([redeemCalls[i]], `redeem-${i + 1}/${redeemCalls.length}`); }
    catch (e) { console.log(`⚠️ [redeem-${i + 1}] failed, continuing: ${String(e.message).slice(0, 120)}`); }
  }
}
else if (redeemCalls.length) console.log(`DRY — would redeem ${redeemCalls.length} condition(s)`);

// === 2) WRAP any USDC.e on the deposit wallet → pUSD (re-tradable) ===
const usdce = await pub.readContract({ address: USDC_E, abi: erc20Abi, functionName: 'balanceOf', args: [DW] });
const usdceHuman = Number(usdce) / 1e6;
console.log(`USDC.e on deposit wallet: $${usdceHuman.toFixed(2)}`);
if (usdce > 10000n) {   // > $0.01
  const wrapCalls = [];
  const allowance = await pub.readContract({ address: USDC_E, abi: erc20Abi, functionName: 'allowance', args: [DW, ONRAMP] });
  if (allowance < usdce) wrapCalls.push({ target: USDC_E, value: '0', data: encodeFunctionData({ abi: erc20Abi, functionName: 'approve', args: [ONRAMP, maxUint256] }) });
  wrapCalls.push({ target: ONRAMP, value: '0', data: encodeFunctionData({ abi: WRAP_ABI, functionName: 'wrap', args: [USDC_E, DW, usdce] }) });
  if (DRY) console.log(`DRY — would wrap $${usdceHuman.toFixed(2)} USDC.e → pUSD (${wrapCalls.length} call(s))`);
  else await submit(wrapCalls, 'wrap');
} else {
  console.log('nothing to wrap');
}