@gatedpath/preflight 0.2.1 → 0.2.3

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.
package/README.md CHANGED
@@ -90,10 +90,31 @@ Recorded runs against the real feeds, read-only, on 14 September 2026: `reports/
90
90
  and `reports/gas-151-2026-09-14.md`. On both, 21,000 gas came to 1.000 US cents and the scaffold's
91
91
  `GatedERC20` deploys for 1,898,391 gas, 383.7 RBNT, 90.4 cents at a feed price of US$0.002356 per RBNT.
92
92
 
93
+ ## redbelly fees
94
+
95
+ What the calls a builder meets cost today, the command-line twin of the site's fee checker (`/fees/`,
96
+ "What it costs"). `redbelly fees` reads both chains and prints, for each, the head block and its time,
97
+ the base fee in gwei, RBNT in US dollars from the on-chain feed with the feed's time, then four
98
+ measured calls (a native transfer, deploying the recipient guard, deploying the gated token, enabling
99
+ a wallet) in gas, RBNT and US cents at today's base fee through the same integer arithmetic as
100
+ `redbelly gas`, and the enable line against the network's fixed rate of US$0.01 per 21,000 gas. On
101
+ mainnet it also reads the base fee at nine block heights from the first week to the head and prices
102
+ the enable call at each, which is the site's two-year chart as a table. The gas figures are the ones
103
+ measured on 17 October 2026 and carried in the report with the run that measured each. `--chain`
104
+ limits it to one chain, `--rpc` names another endpoint (a fork works; with no `--chain` the RPC says
105
+ which chain it is), `--no-history` skips the mainnet history, `--json` for agents. Exit 0 when every
106
+ chain was read, 1 when one could not be (the line says which and why), 2 on bad arguments. Read-only,
107
+ no key, nothing written.
108
+
109
+ ```
110
+ redbelly fees [--chain 151|153] [--rpc <url>] [--timeout <ms>] [--no-history] [--json]
111
+ ```
112
+
93
113
  ## redbelly ship
94
114
 
95
115
  The mainnet gate that produces the document you wanted anyway (PLAN.md 18.3). `redbelly ship --chain
96
- <151|153> --account <keystore-name> [--admin <safe>] [--gas <n>]` (also `npm run ship` in a scaffold
116
+ <151|153> --account <keystore-name> [--admin <safe>] [--gas <n>]` (or `--address <0x…>` in place of
117
+ `--account`, or `DEPLOYER` in `.env`; also `npm run ship` in a scaffold
97
118
  and the MCP server's `ship_report` tool) runs the gas report first (its deployment row is the gas the
98
119
  balance check prices), then pre-flight's seven checks with the keystore's address, checks the verifier
99
120
  (`--verifier` or `VERIFIER` in `.env`; required on 151, where a missing one fails and on 153 warns that
@@ -104,7 +125,16 @@ the deployer and admin readings, the contracts from `deployments/<chain>-*.json`
104
125
  constructor arguments and the exact `forge verify-contract` command, the gas table, the tests, the
105
126
  Slither line, the compiler pins, and the list of what the report does not prove. It refuses to write
106
127
  when any check fails and exits 1 with the failing lines. Run it again after the deploy to fill in the
107
- contract section; the file is dated in UTC.
128
+ contract section; the file is dated in UTC. On 153 the report also says how many faucet claims the
129
+ deployment needs at today's base fee (the deployment rows plus the 25% margin, 500 test RBNT a claim,
130
+ one a day); `redbelly gas` prints the same line.
131
+
132
+ Two things learnt on the first real deploy (17 October 2026). `cast wallet address --account` asks for
133
+ the keystore password on a terminal, so run `redbelly ship` in one, not from an editor's run button;
134
+ without a terminal pass `--password-file`, or `--address` and no cast call is made. And the deploy
135
+ command itself needs `--sender <your address>` beside `--account`: without it forge rehearses as its
136
+ default sender, which fails `isAllowed`, and the scaffold's deploy script now says so and refuses to
137
+ broadcast.
108
138
 
109
139
  The scaffold's `RedbellyDeployScript.sol` refuses to broadcast on 151 without `deployments/ship-151-<today>.md`
110
140
  (a dry run says so and continues; 153 warns), and the Hardhat deploy script does the same. Tested on
