From 548ae9c1b2a0879fb5e7be10f962afcbb2968ae4 Mon Sep 17 00:00:00 2001 From: Reinis Martinsons <77973553+Reinis-FRP@users.noreply.github.com> Date: Wed, 5 Mar 2025 11:59:08 +0200 Subject: [PATCH] feat(solana): cached solana provider (#905) Signed-off-by: Reinis Martinsons Co-authored-by: Paul <108695806+pxrl@users.noreply.github.com> --- src/caching/Memory/MemoryCacheClient.ts | 35 +++++ src/caching/Memory/index.ts | 1 + src/caching/index.ts | 1 + src/providers/solana/cachedRpcFactory.ts | 124 ++++++++++++++++ src/providers/solana/index.ts | 1 + src/providers/solana/rateLimitedRpcFactory.ts | 2 +- src/utils/JSONUtils.ts | 14 ++ test/SolanaCachedProvider.ts | 138 ++++++++++++++++++ test/SolanaRateLimitedProvider.ts | 104 +++++++++++++ test/mocks/MockCachedSolanaRpcFactory.ts | 15 ++ test/mocks/MockRateLimitedSolanaRpcFactory.ts | 14 ++ test/mocks/MockSolanaRpcFactory.ts | 42 ++++++ test/mocks/index.ts | 3 + 13 files changed, 493 insertions(+), 1 deletion(-) create mode 100644 src/caching/Memory/MemoryCacheClient.ts create mode 100644 src/caching/Memory/index.ts create mode 100644 src/providers/solana/cachedRpcFactory.ts create mode 100644 test/SolanaCachedProvider.ts create mode 100644 test/SolanaRateLimitedProvider.ts create mode 100644 test/mocks/MockCachedSolanaRpcFactory.ts create mode 100644 test/mocks/MockRateLimitedSolanaRpcFactory.ts create mode 100644 test/mocks/MockSolanaRpcFactory.ts diff --git a/src/caching/Memory/MemoryCacheClient.ts b/src/caching/Memory/MemoryCacheClient.ts new file mode 100644 index 000000000..6a66aee4d --- /dev/null +++ b/src/caching/Memory/MemoryCacheClient.ts @@ -0,0 +1,35 @@ +import { CachingMechanismInterface } from "../../interfaces"; + +interface CacheEntry { + value: unknown; + expiresAt?: number | null; +} + +/** + * A simple in-memory cache client that stores values in a map with TTL support. + */ +export class MemoryCacheClient implements CachingMechanismInterface { + private cache: Map = new Map(); + + get(key: string): Promise { + return new Promise((resolve) => { + const entry = this.cache.get(key); + if (entry === undefined) return resolve(null); + + if (entry.expiresAt && entry.expiresAt < Date.now()) { + this.cache.delete(key); + return resolve(null); + } + + resolve(entry.value as T); + }); + } + + set(key: string, value: T, ttl?: number): Promise { + return new Promise((resolve) => { + const expiresAt = ttl ? Date.now() + ttl * 1000 : null; + this.cache.set(key, { value, expiresAt }); + resolve(key); + }); + } +} diff --git a/src/caching/Memory/index.ts b/src/caching/Memory/index.ts new file mode 100644 index 000000000..281c4c766 --- /dev/null +++ b/src/caching/Memory/index.ts @@ -0,0 +1 @@ +export * from "./MemoryCacheClient"; diff --git a/src/caching/index.ts b/src/caching/index.ts index 5118e9015..76d79ca9f 100644 --- a/src/caching/index.ts +++ b/src/caching/index.ts @@ -1,2 +1,3 @@ export * from "./IPFS"; export * from "./Arweave"; +export * from "./Memory"; diff --git a/src/providers/solana/cachedRpcFactory.ts b/src/providers/solana/cachedRpcFactory.ts new file mode 100644 index 000000000..c3cc30ad5 --- /dev/null +++ b/src/providers/solana/cachedRpcFactory.ts @@ -0,0 +1,124 @@ +import { RpcTransport, GetTransactionApi, RpcFromTransport, SolanaRpcApiFromTransport } from "@solana/web3.js"; +import { is, object, optional, string, tuple } from "superstruct"; +import { CachingMechanismInterface } from "../../interfaces"; +import { SolanaClusterRpcFactory } from "./baseRpcFactories"; +import { RateLimitedSolanaRpcFactory } from "./rateLimitedRpcFactory"; +import { CacheType } from "../utils"; +import { jsonReplacerWithBigInts, jsonReviverWithBigInts } from "../../utils"; + +export class CachedSolanaRpcFactory extends SolanaClusterRpcFactory { + public readonly getTransactionCachePrefix: string; + + // Holds the underlying transport that the cached transport wraps. + protected rateLimitedTransport: RpcTransport; + + // RPC client based on the rate limited transport, used internally to check confirmation status. + protected rateLimitedRpcClient: RpcFromTransport, RpcTransport>; + + constructor( + providerCacheNamespace: string, + readonly redisClient?: CachingMechanismInterface, + ...rateLimitedConstructorParams: ConstructorParameters + ) { + // SolanaClusterRpcFactory shares the last two constructor parameters with RateLimitedSolanaRpcFactory. + const superParams = rateLimitedConstructorParams.slice(-2) as [ + ConstructorParameters[0], // clusterUrl: ClusterUrl + ConstructorParameters[1], // chainId: number + ]; + super(...superParams); + + // Create the rate limited transport and RPC client. + const rateLimitedRpcFactory = new RateLimitedSolanaRpcFactory(...rateLimitedConstructorParams); + this.rateLimitedTransport = rateLimitedRpcFactory.createTransport(); + this.rateLimitedRpcClient = rateLimitedRpcFactory.createRpcClient(); + + // Pre-compute as much of the redis key as possible. + const cachePrefix = `${providerCacheNamespace},${new URL(this.clusterUrl).hostname},${this.chainId}`; + this.getTransactionCachePrefix = `${cachePrefix}:getTransaction,`; + } + + public createTransport(): RpcTransport { + return async (...args: Parameters): Promise => { + const { method, params } = args[0].payload as { method: string; params?: unknown[] }; + const cacheType = this.redisClient ? this.cacheType(method) : CacheType.NONE; + + if (cacheType === CacheType.NONE) { + return this.rateLimitedTransport(...args); + } + + const redisKey = this.buildRedisKey(method, params); + + // Attempt to pull the result from the cache. + const redisResult = await this.redisClient?.get(redisKey); + + // If cache has the result, parse the json and return it. + if (redisResult) { + return JSON.parse(redisResult, jsonReviverWithBigInts); + } + + // Cache does not have the result. Query it directly and cache it if finalized. + return this.requestAndCacheFinalized(...args); + }; + } + + private async requestAndCacheFinalized(...args: Parameters): Promise { + const { method, params } = args[0].payload as { method: string; params?: unknown[] }; + + // Only handles getTransaction right now. + if (method !== "getTransaction") return this.rateLimitedTransport(...args); + + // Do not throw if params are not valid, just skip caching and pass through to the underlying transport. + if (!this.isGetTransactionParams(params)) return this.rateLimitedTransport(...args); + + // Check the confirmation status first to avoid caching non-finalized transactions. + const getSignatureStatusesResponse = await this.rateLimitedRpcClient + .getSignatureStatuses([params[0]], { + searchTransactionHistory: true, + }) + .send(); + + const getTransactionResponse = await this.rateLimitedTransport(...args); + + // Cache the transaction only if it is finalized. + if (getSignatureStatusesResponse.value[0]?.confirmationStatus === "finalized") { + const redisKey = this.buildRedisKey(method, params); + await this.redisClient?.set( + redisKey, + JSON.stringify(getTransactionResponse, jsonReplacerWithBigInts), + Number.POSITIVE_INFINITY + ); + } + + return getTransactionResponse; + } + + private buildRedisKey(method: string, params?: unknown[]) { + // Only handles getTransaction right now. + switch (method) { + case "getTransaction": + return this.getTransactionCachePrefix + JSON.stringify(params, jsonReplacerWithBigInts); + default: + throw new Error(`CachedSolanaRpcFactory::buildRedisKey: invalid JSON-RPC method ${method}`); + } + } + + private cacheType(method: string): CacheType { + // Today, we only cache getTransaction. + if (method === "getTransaction") { + // We only store finalized transactions in the cache, hence TTL is not required. + return CacheType.NO_TTL; + } else { + return CacheType.NONE; + } + } + + private isGetTransactionParams(params: unknown): params is Parameters { + return is( + params, + tuple([ + string(), // Signature (Base58 string) + optional(object()), // We use only the tx signature to get its commitment, but pass through the options as is. + ]) + ); + } +} diff --git a/src/providers/solana/index.ts b/src/providers/solana/index.ts index 84f1055d0..089e6886a 100644 --- a/src/providers/solana/index.ts +++ b/src/providers/solana/index.ts @@ -1,4 +1,5 @@ export * from "./baseRpcFactories"; +export * from "./cachedRpcFactory"; export * from "./defaultRpcFactory"; export * from "./rateLimitedRpcFactory"; export * from "./utils"; diff --git a/src/providers/solana/rateLimitedRpcFactory.ts b/src/providers/solana/rateLimitedRpcFactory.ts index 8f2f815cd..d2d3b3af5 100644 --- a/src/providers/solana/rateLimitedRpcFactory.ts +++ b/src/providers/solana/rateLimitedRpcFactory.ts @@ -13,7 +13,7 @@ export class RateLimitedSolanaRpcFactory extends SolanaClusterRpcFactory { private queue: QueueObject; // Holds the underlying transport that the rate limiter wraps. - private readonly defaultTransport: RpcTransport; + protected defaultTransport: RpcTransport; // Takes the same arguments as the SolanaDefaultRpcFactory, but it has an additional parameters to control // concurrency and logging at the beginning of the list. diff --git a/src/utils/JSONUtils.ts b/src/utils/JSONUtils.ts index 7b45b4f63..eb1b00822 100644 --- a/src/utils/JSONUtils.ts +++ b/src/utils/JSONUtils.ts @@ -83,3 +83,17 @@ export function jsonReviverWithBigNumbers(_key: string, value: unknown): unknown } return value; } + +/** + * A replacer for `JSON.stringify` that converts BigInt to a decimal string with 'n' suffix. + */ +export function jsonReplacerWithBigInts(_key: string, value: unknown): unknown { + return typeof value === "bigint" ? value.toString() + "n" : value; +} + +/** + * A reviver for `JSON.parse` that converts strings ending in 'n' back to BigInt. + */ +export function jsonReviverWithBigInts(_key: string, value: unknown): unknown { + return typeof value === "string" && /^-?\d+n$/.test(value) ? BigInt(value.slice(0, -1)) : value; +} diff --git a/test/SolanaCachedProvider.ts b/test/SolanaCachedProvider.ts new file mode 100644 index 000000000..724b210d9 --- /dev/null +++ b/test/SolanaCachedProvider.ts @@ -0,0 +1,138 @@ +import { signature, Commitment, Rpc, SolanaRpcApiFromTransport, RpcTransport } from "@solana/web3.js"; +import bs58 from "bs58"; +import { createHash } from "crypto"; +import winston from "winston"; +import { MockRateLimitedSolanaRpcFactory, MockSolanaRpcFactory, MockCachedSolanaRpcFactory } from "./mocks"; +import { createSpyLogger, expect, spyLogIncludes } from "./utils"; +import { MemoryCacheClient } from "../src/caching"; +import { jsonReviverWithBigInts } from "../src/utils"; + +const chainId = 1234567890; +const url = "https://test.example.com/"; +const maxConcurrency = 1; +const pctRpcCallsLogged = 100; // Will use logs to check underlying transport calls. +const providerCacheNamespace = "test"; +const testSignature = signature(bs58.encode(createHash("sha512").update("testSignature").digest())); +const getSignatureStatusesParams = [[testSignature], { searchTransactionHistory: true }]; +const getTransactionConfig = { + commitment: "confirmed" as Commitment, + encoding: "base58" as const, +}; +const getTransactionResult = { + slot: 0n, + transaction: bs58.encode(Buffer.from("testTransaction")), + blockTime: null, + meta: null, +}; + +describe("cached solana provider", () => { + let spy: sinon.SinonSpy; + let mockRpcFactory: MockSolanaRpcFactory; + let memoryCache: MemoryCacheClient; + let cachedRpcClient: Rpc>; + + beforeEach(() => { + const spyLoggerResult = createSpyLogger(); + spy = spyLoggerResult.spy; + + mockRpcFactory = new MockSolanaRpcFactory(url, chainId); + const rateLimitedParams: [number, number, winston.Logger, string, number] = [ + maxConcurrency, + pctRpcCallsLogged, + spyLoggerResult.spyLogger, + url, + chainId, + ]; + const rateLimitedRpcFactory = new MockRateLimitedSolanaRpcFactory(mockRpcFactory, ...rateLimitedParams); + memoryCache = new MemoryCacheClient(); + cachedRpcClient = new MockCachedSolanaRpcFactory( + rateLimitedRpcFactory, + providerCacheNamespace, + memoryCache, + ...rateLimitedParams + ).createRpcClient(); + }); + + it("caches finalized transaction", async () => { + // Prepare required mock results for finalized transaction. + mockRpcFactory.setResult("getSignatureStatuses", getSignatureStatusesParams, { + value: [{ confirmationStatus: "finalized" }], + }); + mockRpcFactory.setResult("getTransaction", [testSignature, getTransactionConfig], getTransactionResult); + + let result = await cachedRpcClient.getTransaction(testSignature, getTransactionConfig).send(); + expect(result).to.deep.equal(getTransactionResult); + + // Check the cache. + const cacheKey = `${providerCacheNamespace},${ + new URL(url).hostname + },${chainId}:getTransaction,["${testSignature}",${JSON.stringify(getTransactionConfig)}]`; + const cacheValue = JSON.parse((await memoryCache.get(cacheKey)) || "{}", jsonReviverWithBigInts); + expect(cacheValue).to.have.property("result"); + expect(cacheValue.result).to.deep.equal(getTransactionResult); + + // Expect 2 log entries from the underlying transport: one for getSignatureStatuses and one for getTransaction. + expect(spy.callCount).to.equal(2); + expect(spyLogIncludes(spy, 0, "getSignatureStatuses")).to.be.true; + expect(spyLogIncludes(spy, 1, "getTransaction")).to.be.true; + + // Second request should fetch from cache. + result = await cachedRpcClient.getTransaction(testSignature, getTransactionConfig).send(); + expect(result).to.deep.equal(getTransactionResult); + + // No new log entries should be emitted from the underlying transport, expect the same 2 as after the first request. + expect(spy.callCount).to.equal(2); + }); + + it("does not cache non-finalized transaction", async () => { + // Prepare required mock results for non-finalized transaction. + mockRpcFactory.setResult("getSignatureStatuses", getSignatureStatusesParams, { + value: [{ confirmationStatus: "processed" }], + }); + mockRpcFactory.setResult("getTransaction", [testSignature, getTransactionConfig], getTransactionResult); + + let result = await cachedRpcClient.getTransaction(testSignature, getTransactionConfig).send(); + expect(result).to.deep.equal(getTransactionResult); + + // Check the cache is empty. + const cacheKey = `${providerCacheNamespace},${ + new URL(url).hostname + },${chainId}:getTransaction,["${testSignature}",${JSON.stringify(getTransactionConfig)}]`; + const cacheValue = JSON.parse((await memoryCache.get(cacheKey)) || "{}", jsonReviverWithBigInts); + expect(cacheValue).to.be.empty; + + // Expect 2 log entries from the underlying transport: one for getSignatureStatuses and one for getTransaction. + expect(spy.callCount).to.equal(2); + expect(spyLogIncludes(spy, 0, "getSignatureStatuses")).to.be.true; + expect(spyLogIncludes(spy, 1, "getTransaction")).to.be.true; + + result = await cachedRpcClient.getTransaction(testSignature, getTransactionConfig).send(); + expect(result).to.deep.equal(getTransactionResult); + + // Second request should have triggered the underlying transport again, doubling the log entries. + expect(spy.callCount).to.equal(4); + expect(spyLogIncludes(spy, 2, "getSignatureStatuses")).to.be.true; + expect(spyLogIncludes(spy, 3, "getTransaction")).to.be.true; + }); + + it("does not cache other methods", async () => { + let slotResult = 1; + mockRpcFactory.setResult("getSlot", [], slotResult); + + let rpcResult = await cachedRpcClient.getSlot().send(); + expect(rpcResult).to.equal(BigInt(slotResult)); + + // Expect 1 log entry from the underlying transport. + expect(spy.callCount).to.equal(1); + expect(spyLogIncludes(spy, 0, "getSlot")).to.be.true; + + slotResult = 2; + mockRpcFactory.setResult("getSlot", [], slotResult); + rpcResult = await cachedRpcClient.getSlot().send(); + expect(rpcResult).to.equal(BigInt(slotResult)); + + // Second request should have triggered the underlying transport again, doubling the log entries. + expect(spy.callCount).to.equal(2); + expect(spyLogIncludes(spy, 1, "getSlot")).to.be.true; + }); +}); diff --git a/test/SolanaRateLimitedProvider.ts b/test/SolanaRateLimitedProvider.ts new file mode 100644 index 000000000..5b295c5e1 --- /dev/null +++ b/test/SolanaRateLimitedProvider.ts @@ -0,0 +1,104 @@ +import { GetSlotApi } from "@solana/web3.js"; +import { MockRateLimitedSolanaRpcFactory, MockSolanaRpcFactory } from "./mocks"; +import { createSpyLogger, expect } from "./utils"; + +const chainId = 1234567890; +const url = "https://test.example.com/"; + +describe("rate limited solana provider", () => { + it("serial requests updates results", async () => { + const numRequests = 10; + const maxConcurrency = 1; + const pctRpcCallsLogged = 0; + let mockResult = 0; + const mockResponseTime = 10; + + // Update the mock result at the same rate as the response time. + const mockRpcFactory = new MockSolanaRpcFactory(url, chainId); + mockRpcFactory.setResponseTime(mockResponseTime); + const interval = setInterval(() => { + mockRpcFactory.setResult("getSlot", [], mockResult); + mockResult += 1; + }, mockResponseTime); + await new Promise((resolve) => setTimeout(resolve, mockResponseTime)); // Wait for the first result to be set + + const rateLimitedRpcClient = new MockRateLimitedSolanaRpcFactory( + mockRpcFactory, + maxConcurrency, + pctRpcCallsLogged, + undefined, + url, + chainId + ).createRpcClient(); + + const rateLimitedRequests = Array.from({ length: numRequests }, () => rateLimitedRpcClient.getSlot().send()); + const results = await Promise.all(rateLimitedRequests); + clearInterval(interval); // Stop updating results + + // The total difference between the first and the last result should be 1 less than the number of requests as the + // mock results are updated at the same rate as the response time and this test has concurrency of 1. + const expectedTotalDiff = BigInt(numRequests - 1); + expect(results[results.length - 1] - results[0]).to.equal(expectedTotalDiff); + }); + + it("concurrent requests get the same result", async () => { + const numRequests = 10; + const maxConcurrency = numRequests; + const pctRpcCallsLogged = 0; + let mockResult = 0; + const mockResponseTime = 10; + + // Update the mock result at the same rate as the response time. + const mockRpcFactory = new MockSolanaRpcFactory(url, chainId); + mockRpcFactory.setResponseTime(mockResponseTime); + const interval = setInterval(() => { + mockRpcFactory.setResult("getSlot", [], mockResult); + mockResult += 1; + }, mockResponseTime); + await new Promise((resolve) => setTimeout(resolve, mockResponseTime)); // Wait for the first result to be set + + const rateLimitedRpcClient = new MockRateLimitedSolanaRpcFactory( + mockRpcFactory, + maxConcurrency, + pctRpcCallsLogged, + undefined, + url, + chainId + ).createRpcClient(); + + const rateLimitedRequests = Array.from({ length: numRequests }, () => rateLimitedRpcClient.getSlot().send()); + const results = await Promise.all(rateLimitedRequests); + clearInterval(interval); // Stop updating results + + // The last and the first result should be the same as the mock result is updated at the same rate as the response + // time and this test has concurrency equal to the number of requests. + expect(results[results.length - 1]).to.equal(results[0]); + }); + + it("logs rate limited request", async () => { + const maxConcurrency = 1; + const pctRpcCallsLogged = 100; + const mockResult = 1; + const { spy, spyLogger } = createSpyLogger(); + const mockRpcFactory = new MockSolanaRpcFactory(url, chainId); + const rateLimitedRpcClient = new MockRateLimitedSolanaRpcFactory( + mockRpcFactory, + maxConcurrency, + pctRpcCallsLogged, + spyLogger, + url, + chainId + ).createRpcClient(); + + mockRpcFactory.setResult("getSlot", [], mockResult); + + const getSlotParams: Parameters = [{ commitment: "confirmed" }]; + await rateLimitedRpcClient.getSlot(getSlotParams[0]).send(); + + expect(spy.calledOnce).to.be.true; + expect(spy.getCall(0).lastArg.provider).to.equal(new URL(url).origin); + expect(spy.getCall(0).lastArg.chainId).to.equal(chainId); + expect(spy.getCall(0).lastArg.method).to.equal("getSlot"); + expect(spy.getCall(0).lastArg.params).to.deep.equal(getSlotParams); + }); +}); diff --git a/test/mocks/MockCachedSolanaRpcFactory.ts b/test/mocks/MockCachedSolanaRpcFactory.ts new file mode 100644 index 000000000..7af062f61 --- /dev/null +++ b/test/mocks/MockCachedSolanaRpcFactory.ts @@ -0,0 +1,15 @@ +import { CachedSolanaRpcFactory } from "../../src/providers"; +import { MockRateLimitedSolanaRpcFactory } from "./MockRateLimitedSolanaRpcFactory"; + +// Creates mocked cached Solana RPC factory by using the mocked Solana RPC factory. +export class MockCachedSolanaRpcFactory extends CachedSolanaRpcFactory { + constructor( + mockRateLimitedSolanaRpcFactory: MockRateLimitedSolanaRpcFactory, + ...cachedConstructorParams: ConstructorParameters + ) { + super(...cachedConstructorParams); + + this.rateLimitedTransport = mockRateLimitedSolanaRpcFactory.createTransport(); + this.rateLimitedRpcClient = mockRateLimitedSolanaRpcFactory.createRpcClient(); + } +} diff --git a/test/mocks/MockRateLimitedSolanaRpcFactory.ts b/test/mocks/MockRateLimitedSolanaRpcFactory.ts new file mode 100644 index 000000000..4df9340b9 --- /dev/null +++ b/test/mocks/MockRateLimitedSolanaRpcFactory.ts @@ -0,0 +1,14 @@ +import { RateLimitedSolanaRpcFactory } from "../../src/providers"; +import { MockSolanaRpcFactory } from "./MockSolanaRpcFactory"; + +// Creates mocked rate limited Solana RPC factory by using the mocked Solana RPC factory. +export class MockRateLimitedSolanaRpcFactory extends RateLimitedSolanaRpcFactory { + constructor( + mockSolanaRpcFactory: MockSolanaRpcFactory, + ...rateLimitedConstructorParams: ConstructorParameters + ) { + super(...rateLimitedConstructorParams); + + this.defaultTransport = mockSolanaRpcFactory.createTransport(); + } +} diff --git a/test/mocks/MockSolanaRpcFactory.ts b/test/mocks/MockSolanaRpcFactory.ts new file mode 100644 index 000000000..1b3d3aea3 --- /dev/null +++ b/test/mocks/MockSolanaRpcFactory.ts @@ -0,0 +1,42 @@ +import { RpcResponse, RpcTransport } from "@solana/web3.js"; +import { SolanaClusterRpcFactory } from "../../src/providers"; + +// Exposes mocked RPC transport for Solana in the SolanaClusterRpcFactory class. +export class MockSolanaRpcFactory extends SolanaClusterRpcFactory { + private responseTime: number; // in milliseconds + private responses: Map = new Map(); + + constructor(...clusterConstructorParams: ConstructorParameters) { + super(...clusterConstructorParams); + } + + public createTransport(): RpcTransport { + return (...args: Parameters): Promise => { + return this.createMockRpcTransport()(...args); + }; + } + + public setResult(method: string, params: unknown[], result: unknown) { + const requestKey = JSON.stringify({ method, params }); + this.responses.set(requestKey, result); + } + + public setResponseTime(responseTime: number) { + this.responseTime = responseTime; + } + + private createMockRpcTransport(): RpcTransport { + return async ({ payload }: Parameters[0]): Promise> => { + const { method, params } = payload as { method: string; params?: unknown[] }; + const requestKey = JSON.stringify({ method, params }); + let result = this.responses.get(requestKey); + if (result === undefined) { + const requestKeyWithoutParams = JSON.stringify({ method, params: [] }); + result = this.responses.get(requestKeyWithoutParams); + if (result === undefined) result = null; + } + await new Promise((resolve) => setTimeout(resolve, this.responseTime)); + return { result } as TResponse; + }; + } +} diff --git a/test/mocks/index.ts b/test/mocks/index.ts index 8660c4b86..3b43b9eed 100644 --- a/test/mocks/index.ts +++ b/test/mocks/index.ts @@ -2,5 +2,8 @@ import { clients } from "../../src"; export class MockSpokePoolClient extends clients.mocks.MockSpokePoolClient {} +export * from "./MockCachedSolanaRpcFactory"; export * from "./MockConfigStoreClient"; export * from "./MockHubPoolClient"; +export * from "./MockRateLimitedSolanaRpcFactory"; +export * from "./MockSolanaRpcFactory";