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
7 changes: 7 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Empty file added backend/prisma/dev.db
Empty file.
49 changes: 37 additions & 12 deletions backend/src/api/controllers/sep38.controller.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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,
Expand All @@ -162,21 +169,37 @@ 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 };
}

const cached = await this.cache.cacheAside<PriceQuote>(
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 };
}

Expand All @@ -191,7 +214,9 @@ export class Sep38Controller {
): Promise<QuoteResponse> {
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: {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -336,7 +361,7 @@ export class Sep38Controller {
this.assetsCacheKey,
fetchAssets,
{
ttlSeconds: 3600,
ttlSeconds: this.cacheConfig.assetsCacheTtlSeconds,
tags: ['sep38', 'assets'],
}
);
Expand Down
43 changes: 40 additions & 3 deletions backend/src/config/env.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { z } from 'zod';
import dotenv from 'dotenv';
import { validateSep38QuotesCacheConfig } from './sep38-quotes-cache.config';

dotenv.config();

Expand Down Expand Up @@ -96,11 +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
SEP12_MAX_FILE_SIZE_MB: z
SEP38_INDICATIVE_QUOTE_EXPIRATION_SECONDS: z
.string()
.default('20')
.default('60')
.transform((val: string) => parseInt(val, 10))
.pipe(z.number().int().positive()),
.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({
Expand Down
53 changes: 53 additions & 0 deletions backend/src/config/sep38-quotes-cache.config.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
94 changes: 94 additions & 0 deletions backend/src/config/sep38-quotes-cache.config.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading