@gatedpath/preflight 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +189 -0
  3. package/dist/bip39-words.d.ts +1 -0
  4. package/dist/bip39-words.js +132 -0
  5. package/dist/checks/admin.d.ts +24 -0
  6. package/dist/checks/admin.js +81 -0
  7. package/dist/checks/balance.d.ts +3 -0
  8. package/dist/checks/balance.js +39 -0
  9. package/dist/checks/chain-id.d.ts +2 -0
  10. package/dist/checks/chain-id.js +11 -0
  11. package/dist/checks/compiler.d.ts +5 -0
  12. package/dist/checks/compiler.js +20 -0
  13. package/dist/checks/deployer.d.ts +14 -0
  14. package/dist/checks/deployer.js +53 -0
  15. package/dist/checks/git-secrets.d.ts +14 -0
  16. package/dist/checks/git-secrets.js +91 -0
  17. package/dist/checks/slither.d.ts +11 -0
  18. package/dist/checks/slither.js +119 -0
  19. package/dist/cli.d.ts +8 -0
  20. package/dist/cli.js +131 -0
  21. package/dist/config.d.ts +25 -0
  22. package/dist/config.js +197 -0
  23. package/dist/doctor-cli.d.ts +9 -0
  24. package/dist/doctor-cli.js +76 -0
  25. package/dist/doctor.d.ts +60 -0
  26. package/dist/doctor.js +251 -0
  27. package/dist/dotenv.d.ts +7 -0
  28. package/dist/dotenv.js +28 -0
  29. package/dist/gas-cli.d.ts +8 -0
  30. package/dist/gas-cli.js +100 -0
  31. package/dist/gas.d.ts +110 -0
  32. package/dist/gas.js +221 -0
  33. package/dist/index.d.ts +28 -0
  34. package/dist/index.js +20 -0
  35. package/dist/proc.d.ts +14 -0
  36. package/dist/proc.js +28 -0
  37. package/dist/redbelly.d.ts +2 -0
  38. package/dist/redbelly.js +40 -0
  39. package/dist/run.d.ts +9 -0
  40. package/dist/run.js +106 -0
  41. package/dist/secrets.d.ts +18 -0
  42. package/dist/secrets.js +89 -0
  43. package/dist/ship-cli.d.ts +8 -0
  44. package/dist/ship-cli.js +128 -0
  45. package/dist/ship.d.ts +108 -0
  46. package/dist/ship.js +383 -0
  47. package/dist/suites.d.ts +26 -0
  48. package/dist/suites.js +53 -0
  49. package/dist/types.d.ts +58 -0
  50. package/dist/types.js +3 -0
  51. package/dist/well-known.d.ts +2 -0
  52. package/dist/well-known.js +21 -0
  53. package/package.json +50 -0
