Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 27 additions & 2 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@ import {
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;
}
import { createContractBinding, SorobanContractClient } from './contract';


Expand All @@ -18,6 +28,7 @@ import { createContractBinding, SorobanContractClient } from './contract';
*/
export class TrustFlowClient {
private server: Horizon.Server;
private readonly balanceCache?: SimpleCache<string, string>;
private _connected: boolean = false;

readonly network: Network;
Expand Down Expand Up @@ -65,6 +76,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]);
}
Expand Down Expand Up @@ -105,22 +119,33 @@ 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
*
* @example
* ```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<string> {
async getBalance(address: string, options: GetBalanceOptions = {}): Promise<string> {
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}`,
Expand Down
11 changes: 11 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
40 changes: 40 additions & 0 deletions src/utils/cache.ts
Original file line number Diff line number Diff line change
@@ -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<K, V> {
private readonly entries = new Map<K, { value: V; expiresAt: number }>();

/**
* @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();
}
}
1 change: 1 addition & 0 deletions src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export * from './format';
export * from './retry';
export * from './logger';
export * from './http';
export * from './cache';
57 changes: 57 additions & 0 deletions tests/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down