Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions src/base58.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
'use strict';

const ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';

// Returns a Uint8Array, or null if the string is empty or has a character outside the alphabet.
function decodeBase58(text) {
if (typeof text !== 'string' || text.length === 0) return null;

let num = 0n;
for (const char of text) {
const index = ALPHABET.indexOf(char);
if (index === -1) return null;
num = num * 58n + BigInt(index);
}

const bytes = [];
while (num > 0n) {
bytes.push(Number(num & 0xffn));
num >>= 8n;
}

// Each leading "1" stands for one leading zero byte.
for (let i = 0; i < text.length && text[i] === '1'; i++) bytes.push(0);

return Uint8Array.from(bytes.reverse());
}

// A Solana address is a base58 string that decodes to exactly 32 bytes.
function isValidAddress(text) {
if (typeof text !== 'string' || text.length < 32 || text.length > 44) return false;
const bytes = decodeBase58(text);
return bytes !== null && bytes.length === 32;
}

module.exports = { decodeBase58, isValidAddress };
59 changes: 51 additions & 8 deletions src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,27 @@
const pkg = require('../package.json');
const { parseArgs } = require('./args');
const { resolveEndpoint, hostLabel, RpcError } = require('./rpc');
const { sampleRecentFees, summarizeFees, DEFAULT_COMPUTE_UNITS } = require('./fees');
const { formatFees } = require('./format');
const { isValidAddress } = require('./base58');
const {
sampleRecentFees,
fetchAccountFees,
summarizeFees,
summarizeAccountFees,
DEFAULT_COMPUTE_UNITS,
} = require('./fees');
const { formatFees, formatAccountFees } = require('./format');

const MAX_ACCOUNTS = 128;

