From 674b13b5def7ee9b1992ea14d0c4fb097a3233fe 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 37feb0992918..b02db298a346 100644 --- a/packages/rpc-transport-http/README.md +++ b/packages/rpc-transport-http/README.md @@ -106,6 +106,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';