Skip to content

Commit bbad8ca

Browse files
BotID Developerclaude
andcommitted
feat(a2a): orchestrator + JSONL run logs
a2a_orchestrate sequences verify_safe -> inventory -> secure_sweep(dry) -> lp_unwind(dry) -> prep_eis_deploy, emitting one JSONL line per step and aborting on the first failure. No flag combination broadcasts. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent f302726 commit bbad8ca

3 files changed

Lines changed: 334 additions & 0 deletions

File tree

scripts/a2a_orchestrate.cjs

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
#!/usr/bin/env node
2+
/**
3+
* C1 — a2a_orchestrate: single entry point running every READ-ONLY / DRY-RUN
4+
* step in dependency order. No flag combination here can broadcast — each
5+
* broadcast remains a separate, manually-gated, owner-run command.
6+
*
7+
* Order: verify_safe -> inventory -> secure_sweep(dry) -> lp_unwind(dry) -> prep_eis_deploy
8+
* Output: one JSONL line per step to out/a2a_run_<ISO_TS>.jsonl, final a2a_summary line.
9+
* On any non-OK step: abort the rest, still emit the summary with the failure reason.
10+
*
11+
* Env: SAFE_ADDRESS (required), FROM_ADDRESS (default 0x9545…6956),
12+
* POOL_ADDRESS (for lp_unwind), DEPLOYER_ADDRESS (for prep_eis_deploy), RPC.
13+
*/
14+
const fs = require('node:fs');
15+
const path = require('node:path');
16+
const { execFileSync } = require('node:child_process');
17+
18+
const ts = () => new Date().toISOString();
19+
const FROM = process.env.FROM_ADDRESS || '0x9545e2439c5c75d3aA723AcaC1AA6B0fa1DB6956';
20+
const SAFE = process.env.SAFE_ADDRESS || '';
21+
22+
const runDir = path.join(process.cwd(), 'out');
23+
fs.mkdirSync(runDir, { recursive: true });
24+
fs.mkdirSync(path.join(process.cwd(), '.yennefer-cache'), { recursive: true });
25+
const runFile = path.join(runDir, `a2a_run_${ts().replace(/[:.]/g, '-')}.jsonl`);
26+
const append = (obj) => fs.appendFileSync(runFile, JSON.stringify(obj) + '\n');
27+
28+
function lastJsonLine(text) {
29+
const lines = String(text || '').trim().split('\n').filter(Boolean);
30+
for (let i = lines.length - 1; i >= 0; i--) {
31+
try { return JSON.parse(lines[i]); } catch { /* not json */ }
32+
}
33+
return null;
34+
}
35+
36+
// Run a child step; returns {ok, line}. Never throws.
37+
function runStep(name, file, args, extraEnv, cacheTo) {
38+
const env = { ...process.env, ...extraEnv };
39+
let stdout = '', ok = false;
40+
try {
41+
stdout = execFileSync('node', [file, ...args], { env, encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'] });
42+
ok = true;
43+
} catch (e) {
44+
stdout = (e.stdout || '').toString();
45+
ok = false;
46+
}
47+
if (cacheTo) {
48+
try { fs.writeFileSync(cacheTo, JSON.stringify({ step: name, raw: stdout, ts: ts() }, null, 2)); } catch { /* ignore */ }
49+
}
50+
const emitted = lastJsonLine(stdout);
51+
const line = emitted && emitted.step
52+
? { ...emitted, ok: emitted.ok !== undefined ? emitted.ok : ok }
53+
: { step: name, ok, ts: ts() };
54+
append(line);
55+
return { ok: line.ok === true, line };
56+
}
57+
58+
function summarize(steps, ok, nextActions, failReason) {
59+
const summary = {
60+
step: 'a2a_summary', ok, ts: ts(),
61+
run_file: path.relative(process.cwd(), runFile),
62+
steps: steps.map((s) => ({ step: s.line.step, ok: s.ok })),
63+
next_actions: nextActions,
64+
...(failReason ? { fail_reason: failReason } : {}),
65+
};
66+
append(summary);
67+
process.stdout.write(JSON.stringify(summary) + '\n');
68+
process.exit(ok ? 0 : 1);
69+
}
70+
71+
function main() {
72+
if (!SAFE || !/^0x[0-9a-fA-F]{40}$/.test(SAFE)) {
73+
append({ step: 'a2a_preflight', ok: false, ts: ts(), reason: 'SAFE_ADDRESS unset/malformed' });
74+
return summarize([], false, ['Deploy the Safe (docs/safe-setup.md), then export SAFE_ADDRESS'], 'SAFE_ADDRESS unset');
75+
}
76+
77+
const cacheDir = path.join(process.cwd(), '.yennefer-cache');
78+
const steps = [];
79+
80+
// 1. verify_safe — gate for everything downstream.
81+
const vs = runStep('verify_safe', 'scripts/verify_safe.cjs', [], { SAFE_ADDRESS: SAFE });
82+
steps.push(vs);
83+
if (!vs.ok) {
84+
return summarize(steps, false,
85+
['Safe failed verification — confirm it is deployed on Base (chainId 8453), 2-of-3, via docs/safe-setup.md, then re-run'],
86+
'verify_safe failed');
87+
}
88+
89+
// 2. inventory (read-only) — source wallet holdings.
90+
steps.push(runStep('inventory', 'scripts/inventory.cjs', [FROM], {}, path.join(cacheDir, 'inventory.json')));
91+
92+
// 3. secure_sweep dry-run -> Safe.
93+
steps.push(runStep('secure_sweep', 'scripts/secure_sweep.cjs', ['--to', SAFE, '--from', FROM], {}));
94+
95+
// 4. lp_unwind dry-run -> Safe.
96+
steps.push(runStep('lp_unwind', 'scripts/lp_unwind.cjs', [], { SAFE_ADDRESS: SAFE, TO_ADDRESS: SAFE, FROM_ADDRESS: FROM }));
97+
98+
// 5. prep_eis_deploy (treasurySink = Safe).
99+
steps.push(runStep('prep_eis_deploy', 'scripts/prep_eis_deploy.cjs', [], { SAFE_ADDRESS: SAFE }));
100+
101+
const allOk = steps.every((s) => s.ok);
102+
const nextActions = [];
103+
if (!steps.find((s) => s.line.step === 'lp_unwind')?.ok) {
104+
nextActions.push('Set POOL_ADDRESS to the real Aerodrome LP token (not the router) and re-run lp_unwind');
105+
}
106+
if (!steps.find((s) => s.line.step === 'prep_eis_deploy')?.ok) {
107+
nextActions.push('Add+compile EulersIdentitySynthesis (git apply patches/eis-immutable-sink.patch; npx hardhat compile), set DEPLOYER_ADDRESS');
108+
}
109+
nextActions.push('Review out/*.json plans; run each broadcast manually with your Ledger + the per-script gate');
110+
111+
summarize(steps, allOk, nextActions, allOk ? null : 'one or more dry-run steps reported not-ok');
112+
}
113+
114+
main();

scripts/inventory.cjs

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
#!/usr/bin/env node
2+
/**
3+
* READ-ONLY asset inventory for a Base address.
4+
* No private key, no signing, no transactions. Safe to run anytime.
5+
*
6+
* Usage:
7+
* node scripts/inventory.cjs 0xWALLET [0xWALLET2 ...]
8+
* BASE_RPC_URL=https://... node scripts/inventory.cjs 0xWALLET
9+
*/
10+
const { ethers } = require('ethers');
11+
12+
const RPC = process.env.BASE_MAINNET_RPC || process.env.BASE_RPC_URL || 'https://mainnet.base.org';
13+
14+
// Known Base tokens/positions to probe. Extend freely.
15+
const TOKENS = [
16+
{ sym: 'QFLOP', addr: '0xa8F5e136aa74803B8DB377a14f79F6c8Dd3959c7' },
17+
{ sym: 'wQFLOP', addr: '0x69262A2D7c92c074729823B654fE7E4Cdb749747' },
18+
{ sym: 'WETH', addr: '0x4200000000000000000000000000000000000006' },
19+
{ sym: 'AERO-LP(wQFLOP/WETH)', addr: '0x4aBC6D796cd036b6f1E433A97F9784a00f90C53e' },
20+
];
21+
22+
const ERC20_ABI = [
23+
'function balanceOf(address) view returns (uint256)',
24+
'function decimals() view returns (uint8)',
25+
'function symbol() view returns (string)',
26+
];
27+
28+
async function inventory(provider, address) {
29+
console.log(`\n=== ${address} ===`);
30+
const eth = await provider.getBalance(address);
31+
console.log(` ETH: ${ethers.formatEther(eth)}`);
32+
33+
for (const t of TOKENS) {
34+
try {
35+
const c = new ethers.Contract(t.addr, ERC20_ABI, provider);
36+
const [bal, dec] = await Promise.all([c.balanceOf(address), c.decimals().catch(() => 18)]);
37+
const human = ethers.formatUnits(bal, dec);
38+
const flag = bal > 0n ? ' <-- nonzero' : '';
39+
console.log(` ${t.sym} (${t.addr}): ${human}${flag}`);
40+
} catch (e) {
41+
console.log(` ${t.sym} (${t.addr}): <read error: ${e.shortMessage || e.message}>`);
42+
}
43+
}
44+
}
45+
46+
async function main() {
47+
const targets = process.argv.slice(2);
48+
if (targets.length === 0) {
49+
console.error('usage: node scripts/inventory.cjs 0xWALLET [0xWALLET2 ...]');
50+
process.exit(2);
51+
}
52+
const provider = new ethers.JsonRpcProvider(RPC);
53+
const net = await provider.getNetwork();
54+
console.log(`RPC: ${RPC} chainId: ${net.chainId}`);
55+
for (const addr of targets) {
56+
if (!/^0x[0-9a-fA-F]{40}$/.test(addr)) {
57+
console.log(`\n=== ${addr} ===\n SKIP: not a valid address`);
58+
continue;
59+
}
60+
await inventory(provider, ethers.getAddress(addr));
61+
}
62+
console.log('\n(read-only — no transactions sent)');
63+
}
64+
65+
main().catch((e) => { console.error(e); process.exit(1); });

scripts/secure_sweep.cjs

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
#!/usr/bin/env node
2+
/**
3+
* secure_sweep — evacuate assets from a (possibly compromised) hot wallet to a
4+
* secure destination (your Ledger / Safe), as part of key rotation.
5+
*
6+
* SAFETY MODEL:
7+
* - DRY-RUN by default: builds + simulates + prints. Sends NOTHING.
8+
* - Broadcasting is DOUBLE-GATED and must be run BY YOU:
9+
* --broadcast AND env I_UNDERSTAND_IRREVERSIBLE=yes
10+
* - The hot key is read from YOUR env (ETH_PRIVATE_KEY) at run time. This
11+
* script (and Claude) never store, log, or transmit it anywhere.
12+
* - Transfers are irreversible. Review the dry-run plan before broadcasting.
13+
*
14+
* Usage (dry-run, no key needed):
15+
* node scripts/secure_sweep.cjs --to 0xLEDGER --from 0xHOTWALLET
16+
*
17+
* Usage (broadcast — YOU run this, with your key in env):
18+
* ETH_PRIVATE_KEY=0x... I_UNDERSTAND_IRREVERSIBLE=yes \
19+
* node scripts/secure_sweep.cjs --to 0xLEDGER --broadcast
20+
*
21+
* Note: ERC-20 transfers cost gas — the hot wallet needs enough ETH first.
22+
* LP positions (Aerodrome) must be withdrawn via the router separately;
23+
* this script handles native ETH + ERC-20 balances only.
24+
*/
25+
const { ethers } = require('ethers');
26+
27+
const RPC = process.env.BASE_MAINNET_RPC || process.env.BASE_RPC_URL || 'https://mainnet.base.org';
28+
29+
const DEFAULT_TOKENS = {
30+
QFLOP: '0xa8F5e136aa74803B8DB377a14f79F6c8Dd3959c7',
31+
wQFLOP: '0x69262A2D7c92c074729823B654fE7E4Cdb749747',
32+
WETH: '0x4200000000000000000000000000000000000006',
33+
};
34+
35+
const ERC20_ABI = [
36+
'function balanceOf(address) view returns (uint256)',
37+
'function decimals() view returns (uint8)',
38+
'function transfer(address to, uint256 amount) returns (bool)',
39+
];
40+
41+
function parseArgs(argv) {
42+
const a = { broadcast: false, to: null, from: null, tokens: Object.values(DEFAULT_TOKENS) };
43+
for (let i = 0; i < argv.length; i++) {
44+
if (argv[i] === '--broadcast') a.broadcast = true;
45+
else if (argv[i] === '--to') a.to = argv[++i];
46+
else if (argv[i] === '--from') a.from = argv[++i];
47+
else if (argv[i] === '--tokens') a.tokens = argv[++i].split(',').map((s) => s.trim());
48+
}
49+
return a;
50+
}
51+
52+
async function main() {
53+
const args = parseArgs(process.argv.slice(2));
54+
const provider = new ethers.JsonRpcProvider(RPC);
55+
56+
if (!args.to || !/^0x[0-9a-fA-F]{40}$/.test(args.to)) {
57+
console.error('ERROR: --to <destination address> is required (your Ledger/Safe).');
58+
process.exit(2);
59+
}
60+
const dest = ethers.getAddress(args.to);
61+
62+
// Resolve the source. Broadcast derives it from the key; dry-run can use --from.
63+
let signer = null;
64+
let from;
65+
const rawKey = process.env.ETH_PRIVATE_KEY || process.env.BASE_PRIVATE_KEY || '';
66+
if (/^0x[0-9a-fA-F]{64}$/.test(rawKey)) {
67+
signer = new ethers.Wallet(rawKey, provider);
68+
from = await signer.getAddress();
69+
} else if (args.from && /^0x[0-9a-fA-F]{40}$/.test(args.from)) {
70+
from = ethers.getAddress(args.from);
71+
} else {
72+
console.error('ERROR: provide --from <addr> for dry-run, or set ETH_PRIVATE_KEY to broadcast.');
73+
process.exit(2);
74+
}
75+
76+
const net = await provider.getNetwork();
77+
console.log(`RPC ${RPC} chainId ${net.chainId}`);
78+
console.log(`FROM ${from}`);
79+
console.log(`TO ${dest}`);
80+
console.log(`MODE ${args.broadcast ? 'BROADCAST' : 'DRY-RUN'}\n`);
81+
82+
if (from.toLowerCase() === dest.toLowerCase()) {
83+
console.error('ERROR: source and destination are identical. Aborting.');
84+
process.exit(2);
85+
}
86+
87+
const feeData = await provider.getFeeData();
88+
const gasPrice = feeData.maxFeePerGas || feeData.gasPrice || 0n;
89+
const plan = [];
90+
91+
// ERC-20 balances first (these need gas to move).
92+
for (const addr of args.tokens) {
93+
if (!/^0x[0-9a-fA-F]{40}$/.test(addr)) continue;
94+
try {
95+
const c = new ethers.Contract(addr, ERC20_ABI, provider);
96+
const [bal, dec] = await Promise.all([c.balanceOf(from), c.decimals().catch(() => 18)]);
97+
if (bal > 0n) {
98+
let gas = 65000n;
99+
try { gas = await c.transfer.estimateGas(dest, bal, { from }); } catch { /* keep default */ }
100+
plan.push({ kind: 'ERC20', token: addr, amount: bal.toString(), human: ethers.formatUnits(bal, dec), gas });
101+
}
102+
} catch (e) {
103+
console.log(` (skip ${addr}: ${e.shortMessage || e.message})`);
104+
}
105+
}
106+
107+
// Native ETH last — sweep balance minus a gas reserve for the ERC-20 txs above.
108+
const ethBal = await provider.getBalance(from);
109+
const erc20GasCost = plan.reduce((s, p) => s + (p.gas || 0n), 0n) * gasPrice;
110+
const ethTransferGas = 21000n;
111+
const reserve = erc20GasCost + ethTransferGas * gasPrice;
112+
const ethToSend = ethBal > reserve ? ethBal - reserve : 0n;
113+
114+
console.log('PLANNED TRANSFERS:');
115+
for (const p of plan) console.log(` ERC20 ${p.human} (${p.token}) ~gas ${p.gas}`);
116+
console.log(` ETH ${ethers.formatEther(ethToSend)} (after gas reserve ${ethers.formatEther(reserve)})`);
117+
118+
// Sufficiency check — the classic trap: tokens present, no gas to move them.
119+
if (plan.length > 0 && ethBal < erc20GasCost) {
120+
console.log(`\n⚠️ INSUFFICIENT GAS: holding ${plan.length} token balance(s) but only ` +
121+
`${ethers.formatEther(ethBal)} ETH; need ~${ethers.formatEther(erc20GasCost)} ETH to move them. ` +
122+
`Fund ${from} with a little ETH first.`);
123+
}
124+
125+
if (!args.broadcast) {
126+
console.log('\nDRY-RUN complete. Nothing sent. Re-run with --broadcast (and the safety env) to execute.');
127+
return;
128+
}
129+
130+
// ── BROADCAST PATH (gated; intended to be run by the asset owner only) ──
131+
if (process.env.I_UNDERSTAND_IRREVERSIBLE !== 'yes') {
132+
console.error('\nREFUSED: --broadcast requires env I_UNDERSTAND_IRREVERSIBLE=yes. ' +
133+
'These transfers are irreversible. Aborting.');
134+
process.exit(1);
135+
}
136+
if (!signer) {
137+
console.error('REFUSED: no signing key in env. Set ETH_PRIVATE_KEY to broadcast.');
138+
process.exit(1);
139+
}
140+
141+
for (const p of plan) {
142+
const c = new ethers.Contract(p.token, ERC20_ABI, signer);
143+
const tx = await c.transfer(dest, BigInt(p.amount));
144+
console.log(` sent ERC20 ${p.human} (${p.token}) tx ${tx.hash}`);
145+
await tx.wait();
146+
}
147+
if (ethToSend > 0n) {
148+
const tx = await signer.sendTransaction({ to: dest, value: ethToSend });
149+
console.log(` sent ETH ${ethers.formatEther(ethToSend)} tx ${tx.hash}`);
150+
await tx.wait();
151+
}
152+
console.log('\nSweep complete. Verify on BaseScan, then retire the old key.');
153+
}
154+
155+
main().catch((e) => { console.error(e); process.exit(1); });

0 commit comments

Comments
 (0)