From c1225ae0de7059ad1e7bba257091b2f0d1e0e51a Mon Sep 17 00:00:00 2001 From: Sybi Date: Sat, 10 Oct 2026 20:55:20 +0000 Subject: [PATCH] Add upgrade authority, deploy slot and date, and program size --- README.md | 44 ++++++++--- src/cli.js | 10 ++- src/format.js | 72 ++++++++++++++++- src/loaders.js | 2 +- src/program.js | 132 +++++++++++++++++++++++++++---- tests/cli.test.js | 105 ++++++++++++++++++++++--- tests/format.test.js | 109 +++++++++++++++++++++++--- tests/program.test.js | 176 +++++++++++++++++++++++++++++++++++++++++- 8 files changed, 596 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index b1e01b6..f6b853b 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Check if a Solana program is upgradeable, who controls it, and when it was last deployed. -Node 18+. Early release: the program lookup works, the upgrade authority and deploy time are coming. +Node 18+. Early release: the program lookup, upgrade authority, deploy date and size work. `--json` and a plain-English verdict are coming. ## Usage @@ -10,25 +10,49 @@ Node 18+. Early release: the program lookup works, the upgrade authority and dep npx sol-program-info ``` -Example: +For a program on the Upgradeable BPF Loader, which covers most programs on Solana, it shows who can upgrade it, when it was last deployed and how big it is. The values below are illustrative: ``` -Program: TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA -Type: program -Loader: BPF Loader v2 (BPFLoader2111111111111111111111111111111111) -Upgrades: never; this loader has no upgrade mechanism +Program: JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 +Type: program +Loader: Upgradeable BPF Loader (BPFLoaderUpgradeab1e11111111111111111111111) +ProgramData: 4Ec7ZxZS6Sbdg5UGSLHbAnM7GQHp2eFd4KYWRexAipQT +Authority: +Last deployed: slot 250,000,000, 2025-10-09 08:53 UTC (about 12 months ago) +Size: 134,567 bytes (131.4 KiB) +Upgrades: yes, by whoever can sign as the upgrade authority ``` -The tool reads the program account and checks which loader owns it: +Built-in programs are simpler: + +``` +Program: 11111111111111111111111111111111 +Type: built-in program +Loader: Native loader (NativeLoader1111111111111111111111111111111) +Upgrades: only by a validator software release, not by any wallet +``` + +### What the rows mean + +| Row | Meaning | +| --- | --- | +| `Authority` | The address that can replace the program's code. `none` means the authority was removed and the program is frozen for good. | +| `Last deployed` | The slot of the last deploy or upgrade, and the time of that slot. Old blocks are sometimes pruned from RPCs, in which case the date is shown as unavailable with the reason. | +| `Size` | The program binary size. Left out when your RPC does not report the full account size. | +| `Upgrades` | The plain answer: can this program still change, and who can change it. | + +A closed program, whose ProgramData account no longer exists, is reported as closed. + +### Loaders | Loader | What it means | | --- | --- | | Native loader | Built into the validator. Changes only with a validator release. | | BPF Loader v1, v2 | Old loaders with no upgrade mechanism. The program can never change. | -| Upgradeable BPF Loader | The program can be upgraded by its upgrade authority. The ProgramData address is shown. | +| Upgradeable BPF Loader | The program can be upgraded by its upgrade authority, if one is set. | | Loader v4 | Recognized, but its details are not read yet. | -Only the first 36 bytes of the account are requested, so a lookup is cheap even for programs with megabytes of code. +Only small slices of each account are requested (36 and 45 bytes), so a lookup is cheap even for programs with megabytes of code. ## Options @@ -68,7 +92,7 @@ When the endpoint is busy, requests are retried with backoff and a `Retry-After` - [x] Program ID validation - [x] RPC client with retries and clear errors - [x] Look up the program on-chain: loader and ProgramData address -- [ ] Upgrade authority, last deploy time and size +- [x] Upgrade authority, last deploy time and size - [ ] `--json` and a plain-English verdict ## Development diff --git a/src/cli.js b/src/cli.js index 2cb4505..b6ee77c 100644 --- a/src/cli.js +++ b/src/cli.js @@ -5,7 +5,7 @@ const { AddressError, validateProgramId } = require('./address'); const { parseArgs } = require('./args'); const { EndpointError, RPC_ENV, rateLimitHint, resolveEndpoint } = require('./endpoint'); const { explainProblem, formatProgram } = require('./format'); -const { lookupProgram } = require('./program'); +const { inspectProgram } = require('./program'); const { RpcError, createRpcClient } = require('./rpc'); const HELP = `sol-program-info v${pkg.version} @@ -44,12 +44,14 @@ const PROBLEM_EXIT = { /** * Run the CLI. Returns an exit code instead of exiting, so it can be tested. - * Tests pass io.rpcOptions (fetch, sleep, attempts) to avoid the network. + * Tests pass io.rpcOptions (fetch, sleep, attempts) to avoid the network, + * and io.now to fix the clock. */ async function run(argv, io = {}) { const stdout = io.stdout || process.stdout; const stderr = io.stderr || process.stderr; const env = io.env || process.env; + const now = io.now || Date.now; const args = parseArgs(argv); @@ -120,7 +122,7 @@ async function run(argv, io = {}) { let info; try { - info = await lookupProgram(rpc, programId); + info = await inspectProgram(rpc, programId); } catch (error) { if (!(error instanceof RpcError)) throw error; stderr.write(`Could not look up the program on ${endpoint.host}: ${error.message}\n`); @@ -134,7 +136,7 @@ async function run(argv, io = {}) { return problemExit; } - stdout.write(formatProgram(info)); + stdout.write(formatProgram(info, now())); return 0; } diff --git a/src/format.js b/src/format.js index 2aafeb2..1bb2c0c 100644 --- a/src/format.js +++ b/src/format.js @@ -2,14 +2,75 @@ const { LOADERS, SYSTEM_PROGRAM, UPGRADEABLE_LOADER } = require('./loaders'); +const DAY_MS = 24 * 60 * 60 * 1000; + /** Aligned "Label: value" lines. */ function rows(pairs) { const width = Math.max(...pairs.map(([label]) => label.length)) + 2; return pairs.map(([label, value]) => `${`${label}:`.padEnd(width)}${value}`).join('\n'); } +/** 134567 -> "134,567" */ +function withCommas(n) { + return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ','); +} + +/** 134567 -> "134,567 bytes (131.4 KiB)" */ +function formatBytes(n) { + return `${withCommas(n)} bytes (${(n / 1024).toFixed(1)} KiB)`; +} + +/** How long ago, in words, for a duration in milliseconds. */ +function formatAge(ms) { + const days = Math.floor(ms / DAY_MS); + if (days < 1) return 'today'; + if (days < 2) return '1 day ago'; + if (days < 60) return `${days} days ago`; + if (days < 730) return `about ${Math.round(days / 30.44)} months ago`; + return `about ${Math.floor(days / 365.25)} years ago`; +} + +/** "slot 250,000,000, 2025-10-09 08:53 UTC (about 12 months ago)" */ +function formatDeployed(details, now) { + const slot = `slot ${withCommas(details.slot)}`; + + if (details.deployedAt === null || details.deployedAt === undefined) { + const why = details.deployedNote ? `: ${details.deployedNote}` : ''; + return `${slot}, date unavailable${why}`; + } + + const iso = new Date(details.deployedAt * 1000).toISOString(); + const when = `${iso.slice(0, 10)} ${iso.slice(11, 16)} UTC`; + return `${slot}, ${when} (${formatAge(now - details.deployedAt * 1000)})`; +} + +/** The rows that describe an upgradeable program's ProgramData account. */ +function upgradeableRows(details, now) { + if (details.state === 'closed') { + return [ + ['Status', 'closed; its ProgramData account no longer exists, so it cannot run'], + ['Upgrades', 'not applicable; the program is closed'], + ]; + } + + const pairs = [ + ['Authority', details.authority || 'none; the upgrade authority was removed'], + ['Last deployed', formatDeployed(details, now)], + ]; + if (details.sizeBytes !== null && details.sizeBytes !== undefined) { + pairs.push(['Size', formatBytes(details.sizeBytes)]); + } + pairs.push([ + 'Upgrades', + details.authority + ? 'yes, by whoever can sign as the upgrade authority' + : 'never; the program is frozen', + ]); + return pairs; +} + /** The result block for an address that is a program. Ends with a newline. */ -function formatProgram(info) { +function formatProgram(info, now = Date.now()) { const loader = LOADERS[info.owner]; const pairs = [['Program', info.programId]]; @@ -17,7 +78,12 @@ function formatProgram(info) { pairs.push(['Type', loader.type]); pairs.push(['Loader', `${loader.name} (${info.owner})`]); if (info.programDataAddress) pairs.push(['ProgramData', info.programDataAddress]); - pairs.push(['Upgrades', loader.upgrades]); + + if (info.details) { + pairs.push(...upgradeableRows(info.details, now)); + } else { + pairs.push(['Upgrades', loader.upgrades]); + } } else { pairs.push(['Type', 'program']); pairs.push(['Owner', `${info.owner} (loader not recognized)`]); @@ -59,4 +125,4 @@ function explainProblem(info, host) { } } -module.exports = { explainProblem, formatProgram }; \ No newline at end of file +module.exports = { explainProblem, formatAge, formatBytes, formatProgram, withCommas }; \ No newline at end of file diff --git a/src/loaders.js b/src/loaders.js index 5b8ab5e..0f8326b 100644 --- a/src/loaders.js +++ b/src/loaders.js @@ -33,7 +33,7 @@ const LOADERS = { kind: 'upgradeable', name: 'Upgradeable BPF Loader', type: 'program', - upgrades: 'depends on the upgrade authority (checking it comes in the next release)', + upgrades: 'depends on the upgrade authority', }, [LOADER_V4]: { kind: 'loader-v4', diff --git a/src/program.js b/src/program.js index 0c51911..e613e39 100644 --- a/src/program.js +++ b/src/program.js @@ -1,15 +1,23 @@ 'use strict'; const { base58Encode } = require('./base58'); -const { LOADERS } = require('./loaders'); +const { LOADERS, UPGRADEABLE_LOADER } = require('./loaders'); const { RpcError } = require('./rpc'); // The upgradeable loader's Program account is a 4 byte variant (2) followed -// by the 32 byte ProgramData address, 36 bytes in all. Nothing else is needed, -// so only that much is requested, however large the program is. +// by the 32 byte ProgramData address, 36 bytes in all. const SLICE = { offset: 0, length: 36 }; const PROGRAM_VARIANT = 2; +// A ProgramData account starts with 45 bytes of metadata: a 4 byte variant (3), +// the 8 byte slot of the last deploy, a 1 byte flag for whether an upgrade +// authority is set, and the 32 byte authority address (zeros when there is none). +// The program binary follows. Only the metadata is requested, however large +// the program is. +const PROGRAM_DATA_SLICE = { offset: 0, length: 45 }; +const METADATA_SIZE = 45; +const PROGRAM_DATA_VARIANT = 3; + /** The raw bytes of an account's data, or null if the RPC did not send base64. */ function decodeData(value) { const data = value && value.data; @@ -17,6 +25,19 @@ function decodeData(value) { return Buffer.from(data[0], 'base64'); } +/** getAccountInfo for the first bytes of an account. Resolves to the account, or null if it is missing. */ +async function fetchAccount(rpc, address, slice) { + const result = await rpc.call('getAccountInfo', [ + address, + { encoding: 'base64', dataSlice: slice, commitment: 'confirmed' }, + ]); + + if (!result || typeof result !== 'object' || !('value' in result)) { + throw new RpcError('the endpoint answer was not in the expected shape', 'bad-response'); + } + return result.value; +} + /** The ProgramData address inside a Program account's data, or null. */ function readProgramDataAddress(bytes) { if (!bytes || bytes.length < 36) return null; @@ -24,6 +45,23 @@ function readProgramDataAddress(bytes) { return base58Encode(bytes.subarray(4, 36)); } +/** { slot, authority } from a ProgramData account's metadata, or null. authority is null when none is set. */ +function readProgramDataHeader(bytes) { + if (!bytes || bytes.length < METADATA_SIZE) return null; + if (bytes.readUInt32LE(0) !== PROGRAM_DATA_VARIANT) return null; + + const slot = bytes.readBigUInt64LE(4); + if (slot > BigInt(Number.MAX_SAFE_INTEGER)) return null; + + const flag = bytes[12]; + if (flag !== 0 && flag !== 1) return null; + + return { + slot: Number(slot), + authority: flag === 1 ? base58Encode(bytes.subarray(13, 45)) : null, + }; +} + /** * Look up an address on-chain and work out what kind of program it is. * @@ -34,17 +72,8 @@ function readProgramDataAddress(bytes) { * Rejects with RpcError when the RPC cannot be reached or answers oddly. */ async function lookupProgram(rpc, programId) { - const result = await rpc.call('getAccountInfo', [ - programId, - { encoding: 'base64', dataSlice: SLICE, commitment: 'confirmed' }, - ]); - - if (!result || typeof result !== 'object' || !('value' in result)) { - throw new RpcError('the endpoint answer was not in the expected shape', 'bad-response'); - } - const base = { programId, owner: null, programDataAddress: null }; - const value = result.value; + const value = await fetchAccount(rpc, programId, SLICE); if (!value) return { ...base, kind: 'not-found' }; @@ -62,4 +91,79 @@ async function lookupProgram(rpc, programId) { return { ...base, kind: 'upgradeable', owner, programDataAddress: address }; } -module.exports = { SLICE, lookupProgram, readProgramDataAddress }; \ No newline at end of file +/** + * Read a ProgramData account. Resolves to one of + * { state: 'ok', slot, authority, sizeBytes } authority is null when frozen; + * sizeBytes is null when unknown + * { state: 'closed' } the account no longer exists + * { state: 'unreadable' } it is not in the expected format + */ +async function lookupProgramData(rpc, address) { + const value = await fetchAccount(rpc, address, PROGRAM_DATA_SLICE); + + if (!value) return { state: 'closed' }; + + const header = value.owner === UPGRADEABLE_LOADER ? readProgramDataHeader(decodeData(value)) : null; + if (!header) return { state: 'unreadable' }; + + // `space` is the account's full data size, even when only a slice was requested. + const sizeBytes = + Number.isSafeInteger(value.space) && value.space > METADATA_SIZE + ? value.space - METADATA_SIZE + : null; + + return { state: 'ok', slot: header.slot, authority: header.authority, sizeBytes }; +} + +/** + * The time a slot was produced, as unix seconds. Never rejects with an RpcError: + * old blocks are often pruned from RPCs, and a missing date should not hide + * everything else, so the reason comes back in `note` instead. + */ +async function fetchDeployTime(rpc, slot) { + try { + const seconds = await rpc.call('getBlockTime', [slot]); + if (Number.isSafeInteger(seconds)) return { deployedAt: seconds, note: null }; + return { deployedAt: null, note: 'the RPC no longer has that block' }; + } catch (error) { + if (!(error instanceof RpcError)) throw error; + return { deployedAt: null, note: error.message }; + } +} + +/** + * lookupProgram, plus the deploy details for upgradeable programs. + * + * For an upgradeable program the result also has + * details: { state: 'ok', slot, authority, sizeBytes, deployedAt, deployedNote } + * or { state: 'closed' } + * and if the ProgramData account cannot be read, kind becomes 'unreadable'. + * Rejects with RpcError when the RPC cannot be reached or answers oddly. + */ +async function inspectProgram(rpc, programId) { + const info = await lookupProgram(rpc, programId); + if (info.kind !== 'upgradeable') return info; + + const details = await lookupProgramData(rpc, info.programDataAddress); + + if (details.state === 'unreadable') return { ...info, kind: 'unreadable' }; + + if (details.state === 'ok') { + const time = await fetchDeployTime(rpc, details.slot); + details.deployedAt = time.deployedAt; + details.deployedNote = time.note; + } + + return { ...info, details }; +} + +module.exports = { + METADATA_SIZE, + PROGRAM_DATA_SLICE, + SLICE, + inspectProgram, + lookupProgram, + lookupProgramData, + readProgramDataAddress, + readProgramDataHeader, +}; \ No newline at end of file diff --git a/tests/cli.test.js b/tests/cli.test.js index c09b1bd..d7b4c0a 100644 --- a/tests/cli.test.js +++ b/tests/cli.test.js @@ -9,16 +9,13 @@ const { createMockFetch, http, ok } = require('./helpers/mock-rpc'); const PROGRAM = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'; const PROGRAM_DATA = 'JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4'; +const AUTHORITY = 'BPFLoaderUpgradeab1e11111111111111111111111'; const LOADER_V2 = 'BPFLoader2111111111111111111111111111111111'; const UPGRADEABLE = 'BPFLoaderUpgradeab1e11111111111111111111111'; const SYSTEM = '11111111111111111111111111111111'; -function programData(address) { - const buffer = Buffer.alloc(36); - buffer.writeUInt32LE(2, 0); - base58Decode(address).copy(buffer, 4); - return buffer.toString('base64'); -} +const BLOCK_TIME = 1760000000; // 2025-10-09 08:53:20 UTC +const NOW = Date.parse('2026-10-10T00:00:00Z'); function account(owner, { executable = true, data = '' } = {}) { return { @@ -31,10 +28,40 @@ function account(owner, { executable = true, data = '' } = {}) { }; } +function upgradeableAccount() { + const buffer = Buffer.alloc(36); + buffer.writeUInt32LE(2, 0); + base58Decode(PROGRAM_DATA).copy(buffer, 4); + return account(UPGRADEABLE, { data: buffer.toString('base64') }); +} + +function programDataAccount({ authority = AUTHORITY, size = 134567 } = {}) { + const buffer = Buffer.alloc(45); + buffer.writeUInt32LE(3, 0); + buffer.writeBigUInt64LE(250000000n, 4); + if (authority) { + buffer[12] = 1; + base58Decode(authority).copy(buffer, 13); + } + return { + lamports: 1, + owner: UPGRADEABLE, + executable: false, + rentEpoch: 0, + space: 45 + size, + data: [buffer.toString('base64'), 'base64'], + }; +} + function reply(value) { return ok({ context: { slot: 1 }, value }); } +/** The three answers an upgradeable program needs. */ +function upgradeableSteps(programData = programDataAccount()) { + return [reply(upgradeableAccount()), reply(programData), ok(BLOCK_TIME)]; +} + /** Every test gets a mock fetch, so nothing here can reach the network. */ function capture({ env = {}, steps = [reply(account(LOADER_V2))], rpc = {} } = {}) { const out = []; @@ -46,6 +73,7 @@ function capture({ env = {}, steps = [reply(account(LOADER_V2))], rpc = {} } = { stdout: { write: (text) => out.push(text) }, stderr: { write: (text) => err.push(text) }, env, + now: () => NOW, rpcOptions: { fetch, sleep: async () => {}, random: () => 0.5, ...rpc }, }, stdout: () => out.join(''), @@ -114,14 +142,60 @@ test('a legacy program prints its details to stdout and exits 0', async () => { assert.equal(c.fetch.calls.length, 1); }); -test('an upgradeable program prints its ProgramData address', async () => { +test('an upgradeable program prints authority, deploy date and size', async () => { + const c = capture({ steps: upgradeableSteps() }); + + assert.equal(await run([PROGRAM], c.io), 0); + + const text = c.stdout(); + assert.match(text, /^ProgramData: +JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4$/m); + assert.match(text, /^Authority: +BPFLoaderUpgradeab1e11111111111111111111111$/m); + assert.match(text, /^Last deployed: +slot 250,000,000, 2025-10-09 08:53 UTC \(about 12 months ago\)$/m); + assert.match(text, /^Size: +134,567 bytes/m); + assert.match(text, /^Upgrades: +yes, by whoever can sign/m); + assert.equal(c.fetch.calls.length, 3); +}); + +test('a frozen program says it can never be upgraded', async () => { + const c = capture({ steps: upgradeableSteps(programDataAccount({ authority: null })) }); + + assert.equal(await run([PROGRAM], c.io), 0); + + assert.match(c.stdout(), /^Authority: +none; the upgrade authority was removed$/m); + assert.match(c.stdout(), /^Upgrades: +never; the program is frozen$/m); +}); + +test('a closed program is reported as closed and exits 0', async () => { + const c = capture({ steps: [reply(upgradeableAccount()), reply(null)] }); + + assert.equal(await run([PROGRAM], c.io), 0); + + assert.match(c.stdout(), /^Status: +closed;/m); + assert.equal(c.fetch.calls.length, 2); +}); + +test('unreadable ProgramData exits 3', async () => { + const wrong = { ...programDataAccount(), owner: SYSTEM }; + const c = capture({ steps: [reply(upgradeableAccount()), reply(wrong)] }); + + assert.equal(await run([PROGRAM], c.io), 3); + + assert.match(c.stderr(), /not in the expected format/); + assert.equal(c.stdout(), ''); +}); + +test('a missing deploy date does not fail the run', async () => { const c = capture({ - steps: [reply(account(UPGRADEABLE, { data: programData(PROGRAM_DATA) }))], + env: { SOL_PROGRAM_INFO_RPC: 'https://rpc.example.com/?key=secret123' }, + steps: [reply(upgradeableAccount()), reply(programDataAccount()), http(401)], }); assert.equal(await run([PROGRAM], c.io), 0); - assert.match(c.stdout(), /^ProgramData: +JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4$/m); + assert.match(c.stdout(), /^Last deployed: +slot 250,000,000, date unavailable/m); + assert.match(c.stdout(), /^Authority:/m); + assert.equal(c.stdout().includes('secret123'), false); + assert.equal(c.stderr().includes('secret123'), false); }); test('your own endpoint shows only its hostname, never the key', async () => { @@ -187,6 +261,19 @@ test('a steady rate limit exits 3 after the allowed attempts', async () => { assert.equal(c.stdout(), ''); }); +test('a rate limit while reading ProgramData exits 3', async () => { + const c = capture({ + steps: [reply(upgradeableAccount()), http(429)], + rpc: { attempts: 2 }, + }); + + assert.equal(await run([PROGRAM], c.io), 3); + + assert.match(c.stderr(), /rate limiting/); + assert.equal(c.fetch.calls.length, 3); + assert.equal(c.stdout(), ''); +}); + test('with your own RPC the hint is about plan limits', async () => { const c = capture({ env: { SOL_PROGRAM_INFO_RPC: 'https://rpc.example.com/?key=secret123' }, diff --git a/tests/format.test.js b/tests/format.test.js index 391feb5..8310ee8 100644 --- a/tests/format.test.js +++ b/tests/format.test.js @@ -2,21 +2,44 @@ const test = require('node:test'); const assert = require('node:assert/strict'); -const { explainProblem, formatProgram } = require('../src/format'); +const { explainProblem, formatAge, formatBytes, formatProgram, withCommas } = require('../src/format'); const PROGRAM = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'; const PROGRAM_DATA = 'JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4'; +const AUTHORITY = 'BPFLoaderUpgradeab1e11111111111111111111111'; const LOADER_V2 = 'BPFLoader2111111111111111111111111111111111'; const UPGRADEABLE = 'BPFLoaderUpgradeab1e11111111111111111111111'; const NATIVE = 'NativeLoader1111111111111111111111111111111'; const SYSTEM = '11111111111111111111111111111111'; +const DAY = 24 * 60 * 60 * 1000; +const BLOCK_TIME = 1760000000; // 2025-10-09 08:53:20 UTC +const NOW = Date.parse('2026-10-10T00:00:00Z'); + function info(extra) { return { programId: PROGRAM, owner: null, programDataAddress: null, ...extra }; } +function upgradeable(details) { + return info({ + kind: 'upgradeable', + owner: UPGRADEABLE, + programDataAddress: PROGRAM_DATA, + details, + }); +} + +const OK_DETAILS = { + state: 'ok', + slot: 250000000, + authority: AUTHORITY, + sizeBytes: 134567, + deployedAt: BLOCK_TIME, + deployedNote: null, +}; + test('a loader v2 program says it can never be upgraded', () => { - const text = formatProgram(info({ kind: 'loader-v2', owner: LOADER_V2 })); + const text = formatProgram(info({ kind: 'loader-v2', owner: LOADER_V2 }), NOW); assert.match(text, /^Program: +TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA$/m); assert.match(text, /^Type: +program$/m); @@ -26,30 +49,98 @@ test('a loader v2 program says it can never be upgraded', () => { assert.ok(text.endsWith('\n')); }); -test('an upgradeable program shows its ProgramData address', () => { - const text = formatProgram( - info({ kind: 'upgradeable', owner: UPGRADEABLE, programDataAddress: PROGRAM_DATA }) - ); +test('an upgradeable program with an authority shows every row', () => { + const text = formatProgram(upgradeable(OK_DETAILS), NOW); assert.match(text, /^ProgramData: +JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4$/m); assert.match(text, /^Loader: +Upgradeable BPF Loader/m); - assert.match(text, /^Upgrades: +depends on the upgrade authority/m); + assert.match(text, /^Authority: +BPFLoaderUpgradeab1e11111111111111111111111$/m); + assert.match( + text, + /^Last deployed: +slot 250,000,000, 2025-10-09 08:53 UTC \(about 12 months ago\)$/m + ); + assert.match(text, /^Size: +134,567 bytes \(131\.4 KiB\)$/m); + assert.match(text, /^Upgrades: +yes, by whoever can sign as the upgrade authority$/m); +}); + +test('rows are aligned to the longest label', () => { + const lines = formatProgram(upgradeable(OK_DETAILS), NOW).trimEnd().split('\n'); + const starts = lines.map((line) => line.match(/^[A-Za-z ]+: +/)[0].length); + assert.equal(new Set(starts).size, 1); +}); + +test('a frozen program says it can never change', () => { + const text = formatProgram(upgradeable({ ...OK_DETAILS, authority: null }), NOW); + + assert.match(text, /^Authority: +none; the upgrade authority was removed$/m); + assert.match(text, /^Upgrades: +never; the program is frozen$/m); +}); + +test('an unknown size leaves out the Size row', () => { + const text = formatProgram(upgradeable({ ...OK_DETAILS, sizeBytes: null }), NOW); + + assert.equal(/Size:/.test(text), false); + assert.match(text, /^Authority:/m); +}); + +test('a missing deploy date shows the slot and the reason', () => { + const text = formatProgram( + upgradeable({ ...OK_DETAILS, deployedAt: null, deployedNote: 'the RPC no longer has that block' }), + NOW + ); + + assert.match( + text, + /^Last deployed: +slot 250,000,000, date unavailable: the RPC no longer has that block$/m + ); +}); + +test('a closed program says so instead of showing an authority', () => { + const text = formatProgram(upgradeable({ state: 'closed' }), NOW); + + assert.match(text, /^Status: +closed; its ProgramData account no longer exists/m); + assert.match(text, /^Upgrades: +not applicable; the program is closed$/m); + assert.equal(/Authority:/.test(text), false); +}); + +test('an upgradeable program without details falls back to the loader text', () => { + const text = formatProgram(info({ kind: 'upgradeable', owner: UPGRADEABLE, programDataAddress: PROGRAM_DATA }), NOW); + + assert.match(text, /^Upgrades: +depends on the upgrade authority$/m); }); test('a native program is called built-in', () => { - const text = formatProgram(info({ kind: 'native', owner: NATIVE })); + const text = formatProgram(info({ kind: 'native', owner: NATIVE }), NOW); assert.match(text, /^Type: +built-in program$/m); assert.match(text, /^Upgrades: +only by a validator software release/m); }); test('an unrecognized loader is labelled as unknown', () => { - const text = formatProgram(info({ kind: 'unknown-loader', owner: PROGRAM_DATA })); + const text = formatProgram(info({ kind: 'unknown-loader', owner: PROGRAM_DATA }), NOW); assert.match(text, /^Owner: +JUP6Lkb.* \(loader not recognized\)$/m); assert.match(text, /^Upgrades: +unknown/m); }); +test('formatAge uses days, months and years', () => { + assert.equal(formatAge(0), 'today'); + assert.equal(formatAge(-5 * DAY), 'today'); + assert.equal(formatAge(1 * DAY), '1 day ago'); + assert.equal(formatAge(10 * DAY), '10 days ago'); + assert.equal(formatAge(90 * DAY), 'about 3 months ago'); + assert.equal(formatAge(400 * DAY), 'about 13 months ago'); + assert.equal(formatAge(800 * DAY), 'about 2 years ago'); +}); + +test('numbers get thousands separators', () => { + assert.equal(withCommas(0), '0'); + assert.equal(withCommas(999), '999'); + assert.equal(withCommas(1000), '1,000'); + assert.equal(withCommas(250000000), '250,000,000'); + assert.equal(formatBytes(134567), '134,567 bytes (131.4 KiB)'); +}); + test('not-found points at the program ID and the network', () => { const text = explainProblem(info({ kind: 'not-found' }), 'rpc.example.com'); diff --git a/tests/program.test.js b/tests/program.test.js index 4ec31aa..bab69b7 100644 --- a/tests/program.test.js +++ b/tests/program.test.js @@ -4,11 +4,17 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const { base58Decode } = require('../src/address'); const { RpcError, createRpcClient } = require('../src/rpc'); -const { SLICE, lookupProgram } = require('../src/program'); -const { createMockFetch, http, ok } = require('./helpers/mock-rpc'); +const { + SLICE, + inspectProgram, + lookupProgram, + readProgramDataHeader, +} = require('../src/program'); +const { createMockFetch, http, ok, rpcFail } = require('./helpers/mock-rpc'); const PROGRAM = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'; const PROGRAM_DATA = 'JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4'; +const AUTHORITY = 'BPFLoaderUpgradeab1e11111111111111111111111'; const NATIVE = 'NativeLoader1111111111111111111111111111111'; const LOADER_V1 = 'BPFLoader1111111111111111111111111111111111'; const LOADER_V2 = 'BPFLoader2111111111111111111111111111111111'; @@ -16,6 +22,9 @@ const UPGRADEABLE = 'BPFLoaderUpgradeab1e11111111111111111111111'; const LOADER_V4 = 'LoaderV411111111111111111111111111111111111'; const SYSTEM = '11111111111111111111111111111111'; +const SLOT = 250000000; +const BLOCK_TIME = 1760000000; + /** base64 of a 36 byte Program account slice: variant, then an address. */ function programData(variant, address) { const buffer = Buffer.alloc(36); @@ -35,10 +44,35 @@ function account(owner, { executable = true, data = '' } = {}) { }; } +/** The first 45 bytes of a ProgramData account. */ +function metadata({ variant = 3, slot = SLOT, authority = AUTHORITY, flag } = {}) { + const buffer = Buffer.alloc(45); + buffer.writeUInt32LE(variant, 0); + buffer.writeBigUInt64LE(BigInt(slot), 4); + if (authority) base58Decode(authority).copy(buffer, 13); + buffer[12] = flag === undefined ? (authority ? 1 : 0) : flag; + return buffer; +} + +function programDataAccount({ owner = UPGRADEABLE, size = 134567, space, bytes = metadata() } = {}) { + return { + lamports: 1, + owner, + executable: false, + rentEpoch: 0, + space: space === undefined ? 45 + size : space, + data: [bytes.toString('base64'), 'base64'], + }; +} + function reply(value) { return ok({ context: { slot: 1 }, value }); } +function upgradeableProgram() { + return reply(account(UPGRADEABLE, { data: programData(2, PROGRAM_DATA) })); +} + function setup(steps) { const fetch = createMockFetch(steps); const rpc = createRpcClient({ @@ -47,7 +81,11 @@ function setup(steps) { sleep: async () => {}, attempts: 1, }); - return { fetch, lookup: (id = PROGRAM) => lookupProgram(rpc, id) }; + return { + fetch, + lookup: (id = PROGRAM) => lookupProgram(rpc, id), + inspect: (id = PROGRAM) => inspectProgram(rpc, id), + }; } test('asks for only the first 36 bytes as base64', async () => { @@ -66,7 +104,7 @@ test('asks for only the first 36 bytes as base64', async () => { }); test('an upgradeable program reports its ProgramData address', async () => { - const { lookup } = setup([reply(account(UPGRADEABLE, { data: programData(2, PROGRAM_DATA) }))]); + const { lookup } = setup([upgradeableProgram()]); const info = await lookup(); @@ -153,4 +191,134 @@ test('RPC failures pass through as RpcError', async () => { assert.equal(error.rateLimited, true); return true; }); +}); + +test('inspect reads the ProgramData metadata, size and deploy time', async () => { + const { fetch, inspect } = setup([ + upgradeableProgram(), + reply(programDataAccount()), + ok(BLOCK_TIME), + ]); + + const info = await inspect(); + + assert.equal(info.kind, 'upgradeable'); + assert.equal(info.programDataAddress, PROGRAM_DATA); + assert.deepEqual(info.details, { + state: 'ok', + slot: SLOT, + authority: AUTHORITY, + sizeBytes: 134567, + deployedAt: BLOCK_TIME, + deployedNote: null, + }); + + assert.equal(fetch.calls.length, 3); + assert.deepEqual(fetch.calls[1].request.params, [ + PROGRAM_DATA, + { encoding: 'base64', dataSlice: { offset: 0, length: 45 }, commitment: 'confirmed' }, + ]); + assert.equal(fetch.calls[2].request.method, 'getBlockTime'); + assert.deepEqual(fetch.calls[2].request.params, [SLOT]); +}); + +test('a frozen program has no authority', async () => { + const bytes = metadata({ authority: null }); + const { inspect } = setup([ + upgradeableProgram(), + reply(programDataAccount({ bytes })), + ok(BLOCK_TIME), + ]); + + const info = await inspect(); + + assert.equal(info.details.state, 'ok'); + assert.equal(info.details.authority, null); +}); + +test('a closed program has no ProgramData account and no further calls', async () => { + const { fetch, inspect } = setup([upgradeableProgram(), reply(null)]); + + const info = await inspect(); + + assert.equal(info.kind, 'upgradeable'); + assert.deepEqual(info.details, { state: 'closed' }); + assert.equal(fetch.calls.length, 2); +}); + +test('a ProgramData account in the wrong shape makes the program unreadable', async () => { + const cases = [ + programDataAccount({ bytes: metadata({ variant: 2 }) }), + programDataAccount({ bytes: metadata({ flag: 7 }) }), + programDataAccount({ bytes: Buffer.alloc(10) }), + programDataAccount({ owner: SYSTEM }), + ]; + + for (const value of cases) { + const { fetch, inspect } = setup([upgradeableProgram(), reply(value)]); + const info = await inspect(); + assert.equal(info.kind, 'unreadable'); + assert.equal('details' in info, false); + assert.equal(fetch.calls.length, 2); + } +}); + +test('the size is unknown when the RPC does not report the full account size', async () => { + for (const space of [undefined, 45, 0]) { + const value = programDataAccount({ space: space === undefined ? 45 : space }); + if (space === undefined) delete value.space; + + const { inspect } = setup([upgradeableProgram(), reply(value), ok(BLOCK_TIME)]); + const info = await inspect(); + + assert.equal(info.details.sizeBytes, null); + } +}); + +test('a missing block time does not hide the rest', async () => { + const cases = [ + [ok(null), /no longer has that block/], + [rpcFail(-32004, 'Block not available for slot 250000000'), /Block not available/], + [http(429), /rate limiting/], + ]; + + for (const [step, pattern] of cases) { + const { inspect } = setup([upgradeableProgram(), reply(programDataAccount()), step]); + + const info = await inspect(); + + assert.equal(info.details.state, 'ok'); + assert.equal(info.details.authority, AUTHORITY); + assert.equal(info.details.deployedAt, null); + assert.match(info.details.deployedNote, pattern); + } +}); + +test('programs on other loaders skip the ProgramData lookup', async () => { + const { fetch, inspect } = setup([reply(account(LOADER_V2))]); + + const info = await inspect(); + + assert.equal(info.kind, 'loader-v2'); + assert.equal('details' in info, false); + assert.equal(fetch.calls.length, 1); +}); + +test('a failure reading ProgramData is reported, not swallowed', async () => { + const { inspect } = setup([upgradeableProgram(), http(401)]); + + await assert.rejects(inspect(), (error) => { + assert.ok(error instanceof RpcError); + assert.equal(error.kind, 'auth'); + return true; + }); +}); + +test('readProgramDataHeader reads the slot and the authority flag', () => { + assert.deepEqual(readProgramDataHeader(metadata()), { slot: SLOT, authority: AUTHORITY }); + assert.deepEqual(readProgramDataHeader(metadata({ authority: null })), { + slot: SLOT, + authority: null, + }); + assert.equal(readProgramDataHeader(null), null); }); \ No newline at end of file