From 13d2e9797ec0a36272712f31cd08bc16600a930b Mon Sep 17 00:00:00 2001 From: connelblaze Date: Mon, 31 Aug 2026 10:18:42 +0100 Subject: [PATCH] TTL-Cached getBalance --- src/client.ts | 29 ++++++++++++++++++++-- src/types.ts | 11 +++++++++ src/utils/cache.ts | 40 +++++++++++++++++++++++++++++++ src/utils/index.ts | 1 + tests/client.test.ts | 57 ++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 136 insertions(+), 2 deletions(-) create mode 100644 src/utils/cache.ts diff --git a/src/client.ts b/src/client.ts index 5ca697d..9017eff 100644 --- a/src/client.ts +++ b/src/client.ts @@ -3,6 +3,16 @@ import { HORIZON_URLS, SOROBAN_RPC_URLS, DEFAULT_NETWORK, SDK_VERSION } from './ import { TrustFlowError } from './errors'; import type { Network, ClientConfig } from './types'; import { IPFSStorage } from './storage'; +import { SimpleCache } from './utils/cache'; + +/** Default TTL for opt-in Horizon balance caching. */ +export const DEFAULT_BALANCE_CACHE_TTL_MS = 5_000; + +/** Controls cache behavior for a single balance lookup. */ +export interface GetBalanceOptions { + /** Fetch from Horizon even when a non-expired cached balance is available. */ + skipCache?: boolean; +} /** * TrustFlowClient is the main entry point for interacting with the TrustFlow Protocol. @@ -10,6 +20,7 @@ import { IPFSStorage } from './storage'; */ export class TrustFlowClient { private server: Horizon.Server; + private readonly balanceCache?: SimpleCache; private _connected: boolean = false; readonly network: Network; @@ -57,6 +68,9 @@ export class TrustFlowClient { this.apiBaseUrl = config.apiBaseUrl; this.apiKey = config.apiKey; this.storage = new IPFSStorage(config.ipfs); + this.balanceCache = config.balanceCache + ? new SimpleCache(config.balanceCache.ttlMs ?? DEFAULT_BALANCE_CACHE_TTL_MS) + : undefined; this.server = new Horizon.Server(HORIZON_URLS[this.network]); } @@ -97,6 +111,7 @@ export class TrustFlowClient { * Retrieves the native XLM balance for a given Stellar address. * * @param address - Stellar public key (G... address) + * @param options - Set `skipCache` to bypass a configured balance cache. * @returns Balance in XLM as a string * @throws {TrustFlowError} If the account doesn't exist or network error occurs * @@ -104,15 +119,25 @@ export class TrustFlowClient { * ```typescript * const balance = await client.getBalance('GDEPOSITOR...'); * console.log(`Balance: ${balance} XLM`); + * + * // When `balanceCache` is configured, force a fresh Horizon lookup: + * const freshBalance = await client.getBalance('GDEPOSITOR...', { skipCache: true }); * ``` */ - async getBalance(address: string): Promise { + async getBalance(address: string, options: GetBalanceOptions = {}): Promise { + if (!options.skipCache) { + const cachedBalance = this.balanceCache?.get(address); + if (cachedBalance !== undefined) return cachedBalance; + } + try { const account = await this.server.loadAccount(address); const native = account.balances.find( (b: { asset_type: string }) => b.asset_type === 'native', ); - return native?.balance ?? '0'; + const balance = native?.balance ?? '0'; + this.balanceCache?.set(address, balance); + return balance; } catch (error) { throw new TrustFlowError( `Failed to fetch balance for ${address}`, diff --git a/src/types.ts b/src/types.ts index 4d27e65..c6c5d47 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,12 +2,23 @@ import type { IPFSConfig } from './storage'; export type Network = 'TESTNET' | 'MAINNET'; +/** Options for opt-in caching of Horizon balance lookups. */ +export interface BalanceCacheConfig { + /** Cache lifetime in milliseconds. Defaults to 5 seconds when caching is enabled. */ + ttlMs?: number; +} + export interface ClientConfig { network?: Network; contractId: string; rpcUrl?: string; apiBaseUrl?: string; apiKey?: string; + /** + * Enables short-lived caching for `getBalance` calls. Omit this option to + * preserve the default behavior of fetching every balance from Horizon. + */ + balanceCache?: BalanceCacheConfig; /** Optional configuration for the built-in `storage.upload()` IPFS helper. */ ipfs?: IPFSConfig; } diff --git a/src/utils/cache.ts b/src/utils/cache.ts new file mode 100644 index 0000000..ef8e551 --- /dev/null +++ b/src/utils/cache.ts @@ -0,0 +1,40 @@ +/** + * A small in-memory cache that expires entries after a configurable TTL. + * Expired values are removed lazily when they are read. + */ +export class SimpleCache { + private readonly entries = new Map(); + + /** + * @param defaultTtlMs - Lifetime used when `set` does not receive a TTL. + */ + constructor(private readonly defaultTtlMs: number) {} + + /** Returns a cached value, or `undefined` when it is missing or expired. */ + get(key: K): V | undefined { + const entry = this.entries.get(key); + if (!entry) return undefined; + + if (entry.expiresAt <= Date.now()) { + this.entries.delete(key); + return undefined; + } + + return entry.value; + } + + /** Stores a value for the supplied TTL. A non-positive TTL does not cache it. */ + set(key: K, value: V, ttlMs = this.defaultTtlMs): void { + if (ttlMs <= 0) { + this.entries.delete(key); + return; + } + + this.entries.set(key, { value, expiresAt: Date.now() + ttlMs }); + } + + /** Removes all cached values. */ + clear(): void { + this.entries.clear(); + } +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 4517d2d..8d80826 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -3,3 +3,4 @@ export * from './format'; export * from './retry'; export * from './logger'; export * from './http'; +export * from './cache'; diff --git a/tests/client.test.ts b/tests/client.test.ts index 24a9fec..2d283d4 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -64,6 +64,63 @@ describe('TrustFlowClient', () => { }); }); + describe('getBalance caching', () => { + const address = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'; + + afterEach(() => { + jest.useRealTimers(); + }); + + function mockBalanceLookup(client: TrustFlowClient, balance = '42.0000000') { + return jest + .spyOn(client.getServer(), 'loadAccount') + .mockResolvedValue({ + balances: [{ asset_type: 'native', balance }], + } as any); + } + + it('uses a cached balance within the configured TTL', async () => { + const client = new TrustFlowClient({ + contractId: mockContractId, + balanceCache: { ttlMs: 5_000 }, + }); + const loadAccount = mockBalanceLookup(client); + + await expect(client.getBalance(address)).resolves.toBe('42.0000000'); + await expect(client.getBalance(address)).resolves.toBe('42.0000000'); + + expect(loadAccount).toHaveBeenCalledTimes(1); + }); + + it('fetches a new balance after the cache TTL expires', async () => { + jest.useFakeTimers(); + const client = new TrustFlowClient({ + contractId: mockContractId, + balanceCache: { ttlMs: 5_000 }, + }); + const loadAccount = mockBalanceLookup(client); + + await client.getBalance(address); + jest.advanceTimersByTime(5_001); + await client.getBalance(address); + + expect(loadAccount).toHaveBeenCalledTimes(2); + }); + + it('bypasses a cached balance when skipCache is requested', async () => { + const client = new TrustFlowClient({ + contractId: mockContractId, + balanceCache: {}, + }); + const loadAccount = mockBalanceLookup(client); + + await client.getBalance(address); + await client.getBalance(address, { skipCache: true }); + + expect(loadAccount).toHaveBeenCalledTimes(2); + }); + }); + describe('getNetworkPassphrase', () => { it('should return testnet passphrase', () => { const client = new TrustFlowClient({