@@ -6,6 +6,15 @@ export function checkCompilerPins(config) {
6
6
  const where = `${config.file}${config.profile ? ` (${config.kind === 'foundry' ? 'profile' : 'network'} ${config.profile})` : ''}`;
7
7
  const data = { solc: config.solc, evmVersion: config.evmVersion, file: config.file, profile: config.profile };
8
8
  const problems = [];
9
+ // A project that vendors a suite with its own compiler (ERC-3643's reference contracts pin 0.8.17, which
10
+ // knows neither prague nor cancun) can only build with solc detected per file. The pin then lives in each
11
+ // file's pragma, which this check does not read, so it warns instead of failing (card 15, 17 October 2026).
12
+ if (config.kind === 'foundry' && config.solc === null && config.autoDetectSolc) {
13
+ const evmOk = config.evmVersion !== null && config.evmVersion.toLowerCase() === EVM_PIN;
14
+ return { id: 'compiler-pins', status: evmOk ? 'warn' : 'fail', reason: evmOk
15
+ ? `${where} detects solc per file (auto_detect_solc = true) and sets EVM ${EVM_PIN}. The pin is not in the config: your own files must say pragma solidity ${SOLC_PIN}; a vendored dependency may compile with its own solc for the newest EVM it knows.`
16
+ : `${where} detects solc per file but ${config.evmVersion === null ? 'sets no evm_version' : `sets evm version ${config.evmVersion}, not ${EVM_PIN}`}; Redbelly is Prague.`, data };
17
+ }
9
18
  if (config.solc === null)
10
19
  problems.push(`no ${config.kind === 'foundry' ? 'solc_version' : 'solidity version'} is pinned`);
11
20
  else if (config.solc !== SOLC_PIN)
@@ -16,12 +16,12 @@ export async function resolveDeployer(src) {
16
16
  const args = ['wallet', 'address', '--account', src.account];
17
17
  if (src.passwordFile)
18
18
  args.push('--password-file', src.passwordFile);
19
- const r = await run('cast', args, { cwd: src.cwd, env: src.env, timeoutMs: 60_000, stdin: '' });
19
+ const r = await run('cast', args, { cwd: src.cwd, env: src.env, timeoutMs: 180_000, stdin: '' });
20
20
  if (r.spawnError)
21
21
  return { address: null, how: '--account', error: `cast is not installed or not on PATH (${r.spawnError}); install Foundry or pass --address for a read-only run.` };
22
22
  const m = /0x[0-9a-fA-F]{40}/.exec(r.stdout);
23
23
  if (r.code !== 0 || !m)
24
- return { address: null, how: '--account', error: `cast wallet address --account ${src.account} failed: ${(r.stderr || r.stdout).trim().split('\n').pop() ?? 'no output'}.` };
24
+ return { address: null, how: '--account', error: `cast wallet address --account ${src.account} failed: ${(r.stderr || r.stdout).trim().split('\n').pop() ?? 'no output'}. cast asks for the keystore password on a terminal; without one pass --password-file or --address.` };
25
25
  return { address: m[0], how: `cast wallet address --account ${src.account}` };
26
26
  }
27
27
  return { address: null, how: 'none' };
package/dist/config.d.ts CHANGED
@@ -3,6 +3,8 @@ export interface ProjectConfig {
3
3
  file: string | null;
4
4
  profile: string | null;
5
5
  solc: string | null;
6
+ /** foundry `auto_detect_solc = true`: solc chosen per file from its pragma; the pin cannot be read from the config alone. */
7
+ autoDetectSolc: boolean;
6
8
  evmVersion: string | null;
7
9
  optimizerRuns: number | null;
8
10
  /** foundry: `src`; hardhat: `contracts`. */
package/dist/config.js CHANGED
@@ -16,7 +16,7 @@ export function readProjectConfig(project, profile, env = process.env) {
16
16
  if (existsSync(file))
17
17
  return readHardhatConfig(file, profile ?? null);
18
18
  }
19
- return { kind: 'none', file: null, profile: null, solc: null, evmVersion: null, optimizerRuns: null, sourceDir: 'src', chainId: null, rpcEndpoints: {}, rpcUrl: null };
19
+ return { kind: 'none', file: null, profile: null, solc: null, autoDetectSolc: false, evmVersion: null, optimizerRuns: null, sourceDir: 'src', chainId: null, rpcEndpoints: {}, rpcUrl: null };
20
20
  }
21
21
  /** Enough TOML for foundry.toml: sections, dotted headers, scalars, one-line arrays, comments. */
22
22
  export function parseToml(text) {
@@ -125,6 +125,7 @@ export function readFoundryConfig(file, profile) {
125
125
  file,
126
126
  profile,
127
127
  solc: str('solc') ?? str('solc_version'),
128
+ autoDetectSolc: get('auto_detect_solc') === true,
128
129
  evmVersion: str('evm_version'),
129
130
  optimizerRuns: num('optimizer_runs'),
130
131
  sourceDir: str('src') ?? 'src',
@@ -166,6 +167,7 @@ export function readHardhatConfig(file, network) {
166
167
  file,
167
168
  profile: chosen,
168
169
  solc,
170
+ autoDetectSolc: false,
169
171
  evmVersion: evm ? evm[1] : null,
170
172
  optimizerRuns: runs ? Number(runs[1]) : null,
171
173
  sourceDir: contractsDir ? contractsDir[1].replace(/^\.\//, '') : 'contracts',
package/dist/dotenv.d.ts CHANGED
@@ -3,5 +3,6 @@ export declare const ENV_NAMES: {
3
3
  readonly admin: "ADMIN_SAFE";
4
4
  readonly chain: "CHAIN_ID";
5
5
  readonly legacyAdmin: "PREFLIGHT_ADMIN";
6
+ readonly verifier: "VERIFIER";
6
7
  };
7
8
  export declare function readDotEnv(project: string, env: NodeJS.ProcessEnv): Record<string, string>;
package/dist/dotenv.js CHANGED
@@ -4,7 +4,9 @@
4
4
  // git-secrets check covers what should never be in that file.
5
5
  import { existsSync, readFileSync } from 'node:fs';
6
6
  import { join } from 'node:path';
7
- export const ENV_NAMES = { deployer: 'DEPLOYER', admin: 'ADMIN_SAFE', chain: 'CHAIN_ID', legacyAdmin: 'PREFLIGHT_ADMIN' };
7
+ // VERIFIER joined on 25 October 2026: the deploy script reads it with vm.envOr, which sees only the
8
+ // process environment, so ship has to see it there too or the two disagree (card 9's run, 23 October).
9
+ export const ENV_NAMES = { deployer: 'DEPLOYER', admin: 'ADMIN_SAFE', chain: 'CHAIN_ID', legacyAdmin: 'PREFLIGHT_ADMIN', verifier: 'VERIFIER' };
8
10
  export function readDotEnv(project, env) {
9
11
  const out = {};
10
12
  const file = join(project, '.env');
@@ -0,0 +1,8 @@
1
+ import type { FeesOptions } from './fees.js';
2
+ export declare const FEES_HELP = "redbelly fees [options]\n\nReads both chains (151 mainnet, 153 testnet) and prints, for each, the head block and its time,\nthe base fee, RBNT in US dollars from the on-chain feed, the measured calls a builder meets (a\ntransfer, the recipient guard, the gated token, enabling a wallet) in gas, RBNT and US cents at\ntoday's base fee, and on mainnet the enable call at nine block heights since the first week.\nThe command-line twin of the site's fee checker. Read-only, no key.\n\n --chain <151|153> one chain instead of both\n --rpc <url> JSON-RPC endpoint in place of the chain's governors RPC (a fork works)\n --timeout <ms> per request (default 10000)\n --no-history skip the mainnet history reads\n --json print the report as JSON\n --version, --help\n";
3
+ export declare function parseFeesArgs(argv: readonly string[]): FeesOptions & {
4
+ json: boolean;
5
+ help: boolean;
6
+ version: boolean;
7
+ };
8
+ export declare function feesMain(argv?: readonly string[]): Promise<number>;
@@ -0,0 +1,96 @@
1
+ // `redbelly fees`: what the measured calls cost on each chain today. Exit 0 when every chain was
2
+ // read, 1 when one could not be (the line says which and why), 2 on bad arguments. --json for agents.
3
+ import { FEES_VERSION, feesReportJson, renderFeesText, runFees } from './fees.js';
4
+ export const FEES_HELP = `redbelly fees [options]
5
+
6
+ Reads both chains (151 mainnet, 153 testnet) and prints, for each, the head block and its time,
7
+ the base fee, RBNT in US dollars from the on-chain feed, the measured calls a builder meets (a
8
+ transfer, the recipient guard, the gated token, enabling a wallet) in gas, RBNT and US cents at
9
+ today's base fee, and on mainnet the enable call at nine block heights since the first week.
10
+ The command-line twin of the site's fee checker. Read-only, no key.
11
+
12
+ --chain <151|153> one chain instead of both
13
+ --rpc <url> JSON-RPC endpoint in place of the chain's governors RPC (a fork works)
14
+ --timeout <ms> per request (default 10000)
15
+ --no-history skip the mainnet history reads
16
+ --json print the report as JSON
17
+ --version, --help
18
+ `;
19
+ export function parseFeesArgs(argv) {
20
+ const o = { json: false, help: false, version: false };
21
+ for (let i = 0; i < argv.length; i++) {
22
+ const a = argv[i];
23
+ const [flag, inline] = a.includes('=') ? [a.slice(0, a.indexOf('=')), a.slice(a.indexOf('=') + 1)] : [a, undefined];
24
+ const next = () => {
25
+ if (inline !== undefined)
26
+ return inline;
27
+ const v = argv[++i];
28
+ if (v === undefined)
29
+ throw new Error(`${flag} needs a value`);
30
+ return v;
31
+ };
32
+ switch (flag) {
33
+ case '--chain': {
34
+ const c = Number(next());
35
+ if (c !== 151 && c !== 153)
36
+ throw new Error('--chain must be 151 or 153');
37
+ o.chain = c;
38
+ break;
39
+ }
40
+ case '--rpc':
41
+ o.rpc = next();
42
+ break;
43
+ case '--timeout': {
44
+ const t = Number(next());
45
+ if (!Number.isInteger(t) || t <= 0)
46
+ throw new Error('--timeout must be a whole number of milliseconds');
47
+ o.timeoutMs = t;
48
+ break;
49
+ }
50
+ case '--no-history':
51
+ o.history = false;
52
+ break;
53
+ case '--json':
54
+ o.json = true;
55
+ break;
56
+ case '--help':
57
+ case '-h':
58
+ o.help = true;
59
+ break;
60
+ case '--version':
61
+ o.version = true;
62
+ break;
63
+ default:
64
+ if (/^(0x)?[0-9a-fA-F]{64}$/.test(a))
65
+ throw new Error('that argument looks like a private key; redbelly fees reads the chain and never takes one');
66
+ throw new Error(`unknown argument "${a}"\n\n${FEES_HELP}`);
67
+ }
68
+ }
69
+ return o;
70
+ }
71
+ export async function feesMain(argv = process.argv.slice(2)) {
72
+ let args;
73
+ try {
74
+ args = parseFeesArgs(argv);
75
+ }
76
+ catch (e) {
77
+ process.stderr.write(`${e.message}\n`);
78
+ return 2;
79
+ }
80
+ if (args.help) {
81
+ process.stdout.write(FEES_HELP);
82
+ return 0;
83
+ }
84
+ if (args.version) {
85
+ process.stdout.write(`${FEES_VERSION}\n`);
86
+ return 0;
87
+ }
88
+ const { json, help: _h, version: _v, ...opts } = args;
89
+ const report = await runFees(opts);
90
+ process.stdout.write(json ? feesReportJson(report) : renderFeesText(report));
91
+ // The failures are already in the report; repeat them on stderr so a pipe that keeps only stdout still shows the reason in a terminal.
92
+ for (const c of report.chains)
93
+ if (!c.ok)
94
+ process.stderr.write(`redbelly fees: chain ${c.chain ?? 'at ' + c.rpc} could not be read: ${c.error}\n`);
95
+ return report.ok ? 0 : 1;
96
+ }
package/dist/fees.d.ts ADDED
@@ -0,0 +1,85 @@
1
+ import type { GasQuote } from './gas.js';
2
+ export declare const FEES_VERSION: string;
3
+ export interface ReferenceCall {
4
+ readonly key: string;
5
+ readonly label: string;
6
+ readonly gas: bigint;
7
+ /** Where the gas figure comes from, with the date. */
8
+ readonly measured: string;
9
+ }
10
+ /** Calls a builder meets, with gas as measured, oldest measurement last. Same table as the site. */
11
+ export declare const REFERENCE_CALLS: readonly ReferenceCall[];
12
+ /** The network prices gas in US dollars: Vine's figure, measured in RESEARCH.md question 3 (US$0.01 for a 21,000-gas transfer). */
13
+ export declare const USD_PER_GAS: number;
14
+ /** Mainnet block heights sampled for the history: genesis week, then roughly every four months to the head. */
15
+ export declare const HISTORY_BLOCKS_151: readonly number[];
16
+ /** The call the history is drawn for: enabling a wallet. */
17
+ export declare const HISTORY_GAS = 2154375n;
18
+ export interface PricedCall extends ReferenceCall {
19
+ readonly wei: bigint;
20
+ readonly rbnt: string;
21
+ readonly usd: number;
22
+ /** US cents to three decimal places, as a number. */
23
+ readonly cents: number;
24
+ }
25
+ export interface HistoryPoint {
26
+ readonly block: number;
27
+ readonly time: string;
28
+ readonly baseFeeWei: bigint;
29
+ /** The enable call at that block's base fee. */
30
+ readonly rbnt: string;
31
+ /** The same call at the network's fixed dollar rate; the line that should not move. */
32
+ readonly usd: number;
33
+ }
34
+ export interface FeesChainOk {
35
+ readonly ok: true;
36
+ readonly chain: 151 | 153;
37
+ readonly network: 'mainnet' | 'testnet';
38
+ readonly rpc: string;
39
+ readonly latencyMs: number;
40
+ readonly quote: GasQuote;
41
+ readonly calls: readonly PricedCall[];
42
+ /** The history, mainnet only; null on testnet, and null with a reason when the reads failed. */
43
+ readonly history: readonly HistoryPoint[] | null;
44
+ readonly historyError: string | null;
45
+ }
46
+ export interface FeesChainFailed {
47
+ readonly ok: false;
48
+ readonly chain: 151 | 153 | null;
49
+ readonly network: 'mainnet' | 'testnet' | null;
50
+ readonly rpc: string;
51
+ readonly latencyMs: number;
52
+ readonly error: string;
53
+ }
54
+ export type FeesChain = FeesChainOk | FeesChainFailed;
55
+ export interface FeesReport {
56
+ readonly tool: 'redbelly-fees';
57
+ readonly version: string;
58
+ readonly generatedAt: string;
59
+ readonly chains: readonly FeesChain[];
60
+ /** True when every chain was read. */
61
+ readonly ok: boolean;
62
+ }
63
+ export interface FeesOptions {
64
+ /** One chain; both when absent. With --rpc and no chain, the RPC says which it is. */
65
+ chain?: 151 | 153;
66
+ rpc?: string;
67
+ /** Per request. Default 10 seconds. */
68
+ timeoutMs?: number;
69
+ /** Tests pass a fake; otherwise the global fetch. */
70
+ fetch?: typeof fetch;
71
+ /** Skip the mainnet history reads. */
72
+ history?: boolean;
73
+ }
74
+ export declare function priceCalls(quote: Pick<GasQuote, 'baseFeeWei' | 'priceRaw'>, calls?: readonly ReferenceCall[]): PricedCall[];
75
+ /** The base fee at past block heights, each priced for the enable call; blocks the node does not have are left out. */
76
+ export declare function readFeeHistory(rpc: string, blocks: readonly number[], opts?: {
77
+ fetch?: typeof fetch;
78
+ gas?: bigint;
79
+ }): Promise<HistoryPoint[]>;
80
+ /** One chain: the quote, the four calls priced, and on mainnet the history. Never throws; a failed read is a result. */
81
+ export declare function readFeesChain(rpc: string, expected: 151 | 153 | undefined, opts?: FeesOptions): Promise<FeesChain>;
82
+ export declare function runFees(opts?: FeesOptions): Promise<FeesReport>;
83
+ export declare function renderFeesText(report: FeesReport): string;
84
+ /** JSON with BigInt as decimal strings. */
85
+ export declare function feesReportJson(report: FeesReport): string;
package/dist/fees.js ADDED
@@ -0,0 +1,128 @@
1
+ // redbelly fees: the command-line twin of the site's fee checker (/fees/, "What it costs"). Per chain
2
+ // it reads the head block, the base fee and the feed price (the same three reads as `redbelly gas`,
3
+ // through quoteGas), prices the measured calls a builder meets at today's base fee, and on mainnet
4
+ // reads the base fee at nine block heights so the enable call can be seen across the network's life.
5
+ // Read-only, no key, nothing written. The gas figures below are copied from the site's data file;
6
+ // each one names the run that measured it and is never typed from a document.
7
+ import { createRpc, chainById } from '@gatedpath/chains';
8
+ import { GAS_VERSION, priceGas, quoteGas } from './gas.js';
9
+ export const FEES_VERSION = GAS_VERSION;
10
+ /** Calls a builder meets, with gas as measured, oldest measurement last. Same table as the site. */
11
+ export const REFERENCE_CALLS = [
12
+ { key: 'transfer', label: 'A native transfer', gas: 21000n, measured: 'the EVM constant; Vine prices it at US$0.01' },
13
+ { key: 'guard', label: 'Deploying the recipient guard (project 13)', gas: 503685n, measured: 'measured on 153, 17 October 2026, block 3,052,324' },
14
+ { key: 'token', label: 'Deploying the gated token (the scaffold)', gas: 1971486n, measured: 'the ship report on 153, 17 October 2026' },
15
+ { key: 'enable', label: 'Enabling a wallet on the network', gas: 2154375n, measured: 'measured on 151, 17 October 2026, block 3,228,354' },
16
+ ];
17
+ /** The network prices gas in US dollars: Vine's figure, measured in RESEARCH.md question 3 (US$0.01 for a 21,000-gas transfer). */
18
+ export const USD_PER_GAS = 0.01 / 21_000;
19
+ /** Mainnet block heights sampled for the history: genesis week, then roughly every four months to the head. */
20
+ export const HISTORY_BLOCKS_151 = [204, 400_000, 800_000, 1_200_000, 1_600_000, 2_000_000, 2_400_000, 2_800_000, 3_100_000];
21
+ /** The call the history is drawn for: enabling a wallet. */
22
+ export const HISTORY_GAS = 2154375n;
23
+ /** Each request gets its own deadline; a node that hangs is reported as a timeout rather than waited on. */
24
+ function withTimeout(doFetch, timeoutMs) {
25
+ return (input, init) => doFetch(input, { ...init, signal: AbortSignal.timeout(timeoutMs) });
26
+ }
27
+ export function priceCalls(quote, calls = REFERENCE_CALLS) {
28
+ return calls.map((c) => ({ ...c, ...priceGas(c.gas, quote) }));
29
+ }
30
+ /** The base fee at past block heights, each priced for the enable call; blocks the node does not have are left out. */
31
+ export async function readFeeHistory(rpc, blocks, opts = {}) {
32
+ const caller = createRpc(rpc, opts.fetch ? { fetch: opts.fetch } : {});
33
+ const gas = opts.gas ?? HISTORY_GAS;
34
+ const results = await Promise.all(blocks.map((b) => caller('eth_getBlockByNumber', ['0x' + b.toString(16), false])));
35
+ const points = [];
36
+ for (const b of results) {
37
+ if (!b?.number || !b.baseFeePerGas)
38
+ continue;
39
+ const baseFeeWei = BigInt(b.baseFeePerGas);
40
+ // The dollar figure is the fixed rate, not the feed at that block: it is the line the chart shows flat.
41
+ points.push({ block: Number(BigInt(b.number)), time: new Date(Number(BigInt(b.timestamp ?? '0x0')) * 1000).toISOString(), baseFeeWei, rbnt: priceGas(gas, { baseFeeWei, priceRaw: 0n }).rbnt, usd: Number(gas) * USD_PER_GAS });
42
+ }
43
+ return points.sort((a, b) => a.block - b.block);
44
+ }
45
+ /** One chain: the quote, the four calls priced, and on mainnet the history. Never throws; a failed read is a result. */
46
+ export async function readFeesChain(rpc, expected, opts = {}) {
47
+ const doFetch = withTimeout(opts.fetch ?? fetch, opts.timeoutMs ?? 10_000);
48
+ const t0 = Date.now();
49
+ let quote;
50
+ try {
51
+ quote = await quoteGas(rpc, expected, { fetch: doFetch });
52
+ }
53
+ catch (e) {
54
+ const chain = expected ?? null;
55
+ return { ok: false, chain, network: chain === null ? null : chain === 151 ? 'mainnet' : 'testnet', rpc, latencyMs: Date.now() - t0, error: e.message };
56
+ }
57
+ const calls = priceCalls(quote);
58
+ let history = null;
59
+ let historyError = null;
60
+ if (quote.chain === 151 && opts.history !== false) {
61
+ try {
62
+ // The head is read again by height so the last point and the quote name the same block.
63
+ history = await readFeeHistory(rpc, [...HISTORY_BLOCKS_151.filter((b) => b < quote.block), quote.block], { fetch: doFetch });
64
+ }
65
+ catch (e) {
66
+ historyError = e.message;
67
+ }
68
+ }
69
+ return { ok: true, chain: quote.chain, network: quote.network, rpc, latencyMs: Date.now() - t0, quote, calls, history, historyError };
70
+ }
71
+ export async function runFees(opts = {}) {
72
+ let chains;
73
+ if (opts.rpc) {
74
+ chains = [await readFeesChain(opts.rpc, opts.chain, opts)];
75
+ }
76
+ else {
77
+ const ids = opts.chain ? [opts.chain] : [151, 153];
78
+ chains = await Promise.all(ids.map((id) => readFeesChain(chainById(id).rpcUrls.default.http[0], id, opts)));
79
+ }
80
+ return { tool: 'redbelly-fees', version: FEES_VERSION, generatedAt: new Date().toISOString(), chains, ok: chains.every((c) => c.ok) };
81
+ }
82
+ // ---- text ----
83
+ const num = (n) => n.toLocaleString('en-US');
84
+ const fixed = (n, d) => n.toLocaleString('en-US', { minimumFractionDigits: d, maximumFractionDigits: d });
85
+ const gwei = (wei) => `${fixed(Number(wei) / 1e9, 2)} gwei`;
86
+ const rbnt = (s) => {
87
+ const n = Number(s);
88
+ return n === 0 ? '0' : n < 0.000001 ? '<0.000001' : fixed(n, n < 1 ? 6 : 3);
89
+ };
90
+ const when = (iso) => iso.replace('T', ' ').replace(/\.\d+Z$/, ' UTC');
91
+ export function renderFeesText(report) {
92
+ const lines = [];
93
+ lines.push(`redbelly fees ${report.version} ${when(report.generatedAt)}`);
94
+ for (const c of report.chains) {
95
+ lines.push('');
96
+ if (!c.ok) {
97
+ lines.push(`chain ${c.chain ?? '?'}${c.network ? ` (${c.network})` : ''} via ${c.rpc}: could not be read: ${c.error}`);
98
+ continue;
99
+ }
100
+ const q = c.quote;
101
+ lines.push(`chain ${c.chain} (${c.network}) via ${c.rpc} read in ${num(c.latencyMs)} ms`);
102
+ lines.push(`head block ${num(q.block)} at ${when(q.blockTimestamp)} base fee ${gwei(q.baseFeeWei)}`);
103
+ lines.push(`RBNT US$${fixed(q.usdPerRbnt, 6)} from the feed at ${when(q.priceTimestamp)}`);
104
+ lines.push('');
105
+ const w = Math.max(...c.calls.map((r) => r.label.length));
106
+ lines.push(['Call'.padEnd(w), 'Gas'.padStart(11), 'RBNT'.padStart(12), 'US cents'.padStart(10)].join(' '));
107
+ for (const r of c.calls)
108
+ lines.push([r.label.padEnd(w), num(r.gas).padStart(11), rbnt(r.rbnt).padStart(12), fixed(r.cents, 3).padStart(10)].join(' '));
109
+ const enable = c.calls.find((r) => r.key === 'enable');
110
+ if (enable)
111
+ lines.push('', `enable: ${num(enable.gas)} gas = ${rbnt(enable.rbnt)} RBNT = US$${fixed(enable.usd, 4)} now; at the network's fixed rate of US$0.01 per 21,000 gas it is US$${fixed(Number(enable.gas) * USD_PER_GAS, 2)}, whatever RBNT trades at.`);
112
+ if (c.history && c.history.length) {
113
+ lines.push('', `the enable call across mainnet's life (${num(HISTORY_GAS)} gas at the base fee of each block; dollars at the fixed rate):`);
114
+ lines.push(['Block'.padStart(11), 'Date'.padEnd(10), 'Base fee'.padStart(20), 'RBNT'.padStart(12), 'US$'.padStart(8)].join(' '));
115
+ for (const p of c.history)
116
+ lines.push([num(p.block).padStart(11), p.time.slice(0, 10).padEnd(10), gwei(p.baseFeeWei).padStart(20), rbnt(p.rbnt).padStart(12), fixed(p.usd, 2).padStart(8)].join(' '));
117
+ }
118
+ else if (c.historyError) {
119
+ lines.push('', `history: not read (${c.historyError})`);
120
+ }
121
+ }
122
+ 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.');
123
+ return lines.join('\n') + '\n';
124
+ }
125
+ /** JSON with BigInt as decimal strings. */
126
+ export function feesReportJson(report) {
127
+ return JSON.stringify(report, (_k, v) => (typeof v === 'bigint' ? v.toString() : v), 2) + '\n';
128
+ }
package/dist/gas.d.ts CHANGED
@@ -26,6 +26,8 @@ export interface GasQuote {
26
26
  readonly network: 'mainnet' | 'testnet';
27
27
  readonly rpc: string;
28
28
  readonly block: number;
29
+ /** The head block's timestamp as ISO, so a quote can be dated without a second block read. */
30
+ readonly blockTimestamp: string;
29
31
  readonly baseFeeWei: bigint;
30
32
  /** The feed's raw integer (six decimals) and the derived USD per RBNT. */
31
33
  readonly priceRaw: bigint;
@@ -62,7 +64,24 @@ export interface GasReport {
62
64
  readonly added: readonly string[];
63
65
  readonly removed: readonly string[];
64
66
  } | null;
67
+ /** Testnet only: what the deployment rows cost in faucet claims, with pre-flight's 25% margin, at this quote. */
68
+ readonly faucet: FaucetDays | null;
65
69
  }
70
+ export interface FaucetDays {
71
+ /** What one FAUCETME claim pays, in test RBNT, and how often (read 15 September 2026). */
72
+ readonly perClaimRbnt: number;
73
+ readonly claimsPerDay: number;
74
+ /** Deployment gas summed over the rows, plus the 25% pre-flight's balance check adds. */
75
+ readonly gasWithMargin: bigint;
76
+ readonly rbnt: number;
77
+ readonly claims: number;
78
+ readonly days: number;
79
+ readonly pricedAt: string;
80
+ }
81
+ export declare const FAUCET_PER_CLAIM_RBNT = 500;
82
+ export declare const FAUCET_CLAIMS_PER_DAY = 1;
83
+ /** Faucet claims a project needs before its deploy: the deployment rows, 25% on top, at the quote's base fee and price. 153 only. */
84
+ export declare function faucetDays(rows: readonly PricedRow[], quote: GasQuote): FaucetDays | null;
66
85
  export interface GasOptions {
67
86
  /** A Foundry project, or a scaffold root (its contracts/ is used). Default: cwd. */
68
87
  project?: string;
@@ -97,7 +116,10 @@ export declare function priceRows(rows: readonly GasRow[], quote: Pick<GasQuote,
97
116
  added: string[];
98
117
  removed: string[];
99
118
  };
100
- export declare function quoteGas(rpc: string, expected?: 151 | 153): Promise<GasQuote>;
119
+ /** `fetch` is for tests and for callers that wrap a timeout round the global one; the reads are the same either way. */
120
+ export declare function quoteGas(rpc: string, expected?: 151 | 153, opts?: {
121
+ fetch?: typeof fetch;
122
+ }): Promise<GasQuote>;
101
123
  /** A scaffold root's contracts/ when that is where foundry.toml is; otherwise the directory itself. */
102
124
  export declare function foundryDir(project: string): string;
103
125
  export declare function readGasSource(dir: string, opts: Pick<GasOptions, 'snapshot' | 'report' | 'env'>): Promise<{
package/dist/gas.js CHANGED
@@ -7,10 +7,25 @@
7
7
  import { existsSync, readFileSync } from 'node:fs';
8
8
  import { createRequire } from 'node:module';
9
9
  import { basename, join, relative, resolve } from 'node:path';
10
- import { PRICE_FEED_DECIMALS, chainById, formatUnits, getLatestPrice, toCaller } from '@gatedpath/chains';
10
+ import { PRICE_FEED_DECIMALS, chainById, createRpc, formatUnits, getLatestPrice, toCaller } from '@gatedpath/chains';
11
11
  import { run } from './proc.js';
12
12
  const require = createRequire(import.meta.url);
13
13
  export const GAS_VERSION = require('../package.json').version;
14
+ export const FAUCET_PER_CLAIM_RBNT = 500;
15
+ export const FAUCET_CLAIMS_PER_DAY = 1;
16
+ /** Faucet claims a project needs before its deploy: the deployment rows, 25% on top, at the quote's base fee and price. 153 only. */
17
+ export function faucetDays(rows, quote) {
18
+ if (quote.chain !== 153)
19
+ return null;
20
+ const deploys = rows.filter((r) => r.item === 'deployment');
21
+ if (deploys.length === 0)
22
+ return null;
23
+ const gas = deploys.reduce((a, r) => a + r.gas, 0n);
24
+ const gasWithMargin = gas + gas / 4n;
25
+ const rbnt = Number(gasWithMargin * quote.baseFeeWei) / 1e18;
26
+ const claims = Math.max(1, Math.ceil(rbnt / FAUCET_PER_CLAIM_RBNT));
27
+ return { perClaimRbnt: FAUCET_PER_CLAIM_RBNT, claimsPerDay: FAUCET_CLAIMS_PER_DAY, gasWithMargin, rbnt, claims, days: Math.ceil(claims / FAUCET_CLAIMS_PER_DAY), pricedAt: quote.priceTimestamp };
28
+ }
14
29
  // ---- parsing, pure ----
15
30
  const SNAPSHOT_LINE = /^([A-Za-z0-9_$]+):(\S.*?)\s+\((.*)\)\s*$/;
16
31
  /** `.gas-snapshot`: `Contract:test() (gas: N)`, fuzz lines by their mean (μ), invariant lines (no gas) skipped. */
@@ -90,8 +105,9 @@ export function priceRows(rows, quote, before) {
90
105
  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
106
  }
92
107
  // ---- the two reads ----
93
- export async function quoteGas(rpc, expected) {
94
- const caller = toCaller(rpc);
108
+ /** `fetch` is for tests and for callers that wrap a timeout round the global one; the reads are the same either way. */
109
+ export async function quoteGas(rpc, expected, opts = {}) {
110
+ const caller = opts.fetch ? createRpc(rpc, { fetch: opts.fetch }) : toCaller(rpc);
95
111
  const reported = Number(BigInt((await caller('eth_chainId'))));
96
112
  if (reported !== 151 && reported !== 153)
97
113
  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)`);
@@ -100,12 +116,13 @@ export async function quoteGas(rpc, expected) {
100
116
  const block = (await caller('eth_getBlockByNumber', ['latest', false]));
101
117
  if (!block?.baseFeePerGas)
102
118
  throw new Error('the latest block has no baseFeePerGas');
103
- const price = await getLatestPrice({ rpc });
119
+ const price = await getLatestPrice({ rpc: caller });
104
120
  return {
105
121
  chain: reported,
106
122
  network: reported === 151 ? 'mainnet' : 'testnet',
107
123
  rpc,
108
124
  block: Number(BigInt(block.number ?? '0x0')),
125
+ blockTimestamp: new Date(Number(BigInt(block.timestamp ?? '0x0')) * 1000).toISOString(),
109
126
  baseFeeWei: BigInt(block.baseFeePerGas),
110
127
  priceRaw: price.raw,
111
128
  usdPerRbnt: price.usdPerRbnt,
@@ -175,6 +192,7 @@ export async function runGas(opts = {}) {
175
192
  quote,
176
193
  rows: priced.rows,
177
194
  diff: diffFile ? { file: diffFile, rows: before.length, added: priced.added, removed: priced.removed } : null,
195
+ faucet: faucetDays(priced.rows, quote),
178
196
  };
179
197
  }
180
198
  // ---- text ----
@@ -212,6 +230,8 @@ export function renderGasText(report) {
212
230
  lines.push('');
213
231
  if (deploys.length)
214
232
  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.`);
233
+ if (report.faucet)
234
+ lines.push(`faucet: ${num(report.faucet.gasWithMargin)} gas with the 25% margin = ${fixed(report.faucet.rbnt, 1)} test RBNT at this base fee = ${report.faucet.claims} claim${report.faucet.claims === 1 ? '' : 's'} of ${report.faucet.perClaimRbnt} (${report.faucet.days} day${report.faucet.days === 1 ? '' : 's'}, one claim a day) before you can deploy. Priced at ${report.faucet.pricedAt}; the base fee moves, so re-run on the day.`);
215
235
  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
236
  return lines.join('\n') + '\n';
217
237
  }
package/dist/index.d.ts CHANGED
@@ -21,6 +21,9 @@ export { doctorMain, parseDoctorArgs, DOCTOR_HELP } from './doctor-cli.js';
21
21
  export { runGas, quoteGas, priceGas, priceRows, parseGasSnapshot, parseGasReportJson, parseGasFile, readGasSource, foundryDir, renderGasText, gasReportJson } from './gas.js';
22
22
  export type { GasRow, GasSource, GasQuote, PricedRow, GasReport, GasOptions } from './gas.js';
23
23
  export { gasMain, parseGasArgs, GAS_HELP } from './gas-cli.js';
24
+ export { runFees, readFeesChain, readFeeHistory, priceCalls, renderFeesText, feesReportJson, REFERENCE_CALLS, USD_PER_GAS, HISTORY_BLOCKS_151, HISTORY_GAS, FEES_VERSION } from './fees.js';
25
+ export type { ReferenceCall, PricedCall, HistoryPoint, FeesChain, FeesChainOk, FeesChainFailed, FeesReport, FeesOptions } from './fees.js';
26
+ export { feesMain, parseFeesArgs, FEES_HELP } from './fees-cli.js';
24
27
  export { findGatedSuites, summariseTests } from './suites.js';
25
28
  export type { ForgeTestJson, SuiteSummary } from './suites.js';
26
29
  export { runShip, renderShipMarkdown, renderShipText, shipReportJson, shipReportPath, parseShipHeader, readShipContracts, routescanBase, utcDate, SHIP_HEADER } from './ship.js';
package/dist/index.js CHANGED
@@ -15,6 +15,8 @@ export { runDoctor, renderDoctorText, whichOnPath, toolVersion, exitCodePropagat
15
15
  export { doctorMain, parseDoctorArgs, DOCTOR_HELP } from './doctor-cli.js';
16
16
  export { runGas, quoteGas, priceGas, priceRows, parseGasSnapshot, parseGasReportJson, parseGasFile, readGasSource, foundryDir, renderGasText, gasReportJson } from './gas.js';
17
17
  export { gasMain, parseGasArgs, GAS_HELP } from './gas-cli.js';
18
+ export { runFees, readFeesChain, readFeeHistory, priceCalls, renderFeesText, feesReportJson, REFERENCE_CALLS, USD_PER_GAS, HISTORY_BLOCKS_151, HISTORY_GAS, FEES_VERSION } from './fees.js';
19
+ export { feesMain, parseFeesArgs, FEES_HELP } from './fees-cli.js';
18
20
  export { findGatedSuites, summariseTests } from './suites.js';
19
21
  export { runShip, renderShipMarkdown, renderShipText, shipReportJson, shipReportPath, parseShipHeader, readShipContracts, routescanBase, utcDate, SHIP_HEADER } from './ship.js';
20
22
  export { shipMain, parseShipArgs, SHIP_HELP } from './ship-cli.js';
package/dist/redbelly.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
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.
3
+ // checks, `doctor` the machine, `gas` the report in RBNT and US cents, `fees` what the measured calls
4
+ // cost on each chain today, `ship` the mainnet gate that writes its own report. Each is also its own bin (redbelly-preflight, redbelly-doctor) or an MCP tool.
5
5
  import { realpathSync } from 'node:fs';
6
6
  import { pathToFileURL } from 'node:url';
7
7
  import { main as preflightMain } from './cli.js';
@@ -11,7 +11,8 @@ const HELP = `redbelly <command> [options]
11
11
  preflight the seven checks before a deploy (redbelly-preflight)
12
12
  doctor the machine before the first command (redbelly-doctor)
13
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
14
+ fees what the measured calls cost on 151 and 153 today: head block, base fee, feed price, RBNT and US cents
15
+ ship pre-flight, Slither, the five-state tests and the gas figures, written to deployments/ship-<chain>-<date>.md
15
16
 
16
17
  redbelly <command> --help for each. Nothing here takes a key; signing tools take a keystore name.
17
18
  `;
@@ -19,6 +20,7 @@ const commands = {
19
20
  preflight: preflightMain,
20
21
  doctor: doctorMain,
21
22
  gas: async (argv) => (await import('./gas-cli.js')).gasMain(argv),
23
+ fees: async (argv) => (await import('./fees-cli.js')).feesMain(argv),
22
24
  ship: async (argv) => (await import('./ship-cli.js')).shipMain(argv),
23
25
  };
24
26
  export async function redbellyMain(argv = process.argv.slice(2)) {
@@ -1,5 +1,5 @@
1
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";
2
+ export declare const SHIP_HELP = "redbelly ship --chain <151|153> --account <keystore-name> [options]\n (or --address <0x\u2026> in place of --account; DEPLOYER in .env also works)\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> Foundry keystore name, resolved with cast wallet address. cast asks for the\n keystore password on the terminal, so run this in one (not from an editor's\n run button); without a terminal pass --password-file, or use --address\n --address <0x\u2026> the deployer's address, no cast call; the report says it came this way\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
3
  export declare function parseShipArgs(argv: readonly string[]): (Partial<ShipOptions> & {
4
4
  json: boolean;
5
5
  help: boolean;
package/dist/ship-cli.js CHANGED
@@ -3,6 +3,7 @@
3
3
  // arguments. --json for agents. Never takes a key: --account names a keystore that cast reads.
4
4
  import { SHIP_VERSION, renderShipText, runShip, shipReportJson, ShipUsageError } from './ship.js';
5
5
  export const SHIP_HELP = `redbelly ship --chain <151|153> --account <keystore-name> [options]
6
+ (or --address <0x…> in place of --account; DEPLOYER in .env also works)
6
7
 
7
8
  Runs pre-flight (seven checks) with the keystore's address, checks the Slither sidecar, runs
8
9
  forge test and the five-state suites, prices the gas report at the chain's base fee and feed
@@ -12,7 +13,10 @@ constructor arguments and the verify command. Refuses to write when any check fa
12
13
  deploy script refuses to broadcast on 151 without a report dated today; 153 warns.
13
14
 
14
15
  --chain <151|153> required
15
- --account <name> required; Foundry keystore name, resolved with cast wallet address
16
+ --account <name> Foundry keystore name, resolved with cast wallet address. cast asks for the
17
+ keystore password on the terminal, so run this in one (not from an editor's
18
+ run button); without a terminal pass --password-file, or use --address
19
+ --address <0x…> the deployer's address, no cast call; the report says it came this way
16
20
  --password-file <path> passed through to cast (or set ETH_PASSWORD)
17
21
  --admin <0x…> the Safe that receives every role (or ADMIN_SAFE in .env); required on 151
18
22
  --gas <units> deployment gas for the balance check (default: the gas report's deployment row)
@@ -50,6 +54,9 @@ export function parseShipArgs(argv) {
50
54
  case '--account':
51
55
  o.account = next();
52
56
  break;
57
+ case '--address':
58
+ o.address = next();
59
+ break;
53
60
  case '--password-file':
54
61
  o.passwordFile = next();
55
62
  break;
@@ -91,9 +98,13 @@ export function parseShipArgs(argv) {
91
98
  if (!o.help && !o.version) {
92
99
  if (!o.chain)
93
100
  throw new Error(`--chain 151 or 153 is required\n\n${SHIP_HELP}`);
94
- if (!o.account)
95
- throw new Error(`--account <keystore-name> is required: the report carries the deployer's address and its isAllowed reading\n\n${SHIP_HELP}`);
96
- if (!/^[A-Za-z0-9._-]{1,64}$/.test(o.account))
101
+ if (!o.account && !o.address && !process.env['DEPLOYER'])
102
+ throw new Error(`--account <keystore-name> is required (or --address <0x…>, or DEPLOYER in the environment): the report carries the deployer's address and its isAllowed reading\n\n${SHIP_HELP}`);
103
+ if (o.account && o.address)
104
+ throw new Error('give --account or --address, not both');
105
+ if (o.address && !/^0x[0-9a-fA-F]{40}$/.test(o.address))
106
+ throw new Error('--address must be a 20-byte hex address');
107
+ if (o.account && !/^[A-Za-z0-9._-]{1,64}$/.test(o.account))
97
108
  throw new Error('--account: keystore names use letters, digits, dot, dash and underscore only');
98
109
  }
99
110
  return o;
package/dist/ship.d.ts CHANGED
@@ -7,8 +7,10 @@ export interface ShipOptions {
7
7
  /** Foundry project or scaffold root (its contracts/ is used). Default: cwd. */
8
8
  project?: string;
9
9
  chain: 151 | 153;
10
- /** Foundry keystore name; cast resolves the address, nothing else is read from it. */
11
- account: string;
10
+ /** Foundry keystore name; cast resolves the address, nothing else is read from it. One of account, address or DEPLOYER in .env. */
11
+ account?: string;
12
+ /** The deployer's address given directly: no cast call, no password prompt. */
13
+ address?: string;
12
14
  passwordFile?: string;
13
15
  /** The Safe that receives every role. Required on 151; ADMIN_SAFE in .env fills it in. */
14
16
  admin?: string;
@@ -31,6 +33,8 @@ export interface ShipContract {
31
33
  readonly name: string;
32
34
  readonly address: string;
33
35
  readonly record: string;
36
+ /** When the record file was last written, so a report made before a deploy is read as such. */
37
+ readonly writtenAt: string;
34
38
  readonly deployer: string | null;
35
39
  readonly admin: string | null;
36
40
  readonly verifier: string | null;
package/dist/ship.js CHANGED
@@ -6,14 +6,14 @@
6
6
  // refuses to write when any check fails; the deploy script refuses to broadcast on 151 without a
7
7
  // report dated today. Nothing here signs: the account name resolves to an address through cast,
8
8
  // the RPC is read, forge runs tests on the local machine.
9
- import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
9
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
10
10
  import { createRequire } from 'node:module';
11
11
  import { basename, join, relative, resolve } from 'node:path';
12
12
  import { chainById, decodeWords, ethCall, isAllowed, toCaller } from '@gatedpath/chains';
13
13
  import { readProjectConfig } from './config.js';
14
14
  import { readDotEnv } from './dotenv.js';
15
15
  import { readAdmin } from './checks/admin.js';
16
- import { parseGasReportJson, priceRows, quoteGas } from './gas.js';
16
+ import { faucetDays, parseGasReportJson, priceRows, quoteGas } from './gas.js';
17
17
  import { run } from './proc.js';
18
18
  import { DEFAULT_GAS, runPreflight } from './run.js';
19
19
  import { findGatedSuites, summariseTests } from './suites.js';
@@ -66,10 +66,15 @@ export function readShipContracts(dir, chain, pins) {
66
66
  for (const name of readdirSync(dep).sort()) {
67
67
  if (!(name === `${chain}.json` || (name.startsWith(`${chain}-`) && name.endsWith('.json'))))
68
68
  continue;
69
+ // The deploy script writes a dry run's record as <chain>-<Name>.dry-run.json (0.2.3) and says
70
+ // "broadcast": false inside it; neither describes a contract anyone can verify.
71
+ if (name.endsWith('.dry-run.json'))
72
+ continue;
69
73
  const record = readJson(join(dep, name));
70
74
  const address = str(record?.['contract']);
71
- if (!record || !address)
75
+ if (!record || !address || record['broadcast'] === false)
72
76
  continue;
77
+ const writtenAt = statSync(join(dep, name)).mtime.toISOString();
73
78
  const contractName = str(record['contractName']) ?? basename(name, '.json').replace(new RegExp(`^${chain}-`), '');
74
79
  const sig = str(record['constructorSignature']);
75
80
  const args = str(record['constructorArgs']);
@@ -79,6 +84,7 @@ export function readShipContracts(dir, chain, pins) {
79
84
  name: contractName,
80
85
  address,
81
86
  record: join(dep, name),
87
+ writtenAt,
82
88
  deployer: str(record['deployer']),
83
89
  admin: str(record['admin']),
84
90
  verifier: str(record['verifier']),
@@ -151,14 +157,14 @@ export async function runShip(opts) {
151
157
  // 2. pre-flight, seven checks, with the keystore's address and the measured deployment gas
152
158
  const gasForBalance = opts.gas !== undefined ? BigInt(opts.gas) : (deploymentGas ?? DEFAULT_GAS);
153
159
  const admin = opts.admin ?? rootEnv['ADMIN_SAFE'];
154
- const preflight = await runPreflight({ project: dir, chain, rpc, account: opts.account, ...(opts.passwordFile ? { passwordFile: opts.passwordFile } : {}), ...(admin ? { admin } : {}), gas: gasForBalance, env });
160
+ const preflight = await runPreflight({ project: dir, chain, rpc, ...(opts.account ? { account: opts.account } : {}), ...(opts.address ? { address: opts.address } : {}), ...(opts.passwordFile ? { passwordFile: opts.passwordFile } : {}), ...(admin ? { admin } : {}), gas: gasForBalance, env });
155
161
  for (const c of preflight.checks)
156
162
  checks.push({ id: c.id, status: c.status, reason: c.reason, ...(c.data ? { data: c.data } : {}) });
157
163
  if (chain === 151 && !admin)
158
164
  checks.push({ id: 'admin-required', status: 'fail', reason: 'Chain 151 needs --admin <safe> (or ADMIN_SAFE in .env): the Safe 1.4.1 that receives every role.' });
159
165
  // 3. the verifier the deploy script will bind
160
166
  const verifierAddr = opts.verifier ?? rootEnv['VERIFIER'] ?? null;
161
- const verifierSource = opts.verifier ? '--verifier' : rootEnv['VERIFIER'] ? 'VERIFIER in .env' : 'none';
167
+ const verifierSource = opts.verifier ? '--verifier' : env['VERIFIER'] ? 'VERIFIER in the environment' : rootEnv['VERIFIER'] ? 'VERIFIER in .env' : 'none';
162
168
  let verifierIsContract = null;
163
169
  if (verifierAddr) {
164
170
  if (!/^0x[0-9a-fA-F]{40}$/.test(verifierAddr))
@@ -214,7 +220,7 @@ export async function runShip(opts) {
214
220
  const deployerCheck = preflight.checks.find((c) => c.id === 'deployer-verified');
215
221
  const deployer = {
216
222
  address: preflight.deployer,
217
- how: deployerCheck?.data?.['how'] ?? (opts.account ? `cast wallet address --account ${opts.account}` : 'none'),
223
+ how: deployerCheck?.data?.['how'] ?? (opts.account ? `cast wallet address --account ${opts.account}` : opts.address ? '--address' : 'none'),
218
224
  isAllowed: typeof deployerCheck?.data?.['allowed'] === 'boolean' ? deployerCheck.data['allowed'] : null,
219
225
  wellKnownDevAccount: deployerCheck?.data?.['wellKnownDevAccount'] === true,
220
226
  };
@@ -286,7 +292,22 @@ export class ShipUsageError extends Error {
286
292
  // ---- the document ----
287
293
  const fmt = (n) => n.toLocaleString('en-US');
288
294
  const short = (s) => (s && s.includes('.') ? s.replace(/(\.\d{4})\d+$/, '$1') : s ?? '');
295
+ /** The document goes into deployments/, which is committed and, for a public project, published. So it
296
+ * names the project by its last two path segments, never by a path on the machine that wrote it, and
297
+ * says `<keystore-name>` where the terminal said the real one (found 25 October 2026 by the project
298
+ * export's scan on the escrow's report). The JSON and the terminal keep both. */
299
+ function forTheRecord(text, r) {
300
+ const shown = r.project.split(/[\\/]/).filter(Boolean).slice(-2).join('/');
301
+ let out = text.split(r.project).join(shown);
302
+ const account = /--account (\S+)/.exec(r.deployer.how)?.[1];
303
+ if (account)
304
+ out = out.split(`--account ${account}`).join('--account <keystore-name>');
305
+ return out;
306
+ }
289
307
  export function renderShipMarkdown(r) {
308
+ return forTheRecord(renderShipBody(r), r);
309
+ }
310
+ function renderShipBody(r) {
290
311
  const L = [];
291
312
  const fails = r.checks.filter((c) => c.status === 'fail');
292
313
  const warns = r.checks.filter((c) => c.status === 'warn');
@@ -316,7 +337,7 @@ export function renderShipMarkdown(r) {
316
337
  L.push('## Contracts');
317
338
  L.push('');
318
339
  if (r.contracts.length === 0) {
319
- L.push(`No deployment record for chain ${r.chain} under \`deployments/\` yet. Deploy with \`forge script script/Deploy.s.sol --rpc-url ${r.network === 'mainnet' ? 'redbelly_mainnet' : 'redbelly_testnet'} --account <keystore-name> --broadcast\`, then run \`redbelly ship\` again today: this section fills in with the address, the constructor arguments and the exact verify command.`);
340
+ L.push(`No deployment record for chain ${r.chain} under \`deployments/\` yet. Deploy with \`forge script script/Deploy.s.sol --rpc-url ${r.network === 'mainnet' ? 'redbelly_mainnet' : 'redbelly_testnet'} --account <keystore-name> --sender ${r.deployer.address ?? '<your-address>'} --broadcast\`, then run \`redbelly ship\` again today: this section fills in with the address, the constructor arguments and the exact verify command.`);
320
341
  }
321
342
  else {
322
343
  for (const c of r.contracts) {
@@ -324,6 +345,8 @@ export function renderShipMarkdown(r) {
324
345
  L.push('');
325
346
  L.push(`Record \`${relative(r.project, c.record)}\`${c.deployer ? `, deployed by ${c.deployer}` : ''}${c.admin ? `, admin ${c.admin}` : ''}${c.verifier ? `, verifier ${c.verifier}` : ''}${c.requestId ? `, request id ${c.requestId}` : ''}. Explorer: ${c.explorer}`);
326
347
  L.push('');
348
+ L.push(`This section describes the record as it stood at ${c.writtenAt}. A deploy made after that rewrites the record; run \`redbelly ship\` again afterwards and the command below follows the new address.`);
349
+ L.push('');
327
350
  if (c.constructorSignature)
328
351
  L.push(`Constructor \`${c.constructorSignature}\`, arguments as encoded by the deploy script:`);
329
352
  else
@@ -343,6 +366,9 @@ export function renderShipMarkdown(r) {
343
366
  const q = r.gas.quote;
344
367
  L.push(`Priced at block ${fmt(q.block)}: base fee ${(Number(q.baseFeeWei) / 1e9).toLocaleString('en-US', { maximumFractionDigits: 2 })} gwei, RBNT US$${q.usdPerRbnt} on the feed at ${q.priceFeed} (${q.priceTimestamp}). Gas is priced in US dollars on this chain and converted to RBNT at execution, so the cents column is the stable one.`);
345
368
  L.push('');
369
+ const faucet = faucetDays(r.gas.rows, r.gas.quote);
370
+ if (faucet)
371
+ L.push(`Faucet: the deployment rows with pre-flight's 25% margin come to ${fmt(faucet.gasWithMargin)} gas = ${faucet.rbnt.toFixed(1)} test RBNT at this base fee, ${faucet.claims} claim${faucet.claims === 1 ? '' : 's'} of ${faucet.perClaimRbnt} from the faucet (${faucet.days} day${faucet.days === 1 ? '' : 's'}, one claim a day). The base fee moves; re-run on the day.`, '');
346
372
  if (r.gas.deployment)
347
373
  L.push(`Deployment of ${r.gas.deployment.contract}: ${fmt(r.gas.deployment.gas)} gas = ${short(r.gas.deployment.rbnt)} RBNT = ${r.gas.deployment.cents.toFixed(2)} US cents. Pre-flight's balance check used ${fmt(BigInt(r.preflight.checks.find((c) => c.id === 'balance')?.data?.['gas'] ?? '0'))} gas with 25% on top.`);
348
374
  L.push('');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gatedpath/preflight",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Pre-flight checks before deploying to Redbelly Network (151 mainnet, 153 testnet): chain ID, deployer verification, Safe admin, compiler pins, secrets in git history, RBNT balance, Slither report.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -35,13 +35,13 @@
35
35
  ],
36
36
  "scripts": {
37
37
  "build": "node scripts/ensure-deps-built.mjs && tsc -p tsconfig.json && node -e \"for (const f of ['cli.js','doctor-cli.js','redbelly.js']) require('fs').chmodSync('dist/'+f,0o755)\"",
38
- "test": "npm run build && node --test --test-reporter=spec --test-concurrency=1 test/unit.test.mjs test/preflight.test.mjs test/doctor.test.mjs test/gas.test.mjs test/ship.test.mjs",
38
+ "test": "npm run build && node --test --test-reporter=spec --test-concurrency=1 test/unit.test.mjs test/preflight.test.mjs test/doctor.test.mjs test/gas.test.mjs test/fees.test.mjs test/ship.test.mjs",
39
39
  "test:live": "npm run build && node --test --test-reporter=spec test/live.test.mjs",
40
40
  "prepack": "npm run build"
41
41
  },
42
42
  "dependencies": {
43
- "@gatedpath/agent-rules": "0.2.1",
44
- "@gatedpath/chains": "0.2.1"
43
+ "@gatedpath/agent-rules": "0.2.3",
44
+ "@gatedpath/chains": "0.2.3"
45
45
  },
46
46
  "devDependencies": {
47
47
  "@types/node": "^22.20.2",