const HELP = `sol-priority-fee - recommended Solana priority fee from what recent transactions paid

Usage:
sol-priority-fee [options]

Options:
--units <n> Compute unit limit used for the per-CU price (default: ${DEFAULT_COMPUTE_UNITS})
--account <address> Only consider fees for transactions touching this account (repeatable)
--account <address> Fee floor for landing a transaction that locks this account as
writable (repeatable, up to ${MAX_ACCOUNTS})
--units <n> Compute unit limit used for the prices (default: ${DEFAULT_COMPUTE_UNITS})
--rpc <url> RPC endpoint (default: SOL_PRIORITY_FEE_RPC, then public mainnet RPC)
--watch Keep refreshing the recommendation
--json Print machine-readable JSON
Expand All @@ -28,6 +38,20 @@ function defaultIo() {
};
}

function checkAccounts(accounts) {
const unique = [...new Set(accounts)];
const errors = [];
for (const address of unique) {
if (!isValidAddress(address)) {
errors.push(`--account is not a valid Solana address: ${address.slice(0, 60)}`);
}
}
if (unique.length > MAX_ACCOUNTS) {
errors.push(`--account can be used at most ${MAX_ACCOUNTS} times`);
}
return { accounts: unique, errors };
}

async function run(argv, io = defaultIo(), deps = {}) {
const opts = parseArgs(argv);

Expand All @@ -47,27 +71,46 @@ async function run(argv, io = defaultIo(), deps = {}) {
return 0;
}

const checked = checkAccounts(opts.accounts);
if (checked.errors.length > 0) {
for (const message of checked.errors) io.err(`error: ${message}`);
return 2;
}

const notYet = [];
if (opts.accounts.length > 0) notYet.push('--account');
if (opts.watch) notYet.push('--watch');
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);

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 <url>).');
return 1;
}
io.out(formatAccountFees(summary, host, checked.accounts.length));
return 0;
}

const sample = await sampleRecentFees(endpoint, { rpcOptions });
const summary = summarizeFees(sample, { computeUnits: opts.units || DEFAULT_COMPUTE_UNITS });
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 <url>).');
return 1;
}
io.out(formatFees(summary, hostLabel(endpoint)));
io.out(formatFees(summary, host));
return 0;
} catch (err) {
if (err instanceof RpcError) {
Expand Down
48 changes: 48 additions & 0 deletions src/fees.js
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,19 @@ async function sampleRecentFees(endpoint, options = {}) {
return { fees, slots: blocks.map((block) => block.slot), voteCount, unreadable };
}

// Per-slot fee reported by the RPC for landing a transaction that locks all of
// the given accounts as writable.
async function fetchAccountFees(endpoint, accounts, rpcOptions = {}) {
const result = await rpcCall(endpoint, 'getRecentPrioritizationFees', [accounts], rpcOptions);
if (!Array.isArray(result)) return [];
return result.filter(
(sample) =>
sample &&
Number.isFinite(sample.slot) &&
Number.isFinite(sample.prioritizationFee),
);
}

// Nearest-rank percentile on an ascending sorted array.
function percentile(sortedAsc, p) {
if (sortedAsc.length === 0) return null;
Expand All @@ -111,6 +124,11 @@ function computePriceMicroLamports(lamports, units = DEFAULT_COMPUTE_UNITS) {
return Math.ceil((lamports * 1e6) / units);
}

// The reverse: what a given compute unit price costs for `units` compute units.
function lamportsForPrice(microLamportsPerUnit, units = DEFAULT_COMPUTE_UNITS) {
return Math.ceil((microLamportsPerUnit * units) / 1e6);
}

function summarizeFees(sample, options = {}) {
const computeUnits = options.computeUnits || DEFAULT_COMPUTE_UNITS;
if (!sample || sample.fees.length === 0) return null;
Expand Down Expand Up @@ -138,13 +156,43 @@ function summarizeFees(sample, options = {}) {
};
}

function summarizeAccountFees(samples, options = {}) {
const computeUnits = options.computeUnits || DEFAULT_COMPUTE_UNITS;
if (!samples || samples.length === 0) return null;

const fees = samples.map((sample) => sample.prioritizationFee).sort((a, b) => a - b);
const slots = samples.map((sample) => sample.slot);

const levels = {};
for (const [name, p] of Object.entries(LEVEL_PERCENTILES)) {
const microLamportsPerUnit = percentile(fees, p);
levels[name] = {
percentile: p,
lamports: lamportsForPrice(microLamportsPerUnit, computeUnits),
microLamportsPerUnit,
};
}

return {
sampleCount: samples.length,
slotRange: { from: Math.min(...slots), to: Math.max(...slots) },
zeroCount: fees.filter((fee) => fee === 0).length,
max: fees[fees.length - 1],
computeUnits,
levels,
};
}

module.exports = {
fetchRecentBlocks,
extractPriorityFees,
sampleRecentFees,
fetchAccountFees,
percentile,
computePriceMicroLamports,
lamportsForPrice,
summarizeFees,
summarizeAccountFees,
DEFAULT_COMPUTE_UNITS,
LEVEL_PERCENTILES,
BASE_FEE_LAMPORTS,
Expand Down
55 changes: 43 additions & 12 deletions src/format.js
Original file line number Diff line number Diff line change
@@ -1,37 +1,68 @@
'use strict';

const LEVEL_NAMES = ['low', 'medium', 'high'];

function formatInt(n) {
return Number(n).toLocaleString('en-US');
}

function formatFees(summary, host) {
const { levels, sampleCount, blockCount, slotRange, payingCount, computeUnits } = summary;
const share = Math.round((100 * payingCount) / sampleCount);
const names = ['low', 'medium', 'high'];
const pct = names.map((name) => `${levels[name].percentile}th`);

const lines = ['Priority fee recommendation', ''];
lines.push(` ${'level'.padEnd(8)}${'lamports/tx'.padStart(14)}${'micro-lamports/CU'.padStart(19)}`);
for (const name of names) {
function levelTable(levels) {
const lines = [
` ${'level'.padEnd(8)}${'lamports/tx'.padStart(14)}${'micro-lamports/CU'.padStart(19)}`,
];
for (const name of LEVEL_NAMES) {
const level = levels[name];
lines.push(
` ${name.padEnd(8)}${formatInt(level.lamports).padStart(14)}${formatInt(level.microLamportsPerUnit).padStart(19)}`,
);
}
return lines;
}

function percentileList(levels) {
const pct = LEVEL_NAMES.map((name) => `${levels[name].percentile}th`);
return `${pct[0]}, ${pct[1]} and ${pct[2]}`;
}

function formatFees(summary, host) {
const { levels, sampleCount, blockCount, slotRange, payingCount, computeUnits } = summary;
const share = Math.round((100 * payingCount) / sampleCount);

lines.push('');
const lines = ['Priority fee recommendation', '', ...levelTable(levels), ''];
lines.push(`Per-CU price assumes a ${formatInt(computeUnits)} compute unit limit (change it with --units).`);
lines.push(
`Based on ${formatInt(sampleCount)} non-vote transactions in ${blockCount} recent ` +
`${blockCount === 1 ? 'block' : 'blocks'} (slots ${slotRange.from} to ${slotRange.to}) via ${host}.`,
);
lines.push(`${share}% of them paid a priority fee.`);
lines.push(`low, medium and high are the ${pct[0]}, ${pct[1]} and ${pct[2]} percentile of the priority fee those transactions paid.`);
lines.push(`low, medium and high are the ${percentileList(levels)} percentile of the priority fee those transactions paid.`);
if (levels.low.lamports === 0) {
lines.push('A low fee of 0 means at least half of the sampled transactions paid no priority fee.');
}

return lines.join('\n');
}

module.exports = { formatFees, formatInt };
function formatAccountFees(summary, host, accountCount) {
const { levels, sampleCount, slotRange, computeUnits } = summary;
const single = accountCount === 1;

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(
`For each slot the RPC reports the fee needed to land a transaction that locks ` +
`${single ? 'this account' : 'all of these accounts'} as writable.`,
);
lines.push(`low, medium and high are the ${percentileList(levels)} percentile across those slots.`);
lines.push('This is a floor for getting in, not a bid to win: if the account is contested, pay above it.');
if (levels.low.microLamportsPerUnit === 0) {
lines.push(
`A low fee of 0 means at least half of the recent slots needed no priority fee for ${single ? 'this account' : 'these accounts'}.`,
);
}

return lines.join('\n');
}

module.exports = { formatFees, formatAccountFees, formatInt };
12 changes: 11 additions & 1 deletion src/index.js
Original file line number Diff line number Diff line change
@@ -1,24 +1,34 @@
'use strict';

const { rpcCall, resolveEndpoint, hostLabel, RpcError } = require('./rpc');
const { isValidAddress, decodeBase58 } = require('./base58');
const {
sampleRecentFees,
extractPriorityFees,
fetchAccountFees,
summarizeFees,
summarizeAccountFees,
percentile,
computePriceMicroLamports,
lamportsForPrice,
} = require('./fees');
const { formatFees } = require('./format');
const { formatFees, formatAccountFees } = require('./format');

module.exports = {
rpcCall,
resolveEndpoint,
hostLabel,
RpcError,
isValidAddress,
decodeBase58,
sampleRecentFees,
extractPriorityFees,
fetchAccountFees,
summarizeFees,
summarizeAccountFees,
percentile,
computePriceMicroLamports,
lamportsForPrice,
formatFees,
formatAccountFees,
};
40 changes: 40 additions & 0 deletions tests/base58.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
'use strict';

const test = require('node:test');
const assert = require('node:assert/strict');
const { decodeBase58, isValidAddress } = require('../src/base58');
const { encodeBase58, fakeAddress } = require('./helpers/base58');

const TOKEN_PROGRAM = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA';

test('decodeBase58 decodes known values', () => {
assert.deepEqual(Array.from(decodeBase58('2g')), [97]);
assert.deepEqual(Array.from(decodeBase58('1')), [0]);
assert.deepEqual(Array.from(decodeBase58('11')), [0, 0]);
});

test('decodeBase58 rejects empty input and characters outside the alphabet', () => {
assert.equal(decodeBase58(''), null);
assert.equal(decodeBase58('0abc'), null);
assert.equal(decodeBase58('Oabc'), null);
assert.equal(decodeBase58('Iabc'), null);
assert.equal(decodeBase58('labc'), null);
assert.equal(decodeBase58(42), null);
});

test('isValidAddress accepts 32-byte addresses and rejects everything else', () => {
assert.equal(isValidAddress(TOKEN_PROGRAM), true);
assert.equal(isValidAddress('1'.repeat(32)), true);
assert.equal(isValidAddress('1'.repeat(31)), false);
assert.equal(isValidAddress('1'.repeat(33)), false);
assert.equal(isValidAddress('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5D0'), false);
assert.equal(isValidAddress('abc'), false);
assert.equal(isValidAddress(undefined), false);
assert.equal(isValidAddress(fakeAddress(5)), true);
});

test('decode and encode round-trip, including leading zero bytes', () => {
assert.equal(encodeBase58(decodeBase58(TOKEN_PROGRAM)), TOKEN_PROGRAM);
const bytes = [0, 0, 1, 2, 3, 250];
assert.deepEqual(Array.from(decodeBase58(encodeBase58(bytes))), bytes);
});
Loading
Loading