package/dist/gas.js ADDED
@@ -0,0 +1,221 @@
1
+ // redbelly gas: a gas report in the unit the chain charges in. Reads `.gas-snapshot` (one line per
2
+ // test) or `forge test --gas-report --json` (deployment and every function of the contracts in
3
+ // foundry.toml's gas_reports), prices each row at the latest base fee and the on-chain feed price
4
+ // through the same arithmetic as gasCostUsd in @gatedpath/chains, and prints RBNT and US cents per
5
+ // row, with `--diff <file>` against a previous snapshot or report. Read-only: two RPC reads
6
+ // (eth_getBlockByNumber and the feed) and nothing else. No key.
7
+ import { existsSync, readFileSync } from 'node:fs';
8
+ import { createRequire } from 'node:module';
9
+ import { basename, join, relative, resolve } from 'node:path';
10
+ import { PRICE_FEED_DECIMALS, chainById, formatUnits, getLatestPrice, toCaller } from '@gatedpath/chains';
11
+ import { run } from './proc.js';
12
+ const require = createRequire(import.meta.url);
13
+ export const GAS_VERSION = require('../package.json').version;
14
+ // ---- parsing, pure ----
15
+ const SNAPSHOT_LINE = /^([A-Za-z0-9_$]+):(\S.*?)\s+\((.*)\)\s*$/;
16
+ /** `.gas-snapshot`: `Contract:test() (gas: N)`, fuzz lines by their mean (μ), invariant lines (no gas) skipped. */
17
+ export function parseGasSnapshot(text) {
18
+ const rows = [];
19
+ for (const raw of text.split('\n')) {
20
+ const line = raw.trim();
21
+ if (!line)
22
+ continue;
23
+ const m = SNAPSHOT_LINE.exec(line);
24
+ if (!m)
25
+ continue;
26
+ const [, contract, item, inside] = m;
27
+ const gas = /(?:^|,\s*)gas:\s*(\d+)/.exec(inside);
28
+ const mean = /μ:\s*(\d+)/.exec(inside);
29
+ const runs = /runs:\s*(\d+)/.exec(inside);
30
+ const median = /~:\s*(\d+)/.exec(inside);
31
+ if (gas)
32
+ rows.push({ name: `${contract}:${item}`, contract: contract, item: item, gas: BigInt(gas[1]) });
33
+ else if (mean)
34
+ rows.push({ name: `${contract}:${item}`, contract: contract, item: item, gas: BigInt(mean[1]), ...(runs ? { calls: Number(runs[1]) } : {}), ...(median ? { min: BigInt(median[1]), max: BigInt(median[1]) } : {}) });
35
+ }
36
+ return rows;
37
+ }
38
+ /** `forge test --gas-report --json`: the deployment row, then each function by its mean. Contract names lose their path. */
39
+ export function parseGasReportJson(json) {
40
+ const entries = Array.isArray(json) ? json : [];
41
+ const rows = [];
42
+ for (const e of entries) {
43
+ if (!e || typeof e.contract !== 'string')
44
+ continue;
45
+ const contract = e.contract.includes(':') ? e.contract.slice(e.contract.lastIndexOf(':') + 1) : basename(e.contract, '.sol');
46
+ if (e.deployment && typeof e.deployment.gas === 'number')
47
+ rows.push({ name: `${contract}:deployment`, contract, item: 'deployment', gas: BigInt(e.deployment.gas) });
48
+ for (const [sig, f] of Object.entries(e.functions ?? {}).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) {
49
+ rows.push({ name: `${contract}:${sig}`, contract, item: sig, gas: BigInt(Math.round(f.mean)), calls: f.calls, min: BigInt(f.min), max: BigInt(f.max) });
50
+ }
51
+ }
52
+ return rows;
53
+ }
54
+ /** Either format, by content: JSON that parses as an array is a gas report; anything else is a snapshot. */
55
+ export function parseGasFile(text) {
56
+ const trimmed = text.trim();
57
+ if (trimmed.startsWith('[')) {
58
+ try {
59
+ return { kind: 'gas-report-file', rows: parseGasReportJson(JSON.parse(trimmed)) };
60
+ }
61
+ catch {
62
+ /* fall through: not JSON after all */
63
+ }
64
+ }
65
+ return { kind: 'snapshot', rows: parseGasSnapshot(text) };
66
+ }
67
+ // ---- pricing, pure given a quote ----
68
+ /** wei = gas * baseFee; thousandths of a US cent = wei * raw / 10^(18 + 6 - 5). Only the last step leaves BigInt. */
69
+ export function priceGas(gas, quote) {
70
+ const wei = gas * quote.baseFeeWei;
71
+ const scaled = wei * quote.priceRaw; // USD times 10^(18+6)
72
+ const milliCents = scaled / 10n ** BigInt(18 + PRICE_FEED_DECIMALS - 5); // cents times 1000
73
+ const cents = Number(milliCents) / 1000;
74
+ const usd = Number((scaled * 1000000000n) / 10n ** BigInt(18 + PRICE_FEED_DECIMALS)) / 1e9;
75
+ return { wei, rbnt: formatUnits(wei, 18), usd, cents };
76
+ }
77
+ export function priceRows(rows, quote, before) {
78
+ const prior = new Map((before ?? []).map((r) => [r.name, r]));
79
+ const priced = rows.map((r) => {
80
+ const p = priceGas(r.gas, quote);
81
+ const b = prior.get(r.name);
82
+ if (!before)
83
+ return { ...r, ...p };
84
+ if (!b)
85
+ return { ...r, ...p };
86
+ const bp = priceGas(b.gas, quote);
87
+ return { ...r, ...p, before: { gas: b.gas, cents: bp.cents }, delta: { gas: r.gas - b.gas, cents: Math.round((p.cents - bp.cents) * 1000) / 1000 } };
88
+ });
89
+ const names = new Set(rows.map((r) => r.name));
90
+ return { rows: priced, added: before ? rows.filter((r) => !prior.has(r.name)).map((r) => r.name) : [], removed: before ? [...prior.keys()].filter((n) => !names.has(n)) : [] };
91
+ }
92
+ // ---- the two reads ----
93
+ export async function quoteGas(rpc, expected) {
94
+ const caller = toCaller(rpc);
95
+ const reported = Number(BigInt((await caller('eth_chainId'))));
96
+ if (reported !== 151 && reported !== 153)
97
+ throw new Error(`the RPC at ${rpc} reports chain ${reported}; redbelly gas prices through the feed on 151 or 153 (a fork of either works)`);
98
+ if (expected && reported !== expected)
99
+ throw new Error(`--chain ${expected} but the RPC at ${rpc} reports ${reported}`);
100
+ const block = (await caller('eth_getBlockByNumber', ['latest', false]));
101
+ if (!block?.baseFeePerGas)
102
+ throw new Error('the latest block has no baseFeePerGas');
103
+ const price = await getLatestPrice({ rpc });
104
+ return {
105
+ chain: reported,
106
+ network: reported === 151 ? 'mainnet' : 'testnet',
107
+ rpc,
108
+ block: Number(BigInt(block.number ?? '0x0')),
109
+ baseFeeWei: BigInt(block.baseFeePerGas),
110
+ priceRaw: price.raw,
111
+ usdPerRbnt: price.usdPerRbnt,
112
+ priceTimestamp: new Date(price.timestamp * 1000).toISOString(),
113
+ priceFeed: price.priceFeed,
114
+ };
115
+ }
116
+ /** A scaffold root's contracts/ when that is where foundry.toml is; otherwise the directory itself. */
117
+ export function foundryDir(project) {
118
+ if (!existsSync(join(project, 'foundry.toml')) && existsSync(join(project, 'contracts', 'foundry.toml')))
119
+ return join(project, 'contracts');
120
+ return project;
121
+ }
122
+ export async function readGasSource(dir, opts) {
123
+ if (opts.snapshot) {
124
+ const file = resolve(dir, opts.snapshot);
125
+ if (!existsSync(file))
126
+ throw new Error(`no such file: ${file}`);
127
+ const parsed = parseGasFile(readFileSync(file, 'utf8'));
128
+ return { source: { kind: parsed.kind, file }, rows: parsed.rows };
129
+ }
130
+ const snapshot = join(dir, '.gas-snapshot');
131
+ if (!opts.report && existsSync(snapshot))
132
+ return { source: { kind: 'snapshot', file: snapshot }, rows: parseGasSnapshot(readFileSync(snapshot, 'utf8')) };
133
+ if (!existsSync(join(dir, 'foundry.toml')))
134
+ throw new Error(`no .gas-snapshot and no foundry.toml in ${dir}; pass --snapshot <file> or run from a Foundry project`);
135
+ const args = ['test', '--gas-report', '--json'];
136
+ const r = await run('forge', args, { cwd: dir, env: opts.env ?? process.env, timeoutMs: 600_000 });
137
+ if (r.spawnError)
138
+ throw new Error(`forge is not installed or not on PATH (${r.spawnError}); run redbelly-doctor`);
139
+ const jsonStart = r.stdout.indexOf('[');
140
+ if (r.code !== 0 || jsonStart < 0)
141
+ throw new Error(`forge test --gas-report --json exited ${r.code}:\n${(r.stderr || r.stdout).trim().slice(-2000)}`);
142
+ let parsed;
143
+ try {
144
+ parsed = JSON.parse(r.stdout.slice(jsonStart));
145
+ }
146
+ catch {
147
+ throw new Error('forge printed a gas report that is not JSON');
148
+ }
149
+ return { source: { kind: 'gas-report', command: `forge ${args.join(' ')}` }, rows: parseGasReportJson(parsed) };
150
+ }
151
+ export async function runGas(opts = {}) {
152
+ const project = resolve(opts.project ?? process.cwd());
153
+ const dir = foundryDir(project);
154
+ const { source, rows } = await readGasSource(dir, opts);
155
+ if (rows.length === 0)
156
+ throw new Error(`nothing to price: ${source.kind === 'gas-report' ? 'the gas report has no contracts (set gas_reports in foundry.toml)' : `${source.file} has no gas lines`}`);
157
+ let before;
158
+ let diffFile = null;
159
+ if (opts.diff) {
160
+ diffFile = resolve(dir, opts.diff);
161
+ if (!existsSync(diffFile))
162
+ throw new Error(`--diff: no such file: ${diffFile}`);
163
+ before = parseGasFile(readFileSync(diffFile, 'utf8')).rows;
164
+ }
165
+ const chain = opts.chain ?? 153;
166
+ const rpc = opts.rpc ?? chainById(chain).rpcUrls.default.http[0];
167
+ const quote = await quoteGas(rpc, opts.chain);
168
+ const priced = priceRows(rows, quote, before);
169
+ return {
170
+ tool: 'redbelly-gas',
171
+ version: GAS_VERSION,
172
+ generatedAt: new Date().toISOString(),
173
+ project,
174
+ source,
175
+ quote,
176
+ rows: priced.rows,
177
+ diff: diffFile ? { file: diffFile, rows: before.length, added: priced.added, removed: priced.removed } : null,
178
+ };
179
+ }
180
+ // ---- text ----
181
+ const num = (n) => n.toLocaleString('en-US');
182
+ const fixed = (n, d) => n.toLocaleString('en-US', { minimumFractionDigits: d, maximumFractionDigits: d });
183
+ const gwei = (wei) => `${fixed(Number(wei) / 1e9, 2)} gwei`;
184
+ const rbnt6 = (s) => {
185
+ const n = Number(s);
186
+ return n === 0 ? '0' : n < 0.000001 ? '<0.000001' : fixed(n, 6);
187
+ };
188
+ const signed = (n, d = 0) => (Number(n) > 0 ? '+' : '') + (typeof n === 'bigint' ? num(n) : fixed(n, d));
189
+ export function renderGasText(report) {
190
+ const q = report.quote;
191
+ const src = report.source.kind === 'gas-report' ? `${report.source.command} (${report.rows.length} rows)` : `${relative(report.project, report.source.file) || basename(report.source.file)} (${report.rows.length} rows${report.source.kind === 'snapshot' ? ', one per test' : ''})`;
192
+ const lines = [];
193
+ lines.push(`redbelly gas ${report.version} project ${report.project} source ${src}`);
194
+ lines.push(`chain ${q.chain} (${q.network}) via ${q.rpc} block ${num(q.block)} base fee ${gwei(q.baseFeeWei)} RBNT US$${fixed(q.usdPerRbnt, 6)} (feed at ${q.priceTimestamp})`);
195
+ if (report.diff)
196
+ lines.push(`diff against ${relative(report.project, report.diff.file) || report.diff.file} (${report.diff.rows} rows${report.diff.added.length ? `, ${report.diff.added.length} new here` : ''}${report.diff.removed.length ? `, ${report.diff.removed.length} gone` : ''})`);
197
+ lines.push('');
198
+ const w = Math.min(72, Math.max(24, ...report.rows.map((r) => r.name.length)));
199
+ const head = ['Item'.padEnd(w), 'Gas'.padStart(11), 'RBNT'.padStart(12), 'US cents'.padStart(10)];
200
+ if (report.diff)
201
+ head.push('Gas diff'.padStart(11), 'Cents diff'.padStart(11));
202
+ lines.push(head.join(' '));
203
+ for (const r of report.rows) {
204
+ const cells = [r.name.length > w ? r.name.slice(0, w - 1) + '…' : r.name.padEnd(w), num(r.gas).padStart(11), rbnt6(r.rbnt).padStart(12), fixed(r.cents, 3).padStart(10)];
205
+ if (report.diff)
206
+ cells.push((r.delta ? signed(r.delta.gas) : 'new').padStart(11), (r.delta ? signed(r.delta.cents, 3) : '').padStart(11));
207
+ lines.push(cells.join(' '));
208
+ }
209
+ if (report.diff?.removed.length)
210
+ lines.push('', `gone since ${basename(report.diff.file)}: ${report.diff.removed.join(', ')}`);
211
+ const deploys = report.rows.filter((r) => r.item === 'deployment');
212
+ lines.push('');
213
+ if (deploys.length)
214
+ lines.push(`deployment: ${deploys.map((d) => `${d.contract} ${num(d.gas)} gas = ${rbnt6(d.rbnt)} RBNT = US$${fixed(d.usd, 4)}`).join('; ')}. Pre-flight's balance check adds 25% on top; pass its --gas the figure here.`);
215
+ lines.push(`Gas is priced in US dollars and converted to RBNT at execution from the feed, so the cents column is the stable one; the RBNT column moves with the price. Base fee only: the chain charges no priority fee.`);
216
+ return lines.join('\n') + '\n';
217
+ }
218
+ /** JSON with BigInt as decimal strings. */
219
+ export function gasReportJson(report) {
220
+ return JSON.stringify(report, (_k, v) => (typeof v === 'bigint' ? v.toString() : v), 2) + '\n';
221
+ }
@@ -0,0 +1,28 @@
1
+ export { runPreflight, renderText, CHECK_IDS, DEFAULT_GAS, VERSION } from './run.js';
2
+ export type { RunOptions } from './run.js';
3
+ export type { CheckId, CheckResult, CheckStatus, PreflightOptions, PreflightReport } from './types.js';
4
+ export { readProjectConfig, parseToml, readFoundryConfig, readHardhatConfig, HARDHAT_CONFIG_NAMES } from './config.js';
5
+ export type { ProjectConfig } from './config.js';
6
+ export { scanLine, scanText, scanValue, mnemonicRun } from './secrets.js';
7
+ export type { SecretKind, SecretMatch } from './secrets.js';
8
+ export { scanGitHistory, checkGitSecrets } from './checks/git-secrets.js';
9
+ export { readAdmin, checkAdminSafe, codeHash, SAFE_141_CODE_HASHES } from './checks/admin.js';
10
+ export { checkCompilerPins, SOLC_PIN, EVM_PIN } from './checks/compiler.js';
11
+ export { checkBalance, MARGIN_PERCENT } from './checks/balance.js';
12
+ export { checkSlitherReport, hashSources, DEFAULT_REPORT_PATHS, SOURCES_HASH_FILE } from './checks/slither.js';
13
+ export { checkChainId } from './checks/chain-id.js';
14
+ export { resolveDeployer, checkDeployerVerified } from './checks/deployer.js';
15
+ export { run as runProcess, ACCOUNT_NAME } from './proc.js';
16
+ export { WELL_KNOWN_DEV_ACCOUNTS, isWellKnownDevAccount } from './well-known.js';
17
+ export { readDotEnv, ENV_NAMES } from './dotenv.js';
18
+ export { runDoctor, renderDoctorText, whichOnPath, toolVersion, exitCodePropagates, looksLikeNodeShim, realBinaryNear, scaffoldRoot, DOCTOR_CHECK_IDS, NODE_MIN_MAJOR, SLITHER_PIN, ADERYN_PIN } from './doctor.js';
19
+ export type { DoctorCheck, DoctorCheckId, DoctorOptions, DoctorReport, DoctorStatus } from './doctor.js';
20
+ export { doctorMain, parseDoctorArgs, DOCTOR_HELP } from './doctor-cli.js';
21
+ export { runGas, quoteGas, priceGas, priceRows, parseGasSnapshot, parseGasReportJson, parseGasFile, readGasSource, foundryDir, renderGasText, gasReportJson } from './gas.js';
22
+ export type { GasRow, GasSource, GasQuote, PricedRow, GasReport, GasOptions } from './gas.js';
23
+ export { gasMain, parseGasArgs, GAS_HELP } from './gas-cli.js';
24
+ export { findGatedSuites, summariseTests } from './suites.js';
25
+ export type { ForgeTestJson, SuiteSummary } from './suites.js';
26
+ export { runShip, renderShipMarkdown, renderShipText, shipReportJson, shipReportPath, parseShipHeader, readShipContracts, routescanBase, utcDate, SHIP_HEADER } from './ship.js';
27
+ export type { ShipOptions, ShipReport, ShipCheck, ShipContract } from './ship.js';
28
+ export { shipMain, parseShipArgs, SHIP_HELP } from './ship-cli.js';
package/dist/index.js ADDED
@@ -0,0 +1,20 @@
1
+ export { runPreflight, renderText, CHECK_IDS, DEFAULT_GAS, VERSION } from './run.js';
2
+ export { readProjectConfig, parseToml, readFoundryConfig, readHardhatConfig, HARDHAT_CONFIG_NAMES } from './config.js';
3
+ export { scanLine, scanText, scanValue, mnemonicRun } from './secrets.js';
4
+ export { scanGitHistory, checkGitSecrets } from './checks/git-secrets.js';
5
+ export { readAdmin, checkAdminSafe, codeHash, SAFE_141_CODE_HASHES } from './checks/admin.js';
6
+ export { checkCompilerPins, SOLC_PIN, EVM_PIN } from './checks/compiler.js';
7
+ export { checkBalance, MARGIN_PERCENT } from './checks/balance.js';
8
+ export { checkSlitherReport, hashSources, DEFAULT_REPORT_PATHS, SOURCES_HASH_FILE } from './checks/slither.js';
9
+ export { checkChainId } from './checks/chain-id.js';
10
+ export { resolveDeployer, checkDeployerVerified } from './checks/deployer.js';
11
+ export { run as runProcess, ACCOUNT_NAME } from './proc.js';
12
+ export { WELL_KNOWN_DEV_ACCOUNTS, isWellKnownDevAccount } from './well-known.js';
13
+ export { readDotEnv, ENV_NAMES } from './dotenv.js';
14
+ export { runDoctor, renderDoctorText, whichOnPath, toolVersion, exitCodePropagates, looksLikeNodeShim, realBinaryNear, scaffoldRoot, DOCTOR_CHECK_IDS, NODE_MIN_MAJOR, SLITHER_PIN, ADERYN_PIN } from './doctor.js';
15
+ export { doctorMain, parseDoctorArgs, DOCTOR_HELP } from './doctor-cli.js';
16
+ export { runGas, quoteGas, priceGas, priceRows, parseGasSnapshot, parseGasReportJson, parseGasFile, readGasSource, foundryDir, renderGasText, gasReportJson } from './gas.js';
17
+ export { gasMain, parseGasArgs, GAS_HELP } from './gas-cli.js';
18
+ export { findGatedSuites, summariseTests } from './suites.js';
19
+ export { runShip, renderShipMarkdown, renderShipText, shipReportJson, shipReportPath, parseShipHeader, readShipContracts, routescanBase, utcDate, SHIP_HEADER } from './ship.js';
20
+ export { shipMain, parseShipArgs, SHIP_HELP } from './ship-cli.js';
package/dist/proc.d.ts ADDED
@@ -0,0 +1,14 @@
1
+ export interface RunResult {
2
+ code: number | null;
3
+ stdout: string;
4
+ stderr: string;
5
+ /** Set when the binary could not be started at all (ENOENT and friends). */
6
+ spawnError?: string;
7
+ }
8
+ export declare function run(cmd: string, args: readonly string[], opts?: {
9
+ cwd?: string;
10
+ env?: NodeJS.ProcessEnv;
11
+ timeoutMs?: number;
12
+ stdin?: string;
13
+ }): Promise<RunResult>;
14
+ export declare const ACCOUNT_NAME: RegExp;
package/dist/proc.js ADDED
@@ -0,0 +1,28 @@
1
+ // One place that spawns processes. Arguments go as an array, never through a shell, so a
2
+ // keystore name or a path cannot become a command.
3
+ import { spawn } from 'node:child_process';
4
+ export function run(cmd, args, opts = {}) {
5
+ return new Promise((resolve) => {
6
+ const child = spawn(cmd, args, { cwd: opts.cwd, env: opts.env ?? process.env, stdio: [opts.stdin === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'] });
7
+ let stdout = '';
8
+ let stderr = '';
9
+ let done = false;
10
+ const finish = (r) => {
11
+ if (done)
12
+ return;
13
+ done = true;
14
+ resolve(r);
15
+ };
16
+ const timer = opts.timeoutMs ? setTimeout(() => { child.kill('SIGKILL'); finish({ code: null, stdout, stderr: stderr + `\n${cmd} timed out after ${opts.timeoutMs}ms` }); }, opts.timeoutMs) : null;
17
+ child.stdout?.on('data', (d) => (stdout += d));
18
+ child.stderr?.on('data', (d) => (stderr += d));
19
+ child.on('error', (e) => { if (timer)
20
+ clearTimeout(timer); finish({ code: null, stdout, stderr, spawnError: e.message }); });
21
+ child.on('close', (code) => { if (timer)
22
+ clearTimeout(timer); finish({ code, stdout, stderr }); });
23
+ if (opts.stdin !== undefined && child.stdin) {
24
+ child.stdin.end(opts.stdin);
25
+ }
26
+ });
27
+ }
28
+ export const ACCOUNT_NAME = /^[A-Za-z0-9._-]{1,64}$/;
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export declare function redbellyMain(argv?: readonly string[]): Promise<number>;
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env node
2
+ // `redbelly <command>`: one bin for the checks a builder runs by hand. `preflight` is the seven
3
+ // checks, `doctor` the machine, `gas` the report in RBNT and US cents, `ship` the mainnet gate that
4
+ // writes its own report. Each is also its own bin (redbelly-preflight, redbelly-doctor) or an MCP tool.
5
+ import { realpathSync } from 'node:fs';
6
+ import { pathToFileURL } from 'node:url';
7
+ import { main as preflightMain } from './cli.js';
8
+ import { doctorMain } from './doctor-cli.js';
9
+ const HELP = `redbelly <command> [options]
10
+
11
+ preflight the seven checks before a deploy (redbelly-preflight)
12
+ doctor the machine before the first command (redbelly-doctor)
13
+ gas gas per function in RBNT and US cents at the chain's base fee and feed price
14
+ ship pre-flight, Slither, the five-state tests and the gas figures, written to deployments/ship-<chain>-<date>.md
15
+
16
+ redbelly <command> --help for each. Nothing here takes a key; signing tools take a keystore name.
17
+ `;
18
+ const commands = {
19
+ preflight: preflightMain,
20
+ doctor: doctorMain,
21
+ gas: async (argv) => (await import('./gas-cli.js')).gasMain(argv),
22
+ ship: async (argv) => (await import('./ship-cli.js')).shipMain(argv),
23
+ };
24
+ export async function redbellyMain(argv = process.argv.slice(2)) {
25
+ const [cmd, ...rest] = argv;
26
+ if (!cmd || cmd === '--help' || cmd === '-h') {
27
+ process.stdout.write(HELP);
28
+ return cmd ? 0 : 2;
29
+ }
30
+ const fn = commands[cmd];
31
+ if (!fn) {
32
+ process.stderr.write(`unknown command "${cmd}"\n\n${HELP}`);
33
+ return 2;
34
+ }
35
+ return fn(rest);
36
+ }
37
+ /** True when this file is the process entry point (a bin symlink resolves to it), not an import. */
38
+ const isEntry = Boolean(process.argv[1]) && pathToFileURL(realpathSync(process.argv[1])).href === import.meta.url;
39
+ if (isEntry)
40
+ redbellyMain().then((code) => { process.exitCode = code; }, (e) => { process.stderr.write(`${e.stack ?? e}\n`); process.exitCode = 2; });
package/dist/run.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ import type { CheckId, PreflightOptions, PreflightReport } from './types.js';
2
+ export declare const VERSION: string;
3
+ export declare const CHECK_IDS: readonly CheckId[];
4
+ export declare const DEFAULT_GAS = 3000000n;
5
+ export interface RunOptions extends PreflightOptions {
6
+ passwordFile?: string;
7
+ }
8
+ export declare function runPreflight(opts?: RunOptions): Promise<PreflightReport>;
9
+ export declare function renderText(report: PreflightReport): string;
package/dist/run.js ADDED
@@ -0,0 +1,106 @@
1
+ // Orchestration: pick the chain and RPC, resolve the deployer, run every check, build the
2
+ // report. Nothing here signs, and the only network traffic is JSON-RPC to the chosen RPC.
3
+ import { createRequire } from 'node:module';
4
+ import { resolve } from 'node:path';
5
+ import { chainById, toCaller } from '@gatedpath/chains';
6
+ import { readProjectConfig } from './config.js';
7
+ import { ENV_NAMES, readDotEnv } from './dotenv.js';
8
+ import { checkAdminSafe } from './checks/admin.js';
9
+ import { checkBalance } from './checks/balance.js';
10
+ import { checkChainId } from './checks/chain-id.js';
11
+ import { checkCompilerPins } from './checks/compiler.js';
12
+ import { checkDeployerVerified, resolveDeployer } from './checks/deployer.js';
13
+ import { checkGitSecrets } from './checks/git-secrets.js';
14
+ import { checkSlitherReport } from './checks/slither.js';
15
+ const require = createRequire(import.meta.url);
16
+ export const VERSION = require('../package.json').version;
17
+ export const CHECK_IDS = ['chain-id', 'deployer-verified', 'admin-safe', 'compiler-pins', 'git-secrets', 'balance', 'slither-report'];
18
+ export const DEFAULT_GAS = 3000000n;
19
+ export async function runPreflight(opts = {}) {
20
+ const env = opts.env ?? process.env;
21
+ const project = resolve(opts.project ?? process.cwd());
22
+ const config = readProjectConfig(project, opts.profile, env);
23
+ const skip = new Set(opts.skip ?? []);
24
+ // The scaffolder's .env names (DEPLOYER, ADMIN_SAFE, CHAIN_ID) fill in what the flags leave out.
25
+ const dotenv = readDotEnv(project, env);
26
+ const envChain = dotenv[ENV_NAMES.chain] !== undefined ? Number(dotenv[ENV_NAMES.chain]) : null;
27
+ // Chain and RPC. --chain wins, then the config's chain id, then CHAIN_ID, then whatever the RPC reports.
28
+ let expected = opts.chain ?? config.chainId ?? (envChain === 151 || envChain === 153 ? envChain : null);
29
+ let expectedSource = opts.chain ? '--chain' : config.chainId !== null ? (config.kind === 'hardhat' ? `${config.file} network ${config.profile}` : `${config.file}`) : `${ENV_NAMES.chain}`;
30
+ let rpc = opts.rpc ?? config.rpcUrl ?? pickRpcFromConfig(config.rpcEndpoints, expected) ?? defaultRpc(expected);
31
+ if (!opts.rpc && !config.rpcUrl && expected === null && Object.keys(config.rpcEndpoints).length === 0) {
32
+ // nothing said which chain; testnet is the safe default and the chain-id check will say so
33
+ expectedSource = 'nothing';
34
+ }
35
+ let reported = null;
36
+ try {
37
+ reported = Number(BigInt((await toCaller(rpc)('eth_chainId'))));
38
+ }
39
+ catch {
40
+ reported = null;
41
+ }
42
+ const network = reported === 151 ? 'mainnet' : reported === 153 ? 'testnet' : 'unknown';
43
+ const address = opts.address ?? (opts.account ? undefined : dotenv[ENV_NAMES.deployer]);
44
+ const deployer = await resolveDeployer({ account: opts.account, address, passwordFile: opts.passwordFile, cwd: project, env });
45
+ if (deployer.how === '--address' && !opts.address)
46
+ deployer.how = ENV_NAMES.deployer;
47
+ const admin = opts.admin ?? dotenv[ENV_NAMES.admin] ?? dotenv[ENV_NAMES.legacyAdmin] ?? null;
48
+ const gas = BigInt(opts.gas ?? DEFAULT_GAS);
49
+ const checks = [];
50
+ const push = async (id, fn) => {
51
+ if (skip.has(id))
52
+ checks.push({ id, status: 'skip', reason: 'Skipped on request.' });
53
+ else
54
+ checks.push(await fn());
55
+ };
56
+ await push('chain-id', () => checkChainId(expected, reported, expectedSource, rpc));
57
+ await push('deployer-verified', () => checkDeployerVerified(deployer.address, deployer.how, deployer.error, rpc, reported));
58
+ await push('admin-safe', () => checkAdminSafe(admin, rpc, reported));
59
+ await push('compiler-pins', () => checkCompilerPins(config));
60
+ await push('git-secrets', () => checkGitSecrets(project));
61
+ await push('balance', () => checkBalance(deployer.address, gas, rpc, reported));
62
+ await push('slither-report', () => checkSlitherReport(project, config.sourceDir, opts.slitherReport));
63
+ return {
64
+ tool: 'redbelly-preflight',
65
+ version: VERSION,
66
+ generatedAt: new Date().toISOString(),
67
+ project,
68
+ config: { kind: config.kind, file: config.file, profile: config.profile },
69
+ chain: { expected, reported, rpc, network },
70
+ deployer: deployer.address,
71
+ admin,
72
+ checks,
73
+ ok: checks.every((c) => c.status !== 'fail'),
74
+ };
75
+ }
76
+ function defaultRpc(chain) {
77
+ const c = chainById(chain === 151 ? 151 : 153);
78
+ if (!c)
79
+ throw new Error('chain definitions missing');
80
+ return c.rpcUrls.default.http[0];
81
+ }
82
+ function pickRpcFromConfig(endpoints, chain) {
83
+ const entries = Object.entries(endpoints);
84
+ if (entries.length === 0)
85
+ return null;
86
+ const want = chain === 151 ? /mainnet/i : chain === 153 ? /testnet/i : null;
87
+ if (want) {
88
+ const hit = entries.find(([name, url]) => want.test(name) || want.test(url));
89
+ if (hit)
90
+ return hit[1];
91
+ }
92
+ return entries.length === 1 ? entries[0][1] : null;
93
+ }
94
+ export function renderText(report) {
95
+ const lines = [];
96
+ lines.push(`redbelly-preflight ${report.version} project ${report.project}`);
97
+ lines.push(`chain ${report.chain.reported ?? '?'} (${report.chain.network}) via ${report.chain.rpc}${report.deployer ? ` deployer ${report.deployer}` : ''}${report.admin ? ` admin ${report.admin}` : ''}`);
98
+ lines.push('');
99
+ for (const c of report.checks)
100
+ lines.push(`${c.status.padEnd(5)} ${c.id.padEnd(18)} ${c.reason}`);
101
+ lines.push('');
102
+ const fails = report.checks.filter((c) => c.status === 'fail').length;
103
+ const warns = report.checks.filter((c) => c.status === 'warn').length;
104
+ lines.push(report.ok ? `ok: no check failed${warns ? ` (${warns} warning${warns === 1 ? '' : 's'})` : ''}.` : `not ok: ${fails} check${fails === 1 ? '' : 's'} failed. Do not deploy.`);
105
+ return lines.join('\n') + '\n';
106
+ }
@@ -0,0 +1,18 @@
1
+ export type SecretKind = 'hex64' | 'hex64-keyword' | 'key-assignment' | 'mnemonic' | 'keystore-json';
2
+ export interface SecretMatch {
3
+ readonly kind: SecretKind;
4
+ /** Why this counts. One sentence, no secret material. */
5
+ readonly why: string;
6
+ }
7
+ /** Every secret-shaped thing on one line of text. */
8
+ export declare function scanLine(line: string): SecretMatch[];
9
+ /** True when the line holds 12+ consecutive BIP-39 words separated by single spaces. */
10
+ export declare function mnemonicRun(line: string): boolean;
11
+ /** Scan a whole string; returns matches with 1-based line numbers. */
12
+ export declare function scanText(text: string): Array<SecretMatch & {
13
+ line: number;
14
+ }>;
15
+ /** Walks any JSON-like value (tool arguments, config) and reports secret-shaped strings by path. */
16
+ export declare function scanValue(value: unknown, path?: string): Array<SecretMatch & {
17
+ path: string;
18
+ }>;
@@ -0,0 +1,89 @@
1
+ // Spots secret-shaped text. Used by the git-history check here and by the MCP server's
2
+ // argument guard. It reports the kind of match and where, never the matched text.
3
+ import { BIP39_ENGLISH } from './bip39-words.js';
4
+ const HEX64 = /(?<![0-9a-fA-F])(0x)?[0-9a-fA-F]{64}(?![0-9a-fA-F])/g;
5
+ /** A 0x-prefixed 64-hex string is usually a hash. It reads as a key when the line says so. */
6
+ const KEY_WORDS = /\b(private[_ -]?key|privkey|secret|signer[_ -]?key|deployer[_ -]?key|wallet[_ -]?key|mnemonic|seed)\b/i;
7
+ /** Lines that are plainly about hashes stay quiet even without the 0x prefix. */
8
+ const HASH_WORDS = /"id"\s*:|\b(sha-?256|sha-?512|sha-?3|keccak|blake2?b?|integrity|checksum|digest|codehash|code hash|tx ?hash|transactionHash|blockHash|merkle|root)\b/i;
9
+ const KEY_ASSIGNMENT = /\b(PRIVATE_KEY|PRIV_KEY|DEPLOYER_KEY|DEPLOYER_PRIVATE_KEY|WALLET_KEY|SIGNER_KEY|MNEMONIC|SEED_PHRASE|SEED)\b\s*[=:]\s*["'`]?([^\s"'`#]+(?:\s+[a-z]+)*)/;
10
+ const PLACEHOLDER = /^(<|\$|\{\{|your|xxx+|\.\.\.|changeme|replace|example|0x0+$|todo|none|null|undefined|""|'')/i;
11
+ const KEYSTORE = /"(ciphertext|kdfparams|mac)"\s*:/;
12
+ /** Every secret-shaped thing on one line of text. */
13
+ export function scanLine(line) {
14
+ const out = [];
15
+ if (line.length > 20_000)
16
+ return out; // minified bundles; a key there is not a key someone typed
17
+ for (const m of line.matchAll(HEX64)) {
18
+ const prefixed = m[1] === '0x';
19
+ if (prefixed) {
20
+ if (KEY_WORDS.test(line))
21
+ out.push({ kind: 'hex64-keyword', why: 'a 0x-prefixed 64-hex value on a line that talks about a key' });
22
+ }
23
+ else if (!HASH_WORDS.test(line)) {
24
+ out.push({ kind: 'hex64', why: 'a bare 64-hex value, the shape of a private key in a .env or keystore export' });
25
+ }
26
+ }
27
+ const assign = KEY_ASSIGNMENT.exec(line);
28
+ if (assign) {
29
+ const value = assign[2].trim();
30
+ const isMnemonicVar = /MNEMONIC|SEED/.test(assign[1]);
31
+ if (!PLACEHOLDER.test(value) && (isMnemonicVar ? value.split(/\s+/).length >= 3 : value.length >= 16)) {
32
+ out.push({ kind: 'key-assignment', why: `${assign[1]} is assigned a non-placeholder value` });
33
+ }
34
+ }
35
+ if (mnemonicRun(line))
36
+ out.push({ kind: 'mnemonic', why: 'twelve or more consecutive BIP-39 words' });
37
+ if (KEYSTORE.test(line))
38
+ out.push({ kind: 'keystore-json', why: 'an encrypted keystore JSON committed to the repository' });
39
+ return out;
40
+ }
41
+ /** True when the line holds 12+ consecutive BIP-39 words separated by single spaces. */
42
+ export function mnemonicRun(line) {
43
+ const words = line.trim().split(/[\s"'`=,;:()[\]{}]+/);
44
+ let run = 0;
45
+ for (const w of words) {
46
+ if (w && BIP39_ENGLISH.has(w)) {
47
+ run++;
48
+ if (run >= 12)
49
+ return true;
50
+ }
51
+ else
52
+ run = 0;
53
+ }
54
+ return false;
55
+ }
56
+ /** Scan a whole string; returns matches with 1-based line numbers. */
57
+ export function scanText(text) {
58
+ const out = [];
59
+ const lines = text.split(/\r?\n/);
60
+ for (let i = 0; i < lines.length; i++)
61
+ for (const m of scanLine(lines[i]))
62
+ out.push({ ...m, line: i + 1 });
63
+ return out;
64
+ }
65
+ /** Walks any JSON-like value (tool arguments, config) and reports secret-shaped strings by path. */
66
+ export function scanValue(value, path = '$') {
67
+ const out = [];
68
+ if (typeof value === 'string') {
69
+ for (const m of scanLine(value))
70
+ out.push({ ...m, path });
71
+ // a bare 64-hex string is a key even without context words; a 0x one alone is ambiguous but
72
+ // no tool here takes a hash, so treat it as a key too
73
+ if (/^(0x)?[0-9a-fA-F]{64}$/.test(value.trim()) && !out.some((m) => m.kind.startsWith('hex64'))) {
74
+ out.push({ kind: 'hex64', path, why: 'the whole value is 64 hex characters, the shape of a private key' });
75
+ }
76
+ }
77
+ else if (Array.isArray(value)) {
78
+ value.forEach((v, i) => out.push(...scanValue(v, `${path}[${i}]`)));
79
+ }
80
+ else if (value && typeof value === 'object') {
81
+ for (const [k, v] of Object.entries(value)) {
82
+ if (/^(private_?key|privkey|mnemonic|seed(_phrase)?|secret|password)$/i.test(k)) {
83
+ out.push({ kind: 'key-assignment', path: `${path}.${k}`, why: `an argument named ${k}` });
84
+ }
85
+ out.push(...scanValue(v, `${path}.${k}`));
86
+ }
87
+ }
88
+ return out;
89
+ }
@@ -0,0 +1,8 @@
1
+ import type { ShipOptions } from './ship.js';
2
+ export declare const SHIP_HELP = "redbelly ship --chain <151|153> --account <keystore-name> [options]\n\nRuns pre-flight (seven checks) with the keystore's address, checks the Slither sidecar, runs\nforge test and the five-state suites, prices the gas report at the chain's base fee and feed\nprice, reads the deployer and the admin (isAllowed, Safe 1.4.1, threshold, owners), and writes\ndeployments/ship-<chain>-<date>.md with every check and its result, the addresses, the\nconstructor arguments and the verify command. Refuses to write when any check fails. The\ndeploy script refuses to broadcast on 151 without a report dated today; 153 warns.\n\n --chain <151|153> required\n --account <name> required; Foundry keystore name, resolved with cast wallet address\n --password-file <path> passed through to cast (or set ETH_PASSWORD)\n --admin <0x\u2026> the Safe that receives every role (or ADMIN_SAFE in .env); required on 151\n --gas <units> deployment gas for the balance check (default: the gas report's deployment row)\n --verifier <0x\u2026> the dApp's verifier (or VERIFIER in .env); required on 151\n --project <dir> Foundry project or scaffold root (default: current directory)\n --rpc <url> JSON-RPC endpoint (default: the governors RPC for --chain; a fork works)\n --out <path> write the report here instead\n --json print the report as JSON (the file is still written when checks pass)\n --version, --help\n";
3
+ export declare function parseShipArgs(argv: readonly string[]): (Partial<ShipOptions> & {
4
+ json: boolean;
5
+ help: boolean;
6
+ version: boolean;
7
+ });
8
+ export declare function shipMain(argv?: readonly string[]): Promise<number>;