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
19 changes: 19 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -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
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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 <program-id>
```

## 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
13 changes: 13 additions & 0 deletions bin/sol-program-info.js
Original file line number Diff line number Diff line change
@@ -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;
});
38 changes: 38 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -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"
}
62 changes: 62 additions & 0 deletions src/address.js
Original file line number Diff line number Diff line change
@@ -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 };
30 changes: 30 additions & 0 deletions src/args.js
Original file line number Diff line number Diff line change
@@ -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 };
73 changes: 73 additions & 0 deletions src/cli.js
Original file line number Diff line number Diff line change
@@ -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] <program-id>

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 };
65 changes: 65 additions & 0 deletions tests/address.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
31 changes: 31 additions & 0 deletions tests/args.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 { 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']);
});
Loading
Loading