From 95767780bb8254356358eaff1441ba9dbf90d913 Mon Sep 17 00:00:00 2001 From: Sybi Date: Sat, 10 Oct 2026 21:03:38 +0000 Subject: [PATCH] Add --json output, verdict line and MiB sizes --- README.md | 95 +++++++++++++++++++--- src/args.js | 3 + src/cli.js | 16 +++- src/format.js | 6 +- src/report.js | 120 ++++++++++++++++++++++++++++ tests/args.test.js | 8 ++ tests/cli.test.js | 88 ++++++++++++++++++-- tests/format.test.js | 11 ++- tests/report.test.js | 185 +++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 511 insertions(+), 21 deletions(-) create mode 100644 src/report.js create mode 100644 tests/report.test.js diff --git a/README.md b/README.md index f6b853b..971d600 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, upgrade authority, deploy date and size work. `--json` and a plain-English verdict are coming. +Node 18+. Zero dependencies. ## Usage @@ -10,38 +10,114 @@ Node 18+. Early release: the program lookup, upgrade authority, deploy date and npx sol-program-info ``` -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: +The first line is the plain answer, followed by the details. This is the SPL Token program (output from October 2026): ``` +Frozen: the upgrade authority was removed, so this program's code can never change. + +Program: TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA +Type: program +Loader: Upgradeable BPF Loader (BPFLoaderUpgradeab1e11111111111111111111111) +ProgramData: 3gvYRKWyXRR9xKWe1ZjPhLY5ZJRN7KDB4rFZFGoJfFk2 +Authority: none; the upgrade authority was removed +Last deployed: slot 419,472,000, 2026-05-13 12:39 UTC (about 5 months ago) +Size: 108,600 bytes (106.1 KiB) +Upgrades: never; the program is frozen +``` + +A program that can still be changed names who can change it. This is Jupiter v6, a few days after its last upgrade: + +``` +Upgradeable: whoever can sign as CvQZ...tipQ can replace this program's code at any time. Last deployed 2 days ago. + 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) +Authority: CvQZZ23qYDWF2RUpxYJ8y9K4skmuvYEEjH7fK58jtipQ +Last deployed: slot 454,465,850, 2026-10-08 06:36 UTC (2 days ago) +Size: 2,892,224 bytes (2.76 MiB) Upgrades: yes, by whoever can sign as the upgrade authority ``` Built-in programs are simpler: ``` +Built-in: part of the validator itself. Wallets cannot upgrade it; only a validator software release can. + Program: 11111111111111111111111111111111 Type: built-in program Loader: Native loader (NativeLoader1111111111111111111111111111111) Upgrades: only by a validator software release, not by any wallet ``` +### Verdicts + +| Verdict | Meaning | +| --- | --- | +| `upgradeable` | An upgrade authority is set. Whoever can sign as that address can replace the code at any time. | +| `frozen` | The upgrade authority was removed. The code can never change. | +| `immutable` | An old loader with no upgrade mechanism. The code can never change. | +| `built-in` | Part of the validator. Only a validator release can change it. | +| `closed` | The ProgramData account no longer exists, so the program cannot run. | +| `unknown` | A loader this version does not understand, so it cannot say. | + +The authority can be one person's key or a multisig. The tool shows the address; it does not tell you which. + ### 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. | +| `Authority` | The address that can replace the program's code. `none` means the authority was removed. | | `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. +Only small slices of each account are requested (36 and 45 bytes), so a lookup is cheap even for programs with megabytes of code. + +## JSON output + +```bash +npx sol-program-info --json | jq . +``` + +stdout holds one JSON object and nothing else, so it is safe to pipe. Status messages and errors go to stderr. This is the System program: + +```json +{ + "programId": "11111111111111111111111111111111", + "kind": "native", + "verdict": "built-in", + "canUpgrade": false, + "summary": "Built-in: part of the validator itself. Wallets cannot upgrade it; only a validator software release can.", + "loader": { + "address": "NativeLoader1111111111111111111111111111111", + "name": "Native loader" + }, + "programData": null, + "authority": null, + "lastDeployed": null, + "sizeBytes": null +} +``` + +| Field | Meaning | +| --- | --- | +| `kind` | `upgradeable`, `loader-v1`, `loader-v2`, `loader-v4`, `native` or `unknown-loader` | +| `verdict` | One of the verdicts above | +| `canUpgrade` | `true`, `false`, or `null` when the tool cannot tell | +| `summary` | The one-line answer as a sentence | +| `loader` | The loader's address, and its name when it is recognized | +| `programData` | The ProgramData address, for upgradeable programs | +| `authority` | The upgrade authority, or `null` when frozen or not applicable (use `verdict` to tell which) | +| `lastDeployed` | `slot`, `time` (ISO 8601, or `null`) and `timeNote` (why there is no time), or `null` when not applicable | +| `sizeBytes` | The program binary size in bytes, or `null` when unknown | + +A handy check for scripts: fail unless a program can never change. + +```bash +test "$(npx sol-program-info --json | jq -r .canUpgrade)" = "false" +``` ### Loaders @@ -52,12 +128,11 @@ A closed program, whose ProgramData account no longer exists, is reported as clo | 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 small slices of each account are requested (36 and 45 bytes), so a lookup is cheap even for programs with megabytes of code. - ## Options | Option | What it does | | --- | --- | +| `--json` | Print the result as JSON instead of text | | `-h`, `--help` | Show help | | `-v`, `--version` | Show the version | @@ -93,7 +168,7 @@ When the endpoint is busy, requests are retried with backoff and a `Retry-After` - [x] RPC client with retries and clear errors - [x] Look up the program on-chain: loader and ProgramData address - [x] Upgrade authority, last deploy time and size -- [ ] `--json` and a plain-English verdict +- [x] `--json` and a plain-English verdict ## Development diff --git a/src/args.js b/src/args.js index f90420e..912f2ac 100644 --- a/src/args.js +++ b/src/args.js @@ -8,6 +8,7 @@ function parseArgs(argv) { const result = { help: false, version: false, + json: false, unknown: null, programIds: [], }; @@ -17,6 +18,8 @@ function parseArgs(argv) { result.help = true; } else if (arg === '-v' || arg === '--version') { result.version = true; + } else if (arg === '--json') { + result.json = true; } else if (arg.startsWith('-')) { if (result.unknown === null) result.unknown = arg; } else { diff --git a/src/cli.js b/src/cli.js index b6ee77c..a18ebc4 100644 --- a/src/cli.js +++ b/src/cli.js @@ -6,6 +6,7 @@ const { parseArgs } = require('./args'); const { EndpointError, RPC_ENV, rateLimitHint, resolveEndpoint } = require('./endpoint'); const { explainProblem, formatProgram } = require('./format'); const { inspectProgram } = require('./program'); +const { describe, toJson } = require('./report'); const { RpcError, createRpcClient } = require('./rpc'); const HELP = `sol-program-info v${pkg.version} @@ -17,6 +18,7 @@ Usage: sol-program-info [options] Options: + --json Print the result as JSON instead of text -h, --help Show this help -v, --version Show the version @@ -25,7 +27,8 @@ Environment: rate-limit heavily) Output: - Results go to stdout, status messages go to stderr. + Results go to stdout, status messages go to stderr. With --json, stdout holds + only the JSON result; problems still go to stderr with the exit codes below. Exit codes: 0 success @@ -35,7 +38,7 @@ Exit codes: 4 the address is not a program `; -// Problems that end the run without a result block. +// Problems that end the run without a result. const PROBLEM_EXIT = { 'not-found': 4, 'not-a-program': 4, @@ -136,7 +139,14 @@ async function run(argv, io = {}) { return problemExit; } - stdout.write(formatProgram(info, now())); + const time = now(); + + if (args.json) { + stdout.write(`${JSON.stringify(toJson(info, time), null, 2)}\n`); + return 0; + } + + stdout.write(`${describe(info, time).summary}\n\n${formatProgram(info, time)}`); return 0; } diff --git a/src/format.js b/src/format.js index 1bb2c0c..3c7c916 100644 --- a/src/format.js +++ b/src/format.js @@ -3,6 +3,7 @@ const { LOADERS, SYSTEM_PROGRAM, UPGRADEABLE_LOADER } = require('./loaders'); const DAY_MS = 24 * 60 * 60 * 1000; +const MIB = 1024 * 1024; /** Aligned "Label: value" lines. */ function rows(pairs) { @@ -15,9 +16,10 @@ function withCommas(n) { return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ','); } -/** 134567 -> "134,567 bytes (131.4 KiB)" */ +/** 134567 -> "134,567 bytes (131.4 KiB)"; 2892224 -> "2,892,224 bytes (2.76 MiB)" */ function formatBytes(n) { - return `${withCommas(n)} bytes (${(n / 1024).toFixed(1)} KiB)`; + const human = n >= MIB ? `${(n / MIB).toFixed(2)} MiB` : `${(n / 1024).toFixed(1)} KiB`; + return `${withCommas(n)} bytes (${human})`; } /** How long ago, in words, for a duration in milliseconds. */ diff --git a/src/report.js b/src/report.js new file mode 100644 index 0000000..1f9353c --- /dev/null +++ b/src/report.js @@ -0,0 +1,120 @@ +'use strict'; + +const { formatAge } = require('./format'); +const { LOADERS } = require('./loaders'); + +/** First and last four characters, e.g. Toke...Q5DA. */ +function shortAddress(address) { + if (address.length <= 11) return address; + return `${address.slice(0, 4)}...${address.slice(-4)}`; +} + +/** + * The plain-English answer for a program that was found. + * Returns { verdict, canUpgrade, summary }: + * verdict upgradeable, frozen, immutable, built-in, closed or unknown + * canUpgrade true, false, or null when this tool cannot tell + * summary one sentence + */ +function describe(info, now = Date.now()) { + if (info.kind === 'native') { + return { + verdict: 'built-in', + canUpgrade: false, + summary: + 'Built-in: part of the validator itself. Wallets cannot upgrade it; ' + + 'only a validator software release can.', + }; + } + + if (info.kind === 'loader-v1' || info.kind === 'loader-v2') { + return { + verdict: 'immutable', + canUpgrade: false, + summary: + 'Immutable: this program uses an old loader with no upgrade mechanism, ' + + 'so its code can never change.', + }; + } + + if (info.kind === 'upgradeable' && info.details) { + const details = info.details; + + if (details.state === 'closed') { + return { + verdict: 'closed', + canUpgrade: false, + summary: "Closed: the program's ProgramData account no longer exists, so it cannot run.", + }; + } + + if (!details.authority) { + return { + verdict: 'frozen', + canUpgrade: false, + summary: + "Frozen: the upgrade authority was removed, so this program's code can never change.", + }; + } + + let summary = + `Upgradeable: whoever can sign as ${shortAddress(details.authority)} ` + + "can replace this program's code at any time."; + if (details.deployedAt !== null && details.deployedAt !== undefined) { + summary += ` Last deployed ${formatAge(now - details.deployedAt * 1000)}.`; + } + return { verdict: 'upgradeable', canUpgrade: true, summary }; + } + + if (info.kind === 'loader-v4') { + return { + verdict: 'unknown', + canUpgrade: null, + summary: 'Loader v4: this version cannot tell who controls it yet.', + }; + } + + return { + verdict: 'unknown', + canUpgrade: null, + summary: + 'Unknown loader: this program is owned by a loader this tool does not recognize, ' + + 'so it cannot say whether the program can change.', + }; +} + +/** + * The result as a plain object, ready for JSON.stringify. + * authority is null both for a frozen program and when it does not apply; + * verdict tells them apart. + */ +function toJson(info, now = Date.now()) { + const loader = LOADERS[info.owner]; + const details = info.details && info.details.state === 'ok' ? info.details : null; + const { verdict, canUpgrade, summary } = describe(info, now); + + let lastDeployed = null; + if (details) { + const hasTime = details.deployedAt !== null && details.deployedAt !== undefined; + lastDeployed = { + slot: details.slot, + time: hasTime ? new Date(details.deployedAt * 1000).toISOString() : null, + timeNote: details.deployedNote || null, + }; + } + + return { + programId: info.programId, + kind: info.kind, + verdict, + canUpgrade, + summary, + loader: { address: info.owner, name: loader ? loader.name : null }, + programData: info.programDataAddress, + authority: details ? details.authority : null, + lastDeployed, + sizeBytes: details && details.sizeBytes !== undefined ? details.sizeBytes : null, + }; +} + +module.exports = { describe, shortAddress, toJson }; \ No newline at end of file diff --git a/tests/args.test.js b/tests/args.test.js index 8231854..f485b08 100644 --- a/tests/args.test.js +++ b/tests/args.test.js @@ -8,6 +8,7 @@ test('no arguments gives empty defaults', () => { assert.deepEqual(parseArgs([]), { help: false, version: false, + json: false, unknown: null, programIds: [], }); @@ -20,6 +21,13 @@ test('help and version flags, short and long', () => { assert.equal(parseArgs(['--version']).version, true); }); +test('--json is a flag and can come before or after the program ID', () => { + assert.equal(parseArgs(['--json']).json, true); + assert.deepEqual(parseArgs(['--json', 'abc']).programIds, ['abc']); + assert.deepEqual(parseArgs(['abc', '--json']).programIds, ['abc']); + assert.equal(parseArgs(['abc', '--json']).json, true); +}); + test('positional arguments are collected as program IDs', () => { assert.deepEqual(parseArgs(['abc', 'def']).programIds, ['abc', 'def']); }); diff --git a/tests/cli.test.js b/tests/cli.test.js index d7b4c0a..395b1c7 100644 --- a/tests/cli.test.js +++ b/tests/cli.test.js @@ -81,10 +81,11 @@ function capture({ env = {}, steps = [reply(account(LOADER_V2))], rpc = {} } = { }; } -test('--help prints usage, the env var and the exit codes', async () => { +test('--help prints usage, --json, the env var and the exit codes', async () => { const c = capture(); assert.equal(await run(['--help'], c.io), 0); assert.match(c.stdout(), /Usage:/); + assert.match(c.stdout(), /--json {11}Print the result as JSON/); assert.match(c.stdout(), /SOL_PROGRAM_INFO_RPC/); assert.match(c.stdout(), /3 {2}the RPC lookup failed/); assert.match(c.stdout(), /4 {2}the address is not a program/); @@ -130,24 +131,28 @@ test('a bad endpoint setting exits 1 without echoing the value', async () => { assert.equal(c.fetch.calls.length, 0); }); -test('a legacy program prints its details to stdout and exits 0', async () => { +test('a legacy program opens with its verdict, then the details', async () => { const c = capture(); assert.equal(await run([PROGRAM], c.io), 0); - assert.match(c.stdout(), /^Program: +TokenkegQfe/m); + const lines = c.stdout().split('\n'); + assert.match(lines[0], /^Immutable:/); + assert.equal(lines[1], ''); + assert.match(lines[2], /^Program: +TokenkegQfe/); assert.match(c.stdout(), /^Loader: +BPF Loader v2/m); assert.match(c.stdout(), /^Upgrades: +never/m); assert.match(c.stderr(), /Using api\.mainnet-beta\.solana\.com\./); assert.equal(c.fetch.calls.length, 1); }); -test('an upgradeable program prints authority, deploy date and size', async () => { +test('an upgradeable program prints verdict, 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.split('\n')[0], /^Upgradeable: whoever can sign as BPFL\.\.\.1111 can replace/); 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); @@ -156,11 +161,12 @@ test('an upgradeable program prints authority, deploy date and size', async () = assert.equal(c.fetch.calls.length, 3); }); -test('a frozen program says it can never be upgraded', async () => { +test('a frozen program opens with a frozen verdict', async () => { const c = capture({ steps: upgradeableSteps(programDataAccount({ authority: null })) }); assert.equal(await run([PROGRAM], c.io), 0); + assert.match(c.stdout().split('\n')[0], /^Frozen:/); assert.match(c.stdout(), /^Authority: +none; the upgrade authority was removed$/m); assert.match(c.stdout(), /^Upgrades: +never; the program is frozen$/m); }); @@ -170,10 +176,81 @@ test('a closed program is reported as closed and exits 0', async () => { assert.equal(await run([PROGRAM], c.io), 0); + assert.match(c.stdout().split('\n')[0], /^Closed:/); assert.match(c.stdout(), /^Status: +closed;/m); assert.equal(c.fetch.calls.length, 2); }); +test('--json prints one parsable object and nothing else on stdout', async () => { + const c = capture({ steps: upgradeableSteps() }); + + assert.equal(await run(['--json', PROGRAM], c.io), 0); + + assert.ok(c.stdout().endsWith('}\n')); + const { summary, ...rest } = JSON.parse(c.stdout()); + assert.match(summary, /^Upgradeable:/); + assert.deepEqual(rest, { + programId: PROGRAM, + kind: 'upgradeable', + verdict: 'upgradeable', + canUpgrade: true, + loader: { address: UPGRADEABLE, name: 'Upgradeable BPF Loader' }, + programData: PROGRAM_DATA, + authority: AUTHORITY, + lastDeployed: { slot: 250000000, time: '2025-10-09T08:53:20.000Z', timeNote: null }, + sizeBytes: 134567, + }); + assert.match(c.stderr(), /Using api\.mainnet-beta\.solana\.com\./); +}); + +test('--json works after the program ID and for a frozen program', async () => { + const c = capture({ steps: upgradeableSteps(programDataAccount({ authority: null })) }); + + assert.equal(await run([PROGRAM, '--json'], c.io), 0); + + const json = JSON.parse(c.stdout()); + assert.equal(json.verdict, 'frozen'); + assert.equal(json.canUpgrade, false); + assert.equal(json.authority, null); +}); + +test('--json for a legacy program has nulls for the ProgramData fields', async () => { + const c = capture(); + + assert.equal(await run(['--json', PROGRAM], c.io), 0); + + const json = JSON.parse(c.stdout()); + assert.equal(json.verdict, 'immutable'); + assert.equal(json.programData, null); + assert.equal(json.lastDeployed, null); + assert.equal(json.sizeBytes, null); +}); + +test('--json problems keep stdout empty and use the usual exit codes', async () => { + const missing = capture({ steps: [reply(null)] }); + assert.equal(await run(['--json', PROGRAM], missing.io), 4); + assert.equal(missing.stdout(), ''); + assert.match(missing.stderr(), /No account found/); + + const failing = capture({ steps: [http(401)] }); + assert.equal(await run(['--json', PROGRAM], failing.io), 3); + assert.equal(failing.stdout(), ''); + assert.match(failing.stderr(), /API key/); +}); + +test('--json output never contains the endpoint URL or key', async () => { + const c = capture({ + env: { SOL_PROGRAM_INFO_RPC: 'https://rpc.example.com/v2/secret123?key=secret123' }, + steps: upgradeableSteps(), + }); + + assert.equal(await run(['--json', PROGRAM], c.io), 0); + + assert.equal(c.stdout().includes('secret123'), false); + assert.equal(c.stdout().includes('rpc.example.com'), false); + assert.equal(c.stderr().includes('secret123'), false); +}); + test('unreadable ProgramData exits 3', async () => { const wrong = { ...programDataAccount(), owner: SYSTEM }; const c = capture({ steps: [reply(upgradeableAccount()), reply(wrong)] }); @@ -194,6 +271,7 @@ test('a missing deploy date does not fail the run', async () => { assert.match(c.stdout(), /^Last deployed: +slot 250,000,000, date unavailable/m); assert.match(c.stdout(), /^Authority:/m); + assert.equal(c.stdout().includes('Last deployed about'), false); assert.equal(c.stdout().includes('secret123'), false); assert.equal(c.stderr().includes('secret123'), false); }); diff --git a/tests/format.test.js b/tests/format.test.js index 8310ee8..aa0e8df 100644 --- a/tests/format.test.js +++ b/tests/format.test.js @@ -104,7 +104,10 @@ test('a closed program says so instead of showing an authority', () => { }); test('an upgradeable program without details falls back to the loader text', () => { - const text = formatProgram(info({ kind: 'upgradeable', owner: UPGRADEABLE, programDataAddress: PROGRAM_DATA }), NOW); + const text = formatProgram( + info({ kind: 'upgradeable', owner: UPGRADEABLE, programDataAddress: PROGRAM_DATA }), + NOW + ); assert.match(text, /^Upgrades: +depends on the upgrade authority$/m); }); @@ -138,7 +141,13 @@ test('numbers get thousands separators', () => { assert.equal(withCommas(999), '999'); assert.equal(withCommas(1000), '1,000'); assert.equal(withCommas(250000000), '250,000,000'); +}); + +test('sizes use KiB below 1 MiB and MiB from 1 MiB up', () => { assert.equal(formatBytes(134567), '134,567 bytes (131.4 KiB)'); + assert.equal(formatBytes(1048575), '1,048,575 bytes (1024.0 KiB)'); + assert.equal(formatBytes(1048576), '1,048,576 bytes (1.00 MiB)'); + assert.equal(formatBytes(2892224), '2,892,224 bytes (2.76 MiB)'); }); test('not-found points at the program ID and the network', () => { diff --git a/tests/report.test.js b/tests/report.test.js new file mode 100644 index 0000000..d70081a --- /dev/null +++ b/tests/report.test.js @@ -0,0 +1,185 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { describe, shortAddress, toJson } = require('../src/report'); + +const PROGRAM = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'; +const PROGRAM_DATA = 'JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4'; +const AUTHORITY = 'CvQZZ23qYDWF2RUpxYJ8y9K4skmuvYEEjH7fK58jtipQ'; +const LOADER_V1 = 'BPFLoader1111111111111111111111111111111111'; +const LOADER_V2 = 'BPFLoader2111111111111111111111111111111111'; +const LOADER_V4 = 'LoaderV411111111111111111111111111111111111'; +const UPGRADEABLE = 'BPFLoaderUpgradeab1e11111111111111111111111'; +const NATIVE = 'NativeLoader1111111111111111111111111111111'; + +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 }; +} + +const OK_DETAILS = { + state: 'ok', + slot: 250000000, + authority: AUTHORITY, + sizeBytes: 134567, + deployedAt: BLOCK_TIME, + deployedNote: null, +}; + +function upgradeable(details) { + return info({ + kind: 'upgradeable', + owner: UPGRADEABLE, + programDataAddress: PROGRAM_DATA, + details, + }); +} + +test('shortAddress keeps the first and last four characters', () => { + assert.equal(shortAddress(AUTHORITY), 'CvQZ...tipQ'); + assert.equal(shortAddress('short'), 'short'); +}); + +test('an upgradeable program names its authority and when it last changed', () => { + const result = describe(upgradeable(OK_DETAILS), NOW); + + assert.equal(result.verdict, 'upgradeable'); + assert.equal(result.canUpgrade, true); + assert.match(result.summary, /^Upgradeable: whoever can sign as CvQZ\.\.\.tipQ can replace/); + assert.match(result.summary, /Last deployed about 12 months ago\.$/); +}); + +test('an upgradeable program without a deploy date leaves that sentence out', () => { + const result = describe(upgradeable({ ...OK_DETAILS, deployedAt: null }), NOW); + + assert.equal(result.verdict, 'upgradeable'); + assert.equal(/Last deployed/.test(result.summary), false); +}); + +test('a program with no authority is frozen', () => { + const result = describe(upgradeable({ ...OK_DETAILS, authority: null }), NOW); + + assert.equal(result.verdict, 'frozen'); + assert.equal(result.canUpgrade, false); + assert.match(result.summary, /^Frozen: the upgrade authority was removed/); + assert.match(result.summary, /can never change\.$/); +}); + +test('a closed program says it cannot run', () => { + const result = describe(upgradeable({ state: 'closed' }), NOW); + + assert.equal(result.verdict, 'closed'); + assert.equal(result.canUpgrade, false); + assert.match(result.summary, /^Closed:/); +}); + +test('old loaders are immutable', () => { + for (const [kind, owner] of [ + ['loader-v1', LOADER_V1], + ['loader-v2', LOADER_V2], + ]) { + const result = describe(info({ kind, owner }), NOW); + assert.equal(result.verdict, 'immutable'); + assert.equal(result.canUpgrade, false); + assert.match(result.summary, /^Immutable:/); + } +}); + +test('a native program is built-in', () => { + const result = describe(info({ kind: 'native', owner: NATIVE }), NOW); + + assert.equal(result.verdict, 'built-in'); + assert.equal(result.canUpgrade, false); + assert.match(result.summary, /validator software release/); +}); + +test('Loader v4 and unknown loaders are honest about not knowing', () => { + const v4 = describe(info({ kind: 'loader-v4', owner: LOADER_V4 }), NOW); + assert.equal(v4.verdict, 'unknown'); + assert.equal(v4.canUpgrade, null); + assert.match(v4.summary, /^Loader v4:/); + + const other = describe(info({ kind: 'unknown-loader', owner: PROGRAM_DATA }), NOW); + assert.equal(other.verdict, 'unknown'); + assert.equal(other.canUpgrade, null); + assert.match(other.summary, /^Unknown loader:/); +}); + +test('toJson has every field for an upgradeable program', () => { + const { summary, ...rest } = toJson(upgradeable(OK_DETAILS), NOW); + + assert.match(summary, /^Upgradeable:/); + assert.deepEqual(rest, { + programId: PROGRAM, + kind: 'upgradeable', + verdict: 'upgradeable', + canUpgrade: true, + loader: { address: UPGRADEABLE, name: 'Upgradeable BPF Loader' }, + programData: PROGRAM_DATA, + authority: AUTHORITY, + lastDeployed: { slot: 250000000, time: '2025-10-09T08:53:20.000Z', timeNote: null }, + sizeBytes: 134567, + }); +}); + +test('toJson for a frozen program has a null authority', () => { + const json = toJson(upgradeable({ ...OK_DETAILS, authority: null }), NOW); + + assert.equal(json.verdict, 'frozen'); + assert.equal(json.canUpgrade, false); + assert.equal(json.authority, null); + assert.equal(json.lastDeployed.slot, 250000000); +}); + +test('toJson keeps the reason when the deploy date is unavailable', () => { + const json = toJson( + upgradeable({ ...OK_DETAILS, deployedAt: null, deployedNote: 'the RPC no longer has that block' }), + NOW + ); + + assert.deepEqual(json.lastDeployed, { + slot: 250000000, + time: null, + timeNote: 'the RPC no longer has that block', + }); +}); + +test('toJson for an unknown size has a null sizeBytes', () => { + const json = toJson(upgradeable({ ...OK_DETAILS, sizeBytes: null }), NOW); + assert.equal(json.sizeBytes, null); +}); + +test('toJson for a closed program has no deploy details', () => { + const json = toJson(upgradeable({ state: 'closed' }), NOW); + + assert.equal(json.verdict, 'closed'); + assert.equal(json.lastDeployed, null); + assert.equal(json.authority, null); + assert.equal(json.sizeBytes, null); + assert.equal(json.programData, PROGRAM_DATA); +}); + +test('toJson for a legacy program has no ProgramData fields', () => { + const json = toJson(info({ kind: 'loader-v2', owner: LOADER_V2 }), NOW); + + assert.equal(json.programData, null); + assert.equal(json.authority, null); + assert.equal(json.lastDeployed, null); + assert.equal(json.sizeBytes, null); + assert.deepEqual(json.loader, { address: LOADER_V2, name: 'BPF Loader v2' }); +}); + +test('toJson for an unrecognized loader has no loader name', () => { + const json = toJson(info({ kind: 'unknown-loader', owner: PROGRAM_DATA }), NOW); + + assert.deepEqual(json.loader, { address: PROGRAM_DATA, name: null }); + assert.equal(json.canUpgrade, null); +}); + +test('the result survives JSON.stringify and parse unchanged', () => { + const json = toJson(upgradeable(OK_DETAILS), NOW); + assert.deepEqual(JSON.parse(JSON.stringify(json)), json); +}); \ No newline at end of file