From 832d854af005fd1af0edc9d5fef978f6d2ec6870 Mon Sep 17 00:00:00 2001 From: Tobi-8 Date: Wed, 17 Jun 2026 21:13:59 +0100 Subject: [PATCH] docs(cache): document contract-cache layer and add-a-reader recipe Adds TSDoc to cache/registry plus docs/contract-cache.md covering TTL, invalidation, and a step-by-step new cached read recipe. --- README.md | 3 +- docs/contract-cache.md | 152 +++++++++++++++++++++++++++++ lib/cache/contract-cache.ts | 16 +-- lib/cache/registry.ts | 18 ++++ tests/unit/cached-wrappers.test.ts | 16 +-- 5 files changed, 190 insertions(+), 15 deletions(-) create mode 100644 docs/contract-cache.md diff --git a/README.md b/README.md index f2897c2..4597730 100644 --- a/README.md +++ b/README.md @@ -162,7 +162,8 @@ remitwise-frontend/ ├── lib/ # Utilities and helpers │ └── auth.ts # Auth middleware ├── docs/ # Documentation -│ └── API_ROUTES.md # API routes documentation +│ ├── API_ROUTES.md # API routes documentation +│ └── contract-cache.md # Contract caching architecture and guidelines ├── public/ # Static assets └── package.json ``` diff --git a/docs/contract-cache.md b/docs/contract-cache.md new file mode 100644 index 0000000..8a0898e --- /dev/null +++ b/docs/contract-cache.md @@ -0,0 +1,152 @@ +# Contract Cache Layer + +The Remitwise frontend utilizes a cached contract layer (`lib/cache/contract-cache.ts`) for Soroban read-only contract calls. This design pattern reduces the volume of redundant RPC calls, enforces input validation, prevents DoS vectors, and minimizes latency. + +--- + +## Architecture & Lifecycle Flow + +``` +[Client/API Component] + │ + ▼ +[Cached Wrapper (e.g., remittance-split-cached.ts)] + │ + ▼ +[lib/cache/contract-cache.ts] ◄───► [lru-cache Memory Store] + │ (Cache Miss or Expired TTL) + ▼ +[Original Wrapper (e.g., remittance-split.ts)] + │ + ▼ +[Soroban Contract (via RPC)] +``` + +### 1. Read / Cache Flow +- **Request**: A cached wrapper method (e.g., `getSplit`) is called. +- **Validation**: Inputs (contract ID, method name, TTL, arguments) are validated to prevent cache poisoning, ReDoS, or DoS attacks. +- **Cache Lookup**: A deterministic cache key is generated based on: + ``` + contractId:methodName:JSON.stringify(sortedArguments) + ``` +- **Cache Hit**: If the entry exists and the timestamp is within the TTL window, the cached data is returned directly. +- **Cache Miss / Expiry**: If the entry is absent, expired, or a TTL mismatch is detected, the wrapper executes the underlying RPC fetch function, validates that it returned a non-null/non-undefined value, updates the cache, and returns the fresh data. + +### 2. Cache Invalidation Flow +Caches can be invalidated in two ways: +- **Automatic (TTL Expiry)**: Entries are automatically discarded upon the next read request if their age exceeds `ttlSeconds`. +- **Manual Invalidation**: Initiated programmatically via helper functions or externally via the API invalidation endpoint. + +### 3. Registry +The cache registry (`lib/cache/registry.ts`) manages and tracks all active caching subsystems. +- Caches register their clearing functions using `registerCache(name, clearFn)`. +- Bulk operations can list (`listRegisteredCaches()`) or flush (`clearRegisteredCaches()`) all registered caches. + +--- + +## Invalidation Endpoint (`app/api/cache/invalidate/route.ts`) + +The application exposes an internal API for cache administration. + +### POST `/api/cache/invalidate` +Performs targeted or bulk invalidation. + +**Request Body Schema:** +- `clearAll` (boolean, optional): Set to `true` to completely wipe all cached data. +- `pattern` (string, optional): Invalidate all cache keys containing this string (e.g., `"INSURANCE_CONTRACT"`). +- `contractId` (string, optional): Invalidate entries associated with a contract ID. + - `method` (string, optional): Target a specific method under the contract. + - `args` (object, optional): Target a specific call with exact arguments. + +*Example payload to invalidate a specific split configuration:* +```json +{ + "contractId": "REMITTANCE_SPLIT_CONTRACT", + "method": "getSplit", + "args": { "env": "testnet" } +} +``` + +### GET `/api/cache/invalidate` +Retrieves cache statistics. +- **Query Params**: `includeKeys=true` (only returns keys in development mode). +- **Response**: Returns metrics like `size`, `maxSize`, `itemCount`, `hitRate`, and `missRate`. + +--- + +## Edge Cases & Caching Constraints + +> [!CAUTION] +> Stale data in remittance and financial systems can lead to double-spends, incorrect balances, and transaction failures. + +### What Must NEVER Be Cached +1. **Write Operations**: Methods that compile, submit, or execute transactions (e.g., `buildTransferTx`, `submitPayment`) must **never** be wrapped in `cachedContractCall`. +2. **Post-Transaction Balances**: Do not cache user balances or policy states right after a transaction/transfer has completed. Always read fresh data from the chain or explicitly trigger a manual cache invalidation for the affected user's address/ID. +3. **Transient States**: Temporary states, validation tokens, or fast-changing queue items. + +### Key Lifecycle Scenarios +- **Cache Miss**: The cache is empty or doesn't have the entry. The system degrades gracefully to perform the RPC request. +- **Manual Invalidation**: Used after state-changing write operations. The entry is removed instantly so the next read fetches fresh data. +- **TTL Expiry**: The key has passed its time-to-live threshold. It is deleted on the next read attempt and refreshed. +- **TTL Confusion Protection**: If a cached entry is fetched with a different TTL than the current call's TTL, the cache layer invalidates the old entry and triggers a fresh fetch to prevent TTL confusion. + +--- + +## Step-by-Step Recipe: Adding a New Cached Read + +To add a new cached read, follow this recipe modeled after `lib/contracts/remittance-split-cached.ts`: + +### Step 1: Update Constants in `lib/cache/contract-cache.ts` +1. Add a contract ID mapping in `CONTRACT_IDS` (if it's a new contract). +2. Add a default TTL config (in seconds) in `CACHE_TTL`. + +```typescript +export const CACHE_TTL = { + // ... + getNewData: 45, // Add your new method here +} as const; +``` + +### Step 2: Create the Cached Wrapper File +Create a new file `lib/contracts/your-contract-cached.ts` (or append to an existing cached wrapper). + +```typescript +import { cachedContractCall, CONTRACT_IDS, CACHE_TTL, CacheError } from '@/lib/cache/contract-cache'; +import * as originalContract from './your-contract'; + +/** + * Get data with caching and error handling. + * + * @param param - Query parameter + * @returns Cached or freshly fetched contract data + * @throws CacheError on validation failures + * @throws Error on RPC failures + */ +export async function getNewData(param: string): Promise { + // 1. Perform parameter validation (essential for security and key serialization) + if (!param || typeof param !== 'string') { + throw new Error('Param must be a non-empty string'); + } + + try { + // 2. Wrap the call with cachedContractCall + return await cachedContractCall( + CONTRACT_IDS.YOUR_CONTRACT, + 'getNewData', + { param }, + CACHE_TTL.getNewData, + async () => await originalContract.getNewData(param) + ); + } catch (error) { + // 3. Propagate CacheErrors as-is + if (error instanceof CacheError) { + throw error; + } + // 4. Wrap RPC/Contract errors with context + throw new Error( + `Failed to get new data: ${error instanceof Error ? error.message : 'Unknown error'}`, + { cause: error } + ); + } +} +``` diff --git a/lib/cache/contract-cache.ts b/lib/cache/contract-cache.ts index 92a7a26..74c1226 100644 --- a/lib/cache/contract-cache.ts +++ b/lib/cache/contract-cache.ts @@ -456,8 +456,9 @@ export function invalidatePattern(pattern: string): number { } /** - * Clears all cache entries - * @security Should be restricted in production environments + * Clears all cache entries from memory and resets cache hits/misses metrics. + * + * @security Should be restricted in production environments to avoid performance degradation. */ export function clearCache(): void { cache.clear(); @@ -467,8 +468,9 @@ export function clearCache(): void { } /** - * Gets cache statistics for monitoring - * @returns Immutable cache statistics object + * Retrieves the current cache metrics and statistics for monitoring and observability. + * + * @returns An object containing the cache size, maxSize, itemCount, hitRate, and missRate. */ export function getCacheStats(): CacheStats { const total = cacheHits + cacheMisses; @@ -482,8 +484,10 @@ export function getCacheStats(): CacheStats { } /** - * Gets all cache keys for debugging - * @security Should be restricted in production environments + * Retrieves all keys currently stored in the cache. + * + * @returns A frozen read-only array of cache keys. + * @security Access should be restricted in production environments to prevent information disclosure. */ export function getCacheKeys(): readonly string[] { return Object.freeze([...cache.keys()]); diff --git a/lib/cache/registry.ts b/lib/cache/registry.ts index 8f0fdfd..5f7d242 100644 --- a/lib/cache/registry.ts +++ b/lib/cache/registry.ts @@ -2,10 +2,22 @@ type CacheClearer = () => void | Promise; const cacheClearers = new Map(); +/** + * Registers a cache clearance function under a unique name. + * Registered clearers are invoked during bulk cache clearing operations. + * + * @param name - The unique identifier/key of the cache to register. + * @param clearer - The synchronous or asynchronous function that clears the specific cache. + */ export function registerCache(name: string, clearer: CacheClearer): void { cacheClearers.set(name, clearer); } +/** + * Clears all registered caches sequentially by invoking their registered clearance functions. + * + * @returns A promise that resolves to an array of names of the caches that were cleared. + */ export async function clearRegisteredCaches(): Promise { const cleared: string[] = []; for (const [name, clearer] of cacheClearers.entries()) { @@ -15,7 +27,13 @@ export async function clearRegisteredCaches(): Promise { return cleared; } +/** + * Lists the names of all currently registered caches. + * + * @returns An array of registered cache names. + */ export function listRegisteredCaches(): string[] { return Array.from(cacheClearers.keys()); } + diff --git a/tests/unit/cached-wrappers.test.ts b/tests/unit/cached-wrappers.test.ts index e2a1057..32fb4a8 100644 --- a/tests/unit/cached-wrappers.test.ts +++ b/tests/unit/cached-wrappers.test.ts @@ -232,7 +232,7 @@ describe('Insurance Cached Wrapper', () => { describe('getActivePolicies', () => { it('should return active policies', async () => { - const result = await insuranceCached.getActivePolicies('GABC123456789012345678901234567890123456789012345678901234'); + const result = await insuranceCached.getActivePolicies('GABC1234567890123456789012345678901234567890123456789012'); expect(result).toHaveLength(1); expect(result[0].id).toBe('policy-1'); @@ -248,13 +248,13 @@ describe('Insurance Cached Wrapper', () => { ).rejects.toThrow('owner must be a valid Stellar address'); await expect( - insuranceCached.getActivePolicies('XABC123456789012345678901234567890123456789012345678901234') + insuranceCached.getActivePolicies('XABC1234567890123456789012345678901234567890123456789012') ).rejects.toThrow('owner must be a valid Stellar address'); }); it('should cache results', async () => { const originalModule = await import('@/lib/contracts/insurance'); - const address = 'GABC123456789012345678901234567890123456789012345678901234'; + const address = 'GABC1234567890123456789012345678901234567890123456789012'; await insuranceCached.getActivePolicies(address); await insuranceCached.getActivePolicies(address); @@ -269,14 +269,14 @@ describe('Insurance Cached Wrapper', () => { vi.mocked(originalModule.getActivePolicies).mockRejectedValueOnce(invalidError); await expect( - insuranceCached.getActivePolicies('GABC123456789012345678901234567890123456789012345678901234') + insuranceCached.getActivePolicies('GABC1234567890123456789012345678901234567890123456789012') ).rejects.toThrow('Invalid address'); }); }); describe('getTotalMonthlyPremium', () => { it('should return total premium', async () => { - const result = await insuranceCached.getTotalMonthlyPremium('GABC123456789012345678901234567890123456789012345678901234'); + const result = await insuranceCached.getTotalMonthlyPremium('GABC1234567890123456789012345678901234567890123456789012'); expect(result).toBe(100); }); @@ -296,7 +296,7 @@ describe('Insurance Cached Wrapper', () => { vi.mocked(originalModule.getTotalMonthlyPremium).mockResolvedValueOnce(NaN); await expect( - insuranceCached.getTotalMonthlyPremium('GABC123456789012345678901234567890123456789012345678901234') + insuranceCached.getTotalMonthlyPremium('GABC1234567890123456789012345678901234567890123456789012') ).rejects.toThrow('Invalid premium value'); }); @@ -305,13 +305,13 @@ describe('Insurance Cached Wrapper', () => { vi.mocked(originalModule.getTotalMonthlyPremium).mockResolvedValueOnce(-100); await expect( - insuranceCached.getTotalMonthlyPremium('GABC123456789012345678901234567890123456789012345678901234') + insuranceCached.getTotalMonthlyPremium('GABC1234567890123456789012345678901234567890123456789012') ).rejects.toThrow('Premium cannot be negative'); }); it('should cache results', async () => { const originalModule = await import('@/lib/contracts/insurance'); - const address = 'GABC123456789012345678901234567890123456789012345678901234'; + const address = 'GABC1234567890123456789012345678901234567890123456789012'; await insuranceCached.getTotalMonthlyPremium(address); await insuranceCached.getTotalMonthlyPremium(address);