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
38 changes: 35 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,52 @@

Check if a Solana program is upgradeable, who controls it, and when it was last deployed.

Node 18+. Early scaffold: program ID validation and the RPC connection work, the on-chain lookup is coming.
Node 18+. Early release: the program lookup works, the upgrade authority and deploy time are coming.

## Usage

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

Example:

```
Program: TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA
Type: program
Loader: BPF Loader v2 (BPFLoader2111111111111111111111111111111111)
Upgrades: never; this loader has no upgrade mechanism
```

The tool reads the program account and checks which loader owns it:

| 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. |
| 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.

## Options

| Option | What it does |
| --- | --- |
| `-h`, `--help` | Show help |
| `-v`, `--version` | Show the version |

Results go to stdout, status messages go to stderr. Exit codes: `0` success, `1` invalid program ID or RPC endpoint setting, `2` bad usage.
Results go to stdout, status messages go to stderr.

Exit codes:

| Code | Meaning |
| --- | --- |
| `0` | Success |
| `1` | Invalid program ID or RPC endpoint setting |
| `2` | Bad usage |
| `3` | The RPC lookup failed |
| `4` | The address is not a program (missing, or a wallet or data account) |

## Use your own RPC

Expand All @@ -27,6 +57,8 @@ Public RPCs rate-limit heavily. Set your own endpoint:
export SOL_PROGRAM_INFO_RPC="https://your-rpc-endpoint"
```

By default the tool reads mainnet. If you point it at devnet or a local validator through this variable, it reads that network instead.

Only the hostname is ever printed, never the full URL, so API keys stay out of your terminal output.

When the endpoint is busy, requests are retried with backoff and a `Retry-After` header is respected. A rejected API key (HTTP 401 or 403) or a wrong URL (HTTP 404) stops right away with a plain-English message instead of retrying.
Expand All @@ -35,7 +67,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
- [ ] Look up the program on-chain: upgradeable or immutable
- [x] Look up the program on-chain: loader and ProgramData address
- [ ] Upgrade authority, last deploy time and size
- [ ] `--json` and a plain-English verdict

Expand Down
26 changes: 26 additions & 0 deletions src/base58.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
'use strict';

const { ALPHABET } = require('./address');

/** Encode bytes as base58. Leading zero bytes become leading "1" characters. */
function base58Encode(bytes) {
const buffer = Buffer.from(bytes);

let n = 0n;
for (const byte of buffer) n = n * 256n + BigInt(byte);

let out = '';
while (n > 0n) {
out = ALPHABET[Number(n % 58n)] + out;
n /= 58n;
}

for (const byte of buffer) {
if (byte !== 0) break;
out = `1${out}`;
}

return out;
}

module.exports = { base58Encode };
53 changes: 51 additions & 2 deletions src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@
const pkg = require('../package.json');
const { AddressError, validateProgramId } = require('./address');
const { parseArgs } = require('./args');
const { EndpointError, RPC_ENV, resolveEndpoint } = require('./endpoint');
const { EndpointError, RPC_ENV, rateLimitHint, resolveEndpoint } = require('./endpoint');
const { explainProblem, formatProgram } = require('./format');
const { lookupProgram } = require('./program');
const { RpcError, createRpcClient } = require('./rpc');

const HELP = `sol-program-info v${pkg.version}

