diff --git a/src/services/fx/common.ts b/src/services/fx/common.ts index 4d15e09f..25a90dd1 100644 --- a/src/services/fx/common.ts +++ b/src/services/fx/common.ts @@ -116,6 +116,16 @@ export type KeetaFXAnchorQuote = { /* Signature of the account public key and the nonce as an ASN.1 Sequence, Base64 DER */ signature: string; } + + /** + * Optional expiry information for the quote + */ + expiry?: { + /* Server time in ISO 8601 format with millisecond precision */ + serverTime: string; + /* Expiry time in ISO 8601 format with millisecond precision */ + expiresAt: string; + } }; export type KeetaFXAnchorQuoteJSON = ToJSONSerializable; diff --git a/src/services/fx/server.test.ts b/src/services/fx/server.test.ts index ec91a1a0..75e917fd 100644 --- a/src/services/fx/server.test.ts +++ b/src/services/fx/server.test.ts @@ -176,6 +176,7 @@ test('FX Server Quote Validation Tests', async function() { let validateQuoteCalled = false; let shouldAcceptQuote = true; + let currentTTL = 5000; // Start with 5 seconds await using server = new KeetaNetFXAnchorHTTPServer({ account: account, @@ -205,6 +206,10 @@ test('FX Server Quote Validation Tests', async function() { expect(quote).toHaveProperty('cost'); expect(quote).toHaveProperty('signed'); return(shouldAcceptQuote); + }, + // Use a function to determine TTL based on captured variable + quoteTTL: function(_ignore_request) { + return(currentTTL); } } }); @@ -240,6 +245,28 @@ test('FX Server Quote Validation Tests', async function() { const quote = quoteData.quote; + /* Verify that expiry information is included */ + if (typeof quote !== 'object' || quote === null || !('expiry' in quote)) { + throw(new Error('Quote should have expiry field')); + } + + const expiry = quote.expiry; + if (typeof expiry !== 'object' || expiry === null || + !('serverTime' in expiry) || typeof expiry.serverTime !== 'string' || + !('expiresAt' in expiry) || typeof expiry.expiresAt !== 'string') { + throw(new Error('Expiry should contain serverTime and expiresAt as strings')); + } + + const serverTime = new Date(expiry.serverTime); + const expiresAt = new Date(expiry.expiresAt); + expect(serverTime.toISOString()).toBe(expiry.serverTime); + expect(expiresAt.toISOString()).toBe(expiry.expiresAt); + + /* Verify that expiresAt is approximately 5 seconds after serverTime */ + const timeDiff = expiresAt.getTime() - serverTime.getTime(); + expect(timeDiff).toBeGreaterThanOrEqual(4999); // Allow 1ms tolerance + expect(timeDiff).toBeLessThanOrEqual(5001); // Allow 1ms tolerance + /* Test that the quote is rejected when validateQuote returns false */ validateQuoteCalled = false; shouldAcceptQuote = false; @@ -269,4 +296,62 @@ test('FX Server Quote Validation Tests', async function() { if (typeof errorData === 'object' && errorData !== null && 'name' in errorData) { expect(errorData.name).toBe('KeetaFXAnchorQuoteValidationFailedError'); } + + /* Test with an expired quote by changing the TTL to 1ms */ + currentTTL = 1; // Change TTL to 1 millisecond + + /* Get a quote with very short TTL */ + const shortQuoteResponse = await fetch(`${url}/api/getQuote`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: JSON.stringify({ + request: { + from: token1.publicKeyString.get(), + to: token2.publicKeyString.get(), + amount: '100', + affinity: 'from' + } + }) + }); + + expect(shortQuoteResponse.status).toBe(200); + const shortQuoteData: unknown = await shortQuoteResponse.json(); + expect(shortQuoteData).toHaveProperty('ok', true); + expect(shortQuoteData).toHaveProperty('quote'); + + if (typeof shortQuoteData !== 'object' || shortQuoteData === null || !('quote' in shortQuoteData)) { + throw(new Error('Invalid quote response')); + } + + const expiredQuote = shortQuoteData.quote; + + /* Wait to ensure the quote expires */ + await new Promise(resolve => setTimeout(resolve, 10)); + + const exchangeResponseExpired = await fetch(`${url}/api/createExchange`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: JSON.stringify({ + request: { + quote: expiredQuote, + block: 'AAAAAAAAAA==' + } + }) + }); + + /* The expired quote should be rejected */ + expect(exchangeResponseExpired.status).toBe(400); + const expiredData: unknown = await exchangeResponseExpired.json(); + expect(expiredData).toHaveProperty('ok', false); + expect(expiredData).toHaveProperty('error'); + if (typeof expiredData === 'object' && expiredData !== null && 'name' in expiredData) { + expect(expiredData.name).toBe('KeetaFXAnchorQuoteValidationFailedError'); + } }); + diff --git a/src/services/fx/server.ts b/src/services/fx/server.ts index 4bbbe1be..97e351d7 100644 --- a/src/services/fx/server.ts +++ b/src/services/fx/server.ts @@ -22,6 +22,27 @@ import * as Signing from '../../lib/utils/signing.js'; import type { AssertNever } from '../../lib/utils/never.ts'; import type { ServiceMetadata } from '../../lib/resolver.js'; +/** + * Maximum quote TTL in milliseconds (5 minutes, matching message expiry) + */ +const MAX_QUOTE_TTL = 5 * 60 * 1000; + +/** + * Default quote TTL in milliseconds (5 minutes, matching message expiry) + */ +const DEFAULT_QUOTE_TTL = MAX_QUOTE_TTL; + +/** + * Validates that a quote TTL value doesn't exceed the maximum allowed + * @param ttl The TTL value to validate in milliseconds + * @throws Error if the TTL exceeds the maximum + */ +function validateQuoteTTL(ttl: number): void { + if (ttl > MAX_QUOTE_TTL) { + throw(new Error(`quoteTTL cannot exceed ${MAX_QUOTE_TTL}ms`)); + } +} + export interface KeetaAnchorFXServerConfig extends KeetaAnchorHTTPServer.KeetaAnchorHTTPServerConfig { /** * The data to use for the index page (optional) @@ -72,6 +93,20 @@ export interface KeetaAnchorFXServerConfig extends KeetaAnchorHTTPServer.KeetaAn * @returns true to accept the quote and proceed with the exchange, false to reject it */ validateQuote?: (quote: KeetaFXAnchorQuoteJSON) => Promise | boolean; + /** + * Optional quote time-to-live (TTL) in milliseconds + * + * Can be either: + * - A number representing the TTL in milliseconds + * - A function that receives the conversion request and returns the TTL in milliseconds + * + * If specified, quotes will include expiry information and will be rejected + * if they are expired when used in createExchange requests. + * + * Default: 300000 (5 minutes, matching message expiry) + * Maximum: 300000 (5 minutes) + */ + quoteTTL?: number | ((request: ConversionInputCanonicalJSON) => number | Promise); }; /** @@ -92,6 +127,12 @@ async function formatQuoteSignable(unsignedQuote: Omit> & // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents,@typescript-eslint/no-duplicate-type-constituents - AssertNever> + AssertNever> >; } -async function generateSignedQuote(signer: Signing.SignableAccount, unsignedQuote: Omit): Promise { - const signableQuote = await formatQuoteSignable(unsignedQuote); - const signed = await Signing.SignData(signer, signableQuote); +async function generateSignedQuote(signer: Signing.SignableAccount, unsignedQuote: Omit, quoteTTL?: number): Promise { + // Create expiry information before signing (if quoteTTL is provided) + let expiry: KeetaFXAnchorQuoteJSON['expiry'] = undefined; + if (quoteTTL !== undefined && quoteTTL > 0) { + const serverTime = new Date(); + const expiresAt = new Date(serverTime.getTime() + quoteTTL); + expiry = { + serverTime: serverTime.toISOString(), + expiresAt: expiresAt.toISOString() + }; + } - return({ + // Create the quote with expiry (to be included in signature) + const quoteToSign: Omit = { ...unsignedQuote, + ...(expiry !== undefined ? { expiry } : {}) + }; + + // Sign the quote (including expiry if present) + const signableQuote = await formatQuoteSignable(quoteToSign); + const signed = await Signing.SignData(signer, signableQuote); + + // Return the complete quote with signature + const result: KeetaFXAnchorQuoteJSON = { + ...quoteToSign, signed: signed - }); + }; + + return(result); } async function verifySignedData(signedBy: Signing.VerifableAccount, quote: KeetaFXAnchorQuoteJSON): Promise { - const signableQuote = await formatQuoteSignable(quote); + // Extract the fields that are signed (all fields except 'signed') + const unsignedQuote: Omit = { + request: quote.request, + account: quote.account, + convertedAmount: quote.convertedAmount, + cost: quote.cost, + ...(quote.expiry !== undefined ? { expiry: quote.expiry } : {}) + }; + const signableQuote = await formatQuoteSignable(unsignedQuote); return(await Signing.VerifySignedData(signedBy, signableQuote, quote.signed)); } @@ -160,7 +230,24 @@ export class KeetaNetFXAnchorHTTPServer extends KeetaAnchorHTTPServer.KeetaNetAn this.homepage = config.homepage ?? ''; this.client = config.client; - this.fx = config.fx; + + /* Validate and set quoteTTL with default and maximum */ + let quoteTTL = config.fx.quoteTTL; + + // If quoteTTL is a number, validate it doesn't exceed maximum + if (typeof quoteTTL === 'number') { + validateQuoteTTL(quoteTTL); + } + + // Set default if not provided + if (quoteTTL === undefined) { + quoteTTL = DEFAULT_QUOTE_TTL; + } + + this.fx = { + ...config.fx, + quoteTTL: quoteTTL + }; this.account = config.account; this.signer = config.signer ?? config.account; this.quoteSigner = config.quoteSigner; @@ -239,7 +326,17 @@ export class KeetaNetFXAnchorHTTPServer extends KeetaAnchorHTTPServer.KeetaNetAn ...rateAndFee }); - const signedQuote = await generateSignedQuote(config.quoteSigner, unsignedQuote); + // Resolve quoteTTL (could be a number or a function) + let resolvedQuoteTTL: number | undefined; + if (typeof config.fx.quoteTTL === 'function') { + resolvedQuoteTTL = await config.fx.quoteTTL(conversion); + // Validate the returned TTL doesn't exceed maximum + validateQuoteTTL(resolvedQuoteTTL); + } else { + resolvedQuoteTTL = config.fx.quoteTTL; + } + + const signedQuote = await generateSignedQuote(config.quoteSigner, unsignedQuote, resolvedQuoteTTL); const quoteResponse: KeetaFXAnchorQuoteResponse = { ok: true, quote: signedQuote @@ -276,6 +373,15 @@ export class KeetaNetFXAnchorHTTPServer extends KeetaAnchorHTTPServer.KeetaNetAn throw(new Error('Invalid quote signature')); } + /* Check if the quote has expired (default validation) */ + if (quote.expiry !== undefined) { + const now = new Date(); + const expiresAt = new Date(quote.expiry.expiresAt); + if (now >= expiresAt) { + throw(new Errors.QuoteValidationFailed('Quote has expired')); + } + } + /* Validate the quote using the optional callback */ if (config.fx.validateQuote !== undefined) { const isAcceptable = await config.fx.validateQuote(quote);