โ† Back
โ˜†
/** Privy "orders-only" policy for the server delegate (Friend-beta Block E / Chunk 1.3).
 *  Implements Variant A from docs/PRIVY_POLICY_SPEC.md ยง4b: the server-auth key may ONLY
 *  sign CLOB orders (eth_signTypedData_v4 where domain.verifyingContract โˆˆ the two Exchange
 *  contracts). Everything else โ€” the deposit-wallet Batch domain (the withdraw vector),
 *  eth_sendTransaction, any other domain โ€” falls to Privy's default-DENY in the enclave, so
 *  the delegate physically cannot move funds. The user keeps full control via their own
 *  Privy auth (deposit / redeem / withdraw are user-present and unaffected by this policy).
 *
 *  Modes (default = DRY, prints intended action only):
 *    node privy-policy.mjs create            โ†’ POST /v1/policies, print the new policyId
 *    POLICY_ID=<id> node privy-policy.mjs show    โ†’ GET that policy
 *    APPLY=1 POLICY_ID=<id> node privy-policy.mjs apply  โ†’ addSigners on the delegate
 *
 *  โš ๏ธ RULE (MEMORY + plan): do NOT `apply` the policy to live delegates until the edge gate
 *  passes (PF>1.0 on โ‰ฅ30) โ€” applying mid-measurement breaks auto-redeem/wrap and the gate.
 *  `create`/`show` are safe to run anytime (a free-standing policy enforces nothing until
 *  it is attached to a signer). `apply` is double-guarded behind APPLY=1.
 *
 *  Run on the main/DE server (.env.privy with PRIVY_APP_ID/SECRET/AUTHORIZATION_KEY). */
import fs from 'node:fs';
import { PrivyClient } from '@privy-io/server-auth';

const MODE = process.argv[2] || '';
const APPLY = process.env.APPLY === '1';
const POLICY_ID = process.env.POLICY_ID || '';
const SIGNER_ID = process.env.SIGNER_ID || 'kc34moyfw73mo5whwfjqpewy';   // server delegate (from spec ยง4b)

const EXCHANGE_V2 = '0xE111180000d2663C0091e4f400237545B87B996B';
const NEG_RISK_EXCHANGE_V2 = '0xe2222d279d744050d28e00520010520000310F59';

// Variant A policy โ€” one ALLOW rule, everything else default-DENY (spec ยง4b, API-validated).
const POLICY = {
  version: '1.0',
  name: 'polycopy-orders-only',
  chain_type: 'ethereum',
  rules: [{
    name: 'allow-clob-orders',
    method: 'eth_signTypedData_v4',
    action: 'ALLOW',
    conditions: [{
      field_source: 'ethereum_typed_data_domain',
      field: 'verifyingContract',
      operator: 'in',
      value: [EXCHANGE_V2, NEG_RISK_EXCHANGE_V2],
    }],
  }],
};

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;
}

const env = loadEnv();
const APP_ID = env.PRIVY_APP_ID;
const APP_SECRET = env.PRIVY_APP_SECRET;
if (!APP_ID || !APP_SECRET) { console.log('missing PRIVY_APP_ID / PRIVY_APP_SECRET in .env.privy'); process.exit(1); }
const authHeader = 'Basic ' + Buffer.from(`${APP_ID}:${APP_SECRET}`).toString('base64');
const baseHeaders = { 'content-type': 'application/json', 'privy-app-id': APP_ID, authorization: authHeader };

async function createPolicy() {
  console.log('POST /v1/policies โ†’', JSON.stringify(POLICY));
  const r = await fetch('https://api.privy.io/v1/policies', { method: 'POST', headers: baseHeaders, body: JSON.stringify(POLICY) });
  const d = await r.json().catch(() => ({}));
  if (!r.ok) { console.log('โŒ create failed', r.status, JSON.stringify(d).slice(0, 300)); process.exit(1); }
  console.log(`โœ… policy created: id=${d.id || '(see body)'}`);
  console.log(JSON.stringify(d, null, 2));
  console.log('\nNext: APPLY=1 POLICY_ID=' + (d.id || '<id>') + ' node privy-policy.mjs apply  (ONLY after the edge gate passes)');
}

async function showPolicy() {
  if (!POLICY_ID) { console.log('set POLICY_ID'); process.exit(1); }
  const r = await fetch(`https://api.privy.io/v1/policies/${POLICY_ID}`, { headers: baseHeaders });
  console.log(r.status, JSON.stringify(await r.json().catch(() => ({})), null, 2));
}

async function applyPolicy() {
  if (!POLICY_ID) { console.log('set POLICY_ID'); process.exit(1); }
  // Privy policies attach PER-WALLET (not per-signer), so target one embedded wallet at a time
  // (roll out to the tester first, verify copying still works, then the rest).
  const WALLET_ID = process.env.WALLET_ID || '';
  if (!WALLET_ID) { console.log('set WALLET_ID (target embedded wallet id โ€” policies attach per-wallet)'); process.exit(1); }
  console.log(`โš ๏ธ This attaches policy ${POLICY_ID} to wallet ${WALLET_ID}.`);
  console.log('โš ๏ธ RULE: only after the edge gate passes (CLOB creds must be cached โ€” no ClobAuth re-sign).');
  if (!APPLY) { console.log('DRY โ€” re-run with APPLY=1 to actually attach. Aborting.'); return; }
  const privy = new PrivyClient(APP_ID, APP_SECRET, { walletApi: { authorizationPrivateKey: env.PRIVY_AUTHORIZATION_KEY } });
  try {
    const res = await privy.walletApi.updateWallet({ id: WALLET_ID, policyIds: [POLICY_ID] });
    console.log('โœ… updateWallet result:', JSON.stringify(res).slice(0, 400));
  } catch (e) {
    console.log('โŒ updateWallet failed:', e?.message || String(e)); process.exit(1);
  }
  console.log('\nVerify next: live CLOB order should sign โœ…; a deposit-wallet Batch (withdraw) must DENY.');
}

const fn = MODE === 'create' ? createPolicy : MODE === 'show' ? showPolicy : MODE === 'apply' ? applyPolicy : null;
if (!fn) { console.log('usage: node privy-policy.mjs <create|show|apply>  (apply needs APPLY=1 + POLICY_ID)'); process.exit(1); }
fn();