← Back
β˜†
/** Execution primitive: build + sign + post sigType3/ERC-7739 deposit-wallet orders
 *  server-side via Privy server-auth. Reused by copy-loop in live mode.
 *  MUST run from a non-geoblocked region (Malaysia VPS). Node>=20 or
 *  --experimental-global-webcrypto (L2 HMAC needs globalThis.crypto.subtle).
 *
 *  makeExecutor() β†’ { buy(order), sellAll({tokenID,price,...}), tokenBalance(tokenID) } */
import fs from 'node:fs';
import { PrivyClient } from '@privy-io/server-auth';
import { createWalletClient, createPublicClient, http } from 'viem';
import { toAccount } from 'viem/accounts';
import { polygon } from 'viem/chains';
import { ClobClient, SignatureTypeV2, Side } from '@polymarket/clob-client-v2';

const DW = process.env.DEPOSIT_WALLET || '0x50A8061e9448EB1e5d5e7aF07BE4E4F63C6F24Ff';
const RPC = process.env.RPC || 'https://polygon.drpc.org';
// SZHub builder code (bytes32, verified vs live builder-fees API). Attaching it to
// copy orders attributes their volume to our builder β†’ shows in Builder Analytics
// and earns builder fees (taker fee = 0 until it's enabled, so no cost meanwhile).
const BUILDER_CODE = process.env.BUILDER_CODE || '0x3c829d5150b70f3ba347670d4b1eda96be3c255b3f64895a7eeef5caea7952d5';
const CTF = '0x4D97DCd97eC945f40cF65F87097ACe5EA0476045';
const CTF_ABI = [{ name: 'balanceOf', type: 'function', stateMutability: 'view', inputs: [{ type: 'address' }, { type: 'uint256' }], outputs: [{ type: 'uint256' }] }];
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);

export async function makeExecutor(envPath = './.env.privy', opts = {}) {
  // opts.{dw,eoa} parameterize the executor for multi-user (copy-loop-mu); when omitted the
  // module-level env defaults are used β†’ byte-identical behavior for the live single-user daemon.
  const dwAddr = opts.dw || DW;
  const env = {};
  for (const l of fs.readFileSync(new URL(envPath, import.meta.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();
  }
  const privy = new PrivyClient(env.PRIVY_APP_ID, env.PRIVY_APP_SECRET, { walletApi: { authorizationPrivateKey: env.PRIVY_AUTHORIZATION_KEY } });
  const users = await privy.getUsers();
  // Pick the embedded EOA to sign with. Multi-tenant: when >1 Privy user exists the old
  // "last wins" loop was ambiguous and could sign a daemon's orders with the WRONG user's
  // key. Require USER_EOA to disambiguate; only auto-pick when exactly one wallet exists.
  const wallets = [];
  for (const u of users) for (const a of u.linkedAccounts || []) if (a.type === 'wallet' && a.walletClientType === 'privy') wallets.push(a.address);
  const want = (opts.eoa || process.env.USER_EOA || '').toLowerCase();
  const EOA = want ? (wallets.find((w) => w.toLowerCase() === want) || null)
    : (wallets.length === 1 ? wallets[0] : null);
  if (!EOA) throw new Error(want ? `USER_EOA ${process.env.USER_EOA} not among Privy embedded wallets` : `set USER_EOA β€” ${wallets.length} Privy embedded wallets exist, ambiguous`);

  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(RPC) });
  const pub = createPublicClient({ chain: polygon, transport: http(RPC) });
  const clob = new ClobClient({ host: 'https://clob.polymarket.com', chain: 137, signer: walletClient, signatureType: SignatureTypeV2.POLY_1271, funderAddress: dwAddr, builderConfig: { builderCode: BUILDER_CODE } });
  // Cache L2 API creds per EOA so we DON'T re-sign ClobAuth on every init. Required for the
  // orders-only Privy policy (Variant A): under it the delegate may ONLY sign Exchange-domain
  // orders, so createOrDeriveApiKey (a ClobAuth signature) must NOT run at runtime β€” derive
  // once (pre-policy) and reuse from cache thereafter. Cache file holds API secrets β†’ gitignored.
  const credsPath = new URL(`./clob-creds-${EOA.toLowerCase()}.json`, import.meta.url);
  let creds;
  try { creds = JSON.parse(fs.readFileSync(credsPath, 'utf8')); }
  catch {
    creds = await clob.createOrDeriveApiKey();
    try { fs.writeFileSync(credsPath, JSON.stringify(creds)); } catch { /* non-fatal */ }
  }
  clob.setApiCreds ? clob.setApiCreds(creds) : (clob.creds = creds);

  const tokenBalance = async (tokenID) => pub.readContract({ address: CTF, abi: CTF_ABI, functionName: 'balanceOf', args: [dwAddr, BigInt(tokenID)] });

  // tickSize + negRisk are resolved per-market by the SDK (do NOT hardcode β€” negRisk:false
  // would misroute neg-risk markets); the SDK also rounds price to the market tick.
  // Cross the spread so the copy actually FILLS: we copy seconds after the leader, by which
  // time the ask has usually ticked up, so a limit AT the leader's price just rests unfilled.
  // Post a touch above (mirrors sellAll's slippage), capped at 0.99. The size is unchanged,
  // so actual cost can exceed the leader-priced notional cap by ~BUY_SLIPPAGE (negligible).
  const BUY_SLIPPAGE = Number(process.env.BUY_SLIPPAGE || '0.02');
  const buy = async ({ tokenID, price, size }) => {
    const buyPrice = Math.min(0.99, Number(price) * (1 + BUY_SLIPPAGE));
    return fix(await clob.createAndPostOrder({ tokenID, price: buyPrice, size: Number(size), side: Side.BUY, feeRateBps: 0 }, {}));
  };

  /** sell our entire on-chain holding of tokenID (close a copied position). Price a touch
   *  below the leader's exit so the SELL crosses the book and actually fills. */
  const SELL_SLIPPAGE = Number(process.env.SELL_SLIPPAGE || '0.03');
  const sellAll = async ({ tokenID, price }) => {
    const raw = await tokenBalance(tokenID);
    const shares = Math.floor(Number(raw) / 1e6);   // CTF outcome tokens are 6-decimals
    if (shares < 1) return { skipped: 'no balance' };
    const sellPrice = Math.max(0.01, Number(price) * (1 - SELL_SLIPPAGE));
    return fix(await clob.createAndPostOrder({ tokenID, price: sellPrice, size: shares, side: Side.SELL, feeRateBps: 0 }, {}));
  };

  // Cancel a resting order by id. Used when a copy BUY/close SELL posts but doesn't fill
  // (rests on the book) β€” we cancel it so it can't fill later untracked (orphan).
  const cancel = async (orderID) => fix(await clob.cancelOrders([orderID]));

  return { buy, sellAll, cancel, tokenBalance, EOA, DW: dwAddr };
}

if (import.meta.url === `file://${process.argv[1]}`) {
  const ex = await makeExecutor();
  const o = JSON.parse(process.env.ORDER_JSON || '{}');
  console.log('buy', o, 'β†’', JSON.stringify(await ex.buy(o)).slice(0, 300));
}

πŸ“œ Git History

c7f2b82fix(copy-mu): 3 money-path фикса β€” pending-timeout ΠΎΡ‚ reservedAt, slippage-adj cost, fade double-open guard8 days ago
Show last diff
Loading...