From b64962c6af49e2662fe53c8eb9773c9a5136b459 Mon Sep 17 00:00:00 2001 From: Sybi Date: Sun, 11 Oct 2026 17:41:20 +0000 Subject: [PATCH] feat: add --json, --watch and --interval --- bin/sol-priority-fee.js | 6 ++ src/args.js | 23 ++++++- src/cli.js | 141 +++++++++++++++++++++++++++++----------- src/format.js | 6 +- src/index.js | 3 + src/json.js | 46 +++++++++++++ src/watch.js | 49 ++++++++++++++ tests/args.test.js | 21 ++++++ tests/cli.test.js | 137 +++++++++++++++++++++++++++++++++++--- tests/format.test.js | 4 +- tests/json.test.js | 44 +++++++++++++ tests/watch.test.js | 140 +++++++++++++++++++++++++++++++++++++++ 12 files changed, 565 insertions(+), 55 deletions(-) create mode 100644 src/json.js create mode 100644 src/watch.js create mode 100644 tests/json.test.js create mode 100644 tests/watch.test.js diff --git a/bin/sol-priority-fee.js b/bin/sol-priority-fee.js index ae4f488..22a03a1 100755 --- a/bin/sol-priority-fee.js +++ b/bin/sol-priority-fee.js @@ -3,6 +3,12 @@ const { run } = require('../src/cli'); +// Piping into something like `head` closes stdout early: exit quietly instead of crashing. +process.stdout.on('error', (err) => { + if (err.code === 'EPIPE') process.exit(0); + throw err; +}); + run(process.argv.slice(2)).then((code) => { process.exitCode = code; }); \ No newline at end of file diff --git a/src/args.js b/src/args.js index 8da35a2..4fb781a 100644 --- a/src/args.js +++ b/src/args.js @@ -1,6 +1,9 @@ 'use strict'; const MAX_UNITS = 1400000; +const DEFAULT_INTERVAL = 10; +const MIN_INTERVAL = 5; +const MAX_INTERVAL = 3600; function parseArgs(argv) { const opts = { @@ -10,6 +13,7 @@ function parseArgs(argv) { watch: false, rpc: null, units: null, + interval: null, accounts: [], errors: [], }; @@ -25,7 +29,7 @@ function parseArgs(argv) { opts.json = true; } else if (arg === '--watch') { opts.watch = true; - } else if (arg === '--rpc' || arg === '--account' || arg === '--units') { + } else if (arg === '--rpc' || arg === '--account' || arg === '--units' || arg === '--interval') { const value = argv[i + 1]; if (value === undefined || value.startsWith('--')) { opts.errors.push(`${arg} needs a value`); @@ -35,13 +39,22 @@ function parseArgs(argv) { opts.rpc = value; } else if (arg === '--account') { opts.accounts.push(value); - } else { + } else if (arg === '--units') { const units = Number(value); if (/^\d+$/.test(value) && units >= 1 && units <= MAX_UNITS) { opts.units = units; } else { opts.errors.push(`--units must be a whole number between 1 and ${MAX_UNITS}`); } + } else { + const seconds = Number(value); + if (/^\d+$/.test(value) && seconds >= MIN_INTERVAL && seconds <= MAX_INTERVAL) { + opts.interval = seconds; + } else { + opts.errors.push( + `--interval must be a whole number of seconds between ${MIN_INTERVAL} and ${MAX_INTERVAL}`, + ); + } } } } else { @@ -49,7 +62,11 @@ function parseArgs(argv) { } } + if (opts.interval !== null && !opts.watch) { + opts.errors.push('--interval only works with --watch'); + } + return opts; } -module.exports = { parseArgs, MAX_UNITS }; \ No newline at end of file +module.exports = { parseArgs, MAX_UNITS, DEFAULT_INTERVAL, MIN_INTERVAL, MAX_INTERVAL }; \ No newline at end of file diff --git a/src/cli.js b/src/cli.js index 2a9fdf5..aa3905f 100644 --- a/src/cli.js +++ b/src/cli.js @@ -1,7 +1,7 @@ 'use strict'; const pkg = require('../package.json'); -const { parseArgs } = require('./args'); +const { parseArgs, DEFAULT_INTERVAL, MIN_INTERVAL } = require('./args'); const { resolveEndpoint, hostLabel, RpcError } = require('./rpc'); const { isValidAddress } = require('./base58'); const { @@ -12,8 +12,11 @@ const { DEFAULT_COMPUTE_UNITS, } = require('./fees'); const { formatFees, formatAccountFees } = require('./format'); +const { blocksToJson, accountToJson } = require('./json'); +const { watchLoop, wait } = require('./watch'); const MAX_ACCOUNTS = 128; +const NO_DATA_HINT = 'Try again in a moment or use a different RPC (--rpc ).'; const HELP = `sol-priority-fee - recommended Solana priority fee from what recent transactions paid @@ -24,17 +27,21 @@ Options: --account
Fee floor for landing a transaction that locks this account as writable (repeatable, up to ${MAX_ACCOUNTS}) --units Compute unit limit used for the prices (default: ${DEFAULT_COMPUTE_UNITS}) + --watch Keep refreshing until you press Ctrl+C + --interval Seconds between refreshes with --watch (default: ${DEFAULT_INTERVAL}, minimum ${MIN_INTERVAL}) + --json Print JSON (one object per line with --watch) --rpc RPC endpoint (default: SOL_PRIORITY_FEE_RPC, then public mainnet RPC) - --watch Keep refreshing the recommendation - --json Print machine-readable JSON -v, --version Print version -h, --help Show this help + +Without --account, each refresh reads 3 recent blocks (about 4 RPC calls). `; function defaultIo() { return { out: (text) => process.stdout.write(text + '\n'), err: (text) => process.stderr.write(text + '\n'), + clear: process.stdout.isTTY ? () => process.stdout.write('\x1b[2J\x1b[H') : null, }; } @@ -52,6 +59,49 @@ function checkAccounts(accounts) { return { accounts: unique, errors }; } +async function collect(ctx) { + const { endpoint, accounts, units, rpcOptions } = ctx; + + if (accounts.length > 0) { + const samples = await fetchAccountFees(endpoint, accounts, rpcOptions); + const summary = summarizeAccountFees(samples, { computeUnits: units }); + if (!summary) { + throw new RpcError('the RPC returned no recent fee samples for these accounts.', { + hint: NO_DATA_HINT, + code: 'no-data', + }); + } + return { kind: 'account', summary }; + } + + const sample = await sampleRecentFees(endpoint, { rpcOptions }); + const summary = summarizeFees(sample, { computeUnits: units }); + if (!summary) { + throw new RpcError('no usable transactions found in recent blocks.', { + hint: NO_DATA_HINT, + code: 'no-data', + }); + } + return { kind: 'blocks', summary }; +} + +function render(result, ctx, now) { + const { host, accounts, json, watch } = ctx; + + if (json) { + const data = + result.kind === 'account' + ? accountToJson(result.summary, { host, now, accounts }) + : blocksToJson(result.summary, { host, now }); + // Pretty for a single run, one compact line per refresh while watching. + return JSON.stringify(data, null, watch ? undefined : 2); + } + + return result.kind === 'account' + ? formatAccountFees(result.summary, host, accounts.length) + : formatFees(result.summary, host); +} + async function run(argv, io = defaultIo(), deps = {}) { const opts = parseArgs(argv); @@ -77,48 +127,61 @@ async function run(argv, io = defaultIo(), deps = {}) { return 2; } - const notYet = []; - if (opts.json) notYet.push('--json'); - if (opts.watch) notYet.push('--watch'); - if (notYet.length > 0) { - io.err(`error: ${notYet.join(', ')} not available yet (coming in a later release).`); - return 2; - } - const endpoint = resolveEndpoint(opts.rpc, deps.env || process.env); - const rpcOptions = { fetchImpl: deps.fetchImpl, sleepImpl: deps.sleepImpl }; - const units = opts.units || DEFAULT_COMPUTE_UNITS; - const host = hostLabel(endpoint); + const ctx = { + endpoint, + host: hostLabel(endpoint), + accounts: checked.accounts, + units: opts.units || DEFAULT_COMPUTE_UNITS, + json: opts.json, + watch: opts.watch, + rpcOptions: { fetchImpl: deps.fetchImpl, sleepImpl: deps.sleepImpl }, + }; + const now = deps.now || (() => new Date()); - try { - if (checked.accounts.length > 0) { - const samples = await fetchAccountFees(endpoint, checked.accounts, rpcOptions); - const summary = summarizeAccountFees(samples, { computeUnits: units }); - if (!summary) { - io.err('error: the RPC returned no recent fee samples for these accounts.'); - io.err('Try again in a moment or use a different RPC (--rpc ).'); + if (!opts.watch) { + try { + const result = await collect(ctx); + io.out(render(result, ctx, now())); + return 0; + } catch (err) { + if (err instanceof RpcError) { + io.err(`error: ${err.message}`); + if (err.hint) io.err(err.hint); return 1; } - io.out(formatAccountFees(summary, host, checked.accounts.length)); - return 0; + throw err; } + } - const sample = await sampleRecentFees(endpoint, { rpcOptions }); - const summary = summarizeFees(sample, { computeUnits: units }); - if (!summary) { - io.err('error: no usable transactions found in recent blocks.'); - io.err('Try again in a moment or use a different RPC (--rpc ).'); - return 1; - } - io.out(formatFees(summary, host)); - return 0; - } catch (err) { - if (err instanceof RpcError) { - io.err(`error: ${err.message}`); - if (err.hint) io.err(err.hint); - return 1; - } - throw err; + const intervalSeconds = opts.interval || DEFAULT_INTERVAL; + const controller = deps.signal ? null : new AbortController(); + const signal = deps.signal || controller.signal; + const onSigint = controller ? () => controller.abort() : null; + if (onSigint) process.once('SIGINT', onSigint); + + try { + return await watchLoop({ + intervalMs: intervalSeconds * 1000, + signal, + wait: deps.waitImpl || wait, + io, + refresh: async () => { + const result = await collect(ctx); + const time = now(); + if (opts.json) { + io.out(render(result, ctx, time)); + return; + } + if (io.clear) io.clear(); + else io.out(''); + io.out(`Updated ${time.toISOString().slice(11, 19)} UTC - refreshing every ${intervalSeconds}s, Ctrl+C to stop`); + io.out(''); + io.out(render(result, ctx, time)); + }, + }); + } finally { + if (onSigint) process.removeListener('SIGINT', onSigint); } } diff --git a/src/format.js b/src/format.js index cf2b7c2..f687a05 100644 --- a/src/format.js +++ b/src/format.js @@ -1,6 +1,7 @@ 'use strict'; const LEVEL_NAMES = ['low', 'medium', 'high']; +const SLOT_SECONDS = 0.4; function formatInt(n) { return Number(n).toLocaleString('en-US'); @@ -46,10 +47,13 @@ function formatFees(summary, host) { function formatAccountFees(summary, host, accountCount) { const { levels, sampleCount, slotRange, computeUnits } = summary; const single = accountCount === 1; + const seconds = Math.max(1, Math.round(sampleCount * SLOT_SECONDS)); const lines = [`Priority fee floor for ${accountCount} ${single ? 'account' : 'accounts'}`, '', ...levelTable(levels), '']; lines.push(`Lamports per transaction assume a ${formatInt(computeUnits)} compute unit limit (change it with --units).`); - lines.push(`Based on ${formatInt(sampleCount)} recent slots (${slotRange.from} to ${slotRange.to}) via ${host}.`); + lines.push( + `Based on ${formatInt(sampleCount)} recent slots (${slotRange.from} to ${slotRange.to}, about ${seconds} seconds) via ${host}.`, + ); lines.push( `For each slot the RPC reports the fee needed to land a transaction that locks ` + `${single ? 'this account' : 'all of these accounts'} as writable.`, diff --git a/src/index.js b/src/index.js index aebf8c7..43bf736 100644 --- a/src/index.js +++ b/src/index.js @@ -13,6 +13,7 @@ const { lamportsForPrice, } = require('./fees'); const { formatFees, formatAccountFees } = require('./format'); +const { blocksToJson, accountToJson } = require('./json'); module.exports = { rpcCall, @@ -31,4 +32,6 @@ module.exports = { lamportsForPrice, formatFees, formatAccountFees, + blocksToJson, + accountToJson, }; \ No newline at end of file diff --git a/src/json.js b/src/json.js new file mode 100644 index 0000000..c35cf20 --- /dev/null +++ b/src/json.js @@ -0,0 +1,46 @@ +'use strict'; + +const LEVEL_NAMES = ['low', 'medium', 'high']; + +function levelsToJson(levels) { + const out = {}; + for (const name of LEVEL_NAMES) { + const level = levels[name]; + out[name] = { + percentile: level.percentile, + lamports: level.lamports, + microLamportsPerUnit: level.microLamportsPerUnit, + }; + } + return out; +} + +function blocksToJson(summary, { host, now }) { + return { + mode: 'recent-transactions', + time: now.toISOString(), + rpc: host, + computeUnits: summary.computeUnits, + slotRange: { from: summary.slotRange.from, to: summary.slotRange.to }, + blockCount: summary.blockCount, + sampleCount: summary.sampleCount, + payingCount: summary.payingCount, + levels: levelsToJson(summary.levels), + }; +} + +function accountToJson(summary, { host, now, accounts }) { + return { + mode: 'account-floor', + time: now.toISOString(), + rpc: host, + accounts: [...accounts], + computeUnits: summary.computeUnits, + slotRange: { from: summary.slotRange.from, to: summary.slotRange.to }, + sampleCount: summary.sampleCount, + zeroCount: summary.zeroCount, + levels: levelsToJson(summary.levels), + }; +} + +module.exports = { blocksToJson, accountToJson }; \ No newline at end of file diff --git a/src/watch.js b/src/watch.js new file mode 100644 index 0000000..c1cd846 --- /dev/null +++ b/src/watch.js @@ -0,0 +1,49 @@ +'use strict'; + +const { RpcError } = require('./rpc'); + +// Resolves after `ms`, or as soon as the signal aborts. +function wait(ms, signal) { + return new Promise((resolve) => { + if (signal && signal.aborted) { + resolve(); + return; + } + const timer = setTimeout(done, ms); + function done() { + clearTimeout(timer); + if (signal) signal.removeEventListener('abort', done); + resolve(); + } + if (signal) signal.addEventListener('abort', done, { once: true }); + }); +} + +// Calls refresh() every intervalMs until the signal aborts. +// RPC failures are reported and retried; too many in a row ends the loop with exit code 1. +async function watchLoop({ refresh, intervalMs, signal, wait: waitFn = wait, io, maxFailures = 5 }) { + let failures = 0; + + while (!signal.aborted) { + try { + await refresh(); + failures = 0; + } catch (err) { + if (!(err instanceof RpcError)) throw err; + failures++; + io.err(`error: ${err.message}`); + if (err.hint && failures === 1) io.err(err.hint); + if (failures >= maxFailures) { + io.err(`error: giving up after ${maxFailures} failed refreshes in a row.`); + return 1; + } + } + + if (signal.aborted) break; + await waitFn(intervalMs, signal); + } + + return 0; +} + +module.exports = { watchLoop, wait }; \ No newline at end of file diff --git a/tests/args.test.js b/tests/args.test.js index 5d59b1e..5ed9fa9 100644 --- a/tests/args.test.js +++ b/tests/args.test.js @@ -11,6 +11,7 @@ test('no arguments gives defaults', () => { assert.equal(opts.watch, false); assert.equal(opts.rpc, null); assert.equal(opts.units, null); + assert.equal(opts.interval, null); assert.deepEqual(opts.accounts, []); assert.deepEqual(opts.errors, []); }); @@ -38,6 +39,7 @@ test('missing value is reported', () => { assert.deepEqual(parseArgs(['--rpc']).errors, ['--rpc needs a value']); assert.deepEqual(parseArgs(['--account', '--json']).errors, ['--account needs a value']); assert.deepEqual(parseArgs(['--units']).errors, ['--units needs a value']); + assert.deepEqual(parseArgs(['--watch', '--interval']).errors, ['--interval needs a value']); }); test('--units accepts a whole number of compute units', () => { @@ -51,4 +53,23 @@ test('--units rejects invalid values', () => { assert.deepEqual(parseArgs(['--units', 'abc']).errors, [message]); assert.deepEqual(parseArgs(['--units', '0']).errors, [message]); assert.deepEqual(parseArgs(['--units', '2000000']).errors, [message]); +}); + +test('--interval accepts whole seconds with --watch, in either order', () => { + assert.equal(parseArgs(['--watch', '--interval', '30']).interval, 30); + const reversed = parseArgs(['--interval', '30', '--watch']); + assert.equal(reversed.interval, 30); + assert.deepEqual(reversed.errors, []); +}); + +test('--interval rejects invalid values', () => { + const message = '--interval must be a whole number of seconds between 5 and 3600'; + assert.deepEqual(parseArgs(['--watch', '--interval', 'abc']).errors, [message]); + assert.deepEqual(parseArgs(['--watch', '--interval', '4']).errors, [message]); + assert.deepEqual(parseArgs(['--watch', '--interval', '3601']).errors, [message]); + assert.deepEqual(parseArgs(['--watch', '--interval', '1.5']).errors, [message]); +}); + +test('--interval without --watch is reported', () => { + assert.deepEqual(parseArgs(['--interval', '30']).errors, ['--interval only works with --watch']); }); \ No newline at end of file diff --git a/tests/cli.test.js b/tests/cli.test.js index 3679b75..a50edff 100644 --- a/tests/cli.test.js +++ b/tests/cli.test.js @@ -8,6 +8,7 @@ const { createMockRpc } = require('./helpers/mock-rpc'); const { fakeAddress } = require('./helpers/base58'); const noWait = async () => {}; +const now = () => new Date('2026-10-11T12:00:00.000Z'); const PAID = [0, 0, 0, 100, 200, 300, 400, 500, 600, 1000]; const FLOOR_FEES = [0, 0, 100, 200, 300, 400, 500, 600, 700, 800]; const TOKEN_PROGRAM = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'; @@ -24,6 +25,20 @@ function capture() { }; } +// Stand-in for the watch delay: returns at once and aborts the loop after `count` waits. +function stopAfter(count) { + const controller = new AbortController(); + const waits = []; + return { + signal: controller.signal, + waits, + waitImpl: async (ms) => { + waits.push(ms); + if (waits.length >= count) controller.abort(); + }, + }; +} + function block() { return PAID.map((priority) => ({ transaction: { @@ -58,9 +73,11 @@ test('--help prints usage and exits 0', async () => { const { lines, io } = capture(); const code = await run(['--help'], io); assert.equal(code, 0); - assert.match(lines.out.join('\n'), /Usage:/); - assert.match(lines.out.join('\n'), /--units/); - assert.match(lines.out.join('\n'), /--account/); + const out = lines.out.join('\n'); + assert.match(out, /Usage:/); + assert.match(out, /--units/); + assert.match(out, /--account/); + assert.match(out, /--interval/); }); test('--version prints the package version', async () => { @@ -78,6 +95,13 @@ test('unknown option exits 2 with a hint', async () => { assert.match(lines.err.join('\n'), /--help/); }); +test('--interval without --watch exits 2', async () => { + const { lines, io } = capture(); + const code = await run(['--interval', '30'], io); + assert.equal(code, 2); + assert.match(lines.err.join('\n'), /--interval only works with --watch/); +}); + test('prints low, medium and high from what recent transactions paid', async () => { const fetchImpl = createMockRpc(feeRoutes()); const { lines, io } = capture(); @@ -144,13 +168,6 @@ test('rate limiting exits 1 with the own-RPC hint', async () => { assert.match(err, /SOL_PRIORITY_FEE_RPC/); }); -test('options that are not built yet are refused clearly', async () => { - const { lines, io } = capture(); - const code = await run(['--json', '--watch'], io, { env: {} }); - assert.equal(code, 2); - assert.match(lines.err.join('\n'), /--json, --watch not available yet/); -}); - test('--account shows the fee floor for the account and skips block sampling', async () => { const fetchImpl = createMockRpc(floorRoutes()); const { lines, io } = capture(); @@ -214,4 +231,104 @@ test('--account works with --units', async () => { const out = lines.out.join('\n'); assert.match(out, /low\s+30\s+300/); assert.match(out, /assume a 100,000 compute unit limit/); +}); + +test('--json prints one pretty JSON object', async () => { + const fetchImpl = createMockRpc(feeRoutes()); + const { lines, io } = capture(); + const code = await run(['--json'], io, { env: {}, fetchImpl, now }); + assert.equal(code, 0); + assert.equal(lines.out.length, 1); + assert.match(lines.out[0], /\n {2}"mode"/); + const data = JSON.parse(lines.out[0]); + assert.equal(data.mode, 'recent-transactions'); + assert.equal(data.rpc, 'api.mainnet-beta.solana.com'); + assert.equal(data.time, '2026-10-11T12:00:00.000Z'); + assert.equal(data.levels.low.lamports, 200); + assert.equal(data.levels.high.microLamportsPerUnit, 3000); +}); + +test('--json with --account prints the account floor', async () => { + const fetchImpl = createMockRpc(floorRoutes()); + const { lines, io } = capture(); + const code = await run(['--json', '--account', TOKEN_PROGRAM], io, { env: {}, fetchImpl, now }); + assert.equal(code, 0); + const data = JSON.parse(lines.out[0]); + assert.equal(data.mode, 'account-floor'); + assert.deepEqual(data.accounts, [TOKEN_PROGRAM]); + assert.equal(data.levels.medium.microLamportsPerUnit, 600); +}); + +test('--watch refreshes until stopped and shows when it updated', async () => { + const fetchImpl = createMockRpc(feeRoutes()); + const stop = stopAfter(2); + const { lines, io } = capture(); + const code = await run(['--watch'], io, { + env: {}, + fetchImpl, + now, + signal: stop.signal, + waitImpl: stop.waitImpl, + }); + assert.equal(code, 0); + const out = lines.out.join('\n'); + assert.equal(out.match(/Updated 12:00:00 UTC/g).length, 2); + assert.equal(out.match(/Priority fee recommendation/g).length, 2); + assert.deepEqual(stop.waits, [10000, 10000]); + assert.equal(fetchImpl.calls.filter((call) => call.method === 'getSlot').length, 2); +}); + +test('--watch --interval sets the delay between refreshes', async () => { + const fetchImpl = createMockRpc(feeRoutes()); + const stop = stopAfter(1); + const { io } = capture(); + const code = await run(['--watch', '--interval', '30'], io, { + env: {}, + fetchImpl, + now, + signal: stop.signal, + waitImpl: stop.waitImpl, + }); + assert.equal(code, 0); + assert.deepEqual(stop.waits, [30000]); +}); + +test('--watch --json prints one compact JSON object per refresh', async () => { + const fetchImpl = createMockRpc(feeRoutes()); + const stop = stopAfter(2); + const { lines, io } = capture(); + const code = await run(['--watch', '--json'], io, { + env: {}, + fetchImpl, + now, + signal: stop.signal, + waitImpl: stop.waitImpl, + }); + assert.equal(code, 0); + assert.equal(lines.out.length, 2); + for (const line of lines.out) { + assert.doesNotMatch(line, /\n/); + assert.equal(JSON.parse(line).mode, 'recent-transactions'); + } +}); + +test('--watch keeps going after a failed refresh', async () => { + const fetchImpl = createMockRpc({ + ...feeRoutes(), + getSlot: [{ status: 429 }, { status: 429 }, { status: 429 }, { result: 1000 }], + }); + const stop = stopAfter(2); + const { lines, io } = capture(); + const code = await run(['--watch'], io, { + env: {}, + fetchImpl, + sleepImpl: noWait, + now, + signal: stop.signal, + waitImpl: stop.waitImpl, + }); + assert.equal(code, 0); + assert.match(lines.err.join('\n'), /rate-limiting/); + assert.match(lines.err.join('\n'), /SOL_PRIORITY_FEE_RPC/); + assert.match(lines.out.join('\n'), /Priority fee recommendation/); }); \ No newline at end of file diff --git a/tests/format.test.js b/tests/format.test.js index e1c9620..d11caf5 100644 --- a/tests/format.test.js +++ b/tests/format.test.js @@ -31,13 +31,13 @@ test('formatFees explains a zero low fee', () => { assert.match(text, /A low fee of 0 means/); }); -test('formatAccountFees describes a single account and labels the result as a floor', () => { +test('formatAccountFees describes a single account, the window and labels the result as a floor', () => { const summary = summarizeAccountFees(accountSamples([0, 0, 100, 200, 300, 400, 500, 600, 700, 800])); const text = formatAccountFees(summary, 'rpc.example.com', 1); assert.match(text, /Priority fee floor for 1 account\b/); assert.match(text, /low\s+60\s+300/); assert.match(text, /Lamports per transaction assume a 200,000 compute unit limit/); - assert.match(text, /Based on 10 recent slots \(10 to 19\) via rpc\.example\.com\./); + assert.match(text, /Based on 10 recent slots \(10 to 19, about 4 seconds\) via rpc\.example\.com\./); assert.match(text, /locks this account as writable/); assert.match(text, /50th, 75th and 90th percentile/); assert.match(text, /a floor for getting in, not a bid to win/); diff --git a/tests/json.test.js b/tests/json.test.js new file mode 100644 index 0000000..bedcdd2 --- /dev/null +++ b/tests/json.test.js @@ -0,0 +1,44 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { summarizeFees, summarizeAccountFees } = require('../src/fees'); +const { blocksToJson, accountToJson } = require('../src/json'); + +const NOW = new Date('2026-10-11T12:00:00.000Z'); + +test('blocksToJson describes the recent-transactions recommendation', () => { + const summary = summarizeFees({ + fees: [0, 0, 0, 100, 200, 300, 400, 500, 600, 1000], + slots: [10, 11, 12], + }); + const json = blocksToJson(summary, { host: 'rpc.example.com', now: NOW }); + + assert.equal(json.mode, 'recent-transactions'); + assert.equal(json.time, '2026-10-11T12:00:00.000Z'); + assert.equal(json.rpc, 'rpc.example.com'); + assert.equal(json.computeUnits, 200000); + assert.deepEqual(json.slotRange, { from: 10, to: 12 }); + assert.equal(json.blockCount, 3); + assert.equal(json.sampleCount, 10); + assert.equal(json.payingCount, 7); + assert.deepEqual(json.levels.low, { percentile: 50, lamports: 200, microLamportsPerUnit: 1000 }); + assert.deepEqual(json.levels.high, { percentile: 90, lamports: 600, microLamportsPerUnit: 3000 }); +}); + +test('accountToJson describes the per-account fee floor', () => { + const samples = [0, 0, 100, 200, 300, 400, 500, 600, 700, 800].map((fee, i) => ({ + slot: 10 + i, + prioritizationFee: fee, + })); + const summary = summarizeAccountFees(samples); + const json = accountToJson(summary, { host: 'rpc.example.com', now: NOW, accounts: ['A1', 'A2'] }); + + assert.equal(json.mode, 'account-floor'); + assert.equal(json.time, '2026-10-11T12:00:00.000Z'); + assert.deepEqual(json.accounts, ['A1', 'A2']); + assert.deepEqual(json.slotRange, { from: 10, to: 19 }); + assert.equal(json.sampleCount, 10); + assert.equal(json.zeroCount, 2); + assert.deepEqual(json.levels.low, { percentile: 50, lamports: 60, microLamportsPerUnit: 300 }); +}); \ No newline at end of file diff --git a/tests/watch.test.js b/tests/watch.test.js new file mode 100644 index 0000000..ed747ac --- /dev/null +++ b/tests/watch.test.js @@ -0,0 +1,140 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { watchLoop, wait } = require('../src/watch'); +const { RpcError } = require('../src/rpc'); + +function capture() { + const lines = { out: [], err: [] }; + return { + lines, + io: { + out: (text) => lines.out.push(text), + err: (text) => lines.err.push(text), + }, + }; +} + +// A wait() stand-in that returns at once and aborts the loop after `count` waits. +function stopAfter(count) { + const controller = new AbortController(); + const waits = []; + return { + signal: controller.signal, + waits, + waitImpl: async (ms) => { + waits.push(ms); + if (waits.length >= count) controller.abort(); + }, + }; +} + +test('wait resolves after the delay', async () => { + const start = Date.now(); + await wait(20); + assert.ok(Date.now() - start >= 10); +}); + +test('wait ends early when the signal aborts, and at once if already aborted', async () => { + const controller = new AbortController(); + setTimeout(() => controller.abort(), 10); + const start = Date.now(); + await wait(60000, controller.signal); + assert.ok(Date.now() - start < 5000); + await wait(60000, controller.signal); +}); + +test('watchLoop refreshes on every interval until the signal aborts', async () => { + let calls = 0; + const stop = stopAfter(3); + const { io } = capture(); + const code = await watchLoop({ + refresh: async () => { + calls++; + }, + intervalMs: 1234, + signal: stop.signal, + wait: stop.waitImpl, + io, + }); + assert.equal(code, 0); + assert.equal(calls, 3); + assert.deepEqual(stop.waits, [1234, 1234, 1234]); +}); + +test('watchLoop reports failures, keeps going and prints the hint once per streak', async () => { + let calls = 0; + const stop = stopAfter(4); + const { lines, io } = capture(); + const code = await watchLoop({ + refresh: async () => { + calls++; + if (calls <= 2) throw new RpcError('boom', { hint: 'try later' }); + }, + intervalMs: 10, + signal: stop.signal, + wait: stop.waitImpl, + io, + }); + assert.equal(code, 0); + assert.equal(calls, 4); + assert.deepEqual(lines.err, ['error: boom', 'try later', 'error: boom']); +}); + +test('watchLoop gives up after 5 failed refreshes in a row', async () => { + let calls = 0; + const controller = new AbortController(); + const { lines, io } = capture(); + const code = await watchLoop({ + refresh: async () => { + calls++; + throw new RpcError('nope'); + }, + intervalMs: 10, + signal: controller.signal, + wait: async () => {}, + io, + }); + assert.equal(code, 1); + assert.equal(calls, 5); + assert.equal(lines.err.filter((line) => line === 'error: nope').length, 5); + assert.equal(lines.err[lines.err.length - 1], 'error: giving up after 5 failed refreshes in a row.'); +}); + +test('watchLoop resets the failure streak after a success', async () => { + const outcomes = 'FFFFSFFFFS'; + let calls = 0; + const stop = stopAfter(10); + const { lines, io } = capture(); + const code = await watchLoop({ + refresh: async () => { + const outcome = outcomes[calls++]; + if (outcome === 'F') throw new RpcError('flaky'); + }, + intervalMs: 10, + signal: stop.signal, + wait: stop.waitImpl, + io, + }); + assert.equal(code, 0); + assert.equal(calls, 10); + assert.equal(lines.err.some((line) => /giving up/.test(line)), false); +}); + +test('watchLoop rethrows errors that are not RPC errors', async () => { + const controller = new AbortController(); + const { io } = capture(); + await assert.rejects( + watchLoop({ + refresh: async () => { + throw new TypeError('bug'); + }, + intervalMs: 10, + signal: controller.signal, + wait: async () => {}, + io, + }), + TypeError, + ); +}); \ No newline at end of file