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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down
152 changes: 152 additions & 0 deletions docs/contract-cache.md
Original file line number Diff line number Diff line change
@@ -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<DataResult | null> {
// 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 }
);
}
}
```
16 changes: 10 additions & 6 deletions lib/cache/contract-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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;
Expand All @@ -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()]);
Expand Down
18 changes: 18 additions & 0 deletions lib/cache/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,22 @@ type CacheClearer = () => void | Promise<void>;

const cacheClearers = new Map<string, CacheClearer>();

/**
* 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<string[]> {
const cleared: string[] = [];
for (const [name, clearer] of cacheClearers.entries()) {
Expand All @@ -15,7 +27,13 @@ export async function clearRegisteredCaches(): Promise<string[]> {
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());
}


16 changes: 8 additions & 8 deletions tests/unit/cached-wrappers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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);
Expand All @@ -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);
});
Expand All @@ -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');
});

Expand All @@ -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);
Expand Down