From a5a2c4c862c32c3ca78eb400fe9c1c4e010a6d73 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 10 Nov 2025 17:09:58 +0000 Subject: [PATCH 1/9] Initial plan From 42915f5651c5f386863a5c9d4a04e70a158a7e4c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 10 Nov 2025 17:23:40 +0000 Subject: [PATCH 2/9] Add quote expiry information to FX quotes Co-authored-by: rkeene <5068442+rkeene@users.noreply.github.com> --- src/services/fx/common.ts | 4 + src/services/fx/server.test.ts | 217 +++++++++++++++++++++++++++++++++ src/services/fx/server.ts | 35 +++++- 3 files changed, 253 insertions(+), 3 deletions(-) diff --git a/src/services/fx/common.ts b/src/services/fx/common.ts index 4d15e09f..47213b21 100644 --- a/src/services/fx/common.ts +++ b/src/services/fx/common.ts @@ -115,6 +115,10 @@ export type KeetaFXAnchorQuote = { timestamp: string; /* Signature of the account public key and the nonce as an ASN.1 Sequence, Base64 DER */ signature: string; + /* Server time in ISO 8601 format with millisecond precision (optional) */ + serverTime?: string; + /* Expiry time in ISO 8601 format with millisecond precision (optional) */ + expiresAt?: string; } }; diff --git a/src/services/fx/server.test.ts b/src/services/fx/server.test.ts index ec91a1a0..e021e9f4 100644 --- a/src/services/fx/server.test.ts +++ b/src/services/fx/server.test.ts @@ -270,3 +270,220 @@ test('FX Server Quote Validation Tests', async function() { expect(errorData.name).toBe('KeetaFXAnchorQuoteValidationFailedError'); } }); + +test('FX Server Quote Expiry Tests', async function() { + const account = KeetaNet.lib.Account.fromSeed(KeetaNet.lib.Account.generateRandomSeed(), 0); + const token1 = account.generateIdentifier(KeetaNet.lib.Account.AccountKeyAlgorithm.TOKEN, undefined, 1); + const token2 = account.generateIdentifier(KeetaNet.lib.Account.AccountKeyAlgorithm.TOKEN, undefined, 2); + const { userClient: client } = await createNodeAndClient(account); + + /* Test with 5 second TTL */ + await using server = new KeetaNetFXAnchorHTTPServer({ + account: account, + client: client, + quoteSigner: account, + fx: { + from: [{ + currencyCodes: [token1.publicKeyString.get()], + to: [token2.publicKeyString.get()] + }], + getConversionRateAndFee: async function() { + return({ + account: account, + convertedAmount: 1000n, + cost: { + amount: 0n, + token: token1 + } + }); + }, + quoteTTL: 5000 // 5 seconds + } + }); + + await server.start(); + const url = server.url; + + /* Get a quote with expiry information */ + const quoteResponse = 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(quoteResponse.status).toBe(200); + const quoteData: unknown = await quoteResponse.json(); + expect(quoteData).toHaveProperty('ok', true); + expect(quoteData).toHaveProperty('quote'); + + if (typeof quoteData !== 'object' || quoteData === null || !('quote' in quoteData)) { + throw(new Error('Invalid quote response')); + } + + const quote = quoteData.quote; + if (typeof quote !== 'object' || quote === null || !('signed' in quote)) { + throw(new Error('Invalid quote structure')); + } + + const signed = quote.signed; + if (typeof signed !== 'object' || signed === null) { + throw(new Error('Invalid signed structure')); + } + + /* Verify that expiry information is included */ + expect(signed).toHaveProperty('serverTime'); + expect(signed).toHaveProperty('expiresAt'); + + /* Verify that serverTime and expiresAt are valid ISO 8601 timestamps */ + if (typeof signed !== 'object' || signed === null || + !('serverTime' in signed) || typeof signed.serverTime !== 'string' || + !('expiresAt' in signed) || typeof signed.expiresAt !== 'string') { + throw(new Error('serverTime and expiresAt should be strings')); + } + + const serverTime = new Date(signed.serverTime); + const expiresAt = new Date(signed.expiresAt); + expect(serverTime.toISOString()).toBe(signed.serverTime); + expect(expiresAt.toISOString()).toBe(signed.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 a non-expired quote is accepted */ + const exchangeResponseValid = await fetch(`${url}/api/createExchange`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: JSON.stringify({ + request: { + quote: quote, + block: 'AAAAAAAAAA==' // A minimal valid base64 string that will decode but fail later (but after expiry check) + } + }) + }); + + /* The quote should not be rejected for expiry (it will fail for other reasons) */ + /* If it was rejected for expiry, status would be 400 with QuoteValidationFailed error */ + const validData: unknown = await exchangeResponseValid.json(); + if (typeof validData === 'object' && validData !== null && 'name' in validData) { + expect(validData.name).not.toBe('KeetaFXAnchorQuoteValidationFailedError'); + } + + /* Now test with an expired quote by manipulating the expiresAt timestamp */ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const expiredQuote = JSON.parse(JSON.stringify(quote)); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + expiredQuote.signed.expiresAt = new Date(Date.now() - 1000).toISOString(); // 1 second in the past + + const exchangeResponseExpired = await fetch(`${url}/api/createExchange`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: JSON.stringify({ + request: { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + 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'); + } +}); + +test('FX Server Quote Without Expiry Tests', async function() { + const account = KeetaNet.lib.Account.fromSeed(KeetaNet.lib.Account.generateRandomSeed(), 0); + const token1 = account.generateIdentifier(KeetaNet.lib.Account.AccountKeyAlgorithm.TOKEN, undefined, 1); + const token2 = account.generateIdentifier(KeetaNet.lib.Account.AccountKeyAlgorithm.TOKEN, undefined, 2); + const { userClient: client } = await createNodeAndClient(account); + + /* Test without TTL (expiry disabled) */ + await using server = new KeetaNetFXAnchorHTTPServer({ + account: account, + client: client, + quoteSigner: account, + fx: { + from: [{ + currencyCodes: [token1.publicKeyString.get()], + to: [token2.publicKeyString.get()] + }], + getConversionRateAndFee: async function() { + return({ + account: account, + convertedAmount: 1000n, + cost: { + amount: 0n, + token: token1 + } + }); + } + /* quoteTTL not specified - no expiry */ + } + }); + + await server.start(); + const url = server.url; + + /* Get a quote without expiry information */ + const quoteResponse = 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(quoteResponse.status).toBe(200); + const quoteData: unknown = await quoteResponse.json(); + expect(quoteData).toHaveProperty('ok', true); + expect(quoteData).toHaveProperty('quote'); + + if (typeof quoteData !== 'object' || quoteData === null || !('quote' in quoteData)) { + throw(new Error('Invalid quote response')); + } + + const quote = quoteData.quote; + if (typeof quote !== 'object' || quote === null || !('signed' in quote)) { + throw(new Error('Invalid quote structure')); + } + + const signed = quote.signed; + if (typeof signed !== 'object' || signed === null) { + throw(new Error('Invalid signed structure')); + } + + /* Verify that expiry information is NOT included */ + expect('serverTime' in signed ? signed.serverTime : undefined).toBeUndefined(); + expect('expiresAt' in signed ? signed.expiresAt : undefined).toBeUndefined(); +}); diff --git a/src/services/fx/server.ts b/src/services/fx/server.ts index 4bbbe1be..614b0f20 100644 --- a/src/services/fx/server.ts +++ b/src/services/fx/server.ts @@ -72,6 +72,15 @@ 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 + * + * If specified, quotes will include expiry information and will be rejected + * if they are expired when used in createExchange requests. + * + * Default: undefined (no expiry) + */ + quoteTTL?: number; }; /** @@ -110,13 +119,24 @@ async function formatQuoteSignable(unsignedQuote: Omit; } -async function generateSignedQuote(signer: Signing.SignableAccount, unsignedQuote: Omit): Promise { +async function generateSignedQuote(signer: Signing.SignableAccount, unsignedQuote: Omit, quoteTTL?: number): Promise { const signableQuote = await formatQuoteSignable(unsignedQuote); const signed = await Signing.SignData(signer, signableQuote); + const serverTime = new Date(); + const signedWithExpiry: KeetaFXAnchorQuoteJSON['signed'] = { + ...signed + }; + + if (quoteTTL !== undefined && quoteTTL > 0) { + signedWithExpiry.serverTime = serverTime.toISOString(); + const expiresAt = new Date(serverTime.getTime() + quoteTTL); + signedWithExpiry.expiresAt = expiresAt.toISOString(); + } + return({ ...unsignedQuote, - signed: signed + signed: signedWithExpiry }); } @@ -239,7 +259,7 @@ export class KeetaNetFXAnchorHTTPServer extends KeetaAnchorHTTPServer.KeetaNetAn ...rateAndFee }); - const signedQuote = await generateSignedQuote(config.quoteSigner, unsignedQuote); + const signedQuote = await generateSignedQuote(config.quoteSigner, unsignedQuote, config.fx.quoteTTL); const quoteResponse: KeetaFXAnchorQuoteResponse = { ok: true, quote: signedQuote @@ -276,6 +296,15 @@ export class KeetaNetFXAnchorHTTPServer extends KeetaAnchorHTTPServer.KeetaNetAn throw(new Error('Invalid quote signature')); } + /* Check if the quote has expired (default validation) */ + if (quote.signed.expiresAt !== undefined) { + const now = new Date(); + const expiresAt = new Date(quote.signed.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); From ec9ef822cfd921e505db72022e04884bfd190a3e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 10 Nov 2025 18:12:37 +0000 Subject: [PATCH 3/9] Address PR feedback: restructure expiry fields, set default TTL, merge tests Co-authored-by: rkeene <5068442+rkeene@users.noreply.github.com> --- src/services/fx/common.ts | 14 ++- src/services/fx/server.test.ts | 215 ++++----------------------------- src/services/fx/server.ts | 55 ++++++--- 3 files changed, 74 insertions(+), 210 deletions(-) diff --git a/src/services/fx/common.ts b/src/services/fx/common.ts index 47213b21..25a90dd1 100644 --- a/src/services/fx/common.ts +++ b/src/services/fx/common.ts @@ -115,10 +115,16 @@ export type KeetaFXAnchorQuote = { timestamp: string; /* Signature of the account public key and the nonce as an ASN.1 Sequence, Base64 DER */ signature: string; - /* Server time in ISO 8601 format with millisecond precision (optional) */ - serverTime?: string; - /* Expiry time in ISO 8601 format with millisecond precision (optional) */ - expiresAt?: 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; } }; diff --git a/src/services/fx/server.test.ts b/src/services/fx/server.test.ts index e021e9f4..514dfa6e 100644 --- a/src/services/fx/server.test.ts +++ b/src/services/fx/server.test.ts @@ -205,7 +205,8 @@ test('FX Server Quote Validation Tests', async function() { expect(quote).toHaveProperty('cost'); expect(quote).toHaveProperty('signed'); return(shouldAcceptQuote); - } + }, + quoteTTL: 5000 // 5 seconds } }); @@ -240,6 +241,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,125 +292,12 @@ test('FX Server Quote Validation Tests', async function() { if (typeof errorData === 'object' && errorData !== null && 'name' in errorData) { expect(errorData.name).toBe('KeetaFXAnchorQuoteValidationFailedError'); } -}); - -test('FX Server Quote Expiry Tests', async function() { - const account = KeetaNet.lib.Account.fromSeed(KeetaNet.lib.Account.generateRandomSeed(), 0); - const token1 = account.generateIdentifier(KeetaNet.lib.Account.AccountKeyAlgorithm.TOKEN, undefined, 1); - const token2 = account.generateIdentifier(KeetaNet.lib.Account.AccountKeyAlgorithm.TOKEN, undefined, 2); - const { userClient: client } = await createNodeAndClient(account); - - /* Test with 5 second TTL */ - await using server = new KeetaNetFXAnchorHTTPServer({ - account: account, - client: client, - quoteSigner: account, - fx: { - from: [{ - currencyCodes: [token1.publicKeyString.get()], - to: [token2.publicKeyString.get()] - }], - getConversionRateAndFee: async function() { - return({ - account: account, - convertedAmount: 1000n, - cost: { - amount: 0n, - token: token1 - } - }); - }, - quoteTTL: 5000 // 5 seconds - } - }); - - await server.start(); - const url = server.url; - - /* Get a quote with expiry information */ - const quoteResponse = 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(quoteResponse.status).toBe(200); - const quoteData: unknown = await quoteResponse.json(); - expect(quoteData).toHaveProperty('ok', true); - expect(quoteData).toHaveProperty('quote'); - - if (typeof quoteData !== 'object' || quoteData === null || !('quote' in quoteData)) { - throw(new Error('Invalid quote response')); - } - - const quote = quoteData.quote; - if (typeof quote !== 'object' || quote === null || !('signed' in quote)) { - throw(new Error('Invalid quote structure')); - } - - const signed = quote.signed; - if (typeof signed !== 'object' || signed === null) { - throw(new Error('Invalid signed structure')); - } - - /* Verify that expiry information is included */ - expect(signed).toHaveProperty('serverTime'); - expect(signed).toHaveProperty('expiresAt'); - - /* Verify that serverTime and expiresAt are valid ISO 8601 timestamps */ - if (typeof signed !== 'object' || signed === null || - !('serverTime' in signed) || typeof signed.serverTime !== 'string' || - !('expiresAt' in signed) || typeof signed.expiresAt !== 'string') { - throw(new Error('serverTime and expiresAt should be strings')); - } - - const serverTime = new Date(signed.serverTime); - const expiresAt = new Date(signed.expiresAt); - expect(serverTime.toISOString()).toBe(signed.serverTime); - expect(expiresAt.toISOString()).toBe(signed.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 a non-expired quote is accepted */ - const exchangeResponseValid = await fetch(`${url}/api/createExchange`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json' - }, - body: JSON.stringify({ - request: { - quote: quote, - block: 'AAAAAAAAAA==' // A minimal valid base64 string that will decode but fail later (but after expiry check) - } - }) - }); - - /* The quote should not be rejected for expiry (it will fail for other reasons) */ - /* If it was rejected for expiry, status would be 400 with QuoteValidationFailed error */ - const validData: unknown = await exchangeResponseValid.json(); - if (typeof validData === 'object' && validData !== null && 'name' in validData) { - expect(validData.name).not.toBe('KeetaFXAnchorQuoteValidationFailedError'); - } - /* Now test with an expired quote by manipulating the expiresAt timestamp */ + /* Test with an expired quote by manipulating the expiresAt timestamp */ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const expiredQuote = JSON.parse(JSON.stringify(quote)); // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - expiredQuote.signed.expiresAt = new Date(Date.now() - 1000).toISOString(); // 1 second in the past + expiredQuote.expiry.expiresAt = new Date(Date.now() - 1000).toISOString(); // 1 second in the past const exchangeResponseExpired = await fetch(`${url}/api/createExchange`, { method: 'POST', @@ -414,76 +324,3 @@ test('FX Server Quote Expiry Tests', async function() { } }); -test('FX Server Quote Without Expiry Tests', async function() { - const account = KeetaNet.lib.Account.fromSeed(KeetaNet.lib.Account.generateRandomSeed(), 0); - const token1 = account.generateIdentifier(KeetaNet.lib.Account.AccountKeyAlgorithm.TOKEN, undefined, 1); - const token2 = account.generateIdentifier(KeetaNet.lib.Account.AccountKeyAlgorithm.TOKEN, undefined, 2); - const { userClient: client } = await createNodeAndClient(account); - - /* Test without TTL (expiry disabled) */ - await using server = new KeetaNetFXAnchorHTTPServer({ - account: account, - client: client, - quoteSigner: account, - fx: { - from: [{ - currencyCodes: [token1.publicKeyString.get()], - to: [token2.publicKeyString.get()] - }], - getConversionRateAndFee: async function() { - return({ - account: account, - convertedAmount: 1000n, - cost: { - amount: 0n, - token: token1 - } - }); - } - /* quoteTTL not specified - no expiry */ - } - }); - - await server.start(); - const url = server.url; - - /* Get a quote without expiry information */ - const quoteResponse = 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(quoteResponse.status).toBe(200); - const quoteData: unknown = await quoteResponse.json(); - expect(quoteData).toHaveProperty('ok', true); - expect(quoteData).toHaveProperty('quote'); - - if (typeof quoteData !== 'object' || quoteData === null || !('quote' in quoteData)) { - throw(new Error('Invalid quote response')); - } - - const quote = quoteData.quote; - if (typeof quote !== 'object' || quote === null || !('signed' in quote)) { - throw(new Error('Invalid quote structure')); - } - - const signed = quote.signed; - if (typeof signed !== 'object' || signed === null) { - throw(new Error('Invalid signed structure')); - } - - /* Verify that expiry information is NOT included */ - expect('serverTime' in signed ? signed.serverTime : undefined).toBeUndefined(); - expect('expiresAt' in signed ? signed.expiresAt : undefined).toBeUndefined(); -}); diff --git a/src/services/fx/server.ts b/src/services/fx/server.ts index 614b0f20..ef53fdf6 100644 --- a/src/services/fx/server.ts +++ b/src/services/fx/server.ts @@ -78,7 +78,8 @@ export interface KeetaAnchorFXServerConfig extends KeetaAnchorHTTPServer.KeetaAn * If specified, quotes will include expiry information and will be rejected * if they are expired when used in createExchange requests. * - * Default: undefined (no expiry) + * Default: 300000 (5 minutes, matching message expiry) + * Maximum: 300000 (5 minutes) */ quoteTTL?: number; }; @@ -89,7 +90,7 @@ export interface KeetaAnchorFXServerConfig extends KeetaAnchorHTTPServer.KeetaAn client: { client: KeetaNet.Client; network: bigint; networkAlias: typeof KeetaNet.Client.Config.networksArray[number] } | KeetaNet.UserClient; }; -async function formatQuoteSignable(unsignedQuote: Omit): Promise { +async function formatQuoteSignable(unsignedQuote: Omit): Promise { const retval: Signing.Signable = [ unsignedQuote.request.from, unsignedQuote.request.to, @@ -114,34 +115,40 @@ async function formatQuoteSignable(unsignedQuote: Omit> & // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents,@typescript-eslint/no-duplicate-type-constituents AssertNever> & - // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents,@typescript-eslint/no-duplicate-type-constituents AssertNever> >; } -async function generateSignedQuote(signer: Signing.SignableAccount, unsignedQuote: Omit, quoteTTL?: number): Promise { +async function generateSignedQuote(signer: Signing.SignableAccount, unsignedQuote: Omit, quoteTTL?: number): Promise { const signableQuote = await formatQuoteSignable(unsignedQuote); const signed = await Signing.SignData(signer, signableQuote); - const serverTime = new Date(); - const signedWithExpiry: KeetaFXAnchorQuoteJSON['signed'] = { - ...signed + const result: KeetaFXAnchorQuoteJSON = { + ...unsignedQuote, + signed: signed }; if (quoteTTL !== undefined && quoteTTL > 0) { - signedWithExpiry.serverTime = serverTime.toISOString(); + const serverTime = new Date(); const expiresAt = new Date(serverTime.getTime() + quoteTTL); - signedWithExpiry.expiresAt = expiresAt.toISOString(); + result.expiry = { + serverTime: serverTime.toISOString(), + expiresAt: expiresAt.toISOString() + }; } - return({ - ...unsignedQuote, - signed: signedWithExpiry - }); + return(result); } async function verifySignedData(signedBy: Signing.VerifableAccount, quote: KeetaFXAnchorQuoteJSON): Promise { - const signableQuote = await formatQuoteSignable(quote); + // Extract only the fields that are signed (exclude 'signed' and 'expiry') + const unsignedQuote: Omit = { + request: quote.request, + account: quote.account, + convertedAmount: quote.convertedAmount, + cost: quote.cost + }; + const signableQuote = await formatQuoteSignable(unsignedQuote); return(await Signing.VerifySignedData(signedBy, signableQuote, quote.signed)); } @@ -180,7 +187,21 @@ 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 */ + const maxQuoteTTL = 5 * 60 * 1000; // 5 minutes (message expiry) + let quoteTTL = config.fx.quoteTTL; + + if (quoteTTL === undefined) { + quoteTTL = maxQuoteTTL; // Default to 5 minutes + } else if (quoteTTL > maxQuoteTTL) { + throw(new Error(`quoteTTL cannot exceed ${maxQuoteTTL}ms (5 minutes)`)); + } + + this.fx = { + ...config.fx, + quoteTTL: quoteTTL + }; this.account = config.account; this.signer = config.signer ?? config.account; this.quoteSigner = config.quoteSigner; @@ -297,9 +318,9 @@ export class KeetaNetFXAnchorHTTPServer extends KeetaAnchorHTTPServer.KeetaNetAn } /* Check if the quote has expired (default validation) */ - if (quote.signed.expiresAt !== undefined) { + if (quote.expiry !== undefined) { const now = new Date(); - const expiresAt = new Date(quote.signed.expiresAt); + const expiresAt = new Date(quote.expiry.expiresAt); if (now >= expiresAt) { throw(new Errors.QuoteValidationFailed('Quote has expired')); } From a04f8603c8034c08c3d71b85df71ae1a9f474f87 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 10 Nov 2025 18:15:41 +0000 Subject: [PATCH 4/9] Fix linting errors in static assertion Co-authored-by: rkeene <5068442+rkeene@users.noreply.github.com> --- src/services/fx/server.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/services/fx/server.ts b/src/services/fx/server.ts index ef53fdf6..05afe835 100644 --- a/src/services/fx/server.ts +++ b/src/services/fx/server.ts @@ -115,6 +115,7 @@ async function formatQuoteSignable(unsignedQuote: Omit> & // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents,@typescript-eslint/no-duplicate-type-constituents AssertNever> & + // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents,@typescript-eslint/no-duplicate-type-constituents AssertNever> >; } From 9a2a2d89f0a60aa7adec62475fcf9dbd59cfc073 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 10 Nov 2025 19:33:51 +0000 Subject: [PATCH 5/9] Include expiry information in quote signature Co-authored-by: rkeene <5068442+rkeene@users.noreply.github.com> --- src/services/fx/server.test.ts | 65 ++++++++++++++++++++++++++++++---- src/services/fx/server.ts | 45 +++++++++++++++-------- 2 files changed, 89 insertions(+), 21 deletions(-) diff --git a/src/services/fx/server.test.ts b/src/services/fx/server.test.ts index 514dfa6e..4d259390 100644 --- a/src/services/fx/server.test.ts +++ b/src/services/fx/server.test.ts @@ -293,13 +293,65 @@ test('FX Server Quote Validation Tests', async function() { expect(errorData.name).toBe('KeetaFXAnchorQuoteValidationFailedError'); } - /* Test with an expired quote by manipulating the expiresAt timestamp */ - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const expiredQuote = JSON.parse(JSON.stringify(quote)); - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - expiredQuote.expiry.expiresAt = new Date(Date.now() - 1000).toISOString(); // 1 second in the past + /* Test with an expired quote using a server with 1ms TTL */ + await using shortTTLServer = new KeetaNetFXAnchorHTTPServer({ + account: account, + client: client, + quoteSigner: account, + fx: { + from: [{ + currencyCodes: [token1.publicKeyString.get()], + to: [token2.publicKeyString.get()] + }], + getConversionRateAndFee: async function() { + return({ + account: account, + convertedAmount: 1000n, + cost: { + amount: 0n, + token: token1 + } + }); + }, + quoteTTL: 1 // 1 millisecond + } + }); + + await shortTTLServer.start(); + const shortTTLUrl = shortTTLServer.url; + + /* Get a quote with very short TTL */ + const shortQuoteResponse = await fetch(`${shortTTLUrl}/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`, { + const exchangeResponseExpired = await fetch(`${shortTTLUrl}/api/createExchange`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -307,7 +359,6 @@ test('FX Server Quote Validation Tests', async function() { }, body: JSON.stringify({ request: { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment quote: expiredQuote, block: 'AAAAAAAAAA==' } diff --git a/src/services/fx/server.ts b/src/services/fx/server.ts index 05afe835..807413ef 100644 --- a/src/services/fx/server.ts +++ b/src/services/fx/server.ts @@ -90,7 +90,7 @@ export interface KeetaAnchorFXServerConfig extends KeetaAnchorHTTPServer.KeetaAn client: { client: KeetaNet.Client; network: bigint; networkAlias: typeof KeetaNet.Client.Config.networksArray[number] } | KeetaNet.UserClient; }; -async function formatQuoteSignable(unsignedQuote: Omit): Promise { +async function formatQuoteSignable(unsignedQuote: Omit): Promise { const retval: Signing.Signable = [ unsignedQuote.request.from, unsignedQuote.request.to, @@ -102,6 +102,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, quoteTTL?: number): Promise { - const signableQuote = await formatQuoteSignable(unsignedQuote); - const signed = await Signing.SignData(signer, signableQuote); - - const result: KeetaFXAnchorQuoteJSON = { - ...unsignedQuote, - signed: signed - }; - + // 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); - result.expiry = { + expiry = { serverTime: serverTime.toISOString(), expiresAt: expiresAt.toISOString() }; } + // 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 { - // Extract only the fields that are signed (exclude 'signed' and 'expiry') - const unsignedQuote: Omit = { + // 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 + cost: quote.cost, + ...(quote.expiry !== undefined ? { expiry: quote.expiry } : {}) }; const signableQuote = await formatQuoteSignable(unsignedQuote); From 947dd42ef32406830ce0d0e7da254d4feb18c2a9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 10 Nov 2025 19:53:40 +0000 Subject: [PATCH 6/9] Remove hardcoded time unit from error message Co-authored-by: rkeene <5068442+rkeene@users.noreply.github.com> --- src/services/fx/server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/fx/server.ts b/src/services/fx/server.ts index 807413ef..2858a02d 100644 --- a/src/services/fx/server.ts +++ b/src/services/fx/server.ts @@ -213,7 +213,7 @@ export class KeetaNetFXAnchorHTTPServer extends KeetaAnchorHTTPServer.KeetaNetAn if (quoteTTL === undefined) { quoteTTL = maxQuoteTTL; // Default to 5 minutes } else if (quoteTTL > maxQuoteTTL) { - throw(new Error(`quoteTTL cannot exceed ${maxQuoteTTL}ms (5 minutes)`)); + throw(new Error(`quoteTTL cannot exceed ${maxQuoteTTL}ms`)); } this.fx = { From 5266e879f0570852730e4bcfcc01632140c04855 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 10 Nov 2025 20:37:12 +0000 Subject: [PATCH 7/9] Allow quoteTTL to be a function for dynamic TTL determination Co-authored-by: rkeene <5068442+rkeene@users.noreply.github.com> --- src/services/fx/server.test.ts | 7 ++++++- src/services/fx/server.ts | 29 +++++++++++++++++++++++++---- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/src/services/fx/server.test.ts b/src/services/fx/server.test.ts index 4d259390..0efd195e 100644 --- a/src/services/fx/server.test.ts +++ b/src/services/fx/server.test.ts @@ -206,7 +206,12 @@ test('FX Server Quote Validation Tests', async function() { expect(quote).toHaveProperty('signed'); return(shouldAcceptQuote); }, - quoteTTL: 5000 // 5 seconds + // Use a function to determine TTL based on request + quoteTTL: function(_ignore_request) { + // For this test, return 5 seconds for all requests + // In real usage, this could vary based on request properties + return(5000); + } } }); diff --git a/src/services/fx/server.ts b/src/services/fx/server.ts index 2858a02d..be8dbfed 100644 --- a/src/services/fx/server.ts +++ b/src/services/fx/server.ts @@ -75,13 +75,17 @@ export interface KeetaAnchorFXServerConfig extends KeetaAnchorHTTPServer.KeetaAn /** * 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; + quoteTTL?: number | ((request: ConversionInputCanonicalJSON) => number | Promise); }; /** @@ -210,10 +214,14 @@ export class KeetaNetFXAnchorHTTPServer extends KeetaAnchorHTTPServer.KeetaNetAn const maxQuoteTTL = 5 * 60 * 1000; // 5 minutes (message expiry) let quoteTTL = config.fx.quoteTTL; + // If quoteTTL is a number, validate it doesn't exceed maximum + if (typeof quoteTTL === 'number' && quoteTTL > maxQuoteTTL) { + throw(new Error(`quoteTTL cannot exceed ${maxQuoteTTL}ms`)); + } + + // Set default if not provided if (quoteTTL === undefined) { quoteTTL = maxQuoteTTL; // Default to 5 minutes - } else if (quoteTTL > maxQuoteTTL) { - throw(new Error(`quoteTTL cannot exceed ${maxQuoteTTL}ms`)); } this.fx = { @@ -298,7 +306,20 @@ export class KeetaNetFXAnchorHTTPServer extends KeetaAnchorHTTPServer.KeetaNetAn ...rateAndFee }); - const signedQuote = await generateSignedQuote(config.quoteSigner, unsignedQuote, config.fx.quoteTTL); + // 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 + const maxQuoteTTL = 5 * 60 * 1000; // 5 minutes (message expiry) + if (resolvedQuoteTTL > maxQuoteTTL) { + throw(new Error(`quoteTTL cannot exceed ${maxQuoteTTL}ms`)); + } + } else { + resolvedQuoteTTL = config.fx.quoteTTL; + } + + const signedQuote = await generateSignedQuote(config.quoteSigner, unsignedQuote, resolvedQuoteTTL); const quoteResponse: KeetaFXAnchorQuoteResponse = { ok: true, quote: signedQuote From 84a4bcb38a93eb35c8540336080a69e6dbe3ab7d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 10 Nov 2025 20:44:37 +0000 Subject: [PATCH 8/9] Simplify test by reusing server with captured TTL variable Co-authored-by: rkeene <5068442+rkeene@users.noreply.github.com> --- src/services/fx/server.test.ts | 39 ++++++---------------------------- 1 file changed, 7 insertions(+), 32 deletions(-) diff --git a/src/services/fx/server.test.ts b/src/services/fx/server.test.ts index 0efd195e..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, @@ -206,11 +207,9 @@ test('FX Server Quote Validation Tests', async function() { expect(quote).toHaveProperty('signed'); return(shouldAcceptQuote); }, - // Use a function to determine TTL based on request + // Use a function to determine TTL based on captured variable quoteTTL: function(_ignore_request) { - // For this test, return 5 seconds for all requests - // In real usage, this could vary based on request properties - return(5000); + return(currentTTL); } } }); @@ -298,35 +297,11 @@ test('FX Server Quote Validation Tests', async function() { expect(errorData.name).toBe('KeetaFXAnchorQuoteValidationFailedError'); } - /* Test with an expired quote using a server with 1ms TTL */ - await using shortTTLServer = new KeetaNetFXAnchorHTTPServer({ - account: account, - client: client, - quoteSigner: account, - fx: { - from: [{ - currencyCodes: [token1.publicKeyString.get()], - to: [token2.publicKeyString.get()] - }], - getConversionRateAndFee: async function() { - return({ - account: account, - convertedAmount: 1000n, - cost: { - amount: 0n, - token: token1 - } - }); - }, - quoteTTL: 1 // 1 millisecond - } - }); - - await shortTTLServer.start(); - const shortTTLUrl = shortTTLServer.url; + /* 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(`${shortTTLUrl}/api/getQuote`, { + const shortQuoteResponse = await fetch(`${url}/api/getQuote`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -356,7 +331,7 @@ test('FX Server Quote Validation Tests', async function() { /* Wait to ensure the quote expires */ await new Promise(resolve => setTimeout(resolve, 10)); - const exchangeResponseExpired = await fetch(`${shortTTLUrl}/api/createExchange`, { + const exchangeResponseExpired = await fetch(`${url}/api/createExchange`, { method: 'POST', headers: { 'Content-Type': 'application/json', From bedaf40c0ea2a7c97e82a48e867895ac70a188d0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 14 Nov 2025 20:43:30 +0000 Subject: [PATCH 9/9] Refactor: Extract quote TTL constants and validation to module scope Co-authored-by: rkeene <5068442+rkeene@users.noreply.github.com> --- src/services/fx/server.ts | 33 +++++++++++++++++++++++++-------- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/src/services/fx/server.ts b/src/services/fx/server.ts index be8dbfed..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) @@ -211,17 +232,16 @@ export class KeetaNetFXAnchorHTTPServer extends KeetaAnchorHTTPServer.KeetaNetAn this.client = config.client; /* Validate and set quoteTTL with default and maximum */ - const maxQuoteTTL = 5 * 60 * 1000; // 5 minutes (message expiry) let quoteTTL = config.fx.quoteTTL; // If quoteTTL is a number, validate it doesn't exceed maximum - if (typeof quoteTTL === 'number' && quoteTTL > maxQuoteTTL) { - throw(new Error(`quoteTTL cannot exceed ${maxQuoteTTL}ms`)); + if (typeof quoteTTL === 'number') { + validateQuoteTTL(quoteTTL); } // Set default if not provided if (quoteTTL === undefined) { - quoteTTL = maxQuoteTTL; // Default to 5 minutes + quoteTTL = DEFAULT_QUOTE_TTL; } this.fx = { @@ -311,10 +331,7 @@ export class KeetaNetFXAnchorHTTPServer extends KeetaAnchorHTTPServer.KeetaNetAn if (typeof config.fx.quoteTTL === 'function') { resolvedQuoteTTL = await config.fx.quoteTTL(conversion); // Validate the returned TTL doesn't exceed maximum - const maxQuoteTTL = 5 * 60 * 1000; // 5 minutes (message expiry) - if (resolvedQuoteTTL > maxQuoteTTL) { - throw(new Error(`quoteTTL cannot exceed ${maxQuoteTTL}ms`)); - } + validateQuoteTTL(resolvedQuoteTTL); } else { resolvedQuoteTTL = config.fx.quoteTTL; }