/** Detach the Variant A "polycopy-orders-only" policy from every embedded wallet that carries
* a policy (restores pre-15-Jun behaviour: auto-redeem / wrap / approve work again).
*
* ⚠️ Variant B (Rick, 16 Jun): fast restore. This RE-OPENS the withdraw vector — the
* delegate can move funds again. Revisit policies later (secure-redeem plan). See MEMORY.
*
* Enumerates our own users via the copy /wallets feed (Privy has no app-wide wallet list),
* resolves each embedded wallet id via Privy getUser, reads its policy_ids, detaches.
*
* Modes (default = DRY, lists wallets + their policy_ids only):
* SUBS_URL=… SERVICE_TOKEN=… node privy-detach.mjs → list, no change
* APPLY=1 SUBS_URL=… SERVICE_TOKEN=… node privy-detach.mjs → set policyIds:[] on policied wallets
*
* Run on the main/DE server (.env.privy + reachable poly-dev API). */
import fs from 'node:fs';
import { PrivyClient } from '@privy-io/server-auth';
const APPLY = process.env.APPLY === '1';
const SUBS_URL = process.env.SUBS_URL || 'https://poly-dev.szhub.space/api/copy/active';
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, APP_SECRET = env.PRIVY_APP_SECRET;
const SERVICE_TOKEN = process.env.SERVICE_TOKEN || env.COPY_SERVICE_TOKEN;
if (!APP_ID || !APP_SECRET) { console.log('missing PRIVY_APP_ID / PRIVY_APP_SECRET'); process.exit(1); }
const baseHeaders = { 'content-type': 'application/json', 'privy-app-id': APP_ID, authorization: 'Basic ' + Buffer.from(`${APP_ID}:${APP_SECRET}`).toString('base64') };
const privy = new PrivyClient(APP_ID, APP_SECRET, { walletApi: { authorizationPrivateKey: env.PRIVY_AUTHORIZATION_KEY } });
async function listUsers() {
const out = new Set();
for (const path of ['/wallets', '/active']) {
try {
const url = SUBS_URL.replace(/\/active$/, path);
const r = await fetch(url, { headers: { 'x-service-token': SERVICE_TOKEN }, signal: AbortSignal.timeout(12000) });
if (!r.ok) { console.log(`[feed ${path}] http`, r.status); continue; }
const d = await r.json();
for (const s of (d.data || [])) if (s.userAddress) out.add(String(s.userAddress).toLowerCase());
} catch (e) { console.log(`[feed ${path}] err`, e.message); }
}
return [...out];
}
const users = await listUsers();
console.log(`users from feed: ${users.length}`);
const affected = [];
for (const ua of users) {
let walletId = null, addr = null, walletPolicies = [], signers = [];
try {
const pu = await privy.getUser(ua);
const w = (pu.linkedAccounts || []).find((a) => a.type === 'wallet' && a.walletClientType === 'privy');
if (!w) { console.log(` ${ua.slice(0, 20)} — no privy wallet`); continue; }
walletId = w.id; addr = w.address;
} catch (e) { console.log(` ${ua.slice(0, 20)} getUser err: ${e.message}`); continue; }
if (!walletId) { console.log(` ${ua.slice(0, 20)} (${addr}) — wallet has no id field`); continue; }
try {
const r = await fetch(`https://api.privy.io/v1/wallets/${walletId}`, { headers: baseHeaders });
const wd = await r.json().catch(() => ({}));
walletPolicies = wd.policy_ids || [];
signers = wd.additional_signers || [];
} catch (e) { console.log(` ${walletId} GET wallet err: ${e.message}`); }
// The Variant A policy is attached as an override on the server-delegate additional signer.
const policiedSigners = signers.filter((s) => Array.isArray(s.override_policy_ids) && s.override_policy_ids.length);
const hasRestriction = walletPolicies.length || policiedSigners.length;
const detail = `policy_ids=${JSON.stringify(walletPolicies)} signers=[${signers.map((s) => `${s.signer_id.slice(0, 8)}:override=${JSON.stringify(s.override_policy_ids || [])}`).join(', ')}]`;
console.log(` ${ua.slice(0, 20)} wallet=${walletId} ${addr} ${hasRestriction ? '🔒' : 'open'} ${detail}`);
if (hasRestriction) affected.push({ ua, walletId, addr, signers });
}
console.log(`\n${affected.length} wallet(s) carry a restriction.`);
if (!affected.length) { console.log('nothing to detach.'); process.exit(0); }
if (!APPLY) { console.log('DRY — re-run with APPLY=1 to clear wallet policy + signer override policies (keeps delegate as signer).'); process.exit(0); }
for (const w of affected) {
// Keep every additional signer, but strip its override policies so the delegate can sign
// redeem/approve/wrap again. Also clear any wallet-level policy_ids.
const additionalSigners = (w.signers || []).map((s) => ({ signerId: s.signer_id, overridePolicyIds: [] }));
try {
const res = await privy.walletApi.updateWallet({ id: w.walletId, policyIds: [], additionalSigners });
console.log(`✅ detached ${w.walletId} (${w.addr}) → signers=${JSON.stringify(res?.additionalSigners ?? res?.additional_signers ?? [])}`);
} catch (e) { console.log(`❌ updateWallet failed ${w.walletId}: ${e?.message || e}`); }
}
console.log('\nVerify next: run deposit-watch LIVE → redeem must CONFIRM (no policy-violation).');