Skip to content
2 changes: 2 additions & 0 deletions src/client/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type {
KeetaKYCAnchorClientConfig
} from '../services/kyc/client.ts';
import type { KYCEntityType } from '../lib/resolver.ts';
import type {
KeetaFXAnchorClientConfig
} from '../services/fx/client.ts';
Expand All @@ -25,6 +26,7 @@ import type {
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace KYC {
export type ClientConfig = KeetaKYCAnchorClientConfig;
export type EntityType = KYCEntityType;
export const Client: typeof KeetaKYCAnchorClient = KeetaKYCAnchorClient;
}
// TODO: Determine how we want to export the client
Expand Down
17 changes: 16 additions & 1 deletion src/lib/resolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1085,7 +1085,16 @@ test('ignores unparsable anchor metadata', async function() {
createVerification: 'https://kyc.bad.com/createVerification'
},
countryCodes: ['US']
}
},
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
broken_kyc_entity_types: {
operations: {
createVerification: 'https://kyc.badentity.com/createVerification'
},
countryCodes: ['US'],
ca: 'TEST',
entityTypes: 'business'
} as unknown as NonNullable<ServiceMetadata['services']['kyc']>[string]
},
assetMovement: {
good_amp: {
Expand Down Expand Up @@ -1198,6 +1207,12 @@ test('ignores unparsable anchor metadata', async function() {
name: 'kyc ignores provider missing required ca field',
service: 'kyc',
criteria: { countryCodes: ['US'] },
expectedProviderIDs: ['broken_kyc_entity_types', 'good_kyc']
},
{
name: 'kyc ignores provider with unparsable entityTypes when filtering by entity type',
service: 'kyc',
criteria: { countryCodes: ['US'], entityType: 'individual' },
expectedProviderIDs: ['good_kyc']
},
{
Expand Down
79 changes: 78 additions & 1 deletion src/lib/resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,18 @@ type CountrySearchInput = CurrencyInfo.ISOCountryCode | CurrencyInfo.ISOCountryN
type CountrySearchCanonical = CurrencyInfo.ISOCountryCode; /* XXX:TODO */

// #region Global Service Metadata
/**
* The type of legal entity a KYC provider can verify.
*
* - `individual` is the classic Know Your Customer (KYC) flow.
* - `business` is the Know Your Business (KYB) flow.
*
* Both are hosted redirect flows: the provider returns a `webURL` to a
* hosted experience it owns, and the client polls for the certificate.
*/
const kycEntityTypes = ['individual', 'business'] as const;
type KYCEntityType = typeof kycEntityTypes[number];

/**
* Service Metadata General Structure
*/
Expand Down Expand Up @@ -108,6 +120,23 @@ type ServiceMetadata = {
* validate accounts in any country.
*/
countryCodes?: string[];
/**
* The entity types which this KYC provider can
* verify, expressed as a map of explicit booleans
* (mirroring `supportedOperations` on asset
* movement). If omitted, the provider is assumed to
* verify `individual` entities only, preserving the
* classic KYC behavior.
*
* - `individual` is the classic KYC redirect flow.
* - `business` is a Know Your Business (KYB) flow.
*
* Both are hosted redirect flows where the provider
* returns a `webURL`. A type is supported only when
* its key is explicitly `true`; `false` or a missing
* key both mean unsupported.
*/
entityTypes?: { [entityType in KYCEntityType]?: boolean };
/**
* The Certificate Authority (CA) Certificate
* that this KYC provider uses to sign KYC
Expand Down Expand Up @@ -361,6 +390,13 @@ type ServiceSearchCriteria<T extends Services> = {
* of the following countries.
*/
countryCodes: CountrySearchInput[];
/**
* Search for a KYC provider which can verify the given entity
* type. If omitted, providers are matched without filtering on
* entity type (a provider with no declared `entityTypes` is
* treated as `individual`-only).
*/
entityType?: KYCEntityType;
};
'assetMovement': {
asset?: MovableAssetSearchInput | { from: MovableAssetSearchInput; to: MovableAssetSearchInput; };
Expand Down Expand Up @@ -1609,6 +1645,24 @@ async function verifyServiceEntrySignature(entry: ValuizableObject, logger?: Log
return(valid);
}

/**
* A service with no usable `entityTypes` declaration is `individual`-only;
* otherwise a type is supported only when its key is explicitly `true`.
*/
async function kycServiceSupportsEntityType(kycService: ValuizableObject, entityType: KYCEntityType): Promise<boolean> {
const declared = await kycService.entityTypes?.('object');
if (declared === undefined) {
return(entityType === 'individual');
}

const declaredValue = declared[entityType];
if (declaredValue === undefined) {
return(false);
}

return(await declaredValue('boolean'));
}

class Resolver {
readonly #roots: KeetaNetGenericAccount[];
readonly #trustedCAs: ResolverConfig['trustedCAs'];
Expand Down Expand Up @@ -1818,6 +1872,16 @@ class Resolver {
}
}

if (criteria.entityType !== undefined) {
const supported = await kycServiceSupportsEntityType(checkKYCService, criteria.entityType);

this.#logger?.debug(`Resolver:${this.id}`, 'Checking entity type:', criteria.entityType, 'supported:', supported, 'for', checkKYCServiceID);

if (!supported) {
continue;
}
}

retval[checkKYCServiceID] = assertResolverLookupKYCResult(checkKYCService);
} catch (checkKYCServiceError) {
this.#logger?.debug(`Resolver:${this.id}`, 'Error checking KYC service', checkKYCServiceID, ':', checkKYCServiceError, ' -- ignoring');
Expand Down Expand Up @@ -2586,7 +2650,11 @@ class Resolver {
return(retval);
}

async listSupportedKYCCountries(): Promise<CurrencyInfo.Country[]> {
/**
* When `entityType` is omitted, countries are listed across every KYC
* service regardless of the entity types it declares.
*/
async listSupportedKYCCountries(entityType?: KYCEntityType): Promise<CurrencyInfo.Country[]> {
const rootMetadata = await this.#getRootMetadata();

/*
Expand Down Expand Up @@ -2615,6 +2683,13 @@ class Resolver {
continue;
}

if (entityType !== undefined) {
const isSupportedType = await kycServiceSupportsEntityType(kycService, entityType)
if (!isSupportedType) {
continue;
}
}

/*
* If the KYC service does not have a countryCodes
* property, then it can validate accounts in any
Expand Down Expand Up @@ -2900,7 +2975,9 @@ class Resolver {
}

export default Resolver;
export { kycEntityTypes };
export type {
KYCEntityType,
ServiceMetadata,
ServiceMetadataExternalizable,
ServiceSearchCriteria,
Expand Down
126 changes: 126 additions & 0 deletions src/services/kyc/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ test('KYC Anchor Client Test', async function() {
client: client,
kyc: {
countryCodes: ['US'],
entityTypes: ['individual', 'business'],
Comment thread
lucasrosa90 marked this conversation as resolved.
verificationStarted: async function(request) {
const id = crypto.randomUUID();
verifications.set(id, request);
Expand Down Expand Up @@ -225,6 +226,31 @@ test('KYC Anchor Client Test', async function() {
expect(providerCountryCodes).toBeDefined();
expect(providerCountryCodes?.[0]?.code).toBe('US');

/*
* Drive a business (KYB) createVerification through the client. The
* provider advertises both entity types, so requesting `business`
* resolves a provider and starts a redirect flow with a webURL, just
* like individual. The provider hosts the business-details form, so the
* request carries no entity-specific details.
*/
const businessProviders = await kycClient.createVerification({
countryCodes: ['US'],
account: account,
entityType: 'business'
});
expect(businessProviders.length).toBeGreaterThan(0);
const businessProvider = businessProviders[0];
if (businessProvider === undefined) {
throw(new Error('internal error: no business provider available'));
}
const businessVerification = await businessProvider.startVerification();
expect(businessVerification.webURL).toBeDefined();
expect(verifications.get(businessVerification.id)?.entityType).toBe('business');

/* The status poll must resolve the same provider, so it carries the entity type too. */
const businessStatus = await businessVerification.getVerificationStatus();
expect(businessStatus.status).toBe(KYCVerificationStatus.PASSED);

const verification = await provider.startVerification();
loggerBase?.log('Request ID:', verification.id, 'on provider', verification.providerID);

Expand Down Expand Up @@ -306,3 +332,103 @@ test('KYC Anchor Client Test', async function() {
}))).join('\n\n');
loggerBase?.log(output);
}, 30000);

test('KYC Anchor Client Test - business-only provider and the individual default', async function() {
const account = KeetaNet.lib.Account.fromSeed(KeetaNet.lib.Account.generateRandomSeed(), 0);
const { userClient: client } = await createNodeAndClient(account);

const kycCAAccount = KeetaNet.lib.Account.fromSeed(KeetaNet.lib.Account.generateRandomSeed(), 0);
const kycCABuilder = new KeetaNet.lib.Utils.Certificate.CertificateBuilder({
subjectPublicKey: kycCAAccount,
issuer: kycCAAccount,
serial: 1,
validFrom: new Date(Date.now() - 30_000),
validTo: new Date(Date.now() + 120_000)
});
const kycCA = await kycCABuilder.build();

await using server = new KeetaNetKYCAnchorHTTPServer({
signer: account,
ca: kycCA,
client: client,
kycProviderURL: 'https://example.com/journey/{id}',
kyc: {
countryCodes: ['CA'],
entityTypes: ['business'],
verificationStarted: async function() {
return({
ok: true,
id: crypto.randomUUID(),
expectedCost: {
min: '0',
max: '0',
token: client.baseToken.publicKeyString.get()
}
});
},
getCertificates: async function() {
return([{ certificate: '' }]);
},
getVerificationStatus: async function() {
return({ status: KYCVerificationStatus.PASSED });
}
}
});

await server.start();

await client.setInfo({
name: 'TEST',
description: 'KYC Anchor Test Root (business-only)',
metadata: KeetaAnchorResolver.Metadata.formatMetadata({
version: 1,
services: {
kyc: {
Test: await server.serviceMetadata()
}
}
})
});

const kycClient = new KeetaNetAnchor.KYC.Client(client, { root: account });

/* Omitting entityType means individual, which this provider does not verify. */
await expect(kycClient.createVerification({
countryCodes: ['CA'],
account: account
})).rejects.toThrow('No KYC endpoints found for the given criteria');

const businessProviders = await kycClient.createVerification({
countryCodes: ['CA'],
account: account,
entityType: 'business'
});
expect(businessProviders.length).toBeGreaterThan(0);

/*
* A caller polling by provider ID must reach a business-only provider even
* without repeating entityType: the ID already picked the provider, so the
* lookup behind these two methods must not filter it back out.
*/
const businessProvider = businessProviders[0];
if (businessProvider === undefined) {
throw(new Error('internal error: no business provider available'));
}
const businessVerification = await businessProvider.startVerification();
const directStatus = await kycClient.getVerificationStatus(businessVerification.providerID, {
id: businessVerification.id,
account: account,
countryCodes: ['CA']
});
expect(directStatus.status).toBe(KYCVerificationStatus.PASSED);

const individualCountries = (await kycClient.getSupportedCountries()).map(function(country) {
return(country.code);
});
expect(individualCountries).not.toContain('CA');

const businessCountries = (await kycClient.getSupportedCountries('business')).map(function(country) {
return(country.code);
});
expect(businessCountries).toContain('CA');
}, 30000);
24 changes: 15 additions & 9 deletions src/services/kyc/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import { addSignatureToURL } from '../../lib/http-server/common.js';
import { SignData, type SignableAccount } from '../../lib/utils/signing.js';
import type { Logger } from '../../lib/log/index.ts';
import type Resolver from '../../lib/resolver.ts';
import type { ServiceMetadata } from '../../lib/resolver.ts';
import type { KYCEntityType, ServiceMetadata } from '../../lib/resolver.ts';
import crypto from '../../lib/utils/crypto.js';
import { validateURL } from '../../lib/utils/url.js';

Expand Down Expand Up @@ -132,9 +132,10 @@ const isKeetaKYCAnchorCreateVerificationResponse = createIs<KeetaKYCAnchorCreate
const isKeetaKYCAnchorGetCertificateResponse = createIs<KeetaKYCAnchorGetCertificateResponse>();
const isKeetaKYCAnchorGetVerificationStatusResponse = createIs<KeetaKYCAnchorGetVerificationStatusResponse>();

async function getEndpoints(resolver: Resolver, request: Pick<KeetaKYCAnchorCreateVerificationRequest, 'countryCodes'>): Promise<GetEndpointsResult | null> {
async function getEndpoints(resolver: Resolver, request: Pick<KeetaKYCAnchorCreateVerificationRequest, 'countryCodes' | 'entityType'>): Promise<GetEndpointsResult | null> {
const response = await resolver.lookup('kyc', {
countryCodes: request.countryCodes
countryCodes: request.countryCodes,
...(request.entityType !== undefined ? { entityType: request.entityType } : {})
});

if (response === undefined) {
Expand Down Expand Up @@ -296,7 +297,8 @@ class KeetaKYCVerification {
return(this.client.getVerificationStatus(this.providerID, {
id: this.id,
account: this.account,
countryCodes: this.request.countryCodes
countryCodes: this.request.countryCodes,
...(this.request.entityType !== undefined ? { entityType: this.request.entityType } : {})
}));
}
}
Expand Down Expand Up @@ -382,7 +384,11 @@ class KeetaKYCAnchorClient {
signed: signedData
};

const endpoints = await getEndpoints(this.resolver, signedRequest);
/* Only provider selection defaults to individual; the by-ID lookups below must not filter. */
const endpoints = await getEndpoints(this.resolver, {
countryCodes: signedRequest.countryCodes,
entityType: signedRequest.entityType ?? 'individual'
});
if (endpoints === null) {
throw(new Error('No KYC endpoints found for the given criteria'));
}
Expand Down Expand Up @@ -518,12 +524,12 @@ class KeetaKYCAnchorClient {
});
}

async getSupportedCountries(): Promise<CurrencyInfo.Country[]> {
return(await this.resolver.listSupportedKYCCountries());
async getSupportedCountries(entityType: KYCEntityType = 'individual'): Promise<CurrencyInfo.Country[]> {
return(await this.resolver.listSupportedKYCCountries(entityType));
}

async getVerificationStatus(providerID: ProviderID, request: Pick<KeetaKYCAnchorCreateVerificationRequest, 'countryCodes'> & { id: RequestID; account: SignableAccount; }): Promise<KeetaKYCAnchorClientGetVerificationStatusResponse> {
const endpoints = await getEndpoints(this.resolver, { countryCodes: request.countryCodes });
async getVerificationStatus(providerID: ProviderID, request: Pick<KeetaKYCAnchorCreateVerificationRequest, 'countryCodes' | 'entityType'> & { id: RequestID; account: SignableAccount; }): Promise<KeetaKYCAnchorClientGetVerificationStatusResponse> {
const endpoints = await getEndpoints(this.resolver, request);
if (endpoints === null) {
throw(new Error('No KYC endpoints found for the given criteria'));
}
Expand Down
Loading
Loading