From 90ef244889b4b731c985d60e762cf6641c7266d5 Mon Sep 17 00:00:00 2001 From: Sybi Date: Sat, 10 Oct 2026 17:46:59 +0000 Subject: [PATCH] Add program lookup: loader detection and ProgramData address --- README.md | 38 +++++++++- src/base58.js | 26 +++++++ src/cli.js | 53 +++++++++++++- src/format.js | 62 +++++++++++++++++ src/loaders.js | 54 +++++++++++++++ src/program.js | 65 ++++++++++++++++++ tests/base58.test.js | 31 +++++++++ tests/cli.test.js | 155 ++++++++++++++++++++++++++++++++++++++--- tests/format.test.js | 81 ++++++++++++++++++++++ tests/program.test.js | 156 ++++++++++++++++++++++++++++++++++++++++++ 10 files changed, 706 insertions(+), 15 deletions(-) create mode 100644 src/base58.js create mode 100644 src/format.js create mode 100644 src/loaders.js create mode 100644 src/program.js create mode 100644 tests/base58.test.js create mode 100644 tests/format.test.js create mode 100644 tests/program.test.js diff --git a/README.md b/README.md index b640a4a..b1e01b6 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 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 @@ -10,6 +10,26 @@ Node 18+. Early scaffold: program ID validation and the RPC connection work, the npx sol-program-info ``` +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 | @@ -17,7 +37,17 @@ npx sol-program-info | `-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 @@ -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. @@ -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 diff --git a/src/base58.js b/src/base58.js new file mode 100644 index 0000000..6b4443e --- /dev/null +++ b/src/base58.js @@ -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 }; \ No newline at end of file diff --git a/src/cli.js b/src/cli.js index 9edbdb9..2cb4505 100644 --- a/src/cli.js +++ b/src/cli.js @@ -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} @@ -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; @@ -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; } diff --git a/src/format.js b/src/format.js new file mode 100644 index 0000000..2aafeb2 --- /dev/null +++ b/src/format.js @@ -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 }; \ No newline at end of file diff --git a/src/loaders.js b/src/loaders.js new file mode 100644 index 0000000..5b8ab5e --- /dev/null +++ b/src/loaders.js @@ -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, +}; \ No newline at end of file diff --git a/src/program.js b/src/program.js new file mode 100644 index 0000000..0c51911 --- /dev/null +++ b/src/program.js @@ -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 }; \ No newline at end of file diff --git a/tests/base58.test.js b/tests/base58.test.js new file mode 100644 index 0000000..a25c7d1 --- /dev/null +++ b/tests/base58.test.js @@ -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)), ''); +}); \ No newline at end of file diff --git a/tests/cli.test.js b/tests/cli.test.js index dfdaa09..c09b1bd 100644 --- a/tests/cli.test.js +++ b/tests/cli.test.js @@ -3,29 +3,63 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const pkg = require('../package.json'); +const { base58Decode } = require('../src/address'); const { run } = require('../src/cli'); +const { createMockFetch, http, ok } = require('./helpers/mock-rpc'); const PROGRAM = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'; +const PROGRAM_DATA = 'JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4'; +const LOADER_V2 = 'BPFLoader2111111111111111111111111111111111'; +const UPGRADEABLE = 'BPFLoaderUpgradeab1e11111111111111111111111'; +const SYSTEM = '11111111111111111111111111111111'; -function capture(env = {}) { +function programData(address) { + const buffer = Buffer.alloc(36); + buffer.writeUInt32LE(2, 0); + base58Decode(address).copy(buffer, 4); + return buffer.toString('base64'); +} + +function account(owner, { executable = true, data = '' } = {}) { + return { + lamports: 1, + owner, + executable, + rentEpoch: 0, + space: 36, + data: [data, 'base64'], + }; +} + +function reply(value) { + return ok({ context: { slot: 1 }, value }); +} + +/** Every test gets a mock fetch, so nothing here can reach the network. */ +function capture({ env = {}, steps = [reply(account(LOADER_V2))], rpc = {} } = {}) { const out = []; const err = []; + const fetch = createMockFetch(steps); return { + fetch, io: { stdout: { write: (text) => out.push(text) }, stderr: { write: (text) => err.push(text) }, env, + rpcOptions: { fetch, sleep: async () => {}, random: () => 0.5, ...rpc }, }, stdout: () => out.join(''), stderr: () => err.join(''), }; } -test('--help prints usage and exits 0', async () => { +test('--help prints usage, 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(), /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/); assert.equal(c.stderr(), ''); }); @@ -53,30 +87,131 @@ test('more than one program ID exits 2', async () => { assert.match(c.stderr(), /Expected one program ID, got 2/); }); -test('an invalid program ID exits 1 and says why', async () => { +test('an invalid program ID exits 1 and never calls the RPC', async () => { const c = capture(); assert.equal(await run(['not-a-program'], c.io), 1); assert.match(c.stderr(), /Not a valid program ID \(not-a-program\)/); + assert.equal(c.fetch.calls.length, 0); +}); + +test('a bad endpoint setting exits 1 without echoing the value', async () => { + const c = capture({ env: { SOL_PROGRAM_INFO_RPC: 'wss://rpc.example.com/?key=secret123' } }); + assert.equal(await run([PROGRAM], c.io), 1); + assert.match(c.stderr(), /SOL_PROGRAM_INFO_RPC must start with http/); + assert.equal(c.stderr().includes('secret123'), false); + assert.equal(c.fetch.calls.length, 0); }); -test('a valid program ID exits 0 and names the public RPC host', async () => { +test('a legacy program prints its details to stdout and exits 0', async () => { const c = capture(); + assert.equal(await run([PROGRAM], c.io), 0); - assert.match(c.stdout(), /is a valid program ID/); + + assert.match(c.stdout(), /^Program: +TokenkegQfe/m); + 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 its ProgramData address', async () => { + const c = capture({ + steps: [reply(account(UPGRADEABLE, { data: programData(PROGRAM_DATA) }))], + }); + + assert.equal(await run([PROGRAM], c.io), 0); + + assert.match(c.stdout(), /^ProgramData: +JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4$/m); }); test('your own endpoint shows only its hostname, never the key', async () => { - const c = capture({ SOL_PROGRAM_INFO_RPC: 'https://rpc.example.com/v2/secret123?key=secret123' }); + const c = capture({ + env: { SOL_PROGRAM_INFO_RPC: 'https://rpc.example.com/v2/secret123?key=secret123' }, + }); + assert.equal(await run([PROGRAM], c.io), 0); + assert.match(c.stderr(), /Using rpc\.example\.com\./); assert.equal(c.stderr().includes('secret123'), false); assert.equal(c.stdout().includes('secret123'), false); + assert.match(c.fetch.calls[0].url, /secret123/); }); -test('a bad endpoint setting exits 1 without echoing the value', async () => { - const c = capture({ SOL_PROGRAM_INFO_RPC: 'wss://rpc.example.com/?key=secret123' }); - assert.equal(await run([PROGRAM], c.io), 1); - assert.match(c.stderr(), /SOL_PROGRAM_INFO_RPC must start with http/); +test('a missing account exits 4 with a clear message on stderr', async () => { + const c = capture({ steps: [reply(null)] }); + + assert.equal(await run([PROGRAM], c.io), 4); + + assert.match(c.stderr(), /No account found at TokenkegQfe/); + assert.equal(c.stdout(), ''); +}); + +test('a wallet address exits 4 and says it is a wallet', async () => { + const c = capture({ steps: [reply(account(SYSTEM, { executable: false }))] }); + + assert.equal(await run([PROGRAM], c.io), 4); + + assert.match(c.stderr(), /is a wallet/); + assert.equal(c.stdout(), ''); +}); + +test('unreadable upgradeable data exits 3', async () => { + const c = capture({ steps: [reply(account(UPGRADEABLE, { data: '' }))] }); + + assert.equal(await run([PROGRAM], c.io), 3); + + assert.match(c.stderr(), /not in the expected format/); +}); + +test('a rate limit is retried, with one notice and one hint', async () => { + const c = capture({ steps: [http(429), http(429), reply(account(LOADER_V2))] }); + + assert.equal(await run([PROGRAM], c.io), 0); + + const text = c.stderr(); + assert.equal(text.split('Retrying in').length - 1, 2); + assert.equal(text.split('rate-limits heavily').length - 1, 1); + assert.match(text, /SOL_PROGRAM_INFO_RPC/); + assert.match(c.stdout(), /BPF Loader v2/); +}); + +test('a steady rate limit exits 3 after the allowed attempts', async () => { + const c = capture({ steps: [http(429)], rpc: { attempts: 2 } }); + + assert.equal(await run([PROGRAM], c.io), 3); + + assert.match(c.stderr(), /Could not look up the program on api\.mainnet-beta\.solana\.com/); + assert.match(c.stderr(), /rate limiting/); + assert.equal(c.stderr().split('rate-limits heavily').length - 1, 1); + assert.equal(c.fetch.calls.length, 2); + 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' }, + steps: [http(429)], + rpc: { attempts: 1 }, + }); + + assert.equal(await run([PROGRAM], c.io), 3); + + assert.match(c.stderr(), /plan's limits/); + assert.equal(c.stderr().includes('rate-limits heavily'), false); + assert.equal(c.stderr().includes('secret123'), false); +}); + +test('a rejected API key exits 3 at once, with no retries and no key leak', async () => { + const c = capture({ + env: { SOL_PROGRAM_INFO_RPC: 'https://rpc.example.com/?key=secret123' }, + steps: [http(401)], + }); + + assert.equal(await run([PROGRAM], c.io), 3); + + assert.match(c.stderr(), /HTTP 401/); + assert.match(c.stderr(), /API key/); + assert.equal(c.stderr().includes('Retrying'), false); assert.equal(c.stderr().includes('secret123'), false); + assert.equal(c.fetch.calls.length, 1); }); \ No newline at end of file diff --git a/tests/format.test.js b/tests/format.test.js new file mode 100644 index 0000000..391feb5 --- /dev/null +++ b/tests/format.test.js @@ -0,0 +1,81 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { explainProblem, formatProgram } = require('../src/format'); + +const PROGRAM = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'; +const PROGRAM_DATA = 'JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4'; +const LOADER_V2 = 'BPFLoader2111111111111111111111111111111111'; +const UPGRADEABLE = 'BPFLoaderUpgradeab1e11111111111111111111111'; +const NATIVE = 'NativeLoader1111111111111111111111111111111'; +const SYSTEM = '11111111111111111111111111111111'; + +function info(extra) { + return { programId: PROGRAM, owner: null, programDataAddress: null, ...extra }; +} + +test('a loader v2 program says it can never be upgraded', () => { + const text = formatProgram(info({ kind: 'loader-v2', owner: LOADER_V2 })); + + assert.match(text, /^Program: +TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA$/m); + assert.match(text, /^Type: +program$/m); + assert.match(text, /^Loader: +BPF Loader v2 \(BPFLoader2/m); + assert.match(text, /^Upgrades: +never/m); + assert.equal(/ProgramData/.test(text), false); + assert.ok(text.endsWith('\n')); +}); + +test('an upgradeable program shows its ProgramData address', () => { + const text = formatProgram( + info({ kind: 'upgradeable', owner: UPGRADEABLE, programDataAddress: PROGRAM_DATA }) + ); + + assert.match(text, /^ProgramData: +JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4$/m); + assert.match(text, /^Loader: +Upgradeable BPF Loader/m); + 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 })); + + 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 })); + + assert.match(text, /^Owner: +JUP6Lkb.* \(loader not recognized\)$/m); + assert.match(text, /^Upgrades: +unknown/m); +}); + +test('not-found points at the program ID and the network', () => { + const text = explainProblem(info({ kind: 'not-found' }), 'rpc.example.com'); + + assert.match(text, /No account found at TokenkegQfe/); + assert.match(text, /on rpc\.example\.com/); + assert.match(text, /right network/); +}); + +test('not-a-program explains what the address actually is', () => { + const wallet = explainProblem(info({ kind: 'not-a-program', owner: SYSTEM }), 'h'); + assert.match(wallet, /is a wallet/); + + const data = explainProblem(info({ kind: 'not-a-program', owner: UPGRADEABLE }), 'h'); + assert.match(data, /program data or buffer account/); + assert.match(data, /program's own ID/); + + const other = explainProblem(info({ kind: 'not-a-program', owner: PROGRAM }), 'h'); + assert.match(other, /not an executable program/); + assert.match(other, /owned by TokenkegQfe/); +}); + +test('unreadable says the data was not in the expected format', () => { + const text = explainProblem(info({ kind: 'unreadable', owner: UPGRADEABLE }), 'h'); + assert.match(text, /not in the expected format/); +}); + +test('kinds with nothing to explain give an empty string', () => { + assert.equal(explainProblem(info({ kind: 'loader-v2' }), 'h'), ''); +}); \ No newline at end of file diff --git a/tests/program.test.js b/tests/program.test.js new file mode 100644 index 0000000..4ec31aa --- /dev/null +++ b/tests/program.test.js @@ -0,0 +1,156 @@ +'use strict'; + +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 PROGRAM = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'; +const PROGRAM_DATA = 'JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4'; +const NATIVE = 'NativeLoader1111111111111111111111111111111'; +const LOADER_V1 = 'BPFLoader1111111111111111111111111111111111'; +const LOADER_V2 = 'BPFLoader2111111111111111111111111111111111'; +const UPGRADEABLE = 'BPFLoaderUpgradeab1e11111111111111111111111'; +const LOADER_V4 = 'LoaderV411111111111111111111111111111111111'; +const SYSTEM = '11111111111111111111111111111111'; + +/** base64 of a 36 byte Program account slice: variant, then an address. */ +function programData(variant, address) { + const buffer = Buffer.alloc(36); + buffer.writeUInt32LE(variant, 0); + base58Decode(address).copy(buffer, 4); + return buffer.toString('base64'); +} + +function account(owner, { executable = true, data = '' } = {}) { + return { + lamports: 1, + owner, + executable, + rentEpoch: 0, + space: 36, + data: [data, 'base64'], + }; +} + +function reply(value) { + return ok({ context: { slot: 1 }, value }); +} + +function setup(steps) { + const fetch = createMockFetch(steps); + const rpc = createRpcClient({ + url: 'https://rpc.example.com/?key=secret123', + fetch, + sleep: async () => {}, + attempts: 1, + }); + return { fetch, lookup: (id = PROGRAM) => lookupProgram(rpc, id) }; +} + +test('asks for only the first 36 bytes as base64', async () => { + const { fetch, lookup } = setup([reply(account(LOADER_V2))]); + + await lookup(); + + assert.equal(fetch.calls.length, 1); + const { method, params } = fetch.calls[0].request; + assert.equal(method, 'getAccountInfo'); + assert.deepEqual(params, [ + PROGRAM, + { encoding: 'base64', dataSlice: { offset: 0, length: 36 }, commitment: 'confirmed' }, + ]); + assert.deepEqual(SLICE, { offset: 0, length: 36 }); +}); + +test('an upgradeable program reports its ProgramData address', async () => { + const { lookup } = setup([reply(account(UPGRADEABLE, { data: programData(2, PROGRAM_DATA) }))]); + + const info = await lookup(); + + assert.deepEqual(info, { + programId: PROGRAM, + kind: 'upgradeable', + owner: UPGRADEABLE, + programDataAddress: PROGRAM_DATA, + }); +}); + +test('older loaders and native programs are recognized by owner', async () => { + const cases = [ + [LOADER_V1, 'loader-v1'], + [LOADER_V2, 'loader-v2'], + [LOADER_V4, 'loader-v4'], + [NATIVE, 'native'], + ]; + + for (const [owner, kind] of cases) { + const { lookup } = setup([reply(account(owner))]); + const info = await lookup(); + assert.equal(info.kind, kind); + assert.equal(info.owner, owner); + assert.equal(info.programDataAddress, null); + } +}); + +test('an executable account with an unknown owner is reported as such', async () => { + const { lookup } = setup([reply(account(PROGRAM_DATA))]); + + const info = await lookup(); + + assert.equal(info.kind, 'unknown-loader'); + assert.equal(info.owner, PROGRAM_DATA); +}); + +test('a missing account is not-found', async () => { + const { lookup } = setup([reply(null)]); + + const info = await lookup(); + + assert.equal(info.kind, 'not-found'); + assert.equal(info.owner, null); +}); + +test('an account that is not executable is not-a-program', async () => { + const { lookup } = setup([reply(account(SYSTEM, { executable: false }))]); + + const info = await lookup(); + + assert.equal(info.kind, 'not-a-program'); + assert.equal(info.owner, SYSTEM); +}); + +test('upgradeable loader data in the wrong shape is unreadable', async () => { + const wrongVariant = programData(3, PROGRAM_DATA); + const tooShort = Buffer.from([2, 0, 0, 0]).toString('base64'); + + for (const data of [wrongVariant, tooShort, '']) { + const { lookup } = setup([reply(account(UPGRADEABLE, { data }))]); + const info = await lookup(); + assert.equal(info.kind, 'unreadable'); + assert.equal(info.programDataAddress, null); + } +}); + +test('an answer without the expected shape is a bad response', async () => { + for (const step of [ok(null), ok({})]) { + const { lookup } = setup([step]); + await assert.rejects(lookup(), (error) => { + assert.ok(error instanceof RpcError); + assert.equal(error.kind, 'bad-response'); + return true; + }); + } +}); + +test('RPC failures pass through as RpcError', async () => { + const { lookup } = setup([http(429)]); + + await assert.rejects(lookup(), (error) => { + assert.ok(error instanceof RpcError); + assert.equal(error.rateLimited, true); + return true; + }); +}); \ No newline at end of file