From 116d0c840c32ab35e086ec5c69b7bc09d518d333 Mon Sep 17 00:00:00 2001 From: Sybi Date: Sat, 10 Oct 2026 17:28:13 +0000 Subject: [PATCH] Scaffold CLI: address validation, args, tests and CI --- .github/workflows/test.yml | 19 ++++++++++ README.md | 35 ++++++++++++++++++ bin/sol-program-info.js | 13 +++++++ package.json | 38 ++++++++++++++++++++ src/address.js | 62 ++++++++++++++++++++++++++++++++ src/args.js | 30 ++++++++++++++++ src/cli.js | 73 ++++++++++++++++++++++++++++++++++++++ tests/address.test.js | 65 +++++++++++++++++++++++++++++++++ tests/args.test.js | 31 ++++++++++++++++ tests/cli.test.js | 64 +++++++++++++++++++++++++++++++++ 10 files changed, 430 insertions(+) create mode 100644 .github/workflows/test.yml create mode 100755 bin/sol-program-info.js create mode 100644 package.json create mode 100644 src/address.js create mode 100644 src/args.js create mode 100644 src/cli.js create mode 100644 tests/address.test.js create mode 100644 tests/args.test.js create mode 100644 tests/cli.test.js diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..453313a --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,19 @@ +name: test + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [18, 20, 22] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + - run: npm test \ No newline at end of file diff --git a/README.md b/README.md index c0faba2..a389ccf 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,37 @@ # sol-program-info + Check if a Solana program is upgradeable, who controls it, and when it was last deployed. + +Node 18+. Early scaffold: program ID validation works, the on-chain lookup is coming. + +## Usage + +```bash +npx sol-program-info +``` + +## Options + +| Option | What it does | +| --- | --- | +| `-h`, `--help` | Show help | +| `-v`, `--version` | Show the version | + +Exit codes: `0` success, `1` invalid program ID, `2` bad usage. + +## Roadmap + +- [x] Program ID validation +- [ ] Look up the program on-chain: upgradeable or immutable +- [ ] Upgrade authority, last deploy time and size +- [ ] `--json`, clear errors and a plain-English verdict + +## Development + +```bash +npm test +``` + +## License + +MIT \ No newline at end of file diff --git a/bin/sol-program-info.js b/bin/sol-program-info.js new file mode 100755 index 0000000..e7ff5b8 --- /dev/null +++ b/bin/sol-program-info.js @@ -0,0 +1,13 @@ +#!/usr/bin/env node +'use strict'; + +const { run } = require('../src/cli'); + +run(process.argv.slice(2)) + .then((code) => { + process.exitCode = code; + }) + .catch((error) => { + console.error(`Unexpected error: ${error.message}`); + process.exitCode = 1; + }); \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..33d8b7f --- /dev/null +++ b/package.json @@ -0,0 +1,38 @@ +{ + "name": "sol-program-info", + "version": "0.0.1", + "description": "Check if a Solana program is upgradeable, who controls it, and when it was last deployed.", + "bin": { + "sol-program-info": "bin/sol-program-info.js" + }, + "files": [ + "bin", + "src", + "README.md", + "CHANGELOG.md" + ], + "scripts": { + "test": "node --test" + }, + "engines": { + "node": ">=18" + }, + "keywords": [ + "solana", + "program", + "upgrade-authority", + "security", + "cli", + "web3" + ], + "author": "switch-afk", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/switch-afk/sol-program-info.git" + }, + "bugs": { + "url": "https://github.com/switch-afk/sol-program-info/issues" + }, + "homepage": "https://github.com/switch-afk/sol-program-info#readme" +} \ No newline at end of file diff --git a/src/address.js b/src/address.js new file mode 100644 index 0000000..44775d4 --- /dev/null +++ b/src/address.js @@ -0,0 +1,62 @@ +'use strict'; + +const ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; + +class AddressError extends Error { + constructor(message) { + super(message); + this.name = 'AddressError'; + } +} + +/** Decode a base58 string to bytes. Leading "1" characters become leading zero bytes. */ +function base58Decode(text) { + let n = 0n; + for (let i = 0; i < text.length; i++) { + const index = ALPHABET.indexOf(text[i]); + if (index === -1) { + throw new AddressError(`character "${text[i]}" at position ${i + 1} is not valid base58`); + } + n = n * 58n + BigInt(index); + } + + let body = Buffer.alloc(0); + if (n > 0n) { + let hex = n.toString(16); + if (hex.length % 2) hex = `0${hex}`; + body = Buffer.from(hex, 'hex'); + } + + let zeros = 0; + while (zeros < text.length && text[zeros] === '1') zeros++; + + return Buffer.concat([Buffer.alloc(zeros), body]); +} + +/** + * Check that the input is a Solana program ID: base58 that decodes to + * exactly 32 bytes. Program IDs are ordinary 32-byte public keys, so this + * is the same check as for a wallet address. Returns the trimmed ID, or + * throws AddressError. + */ +function validateProgramId(input) { + const text = typeof input === 'string' ? input.trim() : ''; + if (text === '') { + throw new AddressError('the program ID is empty'); + } + + const bytes = base58Decode(text); + + if (bytes.length === 64) { + throw new AddressError( + 'that is 64 bytes long, which looks like a transaction signature, not a program ID' + ); + } + if (bytes.length !== 32) { + throw new AddressError(`it decodes to ${bytes.length} bytes, but a program ID is 32 bytes`); + } + + return text; +} + +module.exports = { ALPHABET, AddressError, base58Decode, validateProgramId }; \ No newline at end of file diff --git a/src/args.js b/src/args.js new file mode 100644 index 0000000..f90420e --- /dev/null +++ b/src/args.js @@ -0,0 +1,30 @@ +'use strict'; + +/** + * Split command-line arguments into flags and program IDs. + * Only the first unknown flag is reported. + */ +function parseArgs(argv) { + const result = { + help: false, + version: false, + unknown: null, + programIds: [], + }; + + for (const arg of argv) { + if (arg === '-h' || arg === '--help') { + result.help = true; + } else if (arg === '-v' || arg === '--version') { + result.version = true; + } else if (arg.startsWith('-')) { + if (result.unknown === null) result.unknown = arg; + } else { + result.programIds.push(arg); + } + } + + return result; +} + +module.exports = { parseArgs }; \ No newline at end of file diff --git a/src/cli.js b/src/cli.js new file mode 100644 index 0000000..5e5890d --- /dev/null +++ b/src/cli.js @@ -0,0 +1,73 @@ +'use strict'; + +const pkg = require('../package.json'); +const { AddressError, validateProgramId } = require('./address'); +const { parseArgs } = require('./args'); + +const HELP = `sol-program-info v${pkg.version} + +Check if a Solana program is upgradeable, who controls it, and when it was +last deployed. + +Usage: + sol-program-info [options] + +Options: + -h, --help Show this help + -v, --version Show the version + +Exit codes: + 0 success + 1 invalid program ID + 2 bad usage +`; + +/** + * Run the CLI. Returns an exit code instead of exiting, so it can be tested. + */ +async function run(argv, io = {}) { + const stdout = io.stdout || process.stdout; + const stderr = io.stderr || process.stderr; + + const args = parseArgs(argv); + + if (args.help) { + stdout.write(HELP); + return 0; + } + + if (args.version) { + stdout.write(`${pkg.version}\n`); + return 0; + } + + if (args.unknown) { + stderr.write(`Unknown option: ${args.unknown}\n\n${HELP}`); + return 2; + } + + if (args.programIds.length === 0) { + stderr.write(`Expected a program ID.\n\n${HELP}`); + return 2; + } + + if (args.programIds.length > 1) { + stderr.write(`Expected one program ID, got ${args.programIds.length}.\n\n${HELP}`); + return 2; + } + + const raw = args.programIds[0]; + let programId; + try { + programId = validateProgramId(raw); + } catch (error) { + if (!(error instanceof AddressError)) throw error; + stderr.write(`Not a valid program ID (${raw}): ${error.message}\n`); + return 1; + } + + stdout.write(`${programId} is a valid program ID. Program lookup is not built yet.\n`); + return 0; +} + +module.exports = { run, HELP }; \ No newline at end of file diff --git a/tests/address.test.js b/tests/address.test.js new file mode 100644 index 0000000..cf2fb40 --- /dev/null +++ b/tests/address.test.js @@ -0,0 +1,65 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { AddressError, ALPHABET, base58Decode, validateProgramId } = require('../src/address'); + +function base58Encode(bytes) { + let n = BigInt(`0x${Buffer.from(bytes).toString('hex') || '0'}`); + let out = ''; + while (n > 0n) { + out = ALPHABET[Number(n % 58n)] + out; + n /= 58n; + } + for (const byte of bytes) { + if (byte !== 0) break; + out = `1${out}`; + } + return out; +} + +const KNOWN_PROGRAMS = [ + '11111111111111111111111111111111', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + 'BPFLoaderUpgradeab1e11111111111111111111111', + 'JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4', +]; + +test('known program IDs are accepted', () => { + for (const id of KNOWN_PROGRAMS) { + assert.equal(validateProgramId(id), id); + } +}); + +test('surrounding whitespace is trimmed', () => { + assert.equal(validateProgramId(` ${KNOWN_PROGRAMS[1]}\n`), KNOWN_PROGRAMS[1]); +}); + +test('empty and non-string input is rejected', () => { + for (const input of ['', ' ', undefined, null, 42]) { + assert.throws(() => validateProgramId(input), AddressError); + } +}); + +test('characters outside base58 are rejected with their position', () => { + assert.throws(() => validateProgramId('0000'), /character "0" at position 1/); + assert.throws(() => validateProgramId('abcO'), /character "O" at position 4/); +}); + +test('the wrong length is rejected and the real length is reported', () => { + const short = base58Encode(Buffer.alloc(16, 7)); + assert.throws(() => validateProgramId(short), /decodes to 16 bytes/); + + const long = base58Encode(Buffer.alloc(33, 7)); + assert.throws(() => validateProgramId(long), /decodes to 33 bytes/); +}); + +test('a 64-byte value is called out as a transaction signature', () => { + const signature = base58Encode(Buffer.alloc(64, 1)); + assert.throws(() => validateProgramId(signature), /transaction signature/); +}); + +test('base58Decode keeps leading zero bytes', () => { + assert.equal(base58Decode('1112').length, 4); + assert.equal(base58Decode('11111111111111111111111111111111').length, 32); +}); \ No newline at end of file diff --git a/tests/args.test.js b/tests/args.test.js new file mode 100644 index 0000000..8231854 --- /dev/null +++ b/tests/args.test.js @@ -0,0 +1,31 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { parseArgs } = require('../src/args'); + +test('no arguments gives empty defaults', () => { + assert.deepEqual(parseArgs([]), { + help: false, + version: false, + unknown: null, + programIds: [], + }); +}); + +test('help and version flags, short and long', () => { + assert.equal(parseArgs(['-h']).help, true); + assert.equal(parseArgs(['--help']).help, true); + assert.equal(parseArgs(['-v']).version, true); + assert.equal(parseArgs(['--version']).version, true); +}); + +test('positional arguments are collected as program IDs', () => { + assert.deepEqual(parseArgs(['abc', 'def']).programIds, ['abc', 'def']); +}); + +test('only the first unknown flag is reported', () => { + const result = parseArgs(['--nope', '--also-nope', 'abc']); + assert.equal(result.unknown, '--nope'); + assert.deepEqual(result.programIds, ['abc']); +}); \ No newline at end of file diff --git a/tests/cli.test.js b/tests/cli.test.js new file mode 100644 index 0000000..bf7144c --- /dev/null +++ b/tests/cli.test.js @@ -0,0 +1,64 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const pkg = require('../package.json'); +const { run } = require('../src/cli'); + +const PROGRAM = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'; + +function capture() { + const out = []; + const err = []; + return { + io: { + stdout: { write: (text) => out.push(text) }, + stderr: { write: (text) => err.push(text) }, + }, + stdout: () => out.join(''), + stderr: () => err.join(''), + }; +} + +test('--help prints usage and exits 0', async () => { + const c = capture(); + assert.equal(await run(['--help'], c.io), 0); + assert.match(c.stdout(), /Usage:/); + assert.equal(c.stderr(), ''); +}); + +test('--version prints the package version', async () => { + const c = capture(); + assert.equal(await run(['--version'], c.io), 0); + assert.equal(c.stdout(), `${pkg.version}\n`); +}); + +test('no program ID exits 2', async () => { + const c = capture(); + assert.equal(await run([], c.io), 2); + assert.match(c.stderr(), /Expected a program ID/); +}); + +test('an unknown option exits 2', async () => { + const c = capture(); + assert.equal(await run(['--nope', PROGRAM], c.io), 2); + assert.match(c.stderr(), /Unknown option: --nope/); +}); + +test('more than one program ID exits 2', async () => { + const c = capture(); + assert.equal(await run([PROGRAM, PROGRAM], c.io), 2); + assert.match(c.stderr(), /Expected one program ID, got 2/); +}); + +test('an invalid program ID exits 1 and says why', 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\)/); +}); + +test('a valid program ID exits 0', async () => { + const c = capture(); + assert.equal(await run([PROGRAM], c.io), 0); + assert.match(c.stdout(), /is a valid program ID/); +}); \ No newline at end of file