@gatedpath/preflight 0.2.2 → 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,6 +90,26 @@ 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
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;
@@ -114,7 +116,10 @@ export declare function priceRows(rows: readonly GasRow[], quote: Pick<GasQuote,
114
116
  added: string[];
115
117
  removed: string[];
116
118
  };
117
- 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>;
118
123
  /** A scaffold root's contracts/ when that is where foundry.toml is; otherwise the directory itself. */
119
124
  export declare function foundryDir(project: string): string;
120
125
  export declare function readGasSource(dir: string, opts: Pick<GasOptions, 'snapshot' | 'report' | 'env'>): Promise<{
package/dist/gas.js CHANGED
@@ -7,7 +7,7 @@
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;
@@ -105,8 +105,9 @@ export function priceRows(rows, quote, before) {
105
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)) : [] };
106
106
  }
107
107
  // ---- the two reads ----
108
- export async function quoteGas(rpc, expected) {
109
- 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);
110
111
  const reported = Number(BigInt((await caller('eth_chainId'))));
111
112
  if (reported !== 151 && reported !== 153)
112
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)`);
@@ -115,12 +116,13 @@ export async function quoteGas(rpc, expected) {
115
116
  const block = (await caller('eth_getBlockByNumber', ['latest', false]));
116
117
  if (!block?.baseFeePerGas)
117
118
  throw new Error('the latest block has no baseFeePerGas');
118
- const price = await getLatestPrice({ rpc });
119
+ const price = await getLatestPrice({ rpc: caller });
119
120
  return {
120
121
  chain: reported,
121
122
  network: reported === 151 ? 'mainnet' : 'testnet',
122
123
  rpc,
123
124
  block: Number(BigInt(block.number ?? '0x0')),
125
+ blockTimestamp: new Date(Number(BigInt(block.timestamp ?? '0x0')) * 1000).toISOString(),
124
126
  baseFeeWei: BigInt(block.baseFeePerGas),
125
127
  priceRaw: price.raw,
126
128
  usdPerRbnt: price.usdPerRbnt,
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)) {
package/dist/ship.d.ts CHANGED
@@ -33,6 +33,8 @@ export interface ShipContract {
33
33
  readonly name: string;
34
34
  readonly address: string;
35
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;
36
38
  readonly deployer: string | null;
37
39
  readonly admin: string | null;
38
40
  readonly verifier: string | null;
package/dist/ship.js CHANGED
@@ -6,7 +6,7 @@
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';
@@ -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']),
@@ -158,7 +164,7 @@ export async function runShip(opts) {
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))
@@ -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');
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gatedpath/preflight",
3
- "version": "0.2.2",
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.2",
44
- "@gatedpath/chains": "0.2.2"
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",