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