Repository navigation
Expand file tree
/
Copy pathseed.mjs
More file actions
742 lines (677 loc) Β· 37 KB
/
Copy pathseed.mjs
File metadata and controls
742 lines (677 loc) Β· 37 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
#!/usr/bin/env node
/**
* gavel β scripts/seed.mjs
*
* The seeder. Deploys and drives the Safe cast that makes every branch of the
* mechanism demoable on demand. Disclosed in the README: these Safes are ours, and
* the payouts they queue are ours. That disclosure is the point β M1 is *mechanism*
* volume and is labelled as such, never summed with M4's third-party demand volume.
*
* Subcommands
* status read-only. Balances, deploy state, roster. Spends nothing.
* predict compute the 12 CREATE2 addresses without deploying. Spends nothing.
* deploy deploy any Safe in the manifest that is not yet on-chain.
* fund move USDC from O1 into each Safe per the manifest.
* stage propose + sign a payout to threshold, and STOP. Never executes.
* --hostile <variant> stages a queue shaped to force ONE named
* refusal instead of the drainable happy path. See HOSTILE below.
* recycle move drained USDC from PAYEE back to O1, closing the loop.
* govern SPENDS GAS. Executes a Safe config change that invalidates an
* already-signed payout, producing threshold-drift or owner-removed.
*
* Usage
* node scripts/seed.mjs status --chain 11155111
* node scripts/seed.mjs predict --chain 11155111
* node scripts/seed.mjs deploy --chain 11155111 [--only HERO_A,VOL_1] [--yes]
* node scripts/seed.mjs fund --chain 11155111 [--yes]
* node scripts/seed.mjs recycle --chain 11155111 [--amount 5.0] [--yes]
* node scripts/seed.mjs stage --chain 11155111 --safe VOL_2 --hostile below-threshold --yes
*
* Keys never enter this tree. They are parsed out of ~/.config/gavel/seed.txt,
* which is `cast wallet new-mnemonic` output β a human-readable block, NOT
* KEY=value, so it must be parsed and must never be `source`d.
*/
import { readFileSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createPublicClient, createWalletClient, http, parseUnits, formatUnits, getContract, encodeFunctionData } from 'viem';
import { privateKeyToAccount } from 'viem/accounts';
import protocolKit from '@safe-global/protocol-kit';
import SafeApiKit from '@safe-global/api-kit';
// @safe-global/protocol-kit ships CJS with an ESM interop shim, so the class lands
// one level deeper than the documented `import Safe from ...`. Unwrap defensively
// rather than pinning to one shape β a patch release that fixes the interop must
// not break this script.
const Safe = protocolKit?.default?.init ? protocolKit.default
: protocolKit?.init ? protocolKit
: null;
if (!Safe) throw new Error('protocol-kit: could not locate the Safe class export');
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const MANIFEST = JSON.parse(readFileSync(join(ROOT, 'src', 'manifest.json'), 'utf8'));
const SEED_FILE = join(homedir(), '.config', 'gavel', 'seed.txt');
/** Roles in derivation order, m/44'/60'/0'/0/{0..6} β matches specs/cast.md. */
const ROLES = ['O1', 'O2', 'O3', 'O4', 'O5', 'PAYEE', 'ATTACKER'];
const ERC20_ABI = [
{ type: 'function', name: 'balanceOf', stateMutability: 'view', inputs: [{ type: 'address' }], outputs: [{ type: 'uint256' }] },
{ type: 'function', name: 'transfer', stateMutability: 'nonpayable', inputs: [{ type: 'address' }, { type: 'uint256' }], outputs: [{ type: 'bool' }] },
{ type: 'function', name: 'decimals', stateMutability: 'view', inputs: [], outputs: [{ type: 'uint8' }] },
{ type: 'function', name: 'symbol', stateMutability: 'view', inputs: [], outputs: [{ type: 'string' }] },
];
/* ------------------------------------------------------------------ keys */
/**
* Parse `cast wallet new-mnemonic --accounts 7` output into role -> {address, pk}.
* Reads only; never writes, never logs a key.
*/
function loadCast() {
let raw;
try {
raw = readFileSync(SEED_FILE, 'utf8');
} catch {
die(`cannot read ${SEED_FILE}\nGenerate it with:\n umask 077 && cast wallet new-mnemonic --words 12 --accounts 7 > ${SEED_FILE} && chmod 600 ${SEED_FILE}`);
}
const addresses = [...raw.matchAll(/^Address:\s+(0x[0-9a-fA-F]{40})\s*$/gm)].map((m) => m[1]);
const keys = [...raw.matchAll(/^Private key:\s+(0x[0-9a-fA-F]{64})\s*$/gm)].map((m) => m[1]);
if (addresses.length < ROLES.length || keys.length < ROLES.length) {
die(`${SEED_FILE} holds ${addresses.length} accounts; ${ROLES.length} are required (O1..O5, PAYEE, ATTACKER).`);
}
const cast = {};
ROLES.forEach((role, i) => {
const account = privateKeyToAccount(keys[i]);
// The file's own address line must agree with the key. A mismatch means the
// file was hand-edited or truncated, and we refuse rather than sign with a
// key whose identity we cannot confirm.
if (account.address.toLowerCase() !== addresses[i].toLowerCase()) {
die(`${role}: key #${i + 1} derives ${account.address} but the file says ${addresses[i]}.`);
}
// `pk` is held in memory for protocol-kit, which takes a signer key rather than
// a viem account. It is never logged, never written, and never leaves this process.
cast[role] = { address: account.address, account, pk: keys[i] };
});
return cast;
}
/** The Safe Transaction Service API key. Single-value file, read not sourced. */
function loadSafeApiKey() {
const path = join(homedir(), '.config', 'gavel', 'safe-api-key');
try {
return readFileSync(path, 'utf8').trim();
} catch {
die(`cannot read ${path}\nGet a JWT from https://developer.safe.global then:\n umask 077 && pbpaste | tr -d '[:space:]' > ${path} && chmod 600 ${path}`);
}
}
/* --------------------------------------------------------------- helpers */
function die(msg) {
console.error(`\n ERROR ${msg}\n`);
process.exit(1);
}
function parseArgs(argv) {
const cmd = argv[2];
const flags = {};
for (let i = 3; i < argv.length; i++) {
if (argv[i].startsWith('--')) {
const key = argv[i].slice(2);
const next = argv[i + 1];
if (!next || next.startsWith('--')) flags[key] = true;
else { flags[key] = next; i++; }
}
}
return { cmd, flags };
}
function resolveChain(flags) {
const id = String(flags.chain ?? '');
const chain = MANIFEST.chains[id];
if (!chain) {
die(`--chain must be one of: ${Object.keys(MANIFEST.chains).join(', ')}\n` +
` 8453 Base mainnet (judged β real value, real evidence)\n` +
` 11155111 Ethereum Sepolia (rehearsal β never enters receipts.json)`);
}
return { id: Number(id), ...chain };
}
function selectSafes(flags) {
if (!flags.only || flags.only === true) return MANIFEST.safes;
const want = String(flags.only).split(',').map((s) => s.trim());
const picked = MANIFEST.safes.filter((s) => want.includes(s.id));
const missing = want.filter((w) => !MANIFEST.safes.some((s) => s.id === w));
if (missing.length) die(`unknown safe id(s): ${missing.join(', ')}`);
return picked;
}
const fundAmount = (safe, chain) => safe[chain.fundKey] ?? '0.000000';
/** Build a protocol-kit instance for a manifest entry, in predicted (undeployed) mode. */
function predictedKit(safe, chain, cast, signerPk) {
return Safe.init({
provider: chain.rpc,
signer: signerPk,
predictedSafe: {
safeAccountConfig: {
owners: safe.owners.map((o) => cast[o].address),
threshold: safe.threshold,
},
safeDeploymentConfig: { saltNonce: safe.saltNonce },
},
});
}
/* -------------------------------------------------------------- commands */
async function cmdPredict(chain, cast, flags) {
const pk = process.env.GAVEL_SIGNER_PK; // not required for address prediction
console.log(`\n ${chain.name} (${chain.id}) β predicted CREATE2 addresses\n`);
console.log(` ${'SAFE'.padEnd(14)}${'THRESHOLD'.padEnd(11)}${'ADDRESS'.padEnd(44)}ROSTER`);
const out = [];
for (const safe of selectSafes(flags)) {
const kit = await predictedKit(safe, chain, cast, pk);
const address = await kit.getAddress();
out.push({ id: safe.id, address });
const th = `${safe.threshold}-of-${safe.owners.length}`;
console.log(` ${safe.id.padEnd(14)}${th.padEnd(11)}${address.padEnd(44)}${safe.roster ? 'yes' : 'no'}`);
}
console.log(`\n ${out.length} addresses. Deterministic: same mnemonic + same salts => same addresses on every chain.\n`);
return out;
}
async function cmdStatus(chain, cast, flags) {
const client = createPublicClient({ transport: http(chain.rpc) });
const usdc = getContract({ address: chain.usdc, abi: ERC20_ABI, client });
console.log(`\n ${chain.name} (${chain.id}) β role: ${chain.role}`);
if (!chain.receiptsEligible) {
console.log(` NOTE receiptsEligible=false β nothing here may enter receipts.json or EVIDENCE.md.`);
}
console.log(`\n ACCOUNTS`);
console.log(` ${'ROLE'.padEnd(10)}${'ADDRESS'.padEnd(44)}${'ETH'.padEnd(14)}USDC`);
for (const role of ROLES) {
const a = cast[role].address;
const [wei, bal] = await Promise.all([
client.getBalance({ address: a }),
usdc.read.balanceOf([a]).catch(() => 0n),
]);
console.log(` ${role.padEnd(10)}${a.padEnd(44)}${Number(formatUnits(wei, 18)).toFixed(6).padEnd(14)}${formatUnits(bal, 6)}`);
}
console.log(`\n SAFES`);
console.log(` ${'SAFE'.padEnd(14)}${'ADDRESS'.padEnd(44)}${'DEPLOYED'.padEnd(10)}${'USDC'.padEnd(12)}WANT`);
let deployed = 0;
for (const safe of selectSafes(flags)) {
const kit = await predictedKit(safe, chain, cast, undefined);
const address = await kit.getAddress();
const [code, bal] = await Promise.all([
client.getCode({ address }).catch(() => undefined),
usdc.read.balanceOf([address]).catch(() => 0n),
]);
const isDeployed = !!code && code !== '0x';
if (isDeployed) deployed++;
console.log(` ${safe.id.padEnd(14)}${address.padEnd(44)}${(isDeployed ? 'yes' : 'no').padEnd(10)}${formatUnits(bal, 6).padEnd(12)}${fundAmount(safe, chain)}`);
}
console.log(`\n ${deployed}/${selectSafes(flags).length} deployed\n`);
}
async function cmdDeploy(chain, cast, flags) {
const signer = cast.O1; // O1 pays for deploys (seed-data.md Β§6.1)
const client = createPublicClient({ transport: http(chain.rpc) });
const wallet = createWalletClient({ account: signer.account, transport: http(chain.rpc) });
const pending = [];
for (const safe of selectSafes(flags)) {
const kit = await predictedKit(safe, chain, cast, undefined);
const address = await kit.getAddress();
const code = await client.getCode({ address }).catch(() => undefined);
if (code && code !== '0x') continue; // idempotent: skip what already exists
pending.push({ safe, address, kit });
}
if (!pending.length) { console.log(`\n nothing to deploy β all selected Safes already exist.\n`); return; }
console.log(`\n ${chain.name} (${chain.id}) β about to deploy ${pending.length} Safe(s), paid by O1 ${signer.address}\n`);
for (const p of pending) console.log(` ${p.safe.id.padEnd(14)} ${p.address}`);
if (!flags.yes) { console.log(`\n Dry run. Re-run with --yes to broadcast.\n`); return; }
for (const { safe, address, kit } of pending) {
const tx = await kit.createSafeDeploymentTransaction();
const hash = await wallet.sendTransaction({
to: tx.to, data: tx.data, value: BigInt(tx.value || 0), chain: null,
});
const receipt = await client.waitForTransactionReceipt({ hash });
console.log(` ${receipt.status === 'success' ? 'OK ' : 'FAIL'} ${safe.id.padEnd(14)} ${address} ${chain.explorer}/tx/${hash}`);
}
console.log();
}
async function cmdFund(chain, cast, flags) {
const signer = cast.O1;
const client = createPublicClient({ transport: http(chain.rpc) });
const wallet = createWalletClient({ account: signer.account, transport: http(chain.rpc) });
const usdc = getContract({ address: chain.usdc, abi: ERC20_ABI, client });
const plan = [];
for (const safe of selectSafes(flags)) {
const want = parseUnits(fundAmount(safe, chain), 6);
if (want === 0n) continue; // includes BENCH_GS013, empty ON PURPOSE
const kit = await predictedKit(safe, chain, cast, undefined);
const address = await kit.getAddress();
const code = await client.getCode({ address }).catch(() => undefined);
if (!code || code === '0x') { console.log(` skip ${safe.id} β not deployed yet`); continue; }
const have = await usdc.read.balanceOf([address]);
if (have >= want) continue; // idempotent
plan.push({ safe, address, amount: want - have });
}
if (!plan.length) { console.log(`\n nothing to fund β every Safe already holds its manifest amount.\n`); return; }
const total = plan.reduce((a, p) => a + p.amount, 0n);
const held = await usdc.read.balanceOf([signer.address]);
console.log(`\n ${chain.name} (${chain.id}) β funding ${plan.length} Safe(s) from O1`);
for (const p of plan) console.log(` ${p.safe.id.padEnd(14)} ${formatUnits(p.amount, 6).padStart(10)} USDC -> ${p.address}`);
console.log(` ${'TOTAL'.padEnd(14)} ${formatUnits(total, 6).padStart(10)} USDC (O1 holds ${formatUnits(held, 6)})`);
if (held < total) die(`O1 holds ${formatUnits(held, 6)} USDC but ${formatUnits(total, 6)} is required.`);
if (!flags.yes) { console.log(`\n Dry run. Re-run with --yes to broadcast.\n`); return; }
for (const { safe, address, amount } of plan) {
const hash = await wallet.writeContract({
address: chain.usdc, abi: ERC20_ABI, functionName: 'transfer',
args: [address, amount], chain: null,
});
const receipt = await client.waitForTransactionReceipt({ hash });
console.log(` ${receipt.status === 'success' ? 'OK ' : 'FAIL'} ${safe.id.padEnd(14)} ${chain.explorer}/tx/${hash}`);
}
console.log();
}
/**
* recycle β close the loop.
*
* Value only ever flowed one way: O1 -> Safe -> PAYEE. The Sepolia faucet gives
* 20 USDC and the manifest allocates 17 of it, so once the cast had been drained
* once there was nothing left to drain and the volume run stopped at 25 executions
* for want of a return path, not for want of gas. Drains are sponsored through
* KeeperHub; the only real budget here is the faucet.
*
* This moves drained USDC from PAYEE back to O1 so `fund` -> `stage` -> drain can
* run again. It touches no Safe: a Safe's balance is gavel's to move, and moving it
* from here would be the seeder doing the product's job.
*
* This does NOT change what the volume means. It is still mechanism volume from
* Safes we own, disclosed as such, and never summed with third-party volume. The
* loop now recycles; say so wherever the seeding is disclosed.
*/
async function cmdRecycle(chain, cast, flags) {
const from = cast.PAYEE;
const to = cast.O1;
const client = createPublicClient({ transport: http(chain.rpc) });
const wallet = createWalletClient({ account: from.account, transport: http(chain.rpc) });
const usdc = getContract({ address: chain.usdc, abi: ERC20_ABI, client });
const held = await usdc.read.balanceOf([from.address]);
const gas = await client.getBalance({ address: from.address });
console.log(`\n ${chain.name} (${chain.id}) β recycle PAYEE -> O1`);
console.log(` PAYEE ${from.address} ${formatUnits(held, 6)} USDC ${formatUnits(gas, 18)} ETH`);
console.log(` O1 ${to.address}`);
if (held === 0n) { console.log(`\n nothing to recycle β PAYEE holds no USDC.\n`); return; }
const amount = flags.amount ? parseUnits(String(flags.amount), 6) : held;
if (amount > held) die(`asked to recycle ${formatUnits(amount, 6)} USDC but PAYEE holds ${formatUnits(held, 6)}.`);
// PAYEE pays its own gas here β this transfer is not sponsored, unlike the drains.
if (gas === 0n) die(`PAYEE holds no ETH and must pay gas for this transfer. Fund ${from.address} first.`);
console.log(` move ${formatUnits(amount, 6)} USDC`);
if (!flags.yes) { console.log(`\n Dry run. Re-run with --yes to broadcast.\n`); return; }
const hash = await wallet.writeContract({
address: chain.usdc, abi: ERC20_ABI, functionName: 'transfer',
args: [to.address, amount], chain: null,
});
const receipt = await client.waitForTransactionReceipt({ hash });
console.log(` ${receipt.status === 'success' ? 'OK ' : 'FAIL'} ${chain.explorer}/tx/${hash}`);
const after = await usdc.read.balanceOf([to.address]);
console.log(` O1 now holds ${formatUnits(after, 6)} USDC β ready to fund another cycle.\n`);
}
/**
* stage β manufacture the product condition.
*
* Builds a USDC payout, has exactly `threshold` owners sign it, proposes it to the
* Safe Transaction Service, and then STOPS. The transaction is fully authorised and
* deliberately not executed: that is the thing gavel exists to drain, and the thing
* that has to age.
*
* Signing is off-chain and free β no gas, no on-chain transaction. Only the eventual
* execTransaction costs anything, and executing is precisely what we do not do here.
*/
/**
* HOSTILE β queue shapes that force one named refusal.
*
* WHY THESE EXIST. The nine named outcomes were covered by unit tests over
* fixtures and by nothing else: no refusal had ever been OBSERVED against a real
* Safe and a real chain, because the happy path is the only thing `stage` could
* produce. A refusal branch proven only in test/ is a claim about a pure
* function; a refusal branch with a row in docs/outcomes-<chain>.jsonl is a claim
* about this software. Those are different evidence.
*
* WHY THEY GO ON ROSTERED SAFES. `not-on-roster` is the FIRST check in
* assemble.mjs, so every BENCH_* Safe β all of which carry roster:false β refuses
* on the roster gate before reaching the outcome the manifest built it for. The
* bench cast cannot demonstrate its own scenarios. Rather than weaken I3 with a
* bypass flag, the hostile queue is staged on a Safe that is genuinely on the
* roster, and the refusal is genuinely the one under test.
*
* COST AND CONSEQUENCE. A proposal is an off-chain signature: these three cost no
* gas and touch no chain state. But the hostile transaction occupies the Safe's
* CURRENT nonce, so that Safe stops being drainable until the nonce moves β which
* is the point, and is why HERO_A (the demo Safe) and VOL_1 (threshold 1, the
* cheapest volume Safe) are left out of the campaign.
*/
const HOSTILE = {
'below-threshold': {
needs: (safe) => safe.threshold >= 2 || 'needs threshold >= 2; at threshold 1 the proposer alone meets it',
describe: 'sign with the proposer ONLY, leaving the queue short of threshold',
// Nothing to change about the payload β the shortfall IS the shape.
signToThreshold: false,
},
'refund-requested': {
needs: () => true,
describe: 'non-zero gasPrice β the refund drain vector aimed at the executor (I4)',
options: { gasPrice: '1' },
signToThreshold: true,
},
'inner-call-failed': {
needs: (safe) => safe.mustStayEmpty || safe.id === 'BENCH_GS013'
|| 'use BENCH_GS013 β the Safe the manifest keeps deliberately empty for exactly this',
describe: 'a payout the Safe cannot cover: execTransaction succeeds, the inner transfer reverts (GS013)',
// Costs no gas. drain.mjs gate 2 runs a local eth_call, sees execTransaction
// return success=false, and refuses to broadcast -- so the outcome is observed
// WITHOUT burning a nonce or paying for a transaction that moves nothing.
overpay: true,
signToThreshold: true,
},
'delegatecall-refused': {
needs: () => true,
describe: 'operation = 1, a DELEGATECALL into someone else\'s treasury (I5)',
operation: 1,
signToThreshold: true,
},
};
async function cmdStage(chain, cast, flags) {
const id = flags.safe;
if (!id || id === true) die(`--safe <ID> is required, e.g. --safe HERO_A`);
const safe = MANIFEST.safes.find((s) => s.id === id);
if (!safe) die(`unknown safe "${id}"`);
const variant = flags.hostile && flags.hostile !== true ? flags.hostile : null;
if (flags.hostile === true) die(`--hostile needs a variant: ${Object.keys(HOSTILE).join(', ')}`);
const hostile = variant ? HOSTILE[variant] : null;
if (variant && !hostile) die(`unknown --hostile "${variant}". Known: ${Object.keys(HOSTILE).join(', ')}`);
// A hostile stage on an off-roster Safe would be silently pointless: drain.mjs
// refuses on the roster gate first and the outcome under test is never reached.
if (hostile && !safe.roster) {
die(`${id} is roster:false. not-on-roster is checked FIRST, so it would shadow ${variant}. ` +
`Stage hostile queues on a rostered Safe.`);
}
if (hostile) {
const ok = hostile.needs(safe);
if (ok !== true) die(`${id} cannot carry "${variant}": ${ok}`);
}
const client = createPublicClient({ transport: http(chain.rpc) });
const usdc = getContract({ address: chain.usdc, abi: ERC20_ABI, client });
const apiKit = new SafeApiKit({ chainId: BigInt(chain.id), apiKey: loadSafeApiKey() });
// Address resolution goes through the same predicted path as every other command,
// so a staged transaction can never target a Safe the manifest did not describe.
const address = await (await predictedKit(safe, chain, cast, undefined)).getAddress();
const code = await client.getCode({ address }).catch(() => undefined);
if (!code || code === '0x') die(`${id} is not deployed on ${chain.name}. Run: deploy --chain ${chain.id} --yes`);
const held = await usdc.read.balanceOf([address]);
const amount = flags.amount && flags.amount !== true
? parseUnits(String(flags.amount), 6)
: held; // default: pay out everything it holds
// A hostile queue is refused before broadcast by construction, so it never needs
// a balance to be a valid test of the refusal. Requiring one would make these
// scenarios cost money for no reason.
if (amount === 0n && !hostile) die(`${id} holds no USDC β nothing to stage. Run: fund --chain ${chain.id} --yes`);
// overpay: ask for more than the Safe holds, so the ERC-20 transfer must revert.
const payout = hostile?.overpay ? held + parseUnits('1', 6) : (amount === 0n ? 1n : amount);
// The overpay variant is the whole point of inner-call-failed: the transfer must
// exceed the balance so the inner call reverts. Every other path keeps the guard.
if (amount > held && !hostile?.overpay) {
die(`${id} holds ${formatUnits(held, 6)} USDC but ${formatUnits(amount, 6)} was requested.`);
}
const to = cast.PAYEE.address;
const data = encodeFunctionData({ abi: ERC20_ABI, functionName: 'transfer', args: [to, payout] });
console.log(`\n ${chain.name} (${chain.id}) β staging a ${hostile ? `HOSTILE queue (${variant})` : 'payout'} on ${id}`);
console.log(` Safe ${address}`);
console.log(` payout ${formatUnits(payout, 6)} USDC -> PAYEE ${to}`);
console.log(` threshold ${safe.threshold} of ${safe.owners.length}`);
if (hostile) {
console.log(` shape ${hostile.describe}`);
console.log(` expect drain.mjs refuses with "${variant}" and records a row`);
console.log(` NOTE this occupies the CURRENT nonce β ${id} stops being drainable until it moves`);
}
if (!flags.yes) { console.log(`\n Dry run. Re-run with --yes to propose and sign.\n`); return; }
// The proposer must be an owner. Signing order is irrelevant to the service; the
// ASCENDING-owner order checkSignatures requires is imposed later by assemble.mjs.
const [proposer, ...rest] = safe.owners;
const kit = await Safe.init({ provider: chain.rpc, signer: cast[proposer].pk, safeAddress: address });
const safeTransaction = await kit.createTransaction({
transactions: [{
to: chain.usdc, value: '0', data,
...(hostile?.operation !== undefined ? { operation: hostile.operation } : {}),
}],
...(hostile?.options ? { options: hostile.options } : {}),
});
const safeTxHash = await kit.getTransactionHash(safeTransaction);
const proposerSig = await kit.signHash(safeTxHash);
await apiKit.proposeTransaction({
safeAddress: address,
safeTransactionData: safeTransaction.data,
safeTxHash,
senderAddress: cast[proposer].address,
senderSignature: proposerSig.data,
});
console.log(`\n proposed safeTxHash ${safeTxHash}`);
console.log(` signed ${proposer} (proposer)`);
// Confirm with just enough additional owners to reach threshold β no more. An
// over-signed transaction would hide the below-threshold and drift branches we
// need reachable elsewhere in the cast.
// below-threshold is produced by NOT signing: the proposer's lone signature is
// the whole shape, so the confirm loop is skipped rather than short-circuited
// somewhere deeper where it would look like a bug.
const wanted = hostile && hostile.signToThreshold === false ? 0 : safe.threshold - 1;
for (const role of rest.slice(0, wanted)) {
const ownerKit = await Safe.init({ provider: chain.rpc, signer: cast[role].pk, safeAddress: address });
const sig = await ownerKit.signHash(safeTxHash);
await apiKit.confirmTransaction(safeTxHash, sig.data);
console.log(` signed ${role}`);
}
const pending = await apiKit.getPendingTransactions(address);
const staged = pending.results.find((t) => t.safeTxHash === safeTxHash);
console.log(`\n queue depth ${pending.count} Β· confirmations ${staged?.confirmations?.length ?? 0}/${staged?.confirmationsRequired ?? safe.threshold}`);
if (hostile) {
console.log(` HOSTILE QUEUE STAGED β expect drain.mjs to refuse with "${variant}".`);
console.log(` Verify: node scripts/drain.mjs --chain ${chain.id} --address ${address}\n`);
} else {
console.log(` THRESHOLD MET AND DELIBERATELY UNEXECUTED β this is the condition gavel drains.\n`);
}
}
/**
* roster β regenerate src/roster.json from the manifest.
*
* The opt-in list is DERIVED, never hand-kept: a Safe is on the roster because the
* manifest says so, and its address comes from the same CREATE2 prediction every
* other command uses. Hand-editing this file is how a Safe ends up executable that
* nobody decided to make executable.
*/
/**
* govern β the two outcomes that need the Safe's OWN configuration to change.
*
* threshold-drift and owner-removed cannot be staged as a queue shape. They are
* what happens when a payout is signed legitimately and the Safe then changes
* underneath it, so producing them means actually executing a config change
* on-chain. This is the only command in the seeder that spends gas on something
* other than a payout.
*
* THE ORDERING IS THE WHOLE TRICK, and getting it wrong yields a different
* outcome that looks superficially right:
*
* 1. Propose the payout at nonce N+1 and sign it to the CURRENT threshold. The
* Safe Transaction Service stamps confirmationsRequired at proposal time, so
* this transaction permanently remembers that 2 signatures were once enough.
* 2. Propose the config change at nonce N, sign it, and EXECUTE it. The nonce
* advances to N+1 and the payout from step 1 becomes the live one.
* 3. drain.mjs now reads a queue that believes it is ready and a chain that
* disagrees. That disagreement is the outcome.
*
* Do it in the other order and step 1's proposal is stamped with the NEW threshold,
* which produces below-threshold instead of threshold-drift -- a strictly weaker
* result that would still look like a refusal in the log.
*/
const GOVERN = {
// Not a config change: the cheap route to outcome 3. A Safe owner can pre-approve
// a transaction hash ON-CHAIN with approveHash(bytes32) instead of producing an
// ECDSA signature. checkSignatures then accepts a 65-byte word whose v is 1 --
// r is the owner address, s is zero, and nothing is actually verified. That is
// exactly the shape assemble.mjs refuses, and it needs no contract deployment:
// the alternative, a real EIP-1271 CONTRACT_SIGNATURE (v=0), would mean writing
// and deploying a signer contract for the same refusal.
'approve-hash': {
kind: 'approve-hash',
describe: (safe) => `owner ${safe.owners[1]} pre-approves the hash on-chain (v=1) instead of signing`,
produces: 'eip1271-unsupported',
},
'raise-threshold': {
kind: 'config',
describe: (safe) => `raise threshold ${safe.threshold} -> ${safe.threshold + 1} after signing`,
produces: 'threshold-drift',
build: (kit, safe, cast) => kit.createTransaction({
transactions: [{
to: null, value: '0',
data: encodeFunctionData({ abi: SAFE_GOV_ABI, functionName: 'changeThreshold',
args: [BigInt(safe.threshold + 1)] }),
}],
}),
},
'remove-owner': {
kind: 'config',
describe: (safe) => `remove owner ${safe.owners[1]}, whose signature is already on the payout`,
produces: 'owner-removed',
build: (kit, safe, cast) => kit.createTransaction({
transactions: [{
to: null, value: '0',
// prevOwner is the SENTINEL when removing the head of Safe's owner linked
// list. owners[1] is the second owner, so its predecessor is owners[0].
data: encodeFunctionData({ abi: SAFE_GOV_ABI, functionName: 'removeOwner',
args: [cast[safe.owners[0]].address, cast[safe.owners[1]].address, BigInt(safe.threshold - 1)] }),
}],
}),
},
};
const SAFE_GOV_ABI = [
{ type: 'function', name: 'approveHash', stateMutability: 'nonpayable', inputs: [{ type: 'bytes32' }], outputs: [] },
{ type: 'function', name: 'changeThreshold', stateMutability: 'nonpayable', inputs: [{ type: 'uint256' }], outputs: [] },
{ type: 'function', name: 'removeOwner', stateMutability: 'nonpayable', inputs: [{ type: 'address' }, { type: 'address' }, { type: 'uint256' }], outputs: [] },
];
async function cmdGovern(chain, cast, flags) {
const id = flags.safe;
if (!id || id === true) die(`--safe <ID> is required, e.g. --safe BENCH_GOV`);
const safe = MANIFEST.safes.find((s) => s.id === id);
if (!safe) die(`unknown safe "${id}"`);
const action = flags.action && flags.action !== true ? flags.action : null;
const plan = action ? GOVERN[action] : null;
if (!plan) die(`--action needs one of: ${Object.keys(GOVERN).join(', ')}`);
if (!safe.roster) die(`${id} is roster:false β not-on-roster would shadow ${plan.produces}.`);
const client = createPublicClient({ transport: http(chain.rpc) });
const usdc = getContract({ address: chain.usdc, abi: ERC20_ABI, client });
const apiKit = new SafeApiKit({ chainId: BigInt(chain.id), apiKey: loadSafeApiKey() });
const address = await (await predictedKit(safe, chain, cast, undefined)).getAddress();
const held = await usdc.read.balanceOf([address]);
const [proposer, second, ...others] = safe.owners;
console.log(`\n ${chain.name} (${chain.id}) β ${plan.produces} on ${id}`);
console.log(` Safe ${address}`);
console.log(` change ${plan.describe(safe)}`);
console.log(` produces drain.mjs should refuse with "${plan.produces}"`);
console.log(plan.kind === 'approve-hash'
? ` SPENDS GAS β one approveHash from ${second} (auto-funded from ${proposer} if it holds none)`
: ` SPENDS GAS β one execTransaction from ${proposer}`);
if (!flags.yes) { console.log(`\n Dry run. Re-run with --yes.\n`); return; }
const kit = await Safe.init({ provider: chain.rpc, signer: cast[proposer].pk, safeAddress: address });
const startNonce = Number(await kit.getNonce());
// ---- step 1: the payout that the config change will invalidate --------------
const payout = held > 0n ? held : parseUnits('1', 6);
// approve-hash needs the payout at the CURRENT nonce β there is no config change
// coming to advance it. The other two put it at N+1 so the change at N can land
// first and leave it stale.
const payoutNonce = plan.kind === 'approve-hash' ? startNonce : startNonce + 1;
const payoutTx = await kit.createTransaction({
transactions: [{
to: chain.usdc, value: '0',
data: encodeFunctionData({ abi: ERC20_ABI, functionName: 'transfer', args: [cast.PAYEE.address, payout] }),
}],
options: { nonce: payoutNonce },
});
const payoutHash = await kit.getTransactionHash(payoutTx);
await apiKit.proposeTransaction({
safeAddress: address, safeTransactionData: payoutTx.data, safeTxHash: payoutHash,
senderAddress: cast[proposer].address, senderSignature: (await kit.signHash(payoutHash)).data,
});
// approve-hash deliberately stops at the proposer: the SECOND confirmation is
// the on-chain approval below, and signing it here too would make it an ordinary
// EOA signature and produce nothing.
const offChain = plan.kind === 'approve-hash' ? [] : [second, ...others].slice(0, safe.threshold - 1);
for (const role of offChain) {
const ok = await Safe.init({ provider: chain.rpc, signer: cast[role].pk, safeAddress: address });
await apiKit.confirmTransaction(payoutHash, (await ok.signHash(payoutHash)).data);
}
console.log(`\n [1/2] payout proposed at nonce ${payoutNonce}, ${offChain.length + 1} off-chain signature(s) β ${payoutHash.slice(0, 18)}β¦`);
if (plan.kind === 'approve-hash') {
// The approver must be a DIFFERENT owner from the proposer: assemble.mjs
// deduplicates a repeated signer, so O1 signing off-chain and approving
// on-chain would collapse to one signature and produce below-threshold.
// A fresh cast owner has never sent a transaction and holds no ETH, so top it
// up rather than failing with an opaque insufficient-funds error.
const gas = await client.getBalance({ address: cast[second].address });
if (gas < parseUnits('0.002', 18)) {
const funder = createWalletClient({
account: privateKeyToAccount(cast[proposer].pk), transport: http(chain.rpc), chain: undefined,
});
const ft = await funder.sendTransaction({
to: cast[second].address, value: parseUnits('0.005', 18), chain: null,
});
await client.waitForTransactionReceipt({ hash: ft });
console.log(` [1b] gas funded ${second} with 0.005 ETH from ${proposer}`);
}
const wallet = createWalletClient({
account: privateKeyToAccount(cast[second].pk), transport: http(chain.rpc), chain: undefined,
});
const hash = await wallet.sendTransaction({
to: address, chain: null,
data: encodeFunctionData({ abi: SAFE_GOV_ABI, functionName: 'approveHash', args: [payoutHash] }),
});
const rc = await client.waitForTransactionReceipt({ hash });
console.log(` [2/2] approve ${second} called approveHash on-chain β ${rc.status}`);
console.log(` tx ${chain.explorer}/tx/${hash}`);
console.log(`\n The queue now carries one EOA signature and one APPROVED_HASH (v=1).`);
console.log(` Verify: node scripts/drain.mjs --chain ${chain.id} --address ${address}\n`);
return;
}
// ---- step 2: the config change, executed --------------------------------
const govTx = await plan.build(kit, safe, cast);
govTx.data.to = address; // a Safe config change targets the Safe itself
const govHash = await kit.getTransactionHash(govTx);
govTx.addSignature(await kit.signHash(govHash));
for (const role of [second, ...others].slice(0, safe.threshold - 1)) {
const ok = await Safe.init({ provider: chain.rpc, signer: cast[role].pk, safeAddress: address });
govTx.addSignature(await ok.signHash(govHash));
}
const res = await kit.executeTransaction(govTx);
const receipt = await client.waitForTransactionReceipt({ hash: res.hash });
console.log(` [2/2] config EXECUTED ${plan.describe(safe)}`);
console.log(` tx ${chain.explorer}/tx/${res.hash} Β· ${receipt.status}`);
console.log(`\n nonce ${startNonce} -> ${await kit.getNonce()}. The payout is now live and stale.`);
console.log(` Verify: node scripts/drain.mjs --chain ${chain.id} --address ${address}\n`);
}
async function cmdRoster(chain, cast, flags) {
const existing = JSON.parse(readFileSync(join(ROOT, 'src', 'roster.json'), 'utf8'));
const wanted = MANIFEST.safes.filter((s) => s.roster);
const safes = [];
for (const safe of wanted) {
safes.push(await (await predictedKit(safe, chain, cast, undefined)).getAddress());
}
const next = {
...existing,
byChain: { ...(existing.byChain ?? {}), [chain.id]: safes },
};
delete next.chainId; delete next.safes; // supersede the flat single-chain shape
const path = join(ROOT, 'src', 'roster.json');
if (!flags.yes) {
console.log(`\n ${chain.name} (${chain.id}) β ${safes.length} roster Safe(s):`);
wanted.forEach((s, i) => console.log(` ${s.id.padEnd(14)} ${safes[i]}`));
console.log(`\n Dry run. Re-run with --yes to write ${path.replace(ROOT, 'build')}.\n`);
return;
}
writeFileSync(path, JSON.stringify(next, null, 2) + '\n');
console.log(`\n wrote ${safes.length} roster Safe(s) for chain ${chain.id} to build/src/roster.json`);
console.log(` re-run scripts/sync.mjs to embed them in the workflow\n`);
}
/* ------------------------------------------------------------------ main */
const { cmd, flags } = parseArgs(process.argv);
const chain = resolveChain(flags);
const cast = loadCast();
const COMMANDS = { status: cmdStatus, predict: cmdPredict, deploy: cmdDeploy, fund: cmdFund, stage: cmdStage, recycle: cmdRecycle, roster: cmdRoster, govern: cmdGovern };
if (!COMMANDS[cmd]) {
die(`unknown command "${cmd ?? ''}". Expected one of: ${Object.keys(COMMANDS).join(', ')}`);
}
await COMMANDS[cmd](chain, cast, flags);