diff --git a/src/client/index.ts b/src/client/index.ts index 146d93ad..ab71d719 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -12,6 +12,10 @@ import type { KeetaAssetMovementClientConfig } from '../services/asset-movement/client.ts'; import KeetaAssetMovementAnchorClient from '../services/asset-movement/client.js'; +import type { + KeetaOrderMatcherClientConfig +} from '../services/order-matcher/client.ts'; +import KeetaOrderMatcherClient from '../services/order-matcher/client.js'; // TODO: Determine how we want to export the client // eslint-disable-next-line @typescript-eslint/no-namespace @@ -32,6 +36,13 @@ export namespace AssetMovement { export const Client: typeof KeetaAssetMovementAnchorClient = KeetaAssetMovementAnchorClient; } +// TODO: Determine how we want to export the client +// eslint-disable-next-line @typescript-eslint/no-namespace +export namespace OrderMatcher { + export type ClientConfig = KeetaOrderMatcherClientConfig; + export const Client: typeof KeetaOrderMatcherClient = KeetaOrderMatcherClient; +} + export { lib, KeetaNet diff --git a/src/lib/resolver.ts b/src/lib/resolver.ts index f44d280f..785473d8 100644 --- a/src/lib/resolver.ts +++ b/src/lib/resolver.ts @@ -1,5 +1,5 @@ import * as KeetaNetClient from '@keetanetwork/keetanet-client'; -import type { GenericAccount as KeetaNetGenericAccount } from '@keetanetwork/keetanet-client/lib/account.js'; +import type { GenericAccount as KeetaNetGenericAccount, TokenAddress } from '@keetanetwork/keetanet-client/lib/account.js'; import * as CurrencyInfo from '@keetanetwork/currency-info'; import type { Logger } from './log/index.ts'; import type { JSONSerializable } from './utils/json.ts'; @@ -16,6 +16,7 @@ type ExternalURL = { external: '2b828e33-2692-46e9-817e-9b93d63f28fd'; url: stri type KeetaNetAccount = InstanceType; const KeetaNetAccount: typeof KeetaNetClient.lib.Account = KeetaNetClient.lib.Account; type KeetaNetAccountTokenPublicKeyString = ReturnType>['publicKeyString']['get']>; +type KeetaNetAccountPublicKeyString = ReturnType['publicKeyString']['get']>; /** * Canonical form of a currency code for use in the ServiceMetadata @@ -162,6 +163,75 @@ type ServiceMetadata = { }[]; } }; + /** + * Order Matcher services + * + * This is used to identify service providers which perform MATCH_SWAP operations on the network, and the pairs that they support + */ + orderMatcher?: { + /** + * Provider ID which identifies the Order Matcher provider + */ + [id: string]: { + operations: { + /** + * Path for retrieving price history for a token pair + */ + getPairHistory?: string; + + /** + * Path for retrieving price info/basic stats for a token pair + */ + getPairInfo?: string; + + /** + * Path for retrieving order book depth for a token pair, with a specified grouping + */ + getPairDepth?: string; + }; + + /** + * A list of accounts which this Order Matcher can sign MATCH_SWAP blocks with + * The user must grant permission to these accounts in order for the order matcher to be able to perform matches + */ + matchingAccounts: KeetaNetAccountPublicKeyString[]; + + /** + * Path for which can be used to identify which + * tokens this order matcher provider will operate on + */ + pairs: { + /** + * Base token(s) for the pair + * These will be combined with each quote token to form trading pairs + */ + base: KeetaNetAccountTokenPublicKeyString[]; + + /** + * Quote token(s) for the pair + * These will be combined with each base token to form trading pairs + */ + quote: KeetaNetAccountTokenPublicKeyString[]; + + /** + * Fees structure for this trading pair (if any) + * The provider can choose to not accept orders for this pair if the fee structure is not met + */ + fees?: { + /** + * Type of fee structure applied to this trading pair + * "sell-token-percentage" means that a percentage of the sold token's amount must be provided as a fee + */ + type: 'sell-token-percentage'; + + /** + * The minimum percentage fee (in basis points) that must be given when creating swaps for this pair + */ + minPercentBasisPoints: number; + } + }[]; + } + }; assetMovement?: { [id: string]: { operations: { @@ -246,6 +316,17 @@ type ServiceSearchCriteria = { */ kycProviders?: string[]; }; + 'orderMatcher': { + /** + * Search for providers which support the base token + */ + base?: KeetaNetAccountTokenPublicKeyString; + + /** + * Search for providers which support the specified quote token + */ + quote?: KeetaNetAccountTokenPublicKeyString; + }; 'kyc': { /** * Search for a KYC provider which can verify accounts in ALL @@ -374,6 +455,11 @@ function assertValidOperationsFX(input: unknown): asserts input is { operations: assertValidOperationsBanking(input); } +function assertValidOperationsOrderMatcher(input: unknown): asserts input is { operations: ToValuizableObject[string]>['operations'] } { + /* XXX:TODO: Validate the specific operations */ + assertValidOperationsBanking(input); +} + function assertValidOperationsAssetMovement(input: unknown): asserts input is { operations: ToValuizableObject[string]>['operations'] } { /* XXX:TODO: Validate the specific operations */ assertValidOperationsBanking(input); @@ -451,6 +537,23 @@ const assertResolverLookupFXResult = async function(input: unknown): Promise[string]> { + assertValidOperationsOrderMatcher(input); + if (!('pairs' in input)) { + throw(new Error('Expected "pairs" key in order matcher service, but it was not found')); + } + + const pairsUnrealized = input.pairs; + // eslint-disable-next-line @typescript-eslint/no-use-before-define + if (!Metadata.isValuizable(pairsUnrealized)) { + throw(new Error(`Expected "pairs" to be an Valuizable, got ${typeof pairsUnrealized}`)); + } + // XXX:TODO: Perform deeper validation of the "pairs" structure + await pairsUnrealized('array'); + // @ts-ignore + return(input); +} + const assertResolverLookupAssetMovementResults = async function(input: unknown): Promise[string]> { assertValidOperationsAssetMovement(input); // assertValidOperationsKYC(input); @@ -1276,6 +1379,9 @@ class Resolver { 'fx': { search: this.lookupFXServices.bind(this) }, + 'orderMatcher': { + search: this.lookupOrderMatcherServices.bind(this) + }, 'assetMovement': { search: this.lookupAssetMovementServices.bind(this) }, @@ -1547,6 +1653,94 @@ class Resolver { return(retval); } + private async lookupOrderMatcherServices(orderMatcherServices: ValuizableObject | undefined, criteria: ServiceSearchCriteria<'orderMatcher'>) { + if (orderMatcherServices === undefined) { + return(undefined); + } + + const retval: ResolverLookupServiceResults<'orderMatcher'> = {}; + for (const checkOrderMatcherServiceID in orderMatcherServices) { + try { + const checkOrderMatcherService = await assertResolverLookupOrderMatcherResult(await orderMatcherServices[checkOrderMatcherServiceID]?.('object')); + if (!checkOrderMatcherService) { + continue; + } + + const pairsUnrealized: ToValuizable[string]['pairs']> = checkOrderMatcherService.pairs; + const pairs = await pairsUnrealized?.('array'); + if (pairs === undefined) { + continue; + } + + let acceptable = false; + + if (criteria.base === undefined && criteria.quote === undefined) { + acceptable = true; + } else { + for (const pairUnrealized of pairs) { + const pair = await pairUnrealized?.('object'); + if (pair === undefined) { + continue; + } + + const [ baseTokenAddresses, quoteTokenAddresses ] = await Promise.all([ pair.base, pair.quote ].map(async (tokenListUnrealized) => { + const tokenList = await tokenListUnrealized?.('array') ?? []; + + const resolvedAddresses = await Promise.all(tokenList.map(async (tokenUnrealized) => { + try { + const publicKey = await tokenUnrealized?.('string'); + return(KeetaNetAccount.fromPublicKeyString(publicKey).assertKeyType(KeetaNetAccount.AccountKeyAlgorithm.TOKEN)); + } catch (error) { + this.#logger?.debug(`Resolver:${this.id}`, 'Error resolving token address in order matcher pair:', error, ' -- ignoring'); + return(undefined); + } + })); + + const tokenAddresses = resolvedAddresses.filter(function(token): token is TokenAddress { + return(token !== undefined); + }); + + return(new KeetaNetAccount.Set(tokenAddresses)); + })); + + if (!baseTokenAddresses || !quoteTokenAddresses) { + throw(new Error('internal error: base or quote token addresses could not be resolved')); + } + + if (criteria.base !== undefined && !baseTokenAddresses.has(KeetaNetAccount.fromPublicKeyString(criteria.base))) { + continue; + } + + if (criteria.quote !== undefined && !quoteTokenAddresses.has(KeetaNetAccount.fromPublicKeyString(criteria.quote))) { + continue; + } + + acceptable = true; + break; + } + } + + if (!acceptable) { + continue; + } + + retval[checkOrderMatcherServiceID] = checkOrderMatcherService; + } catch (checkOrderMatcherServiceError) { + this.#logger?.debug(`Resolver:${this.id}`, 'Error checking Order Matcher service', checkOrderMatcherServiceID, ':', checkOrderMatcherServiceError, ' -- ignoring'); + } + } + + if (Object.keys(retval).length === 0) { + /* + * If we didn't find any order matcher services, then we return + * undefined to indicate that no services were found. + */ + return(undefined); + } + + return(retval); + } + async filterSupportedAssets(assetService: ValuizableObject, criteria: ServiceSearchCriteria<'assetMovement'> = {}): Promise { const assetCanonical = criteria.asset ? convertAssetOrPairSearchInputToCanonical(criteria.asset) : undefined; const fromCanonical = criteria.from ? convertAssetLocationInputToCanonical(criteria.from) : undefined; diff --git a/src/services/order-matcher/client.test.ts b/src/services/order-matcher/client.test.ts new file mode 100644 index 00000000..c39e36e6 --- /dev/null +++ b/src/services/order-matcher/client.test.ts @@ -0,0 +1,168 @@ +import { test, expect } from 'vitest'; +import { KeetaNet } from '../../client/index.js'; +import { createNodeAndClient } from '../../lib/utils/tests/node.js'; +import KeetaAnchorResolver from '../../lib/resolver.js'; +import { KeetaNetOrderMatcherHTTPServer } from './server.js'; +import KeetaOrderMatcherClient from './client.js'; +import type { + KeetaOrderMatcherPriceHistoryResponse, + KeetaOrderMatcherPriceInfoResponse, + KeetaOrderMatcherPairDepthResponse +} from './common.ts'; + +const seed = '3EA9C31127EB9F16D2653D4F0E20BB151B6F508E0D7D0A703BEA7ABF8D1A5B40'; + +test('Order matcher client retrieves price info and history', async function() { + const account = KeetaNet.lib.Account.fromSeed(seed, 0); + + await using nodeAndClient = await createNodeAndClient(account); + const client = nodeAndClient.userClient; + + const { account: baseTokenAccount } = await client.generateIdentifier(KeetaNet.lib.Account.AccountKeyAlgorithm.TOKEN); + const baseToken = baseTokenAccount.assertKeyType(KeetaNet.lib.Account.AccountKeyAlgorithm.TOKEN); + const { account: quoteTokenAccount } = await client.generateIdentifier(KeetaNet.lib.Account.AccountKeyAlgorithm.TOKEN); + const quoteToken = quoteTokenAccount.assertKeyType(KeetaNet.lib.Account.AccountKeyAlgorithm.TOKEN); + + const priceInfoResponse: KeetaOrderMatcherPriceInfoResponse = { + ok: true, + last: '100.50', + priceChange: { + '1h': '1.20' + }, + volume: { + '1h': '2500.00' + } + }; + + const priceHistoryResponse: KeetaOrderMatcherPriceHistoryResponse = { + ok: true, + prices: [ + { + timestamp: Date.now(), + high: '101.00', + low: '99.50', + open: '100.00', + close: '100.25', + volume: '1500.00' + } + ] + }; + + const pairDepthResponse: KeetaOrderMatcherPairDepthResponse = { + ok: true, + grouping: 50, + buy: [ + { + price: '99.75', + volume: '200.00' + }, + { + price: '99.50', + volume: '150.00' + } + ], + sell: [ + { + price: '100.50', + volume: '175.00' + }, + { + price: '100.75', + volume: '125.00' + } + ] + }; + + await using server = new KeetaNetOrderMatcherHTTPServer({ + orderMatcher: { + matchingAccounts: [ account ], + pairs: [ + { + base: [baseToken], + quote: [quoteToken], + fees: { + type: 'sell-token-percentage', + minPercentBasisPoints: 25 + } + } + ], + getPairHistory: async function([tokenA, tokenB]) { + expect(tokenA.comparePublicKey(baseToken)).toBe(true); + expect(tokenB.comparePublicKey(quoteToken)).toBe(true); + return(priceHistoryResponse); + }, + getPairInfo: async function([tokenA, tokenB]) { + expect(tokenA.comparePublicKey(baseToken)).toBe(true); + expect(tokenB.comparePublicKey(quoteToken)).toBe(true); + return(priceInfoResponse); + }, + getPairDepth: async function([tokenA, tokenB], grouping) { + expect(grouping).toBe(50); + expect(tokenA.comparePublicKey(baseToken)).toBe(true); + expect(tokenB.comparePublicKey(quoteToken)).toBe(true); + return(pairDepthResponse); + } + } + }); + + await server.start(); + + const serviceMetadata = await server.serviceMetadata(); + + await client.setInfo({ + name: 'TEST_ORDER_MATCHER_ANCHOR', + description: 'Order matcher service for tests', + metadata: KeetaAnchorResolver.Metadata.formatMetadata({ + version: 1, + currencyMap: {}, + services: { + orderMatcher: { + TestProvider: serviceMetadata + } + } + }) + }); + + const resolver = new KeetaAnchorResolver({ + client: client.client, + root: client.account, + trustedCAs: [] + }); + + const orderMatcherClient = new KeetaOrderMatcherClient(client, { resolver }); + + const providers = await orderMatcherClient.getProvidersForPair([baseToken, quoteToken]); + expect(providers).not.toBeNull(); + const [provider] = providers ?? []; + if (provider === undefined) { + throw(new Error('Provider lookup returned null unexpectedly')); + } + + expect(String(provider.providerID)).toBe('TestProvider'); + + expect(provider.matchingAccounts.map(account => account.publicKeyString.get())).toEqual([ account.publicKeyString.get() ]); + const [metadata] = provider.pairs; + if (metadata === undefined) { + throw(new Error('Expected pair metadata')); + } + expect(metadata.base.map(token => token.publicKeyString.get())).toEqual([baseToken.publicKeyString.get()]); + expect(metadata.quote.map(token => token.publicKeyString.get())).toEqual([quoteToken.publicKeyString.get()]); + expect(metadata.fees).toEqual({ type: 'sell-token-percentage', minPercentBasisPoints: 25 }); + + const info = await provider.getPairInfo([baseToken, quoteToken]); + expect(info).toEqual(priceInfoResponse); + + const history = await provider.getPairHistory([baseToken, quoteToken]); + expect(history).toEqual(priceHistoryResponse); + + const depth = await provider.getPairDepth([baseToken, quoteToken], 50); + expect(depth).toEqual(pairDepthResponse); + + const allPairs = await orderMatcherClient.listAllPairs(); + expect(allPairs.map(([base, quote]) => [base.publicKeyString.get(), quote.publicKeyString.get()])).toEqual([ + [baseToken.publicKeyString.get(), quoteToken.publicKeyString.get()] + ]); + + const unmatched = await orderMatcherClient.getProvidersForPair([quoteToken, baseToken]); + expect(unmatched).toBeNull(); +}); diff --git a/src/services/order-matcher/client.ts b/src/services/order-matcher/client.ts new file mode 100644 index 00000000..88cf9175 --- /dev/null +++ b/src/services/order-matcher/client.ts @@ -0,0 +1,390 @@ +import { lib as KeetaNetLib } from '@keetanetwork/keetanet-client'; +import { getDefaultResolver } from '../../config.js'; +import type { + UserClient as KeetaNetUserClient +} from '@keetanetwork/keetanet-client'; +import type { Logger } from '../../lib/log/index.ts'; +import type Resolver from '../../lib/resolver.ts'; +import type { ServiceSearchCriteria } from '../../lib/resolver.ts'; +import { validateURL } from '../../lib/utils/url.js'; +import type { BrandedString } from '../../lib/utils/brand.ts'; +import crypto from '../../lib/utils/crypto.js'; +import { + assertKeetaNetTokenPublicKeyString, + isKeetaOrderMatcherPriceHistoryResponse, + isKeetaOrderMatcherPriceInfoResponse, + isKeetaOrderMatcherPairDepthResponse +} from './common.js'; +import type { + KeetaNetAccount, + KeetaNetToken, + KeetaOrderMatcherPairMetadata, + KeetaOrderMatcherPriceHistoryResponse, + KeetaOrderMatcherPriceInfoResponse, + KeetaOrderMatcherPairDepthResponse +} from './common.ts'; +import type { TokenAddress, TokenPublicKeyString } from '@keetanetwork/keetanet-client/lib/account.js'; + +type ProviderID = BrandedString<'OrderMatcherProviderID'>; + +type TokenInput = TokenAddress | TokenPublicKeyString | KeetaNetToken; + +type TokenPairInput = [ TokenInput, TokenInput ]; + +type KeetaOrderMatcherTokenPair = [ KeetaNetToken, KeetaNetToken ]; + +type OperationHandler = (params: { [key: string]: string; }) => URL; + +type KeetaOrderMatcherPairFeeMetadata = NonNullable; + +type KeetaOrderMatcherPairMetadataCanonical = { + base: KeetaNetToken[]; + quote: KeetaNetToken[]; + fees?: KeetaOrderMatcherPairFeeMetadata; +}; + +type KeetaOrderMatcherServiceInfo = { + operations: { + getPairHistory?: Promise; + getPairInfo?: Promise; + getPairDepth?: Promise; + }; + matchingAccounts: KeetaNetAccount[]; + pairs: KeetaOrderMatcherPairMetadataCanonical[]; +}; + +type GetEndpointsResult = { [key: string]: KeetaOrderMatcherServiceInfo }; + +function typedServiceEntries(obj: T): [keyof T, T[keyof T]][] { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + return(Object.entries(obj) as [keyof T, T[keyof T]][]); +} + +function toProviderID(value: string): ProviderID { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + return(value as unknown as ProviderID); +} + +function fromTokenPublicKeyString(value: string): KeetaNetToken { + const account = KeetaNetLib.Account.fromPublicKeyString(assertKeetaNetTokenPublicKeyString(value)); + return(account.assertKeyType(KeetaNetLib.Account.AccountKeyAlgorithm.TOKEN)); +} + +function fromAccountPublicKeyString(value: string): KeetaNetAccount { + return(KeetaNetLib.Account.fromPublicKeyString(value)); +} + +function canonicalizePair(pair: TokenPairInput): { tokenA: string; tokenB: string; } { + return({ + tokenA: KeetaNetLib.Account.toPublicKeyString(pair[0]), + tokenB: KeetaNetLib.Account.toPublicKeyString(pair[1]) + }); +} + +async function getEndpoints(resolver: Resolver, criteria: Partial>): Promise { + const response = await resolver.lookup('orderMatcher', criteria); + if (response === undefined) { + return(null); + } + + const serviceInfoPromises = Object.entries(response).map(async function([id, serviceInfo]): Promise<[ProviderID, KeetaOrderMatcherServiceInfo]> { + const operations = await serviceInfo.operations('object'); + const operationsFunctions: Partial = {}; + for (const [operationKey, operation] of Object.entries(operations)) { + if (operation === undefined) { + continue; + } + + const asyncFactory = (async function(): Promise { + const url = await operation('string'); + return(function(params: { [key: string]: string; } = {}): URL { + let substitutedURL = url; + for (const [paramKey, paramValue] of Object.entries(params)) { + substitutedURL = substitutedURL.replace(`{${paramKey}}`, encodeURIComponent(paramValue)); + } + return(validateURL(substitutedURL)); + }); + })(); + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + operationsFunctions[operationKey as keyof KeetaOrderMatcherServiceInfo['operations']] = asyncFactory; + } + + const matchingAccountsEntries = await serviceInfo.matchingAccounts('array'); + const matchingAccounts = await Promise.all(matchingAccountsEntries.map(async (entry) => { + const accountString = await entry('string'); + return(fromAccountPublicKeyString(accountString)); + })); + + const pairsEntries = await serviceInfo.pairs('array'); + const pairs = await Promise.all(pairsEntries.map(async function(pairEntry) { + const pairInfo = await pairEntry('object'); + + const baseEntries = await pairInfo.base('array'); + const baseTokens = await Promise.all(baseEntries.map(async (baseEntry) => { + const baseTokenString = await baseEntry('string'); + return(fromTokenPublicKeyString(baseTokenString)); + })); + + const quoteEntries = await pairInfo.quote('array'); + const quoteTokens = await Promise.all(quoteEntries.map(async (quoteEntry) => { + const quoteTokenString = await quoteEntry('string'); + return(fromTokenPublicKeyString(quoteTokenString)); + })); + + let fees: KeetaOrderMatcherPairFeeMetadata | undefined; + if (pairInfo.fees !== undefined) { + const feesObject = await pairInfo.fees('object'); + const typeValue = await feesObject.type('string'); + if (typeValue === 'sell-token-percentage') { + const minPercentBasisPointsValue = await feesObject.minPercentBasisPoints('number'); + fees = { + type: typeValue, + minPercentBasisPoints: Number(minPercentBasisPointsValue) + }; + } else { + throw(new Error(`Unsupported pair fee type: ${typeValue}`)); + } + + } + + const pairMetadata: KeetaOrderMatcherPairMetadataCanonical = { + base: baseTokens, + quote: quoteTokens + }; + if (fees !== undefined) { + pairMetadata.fees = fees; + } + + return(pairMetadata); + })); + + return([ + toProviderID(id), + { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + operations: operationsFunctions as KeetaOrderMatcherServiceInfo['operations'], + matchingAccounts, + pairs + } + ]); + }); + + if (serviceInfoPromises.length === 0) { + return(null); + } + + const endpoints = Object.fromEntries(await Promise.all(serviceInfoPromises)); + + return(endpoints); +} + +export type KeetaOrderMatcherClientConfig = { + id?: string; + logger?: Logger | undefined; + resolver?: Resolver; +} & Omit[1]>, 'client'>; + +export class KeetaOrderMatcherProvider { + readonly serviceInfo: KeetaOrderMatcherServiceInfo; + readonly providerID: ProviderID; + private readonly parent: KeetaOrderMatcherClient; + + constructor(serviceInfo: KeetaOrderMatcherServiceInfo, providerID: ProviderID, parent: KeetaOrderMatcherClient) { + this.serviceInfo = serviceInfo; + this.providerID = providerID; + this.parent = parent; + } + + get matchingAccounts(): readonly KeetaNetAccount[] { + return(this.serviceInfo.matchingAccounts); + } + + get pairs(): readonly KeetaOrderMatcherPairMetadataCanonical[] { + return(this.serviceInfo.pairs); + } + + /** + * Fetch price history for a given token pair + * @param pair The pair fetch history for + * @returns The price history of the pair, priced in pair[0] (the base token) + */ + async getPairHistory(pair: TokenPairInput): Promise { + const operationFactory = await this.serviceInfo.operations.getPairHistory; + if (operationFactory === undefined) { + throw(new Error('Service getPairHistory does not exist')); + } + + const canonicalPair = canonicalizePair(pair); + const requestURL = operationFactory(canonicalPair); + const response = await fetch(requestURL, { + method: 'GET', + headers: { + 'Accept': 'application/json' + } + }); + + const responseJSON: unknown = await response.json(); + if (!isKeetaOrderMatcherPriceHistoryResponse(responseJSON)) { + throw(new Error(`Invalid response from order matcher price history service: ${JSON.stringify(responseJSON)}`)); + } + + if (!responseJSON.ok) { + throw(new Error(`Order matcher price history request failed: ${responseJSON.error}`)); + } + + this.parent.logger?.debug(`Order matcher price history request successful, provider ${String(this.providerID)} for ${canonicalPair.tokenA}:${canonicalPair.tokenB}`); + return(responseJSON); + } + + /** + * Fetch latest price info for a given token pair + * @param pair The pair to fetch + * @returns {@link KeetaOrderMatcherPriceInfoResponse} the latest price info for the pair, priced in pair[0] (the base token) + */ + async getPairInfo(pair: TokenPairInput): Promise { + const operationFactory = await this.serviceInfo.operations.getPairInfo; + if (operationFactory === undefined) { + throw(new Error('Service getPairInfo does not exist')); + } + + const canonicalPair = canonicalizePair(pair); + const requestURL = operationFactory(canonicalPair); + const response = await fetch(requestURL, { + method: 'GET', + headers: { + 'Accept': 'application/json' + } + }); + + const responseJSON: unknown = await response.json(); + if (!isKeetaOrderMatcherPriceInfoResponse(responseJSON)) { + throw(new Error(`Invalid response from order matcher price info service: ${JSON.stringify(responseJSON)}`)); + } + + if (!responseJSON.ok) { + throw(new Error(`Order matcher price info request failed: ${responseJSON.error}`)); + } + + this.parent.logger?.debug(`Order matcher price info request successful, provider ${String(this.providerID)} for ${canonicalPair.tokenA}:${canonicalPair.tokenB}`); + return(responseJSON); + } + + /** + * Fetch price depth for a given token pair + * @param pair The pair to fetch depth for + * @param grouping The grouping to fetch the depth in, as an integer representing the price in pair[0] (the base token) + * @returns The price depth buckets with volume for the pair, priced in pair[0] (the base token) + */ + async getPairDepth(pair: TokenPairInput, grouping: number): Promise { + const operationFactory = await this.serviceInfo.operations.getPairDepth; + if (operationFactory === undefined) { + throw(new Error('Service getPairDepth does not exist')); + } + + if (!Number.isFinite(grouping) || grouping <= 0) { + throw(new Error('Grouping must be a positive numeric value')); + } + + const canonicalPair = canonicalizePair(pair); + const requestURL = operationFactory({ + ...canonicalPair, + grouping: grouping.toString() + }); + const response = await fetch(requestURL, { + method: 'GET', + headers: { + 'Accept': 'application/json' + } + }); + + const responseJSON: unknown = await response.json(); + if (!isKeetaOrderMatcherPairDepthResponse(responseJSON)) { + throw(new Error(`Invalid response from order matcher pair depth service: ${JSON.stringify(responseJSON)}`)); + } + + if (!responseJSON.ok) { + throw(new Error(`Order matcher pair depth request failed: ${responseJSON.error}`)); + } + + this.parent.logger?.debug(`Order matcher pair depth request successful, provider ${String(this.providerID)} for ${canonicalPair.tokenA}:${canonicalPair.tokenB} grouping ${grouping}`); + return(responseJSON); + } +} + +class KeetaOrderMatcherClient { + readonly resolver: Resolver; + readonly id: string; + readonly logger?: Logger | undefined; + readonly client: KeetaNetUserClient; + + constructor(client: KeetaNetUserClient, config: KeetaOrderMatcherClientConfig = {}) { + this.client = client; + this.logger = config.logger; + this.resolver = config.resolver ?? getDefaultResolver(client, config); + this.id = config.id ?? crypto.randomUUID(); + } + + async getProviders(criteria: Partial<{ base: TokenInput; quote: TokenInput; }> = {}): Promise { + const lookupCriteria: Partial> = {}; + if (criteria.base !== undefined) { + const basePublicKey = KeetaNetLib.Account.toPublicKeyString(criteria.base); + lookupCriteria.base = assertKeetaNetTokenPublicKeyString(basePublicKey); + } + if (criteria.quote !== undefined) { + const quotePublicKey = KeetaNetLib.Account.toPublicKeyString(criteria.quote); + lookupCriteria.quote = assertKeetaNetTokenPublicKeyString(quotePublicKey); + } + + const providerEndpoints = await getEndpoints(this.resolver, lookupCriteria); + if (providerEndpoints === null) { + return(null); + } + + return(typedServiceEntries(providerEndpoints).map(([ providerID, serviceInfo ]) => { + return(new KeetaOrderMatcherProvider(serviceInfo, toProviderID(String(providerID)), this)); + })); + } + + /** + * List all token pairs supported by all discovered Order Matcher providers + * @returns {@link KeetaOrderMatcherTokenPair[]} List of all token pairs supported by all discovered Order Matcher providers + */ + async listAllPairs(): Promise { + const providerEndpoints = await getEndpoints(this.resolver, {}); + if (providerEndpoints === null) { + return([]); + } + + const seenPairs = new Set(); + const pairs: KeetaOrderMatcherTokenPair[] = []; + for (const serviceInfo of Object.values(providerEndpoints)) { + for (const pairMetadata of serviceInfo.pairs) { + for (const baseToken of pairMetadata.base) { + for (const quoteToken of pairMetadata.quote) { + const canonical = canonicalizePair([baseToken, quoteToken]); + const key = `${canonical.tokenA}:${canonical.tokenB}`; + if (seenPairs.has(key)) { + continue; + } + seenPairs.add(key); + pairs.push([baseToken, quoteToken]); + } + } + } + } + + return(pairs); + } + + /** + * List all providers that support a given pair + * @param pair The pair to search + * @returns A list of providers that support the given pair, or null if no providers were found + */ + async getProvidersForPair(pair: TokenPairInput): Promise { + return(await this.getProviders({ base: pair[0], quote: pair[1] })); + } +} + +export default KeetaOrderMatcherClient; diff --git a/src/services/order-matcher/common.ts b/src/services/order-matcher/common.ts new file mode 100644 index 00000000..0a212c93 --- /dev/null +++ b/src/services/order-matcher/common.ts @@ -0,0 +1,71 @@ +import type { lib as KeetaNetLib } from '@keetanetwork/keetanet-client'; +import { createAssert, createIs } from 'typia'; + +import type { ToJSONSerializable } from '../../lib/utils/json.js'; + +export type KeetaNetAccount = InstanceType; +export type KeetaNetToken = InstanceType>; +export type KeetaNetTokenPublicKeyString = ReturnType>['publicKeyString']['get']>; + +export type IntervalString = '1m' | '5m' | '15m' | '30m' | '1h' | '6h' | '12h' | '1d' | '7d' | '14d' | '30d' | '90d' | '180d' | '1y' | '3y' | '5y' | '10y'; + +export type KeetaOrderMatcherPriceHistoryEntry = { + timestamp: number; + high: string; + low: string; + open: string; + close: string; + volume: string; +}; + +export type KeetaOrderMatcherPriceHistoryResponse = { + ok: true; + prices: KeetaOrderMatcherPriceHistoryEntry[]; +} | { + ok: false; + error: string; +}; + +export type KeetaOrderMatcherPriceInfo = { + last: string; + priceChange?: { [interval in IntervalString]?: string; }; + volume?: { [interval in IntervalString]?: string; }; +}; + +export type KeetaOrderMatcherPriceInfoResponse = ({ + ok: true; +} & KeetaOrderMatcherPriceInfo) | { + ok: false; + error: string; +}; + +export type KeetaOrderMatcherPriceInfoJSON = ToJSONSerializable; + +export type KeetaOrderMatcherPairDepthBucket = { + price: string; + volume: string; +}; + +export type KeetaOrderMatcherPairDepthResponse = { + ok: true; + grouping: number; + buy: KeetaOrderMatcherPairDepthBucket[]; + sell: KeetaOrderMatcherPairDepthBucket[]; +} | { + ok: false; + error: string; +}; + +export type KeetaOrderMatcherPairMetadata = { + base: KeetaNetTokenPublicKeyString[]; + quote: KeetaNetTokenPublicKeyString[]; + fees?: { + type: 'sell-token-percentage'; + minPercentBasisPoints: number; + }; +}; + +export const isKeetaOrderMatcherPriceHistoryResponse: (input: unknown) => input is KeetaOrderMatcherPriceHistoryResponse = createIs(); +export const isKeetaOrderMatcherPriceInfoResponse: (input: unknown) => input is KeetaOrderMatcherPriceInfoResponse = createIs(); +export const isKeetaOrderMatcherPairDepthResponse: (input: unknown) => input is KeetaOrderMatcherPairDepthResponse = createIs(); +export const assertKeetaNetTokenPublicKeyString: (input: unknown) => KeetaNetTokenPublicKeyString = createAssert(); diff --git a/src/services/order-matcher/server.test.ts b/src/services/order-matcher/server.test.ts new file mode 100644 index 00000000..0ab1650b --- /dev/null +++ b/src/services/order-matcher/server.test.ts @@ -0,0 +1,142 @@ +import { test, expect } from 'vitest'; +import { KeetaNet } from '../../client/index.js'; +import { createNodeAndClient } from '../../lib/utils/tests/node.js'; +import { KeetaNetOrderMatcherHTTPServer } from './server.js'; +import type { + KeetaOrderMatcherPriceHistoryResponse, + KeetaOrderMatcherPriceInfoResponse, + KeetaOrderMatcherPairDepthResponse +} from './common.ts'; + +const seed = 'DD2063130D5DA116D84890DC8450F0DC79A20B6965C8B7C3DB6B2E1C246D77F4'; + +test('Order matcher server exposes price endpoints and metadata', async function() { + const account = KeetaNet.lib.Account.fromSeed(seed, 0); + + await using nodeAndClient = await createNodeAndClient(account); + const client = nodeAndClient.userClient; + + const { account: baseTokenAccount } = await client.generateIdentifier(KeetaNet.lib.Account.AccountKeyAlgorithm.TOKEN); + const baseToken = baseTokenAccount.assertKeyType(KeetaNet.lib.Account.AccountKeyAlgorithm.TOKEN); + const { account: quoteTokenAccount } = await client.generateIdentifier(KeetaNet.lib.Account.AccountKeyAlgorithm.TOKEN); + const quoteToken = quoteTokenAccount.assertKeyType(KeetaNet.lib.Account.AccountKeyAlgorithm.TOKEN); + + const priceInfoResponse: KeetaOrderMatcherPriceInfoResponse = { + ok: true, + last: '75.25' + }; + + const priceHistoryResponse: KeetaOrderMatcherPriceHistoryResponse = { + ok: true, + prices: [ + { + timestamp: Date.now(), + high: '76.00', + low: '74.00', + open: '75.00', + close: '75.25', + volume: '900.00' + } + ] + }; + + const pairDepthResponse: KeetaOrderMatcherPairDepthResponse = { + ok: true, + grouping: 100, + buy: [ + { + price: '74.50', + volume: '500.00' + }, + { + price: '74.75', + volume: '300.00' + } + ], + sell: [ + { + price: '75.50', + volume: '450.00' + }, + { + price: '75.75', + volume: '350.00' + } + ] + }; + + let priceInfoCalls = 0; + let priceHistoryCalls = 0; + let pairDepthCalls = 0; + + await using server = new KeetaNetOrderMatcherHTTPServer({ + orderMatcher: { + matchingAccounts: [ account ], + pairs: [ + { + base: [baseToken], + quote: [quoteToken] + } + ], + getPairHistory: async function([tokenA, tokenB]) { + priceHistoryCalls += 1; + expect(tokenA.comparePublicKey(baseToken)).toBe(true); + expect(tokenB.comparePublicKey(quoteToken)).toBe(true); + return(priceHistoryResponse); + }, + getPairInfo: async function([tokenA, tokenB]) { + priceInfoCalls += 1; + expect(tokenA.comparePublicKey(baseToken)).toBe(true); + expect(tokenB.comparePublicKey(quoteToken)).toBe(true); + return(priceInfoResponse); + }, + getPairDepth: async function([tokenA, tokenB], grouping) { + pairDepthCalls += 1; + expect(grouping).toBe(100); + expect(tokenA.comparePublicKey(baseToken)).toBe(true); + expect(tokenB.comparePublicKey(quoteToken)).toBe(true); + return(pairDepthResponse); + } + } + }); + + await server.start(); + + const baseKey = baseToken.publicKeyString.get(); + const quoteKey = quoteToken.publicKeyString.get(); + + const infoFetch = await fetch(`${server.url}/api/price-info/${baseKey}:${quoteKey}`); + expect(infoFetch.ok).toBe(true); + const infoJSON: unknown = await infoFetch.json(); + expect(infoJSON).toEqual(priceInfoResponse); + + const historyFetch = await fetch(`${server.url}/api/price-history/${baseKey}:${quoteKey}`); + expect(historyFetch.ok).toBe(true); + const historyJSON: unknown = await historyFetch.json(); + expect(historyJSON).toEqual(priceHistoryResponse); + + const depthFetch = await fetch(`${server.url}/api/pair-depth/${baseKey}:${quoteKey}?grouping=100`); + expect(depthFetch.ok).toBe(true); + const depthJSON: unknown = await depthFetch.json(); + expect(depthJSON).toEqual(pairDepthResponse); + + expect(priceInfoCalls).toBe(1); + expect(priceHistoryCalls).toBe(1); + expect(pairDepthCalls).toBe(1); + + const metadata = await server.serviceMetadata(); + expect(metadata.operations.getPairInfo).toBeDefined(); + expect(metadata.operations.getPairInfo).toContain('/api/price-info'); + expect(metadata.operations.getPairHistory).toBeDefined(); + expect(metadata.operations.getPairHistory).toContain('/api/price-history'); + expect(metadata.operations.getPairDepth).toBeDefined(); + expect(metadata.operations.getPairDepth).toContain('/api/pair-depth'); + expect(metadata.matchingAccounts).toEqual([ account.publicKeyString.get() ]); + expect(metadata.pairs).toHaveLength(1); + const [pairMetadata] = metadata.pairs; + if (pairMetadata === undefined) { + throw(new Error('Expected pair metadata in serviceMetadata response')); + } + expect(pairMetadata.base).toEqual([baseKey]); + expect(pairMetadata.quote).toEqual([quoteKey]); +}); diff --git a/src/services/order-matcher/server.ts b/src/services/order-matcher/server.ts new file mode 100644 index 00000000..22acec32 --- /dev/null +++ b/src/services/order-matcher/server.ts @@ -0,0 +1,168 @@ +import * as KeetaAnchorHTTPServer from '../../lib/http-server/index.js'; +import type { Routes } from '../../lib/http-server/index.ts'; +import { KeetaNet } from '../../client/index.js'; +import type { ServiceMetadata } from '../../lib/resolver.ts'; +import type { TokenAddress } from '@keetanetwork/keetanet-client/lib/account.js'; +import type { + KeetaNetAccount, + KeetaOrderMatcherPairDepthResponse, + KeetaOrderMatcherPriceHistoryResponse, + KeetaOrderMatcherPriceInfoResponse +} from './common.ts'; + +type TokenAccount = InstanceType>; + +type OrderMatcherPairConfig = { + base: TokenAccount[]; + quote: TokenAccount[]; + fees?: { + type: 'sell-token-percentage'; + minPercentBasisPoints: number; + }; +}; + +export interface KeetaAnchorOrderMatcherServerConfig extends KeetaAnchorHTTPServer.KeetaAnchorHTTPServerConfig { + homepage?: string | (() => Promise | string); + orderMatcher: { + matchingAccounts: KeetaNetAccount[]; + pairs: OrderMatcherPairConfig[]; + getPairHistory?: (pair: [ TokenAddress, TokenAddress ]) => Promise; + getPairInfo: (pair: [ TokenAddress, TokenAddress ]) => Promise; + getPairDepth?: (pair: [ TokenAddress, TokenAddress ], grouping: number) => Promise; + }; +} + +function isTokenStringArray(value: unknown): value is [string, string] { + if (!Array.isArray(value) || value.length !== 2) { + return(false); + } + + for (const item of value) { + if (typeof item !== 'string') { + return(false); + } + } + + return(true); +} + +function parseTokenParameter(params: Map): [ TokenAddress, TokenAddress ] { + const tokensParam = params.get('tokens'); + if (typeof tokensParam !== 'string') { + throw(new Error('Missing tokens in params')); + } + + const segments = tokensParam.split(':'); + + if (!isTokenStringArray(segments)) { + throw(new Error('Invalid tokens parameter, expected format {tokenA}:{tokenB}')); + } + + return([ + KeetaNet.lib.Account.fromPublicKeyString(segments[0]).assertKeyType(KeetaNet.lib.Account.AccountKeyAlgorithm.TOKEN), + KeetaNet.lib.Account.fromPublicKeyString(segments[1]).assertKeyType(KeetaNet.lib.Account.AccountKeyAlgorithm.TOKEN) + ]); +} + +export class KeetaNetOrderMatcherHTTPServer extends KeetaAnchorHTTPServer.KeetaNetAnchorHTTPServer { + readonly homepage: KeetaAnchorOrderMatcherServerConfig['homepage']; + readonly orderMatcher: KeetaAnchorOrderMatcherServerConfig['orderMatcher']; + + constructor(config: KeetaAnchorOrderMatcherServerConfig) { + super(config); + + if (config.orderMatcher.getPairInfo === undefined) { + throw(new Error('orderMatcher.getPairInfo is required')); + } + + this.homepage = config.homepage; + this.orderMatcher = config.orderMatcher; + } + + protected override async initRoutes(config: KeetaAnchorOrderMatcherServerConfig): Promise { + const routes: Routes = {}; + + if (config.homepage !== undefined) { + routes['GET /'] = async () => { + const resolvedHomepage = typeof this.homepage === 'function' ? await this.homepage() : this.homepage; + return({ + output: resolvedHomepage ?? '', + contentType: 'text/html' + }); + }; + } + + const { getPairHistory, getPairInfo, getPairDepth } = config.orderMatcher; + if (getPairHistory !== undefined) { + routes['GET /api/price-history/:tokens'] = async (urlParams) => { + const pair = parseTokenParameter(urlParams); + const response = await getPairHistory(pair); + if (response === undefined) { + throw(new Error('Price history handler returned undefined response')); + } + return({ + output: JSON.stringify(response), + contentType: 'application/json' + }); + }; + } + + routes['GET /api/price-info/:tokens'] = async (urlParams) => { + const pair = parseTokenParameter(urlParams); + const response = await getPairInfo(pair); + return({ + output: JSON.stringify(response), + contentType: 'application/json' + }); + }; + + if (getPairDepth !== undefined) { + routes['GET /api/pair-depth/:tokens'] = async (urlParams, _body, _headers, requestUrl) => { + const pair = parseTokenParameter(urlParams); + const groupingParam = requestUrl.searchParams.get('grouping'); + if (groupingParam === null) { + throw(new Error('Missing grouping query parameter')); + } + + const grouping = Number.parseFloat(groupingParam); + if (!Number.isFinite(grouping) || grouping <= 0) { + throw(new Error('Invalid grouping query parameter, expected positive numeric value')); + } + + const response = await getPairDepth(pair, grouping); + if (response === undefined) { + throw(new Error('Pair depth handler returned undefined response')); + } + + return({ + output: JSON.stringify(response), + contentType: 'application/json' + }); + }; + } + + return(routes); + } + + async serviceMetadata(): Promise[string]> { + const operations: NonNullable[string]['operations'] = {}; + + if (this.orderMatcher.getPairHistory !== undefined) { + operations.getPairHistory = (new URL('/api/price-history', this.url)).toString() + '/{tokenA}:{tokenB}'; + } + operations.getPairInfo = (new URL('/api/price-info', this.url)).toString() + '/{tokenA}:{tokenB}'; + if (this.orderMatcher.getPairDepth !== undefined) { + operations.getPairDepth = (new URL('/api/pair-depth', this.url)).toString() + '/{tokenA}:{tokenB}?grouping={grouping}'; + } + + return({ + operations, + matchingAccounts: this.orderMatcher.matchingAccounts.map((account) => account.publicKeyString.get()), + pairs: this.orderMatcher.pairs.map((pair) => ({ + base: pair.base.map((token) => token.publicKeyString.get()), + quote: pair.quote.map((token) => token.publicKeyString.get()), + ...(pair.fees ? { fees: pair.fees } : {}) + })) + }); + } +}