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
4 changes: 3 additions & 1 deletion src/index.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
'use strict';

module.exports = {};
const { rpcCall, resolveEndpoint, hostLabel, RpcError } = require('./rpc');

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

const DEFAULT_RPC = 'https://api.mainnet-beta.solana.com';
const ENV_VAR = 'SOL_PRIORITY_FEE_RPC';
const OWN_RPC_HINT = `Use your own RPC: set ${ENV_VAR} or pass --rpc <url>.`;

class RpcError extends Error {
constructor(message, { hint, code } = {}) {
super(message);
this.name = 'RpcError';
this.hint = hint || null;
this.code = code || null;
}
}

function resolveEndpoint(rpcOption, env = process.env) {
return rpcOption || env[ENV_VAR] || DEFAULT_RPC;
}

// Only the hostname is ever shown to the user: RPC URLs often carry API keys.
function hostLabel(endpoint) {
try {
return new URL(endpoint).hostname;
} catch {
return 'the RPC endpoint';
}
}

function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}

async function rpcCall(endpoint, method, params = [], options = {}) {
const {
fetchImpl = globalThis.fetch,
retries = 2,
baseDelayMs = 500,
sleepImpl = sleep,
} = options;

const host = hostLabel(endpoint);
const body = JSON.stringify({ jsonrpc: '2.0', id: 1, method, params });

for (let attempt = 0; ; attempt++) {
let res;
try {
res = await fetchImpl(endpoint, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body,
});
} catch {
throw new RpcError(`Could not reach ${host}.`, {
hint: 'Check your connection and the RPC URL.',
code: 'network',
});
}

if (res.status === 429) {
if (attempt < retries) {
await sleepImpl(baseDelayMs * 2 ** attempt);
continue;
}
throw new RpcError(`${host} is rate-limiting requests (HTTP 429).`, {
hint: OWN_RPC_HINT,
code: 'rate-limit',
});
}

const text = await res.text();

if (!res.ok) {
const rejected = res.status === 401 || res.status === 403;
throw new RpcError(`${host} answered with HTTP ${res.status}.`, {
hint: rejected ? OWN_RPC_HINT : null,
code: 'http',
});
}

let json;
try {
json = JSON.parse(text);
} catch {
json = null;
}
if (!json || typeof json !== 'object') {
throw new RpcError(`${host} returned a response that is not valid JSON-RPC.`, {
code: 'bad-response',
});
}

if (json.error) {
const message = json.error.message || 'unknown error';
throw new RpcError(`RPC error from ${host}: ${message} (code ${json.error.code}).`, {
code: 'rpc',
});
}

return json.result;
}
}

module.exports = { rpcCall, resolveEndpoint, hostLabel, RpcError, DEFAULT_RPC, ENV_VAR };
49 changes: 49 additions & 0 deletions tests/helpers/mock-rpc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
'use strict';

function respond(status, bodyText) {
return {
ok: status >= 200 && status < 300,
status,
text: async () => bodyText,
};
}

// routes: { [method]: route }
// A route is a descriptor, a function (params, callNumber) => descriptor,
// or an array of descriptors used in order (the last one repeats).
// Descriptors: { result } | { error } | { status, text } | { text } | { throw }
function createMockRpc(routes = {}) {
const calls = [];
const counts = {};

async function mockFetch(url, init = {}) {
const body = JSON.parse(init.body);
const n = counts[body.method] || 0;
counts[body.method] = n + 1;
calls.push({ url, method: body.method, params: body.params, headers: init.headers });

let route = routes[body.method];
if (Array.isArray(route)) route = route[Math.min(n, route.length - 1)];
if (typeof route === 'function') route = route(body.params, n);

if (route === undefined) {
return respond(200, JSON.stringify({
jsonrpc: '2.0',
id: body.id,
error: { code: -32601, message: 'Method not found' },
}));
}
if (route.throw) throw route.throw;
if (route.status) return respond(route.status, route.text || '');
if (route.text !== undefined) return respond(200, route.text);
if (route.error) {
return respond(200, JSON.stringify({ jsonrpc: '2.0', id: body.id, error: route.error }));
}
return respond(200, JSON.stringify({ jsonrpc: '2.0', id: body.id, result: route.result }));
}

mockFetch.calls = calls;
return mockFetch;
}

module.exports = { createMockRpc };
97 changes: 97 additions & 0 deletions tests/rpc.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
'use strict';

