diff --git a/src/index.js b/src/index.js index 41952db..055ea0e 100644 --- a/src/index.js +++ b/src/index.js @@ -1,3 +1,5 @@ 'use strict'; -module.exports = {}; \ No newline at end of file +const { rpcCall, resolveEndpoint, hostLabel, RpcError } = require('./rpc'); + +module.exports = { rpcCall, resolveEndpoint, hostLabel, RpcError }; \ No newline at end of file diff --git a/src/rpc.js b/src/rpc.js new file mode 100644 index 0000000..4f2ac68 --- /dev/null +++ b/src/rpc.js @@ -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 .`; + +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 }; \ No newline at end of file diff --git a/tests/helpers/mock-rpc.js b/tests/helpers/mock-rpc.js new file mode 100644 index 0000000..0e20a94 --- /dev/null +++ b/tests/helpers/mock-rpc.js @@ -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 }; \ No newline at end of file diff --git a/tests/rpc.test.js b/tests/rpc.test.js new file mode 100644 index 0000000..e06f532 --- /dev/null +++ b/tests/rpc.test.js @@ -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: 'oops' } }); + 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; + }); +}); \ No newline at end of file