/** 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));
}