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
21 changes: 17 additions & 4 deletions src/args.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
'use strict';

const MAX_UNITS = 1400000;

function parseArgs(argv) {
const opts = {
help: false,
version: false,
json: false,
watch: false,
rpc: null,
units: null,
accounts: [],
errors: [],
};
Expand All @@ -22,14 +25,24 @@ function parseArgs(argv) {
opts.json = true;
} else if (arg === '--watch') {
opts.watch = true;
} else if (arg === '--rpc' || arg === '--account') {
} else if (arg === '--rpc' || arg === '--account' || arg === '--units') {
const value = argv[i + 1];
if (value === undefined || value.startsWith('--')) {
opts.errors.push(`${arg} needs a value`);
} else {
i++;
if (arg === '--rpc') opts.rpc = value;
else opts.accounts.push(value);
if (arg === '--rpc') {
opts.rpc = value;
} else if (arg === '--account') {
opts.accounts.push(value);
} else {
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 {
opts.errors.push(`unknown option: ${arg}`);
Expand All @@ -39,4 +52,4 @@ function parseArgs(argv) {
return opts;
}

module.exports = { parseArgs };
module.exports = { parseArgs, MAX_UNITS };
40 changes: 36 additions & 4 deletions src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,17 @@

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 HELP = `sol-priority-fee - recommended Solana priority fee from recent network data
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)
--rpc <url> RPC endpoint (default: SOL_PRIORITY_FEE_RPC, then public mainnet RPC)
--watch Keep refreshing the recommendation
Expand All @@ -24,7 +28,7 @@ function defaultIo() {
};
}

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

if (opts.errors.length > 0) {
Expand All @@ -43,8 +47,36 @@ async function run(argv, io = defaultIo()) {
return 0;
}

io.err('Fetching fees is not implemented yet.');
return 1;
const notYet = [];
if (opts.accounts.length > 0) notYet.push('--account');
if (opts.watch) notYet.push('--watch');
if (opts.json) notYet.push('--json');
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 };

try {
const sample = await sampleRecentFees(endpoint, { rpcOptions });
const summary = summarizeFees(sample, { computeUnits: opts.units || DEFAULT_COMPUTE_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)));
return 0;
} catch (err) {
if (err instanceof RpcError) {
io.err(`error: ${err.message}`);
if (err.hint) io.err(err.hint);
return 1;
}
throw err;
}
}

module.exports = { run, HELP };
151 changes: 151 additions & 0 deletions src/fees.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
'use strict';

const { rpcCall } = require('./rpc');

const VOTE_PROGRAM = 'Vote111111111111111111111111111111111111111';
const BASE_FEE_LAMPORTS = 5000;
const DEFAULT_COMPUTE_UNITS = 200000;
const SAMPLE_BLOCKS = 3;
const FIRST_LOOKBACK = 5;
const MAX_LOOKBACK = 30;
const LEVEL_PERCENTILES = { low: 50, medium: 75, high: 90 };

function isMissingBlock(err) {
return /skipped|not available/i.test(err.message);
}

// Walks back from the latest confirmed slot and collects recent blocks,
// skipping slots that have no block.
async function fetchRecentBlocks(endpoint, options = {}) {
const { blocks = SAMPLE_BLOCKS, rpcOptions = {} } = options;
const latest = await rpcCall(endpoint, 'getSlot', [{ commitment: 'confirmed' }], rpcOptions);
const found = [];

for (
let back = FIRST_LOOKBACK;
back < FIRST_LOOKBACK + MAX_LOOKBACK && found.length < blocks;
back++
) {
const slot = latest - back;
try {
const block = await rpcCall(
endpoint,
'getBlock',
[
slot,
{
encoding: 'json',
transactionDetails: 'accounts',
rewards: false,
maxSupportedTransactionVersion: 1,
commitment: 'confirmed',
},
],
rpcOptions,
);
if (block && Array.isArray(block.transactions)) {
found.push({ slot, transactions: block.transactions });
}
} catch (err) {
if (!isMissingBlock(err)) throw err;
}
}

return found;
}

function pubkeyOf(key) {
return typeof key === 'string' ? key : key && key.pubkey;
}

// Priority fee paid = total fee minus the base fee (5000 lamports per signature).
// Vote transactions are skipped: they are not part of the fee market.
function extractPriorityFees(transactions) {
const fees = [];
let voteCount = 0;
let unreadable = 0;

for (const tx of transactions) {
const keys = tx && tx.transaction && tx.transaction.accountKeys;
if (!Array.isArray(keys) || !tx.meta || !Number.isFinite(tx.meta.fee)) {
unreadable++;
continue;
}
if (keys.some((key) => pubkeyOf(key) === VOTE_PROGRAM)) {
voteCount++;
continue;
}
const signatures = (tx.transaction.signatures || []).length || 1;
fees.push(Math.max(0, tx.meta.fee - BASE_FEE_LAMPORTS * signatures));
}

return { fees, voteCount, unreadable };
}

async function sampleRecentFees(endpoint, options = {}) {
const blocks = await fetchRecentBlocks(endpoint, options);
const fees = [];
let voteCount = 0;
let unreadable = 0;

for (const block of blocks) {
const extracted = extractPriorityFees(block.transactions);
for (const fee of extracted.fees) fees.push(fee);
voteCount += extracted.voteCount;
unreadable += extracted.unreadable;
}

return { fees, slots: blocks.map((block) => block.slot), voteCount, unreadable };
}

// Nearest-rank percentile on an ascending sorted array.
function percentile(sortedAsc, p) {
if (sortedAsc.length === 0) return null;
const rank = Math.ceil((p / 100) * sortedAsc.length);
return sortedAsc[Math.max(rank, 1) - 1];
}

// A transaction that pays `lamports` of priority fee with a compute unit limit of
// `units` has a compute unit price of lamports * 1e6 / units micro-lamports.
function computePriceMicroLamports(lamports, units = DEFAULT_COMPUTE_UNITS) {
return Math.ceil((lamports * 1e6) / units);
}

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

const fees = [...sample.fees].sort((a, b) => a - b);

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

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

module.exports = {
fetchRecentBlocks,
extractPriorityFees,
sampleRecentFees,
percentile,
computePriceMicroLamports,
summarizeFees,
DEFAULT_COMPUTE_UNITS,
LEVEL_PERCENTILES,
BASE_FEE_LAMPORTS,
};
37 changes: 37 additions & 0 deletions src/format.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
'use strict';

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) {
const level = levels[name];
lines.push(
` ${name.padEnd(8)}${formatInt(level.lamports).padStart(14)}${formatInt(level.microLamportsPerUnit).padStart(19)}`,
);
}

lines.push('');
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.`);
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 };
21 changes: 20 additions & 1 deletion src/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,24 @@
'use strict';

const { rpcCall, resolveEndpoint, hostLabel, RpcError } = require('./rpc');
const {
sampleRecentFees,
extractPriorityFees,
summarizeFees,
percentile,
computePriceMicroLamports,
} = require('./fees');
const { formatFees } = require('./format');

module.exports = { rpcCall, resolveEndpoint, hostLabel, RpcError };
module.exports = {
rpcCall,
resolveEndpoint,
hostLabel,
RpcError,
sampleRecentFees,
extractPriorityFees,
summarizeFees,
percentile,
computePriceMicroLamports,
formatFees,
};
15 changes: 15 additions & 0 deletions tests/args.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ test('no arguments gives defaults', () => {
assert.equal(opts.json, false);
assert.equal(opts.watch, false);
assert.equal(opts.rpc, null);
assert.equal(opts.units, null);
assert.deepEqual(opts.accounts, []);
assert.deepEqual(opts.errors, []);
});
Expand All @@ -36,4 +37,18 @@ test('unknown option is reported', () => {
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']);
});

test('--units accepts a whole number of compute units', () => {
const opts = parseArgs(['--units', '50000']);
assert.equal(opts.units, 50000);
assert.deepEqual(opts.errors, []);
});

test('--units rejects invalid values', () => {
const message = '--units must be a whole number between 1 and 1400000';
assert.deepEqual(parseArgs(['--units', 'abc']).errors, [message]);
assert.deepEqual(parseArgs(['--units', '0']).errors, [message]);
assert.deepEqual(parseArgs(['--units', '2000000']).errors, [message]);
});
Loading
Loading