From b81b384678433106f45ed902076530202bf084e2 Mon Sep 17 00:00:00 2001 From: GideonBature Date: Fri, 26 Jun 2026 04:44:01 +0100 Subject: [PATCH] feat(backend-sep38): [sep-38] update quotes cache expiration rules (closes #620) --- backend/.env.example | 7 ++ backend/prisma/dev.db | 0 .../src/api/controllers/sep38.controller.ts | 49 +++++++--- backend/src/config/env.ts | 42 +++++++++ .../config/sep38-quotes-cache.config.test.ts | 53 +++++++++++ .../src/config/sep38-quotes-cache.config.ts | 94 +++++++++++++++++++ 6 files changed, 233 insertions(+), 12 deletions(-) create mode 100644 backend/prisma/dev.db create mode 100644 backend/src/config/sep38-quotes-cache.config.test.ts create mode 100644 backend/src/config/sep38-quotes-cache.config.ts diff --git a/backend/.env.example b/backend/.env.example index 1a441809..7721b47f 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -118,6 +118,13 @@ REDIS_URL=redis://localhost:6379 # FEATURE_FLAG_MULTISIG=true # FEATURE_FLAG_BATCH_PAYMENTS=true +# SEP-38 Quote Cache Expiration +# SEP38_INDICATIVE_QUOTE_EXPIRATION_SECONDS=60 +# SEP38_FIRM_QUOTE_VALIDITY_SECONDS=300 +# SEP38_QUOTE_CACHE_TTL_SECONDS=30 +# SEP38_QUOTE_CACHE_STALE_TTL_SECONDS=30 +# SEP38_ASSETS_CACHE_TTL_SECONDS=3600 + # Email (SendGrid with Nodemailer fallback) # SENDGRID_API_KEY=SG.xxxxxxxxxxxxxxxx diff --git a/backend/prisma/dev.db b/backend/prisma/dev.db new file mode 100644 index 00000000..e69de29b diff --git a/backend/src/api/controllers/sep38.controller.ts b/backend/src/api/controllers/sep38.controller.ts index c495f3ed..4dd47e8d 100644 --- a/backend/src/api/controllers/sep38.controller.ts +++ b/backend/src/api/controllers/sep38.controller.ts @@ -1,6 +1,12 @@ import { Redis } from 'ioredis'; -import { PriceAggregationService, AggregatedPrice, PriceFetchOptions } from '../../services/price-aggregation.service'; -import { AdvancedCacheService, CacheAsideResult } from '../../services/advanced-cache.service'; +import { PriceAggregationService, PriceFetchOptions } from '../../services/price-aggregation.service'; +import { AdvancedCacheService } from '../../services/advanced-cache.service'; +import { + buildQuoteExpirationTime, + getSep38QuotesCacheConfig, + isQuoteExpired, + Sep38QuotesCacheConfig, +} from '../../config/sep38-quotes-cache.config'; import logger from '../../utils/logger'; import prisma from '../../lib/prisma'; @@ -96,11 +102,12 @@ export class Sep38Controller { private priceService: PriceAggregationService; private cache: AdvancedCacheService; private assetsCacheKey = 'sep38:supported_assets'; - private quoteCacheTtlSeconds = 30; + private cacheConfig: Sep38QuotesCacheConfig; - constructor(redis: Redis) { + constructor(redis: Redis, cacheConfig: Sep38QuotesCacheConfig = getSep38QuotesCacheConfig()) { this.priceService = new PriceAggregationService(redis); this.cache = new AdvancedCacheService(redis); + this.cacheConfig = cacheConfig; } /** @@ -140,7 +147,7 @@ export class Sep38Controller { destination_asset: destinationAsset, destination_amount: sourceAmount, price: 1.0, - expiration_time: Math.floor(Date.now() / 1000) + 60, + expiration_time: buildQuoteExpirationTime(this.cacheConfig.indicativeQuoteExpirationSeconds), confidence: 1.0, sources_used: 0, is_partial: false, @@ -162,7 +169,12 @@ export class Sep38Controller { if (forceRefresh) { const quote = await fetchQuote(); // Store in cache for future requests - await this.cache.setL2(cacheKey, quote, this.quoteCacheTtlSeconds, 'sep38-quote'); + await this.cache.setL2( + cacheKey, + quote, + this.cacheConfig.quoteCacheTtlSeconds, + 'sep38-quote', + ); return { ...quote, cached: false }; } @@ -170,13 +182,24 @@ export class Sep38Controller { cacheKey, fetchQuote, { - ttlSeconds: this.quoteCacheTtlSeconds, + ttlSeconds: this.cacheConfig.quoteCacheTtlSeconds, tags: ['sep38', 'quote', `asset:${source}`, `asset:${dest}`], staleWhileRevalidate: true, - staleTtlSeconds: 120, + staleTtlSeconds: this.cacheConfig.quoteCacheStaleTtlSeconds, } ); + if (cached.fromCache && isQuoteExpired(cached.data)) { + const quote = await fetchQuote(); + await this.cache.setL2( + cacheKey, + quote, + this.cacheConfig.quoteCacheTtlSeconds, + 'sep38-quote', + ); + return { ...quote, cached: false }; + } + return { ...cached.data, cached: cached.fromCache }; } @@ -191,7 +214,9 @@ export class Sep38Controller { ): Promise { const indicativeQuote = await this.getPriceQuote(sourceAsset, sourceAmount, destinationAsset, context); - const expiresAt = new Date(Date.now() + 5 * 60 * 1000); // 5 minutes validity + const expiresAt = new Date( + Date.now() + this.cacheConfig.firmQuoteValiditySeconds * 1000, + ); const dbQuote = await prisma.quote.create({ data: { @@ -261,7 +286,7 @@ export class Sep38Controller { destination_asset: destAsset, destination_amount: parseFloat(destinationAmount.toFixed(7)), price: parseFloat(crossRate.toFixed(7)), - expiration_time: Math.floor(Date.now() / 1000) + 60, + expiration_time: buildQuoteExpirationTime(this.cacheConfig.indicativeQuoteExpirationSeconds), confidence: parseFloat(avgConfidence.toFixed(4)), sources_used: Math.min(sourcePriceData.aggregatedFrom, destPriceData.aggregatedFrom), is_partial: isPartial, @@ -306,7 +331,7 @@ export class Sep38Controller { destination_asset: destAsset, destination_amount: parseFloat(destinationAmount.toFixed(7)), price: parseFloat(crossRate.toFixed(7)), - expiration_time: Math.floor(Date.now() / 1000) + 60, + expiration_time: buildQuoteExpirationTime(this.cacheConfig.indicativeQuoteExpirationSeconds), confidence: 0.5, sources_used: 0, is_partial: true, @@ -336,7 +361,7 @@ export class Sep38Controller { this.assetsCacheKey, fetchAssets, { - ttlSeconds: 3600, + ttlSeconds: this.cacheConfig.assetsCacheTtlSeconds, tags: ['sep38', 'assets'], } ); diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts index 02191433..3d0d489a 100644 --- a/backend/src/config/env.ts +++ b/backend/src/config/env.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; import dotenv from 'dotenv'; +import { validateSep38QuotesCacheConfig } from './sep38-quotes-cache.config'; dotenv.config(); @@ -96,6 +97,47 @@ const envSchema = z.object({ .pipe(z.number().int().min(5).max(60)), ANCHOR_PUBLIC_KEY: z.string().optional(), // For SEP-10 challenges ANCHOR_SECRET_KEY: z.string().optional(), // For SEP-10 challenges + SEP38_INDICATIVE_QUOTE_EXPIRATION_SECONDS: z + .string() + .default('60') + .transform((val: string) => parseInt(val, 10)) + .pipe(z.number().int().min(15).max(300)), + SEP38_FIRM_QUOTE_VALIDITY_SECONDS: z + .string() + .default('300') + .transform((val: string) => parseInt(val, 10)) + .pipe(z.number().int().min(60).max(3600)), + SEP38_QUOTE_CACHE_TTL_SECONDS: z + .string() + .default('30') + .transform((val: string) => parseInt(val, 10)) + .pipe(z.number().int().min(5).max(300)), + SEP38_QUOTE_CACHE_STALE_TTL_SECONDS: z + .string() + .default('30') + .transform((val: string) => parseInt(val, 10)) + .pipe(z.number().int().min(0).max(300)), + SEP38_ASSETS_CACHE_TTL_SECONDS: z + .string() + .default('3600') + .transform((val: string) => parseInt(val, 10)) + .pipe(z.number().int().min(60).max(86400)), +}).superRefine((data, ctx) => { + if ( + !validateSep38QuotesCacheConfig({ + indicativeQuoteExpirationSeconds: data.SEP38_INDICATIVE_QUOTE_EXPIRATION_SECONDS, + firmQuoteValiditySeconds: data.SEP38_FIRM_QUOTE_VALIDITY_SECONDS, + quoteCacheTtlSeconds: data.SEP38_QUOTE_CACHE_TTL_SECONDS, + quoteCacheStaleTtlSeconds: data.SEP38_QUOTE_CACHE_STALE_TTL_SECONDS, + assetsCacheTtlSeconds: data.SEP38_ASSETS_CACHE_TTL_SECONDS, + }) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Invalid SEP-38 quotes cache timeout settings', + path: ['SEP38_QUOTE_CACHE_TTL_SECONDS'], + }); + } }); const parsed = envSchema.safeParse({ diff --git a/backend/src/config/sep38-quotes-cache.config.test.ts b/backend/src/config/sep38-quotes-cache.config.test.ts new file mode 100644 index 00000000..898e4972 --- /dev/null +++ b/backend/src/config/sep38-quotes-cache.config.test.ts @@ -0,0 +1,53 @@ +import { + buildQuoteExpirationTime, + defaultSep38QuotesCacheConfig, + isQuoteExpired, + validateSep38QuotesCacheConfig, +} from './sep38-quotes-cache.config'; + +describe('SEP-38 quotes cache configuration', () => { + it('accepts the default cache timeout settings', () => { + expect(validateSep38QuotesCacheConfig(defaultSep38QuotesCacheConfig)).toBe(true); + }); + + it('rejects cache TTL that exceeds indicative quote expiration', () => { + expect( + validateSep38QuotesCacheConfig({ + ...defaultSep38QuotesCacheConfig, + quoteCacheTtlSeconds: 90, + }), + ).toBe(false); + }); + + it('rejects stale TTL that pushes total cache lifetime past quote expiration', () => { + expect( + validateSep38QuotesCacheConfig({ + ...defaultSep38QuotesCacheConfig, + quoteCacheStaleTtlSeconds: 45, + }), + ).toBe(false); + }); + + it('rejects firm quote validity shorter than indicative expiration', () => { + expect( + validateSep38QuotesCacheConfig({ + ...defaultSep38QuotesCacheConfig, + firmQuoteValiditySeconds: 30, + }), + ).toBe(false); + }); + + it('detects expired quotes by expiration_time', () => { + const now = 1_700_000_000; + + expect(isQuoteExpired({ expiration_time: now - 1 }, now)).toBe(true); + expect(isQuoteExpired({ expiration_time: now }, now)).toBe(true); + expect(isQuoteExpired({ expiration_time: now + 1 }, now)).toBe(false); + }); + + it('builds quote expiration timestamps from configured seconds', () => { + const nowMs = 1_700_000_000_000; + + expect(buildQuoteExpirationTime(60, nowMs)).toBe(Math.floor(nowMs / 1000) + 60); + }); +}); diff --git a/backend/src/config/sep38-quotes-cache.config.ts b/backend/src/config/sep38-quotes-cache.config.ts new file mode 100644 index 00000000..7d4c5eae --- /dev/null +++ b/backend/src/config/sep38-quotes-cache.config.ts @@ -0,0 +1,94 @@ +import { config } from './env'; + +export interface Sep38QuotesCacheConfig { + indicativeQuoteExpirationSeconds: number; + firmQuoteValiditySeconds: number; + quoteCacheTtlSeconds: number; + quoteCacheStaleTtlSeconds: number; + assetsCacheTtlSeconds: number; +} + +export const defaultSep38QuotesCacheConfig: Sep38QuotesCacheConfig = { + indicativeQuoteExpirationSeconds: 60, + firmQuoteValiditySeconds: 300, + quoteCacheTtlSeconds: 30, + quoteCacheStaleTtlSeconds: 30, + assetsCacheTtlSeconds: 3600, +}; + +/** + * Validates SEP-38 quote cache timeout settings. + * Cache layers must not outlive indicative quote expiration. + */ +export function validateSep38QuotesCacheConfig( + cacheConfig: Sep38QuotesCacheConfig, +): boolean { + const { + indicativeQuoteExpirationSeconds, + firmQuoteValiditySeconds, + quoteCacheTtlSeconds, + quoteCacheStaleTtlSeconds, + assetsCacheTtlSeconds, + } = cacheConfig; + + if ( + indicativeQuoteExpirationSeconds < 15 || + indicativeQuoteExpirationSeconds > 300 + ) { + return false; + } + + if (firmQuoteValiditySeconds < 60 || firmQuoteValiditySeconds > 3600) { + return false; + } + + if (quoteCacheTtlSeconds < 5 || quoteCacheTtlSeconds > indicativeQuoteExpirationSeconds) { + return false; + } + + if (quoteCacheStaleTtlSeconds < 0) { + return false; + } + + if (quoteCacheTtlSeconds + quoteCacheStaleTtlSeconds > indicativeQuoteExpirationSeconds) { + return false; + } + + if (firmQuoteValiditySeconds < indicativeQuoteExpirationSeconds) { + return false; + } + + if (assetsCacheTtlSeconds < 60 || assetsCacheTtlSeconds > 86400) { + return false; + } + + return true; +} + +export function getSep38QuotesCacheConfig(): Sep38QuotesCacheConfig { + const cacheConfig: Sep38QuotesCacheConfig = { + indicativeQuoteExpirationSeconds: config.SEP38_INDICATIVE_QUOTE_EXPIRATION_SECONDS, + firmQuoteValiditySeconds: config.SEP38_FIRM_QUOTE_VALIDITY_SECONDS, + quoteCacheTtlSeconds: config.SEP38_QUOTE_CACHE_TTL_SECONDS, + quoteCacheStaleTtlSeconds: config.SEP38_QUOTE_CACHE_STALE_TTL_SECONDS, + assetsCacheTtlSeconds: config.SEP38_ASSETS_CACHE_TTL_SECONDS, + }; + + if (!validateSep38QuotesCacheConfig(cacheConfig)) { + throw new Error('Invalid SEP-38 quotes cache configuration'); + } + + return cacheConfig; +} + +export function isQuoteExpired(quote: { expiration_time: number }, nowSeconds?: number): boolean { + const now = nowSeconds ?? Math.floor(Date.now() / 1000); + return quote.expiration_time <= now; +} + +export function buildQuoteExpirationTime( + expirationSeconds: number, + nowMs: number = Date.now(), +): number { + return Math.floor(nowMs / 1000) + expirationSeconds; +}