const test = require('node:test');
const assert = require('node:assert/strict');
const { rpcCall, resolveEndpoint, hostLabel, DEFAULT_RPC } = require('../src/rpc');
const { createMockRpc } = require('./helpers/mock-rpc');

const ENDPOINT = 'https://rpc.example.com/secret-key-123';
const noWait = async () => {};

test('resolveEndpoint prefers --rpc, then the env var, then the public RPC', () => {
assert.equal(resolveEndpoint('https://a.test', { SOL_PRIORITY_FEE_RPC: 'https://b.test' }), 'https://a.test');
assert.equal(resolveEndpoint(null, { SOL_PRIORITY_FEE_RPC: 'https://b.test' }), 'https://b.test');
assert.equal(resolveEndpoint(null, {}), DEFAULT_RPC);
});

test('hostLabel returns only the hostname', () => {
assert.equal(hostLabel(ENDPOINT), 'rpc.example.com');
assert.equal(hostLabel('not a url'), 'the RPC endpoint');
});

test('rpcCall sends a JSON-RPC request and returns the result', async () => {
const fetchImpl = createMockRpc({ getVersion: { result: { 'solana-core': '2.0.0' } } });
const result = await rpcCall(ENDPOINT, 'getVersion', [], { fetchImpl });
assert.deepEqual(result, { 'solana-core': '2.0.0' });
assert.equal(fetchImpl.calls.length, 1);
assert.equal(fetchImpl.calls[0].method, 'getVersion');
assert.equal(fetchImpl.calls[0].url, ENDPOINT);
});

test('rpcCall retries on 429 and then succeeds', async () => {
const fetchImpl = createMockRpc({
getVersion: [{ status: 429 }, { status: 429 }, { result: 'ok' }],
});
const result = await rpcCall(ENDPOINT, 'getVersion', [], { fetchImpl, sleepImpl: noWait });
assert.equal(result, 'ok');
assert.equal(fetchImpl.calls.length, 3);
});

test('rpcCall gives a rate-limit error with a hint and never leaks the URL path', async () => {
const fetchImpl = createMockRpc({ getVersion: { status: 429 } });
await assert.rejects(
rpcCall(ENDPOINT, 'getVersion', [], { fetchImpl, sleepImpl: noWait }),
(err) => {
assert.equal(err.code, 'rate-limit');
assert.match(err.message, /rpc\.example\.com/);
assert.doesNotMatch(err.message, /secret-key-123/);
assert.match(err.hint, /SOL_PRIORITY_FEE_RPC/);
return true;
},
);
assert.equal(fetchImpl.calls.length, 3);
});

test('rpcCall reports a JSON-RPC error', async () => {
const fetchImpl = createMockRpc({ getVersion: { error: { code: -32005, message: 'Node is behind' } } });
await assert.rejects(rpcCall(ENDPOINT, 'getVersion', [], { fetchImpl }), (err) => {
assert.equal(err.code, 'rpc');
assert.match(err.message, /Node is behind/);
return true;
});
});

test('rpcCall reports a response that is not JSON', async () => {
const fetchImpl = createMockRpc({ getVersion: { text: '<html>oops</html>' } });
await assert.rejects(rpcCall(ENDPOINT, 'getVersion', [], { fetchImpl }), (err) => {
assert.equal(err.code, 'bad-response');
return true;
});
});

test('rpcCall reports a network failure without leaking the URL path', async () => {
const fetchImpl = createMockRpc({
getVersion: { throw: new Error('connect failed https://rpc.example.com/secret-key-123') },
});
await assert.rejects(rpcCall(ENDPOINT, 'getVersion', [], { fetchImpl }), (err) => {
assert.equal(err.code, 'network');
assert.doesNotMatch(err.message, /secret-key-123/);
return true;
});
});

test('rpcCall reports HTTP errors and hints on 403', async () => {
const serverError = createMockRpc({ getVersion: { status: 500 } });
await assert.rejects(rpcCall(ENDPOINT, 'getVersion', [], { fetchImpl: serverError }), (err) => {
assert.equal(err.code, 'http');
assert.match(err.message, /HTTP 500/);
assert.equal(err.hint, null);
return true;
});

const forbidden = createMockRpc({ getVersion: { status: 403 } });
await assert.rejects(rpcCall(ENDPOINT, 'getVersion', [], { fetchImpl: forbidden }), (err) => {
assert.match(err.hint, /--rpc/);
return true;
});
});
Loading