morphit/apps/indexer/scripts/release-broadcast.ts

205 lines
8.2 KiB
TypeScript

/**
* release-broadcast (cp317) — sign + broadcast a morphit_release_v1 op.
*
* ┌─────────────────────────────────────────────────────────────┐
* │ LAPTOP ONLY. This uses the @morphit PRIVATE posting key │
* │ (the WIF), which by design lives OFF the production │
* │ server. Never run this on the VPS. │
* └─────────────────────────────────────────────────────────────┘
*
* Pipeline:
* 1) Build the payload (pre-filled with the canonical treasury):
* npx tsx apps/indexer/scripts/release-build-payload.ts > release.json
* 2) PREVIEW it — shows the exact op, asks for NO key, sends nothing:
* npx tsx apps/indexer/scripts/release-broadcast.ts release.json --dry-run
* 3) Sign + broadcast for real (prompts for the key, masked):
* npx tsx apps/indexer/scripts/release-broadcast.ts release.json
*
* Flags:
* --dry-run Print the exact op and exit. No key, no network.
* --signer <acct> Signing account (default: morphit).
* --node <url> Override the RPC node(s) (default: the project's
* DEFAULT_BLURT_RPC_ENDPOINTS, tried in order).
*
* The @morphit PRIVATE posting key (the WIF) is read from a MASKED
* prompt at runtime — never a
* file, never an env var (which would leak to shell history / `ps`),
* never logged.
*/
import { readFileSync } from 'node:fs';
import { createInterface } from 'node:readline';
import { Client, PrivateKey } from '@beblurt/dblurt';
import { DEFAULT_BLURT_RPC_ENDPOINTS } from '@morphit/operator-config';
import {
buildReleaseCustomJsonOp,
RELEASE_SIGNER_DEFAULT,
RELEASE_OP_ID
} from '../src/blurt/releaseBroadcastOp.ts';
function errMsg(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}
function die(msg: string): never {
process.stderr.write(`\n✗ ${msg}\n`);
process.exit(1);
}
// ── argv ──────────────────────────────────────────────────────────
const argv = process.argv.slice(2);
let dryRun = false;
let signer = RELEASE_SIGNER_DEFAULT;
let nodeOverride: string | null = null;
let fileArg: string | null = null;
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--dry-run') dryRun = true;
else if (a === '--signer') signer = argv[++i] ?? signer;
else if (a === '--node') nodeOverride = argv[++i] ?? null;
else if (!a.startsWith('--') && fileArg === null) fileArg = a;
}
if (!fileArg) {
die(
'usage: tsx release-broadcast.ts <release.json> [--dry-run] [--signer <acct>] [--node <url>]\n' +
' build the file first: tsx release-build-payload.ts > release.json'
);
}
// ── read + validate + shape the op (pure; throws on any problem) ───
let payloadJson: string;
try {
payloadJson = readFileSync(fileArg, 'utf-8');
} catch (e) {
die(`cannot read ${fileArg}: ${errMsg(e)}`);
}
// cp474 — annotate rather than leave `op` implicitly `any`. This is the
// laptop-only release-broadcast CLI: the op it builds is what gets SIGNED and
// pushed on-chain, so `any` here erased type checking on the one payload in
// the repo that is irreversible once broadcast.
let op: ReturnType<typeof buildReleaseCustomJsonOp>;
try {
op = buildReleaseCustomJsonOp(payloadJson, signer);
} catch (e) {
die(errMsg(e));
}
const nodes = nodeOverride ? [nodeOverride] : [...DEFAULT_BLURT_RPC_ENDPOINTS];
process.stderr.write(
'\n┌─────────────────────────────────────────────────────────────┐\n' +
'│ release-broadcast — LAPTOP ONLY (uses the @morphit │\n' +
'│ PRIVATE posting key / WIF). Never run on the server. │\n' +
'└─────────────────────────────────────────────────────────────┘\n\n'
);
process.stderr.write(`Operation id : ${op.id}\n`);
process.stderr.write(`Signed by : @${op.required_posting_auths[0]} (posting authority)\n`);
process.stderr.write(`RPC node(s) : ${nodes.join(', ')}\n`);
process.stderr.write('\nExact json that will be signed + broadcast:\n');
process.stderr.write(`${op.json}\n`);
// ── dry-run: stop here. No key requested, nothing sent. ───────────
if (dryRun) {
process.stderr.write('\n--dry-run: NOTHING was broadcast and NO key was requested.\n');
process.stderr.write('Re-run without --dry-run to sign + broadcast for real.\n');
process.exit(0);
}
// ── masked prompt helpers ──────────────────────────────────────────
function askHidden(query: string): Promise<string> {
// stderr, not stdout: it's unbuffered and it's where the rest of the
// ceremony prints, so the prompt always shows immediately (a plain
// stdout write with no newline could sit invisibly in a buffer and
// look like the script had stalled).
process.stderr.write(query);
return new Promise((resolve) => {
const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
// Suppress all keystroke echo so the WIF never appears on screen.
(rl as unknown as { _writeToOutput: (s: string) => void })._writeToOutput = () => {};
rl.question('', (ans) => {
rl.close();
process.stderr.write('\n');
resolve(ans.trim());
});
});
}
function ask(query: string): Promise<string> {
return new Promise((resolve) => {
const rl = createInterface({ input: process.stdin, output: process.stdout });
rl.question(query, (ans) => {
rl.close();
resolve(ans.trim());
});
});
}
async function main(): Promise<void> {
const confirm = await ask(
`\nType the signer account name to confirm broadcast (or anything else to abort): `
);
if (confirm !== op.required_posting_auths[0]) {
die('aborted (confirmation did not match the signer account name).');
}
const wif = await askHidden(
`\n→ NOW PASTE the @${op.required_posting_auths[0]} PRIVATE posting key` +
` (the WIF — it starts with "5") and press Enter.\n` +
` Nothing will show as you paste it — that is intentional; the key stays hidden.\n` +
` key> `
);
if (!wif.startsWith('5') || wif.length < 50) {
die('that does not look like a Blurt WIF private key (expected a "5..." string).');
}
let priv: PrivateKey;
try {
priv = PrivateKey.fromString(wif);
} catch (e) {
die(`could not parse the key: ${errMsg(e)}`);
}
// Show the derived public key so the operator can eyeball it
// against @morphit's known posting pubkey before sending. (We do
// NOT print the private key, ever.)
try {
const pub = priv.createPublic('BLT').toString();
process.stderr.write(`\nDerived public key: ${pub}\n`);
} catch {
/* non-fatal — proceed; the broadcast itself will fail loudly if
the key is wrong for the account's posting authority. */
}
const go = await ask(
`Broadcast morphit_release_v1 signed by @${op.required_posting_auths[0]} now? (type "yes"): `
);
if (go !== 'yes') die('aborted.');
const opData = {
required_auths: [...op.required_auths],
required_posting_auths: [...op.required_posting_auths],
id: op.id,
json: op.json
};
let lastErr: unknown;
for (const url of nodes) {
try {
process.stderr.write(`\nBroadcasting via ${url}\n`);
const client = new Client(url, { timeout: 20_000 });
const conf = (await client.broadcast.customJson(opData, priv)) as {
id?: string;
block_num?: number;
};
process.stdout.write(
`\n✓ Broadcast accepted.\n trx_id : ${conf.id ?? '(unknown)'}\n` +
` block_num : ${conf.block_num ?? '(pending)'}\n` +
` op id : ${RELEASE_OP_ID}\n\n` +
'Every Morphit instance picks up the chain-pinned treasury within a block.\n'
);
return;
} catch (e) {
lastErr = e;
process.stderr.write(`${url}: ${errMsg(e)}\n`);
}
}
die(`all RPC nodes failed. Last error: ${errMsg(lastErr)}`);
}
void main();