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: 16 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 works, the on-chain lookup is coming.
Node 18+. Early scaffold: program ID validation and the RPC connection work, the on-chain lookup is coming.

## Usage

Expand All @@ -17,14 +17,27 @@ npx sol-program-info <program-id>
| `-h`, `--help` | Show help |
| `-v`, `--version` | Show the version |

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

## Use your own RPC

Public RPCs rate-limit heavily. Set your own endpoint:

```bash
export SOL_PROGRAM_INFO_RPC="https://your-rpc-endpoint"
```

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.

## Roadmap

- [x] Program ID validation
- [x] RPC client with retries and clear errors
- [ ] Look up the program on-chain: upgradeable or immutable
- [ ] Upgrade authority, last deploy time and size
- [ ] `--json`, clear errors and a plain-English verdict
- [ ] `--json` and a plain-English verdict

## Development

Expand Down
21 changes: 20 additions & 1 deletion src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
const pkg = require('../package.json');
const { AddressError, validateProgramId } = require('./address');
const { parseArgs } = require('./args');
const { EndpointError, RPC_ENV, resolveEndpoint } = require('./endpoint');

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

Expand All @@ -16,9 +17,16 @@ Options:
-h, --help Show this help
-v, --version Show the version

Environment:
${RPC_ENV} Your own RPC endpoint (recommended; public RPCs
rate-limit heavily)

Output:
Results go to stdout, status messages go to stderr.

Exit codes:
0 success
1 invalid program ID
1 invalid program ID or RPC endpoint setting
2 bad usage
`;

Expand All @@ -28,6 +36,7 @@ Exit codes:
async function run(argv, io = {}) {
const stdout = io.stdout || process.stdout;
const stderr = io.stderr || process.stderr;
const env = io.env || process.env;

const args = parseArgs(argv);

Expand Down Expand Up @@ -66,6 +75,16 @@ async function run(argv, io = {}) {
return 1;
}

let endpoint;
try {
endpoint = resolveEndpoint(env);
} catch (error) {
if (!(error instanceof EndpointError)) throw error;
stderr.write(`${error.message}\n`);
return 1;
}

stderr.write(`Using ${endpoint.host}.\n`);
stdout.write(`${programId} is a valid program ID. Program lookup is not built yet.\n`);
return 0;
}
Expand Down
75 changes: 75 additions & 0 deletions src/endpoint.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
'use strict';

const DEFAULT_RPC = 'https://api.mainnet-beta.solana.com';
const RPC_ENV = 'SOL_PROGRAM_INFO_RPC';
const PUBLIC_HOSTS = new Set([
'api.mainnet-beta.solana.com',
'api.devnet.solana.com',
'api.testnet.solana.com',
]);

class EndpointError extends Error {
constructor(message) {
super(message);
this.name = 'EndpointError';
}
}

/**
* Only the hostname is ever printed. RPC URLs often carry an API key
* in the path or query string, so the full URL never reaches the output.
*/
function hostLabel(url) {
try {
return new URL(url).hostname;
} catch {
return 'the endpoint';
}
}

/**
* Pick the RPC endpoint: SOL_PROGRAM_INFO_RPC if set, else the public one.
* Error messages never include the value, because it may contain an API key.
* isPublic is true for Solana's free public endpoints, which rate-limit heavily.
*/
function resolveEndpoint(env = process.env) {
const value = typeof env[RPC_ENV] === 'string' ? env[RPC_ENV].trim() : '';

if (value === '') {
return { rpcUrl: DEFAULT_RPC, host: hostLabel(DEFAULT_RPC), isPublic: true };
}

let parsed;
try {
parsed = new URL(value);
} catch {
throw new EndpointError(`${RPC_ENV} is not a valid URL.`);
}

if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
throw new EndpointError(`${RPC_ENV} must start with http:// or https://.`);
}

return {
rpcUrl: parsed.toString(),
host: parsed.hostname,
isPublic: PUBLIC_HOSTS.has(parsed.hostname),
};
}

/** One-line advice for when the endpoint is throttling us. */
function rateLimitHint(endpoint) {
if (endpoint.isPublic) {
return `The public RPC rate-limits heavily. Set ${RPC_ENV} to your own endpoint to avoid this.`;
}
return "Your RPC endpoint is rate limiting requests. Wait a moment and check your plan's limits.";
}

module.exports = {
DEFAULT_RPC,
EndpointError,
RPC_ENV,
hostLabel,
rateLimitHint,
resolveEndpoint,
};
193 changes: 193 additions & 0 deletions src/rpc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
'use strict';

const DEFAULT_OPTIONS = {
attempts: 4,
initialDelayMs: 500,
maxDelayMs: 8000,
timeoutMs: 15000,
};

const RETRYABLE = new Set(['rate-limited', 'server', 'network', 'timeout']);

/**
* Something went wrong talking to the RPC. `kind` is one of:
* rate-limited, auth, not-found, server, network, timeout, rpc, bad-response.
* The message is plain English and never contains the endpoint URL.
*/
class RpcError extends Error {
constructor(message, kind) {
super(message);
this.name = 'RpcError';
this.kind = kind;
this.rateLimited = kind === 'rate-limited';
this.retryable = RETRYABLE.has(kind);
this.retryAfterMs = null;
}
}

