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
95 changes: 85 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,46 +2,122 @@

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

```bash
npx sol-program-info <program-id>
```

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: <upgrade authority address>
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 <program-id> | 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 <program-id> | jq -r .canUpgrade)" = "false"
```

### Loaders

Expand All @@ -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 |

Expand Down Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions src/args.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ function parseArgs(argv) {
const result = {
help: false,
version: false,
json: false,
unknown: null,
programIds: [],
};
Expand All @@ -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 {
Expand Down
16 changes: 13 additions & 3 deletions src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -17,6 +18,7 @@ Usage:
sol-program-info [options] <program-id>

Options:
--json Print the result as JSON instead of text
-h, --help Show this help
-v, --version Show the version

Expand All @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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;
}

Expand Down
6 changes: 4 additions & 2 deletions src/format.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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. */
Expand Down
120 changes: 120 additions & 0 deletions src/report.js
Original file line number Diff line number Diff line change
@@ -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 };
8 changes: 8 additions & 0 deletions tests/args.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ test('no arguments gives empty defaults', () => {
assert.deepEqual(parseArgs([]), {
help: false,
version: false,
json: false,
unknown: null,
programIds: [],
});
Expand All @@ -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']);
});
Expand Down
Loading
Loading