From 43899c4bff4ef0cf330067fb2c25a49aff4fe305 Mon Sep 17 00:00:00 2001 From: nicholaspai Date: Tue, 12 Aug 2025 18:11:12 -0400 Subject: [PATCH 1/6] feat(SVMProvider): Cache getBlockTime Currently we only cache getTransaction, we should also cache the result of getBlockTime. This PR adds internal methods to keep track of the latest `confirmed` and `finalized` blocks to determine how long to keep data in the cache for. --- e2e/testTimestampForSlot.e2e.ts | 6 +- src/providers/solana/cachedRpcFactory.ts | 161 ++++++++++++++++++----- 2 files changed, 131 insertions(+), 36 deletions(-) diff --git a/e2e/testTimestampForSlot.e2e.ts b/e2e/testTimestampForSlot.e2e.ts index 612d5b407..3887da439 100644 --- a/e2e/testTimestampForSlot.e2e.ts +++ b/e2e/testTimestampForSlot.e2e.ts @@ -6,6 +6,7 @@ import winston from "winston"; import { ClusterUrl } from "@solana/kit"; import { getNearestSlotTime } from "../src/arch/svm/utils"; import { QuorumFallbackSolanaRpcFactory, CachedSolanaRpcFactory } from "../src/providers"; +import { MemoryCacheClient } from "../src/caching/Memory"; /** * USAGE EXAMPLES: @@ -62,7 +63,7 @@ async function testNearestSlotTime( const startTime = Date.now(); try { - const { slot, timestamp } = await getNearestSlotTime(rpcClient, logger); + const { slot, timestamp } = await getNearestSlotTime(rpcClient, { commitment: "confirmed" }, logger); const elapsedTime = Date.now() - startTime; console.log(`✅ Slot ${slot} -> ${timestamp} (${new Date(timestamp * 1000).toISOString()}) (${elapsedTime}ms)`); @@ -100,11 +101,12 @@ async function runTest(options: TestOptions) { // Create the RPC factory const allEndpoints = [options.endpoint, ...options.fallbackEndpoints]; + const memoryCache = new MemoryCacheClient(); const factoryParams = allEndpoints.map( (endpoint) => [ "test-timestamp-for-slot", - undefined, // redisClient + memoryCache, // redisClient options.retries, options.retryDelay, 10, // maxConcurrency diff --git a/src/providers/solana/cachedRpcFactory.ts b/src/providers/solana/cachedRpcFactory.ts index 9bad8ed22..9ac943dff 100644 --- a/src/providers/solana/cachedRpcFactory.ts +++ b/src/providers/solana/cachedRpcFactory.ts @@ -1,16 +1,27 @@ -import { RpcTransport, GetTransactionApi, RpcFromTransport, SolanaRpcApiFromTransport } from "@solana/kit"; +import { + RpcTransport, + GetTransactionApi, + RpcFromTransport, + SolanaRpcApiFromTransport, + GetBlockTimeApi, +} from "@solana/kit"; import { getThrowSolanaErrorResponseTransformer } from "@solana/rpc-transformers"; -import { is, object, optional, string, tuple } from "superstruct"; +import { is, number, object, optional, string, tuple } from "superstruct"; import { CachingMechanismInterface } from "../../interfaces"; import { SolanaClusterRpcFactory } from "./baseRpcFactories"; import { CacheType } from "../utils"; import { jsonReplacerWithBigInts, jsonReviverWithBigInts } from "../../utils"; import { RetrySolanaRpcFactory } from "./retryRpcFactory"; +import { random } from "lodash"; +import { BLOCK_NUMBER_TTL, PROVIDER_CACHE_TTL, PROVIDER_CACHE_TTL_MODIFIER as ttl_modifier } from "../constants"; +import { assert } from "chai"; // A CachedFactory contains a RetryFactory and provides a caching layer that caches // the results of the RetryFactory's RPC calls. export class CachedSolanaRpcFactory extends SolanaClusterRpcFactory { public readonly getTransactionCachePrefix: string; + public readonly getBlockTimeCachePrefix: string; + baseTTL = PROVIDER_CACHE_TTL; // Holds the underlying transport that the cached transport wraps. protected retryTransport: RpcTransport; @@ -18,6 +29,16 @@ export class CachedSolanaRpcFactory extends SolanaClusterRpcFactory { // RPC client based on the retry transport, used internally to check confirmation status. protected retryRpcClient: RpcFromTransport, RpcTransport>; + // Cached latest finalized slot and its publish timestamp. + latestFinalizedSlot: number = Number.MAX_SAFE_INTEGER; + publishTimestampLatestFinalizedSlot: number = 0; + maxAgeLatestFinalizedSlot = 1000 * BLOCK_NUMBER_TTL; + + // Cached latest confirmed slot and its publish timestamp. + latestConfirmedSlot: number = Number.MAX_SAFE_INTEGER; + publishTimestampLatestConfirmedSlot: number = 0; + maxAgeLatestConfirmedSlot = 1000 * BLOCK_NUMBER_TTL; + constructor( providerCacheNamespace: string, readonly redisClient?: CachingMechanismInterface, @@ -38,12 +59,22 @@ export class CachedSolanaRpcFactory extends SolanaClusterRpcFactory { // Pre-compute as much of the redis key as possible. const cachePrefix = `${providerCacheNamespace},${new URL(this.clusterUrl).hostname},${this.chainId}`; this.getTransactionCachePrefix = `${cachePrefix}:getTransaction,`; + this.getBlockTimeCachePrefix = `${cachePrefix}:getBlockTime,`; } 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 (!this.redisClient) { + return this.retryTransport(...args); + } + + const [latestFinalizedSlot, latestConfirmedSlot] = await Promise.all([ + this.getLatestFinalizedSlot(), + this.getLatestConfirmedSlot(), + ]); + + const cacheType = this.cacheType(method, params ?? [], latestFinalizedSlot, latestConfirmedSlot); if (cacheType === CacheType.NONE) { return this.retryTransport(...args); @@ -60,52 +91,95 @@ export class CachedSolanaRpcFactory extends SolanaClusterRpcFactory { } // Cache does not have the result. Query it directly and cache it if finalized. - return this.requestAndCacheFinalized(...args); + return this.requestAndCacheFinalized(cacheType, ...args); + }; + } + + private async getLatestFinalizedSlot(): Promise { + const fetchLatestFinalizedSlot = async () => { + return await this.retryRpcClient.getSlot({ commitment: "finalized" }).send(); + }; + if (this.latestFinalizedSlot === Number.MAX_SAFE_INTEGER) { + this.latestFinalizedSlot = Number(await fetchLatestFinalizedSlot()); + this.publishTimestampLatestFinalizedSlot = Date.now(); + return this.latestFinalizedSlot; + } + if (Date.now() - this.publishTimestampLatestFinalizedSlot > this.maxAgeLatestFinalizedSlot) { + this.latestFinalizedSlot = Number(await fetchLatestFinalizedSlot()); + this.publishTimestampLatestFinalizedSlot = Date.now(); + } + return this.latestFinalizedSlot; + } + + private async getLatestConfirmedSlot(): Promise { + const fetchLatestConfirmedSlot = async () => { + return await this.retryRpcClient.getSlot({ commitment: "confirmed" }).send(); }; + if (this.latestConfirmedSlot === Number.MAX_SAFE_INTEGER) { + this.latestConfirmedSlot = Number(await fetchLatestConfirmedSlot()); + this.publishTimestampLatestConfirmedSlot = Date.now(); + return this.latestConfirmedSlot; + } + if (Date.now() - this.publishTimestampLatestConfirmedSlot > this.maxAgeLatestConfirmedSlot) { + this.latestConfirmedSlot = Number(await fetchLatestConfirmedSlot()); + this.publishTimestampLatestConfirmedSlot = Date.now(); + } + return this.latestConfirmedSlot; } - private async requestAndCacheFinalized(...args: Parameters): Promise { + private async requestAndCacheFinalized( + cacheType: CacheType, + ...args: Parameters + ): Promise { + assert( + cacheType === CacheType.NO_TTL || cacheType === CacheType.WITH_TTL, + "requestAndCacheFinalized: Cache type must be NO_TTL or WITH_TTL" + ); const { method, params } = args[0].payload as { method: string; params?: unknown[] }; - // Only handles getTransaction right now. - if (method !== "getTransaction") return this.retryTransport(...args); + if (method !== "getTransaction" && method !== "getBlockTime") return this.retryTransport(...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.retryTransport(...args); - - // Check the confirmation status first to avoid caching non-finalized transactions. In case of null or errors just - // skip caching and pass through to the underlying transport. - try { - const getSignatureStatusesResponse = await this.retryRpcClient - .getSignatureStatuses([params[0]], { - searchTransactionHistory: true, - }) - .send(); - if (getSignatureStatusesResponse.value[0]?.confirmationStatus !== "finalized") { - return this.retryTransport(...args); - } - } catch (error) { - return this.retryTransport(...args); + switch (method) { + case "getTransaction": + if (!this.isGetTransactionParams(params)) return this.retryTransport(...args); + // Check the confirmation status first to avoid caching non-finalized transactions. In case of null or errors just + // skip caching and pass through to the underlying transport. + try { + const getSignatureStatusesResponse = await this.retryRpcClient + .getSignatureStatuses([params[0]], { + searchTransactionHistory: true, + }) + .send(); + if (getSignatureStatusesResponse.value[0]?.confirmationStatus !== "finalized") { + return this.retryTransport(...args); + } + } catch (error) { + return this.retryTransport(...args); + } + break; + case "getBlockTime": + if (!this.isGetBlockTimeParams(params)) return this.retryTransport(...args); + break; } - const getTransactionResponse = await this.retryTransport(...args); + const response = await this.retryTransport(...args); // Do not cache JSON-RPC error responses, let them pass through for the RPC client to handle. try { - getThrowSolanaErrorResponseTransformer()(getTransactionResponse, { methodName: method, params }); + getThrowSolanaErrorResponseTransformer()(response, { methodName: method, params }); } catch { - return getTransactionResponse; + return response; } // Cache the transaction JSON-RPC response as we checked the transaction is finalized and not an error. const redisKey = this.buildRedisKey(method, params); - await this.redisClient?.set( - redisKey, - JSON.stringify(getTransactionResponse, jsonReplacerWithBigInts), - Number.POSITIVE_INFINITY - ); + // Apply a random margin to spread expiry over a larger time window. + const standardTtl = this.baseTTL + Math.ceil(random(-ttl_modifier, ttl_modifier, true) * this.baseTTL); + const ttl = cacheType === CacheType.WITH_TTL ? standardTtl : Number.POSITIVE_INFINITY; + await this.redisClient?.set(redisKey, JSON.stringify(response, jsonReplacerWithBigInts), ttl); - return getTransactionResponse; + return response; } private buildRedisKey(method: string, params?: unknown[]) { @@ -113,14 +187,29 @@ export class CachedSolanaRpcFactory extends SolanaClusterRpcFactory { switch (method) { case "getTransaction": return this.getTransactionCachePrefix + JSON.stringify(params, jsonReplacerWithBigInts); + case "getBlockTime": + return this.getBlockTimeCachePrefix + 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") { + private cacheType( + method: string, + params: unknown[], + latestFinalizedSlot: number, + latestConfirmedSlot: number + ): CacheType { + if (method === "getBlockTime") { + const targetSlot = (params as Parameters)[0]; + if (targetSlot <= latestFinalizedSlot) { + return CacheType.NO_TTL; + } else if (targetSlot > latestFinalizedSlot && targetSlot <= latestConfirmedSlot) { + return CacheType.WITH_TTL; + } else { + return CacheType.NONE; + } + } else if (method === "getTransaction") { // We only store finalized transactions in the cache, hence TTL is not required. return CacheType.NO_TTL; } else { @@ -137,4 +226,8 @@ export class CachedSolanaRpcFactory extends SolanaClusterRpcFactory { ]) ); } + + private isGetBlockTimeParams(params: unknown): params is Parameters { + return is(params, tuple([number()])); + } } From 027d9283b9db4d11af406020ba9d912a246fb843 Mon Sep 17 00:00:00 2001 From: nicholaspai Date: Tue, 12 Aug 2025 18:17:47 -0400 Subject: [PATCH 2/6] Update cachedRpcFactory.ts --- src/providers/solana/cachedRpcFactory.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/providers/solana/cachedRpcFactory.ts b/src/providers/solana/cachedRpcFactory.ts index 9ac943dff..b5caa6466 100644 --- a/src/providers/solana/cachedRpcFactory.ts +++ b/src/providers/solana/cachedRpcFactory.ts @@ -30,13 +30,13 @@ export class CachedSolanaRpcFactory extends SolanaClusterRpcFactory { protected retryRpcClient: RpcFromTransport, RpcTransport>; // Cached latest finalized slot and its publish timestamp. - latestFinalizedSlot: number = Number.MAX_SAFE_INTEGER; - publishTimestampLatestFinalizedSlot: number = 0; + latestFinalizedSlot = Number.MAX_SAFE_INTEGER; + publishTimestampLatestFinalizedSlot = 0; maxAgeLatestFinalizedSlot = 1000 * BLOCK_NUMBER_TTL; // Cached latest confirmed slot and its publish timestamp. - latestConfirmedSlot: number = Number.MAX_SAFE_INTEGER; - publishTimestampLatestConfirmedSlot: number = 0; + latestConfirmedSlot = Number.MAX_SAFE_INTEGER; + publishTimestampLatestConfirmedSlot = 0; maxAgeLatestConfirmedSlot = 1000 * BLOCK_NUMBER_TTL; constructor( From 1f01af4f89555c5836faffac5bf6e7f3e609b016 Mon Sep 17 00:00:00 2001 From: nicholaspai Date: Tue, 12 Aug 2025 18:22:24 -0400 Subject: [PATCH 3/6] Update cachedRpcFactory.ts --- src/providers/solana/cachedRpcFactory.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/providers/solana/cachedRpcFactory.ts b/src/providers/solana/cachedRpcFactory.ts index b5caa6466..dd04c7e67 100644 --- a/src/providers/solana/cachedRpcFactory.ts +++ b/src/providers/solana/cachedRpcFactory.ts @@ -69,10 +69,14 @@ export class CachedSolanaRpcFactory extends SolanaClusterRpcFactory { return this.retryTransport(...args); } - const [latestFinalizedSlot, latestConfirmedSlot] = await Promise.all([ + let latestFinalizedSlot = 0; + let latestConfirmedSlot = 0; + if (method === "getBlockTime") { + [latestFinalizedSlot, latestConfirmedSlot] = await Promise.all([ this.getLatestFinalizedSlot(), this.getLatestConfirmedSlot(), ]); + } const cacheType = this.cacheType(method, params ?? [], latestFinalizedSlot, latestConfirmedSlot); @@ -196,9 +200,9 @@ export class CachedSolanaRpcFactory extends SolanaClusterRpcFactory { private cacheType( method: string, - params: unknown[], - latestFinalizedSlot: number, - latestConfirmedSlot: number + params: unknown[] = [], + latestFinalizedSlot = 0, + latestConfirmedSlot = 0 ): CacheType { if (method === "getBlockTime") { const targetSlot = (params as Parameters)[0]; From 6c7d9065a36fbd5f760ba6ad72376d5e887f98e8 Mon Sep 17 00:00:00 2001 From: nicholaspai Date: Tue, 12 Aug 2025 18:22:51 -0400 Subject: [PATCH 4/6] Update SolanaCachedProvider.ts --- test/SolanaCachedProvider.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/SolanaCachedProvider.ts b/test/SolanaCachedProvider.ts index 493979784..485142342 100644 --- a/test/SolanaCachedProvider.ts +++ b/test/SolanaCachedProvider.ts @@ -249,4 +249,6 @@ describe("cached solana provider", () => { expect(spyLogIncludes(spy, 2, "getSignatureStatuses")).to.be.true; expect(spyLogIncludes(spy, 3, "getTransaction")).to.be.true; }); + + // TODO: Add tests for getBlockTime. }); From 487882cd1923ad93d2cac3f90fba887f20259573 Mon Sep 17 00:00:00 2001 From: nicholaspai Date: Tue, 12 Aug 2025 18:27:46 -0400 Subject: [PATCH 5/6] Update cachedRpcFactory.ts --- src/providers/solana/cachedRpcFactory.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/providers/solana/cachedRpcFactory.ts b/src/providers/solana/cachedRpcFactory.ts index dd04c7e67..437c8114f 100644 --- a/src/providers/solana/cachedRpcFactory.ts +++ b/src/providers/solana/cachedRpcFactory.ts @@ -72,11 +72,11 @@ export class CachedSolanaRpcFactory extends SolanaClusterRpcFactory { let latestFinalizedSlot = 0; let latestConfirmedSlot = 0; if (method === "getBlockTime") { - [latestFinalizedSlot, latestConfirmedSlot] = await Promise.all([ - this.getLatestFinalizedSlot(), - this.getLatestConfirmedSlot(), - ]); - } + [latestFinalizedSlot, latestConfirmedSlot] = await Promise.all([ + this.getLatestFinalizedSlot(), + this.getLatestConfirmedSlot(), + ]); + } const cacheType = this.cacheType(method, params ?? [], latestFinalizedSlot, latestConfirmedSlot); From 275a28a945d4e3c9d2b9479b3ab369a3866b3032 Mon Sep 17 00:00:00 2001 From: nicholaspai Date: Wed, 13 Aug 2025 12:28:40 -0400 Subject: [PATCH 6/6] Add tests --- src/providers/solana/cachedRpcFactory.ts | 47 +-- test/SolanaCachedProvider.ts | 361 +++++++++++++---------- 2 files changed, 217 insertions(+), 191 deletions(-) diff --git a/src/providers/solana/cachedRpcFactory.ts b/src/providers/solana/cachedRpcFactory.ts index 437c8114f..ce2b7f7bb 100644 --- a/src/providers/solana/cachedRpcFactory.ts +++ b/src/providers/solana/cachedRpcFactory.ts @@ -29,11 +29,6 @@ export class CachedSolanaRpcFactory extends SolanaClusterRpcFactory { // RPC client based on the retry transport, used internally to check confirmation status. protected retryRpcClient: RpcFromTransport, RpcTransport>; - // Cached latest finalized slot and its publish timestamp. - latestFinalizedSlot = Number.MAX_SAFE_INTEGER; - publishTimestampLatestFinalizedSlot = 0; - maxAgeLatestFinalizedSlot = 1000 * BLOCK_NUMBER_TTL; - // Cached latest confirmed slot and its publish timestamp. latestConfirmedSlot = Number.MAX_SAFE_INTEGER; publishTimestampLatestConfirmedSlot = 0; @@ -69,16 +64,12 @@ export class CachedSolanaRpcFactory extends SolanaClusterRpcFactory { return this.retryTransport(...args); } - let latestFinalizedSlot = 0; let latestConfirmedSlot = 0; if (method === "getBlockTime") { - [latestFinalizedSlot, latestConfirmedSlot] = await Promise.all([ - this.getLatestFinalizedSlot(), - this.getLatestConfirmedSlot(), - ]); + latestConfirmedSlot = await this.getLatestConfirmedSlot(); } - const cacheType = this.cacheType(method, params ?? [], latestFinalizedSlot, latestConfirmedSlot); + const cacheType = this.cacheType(method, params ?? [], latestConfirmedSlot); if (cacheType === CacheType.NONE) { return this.retryTransport(...args); @@ -99,31 +90,18 @@ export class CachedSolanaRpcFactory extends SolanaClusterRpcFactory { }; } - private async getLatestFinalizedSlot(): Promise { - const fetchLatestFinalizedSlot = async () => { - return await this.retryRpcClient.getSlot({ commitment: "finalized" }).send(); - }; - if (this.latestFinalizedSlot === Number.MAX_SAFE_INTEGER) { - this.latestFinalizedSlot = Number(await fetchLatestFinalizedSlot()); - this.publishTimestampLatestFinalizedSlot = Date.now(); - return this.latestFinalizedSlot; - } - if (Date.now() - this.publishTimestampLatestFinalizedSlot > this.maxAgeLatestFinalizedSlot) { - this.latestFinalizedSlot = Number(await fetchLatestFinalizedSlot()); - this.publishTimestampLatestFinalizedSlot = Date.now(); - } - return this.latestFinalizedSlot; - } - private async getLatestConfirmedSlot(): Promise { const fetchLatestConfirmedSlot = async () => { return await this.retryRpcClient.getSlot({ commitment: "confirmed" }).send(); }; + // If first time fetching, always get and set the latest confirmed slot. if (this.latestConfirmedSlot === Number.MAX_SAFE_INTEGER) { this.latestConfirmedSlot = Number(await fetchLatestConfirmedSlot()); this.publishTimestampLatestConfirmedSlot = Date.now(); return this.latestConfirmedSlot; } + // If the last time we set the latest confirmed slot was more than maxAgeLatestConfirmedSlot ago, + // reset the latest confirmed slot. if (Date.now() - this.publishTimestampLatestConfirmedSlot > this.maxAgeLatestConfirmedSlot) { this.latestConfirmedSlot = Number(await fetchLatestConfirmedSlot()); this.publishTimestampLatestConfirmedSlot = Date.now(); @@ -176,9 +154,9 @@ export class CachedSolanaRpcFactory extends SolanaClusterRpcFactory { return response; } - // Cache the transaction JSON-RPC response as we checked the transaction is finalized and not an error. + // Cache the transaction JSON-RPC response as we checked the response data is mature enough and not an error. const redisKey = this.buildRedisKey(method, params); - // Apply a random margin to spread expiry over a larger time window. + // Apply a random margin to the standard TTL time to spread expiry over a larger time window. const standardTtl = this.baseTTL + Math.ceil(random(-ttl_modifier, ttl_modifier, true) * this.baseTTL); const ttl = cacheType === CacheType.WITH_TTL ? standardTtl : Number.POSITIVE_INFINITY; await this.redisClient?.set(redisKey, JSON.stringify(response, jsonReplacerWithBigInts), ttl); @@ -198,17 +176,10 @@ export class CachedSolanaRpcFactory extends SolanaClusterRpcFactory { } } - private cacheType( - method: string, - params: unknown[] = [], - latestFinalizedSlot = 0, - latestConfirmedSlot = 0 - ): CacheType { + private cacheType(method: string, params: unknown[] = [], latestConfirmedSlot = 0): CacheType { if (method === "getBlockTime") { const targetSlot = (params as Parameters)[0]; - if (targetSlot <= latestFinalizedSlot) { - return CacheType.NO_TTL; - } else if (targetSlot > latestFinalizedSlot && targetSlot <= latestConfirmedSlot) { + if (targetSlot <= latestConfirmedSlot) { return CacheType.WITH_TTL; } else { return CacheType.NONE; diff --git a/test/SolanaCachedProvider.ts b/test/SolanaCachedProvider.ts index 485142342..5c80f1632 100644 --- a/test/SolanaCachedProvider.ts +++ b/test/SolanaCachedProvider.ts @@ -83,172 +83,227 @@ describe("cached solana provider", () => { ).createRpcClient(); }); - it("caches finalized transaction", async () => { - // Prepare required mock results for finalized transaction. - mockRpcFactory.setResult("getSignatureStatuses", getSignatureStatusesParams, { - value: [{ confirmationStatus: "finalized" }], + describe("getTransaction", () => { + 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); }); - 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" }], + 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; }); - 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); + 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)); + 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; + // 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)); + 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; - }); + // 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; + }); - it("does not cache error responses", async () => { - // Prepare required mock responses. - mockRpcFactory.setResult("getSignatureStatuses", getSignatureStatusesParams, { - value: [{ confirmationStatus: "finalized" }], + it("does not cache error responses", async () => { + // Prepare required mock responses. + mockRpcFactory.setResult("getSignatureStatuses", getSignatureStatusesParams, { + value: [{ confirmationStatus: "finalized" }], + }); + mockRpcFactory.setError("getTransaction", [testSignature, getTransactionConfig], jsonRpcError); + + try { + await cachedRpcClient.getTransaction(testSignature, getTransactionConfig).send(); + expect.fail("Expected an error to be thrown"); + } catch (error) { + expect(error.context.__code).to.equal(errorCode); + expect(error.context.__serverMessage).to.equal(errorMessage); + } + + // 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; }); - mockRpcFactory.setError("getTransaction", [testSignature, getTransactionConfig], jsonRpcError); - - try { - await cachedRpcClient.getTransaction(testSignature, getTransactionConfig).send(); - expect.fail("Expected an error to be thrown"); - } catch (error) { - expect(error.context.__code).to.equal(errorCode); - expect(error.context.__serverMessage).to.equal(errorMessage); - } - - // 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; - }); - it("does not cache when json-rpc error in getting signature status", async () => { - // Prepare required mock responses. - mockRpcFactory.setError("getSignatureStatuses", getSignatureStatusesParams, jsonRpcError); - mockRpcFactory.setResult("getTransaction", [testSignature, getTransactionConfig], getTransactionResult); - - // Only the getSignatureStatuses call returns error, the getTransaction call should still succeed. - 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 when json-rpc error in getting signature status", async () => { + // Prepare required mock responses. + mockRpcFactory.setError("getSignatureStatuses", getSignatureStatusesParams, jsonRpcError); + mockRpcFactory.setResult("getTransaction", [testSignature, getTransactionConfig], getTransactionResult); + + // Only the getSignatureStatuses call returns error, the getTransaction call should still succeed. + 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 when thrown in getting signature status", async () => { - // Prepare required mock responses. - const throwMessage = "test throw message"; - mockRpcFactory.setThrow("getSignatureStatuses", getSignatureStatusesParams, throwMessage); - mockRpcFactory.setResult("getTransaction", [testSignature, getTransactionConfig], getTransactionResult); - - // Only the getSignatureStatuses call throws, the getTransaction call should still succeed. - 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 when thrown in getting signature status", async () => { + // Prepare required mock responses. + const throwMessage = "test throw message"; + mockRpcFactory.setThrow("getSignatureStatuses", getSignatureStatusesParams, throwMessage); + mockRpcFactory.setResult("getTransaction", [testSignature, getTransactionConfig], getTransactionResult); + + // Only the getSignatureStatuses call throws, the getTransaction call should still succeed. + 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; + }); }); + describe("getBlockTime", () => { + it("caches confirmed block time", async () => { + // Set confirmed slot to be >= than block we're going to query: + mockRpcFactory.setResult("getSlot", [{ commitment: "confirmed" }], 800); + + // Set block time to 1234567890. + mockRpcFactory.setResult("getBlockTime", [800], 1234567890); + + const result = await cachedRpcClient.getBlockTime(800n).send(); + expect(result).to.equal(1234567890n); + + // Check the cache is set. + const cacheKey = `${providerCacheNamespace},${new URL(url).hostname},${chainId}:getBlockTime,[800]`; + const cacheValue = JSON.parse((await memoryCache.get(cacheKey)) || "{}", jsonReviverWithBigInts); + expect(cacheValue).to.have.property("result"); + expect(cacheValue.result).to.equal(1234567890); + }); + it("does not cache unconfirmed block time", async () => { + // Set confirmed slot to be lower than block we're going to query: + mockRpcFactory.setResult("getSlot", [{ commitment: "confirmed" }], 799); + + mockRpcFactory.setResult("getBlockTime", [800], 1234567890); + + const result = await cachedRpcClient.getBlockTime(800n).send(); + expect(result).to.equal(1234567890n); + + // Check the cache is empty. + const cacheKey = `${providerCacheNamespace},${new URL(url).hostname},${chainId}:getBlockTime,[800]`; + const cacheValue = JSON.parse((await memoryCache.get(cacheKey)) || "{}", jsonReviverWithBigInts); + expect(cacheValue).to.be.empty; + }); + + it("does not cache error responses", async () => { + // Set confirmed slot to be >= than block we're going to query: + mockRpcFactory.setResult("getSlot", [{ commitment: "confirmed" }], 800); + + // Prepare required mock responses. + mockRpcFactory.setError("getBlockTime", [800], jsonRpcError); + + try { + await cachedRpcClient.getBlockTime(800n).send(); + expect.fail("Expected an error to be thrown"); + } catch (error) { + expect(error.context.__code).to.equal(errorCode); + expect(error.context.__serverMessage).to.equal(errorMessage); + } + + // Check the cache is empty. + const cacheKey = `${providerCacheNamespace},${new URL(url).hostname},${chainId}:getBlockTime,[800]`; + const cacheValue = JSON.parse((await memoryCache.get(cacheKey)) || "{}", jsonReviverWithBigInts); + expect(cacheValue).to.be.empty; + }); + }); // TODO: Add tests for getBlockTime. });