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
10 changes: 9 additions & 1 deletion bin/sol-holders.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,12 @@

const { run } = require('../src/cli');

process.exitCode = run(process.argv.slice(2));
run(process.argv.slice(2)).then(
(code) => {
process.exitCode = code;
},
(err) => {
process.stderr.write(`error: ${err && err.message ? err.message : String(err)}\n`);
process.exitCode = 1;
}
);
52 changes: 47 additions & 5 deletions src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

const pkg = require('../package.json');
const { validateAddress } = require('./validate');
const { RpcError, resolveRpcUrl, createRpcClient } = require('./rpc');
const { MintError, fetchMint } = require('./mint');
const { renderMint } = require('./render');

const USAGE = `sol-holders ${pkg.version}

Expand All @@ -11,23 +14,44 @@ Usage:
sol-holders <mint> [options]

Options:
--rpc <url> RPC endpoint (default: $SOL_HOLDERS_RPC, then the public mainnet RPC)
--json Print JSON instead of text
-h, --help Show this help
-v, --version Show the version
`;

class UsageError extends Error {}

function isHttpUrl(value) {
try {
const { protocol } = new URL(value);
return protocol === 'http:' || protocol === 'https:';
} catch {
return false;
}
}

function parseArgs(argv) {
const opts = { mint: null, json: false, help: false, version: false };
const opts = { mint: null, rpc: null, json: false, help: false, version: false };

for (let i = 0; i < argv.length; i++) {
const arg = argv[i];

for (const arg of argv) {
if (arg === '--help' || arg === '-h') {
opts.help = true;
} else if (arg === '--version' || arg === '-v') {
opts.version = true;
} else if (arg === '--json') {
opts.json = true;
} else if (arg === '--rpc' || arg.startsWith('--rpc=')) {
const value = arg === '--rpc' ? argv[++i] : arg.slice('--rpc='.length);
if (!value || value.startsWith('-')) {
throw new UsageError('--rpc requires a URL');
}
if (!isHttpUrl(value)) {
throw new UsageError('--rpc must be an http(s) URL');
}
opts.rpc = value;
} else if (arg.startsWith('-')) {
throw new UsageError(`unknown option: ${arg}`);
} else if (opts.mint === null) {
Expand All @@ -40,7 +64,7 @@ function parseArgs(argv) {
return opts;
}

function run(argv, io = { stdout: process.stdout, stderr: process.stderr }) {
async function run(argv, io = { stdout: process.stdout, stderr: process.stderr }, deps = {}) {
let opts;
try {
opts = parseArgs(argv);
Expand Down Expand Up @@ -71,10 +95,28 @@ function run(argv, io = { stdout: process.stdout, stderr: process.stderr }) {
return 2;
}

const env = deps.env || process.env;
const client = createRpcClient({
url: resolveRpcUrl(opts.rpc, env),
fetch: deps.fetch,
sleep: deps.sleep,
});

let info;
try {
info = await fetchMint(client, result.value);
} catch (err) {
if (err instanceof RpcError || err instanceof MintError) {
io.stderr.write(`error: ${err.message}\n`);
return 1;
}
throw err;
}

if (opts.json) {
io.stdout.write(`${JSON.stringify({ mint: result.value })}\n`);
io.stdout.write(`${JSON.stringify(info, null, 2)}\n`);
} else {
io.stdout.write(`Mint: ${result.value}\n`);
io.stdout.write(renderMint(info, client.host));
}
return 0;
}
Expand Down
24 changes: 24 additions & 0 deletions src/format.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
'use strict';

// Converts a raw integer amount (string or bigint) to a decimal string without floats.
function formatUnits(raw, decimals) {
const value = BigInt(raw);
const base = 10n ** BigInt(decimals);
const whole = value / base;
const fraction = decimals > 0
? (value % base).toString().padStart(decimals, '0').replace(/0+$/, '')
: '';
return fraction ? `${whole}.${fraction}` : `${whole}`;
}

function withCommas(digits) {
return digits.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}

// Same as formatUnits, with thousands separators for display.
function formatSupply(raw, decimals) {
const [whole, fraction] = formatUnits(raw, decimals).split('.');
return fraction ? `${withCommas(whole)}.${fraction}` : withCommas(whole);
}

module.exports = { formatUnits, formatSupply };
61 changes: 61 additions & 0 deletions src/mint.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
'use strict';

const { formatUnits } = require('./format');

const TOKEN_PROGRAMS = {
TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA: { id: 'spl-token', name: 'Token' },
TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb: { id: 'spl-token-2022', name: 'Token-2022' },
};

class MintError extends Error {
constructor(message) {
super(message);
this.name = 'MintError';
}
}

async function fetchMint(client, mint) {
const result = await client.call('getAccountInfo', [
mint,
{ encoding: 'jsonParsed', commitment: 'finalized' },
]);

const account = result && result.value;
if (!account) {
throw new MintError('Mint account not found. Check the address and the network.');
}

const program = TOKEN_PROGRAMS[account.owner];
if (!program) {
throw new MintError(`Not an SPL token mint (account is owned by ${account.owner}).`);
}

const parsed = account.data && account.data.parsed;
if (!parsed || parsed.type !== 'mint' || !parsed.info) {
throw new MintError('Account is not a token mint.');
}

const { supply, decimals, mintAuthority, freezeAuthority } = parsed.info;
if (
typeof supply !== 'string' ||
!/^\d+$/.test(supply) ||
!Number.isInteger(decimals) ||
decimals < 0 ||
decimals > 255
) {
throw new MintError('RPC returned unexpected mint data.');
}

return {
mint,
program: program.id,
programName: program.name,
programId: account.owner,
decimals,
supply: { raw: supply, ui: formatUnits(supply, decimals) },
mintAuthority: mintAuthority || null,
freezeAuthority: freezeAuthority || null,
};
}

module.exports = { TOKEN_PROGRAMS, MintError, fetchMint };
20 changes: 20 additions & 0 deletions src/render.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
'use strict';

const { formatSupply } = require('./format');

function renderMint(info, host) {
const rows = [
['Mint', info.mint],
['Token program', `${info.programName} (${info.programId})`],
['Decimals', String(info.decimals)],
['Supply', formatSupply(info.supply.raw, info.decimals)],
['Mint authority', info.mintAuthority || 'none (supply is fixed)'],
['Freeze authority', info.freezeAuthority || 'none'],
['RPC', host],
];

const width = Math.max(...rows.map(([label]) => label.length)) + 2;
return `${rows.map(([label, value]) => `${`${label}:`.padEnd(width)}${value}`).join('\n')}\n`;
}

module.exports = { renderMint };
Loading
Loading