← Back
/** Self-serve deposit watcher (Friend-beta Block B / B2). For every active copy user's
 *  deposit wallet, detect incoming USDC and auto-convert it to pUSD (the CLOB V2 collateral
 *  testers can't get directly): native USDC → USDC.e (Uniswap V3 fee=100) → pUSD (Onramp).
 *  All gasless via the Polymarket relayer batch, signed per-user by their Privy embedded EOA.
 *
 *  Run on the main/DE server (relayer not geoblocked; builder-sign at localhost:3240),
 *  e.g. from cron next to redeem-dw.mjs:
 *    [DRY=1] node --experimental-global-webcrypto deposit-watch.mjs
 *
 *  Env: SUBS_URL=https://poly-dev.szhub.space/api/copy/active  SERVICE_TOKEN=...
 *       FOLLOWER=did,did (allow-list — isolate which users to convert)  DRY=1 (default LIVE)
 *       MIN_CONVERT_USD (default 1)  SLIPPAGE_BPS (default 50) */
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 DRY = process.env.DRY === '1';
const SUBS_URL = process.env.SUBS_URL || '';
const SERVICE_TOKEN = process.env.SERVICE_TOKEN || '';
const FOLLOWERS = new Set((process.env.FOLLOWER || '').split(',').map((s) => s.trim().toLowerCase()).filter(Boolean));
const MIN_CONVERT = BigInt(Math.round(Number(process.env.MIN_CONVERT_USD || '1') * 1e6));
const SLIPPAGE_BPS = BigInt(process.env.SLIPPAGE_BPS || '50');

const NATIVE = '0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359';
const USDCE = '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174';
const PUSD = '0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB';
const ONRAMP = '0x93070a847efEf7F70739046A929D47a521F5B8ee';   // USDC.e → pUSD
const ROUTER = '0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45'; // Uniswap V3 SwapRouter02
const FEE = 100;
const WRAP_ABI = [{ name: 'wrap', type: 'function', stateMutability: 'nonpayable', inputs: [{ name: 'asset', type: 'address' }, { name: 'to', type: 'address' }, { name: 'amount', type: 'uint256' }], outputs: [] }];
const ROUTER_ABI = [{ name: 'exactInputSingle', type: 'function', stateMutability: 'payable', inputs: [{ type: 'tuple', components: [
  { name: 'tokenIn', type: 'address' }, { name: 'tokenOut', type: 'address' }, { name: 'fee', type: 'uint24' },
  { name: 'recipient', type: 'address' }, { name: 'amountIn', type: 'uint256' }, { name: 'amountOutMinimum', type: 'uint256' },
  { name: 'sqrtPriceLimitX96', type: 'uint160' } ] }], outputs: [{ name: 'amountOut', type: 'uint256' }] }];

if (!SUBS_URL) { console.log('set SUBS_URL'); process.exit(1); }

