diff --git a/src/services/asset-movement/client.test.ts b/src/services/asset-movement/client.test.ts index dae3498f..5290318a 100644 --- a/src/services/asset-movement/client.test.ts +++ b/src/services/asset-movement/client.test.ts @@ -5,11 +5,13 @@ import { createNodeAndClient } from '../../lib/utils/tests/node.js'; import type { ServiceMetadataExternalizable } from '../../lib/resolver.js'; import KeetaAnchorResolver from '../../lib/resolver.js'; import { type KeetaAnchorAssetMovementServerConfig, KeetaNetAssetMovementAnchorHTTPServer } from './server.js'; -import { Errors, type RailWithExtendedDetails, type KeetaAssetMovementAnchorCreatePersistentForwardingRequest, type KeetaAssetMovementAnchorCreatePersistentForwardingResponse, type KeetaAssetMovementAnchorGetTransferStatusResponse, type KeetaAssetMovementAnchorInitiateTransferClientRequest, type KeetaAssetMovementAnchorInitiateTransferRequest, type KeetaAssetMovementAnchorInitiateTransferResponse, type KeetaAssetMovementAnchorlistPersistentForwardingTransactionsResponse, type KeetaAssetMovementAnchorlistTransactionsRequest, type KeetaAssetMovementTransaction, type ProviderSearchInput } from './common.js'; +import { Errors, type RailWithExtendedDetails, type KeetaAssetMovementAnchorCreatePersistentForwardingRequest, type KeetaAssetMovementAnchorCreatePersistentForwardingResponse, type KeetaAssetMovementAnchorGetTransferStatusResponse, type KeetaAssetMovementAnchorInitiateTransferClientRequest, type KeetaAssetMovementAnchorInitiateTransferRequest, type KeetaAssetMovementAnchorInitiateTransferResponse, type KeetaAssetMovementAnchorlistPersistentForwardingTransactionsResponse, type KeetaAssetMovementAnchorlistTransactionsRequest, type KeetaAssetMovementTransaction, type ProviderSearchInput, toAssetPair, type AssetOrPair } from './common.js'; import { Certificate, CertificateBuilder, SharableCertificateAttributes } from '../../lib/certificates.js'; import type { Routes } from '../../lib/http-server/index.js'; import { KeetaAnchorUserValidationError } from '../../lib/error.js'; +const toJSONSerializable = KeetaNet.lib.Utils.Conversion.toJSONSerializable; + const DEBUG = false; const logger = DEBUG ? console : undefined; @@ -74,9 +76,22 @@ test('Asset Movement Anchor Client Test', async function() { asset: 'USD' }, variableFeeBps: 50 + }, + supportedOperations: { + createPersistentForwarding: false, + initiateTransfer: false } }; + function shouldOperationFailExtendedKeetaSend(inputAsset: AssetOrPair): boolean { + const assetPair = toAssetPair(inputAsset); + if (typeof assetPair.from !== 'string' || !(testCurrencyUSDC.comparePublicKey(assetPair.from)) || assetPair.to !== 'USD') { + return(false); + } + + return(true); + } + await using server = new KeetaNetAssetMovementAnchorHTTPServer({ ...(logger ? { logger: logger } : {}), client: { client: client.client, network: client.config.network, networkAlias: client.config.networkAlias }, @@ -132,6 +147,14 @@ test('Asset Movement Anchor Client Test', async function() { throw(new Error('Missing depositAddress in request')); } + + if (shouldOperationFailExtendedKeetaSend(request.asset)) { + throw(new Errors.OperationNotSupported({ + forAsset: request.asset, + forRail: extendedKeetaSendDetails.rail + })); + } + return({ address: request.destinationAddress }) @@ -145,6 +168,13 @@ test('Asset Movement Anchor Client Test', async function() { throw(new Error('Recipient is not a string')); } + if (shouldOperationFailExtendedKeetaSend(request.asset)) { + throw(new Errors.OperationNotSupported({ + forAsset: 'USD', + forRail: extendedKeetaSendDetails.rail + })); + } + return({ id: '123', instructionChoices: [{ @@ -412,6 +442,49 @@ test('Asset Movement Anchor Client Test', async function() { } expect(supportedAssetWithDetails.paths[0]?.pair[1].rails.inbound?.[0]).toEqual(extendedKeetaSendDetails); + + for (const [ method, expectedArguments ] of [ + [ + () => testProvider?.createPersistentForwardingAddress({ + asset: { from: testCurrencyUSDC, to: 'USD' }, + destinationLocation: 'chain:keeta:123', + destinationAddress: 'test-address', + sourceLocation: 'bank-account:us' + }), + { + forAsset: { from: testCurrencyUSDC, to: 'USD' }, + forRail: 'KEETA_SEND' + } + ], + [ + () => testProvider?.initiateTransfer({ + asset: { from: testCurrencyUSDC, to: 'USD' }, + from: { location: 'bank-account:us' }, + to: { location: 'chain:keeta:123', recipient: 'test-recipient' }, + value: '1000' + }), + { + forAsset: 'USD', + forRail: 'KEETA_SEND' + } + ] + ] as const) { + let error = null; + try { + await method(); + } catch (e) { + error = e; + } + + if (!(error instanceof Errors.OperationNotSupported)) { + throw(new Error('Expected OperationNotSupported error')); + } + + expect(toJSONSerializable({ + forAsset: error.forAsset, + forRail: error.forRail + })).toEqual(toJSONSerializable(expectedArguments)); + } } }); diff --git a/src/services/asset-movement/common.ts b/src/services/asset-movement/common.ts index 4668f1a3..97896631 100644 --- a/src/services/asset-movement/common.ts +++ b/src/services/asset-movement/common.ts @@ -209,6 +209,21 @@ export interface RailWithExtendedDetails { */ variableFeeBps?: number; } + + /** + * Supported operations for this rail + */ + supportedOperations?: { + /** + * Whether this rail supports creating persistent forwarding addresses for (unmanaged) transfers + */ + createPersistentForwarding?: boolean; + + /** + * Whether this rail supports initiating (managed) transfers + */ + initiateTransfer?: boolean; + } } export type RailOrRailWithExtendedDetails = Rail | RailWithExtendedDetails; @@ -1274,9 +1289,96 @@ class KeetaAssetMovementAnchorAdditionalKYCNeededError extends KeetaAnchorUserEr } } + +export interface KeetaAssetMovementAnchorOperationNotSupportedErrorJSONProperties { + forAsset?: AssetOrPair | undefined; + forRail?: Rail | undefined; +} + +export const assertKeetaAssetMovementAnchorOperationNotSupportedErrorJSONProperties: (input: unknown) => KeetaAssetMovementAnchorOperationNotSupportedErrorJSONProperties = createAssertEquals(); + +type KeetaAssetMovementAnchorOperationNotSupportedErrorJSON = ReturnType & KeetaAssetMovementAnchorOperationNotSupportedErrorJSONProperties; + +class KeetaAssetMovementAnchorOperationNotSupportedError extends KeetaAnchorUserError implements KeetaAssetMovementAnchorOperationNotSupportedErrorJSONProperties { + static override readonly name: string = 'KeetaAssetMovementAnchorOperationNotSupportedError'; + private readonly KeetaAssetMovementAnchorOperationNotSupportedErrorObjectTypeID!: string; + private static readonly KeetaAssetMovementAnchorOperationNotSupportedErrorObjectTypeID = 'b613cd80-57ac-4be5-ad4a-bb8644d50de6'; + + readonly forAsset: AssetOrPair | undefined; + readonly forRail: Rail | undefined; + + constructor(args: KeetaAssetMovementAnchorOperationNotSupportedErrorJSONProperties, message?: string) { + super(message ?? `Operation not supported`); + this.statusCode = 400; + + Object.defineProperty(this, 'KeetaAssetMovementAnchorOperationNotSupportedErrorObjectTypeID', { + value: KeetaAssetMovementAnchorOperationNotSupportedError.KeetaAssetMovementAnchorOperationNotSupportedErrorObjectTypeID, + enumerable: false + }); + + this.forAsset = args.forAsset; + this.forRail = args.forRail; + } + + static isInstance(input: unknown): input is KeetaAssetMovementAnchorOperationNotSupportedError { + return(this.hasPropWithValue(input, 'KeetaAssetMovementAnchorOperationNotSupportedErrorObjectTypeID', KeetaAssetMovementAnchorOperationNotSupportedError.KeetaAssetMovementAnchorOperationNotSupportedErrorObjectTypeID)); + } + + asErrorResponse(contentType: 'text/plain' | 'application/json'): { error: string; statusCode: number; contentType: string } { + const { forAsset, forRail } = this.toJSON(); + + let message = this.message; + if (contentType === 'application/json') { + message = JSON.stringify({ + ok: false, + name: this.name, + code: 'KEETA_ANCHOR_ASSET_MOVEMENT_OPERATION_NOT_SUPPORTED', + data: { forAsset, forRail }, + error: this.message + }); + } + + return({ + error: message, + statusCode: this.statusCode, + contentType: contentType + }); + } + + toJSON(): KeetaAssetMovementAnchorOperationNotSupportedErrorJSON { + return({ + ...super.toJSON(), + forRail: this.forRail, + forAsset: this.forAsset ? convertAssetOrPairSearchInputToCanonical(this.forAsset) : undefined + }); + } + + static async fromJSON(input: unknown): Promise { + const { message, other } = this.extractErrorProperties(input, this); + + if (!('data' in other)) { + throw(new Error('Invalid KeetaAssetMovementAnchorOperationNotSupportedError JSON: missing data property')); + } + + const parsed = assertKeetaAssetMovementAnchorOperationNotSupportedErrorJSONProperties(other.data); + + const error = new this( + { + forAsset: parsed.forAsset, + forRail: parsed.forRail + }, + message + ); + + error.restoreFromJSON(other); + return(error); + } +} + export const Errors: { KYCShareNeeded: typeof KeetaAssetMovementAnchorKYCShareNeededError; AdditionalKYCNeeded: typeof KeetaAssetMovementAnchorAdditionalKYCNeededError; + OperationNotSupported: typeof KeetaAssetMovementAnchorOperationNotSupportedError; } = { /** * The user is required to share KYC details @@ -1286,5 +1388,10 @@ export const Errors: { /** * The user is required to complete additional KYC steps */ - AdditionalKYCNeeded: KeetaAssetMovementAnchorAdditionalKYCNeededError + AdditionalKYCNeeded: KeetaAssetMovementAnchorAdditionalKYCNeededError, + + /** + * The requested operation is not supported + */ + OperationNotSupported: KeetaAssetMovementAnchorOperationNotSupportedError };