From 945bd47cab118964b904383f183cbf8cc76da187 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 4 Nov 2025 17:55:57 +0000 Subject: [PATCH 1/6] Initial plan From 9d5dc97fe09fb3b96e2ad5cbb015b1bfa4218f58 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 4 Nov 2025 18:11:26 +0000 Subject: [PATCH 2/6] Implement complete waitForCertificates with all TODO features - Implemented proper waiting logic with configurable poll interval - Added back-off handling using server-provided retryAfter values - Added detection and handling of permanent fatal errors (non-retryable) - Added error deserialization and classification - Added timeout functionality with configurable timeout parameter - Added AbortSignal support for cancellation - Added comprehensive tests covering immediate success, delayed success, timeout, and abort scenarios Co-authored-by: rkeene <5068442+rkeene@users.noreply.github.com> --- src/services/kyc/client.test.ts | 597 ++++++++++++++++++++++++++++++++ src/services/kyc/client.ts | 106 +++++- 2 files changed, 698 insertions(+), 5 deletions(-) diff --git a/src/services/kyc/client.test.ts b/src/services/kyc/client.test.ts index 3cf30fa4..a3bbb197 100644 --- a/src/services/kyc/client.test.ts +++ b/src/services/kyc/client.test.ts @@ -283,3 +283,600 @@ test('KYC Anchor Client Test', async function() { break; } }, 30000); + +test('KYC Anchor Client - waitForCertificates with immediate success', async function() { + /* + * Enable Debug logging if requested + */ + const loggerBase = DEBUG ? console : undefined; + const logger = loggerBase ? { logger: loggerBase } : {}; + + /* + * Create an account to use for the node + */ + const seed = 'B56AA6594977F94A8D40099674ADFACF34E1208ED965E5F7E76EE6D8A2E2744E'; + const account = KeetaNet.lib.Account.fromSeed(seed, 0); + + /* + * Start a KeetaNet Node and get the UserClient for it + */ + const { userClient: client } = await createNodeAndClient(account); + + /* + * Create a dummy Root CA + */ + const rootCAAccount = KeetaNet.lib.Account.fromSeed(KeetaNet.lib.Account.generateRandomSeed(), 0); + const rootCABuilder = new KeetaNet.lib.Utils.Certificate.CertificateBuilder({ + subjectPublicKey: rootCAAccount, + issuerDN: [{ name: 'commonName', value: 'Root CA' }], + subjectDN: [{ name: 'commonName', value: 'Root CA' }], + issuer: rootCAAccount, + serial: 1, + validFrom: new Date(Date.now() - 30_000), + validTo: new Date(Date.now() + 120_000) + }); + const rootCA = await rootCABuilder.build(); + + const kycCAAccount = KeetaNet.lib.Account.fromSeed(KeetaNet.lib.Account.generateRandomSeed(), 0); + const kycCABuilder = new KeetaNet.lib.Utils.Certificate.CertificateBuilder({ + subjectPublicKey: kycCAAccount, + issuerDN: [{ name: 'commonName', value: 'Root CA' }], + subjectDN: [{ name: 'commonName', value: 'Intermediate/KYC CA' }], + issuer: rootCAAccount, + serial: 2, + isCA: true, + validFrom: new Date(Date.now() - 30_000), + validTo: new Date(Date.now() + 120_000) + }); + const kycCA = await kycCABuilder.build(); + + /* + * Start a testing KYC Anchor HTTP Server with immediate certificate issuance + */ + const signer = KeetaNet.lib.Account.fromSeed(KeetaNet.lib.Account.generateRandomSeed(), 0); + + const verifications = new Map; + const certificates = new Map(); + // eslint-disable-next-line prefer-const + let serverURL: string; + await using server = new KeetaNetKYCAnchorHTTPServer({ + signer: signer, + ca: kycCA, + client: client, + kyc: { + countryCodes: ['US'], + verificationStarted: async function(request) { + const id = crypto.randomUUID(); + verifications.set(id, request); + + // Immediately create a certificate for immediate success test + const userAccount = KeetaNet.lib.Account.fromPublicKeyString(request.account).assertAccount(); + const certificateBuilder = new KYCCertificateBuilder({ + subject: userAccount, + subjectDN: [{ name: 'commonName', value: 'KYC Verified User' }], + issuerDN: kycCA.subjectDN, + issuer: kycCAAccount, + serial: 3, + validFrom: new Date(Date.now() - 30_000), + validTo: new Date(Date.now() + 120_000) + }); + certificateBuilder.setAttribute('fullName', true, 'John Doe'); + const builtCertificate = await certificateBuilder.build(); + certificates.set(id, builtCertificate.toPEM()); + + return({ + ok: true, + id: id, + expectedCost: { + min: '0', + max: '0', + token: client.baseToken.publicKeyString.get() + } + }); + }, + getCertificates: async function(verificationID) { + const request = verifications.get(verificationID); + if (request === undefined) { + throw(new KeetaAnchorKYCErrors.VerificationNotFound(`Verification ID ${verificationID} not found`)); + } + + const certificate = certificates.get(verificationID); + if (certificate === undefined) { + throw(new KeetaAnchorKYCErrors.CertificateNotFound('Certificate not ready yet')); + } + + return([{ + certificate: certificate, + intermediates: [kycCA.toPEM()] + }]); + } + }, + kycProviderURL: function(verificationID: string) { + return(new URL(`/provider/${verificationID}`, serverURL).toString()); + }, + ...logger + }); + + await server.start(); + serverURL = server.url; + + const results = await client.setInfo({ + description: 'KYC Anchor Test Root', + name: 'TEST', + metadata: KeetaAnchorResolver.Metadata.formatMetadata({ + version: 1, + services: { + kyc: { + Test: await server.serviceMetadata() + } + } + }) + }); + + const kycClient = new KeetaNetAnchor.KYC.Client(client, { + root: account, + ...logger + }); + + const providers = await kycClient.createVerification({ + countryCodes: ['US'], + account: account + }); + + expect(providers.length).toBeGreaterThan(0); + + const provider = providers[0]; + if (provider === undefined) { + throw(new Error('internal error: no providers available')); + } + + const verification = await provider.startVerification(); + + // Test waitForCertificates - should succeed immediately + const result = await verification.waitForCertificates(500, 10000); + + expect(result.ok).toBe(true); + expect(result.results.length).toBeGreaterThan(0); + loggerBase?.log('waitForCertificates succeeded immediately'); +}, 30000); + +test('KYC Anchor Client - waitForCertificates with delayed success', async function() { + /* + * Enable Debug logging if requested + */ + const loggerBase = DEBUG ? console : undefined; + const logger = loggerBase ? { logger: loggerBase } : {}; + + /* + * Create an account to use for the node + */ + const seed = 'B56AA6594977F94A8D40099674ADFACF34E1208ED965E5F7E76EE6D8A2E2744E'; + const account = KeetaNet.lib.Account.fromSeed(seed, 0); + + /* + * Start a KeetaNet Node and get the UserClient for it + */ + const { userClient: client } = await createNodeAndClient(account); + + /* + * Create a dummy Root CA + */ + const rootCAAccount = KeetaNet.lib.Account.fromSeed(KeetaNet.lib.Account.generateRandomSeed(), 0); + const rootCABuilder = new KeetaNet.lib.Utils.Certificate.CertificateBuilder({ + subjectPublicKey: rootCAAccount, + issuerDN: [{ name: 'commonName', value: 'Root CA' }], + subjectDN: [{ name: 'commonName', value: 'Root CA' }], + issuer: rootCAAccount, + serial: 1, + validFrom: new Date(Date.now() - 30_000), + validTo: new Date(Date.now() + 120_000) + }); + const rootCA = await rootCABuilder.build(); + + const kycCAAccount = KeetaNet.lib.Account.fromSeed(KeetaNet.lib.Account.generateRandomSeed(), 0); + const kycCABuilder = new KeetaNet.lib.Utils.Certificate.CertificateBuilder({ + subjectPublicKey: kycCAAccount, + issuerDN: [{ name: 'commonName', value: 'Root CA' }], + subjectDN: [{ name: 'commonName', value: 'Intermediate/KYC CA' }], + issuer: rootCAAccount, + serial: 2, + isCA: true, + validFrom: new Date(Date.now() - 30_000), + validTo: new Date(Date.now() + 120_000) + }); + const kycCA = await kycCABuilder.build(); + + /* + * Start a testing KYC Anchor HTTP Server with delayed certificate issuance + */ + const signer = KeetaNet.lib.Account.fromSeed(KeetaNet.lib.Account.generateRandomSeed(), 0); + + const verifications = new Map; + const certificates = new Map(); + const certificateIssuanceTimes = new Map(); + // eslint-disable-next-line prefer-const + let serverURL: string; + await using server = new KeetaNetKYCAnchorHTTPServer({ + signer: signer, + ca: kycCA, + client: client, + kyc: { + countryCodes: ['US'], + verificationStarted: async function(request) { + const id = crypto.randomUUID(); + verifications.set(id, request); + + // Schedule certificate to be available after 2 seconds + certificateIssuanceTimes.set(id, Date.now() + 2000); + + return({ + ok: true, + id: id, + expectedCost: { + min: '0', + max: '0', + token: client.baseToken.publicKeyString.get() + } + }); + }, + getCertificates: async function(verificationID) { + const request = verifications.get(verificationID); + if (request === undefined) { + throw(new KeetaAnchorKYCErrors.VerificationNotFound(`Verification ID ${verificationID} not found`)); + } + + let certificate = certificates.get(verificationID); + if (certificate === undefined) { + // Check if it's time to issue the certificate + const issuanceTime = certificateIssuanceTimes.get(verificationID); + if (issuanceTime && Date.now() >= issuanceTime) { + const userAccount = KeetaNet.lib.Account.fromPublicKeyString(request.account).assertAccount(); + const certificateBuilder = new KYCCertificateBuilder({ + subject: userAccount, + subjectDN: [{ name: 'commonName', value: 'KYC Verified User' }], + issuerDN: kycCA.subjectDN, + issuer: kycCAAccount, + serial: 3, + validFrom: new Date(Date.now() - 30_000), + validTo: new Date(Date.now() + 120_000) + }); + certificateBuilder.setAttribute('fullName', true, 'Jane Smith'); + const builtCertificate = await certificateBuilder.build(); + certificate = builtCertificate.toPEM(); + certificates.set(verificationID, certificate); + } else { + throw(new KeetaAnchorKYCErrors.CertificateNotFound('Certificate not ready yet')); + } + } + + return([{ + certificate: certificate, + intermediates: [kycCA.toPEM()] + }]); + } + }, + kycProviderURL: function(verificationID: string) { + return(new URL(`/provider/${verificationID}`, serverURL).toString()); + }, + ...logger + }); + + await server.start(); + serverURL = server.url; + + const results = await client.setInfo({ + description: 'KYC Anchor Test Root', + name: 'TEST', + metadata: KeetaAnchorResolver.Metadata.formatMetadata({ + version: 1, + services: { + kyc: { + Test: await server.serviceMetadata() + } + } + }) + }); + + const kycClient = new KeetaNetAnchor.KYC.Client(client, { + root: account, + ...logger + }); + + const providers = await kycClient.createVerification({ + countryCodes: ['US'], + account: account + }); + + expect(providers.length).toBeGreaterThan(0); + + const provider = providers[0]; + if (provider === undefined) { + throw(new Error('internal error: no providers available')); + } + + const verification = await provider.startVerification(); + + // Test waitForCertificates - should succeed after 2 seconds of polling + const startTime = Date.now(); + const result = await verification.waitForCertificates(500, 10000); + const elapsed = Date.now() - startTime; + + expect(result.ok).toBe(true); + expect(result.results.length).toBeGreaterThan(0); + expect(elapsed).toBeGreaterThanOrEqual(2000); // Should have waited at least 2 seconds + expect(elapsed).toBeLessThan(10000); // Should not have timed out + loggerBase?.log(`waitForCertificates succeeded after ${elapsed}ms`); +}, 30000); + +test('KYC Anchor Client - waitForCertificates timeout', async function() { + /* + * Enable Debug logging if requested + */ + const loggerBase = DEBUG ? console : undefined; + const logger = loggerBase ? { logger: loggerBase } : {}; + + /* + * Create an account to use for the node + */ + const seed = 'B56AA6594977F94A8D40099674ADFACF34E1208ED965E5F7E76EE6D8A2E2744E'; + const account = KeetaNet.lib.Account.fromSeed(seed, 0); + + /* + * Start a KeetaNet Node and get the UserClient for it + */ + const { userClient: client } = await createNodeAndClient(account); + + /* + * Create a dummy Root CA + */ + const rootCAAccount = KeetaNet.lib.Account.fromSeed(KeetaNet.lib.Account.generateRandomSeed(), 0); + const rootCABuilder = new KeetaNet.lib.Utils.Certificate.CertificateBuilder({ + subjectPublicKey: rootCAAccount, + issuerDN: [{ name: 'commonName', value: 'Root CA' }], + subjectDN: [{ name: 'commonName', value: 'Root CA' }], + issuer: rootCAAccount, + serial: 1, + validFrom: new Date(Date.now() - 30_000), + validTo: new Date(Date.now() + 120_000) + }); + const rootCA = await rootCABuilder.build(); + + const kycCAAccount = KeetaNet.lib.Account.fromSeed(KeetaNet.lib.Account.generateRandomSeed(), 0); + const kycCABuilder = new KeetaNet.lib.Utils.Certificate.CertificateBuilder({ + subjectPublicKey: kycCAAccount, + issuerDN: [{ name: 'commonName', value: 'Root CA' }], + subjectDN: [{ name: 'commonName', value: 'Intermediate/KYC CA' }], + issuer: rootCAAccount, + serial: 2, + isCA: true, + validFrom: new Date(Date.now() - 30_000), + validTo: new Date(Date.now() + 120_000) + }); + const kycCA = await kycCABuilder.build(); + + /* + * Start a testing KYC Anchor HTTP Server that never issues certificates + */ + const signer = KeetaNet.lib.Account.fromSeed(KeetaNet.lib.Account.generateRandomSeed(), 0); + + const verifications = new Map; + // eslint-disable-next-line prefer-const + let serverURL: string; + await using server = new KeetaNetKYCAnchorHTTPServer({ + signer: signer, + ca: kycCA, + client: client, + kyc: { + countryCodes: ['US'], + verificationStarted: async function(request) { + const id = crypto.randomUUID(); + verifications.set(id, request); + + return({ + ok: true, + id: id, + expectedCost: { + min: '0', + max: '0', + token: client.baseToken.publicKeyString.get() + } + }); + }, + getCertificates: async function(verificationID) { + const request = verifications.get(verificationID); + if (request === undefined) { + throw(new KeetaAnchorKYCErrors.VerificationNotFound(`Verification ID ${verificationID} not found`)); + } + + // Always return certificate not found + throw(new KeetaAnchorKYCErrors.CertificateNotFound('Certificate not ready yet')); + } + }, + kycProviderURL: function(verificationID: string) { + return(new URL(`/provider/${verificationID}`, serverURL).toString()); + }, + ...logger + }); + + await server.start(); + serverURL = server.url; + + const results = await client.setInfo({ + description: 'KYC Anchor Test Root', + name: 'TEST', + metadata: KeetaAnchorResolver.Metadata.formatMetadata({ + version: 1, + services: { + kyc: { + Test: await server.serviceMetadata() + } + } + }) + }); + + const kycClient = new KeetaNetAnchor.KYC.Client(client, { + root: account, + ...logger + }); + + const providers = await kycClient.createVerification({ + countryCodes: ['US'], + account: account + }); + + expect(providers.length).toBeGreaterThan(0); + + const provider = providers[0]; + if (provider === undefined) { + throw(new Error('internal error: no providers available')); + } + + const verification = await provider.startVerification(); + + // Test waitForCertificates with timeout - should throw timeout error + await expect(async () => { + await verification.waitForCertificates(500, 2000); // Short timeout for test + }).rejects.toThrow(/Timeout waiting for KYC certificates/); + + loggerBase?.log('waitForCertificates correctly timed out'); +}, 30000); + +test('KYC Anchor Client - waitForCertificates abort signal', async function() { + /* + * Enable Debug logging if requested + */ + const loggerBase = DEBUG ? console : undefined; + const logger = loggerBase ? { logger: loggerBase } : {}; + + /* + * Create an account to use for the node + */ + const seed = 'B56AA6594977F94A8D40099674ADFACF34E1208ED965E5F7E76EE6D8A2E2744E'; + const account = KeetaNet.lib.Account.fromSeed(seed, 0); + + /* + * Start a KeetaNet Node and get the UserClient for it + */ + const { userClient: client } = await createNodeAndClient(account); + + /* + * Create a dummy Root CA + */ + const rootCAAccount = KeetaNet.lib.Account.fromSeed(KeetaNet.lib.Account.generateRandomSeed(), 0); + const rootCABuilder = new KeetaNet.lib.Utils.Certificate.CertificateBuilder({ + subjectPublicKey: rootCAAccount, + issuerDN: [{ name: 'commonName', value: 'Root CA' }], + subjectDN: [{ name: 'commonName', value: 'Root CA' }], + issuer: rootCAAccount, + serial: 1, + validFrom: new Date(Date.now() - 30_000), + validTo: new Date(Date.now() + 120_000) + }); + const rootCA = await rootCABuilder.build(); + + const kycCAAccount = KeetaNet.lib.Account.fromSeed(KeetaNet.lib.Account.generateRandomSeed(), 0); + const kycCABuilder = new KeetaNet.lib.Utils.Certificate.CertificateBuilder({ + subjectPublicKey: kycCAAccount, + issuerDN: [{ name: 'commonName', value: 'Root CA' }], + subjectDN: [{ name: 'commonName', value: 'Intermediate/KYC CA' }], + issuer: rootCAAccount, + serial: 2, + isCA: true, + validFrom: new Date(Date.now() - 30_000), + validTo: new Date(Date.now() + 120_000) + }); + const kycCA = await kycCABuilder.build(); + + /* + * Start a testing KYC Anchor HTTP Server that never issues certificates + */ + const signer = KeetaNet.lib.Account.fromSeed(KeetaNet.lib.Account.generateRandomSeed(), 0); + + const verifications = new Map; + // eslint-disable-next-line prefer-const + let serverURL: string; + await using server = new KeetaNetKYCAnchorHTTPServer({ + signer: signer, + ca: kycCA, + client: client, + kyc: { + countryCodes: ['US'], + verificationStarted: async function(request) { + const id = crypto.randomUUID(); + verifications.set(id, request); + + return({ + ok: true, + id: id, + expectedCost: { + min: '0', + max: '0', + token: client.baseToken.publicKeyString.get() + } + }); + }, + getCertificates: async function(verificationID) { + const request = verifications.get(verificationID); + if (request === undefined) { + throw(new KeetaAnchorKYCErrors.VerificationNotFound(`Verification ID ${verificationID} not found`)); + } + + // Always return certificate not found + throw(new KeetaAnchorKYCErrors.CertificateNotFound('Certificate not ready yet')); + } + }, + kycProviderURL: function(verificationID: string) { + return(new URL(`/provider/${verificationID}`, serverURL).toString()); + }, + ...logger + }); + + await server.start(); + serverURL = server.url; + + const results = await client.setInfo({ + description: 'KYC Anchor Test Root', + name: 'TEST', + metadata: KeetaAnchorResolver.Metadata.formatMetadata({ + version: 1, + services: { + kyc: { + Test: await server.serviceMetadata() + } + } + }) + }); + + const kycClient = new KeetaNetAnchor.KYC.Client(client, { + root: account, + ...logger + }); + + const providers = await kycClient.createVerification({ + countryCodes: ['US'], + account: account + }); + + expect(providers.length).toBeGreaterThan(0); + + const provider = providers[0]; + if (provider === undefined) { + throw(new Error('internal error: no providers available')); + } + + const verification = await provider.startVerification(); + + // Test waitForCertificates with abort signal + const abortController = new AbortController(); + + // Abort after 1 second + setTimeout(() => { + abortController.abort(); + }, 1000); + + await expect(async () => { + await verification.waitForCertificates(500, 10000, abortController.signal); + }).rejects.toThrow(/aborted/); + + loggerBase?.log('waitForCertificates correctly aborted'); +}, 30000); diff --git a/src/services/kyc/client.ts b/src/services/kyc/client.ts index 43fcef2c..5dfefdea 100644 --- a/src/services/kyc/client.ts +++ b/src/services/kyc/client.ts @@ -277,17 +277,113 @@ class KeetaKYCVerification { /** * Wait for the certificates to be available, polling at the given interval * and timing out after the given timeout period. + * + * @param pollInterval - The interval in milliseconds between polling attempts (default: 500ms) + * @param timeout - The maximum time in milliseconds to wait for certificates (default: 600000ms = 10 minutes) + * @param signal - Optional AbortSignal to cancel the waiting operation + * @returns A promise that resolves with the certificate response + * @throws Error if timeout is reached or operation is aborted */ - async waitForCertificates(pollInterval: number = 500, timeout: number = 600000): Promise { - for (const startTime = Date.now(); Date.now() - startTime < timeout; ) { + async waitForCertificates(pollInterval: number = 500, timeout: number = 600000, signal?: AbortSignal): Promise> { + const startTime = Date.now(); + + // Check if already aborted + if (signal?.aborted) { + throw(new Error('Certificate wait aborted')); + } + + while (Date.now() - startTime < timeout) { + // Check abort signal before each attempt + if (signal?.aborted) { + throw(new Error('Certificate wait aborted')); + } + try { - return(await this.getCertificates()); + const result = await this.getCertificates(); + + if (result.ok) { + // Successfully retrieved certificates + return(result); + } + + // Handle retryable response (e.g., certificate not ready yet) + this.logger?.debug(`Certificate not ready for request ${this.id}, will retry after ${result.retryAfter}ms. Reason: ${result.reason}`); + + // Use the server-provided retry delay, but respect the poll interval as a minimum + const waitTime = Math.max(result.retryAfter, pollInterval); + + // Check if waiting would exceed the timeout + if (Date.now() - startTime + waitTime >= timeout) { + throw(new Error(`Timeout waiting for KYC certificates (${timeout}ms elapsed)`)); + } + + // Wait before retrying, checking abort signal periodically + await this.sleepWithAbort(waitTime, signal); + } catch (getCertificatesError) { - /* XXX:TODO */ + // Deserialize and handle different error types + + // If it's already a known error type, check if it's retryable + if (getCertificatesError instanceof Error) { + // Check for specific error messages that indicate permanent failures + const errorMessage = getCertificatesError.message; + + // Fatal errors that should not be retried + if ( + errorMessage.includes('Invalid response from KYC certificate service') || + errorMessage.includes('internal error:') || + errorMessage.includes('does not support') || + errorMessage.includes('No KYC endpoints found') + ) { + this.logger?.error(`Permanent error fetching certificates for request ${this.id}: ${errorMessage}`); + throw(getCertificatesError); + } + + // HTTP errors that are fatal (except 404 which is handled in getCertificates) + if (errorMessage.includes('Failed to get certificate:')) { + this.logger?.error(`HTTP error fetching certificates for request ${this.id}: ${errorMessage}`); + throw(getCertificatesError); + } + } + + // For unknown errors, rethrow as they might be fatal + this.logger?.error(`Unexpected error fetching certificates for request ${this.id}:`, getCertificatesError); throw(getCertificatesError); } } - throw(new Error('Timeout waiting for KYC certificates')); + + // Timeout reached + throw(new Error(`Timeout waiting for KYC certificates (${timeout}ms elapsed)`)); + } + + /** + * Helper method to sleep for a given duration while respecting abort signals + */ + private async sleepWithAbort(ms: number, signal?: AbortSignal): Promise { + return(await new Promise((resolve, reject) => { + let abortHandler: (() => void) | undefined; + + // Check if already aborted + if (signal?.aborted) { + reject(new Error('Sleep aborted')); + return; + } + + const timeout = setTimeout(() => { + if (abortHandler) { + signal?.removeEventListener('abort', abortHandler); + } + resolve(); + }, ms); + + if (signal) { + abortHandler = () => { + clearTimeout(timeout); + reject(new Error('Sleep aborted')); + }; + signal.addEventListener('abort', abortHandler, { once: true }); + } + })); } } From 6777e546de14c38e8176ea6c83171e0b14f59d42 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 4 Nov 2025 18:24:23 +0000 Subject: [PATCH 3/6] Address code review feedback on waitForCertificates - Added KeetaKYCAnchorClientGetCertificateSuccessResponse type alias for better readability - Refactored error message checking into isPermanentError() helper method for more maintainable error classification - Fixed linting issues with unused test variables Co-authored-by: rkeene <5068442+rkeene@users.noreply.github.com> --- src/services/kyc/client.test.ts | 8 +++++++ src/services/kyc/client.ts | 41 +++++++++++++++++++++------------ 2 files changed, 34 insertions(+), 15 deletions(-) diff --git a/src/services/kyc/client.test.ts b/src/services/kyc/client.test.ts index a3bbb197..e3be40b7 100644 --- a/src/services/kyc/client.test.ts +++ b/src/services/kyc/client.test.ts @@ -315,6 +315,7 @@ test('KYC Anchor Client - waitForCertificates with immediate success', async fun validFrom: new Date(Date.now() - 30_000), validTo: new Date(Date.now() + 120_000) }); + // eslint-disable-next-line @typescript-eslint/no-unused-vars const rootCA = await rootCABuilder.build(); const kycCAAccount = KeetaNet.lib.Account.fromSeed(KeetaNet.lib.Account.generateRandomSeed(), 0); @@ -400,6 +401,7 @@ test('KYC Anchor Client - waitForCertificates with immediate success', async fun await server.start(); serverURL = server.url; + // eslint-disable-next-line @typescript-eslint/no-unused-vars const results = await client.setInfo({ description: 'KYC Anchor Test Root', name: 'TEST', @@ -471,6 +473,7 @@ test('KYC Anchor Client - waitForCertificates with delayed success', async funct validFrom: new Date(Date.now() - 30_000), validTo: new Date(Date.now() + 120_000) }); + // eslint-disable-next-line @typescript-eslint/no-unused-vars const rootCA = await rootCABuilder.build(); const kycCAAccount = KeetaNet.lib.Account.fromSeed(KeetaNet.lib.Account.generateRandomSeed(), 0); @@ -564,6 +567,7 @@ test('KYC Anchor Client - waitForCertificates with delayed success', async funct await server.start(); serverURL = server.url; + // eslint-disable-next-line @typescript-eslint/no-unused-vars const results = await client.setInfo({ description: 'KYC Anchor Test Root', name: 'TEST', @@ -639,6 +643,7 @@ test('KYC Anchor Client - waitForCertificates timeout', async function() { validFrom: new Date(Date.now() - 30_000), validTo: new Date(Date.now() + 120_000) }); + // eslint-disable-next-line @typescript-eslint/no-unused-vars const rootCA = await rootCABuilder.build(); const kycCAAccount = KeetaNet.lib.Account.fromSeed(KeetaNet.lib.Account.generateRandomSeed(), 0); @@ -701,6 +706,7 @@ test('KYC Anchor Client - waitForCertificates timeout', async function() { await server.start(); serverURL = server.url; + // eslint-disable-next-line @typescript-eslint/no-unused-vars const results = await client.setInfo({ description: 'KYC Anchor Test Root', name: 'TEST', @@ -772,6 +778,7 @@ test('KYC Anchor Client - waitForCertificates abort signal', async function() { validFrom: new Date(Date.now() - 30_000), validTo: new Date(Date.now() + 120_000) }); + // eslint-disable-next-line @typescript-eslint/no-unused-vars const rootCA = await rootCABuilder.build(); const kycCAAccount = KeetaNet.lib.Account.fromSeed(KeetaNet.lib.Account.generateRandomSeed(), 0); @@ -834,6 +841,7 @@ test('KYC Anchor Client - waitForCertificates abort signal', async function() { await server.start(); serverURL = server.url; + // eslint-disable-next-line @typescript-eslint/no-unused-vars const results = await client.setInfo({ description: 'KYC Anchor Test Root', name: 'TEST', diff --git a/src/services/kyc/client.ts b/src/services/kyc/client.ts index 5dfefdea..8e0eaf33 100644 --- a/src/services/kyc/client.ts +++ b/src/services/kyc/client.ts @@ -70,6 +70,11 @@ type KeetaKYCAnchorClientGetCertificateResponse = ({ reason: string; }); +/** + * The successful response type for certificate retrieval operations + */ +type KeetaKYCAnchorClientGetCertificateSuccessResponse = Extract; + type KeetaKYCAnchorClientCreateVerificationRequest = Omit & { account: InstanceType; }; @@ -284,7 +289,7 @@ class KeetaKYCVerification { * @returns A promise that resolves with the certificate response * @throws Error if timeout is reached or operation is aborted */ - async waitForCertificates(pollInterval: number = 500, timeout: number = 600000, signal?: AbortSignal): Promise> { + async waitForCertificates(pollInterval: number = 500, timeout: number = 600000, signal?: AbortSignal): Promise { const startTime = Date.now(); // Check if already aborted @@ -325,23 +330,13 @@ class KeetaKYCVerification { // If it's already a known error type, check if it's retryable if (getCertificatesError instanceof Error) { - // Check for specific error messages that indicate permanent failures const errorMessage = getCertificatesError.message; - // Fatal errors that should not be retried - if ( - errorMessage.includes('Invalid response from KYC certificate service') || - errorMessage.includes('internal error:') || - errorMessage.includes('does not support') || - errorMessage.includes('No KYC endpoints found') - ) { - this.logger?.error(`Permanent error fetching certificates for request ${this.id}: ${errorMessage}`); - throw(getCertificatesError); - } + // Check if this is a permanent error that should not be retried + const isPermanentError = this.isPermanentError(errorMessage); - // HTTP errors that are fatal (except 404 which is handled in getCertificates) - if (errorMessage.includes('Failed to get certificate:')) { - this.logger?.error(`HTTP error fetching certificates for request ${this.id}: ${errorMessage}`); + if (isPermanentError) { + this.logger?.error(`Permanent error fetching certificates for request ${this.id}: ${errorMessage}`); throw(getCertificatesError); } } @@ -385,6 +380,22 @@ class KeetaKYCVerification { } })); } + + /** + * Determine if an error message indicates a permanent (non-retryable) error + */ + private isPermanentError(errorMessage: string): boolean { + // Patterns that indicate permanent failures + const permanentErrorPatterns = [ + 'Invalid response from KYC certificate service', + 'internal error:', + 'does not support', + 'No KYC endpoints found', + 'Failed to get certificate:' // HTTP errors (except 404 which is handled separately) + ]; + + return(permanentErrorPatterns.some(pattern => errorMessage.includes(pattern))); + } } /** From 97ed65006e3d63da864972ea36ae1ea96b0377ae Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 4 Nov 2025 19:57:18 +0000 Subject: [PATCH 4/6] Refactor waitForCertificates based on code review feedback - Created src/lib/utils/asleep.ts with asleep() function for AbortSignal-aware sleep - Removed initial abort check before loop (redundant with loop check) - Removed timeout check before waiting (allows one extra poll attempt) - Updated getCertificates to use KeetaAnchorError.fromJSON for proper error deserialization - Updated waitForCertificates to use error.retryable property instead of pattern matching - Set CertificateNotFoundError.retryable = true for proper retry handling - Removed sleepWithAbort and isPermanentError helper methods - Replaced sleepWithAbort calls with asleep utility Co-authored-by: rkeene <5068442+rkeene@users.noreply.github.com> --- src/lib/utils/asleep.ts | 38 ++++++++++++++ src/services/kyc/client.ts | 102 +++++++------------------------------ src/services/kyc/common.ts | 1 + 3 files changed, 57 insertions(+), 84 deletions(-) create mode 100644 src/lib/utils/asleep.ts diff --git a/src/lib/utils/asleep.ts b/src/lib/utils/asleep.ts new file mode 100644 index 00000000..c22fa962 --- /dev/null +++ b/src/lib/utils/asleep.ts @@ -0,0 +1,38 @@ +/** + * Asynchronously sleep for a specified duration with optional abort signal support. + * + * @param ms - The duration to sleep in milliseconds + * @param signal - Optional AbortSignal to cancel the sleep operation + * @returns A promise that resolves after the specified duration or rejects if aborted + * @throws Error if the operation is aborted via the signal + * + * @remarks + * This function should be replaced when the `@keetanetwork/keetanet-client` + * `KeetaNet.lib.Utils.Helper.asleep` method is updated to support `AbortSignal`. + */ +export async function asleep(ms: number, signal?: AbortSignal): Promise { + return(await new Promise((resolve, reject) => { + let abortHandler: (() => void) | undefined; + + // Check if already aborted + if (signal?.aborted) { + reject(new Error('Sleep aborted')); + return; + } + + const timeout = setTimeout(() => { + if (abortHandler) { + signal?.removeEventListener('abort', abortHandler); + } + resolve(); + }, ms); + + if (signal) { + abortHandler = () => { + clearTimeout(timeout); + reject(new Error('Sleep aborted')); + }; + signal.addEventListener('abort', abortHandler, { once: true }); + } + })); +} diff --git a/src/services/kyc/client.ts b/src/services/kyc/client.ts index 8e0eaf33..91729f26 100644 --- a/src/services/kyc/client.ts +++ b/src/services/kyc/client.ts @@ -4,6 +4,7 @@ import { createIs } from 'typia'; import { getDefaultResolver } from '../../config.js'; import { Certificate as KYCCertificate } from '../../lib/certificates.js'; +import { KeetaAnchorError } from '../../lib/error.js'; import type { Client as KeetaNetClient, @@ -23,6 +24,7 @@ import type Resolver from '../../lib/resolver.ts'; import type { ServiceMetadata } from '../../lib/resolver.ts'; import crypto from '../../lib/utils/crypto.js'; import { validateURL } from '../../lib/utils/url.js'; +import { asleep } from '../../lib/utils/asleep.js'; const PARANOID = true; @@ -292,11 +294,6 @@ class KeetaKYCVerification { async waitForCertificates(pollInterval: number = 500, timeout: number = 600000, signal?: AbortSignal): Promise { const startTime = Date.now(); - // Check if already aborted - if (signal?.aborted) { - throw(new Error('Certificate wait aborted')); - } - while (Date.now() - startTime < timeout) { // Check abort signal before each attempt if (signal?.aborted) { @@ -317,33 +314,25 @@ class KeetaKYCVerification { // Use the server-provided retry delay, but respect the poll interval as a minimum const waitTime = Math.max(result.retryAfter, pollInterval); - // Check if waiting would exceed the timeout - if (Date.now() - startTime + waitTime >= timeout) { - throw(new Error(`Timeout waiting for KYC certificates (${timeout}ms elapsed)`)); - } - // Wait before retrying, checking abort signal periodically - await this.sleepWithAbort(waitTime, signal); + await asleep(waitTime, signal); } catch (getCertificatesError) { - // Deserialize and handle different error types - - // If it's already a known error type, check if it's retryable - if (getCertificatesError instanceof Error) { - const errorMessage = getCertificatesError.message; - - // Check if this is a permanent error that should not be retried - const isPermanentError = this.isPermanentError(errorMessage); - - if (isPermanentError) { - this.logger?.error(`Permanent error fetching certificates for request ${this.id}: ${errorMessage}`); + // Check if this is a KeetaAnchorError with retryable property + if (getCertificatesError instanceof KeetaAnchorError) { + if (!getCertificatesError.retryable) { + // Non-retryable error, rethrow immediately + this.logger?.error(`Permanent error fetching certificates for request ${this.id}: ${getCertificatesError.message}`); throw(getCertificatesError); } + // Retryable error, continue to next iteration after a delay + this.logger?.debug(`Retryable error fetching certificates for request ${this.id}, will retry after ${pollInterval}ms: ${getCertificatesError.message}`); + await asleep(pollInterval, signal); + } else { + // Unknown error type, rethrow as it might be fatal + this.logger?.error(`Unexpected error fetching certificates for request ${this.id}:`, getCertificatesError); + throw(getCertificatesError); } - - // For unknown errors, rethrow as they might be fatal - this.logger?.error(`Unexpected error fetching certificates for request ${this.id}:`, getCertificatesError); - throw(getCertificatesError); } } @@ -351,51 +340,6 @@ class KeetaKYCVerification { throw(new Error(`Timeout waiting for KYC certificates (${timeout}ms elapsed)`)); } - /** - * Helper method to sleep for a given duration while respecting abort signals - */ - private async sleepWithAbort(ms: number, signal?: AbortSignal): Promise { - return(await new Promise((resolve, reject) => { - let abortHandler: (() => void) | undefined; - - // Check if already aborted - if (signal?.aborted) { - reject(new Error('Sleep aborted')); - return; - } - - const timeout = setTimeout(() => { - if (abortHandler) { - signal?.removeEventListener('abort', abortHandler); - } - resolve(); - }, ms); - - if (signal) { - abortHandler = () => { - clearTimeout(timeout); - reject(new Error('Sleep aborted')); - }; - signal.addEventListener('abort', abortHandler, { once: true }); - } - })); - } - - /** - * Determine if an error message indicates a permanent (non-retryable) error - */ - private isPermanentError(errorMessage: string): boolean { - // Patterns that indicate permanent failures - const permanentErrorPatterns = [ - 'Invalid response from KYC certificate service', - 'internal error:', - 'does not support', - 'No KYC endpoints found', - 'Failed to get certificate:' // HTTP errors (except 404 which is handled separately) - ]; - - return(permanentErrorPatterns.some(pattern => errorMessage.includes(pattern))); - } } /** @@ -561,18 +505,6 @@ class KeetaKYCAnchorClient { } }); - /* - * Handle retryable errors by passing them up to the caller to - * retry. - */ - if (response.status === 404) { - return({ - ok: false, - retryAfter: 500, - reason: 'Certificate not found' - }); - } - /* * Handle other errors as fatal errors that should not be retried. */ @@ -586,7 +518,9 @@ class KeetaKYCAnchorClient { } if (!responseJSON.ok) { - throw(new Error(`KYC certificate request failed: ${responseJSON.error}`)); + // Deserialize error using KeetaAnchorError.fromJSON if possible + const error = await KeetaAnchorError.fromJSON(responseJSON); + throw(error); } return({ diff --git a/src/services/kyc/common.ts b/src/services/kyc/common.ts index 4a8b38fa..503882da 100644 --- a/src/services/kyc/common.ts +++ b/src/services/kyc/common.ts @@ -107,6 +107,7 @@ class KeetaKYCAnchorCertificateNotFoundError extends KeetaAnchorUserError { constructor(message?: string) { super(message ?? 'Certificate not found (pending)'); this.statusCode = 404; + this.retryable = true; Object.defineProperty(this, 'KeetaKYCAnchorCertificateNotFoundErrorObjectTypeID', { value: KeetaKYCAnchorCertificateNotFoundError.KeetaKYCAnchorCertificateNotFoundErrorObjectTypeID, From 0117990bf1d03c2198785ab090f8d9f616678ed1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 4 Nov 2025 20:00:35 +0000 Subject: [PATCH 5/6] Clean up asleep function - remove redundant await and async Co-authored-by: rkeene <5068442+rkeene@users.noreply.github.com> --- src/lib/utils/asleep.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/utils/asleep.ts b/src/lib/utils/asleep.ts index c22fa962..695dca98 100644 --- a/src/lib/utils/asleep.ts +++ b/src/lib/utils/asleep.ts @@ -10,8 +10,8 @@ * This function should be replaced when the `@keetanetwork/keetanet-client` * `KeetaNet.lib.Utils.Helper.asleep` method is updated to support `AbortSignal`. */ -export async function asleep(ms: number, signal?: AbortSignal): Promise { - return(await new Promise((resolve, reject) => { +export function asleep(ms: number, signal?: AbortSignal): Promise { + return(new Promise((resolve, reject) => { let abortHandler: (() => void) | undefined; // Check if already aborted From af7f72ab7377a19e76473636f7248ed9c0da217a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 4 Nov 2025 20:16:54 +0000 Subject: [PATCH 6/6] Use KeetaAnchorError.isInstance() instead of instanceof Co-authored-by: rkeene <5068442+rkeene@users.noreply.github.com> --- src/services/kyc/client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/kyc/client.ts b/src/services/kyc/client.ts index 91729f26..ee93219a 100644 --- a/src/services/kyc/client.ts +++ b/src/services/kyc/client.ts @@ -319,7 +319,7 @@ class KeetaKYCVerification { } catch (getCertificatesError) { // Check if this is a KeetaAnchorError with retryable property - if (getCertificatesError instanceof KeetaAnchorError) { + if (KeetaAnchorError.isInstance(getCertificatesError)) { if (!getCertificatesError.retryable) { // Non-retryable error, rethrow immediately this.logger?.error(`Permanent error fetching certificates for request ${this.id}: ${getCertificatesError.message}`);