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
44 changes: 34 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,33 +2,57 @@

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

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

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: <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)
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

Expand Down Expand Up @@ -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
Expand Down
10 changes: 6 additions & 4 deletions src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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`);
Expand All @@ -134,7 +136,7 @@ async function run(argv, io = {}) {
return problemExit;
}

stdout.write(formatProgram(info));
stdout.write(formatProgram(info, now()));
return 0;
}

Expand Down
72 changes: 69 additions & 3 deletions src/format.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,88 @@

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]];

if (loader) {
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)`]);
Expand Down Expand Up @@ -59,4 +125,4 @@ function explainProblem(info, host) {
}
}

module.exports = { explainProblem, formatProgram };
module.exports = { explainProblem, formatAge, formatBytes, formatProgram, withCommas };
2 changes: 1 addition & 1 deletion src/loaders.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
132 changes: 118 additions & 14 deletions src/program.js
Original file line number Diff line number Diff line change
@@ -1,29 +1,67 @@
'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;
if (!Array.isArray(data) || typeof data[0] !== 'string') return null;
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;
if (bytes.readUInt32LE(0) !== PROGRAM_VARIANT) return null;
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.
*
Expand All @@ -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' };

Expand All @@ -62,4 +91,79 @@ async function lookupProgram(rpc, programId) {
return { ...base, kind: 'upgradeable', owner, programDataAddress: address };
}

module.exports = { SLICE, lookupProgram, readProgramDataAddress };
/**
* 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,
};
Loading
Loading