diff --git a/sdk/src/cache.test.ts b/sdk/src/cache.test.ts new file mode 100644 index 0000000..5597ff1 --- /dev/null +++ b/sdk/src/cache.test.ts @@ -0,0 +1,522 @@ +/** + * @bc-forge/sdk — Tests for CacheManager + */ + +import { CacheManager, DEFAULT_CACHE_CONFIG } from './cache'; + +// ─── Construction ───────────────────────────────────────────────────────────── + +describe('CacheManager construction', () => { + it('uses DEFAULT_CACHE_CONFIG when no config provided', () => { + const cache = new CacheManager(); + const m = cache.getMetrics(); + expect(m.size).toBe(0); + expect(m.hits).toBe(0); + expect(m.misses).toBe(0); + }); + + it('merges partial config with defaults', () => { + const cache = new CacheManager({ maxSize: 5 }); + // fill past default max to verify custom maxSize is respected + for (let i = 0; i < 6; i++) cache.set(`k${i}`, i); + expect(cache.getMetrics().size).toBe(5); + }); +}); + +// ─── Basic get/set ──────────────────────────────────────────────────────────── + +describe('get / set', () => { + let cache: CacheManager; + + beforeEach(() => { + cache = new CacheManager({ defaultTtlMs: 60_000 }); + }); + + it('returns stored value', () => { + cache.set('a', 42); + expect(cache.get('a')).toBe(42); + }); + + it('returns undefined for missing key', () => { + expect(cache.get('missing')).toBeUndefined(); + }); + + it('tracks hits and misses', () => { + cache.set('x', 'hello'); + cache.get('x'); // hit + cache.get('y'); // miss + const m = cache.getMetrics(); + expect(m.hits).toBe(1); + expect(m.misses).toBe(1); + }); + + it('hitRate is NaN before any lookups', () => { + expect(Number.isNaN(cache.getMetrics().hitRate)).toBe(true); + }); + + it('hitRate is correct after mixed lookups', () => { + cache.set('k', 1); + cache.get('k'); // hit + cache.get('k'); // hit + cache.get('z'); // miss + expect(cache.getMetrics().hitRate).toBeCloseTo(2 / 3); + }); + + it('overwrites existing entry', () => { + cache.set('k', 1); + cache.set('k', 2); + expect(cache.get('k')).toBe(2); + expect(cache.getMetrics().size).toBe(1); + }); + + it('stores bigint values', () => { + cache.set('big', 123456789012345678901234567890n); + expect(cache.get('big')).toBe(123456789012345678901234567890n); + }); +}); + +// ─── TTL ───────────────────────────────────────────────────────────────────── + +describe('TTL expiry', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + it('returns value before expiry', () => { + const cache = new CacheManager({ defaultTtlMs: 1000 }); + cache.set('k', 'alive'); + jest.advanceTimersByTime(999); + expect(cache.get('k')).toBe('alive'); + }); + + it('returns undefined after expiry', () => { + const cache = new CacheManager({ defaultTtlMs: 1000 }); + cache.set('k', 'alive'); + jest.advanceTimersByTime(1001); + expect(cache.get('k')).toBeUndefined(); + }); + + it('per-entry TTL overrides the default', () => { + const cache = new CacheManager({ defaultTtlMs: 10_000 }); + cache.set('short', 'value', 500); + jest.advanceTimersByTime(501); + expect(cache.get('short')).toBeUndefined(); + }); + + it('ttlMs=0 stores entry with no expiry', () => { + const cache = new CacheManager({ defaultTtlMs: 1000 }); + cache.set('forever', 99, 0); + jest.advanceTimersByTime(99_999); + expect(cache.get('forever')).toBe(99); + }); + + it('expired entries are excluded from size', () => { + const cache = new CacheManager({ defaultTtlMs: 500 }); + cache.set('a', 1); + jest.advanceTimersByTime(600); + cache.get('a'); // triggers lazy removal + expect(cache.getMetrics().size).toBe(0); + }); +}); + +// ─── LRU eviction ──────────────────────────────────────────────────────────── + +describe('LRU eviction', () => { + it('evicts the least-recently-used entry when full', () => { + const cache = new CacheManager({ maxSize: 3, defaultTtlMs: 0 }); + cache.set('a', 1); + cache.set('b', 2); + cache.set('c', 3); + // Access 'a' to make it MRU; 'b' is now LRU + cache.get('a'); + cache.set('d', 4); // should evict 'b' + expect(cache.get('b')).toBeUndefined(); + expect(cache.get('a')).toBe(1); + expect(cache.get('c')).toBe(3); + expect(cache.get('d')).toBe(4); + }); + + it('increments evictions counter', () => { + const cache = new CacheManager({ maxSize: 2, defaultTtlMs: 0 }); + cache.set('a', 1); + cache.set('b', 2); + cache.set('c', 3); // evicts 'a' + expect(cache.getMetrics().evictions).toBe(1); + }); + + it('updating an existing key does not evict', () => { + const cache = new CacheManager({ maxSize: 2, defaultTtlMs: 0 }); + cache.set('a', 1); + cache.set('b', 2); + cache.set('a', 99); // update, not insert + expect(cache.getMetrics().evictions).toBe(0); + expect(cache.getMetrics().size).toBe(2); + expect(cache.get('a')).toBe(99); + }); + + it('caps size at maxSize', () => { + const cache = new CacheManager({ maxSize: 5, defaultTtlMs: 0 }); + for (let i = 0; i < 10; i++) cache.set(`k${i}`, i); + expect(cache.getMetrics().size).toBe(5); + }); +}); + +// ─── has() ──────────────────────────────────────────────────────────────────── + +describe('has()', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + it('returns true for live entries', () => { + const cache = new CacheManager({ defaultTtlMs: 5000 }); + cache.set('k', 1); + expect(cache.has('k')).toBe(true); + }); + + it('returns false for missing keys', () => { + const cache = new CacheManager(); + expect(cache.has('missing')).toBe(false); + }); + + it('returns false for expired entries', () => { + const cache = new CacheManager({ defaultTtlMs: 500 }); + cache.set('k', 1); + jest.advanceTimersByTime(600); + expect(cache.has('k')).toBe(false); + }); +}); + +// ─── Invalidation ───────────────────────────────────────────────────────────── + +describe('invalidate()', () => { + it('removes an existing entry and returns true', () => { + const cache = new CacheManager(); + cache.set('k', 1); + expect(cache.invalidate('k')).toBe(true); + expect(cache.get('k')).toBeUndefined(); + }); + + it('returns false for non-existent key', () => { + const cache = new CacheManager(); + expect(cache.invalidate('ghost')).toBe(false); + }); +}); + +describe('invalidateByPrefix()', () => { + it('removes all matching entries', () => { + const cache = new CacheManager(); + cache.set('balance:addr1', 100n); + cache.set('balance:addr2', 200n); + cache.set('supply', 300n); + const removed = cache.invalidateByPrefix('balance:'); + expect(removed).toBe(2); + expect(cache.get('balance:addr1')).toBeUndefined(); + expect(cache.get('balance:addr2')).toBeUndefined(); + expect(cache.get('supply')).toBe(300n); + }); + + it('returns 0 when no keys match', () => { + const cache = new CacheManager(); + cache.set('other:key', 1); + expect(cache.invalidateByPrefix('balance:')).toBe(0); + }); +}); + +describe('invalidateAll()', () => { + it('clears all entries', () => { + const cache = new CacheManager(); + cache.set('a', 1); + cache.set('b', 2); + cache.invalidateAll(); + expect(cache.getMetrics().size).toBe(0); + expect(cache.get('a')).toBeUndefined(); + }); +}); + +// ─── Cache warming ──────────────────────────────────────────────────────────── + +describe('warmUp()', () => { + it('pre-populates entries', () => { + const cache = new CacheManager({ defaultTtlMs: 60_000 }); + cache.warmUp([ + { key: 'balance:addr1', value: 500n }, + { key: 'supply', value: 1000n }, + ]); + expect(cache.get('balance:addr1')).toBe(500n); + expect(cache.get('supply')).toBe(1000n); + expect(cache.getMetrics().size).toBe(2); + }); + + it('respects per-entry ttlMs override', () => { + jest.useFakeTimers(); + const cache = new CacheManager({ defaultTtlMs: 60_000 }); + cache.warmUp([{ key: 'k', value: 1, ttlMs: 200 }]); + jest.advanceTimersByTime(201); + expect(cache.get('k')).toBeUndefined(); + jest.useRealTimers(); + }); + + it('does not count warm entries as cache hits', () => { + const cache = new CacheManager(); + cache.warmUp([{ key: 'k', value: 42 }]); + // warmUp calls set(), not get(), so metrics should be clean + expect(cache.getMetrics().hits).toBe(0); + expect(cache.getMetrics().misses).toBe(0); + }); +}); + +// ─── Metrics ───────────────────────────────────────────────────────────────── + +describe('resetMetrics()', () => { + it('zeroes counters without clearing entries', () => { + const cache = new CacheManager(); + cache.set('k', 1); + cache.get('k'); + cache.get('z'); + cache.resetMetrics(); + const m = cache.getMetrics(); + expect(m.hits).toBe(0); + expect(m.misses).toBe(0); + expect(m.evictions).toBe(0); + expect(m.size).toBe(1); // entry still there + }); +}); + +// ─── localStorage persistence ───────────────────────────────────────────────── + +describe('localStorage persistence', () => { + const storageKey = 'test-cache-key'; + let mockStorage: Record; + + beforeEach(() => { + mockStorage = {}; + Object.defineProperty(global, 'localStorage', { + value: { + getItem: (k: string) => mockStorage[k] ?? null, + setItem: (k: string, v: string) => { mockStorage[k] = v; }, + removeItem: (k: string) => { delete mockStorage[k]; }, + }, + writable: true, + configurable: true, + }); + }); + + afterEach(() => { + // Remove the mock + Object.defineProperty(global, 'localStorage', { value: undefined, writable: true, configurable: true }); + }); + + it('saves entries to localStorage on set', () => { + const cache = new CacheManager({ persistToLocalStorage: true, storageKey, defaultTtlMs: 0 }); + cache.set('k', 'hello'); + expect(mockStorage[storageKey]).toBeDefined(); + const parsed = JSON.parse(mockStorage[storageKey]); + expect(Array.isArray(parsed)).toBe(true); + expect(parsed[0][0]).toBe('k'); + }); + + it('restores entries on construction', () => { + const cache1 = new CacheManager({ persistToLocalStorage: true, storageKey, defaultTtlMs: 0 }); + cache1.set('persisted', 99); + + const cache2 = new CacheManager({ persistToLocalStorage: true, storageKey, defaultTtlMs: 0 }); + expect(cache2.get('persisted')).toBe(99); + }); + + it('serializes and restores bigint values', () => { + const cache1 = new CacheManager({ persistToLocalStorage: true, storageKey, defaultTtlMs: 0 }); + cache1.set('bal', 9007199254740993n); // > Number.MAX_SAFE_INTEGER + + const cache2 = new CacheManager({ persistToLocalStorage: true, storageKey, defaultTtlMs: 0 }); + expect(cache2.get('bal')).toBe(9007199254740993n); + }); + + it('does not restore expired entries', () => { + jest.useFakeTimers(); + const cache1 = new CacheManager({ persistToLocalStorage: true, storageKey, defaultTtlMs: 1000 }); + cache1.set('stale', 'x'); + jest.advanceTimersByTime(1100); // expire before next cache loads + + const cache2 = new CacheManager({ persistToLocalStorage: true, storageKey }); + expect(cache2.get('stale')).toBeUndefined(); + jest.useRealTimers(); + }); + + it('clears localStorage on invalidateAll', () => { + const cache = new CacheManager({ persistToLocalStorage: true, storageKey, defaultTtlMs: 0 }); + cache.set('k', 1); + cache.invalidateAll(); + expect(mockStorage[storageKey]).toBeUndefined(); + }); + + it('does not persist when persistToLocalStorage is false', () => { + const cache = new CacheManager({ persistToLocalStorage: false, storageKey }); + cache.set('k', 1); + expect(mockStorage[storageKey]).toBeUndefined(); + }); +}); + +// ─── bcForgeClient cache integration ───────────────────────────────────────── + +import { bcForgeClient } from './client'; +import { Keypair as _Keypair } from '@stellar/stellar-sdk'; + +describe('bcForgeClient cache integration', () => { + const MOCK_RPC = 'https://soroban-testnet.stellar.org'; + const MOCK_PASSPHRASE = 'Test SDF Network ; September 2015'; + const MOCK_CONTRACT = 'CAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQC526'; + const ADDR_A = _Keypair.random().publicKey(); + const ADDR_B = _Keypair.random().publicKey(); + + function makeClient(): bcForgeClient { + return new bcForgeClient({ + rpcUrl: MOCK_RPC, + networkPassphrase: MOCK_PASSPHRASE, + contractId: MOCK_CONTRACT, + cacheConfig: { defaultTtlMs: 60_000 }, + }); + } + + it('getCacheMetrics returns undefined without cacheConfig', () => { + const client = new bcForgeClient({ + rpcUrl: MOCK_RPC, + networkPassphrase: MOCK_PASSPHRASE, + contractId: MOCK_CONTRACT, + }); + expect(client.getCacheMetrics()).toBeUndefined(); + }); + + it('getCacheMetrics returns metrics with cacheConfig', () => { + const client = makeClient(); + const m = client.getCacheMetrics(); + expect(m).toBeDefined(); + expect(m!.hits).toBe(0); + }); + + it('getBalance returns cached value on second call', async () => { + const client = makeClient(); + const queryContract = jest.fn().mockResolvedValue({ + toXDR: () => Buffer.alloc(0), + }); + // Seed the cache manually to simulate a cached balance + const cache: CacheManager = (client as any).cache; + cache.set(`balance:${ADDR_A}`, 1000n); + + // Now getBalance should return the cached value without calling queryContract + const patchedQueryContract = jest.fn(); + (client as any).queryContract = patchedQueryContract; + + const result = await client.getBalance(ADDR_A); + expect(result).toBe(1000n); + expect(patchedQueryContract).not.toHaveBeenCalled(); + + const metrics = client.getCacheMetrics()!; + expect(metrics.hits).toBeGreaterThanOrEqual(1); + }); + + it('clearCache empties all entries', async () => { + const client = makeClient(); + const cache: CacheManager = (client as any).cache; + cache.set('supply', 5000n); + cache.set(`balance:${ADDR_A}`, 100n); + client.clearCache(); + expect(cache.getMetrics().size).toBe(0); + }); + + it('warmUpCache pre-populates the cache', () => { + const client = makeClient(); + client.warmUpCache([ + { key: `balance:${ADDR_A}`, value: 999n }, + { key: 'supply', value: 5000n }, + ]); + const cache: CacheManager = (client as any).cache; + expect(cache.get(`balance:${ADDR_A}`)).toBe(999n); + expect(cache.get('supply')).toBe(5000n); + }); + + it('invalidates balance and supply after successful mint', async () => { + const client = makeClient(); + const cache: CacheManager = (client as any).cache; + cache.set(`balance:${ADDR_A}`, 100n); + cache.set('supply', 500n); + + // Mock invokeContract to return success + (client as any).invokeContract = jest.fn().mockResolvedValue({ success: true, hash: 'h1' }); + + const { Keypair } = await import('@stellar/stellar-sdk'); + await client.mint(ADDR_A, 50n, Keypair.random()); + + expect(cache.get(`balance:${ADDR_A}`)).toBeUndefined(); + expect(cache.get('supply')).toBeUndefined(); + }); + + it('does NOT invalidate on failed mint', async () => { + const client = makeClient(); + const cache: CacheManager = (client as any).cache; + cache.set(`balance:${ADDR_A}`, 100n); + cache.set('supply', 500n); + + (client as any).invokeContract = jest.fn().mockResolvedValue({ success: false, hash: 'h1' }); + + const { Keypair } = await import('@stellar/stellar-sdk'); + await client.mint(ADDR_A, 50n, Keypair.random()); + + expect(cache.get(`balance:${ADDR_A}`)).toBe(100n); + expect(cache.get('supply')).toBe(500n); + }); + + it('invalidates sender and receiver balances after transfer', async () => { + const client = makeClient(); + const cache: CacheManager = (client as any).cache; + cache.set(`balance:${ADDR_A}`, 100n); + cache.set(`balance:${ADDR_B}`, 200n); + + (client as any).invokeContract = jest.fn().mockResolvedValue({ success: true, hash: 'h1' }); + + const { Keypair } = await import('@stellar/stellar-sdk'); + await client.transfer(ADDR_A, ADDR_B, 10n, Keypair.random()); + + expect(cache.get(`balance:${ADDR_A}`)).toBeUndefined(); + expect(cache.get(`balance:${ADDR_B}`)).toBeUndefined(); + }); + + it('invalidates allowance after approve', async () => { + const client = makeClient(); + const cache: CacheManager = (client as any).cache; + cache.set(`allowance:${ADDR_A}:${ADDR_B}`, 50n); + + (client as any).invokeContract = jest.fn().mockResolvedValue({ success: true, hash: 'h1' }); + + const { Keypair } = await import('@stellar/stellar-sdk'); + await client.approve(ADDR_A, ADDR_B, 100n, Keypair.random()); + + expect(cache.get(`allowance:${ADDR_A}:${ADDR_B}`)).toBeUndefined(); + }); + + it('invalidates balance and supply after burn', async () => { + const client = makeClient(); + const cache: CacheManager = (client as any).cache; + cache.set(`balance:${ADDR_A}`, 100n); + cache.set('supply', 500n); + + (client as any).invokeContract = jest.fn().mockResolvedValue({ success: true, hash: 'h1' }); + + const { Keypair } = await import('@stellar/stellar-sdk'); + await client.burn(ADDR_A, 20n, Keypair.random()); + + expect(cache.get(`balance:${ADDR_A}`)).toBeUndefined(); + expect(cache.get('supply')).toBeUndefined(); + }); + + it('invalidates name after updateName', async () => { + const client = makeClient(); + const cache: CacheManager = (client as any).cache; + cache.set('name', 'OldName'); + + (client as any).invokeContract = jest.fn().mockResolvedValue({ success: true, hash: 'h1' }); + + const { Keypair } = await import('@stellar/stellar-sdk'); + await client.updateName('NewName', Keypair.random()); + + expect(cache.get('name')).toBeUndefined(); + }); +}); diff --git a/sdk/src/cache.ts b/sdk/src/cache.ts new file mode 100644 index 0000000..3c31189 --- /dev/null +++ b/sdk/src/cache.ts @@ -0,0 +1,322 @@ +/** + * @bc-forge/sdk — In-memory caching layer for read-only contract queries. + * + * Features: + * - LRU eviction with O(1) get/set using Map insertion-order + * - Per-entry and global TTL + * - Hit/miss/eviction metrics + * - Optional localStorage persistence (browser environments) + * - Cache warming (pre-populate with known values) + * - Prefix-based and full invalidation for post-write consistency + */ + +// ─── Interfaces ─────────────────────────────────────────────────────────────── + +export interface CacheEntry { + value: T; + /** Absolute epoch-ms expiry. 0 = no expiry. */ + expiresAt: number; + createdAt: number; + /** Number of times this entry has been read from cache. */ + hits: number; +} + +export interface CacheMetrics { + /** Total cache hits since last reset. */ + hits: number; + /** Total cache misses since last reset. */ + misses: number; + /** Total LRU evictions since last reset. */ + evictions: number; + /** Current number of entries in the cache. */ + size: number; + /** Ratio of hits to total lookups (0–1). NaN when no lookups yet. */ + hitRate: number; +} + +export interface CacheWarmEntry { + key: string; + value: T; + /** Override TTL for this entry (ms). Uses config default when omitted. */ + ttlMs?: number; +} + +export interface CacheConfig { + /** + * Default TTL in milliseconds for new entries. + * 0 disables expiry (entries live until evicted or manually invalidated). + * Default: 30 000 (30 s) + */ + defaultTtlMs: number; + /** + * Maximum number of live entries. When full, the least-recently-used + * entry is evicted on the next set(). Default: 100. + */ + maxSize: number; + /** + * Persist the cache to localStorage across page reloads (browser only). + * Gracefully no-ops in Node / SSR environments. Default: false. + */ + persistToLocalStorage: boolean; + /** + * localStorage key used to save/restore this cache instance. + * Default: 'bc-forge-sdk-cache' + */ + storageKey: string; +} + +export const DEFAULT_CACHE_CONFIG: CacheConfig = { + defaultTtlMs: 30_000, + maxSize: 100, + persistToLocalStorage: false, + storageKey: 'bc-forge-sdk-cache', +}; + +// ─── CacheManager ───────────────────────────────────────────────────────────── + +export class CacheManager { + private readonly entries: Map> = new Map(); + private readonly config: CacheConfig; + private _hits = 0; + private _misses = 0; + private _evictions = 0; + + constructor(config?: Partial) { + this.config = { ...DEFAULT_CACHE_CONFIG, ...(config ?? {}) }; + if (this.config.persistToLocalStorage) { + this.loadFromStorage(); + } + } + + // ─── Core get/set ────────────────────────────────────────────────────────── + + /** + * Retrieve a cached value. Returns `undefined` on miss or expiry. + * Promotes the entry to most-recently-used on hit. + */ + get(key: string): T | undefined { + const entry = this.entries.get(key) as CacheEntry | undefined; + if (!entry) { + this._misses++; + return undefined; + } + if (this.isExpired(entry)) { + this.entries.delete(key); + this._misses++; + return undefined; + } + // Promote to MRU by reinserting at the end of the Map. + this.entries.delete(key); + entry.hits++; + this.entries.set(key, entry); + this._hits++; + return entry.value; + } + + /** + * Store a value. Evicts the LRU entry if the cache is at capacity. + * + * @param key - Cache key + * @param value - Value to store + * @param ttlMs - Override TTL in ms. Uses `defaultTtlMs` when omitted. + * Pass `0` explicitly to store with no expiry. + */ + set(key: string, value: T, ttlMs?: number): void { + // If the key already exists, remove it first so we can re-insert at the end. + if (this.entries.has(key)) { + this.entries.delete(key); + } else if (this.entries.size >= this.config.maxSize) { + this.evictLRU(); + } + + const effectiveTtl = ttlMs !== undefined ? ttlMs : this.config.defaultTtlMs; + const expiresAt = effectiveTtl > 0 ? Date.now() + effectiveTtl : 0; + + this.entries.set(key, { + value, + expiresAt, + createdAt: Date.now(), + hits: 0, + }); + + this.persistIfEnabled(); + } + + /** + * Return true if there is a live (non-expired) entry for `key`. + */ + has(key: string): boolean { + const entry = this.entries.get(key); + if (!entry) return false; + if (this.isExpired(entry)) { + this.entries.delete(key); + return false; + } + return true; + } + + // ─── Invalidation ────────────────────────────────────────────────────────── + + /** + * Remove a single entry. Returns true if the key existed. + */ + invalidate(key: string): boolean { + const deleted = this.entries.delete(key); + if (deleted) this.persistIfEnabled(); + return deleted; + } + + /** + * Remove all entries whose keys start with `prefix`. + * Returns the number of entries removed. + * + * @example + * cache.invalidateByPrefix('balance:') // removes all per-address balances + */ + invalidateByPrefix(prefix: string): number { + let count = 0; + for (const key of this.entries.keys()) { + if (key.startsWith(prefix)) { + this.entries.delete(key); + count++; + } + } + if (count > 0) this.persistIfEnabled(); + return count; + } + + /** + * Remove all cached entries and clear persisted storage. + */ + invalidateAll(): void { + this.entries.clear(); + this.clearStorage(); + } + + // ─── Cache warming ───────────────────────────────────────────────────────── + + /** + * Pre-populate the cache with a set of known values. + * Useful for server-side rendering or seeding from a trusted data source. + */ + warmUp(warmEntries: CacheWarmEntry[]): void { + for (const { key, value, ttlMs } of warmEntries) { + this.set(key, value, ttlMs); + } + } + + // ─── Metrics ─────────────────────────────────────────────────────────────── + + /** + * Return a snapshot of cache performance metrics. + */ + getMetrics(): CacheMetrics { + const total = this._hits + this._misses; + return { + hits: this._hits, + misses: this._misses, + evictions: this._evictions, + size: this.entries.size, + hitRate: total > 0 ? this._hits / total : NaN, + }; + } + + /** + * Reset hit/miss/eviction counters without clearing the cache. + */ + resetMetrics(): void { + this._hits = 0; + this._misses = 0; + this._evictions = 0; + } + + // ─── Internals ───────────────────────────────────────────────────────────── + + private isExpired(entry: CacheEntry): boolean { + return entry.expiresAt > 0 && Date.now() > entry.expiresAt; + } + + private evictLRU(): void { + // Map preserves insertion order; the first key is the LRU entry. + const firstKey = this.entries.keys().next().value; + if (firstKey !== undefined) { + this.entries.delete(firstKey); + this._evictions++; + } + } + + private persistIfEnabled(): void { + if (!this.config.persistToLocalStorage) return; + const storage = getLocalStorage(); + if (!storage) return; + try { + const pairs = Array.from(this.entries.entries()); + storage.setItem(this.config.storageKey, JSON.stringify(pairs, bigintReplacer)); + } catch { + // localStorage may be full or unavailable; silently skip. + } + } + + private loadFromStorage(): void { + const storage = getLocalStorage(); + if (!storage) return; + try { + const raw = storage.getItem(this.config.storageKey); + if (!raw) return; + const pairs = JSON.parse(raw, bigintReviver) as Array<[string, CacheEntry]>; + const now = Date.now(); + for (const [key, entry] of pairs) { + // Skip entries that have already expired. + if (entry.expiresAt === 0 || entry.expiresAt > now) { + this.entries.set(key, entry); + } + } + } catch { + // Corrupt or schema-incompatible storage — start fresh. + } + } + + private clearStorage(): void { + const storage = getLocalStorage(); + if (!storage) return; + try { + storage.removeItem(this.config.storageKey); + } catch { + // ignore + } + } +} + +// ─── JSON BigInt helpers ────────────────────────────────────────────────────── + +interface StorageLike { + getItem(key: string): string | null; + setItem(key: string, value: string): void; + removeItem(key: string): void; +} + +function getLocalStorage(): StorageLike | undefined { + try { + // Works in browsers; undefined in Node / SSR environments. + const ls = (globalThis as Record).localStorage; + return ls as StorageLike | undefined; + } catch { + return undefined; + } +} + +function bigintReplacer(_key: string, value: unknown): unknown { + if (typeof value === 'bigint') return { __bigint: value.toString() }; + return value; +} + +function bigintReviver(_key: string, value: unknown): unknown { + if ( + value !== null && + typeof value === 'object' && + '__bigint' in (value as Record) + ) { + return BigInt((value as { __bigint: string }).__bigint); + } + return value; +} diff --git a/sdk/src/client.ts b/sdk/src/client.ts index 5afb91c..b86ef04 100644 --- a/sdk/src/client.ts +++ b/sdk/src/client.ts @@ -29,6 +29,7 @@ import { } from './utils'; import { SimulationError, RPCError } from './errors'; +import { CacheManager, CacheConfig, CacheMetrics, CacheWarmEntry } from './cache'; // ─── Types ─────────────────────────────────────────────────────────────────── @@ -39,6 +40,12 @@ export interface bcForgeClientConfig { networkPassphrase: string; /** Deployed bc-forge token contract ID */ contractId: string; + /** + * Enable in-memory caching for read-only queries. + * Pass `{}` to use defaults (30 s TTL, 100-entry LRU). + * Omit entirely to disable caching. + */ + cacheConfig?: Partial; } export interface TransactionResult { @@ -57,6 +64,18 @@ export interface BatchMintRecipient { amount: bigint; } +// ─── Cache key constants ────────────────────────────────────────────────────── + +const CK = { + balance: (addr: string) => `balance:${addr}`, + supply: 'supply', + name: 'name', + symbol: 'symbol', + decimals: 'decimals', + allowance: (owner: string, spender: string) => `allowance:${owner}:${spender}`, + version: 'version', +} as const; + // ─── Client ────────────────────────────────────────────────────────────────── export class bcForgeClient { @@ -65,6 +84,7 @@ export class bcForgeClient { private contractId: string; private server: SorobanRpc.Server; private contract: Contract; + private cache: CacheManager | undefined; constructor(config: bcForgeClientConfig) { this.rpcUrl = config.rpcUrl; @@ -72,90 +92,71 @@ export class bcForgeClient { this.contractId = config.contractId; this.server = new SorobanRpc.Server(this.rpcUrl); this.contract = new Contract(this.contractId); + if (config.cacheConfig !== undefined) { + this.cache = new CacheManager(config.cacheConfig); + } } // ─── Read-Only Queries ─────────────────────────────────────────────────── - /** - * Get the token balance for an address. - * - * @param address - Stellar public key (G... address) - * @returns Token balance as bigint - */ async getBalance(address: string): Promise { - const result = await this.queryContract('balance', [addressToScVal(address)]); - return BigInt(scValToNative(result)); + return this.cached(CK.balance(address), async () => { + const result = await this.queryContract('balance', [addressToScVal(address)]); + return BigInt(scValToNative(result)); + }); } - /** - * Get the total token supply. - * - * @returns Total supply as bigint - */ async getTotalSupply(): Promise { - const result = await this.queryContract('supply', []); - return BigInt(scValToNative(result)); + return this.cached(CK.supply, async () => { + const result = await this.queryContract('supply', []); + return BigInt(scValToNative(result)); + }); } - /** - * Get the human-readable token name. - */ async getName(): Promise { - const result = await this.queryContract('name', []); - return scValToNative(result) as string; + return this.cached(CK.name, async () => { + const result = await this.queryContract('name', []); + return scValToNative(result) as string; + }); } - /** - * Get the token ticker symbol. - */ async getSymbol(): Promise { - const result = await this.queryContract('symbol', []); - return scValToNative(result) as string; + return this.cached(CK.symbol, async () => { + const result = await this.queryContract('symbol', []); + return scValToNative(result) as string; + }); } - /** - * Get the number of decimal places. - */ async getDecimals(): Promise { - const result = await this.queryContract('decimals', []); - return scValToNative(result) as number; + return this.cached(CK.decimals, async () => { + const result = await this.queryContract('decimals', []); + return scValToNative(result) as number; + }); } - /** - * Get the spending allowance from `owner` to `spender`. - */ async getAllowance(owner: string, spender: string): Promise { - const result = await this.queryContract('allowance', [ - addressToScVal(owner), - addressToScVal(spender), - ]); - return BigInt(scValToNative(result)); + return this.cached(CK.allowance(owner, spender), async () => { + const result = await this.queryContract('allowance', [ + addressToScVal(owner), + addressToScVal(spender), + ]); + return BigInt(scValToNative(result)); + }); } - /** - * Get the contract version string. - */ async getVersion(): Promise { - const result = await this.queryContract('version', []); - return scValToNative(result) as string; + return this.cached(CK.version, async () => { + const result = await this.queryContract('version', []); + return scValToNative(result) as string; + }); } // ─── Batch Queries ─────────────────────────────────────────────────────── - /** - * Get token balances for multiple addresses in batches. - * - * @param addresses - Array of Stellar public keys - * @param batchSize - Maximum number of concurrent queries (default: 10) - * @returns Array of balances as bigints - */ async getBalances(addresses: string[], batchSize: number = 10): Promise { return this.executeBatch(addresses, (addr) => this.getBalance(addr), batchSize); } - /** - * Internal helper to execute a list of async tasks in chunks using Promise.all. - */ private async executeBatch( items: T[], task: (item: T) => Promise, @@ -172,15 +173,6 @@ export class bcForgeClient { // ─── Write Transactions ────────────────────────────────────────────────── - /** - * Initialize the token contract. Can only be called once. - * - * @param admin - Admin address - * @param decimals - Number of decimal places - * @param name - Token name - * @param symbol - Token symbol - * @param source - Keypair of the transaction signer - */ async initialize( admin: string, decimals: number, @@ -188,30 +180,28 @@ export class bcForgeClient { symbol: string, source: Keypair, ): Promise { - return this.invokeContract( + const result = await this.invokeContract( 'initialize', [addressToScVal(admin), u32ToScVal(decimals), stringToScVal(name), stringToScVal(symbol)], source, ); + if (result.success) this.cache?.invalidateAll(); + return result; } - /** - * Mint tokens to an address. Admin-only. - * - * @param to - Recipient address - * @param amount - Number of tokens to mint - * @param source - Admin keypair - */ async mint(to: string, amount: bigint, source: Keypair): Promise { - return this.invokeContract('mint', [addressToScVal(to), i128ToScVal(amount)], source); + const result = await this.invokeContract( + 'mint', + [addressToScVal(to), i128ToScVal(amount)], + source, + ); + if (result.success) { + this.cache?.invalidate(CK.balance(to)); + this.cache?.invalidate(CK.supply); + } + return result; } - /** - * Batch mint tokens to multiple recipients. Admin-only. - * - * @param recipients - Array of recipient objects - * @param source - Admin keypair - */ async batchMint(recipients: BatchMintRecipient[], source: Keypair): Promise { const recipientScVals = recipients.map(({ to, amount }) => xdr.ScVal.scvMap([ @@ -226,45 +216,41 @@ export class bcForgeClient { ]), ); const recipientsVec = xdr.ScVal.scvVec(recipientScVals); - return this.invokeContract('batch_mint', [recipientsVec], source); + const result = await this.invokeContract('batch_mint', [recipientsVec], source); + if (result.success) { + this.cache?.invalidate(CK.supply); + for (const { to } of recipients) { + this.cache?.invalidate(CK.balance(to)); + } + } + return result; } - /** - * Transfer tokens between addresses. - * - * @param from - Sender address - * @param to - Recipient address - * @param amount - Number of tokens - * @param source - Sender's keypair - */ async transfer( from: string, to: string, amount: bigint, source: Keypair, ): Promise { - return this.invokeContract( + const result = await this.invokeContract( 'transfer', [addressToScVal(from), addressToScVal(to), i128ToScVal(amount)], source, ); + if (result.success) { + this.cache?.invalidate(CK.balance(from)); + this.cache?.invalidate(CK.balance(to)); + } + return result; } - /** - * Approve a spender to use tokens on your behalf. - * - * @param from - Token owner - * @param spender - Approved spender - * @param amount - Maximum spendable amount - * @param source - Owner's keypair - */ async approve( from: string, spender: string, amount: bigint, source: Keypair, ): Promise { - return this.invokeContract( + const result = await this.invokeContract( 'approve', [ addressToScVal(from), @@ -274,57 +260,39 @@ export class bcForgeClient { ], source, ); + if (result.success) { + this.cache?.invalidate(CK.allowance(from, spender)); + } + return result; } - /** - * Burn tokens from an address. - * - * @param from - Address whose tokens to burn - * @param amount - Number of tokens to burn - * @param source - Burner's keypair - */ async burn(from: string, amount: bigint, source: Keypair): Promise { - return this.invokeContract('burn', [addressToScVal(from), i128ToScVal(amount)], source); + const result = await this.invokeContract( + 'burn', + [addressToScVal(from), i128ToScVal(amount)], + source, + ); + if (result.success) { + this.cache?.invalidate(CK.balance(from)); + this.cache?.invalidate(CK.supply); + } + return result; } - /** - * Transfer admin/ownership to a new address. Current admin only. - * - * @param newAdmin - New admin address - * @param source - Current admin's keypair - */ async transferOwnership(newAdmin: string, source: Keypair): Promise { return this.invokeContract('transfer_ownership', [addressToScVal(newAdmin)], source); } - /** - * Pause all token operations. Admin-only. - * - * @param source - Admin keypair - */ async pause(source: Keypair): Promise { return this.invokeContract('pause', [], source); } - /** - * Unpause token operations. Admin-only. - * - * @param source - Admin keypair - */ async unpause(source: Keypair): Promise { return this.invokeContract('unpause', [], source); } // ─── Offline Transaction Builders ────────────────────────────────────────── - /** - * Build an unsigned mint transaction for offline signing. - * - * @param to - Recipient address - * @param amount - Number of tokens to mint - * @param sourcePublicKey - Admin's public key - * @returns Unsigned transaction XDR string - */ async buildMintTx(to: string, amount: bigint, sourcePublicKey: string): Promise { return buildUnsignedTransaction( this.rpcUrl, @@ -336,15 +304,6 @@ export class bcForgeClient { ); } - /** - * Build an unsigned transfer transaction for offline signing. - * - * @param from - Sender address - * @param to - Recipient address - * @param amount - Number of tokens - * @param sourcePublicKey - Sender's public key - * @returns Unsigned transaction XDR string - */ async buildTransferTx( from: string, to: string, @@ -361,16 +320,6 @@ export class bcForgeClient { ); } - /** - * Build an unsigned approve transaction for offline signing. - * - * @param from - Token owner - * @param spender - Approved spender - * @param amount - Maximum spendable amount - * @param exp - Expiration ledger (0 for no expiration) - * @param sourcePublicKey - Owner's public key - * @returns Unsigned transaction XDR string - */ async buildApproveTx( from: string, spender: string, @@ -388,14 +337,6 @@ export class bcForgeClient { ); } - /** - * Build an unsigned burn transaction for offline signing. - * - * @param from - Address whose tokens to burn - * @param amount - Number of tokens to burn - * @param sourcePublicKey - Burner's public key - * @returns Unsigned transaction XDR string - */ async buildBurnTx(from: string, amount: bigint, sourcePublicKey: string): Promise { return buildUnsignedTransaction( this.rpcUrl, @@ -407,25 +348,10 @@ export class bcForgeClient { ); } - /** - * Sign an unsigned transaction XDR. - * - * @param txXdr - Unsigned transaction XDR string - * @param keypair - Keypair to sign with - * @returns Signed transaction XDR string - */ signTx(txXdr: string, keypair: Keypair): string { return signTransaction(txXdr, this.networkPassphrase, keypair); } - /** - * Simulate a contract invocation without submitting. - * - * @param method - Contract method name - * @param args - Method arguments as ScVal array - * @param sourcePublicKey - Public key for simulation context - * @returns Simulation result with return value and cost - */ async simulate(method: string, args: xdr.ScVal[], sourcePublicKey: string): Promise { return simulateTransaction( this.rpcUrl, @@ -437,27 +363,10 @@ export class bcForgeClient { ); } - /** - * Simulate a mint operation. - * - * @param to - Recipient address - * @param amount - Number of tokens to mint - * @param sourcePublicKey - Admin's public key - * @returns Simulation result - */ async simulateMint(to: string, amount: bigint, sourcePublicKey: string): Promise { return this.simulate('mint', [addressToScVal(to), i128ToScVal(amount)], sourcePublicKey); } - /** - * Simulate a transfer operation. - * - * @param from - Sender address - * @param to - Recipient address - * @param amount - Number of tokens - * @param sourcePublicKey - Sender's public key - * @returns Simulation result - */ async simulateTransfer( from: string, to: string, @@ -473,13 +382,6 @@ export class bcForgeClient { // ─── Multi-Sig / Admin Pool ────────────────────────────────────────────── - /** - * Configure the multi-signature admin pool. - * - * @param pool - Array of admin addresses - * @param threshold - Quorum threshold - * @param source - Current admin keypair - */ async setAdminPool( pool: string[], threshold: number, @@ -498,24 +400,13 @@ export class bcForgeClient { ); } - /** - * Upgrades the contract to a new WASM hash. Admin-only. - * - * @param newWasmHash - 32-byte hex string or Buffer of the new WASM hash - * @param source - Admin keypair - */ async upgrade(newWasmHash: string | Buffer, source: Keypair): Promise { - return this.invokeContract('upgrade', [hashToScVal(newWasmHash)], source); + const result = await this.invokeContract('upgrade', [hashToScVal(newWasmHash)], source); + // Contract logic may change after upgrade; full invalidation is safest. + if (result.success) this.cache?.invalidateAll(); + return result; } - /** - * Propose a sensitive action for multi-sig approval. - * - * @param admin - Proposing admin address - * @param action - The action to propose (Mint, Pause, or Unpause) - * @param description - Human-readable description - * @param source - Proposing admin keypair - */ async proposeAction( admin: string, action: { Mint: [string, bigint] } | { Pause: [] } | { Unpause: [] }, @@ -536,9 +427,6 @@ export class bcForgeClient { ); } - /** - * Approve a pending proposal. - */ async approveProposal( admin: string, proposalId: bigint, @@ -551,9 +439,6 @@ export class bcForgeClient { ); } - /** - * Execute a proposal once quorum is reached. - */ async executeProposal(proposalId: bigint, source: Keypair): Promise { return this.invokeContract( 'execute_proposal', @@ -564,69 +449,59 @@ export class bcForgeClient { // ─── Clawback / Regulatory ─────────────────────────────────────────────── - /** - * Set the designated clawback administrator. - */ async setClawbackAdmin(admin: string, source: Keypair): Promise { return this.invokeContract('set_clawback_admin', [addressToScVal(admin)], source); } - /** - * Update the token name. Admin-only. - * - * @param newName - The new token name - * @param source - Admin keypair - */ async updateName(newName: string, source: Keypair): Promise { - return this.invokeContract('update_name', [stringToScVal(newName)], source); + const result = await this.invokeContract('update_name', [stringToScVal(newName)], source); + if (result.success) this.cache?.invalidate(CK.name); + return result; } - /** - * Execute a clawback operation. - */ async clawback( from: string, to: string, amount: bigint, source: Keypair, ): Promise { - return this.invokeContract( + const result = await this.invokeContract( 'clawback', [addressToScVal(from), addressToScVal(to), i128ToScVal(amount)], source, ); + if (result.success) { + this.cache?.invalidate(CK.balance(from)); + this.cache?.invalidate(CK.balance(to)); + } + return result; } // ─── Locking / Vesting ─────────────────────────────────────────────────── - /** - * Lock tokens for a user until a specific timestamp. - */ async lockTokens( user: string, amount: bigint, unlockTime: bigint, source: Keypair, ): Promise { - return this.invokeContract( + const result = await this.invokeContract( 'lock_tokens', [addressToScVal(user), i128ToScVal(amount), nativeToScVal(unlockTime, { type: 'u64' })], source, ); + if (result.success) this.cache?.invalidate(CK.balance(user)); + return result; } - /** - * Withdraw matured locked tokens. - */ async withdrawLocked(user: string, source: Keypair): Promise { - return this.invokeContract('withdraw_locked', [addressToScVal(user)], source); + const result = await this.invokeContract('withdraw_locked', [addressToScVal(user)], source); + if (result.success) this.cache?.invalidate(CK.balance(user)); + return result; } // ─── Events ────────────────────────────────────────────────────────────── - /** - * Get recent events for the contract. - */ async getEvents(startLedger?: number): Promise { const response = await this.server.getEvents({ startLedger: startLedger || (await this.server.getLatestLedger()).sequence - 1000, @@ -635,21 +510,63 @@ export class bcForgeClient { return response.events; } + async updateSymbol(newSymbol: string, source: Keypair): Promise { + const result = await this.invokeContract('update_symbol', [stringToScVal(newSymbol)], source); + if (result.success) this.cache?.invalidate(CK.symbol); + return result; + } + + // ─── Cache Management ───────────────────────────────────────────────────── + + /** + * Return a snapshot of cache performance metrics. + * Returns `undefined` when caching is not configured. + */ + getCacheMetrics(): CacheMetrics | undefined { + return this.cache?.getMetrics(); + } + + /** + * Evict all cached entries. No-op when caching is not configured. + */ + clearCache(): void { + this.cache?.invalidateAll(); + } + /** - * Update the token symbol. Admin-only. + * Pre-populate the cache with known values (e.g. from SSR or a trusted source). + * No-op when caching is not configured. + */ + warmUpCache(entries: CacheWarmEntry[]): void { + this.cache?.warmUp(entries); + } + + /** + * Fetch balances for multiple addresses and cache the results. + * Useful for warming the cache before rendering a list of balances. * - * @param newSymbol - The new token symbol - * @param source - Admin keypair + * @returns Map of address → balance */ - async updateSymbol(newSymbol: string, source: Keypair): Promise { - return this.invokeContract('update_symbol', [stringToScVal(newSymbol)], source); + async prefetchBalances(addresses: string[]): Promise> { + const balances = await this.getBalances(addresses); + return new Map(addresses.map((addr, i) => [addr, balances[i]])); } // ─── Internal Helpers ──────────────────────────────────────────────────── /** - * Internal helper to execute a task with retries. + * Return a cached value for `key`, or execute `fn`, cache the result, and return it. + * When caching is disabled the result of `fn` is returned directly. */ + private async cached(key: string, fn: () => Promise, ttlMs?: number): Promise { + if (!this.cache) return fn(); + const hit = this.cache.get(key); + if (hit !== undefined) return hit; + const value = await fn(); + this.cache.set(key, value, ttlMs); + return value; + } + private async withRetry(fn: () => Promise, retries: number = 3): Promise { let lastError: any; for (let i = 0; i < retries; i++) { @@ -657,8 +574,6 @@ export class bcForgeClient { return await fn(); } catch (error) { lastError = error; - // Only retry on certain errors (e.g., network/RPC errors) - // For now, we retry on any error that isn't a known terminal error if (i < retries - 1) { await new Promise((resolve) => setTimeout(resolve, 1000 * (i + 1))); } @@ -667,9 +582,6 @@ export class bcForgeClient { throw lastError; } - /** - * Simulates a read-only contract call (no transaction submission). - */ private async queryContract(method: string, args: xdr.ScVal[]): Promise { return this.withRetry(async () => { try { @@ -704,9 +616,6 @@ export class bcForgeClient { }); } - /** - * Builds, signs, submits, and polls a contract invocation transaction. - */ private async invokeContract( method: string, args: xdr.ScVal[], @@ -738,7 +647,6 @@ export class bcForgeClient { hash: (response as any).hash, }; } catch (error: any) { - // Don't retry on simulation errors (usually logic errors) if (error instanceof SimulationError) throw error; throw error; } diff --git a/sdk/src/index.ts b/sdk/src/index.ts index d88a55e..d9e8814 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -24,3 +24,7 @@ export { buildInvokeTransaction, submitTransaction, scValToNative } from './util export { bcForgeEventType, decodeEvent, decodeDiagnosticEvent, subscribeEvents } from './events'; export type { bcForgeEvent, SubscriptionOptions } from './events'; export * from './mockClient'; + +// Caching +export { CacheManager, DEFAULT_CACHE_CONFIG } from './cache'; +export type { CacheConfig, CacheEntry, CacheMetrics, CacheWarmEntry } from './cache';