Expand All @@ -28,10 +31,20 @@ Exit codes:
0 success
1 invalid program ID or RPC endpoint setting
2 bad usage
3 the RPC lookup failed
4 the address is not a program
`;

// Problems that end the run without a result block.
const PROBLEM_EXIT = {
'not-found': 4,
'not-a-program': 4,
unreadable: 3,
};

/**
* 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.
*/
async function run(argv, io = {}) {
const stdout = io.stdout || process.stdout;
Expand Down Expand Up @@ -85,7 +98,43 @@ async function run(argv, io = {}) {
}

stderr.write(`Using ${endpoint.host}.\n`);
stdout.write(`${programId} is a valid program ID. Program lookup is not built yet.\n`);

// Shown once per run, however many times the endpoint throttles us.
let hintShown = false;
const showRateLimitHint = () => {
if (hintShown) return;
hintShown = true;
stderr.write(`${rateLimitHint(endpoint)}\n`);
};

const rpc = createRpcClient({
url: endpoint.rpcUrl,
onRetry: ({ delayMs, error }) => {
stderr.write(
`Request failed: ${error.message}. Retrying in ${(delayMs / 1000).toFixed(1)}s...\n`
);
if (error.rateLimited) showRateLimitHint();
},
...io.rpcOptions,
});

let info;
try {
info = await lookupProgram(rpc, programId);
} catch (error) {
if (!(error instanceof RpcError)) throw error;
stderr.write(`Could not look up the program on ${endpoint.host}: ${error.message}\n`);
if (error.rateLimited) showRateLimitHint();
return 3;
}

const problemExit = PROBLEM_EXIT[info.kind];
if (problemExit) {
stderr.write(`${explainProblem(info, endpoint.host)}\n`);
return problemExit;
}

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

Expand Down
62 changes: 62 additions & 0 deletions src/format.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
'use strict';

const { LOADERS, SYSTEM_PROGRAM, UPGRADEABLE_LOADER } = require('./loaders');

/** 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');
}

/** The result block for an address that is a program. Ends with a newline. */
function formatProgram(info) {
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]);
} else {
pairs.push(['Type', 'program']);
pairs.push(['Owner', `${info.owner} (loader not recognized)`]);
pairs.push(['Upgrades', 'unknown; this loader is not recognized']);
}

return `${rows(pairs)}\n`;
}

/** Plain-English explanation for an address that is not a usable program. */
function explainProblem(info, host) {
switch (info.kind) {
case 'not-found':
return (
`No account found at ${info.programId} on ${host}. Check the program ID, ` +
'and that your RPC points at the right network (mainnet by default).'
);
case 'not-a-program':
if (info.owner === SYSTEM_PROGRAM) {
return `${info.programId} is a wallet (a system account), not a program.`;
}
if (info.owner === UPGRADEABLE_LOADER) {
return (
`${info.programId} is a program data or buffer account, not a program. ` +
"Use the program's own ID instead."
);
}
return (
`${info.programId} exists on ${host} but is not an executable program ` +
`(it is owned by ${info.owner}).`
);
case 'unreadable':
return (
`${info.programId} is owned by the upgradeable loader, ` +
'but its data is not in the expected format.'
);
default:
return '';
}
}

module.exports = { explainProblem, formatProgram };
54 changes: 54 additions & 0 deletions src/loaders.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
'use strict';

const NATIVE_LOADER = 'NativeLoader1111111111111111111111111111111';
const LOADER_V1 = 'BPFLoader1111111111111111111111111111111111';
const LOADER_V2 = 'BPFLoader2111111111111111111111111111111111';
const UPGRADEABLE_LOADER = 'BPFLoaderUpgradeab1e11111111111111111111111';
const LOADER_V4 = 'LoaderV411111111111111111111111111111111111';
const SYSTEM_PROGRAM = '11111111111111111111111111111111';

const NEVER = 'never; this loader has no upgrade mechanism';

/** The loaders that can own an executable account, by address. */
const LOADERS = {
[NATIVE_LOADER]: {
kind: 'native',
name: 'Native loader',
type: 'built-in program',
upgrades: 'only by a validator software release, not by any wallet',
},
[LOADER_V1]: {
kind: 'loader-v1',
name: 'BPF Loader v1 (deprecated)',
type: 'program',
upgrades: NEVER,
},
[LOADER_V2]: {
kind: 'loader-v2',
name: 'BPF Loader v2',
type: 'program',
upgrades: NEVER,
},
[UPGRADEABLE_LOADER]: {
kind: 'upgradeable',
name: 'Upgradeable BPF Loader',
type: 'program',
upgrades: 'depends on the upgrade authority (checking it comes in the next release)',
},
[LOADER_V4]: {
kind: 'loader-v4',
name: 'Loader v4',
type: 'program',
upgrades: 'not read yet; this version does not understand Loader v4 accounts',
},
};

module.exports = {
LOADERS,
LOADER_V1,
LOADER_V2,
LOADER_V4,
NATIVE_LOADER,
SYSTEM_PROGRAM,
UPGRADEABLE_LOADER,
};
65 changes: 65 additions & 0 deletions src/program.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
'use strict';

const { base58Encode } = require('./base58');
const { LOADERS } = 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.
const SLICE = { offset: 0, length: 36 };
const PROGRAM_VARIANT = 2;

/** 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');
}

/** 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));
}

/**
* Look up an address on-chain and work out what kind of program it is.
*
* Resolves to { programId, kind, owner, programDataAddress } where kind is
* not-found, not-a-program, native, loader-v1, loader-v2, upgradeable,
* loader-v4, unknown-loader or unreadable.
* programDataAddress is set only for upgradeable programs.
* 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;

if (!value) return { ...base, kind: 'not-found' };

const owner = value.owner;
if (!value.executable) return { ...base, kind: 'not-a-program', owner };

const loader = LOADERS[owner];
if (!loader) return { ...base, kind: 'unknown-loader', owner };

if (loader.kind !== 'upgradeable') return { ...base, kind: loader.kind, owner };

const address = readProgramDataAddress(decodeData(value));
if (!address) return { ...base, kind: 'unreadable', owner };

return { ...base, kind: 'upgradeable', owner, programDataAddress: address };
}

module.exports = { SLICE, lookupProgram, readProgramDataAddress };
31 changes: 31 additions & 0 deletions tests/base58.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
'use strict';

const test = require('node:test');
const assert = require('node:assert/strict');
const { base58Decode } = require('../src/address');
const { base58Encode } = require('../src/base58');

const ADDRESSES = [
'11111111111111111111111111111111',
'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA',
'BPFLoaderUpgradeab1e11111111111111111111111',
'JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4',
];

test('real addresses survive a decode then encode round trip', () => {
for (const address of ADDRESSES) {
assert.equal(base58Encode(base58Decode(address)), address);
}
});

test('32 zero bytes encode to 32 ones', () => {
assert.equal(base58Encode(Buffer.alloc(32)), '1'.repeat(32));
});

test('leading zero bytes become leading ones', () => {
assert.equal(base58Encode(Buffer.from([0, 0, 1])), '112');
});

test('empty input encodes to an empty string', () => {
assert.equal(base58Encode(Buffer.alloc(0)), '');
});
Loading
Loading