From 616354763b632a9194aaa73a7df9adfd41b13a07 Mon Sep 17 00:00:00 2001 From: Loris Leiva Date: Mon, 2 Sep 2024 16:38:58 +0100 Subject: [PATCH] Add `createHttpTransportForSolanaRpc` function --- .changeset/forty-ravens-train.md | 5 ++ packages/rpc-transport-http/README.md | 8 ++ .../http-transport-for-solana-rpc-test.ts | 86 +++++++++++++++++++ .../src/http-transport-for-solana-rpc.ts | 24 ++++++ packages/rpc-transport-http/src/index.ts | 1 + 5 files changed, 124 insertions(+) create mode 100644 .changeset/forty-ravens-train.md create mode 100644 packages/rpc-transport-http/src/__tests__/http-transport-for-solana-rpc-test.ts create mode 100644 packages/rpc-transport-http/src/http-transport-for-solana-rpc.ts diff --git a/.changeset/forty-ravens-train.md b/.changeset/forty-ravens-train.md new file mode 100644 index 000000000000..613752a33d22 --- /dev/null +++ b/.changeset/forty-ravens-train.md @@ -0,0 +1,5 @@ +--- +'@solana/rpc-transport-http': patch +--- + +Add new `createHttpTransportForSolanaRpc` function that creates a new HTTP transport specific to the Solana RPC API. This transport uses custom JSON parsing and stringifying strategies on both the request and response of Solana RPC API requests in order to prevents loss of precision for large integers. diff --git a/packages/rpc-transport-http/README.md b/packages/rpc-transport-http/README.md index 01797306e94e..43252c02f7c0 100644 --- a/packages/rpc-transport-http/README.md +++ b/packages/rpc-transport-http/README.md @@ -110,6 +110,14 @@ An optional function that takes the request payload and converts it to a JSON st A string representing the target endpoint. In Node, it must be an absolute URL using the `http` or `https` protocol. +### `createHttpTransportForSolanaRpc()` + +Creates an `RpcTransport` that uses JSON HTTP requests — much like the `createHttpTransport` function — except that it also uses custom `toJson` and `fromJson` functions in order to allow `bigint` values to be serialized and deserialized correctly over the wire. + +Since this is something specific to the Solana RPC API, these custom JSON functions are only triggered when the request is recognized as a Solana RPC request. Normal RPC APIs should aim to wrap their `bigint` values — e.g. `u64` or `i64` — in special value objects that represent the number as a string to avoid numerical values going above `Number.MAX_SAFE_INTEGER`. + +It has the same configuration options as `createHttpTransport`, but without the `fromJson` and `toJson` options. + ## Augmenting Transports Using this core transport, you can implement specialized functionality for leveraging multiple transports, attempting/handling retries, and more. diff --git a/packages/rpc-transport-http/src/__tests__/http-transport-for-solana-rpc-test.ts b/packages/rpc-transport-http/src/__tests__/http-transport-for-solana-rpc-test.ts new file mode 100644 index 000000000000..b4ad7997a968 --- /dev/null +++ b/packages/rpc-transport-http/src/__tests__/http-transport-for-solana-rpc-test.ts @@ -0,0 +1,86 @@ +import { RpcTransport } from '@solana/rpc-spec'; + +const MAX_SAFE_INTEGER = BigInt(Number.MAX_SAFE_INTEGER); +const MAX_SAFE_INTEGER_PLUS_ONE = BigInt(Number.MAX_SAFE_INTEGER) + 1n; + +describe('createHttpTransportForSolanaRpc', () => { + let fetchSpy: jest.SpyInstance; + let makeHttpRequest: RpcTransport; + beforeEach(async () => { + await jest.isolateModulesAsync(async () => { + fetchSpy = jest.spyOn(globalThis, 'fetch'); + const { createHttpTransportForSolanaRpc } = + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + await import('../http-transport-for-solana-rpc'); + makeHttpRequest = createHttpTransportForSolanaRpc({ url: 'http://localhost' }); + }); + }); + describe('when the request is from the Solana RPC API', () => { + it('passes all bigints as large numerical values in the request body', async () => { + expect.assertions(1); + fetchSpy.mockResolvedValue({ ok: true, text: () => `{"ok":true}` }); + await makeHttpRequest({ + methodName: 'getBalance', + params: { + numbersInString: 'He said: "1, 2, 3, Soleil!"', + safeNumber: MAX_SAFE_INTEGER, + unsafeNumber: MAX_SAFE_INTEGER_PLUS_ONE, + }, + }); + expect(fetchSpy).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + body: expect.stringContaining( + `"params":{` + + `"numbersInString":"He said: \\"1, 2, 3, Soleil!\\"",` + + `"safeNumber":${MAX_SAFE_INTEGER},` + + `"unsafeNumber":${MAX_SAFE_INTEGER_PLUS_ONE}}`, + ), + }), + ); + }); + it('gets all integers as bigints within the response', async () => { + expect.assertions(1); + fetchSpy.mockResolvedValue({ + ok: true, + text: () => + `{"safeNumber": ${MAX_SAFE_INTEGER}, ` + + `"unsafeNumber": ${MAX_SAFE_INTEGER_PLUS_ONE}, ` + + `"numbersInString": "He said: \\"1, 2, 3, Soleil!\\""}`, + }); + const requestPromise = makeHttpRequest({ methodName: 'getBalance', params: ['1234..5678'] }); + await expect(requestPromise).resolves.toStrictEqual({ + numbersInString: 'He said: "1, 2, 3, Soleil!"', + safeNumber: MAX_SAFE_INTEGER, + unsafeNumber: MAX_SAFE_INTEGER_PLUS_ONE, + }); + }); + }); + describe('when the request is not from the Solana RPC API', () => { + it('fails to stringify bigints in requests', async () => { + expect.assertions(1); + const promise = makeHttpRequest({ + methodName: 'getAssetsByOwner', + params: [MAX_SAFE_INTEGER_PLUS_ONE], + }); + await expect(promise).rejects.toThrow(new TypeError('Do not know how to serialize a BigInt')); + }); + it('downcasts bigints to numbers in responses', async () => { + expect.assertions(1); + fetchSpy.mockResolvedValue({ + ok: true, + text: () => + `{"safeNumber": ${MAX_SAFE_INTEGER}, ` + + `"unsafeNumber": ${MAX_SAFE_INTEGER_PLUS_ONE}, ` + + `"numbersInString": "He said: \\"1, 2, 3, Soleil!\\""}`, + }); + const requestPromise = makeHttpRequest({ methodName: 'getAssetsByOwner', params: ['1234..5678'] }); + await expect(requestPromise).resolves.toStrictEqual({ + numbersInString: 'He said: "1, 2, 3, Soleil!"', + safeNumber: Number(MAX_SAFE_INTEGER), + unsafeNumber: Number(MAX_SAFE_INTEGER_PLUS_ONE), + }); + }); + }); +}); diff --git a/packages/rpc-transport-http/src/http-transport-for-solana-rpc.ts b/packages/rpc-transport-http/src/http-transport-for-solana-rpc.ts new file mode 100644 index 000000000000..45924cbffe4e --- /dev/null +++ b/packages/rpc-transport-http/src/http-transport-for-solana-rpc.ts @@ -0,0 +1,24 @@ +import { RpcRequest, RpcTransport } from '@solana/rpc-spec'; +import type Dispatcher from 'undici-types/dispatcher'; + +import { createHttpTransport } from './http-transport'; +import { AllowedHttpRequestHeaders } from './http-transport-headers'; +import { isSolanaRequest } from './is-solana-request'; +import { parseJsonWithBigInts } from './parse-json-with-bigints'; +import { stringifyJsonWithBigints } from './stringify-json-with-bigints'; + +type Config = Readonly<{ + dispatcher_NODE_ONLY?: Dispatcher; + headers?: AllowedHttpRequestHeaders; + url: string; +}>; + +export function createHttpTransportForSolanaRpc(config: Config): RpcTransport { + return createHttpTransport({ + ...config, + fromJson: (rawResponse: string, request: RpcRequest) => + isSolanaRequest(request) ? parseJsonWithBigInts(rawResponse) : JSON.parse(rawResponse), + toJson: (payload: unknown, request: RpcRequest) => + isSolanaRequest(request) ? stringifyJsonWithBigints(payload) : JSON.stringify(payload), + }); +} diff --git a/packages/rpc-transport-http/src/index.ts b/packages/rpc-transport-http/src/index.ts index 6f550e6bdda5..f2414f1f1bac 100644 --- a/packages/rpc-transport-http/src/index.ts +++ b/packages/rpc-transport-http/src/index.ts @@ -1 +1,2 @@ export * from './http-transport'; +export * from './http-transport-for-solana-rpc';