const sleepFor = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

/** Exponential backoff with +/-25% jitter, never above maxDelayMs. */
function backoffDelay(attempt, options = {}, random = Math.random) {
const { initialDelayMs, maxDelayMs } = { ...DEFAULT_OPTIONS, ...options };
const base = Math.min(maxDelayMs, initialDelayMs * 2 ** attempt);
return Math.min(maxDelayMs, Math.round(base * (0.75 + random() * 0.5)));
}

/** A Retry-After header is seconds or an HTTP date. Returns milliseconds, or null. */
function parseRetryAfter(value, now = Date.now()) {
if (value === null || value === undefined) return null;
const text = String(value).trim();
if (/^\d+$/.test(text)) return Number(text) * 1000;
const date = Date.parse(text);
if (Number.isNaN(date)) return null;
return Math.max(0, date - now);
}

function looksRateLimited(code, message) {
return code === 429 || /rate.?limit|too many requests/i.test(String(message || ''));
}

/** Turn a thrown fetch error into a plain-English RpcError. */
function classifyNetworkError(error) {
if (error && error.name === 'AbortError') {
return new RpcError('the endpoint did not answer in time', 'timeout');
}

const code = (error && error.cause && error.cause.code) || (error && error.code) || '';

if (code === 'ENOTFOUND' || code === 'EAI_AGAIN') {
return new RpcError('could not find the endpoint host (DNS lookup failed)', 'network');
}
if (code === 'ECONNREFUSED') {
return new RpcError('the endpoint refused the connection', 'network');
}
if (code === 'ETIMEDOUT' || code === 'UND_ERR_CONNECT_TIMEOUT' || code === 'UND_ERR_HEADERS_TIMEOUT') {
return new RpcError('the connection timed out', 'timeout');
}
if (code === 'ECONNRESET' || code === 'UND_ERR_SOCKET') {
return new RpcError('the connection was reset', 'network');
}
return new RpcError('could not reach the endpoint', 'network');
}

/** Turn an HTTP response and its body text into a result, or throw an RpcError. */
function interpret(response, text) {
const status = response.status;

if (status === 429) {
const error = new RpcError('the endpoint is rate limiting requests (HTTP 429)', 'rate-limited');
error.retryAfterMs = parseRetryAfter(response.headers.get('retry-after'));
throw error;
}
if (status === 401 || status === 403) {
throw new RpcError(
`the endpoint rejected the request (HTTP ${status}); check the API key in your endpoint URL`,
'auth'
);
}
if (status === 404) {
throw new RpcError('the endpoint was not found (HTTP 404); check the URL', 'not-found');
}
if (status >= 500) {
throw new RpcError(`the endpoint had a server error (HTTP ${status})`, 'server');
}
if (status < 200 || status >= 300) {
throw new RpcError(`the endpoint answered with HTTP ${status}`, 'bad-response');
}

let body;
try {
body = JSON.parse(text);
} catch {
throw new RpcError('the endpoint did not return JSON', 'bad-response');
}
if (!body || typeof body !== 'object') {
throw new RpcError('the endpoint did not return JSON', 'bad-response');
}

if (body.error) {
const message = typeof body.error.message === 'string' ? body.error.message : 'unknown error';
if (looksRateLimited(body.error.code, message)) {
throw new RpcError('the endpoint is rate limiting requests', 'rate-limited');
}
throw new RpcError(message, 'rpc');
}

if (!('result' in body)) {
throw new RpcError('the endpoint answer had no result', 'bad-response');
}
return body.result;
}

/**
* A small JSON-RPC client over fetch.
*
* call(method, params) resolves to the result, or rejects with an RpcError.
* Rate limits, server errors, network errors and timeouts are retried with
* backoff (a Retry-After header is respected); everything else fails at once.
* onRetry({ attempt, delayMs, error }) is called before each wait.
*/
function createRpcClient(options) {
const settings = { ...DEFAULT_OPTIONS, ...options };
const { url, onRetry = () => {} } = settings;
const fetchImpl = settings.fetch || globalThis.fetch;
const sleep = settings.sleep || sleepFor;
const random = settings.random || Math.random;
let nextId = 1;

async function once(method, params) {
const id = nextId;
nextId += 1;

const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), settings.timeoutMs);

try {
let response;
let text;
try {
response = await fetchImpl(url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id, method, params }),
signal: controller.signal,
});
text = await response.text();
} catch (error) {
throw classifyNetworkError(error);
}
return interpret(response, text);
} finally {
clearTimeout(timer);
}
}

async function call(method, params = []) {
for (let attempt = 0; ; attempt += 1) {
try {
return await once(method, params);
} catch (error) {
const isLast = attempt >= settings.attempts - 1;
if (!(error instanceof RpcError) || !error.retryable || isLast) throw error;

let delayMs = backoffDelay(attempt, settings, random);
if (error.retryAfterMs !== null) {
delayMs = Math.min(settings.maxDelayMs, Math.max(delayMs, error.retryAfterMs));
}
onRetry({ attempt: attempt + 1, delayMs, error });
await sleep(delayMs);
}
}
}

return { call };
}

module.exports = {
DEFAULT_OPTIONS,
RpcError,
backoffDelay,
createRpcClient,
parseRetryAfter,
};
Loading
Loading