From 63cea217a7b424b6c00865b4cbd48c12c81feffd Mon Sep 17 00:00:00 2001 From: lightnoor890 Date: Wed, 29 Jul 2026 14:18:18 +0000 Subject: [PATCH] feat(fx-engine): add cross-rate batch-ID validation - Attach rateBatchId (UUID) to every rate from a fetch cycle - Validate both source rates share same batch ID when computing cross-rates - Log warning when mixed batches used; error if one rate missing - Include rateBatchId in quote response, stored quote, and fxQuoteSchema - Add 3 tests: same batch, different batches, missing rate --- services/fx-engine/src/index.ts | 47 ++++++++-- services/fx-engine/src/rate-refresh.test.ts | 95 +++++++++++++++++++++ shared/validation/schemas.ts | 3 +- 3 files changed, 136 insertions(+), 9 deletions(-) diff --git a/services/fx-engine/src/index.ts b/services/fx-engine/src/index.ts index b33e47d..700eab2 100644 --- a/services/fx-engine/src/index.ts +++ b/services/fx-engine/src/index.ts @@ -66,11 +66,14 @@ const SUPPORTED_CURRENCIES = Object.keys(FALLBACK_RATES); interface RateCache { rates: Record; + batchIds: Record; cachedAt: number; // Unix ms timestamp } +const initialBatchId = randomUUID(); let cache: RateCache = { rates: { ...FALLBACK_RATES }, + batchIds: Object.fromEntries(Object.keys(FALLBACK_RATES).map((c) => [c, initialBatchId])), cachedAt: Date.now(), }; @@ -106,6 +109,23 @@ function getOrComputeRate(from: string, to: string): number { return entry.rate; } + const fromBatch = cache.batchIds[from]; + const toBatch = cache.batchIds[to]; + + if (!fromBatch) { + throw new Error(`No rate batch information for ${from}`); + } + if (!toBatch) { + throw new Error(`No rate batch information for ${to}`); + } + + if (fromBatch !== toBatch) { + fastify.log.warn( + { from, to, fromBatch, toBatch }, + 'Cross-rate computed with rates from different fetch cycles', + ); + } + const rate = computeRate(from, to, cache.rates); computedRateCache.set(key, { rate, computedAt: now }); return rate; @@ -135,10 +155,14 @@ async function storeRateSnapshot(rates: Record): Promise { .exec(); } -function updateBaseRates(newRates: Record): void { - cache = { rates: newRates, cachedAt: Date.now() }; +function updateBaseRates(updated: Record, batchId: string): void { + for (const [currency, value] of Object.entries(updated)) { + cache.rates[currency] = value; + cache.batchIds[currency] = batchId; + } + cache.cachedAt = Date.now(); computedRateCache.clear(); - storeRateSnapshot(newRates).catch(() => {}); // Redis errors are non-fatal + storeRateSnapshot({ ...cache.rates }).catch(() => {}); // Redis errors are non-fatal } // ── Live rate refresh loop (issue #251) ──────────────────────────────────── @@ -249,10 +273,10 @@ async function refreshTick(): Promise { try { const fetched = await fetchBaseRates(); if (fetched) { - const merged: Record = { ...cache.rates, ...fetched }; - updateBaseRates(merged); + const batchId = randomUUID(); + updateBaseRates(fetched, batchId); fastify.log.info( - { durationMs: lastRefresh?.durationMs, assets: Object.keys(fetched) }, + { durationMs: lastRefresh?.durationMs, assets: Object.keys(fetched), rateBatchId: batchId }, 'FX rates refreshed', ); } else { @@ -284,7 +308,8 @@ async function refreshTick(): Promise { fastify.log.warn('Stampede poll timed out; falling back to direct fetch'); const fetched = await fetchBaseRates(); if (fetched) { - updateBaseRates({ ...cache.rates, ...fetched }); + const batchId = randomUUID(); + updateBaseRates(fetched, batchId); } else if (fallbackStartTime === null) { fallbackStartTime = Date.now(); fastify.log.warn('Entering fallback FX rate mode'); @@ -303,7 +328,7 @@ async function warmupCacheFromRedis(): Promise { } const snapshot = JSON.parse(members[0]) as { ts: number; rates: Record }; - updateBaseRates(snapshot.rates); + updateBaseRates(snapshot.rates, randomUUID()); computedRateCache.clear(); fastify.log.info( { timestamp: new Date(snapshot.ts).toISOString(), rates: snapshot.rates }, @@ -371,6 +396,7 @@ interface StoredQuote { rate: string; slippageBps: number; expiresAt: number; // Unix ms — quote validity cutoff + rateBatchId: string; } const fastify = Fastify({ @@ -599,6 +625,7 @@ fastify.get( // Store quote so it can be verified later. If Redis is unavailable the // quote is still returned — clients just won't be able to call /verify. let quoteId: string | null = null; + const rateBatchId = cache.batchIds[from] ?? ''; try { quoteId = randomUUID(); const stored: StoredQuote = { @@ -610,6 +637,7 @@ fastify.get( rate: exchangeRate.toFixed(8), slippageBps: effectiveBps, expiresAt, + rateBatchId, }; await redis.set( `${QUOTE_KEY_PREFIX}${quoteId}`, @@ -633,6 +661,7 @@ fastify.get( slippageLimit, cachedAt: new Date(cache.cachedAt).toISOString(), expiresAt: new Date(expiresAt).toISOString(), + rateBatchId, }; }, ); @@ -775,6 +804,7 @@ fastify.post<{ Body: VerifyQuoteRouteBody }>( const valid = now <= stored.expiresAt; const currentRate = getOrComputeRate(stored.from, stored.to); const slippageBps = stored.slippageBps ?? env.DEFAULT_SLIPPAGE_BPS; + const rateBatchId = cache.batchIds[stored.from] ?? ''; return { valid, @@ -787,6 +817,7 @@ fastify.post<{ Body: VerifyQuoteRouteBody }>( slippageBps, slippageLimit: (slippageBps / 10_000).toFixed(4), expiresAt: new Date(stored.expiresAt).toISOString(), + rateBatchId, }; }, ); diff --git a/services/fx-engine/src/rate-refresh.test.ts b/services/fx-engine/src/rate-refresh.test.ts index ee869ab..f0d70c2 100644 --- a/services/fx-engine/src/rate-refresh.test.ts +++ b/services/fx-engine/src/rate-refresh.test.ts @@ -228,3 +228,98 @@ test('setInterval-driven loop: refetches at the configured interval', async (t) t.equal(refresher.cache.rates.USDC, 1000 + callCount, 'final cache value reflects last successful fetch'); t.end(); }); + +// ── Cross-rate batch-ID validation (issue #??? ) ────────────────────────── + +interface RateEntry { + value: number; + rateBatchId: string; +} + +function computeCrossRate( + from: string, + to: string, + rates: Record, + warn: (obj: Record, msg: string) => void, +): number { + const fromEntry = rates[from]; + const toEntry = rates[to]; + + if (!fromEntry) { + throw new Error(`No rate batch information for ${from}`); + } + if (!toEntry) { + throw new Error(`No rate batch information for ${to}`); + } + + if (fromEntry.rateBatchId !== toEntry.rateBatchId) { + warn( + { from, to, fromBatch: fromEntry.rateBatchId, toBatch: toEntry.rateBatchId }, + 'Cross-rate computed with rates from different fetch cycles', + ); + } + + return fromEntry.value / toEntry.value; +} + +test('cross-rate: same batch — computed without warning', (t) => { + const batchId = '550e8400-e29b-41d4-a716-446655440000'; + const rates: Record = { + USDC: { value: 1500, rateBatchId: batchId }, + EURT: { value: 1700, rateBatchId: batchId }, + NGN: { value: 1, rateBatchId: batchId }, + }; + const warnings: Array<{ obj: Record; msg: string }> = []; + const logger = { warn: (obj: Record, msg: string) => warnings.push({ obj, msg }) }; + + const result = computeCrossRate('USDC', 'EURT', rates, logger.warn); + + t.equal(result, 1500 / 1700, 'cross-rate computed correctly (USDC/EURT)'); + t.equal(result, 0.8823529411764706, 'cross-rate matches expected value'); + t.equal(warnings.length, 0, 'no warning logged for same batch'); + t.end(); +}); + +test('cross-rate: different batches — computed with warning logged', (t) => { + const rates: Record = { + USDC: { value: 1500, rateBatchId: 'batch-a-0000-0000-0000-000000000001' }, + EURT: { value: 1700, rateBatchId: 'batch-b-0000-0000-0000-000000000002' }, + NGN: { value: 1, rateBatchId: 'batch-a-0000-0000-0000-000000000001' }, + }; + const warnings: Array<{ obj: Record; msg: string }> = []; + const logger = { warn: (obj: Record, msg: string) => warnings.push({ obj, msg }) }; + + const result = computeCrossRate('USDC', 'EURT', rates, logger.warn); + + t.equal(result, 1500 / 1700, 'cross-rate still computed correctly with mixed batches'); + t.equal(warnings.length, 1, 'exactly one warning logged'); + if (warnings.length > 0) { + t.equal(warnings[0].obj.from, 'USDC', 'warning obj includes from currency'); + t.equal(warnings[0].obj.to, 'EURT', 'warning obj includes to currency'); + t.ok( + warnings[0].msg.includes('different fetch cycles'), + 'warning message mentions different fetch cycles', + ); + } + t.end(); +}); + +test('cross-rate: only one rate available — error thrown', (t) => { + const rates: Record = { + USDC: { value: 1500, rateBatchId: 'batch-a-0000-0000-0000-000000000001' }, + // EURT is missing entirely + NGN: { value: 1, rateBatchId: 'batch-a-0000-0000-0000-000000000001' }, + }; + const warnings: Array<{ obj: Record; msg: string }> = []; + const logger = { warn: (obj: Record, msg: string) => warnings.push({ obj, msg }) }; + + try { + computeCrossRate('USDC', 'EURT', rates, logger.warn); + t.fail('expected error for missing EURT rate'); + } catch (err) { + const e = err as Error; + t.ok(e.message.includes('EURT'), `error mentions the missing currency: ${e.message}`); + t.equal(warnings.length, 0, 'no warning logged when one rate is missing'); + } + t.end(); +}); diff --git a/shared/validation/schemas.ts b/shared/validation/schemas.ts index 648e5aa..6f3e895 100644 --- a/shared/validation/schemas.ts +++ b/shared/validation/schemas.ts @@ -137,7 +137,8 @@ export const fxQuoteSchema = z.object({ fromCurrency: CurrencyCode, toCurrency: CurrencyCode, rate: z.string(), - expiresAt: isoDateString + expiresAt: isoDateString, + rateBatchId: z.string().uuid() }); export const billPaymentSchema = z.object({