Skip to content
10 changes: 10 additions & 0 deletions src/services/fx/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<KeetaFXAnchorQuote>;
Expand Down
85 changes: 85 additions & 0 deletions src/services/fx/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
}
}
});
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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');
}
});

124 changes: 115 additions & 9 deletions src/services/fx/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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> | 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<number>);
};

/**
Expand All @@ -92,6 +127,12 @@ async function formatQuoteSignable(unsignedQuote: Omit<KeetaFXAnchorQuoteJSON, '
unsignedQuote.cost.amount
];

// Include expiry information in the signature if present
if (unsignedQuote.expiry !== undefined) {
retval.push(unsignedQuote.expiry.serverTime);
retval.push(unsignedQuote.expiry.expiresAt);
}

return(retval);

/**
Expand All @@ -106,22 +147,51 @@ async function formatQuoteSignable(unsignedQuote: Omit<KeetaFXAnchorQuoteJSON, '
// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents,@typescript-eslint/no-duplicate-type-constituents
AssertNever<keyof Omit<typeof unsignedQuote['cost'], 'token' | 'amount'>> &
// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents,@typescript-eslint/no-duplicate-type-constituents
AssertNever<keyof Omit<typeof unsignedQuote, 'request' | 'convertedAmount' | 'cost' | 'account'>>
AssertNever<keyof Omit<typeof unsignedQuote, 'request' | 'convertedAmount' | 'cost' | 'account' | 'expiry'>>
>;
}

async function generateSignedQuote(signer: Signing.SignableAccount, unsignedQuote: Omit<KeetaFXAnchorQuoteJSON, 'signed'>): Promise<KeetaFXAnchorQuoteJSON> {
const signableQuote = await formatQuoteSignable(unsignedQuote);
const signed = await Signing.SignData(signer, signableQuote);
async function generateSignedQuote(signer: Signing.SignableAccount, unsignedQuote: Omit<KeetaFXAnchorQuoteJSON, 'signed' | 'expiry'>, quoteTTL?: number): Promise<KeetaFXAnchorQuoteJSON> {
// 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<KeetaFXAnchorQuoteJSON, 'signed'> = {
...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<boolean> {
const signableQuote = await formatQuoteSignable(quote);
// Extract the fields that are signed (all fields except 'signed')
const unsignedQuote: Omit<KeetaFXAnchorQuoteJSON, 'signed'> = {
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));
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down