const pub = createPublicClient({ chain: polygon, transport: http(process.env.RPC || '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);
const usd = (raw) => `$${(Number(raw) / 1e6).toFixed(4)}`;

// --- Privy (shared) + per-user relay (signs with that user's embedded EOA, batches into their DW) ---
function loadEnv() {
  const env = {};
  const url = 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(url, '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(); }
  return env;
}
let privy = null;
function getPrivy(env) {
  if (!privy) privy = new PrivyClient(env.PRIVY_APP_ID, env.PRIVY_APP_SECRET, { walletApi: { authorizationPrivateKey: env.PRIVY_AUTHORIZATION_KEY } });
  return privy;
}
async function makeRelay(eoa) {
  const env = loadEnv();
  const p = getPrivy(env);
  // Resolve the EOA to Privy's STORED (checksummed) address. The /api/copy/active
  // payload lowercases ownerEoa, and Privy's walletApi matches case-sensitively —
  // signing by a lowercased address fails "No wallet account found". Match the
  // working copy-exec-mu path: look it up in linkedAccounts and use that casing.
  const users = await p.getUsers();
  let signer = null;
  for (const u of users) for (const a of u.linkedAccounts || []) if (a.type === 'wallet' && a.walletClientType === 'privy' && a.address.toLowerCase() === eoa.toLowerCase()) signer = a.address;
  if (!signer) throw new Error(`EOA ${eoa} not among Privy embedded wallets`);
  const account = toAccount({
    address: signer,
    async signMessage({ message }) { const m = typeof message === 'string' ? message : (message.raw ?? message); return (await p.walletApi.ethereum.signMessage({ address: signer, chainType: 'ethereum', message: m })).signature; },
    async signTypedData(td) { return (await p.walletApi.ethereum.signTypedData({ address: signer, 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') });
  // 127.0.0.1, not localhost: Node resolves localhost→::1 (IPv6) first, but the API
  // binds 127.0.0.1 (IPv4) only, so localhost gives ECONNREFUSED ::1:3240.
  const builderConfig = new BuilderConfig({ remoteBuilderConfig: { url: 'http://127.0.0.1:3240/api/polymarket/sign' } });
  return new RelayClient('https://relayer-v2.polymarket.com/', 137, walletClient, builderConfig);
}
async function submit(relay, calls, dw, tag) {
  const deadline = (Math.floor(Date.now() / 1000) + 3600).toString();
  const resp = await relay.executeDepositWalletBatch(calls, dw, deadline);
  console.log(`  [${tag}] relayer tx ${resp.transactionID} | ${resp.state}`);
  const result = await relay.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, 160)}`);
  return ok;
}

/** Convert one DW's incoming USDC → pUSD. swap (native→USDC.e) then wrap (USDC.e→pUSD). */
async function convertUser(dw, eoa, label) {
  const native = await pub.readContract({ address: NATIVE, abi: erc20Abi, functionName: 'balanceOf', args: [dw] });
  let usdce = await pub.readContract({ address: USDCE, abi: erc20Abi, functionName: 'balanceOf', args: [dw] });
  if (native < MIN_CONVERT && usdce < MIN_CONVERT) return;   // nothing actionable
  console.log(`${label} dw=${dw.slice(0, 10)} native=${usd(native)} USDC.e=${usd(usdce)}`);
  let relay = null;

  // 1) swap native USDC → USDC.e (Uniswap V3 fee=100)
  if (native >= MIN_CONVERT) {
    const amountOutMin = native - (native * SLIPPAGE_BPS) / 10000n;
    const calls = [];
    const allow = await pub.readContract({ address: NATIVE, abi: erc20Abi, functionName: 'allowance', args: [dw, ROUTER] });
    if (allow < native) calls.push({ target: NATIVE, value: '0', data: encodeFunctionData({ abi: erc20Abi, functionName: 'approve', args: [ROUTER, maxUint256] }) });
    calls.push({ target: ROUTER, value: '0', data: encodeFunctionData({ abi: ROUTER_ABI, functionName: 'exactInputSingle', args: [{ tokenIn: NATIVE, tokenOut: USDCE, fee: FEE, recipient: dw, amountIn: native, amountOutMinimum: amountOutMin, sqrtPriceLimitX96: 0n }] }) });
    if (DRY) { console.log(`  DRY — would swap ${usd(native)} native→USDC.e (${calls.length} call(s))`); }
    else { relay = relay || await makeRelay(eoa); if (await submit(relay, calls, dw, 'swap')) usdce = await pub.readContract({ address: USDCE, abi: erc20Abi, functionName: 'balanceOf', args: [dw] }); }
  }

  // 2) wrap USDC.e → pUSD (Onramp)
  if (usdce >= MIN_CONVERT) {
    const calls = [];
    const allow = await pub.readContract({ address: USDCE, abi: erc20Abi, functionName: 'allowance', args: [dw, ONRAMP] });
    if (allow < usdce) calls.push({ target: USDCE, value: '0', data: encodeFunctionData({ abi: erc20Abi, functionName: 'approve', args: [ONRAMP, maxUint256] }) });
    calls.push({ target: ONRAMP, value: '0', data: encodeFunctionData({ abi: WRAP_ABI, functionName: 'wrap', args: [USDCE, dw, usdce] }) });
    if (DRY) { console.log(`  DRY — would wrap ${usd(usdce)} USDC.e→pUSD (${calls.length} call(s))`); }
    else { relay = relay || await makeRelay(eoa); if (await submit(relay, calls, dw, 'wrap')) { const p = await pub.readContract({ address: PUSD, abi: erc20Abi, functionName: 'balanceOf', args: [dw] }); console.log(`  pUSD now ${usd(p)}`); } }
  }
}

async function main() {
  const r = await fetch(SUBS_URL, { headers: { 'x-service-token': SERVICE_TOKEN }, signal: AbortSignal.timeout(12000) });
  if (!r.ok) { console.log('[subs] http', r.status); process.exit(1); }
  const d = await r.json();
  const seen = new Map();   // userAddress → { dw, eoa }
  for (const s of (d.data || [])) {
    const ua = String(s.userAddress || '').toLowerCase();
    if (FOLLOWERS.size && !FOLLOWERS.has(ua)) continue;
    const dw = s.depositWallet ? String(s.depositWallet) : null;
    const eoa = s.ownerEoa ? String(s.ownerEoa) : null;
    if (dw && eoa && !seen.has(ua)) seen.set(ua, { dw, eoa });
  }
  console.log(`[deposit-watch] ${DRY ? 'DRY' : 'LIVE'} | ${seen.size} wallet(s)${FOLLOWERS.size ? ` (allow-list ${FOLLOWERS.size})` : ''}`);
  for (const [ua, { dw, eoa }] of seen) {
    try { await convertUser(dw, eoa, `[${ua.slice(0, 16)}]`); }
    catch (e) { console.log(`[${ua.slice(0, 16)}] err:`, e.message); }   // one user's failure never blocks others
  }
  console.log('[deposit-watch] done');
}

main();