From ff063664a962acc53a00f6ab965c63691d9e5680 Mon Sep 17 00:00:00 2001 From: Sybi Date: Fri, 16 Oct 2026 17:07:17 +0530 Subject: [PATCH] feat: look up mint supply, decimals, program and authorities --- bin/sol-holders.js | 10 +++- src/cli.js | 52 ++++++++++++++++-- src/format.js | 24 +++++++++ src/mint.js | 61 +++++++++++++++++++++ src/render.js | 20 +++++++ tests/cli.test.js | 126 +++++++++++++++++++++++++++++++++++++------ tests/format.test.js | 28 ++++++++++ tests/mint.test.js | 110 +++++++++++++++++++++++++++++++++++++ 8 files changed, 409 insertions(+), 22 deletions(-) create mode 100644 src/format.js create mode 100644 src/mint.js create mode 100644 src/render.js create mode 100644 tests/format.test.js create mode 100644 tests/mint.test.js diff --git a/bin/sol-holders.js b/bin/sol-holders.js index 54cd366..fa0f262 100644 --- a/bin/sol-holders.js +++ b/bin/sol-holders.js @@ -3,4 +3,12 @@ const { run } = require('../src/cli'); -process.exitCode = run(process.argv.slice(2)); \ No newline at end of file +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; + } +); \ No newline at end of file diff --git a/src/cli.js b/src/cli.js index fc67f83..2f6e151 100644 --- a/src/cli.js +++ b/src/cli.js @@ -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} @@ -11,6 +14,7 @@ Usage: sol-holders [options] Options: + --rpc 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 @@ -18,16 +22,36 @@ Options: 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) { @@ -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); @@ -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; } diff --git a/src/format.js b/src/format.js new file mode 100644 index 0000000..debfdad --- /dev/null +++ b/src/format.js @@ -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 }; \ No newline at end of file diff --git a/src/mint.js b/src/mint.js new file mode 100644 index 0000000..b8745ed --- /dev/null +++ b/src/mint.js @@ -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 }; \ No newline at end of file diff --git a/src/render.js b/src/render.js new file mode 100644 index 0000000..f0662b6 --- /dev/null +++ b/src/render.js @@ -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 }; \ No newline at end of file diff --git a/tests/cli.test.js b/tests/cli.test.js index 70b6013..95be516 100644 --- a/tests/cli.test.js +++ b/tests/cli.test.js @@ -3,9 +3,13 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const { run, parseArgs, UsageError } = require('../src/cli'); +const { createMockRpc, httpError } = require('./helpers/mock-rpc'); const pkg = require('../package.json'); const USDC = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; +const TOKEN_PROGRAM = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'; +const FREEZE = 'FreezeAuthority1111111111111111111111111111'; +const RPC_ENV = 'https://rpc.example.com/?api-key=secret'; function capture() { let out = ''; @@ -20,9 +24,34 @@ function capture() { }; } +function mintAccount() { + return { + value: { + owner: TOKEN_PROGRAM, + data: { + program: 'spl-token', + parsed: { + type: 'mint', + info: { + supply: '9876543210123', + decimals: 6, + mintAuthority: null, + freezeAuthority: FREEZE, + }, + }, + }, + }, + }; +} + +function depsFor(mock, env = { SOL_HOLDERS_RPC: RPC_ENV }) { + return { fetch: mock.fetch, env }; +} + test('parseArgs defaults', () => { assert.deepEqual(parseArgs([]), { mint: null, + rpc: null, json: false, help: false, version: false, @@ -35,50 +64,115 @@ test('parseArgs reads mint and flags', () => { assert.equal(opts.json, true); }); +test('parseArgs reads --rpc in both forms', () => { + assert.equal(parseArgs(['--rpc', 'https://a.example.com', USDC]).rpc, 'https://a.example.com'); + assert.equal(parseArgs([USDC, '--rpc=https://b.example.com']).rpc, 'https://b.example.com'); +}); + test('parseArgs rejects unknown options and extra arguments', () => { assert.throws(() => parseArgs(['--nope']), UsageError); assert.throws(() => parseArgs([USDC, 'another']), UsageError); }); -test('--help prints usage and exits 0', () => { +test('parseArgs rejects a missing or invalid --rpc value', () => { + assert.throws(() => parseArgs(['--rpc']), /requires a URL/); + assert.throws(() => parseArgs(['--rpc', '--json']), /requires a URL/); + assert.throws(() => parseArgs(['--rpc=']), /requires a URL/); + assert.throws(() => parseArgs(['--rpc', 'nonsense']), /http\(s\)/); + assert.throws(() => parseArgs(['--rpc=ftp://example.com']), /http\(s\)/); +}); + +test('--help prints usage and exits 0', async () => { const c = capture(); - assert.equal(run(['--help'], c.io), 0); + assert.equal(await run(['--help'], c.io), 0); assert.match(c.out(), /Usage:/); + assert.match(c.out(), /--rpc /); }); -test('--version prints the package version', () => { +test('--version prints the package version', async () => { const c = capture(); - assert.equal(run(['--version'], c.io), 0); + assert.equal(await run(['--version'], c.io), 0); assert.equal(c.out().trim(), pkg.version); }); -test('missing mint exits 2 with usage on stderr', () => { +test('missing mint exits 2 with usage on stderr', async () => { const c = capture(); - assert.equal(run([], c.io), 2); + assert.equal(await run([], c.io), 2); assert.match(c.err(), /missing token mint address/); assert.equal(c.out(), ''); }); -test('unknown option exits 2', () => { +test('unknown option exits 2', async () => { const c = capture(); - assert.equal(run(['--nope'], c.io), 2); + assert.equal(await run(['--nope'], c.io), 2); assert.match(c.err(), /unknown option: --nope/); }); -test('invalid mint exits 2', () => { +test('invalid mint exits 2 without calling the RPC', async () => { const c = capture(); - assert.equal(run(['not-a-mint'], c.io), 2); + const mock = createMockRpc({ getAccountInfo: mintAccount() }); + assert.equal(await run(['not-a-mint'], c.io, depsFor(mock)), 2); assert.match(c.err(), /invalid mint address/); + assert.equal(mock.calls.length, 0); }); -test('valid mint prints text and exits 0', () => { +test('prints mint details as text', async () => { const c = capture(); - assert.equal(run([USDC], c.io), 0); - assert.equal(c.out(), `Mint: ${USDC}\n`); + const mock = createMockRpc({ getAccountInfo: mintAccount() }); + assert.equal(await run([USDC], c.io, depsFor(mock)), 0); + assert.match(c.out(), new RegExp(`Mint:\\s+${USDC}`)); + assert.match(c.out(), /Token program:\s+Token \(TokenkegQ/); + assert.match(c.out(), /Decimals:\s+6/); + assert.match(c.out(), /Supply:\s+9,876,543\.210123/); + assert.match(c.out(), /Mint authority:\s+none/); + assert.match(c.out(), new RegExp(`Freeze authority:\\s+${FREEZE}`)); + assert.match(c.out(), /RPC:\s+rpc\.example\.com/); + assert.ok(!c.out().includes('secret')); +}); + +test('--json prints parseable mint info', async () => { + const c = capture(); + const mock = createMockRpc({ getAccountInfo: mintAccount() }); + assert.equal(await run([USDC, '--json'], c.io, depsFor(mock)), 0); + assert.deepEqual(JSON.parse(c.out()), { + mint: USDC, + program: 'spl-token', + programName: 'Token', + programId: TOKEN_PROGRAM, + decimals: 6, + supply: { raw: '9876543210123', ui: '9876543.210123' }, + mintAuthority: null, + freezeAuthority: FREEZE, + }); +}); + +test('--rpc beats SOL_HOLDERS_RPC', async () => { + const c = capture(); + const mock = createMockRpc({ getAccountInfo: mintAccount() }); + await run([USDC, '--rpc', 'https://flag.example.com'], c.io, depsFor(mock)); + assert.equal(mock.calls[0].url, 'https://flag.example.com'); + assert.match(c.out(), /RPC:\s+flag\.example\.com/); +}); + +test('SOL_HOLDERS_RPC is used when --rpc is absent', async () => { + const c = capture(); + const mock = createMockRpc({ getAccountInfo: mintAccount() }); + await run([USDC], c.io, depsFor(mock)); + assert.equal(mock.calls[0].url, RPC_ENV); +}); + +test('a missing mint account exits 1', async () => { + const c = capture(); + const mock = createMockRpc({ getAccountInfo: { value: null } }); + assert.equal(await run([USDC], c.io, depsFor(mock)), 1); + assert.match(c.err(), /error: Mint account not found/); + assert.equal(c.out(), ''); }); -test('valid mint with --json prints parseable JSON', () => { +test('an RPC failure exits 1 and never prints the URL', async () => { const c = capture(); - assert.equal(run([USDC, '--json'], c.io), 0); - assert.deepEqual(JSON.parse(c.out()), { mint: USDC }); + const mock = createMockRpc({ getAccountInfo: () => httpError(500) }); + assert.equal(await run([USDC], c.io, depsFor(mock)), 1); + assert.match(c.err(), /error: rpc\.example\.com returned HTTP 500/); + assert.ok(!c.err().includes('secret')); }); \ No newline at end of file diff --git a/tests/format.test.js b/tests/format.test.js new file mode 100644 index 0000000..da80e53 --- /dev/null +++ b/tests/format.test.js @@ -0,0 +1,28 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { formatUnits, formatSupply } = require('../src/format'); + +test('formatUnits drops trailing zeros and the decimal point', () => { + assert.equal(formatUnits('1500000', 6), '1.5'); + assert.equal(formatUnits('1000000', 6), '1'); + assert.equal(formatUnits('0', 6), '0'); +}); + +test('formatUnits keeps leading fractional zeros', () => { + assert.equal(formatUnits('1', 9), '0.000000001'); + assert.equal(formatUnits('1050000', 6), '1.05'); +}); + +test('formatUnits handles zero decimals and u64 max without precision loss', () => { + assert.equal(formatUnits('123', 0), '123'); + assert.equal(formatUnits('18446744073709551615', 9), '18446744073.709551615'); +}); + +test('formatSupply adds thousands separators', () => { + assert.equal(formatSupply('9876543210123', 6), '9,876,543.210123'); + assert.equal(formatSupply('1000', 0), '1,000'); + assert.equal(formatSupply('999', 0), '999'); + assert.equal(formatSupply('0', 6), '0'); +}); \ No newline at end of file diff --git a/tests/mint.test.js b/tests/mint.test.js new file mode 100644 index 0000000..1d89ddb --- /dev/null +++ b/tests/mint.test.js @@ -0,0 +1,110 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { createRpcClient } = require('../src/rpc'); +const { MintError, fetchMint } = require('../src/mint'); +const { createMockRpc } = require('./helpers/mock-rpc'); + +const USDC = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; +const TOKEN_PROGRAM = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'; +const TOKEN_2022 = 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb'; + +function mintAccount({ owner = TOKEN_PROGRAM, type = 'mint', info = {}, data } = {}) { + return { + value: { + owner, + data: data || { + program: 'spl-token', + parsed: { + type, + info: { + supply: '9876543210123', + decimals: 6, + mintAuthority: null, + freezeAuthority: null, + ...info, + }, + }, + }, + }, + }; +} + +function clientFor(account) { + const mock = createMockRpc({ getAccountInfo: account }); + return { mock, client: createRpcClient({ url: 'https://rpc.example.com', fetch: mock.fetch }) }; +} + +async function assertMintError(promise, pattern) { + await assert.rejects(promise, (err) => { + assert.ok(err instanceof MintError); + assert.match(err.message, pattern); + return true; + }); +} + +test('fetchMint returns decoded mint info and asks for jsonParsed data', async () => { + const { mock, client } = clientFor(mintAccount()); + const info = await fetchMint(client, USDC); + assert.deepEqual(info, { + mint: USDC, + program: 'spl-token', + programName: 'Token', + programId: TOKEN_PROGRAM, + decimals: 6, + supply: { raw: '9876543210123', ui: '9876543.210123' }, + mintAuthority: null, + freezeAuthority: null, + }); + assert.equal(mock.calls[0].method, 'getAccountInfo'); + assert.deepEqual(mock.calls[0].params, [USDC, { encoding: 'jsonParsed', commitment: 'finalized' }]); +}); + +test('fetchMint recognises Token-2022 mints', async () => { + const { client } = clientFor(mintAccount({ owner: TOKEN_2022 })); + const info = await fetchMint(client, USDC); + assert.equal(info.program, 'spl-token-2022'); + assert.equal(info.programName, 'Token-2022'); + assert.equal(info.programId, TOKEN_2022); +}); + +test('fetchMint keeps authorities when they are set', async () => { + const { client } = clientFor( + mintAccount({ info: { mintAuthority: 'MintAuth111', freezeAuthority: 'FreezeAuth111' } }) + ); + const info = await fetchMint(client, USDC); + assert.equal(info.mintAuthority, 'MintAuth111'); + assert.equal(info.freezeAuthority, 'FreezeAuth111'); +}); + +test('fetchMint reports a missing account', async () => { + const { client } = clientFor({ value: null }); + await assertMintError(fetchMint(client, USDC), /Mint account not found/); +}); + +test('fetchMint rejects accounts owned by another program', async () => { + const owner = 'NativeLoader1111111111111111111111111111111'; + const { client } = clientFor(mintAccount({ owner })); + await assertMintError(fetchMint(client, USDC), /Not an SPL token mint/); +}); + +test('fetchMint rejects token accounts that are not mints', async () => { + const { client } = clientFor(mintAccount({ type: 'account' })); + await assertMintError(fetchMint(client, USDC), /not a token mint/); +}); + +test('fetchMint rejects data the RPC could not parse', async () => { + const { client } = clientFor(mintAccount({ data: ['AQID', 'base64'] })); + await assertMintError(fetchMint(client, USDC), /not a token mint/); +}); + +test('fetchMint rejects a malformed supply', async () => { + const { client } = clientFor(mintAccount({ info: { supply: 'abc' } })); + await assertMintError(fetchMint(client, USDC), /unexpected mint data/); +}); + +test('fetchMint rejects malformed decimals', async () => { + const { client } = clientFor(mintAccount({ info: { decimals: 'six' } })); + await assertMintError(fetchMint(client, USDC), /unexpected mint data/); +}); \ No newline at end of file