From e193e2a9c9cbc4864f4af69e3344d91e2740e0b0 Mon Sep 17 00:00:00 2001 From: Tanveer Wahid Date: Thu, 25 Jun 2026 01:53:42 -0700 Subject: [PATCH 1/3] chore(chaining): refactor --- .cspell.config.mjs | 2 +- .sonar-project.properties | 2 +- .vitest.config.js | 7 +- src/lib/chaining.test.ts | 3073 ----------------- src/lib/chaining.ts | 2452 ------------- src/lib/chaining/chaining.test.ts | 763 ++++ src/lib/chaining/errors.ts | 152 + src/lib/chaining/execution.ts | 597 ++++ src/lib/chaining/facade.ts | 194 ++ src/lib/chaining/fixtures.ts | 1580 +++++++++ .../graph.cli.ts} | 16 +- src/lib/chaining/graph.test.ts | 443 +++ src/lib/chaining/graph.ts | 829 +++++ src/lib/chaining/index.ts | 15 + src/lib/chaining/plan.ts | 323 ++ src/lib/chaining/retry.test.ts | 161 + src/lib/chaining/retry.ts | 264 ++ src/lib/chaining/steps/asset-movement.ts | 245 ++ src/lib/chaining/steps/context.ts | 142 + src/lib/chaining/steps/executor.ts | 56 + src/lib/chaining/steps/external.ts | 46 + src/lib/chaining/steps/forwarded.ts | 159 + src/lib/chaining/steps/fx.ts | 124 + src/lib/chaining/steps/keeta-send.ts | 121 + src/lib/chaining/steps/poll.ts | 74 + src/lib/chaining/steps/run.ts | 120 + src/lib/chaining/store.ts | 174 + src/lib/chaining/types.ts | 452 +++ src/lib/index.ts | 20 + 29 files changed, 7070 insertions(+), 5536 deletions(-) delete mode 100644 src/lib/chaining.test.ts delete mode 100644 src/lib/chaining.ts create mode 100644 src/lib/chaining/chaining.test.ts create mode 100644 src/lib/chaining/errors.ts create mode 100644 src/lib/chaining/execution.ts create mode 100644 src/lib/chaining/facade.ts create mode 100644 src/lib/chaining/fixtures.ts rename src/lib/{chaining-graph.cli.ts => chaining/graph.cli.ts} (94%) create mode 100644 src/lib/chaining/graph.test.ts create mode 100644 src/lib/chaining/graph.ts create mode 100644 src/lib/chaining/index.ts create mode 100644 src/lib/chaining/plan.ts create mode 100644 src/lib/chaining/retry.test.ts create mode 100644 src/lib/chaining/retry.ts create mode 100644 src/lib/chaining/steps/asset-movement.ts create mode 100644 src/lib/chaining/steps/context.ts create mode 100644 src/lib/chaining/steps/executor.ts create mode 100644 src/lib/chaining/steps/external.ts create mode 100644 src/lib/chaining/steps/forwarded.ts create mode 100644 src/lib/chaining/steps/fx.ts create mode 100644 src/lib/chaining/steps/keeta-send.ts create mode 100644 src/lib/chaining/steps/poll.ts create mode 100644 src/lib/chaining/steps/run.ts create mode 100644 src/lib/chaining/store.ts create mode 100644 src/lib/chaining/types.ts diff --git a/.cspell.config.mjs b/.cspell.config.mjs index a7950cbb..dd3ec02d 100644 --- a/.cspell.config.mjs +++ b/.cspell.config.mjs @@ -182,7 +182,7 @@ export default { ] }, { - filename: [ 'src/lib/chaining-graph.cli.ts' ], + filename: [ 'src/lib/chaining/graph.cli.ts' ], words: [ 'rankdir', 'fontname', 'darkorange', 'steelblue' ] diff --git a/.sonar-project.properties b/.sonar-project.properties index 9a0dbdaf..0ef34e93 100644 --- a/.sonar-project.properties +++ b/.sonar-project.properties @@ -6,4 +6,4 @@ sonar.tests=. sonar.exclusions=**/*.test.ts sonar.test.inclusions=**/*.test.ts # This should be kept in sync with the excludes from ".vitest.config.js" -sonar.coverage.exclusions=src/lib/utils/never.ts,.eslint.config.mjs,src/**/*.generated.ts,src/services/kyc/utils/generate-kyc-schema.ts +sonar.coverage.exclusions=src/lib/utils/never.ts,.eslint.config.mjs,src/**/*.generated.ts,src/services/kyc/utils/generate-kyc-schema.ts,src/**/fixtures.ts diff --git a/.vitest.config.js b/.vitest.config.js index bad6ac81..3c0cb317 100644 --- a/.vitest.config.js +++ b/.vitest.config.js @@ -20,7 +20,12 @@ export default defineConfig({ * Exclude test files from coverage since they are not * part of the source code */ - 'src/**/*.test.ts' + 'src/**/*.test.ts', + /* + * Exclude shared test fixtures (servers, harnesses, helpers); + * they are test infrastructure, not production source. + */ + 'src/**/fixtures.ts' ], enabled: true } diff --git a/src/lib/chaining.test.ts b/src/lib/chaining.test.ts deleted file mode 100644 index 58495aac..00000000 --- a/src/lib/chaining.test.ts +++ /dev/null @@ -1,3073 +0,0 @@ -import { test, expect, describe } from 'vitest'; -import { createNodeAndClient } from './utils/tests/node.js'; -import { KeetaNet } from '../client/index.js'; -import { KeetaNetAssetMovementAnchorHTTPServer, type KeetaAnchorAssetMovementServerConfig } from '../services/asset-movement/server.js'; -import { type AnchorTokenLocationMetadata, convertAssetLocationToString, toAssetLocation, toAssetPair, type AssetLocationLike, type KeetaAssetMovementTransaction, type KeetaPersistentForwardingAddressDetails } from '../services/asset-movement/common.js'; -import { KeetaNetFXAnchorHTTPServer, type KeetaAnchorFXServerConfig, type GetConversionRateAndFeeContext, type KeetaFXInternalPriceQuote } from '../services/fx/server.js'; -import type { ConversionInputCanonicalJSON } from '../services/fx/common.js'; -import { Resolver } from './index.js'; -import type { ServiceMetadataExternalizable } from './resolver.js'; -import { AnchorChaining, AnchorChainingPlan } from './chaining.js'; -import type { AnchorChainingPathState, ExecutedStep, AnchorChainingAsset, AnchorChainingAssetInfo, AnchorChainingResolveAssetsFilter, ComputePlanOptions, Disclaimer } from './chaining.js'; -import type { GenericAccount, TokenAddress } from '@keetanetwork/keetanet-client/lib/account.js'; -import { KeetaAnchorUserError } from './error.js'; -import { AnchorExternal } from './anchor-external.js'; -import { BlockListener } from './block-listener.js'; -import type { AnchorMetadataLegalField } from './metadata.types.js'; - -const DEBUG = false; -const logger = DEBUG ? console : undefined; - -const toJSONSerializable = KeetaNet.lib.Utils.Conversion.toJSONSerializable; - -type InitiateTransferFn = NonNullable; -type RateFn = (request: ConversionInputCanonicalJSON, context: GetConversionRateAndFeeContext) => Promise; - -const EMPTY_FROM_TRANSACTIONS = { deposit: null, persistentForwarding: null, finalization: null } as const; -const EMPTY_TO_TRANSACTIONS = { withdraw: null } as const; - -/** - * `true` when a SEND's external field references the given transfer, either - * as the raw transfer id (anchor-provided external) or as an entry in a - * decodable plaintext envelope (client-constructed external). - */ -async function externalReferencesTransfer(external: unknown, txId: string): Promise { - if (external === txId) { - return(true); - } - if (typeof external !== 'string' || external === '') { - return(false); - } - - let decoded; - try { - decoded = await AnchorExternal.fromPlainExternal(external); - } catch { - return(false); - } - - return(Object.values(decoded.envelope.anchors).some(function(entry) { - return('transactionId' in entry && entry.transactionId === txId); - })); -} - -/** - * Initiate-transfer wrapper simulating an anchor under the construction - * model: KEETA_SEND instructions carry no external, so the client must - * build the correlation envelope itself. - */ -async function stripKeetaSendExternal(request: Parameters[0], next: InitiateTransferFn): ReturnType { - const response = await next(request); - return({ - ...response, - instructionChoices: response.instructionChoices.map(function(choice) { - if (choice.type === 'KEETA_SEND') { - const rest = { ...choice }; - delete rest.external; - return(rest); - } - - return(choice); - }) - }); -} - -/** - * Build a `KeetaAssetMovementTransaction` record for in-memory test bridges. - * `fromValue`/`toValue` are kept separate so bridges that charge a fee can - * model the asymmetry (e.g. `fromValue = value`, `toValue = value - fee`). - */ -function buildTxRecord(args: { - id: string; - status: KeetaAssetMovementTransaction['status']; - asset: KeetaAssetMovementTransaction['asset']; - fromLocation: KeetaAssetMovementTransaction['from']['location']; - toLocation: KeetaAssetMovementTransaction['to']['location']; - fromValue: string; - toValue: string; -}): KeetaAssetMovementTransaction { - const now = new Date().toISOString(); - return({ - id: args.id, - status: args.status, - asset: args.asset, - from: { location: args.fromLocation, value: args.fromValue, transactions: { ...EMPTY_FROM_TRANSACTIONS }}, - to: { location: args.toLocation, value: args.toValue, transactions: { ...EMPTY_TO_TRANSACTIONS }}, - fee: null, - createdAt: now, - updatedAt: now - }); -} - -class TestBankServer extends KeetaNetAssetMovementAnchorHTTPServer { - private readonly _initiateRef: { fn: InitiateTransferFn }; - #defaultInitiateRef: { fn: InitiateTransferFn; }; - private readonly _statusMap: Map; - private readonly _getStatusRef: { interceptor: (() => void) | null }; - - constructor(config: Omit & { - assetMovement: Omit; - client: KeetaNet.UserClient; - }) { - const { client: userClient, ...serverConfig } = config; - - const bankAccount = KeetaNet.lib.Account.fromSeed(KeetaNet.lib.Account.generateRandomSeed(), 0); - - const statusMap = new Map(); - const blockListener = new BlockListener({ client: userClient.client }); - const getStatusRef: { interceptor: (() => void) | null } = { interceptor: null }; - - const initiateRef: { fn: InitiateTransferFn } = { - fn: async (request) => { - const value = BigInt(request.value); - const fee = 10n; - const receive = value - fee; - const txId = `tx-${Date.now()}-${Math.random().toString(36).slice(2)}`; - - const parsedFrom = toAssetLocation(request.from.location); - if (parsedFrom.type === 'chain' && parsedFrom.chain.type === 'keeta') { - const assetPair = toAssetPair(request.asset); - - statusMap.set(txId, buildTxRecord({ - id: txId, - status: 'PENDING', - asset: request.asset, - fromLocation: request.from.location, - toLocation: request.to.location, - fromValue: value.toString(), - toValue: receive.toString() - })); - - let listenerHandle: { remove: () => void } | null = null; - listenerHandle = blockListener.on('block', { - callback: async ({ block }) => { - for (const op of block.operations) { - if (op.type === KeetaNet.lib.Block.OperationType.SEND && await externalReferencesTransfer(op.external, txId)) { - if (op.amount !== value) { - throw(new KeetaAnchorUserError(`Invalid transfer amount: expected ${value}, got ${op.amount}`)); - } - const existing = statusMap.get(txId); - if (existing && existing.status !== 'COMPLETE') { - statusMap.set(txId, { ...existing, status: 'COMPLETE', updatedAt: new Date().toISOString() }); - } - listenerHandle?.remove(); - return({ requiresWork: false }); - } - } - return({ requiresWork: false }); - } - }); - - const tokenAddress = assetPair.from; - if (typeof tokenAddress !== 'string') { - throw(new Error('invalid keeta send asset')); - } - - return({ - id: txId, - instructionChoices: [{ - type: 'KEETA_SEND' as const, - location: request.from.location, - sendToAddress: bankAccount.publicKeyString.get(), - external: txId, - value: value.toString(), - tokenAddress: KeetaNet.lib.Account.fromPublicKeyString(tokenAddress) - .assertKeyType(KeetaNet.lib.Account.AccountKeyAlgorithm.TOKEN) - .publicKeyString.get(), - assetFee: fee.toString(), - totalReceiveAmount: receive.toString() - }] - }); - } else { - statusMap.set(txId, buildTxRecord({ - id: txId, - status: 'COMPLETE', - asset: request.asset, - fromLocation: request.from.location, - toLocation: request.to.location, - fromValue: value.toString(), - toValue: receive.toString() - })); - return({ - id: txId, - instructionChoices: [{ - type: 'ACH', - account: { - type: 'bank-account', - accountType: 'us', - accountNumber: `test-acct-${txId}`, - routingNumber: '021000021', - accountTypeDetail: 'checking', - accountOwner: { type: 'business', businessName: 'TestBank' } - } as const, - value: value.toString(), - assetFee: fee.toString(), - totalReceiveAmount: receive.toString() - }] - }); - } - } - }; - - super({ - ...serverConfig, - assetMovement: { - ...serverConfig.assetMovement, - initiateTransfer: async (request) => { - // Status management is handled inside initiateRef.fn per direction. - return(await initiateRef.fn(request)); - }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - getTransferStatus: async (id: string): Promise => { - // Allow tests to arm a one-shot failure for the next status poll. - const interceptor = getStatusRef.interceptor; - if (interceptor) { - getStatusRef.interceptor = null; - interceptor(); - } - // Scan recent blocks to detect any KEETA_SEND that completes a pending transfer. - await blockListener.scan(); - const tx = statusMap.get(id); - if (!tx) {throw(new Error(`Unknown transfer ID: ${id}`));} - return({ transaction: tx }); - } - } - }); - - // Store references to the shared objects so instance methods can mutate them. - this._initiateRef = initiateRef; - this.#defaultInitiateRef = { ...initiateRef }; - this._statusMap = statusMap; - this._getStatusRef = getStatusRef; - } - - setInitiateTransfer(fn: InitiateTransferFn | null): this { - if (!fn) { - fn = this.#defaultInitiateRef.fn; - } - - this._initiateRef.fn = fn; - - return(this); - } - - wrapInitiateTransfer(wrapper: (request: Parameters[0], next: InitiateTransferFn) => ReturnType): this { - const saved = this._initiateRef.fn; - this._initiateRef.fn = async (request) => { - return(await wrapper(request, saved)); - }; - return(this); - } - - setFee(fee: bigint): this { - return(this.setInitiateTransfer(async (request) => { - const value = BigInt(request.value); - const receive = value - fee; - const txId = `tx-${Date.now()}-${Math.random().toString(36).slice(2)}`; - this._statusMap.set(txId, buildTxRecord({ - id: txId, - status: 'COMPLETE', - asset: request.asset, - fromLocation: request.from.location, - toLocation: request.to.location, - fromValue: value.toString(), - toValue: receive.toString() - })); - - if (typeof request.to.recipient !== 'string') { - throw(new Error('invalid keeta send recipient')); - } - - const assetPair = toAssetPair(request.asset); - const tokenAddress = assetPair.from; - if (typeof tokenAddress !== 'string') { - throw(new Error('invalid keeta send asset')); - } - - return({ - id: txId, - instructionChoices: [{ - type: 'KEETA_SEND' as const, - location: request.from.location, - sendToAddress: KeetaNet.lib.Account.fromPublicKeyString(request.to.recipient).publicKeyString.get(), - value: value.toString(), - tokenAddress: KeetaNet.lib.Account.fromPublicKeyString(tokenAddress) - .assertKeyType(KeetaNet.lib.Account.AccountKeyAlgorithm.TOKEN) - .publicKeyString.get(), - assetFee: fee.toString(), - totalReceiveAmount: receive.toString() - }] - }); - })); - } - - /** Arm the server so the next initiateTransfer call throws (then restores). */ - failNextInitiate(message = 'Transfer initiation failed'): this { - const saved = this._initiateRef.fn; - this._initiateRef.fn = async () => { - this._initiateRef.fn = saved; - throw(new KeetaAnchorUserError(message)); - }; - - return(this); - } - - /** Arm the server so the next getTransferStatus call throws (then restores). */ - failNextTransferStatus(message = 'Transfer status check failed'): this { - this._getStatusRef.interceptor = () => { throw(new KeetaAnchorUserError(message)); }; - return(this); - } - - /** Manually update the status of an in-flight transfer. */ - setTransferStatus(id: string, update: Partial>): this { - const existing = this._statusMap.get(id); - if (!existing) {throw(new Error(`Unknown transfer ID: ${id}`));} - this._statusMap.set(id, { ...existing, ...update, updatedAt: new Date().toISOString() }); - return(this); - } -} - -type TestFXServerConfig = Omit & { - fx: Pick; - giveTokens: (to: GenericAccount, amount: bigint, token: TokenAddress) => Promise; - /** Must be a UserClient so we can read LP balances and mint tokens on demand. */ - client: KeetaNet.UserClient; -}; - -class TestFXServer extends KeetaNetFXAnchorHTTPServer { - private readonly _rateRef: { fn: RateFn }; - private readonly _giveTokens: (to: GenericAccount, amount: bigint, token: TokenAddress) => Promise; - private readonly _keetaClient: KeetaNet.UserClient; - private readonly _lp: InstanceType; - - constructor(config: TestFXServerConfig) { - // Resolve the LP from the accounts set - const lp = config.accounts?.values().next().value; - if (!lp) { - throw(new Error('TestFXServer requires at least one account in the accounts set')); - } - - const giveTokens = config.giveTokens; - const keetaClient = config.client; - - // Shared rate ref captured by the super() closure - const rateRef: { fn: RateFn } = { - fn: async (request) => { - const rate = request.affinity === 'to' ? 1 / 0.88 : 0.88; - const convertedAmount = BigInt(Math.round(Number(request.amount) * rate)); - const balance = await keetaClient.client.getBalance(lp, request.to); - if (balance < convertedAmount * 2n) { - const token = KeetaNet.lib.Account.fromPublicKeyString(request.to).assertKeyType(KeetaNet.lib.Account.AccountKeyAlgorithm.TOKEN); - await giveTokens(lp, convertedAmount * 2n, token); - } - return({ - account: lp, - convertedAmount, - cost: { amount: 0n, token: KeetaNet.lib.Account.fromPublicKeyString(request.from).assertKeyType(KeetaNet.lib.Account.AccountKeyAlgorithm.TOKEN) } - }); - } - }; - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { giveTokens: _gt, client: _userClient, quoteSigner: _qs, ...baseConfig } = config; - - // Pass the raw client config (not a UserClient) so the server uses the else-branch in - // its processor: it picks up config.signer (= LP) to build an LP-scoped UserClient itself. - const rawClient = { - client: keetaClient.client, - network: keetaClient.network, - networkAlias: keetaClient.config.networkAlias - }; - - super({ - ...baseConfig, - quoteSigner: null, - quoteConfiguration: { requiresQuote: false, validateQuoteBeforeExchange: false, issueQuotes: false }, - client: rawClient, - fx: { - ...config.fx, - getConversionRateAndFee: (request, context) => rateRef.fn(request, context) - } satisfies KeetaAnchorFXServerConfig['fx'] - }); - - this._rateRef = rateRef; - this._giveTokens = giveTokens; - this._keetaClient = keetaClient; - this._lp = lp; - } - - /** Set a fixed exchange rate (forward direction; reverse is 1/rate). */ - setRate(rate: number): this { - const lp = this._lp; - const giveTokens = this._giveTokens; - const keetaClient = this._keetaClient; - this._rateRef.fn = async (request, context) => { - const effectiveRate = request.affinity === 'to' ? 1 / rate : rate; - const convertedAmount = BigInt(Math.round(Number(request.amount) * effectiveRate)); - const balance = await keetaClient.client.getBalance(lp, request.to); - if (context.purpose === 'exchange' || context.purpose === 'quote') { - if (balance < convertedAmount * 2n) { - const token = KeetaNet.lib.Account.fromPublicKeyString(request.to).assertKeyType(KeetaNet.lib.Account.AccountKeyAlgorithm.TOKEN); - await giveTokens(lp, convertedAmount * 2n, token); - } - } - return({ - account: lp, - convertedAmount, - cost: { amount: 0n, token: KeetaNet.lib.Account.fromPublicKeyString(request.from).assertKeyType(KeetaNet.lib.Account.AccountKeyAlgorithm.TOKEN) } - }); - }; - return(this); - } - - /** Replace the full conversion handler. */ - setGetConversionRateAndFee(fn: RateFn): this { - this._rateRef.fn = fn; - return(this); - } - - /** - * Arm so the next estimate-phase call throws during createExchange. - * computeSteps() calls getEstimate BEFORE arming so it succeeds. - * execute() -> createExchange() hits getUnsignedQuoteData(purpose='estimate') - * BEFORE queuing, so the failure propagates immediately to the client. - */ - failNextExchange(message = 'FX exchange failed'): this { - const saved = this._rateRef.fn; - this._rateRef.fn = async (request, context) => { - if (context.purpose === 'estimate') { - this._rateRef.fn = saved; - throw(new KeetaAnchorUserError(message)); - } - return(await saved(request, context)); - }; - return(this); - } -} - -type PersistentForwardingBridgeAddressMeta = { - sourceLocation: AssetLocationLike; - destinationLocation: AssetLocationLike; - destinationAddress: string; - asset: KeetaAssetMovementTransaction['asset']; -}; - -type TestPersistentForwardingBridgeServerConfig = Omit & { - assetMovement: Omit< - KeetaAnchorAssetMovementServerConfig['assetMovement'], - 'initiateTransfer' | 'getTransferStatus' | 'simulateTransfer' | 'createPersistentForwarding' | 'listPersistentForwarding' | 'listTransactions' - >; - client: KeetaNet.UserClient; -}; - -/** - * Test bridge for the persistent-forwarding flow used by anchor chaining. - */ -class TestPersistentForwardingBridgeServer extends KeetaNetAssetMovementAnchorHTTPServer { - readonly bridgeAccount: GenericAccount; - readonly addresses: Map; - readonly transactionsByAddress: Map; - readonly transferStatuses: Map; - - constructor(config: TestPersistentForwardingBridgeServerConfig) { - const { client: userClient, ...serverConfig } = config; - - const bridgeAccount = KeetaNet.lib.Account.fromSeed(KeetaNet.lib.Account.generateRandomSeed(), 0); - const blockListener = new BlockListener({ client: userClient.client }); - - const addresses = new Map(); - const transactionsByAddress = new Map(); - const transferStatuses = new Map(); - - super({ - ...serverConfig, - assetMovement: { - ...serverConfig.assetMovement, - async initiateTransfer(request) { - const parsedFrom = toAssetLocation(request.from.location); - const isKeetaSource = parsedFrom.type === 'chain' && parsedFrom.chain.type === 'keeta'; - if (!isKeetaSource) { - throw(new KeetaAnchorUserError(`initiateTransfer not supported from ${convertAssetLocationToString(request.from.location)}; use createPersistentForwarding instead`)); - } - - const value = BigInt(request.value); - const txId = `tx-${Date.now()}-${Math.random().toString(36).slice(2)}`; - const recipientAddress = typeof request.to.recipient === 'string' ? request.to.recipient : ''; - - transferStatuses.set(txId, buildTxRecord({ - id: txId, - status: 'PENDING', - asset: request.asset, - fromLocation: request.from.location, - toLocation: request.to.location, - fromValue: value.toString(), - toValue: value.toString() - })); - - let handle: { remove: () => void } | null = null; - handle = blockListener.on('block', { - callback: async ({ block }) => { - for (const op of block.operations) { - if (op.type === KeetaNet.lib.Block.OperationType.SEND && op.external === txId) { - if (op.amount !== value) { - throw(new KeetaAnchorUserError(`Invalid transfer amount: expected ${value}, got ${op.amount}`)); - } - - const withdrawTxId = `withdraw-${txId}`; - const existing = transferStatuses.get(txId); - if (existing && existing.status !== 'COMPLETE') { - transferStatuses.set(txId, { - ...existing, - status: 'COMPLETE', - updatedAt: new Date().toISOString(), - to: { - ...existing.to, - transactions: { withdraw: { id: withdrawTxId, nonce: '0' }} - } - }); - } - - /* - * Mimics the bridge's EVM withdrawal landing at the - * persistent forwarding address and auto-forwarding to the - * chain destination. - */ - const meta = addresses.get(recipientAddress); - if (meta) { - const list = transactionsByAddress.get(recipientAddress) ?? []; - const forwarded = buildTxRecord({ - id: `persistentForwarding-tx-${Date.now()}-${Math.random().toString(36).slice(2)}`, - status: 'COMPLETE', - asset: meta.asset, - fromLocation: convertAssetLocationToString(meta.sourceLocation), - toLocation: convertAssetLocationToString(meta.destinationLocation), - fromValue: value.toString(), - toValue: value.toString() - }); - forwarded.from.transactions = { - ...forwarded.from.transactions, - persistentForwarding: { id: withdrawTxId, nonce: '0' } - }; - list.push(forwarded); - transactionsByAddress.set(recipientAddress, list); - } - - handle?.remove(); - return({ requiresWork: false }); - } - } - return({ requiresWork: false }); - } - }); - - const tokenAddress = toAssetPair(request.asset).from; - if (typeof tokenAddress !== 'string') { - throw(new Error('invalid keeta send asset')); - } - - return({ - id: txId, - instructionChoices: [{ - type: 'KEETA_SEND' as const, - location: request.from.location, - sendToAddress: bridgeAccount.publicKeyString.get(), - external: txId, - value: value.toString(), - tokenAddress: KeetaNet.lib.Account.fromPublicKeyString(tokenAddress) - .assertKeyType(KeetaNet.lib.Account.AccountKeyAlgorithm.TOKEN) - .publicKeyString.get(), - assetFee: '0', - totalReceiveAmount: value.toString() - }] - }); - }, - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - async getTransferStatus(id: string): Promise { - await blockListener.scan(); - const tx = transferStatuses.get(id); - if (!tx) { - throw(new Error(`Unknown transfer ID: ${id}`)); - } - - return({ transaction: tx }); - }, - - async simulateTransfer(request) { - const value = BigInt(request.value); - const tokenAddress = toAssetPair(request.asset).from; - if (typeof tokenAddress !== 'string') { - throw(new Error('invalid asset for simulate')); - } - - const parsedFrom = toAssetLocation(request.from.location); - const isKeetaSource = parsedFrom.type === 'chain' && parsedFrom.chain.type === 'keeta'; - if (isKeetaSource) { - return({ - instructionChoices: [{ - type: 'KEETA_SEND' as const, - location: request.from.location, - value: value.toString(), - tokenAddress: KeetaNet.lib.Account.fromPublicKeyString(tokenAddress) - .assertKeyType(KeetaNet.lib.Account.AccountKeyAlgorithm.TOKEN) - .publicKeyString.get(), - assetFee: '0', - totalReceiveAmount: value.toString() - }] - }); - } - - /* - * EVM-side simulation for the persistent-forwarding leg. - */ - if (!tokenAddress.startsWith('evm:0x')) { - throw(new Error(`invalid evm asset format for simulate: ${tokenAddress}`)); - } - - /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */ - const evmTokenHex = tokenAddress.slice('evm:'.length) as `0x${string}`; - return({ - instructionChoices: [{ - type: 'EVM_SEND' as const, - location: request.from.location, - value: value.toString(), - tokenAddress: evmTokenHex, - assetFee: '0', - totalReceiveAmount: value.toString() - }] - }); - }, - - async createPersistentForwarding(request) { - if (!('destinationLocation' in request) || !('destinationAddress' in request)) { - throw(new KeetaAnchorUserError('createPersistentForwarding via template is not supported in this test bridge')); - } - if (typeof request.destinationAddress !== 'string') { - throw(new KeetaAnchorUserError('Test bridge only supports string destinationAddress for persistent forwarding')); - } - - const address = `persistentForwarding-${Math.random().toString(36).slice(2)}`; - const meta: PersistentForwardingBridgeAddressMeta = { - sourceLocation: request.sourceLocation, - destinationLocation: request.destinationLocation, - destinationAddress: request.destinationAddress, - asset: request.asset - }; - - addresses.set(address, meta); - - return({ - address, - asset: meta.asset, - sourceLocation: meta.sourceLocation, - destinationLocation: meta.destinationLocation, - destinationAddress: meta.destinationAddress - }); - }, - - async listPersistentForwarding(request) { - const all: KeetaPersistentForwardingAddressDetails[] = []; - for (const [address, meta] of addresses) { - all.push({ - address, - asset: meta.asset, - sourceLocation: meta.sourceLocation, - destinationLocation: meta.destinationLocation, - destinationAddress: meta.destinationAddress - }); - } - - let filtered = all; - const searches = request.search; - if (searches && searches.length > 0) { - filtered = all.filter(addr => searches.some(search => { - if (search.destinationAddress !== undefined && addr.destinationAddress !== search.destinationAddress) { - return(false); - } - return(true); - })); - } - - return({ - addresses: filtered, - total: filtered.length.toString() - }); - }, - - async listTransactions(request) { - const transactions: KeetaAssetMovementTransaction[] = []; - for (const pf of (request.persistentAddresses ?? [])) { - if (!('persistentAddress' in pf) || !pf.persistentAddress) { - continue; - } - const found = transactionsByAddress.get(pf.persistentAddress) ?? []; - transactions.push(...found); - } - - const txFilters = request.transactions; - let filtered = transactions; - if (txFilters && txFilters.length > 0) { - const wantedIds = new Set(txFilters - .map(f => f.transaction.id) - .filter((id): id is string => typeof id === 'string')); - - filtered = transactions.filter(tx => { - const fromIds = [ - tx.from.transactions.persistentForwarding?.id, - tx.from.transactions.deposit?.id, - tx.from.transactions.finalization?.id - ]; - const toIds = [ tx.to.transactions.withdraw?.id ]; - for (const id of [ ...fromIds, ...toIds ]) { - if (id && wantedIds.has(id)) { - return(true); - } - } - return(false); - }); - } - - return({ - transactions: filtered, - total: filtered.length.toString() - }); - } - } - }); - - this.bridgeAccount = bridgeAccount; - this.addresses = addresses; - this.transactionsByAddress = transactionsByAddress; - this.transferStatuses = transferStatuses; - } -} - -test('Asset Movement Chaining Test', async function({ expect }) { - const account = KeetaNet.lib.Account.fromSeed(KeetaNet.lib.Account.generateRandomSeed(), 0); - const { userClient: client } = await createNodeAndClient(account); - - const makeTokenAssert = async () => { - const { account } = await client.generateIdentifier(KeetaNet.lib.Account.AccountKeyAlgorithm.TOKEN); - return(account.assertKeyType(KeetaNet.lib.Account.AccountKeyAlgorithm.TOKEN)); - } - - const evmChainLocation = 'chain:evm:500' satisfies AssetLocationLike; - const keetaLocation = `chain:keeta:${client.network}` satisfies AssetLocationLike; - - const tokens = { - USDC: await makeTokenAssert(), - EURC: await makeTokenAssert(), - USDT: await makeTokenAssert(), - BTC: await makeTokenAssert() - } - - await using baseAnchorAssetMovementServer = new KeetaNetAssetMovementAnchorHTTPServer({ - ...(logger ? { logger: logger } : {}), - assetMovement: { - supportedAssets: [ - { - asset: tokens.USDC.publicKeyString.get(), - paths: [ - { - pair: [ - { location: keetaLocation, id: tokens.USDC.publicKeyString.get(), rails: { common: [ { rail: 'KEETA_SEND' } ] }}, - { location: evmChainLocation, id: 'evm:0xc0634090F2Fe6c6d75e61Be2b949464aBB498973', rails: { common: [ 'EVM_SEND' ], inbound: [ 'EVM_CALL' ] }} - ] - } - ] - }, - { - asset: '$USDC', - paths: [ - { - pair: [ - { location: evmChainLocation, id: 'evm:0xc0634090F2Fe6c6d75e61Be2b949464aBB498973', rails: { common: [ 'EVM_SEND' ] }}, - { location: keetaLocation, id: tokens.USDC.publicKeyString.get(), rails: { inbound: [ 'KEETA_SEND' ] }} - ] - } - ] - } - ], - async createPersistentForwarding() { - throw(new Error('getTransferStatus not used in metadata tests')); - }, - async initiateTransfer() { - throw(new Error('getTransferStatus not used in metadata tests')); - }, - - async getTransferStatus() { - return({ - transaction: { - id: 'tx123', - status: 'pending', - asset: tokens.USDC.publicKeyString.get(), - from: { - location: evmChainLocation, - value: '500', - transactions: { - deposit: null, - persistentForwarding: null, - finalization: null - } - }, - to: { - location: keetaLocation, - value: '500', - transactions: { - withdraw: null - } - }, - fee: null, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString() - } - }) - } - } - }); - - await using bankAnchorServer = new KeetaNetAssetMovementAnchorHTTPServer({ - logger: logger, - assetMovement: { - supportedAssets: [ - { - asset: [ tokens.USDC.publicKeyString.get(), 'USD' ], - paths: [ - { - pair: [ - { location: 'bank-account:us', id: 'USD', rails: { common: [ 'ACH', 'WIRE' ] }}, - { location: keetaLocation, id: tokens.USDC.publicKeyString.get(), rails: { common: [ 'KEETA_SEND' ] }} - ] - } - ] - }, - { - asset: [ tokens.EURC.publicKeyString.get(), 'EUR' ], - paths: [ - { - pair: [ - { location: 'bank-account:iban-swift', id: 'EUR', rails: { common: [ 'SEPA_PUSH' ] }}, - { location: keetaLocation, id: tokens.EURC.publicKeyString.get(), rails: { common: [ 'KEETA_SEND' ] }} - ] - } - ] - } - ], - - async getTransferStatus() { - return({ - transaction: { - id: 'tx123', - status: 'pending', - asset: tokens.USDC.publicKeyString.get(), - from: { - location: evmChainLocation, - value: '500', - transactions: { - deposit: null, - persistentForwarding: null, - finalization: null - } - }, - to: { - location: keetaLocation, - value: '500', - transactions: { - withdraw: null - } - }, - fee: null, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString() - } - }) - }, - async createPersistentForwarding() { - throw(new Error('getTransferStatus not used in metadata tests')); - }, - async initiateTransfer() { - throw(new Error('getTransferStatus not used in metadata tests')); - } - } - }); - - const fxServerQuoteSigner = KeetaNet.lib.Account.fromSeed(KeetaNet.lib.Account.generateRandomSeed(), 0); - const fxServerLiquidityProvider = KeetaNet.lib.Account.fromSeed(KeetaNet.lib.Account.generateRandomSeed(), 0); - await using fxServer = new KeetaNetFXAnchorHTTPServer({ - logger: logger, - quoteSigner: fxServerQuoteSigner, - accounts: new KeetaNet.lib.Account.Set([ fxServerLiquidityProvider ]), - signer: fxServerLiquidityProvider, - client: { client: client.client, network: client.config.network, networkAlias: client.config.networkAlias }, - fx: { - from: [ - { - currencyCodes: [ tokens.USDC.publicKeyString.get(), tokens.USDT.publicKeyString.get(), tokens.BTC.publicKeyString.get(), tokens.EURC.publicKeyString.get() ], - to: [ tokens.USDC.publicKeyString.get(), tokens.USDT.publicKeyString.get(), tokens.BTC.publicKeyString.get(), tokens.EURC.publicKeyString.get() ] - } - ], - getConversionRateAndFee: async function(request) { - let rate = 0.88; - if (request.affinity === 'to') { - rate = 1 / rate; - } - return({ - account: fxServerLiquidityProvider, - convertedAmount: BigInt(request.amount) * BigInt(Math.round(rate * 1000)) / 1000n, - cost: { - amount: 0n, - token: KeetaNet.lib.Account.fromPublicKeyString(request.from).assertKeyType(KeetaNet.lib.Account.AccountKeyAlgorithm.TOKEN) - } - }); - } - } - }); - - await fxServer.start(); - await baseAnchorAssetMovementServer.start(); - await bankAnchorServer.start(); - - await client.setInfo({ - description: 'FX Anchor Test Root', - name: 'TEST', - metadata: Resolver.Metadata.formatMetadata({ - version: 1, - currencyMap: Object.fromEntries(Object.entries(tokens).map(function([ symbol, token ]) { - return([ `$${symbol}`, token.publicKeyString.get() ]); - })), - services: { - fx: { - FXOne: await fxServer.serviceMetadata() - }, - assetMovement: { - BaseAnchor: await baseAnchorAssetMovementServer.serviceMetadata(), - BankAnchor: await bankAnchorServer.serviceMetadata() - } - } - } satisfies ServiceMetadataExternalizable) - }); - - const anchorChaining = new AnchorChaining({ - client: client, - resolver: new Resolver({ - root: client.account, - client: client, - trustedCAs: [] - }) - }); - - const paths = await anchorChaining.getPaths({ - source: { - asset: 'USD', - location: 'bank-account:us', - value: 100n, - rail: 'ACH' - }, - destination: { - asset: 'EUR', - location: 'bank-account:iban-swift', - recipient: client.account.publicKeyString.get(), - rail: 'SEPA_PUSH' - } - }); - - const path = paths?.[0]; - if (!paths || !path) { - throw(new Error(`No paths found`)); - } - - expect(paths.length).toEqual(1); - - expect(path.path.length).toEqual(3); - - const BANK_ANCHOR_SUPPORTED_OPS = { createPersistentForwarding: true, initiateTransfer: true } as const; - expect(toJSONSerializable([ - { - providerID: 'BankAnchor', - type: 'assetMovement', - from: { asset: 'USD', location: 'bank-account:us', rail: 'ACH', supportedOperations: BANK_ANCHOR_SUPPORTED_OPS }, - to: { asset: tokens.USDC, location: keetaLocation, rail: 'KEETA_SEND', supportedOperations: BANK_ANCHOR_SUPPORTED_OPS } - }, - { - from: { asset: tokens.USDC, location: keetaLocation, rail: 'KEETA_SEND' }, - providerID: 'FXOne', - type: 'fx', - to: { asset: tokens.EURC, location: keetaLocation, rail: 'KEETA_SEND' } - }, - { - providerID: 'BankAnchor', - type: 'assetMovement', - from: { asset: tokens.EURC, location: keetaLocation, rail: 'KEETA_SEND', supportedOperations: BANK_ANCHOR_SUPPORTED_OPS }, - to: { asset: 'EUR', location: 'bank-account:iban-swift', rail: 'SEPA_PUSH', supportedOperations: BANK_ANCHOR_SUPPORTED_OPS } - } - ])).toEqual(toJSONSerializable(path.path)); -}); - -async function createChainingTestHarness(options: { includeSwapAnchor?: boolean } = {}) { - const account = KeetaNet.lib.Account.fromSeed(KeetaNet.lib.Account.generateRandomSeed(), 0); - const { userClient: client, fees } = await createNodeAndClient(account); - - const makeToken = async () => { - const { account } = await client.generateIdentifier(KeetaNet.lib.Account.AccountKeyAlgorithm.TOKEN); - await client.setInfo( - { name: '', description: '', metadata: '', defaultPermission: new KeetaNet.lib.Permissions(['ACCESS']) }, - { account } - ); - return(account.assertKeyType(KeetaNet.lib.Account.AccountKeyAlgorithm.TOKEN)); - }; - - const giveTokens = async (to: GenericAccount, amount: bigint, token: TokenAddress) => { - await client.modTokenSupplyAndBalance(amount, token, { account: to }); - }; - - const keetaLocation = `chain:keeta:${client.network}` satisfies AssetLocationLike; - const tokens = { USDC: await makeToken(), EURC: await makeToken() }; - - const fxLPOne = KeetaNet.lib.Account.fromSeed(KeetaNet.lib.Account.generateRandomSeed(), 0); - const fxLPTwo = KeetaNet.lib.Account.fromSeed(KeetaNet.lib.Account.generateRandomSeed(), 0); - - const [usBankProviderID, euBankProviderID] = ['BankUS', 'BankEU'] as const; - const bankProviderDisclaimers: { - [bankProviderID in typeof usBankProviderID | typeof euBankProviderID]: Exclude - } = { - [usBankProviderID]: [ - { - purpose: 'general', - content: { - type: 'plaintext', - content: 'This is a legal disclaimer for the US bank server' - } - } - ], - [euBankProviderID]: [ - { - purpose: 'general', - content: { - type: 'plaintext', - content: 'This is a legal disclaimer for the EU bank server' - } - }, - { - purpose: 'general', - content: { - type: 'markdown', - content: 'This is another legal disclaimer for the EU bank server' - } - } - ] - }; - - /* - * Bank entries are signed so providers resolve with a service-entry - * account, which client-side external construction files entries under. - */ - const bankSignerUS = KeetaNet.lib.Account.fromSeed(KeetaNet.lib.Account.generateRandomSeed(), 0); - const bankSignerEU = KeetaNet.lib.Account.fromSeed(KeetaNet.lib.Account.generateRandomSeed(), 0); - const swapSigner = KeetaNet.lib.Account.fromSeed(KeetaNet.lib.Account.generateRandomSeed(), 0); - - const bankServerUS = new TestBankServer({ - ...(DEBUG ? { logger } : {}), - client, - metadataSigner: bankSignerUS, - assetMovement: { - legal: { - disclaimers: bankProviderDisclaimers['BankUS'] - }, - supportedAssets: [{ - asset: [ tokens.USDC.publicKeyString.get(), 'USD' ], - paths: [{ pair: [ - { location: 'bank-account:us', id: 'USD', rails: { common: [ 'ACH' ] }}, - { location: keetaLocation, id: tokens.USDC.publicKeyString.get(), rails: { common: [ 'KEETA_SEND' ] }} - ] }] - }] - } - }); - - const bankServerEU = new TestBankServer({ - ...(DEBUG ? { logger } : {}), - client, - metadataSigner: bankSignerEU, - assetMovement: { - legal: { - disclaimers: bankProviderDisclaimers['BankEU'] - }, - supportedAssets: [{ - asset: [ tokens.EURC.publicKeyString.get(), 'EUR' ], - paths: [{ pair: [ - { location: 'bank-account:iban-swift', id: 'EUR', rails: { common: [ 'SEPA_PUSH' ] }}, - { location: keetaLocation, id: tokens.EURC.publicKeyString.get(), rails: { common: [ 'KEETA_SEND' ] }} - ] }] - }] - } - }); - - /* - * Keeta-to-Keeta token swap anchor (USDC -> EURC). Both rails are - * KEETA_SEND, so chaining it before a bank withdrawal produces two - * user-funded sends in one execution. - */ - const swapServer = new TestBankServer({ - ...(DEBUG ? { logger } : {}), - client, - metadataSigner: swapSigner, - assetMovement: { - supportedAssets: [{ - asset: [ tokens.USDC.publicKeyString.get(), tokens.EURC.publicKeyString.get() ], - paths: [{ pair: [ - { location: keetaLocation, id: tokens.USDC.publicKeyString.get(), rails: { common: [ 'KEETA_SEND' ] }}, - { location: keetaLocation, id: tokens.EURC.publicKeyString.get(), rails: { common: [ 'KEETA_SEND' ] }} - ] }] - }] - } - }); - - const [fxOneProviderID, fxTwoProviderID] = ['FXOne', 'FXTwo'] as const; - const fxProviderDisclaimers: { - [fxProviderID in typeof fxOneProviderID | typeof fxTwoProviderID]: Exclude - } = { - [fxOneProviderID]: [ - { - purpose: 'general', - content: { type: 'plaintext', content: 'This is a legal disclaimer for FX provider One' } - } - ], - [fxTwoProviderID]: [ - { - purpose: 'general', - content: { type: 'plaintext', content: 'This is a legal disclaimer for FX provider Two' } - }, - { - purpose: 'general', - content: { type: 'markdown', content: 'This is another legal disclaimer for FX provider Two' } - } - ] - }; - // fxServerOne: 0.88 rate (primary) - const fxServerOne = new TestFXServer({ - ...(DEBUG ? { logger } : {}), - quoteSigner: KeetaNet.lib.Account.fromSeed(KeetaNet.lib.Account.generateRandomSeed(), 0), - accounts: new KeetaNet.lib.Account.Set([ fxLPOne ]), - signer: fxLPOne, - client, - giveTokens, - fx: { - legal: { disclaimers: fxProviderDisclaimers[fxOneProviderID] }, - from: [{ currencyCodes: [ tokens.USDC.publicKeyString.get(), tokens.EURC.publicKeyString.get() ], to: [ tokens.USDC.publicKeyString.get(), tokens.EURC.publicKeyString.get() ] }] - } - }); - - // fxServerTwo: 0.85 rate (alternative, slightly worse) - const fxServerTwo = new TestFXServer({ - ...(DEBUG ? { logger } : {}), - quoteSigner: KeetaNet.lib.Account.fromSeed(KeetaNet.lib.Account.generateRandomSeed(), 0), - accounts: new KeetaNet.lib.Account.Set([ fxLPTwo ]), - signer: fxLPTwo, - client, - giveTokens, - fx: { - legal: { disclaimers: fxProviderDisclaimers[fxTwoProviderID] }, - from: [{ currencyCodes: [ tokens.USDC.publicKeyString.get(), tokens.EURC.publicKeyString.get() ], to: [ tokens.USDC.publicKeyString.get(), tokens.EURC.publicKeyString.get() ] }] - } - }).setRate(0.85); - - await bankServerUS.start(); - await bankServerEU.start(); - await swapServer.start(); - await fxServerOne.start(); - await fxServerTwo.start(); - - // Make FX LPs fee-free so they don't need KTA to execute exchanges - fees.addFeeFreeAccount(fxLPOne); - fees.addFeeFreeAccount(fxLPTwo); - - /* - * The swap anchor is opt-in: its keeta-to-keeta pair adds round-trip - * paths that would change path counts in unrelated tests. - */ - const assetMovementServices: { [providerID: string]: Awaited> } = { - [usBankProviderID]: await bankServerUS.serviceMetadata(), - [euBankProviderID]: await bankServerEU.serviceMetadata() - }; - if (options.includeSwapAnchor === true) { - assetMovementServices['SwapKeeta'] = await swapServer.serviceMetadata(); - } - - await client.setInfo({ - description: 'Chaining Test', - name: 'TEST', - metadata: Resolver.Metadata.formatMetadata({ - version: 1, - currencyMap: { '$USDC': tokens.USDC.publicKeyString.get(), '$EURC': tokens.EURC.publicKeyString.get() }, - services: { - fx: { - FXOne: await fxServerOne.serviceMetadata(), - FXTwo: await fxServerTwo.serviceMetadata() - }, - assetMovement: assetMovementServices - } - } satisfies ServiceMetadataExternalizable) - }); - - const anchorChaining = new AnchorChaining({ - client, - resolver: new Resolver({ root: client.account, client, trustedCAs: [] }) - }); - - const getPathVia = async (fxProviderID: 'FXOne' | 'FXTwo', affinity: 'to' | 'from' = 'from') => { - const paths = await anchorChaining.getPaths({ - source: { asset: tokens.USDC, location: keetaLocation, rail: 'KEETA_SEND', ...(affinity === 'from' ? { value: 100n } : {}) }, - destination: { asset: 'EUR', location: 'bank-account:iban-swift', recipient: client.account.publicKeyString.get(), rail: 'SEPA_PUSH', ...(affinity === 'to' ? { value: 100n } : {}) } - }); - const path = paths?.find(p => p.path.some(n => n.type === 'fx' && n.providerID === fxProviderID)); - if (!path) { - throw(new Error(`No path found using ${fxProviderID}`)); - } - return(path); - }; - - const getPlanVia = async (fxProviderID: 'FXOne' | 'FXTwo', options?: ComputePlanOptions) => { - const plans = await anchorChaining.getPlans({ - source: { asset: tokens.USDC, location: keetaLocation, value: 100n, rail: 'KEETA_SEND' }, - destination: { asset: 'EUR', location: 'bank-account:iban-swift', recipient: client.account.publicKeyString.get(), rail: 'SEPA_PUSH' } - }, options); - - const path = plans?.find(p => p.plan.steps.some(n => n.type === 'fx' && n.step.providerID === fxProviderID)); - - if (!path) { - throw(new Error(`No path found using ${fxProviderID}`)); - } - - return(path); - }; - - return({ - client, - fees, - tokens, - keetaLocation, - bankServerUS, - bankServerEU, - swapServer, - bankSignerUS, - bankSignerEU, - swapSigner, - fxServerOne, - fxServerTwo, - anchorChaining, - bankProviderDisclaimers, - euBankProviderID, - usBankProviderID, - fxProviderDisclaimers, - fxOneProviderID, - fxTwoProviderID, - giveTokens, - getPlanVia, - getPathVia, - [Symbol.asyncDispose]: async function() { - await bankServerUS[Symbol.asyncDispose]?.(); - await bankServerEU[Symbol.asyncDispose]?.(); - await swapServer[Symbol.asyncDispose]?.(); - await fxServerOne[Symbol.asyncDispose]?.(); - await fxServerTwo[Symbol.asyncDispose]?.(); - } - }); -} - -describe('AnchorChainingPath computeSteps', function() { - test.each([ - { providerID: 'FXOne' as const, expectedFxOut: 88n, totalOut: 78n }, - { providerID: 'FXTwo' as const, expectedFxOut: 85n, totalOut: 75n } - ])('FX rate comparison: $providerID', async function({ providerID, expectedFxOut, totalOut }) { - await using h = await createChainingTestHarness(); - const path = await h.getPlanVia(providerID); - - expect(path.plan.steps.length).toEqual(2); - expect(path.plan.totalValueIn).toEqual(100n); - expect(path.plan.totalValueOut).toEqual(totalOut); - - const fxStep = path.plan.steps.find(s => s.type === 'fx'); - if (fxStep?.type === 'fx') {expect(fxStep.valueOut).toEqual(expectedFxOut);} - - for (let i = 0; i < path.plan.steps.length - 1; i++) { - const valueOut = path.plan.steps[i]?.valueOut; - const valueIn = path.plan.steps[i + 1]?.valueIn; - if (!valueIn || !valueOut) { - throw(new Error(`Missing valueIn or valueOut for step ${i}`)); - } - expect(valueOut).toEqual(valueIn); - } - }); - - test('affinity:to is unsupported for paths with AM steps', async function() { - await using h = await createChainingTestHarness(); - const path = await h.getPathVia('FXOne', 'to'); - await expect(AnchorChainingPlan.create(path)).rejects.toThrow('not currently supported for asset movement steps'); - }); - - test('BankEU initiateTransfer failure propagates from computeSteps', async function() { - await using h = await createChainingTestHarness(); - h.bankServerEU.failNextInitiate('Bank EU initiate failed'); - const path = await h.getPathVia('FXOne'); - await expect(AnchorChainingPlan.create(path)).rejects.toThrow('Bank EU initiate failed'); - }); -}); - -describe('AnchorChainingPath computeSteps for fx with "to" affinity', function() { - test('destination.value on FX-only path computes correct values', async function() { - await using h = await createChainingTestHarness(); - - const plans = await h.anchorChaining.getPlans({ - source: { asset: h.tokens.USDC, location: h.keetaLocation, rail: 'KEETA_SEND' }, - destination: { asset: h.tokens.EURC, location: h.keetaLocation, recipient: h.client.account.publicKeyString.get(), rail: 'KEETA_SEND', value: 100n } - }); - - const plan = plans?.find(p => p.path.length === 1 && p.path[0]?.type === 'fx' && p.path[0]?.providerID === 'FXOne'); - if (!plan) { throw(new Error('No single-step FX path found')); } - - const result = plan.plan - - expect(result.steps.length).toEqual(1); - expect(result.totalValueOut).toEqual(100n); - expect(result.totalValueIn).toEqual(114n); - }); - - test.each([ - { providerID: 'FXOne' as const, expectedValueIn: 114n }, - { providerID: 'FXTwo' as const, expectedValueIn: 118n } - ])('destination.value FX-only path via $providerID', async function({ providerID, expectedValueIn }) { - await using h = await createChainingTestHarness(); - - const plans = await h.anchorChaining.getPlans({ - source: { asset: h.tokens.USDC, location: h.keetaLocation, rail: 'KEETA_SEND' }, - destination: { asset: h.tokens.EURC, location: h.keetaLocation, recipient: h.client.account.publicKeyString.get(), rail: 'KEETA_SEND', value: 100n } - }); - - const plan = plans?.find(p => p.path.some(n => n.type === 'fx' && n.providerID === providerID)); - if (!plan) { throw(new Error(`No FX path found for ${providerID}`)); } - - const result = plan.plan - - expect(result.totalValueOut).toEqual(100n); - expect(result.totalValueIn).toEqual(expectedValueIn); - }); - - test('destination.value FX-only path with different amount chains backward correctly', async function() { - await using h = await createChainingTestHarness(); - - const plans = await h.anchorChaining.getPlans({ - source: { asset: h.tokens.USDC, location: h.keetaLocation, rail: 'KEETA_SEND' }, - destination: { asset: h.tokens.EURC, location: h.keetaLocation, recipient: h.client.account.publicKeyString.get(), rail: 'KEETA_SEND', value: 50n } - }); - - const plan = plans?.find(p => p.path.length === 1 && p.path[0]?.providerID === 'FXOne'); - if (!plan) { throw(new Error('No FX path found')); } - - const result = plan.plan - - expect(result.totalValueIn).toEqual(57n); - expect(result.totalValueOut).toEqual(50n); - - for (let i = 0; i < result.steps.length - 1; i++) { - expect(result.steps[i]?.valueOut).toEqual(result.steps[i + 1]?.valueIn); - } - }); -}) - -describe('AnchorChainingPath execute with destination.value (to affinity)', function() { - test('FX-only path: executes successfully with correct amounts', async function() { - await using h = await createChainingTestHarness(); - await h.giveTokens(h.client.account, 1000n, h.tokens.USDC); - - const plans = await h.anchorChaining.getPlans({ - source: { asset: h.tokens.USDC, location: h.keetaLocation, rail: 'KEETA_SEND' }, - destination: { asset: h.tokens.EURC, location: h.keetaLocation, recipient: h.client.account.publicKeyString.get(), rail: 'KEETA_SEND', value: 100n } - }); - - const path = plans?.find(p => p.path.length === 1 && p.path[0]?.providerID === 'FXOne'); - if (!path) { throw(new Error('No FX path found')); } - - const computed = path.plan - expect(computed.totalValueIn).toEqual(114n); - expect(computed.totalValueOut).toEqual(100n); - - const result = await path.execute(); - - expect(result.steps.length).toEqual(1); - expect(result.steps[0]?.type).toEqual('fx'); - if (result.steps[0]?.type === 'fx') { - const exchangeStatus = await result.steps[0].exchange.getExchangeStatus(); - expect(exchangeStatus.status).toEqual('completed'); - } - expect(path.state.status).toEqual('completed'); - }); - - test.each([ - { providerID: 'FXOne' as const, expectedValueIn: 114n }, - { providerID: 'FXTwo' as const, expectedValueIn: 118n } - ])('FX-only path via $providerID: state transitions and events', async function({ providerID, expectedValueIn }) { - await using h = await createChainingTestHarness(); - await h.giveTokens(h.client.account, 1000n, h.tokens.USDC); - - const plans = await h.anchorChaining.getPlans({ - source: { asset: h.tokens.USDC, location: h.keetaLocation, rail: 'KEETA_SEND' }, - destination: { asset: h.tokens.EURC, location: h.keetaLocation, recipient: h.client.account.publicKeyString.get(), rail: 'KEETA_SEND', value: 100n } - }); - - const path = plans?.find(p => p.path.some(n => n.type === 'fx' && n.providerID === providerID)); - if (!path) { throw(new Error(`No FX path found for ${providerID}`)); } - - const stateHistory: AnchorChainingPathState['status'][] = []; - path.on('stateChange', (state: AnchorChainingPathState) => stateHistory.push(state.status)); - - const emittedSteps: { step: ExecutedStep; index: number }[] = []; - path.on('stepExecuted', (step: ExecutedStep, index: number) => emittedSteps.push({ step, index })); - - let completedResult: Awaited> | null = null; - path.on('completed', (result: Awaited>) => { completedResult = result; }); - - const computed = path.plan - expect(computed.totalValueIn).toEqual(expectedValueIn); - expect(computed.totalValueOut).toEqual(100n); - - const result = await path.execute(); - - expect(result.steps.length).toEqual(1); - expect(path.state.status).toEqual('completed'); - expect(stateHistory[0]).toEqual('executing'); - expect(stateHistory[stateHistory.length - 1]).toEqual('completed'); - expect(emittedSteps.length).toEqual(1); - expect(emittedSteps[0]?.step).toBe(result.steps[0]); - expect(completedResult).toBe(result); - }); - - test('FX-only path: exchange failure emits failed event', async function() { - await using h = await createChainingTestHarness(); - await h.giveTokens(h.client.account, 1000n, h.tokens.USDC); - - const plans = await h.anchorChaining.getPlans({ - source: { asset: h.tokens.USDC, location: h.keetaLocation, rail: 'KEETA_SEND' }, - destination: { asset: h.tokens.EURC, location: h.keetaLocation, recipient: h.client.account.publicKeyString.get(), rail: 'KEETA_SEND', value: 100n } - }); - - const plan = plans?.find(p => p.path.length === 1 && p.path[0]?.providerID === 'FXOne'); - if (!plan) { throw(new Error('No FX path found')); } - - const computed = plan.plan - expect(computed.totalValueIn).toEqual(114n); - expect(computed.totalValueOut).toEqual(100n); - - h.fxServerOne.failNextExchange('FX to-affinity exchange failed'); - - const failedEvents: { error: Error; completedSteps: ExecutedStep[]; index: number }[] = []; - plan.on('failed', (error: Error, completedSteps: ExecutedStep[], failedAtStepIndex: number) => { - failedEvents.push({ error, completedSteps, index: failedAtStepIndex }); - }); - - await expect(plan.execute()).rejects.toThrow('FX to-affinity exchange failed'); - expect(plan.state.status).toEqual('failed'); - if (plan.state.status === 'failed') { - expect(plan.state.failedAtStepIndex).toEqual(0); - expect(plan.state.completedSteps.length).toEqual(0); - } - expect(failedEvents).toHaveLength(1); - const failedEvent = failedEvents[0]; - if (!failedEvent) { throw(new Error('Expected failed event')); } - expect(failedEvent.index).toEqual(0); - expect(failedEvent.completedSteps.length).toEqual(0); - }); - - test('FX-only path: re-executing after completion throws', async function() { - await using h = await createChainingTestHarness(); - await h.giveTokens(h.client.account, 1000n, h.tokens.USDC); - - const plans = await h.anchorChaining.getPlans({ - source: { asset: h.tokens.USDC, location: h.keetaLocation, rail: 'KEETA_SEND' }, - destination: { asset: h.tokens.EURC, location: h.keetaLocation, recipient: h.client.account.publicKeyString.get(), rail: 'KEETA_SEND', value: 100n } - }); - - const plan = plans?.find(p => p.path.length === 1 && p.path[0]?.providerID === 'FXOne'); - if (!plan) { throw(new Error('No FX path found')); } - - await plan.execute(); - - await expect(plan.execute()).rejects.toThrow('Cannot execute'); - }); - - test('providing both source.value and destination.value throws', async function() { - await using h = await createChainingTestHarness(); - const path = await h.getPathVia('FXOne'); - - path.request.source.value = 100n; - path.request.destination.value = 100n; - - await expect(AnchorChainingPlan.create(path)).rejects.toThrow('Must have source.value or destination.value but not both'); - }); - - test('providing neither source.value nor destination.value throws', async function() { - await using h = await createChainingTestHarness(); - const path = await h.getPathVia('FXOne'); - - delete path.request.source.value; - delete path.request.destination.value; - - await expect(AnchorChainingPlan.create(path)).rejects.toThrow('Must have source.value or destination.value'); - }); -}); - -describe('AnchorChainingPath execute', function() { - test('success: step structure, events, state transitions, and guard rails', async function() { - await using h = await createChainingTestHarness(); - await h.giveTokens(h.client.account, 1000n, h.tokens.USDC); - const path = await h.getPlanVia('FXOne'); - expect(path.state.status).toEqual('idle'); - - const stateHistory: AnchorChainingPathState['status'][] = []; - path.on('stateChange', (state: AnchorChainingPathState) => stateHistory.push(state.status)); - - const emittedSteps: { step: ExecutedStep; index: number }[] = []; - path.on('stepExecuted', (step: ExecutedStep, index: number) => emittedSteps.push({ step, index })); - - let completedResult: Awaited> | null = null; - path.on('completed', (result: Awaited>) => { completedResult = result; }); - - // Register then immediately remove a listener to verify off() is effective - let removedListenerCallCount = 0; - const removedListener = () => { removedListenerCallCount++; }; - path.on('stepExecuted', removedListener); - path.off('stepExecuted', removedListener); - - const result = await path.execute(); - - // Step structure and server-side verification - expect(result.steps.length).toEqual(2); - const [step0, step1] = result.steps; - expect(step0?.type).toEqual('fx'); - if (step0?.type === 'fx') { - expect(step0.exchange.exchange.exchangeID).toBeTruthy(); - const exchangeStatus = await step0.exchange.getExchangeStatus(); - expect(exchangeStatus.status).toEqual('completed'); - if (exchangeStatus.status === 'completed') { - expect(exchangeStatus.blockhash).toBeTruthy(); - } - } - // 88 EURC from FX - 10 fee = 78 EUR output - expect(step1?.type).toEqual('assetMovement'); - if (step1?.type === 'assetMovement') { - expect(step1.plan.transfer.transferID).toBeTruthy(); - expect(step1.plan.usingInstruction.type).toEqual('KEETA_SEND'); - const transferStatus = await step1.plan.transfer.getTransferStatus(); - expect(transferStatus.transaction.status).toEqual('COMPLETE'); - expect(transferStatus.transaction.to.value).toEqual('78'); - } - - // State transitions: idle -> executing -> completed - expect(path.state.status).toEqual('completed'); - expect(stateHistory[0]).toEqual('executing'); - expect(stateHistory[stateHistory.length - 1]).toEqual('completed'); - if (path.state.status === 'completed') { - expect(path.state.result).toBe(result); - } - - // stepExecuted fired once per step, each with the correct step reference - expect(emittedSteps.length).toEqual(result.steps.length); - emittedSteps.forEach(({ step, index }) => expect(step).toBe(result.steps[index])); - - // completed event carries the result object - expect(completedResult).toBe(result); - - // Removed listener was never called - expect(removedListenerCallCount).toEqual(0); - - // Re-executing a completed path throws - await expect(path.execute()).rejects.toThrow('Cannot execute'); - }); - - test('success: step structure, events, state transitions, and guard rails for storage accounts', async function() { - await using h = await createChainingTestHarness(); - - const { account: storageAccount } = await h.client.generateIdentifier(KeetaNet.lib.Account.AccountKeyAlgorithm.STORAGE); - - await h.client.setInfo({ - name: '', - description: 'Storage account with permissions from user account', - metadata: '', - defaultPermission: new KeetaNet.lib.Permissions(['STORAGE_CAN_HOLD', 'STORAGE_DEPOSIT']) - }, { account: storageAccount }); - - await h.giveTokens(h.client.account, 2000n, h.tokens.USDC); - await h.client.send(storageAccount, 1000n, h.tokens.USDC); - await h.client.send(storageAccount, 10n, h.client.baseToken); - - const userSendTokenBalancePre = await h.client.balance(h.tokens.USDC); - const storageSendTokenBalancePre = await h.client.balance(h.tokens.USDC, { account: storageAccount }); - const userReceiveTokenBalancePre = await h.client.balance(h.tokens.EURC); - - const path = await h.getPlanVia('FXOne', { overrides: { account: storageAccount }}); - - expect(path.state.status).toEqual('idle'); - - const stateHistory: AnchorChainingPathState['status'][] = []; - path.on('stateChange', (state: AnchorChainingPathState) => stateHistory.push(state.status)); - - const emittedSteps: { step: ExecutedStep; index: number }[] = []; - path.on('stepExecuted', (step: ExecutedStep, index: number) => emittedSteps.push({ step, index })); - - let completedResult: Awaited> | null = null; - path.on('completed', (result: Awaited>) => { completedResult = result; }); - - // Register then immediately remove a listener to verify off() is effective - let removedListenerCallCount = 0; - const removedListener = () => { removedListenerCallCount++; }; - path.on('stepExecuted', removedListener); - path.off('stepExecuted', removedListener); - - // Plan totals from FX rate (0.88 forward) - expect(path.plan.totalValueIn).toEqual(100n); - expect(path.plan.totalValueOut).toEqual(78n); - - const result = await path.execute(); - - // Step structure and server-side verification - expect(result.steps.length).toEqual(2); - const [step0, step1] = result.steps; - expect(step0?.type).toEqual('fx'); - if (step0?.type === 'fx') { - expect(step0.exchange.exchange.exchangeID).toBeTruthy(); - const exchangeStatus = await step0.exchange.getExchangeStatus(); - expect(exchangeStatus.status).toEqual('completed'); - if (exchangeStatus.status === 'completed') { - expect(exchangeStatus.blockhash).toBeTruthy(); - } - } - - expect(step1?.type).toEqual('assetMovement'); - if (step1?.type === 'assetMovement') { - expect(step1.plan.transfer.transferID).toBeTruthy(); - expect(step1.plan.usingInstruction.type).toEqual('KEETA_SEND'); - const transferStatus = await step1.plan.transfer.getTransferStatus(); - expect(transferStatus.transaction.status).toEqual('COMPLETE'); - expect(transferStatus.transaction.to.value).toEqual('78'); - } - // State transitions: idle -> executing -> completed - expect(path.state.status).toEqual('completed'); - expect(stateHistory[0]).toEqual('executing'); - expect(stateHistory[stateHistory.length - 1]).toEqual('completed'); - if (path.state.status === 'completed') { - expect(path.state.result).toBe(result); - } - - const userSendTokenBalancePost = await h.client.balance(h.tokens.USDC); - const storageSendTokenBalancePost = await h.client.balance(h.tokens.USDC, { account: storageAccount }); - const userReceiveTokenBalancePost = await h.client.balance(h.tokens.EURC); - - expect(storageSendTokenBalancePre - storageSendTokenBalancePost).toEqual(100n); - expect(userSendTokenBalancePre).toEqual(userSendTokenBalancePost); - expect(userReceiveTokenBalancePre).toEqual(userReceiveTokenBalancePost); - - // stepExecuted fired once per step, each with the correct step reference - expect(emittedSteps.length).toEqual(result.steps.length); - emittedSteps.forEach(({ step, index }) => expect(step).toBe(result.steps[index])); - - // completed event carries the result object - expect(completedResult).toBe(result); - - // Removed listener was never called - expect(removedListenerCallCount).toEqual(0); - - // Re-executing a completed path throws - await expect(path.execute()).rejects.toThrow('Cannot execute'); - }); - - test('FX step failure: failed event, state, and double-execute guard', async function() { - await using h = await createChainingTestHarness(); - await h.giveTokens(h.client.account, 1000n, h.tokens.USDC); - const path = await h.getPlanVia('FXOne'); - - h.fxServerOne.failNextExchange('FX step 0 failed'); - - const failedEvents: { error: Error; completedSteps: ExecutedStep[]; index: number }[] = []; - path.on('failed', (error: Error, completedSteps: ExecutedStep[], failedAtStepIndex: number) => { - failedEvents.push({ error, completedSteps, index: failedAtStepIndex }); - }); - - await expect(path.execute()).rejects.toThrow('FX step 0 failed'); - expect(path.state.status).toEqual('failed'); - if (path.state.status === 'failed') { - expect(path.state.failedAtStepIndex).toEqual(0); - expect(path.state.completedSteps.length).toEqual(0); - } - expect(failedEvents).toHaveLength(1); - const failedEvent = failedEvents[0]; - if (!failedEvent) {throw(new Error('Expected failed event'));} - expect(failedEvent.index).toEqual(0); - expect(failedEvent.completedSteps.length).toEqual(0); - - // Re-executing a failed path throws - await expect(path.execute()).rejects.toThrow('Cannot execute'); - }); - - test('AM step failure: failed event carries the completed FX step', async function() { - await using h = await createChainingTestHarness(); - await h.giveTokens(h.client.account, 1000n, h.tokens.USDC); - const path = await h.getPlanVia('FXOne'); - - // Arm failure after computeSteps so initiation succeeds but status polling throws. - h.bankServerEU.failNextTransferStatus('AM step 1 poll failed'); - - const emittedSteps: ExecutedStep[] = []; - path.on('stepExecuted', (step: ExecutedStep) => emittedSteps.push(step)); - - const failedEvents: { error: Error; completedSteps: ExecutedStep[]; index: number }[] = []; - path.on('failed', (error: Error, completedSteps: ExecutedStep[], failedAtStepIndex: number) => { - failedEvents.push({ error, completedSteps, index: failedAtStepIndex }); - }); - - await expect(path.execute()).rejects.toThrow('AM step 1 poll failed'); - expect(path.state.status).toEqual('failed'); - if (path.state.status === 'failed') { - expect(path.state.failedAtStepIndex).toEqual(1); - expect(path.state.completedSteps.length).toEqual(1); - expect(path.state.completedSteps[0]?.type).toEqual('fx'); - } - // stepExecuted fired for FX step only - expect(emittedSteps.length).toEqual(1); - expect(emittedSteps[0]?.type).toEqual('fx'); - // failed event carries the same completed steps - expect(failedEvents).toHaveLength(1); - const failedEvent = failedEvents[0]; - if (!failedEvent) {throw(new Error('Expected failed event'));} - expect(failedEvent.index).toEqual(1); - expect(failedEvent.completedSteps.length).toEqual(1); - expect(failedEvent.completedSteps[0]?.type).toEqual('fx'); - }); - - test('AM -> FX -> AM chain: each keeta-side hop holds tokens at the user address', async function() { - await using h = await createChainingTestHarness(); - - const userAddress = h.client.account.publicKeyString.get(); - const capturedUSRecipients: (string | undefined)[] = []; - const capturedEURecipients: (string | undefined)[] = []; - - h.bankServerUS.wrapInitiateTransfer(async (request, next) => { - capturedUSRecipients.push(typeof request.to.recipient === 'string' ? request.to.recipient : undefined); - return(await next(request)); - }); - h.bankServerEU.wrapInitiateTransfer(async (request, next) => { - capturedEURecipients.push(typeof request.to.recipient === 'string' ? request.to.recipient : undefined); - return(await next(request)); - }); - - const plans = await h.anchorChaining.getPlans({ - source: { asset: 'USD', location: 'bank-account:us', value: 100n, rail: 'ACH' }, - destination: { asset: 'EUR', location: 'bank-account:iban-swift', recipient: userAddress, rail: 'SEPA_PUSH' } - }); - - const path = plans?.find(p => - p.plan.steps.length === 3 && p.plan.steps.some(s => s.type === 'fx' && s.step.providerID === 'FXOne') - ); - if (!path) { throw(new Error('Expected 3-step path via FXOne')); } - - expect(path.plan.steps).toHaveLength(3); - expect(path.plan.steps[0]?.type).toBe('assetMovement'); - expect(path.plan.steps[1]?.type).toBe('fx'); - expect(path.plan.steps[2]?.type).toBe('assetMovement'); - - expect(await h.client.balance(h.tokens.USDC)).toBe(0n); - expect(await h.client.balance(h.tokens.EURC)).toBe(0n); - - let afterStep0Balance: { usdc: bigint; eurc: bigint } | null = null; - let afterStep1Balance: { usdc: bigint; eurc: bigint } | null = null; - - path.on('stepNeedsAction', async (payload) => { - if (payload.type === 'assetMovementUserExecutionRequired') { - await h.giveTokens(h.client.account, 90n, h.tokens.USDC); - afterStep0Balance = { - usdc: await h.client.balance(h.tokens.USDC), - eurc: await h.client.balance(h.tokens.EURC) - }; - payload.markCompleted(); - } else if (payload.type === 'keetaSendAuthRequired') { - afterStep1Balance = { - usdc: await h.client.balance(h.tokens.USDC), - eurc: await h.client.balance(h.tokens.EURC) - }; - payload.markCompleted({ sent: true }); - } - }); - - const result = await path.execute({ requireSendAuth: true }); - - expect(result.steps).toHaveLength(3); - expect(path.state.status).toBe('completed'); - - expect(capturedUSRecipients.length).toBeGreaterThan(0); - capturedUSRecipients.forEach(r => expect(r).toBe(userAddress)); - expect(afterStep0Balance).toEqual({ usdc: 90n, eurc: 0n }); - - expect(afterStep1Balance).toEqual({ usdc: 0n, eurc: 79n }); - - expect(await h.client.balance(h.tokens.USDC)).toBe(0n); - expect(await h.client.balance(h.tokens.EURC)).toBe(0n); - expect(capturedEURecipients.length).toBeGreaterThan(0); - capturedEURecipients.forEach(r => expect(r).toBe(userAddress)); - }); -}); - -describe('AnchorChainingPath direct send', function() { - test('same Keeta location and asset: zero-step path sends on-chain directly', async function() { - await using h = await createChainingTestHarness(); - const recipient = KeetaNet.lib.Account.fromSeed(KeetaNet.lib.Account.generateRandomSeed(), 0); - await h.giveTokens(h.client.account, 500n, h.tokens.USDC); - - const paths = await h.anchorChaining.getPlans({ - source: { asset: h.tokens.USDC, location: h.keetaLocation, value: 200n, rail: 'KEETA_SEND' }, - destination: { asset: h.tokens.USDC, location: h.keetaLocation, recipient: recipient.publicKeyString.get(), rail: 'KEETA_SEND' } - }); - - if (!paths?.[0]) { - throw(new Error('Expected to find a path')); - } - expect(paths.length).toEqual(1); - const path = paths[0]; - - expect(path.path.length).toEqual(1); - - expect(path.plan.steps.length).toEqual(1); - expect(path.plan.totalValueIn).toEqual(200n); - expect(path.plan.totalValueOut).toEqual(200n); - - expect(path.state.status).toEqual('idle'); - const result = await path.execute(); - expect(result.steps.length).toEqual(1); - expect(path.state.status).toEqual('completed'); - - const balance = await h.client.client.getBalance(recipient, h.tokens.USDC); - expect(balance).toEqual(200n); - }); -}); - -describe('AnchorChainingPath ACH fiat path', function() { - async function getBankUSPath(h: Awaited>) { - const paths = await h.anchorChaining.getPlans({ - source: { asset: 'USD', location: 'bank-account:us', value: 100n, rail: 'ACH' }, - destination: { asset: h.tokens.USDC, location: h.keetaLocation, recipient: h.client.account.publicKeyString.get(), rail: 'KEETA_SEND' } - }); - const p = paths?.find(p => p.path.length === 1 && p.path[0]?.providerID === 'BankUS'); - if (!p) {throw(new Error('No single-step BankUS path found'));} - return(p); - } - - test('no stepNeedsAction listener causes execute to throw', async function() { - await using h = await createChainingTestHarness(); - const path = await getBankUSPath(h); - - expect(path.plan.steps[0]?.type).toEqual('assetMovement'); - if (path.plan.steps[0]?.type === 'assetMovement') { - expect(path.plan.steps[0].usingInstruction.type).toEqual('ACH'); - } - - await expect(path.execute()).rejects.toThrow('No listeners for stepNeedsAction'); - }); - - test('markCompleted signals completion; server records transfer as COMPLETE', async function() { - await using h = await createChainingTestHarness(); - const path = await getBankUSPath(h); - - path.on('stepNeedsAction', (payload) => { - if (payload.type === 'keetaSendAuthRequired') { - payload.markCompleted({ sent: true }); - } else { - payload.markCompleted() - } - }); - const result = await path.execute(); - - expect(result.steps.length).toEqual(1); - expect(result.steps[0]?.type).toEqual('assetMovement'); - if (result.steps[0]?.type === 'assetMovement') { - // value = 100 - 10 fee = 90 - const transferStatus = await result.steps[0].plan.transfer.getTransferStatus(); - expect(transferStatus.transaction.status).toEqual('COMPLETE'); - expect(transferStatus.transaction.to.value).toEqual('90'); - } - }); - - test('transfer status polling failure emits failed event at step 0', async function() { - await using h = await createChainingTestHarness(); - const path = await getBankUSPath(h); - - h.bankServerUS.failNextTransferStatus('ACH poll failed'); - - const failedEvents: { error: Error; completedSteps: ExecutedStep[]; index: number }[] = []; - path.on('failed', (error: Error, completedSteps: ExecutedStep[], failedAtStepIndex: number) => { - failedEvents.push({ error, completedSteps, index: failedAtStepIndex }); - }); - - path.on('stepNeedsAction', (payload) => { - if (payload.type === 'keetaSendAuthRequired') { - payload.markCompleted({ sent: true }); - } else { - payload.markCompleted() - } - }); - await expect(path.execute()).rejects.toThrow('ACH poll failed'); - - expect(path.state.status).toEqual('failed'); - if (path.state.status === 'failed') { - expect(path.state.failedAtStepIndex).toEqual(0); - expect(path.state.completedSteps.length).toEqual(0); - } - expect(failedEvents).toHaveLength(1); - const failedEvent = failedEvents[0]; - if (!failedEvent) {throw(new Error('Expected failed event'));} - expect(failedEvent.index).toEqual(0); - expect(failedEvent.completedSteps.length).toEqual(0); - }); -}); - -describe('AnchorChainingPath keetaSendAuthRequired', function() { - // Uses the FX+AM path (USDC -> EURC via FXOne, then EURC -> EUR bank via BankEU). - // The AM step has a KEETA_SEND instruction, so execute() calls client.send() internally. - // With requireSendAuth: true, a keetaSendAuthRequired event fires before that send. - - test('no stepNeedsAction listener throws when requireSendAuth is set', async function() { - await using h = await createChainingTestHarness(); - await h.giveTokens(h.client.account, 1000n, h.tokens.USDC); - const path = await h.getPlanVia('FXOne'); - await expect(path.execute({ requireSendAuth: true })).rejects.toThrow('No listeners for stepNeedsAction'); - }); - - test('markCompleted({ sent: true }): event fires with correct payload and execute succeeds', async function() { - await using h = await createChainingTestHarness(); - await h.giveTokens(h.client.account, 1000n, h.tokens.USDC); - const path = await h.getPlanVia('FXOne'); - - const capturedActions: { sendToAddress: GenericAccount; value: bigint; token: TokenAddress; external?: string }[] = []; - - path.on('stepNeedsAction', (payload) => { - if (payload.type === 'keetaSendAuthRequired') { - capturedActions.push(payload.action); - payload.markCompleted({ sent: true }); - } else { - payload.markCompleted(); - } - }); - - const result = await path.execute({ requireSendAuth: true }); - - expect(result.steps).toHaveLength(2); - expect(capturedActions).toHaveLength(1); - const action = capturedActions[0]; - if (!action) { throw(new Error('Expected keetaSendAuthRequired action')); } - - // sendToAddress is the bank server's Keeta account - expect(KeetaNet.lib.Account.isInstance(action.sendToAddress)).toBe(true); - // value is the post-FX EURC amount: 100 * 0.88 = 88 - expect(action.value).toBe(88n); - // token is the EURC token - expect(action.token.publicKeyString.get()).toBe(h.tokens.EURC.publicKeyString.get()); - // external is the bank transfer ID used to match the on-chain send - expect(typeof action.external).toBe('string'); - }); - - test('markCompleted({ sent: false }): execute still proceeds (sent value is advisory)', async function() { - await using h = await createChainingTestHarness(); - await h.giveTokens(h.client.account, 1000n, h.tokens.USDC); - const path = await h.getPlanVia('FXOne'); - - path.on('stepNeedsAction', (payload) => { - if (payload.type === 'keetaSendAuthRequired') { - payload.markCompleted({ sent: false }); - } else { - payload.markCompleted(); - } - }); - - const result = await path.execute({ requireSendAuth: true }); - expect(result.steps).toHaveLength(2); - }); - - test('markFailed: execute rejects with the provided error', async function() { - await using h = await createChainingTestHarness(); - await h.giveTokens(h.client.account, 1000n, h.tokens.USDC); - const path = await h.getPlanVia('FXOne'); - - path.on('stepNeedsAction', (payload) => { - if (payload.type === 'keetaSendAuthRequired') { - payload.markFailed(new Error('send rejected by user')); - } else { - payload.markCompleted(); - } - }); - - const failedEvents: { error: Error; completedSteps: ExecutedStep[]; index: number }[] = []; - path.on('failed', (error: Error, completedSteps: ExecutedStep[], failedAtStepIndex: number) => { - failedEvents.push({ error, completedSteps, index: failedAtStepIndex }); - }); - - await expect(path.execute({ requireSendAuth: true })).rejects.toThrow('send rejected by user'); - expect(path.state.status).toBe('failed'); - expect(failedEvents).toHaveLength(1); - const failedEvent = failedEvents[0]; - if (!failedEvent) { throw(new Error('Expected failed event')); } - // FX step (index 0) completed; rejection happened at AM step (index 1) - expect(failedEvent.index).toBe(1); - expect(failedEvent.completedSteps[0]?.type).toBe('fx'); - }); - - test('anchor omitting external: client constructs the unsigned correlation envelope', async function() { - await using h = await createChainingTestHarness(); - await h.giveTokens(h.client.account, 1000n, h.tokens.USDC); - - // Anchor under the construction model: instructions carry no external. - h.bankServerEU.wrapInitiateTransfer(stripKeetaSendExternal); - - const path = await h.getPlanVia('FXOne'); - - const capturedActions: { sendToAddress: GenericAccount; value: bigint; token: TokenAddress; external?: string }[] = []; - path.on('stepNeedsAction', (payload) => { - if (payload.type === 'keetaSendAuthRequired') { - capturedActions.push(payload.action); - payload.markCompleted({ sent: true }); - } else { - payload.markCompleted(); - } - }); - - /* - * Completion proves the fixture correlated the SEND by decoding the - * client-built envelope rather than matching a raw transfer id. - */ - const result = await path.execute({ requireSendAuth: true }); - expect(result.steps).toHaveLength(2); - - const step1 = result.steps[1]; - if (step1?.type !== 'assetMovement') { - throw(new Error('Expected asset movement step')); - } - - const action = capturedActions[0]; - if (action?.external === undefined) { - throw(new Error('Expected client-built external on the send action')); - } - - /* - * The prior FX hop forwards its settled swap block, so the client-built - * envelope references it as an on-chain input. - */ - const step0 = result.steps[0]; - if (step0?.type !== 'fx') { - throw(new Error('Expected fx step')); - } - - const fxStatus = await step0.exchange.getExchangeStatus(); - if (fxStatus?.status !== 'completed') { - throw(new Error('Expected fx exchange to complete')); - } - - const decoded = await AnchorExternal.fromPlainExternal(action.external); - expect(decoded.signed).toBeUndefined(); - expect(decoded.envelope.inputs).toEqual([ { blockHash: fxStatus.blockhash } ]); - expect(decoded.envelope.anchors).toEqual({ - [h.bankSignerEU.publicKeyString.get()]: { transactionId: step1.plan.transfer.transferID } - }); - }); - - test('chained keeta sends: the second envelope references the first send as an input', async function() { - await using h = await createChainingTestHarness({ includeSwapAnchor: true }); - await h.giveTokens(h.client.account, 1000n, h.tokens.USDC); - /* - * The swap anchor settles EURC off-chain in this fixture, so the - * user's EURC for the second hop is pre-funded. - */ - await h.giveTokens(h.client.account, 1000n, h.tokens.EURC); - - /* - * Both anchors omit external, so the client builds both envelopes. - */ - h.swapServer.wrapInitiateTransfer(stripKeetaSendExternal); - h.bankServerEU.wrapInitiateTransfer(stripKeetaSendExternal); - - const plans = await h.anchorChaining.getPlans({ - source: { asset: h.tokens.USDC, location: h.keetaLocation, value: 100n, rail: 'KEETA_SEND' }, - destination: { asset: 'EUR', location: 'bank-account:iban-swift', recipient: h.client.account.publicKeyString.get(), rail: 'SEPA_PUSH' } - }); - const path = plans?.find(function(plan) { - if (plan.path.length !== 2) { - return(false); - } - - return(plan.path[0]?.providerID === 'SwapKeeta' && plan.path[1]?.providerID === h.euBankProviderID); - }); - if (!path) { - throw(new Error('Expected SwapKeeta -> BankEU path')); - } - - const capturedExternals: (string | undefined)[] = []; - path.on('stepNeedsAction', (payload) => { - if (payload.type === 'keetaSendAuthRequired') { - capturedExternals.push(payload.action.external); - payload.markCompleted({ sent: true }); - } else { - payload.markCompleted(); - } - }); - - const result = await path.execute({ requireSendAuth: true }); - expect(result.steps).toHaveLength(2); - expect(capturedExternals).toHaveLength(2); - - const [firstExternal, secondExternal] = capturedExternals; - if (firstExternal === undefined || secondExternal === undefined) { - throw(new Error('Expected client-built externals on both sends')); - } - - // First hop: no prior on-chain operations, so no inputs. - const firstDecoded = await AnchorExternal.fromPlainExternal(firstExternal); - expect(firstDecoded.envelope.inputs).toBeUndefined(); - expect(Object.keys(firstDecoded.envelope.anchors)).toEqual([ h.swapSigner.publicKeyString.get() ]); - - // Second hop: filed under BankEU and referencing the first send. - const secondDecoded = await AnchorExternal.fromPlainExternal(secondExternal); - expect(Object.keys(secondDecoded.envelope.anchors)).toEqual([ h.bankSignerEU.publicKeyString.get() ]); - expect(secondDecoded.envelope.inputs).toHaveLength(1); - - const input = secondDecoded.envelope.inputs?.[0]; - if (input === undefined) { - throw(new Error('Expected an input referencing the first send')); - } - - expect(input.operationIndex).toBe(0); - - // The referenced block is the first hop's on-chain SEND. - const referencedBlock = await h.client.block(input.blockHash); - if (referencedBlock === null) { - throw(new Error('Referenced input block not found on chain')); - } - - const referencedExternals = referencedBlock.operations.flatMap(function(op) { - if (op.type === KeetaNet.lib.Block.OperationType.SEND) { - return([ op.external ]); - } - - return([]); - }); - - expect(referencedExternals).toEqual([ firstExternal ]); - }); - - test('direct send: keetaSendAuthRequired fires with correct sendToAddress and value', async function() { - await using h = await createChainingTestHarness(); - const recipient = KeetaNet.lib.Account.fromSeed(KeetaNet.lib.Account.generateRandomSeed(), 0); - await h.giveTokens(h.client.account, 500n, h.tokens.USDC); - - const paths = await h.anchorChaining.getPlans({ - source: { asset: h.tokens.USDC, location: h.keetaLocation, value: 200n, rail: 'KEETA_SEND' }, - destination: { asset: h.tokens.USDC, location: h.keetaLocation, recipient: recipient.publicKeyString.get(), rail: 'KEETA_SEND' } - }); - if (!paths?.[0]) { throw(new Error('Expected direct-send path')); } - const path = paths[0]; - expect(path.plan.steps).toHaveLength(1); - - const capturedActions: { sendToAddress: GenericAccount; value: bigint; token: TokenAddress; external?: string }[] = []; - - path.on('stepNeedsAction', (payload) => { - if (payload.type === 'keetaSendAuthRequired') { - capturedActions.push(payload.action); - payload.markCompleted({ sent: true }); - } else { - payload.markCompleted(); - } - }); - - const result = await path.execute({ requireSendAuth: true }); - expect(result.steps).toHaveLength(1); - - expect(capturedActions).toHaveLength(1); - const action = capturedActions[0]; - if (!action) { throw(new Error('Expected keetaSendAuthRequired action')); } - - expect(action.sendToAddress.publicKeyString.get()).toBe(recipient.publicKeyString.get()); - expect(action.value).toBe(200n); - expect(action.token.publicKeyString.get()).toBe(h.tokens.USDC.publicKeyString.get()); - expect(action.external).toBeUndefined(); - - const balance = await h.client.client.getBalance(recipient, h.tokens.USDC); - expect(balance).toBe(200n); - }); -}); - -describe('AnchorChaining listAssets', function() { - function assetKey(asset: AnchorChainingAsset): string { - if (KeetaNet.lib.Account.isInstance(asset)) { - return(asset.publicKeyString.get()); - } - return(String(asset)); - } - - function resultKey(item: AnchorChainingAssetInfo): string { - return(`${assetKey(item.asset)}@${convertAssetLocationToString(item.location)}`); - } - - test('onlyAllowFXLike excludes the source token and bank-account destinations', async function() { - await using h = await createChainingTestHarness(); - const assets = await h.anchorChaining.graph.listAssets({ - from: { asset: h.tokens.USDC, location: h.keetaLocation }, - onlyAllowFXLike: true - }); - - // EURC@keeta reachable; USDC itself excluded even though reachable via round-trip; - // bank-account destinations excluded because they are not FX-like nodes - expect(assets).toHaveLength(1); - const [eurc] = assets; - if (!eurc) { - throw(new Error('Expected to find EURC asset for onlyAllowFXLike filter')); - } - expect(assetKey(eurc.asset)).toBe(h.tokens.EURC.publicKeyString.get()); - expect(eurc.location).toBe(h.keetaLocation); - expect(eurc.rails.inbound).toEqual(['KEETA_SEND']); - expect(eurc.rails.outbound).toEqual(['KEETA_SEND']); - }); - - test('from filter with maxStepCount=1 returns only direct 1-hop destinations', async function() { - await using h = await createChainingTestHarness(); - const assets = await h.anchorChaining.graph.listAssets({ - from: { asset: h.tokens.USDC, location: h.keetaLocation }, - maxStepCount: 1 - }); - - expect(assets).toHaveLength(2); - const keys = assets.map(resultKey); - expect(keys).toContain(`${h.tokens.EURC.publicKeyString.get()}@${h.keetaLocation}`); - expect(keys).toContain(`USD@bank-account:us`); - expect(keys).not.toContain(`EUR@bank-account:iban-swift`); - }); - - test('from filter without maxStepCount finds all reachable assets in the graph', async function() { - await using h = await createChainingTestHarness(); - const assets = await h.anchorChaining.graph.listAssets({ - from: { asset: h.tokens.USDC, location: h.keetaLocation } - }); - - expect(assets).toHaveLength(4); - const keys = assets.map(resultKey); - expect(keys).toContain(`${h.tokens.EURC.publicKeyString.get()}@${h.keetaLocation}`); - expect(keys).toContain(`EUR@bank-account:iban-swift`); - expect(keys).toContain(`USD@bank-account:us`); - }); - - test('to filter with maxStepCount=1 returns only direct 1-hop sources for US bank', async function() { - await using h = await createChainingTestHarness(); - const assets = await h.anchorChaining.graph.listAssets({ - to: { location: 'bank-account:us' }, - maxStepCount: 1 - }); - - // Only USDC@keeta can reach bank-account:us in a single hop (BankUS USDC->USD) - expect(assets).toHaveLength(1); - const [usdc] = assets; - if (!usdc) { - throw(new Error('Expected to find USDC asset for bank-account:us')); - } - expect(assetKey(usdc.asset)).toBe(h.tokens.USDC.publicKeyString.get()); - expect(usdc.location).toBe(h.keetaLocation); - expect(usdc.rails.outbound).toContain('KEETA_SEND'); - }); - - test('no filter returns all 4 distinct asset-location pairs in the graph', async function() { - await using h = await createChainingTestHarness(); - const assets = await h.anchorChaining.graph.listAssets(); - - expect(assets).toHaveLength(4); - const keys = assets.map(resultKey); - expect(keys).toContain(`${h.tokens.USDC.publicKeyString.get()}@${h.keetaLocation}`); - expect(keys).toContain(`${h.tokens.EURC.publicKeyString.get()}@${h.keetaLocation}`); - expect(keys).toContain(`USD@bank-account:us`); - expect(keys).toContain(`EUR@bank-account:iban-swift`); - }); - - test('from filter populates distance.pathLength with shortest hop count', async function() { - await using h = await createChainingTestHarness(); - const assets = await h.anchorChaining.graph.listAssets({ - from: { asset: h.tokens.USDC, location: h.keetaLocation } - }); - - const distanceByKey = new Map(assets.map(a => [resultKey(a), a.distance?.pathLength])); - expect(distanceByKey.get(`${h.tokens.EURC.publicKeyString.get()}@${h.keetaLocation}`)).toBe(1); - expect(distanceByKey.get(`USD@bank-account:us`)).toBe(1); - expect(distanceByKey.get(`EUR@bank-account:iban-swift`)).toBe(2); - }); - - test('to filter populates distance.pathLength with shortest hop count', async function() { - await using h = await createChainingTestHarness(); - const assets = await h.anchorChaining.graph.listAssets({ - to: { location: 'bank-account:us' }, - maxStepCount: 1 - }); - - expect(assets).toHaveLength(1); - expect(assets[0]?.distance).toEqual({ pathLength: 1 }); - }); - - test('no filter returns distance null for all assets', async function() { - await using h = await createChainingTestHarness(); - const assets = await h.anchorChaining.graph.listAssets(); - - for (const asset of assets) { - expect(asset.distance).toBeNull(); - } - }); -}); - -test('AnchorChaining getPlans includeAllOutput', async function() { - await using h = await createChainingTestHarness(); - - const input = { - source: { asset: h.tokens.USDC, location: h.keetaLocation, value: 100n, rail: 'KEETA_SEND' as const }, - destination: { asset: 'EUR' as const, location: 'bank-account:iban-swift' as const, recipient: h.client.account.publicKeyString.get(), rail: 'SEPA_PUSH' as const } - }; - - const allOk = await h.anchorChaining.getPlans(input, { includeAllOutput: true }); - expect(allOk).not.toBeNull(); - expect(allOk).toHaveLength(2); - for (const result of allOk ?? []) { - expect(result.success).toBe(true); - if (result.success) { - expect(result.plan).toBeDefined(); - expect(result.path).toBeDefined(); - } - } - - h.fxServerOne.setGetConversionRateAndFee(async () => { - throw(new Error('FXOne rate unavailable')); - }); - - const mixed = await h.anchorChaining.getPlans(input, { includeAllOutput: true }); - expect(mixed).not.toBeNull(); - expect(mixed).toHaveLength(2); - - const failed = mixed?.find(r => !r.success); - const succeeded = mixed?.find(r => r.success); - - expect(failed).toBeDefined(); - if (!failed || failed.success) { - throw(new Error('Expected a failed result')); - } - expect(failed.error).toBeTruthy(); - expect(failed.path).toBeDefined(); - - expect(succeeded).toBeDefined(); - if (!succeeded || !succeeded.success) { - throw(new Error('Expected a successful result')); - } - expect(succeeded.plan).toBeDefined(); - expect(succeeded.path).toBeDefined(); - expect(succeeded.plan.plan.steps.some(s => s.type === 'fx' && s.step.providerID === 'FXTwo')).toBe(true); - - const defaultResults = await h.anchorChaining.getPlans(input); - expect(defaultResults).not.toBeNull(); - expect(defaultResults).toHaveLength(1); - expect(defaultResults?.[0]?.plan.steps.some(s => s.type === 'fx' && s.step.providerID === 'FXTwo')).toBe(true); -}); - -test('AnchorChaining resolveAssets', async function() { - await using h = await createChainingTestHarness(); - - const usdcKey = `${h.tokens.USDC.publicKeyString.get()}@${h.keetaLocation}`; - const eurcKey = `${h.tokens.EURC.publicKeyString.get()}@${h.keetaLocation}`; - const usdKey = `USD@bank-account:us`; - const eurKey = `EUR@bank-account:iban-swift`; - - const resultKey = (item: AnchorChainingAssetInfo): string => { - const assetStr = KeetaNet.lib.Account.isInstance(item.asset) - ? item.asset.publicKeyString.get() - : String(item.asset); - return(`${assetStr}@${convertAssetLocationToString(item.location)}`); - }; - - type ExpectedAsset = { key: string; distance: number | null }; - - const testCases: { - name: string; - args: AnchorChainingResolveAssetsFilter | AnchorChainingResolveAssetsFilter[]; - expected: { from: ExpectedAsset[]; to: ExpectedAsset[] }; - }[] = [ - { - name: 'from only', - args: { from: { asset: h.tokens.USDC, location: h.keetaLocation }}, - expected: { - from: [], - to: [ - { key: eurcKey, distance: 1 }, - { key: usdKey, distance: 1 }, - { key: eurKey, distance: 2 }, - { key: usdcKey, distance: 2 } - ] - } - }, - { - name: 'to only with maxStepCount: 1', - args: { to: { location: 'bank-account:us' }, maxStepCount: 1 }, - expected: { - from: [{ key: usdcKey, distance: 1 }], - to: [] - } - }, - { - name: 'no filter', - args: {}, - expected: { - from: [ - { key: usdcKey, distance: null }, - { key: eurcKey, distance: null }, - { key: usdKey, distance: null }, - { key: eurKey, distance: null } - ], - to: [ - { key: usdcKey, distance: null }, - { key: eurcKey, distance: null }, - { key: usdKey, distance: null }, - { key: eurKey, distance: null } - ] - } - }, - { - name: 'from+to: keeta -> bank-account:us', - args: [ - { from: { location: h.keetaLocation }, to: { location: 'bank-account:us' }}, - { from: { location: h.keetaLocation, rail: 'KEETA_SEND' }, to: { location: 'bank-account:us' }}, - { from: { location: h.keetaLocation }, to: { location: 'bank-account:us', rail: 'ACH' }}, - { from: { location: h.keetaLocation, rail: 'KEETA_SEND' }, to: { location: 'bank-account:us', rail: 'ACH' }}, - { from: { location: h.keetaLocation, rail: undefined }, to: { location: 'bank-account:us', rail: 'ACH' }}, - { from: { location: h.keetaLocation, rail: undefined }, to: { location: 'bank-account:us', rail: undefined }} - ], - expected: { - from: [ - { key: usdcKey, distance: 1 }, - { key: eurcKey, distance: 2 } - ], - to: [{ key: usdKey, distance: 1 }] - } - }, - { - name: 'from+to: keeta -> bank-account:us with invalid rail', - args: [ - { from: { location: h.keetaLocation }, to: { location: 'bank-account:us', rail: 'BITCOIN_SEND' }}, - { from: { location: h.keetaLocation, rail: 'ACH' }, to: { location: 'bank-account:us' }} - ], - expected: { - from: [], - to: [] - } - }, - { - name: 'from+to: to.rail SEPA_PUSH filters to EU corridor', - args: { from: { location: h.keetaLocation }, to: { location: 'bank-account:iban-swift', rail: 'SEPA_PUSH' }}, - expected: { - from: [ - { key: eurcKey, distance: 1 }, - { key: usdcKey, distance: 2 } - ], - to: [{ key: eurKey, distance: 1 }] - } - }, - { - name: 'from+to: from.rail ACH limits from assets to those with ACH outbound', - args: { from: { rail: 'ACH' }, to: { location: h.keetaLocation }}, - expected: { - from: [{ key: usdKey, distance: 1 }], - to: [ - { key: usdcKey, distance: 1 }, - { key: eurcKey, distance: 2 } - ] - } - } - ]; - - for (const { name, args, expected } of testCases) { - let argsArray; - if (Array.isArray(args)) { - argsArray = args; - } else { - argsArray = [ args ]; - } - - for (const argValue of argsArray) { - const result = await h.anchorChaining.graph.resolveAssets(argValue); - const toActual = (side: AnchorChainingAssetInfo[]): ExpectedAsset[] => - side.map(a => ({ key: resultKey(a), distance: a.distance?.pathLength ?? null })); - - expect(toActual(result.from), `${name}: from`).toEqual(expect.arrayContaining(expected.from)); - expect(result.from, `${name}: from length`).toHaveLength(expected.from.length); - expect(toActual(result.to), `${name}: to`).toEqual(expect.arrayContaining(expected.to)); - expect(result.to, `${name}: to length`).toHaveLength(expected.to.length); - } - } -}); - -describe('AnchorChainingAssetInfo metadata', function() { - async function createMetadataHarness() { - const account = KeetaNet.lib.Account.fromSeed(KeetaNet.lib.Account.generateRandomSeed(), 0); - const { userClient: client } = await createNodeAndClient(account); - - const makeToken = async () => { - const { account } = await client.generateIdentifier(KeetaNet.lib.Account.AccountKeyAlgorithm.TOKEN); - return(account.assertKeyType(KeetaNet.lib.Account.AccountKeyAlgorithm.TOKEN)); - }; - - // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion - const evmChainLocation = 'chain:evm:500' as const; - const keetaLocation = `chain:keeta:${client.network}` as const; - const tokens = { USDC: await makeToken() }; - const usdcEvmId: AnchorChainingAsset = 'evm:0xc0634090F2Fe6c6d75e61Be2b949464aBB498973'; - - const bridgeOneMetadata: AnchorTokenLocationMetadata = { - displayName: 'Circle USDC', - decimalPlaces: 6, - ticker: '$USDC', - logoURI: 'example.com/usdc-logo' - }; - - const bridgeTwoMetadata: AnchorTokenLocationMetadata = { - displayName: 'USDC (alt)', - decimalPlaces: 6, - ticker: '$USDC', - logoURI: 'example.com/usdc-logo-2' - }; - - const makeBridge = (metadata: typeof bridgeOneMetadata) => new KeetaNetAssetMovementAnchorHTTPServer({ - ...(logger ? { logger: logger } : {}), - assetMovement: { - supportedAssets: [ - { - asset: tokens.USDC.publicKeyString.get(), - paths: [{ - pair: [ - { location: keetaLocation, id: tokens.USDC.publicKeyString.get(), rails: { common: [ 'KEETA_SEND' ] }}, - { location: evmChainLocation, id: usdcEvmId, rails: { common: [ 'EVM_SEND' ], inbound: [ 'EVM_CALL' ] }} - ] - }] - }, - { - asset: '$USDC', - paths: [ - { - pair: [ - { location: evmChainLocation, id: usdcEvmId, rails: { common: [ 'EVM_SEND' ] }}, - { location: keetaLocation, id: tokens.USDC.publicKeyString.get(), rails: { inbound: [ 'KEETA_SEND' ] }} - ] - } - ] - } - ], - locationMetadata: { - [evmChainLocation]: { - assets: { - [usdcEvmId]: metadata - } - } - }, - async getTransferStatus() { - throw(new Error('getTransferStatus not used in metadata tests')); - }, - async createPersistentForwarding() { - throw(new Error('getTransferStatus not used in metadata tests')); - }, - async initiateTransfer() { - throw(new Error('getTransferStatus not used in metadata tests')); - } - } - }); - - const bridgeOne = makeBridge(bridgeOneMetadata); - const bridgeTwo = makeBridge(bridgeTwoMetadata); - - await bridgeOne.start(); - await bridgeTwo.start(); - - await client.setInfo({ - description: 'Metadata Test', - name: 'TEST', - metadata: Resolver.Metadata.formatMetadata({ - version: 1, - currencyMap: { '$USDC': tokens.USDC.publicKeyString.get() }, - services: { - assetMovement: { - BridgeOne: await bridgeOne.serviceMetadata(), - BridgeTwo: await bridgeTwo.serviceMetadata() - } - } - } satisfies ServiceMetadataExternalizable) - }); - - const anchorChaining = new AnchorChaining({ - client, - resolver: new Resolver({ root: client.account, client, trustedCAs: [] }) - }); - - return({ - client, - tokens, - keetaLocation, - evmChainLocation, - usdcEvmId, - bridgeOneMetadata, - bridgeTwoMetadata, - anchorChaining, - [Symbol.asyncDispose]: async function() { - await bridgeOne[Symbol.asyncDispose]?.(); - await bridgeTwo[Symbol.asyncDispose]?.(); - } - }); - } - - test('listAssetsWithMetadata populates metadata for external chain assets', async function() { - await using h = await createMetadataHarness(); - const assets = await h.anchorChaining.graph.listAssetsWithMetadata(); - - const evmAsset = assets.find(a => - !KeetaNet.lib.Account.isInstance(a.asset) && - String(a.asset) === h.usdcEvmId && - a.location === h.evmChainLocation - ); - - expect(evmAsset).toBeDefined(); - expect(evmAsset?.metadata).toBeTruthy(); - expect(evmAsset?.metadata).toMatchObject({ - ticker: '$USDC', - decimalPlaces: 6 - }); - }); - - test('listAssetsWithMetadata returns undefined metadata for Keeta-native tokens', async function() { - await using h = await createMetadataHarness(); - const assets = await h.anchorChaining.graph.listAssetsWithMetadata(); - - const keetaAsset = assets.find(a => - KeetaNet.lib.Account.isInstance(a.asset) && - a.asset.publicKeyString.get() === h.tokens.USDC.publicKeyString.get() - ); - - expect(keetaAsset).toBeDefined(); - expect(keetaAsset?.metadata).toBeUndefined(); - }); - - test('resolveAssetsWithMetadata populates metadata on results', async function() { - await using h = await createMetadataHarness(); - const result = await h.anchorChaining.graph.resolveAssetsWithMetadata({ - from: { location: h.keetaLocation }, - to: { location: h.evmChainLocation } - }); - - const evmAsset = result.to.find(a => - !KeetaNet.lib.Account.isInstance(a.asset) && - String(a.asset) === h.usdcEvmId - ); - - expect(evmAsset).toBeDefined(); - expect(evmAsset?.metadata).toBeTruthy(); - expect(evmAsset?.metadata).toMatchObject({ - ticker: '$USDC', - decimalPlaces: 6 - }); - - const keetaAsset = result.from.find(a => - KeetaNet.lib.Account.isInstance(a.asset) && - a.asset.publicKeyString.get() === h.tokens.USDC.publicKeyString.get() - ); - expect(keetaAsset).toBeDefined(); - expect(keetaAsset?.metadata).toBeUndefined(); - }); - - test('resolveAssetsWithMetadata with providerID returns that providers metadata', async function() { - await using h = await createMetadataHarness(); - - const bridgeOneResult = await h.anchorChaining.graph.resolveAssetsWithMetadata( - { to: { location: h.evmChainLocation }, from: { location: h.keetaLocation }}, - { providerID: 'BridgeOne' } - ); - - const bridgeOneEvmAsset = bridgeOneResult.to.find(a => - !KeetaNet.lib.Account.isInstance(a.asset) && - String(a.asset) === h.usdcEvmId && - a.location === h.evmChainLocation - ); - - expect(bridgeOneEvmAsset).toBeDefined(); - expect(bridgeOneEvmAsset?.metadata).toEqual(h.bridgeOneMetadata); - - const bridgeTwoResult = await h.anchorChaining.graph.resolveAssetsWithMetadata( - { to: { location: h.evmChainLocation }, from: { location: h.keetaLocation }}, - { providerID: 'BridgeTwo' } - ); - - const bridgeTwoEvmAsset = bridgeTwoResult.to.find(a => - !KeetaNet.lib.Account.isInstance(a.asset) && - String(a.asset) === h.usdcEvmId - ); - - expect(bridgeTwoEvmAsset).toBeDefined(); - expect(bridgeTwoEvmAsset?.metadata).toEqual(h.bridgeTwoMetadata); - }); - - test('listAssetsWithMetadata with unknown providerID returns undefined metadata', async function() { - await using h = await createMetadataHarness(); - - const assets = await h.anchorChaining.graph.listAssetsWithMetadata( - { to: { location: h.evmChainLocation }}, - { providerID: 'NonExistentBridge' } - ); - - const evmAsset = assets.find(a => - !KeetaNet.lib.Account.isInstance(a.asset) && - String(a.asset) === h.usdcEvmId - ); - - expect(evmAsset).toBeDefined(); - expect(evmAsset?.metadata).toBeUndefined(); - }); - - test('getAssetMovementProvidersForAsset returns all providers supporting an asset/location', async function() { - await using h = await createMetadataHarness(); - - const providers = await h.anchorChaining.graph.getAssetMovementProvidersForAsset( - h.usdcEvmId, - h.evmChainLocation - ); - - expect(providers).not.toBeNull(); - expect(Object.keys(providers ?? {}).sort()).toEqual(['BridgeOne', 'BridgeTwo']); - - for (const entry of Object.values(providers ?? {})) { - expect(entry.provider).toBeDefined(); - } - }); - - test('getAssetMovementProvidersForAsset finds providers for Keeta-side assets too', async function() { - await using h = await createMetadataHarness(); - - const providers = await h.anchorChaining.graph.getAssetMovementProvidersForAsset( - h.tokens.USDC, - h.keetaLocation - ); - - expect(providers).not.toBeNull(); - expect(Object.keys(providers ?? {}).sort()).toEqual(['BridgeOne', 'BridgeTwo']); - }); - - test('getAssetMovementProvidersForAsset returns null for an unknown asset/location pair', async function() { - await using h = await createMetadataHarness(); - - const providers = await h.anchorChaining.graph.getAssetMovementProvidersForAsset( - 'evm:0x000000000000000000000000000000000000dEaD', - h.evmChainLocation - ); - - expect(providers).toBeNull(); - }); -}); - -describe('AnchorChainingPlan disclaimers', function() { - test('anchorChaining paths should return the correct legal disclaimers', async function() { - await using h = await createChainingTestHarness(); - - // EU Bank Paths - - const euBankPaths = await h.anchorChaining.getPaths({ - source: { asset: h.tokens.USDC, location: h.keetaLocation, rail: 'KEETA_SEND', value: 100n }, - destination: { asset: 'EUR', location: 'bank-account:iban-swift', recipient: h.client.account.publicKeyString.get(), rail: 'SEPA_PUSH' } - }); - - if (!euBankPaths || euBankPaths.length === 0) { - throw(new Error('Expected at least one valid path')); - } - - for (const euBankPath of euBankPaths) { - const expectedProviderDisclaimers = euBankPath.path.map((step) => { - if (!step.providerID) { - throw(new Error('Expected step to have a provider ID')); - } - - const expectedDisclaimersMap: { [key: string]: Disclaimer[] } = step.type === 'assetMovement' ? h.bankProviderDisclaimers : h.fxProviderDisclaimers; - - return({ - providerID: step.providerID, - disclaimers: expectedDisclaimersMap[step.providerID] - }) - }) - - const disclaimers = await euBankPath.getProviderLegalDisclaimers(); - expect(disclaimers?.length).toEqual(euBankPath.path.length); - expect(disclaimers).toEqual(expectedProviderDisclaimers); - } - - // US Bank Paths - - const usBankPaths = await h.anchorChaining.getPaths({ - source: { asset: h.tokens.EURC, location: h.keetaLocation, rail: 'KEETA_SEND', value: 100n }, - destination: { asset: 'USD', location: 'bank-account:us', recipient: h.client.account.publicKeyString.get(), rail: 'ACH' } - }); - - if (!usBankPaths || usBankPaths.length === 0) { - throw(new Error('Expected at least one valid path')); - } - - for (const usBankPath of usBankPaths) { - const expectedProviderDisclaimers = usBankPath.path.map((step) => { - if (!step.providerID) { - throw(new Error('Expected step to have a provider ID')); - } - const expectedDisclaimersMap: { [key: string]: Disclaimer[] } = step.type === 'assetMovement' ? h.bankProviderDisclaimers : h.fxProviderDisclaimers; - return({ - providerID: step.providerID, - disclaimers: expectedDisclaimersMap[step.providerID] - }) - }) - - const disclaimers = await usBankPath.getProviderLegalDisclaimers(); - expect(disclaimers?.length).toEqual(usBankPath.path.length); - expect(disclaimers).toEqual(expectedProviderDisclaimers); - } - - // Keeta Paths - - const networkPaths = await h.anchorChaining.getPaths({ - source: { asset: h.tokens.EURC, location: h.keetaLocation, rail: 'KEETA_SEND', value: 100n }, - destination: { asset: h.tokens.USDC, location: h.keetaLocation, recipient: h.client.account.publicKeyString.get(), rail: 'KEETA_SEND' } - }); - - if (!networkPaths || networkPaths.length === 0) { - throw(new Error('Expected at least one valid path')); - } - - for (const networkPath of networkPaths) { - const expectedProviderDisclaimers = networkPath.path.slice(0, 2).map((step) => { - if (!step.providerID) { - throw(new Error('Expected step to have a provider ID')); - } - const expectedDisclaimersMap: { [key: string]: Disclaimer[] } = step.type === 'assetMovement' ? h.bankProviderDisclaimers : h.fxProviderDisclaimers; - return({ - providerID: step.providerID, - disclaimers: expectedDisclaimersMap[step.providerID] - }) - }) - const disclaimers = await networkPath.getProviderLegalDisclaimers(); - expect(disclaimers?.length).toEqual(expectedProviderDisclaimers.length); - expect(disclaimers).toEqual(expectedProviderDisclaimers); - } - }); -}); - -describe('Persistent Forwarding chaining', function() { - const PFR_SUPPORTED_OPS = { initiateTransfer: false, createPersistentForwarding: true } as const; - - function newDestinationAccount() { - return(KeetaNet.lib.Account.fromSeed(KeetaNet.lib.Account.generateRandomSeed(), 0)); - } - - function firstPath(paths: T[] | null | undefined): T { - const found = paths?.[0]; - if (!paths || !found) { - throw(new Error(`No paths found`)); - } - - return(found); - } - - type PersistentForwardingHarness = Awaited>; - - async function getKeetaUsdcToUsdc2Path(h: PersistentForwardingHarness, value: bigint, recipient: GenericAccount) { - const paths = await h.anchorChaining.getPaths({ - source: { asset: h.tokens.USDC, location: h.keetaLocation, value, rail: 'KEETA_SEND' }, - destination: { asset: h.tokens.USDC2, location: h.keetaLocation, recipient: recipient.publicKeyString.get(), rail: 'KEETA_SEND' } - }); - return(firstPath(paths)); - } - - async function createPersistentForwardingHarness() { - const account = KeetaNet.lib.Account.fromSeed(KeetaNet.lib.Account.generateRandomSeed(), 0); - const { userClient: client } = await createNodeAndClient(account); - - const makeToken = async () => { - const { account: tokenAccount } = await client.generateIdentifier(KeetaNet.lib.Account.AccountKeyAlgorithm.TOKEN); - - await client.setInfo( - { name: '', description: '', metadata: '', defaultPermission: new KeetaNet.lib.Permissions(['ACCESS']) }, - { account: tokenAccount } - ); - - return(tokenAccount.assertKeyType(KeetaNet.lib.Account.AccountKeyAlgorithm.TOKEN)); - }; - - const evmChainLocation = 'chain:evm:500' satisfies AssetLocationLike; - const keetaLocation = `chain:keeta:${client.network}` satisfies AssetLocationLike; - const evmUsdcId = 'evm:0xc0634090F2Fe6c6d75e61Be2b949464aBB498973'; - - const tokens = { USDC: await makeToken(), USDC2: await makeToken() }; - - type AssetEntry = KeetaAnchorAssetMovementServerConfig['assetMovement']['supportedAssets'][number]; - const makeAssetEntry = (keetaToken: TokenAddress): AssetEntry => ({ - asset: keetaToken.publicKeyString.get(), - paths: [{ - pair: [ - { location: keetaLocation, id: keetaToken.publicKeyString.get(), rails: { common: [ 'KEETA_SEND' ] }}, - { location: evmChainLocation, id: evmUsdcId, rails: { common: [{ rail: 'EVM_SEND', supportedOperations: PFR_SUPPORTED_OPS }] }} - ] - }] - }); - - const bridgeServer = new TestPersistentForwardingBridgeServer({ - ...(DEBUG ? { logger } : {}), - client, - assetMovement: { - supportedAssets: [ - makeAssetEntry(tokens.USDC), - makeAssetEntry(tokens.USDC2) - ] - } - }); - - await bridgeServer.start(); - - await client.setInfo({ - description: 'Persistent Forwarding Chain Test Root', - name: 'TEST', - metadata: Resolver.Metadata.formatMetadata({ - version: 1, - currencyMap: Object.fromEntries(Object.entries(tokens).map(function([ symbol, token ]) { - return([ `$${symbol}`, token.publicKeyString.get() ]); - })), - services: { - assetMovement: { - PersistentForwardingBridge: await bridgeServer.serviceMetadata() - } - } - } satisfies ServiceMetadataExternalizable) - }); - - const anchorChaining = new AnchorChaining({ - client, - resolver: new Resolver({ root: client.account, client, trustedCAs: [] }) - }); - - return({ - client, - anchorChaining, - tokens, - keetaLocation, - evmChainLocation, - bridgeServer, - [Symbol.asyncDispose]: async function() { - await bridgeServer[Symbol.asyncDispose]?.(); - } - }); - } - - test('graph nodes carry rail supportedOperations metadata', async function() { - await using h = await createPersistentForwardingHarness(); - - const nodes = await h.anchorChaining.graph.computeGraphNodes(); - const evmSourceNode = nodes.find(n => - n.type === 'assetMovement' && - n.from.location === h.evmChainLocation && - n.from.rail === 'EVM_SEND' - ); - expect(evmSourceNode).toBeDefined(); - expect(evmSourceNode?.from.supportedOperations).toEqual(PFR_SUPPORTED_OPS); - }); - - test('plan computes a forwarded step when last step source rail forbids initiateTransfer', async function() { - await using h = await createPersistentForwardingHarness(); - - const destinationAccount = newDestinationAccount(); - const path = await getKeetaUsdcToUsdc2Path(h, 1000n, destinationAccount); - expect(path.path).toHaveLength(2); - - const lastPathStep = path.path[1]; - if (!lastPathStep || lastPathStep.type !== 'assetMovement') { - throw(new Error(`Expected last path step to be assetMovement`)); - } - - expect(lastPathStep.from.supportedOperations).toEqual(PFR_SUPPORTED_OPS); - - const plan = await AnchorChainingPlan.create(path); - expect(plan.plan.steps).toHaveLength(2); - expect(plan.plan.steps[0]?.type).toEqual('assetMovement'); - expect(plan.plan.steps[1]?.type).toEqual('forwarded'); - - expect(h.bridgeServer.addresses.size).toEqual(1); - - const onlyAddress = [...h.bridgeServer.addresses.entries()][0]; - if (!onlyAddress) { - throw(new Error(`Persistent forwarding address was not created during plan computation`)); - } - - const [persistentAddress, addressMeta] = onlyAddress; - expect(addressMeta.destinationAddress).toEqual(destinationAccount.publicKeyString.get()); - expect(addressMeta.sourceLocation).toEqual(h.evmChainLocation); - expect(addressMeta.destinationLocation).toEqual(h.keetaLocation); - - /* - * Prior step must deposit into the persistent forwarding address. - */ - const firstResolved = plan.plan.steps[0]; - if (firstResolved?.type !== 'assetMovement') { - throw(new Error(`Expected first step to be assetMovement`)); - } - - expect(firstResolved.transfer).toBeDefined(); - expect(firstResolved.sendingTo).toEqual('NEXT_STEP'); - - const forwardedResolved = plan.plan.steps[1]; - if (forwardedResolved?.type !== 'forwarded') { - throw(new Error(`Expected last step to be forwarded`)); - } - - expect(forwardedResolved.persistentAddress.address).toEqual(persistentAddress); - expect(forwardedResolved.valueIn).toEqual(1000n); - expect(forwardedResolved.valueOut).toEqual(1000n); - }); - - test('plan reuses an existing persistent forwarding address when present', async function() { - await using h = await createPersistentForwardingHarness(); - - const destinationAccount = newDestinationAccount(); - - const initialPath = await getKeetaUsdcToUsdc2Path(h, 500n, destinationAccount); - await AnchorChainingPlan.create(initialPath); - expect(h.bridgeServer.addresses.size).toEqual(1); - - const retryPath = await getKeetaUsdcToUsdc2Path(h, 500n, destinationAccount); - await AnchorChainingPlan.create(retryPath); - expect(h.bridgeServer.addresses.size).toEqual(1); - }); - - test('executes the chain through the persistent forwarding step end-to-end', async function() { - await using h = await createPersistentForwardingHarness(); - - await h.client.modTokenSupplyAndBalance(2000n, h.tokens.USDC); - - const destinationAccount = newDestinationAccount(); - const path = await getKeetaUsdcToUsdc2Path(h, 1000n, destinationAccount); - - const plan = await AnchorChainingPlan.create(path); - const result = await plan.execute(); - expect(result.steps).toHaveLength(2); - - const firstExecuted = result.steps[0]; - if (firstExecuted?.type !== 'assetMovement') { - throw(new Error(`Expected first executed step to be assetMovement`)); - } - - const forwardedExecuted = result.steps[1]; - if (forwardedExecuted?.type !== 'forwarded') { - throw(new Error(`Expected last executed step to be forwarded`)); - } - - expect(forwardedExecuted.observedTransaction.status).toEqual('COMPLETE'); - expect(forwardedExecuted.observedTransaction.from.value).toEqual('1000'); - expect(forwardedExecuted.observedTransaction.to.location).toEqual(h.keetaLocation); - - expect(plan.state.status).toEqual('completed'); - }); -}); diff --git a/src/lib/chaining.ts b/src/lib/chaining.ts deleted file mode 100644 index 97cbadda..00000000 --- a/src/lib/chaining.ts +++ /dev/null @@ -1,2452 +0,0 @@ -import type { lib as KeetaNetLib } from '@keetanetwork/keetanet-client'; -import * as KeetaNet from "@keetanetwork/keetanet-client"; -import type { AnchorTokenLocationMetadata, AssetLocationLike, AssetTransferInstructions, AssetWithRails, FiatPushRails, KeetaAssetMovementTransaction, KeetaPersistentForwardingAddressDetails, MovableAssetSearchCanonical, PickChainLocation, Rail, RailOrRailWithExtendedDetails, RecipientResolved, SimulatedAssetTransferInstructions } from "../services/asset-movement/common.js"; -import { convertAssetLocationToString, convertAssetSearchInputToCanonical, isChainLocation, toAssetLocation } from "../services/asset-movement/common.js"; -import type { Resolver } from "./index.js"; -import { getDefaultResolver } from '../config.js'; -import type { ISOCurrencyCode } from '@keetanetwork/currency-info'; -import { Currency } from '@keetanetwork/currency-info'; -import type { Account, GenericAccount, TokenAddress } from '@keetanetwork/keetanet-client/lib/account.js'; -import { isAssetLocationLike } from '../services/asset-movement/lib/location.generated.js'; -import type { ToValuizable } from './resolver.js'; -import { isFiatRail, isMovableAssetSearchCanonical, isRail } from '../services/asset-movement/common.generated.js'; -import { assertNever } from './utils/never.js'; -import KeetaFXAnchorClient from '../services/fx/client.js'; -import type { KeetaAssetMovementAnchorProvider } from '../services/asset-movement/client.js'; -import KeetaAssetMovementAnchorClient from '../services/asset-movement/client.js'; -import type { ExternalChainAsset } from './asset.js'; -import { isExternalChainAsset } from './asset.js'; -import type { Logger } from './log/index.js'; -import type { BlockHash } from '@keetanetwork/keetanet-client/lib/block/index.js'; -import type { AnchorExternalInput } from './anchor-external.js'; -import type { AnchorMetadataLegalField } from './metadata.types.js'; -import { AnchorExternalBuilder } from './anchor-external.js'; - -type FXQuoteOrEstimate = NonNullable>>[number]; -type AssetMovementProvider = NonNullable>>[number]; -type AssetMovementTransfer = Awaited>; -type FXExchange = Awaited>; - -interface ChainStepResolutionBase { - type: Type; - valueIn: bigint; - valueOut: bigint; - step: Type extends 'keetaSend' ? null : ( - Type extends 'forwarded' ? AssetMovementGraphNode : Extract - ); -} - -interface ChainStepResolutionFX extends ChainStepResolutionBase<'fx'> { - type: 'fx'; - result: FXQuoteOrEstimate; -}; - -type SendingToType = 'SELF' | 'NEXT_STEP' | 'FINAL_DESTINATION'; - -interface ChainStepResolutionAssetMovement extends ChainStepResolutionBase<'assetMovement'> { - usingInstruction: AssetTransferInstructions; - sendingTo: SendingToType; - transfer: AssetMovementTransfer; - provider: AssetMovementProvider; -}; - -interface ChainStepResolutionKeetaSend extends ChainStepResolutionBase<'keetaSend'> { - usingInstruction: Extract; -}; - -interface ChainStepResolutionForwarded extends ChainStepResolutionBase<'forwarded'> { - persistentAddress: KeetaPersistentForwardingAddressDetails; - provider: AssetMovementProvider; -}; - -export type Disclaimer = Exclude[number]; -type ProviderDisclaimers = { - providerID: string; - disclaimers: Disclaimer[]; -} -type PlanDisclaimers = ProviderDisclaimers[]; - -export type ChainStepResolution = ChainStepResolutionFX | ChainStepResolutionAssetMovement | ChainStepResolutionKeetaSend | ChainStepResolutionForwarded; - -type AnchorChainingPathComputedPlan = { - steps: ChainStepResolution[]; - totalValueIn: bigint; - totalValueOut: bigint; -}; - -type ExecutedStepFX = { - type: 'fx'; - plan: ChainStepResolutionFX; - exchange: FXExchange; -}; - -type ExecutedStepAssetMovement = { - type: 'assetMovement'; - plan: ChainStepResolutionAssetMovement; -}; - -type ExecutedStepKeetaSend = { - type: 'keetaSend'; - plan: ChainStepResolutionKeetaSend; -}; - -type ExecutedStepForwarded = { - type: 'forwarded'; - plan: ChainStepResolutionForwarded; - observedTransaction: KeetaAssetMovementTransaction; -}; - - -export type ExecutedStep = ExecutedStepFX | ExecutedStepAssetMovement | ExecutedStepKeetaSend | ExecutedStepForwarded; - -export type AnchorChainingPathExecuteResult = { - steps: ExecutedStep[]; -}; - -export type AnchorChainingPathExecuteOptions = { - requireSendAuth?: boolean; -}; - -export type AnchorChainingPathState = - | { status: 'idle' } - | { status: 'executing'; completedSteps: ExecutedStep[]; currentStepIndex: number } - | { status: 'completed'; result: AnchorChainingPathExecuteResult } - | { status: 'failed'; error: Error; completedSteps: ExecutedStep[]; failedAtStepIndex: number }; - -interface StepNeededActionEventPayloadBase { - type: ActionType; - - markCompleted: (...args: CompletedPayload) => void; - markFailed: (error?: unknown) => void; - - action: ActionPayload; -} - -// eslint-disable-next-line @typescript-eslint/no-empty-object-type -interface StepNeededActionEventAssetMovement extends StepNeededActionEventPayloadBase<'assetMovementUserExecutionRequired', { assetMovementTransfer: AssetMovementTransfer; }, []> {} - -// eslint-disable-next-line @typescript-eslint/no-empty-object-type -interface StepNeededActionEventKeetaSend extends StepNeededActionEventPayloadBase<'keetaSendAuthRequired', { - sendToAddress: GenericAccount; - value: bigint; - token: TokenAddress; - external?: string; -}, [ { sent: boolean | BlockHash; }]> {} - -type StepNeededActionEventPayload = StepNeededActionEventKeetaSend | StepNeededActionEventAssetMovement; - -type AnchorChainingPathEventMap = { - stateChange: [state: AnchorChainingPathState]; - stepExecuted: [step: ExecutedStep, index: number]; - completed: [result: AnchorChainingPathExecuteResult]; - failed: [error: Error, completedSteps: ExecutedStep[], failedAtStepIndex: number]; - stepNeedsAction: [StepNeededActionEventPayload]; -} - -interface RailSupportedOperations { - createPersistentForwarding?: boolean; - initiateTransfer?: boolean; -} - -interface RailWithSupportedOperations { - rail: Rail; - supportedOperations?: RailSupportedOperations; -} - -interface AnchorChainingAssetAndLocation { - asset: AssetType; - location: Location; - rail: Rail; - supportedOperations?: RailSupportedOperations; - value?: bigint; - -} - -interface AnchorChainingDestination extends AnchorChainingAssetAndLocation { - recipient: RecipientResolved; -} - -interface AnchorChainingPathInput { - source: AnchorChainingAssetAndLocation; - destination: AnchorChainingDestination; -} - -export interface AnchorChainingConfig { - client: KeetaNet.UserClient; - resolver?: Resolver; - signer?: InstanceType; - account?: InstanceType; - logger?: Logger; -} - -interface BaseGraphNodeLike { - type: Type; - providerID: string; - - from: AnchorChainingAssetAndLocation; - to: AnchorChainingAssetAndLocation; -} - -// eslint-disable-next-line @typescript-eslint/no-empty-object-type -interface FXGraphNode extends BaseGraphNodeLike<'fx', Exclude> {} -// eslint-disable-next-line @typescript-eslint/no-empty-object-type -interface AssetMovementGraphNode extends BaseGraphNodeLike<'assetMovement', AnchorChainingAsset> {} -export type GraphNodeLike = FXGraphNode | AssetMovementGraphNode; - -type KeetaLocationLike = Extract | PickChainLocation<'keeta'>; -interface KeetaSendStepLike { - type: 'keetaSend'; - - providerID?: null; - - from: AnchorChainingAssetAndLocation; - to: AnchorChainingAssetAndLocation; -} - -export type AnchorChainingStepLike = GraphNodeLike | KeetaSendStepLike; - -export type AnchorChainingAsset = TokenAddress | ISOCurrencyCode | ExternalChainAsset; - -function areBothTokenAndEqual(a: string | TokenAddress, b: string | TokenAddress): boolean { - try { - const aParsed = KeetaNet.lib.Account.toAccount(a); - const bParsed = KeetaNet.lib.Account.toAccount(b); - - if (!aParsed.isToken() || !bParsed.isToken()) { - return(false); - } - - - return(aParsed.comparePublicKey(bParsed)); - } catch { - return(false); - } -} - -function isAnchorChainingAssetEqual(a: AnchorChainingAsset, b: AnchorChainingAsset): boolean { - if (typeof a === 'string' && typeof b === 'string' && a === b) { - return(true); - } else if (areBothTokenAndEqual(a, b)) { - return(true); - } else { - return(false); - } -} - -function nodeSideSupports(side: AnchorChainingAssetAndLocation, required: AnchorChainingAssetAndLocation): boolean { - if (side.rail !== required.rail) { - return(false); - } - - if (convertAssetLocationToString(side.location) !== convertAssetLocationToString(required.location)) { - return(false); - } - - if (!isAnchorChainingAssetEqual(side.asset, required.asset)) { - return(false); - } - - return(true); -} - -/** - * Returns true for nodes that keep assets on Keeta: FX nodes, plus - * asset-movement nodes whose from and to share the same Keeta chain location - * (custodial FX anchors that don't actually move funds off-chain). - */ -function isFXLikeNode(node: GraphNodeLike): boolean { - if (node.type === 'fx') { - return(true); - } - const fromStr = convertAssetLocationToString(node.from.location); - const toStr = convertAssetLocationToString(node.to.location); - return(fromStr === toStr && fromStr.startsWith('chain:keeta:')); -} - -interface AssetMovementResolvedRails { - common: RailWithSupportedOperations[]; - inbound: RailWithSupportedOperations[]; - outbound: RailWithSupportedOperations[]; -} - -export type AnchorChainingListAssetsSideFilter = { - location?: AssetLocationLike | undefined; - asset?: AnchorChainingAsset | undefined; - rail?: Rail | undefined; -}; - -type AnchorChainingListAssetsShared = { - maxStepCount?: number; - onlyAllowFXLike?: boolean; -}; - -export type AnchorChainingListAssetsFilter = - | ({ from: AnchorChainingListAssetsSideFilter; to?: never } & AnchorChainingListAssetsShared) - | ({ to: AnchorChainingListAssetsSideFilter; from?: never } & AnchorChainingListAssetsShared) - | ({ from?: never; to?: never } & AnchorChainingListAssetsShared); - -export type AnchorChainingResolveAssetsFilter = { - from?: AnchorChainingListAssetsSideFilter; - to?: AnchorChainingListAssetsSideFilter; - maxStepCount?: number; - onlyAllowFXLike?: boolean; -}; - -export interface AnchorChainingResolveAssetsResult { - from: AnchorChainingAssetInfo[]; - to: AnchorChainingAssetInfo[]; -} - -export interface AnchorChainingAssetInfo { - asset: AnchorChainingAsset; - location: AssetLocationLike; - rails: { - inbound: Rail[]; - outbound: Rail[]; - }; - - distance: { - pathLength: number; - } | null; -} - -type AnchorChainingAssetInfoWithMetadata = AnchorChainingAssetInfo & { - metadata?: AnchorTokenLocationMetadata; -} - -interface AnchorChainingResolveAssetsWithMetadataResult { - from: AnchorChainingAssetInfoWithMetadata[]; - to: AnchorChainingAssetInfoWithMetadata[]; -} - -type AnchorChainingWithMetadataOptions = { - providerID?: string; -}; - -type GetAccountForActionPayload = { - type: 'assetMovement'; - providerMethod: 'initiateTransfer'; - provider?: AssetMovementProvider; -} | { - type: 'fx'; - providerMethod: 'getAccountForAction'; -} - -type AccountLike = InstanceType | undefined | ((providerMethodPayload: GetAccountForActionPayload) => Promise | Account); -interface AnchorChainingAccountOverrides { - account?: AccountLike; - signer?: AccountLike; -} - -class AnchorGraph { - client: KeetaNet.UserClient; - resolver: Resolver; - logger?: Logger | undefined; - - readonly assetMovementClient: KeetaAssetMovementAnchorClient; - readonly fxClient: KeetaFXAnchorClient; - readonly #assetMovementProviderCache = new Map(); - readonly #assetNameCache = new Map(); - #graphNodePromise: Promise | null = null; - - constructor(args: { client: KeetaNet.UserClient; resolver: Resolver; logger?: Logger | undefined; }) { - this.resolver = args.resolver; - this.client = args.client; - this.logger = args.logger; - this.assetMovementClient = new KeetaAssetMovementAnchorClient(this.client, { - resolver: this.resolver, - ...(this.logger ? { logger: this.logger } : {}) - }); - this.fxClient = new KeetaFXAnchorClient(this.client, { - resolver: this.resolver, - ...(this.logger ? { logger: this.logger } : {}) - }); - } - - #assetLocationKey = (side: { asset: AnchorChainingAsset; location: AssetLocationLike }) => { - return(`${convertAssetSearchInputToCanonical(side.asset)}@${convertAssetLocationToString(side.location)}`); - }; - - async getAssetMovementProviderById(providerID: string): Promise { - let provider: KeetaAssetMovementAnchorProvider | undefined | null = this.#assetMovementProviderCache.get(providerID); - if (provider === undefined) { - provider = await this.assetMovementClient.getProviderByID(providerID); - } - - this.#assetMovementProviderCache.set(providerID, provider); - - return(provider); - } - - async getAssetMovementProvidersForAsset(asset: AnchorChainingAsset, location: AssetLocationLike): Promise { - let retval: null | { [providerID: string]: { provider: AssetMovementProvider; }} = null; - - for (const node of await this.computeGraphNodes()) { - if (node.type !== 'assetMovement') { - continue; - } - - for (const side of [ node.from, node.to ] as const) { - if (!isAnchorChainingAssetEqual(side.asset, asset) || convertAssetLocationToString(side.location) !== convertAssetLocationToString(location)) { - continue; - } - - if (!retval) { - retval = {}; - } - - if (!retval[node.providerID]) { - const provider = await this.getAssetMovementProviderById(node.providerID); - if (!provider) { - this.logger?.debug('AnchorGraph::getAssetMovementProvidersForAsset', `No provider found for providerID ${node.providerID}, although provider was previously known to exist in the graph nodes`); - continue; - } - - retval[node.providerID] = { provider }; - } - } - } - - return(retval); - } - - async #computeFXNodes() { - const fxServices = await this.resolver.lookup('fx', {}); - - if (!fxServices) { - return([]); - } - - const networkLocation = `chain:keeta:${this.client.network}` satisfies AssetLocationLike; - - const providerLookupResult = await Promise.all(Object.entries(fxServices).map(async ([ providerID, service ]) => { - const fromEntries = await service.from('array'); - - if (!fromEntries) { - return(null); - } - - const operations = await service.operations('object'); - if (!operations.createExchange) { - this.logger?.debug('AnchorGraph::computeFXNodes', `FX service ${providerID} does not support createExchange operation, skipping`); - return(null); - } - - const pathNodes = await Promise.all(fromEntries.map(async function(fromEntry) { - const pathNodesResult: GraphNodeLike[] = []; - - const parsedEntry = await fromEntry('object'); - - const [ fromCodes, toCodes ] = await Promise.all([ - parsedEntry.currencyCodes('array'), - parsedEntry.to('array') - ]); - - for (const from of fromCodes) { - const fromResolved = await from('string'); - if (!fromResolved) { - continue; - } - - const fromAccount = KeetaNet.lib.Account.fromPublicKeyString(fromResolved).assertKeyType(KeetaNet.lib.Account.AccountKeyAlgorithm.TOKEN); - - for (const to of toCodes) { - const toResolved = await to('string'); - if (!toResolved) { - continue; - } - - const toAccount = KeetaNet.lib.Account.fromPublicKeyString(toResolved).assertKeyType(KeetaNet.lib.Account.AccountKeyAlgorithm.TOKEN); - - if (fromAccount.comparePublicKey(toAccount)) { - continue; - } - - pathNodesResult.push({ - type: 'fx', - providerID: providerID, - from: { asset: fromAccount, location: networkLocation, rail: 'KEETA_SEND' }, - to: { asset: toAccount, location: networkLocation, rail: 'KEETA_SEND' } - }); - } - } - - return(pathNodesResult); - })); - - return(pathNodes.flat()); - })); - - return(providerLookupResult.flat().filter((node): node is GraphNodeLike => !!node)); - } - - async #resolveAssetName(name: MovableAssetSearchCanonical): Promise { - if (KeetaNet.lib.Account.isInstance(name) && name.isToken()) { - return(name); - } - - if (typeof name === 'string') { - try { - return(KeetaNet.lib.Account.fromPublicKeyString(name).assertKeyType(KeetaNet.lib.Account.AccountKeyAlgorithm.TOKEN)); - } catch { - /* ignore error and continue with other resolution methods */ - } - } - - let found = this.#assetNameCache.get(name); - if (found) { - return(found); - } - - if (isExternalChainAsset(name)) { - found = name; - } else if (Currency.isCurrencyCode(name)) { - found = name; - } else if (Currency.isISOCurrencyNumber(name)) { - found = new Currency(name).code; - } else { - const lookupRet = await this.resolver.lookupToken(name); - if (lookupRet) { - found = KeetaNet.lib.Account.toAccount(lookupRet.token); - } - } - - if (!found) { - throw(new Error(`Unable to resolve asset name: ${name}`)); - } - - this.#assetNameCache.set(name, found); - - return(found); - } - - async #computeAssetRails(assetInput: ToValuizable): Promise { - try { - const railResolved = await assetInput('string'); - - if (!isRail(railResolved)) { - throw(new Error(`Invalid rail format: ${railResolved}`)); - } - - return({ rail: railResolved }); - } catch { - /* ignore error */ - } - - const extendedDetailsResolved = await assetInput('object'); - - if (!extendedDetailsResolved || typeof extendedDetailsResolved !== 'object' || Array.isArray(extendedDetailsResolved)) { - throw(new Error(`Invalid asset format, expected string or object with extended details`)); - } - - if (!('rail' in extendedDetailsResolved)) { - throw(new Error(`Invalid asset format, missing 'rail' field in extended details`)); - } - - const railResolved = await extendedDetailsResolved.rail?.('string'); - - if (!isRail(railResolved)) { - throw(new Error(`Invalid rail format in extended details: ${railResolved}`)); - } - - let supportedOperations: RailSupportedOperations | undefined; - if ('supportedOperations' in extendedDetailsResolved && extendedDetailsResolved.supportedOperations) { - const opsResolved = await extendedDetailsResolved.supportedOperations('object'); - if (opsResolved && typeof opsResolved === 'object' && !Array.isArray(opsResolved)) { - const parsed: RailSupportedOperations = {}; - if ('createPersistentForwarding' in opsResolved && opsResolved.createPersistentForwarding) { - const val = await opsResolved.createPersistentForwarding('boolean'); - if (typeof val === 'boolean') { - parsed.createPersistentForwarding = val; - } - } - if ('initiateTransfer' in opsResolved && opsResolved.initiateTransfer) { - const val = await opsResolved.initiateTransfer('boolean'); - if (typeof val === 'boolean') { - parsed.initiateTransfer = val; - } - } - if (Object.keys(parsed).length > 0) { - supportedOperations = parsed; - } - } - } - - const result: RailWithSupportedOperations = { rail: railResolved }; - if (supportedOperations) { - result.supportedOperations = supportedOperations; - } - - return(result); - } - - async #computeAssetMovementPairSide(pairSideInput: ToValuizable): Promise<{ rails: AssetMovementResolvedRails; location: AssetLocationLike; id: AnchorChainingAsset; }> { - const pairSideResolved = await pairSideInput('object'); - - let location: AssetLocationLike; - if (pairSideResolved.location) { - const locationRaw = await pairSideResolved.location('string'); - if (!isAssetLocationLike(locationRaw)) { - throw(new Error(`Invalid location format: ${locationRaw}`)); - } - - location = locationRaw; - } else { - location = `chain:keeta:${this.client.network}`; - } - - const railsResolved = await pairSideResolved.rails('object'); - - const rails: AssetMovementResolvedRails = { - common: await Promise.all((await railsResolved.common?.('array'))?.map(async (commonInput) => { - return(await this.#computeAssetRails(commonInput)); - }) ?? []), - inbound: await Promise.all((await railsResolved.inbound?.('array'))?.map(async (commonInput) => { - return(await this.#computeAssetRails(commonInput)); - }) ?? []), - outbound: await Promise.all((await railsResolved.outbound?.('array'))?.map(async (commonInput) => { - return(await this.#computeAssetRails(commonInput)); - }) ?? []) - }; - - const id = await pairSideResolved.id('string'); - if (!isMovableAssetSearchCanonical(id)) { - throw(new Error(`Invalid asset id format: ${id}`)); - } - - return({ - rails: rails, - location: location, - id: await this.#resolveAssetName(id) - }); - } - - async #computeAssetMovementNodes() { - const assetMovementServices = await this.resolver.lookup('assetMovement', {}); - - if (!assetMovementServices) { - return([]); - } - - const providerResults = await Promise.all(Object.entries(assetMovementServices).map(async ([ providerID, service ]) => { - const supportedOperationsMetadata = await service.operations('object'); - - const supportedAssetsEntries = await service.supportedAssets('array'); - - if (!supportedAssetsEntries) { - this.logger?.debug('AnchorGraph::computeAssetMovementNodes', `No supported assets found for provider ${providerID}`); - return(null); - } - - const pathNodesResult = await Promise.all(supportedAssetsEntries.map(async (assetEntry): Promise => { - const parsedEntry = await assetEntry('object'); - - const pathsResolved = await parsedEntry.paths('array'); - const pathPromises = await Promise.allSettled(pathsResolved.map(async (pathResolvedInput): Promise => { - const pathResolved = await pathResolvedInput('object'); - - const pairResolved = await pathResolved.pair('array'); - - const [ fromResolved, toResolved ] = await Promise.all([ - this.#computeAssetMovementPairSide(pairResolved[0]), - this.#computeAssetMovementPairSide(pairResolved[1]) - ]); - - function getProviderSupportedOperationsForRail(railSpecific?: RailSupportedOperations): RailSupportedOperations { - const retval: RailSupportedOperations = { - createPersistentForwarding: supportedOperationsMetadata.createPersistentForwarding !== undefined, - initiateTransfer: supportedOperationsMetadata.initiateTransfer !== undefined - }; - - if (railSpecific) { - retval.createPersistentForwarding = railSpecific.createPersistentForwarding ?? false; - retval.initiateTransfer = railSpecific.initiateTransfer ?? false; - } - - return(retval); - } - - const pathNodes: GraphNodeLike[] = []; - for (const [ src, dest ] of [ - [ fromResolved, toResolved ], - [ toResolved, fromResolved ] - ] as const) { - for (const inboundRail of [ ...(src.rails.common ?? []), ...(src.rails.inbound ?? []) ]) { - /* - * Drop edges whose source rail explicitly cannot - * initiate a transfer and also cannot create a - * persistent forwarding address. - */ - const inboundSupportedOperations = getProviderSupportedOperationsForRail(inboundRail.supportedOperations); - if (inboundSupportedOperations.initiateTransfer === false && inboundSupportedOperations.createPersistentForwarding === false) { - this.logger?.debug('AnchorGraph::computeAssetMovementNodes', `Skipping ${providerID} edge from ${convertAssetLocationToString(src.location)} via rail ${inboundRail.rail}: neither initiateTransfer nor createPersistentForwarding supported`); - continue; - } - - for (const outboundRail of [ ...(dest.rails.common ?? []), ...(dest.rails.outbound ?? []) ]) { - pathNodes.push({ - type: 'assetMovement', - providerID: providerID, - from: { - asset: src.id, - location: src.location, - rail: inboundRail.rail, - supportedOperations: getProviderSupportedOperationsForRail(inboundRail.supportedOperations) - }, - to: { - asset: dest.id, - location: dest.location, - rail: outboundRail.rail, - supportedOperations: getProviderSupportedOperationsForRail(outboundRail.supportedOperations) - } - }); - } - } - - } - - return(pathNodes); - })); - - const allPaths = []; - - for (const resolved of pathPromises) { - if (resolved.status === 'rejected') { - this.logger?.debug('AnchorGraph::computeAssetMovementNodes', `error fetching nodes for ... TODO`, resolved.reason); - } else { - allPaths.push(...resolved.value); - } - } - - return(allPaths); - })); - - return(pathNodesResult.flat()); - })); - - return(providerResults.flat().filter((node): node is GraphNodeLike => !!node)); - } - - async computeGraphNodes(): Promise { - if (this.#graphNodePromise === null) { - this.#graphNodePromise = (async () => { - const receivedNodes = await Promise.all([ - this.#computeFXNodes(), - this.#computeAssetMovementNodes() - ]); - - return(receivedNodes.flat()); - })(); - } - - return(await this.#graphNodePromise); - } - - async findPaths(input: AnchorChainingPathInput): Promise { - const graph = await this.computeGraphNodes(); - - const nodesWithNext: { node: GraphNodeLike, next: number[] }[] = graph.map(function(node) { - return({ node, next: [] }); - }); - - for (const node of nodesWithNext) { - for (let secondNodeIdx = 0; secondNodeIdx < nodesWithNext.length; secondNodeIdx++) { - const nodeJ = nodesWithNext[secondNodeIdx]; - if (!nodeJ) { - continue; - } - - // We can ignore chaining one fx anchor to itself - if (node.node.type === 'fx') { - if (node.node.type === nodeJ.node.type && node.node.providerID === nodeJ.node.providerID) { - continue; - } - } - - if (nodeSideSupports(node.node.to, nodeJ.node.from)) { - node.next.push(secondNodeIdx); - } - } - } - - const paths: GraphNodeLike[][] = []; - - function getAssetLocationString(input: GraphNodeLike['to'], includeRail = false) { - let railStr = ''; - if (includeRail) { - railStr = `#${input.rail}`; - } - return(`${convertAssetSearchInputToCanonical(input.asset)}@${convertAssetLocationToString(input.location)}${railStr}`) - } - - function dfs( - currentIndex: number, - target: AnchorChainingAssetAndLocation, - visitedAssets = new Set(), - path: GraphNodeLike[] = [] - ) { - const cur = nodesWithNext[currentIndex]; - - if (!cur) { - throw(new Error(`Invalid node index: ${currentIndex}`)); - } - - const assetLocationStr = getAssetLocationString(cur.node.from, true); - if (visitedAssets.has(assetLocationStr)) { - return; - } - - visitedAssets.add(assetLocationStr); - - const newPath = [ ...path, cur.node ]; - - if (nodeSideSupports(cur.node.to, target)) { - paths.push(newPath); - } - - for (const nextIndex of nodesWithNext[currentIndex]?.next ?? []) { - dfs(nextIndex, target, visitedAssets, newPath); - } - - visitedAssets.delete(assetLocationStr); - } - - for (let index = 0; index < nodesWithNext.length; index++) { - const node = nodesWithNext[index]; - - if (!node) { - continue; - } - - if (nodeSideSupports(node.node.from, input.source)) { - dfs(index, input.destination); - } - } - - return(paths); - } - - async resolveAssets(filter: AnchorChainingResolveAssetsFilter = {}): Promise { - const { from: fromFilterInput, to: toFilterInput, maxStepCount, onlyAllowFXLike } = filter; - - const keetaNetworkLocation = `chain:keeta:${this.client.network}` satisfies AssetLocationLike; - - // When onlyAllowFXLike, default omitted locations to the Keeta network location - const fromFilter = (onlyAllowFXLike && fromFilterInput !== undefined && fromFilterInput.location === undefined) - ? { ...fromFilterInput, location: keetaNetworkLocation } - : fromFilterInput; - const toFilter = (onlyAllowFXLike && toFilterInput !== undefined && toFilterInput.location === undefined) - ? { ...toFilterInput, location: keetaNetworkLocation } - : toFilterInput; - - const nodes = await this.computeGraphNodes(); - - // Build forward (next) and backward (prev) adjacency in a single pass. - const nodesWithAdj: { node: GraphNodeLike; next: number[]; prev: number[] }[] = nodes.map(node => ({ node, next: [], prev: [] })); - for (let i = 0; i < nodesWithAdj.length; i++) { - for (let j = 0; j < nodesWithAdj.length; j++) { - const ni = nodesWithAdj[i]; - const nj = nodesWithAdj[j]; - if (!ni || !nj) { - throw(new Error(`Invalid node index during adjacency construction: ${i} or ${j}`)); - } - if (ni.node.type === 'fx' && nj.node.type === 'fx' && ni.node.providerID === nj.node.providerID) { - continue; - } - if (nodeSideSupports(ni.node.to, nj.node.from)) { - ni.next.push(j); - nj.prev.push(i); - } - } - } - - const sideMatchesFilter = ( - side: GraphNodeLike['from' | 'to'], - f: AnchorChainingListAssetsSideFilter - ): boolean => { - if (f.location !== undefined && convertAssetLocationToString(side.location) !== convertAssetLocationToString(f.location)) { - return(false); - } - if (f.asset !== undefined && !isAnchorChainingAssetEqual(side.asset, f.asset)) { - return(false); - } - if (f.rail !== undefined && side.rail !== f.rail) { - return(false); - } - return(true); - }; - - // Separate reachable sets and distance maps for backward (from) and forward (to) traversals. - const fromReachable = new Set(); - const fromDistances = new Map(); - const toReachable = new Set(); - const toDistances = new Map(); - - const makeMarkFn = (reachable: Set, distances: Map) => - (side: GraphNodeLike['from' | 'to'], depth?: number) => { - const key = this.#assetLocationKey(side); - reachable.add(key); - if (depth !== undefined) { - const existing = distances.get(key); - if (existing === undefined || depth < existing) { - distances.set(key, depth); - } - } - }; - - const markFromReachable = makeMarkFn(fromReachable, fromDistances); - const markToReachable = makeMarkFn(toReachable, toDistances); - - const bfs = ( - startCondition: (item: (typeof nodesWithAdj)[number]) => boolean, - adjacency: 'next' | 'prev', - markSide: 'from' | 'to', - markFn: (side: GraphNodeLike['from' | 'to'], depth: number) => void - ) => { - const nodeVisited = new Set(); - const queue: { nodeIdx: number; depth: number }[] = []; - for (let i = 0; i < nodesWithAdj.length; i++) { - const item = nodesWithAdj[i]; - if (!item) { - throw(new Error(`Invalid node index during BFS initialization: ${i}`)); - } - if (startCondition(item) && !nodeVisited.has(i)) { - nodeVisited.add(i); - queue.push({ nodeIdx: i, depth: 1 }); - } - } - while (queue.length > 0) { - const queueItem = queue.shift(); - if (!queueItem) { - throw(new Error(`Unexpected empty queue during BFS processing`)); - } - const { nodeIdx, depth } = queueItem; - const item = nodesWithAdj[nodeIdx]; - if (!item) { - throw(new Error(`Invalid node index during BFS processing: ${nodeIdx}`)); - } - if (onlyAllowFXLike && !isFXLikeNode(item.node)) { - continue; - } - markFn(item.node[markSide], depth); - if (maxStepCount === undefined || depth < maxStepCount) { - for (const neighborIdx of item[adjacency]) { - if (!nodeVisited.has(neighborIdx)) { - nodeVisited.add(neighborIdx); - queue.push({ nodeIdx: neighborIdx, depth: depth + 1 }); - } - } - } - } - }; - - if (fromFilter) { - bfs(item => sideMatchesFilter(item.node.from, fromFilter), 'next', 'to', markToReachable); - } - if (toFilter) { - bfs(item => sideMatchesFilter(item.node.to, toFilter), 'prev', 'from', markFromReachable); - } - if (!fromFilter && !toFilter) { - for (const { node } of nodesWithAdj) { - if (!onlyAllowFXLike || isFXLikeNode(node)) { - markFromReachable(node.from); - markFromReachable(node.to); - markToReachable(node.from); - markToReachable(node.to); - } - } - } - - // Second pass: build result maps by collecting inbound/outbound rails for every reachable - // (asset, location) pair from ALL graph nodes, not just those on the traversal path. - const buildResultMap = ( - reachable: Set, - distances: Map - ): Map => { - const resultMap = new Map(); - const getOrCreate = (side: { asset: AnchorChainingAsset; location: AssetLocationLike }): AnchorChainingAssetInfo => { - const key = this.#assetLocationKey(side); - let resultObj = resultMap.get(key); - if (!resultObj) { - const distanceValue = distances.get(key); - - resultObj = { - asset: side.asset, - location: side.location, - rails: { inbound: [], outbound: [] }, - distance: distanceValue !== undefined ? { pathLength: distanceValue } : null - }; - - resultMap.set(key, resultObj); - } - return(resultObj); - }; - for (const { node } of nodesWithAdj) { - if (onlyAllowFXLike && !isFXLikeNode(node)) { - continue; - } - if (reachable.has(this.#assetLocationKey(node.to))) { - const entry = getOrCreate(node.to); - if (!entry.rails.inbound.includes(node.to.rail)) { - entry.rails.inbound.push(node.to.rail); - } - } - if (reachable.has(this.#assetLocationKey(node.from))) { - const entry = getOrCreate(node.from); - if (!entry.rails.outbound.includes(node.from.rail)) { - entry.rails.outbound.push(node.from.rail); - } - } - } - return(resultMap); - }; - - const fromResultMap = buildResultMap(fromReachable, fromDistances); - const toResultMap = buildResultMap(toReachable, toDistances); - - // When onlyAllowFXLike, exclude the filter asset from the result set so that - // "what can USDC be swapped to?" doesn't include USDC itself via a round-trip. - if (onlyAllowFXLike) { - if (fromFilter?.asset !== undefined) { - toResultMap.delete(this.#assetLocationKey({ asset: fromFilter.asset, location: fromFilter.location ?? keetaNetworkLocation })); - } - if (toFilter?.asset !== undefined) { - fromResultMap.delete(this.#assetLocationKey({ asset: toFilter.asset, location: toFilter.location ?? keetaNetworkLocation })); - } - } - - const filterMap = ( - map: Map, - f: AnchorChainingListAssetsSideFilter, - railSide: 'inbound' | 'outbound' - ): AnchorChainingAssetInfo[] => - Array.from(map.values()).filter(info => { - if (f.location !== undefined && convertAssetLocationToString(info.location) !== convertAssetLocationToString(f.location)) { - return(false); - } - if (f.asset !== undefined && !isAnchorChainingAssetEqual(info.asset, f.asset)) { - return(false); - } - if (f.rail !== undefined && !info.rails[railSide].includes(f.rail)) { - return(false); - } - return(true); - }); - - const fromAssets = (fromFilter !== undefined && toFilter !== undefined) - ? filterMap(fromResultMap, fromFilter, 'outbound') - : Array.from(fromResultMap.values()); - const toAssets = (fromFilter !== undefined && toFilter !== undefined) - ? filterMap(toResultMap, toFilter, 'inbound') - : Array.from(toResultMap.values()); - - return({ from: fromAssets, to: toAssets }); - } - - async listAssets(filter: AnchorChainingListAssetsFilter = {}): Promise { - const result = await this.resolveAssets(filter); - - if (filter.from) { - return(result.to); - } else if (filter.to) { - return(result.from); - } else { - return(result.to); - } - } - - async getExternalAssetMetadata( - asset: AnchorChainingAssetInfo['asset'], - location: AnchorChainingAssetInfo['location'], - providerID?: string - ): Promise { - if (!isExternalChainAsset(asset)) { - return(undefined); - } - - const providers = await this.getAssetMovementProvidersForAsset(asset, location); - if (!providers) { - return(undefined); - } - - if (providerID) { - const found = providers[providerID]; - if (!found) { - return(undefined); - } - return(found.provider.getAssetMetadataForLocation(location, asset) ?? undefined); - } - - for (const { provider } of Object.values(providers)) { - const metadata = provider.getAssetMetadataForLocation(location, asset); - if (metadata) { - return(metadata); - } - } - - return(undefined); - } - - async #attachMetadata( - assetInfo: AnchorChainingAssetInfo, - options?: AnchorChainingWithMetadataOptions - ): Promise { - const metadata = await this.getExternalAssetMetadata(assetInfo.asset, assetInfo.location, options?.providerID); - if (!metadata) { - return(assetInfo); - } - return({ ...assetInfo, metadata }); - } - - async resolveAssetsWithMetadata( - filter: AnchorChainingResolveAssetsFilter = {}, - options?: AnchorChainingWithMetadataOptions - ): Promise { - const result = await this.resolveAssets(filter); - - const [from, to] = await Promise.all([ - Promise.all(result.from.map((info) => this.#attachMetadata(info, options))), - Promise.all(result.to.map((info) => this.#attachMetadata(info, options))) - ]); - - return({ from, to }); - } - - async listAssetsWithMetadata( - filter: AnchorChainingListAssetsFilter = {}, - options?: AnchorChainingWithMetadataOptions - ): Promise { - const result = await this.resolveAssetsWithMetadata(filter, options); - - if (filter.from) { - return(result.to); - } else if (filter.to) { - return(result.from); - } else { - return(result.to); - } - } -} - -export interface ComputePlanOptions { - overrides?: AnchorChainingAccountOverrides; - /** - * Limit the number of plans to calculate, defaults to 3 - */ - limit?: number; -} - -export class AnchorChainingPath { - readonly request: AnchorChainingPathInput; - readonly path: AnchorChainingStepLike[]; - readonly parent: AnchorChaining; - - constructor(input: { - request: AnchorChainingPathInput; - path: AnchorChainingStepLike[]; - parent: AnchorChaining; - }) { - this.request = input.request; - this.path = input.path; - this.parent = input.parent; - } - - get logger(): Logger | undefined { - return(this.parent['logger']); - } - - protected async getAccountLike(action: GetAccountForActionPayload, override?: AccountLike): Promise> { - let found: InstanceType | undefined = undefined; - - if (this.parent['client'].account.isAccount()) { - found = this.parent['client'].account; - } else if (this.parent['client'].signer !== null) { - found = this.parent['client'].signer; - } - - if (override) { - if (typeof override === 'function') { - found = await override(action); - } else { - found = override; - } - } - - if (!found) { - throw(new Error(`Could not get account for ${action.type} action ${action.providerMethod}`)); - } - - return(found); - } - - protected async getAccountsForAction(action: GetAccountForActionPayload, overrides?: AnchorChainingAccountOverrides): Promise<{ account: InstanceType; signer: InstanceType }> { - const [signer, account] = await Promise.all([ - this.getAccountLike(action, overrides?.signer), - this.getAccountLike(action, overrides?.account) - ]); - - return({ signer, account }); - } - - async getProviderLegalDisclaimers(): Promise { - const legalDisclaimerPromises: { key: string; promise: () => Promise }[] = []; - - for (const step of this.path) { - if (step.type === 'keetaSend') { - continue - } - - const key = `${step.type}:${step.providerID}`; - if (legalDisclaimerPromises.find(entry => entry.key === key)) { - continue - } - - const promise = async () => { - try { - let disclaimers: ProviderDisclaimers['disclaimers'] | null | undefined = null; - if (step.type === 'assetMovement') { - const provider = await this.parent.graph.getAssetMovementProviderById(step.providerID); - disclaimers = provider?.getLegalDisclaimers(); - } else { - disclaimers = await this.parent.graph.fxClient.getLegalDisclaimersById(step.providerID); - } - - if (!disclaimers) { - return(null); - } - - return({ providerID: step.providerID, disclaimers }); - } catch (error) { - this.logger?.debug(`AnchorChainingPath::getProviderLegalDisclaimers`, `Error getting provider disclaimers for providerId: ${step.providerID}`, error); - throw(error) - } - } - - legalDisclaimerPromises.push({ key, promise }); - } - - try { - const disclaimersOrNull = await Promise.all(legalDisclaimerPromises.map((entry) => entry.promise())); - const disclaimers = disclaimersOrNull.filter((entry) => entry !== null); - return(disclaimers); - } catch (error) { - this.logger?.debug(`AnchorChainingPath::getProviderLegalDisclaimers`, 'Error getting legal disclaimers for path', error); - return(null); - } - } -} - -export class AnchorChainingPlan extends AnchorChainingPath { - #_plan: AnchorChainingPathComputedPlan | null = null; - - #state: AnchorChainingPathState = { status: 'idle' }; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - #listeners = new Map void)>>(); - #options: ComputePlanOptions | undefined = undefined; - - private constructor(path: AnchorChainingPath, options?: ComputePlanOptions) { - super({ ...path }); - this.#options = options; - } - - get plan(): AnchorChainingPathComputedPlan { - if (!this.#_plan) { - throw(new Error(`Steps have not been computed yet`)); - } - - return(this.#_plan); - } - - async #computePlan() { - if (this.#_plan) { - throw(new Error(`Steps have already been computed`)); - } - - const sharedClientOptions = { - resolver: this.parent['resolver'], - ...(this.parent['logger'] ? { logger: this.parent['logger'] } : {}) - } as const; - - const fxClient = new KeetaFXAnchorClient(this.parent['client'], sharedClientOptions); - - const assetMovementClient = new KeetaAssetMovementAnchorClient(this.parent['client'], sharedClientOptions); - - let affinityAndAmount: { affinity: 'to' | 'from'; amount: bigint } | undefined = undefined; - - if (this.request.source.value !== undefined && this.request.destination.value !== undefined) { - throw(new Error('Must have source.value or destination.value but not both')); - } else if (this.request.source.value !== undefined) { - affinityAndAmount = { - affinity: 'from', - amount: this.request.source.value - } - } else if (this.request.destination.value !== undefined) { - affinityAndAmount = { - affinity: 'to', - amount: this.request.destination.value - } - } else { - throw(new Error('Must have source.value or destination.value')); - } - - const { affinity } = affinityAndAmount - - const findInstruction = (allInstructions: AssetTransferInstructions[], type: R): Extract => { - const found = allInstructions.find((instr): instr is Extract => { - return(instr.type === type); - }); - - if (!found) { - throw(new Error(`Expected to find instruction of type ${type} in next step's instructions`)); - } - - return(found); - }; - - /** - * Resolve persistent forwarding addresses for path steps whose source rail - * cannot accept an initiated transfer. - */ - type ForwardedStepInfo = { provider: AssetMovementProvider; persistentAddress: KeetaPersistentForwardingAddressDetails }; - const forwardedSteps = new Map(); - for (let scanIndex = 0; scanIndex < this.path.length; scanIndex++) { - const scanStep = this.path[scanIndex]; - if (!scanStep || scanStep.type !== 'assetMovement') { - continue; - } - - /** - * PFR is selected in two cases: - * (a) the source rail explicitly cannot accept a managed transfer. - * (b) the prior step is also an asset-movement step (AMP -> AMP - * transition) and the source rail supports PFR. - */ - const priorStep = scanIndex > 0 ? this.path[scanIndex - 1] : null; - const isAmpToAmpTransition = priorStep?.type === 'assetMovement'; - const pfrSupported = scanStep.from.supportedOperations?.createPersistentForwarding === true; - const initiateForbidden = scanStep.from.supportedOperations?.initiateTransfer === false; - - const shouldUsePFR = initiateForbidden || (isAmpToAmpTransition && pfrSupported); - if (!shouldUsePFR) { - continue; - } - if (!pfrSupported) { - throw(new Error(`Asset movement provider ${scanStep.providerID} source rail ${scanStep.from.rail} at ${convertAssetLocationToString(scanStep.from.location)} declares initiateTransfer:false but does not support createPersistentForwarding`)); - } - - if (scanIndex !== this.path.length - 1) { - throw(new Error(`Persistent-forwarding (PersistentForwardingRelay-only) asset movement steps are currently only supported as the last step in a chain (step ${scanIndex} of ${this.path.length})`)); - } - - const destinationAddress = this.request.destination.recipient; - if (typeof destinationAddress !== 'string') { - throw(new Error(`Persistent-forwarding step at index ${scanIndex} requires the chain's destination recipient to be a resolved address string`)); - } - - const forwardedAssetPair = { from: scanStep.from.asset, to: scanStep.to.asset }; - const forwardedProviders = await assetMovementClient.getProvidersForTransfer( - { asset: forwardedAssetPair, from: scanStep.from.location, to: scanStep.to.location }, - { providerIDs: [ scanStep.providerID ] } - ); - if (!forwardedProviders?.[0] || forwardedProviders.length === 0) { - throw(new Error(`Could not get asset movement provider ${scanStep.providerID} for persistent-forwarding step at index ${scanIndex}`)); - } - - const forwardedProvider = forwardedProviders[0]; - if (!await forwardedProvider.isOperationSupported('createPersistentForwarding')) { - throw(new Error(`Asset movement provider ${scanStep.providerID} does not support createPersistentForwarding, but the source rail ${scanStep.from.rail} at ${convertAssetLocationToString(scanStep.from.location)} requires it (initiateTransfer is unsupported)`)); - } - if (!await forwardedProvider.isOperationSupported('simulateTransfer')) { - throw(new Error(`Asset movement provider ${scanStep.providerID} does not support simulateTransfer, which is required to compute valueOut for a persistent-forwarding step at ${convertAssetLocationToString(scanStep.from.location)}`)); - } - - const { signer: forwardedSigner } = await this.getAccountsForAction({ - type: 'assetMovement', - providerMethod: 'initiateTransfer', - provider: forwardedProvider - }, this.#options?.overrides); - - let persistentAddress: KeetaPersistentForwardingAddressDetails | undefined; - if (await forwardedProvider.isOperationSupported('listPersistentForwarding')) { - try { - const existing = await forwardedProvider.listForwardingAddresses({ - account: forwardedSigner, - search: [{ - sourceLocation: scanStep.from.location, - destinationLocation: scanStep.to.location, - asset: scanStep.from.asset, - destinationAddress - }] - }); - - /* - * Filter to the exact address this step requires. - */ - const sourceLocationString = convertAssetLocationToString(scanStep.from.location); - const destLocationString = convertAssetLocationToString(scanStep.to.location); - const match = existing.addresses.find(addr => { - if (addr.destinationAddress !== destinationAddress) { - return(false); - } - if (!addr.sourceLocation || convertAssetLocationToString(addr.sourceLocation) !== sourceLocationString) { - return(false); - } - if (!addr.destinationLocation || convertAssetLocationToString(addr.destinationLocation) !== destLocationString) { - return(false); - } - - return(true); - }); - if (match) { - persistentAddress = match; - } - } catch (error) { - this.logger?.debug('AnchorChainingPlan::computePlan', `listForwardingAddresses lookup failed for step ${scanIndex}, will create a new address`, error); - } - } - - if (!persistentAddress) { - persistentAddress = await forwardedProvider.createPersistentForwardingAddress({ - account: forwardedSigner, - sourceLocation: scanStep.from.location, - destinationLocation: scanStep.to.location, - destinationAddress, - asset: forwardedAssetPair - }); - } - - if (typeof persistentAddress.address !== 'string') { - throw(new Error(`Persistent forwarding address for step ${scanIndex} is not a resolved string (got ${typeof persistentAddress.address})`)); - } - - forwardedSteps.set(scanIndex, { provider: forwardedProvider, persistentAddress }); - } - - const stepPromises: Promise[] = []; - const resolvingSteps = new Set(); - const precomputedValueOuts = new Map(); - const resolveStep = async (index: number): Promise => { - const step = this.path[index]; - - if (!step) { - throw(new Error(`Step ${index} is not defined`)); - } - - /* - * Detect cycles - */ - if (resolvingSteps.has(index)) { - throw(new Error(`Cyclic dependency detected in resolveStep: step ${index} is already being resolved`)); - } - - let promise: Promise | undefined = stepPromises[index]; - - if (!promise) { - resolvingSteps.add(index); - - promise = (async (): Promise => { - if (step.type === 'fx') { - let amount; - - if (affinity === 'from') { - if (index === 0) { - amount = affinityAndAmount.amount; - } else { - const previous = await resolveStep(index - 1); - amount = previous.valueOut; - } - } else if (affinity === 'to') { - if (index === (this.path.length - 1)) { - // XXX:TODO Move this to destination - amount = affinityAndAmount.amount; - } else { - const next = await resolveStep(index + 1); - amount = next.valueIn; - } - } else { - assertNever(affinity); - } - - const fxAccountOptions = await this.getAccountsForAction({ - type: 'fx', - providerMethod: 'getAccountForAction' - }, this.#options?.overrides); - - const quotesOrEstimates = await fxClient.getQuotesOrEstimates( - { from: step.from.asset, to: step.to.asset, amount, affinity }, - fxAccountOptions, - { providerIDs: [ step.providerID ] } - ); - - if (!quotesOrEstimates?.[0] || quotesOrEstimates.length === 0) { - throw(new Error(`Could not get FX quote/estimate for provider ${step.providerID}`)); - } - - const result = quotesOrEstimates[0]; - - if (!result.isQuote && result.estimate.canPerformExchange === false) { - throw(new Error(`FX estimate from provider ${step.providerID} indicates exchange cannot be performed`)); - } - - const convertedAmount = result.isQuote ? result.quote.convertedAmount : result.estimate.convertedAmount; - - let valueIn; - let valueOut; - - if (affinity === 'to') { - valueOut = amount; - valueIn = convertedAmount; - } else if (affinity === 'from') { - valueOut = convertedAmount; - valueIn = amount; - } else { - assertNever(affinity); - } - - return({ type: 'fx', step, valueIn, valueOut, result }); - } else if (step.type === 'assetMovement') { - if (affinity === 'to') { - throw(new Error(`Chaining with affinity 'to' is not currently supported for asset movement steps, as it requires looking up transfer quotes/estimates which is not currently implemented`)); - } - - let depositValue: bigint; - if (index === 0) { - depositValue = affinityAndAmount.amount; - } else { - const precomputedPrev = precomputedValueOuts.get(index - 1); - if (precomputedPrev !== undefined) { - depositValue = precomputedPrev; - } else { - const previous = await resolveStep(index - 1); - depositValue = previous.valueOut; - } - } - - const assetPair = { from: step.from.asset, to: step.to.asset }; - - /* - * Forwarded step: prior step deposits into a pre-resolved persistent address. - */ - const forwardedInfo = forwardedSteps.get(index); - if (forwardedInfo) { - const { provider: forwardedProvider, persistentAddress } = forwardedInfo; - - /* - * Best-effort plan-time valueOut: simulateTransfer if available, - * otherwise assume no rail fee. - */ - let estimatedValueOut = depositValue; - if (await forwardedProvider.isOperationSupported('simulateTransfer')) { - try { - const { signer: forwardedSigner } = await this.getAccountsForAction({ - type: 'assetMovement', - providerMethod: 'initiateTransfer', - provider: forwardedProvider - }, this.#options?.overrides); - const simulated = await forwardedProvider.simulateTransfer({ - account: forwardedSigner, - asset: assetPair, - from: { location: step.from.location }, - to: { location: step.to.location }, - value: depositValue - }); - - const simulatedInstruction = simulated.instructions.find((instr): instr is Extract => instr.type === step.from.rail); - let simulatedTotalReceive: string | undefined; - if (simulatedInstruction) { - simulatedTotalReceive = simulatedInstruction.totalReceiveAmount; - if (simulatedTotalReceive === undefined && 'value' in simulatedInstruction) { - simulatedTotalReceive = simulatedInstruction.value; - } - } - if (simulatedTotalReceive !== undefined) { - estimatedValueOut = BigInt(simulatedTotalReceive); - } - } catch (error) { - this.logger?.debug('AnchorChainingPlan::resolveStep', `simulateTransfer for forwarded step ${index} valueOut estimation failed; falling back to depositValue`, error); - } - } - - return({ - type: 'forwarded', - step, - valueIn: depositValue, - valueOut: estimatedValueOut, - persistentAddress, - provider: forwardedProvider - }); - } - - const providers = await assetMovementClient.getProvidersForTransfer( - { asset: assetPair, from: step.from.location, to: step.to.location }, - { providerIDs: [ step.providerID ] } - ); - - if (!providers?.[0] || providers.length === 0) { - throw(new Error(`Could not get asset movement provider ${step.providerID}`)); - } - const provider = providers[0]; - - const { signer } = await this.getAccountsForAction({ - type: 'assetMovement', - providerMethod: 'initiateTransfer', - provider - }, this.#options?.overrides); - - let resolvedRecipient: RecipientResolved | GenericAccount; - let sendingToType: SendingToType; - - if (index === this.path.length - 1) { - resolvedRecipient = this.request.destination.recipient; - sendingToType = 'FINAL_DESTINATION'; - } else { - sendingToType = 'NEXT_STEP'; - - const nextPathStep = this.path[index + 1]; - - if (!nextPathStep) { - throw(new Error(`Expected next step at index ${index + 1} for asset movement step at index ${index}`)); - } - - /* - * Next step is forwarded: recipient is its persistent address, - * no need to resolve the next step's instructions. - */ - const nextForwardedInfo = forwardedSteps.get(index + 1); - if (nextForwardedInfo) { - const pfiAddress = nextForwardedInfo.persistentAddress.address; - if (typeof pfiAddress !== 'string') { - throw(new Error(`Persistent forwarding address for next step ${index + 1} is not a resolved string`)); - } - - resolvedRecipient = pfiAddress; - } else if (nextPathStep.from.location === `chain:keeta:${this.parent['client'].network}`) { - const { account } = await this.getAccountsForAction({ - type: 'assetMovement', - providerMethod: 'initiateTransfer' - }, this.#options?.overrides); - - // Store funds in-transit in the account instead of forwarding directly to provider. - resolvedRecipient = account; - } else { - /** - * If the provider does not support simulateTransfer, - * we cannot chain to this step. - */ - if (!await provider.isOperationSupported('simulateTransfer')) { - throw(new Error(`Asset movement provider ${step.providerID} does not support simulateTransfer, which is required for chaining at non-keeta intermediate location ${convertAssetLocationToString(nextPathStep.from.location)}`)); - } - - const simulated = await provider.simulateTransfer({ - account: signer, - asset: assetPair, - from: { location: step.from.location }, - to: { location: step.to.location }, - value: depositValue - }); - - const simulatedInstruction = simulated.instructions.find((instr): instr is Extract => instr.type === step.from.rail); - if (!simulatedInstruction) { - throw(new Error(`Simulated transfer for step ${index} did not return an instruction matching rail ${step.from.rail}`)); - } - - let simulatedTotalReceive: string | undefined = simulatedInstruction.totalReceiveAmount; - if (simulatedTotalReceive === undefined && 'value' in simulatedInstruction) { - simulatedTotalReceive = simulatedInstruction.value; - } - if (simulatedTotalReceive === undefined) { - throw(new Error(`totalReceiveAmount must be defined for simulated transfer when chaining`)); - } - - precomputedValueOuts.set(index, BigInt(simulatedTotalReceive)); - - const nextStep = await resolveStep(index + 1); - - if (nextStep.type === 'assetMovement' || nextStep.type === 'keetaSend') { - if (nextStep.usingInstruction.type !== step.to.rail) { - throw(new Error(`Next step's usingInstruction type ${nextStep.usingInstruction.type} does not match expected ${step.to.rail} for recipient resolution`)); - } - - const foundInstruction = nextStep.usingInstruction; - - const isFiatPushRailFoundInstruction = (input: AssetTransferInstructions | SimulatedAssetTransferInstructions): input is Extract => { - return(isFiatRail(input.type)); - } - - if (foundInstruction.type === 'KEETA_SEND') { - throw(new Error(`Cannot currently chain from asset movement to KEETA_SEND step, as this implies multiple keeta locations in the path which is not currently supported`)); - } else if (isFiatPushRailFoundInstruction(foundInstruction)) { - if (foundInstruction.depositMessage) { - throw(new Error(`Deposit message outbound is not currently supported for chaining`)); - } - resolvedRecipient = foundInstruction.account; - } else if (foundInstruction.type === 'EVM_SEND') { - resolvedRecipient = foundInstruction.sendToAddress; - } else { - throw(new Error(`Unsupported rail for chaining: ${step.to.rail}`)); - } - } else if (nextStep.type === 'fx') { - throw(new Error(`Cannot currently chain from asset movement to fx step, as fx step does not have recipient information`)); - } else if (nextStep.type === 'forwarded') { - throw(new Error(`Internal invariant violation: forwarded step at index ${index + 1} reached simulate-cycle-break path; expected nextForwardedInfo branch to have handled it`)); - } else { - assertNever(nextStep); - } - } - } - - const recipientString = KeetaNet.lib.Account.isInstance(resolvedRecipient) - ? resolvedRecipient.publicKeyString.get() - : resolvedRecipient; - - const transfer = await provider.initiateTransfer({ - account: signer, - asset: assetPair, - from: { location: step.from.location }, - to: { - location: step.to.location, - recipient: recipientString - }, - value: depositValue - }); - - const usingInstruction = findInstruction(transfer.instructions, step.from.rail); - - let totalReceiveAmount: string | undefined = usingInstruction.totalReceiveAmount; - if (totalReceiveAmount === undefined && 'value' in usingInstruction) { - totalReceiveAmount = usingInstruction.value; - } - if (totalReceiveAmount === undefined) { - throw(new Error(`totalReceiveAmount must be defined for chaining`)); - } - - const actualValueOut = BigInt(totalReceiveAmount); - - // If we simulated to break a cycle, the next step's initiateTransfer was - // keyed off the simulated valueOut; a mismatch here means the next step - // is now misaligned, so fail at plan-time instead of letting execute() catch it. - const simulatedValueOut = precomputedValueOuts.get(index); - if (simulatedValueOut !== undefined && simulatedValueOut !== actualValueOut) { - throw(new Error(`Simulated valueOut ${simulatedValueOut} for step ${index} does not match actual ${actualValueOut} from initiateTransfer`)); - } - - return({ - type: 'assetMovement', - step: step, - valueIn: depositValue, - usingInstruction: usingInstruction, - transfer: transfer, - sendingTo: sendingToType, - valueOut: actualValueOut, - provider: provider - }) - } else if (step.type === 'keetaSend') { - if (this.path.length !== 1) { - throw(new Error(`Direct same-location/same-asset send steps must be the only step in the path`)); - } - - if (!KeetaNet.lib.Account.isInstance(step.from.asset) || !KeetaNet.lib.Account.isInstance(step.to.asset)) { - throw(new Error(`Expected assets to be token accounts for KEETA_SEND rail`)); - } - - if (!step.from.asset.comparePublicKey(step.to.asset)) { - throw(new Error(`For KEETA_SEND step, from and to asset must be the same account`)); - } - - let keetaRecipientDestination = null; - if (KeetaNet.lib.Account.isInstance(this.request.destination.recipient)) { - keetaRecipientDestination = this.request.destination.recipient; - } else if (typeof this.request.destination.recipient === 'string') { - try { - keetaRecipientDestination = KeetaNet.lib.Account.fromPublicKeyString(this.request.destination.recipient); - } catch { - /* ignore errors */ - } - } - if (!keetaRecipientDestination) { - throw(new Error(`Expected destination recipient to be a public key string for KEETA_SEND step`)); - } - - return({ - type: 'keetaSend', - step: null, - valueIn: affinityAndAmount.amount, - valueOut: affinityAndAmount.amount, - usingInstruction: { - type: 'KEETA_SEND', - tokenAddress: step.to.asset.publicKeyString.get(), - sendToAddress: keetaRecipientDestination.publicKeyString.get(), - totalReceiveAmount: affinityAndAmount.amount.toString(), - location: `chain:keeta:${this.parent['client'].network}`, - value: String(affinityAndAmount.amount), - assetFee: '0' - } - }) - } else { - assertNever(step); - } - })(); - - promise.then(() => resolvingSteps.delete(index), () => resolvingSteps.delete(index)); - stepPromises[index] = promise; - } - - return(await promise); - } - - const steps = []; - for (let index = 0; index < this.path.length; index++) { - steps.push(await resolveStep(index)); - } - - // Direct same-location/same-asset send: no provider steps needed. - if (steps.length === 0) { - return({ - steps: [], - totalValueIn: affinityAndAmount.amount, - totalValueOut: affinityAndAmount.amount - }); - } - - const firstStep = steps[0]; - const lastStep = steps[steps.length - 1]; - - if (!firstStep || !lastStep) { - throw(new Error(`Steps array is empty`)); - } - - if (affinity === 'from') { - if (firstStep.valueIn !== this.request.source.value) { - throw(new Error(`Computed valueIn for first step ${firstStep.valueIn} does not match request source value ${this.request.source.value}`)); - } - } else if (affinity === 'to') { - if (lastStep.valueOut !== this.request.destination.value) { - throw(new Error(`Computed valueOut for last step ${lastStep.valueOut} does not match requested destination value ${this.request.destination.value}`)); - } - } - - if (lastStep.valueOut <= 0n) { - throw(new Error(`Computed valueOut for last step must be greater than 0, got ${lastStep.valueOut}`)); - } - - return({ - steps, - totalValueIn: firstStep.valueIn, - totalValueOut: lastStep.valueOut - }); - } - - static async create(path: AnchorChainingPath, options?: ComputePlanOptions): Promise { - const instance = new this(path, options); - instance.#_plan = await instance.#computePlan(); - return(instance); - } - - get state(): AnchorChainingPathState { - return(this.#state); - } - - on(event: E, listener: (...args: AnchorChainingPathEventMap[E]) => void): void { - let listenerSet = this.#listeners.get(event); - if (!listenerSet) { - listenerSet = new Set(); - this.#listeners.set(event, listenerSet); - } - listenerSet.add(listener); - } - - off(event: E, listener: (...args: AnchorChainingPathEventMap[E]) => void): void { - this.#listeners.get(event)?.delete(listener); - } - - #setState(state: AnchorChainingPathState): void { - this.#state = state; - this.#emit('stateChange', state); - } - - get logger(): Logger | undefined { - return(this.parent['logger']); - } - - #emit(event: E, ...args: AnchorChainingPathEventMap[E]): { sendCount: number; } { - let sendCount = 0; - - for (const listener of (this.#listeners.get(event) ?? [])) { - try { - listener(...args); - sendCount++; - } catch (err) { - this.logger?.debug(`AnchorChainingPath::emit`, `Error in listener for event '${event}'`, err); - } - } - - return({ sendCount }); - } - - async #awaitStepCompletion< - Type extends StepNeededActionEventPayload['type'], - Ret extends Parameters['markCompleted']> - >(step: Pick, 'action' | 'type'>): Promise { - let didComplete = false; - - function assertDidNotComplete() { - if (didComplete) { - throw(new Error(`Step was already marked as completed or failed`)); - } - - didComplete = true; - } - - let resolveFn: undefined | StepNeededActionEventPayload['markCompleted']; - let rejectFn: undefined | StepNeededActionEventPayload['markFailed']; - - const promise = new Promise(function(resolve, reject) { - resolveFn = (...args: Ret) => { - assertDidNotComplete(); - resolve(args); - }; - - rejectFn = (error) => { - assertDidNotComplete(); - - let usingErr = error; - if (!usingErr) { - usingErr = new Error(`Step marked as failed without error`); - } - - // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors - reject(usingErr); - } - }); - - if (!resolveFn || !rejectFn) { - throw(new Error(`Failed to create step completion promise`)); - } - - // Typescript Cannot infer the correct payload type for the stepNeedsAction event, so we have to assert it here. We ensure type safety by constraining the step parameter to the correct action type, which guarantees that the payload will match the expected structure for that action type. - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - const { sendCount } = this.#emit('stepNeedsAction', { - ...step, - markCompleted: resolveFn, - markFailed: rejectFn - } as Extract); - - if (sendCount === 0) { - throw(new Error(`No listeners for stepNeedsAction event, but a step (actionType=${step.type}) is awaiting completion`)); - } - - return(await promise); - } - - async #authorizedSend(options: Pick | undefined, sendToAddress: string | GenericAccount, value: bigint, token: TokenAddress | string, external?: string): Promise { - if (options?.requireSendAuth) { - await this.#awaitStepCompletion({ - type: 'keetaSendAuthRequired', - action: { - sendToAddress: KeetaNet.lib.Account.toAccount(sendToAddress), - value, - token: KeetaNet.lib.Account.toAccount(token).assertKeyType(KeetaNet.lib.Account.AccountKeyAlgorithm.TOKEN), - ...(external !== undefined ? { external } : {}) - } - }); - } - - const { account } = await this.getAccountsForAction({ type: 'assetMovement', providerMethod: 'initiateTransfer' }, this.#options?.overrides); - const published = await this.parent['client'].send(sendToAddress, value, token, external, { account }); - let publishedBlocks; - if ('blocks' in published) { - publishedBlocks = published.blocks; - } else { - publishedBlocks = published.voteStaple.blocks; - } - - const sendBlock = publishedBlocks[0]; - if (sendBlock === undefined) { - return(undefined); - } - - return(sendBlock.hash.toString()); - } - - /** - * Construct the unsigned external envelope for a user-funded KEETA_SEND. - */ - static async #buildKeetaSendExternal(provider: AssetMovementProvider, transactionID: string, inputs: readonly AnchorExternalInput[]): Promise { - const anchorKey = provider.serviceInfo.account; - if (anchorKey === undefined) { - return(undefined); - } - - const anchor = KeetaNet.lib.Account.fromPublicKeyString(anchorKey); - const builder = new AnchorExternalBuilder().setAnchor(anchor, { transactionId: transactionID }); - - for (const input of inputs) { - builder.addInput(input.blockHash, input.operationIndex); - } - - const external = await builder.build(); - return(external); - } - - async #pollTransferStatus( - transfer: AssetMovementTransfer, - options?: { intervalMs?: number; timeoutMs?: number; abortSignal?: AbortSignal; } - ): Promise>> { - const intervalMs = options?.intervalMs ?? 2000; - const timeoutMs = options?.timeoutMs ?? 300_000; - const deadline = Date.now() + timeoutMs; - - while (true) { - if (options?.abortSignal?.aborted) { - throw(new Error(`Aborted while waiting for transfer ${transfer.transferID} to complete`)); - } - - const status = await transfer.getTransferStatus(); - if (status.transaction.status === 'COMPLETE') { - return(status); - } - if (Date.now() >= deadline) { - throw(new Error(`Timed out waiting for transfer ${transfer.transferID} to complete`)); - } - await KeetaNet.lib.Utils.Helper.asleep(intervalMs); - } - } - - /** - * Wait for the forwarded transfer the bridge creates after observing the - * prior step's withdraw deposit in the persistent-forwarding address. - */ - async #pollForwardedTransaction( - step: ChainStepResolutionForwarded, - sourceTransaction: { location: AssetLocationLike; transaction: { id: string }}, - options?: { intervalMs?: number; timeoutMs?: number; abortSignal?: AbortSignal; } - ): Promise { - const intervalMs = options?.intervalMs ?? 2000; - const timeoutMs = options?.timeoutMs ?? 300_000; - const deadline = Date.now() + timeoutMs; - - const { provider, persistentAddress } = step; - const pfiAddress = persistentAddress.address; - if (typeof pfiAddress !== 'string') { - throw(new Error(`Persistent forwarding address must be a resolved string`)); - } - - const { account } = await this.getAccountsForAction({ - type: 'assetMovement', - providerMethod: 'initiateTransfer', - provider - }, this.#options?.overrides); - - while (true) { - if (options?.abortSignal?.aborted) { - throw(new Error(`Aborted while waiting for forwarded transaction at ${pfiAddress} correlated to source tx ${sourceTransaction.transaction.id}`)); - } - - let transactions: KeetaAssetMovementTransaction[] = []; - try { - const response = await provider.listTransactions({ - account, - persistentAddresses: [{ - location: step.step.from.location, - persistentAddress: pfiAddress - }], - transactions: [ sourceTransaction ] - }); - - transactions = response.transactions; - } catch (error) { - this.logger?.debug('AnchorChainingPlan::pollForwardedTransaction', `listTransactions failed for PersistentForwardingRelay address ${pfiAddress}`, error); - } - - const candidate = transactions.find(tx => tx.status === 'COMPLETE'); - if (candidate) { - return(candidate); - } - - if (Date.now() >= deadline) { - throw(new Error(`Timed out waiting for persistent-forwarding transaction at ${pfiAddress} correlated to source tx ${sourceTransaction.transaction.id}`)); - } - - await KeetaNet.lib.Utils.Helper.asleep(intervalMs); - } - } - - async #pollExchangeStatus( - exchange: FXExchange, - options?: { intervalMs?: number; timeoutMs?: number; abortSignal?: AbortSignal; } - ): Promise>, { status: 'completed' }>> { - const intervalMs = options?.intervalMs ?? 2000; - const timeoutMs = options?.timeoutMs ?? 300_000; - const deadline = Date.now() + timeoutMs; - - while (true) { - if (options?.abortSignal?.aborted) { - throw(new Error(`Aborted while waiting for FX exchange ${exchange.exchange.exchangeID} to complete`)); - } - - const status = await exchange.getExchangeStatus(); - if (status.status === 'completed') { - return(status); - } - if (status.status === 'failed') { - throw(new Error(`FX exchange ${exchange.exchange.exchangeID} failed`)); - } - if (Date.now() >= deadline) { - throw(new Error(`Timed out waiting for FX exchange ${exchange.exchange.exchangeID} to complete`)); - } - - await KeetaNet.lib.Utils.Helper.asleep(intervalMs); - } - } - - async execute(options?: { requireSendAuth?: boolean; abortSignal?: AbortSignal; }): Promise { - if (this.#state.status !== 'idle') { - throw(new Error(`Cannot execute: path is already in state "${this.#state.status}"`)); - } - - const executedSteps: ExecutedStep[] = []; - this.#setState({ status: 'executing', completedSteps: [], currentStepIndex: 0 }); - - /* - * Actual output value from each completed step, used for equality checking. - */ - let prevActualValueOut: bigint | null = null; - - /** - * Source-tx anchor for the next forwarded step's poll. Populated only - * when the prior step is an asset-movement transfer that produced a - * withdraw transaction on its destination chain; reset for any step - * type that cannot deposit into a persistent-forwarding address. - */ - let prevWithdrawTx: { location: AssetLocationLike; transaction: { id: string }} | null = null; - - /* - * On-chain operations this execution has published so far. Deferred - * initiations forward these as the external envelope's inputs so the - * anchor's signed envelope references the prior steps' operations. - */ - const publishedInputs: AnchorExternalInput[] = []; - let index = 0; - try { - for (index = 0; index < this.plan.steps.length; index++) { - if (options?.abortSignal?.aborted) { - throw(new Error(`Execution aborted`)); - } - - const onStepCompleted = (step: ExecutedStep) => { - executedSteps.push(step); - this.#emit('stepExecuted', step, index); - } - - this.#setState({ status: 'executing', completedSteps: [...executedSteps], currentStepIndex: index }); - - const step = this.plan.steps[index]; - - if (!step) { - throw(new Error(`Step ${index} is not defined`)); - } - - // Verify the actual output from the previous step matches the expected - // input for this step. A mismatch indicates a provider delivered a - // different amount than was negotiated in computeSteps. - if (index > 0 && prevActualValueOut !== null) { - if (prevActualValueOut !== step.valueIn) { - if (prevActualValueOut < step.valueIn) { - throw(new Error(`Execution failed at step ${index} due to value mismatch: expected at least ${step.valueIn} but previous step produced ${prevActualValueOut}`)); - } else { - this.logger?.debug(`AnchorChainingPlan::execute`, `Value mismatch at step ${index} is non-critical since previous step produced more (${prevActualValueOut}) than expected (${step.valueIn}), proceeding with execution`); - } - } - } - - if (step.type === 'fx') { - const exchange = await step.result.createExchange(undefined, { inputs: [...publishedInputs] }); - const exchangeStatus = await this.#pollExchangeStatus(exchange); - - publishedInputs.push({ blockHash: exchangeStatus.blockhash }); - - prevActualValueOut = step.valueOut; - prevWithdrawTx = null; - onStepCompleted({ type: 'fx', plan: step, exchange }); - } else if (step.type === 'forwarded') { - if (!prevWithdrawTx) { - throw(new Error(`Forwarded step at index ${index} requires the prior step to produce a withdraw transaction on its destination chain`)); - } - - const observed = await this.#pollForwardedTransaction(step, prevWithdrawTx, { - ...(options?.abortSignal ? { abortSignal: options.abortSignal } : {}) - }); - prevActualValueOut = BigInt(observed.to.value); - prevWithdrawTx = null; - onStepCompleted({ type: 'forwarded', plan: step, observedTransaction: observed }); - } else if (step.type === 'assetMovement' || step.type === 'keetaSend') { - if (step.usingInstruction.type === 'KEETA_SEND') { - /* - * Prefer the anchor-provided external. When absent, - * construct the unsigned correlation envelope locally, - * referencing the prior steps' on-chain operations. - */ - let external = step.usingInstruction.external; - if (external === undefined && step.type === 'assetMovement') { - external = await AnchorChainingPlan.#buildKeetaSendExternal(step.provider, step.transfer.transferID, publishedInputs); - } - - const sentBlockHash = await this.#authorizedSend( - options, - step.usingInstruction.sendToAddress, - BigInt(step.usingInstruction.value), - KeetaNet.lib.Account.fromPublicKeyString(step.usingInstruction.tokenAddress).assertKeyType(KeetaNet.lib.Account.AccountKeyAlgorithm.TOKEN), - external - ); - if (sentBlockHash !== undefined) { - publishedInputs.push({ blockHash: sentBlockHash, operationIndex: 0 }); - } - } else if (index === 0) { - if (step.type !== 'assetMovement') { - throw(new Error(`Unexpected asset movement step at index ${index} for user-initiated transfer`)); - } - - await this.#awaitStepCompletion({ - type: 'assetMovementUserExecutionRequired', - action: { - assetMovementTransfer: step.transfer - } - }); - } else if (step.usingInstruction.type === 'EVM_SEND') { - /* For EVM Sends for now we assume the last step sent to this address */ - this.logger?.debug(`AnchorChainingPlan::execute`, `Executing EVM_SEND instruction for step ${index} by sending to address ${step.usingInstruction.sendToAddress} with value ${step.usingInstruction.value} and token ${step.usingInstruction.tokenAddress}`); - } else { - throw(new Error(`Unsupported instruction type ${step.usingInstruction.type} for user-initiated transfer at step ${index}`)); - } - - if (step.type === 'assetMovement') { - const status = await this.#pollTransferStatus(step.transfer, { - ...(options?.abortSignal ? { abortSignal: options.abortSignal } : {}) - }); - prevActualValueOut = BigInt(status.transaction.to.value); - const withdraw = status.transaction.to.transactions.withdraw; - if (withdraw) { - prevWithdrawTx = { - location: step.step.to.location, - transaction: { id: withdraw.id } - }; - } else { - prevWithdrawTx = null; - } - onStepCompleted({ type: 'assetMovement', plan: step }); - } else if (step.type === 'keetaSend') { - /* - * Direct Keeta send: optimistically treat as completed since - * there is no provider transfer to poll. Cannot feed a forwarded - * step because it does not produce a bridge withdraw. - */ - prevActualValueOut = step.valueIn; - prevWithdrawTx = null; - onStepCompleted({ type: 'keetaSend', plan: step }); - } else { - assertNever(step); - } - - } else { - assertNever(step); - } - } - - // Direct same-location/same-asset send: the loop ran zero iterations, - // so just publish the on-chain transfer directly. - if (this.path.length === 0) { - const sendValue = this.request.source.value ?? this.request.destination.value; - if (!sendValue) { - throw(new Error(`Direct send requires a value for source or destination`)); - } - - if (!KeetaNet.lib.Account.isInstance(this.request.source.asset)) { - throw(new Error(`Direct send requires a Keeta token address as the source asset`)); - } - const recipient = this.request.destination.recipient; - if (typeof recipient !== 'string') { - throw(new Error(`Direct Keeta send requires a crypto address as the recipient`)); - } - await this.#authorizedSend(options, recipient, sendValue, this.request.source.asset); - } - } catch (err) { - const error = err instanceof Error ? err : new Error(String(err)); - this.#setState({ status: 'failed', error, completedSteps: [...executedSteps], failedAtStepIndex: index }); - this.#emit('failed', error, [...executedSteps], index); - throw(error); - } - - const result: AnchorChainingPathExecuteResult = { steps: executedSteps }; - this.#setState({ status: 'completed', result }); - this.#emit('completed', result); - return(result); - } -} - -type AnchorChainingFullPlanResult = (({ success: true; plan: AnchorChainingPlan; } | { success: false; error: unknown; }) & { path: AnchorChainingPath; }); - -export class AnchorChaining { - private client: KeetaNet.UserClient; - private resolver: Resolver; - readonly graph: AnchorGraph; - private logger?: Logger; - - constructor(config: AnchorChainingConfig) { - this.client = config.client; - if (config.resolver) { - this.resolver = config.resolver; - } else { - this.resolver = getDefaultResolver(config.client); - } - this.graph = new AnchorGraph({ resolver: this.resolver, client: this.client, logger: config.logger }); - if (config.logger !== undefined) { - this.logger = config.logger; - } - } - - async getPaths(input: AnchorChainingPathInput): Promise { - // Direct send: same Keeta location, same asset, same rail no providers needed. - const sourceLocation = toAssetLocation(input.source.location); - const destinationLocation = toAssetLocation(input.destination.location); - - let foundPaths: AnchorChainingStepLike[][] | null = null; - - if ( - input.source.rail === 'KEETA_SEND' && - input.destination.rail === 'KEETA_SEND' && - convertAssetLocationToString(sourceLocation) === convertAssetLocationToString(destinationLocation) && - isChainLocation(sourceLocation, 'keeta') && - isChainLocation(destinationLocation, 'keeta') && - isAnchorChainingAssetEqual(input.source.asset, input.destination.asset) - ) { - const fromTo = { - asset: input.source.asset, - location: sourceLocation, - rail: 'KEETA_SEND' - } as const; - - foundPaths = [ - [{ type: 'keetaSend', from: fromTo, to: fromTo }] - ]; - } else { - foundPaths = await this.graph.findPaths(input); - } - - // Filter out paths with non-chain steps in intermediate positions - foundPaths = foundPaths?.filter(path => { - for (let i = 0; i < path.length - 1; i++) { - const item = path[i]; - if (!item) { - continue; - } - - const toLocation = toAssetLocation(item.to.location); - if (toLocation.type !== 'chain' && i < path.length - 1) { - return(false); - } - } - - return(true); - }); - - if (foundPaths.length === 0) { - return(null); - } - - const retval: AnchorChainingPath[] = []; - - for (const path of foundPaths) { - retval.push(new AnchorChainingPath({ request: input, path, parent: this })); - } - - return(retval); - } - - async getPlans(input: AnchorChainingPathInput, options?: ComputePlanOptions & { includeAllOutput?: false }): Promise; - async getPlans(input: AnchorChainingPathInput, options: ComputePlanOptions & { includeAllOutput: true }): Promise; - async getPlans(input: AnchorChainingPathInput, options?: ComputePlanOptions & { includeAllOutput?: boolean }): Promise<(AnchorChainingPlan | AnchorChainingFullPlanResult)[] | null> { - const paths = await this.getPaths(input); - - if (!paths) { - return(null); - } - - const limit = options?.limit ?? 3; - - const sortedPaths = paths.sort((a, b) => a.path.length - b.path.length); - - let successCount = 0; - let lowestStepsSuccessCount = Infinity; - let lastAttemptedPathIdx = -1; - - const maxAttemptLoops = 3; - let currentAttemptLoop = 0; - - const allOutput: PromiseSettledResult[] = []; - - while (successCount < limit && lastAttemptedPathIdx < sortedPaths.length - 1 && currentAttemptLoop < maxAttemptLoops) { - currentAttemptLoop++; - - const pathsToTry = sortedPaths.slice(lastAttemptedPathIdx + 1, lastAttemptedPathIdx + 1 + (limit - successCount)); - - if (pathsToTry.length === 0 || !pathsToTry[0]) { - break; - } - - if (pathsToTry[0].path.length > lowestStepsSuccessCount) { - break; - } - - const currentTry = await Promise.allSettled(pathsToTry.map(async function(path) { - return(await AnchorChainingPlan.create(path, options)); - })); - - allOutput.push(...currentTry); - - for (let i = 0; i < currentTry.length; i++) { - const result = currentTry[i]; - const path = pathsToTry[i]; - - if (!result || !path) { - continue; - } - - if (result.status === 'fulfilled') { - successCount++; - if (path && path.path.length < lowestStepsSuccessCount) { - lowestStepsSuccessCount = path.path.length; - } - } - } - - lastAttemptedPathIdx += pathsToTry.length; - } - - const ret: (AnchorChainingPlan | AnchorChainingFullPlanResult)[] = []; - - for (let i = 0; i < allOutput.length; i++) { - const path = sortedPaths[i]; - const plan = allOutput[i]; - - if (!path || !plan) { - continue; - } - - if (options?.includeAllOutput) { - if (plan.status === 'rejected') { - ret.push({ success: false, error: plan.reason, path }); - } else { - ret.push({ success: true, plan: plan.value, path }); - } - } else { - if (plan.status === 'rejected') { - this.logger?.debug(`AnchorChaining::getPlans`, `Error computing plan for a path:`, plan.reason); - } else { - ret.push(plan.value); - } - } - } - - return(ret); - } -} diff --git a/src/lib/chaining/chaining.test.ts b/src/lib/chaining/chaining.test.ts new file mode 100644 index 00000000..a8eed230 --- /dev/null +++ b/src/lib/chaining/chaining.test.ts @@ -0,0 +1,763 @@ +import { test, expect, describe } from 'vitest'; + +import type { GenericAccount } from '@keetanetwork/keetanet-client/lib/account.js'; + +import { KeetaNet } from '../../client/index.js'; +import { AnchorChainingPlan } from './index.js'; +import { AnchorExternal } from '../anchor-external.js'; +import { + createChainingTestHarness, + createPersistentForwardingHarness, + collectEvents, + defaultApproveAction, + firstPath, + getKeetaUsdcToUsdc2Path, + newDestinationAccount, + runChain, + stripKeetaSendExternal, + PFR_SUPPORTED_OPS, + type ChainingTestHarness +} from './fixtures.js'; + +/** + * Resolve a destination-driven (`to` affinity) USDC -> EURC FX plan for a given + * provider and destination amount. + */ +async function eurcDestinationPlan(h: ChainingTestHarness, providerID: 'FXOne' | 'FXTwo', destinationValue: bigint): Promise { + const plans = await h.anchorChaining.getPlans({ + source: { asset: h.tokens.USDC, location: h.keetaLocation, rail: 'KEETA_SEND' }, + destination: { asset: h.tokens.EURC, location: h.keetaLocation, recipient: h.client.account.publicKeyString.get(), rail: 'KEETA_SEND', value: destinationValue } + }); + + const plan = plans?.find(p => p.path.some(n => n.type === 'fx' && n.providerID === providerID)); + if (!plan) { + throw(new Error(`No FX plan found for ${providerID}`)); + } + + return(plan); +} + +describe('plan preview', function() { + test.each([ + { providerID: 'FXOne' as const, fxOut: 88n }, + { providerID: 'FXTwo' as const, fxOut: 85n } + ])('source-driven FX+AM via $providerID estimates each leg from the prior leg', async function({ providerID, fxOut }) { + await using h = await createChainingTestHarness(); + + const plan = await h.getPlanVia(providerID); + expect(plan.preview.steps).toHaveLength(2); + expect(plan.preview.totalValueIn).toEqual(100n); + /* + * The bank withdrawal leg has no simulate support, so the preview carries + * its deposit value through unchanged; the rail fee only appears in the + * actual execution. + */ + expect(plan.preview.totalValueOut).toEqual(fxOut); + + const fxStep = plan.preview.steps.find(s => s.type === 'fx'); + expect(fxStep?.estimatedValueOut).toEqual(fxOut); + for (let i = 0; i < plan.preview.steps.length - 1; i++) { + expect(plan.preview.steps[i]?.estimatedValueOut).toEqual(plan.preview.steps[i + 1]?.estimatedValueIn); + } + }); + + test.each([ + { providerID: 'FXOne' as const, expectedValueIn: 114n }, + { providerID: 'FXTwo' as const, expectedValueIn: 118n } + ])('destination-driven FX-only via $providerID prices the source backward', async function({ providerID, expectedValueIn }) { + await using h = await createChainingTestHarness(); + + const plan = await eurcDestinationPlan(h, providerID, 100n); + expect(plan.preview.totalValueOut).toEqual(100n); + expect(plan.preview.totalValueIn).toEqual(expectedValueIn); + }); + + test('destination-driven FX-only chains backward for a smaller amount', async function() { + await using h = await createChainingTestHarness(); + + const plan = await eurcDestinationPlan(h, 'FXOne', 50n); + expect(plan.preview.totalValueIn).toEqual(57n); + expect(plan.preview.totalValueOut).toEqual(50n); + for (let i = 0; i < plan.preview.steps.length - 1; i++) { + expect(plan.preview.steps[i]?.estimatedValueOut).toEqual(plan.preview.steps[i + 1]?.estimatedValueIn); + } + }); + + test('destination affinity is rejected for paths with asset-movement legs', async function() { + await using h = await createChainingTestHarness(); + + const path = await h.getPathVia('FXOne', 'to'); + await expect(AnchorChainingPlan.create(path)).rejects.toThrow('not supported for asset movement steps'); + }); + + test('providing both source.value and destination.value is rejected', async function() { + await using h = await createChainingTestHarness(); + + const path = await h.getPathVia('FXOne'); + path.request.source.value = 100n; + path.request.destination.value = 100n; + await expect(AnchorChainingPlan.create(path)).rejects.toThrow('Must have source.value or destination.value but not both'); + }); + + test('providing neither source.value nor destination.value is rejected', async function() { + await using h = await createChainingTestHarness(); + + const path = await h.getPathVia('FXOne'); + delete path.request.source.value; + delete path.request.destination.value; + await expect(AnchorChainingPlan.create(path)).rejects.toThrow('Must have source.value or destination.value'); + }); +}); + +describe('execute: FX-only (destination-driven)', function() { + test.each([ + { providerID: 'FXOne' as const, expectedValueIn: 114n }, + { providerID: 'FXTwo' as const, expectedValueIn: 118n } + ])('via $providerID settles the exchange and reports state and events', async function({ providerID, expectedValueIn }) { + await using h = await createChainingTestHarness(); + await h.giveTokens(h.client.account, 1000n, h.tokens.USDC); + + const plan = await eurcDestinationPlan(h, providerID, 100n); + expect(plan.preview.totalValueIn).toEqual(expectedValueIn); + expect(plan.preview.totalValueOut).toEqual(100n); + expect(plan.state.status).toEqual('idle'); + + const { result, events } = await runChain(plan); + expect(result.steps).toHaveLength(1); + + const step = result.steps[0]; + expect(step?.type).toEqual('fx'); + if (step?.type === 'fx') { + expect(step.exchange.exchange.exchangeID).toBeTruthy(); + + const status = await step.exchange.getExchangeStatus(); + expect(status.status).toEqual('completed'); + } + + expect(plan.state.status).toEqual('completed'); + expect(events.stateHistory[0]).toEqual('executing'); + expect(events.stateHistory[events.stateHistory.length - 1]).toEqual('completed'); + expect(events.executed).toHaveLength(1); + expect(events.executed[0]?.step).toBe(result.steps[0]); + expect(events.completed).toBe(result); + }); + + test('exchange failure surfaces a failed event at step 0', async function() { + await using h = await createChainingTestHarness(); + await h.giveTokens(h.client.account, 1000n, h.tokens.USDC); + const plan = await eurcDestinationPlan(h, 'FXOne', 100n); + + h.fxServerOne.failNextExchange('FX destination-driven exchange failed'); + const events = collectEvents(plan); + + // The FX leg surfaces a generic quote-unavailable error; what matters is + // the failure lands on step 0 with nothing completed. + await expect(plan.execute()).rejects.toThrow(); + expect(plan.state.status).toEqual('failed'); + if (plan.state.status === 'failed') { + expect(plan.state.failedAtStepIndex).toEqual(0); + expect(plan.state.completedSteps).toHaveLength(0); + } + + expect(events.failed).toHaveLength(1); + expect(events.failed[0]?.index).toEqual(0); + expect(events.failed[0]?.completedSteps).toHaveLength(0); + }); + + test('re-executing a completed plan is rejected', async function() { + await using h = await createChainingTestHarness(); + await h.giveTokens(h.client.account, 1000n, h.tokens.USDC); + const plan = await eurcDestinationPlan(h, 'FXOne', 100n); + + await runChain(plan); + await expect(plan.execute()).rejects.toThrow('Cannot execute'); + }); +}); +describe('execute: FX + asset-movement (source-driven)', function() { + test('settles both legs, reports actual values, emits ordered events, honors off()', async function() { + await using h = await createChainingTestHarness(); + await h.giveTokens(h.client.account, 1000n, h.tokens.USDC); + const plan = await h.getPlanVia('FXOne'); + expect(plan.state.status).toEqual('idle'); + + let removedCalls = 0; + const removed = () => { removedCalls++; }; + plan.on('stepExecuted', removed); + plan.off('stepExecuted', removed); + + const { result, events } = await runChain(plan); + expect(result.steps).toHaveLength(2); + + const [fx, am] = result.steps; + expect(fx?.type).toEqual('fx'); + if (fx?.type === 'fx') { + expect(fx.actualValueOut).toEqual(88n); + + const status = await fx.exchange.getExchangeStatus(); + expect(status.status).toEqual('completed'); + if (status.status === 'completed') { + expect(status.blockhash).toBeTruthy(); + } + } + + expect(am?.type).toEqual('assetMovement'); + + if (am?.type === 'assetMovement') { + expect(am.actualValueIn).toEqual(88n); + expect(am.actualValueOut).toEqual(78n); + expect(am.transfer.transferID).toBeTruthy(); + + const transfer = await am.transfer.getTransferStatus(); + expect(transfer.transaction.status).toEqual('COMPLETE'); + expect(transfer.transaction.to.value).toEqual('78'); + } + + expect(result.totalValueOut).toEqual(78n); + expect(plan.state.status).toEqual('completed'); + expect(events.stateHistory[0]).toEqual('executing'); + expect(events.stateHistory[events.stateHistory.length - 1]).toEqual('completed'); + expect(events.executed).toHaveLength(2); + events.executed.forEach(({ step, index }) => expect(step).toBe(result.steps[index])); + expect(events.completed).toBe(result); + expect(removedCalls).toEqual(0); + + await expect(plan.execute()).rejects.toThrow('Cannot execute'); + }); + + test('signs sends from an overridden storage account, leaving the user account untouched', async function() { + await using h = await createChainingTestHarness(); + const { account: storageAccount } = await h.client.generateIdentifier(KeetaNet.lib.Account.AccountKeyAlgorithm.STORAGE); + await h.client.setInfo({ + name: '', + description: 'Storage account with permissions from user account', + metadata: '', + defaultPermission: new KeetaNet.lib.Permissions([ 'STORAGE_CAN_HOLD', 'STORAGE_DEPOSIT' ]) + }, { account: storageAccount }); + + await h.giveTokens(h.client.account, 2000n, h.tokens.USDC); + await h.client.send(storageAccount, 1000n, h.tokens.USDC); + await h.client.send(storageAccount, 10n, h.client.baseToken); + + const userUsdcPre = await h.client.balance(h.tokens.USDC); + const storageUsdcPre = await h.client.balance(h.tokens.USDC, { account: storageAccount }); + const userEurcPre = await h.client.balance(h.tokens.EURC); + + const plan = await h.getPlanVia('FXOne', { overrides: { account: storageAccount }}); + const { result } = await runChain(plan); + expect(result.steps).toHaveLength(2); + expect(plan.state.status).toEqual('completed'); + + expect(storageUsdcPre - await h.client.balance(h.tokens.USDC, { account: storageAccount })).toEqual(100n); + expect(await h.client.balance(h.tokens.USDC)).toEqual(userUsdcPre); + expect(await h.client.balance(h.tokens.EURC)).toEqual(userEurcPre); + }); + + test('FX step failure fails at step 0 with no completed steps', async function() { + await using h = await createChainingTestHarness(); + await h.giveTokens(h.client.account, 1000n, h.tokens.USDC); + const plan = await h.getPlanVia('FXOne'); + + h.fxServerOne.failNextExchange('FX step 0 failed'); + const events = collectEvents(plan); + + await expect(plan.execute()).rejects.toThrow(); + expect(plan.state.status).toEqual('failed'); + if (plan.state.status === 'failed') { + expect(plan.state.failedAtStepIndex).toEqual(0); + expect(plan.state.completedSteps).toHaveLength(0); + } + + expect(events.failed).toHaveLength(1); + expect(events.failed[0]?.index).toEqual(0); + + await expect(plan.execute()).rejects.toThrow('Cannot execute'); + }); + + test('asset-movement poll failure fails at step 1 carrying the completed FX step', async function() { + await using h = await createChainingTestHarness(); + await h.giveTokens(h.client.account, 1000n, h.tokens.USDC); + const plan = await h.getPlanVia('FXOne'); + + h.bankServerEU.failNextTransferStatus('AM step 1 poll failed'); + const events = collectEvents(plan); + + await expect(plan.execute()).rejects.toThrow('AM step 1 poll failed'); + expect(plan.state.status).toEqual('failed'); + if (plan.state.status === 'failed') { + expect(plan.state.failedAtStepIndex).toEqual(1); + expect(plan.state.completedSteps).toHaveLength(1); + expect(plan.state.completedSteps[0]?.type).toEqual('fx'); + } + + expect(events.executed).toHaveLength(1); + expect(events.executed[0]?.step.type).toEqual('fx'); + expect(events.failed[0]?.index).toEqual(1); + expect(events.failed[0]?.completedSteps[0]?.type).toEqual('fx'); + }); +}); +describe('execute: actual-driven value flow', function() { + test('AM -> FX -> AM re-prices each leg from the prior leg actual output', async function() { + await using h = await createChainingTestHarness(); + const userAddress = h.client.account.publicKeyString.get(); + + const capturedUSRecipients: (string | undefined)[] = []; + const capturedEURecipients: (string | undefined)[] = []; + h.bankServerUS.wrapInitiateTransfer(async (request, next) => { + capturedUSRecipients.push(typeof request.to.recipient === 'string' ? request.to.recipient : undefined); + return(await next(request)); + }); + h.bankServerEU.wrapInitiateTransfer(async (request, next) => { + capturedEURecipients.push(typeof request.to.recipient === 'string' ? request.to.recipient : undefined); + return(await next(request)); + }); + + const plans = await h.anchorChaining.getPlans({ + source: { asset: 'USD', location: 'bank-account:us', value: 100n, rail: 'ACH' }, + destination: { asset: 'EUR', location: 'bank-account:iban-swift', recipient: userAddress, rail: 'SEPA_PUSH' } + }); + + const plan = plans?.find(p => p.path.length === 3 && p.path.some(n => n.type === 'fx' && n.providerID === 'FXOne')); + if (!plan) { + throw(new Error('Expected 3-step path via FXOne')); + } + + expect(plan.preview.steps[0]?.estimatedValueIn).toEqual(100n); + expect(await h.client.balance(h.tokens.USDC)).toEqual(0n); + + const { result } = await runChain(plan, { + requireSendAuth: true, + onAction: async (payload) => { + if (payload.type === 'assetMovementUserExecutionRequired') { + await h.giveTokens(h.client.account, 90n, h.tokens.USDC); + payload.markCompleted(); + } else { + void defaultApproveAction(payload); + } + } + }); + expect(result.steps).toHaveLength(3); + + const [first, fx, last] = result.steps; + expect(first?.actualValueOut).toEqual(90n); + expect(fx?.actualValueIn).toEqual(90n); + expect(fx?.actualValueIn).toEqual(first?.actualValueOut); + expect(fx?.preview.estimatedValueIn).toEqual(100n); + expect(fx?.actualValueOut).toEqual(79n); + expect(last?.actualValueIn).toEqual(79n); + expect(result.totalValueOut).toEqual(69n); + + expect(plan.state.status).toEqual('completed'); + expect(await h.client.balance(h.tokens.USDC)).toEqual(0n); + expect(await h.client.balance(h.tokens.EURC)).toEqual(0n); + + expect(capturedUSRecipients.length).toBeGreaterThan(0); + capturedUSRecipients.forEach(r => expect(r).toBe(userAddress)); + expect(capturedEURecipients.length).toBeGreaterThan(0); + capturedEURecipients.forEach(r => expect(r).toBe(userAddress)); + }); +}); + +describe('execute: direct Keeta send', function() { + async function directSendPlan(h: ChainingTestHarness, recipient: GenericAccount, value: bigint): Promise { + const plans = await h.anchorChaining.getPlans({ + source: { asset: h.tokens.USDC, location: h.keetaLocation, value, rail: 'KEETA_SEND' }, + destination: { asset: h.tokens.USDC, location: h.keetaLocation, recipient: recipient.publicKeyString.get(), rail: 'KEETA_SEND' } + }); + expect(plans).toHaveLength(1); + return(firstPath(plans)); + } + + test('a same-asset same-location request sends on-chain directly', async function() { + await using h = await createChainingTestHarness(); + const recipient = newDestinationAccount(); + await h.giveTokens(h.client.account, 500n, h.tokens.USDC); + + const plan = await directSendPlan(h, recipient, 200n); + expect(plan.path).toHaveLength(1); + expect(plan.preview.steps).toHaveLength(1); + expect(plan.preview.totalValueIn).toEqual(200n); + expect(plan.preview.totalValueOut).toEqual(200n); + + const { result } = await runChain(plan); + expect(result.steps).toHaveLength(1); + expect(plan.state.status).toEqual('completed'); + expect(await h.client.client.getBalance(recipient, h.tokens.USDC)).toEqual(200n); + }); + + test('keetaSendAuthRequired carries the recipient, value, and token', async function() { + await using h = await createChainingTestHarness(); + const recipient = newDestinationAccount(); + await h.giveTokens(h.client.account, 500n, h.tokens.USDC); + + const plan = await directSendPlan(h, recipient, 200n); + const { events } = await runChain(plan, { requireSendAuth: true }); + expect(events.actions).toHaveLength(1); + + const action = events.actions[0]; + if (action?.type !== 'keetaSendAuthRequired') { + throw(new Error('Expected keetaSendAuthRequired')); + } + + expect(action.action.sendToAddress.publicKeyString.get()).toBe(recipient.publicKeyString.get()); + expect(action.action.value).toBe(200n); + expect(action.action.token.publicKeyString.get()).toBe(h.tokens.USDC.publicKeyString.get()); + expect(action.action.external).toBeUndefined(); + expect(await h.client.client.getBalance(recipient, h.tokens.USDC)).toBe(200n); + }); +}); + +describe('execute: ACH fiat leg', function() { + async function bankUSPlan(h: ChainingTestHarness): Promise { + const plans = await h.anchorChaining.getPlans({ + source: { asset: 'USD', location: 'bank-account:us', value: 100n, rail: 'ACH' }, + destination: { asset: h.tokens.USDC, location: h.keetaLocation, recipient: h.client.account.publicKeyString.get(), rail: 'KEETA_SEND' } + }); + const plan = plans?.find(p => p.path.length === 1 && p.path[0]?.providerID === 'BankUS'); + if (!plan) { + throw(new Error('No single-step BankUS plan found')); + } + return(plan); + } + + test('a missing stepNeedsAction listener fails the execution', async function() { + await using h = await createChainingTestHarness(); + const plan = await bankUSPlan(h); + expect(plan.preview.steps[0]?.type).toEqual('assetMovement'); + await expect(plan.execute()).rejects.toThrow('No listeners for stepNeedsAction'); + }); + + test('acknowledging user execution records the transfer as COMPLETE', async function() { + await using h = await createChainingTestHarness(); + const plan = await bankUSPlan(h); + + const { result } = await runChain(plan); + expect(result.steps).toHaveLength(1); + + const step = result.steps[0]; + expect(step?.type).toEqual('assetMovement'); + if (step?.type === 'assetMovement') { + const status = await step.transfer.getTransferStatus(); + expect(status.transaction.status).toEqual('COMPLETE'); + expect(status.transaction.to.value).toEqual('90'); + } + }); + + test('a transfer poll failure fails at step 0', async function() { + await using h = await createChainingTestHarness(); + const plan = await bankUSPlan(h); + + h.bankServerUS.failNextTransferStatus('ACH poll failed'); + const events = collectEvents(plan); + plan.on('stepNeedsAction', defaultApproveAction); + + await expect(plan.execute()).rejects.toThrow('ACH poll failed'); + expect(plan.state.status).toEqual('failed'); + if (plan.state.status === 'failed') { + expect(plan.state.failedAtStepIndex).toEqual(0); + expect(plan.state.completedSteps).toHaveLength(0); + } + + expect(events.failed[0]?.index).toEqual(0); + }); +}); +describe('execute: per-leg output floor (slippage)', function() { + test('a zero-slippage floor surfaces the asset-movement under-delivery and proceeds when approved', async function() { + await using h = await createChainingTestHarness(); + await h.giveTokens(h.client.account, 1000n, h.tokens.USDC); + + const plan = await h.getPlanVia('FXOne', { slippageBps: 100 }); + const { result, events } = await runChain(plan); + + const review = events.actions.find(a => a.type === 'underDeliveryReview'); + if (review?.type !== 'underDeliveryReview') { + throw(new Error('Expected an underDeliveryReview action')); + } + + expect(review.action.index).toEqual(1); + expect(review.action.expectedOutput).toEqual(78n); + expect(review.action.minimumOutput).toEqual(87n); + + expect(result.totalValueOut).toEqual(78n); + expect(plan.state.status).toEqual('completed'); + }); + + test('a zero-slippage floor aborts before the irreversible send when declined', async function() { + await using h = await createChainingTestHarness(); + await h.giveTokens(h.client.account, 1000n, h.tokens.USDC); + + const plan = await h.getPlanVia('FXOne', { slippageBps: 100 }); + const events = collectEvents(plan); + plan.on('stepNeedsAction', (payload) => { + events.actions.push(payload); + if (payload.type === 'underDeliveryReview') { + payload.markCompleted({ proceed: false }); + } else { + void defaultApproveAction(payload); + } + }); + + await expect(plan.execute()).rejects.toThrow('below the minimum'); + expect(plan.state.status).toEqual('failed'); + if (plan.state.status === 'failed') { + expect(plan.state.failedAtStepIndex).toEqual(1); + expect(plan.state.completedSteps).toHaveLength(1); + expect(plan.state.completedSteps[0]?.type).toEqual('fx'); + } + }); +}); + +describe('resume', function() { + test('a failed, unsettled leg is re-driven to completion on resume', async function() { + await using h = await createChainingTestHarness(); + await h.giveTokens(h.client.account, 1000n, h.tokens.USDC); + const plan = await eurcDestinationPlan(h, 'FXOne', 100n); + + const correlationID = 'resume-fx-only'; + h.fxServerOne.failNextExchange('transient FX failure'); + + await expect(plan.execute({ correlationID })).rejects.toThrow(); + expect(plan.state.status).toEqual('failed'); + if (plan.state.status === 'failed') { + expect(plan.state.failedAtStepIndex).toEqual(0); + } + + const result = await plan.resume(correlationID); + expect(result.correlationID).toEqual(correlationID); + expect(result.steps).toHaveLength(1); + expect(result.steps[0]?.type).toEqual('fx'); + expect(plan.state.status).toEqual('completed'); + }); + + test('resuming a settled correlation skips every leg and re-reports the delivered total', async function() { + await using h = await createChainingTestHarness(); + await h.giveTokens(h.client.account, 1000n, h.tokens.USDC); + const plan = await h.getPlanVia('FXOne'); + + const { result } = await runChain(plan, { correlationID: 'resume-settled' }); + expect(result.steps).toHaveLength(2); + expect(result.totalValueOut).toEqual(78n); + + const resumed = await plan.resume('resume-settled'); + // Settled legs are skipped, so none re-execute, yet the delivered total stands. + expect(resumed.steps).toHaveLength(0); + expect(resumed.totalValueOut).toEqual(78n); + expect(resumed.totalValueIn).toEqual(100n); + }); +}); + +describe('execute: persistent-forwarding (forwarded leg)', function() { + test('the forwarded leg is previewed without reserving an address', async function() { + await using h = await createPersistentForwardingHarness(); + const destination = newDestinationAccount(); + + const path = await getKeetaUsdcToUsdc2Path(h, 1000n, destination); + expect(path.path).toHaveLength(2); + + const lastNode = path.path[1]; + if (!lastNode || lastNode.type !== 'assetMovement') { + throw(new Error('Expected the last path node to be assetMovement')); + } + + expect(lastNode.from.supportedOperations).toEqual(PFR_SUPPORTED_OPS); + + const plan = await AnchorChainingPlan.create(path); + expect(plan.preview.steps).toHaveLength(2); + expect(plan.preview.steps[0]?.type).toEqual('assetMovement'); + expect(plan.preview.steps[1]?.type).toEqual('forwarded'); + + expect(h.bridgeServer.addresses.size).toEqual(0); + }); + + test('execution lazily creates the forwarding address and sweeps end-to-end', async function() { + await using h = await createPersistentForwardingHarness(); + await h.client.modTokenSupplyAndBalance(2000n, h.tokens.USDC); + + const destination = newDestinationAccount(); + const path = await getKeetaUsdcToUsdc2Path(h, 1000n, destination); + const plan = await AnchorChainingPlan.create(path); + expect(h.bridgeServer.addresses.size).toEqual(0); + + const { result } = await runChain(plan); + expect(h.bridgeServer.addresses.size).toEqual(1); + expect(result.steps).toHaveLength(2); + + const forwarded = result.steps[1]; + if (forwarded?.type !== 'forwarded') { + throw(new Error('Expected the last executed step to be forwarded')); + } + + expect(forwarded.observedTransaction.status).toEqual('COMPLETE'); + expect(forwarded.observedTransaction.from.value).toEqual('1000'); + expect(forwarded.observedTransaction.to.location).toEqual(h.keetaLocation); + expect(plan.state.status).toEqual('completed'); + + const addressMeta = [ ...h.bridgeServer.addresses.values() ][0]; + expect(addressMeta?.destinationAddress).toEqual(destination.publicKeyString.get()); + }); +}); + +describe('execute: keeta send authorization', function() { + test('a missing listener fails the execution when requireSendAuth is set', async function() { + await using h = await createChainingTestHarness(); + await h.giveTokens(h.client.account, 1000n, h.tokens.USDC); + + const plan = await h.getPlanVia('FXOne'); + await expect(plan.execute({ requireSendAuth: true })).rejects.toThrow('No listeners for stepNeedsAction'); + }); + + test('the authorization payload carries the send recipient, value, token, and correlation external', async function() { + await using h = await createChainingTestHarness(); + await h.giveTokens(h.client.account, 1000n, h.tokens.USDC); + const plan = await h.getPlanVia('FXOne'); + + const { result, events } = await runChain(plan, { requireSendAuth: true }); + expect(result.steps).toHaveLength(2); + + const action = events.actions.find(a => a.type === 'keetaSendAuthRequired'); + if (action?.type !== 'keetaSendAuthRequired') { + throw(new Error('Expected a keetaSendAuthRequired action')); + } + + expect(KeetaNet.lib.Account.isInstance(action.action.sendToAddress)).toBe(true); + expect(action.action.value).toEqual(88n); + expect(action.action.token.publicKeyString.get()).toEqual(h.tokens.EURC.publicKeyString.get()); + expect(typeof action.action.external).toEqual('string'); + }); + + test('an advisory sent:false still proceeds to completion', async function() { + await using h = await createChainingTestHarness(); + await h.giveTokens(h.client.account, 1000n, h.tokens.USDC); + const plan = await h.getPlanVia('FXOne'); + + const { result } = await runChain(plan, { + requireSendAuth: true, + onAction: (payload) => { + if (payload.type === 'keetaSendAuthRequired') { + payload.markCompleted({ sent: false }); + } else { + void defaultApproveAction(payload); + } + } + }); + expect(result.steps).toHaveLength(2); + expect(plan.state.status).toEqual('completed'); + }); + + test('markFailed rejects the execution at the awaiting send step', async function() { + await using h = await createChainingTestHarness(); + await h.giveTokens(h.client.account, 1000n, h.tokens.USDC); + const plan = await h.getPlanVia('FXOne'); + + const events = collectEvents(plan); + plan.on('stepNeedsAction', (payload) => { + events.actions.push(payload); + if (payload.type === 'keetaSendAuthRequired') { + payload.markFailed(new Error('send rejected by user')); + } else { + void defaultApproveAction(payload); + } + }); + + await expect(plan.execute({ requireSendAuth: true })).rejects.toThrow('send rejected by user'); + expect(plan.state.status).toEqual('failed'); + expect(events.failed).toHaveLength(1); + expect(events.failed[0]?.index).toEqual(1); + expect(events.failed[0]?.completedSteps[0]?.type).toEqual('fx'); + }); +}); + +describe('execute: external correlation envelope', function() { + test('a construction-model anchor that omits external is correlated by a client-built envelope', async function() { + await using h = await createChainingTestHarness(); + await h.giveTokens(h.client.account, 1000n, h.tokens.USDC); + + /** + * The anchor returns KEETA_SEND instructions without an external, so the + * client must build the correlation envelope itself for the send to match. + */ + h.bankServerEU.wrapInitiateTransfer(stripKeetaSendExternal); + const plan = await h.getPlanVia('FXOne'); + + const { result, events } = await runChain(plan, { requireSendAuth: true }); + expect(result.steps).toHaveLength(2); + + const fxStep = result.steps[0]; + const amStep = result.steps[1]; + if (fxStep?.type !== 'fx' || amStep?.type !== 'assetMovement') { + throw(new Error('Expected an fx then assetMovement step')); + } + + const action = events.actions.find(a => a.type === 'keetaSendAuthRequired'); + if (action?.type !== 'keetaSendAuthRequired' || action.action.external === undefined) { + throw(new Error('Expected a client-built external on the send action')); + } + + const fxStatus = await fxStep.exchange.getExchangeStatus(); + if (fxStatus.status !== 'completed') { + throw(new Error('Expected the fx exchange to complete')); + } + + const decoded = await AnchorExternal.fromPlainExternal(action.action.external); + expect(decoded.signed).toBeUndefined(); + expect(decoded.envelope.inputs).toEqual([ { blockHash: fxStatus.blockhash } ]); + expect(decoded.envelope.anchors).toEqual({ + [h.bankSignerEU.publicKeyString.get()]: { transactionId: amStep.transfer.transferID } + }); + }); + + test('chained keeta sends build envelopes that reference the prior send as an on-chain input', async function() { + await using h = await createChainingTestHarness({ includeSwapAnchor: true }); + await h.giveTokens(h.client.account, 1000n, h.tokens.USDC); + // The swap anchor settles EURC off-chain here, so the second hop's EURC is pre-funded. + await h.giveTokens(h.client.account, 1000n, h.tokens.EURC); + + h.swapServer.wrapInitiateTransfer(stripKeetaSendExternal); + h.bankServerEU.wrapInitiateTransfer(stripKeetaSendExternal); + + const plans = await h.anchorChaining.getPlans({ + source: { asset: h.tokens.USDC, location: h.keetaLocation, value: 100n, rail: 'KEETA_SEND' }, + destination: { asset: 'EUR', location: 'bank-account:iban-swift', recipient: h.client.account.publicKeyString.get(), rail: 'SEPA_PUSH' } + }); + const plan = plans?.find(p => p.path.length === 2 && p.path[0]?.providerID === 'SwapKeeta' && p.path[1]?.providerID === h.euBankProviderID); + if (!plan) { + throw(new Error('Expected a SwapKeeta -> BankEU path')); + } + + const { result, events } = await runChain(plan, { requireSendAuth: true }); + expect(result.steps).toHaveLength(2); + + const externals = events.actions + .filter(a => a.type === 'keetaSendAuthRequired') + .map(a => a.type === 'keetaSendAuthRequired' ? a.action.external : undefined); + expect(externals).toHaveLength(2); + + const [ firstExternal, secondExternal ] = externals; + if (firstExternal === undefined || secondExternal === undefined) { + throw(new Error('Expected client-built externals on both sends')); + } + + const firstDecoded = await AnchorExternal.fromPlainExternal(firstExternal); + expect(firstDecoded.envelope.inputs).toBeUndefined(); + expect(Object.keys(firstDecoded.envelope.anchors)).toEqual([ h.swapSigner.publicKeyString.get() ]); + + const secondDecoded = await AnchorExternal.fromPlainExternal(secondExternal); + expect(Object.keys(secondDecoded.envelope.anchors)).toEqual([ h.bankSignerEU.publicKeyString.get() ]); + + const input = secondDecoded.envelope.inputs?.[0]; + if (input === undefined) { + throw(new Error('Expected an input referencing the first send')); + } + + expect(input.operationIndex).toEqual(0); + + const referencedBlock = await h.client.block(input.blockHash); + if (referencedBlock === null) { + throw(new Error('Referenced input block not found on chain')); + } + + const referencedExternals = referencedBlock.operations.flatMap(op => + op.type === KeetaNet.lib.Block.OperationType.SEND ? [ op.external ] : [] + ); + expect(referencedExternals).toEqual([ firstExternal ]); + }); +}); diff --git a/src/lib/chaining/errors.ts b/src/lib/chaining/errors.ts new file mode 100644 index 00000000..704b8bba --- /dev/null +++ b/src/lib/chaining/errors.ts @@ -0,0 +1,152 @@ +import { KeetaAnchorError } from '../error.js'; + +/** + * Stable, programmatic codes for anchor-chaining failures. Consumers branch on + * these rather than parsing messages. + */ +export const AnchorChainingErrorCodes = [ + 'INVALID_REQUEST', + 'INVALID_PATH', + 'INVALID_STATE', + 'STEP_NOT_DEFINED', + 'UNSUPPORTED_AFFINITY', + 'UNSUPPORTED_RAIL', + 'UNSUPPORTED_INSTRUCTION', + 'PROVIDER_UNAVAILABLE', + 'QUOTE_UNAVAILABLE', + 'EXCHANGE_FAILED', + 'UNDER_DELIVERY', + 'POLL_TIMEOUT', + 'RECOVERABLE_SEND_FAILED', + 'NO_LISTENER', + 'RESUME_UNAVAILABLE', + 'ABORTED', + 'INTERNAL' +] as const; + +export type AnchorChainingErrorCode = typeof AnchorChainingErrorCodes[number]; + +/** + * HTTP status per code. Most chaining errors are caller/setup faults; a few are + * upstream-availability faults that surface as 5xx. + */ +const STATUS_BY_CODE: { [Code in AnchorChainingErrorCode]: number } = { + INVALID_REQUEST: 400, + INVALID_PATH: 400, + INVALID_STATE: 409, + STEP_NOT_DEFINED: 500, + UNSUPPORTED_AFFINITY: 400, + UNSUPPORTED_RAIL: 400, + UNSUPPORTED_INSTRUCTION: 400, + PROVIDER_UNAVAILABLE: 503, + QUOTE_UNAVAILABLE: 503, + EXCHANGE_FAILED: 502, + UNDER_DELIVERY: 422, + POLL_TIMEOUT: 504, + RECOVERABLE_SEND_FAILED: 503, + NO_LISTENER: 500, + RESUME_UNAVAILABLE: 409, + ABORTED: 499, + INTERNAL: 500 +}; + +/** + * Codes the durability layer ({@link withRetry}) may retry on. Transient + * upstream-availability faults only; programmer/setup faults are terminal. + */ +const RETRYABLE_BY_CODE: { [Code in AnchorChainingErrorCode]?: true } = { + PROVIDER_UNAVAILABLE: true, + QUOTE_UNAVAILABLE: true, + RECOVERABLE_SEND_FAILED: true +}; + +interface AnchorChainingErrorJSON { + ok: false; + retryable: boolean; + error: string; + name: string; + statusCode: number; + code: AnchorChainingErrorCode; +} + +/** + * Error raised by the anchor-chaining engine. Carries a stable + * {@link AnchorChainingErrorCode} and the originating cause when wrapping. + */ +export class AnchorChainingError extends KeetaAnchorError { + static override readonly name: string = 'AnchorChainingError'; + private readonly anchorChainingErrorObjectTypeID!: string; + private static readonly anchorChainingErrorObjectTypeID = 'c0b3a6e4-7f1d-4a2c-9e8b-5d3f2a1c7e90'; + readonly code: AnchorChainingErrorCode; + + constructor(code: AnchorChainingErrorCode, message?: string, options?: { cause?: unknown }) { + super(message ?? code); + + this.code = code; + this.statusCode = STATUS_BY_CODE[code]; + this.retryable = RETRYABLE_BY_CODE[code] ?? false; + this.userError = true; + + if (options?.cause !== undefined) { + Object.defineProperty(this, 'cause', { + value: options.cause, + enumerable: false, + writable: true, + configurable: true + }); + } + + Object.defineProperty(this, 'anchorChainingErrorObjectTypeID', { + value: AnchorChainingError.anchorChainingErrorObjectTypeID, + enumerable: false + }); + } + + static isInstance(input: unknown): input is AnchorChainingError { + return(this.hasPropWithValue(input, 'anchorChainingErrorObjectTypeID', AnchorChainingError.anchorChainingErrorObjectTypeID)); + } + + static isValidCode(value: string): value is AnchorChainingErrorCode { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + return(AnchorChainingErrorCodes.includes(value as AnchorChainingErrorCode)); + } + + /** + * Normalize an unknown thrown value into an {@link AnchorChainingError}, + * preserving an already-typed chaining error and otherwise wrapping under + * `code`. + */ + static from(error: unknown, code: AnchorChainingErrorCode = 'INTERNAL'): AnchorChainingError { + if (AnchorChainingError.isInstance(error)) { + return(error); + } + + let message: string; + if (error instanceof Error) { + message = error.message; + } else { + message = String(error); + } + + return(new AnchorChainingError(code, message, { cause: error })); + } + + override toJSON(): AnchorChainingErrorJSON { + return({ + ...super.toJSON(), + code: this.code + }); + } + + static async fromJSON(input: unknown): Promise { + const { message, other } = this.extractErrorProperties(input, this); + + if (!('code' in other) || typeof other.code !== 'string' || !this.isValidCode(other.code)) { + throw(new TypeError('Invalid AnchorChainingError JSON object: missing or invalid code')); + } + + const error = new this(other.code, message); + error.restoreFromJSON(other); + return(error); + } +} diff --git a/src/lib/chaining/execution.ts b/src/lib/chaining/execution.ts new file mode 100644 index 00000000..8ee44d0b --- /dev/null +++ b/src/lib/chaining/execution.ts @@ -0,0 +1,597 @@ +import type { GenericAccount, TokenAddress } from '@keetanetwork/keetanet-client/lib/account.js'; +import { randomUUID } from 'node:crypto'; +import * as KeetaNet from '@keetanetwork/keetanet-client'; + +import type { + AnchorChainingPathEventMap, + AnchorChainingPathExecuteOptions, + AnchorChainingPathExecuteResult, + AnchorChainingPathState, + AnchorChainingPreview, + AnchorChainingStepLike, + AssetMovementGraphNode, + ExecutedStep, + StepNeededActionEventPayload +} from './types.js'; +import type { FiatPushRails, KeetaPersistentForwardingAddressDetails, SimulatedAssetTransferInstructions } from '../../services/asset-movement/common.js'; +import type { Logger } from '../log/index.js'; +import type { AnchorChainingStore, ChainingStepRecord, ExecutionState } from './store.js'; +import type { StepContext } from './steps/context.js'; +import type { ResolvedRecipient, StepRunInput, WithdrawRef } from './steps/run.js'; +import { AnchorChainingError } from './errors.js'; +import { convertAssetLocationToString } from '../../services/asset-movement/common.js'; +import { isFiatRail } from '../../services/asset-movement/common.generated.js'; +import { stepIdempotencyKey } from './store.js'; +import { resolveAccountsForAction } from './steps/context.js'; +import { createStepExecutor } from './steps/executor.js'; +import { recoverableSend } from './retry.js'; + +const DEFAULT_POLL_INTERVAL_MS = 2_000; +const DEFAULT_POLL_TIMEOUT_MS = 300_000; + +/** + * The completion-callback argument tuple for each action type, keyed by the + * action's discriminant. Derived from the event payloads so the engine's + * `markCompleted` contract stays in lockstep with {@link StepNeededActionEventPayload}. + */ +type StepCompletedArgs = { + [Payload in StepNeededActionEventPayload as Payload['type']]: Parameters; +}; + +/** + * Configuration for an {@link AnchorChainingExecution}. + */ +export interface AnchorChainingExecutionConfig { + ctx: StepContext; + preview: AnchorChainingPreview; + store: AnchorChainingStore; +} + +/** + * The durable, resume-forward execution engine for one path. + * + * Execution is driven by the output of each leg: every leg is priced + * and initiated from the real amount the prior leg delivered, so provider + * slippage never strands an intermediate asset. + */ +export class AnchorChainingExecution { + readonly #ctx: StepContext; + readonly #preview: AnchorChainingPreview; + readonly #store: AnchorChainingStore; + readonly #logger: Logger | undefined; + + #state: AnchorChainingPathState = { status: 'idle' }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + readonly #listeners = new Map void>>(); + readonly #forwardedAddresses = new Map(); + + constructor(config: AnchorChainingExecutionConfig) { + this.#ctx = config.ctx; + this.#preview = config.preview; + this.#store = config.store; + this.#logger = config.ctx.logger; + } + + get state(): AnchorChainingPathState { + return(this.#state); + } + + on(event: E, listener: (...args: AnchorChainingPathEventMap[E]) => void): void { + let listenerSet = this.#listeners.get(event); + if (!listenerSet) { + listenerSet = new Set(); + this.#listeners.set(event, listenerSet); + } + + listenerSet.add(listener); + } + + off(event: E, listener: (...args: AnchorChainingPathEventMap[E]) => void): void { + this.#listeners.get(event)?.delete(listener); + } + + #emit(event: E, ...args: AnchorChainingPathEventMap[E]): { sendCount: number } { + let sendCount = 0; + + for (const listener of (this.#listeners.get(event) ?? [])) { + try { + listener(...args); + sendCount++; + } catch (err) { + this.#logger?.debug(`AnchorChainingExecution::emit`, `Error in listener for event '${event}'`, err); + } + } + + return({ sendCount }); + } + + #setState(state: AnchorChainingPathState): void { + this.#state = state; + this.#emit('stateChange', state); + } + + async #awaitStepCompletion( + step: Pick, 'action' | 'type'> + ): Promise { + type Ret = StepCompletedArgs[Type]; + + let didComplete = false; + + function assertDidNotComplete() { + if (didComplete) { + throw(new AnchorChainingError('INVALID_STATE', `Step was already marked as completed or failed`)); + } + + didComplete = true; + } + + let resolveFn: undefined | ((...args: Ret) => void); + let rejectFn: undefined | StepNeededActionEventPayload['markFailed']; + + const promise = new Promise(function(resolve, reject) { + resolveFn = (...args: Ret) => { + assertDidNotComplete(); + resolve(args); + }; + + rejectFn = (error) => { + assertDidNotComplete(); + + let usingErr = error; + if (!usingErr) { + usingErr = new AnchorChainingError('INVALID_STATE', `Step marked as failed without error`); + } + + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors + reject(usingErr); + }; + }); + + if (!resolveFn || !rejectFn) { + throw(new AnchorChainingError('INTERNAL', `Failed to create step completion promise`)); + } + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const payload = { + ...step, + markCompleted: resolveFn, + markFailed: rejectFn + } as unknown as Extract; + + const { sendCount } = this.#emit('stepNeedsAction', payload); + + if (sendCount === 0) { + throw(new AnchorChainingError('NO_LISTENER', `No listeners for stepNeedsAction event, but a step (actionType=${step.type}) is awaiting completion`)); + } + + return(await promise); + } + + /** + * Perform a Keeta send, optionally gating on caller authorization first, + * then publishing with ledger recovery and retry. + */ + async #authorizedSend(options: AnchorChainingPathExecuteOptions, args: { to: string | GenericAccount; value: bigint; token: TokenAddress | string; external?: string | undefined }): Promise { + if (options.requireSendAuth) { + await this.#awaitStepCompletion({ + type: 'keetaSendAuthRequired', + action: { + sendToAddress: KeetaNet.lib.Account.toAccount(args.to), + value: args.value, + token: KeetaNet.lib.Account.toAccount(args.token).assertKeyType(KeetaNet.lib.Account.AccountKeyAlgorithm.TOKEN), + ...(args.external !== undefined ? { external: args.external } : {}) + } + }); + } + + const { account } = await resolveAccountsForAction(this.#ctx.client, { type: 'assetMovement', providerMethod: 'initiateTransfer' }, this.#ctx.overrides); + + return(await recoverableSend(this.#ctx.client, { + to: args.to, + value: args.value, + token: args.token, + external: args.external, + account + }, { logger: this.#logger })); + } + + /** + * Resolve and (lazily) create the persistent-forwarding address for the + * forwarded leg at `index`, reusing an existing one when the provider can + * list it so resume does not create duplicates. + */ + async #ensureForwardedAddress(index: number): Promise { + const cached = this.#forwardedAddresses.get(index); + if (cached) { + return(cached); + } + + const step = this.#ctx.path[index]; + if (!step || step.type !== 'assetMovement') { + throw(new AnchorChainingError('INVALID_PATH', `Step ${index} is not an asset-movement step`)); + } + + const destinationAddress = this.#ctx.request.destination.recipient; + if (typeof destinationAddress !== 'string') { + throw(new AnchorChainingError('INVALID_REQUEST', `Persistent-forwarding step ${index} requires the destination recipient to be a resolved address string`)); + } + + const providers = await this.#ctx.assetMovementClient.getProvidersForTransfer( + { asset: { from: step.from.asset, to: step.to.asset }, from: step.from.location, to: step.to.location }, + { providerIDs: [ step.providerID ] } + ); + const provider = providers?.[0]; + if (!provider) { + throw(new AnchorChainingError('PROVIDER_UNAVAILABLE', `Could not get asset movement provider ${step.providerID} for forwarded step ${index}`)); + } + + if (!await provider.isOperationSupported('createPersistentForwarding')) { + throw(new AnchorChainingError('INVALID_PATH', `Asset movement provider ${step.providerID} does not support createPersistentForwarding`)); + } + + const { signer } = await resolveAccountsForAction(this.#ctx.client, { type: 'assetMovement', providerMethod: 'initiateTransfer', provider }, this.#ctx.overrides); + const assetPair = { from: step.from.asset, to: step.to.asset }; + + let persistentAddress: KeetaPersistentForwardingAddressDetails | undefined; + if (await provider.isOperationSupported('listPersistentForwarding')) { + try { + const existing = await provider.listForwardingAddresses({ + account: signer, + search: [ { sourceLocation: step.from.location, destinationLocation: step.to.location, asset: step.from.asset, destinationAddress } ] + }); + + const sourceLocationString = convertAssetLocationToString(step.from.location); + const destLocationString = convertAssetLocationToString(step.to.location); + persistentAddress = existing.addresses.find(addr => { + if (addr.destinationAddress !== destinationAddress) { + return(false); + } + if (!addr.sourceLocation || convertAssetLocationToString(addr.sourceLocation) !== sourceLocationString) { + return(false); + } + if (!addr.destinationLocation || convertAssetLocationToString(addr.destinationLocation) !== destLocationString) { + return(false); + } + + return(true); + }); + } catch (error) { + this.#logger?.debug('AnchorChainingExecution::ensureForwardedAddress', `listForwardingAddresses lookup failed for step ${index}; will create a new address`, error); + } + } + + if (!persistentAddress) { + persistentAddress = await provider.createPersistentForwardingAddress({ + account: signer, + sourceLocation: step.from.location, + destinationLocation: step.to.location, + destinationAddress, + asset: assetPair + }); + } + + if (typeof persistentAddress.address !== 'string') { + throw(new AnchorChainingError('INVALID_STATE', `Persistent forwarding address for step ${index} is not a resolved string`)); + } + + this.#forwardedAddresses.set(index, persistentAddress); + return(persistentAddress); + } + + /** + * Resolve where an asset-movement leg at `index` should deliver, driven by + * the actual input value for any downstream simulation. + */ + async #resolveRecipient(index: number, actualInput: bigint): Promise { + const step = this.#ctx.path[index]; + if (!step || step.type !== 'assetMovement') { + throw(new AnchorChainingError('INVALID_PATH', `Step ${index} is not an asset-movement step`)); + } + + if (index === this.#ctx.path.length - 1) { + return({ recipient: this.#ctx.request.destination.recipient, sendingTo: 'FINAL_DESTINATION' }); + } + + const nextStep = this.#ctx.path[index + 1]; + if (!nextStep) { + throw(new AnchorChainingError('STEP_NOT_DEFINED', `Expected next step at index ${index + 1}`)); + } + + if (this.#ctx.forwardedIndexes.has(index + 1)) { + const pfi = await this.#ensureForwardedAddress(index + 1); + if (typeof pfi.address !== 'string') { + throw(new AnchorChainingError('INVALID_STATE', `Persistent forwarding address for next step ${index + 1} is not a resolved string`)); + } + return({ recipient: pfi.address, sendingTo: 'NEXT_STEP' }); + } + + const keetaNetworkLocation = `chain:keeta:${this.#ctx.client.network}`; + if (convertAssetLocationToString(nextStep.from.location) === keetaNetworkLocation) { + const { account } = await resolveAccountsForAction(this.#ctx.client, { type: 'assetMovement', providerMethod: 'initiateTransfer' }, this.#ctx.overrides); + return({ recipient: account.publicKeyString.get(), sendingTo: 'NEXT_STEP' }); + } + + return(await this.#resolveOffKeetaRecipient(step, nextStep, actualInput)); + } + + /** + * Resolve the recipient for an off-Keeta intermediate hand-off by simulating + * this leg and reading the next provider's deposit instruction. + */ + async #resolveOffKeetaRecipient(step: AssetMovementGraphNode, nextStep: AnchorChainingStepLike, actualInput: bigint): Promise { + if (nextStep.type !== 'assetMovement') { + throw(new AnchorChainingError('UNSUPPORTED_RAIL', `Cannot chain to a non-asset-movement step at a non-Keeta intermediate location`)); + } + + const providers = await this.#ctx.assetMovementClient.getProvidersForTransfer( + { asset: { from: nextStep.from.asset, to: nextStep.to.asset }, from: nextStep.from.location, to: nextStep.to.location }, + { providerIDs: [ nextStep.providerID ] } + ); + const nextProvider = providers?.[0]; + if (!nextProvider) { + throw(new AnchorChainingError('PROVIDER_UNAVAILABLE', `Could not get next asset movement provider ${nextStep.providerID}`)); + } + + const { signer } = await resolveAccountsForAction(this.#ctx.client, { type: 'assetMovement', providerMethod: 'initiateTransfer', provider: nextProvider }, this.#ctx.overrides); + + const thisProviders = await this.#ctx.assetMovementClient.getProvidersForTransfer( + { asset: { from: step.from.asset, to: step.to.asset }, from: step.from.location, to: step.to.location }, + { providerIDs: [ step.providerID ] } + ); + const thisProvider = thisProviders?.[0]; + if (!thisProvider || !await thisProvider.isOperationSupported('simulateTransfer')) { + throw(new AnchorChainingError('UNSUPPORTED_RAIL', `Asset movement provider ${step.providerID} does not support simulateTransfer required for non-Keeta intermediate chaining`)); + } + + const simulated = await thisProvider.simulateTransfer({ + account: signer, + asset: { from: step.from.asset, to: step.to.asset }, + from: { location: step.from.location }, + to: { location: step.to.location }, + value: actualInput + }); + + const simulatedInstruction = simulated.instructions.find((instr): instr is Extract => instr.type === step.from.rail); + let expectedOut: string | undefined = simulatedInstruction?.totalReceiveAmount; + if (expectedOut === undefined && simulatedInstruction && 'value' in simulatedInstruction) { + expectedOut = simulatedInstruction.value; + } + if (expectedOut === undefined) { + throw(new AnchorChainingError('UNSUPPORTED_RAIL', `Simulated transfer for step did not yield a total-receive amount required for chaining`)); + } + + const nextTransfer = await nextProvider.initiateTransfer({ + account: signer, + asset: { from: nextStep.from.asset, to: nextStep.to.asset }, + from: { location: nextStep.from.location }, + to: { location: nextStep.to.location, recipient: this.#ctx.request.destination.recipient }, + value: BigInt(expectedOut) + }); + + const nextInstruction = nextTransfer.instructions.find(instr => instr.type === step.to.rail); + if (!nextInstruction) { + throw(new AnchorChainingError('UNSUPPORTED_RAIL', `Next step instruction of type ${step.to.rail} not found for recipient resolution`)); + } + + const isFiatPush = (instr: typeof nextInstruction): instr is Extract => isFiatRail(instr.type); + if (nextInstruction.type === 'KEETA_SEND') { + throw(new AnchorChainingError('UNSUPPORTED_RAIL', `Cannot chain from asset movement to KEETA_SEND across a non-Keeta intermediate`)); + } else if (isFiatPush(nextInstruction)) { + if (nextInstruction.depositMessage) { + throw(new AnchorChainingError('UNSUPPORTED_RAIL', `Deposit message outbound is not supported for chaining`)); + } + return({ recipient: nextInstruction.account, sendingTo: 'NEXT_STEP' }); + } else if (nextInstruction.type === 'EVM_SEND') { + return({ recipient: nextInstruction.sendToAddress, sendingTo: 'NEXT_STEP' }); + } + + throw(new AnchorChainingError('UNSUPPORTED_RAIL', `Unsupported rail for chaining: ${step.to.rail}`)); + } + + /** + * Build a fresh, idle execution state for a correlation. + */ + #initState(correlationID: string): ExecutionState { + const now = Date.now(); + const steps: ChainingStepRecord[] = this.#preview.steps.map(step => ({ + index: step.index, + type: step.type, + status: 'pending', + publishedInputs: [] + })); + + return({ + correlationID, + status: 'idle', + currentStepIndex: 0, + steps, + publishedInputs: [], + createdAtMs: now, + updatedAtMs: now + }); + } + + /** + * Start a fresh execution. Drives every leg from the actual amount the prior + * leg delivered. + */ + async execute(options: AnchorChainingPathExecuteOptions = {}): Promise { + if (this.#state.status !== 'idle') { + throw(new AnchorChainingError('INVALID_STATE', `Cannot execute: path is already in state "${this.#state.status}"`)); + } + + const correlationID = options.correlationID ?? randomUUID(); + const state = this.#initState(correlationID); + await this.#store.save(state); + + return(await this.#drive(state, options)); + } + + /** + * Resume a previously-failed or interrupted execution, skipping already + * settled legs and driving the remainder forward. + */ + async resume(correlationID: string, options: AnchorChainingPathExecuteOptions = {}): Promise { + const state = await this.#store.load(correlationID); + if (!state) { + throw(new AnchorChainingError('RESUME_UNAVAILABLE', `No stored execution state for correlation ${correlationID}`)); + } + + this.#state = { status: 'idle' }; + return(await this.#drive(state, options)); + } + + /** + * The shared actual-driven loop used by both {@link execute} and + * {@link resume}. + */ + async #drive(state: ExecutionState, options: AnchorChainingPathExecuteOptions): Promise { + const poll = { + intervalMs: options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS, + timeoutMs: options.pollTimeoutMs ?? DEFAULT_POLL_TIMEOUT_MS, + ...(options.abortSignal ? { abortSignal: options.abortSignal } : {}) + }; + + const executedSteps: ExecutedStep[] = []; + this.#setState({ status: 'executing', completedSteps: [], currentStepIndex: state.currentStepIndex }); + + let actualInput = this.#preview.totalValueIn; + let prevWithdrawTx: WithdrawRef | null = null; + let lastActualOutput = this.#preview.totalValueIn; + let index = 0; + + const persist = async () => { + state.updatedAtMs = Date.now(); + await this.#store.save(state); + }; + + try { + for (index = 0; index < this.#preview.steps.length; index++) { + if (options.abortSignal?.aborted) { + throw(new AnchorChainingError('ABORTED', `Execution aborted`)); + } + + const record = state.steps[index]; + if (!record) { + throw(new AnchorChainingError('STEP_NOT_DEFINED', `Step record ${index} is not defined`)); + } + + if (record.status === 'settled' && record.actualOutput !== undefined) { + actualInput = BigInt(record.actualOutput); + lastActualOutput = actualInput; + prevWithdrawTx = record.withdraw ? { location: record.withdraw.location, transaction: { id: record.withdraw.id }} : null; + continue; + } + + state.currentStepIndex = index; + this.#setState({ status: 'executing', completedSteps: [ ...executedSteps ], currentStepIndex: index }); + + const previewStep = this.#preview.steps[index]; + if (!previewStep) { + throw(new AnchorChainingError('STEP_NOT_DEFINED', `Preview step ${index} is not defined`)); + } + + const capturedInput = actualInput; + const minOutput = previewStep.minOutput; + + const runInput: StepRunInput = { + actualInput: capturedInput, + preview: previewStep, + idempotencyKey: stepIdempotencyKey(state.correlationID, index), + record, + publishedInputs: [ ...state.publishedInputs ], + prevWithdrawTx, + options, + poll, + persist, + checkFloor: async (expectedOutput: bigint) => { + await this.#checkFloor(index, expectedOutput, minOutput); + }, + authorizedSend: async (sendArgs) => { + return(await this.#authorizedSend(options, sendArgs)); + }, + awaitAssetMovementExecution: async (transfer) => { + await this.#awaitStepCompletion({ type: 'assetMovementUserExecutionRequired', action: { assetMovementTransfer: transfer }}); + }, + resolveRecipient: async () => { + return(await this.#resolveRecipient(index, capturedInput)); + }, + ensureForwardedAddress: async () => { + return(await this.#ensureForwardedAddress(index)); + } + }; + + const executor = createStepExecutor(this.#ctx, index); + const result = await executor.run(runInput); + + record.status = 'settled'; + record.actualInput = capturedInput.toString(); + record.actualOutput = result.actualOutput.toString(); + record.publishedInputs = result.publishedInputs; + if (result.withdrawTx) { + record.withdraw = { location: result.withdrawTx.location, id: result.withdrawTx.transaction.id }; + } + + for (const published of result.publishedInputs) { + state.publishedInputs.push(published); + } + + actualInput = result.actualOutput; + lastActualOutput = result.actualOutput; + prevWithdrawTx = result.withdrawTx; + + executedSteps.push(result.executed); + await persist(); + this.#emit('stepExecuted', result.executed, index); + } + } catch (err) { + const error = AnchorChainingError.from(err); + state.status = 'failed'; + state.error = { code: error.code, message: error.message }; + await persist(); + this.#setState({ status: 'failed', error, completedSteps: [ ...executedSteps ], failedAtStepIndex: index }); + this.#emit('failed', error, [ ...executedSteps ], index); + throw(error); + } + + const result: AnchorChainingPathExecuteResult = { + steps: executedSteps, + correlationID: state.correlationID, + totalValueIn: this.#preview.totalValueIn, + totalValueOut: lastActualOutput + }; + + state.status = 'completed'; + await persist(); + this.#setState({ status: 'completed', result }); + this.#emit('completed', result); + return(result); + } + + /** + * Enforce a leg's output floor before an irreversible send. With no floor + * (the default), drift is absorbed by re-pricing downstream. With a floor, + * an under-delivery is surfaced for review and aborts unless the consumer + * explicitly proceeds. + */ + async #checkFloor(index: number, expectedOutput: bigint, minOutput: bigint): Promise { + if (minOutput <= 0n || expectedOutput >= minOutput) { + return; + } + + let proceed = false; + try { + const [ decision ] = await this.#awaitStepCompletion<'underDeliveryReview'>({ + type: 'underDeliveryReview', + action: { index, expectedOutput, actualOutput: expectedOutput, minimumOutput: minOutput } + }); + proceed = decision.proceed; + } catch (error) { + throw(AnchorChainingError.from(error, 'UNDER_DELIVERY')); + } + + if (!proceed) { + throw(new AnchorChainingError('UNDER_DELIVERY', `Step ${index} would deliver ${expectedOutput}, below the minimum ${minOutput}; aborted before send`)); + } + } +} diff --git a/src/lib/chaining/facade.ts b/src/lib/chaining/facade.ts new file mode 100644 index 00000000..5527f565 --- /dev/null +++ b/src/lib/chaining/facade.ts @@ -0,0 +1,194 @@ +import type * as KeetaNet from '@keetanetwork/keetanet-client'; +import type { Resolver } from '../index.js'; +import type { Logger } from '../log/index.js'; +import type { ChainingHost, ComputePlanOptions } from './plan.js'; +import type { + AnchorChainingAssetInfo, + AnchorChainingAssetInfoWithMetadata, + AnchorChainingConfig, + AnchorChainingListAssetsFilter, + AnchorChainingPathInput, + AnchorChainingResolveAssetsFilter, + AnchorChainingResolveAssetsResult, + AnchorChainingResolveAssetsWithMetadataResult, + AnchorChainingStepLike, + AnchorChainingWithMetadataOptions +} from './types.js'; +import { AnchorGraph } from './graph.js'; +import { AnchorChainingPath, AnchorChainingPlan } from './plan.js'; +import { convertAssetLocationToString, isChainLocation, toAssetLocation } from '../../services/asset-movement/common.js'; +import { getDefaultResolver } from '../../config.js'; +import { isAnchorChainingAssetEqual } from './types.js'; + +/** + * A plan-computation outcome that preserves failures alongside successes, for + * callers that pass `includeAllOutput`. + */ +export type AnchorChainingFullPlanResult = (({ success: true; plan: AnchorChainingPlan } | { success: false; error: unknown }) & { path: AnchorChainingPath }); + +const DEFAULT_PLAN_LIMIT = 3; +const MAX_PLAN_ATTEMPT_LOOPS = 3; + +/** + * Entry point for anchor chaining. Discovers routes between a source and + * destination asset, computes side-effect-free plans over them, and hands back + * {@link AnchorChainingPlan}s whose durable, actual-driven engine executes and + * can resume the chain. Backwards-compatible facade over the engine. + */ +export class AnchorChaining implements ChainingHost { + readonly client: KeetaNet.UserClient; + readonly resolver: Resolver; + readonly graph: AnchorGraph; + readonly logger?: Logger | undefined; + + constructor(config: AnchorChainingConfig) { + this.client = config.client; + this.resolver = config.resolver ?? getDefaultResolver(config.client); + this.logger = config.logger; + this.graph = new AnchorGraph({ + resolver: this.resolver, + client: this.client, + ...(this.logger ? { logger: this.logger } : {}) + }); + } + + async resolveAssets(filter: AnchorChainingResolveAssetsFilter = {}): Promise { + return(await this.graph.resolveAssets(filter)); + } + + async listAssets(filter: AnchorChainingListAssetsFilter = {}): Promise { + return(await this.graph.listAssets(filter)); + } + + async resolveAssetsWithMetadata(filter: AnchorChainingResolveAssetsFilter = {}, options?: AnchorChainingWithMetadataOptions): Promise { + return(await this.graph.resolveAssetsWithMetadata(filter, options)); + } + + async listAssetsWithMetadata(filter: AnchorChainingListAssetsFilter = {}, options?: AnchorChainingWithMetadataOptions): Promise { + return(await this.graph.listAssetsWithMetadata(filter, options)); + } + + /** + * Discover candidate paths between the request's source and destination. A + * same-asset, same-Keeta-location request resolves to a single direct send. + */ + async getPaths(input: AnchorChainingPathInput): Promise { + const sourceLocation = toAssetLocation(input.source.location); + const destinationLocation = toAssetLocation(input.destination.location); + + let foundPaths: AnchorChainingStepLike[][] | null; + + if ( + input.source.rail === 'KEETA_SEND' && + input.destination.rail === 'KEETA_SEND' && + convertAssetLocationToString(sourceLocation) === convertAssetLocationToString(destinationLocation) && + isChainLocation(sourceLocation, 'keeta') && + isChainLocation(destinationLocation, 'keeta') && + isAnchorChainingAssetEqual(input.source.asset, input.destination.asset) + ) { + const fromTo = { + asset: input.source.asset, + location: sourceLocation, + rail: 'KEETA_SEND' + } as const; + + foundPaths = [ + [ { type: 'keetaSend', from: fromTo, to: fromTo } ] + ]; + } else { + foundPaths = await this.graph.findPaths(input); + } + + foundPaths = foundPaths?.filter(path => { + for (let i = 0; i < path.length - 1; i++) { + const item = path[i]; + if (!item) { + continue; + } + + const toLocation = toAssetLocation(item.to.location); + if (toLocation.type !== 'chain' && i < path.length - 1) { + return(false); + } + } + + return(true); + }) ?? null; + + if (!foundPaths || foundPaths.length === 0) { + return(null); + } + + return(foundPaths.map(path => new AnchorChainingPath({ request: input, path, host: this }))); + } + + async getPlans(input: AnchorChainingPathInput, options?: ComputePlanOptions & { includeAllOutput?: false }): Promise; + async getPlans(input: AnchorChainingPathInput, options: ComputePlanOptions & { includeAllOutput: true }): Promise; + async getPlans(input: AnchorChainingPathInput, options?: ComputePlanOptions & { includeAllOutput?: boolean }): Promise<(AnchorChainingPlan | AnchorChainingFullPlanResult)[] | null> { + const paths = await this.getPaths(input); + if (!paths) { + return(null); + } + + const limit = options?.limit ?? DEFAULT_PLAN_LIMIT; + const sortedPaths = paths.sort((a, b) => a.path.length - b.path.length); + + const allOutput: PromiseSettledResult[] = []; + let successCount = 0; + let lowestStepsSuccessCount = Infinity; + let lastAttemptedPathIdx = -1; + let currentAttemptLoop = 0; + while (successCount < limit && lastAttemptedPathIdx < sortedPaths.length - 1 && currentAttemptLoop < MAX_PLAN_ATTEMPT_LOOPS) { + currentAttemptLoop++; + + const pathsToTry = sortedPaths.slice(lastAttemptedPathIdx + 1, lastAttemptedPathIdx + 1 + (limit - successCount)); + const firstToTry = pathsToTry[0]; + if (!firstToTry || firstToTry.path.length > lowestStepsSuccessCount) { + break; + } + + const currentTry = await Promise.allSettled(pathsToTry.map(path => AnchorChainingPlan.create(path, options))); + allOutput.push(...currentTry); + + for (let i = 0; i < currentTry.length; i++) { + const result = currentTry[i]; + const path = pathsToTry[i]; + if (!result || !path) { + continue; + } + + if (result.status === 'fulfilled') { + successCount++; + if (path.path.length < lowestStepsSuccessCount) { + lowestStepsSuccessCount = path.path.length; + } + } + } + + lastAttemptedPathIdx += pathsToTry.length; + } + + const ret: (AnchorChainingPlan | AnchorChainingFullPlanResult)[] = []; + for (let i = 0; i < allOutput.length; i++) { + const path = sortedPaths[i]; + const plan = allOutput[i]; + if (!path || !plan) { + continue; + } + + if (options?.includeAllOutput) { + if (plan.status === 'rejected') { + ret.push({ success: false, error: plan.reason, path }); + } else { + ret.push({ success: true, plan: plan.value, path }); + } + } else if (plan.status === 'rejected') { + this.logger?.debug(`AnchorChaining::getPlans`, `Error computing plan for a path:`, plan.reason); + } else { + ret.push(plan.value); + } + } + + return(ret); + } +} diff --git a/src/lib/chaining/fixtures.ts b/src/lib/chaining/fixtures.ts new file mode 100644 index 00000000..f5a732df --- /dev/null +++ b/src/lib/chaining/fixtures.ts @@ -0,0 +1,1580 @@ +import type { GenericAccount, TokenAddress } from '@keetanetwork/keetanet-client/lib/account.js'; + +import type { KeetaAnchorAssetMovementServerConfig } from '../../services/asset-movement/server.js'; +import type { AnchorTokenLocationMetadata, AssetLocationLike, KeetaAssetMovementTransaction, KeetaPersistentForwardingAddressDetails } from '../../services/asset-movement/common.js'; +import type { KeetaAnchorFXServerConfig, GetConversionRateAndFeeContext, KeetaFXInternalPriceQuote } from '../../services/fx/server.js'; +import type { ConversionInputCanonicalJSON } from '../../services/fx/common.js'; +import type { ServiceMetadataExternalizable } from '../resolver.js'; +import type { AnchorChainingPath, AnchorChainingPathState, ExecutedStep, AnchorChainingAsset, ComputePlanOptions, AnchorChainingPathExecuteOptions, AnchorChainingPathExecuteResult, StepNeededActionEventPayload, AnchorChainingPlan } from './index.js'; +import type { AnchorMetadataLegalField } from '../metadata.types.js'; +import { createNodeAndClient } from '../utils/tests/node.js'; +import { KeetaNet } from '../../client/index.js'; +import { KeetaNetAssetMovementAnchorHTTPServer } from '../../services/asset-movement/server.js'; +import { convertAssetLocationToString, toAssetLocation, toAssetPair } from '../../services/asset-movement/common.js'; +import { KeetaNetFXAnchorHTTPServer } from '../../services/fx/server.js'; +import { Resolver } from '../index.js'; +import { AnchorChaining } from './index.js'; +import { KeetaAnchorUserError } from '../error.js'; +import { AnchorExternal } from '../anchor-external.js'; +import { BlockListener } from '../block-listener.js'; + + +const DEBUG = false; +const logger = DEBUG ? console : undefined; + +export type InitiateTransferFn = NonNullable; +export type RateFn = (request: ConversionInputCanonicalJSON, context: GetConversionRateAndFeeContext) => Promise; + +const EMPTY_FROM_TRANSACTIONS = { deposit: null, persistentForwarding: null, finalization: null } as const; +const EMPTY_TO_TRANSACTIONS = { withdraw: null } as const; + +type KeetaAccount = InstanceType; +type TestNodeAndClient = Awaited>; +type TestUserClient = NonNullable; +type TestFees = TestNodeAndClient['fees']; +type DisclaimerList = Exclude; + +/** + * The full corridor fixture: two fiat bank anchors (US/EU), a Keeta-to-Keeta + * swap anchor, and two FX anchors at different rates, wired into a resolver. + */ +export interface ChainingTestHarness extends AsyncDisposable { + client: TestUserClient; + fees: TestFees; + tokens: { USDC: TokenAddress; EURC: TokenAddress }; + keetaLocation: AssetLocationLike; + bankServerUS: TestBankServer; + bankServerEU: TestBankServer; + swapServer: TestBankServer; + bankSignerUS: KeetaAccount; + bankSignerEU: KeetaAccount; + swapSigner: KeetaAccount; + fxServerOne: TestFXServer; + fxServerTwo: TestFXServer; + anchorChaining: AnchorChaining; + bankProviderDisclaimers: { BankUS: DisclaimerList; BankEU: DisclaimerList }; + euBankProviderID: 'BankEU'; + usBankProviderID: 'BankUS'; + fxProviderDisclaimers: { FXOne: DisclaimerList; FXTwo: DisclaimerList }; + fxOneProviderID: 'FXOne'; + fxTwoProviderID: 'FXTwo'; + giveTokens: (to: GenericAccount, amount: bigint, token: TokenAddress) => Promise; + getPlanVia: (fxProviderID: 'FXOne' | 'FXTwo', options?: ComputePlanOptions) => Promise; + getPathVia: (fxProviderID: 'FXOne' | 'FXTwo', affinity?: 'to' | 'from') => Promise; +} + +/** + * A bridge fixture exposing external-chain asset metadata for two providers. + */ +export interface MetadataHarness extends AsyncDisposable { + client: TestUserClient; + tokens: { USDC: TokenAddress }; + keetaLocation: AssetLocationLike; + evmChainLocation: AssetLocationLike; + usdcEvmId: AnchorChainingAsset; + bridgeOneMetadata: AnchorTokenLocationMetadata; + bridgeTwoMetadata: AnchorTokenLocationMetadata; + anchorChaining: AnchorChaining; +} + +/** + * A persistent-forwarding bridge fixture for forwarded-leg chaining. + */ +export interface PersistentForwardingHarness extends AsyncDisposable { + client: TestUserClient; + anchorChaining: AnchorChaining; + tokens: { USDC: TokenAddress; USDC2: TokenAddress }; + keetaLocation: AssetLocationLike; + evmChainLocation: AssetLocationLike; + bridgeServer: TestPersistentForwardingBridgeServer; +} + +/** + * A 3-leg fiat corridor fixture for path-discovery assertions. + */ +export interface AssetMovementPathHarness extends AsyncDisposable { + client: TestUserClient; + tokens: { USDC: TokenAddress; EURC: TokenAddress; USDT: TokenAddress; BTC: TokenAddress }; + keetaLocation: AssetLocationLike; + evmChainLocation: AssetLocationLike; + anchorChaining: AnchorChaining; +} + +/** + * `true` when a SEND's external field references the given transfer, either + * as the raw transfer id (anchor-provided external) or as an entry in a + * decodable plaintext envelope (client-constructed external). + */ +export async function externalReferencesTransfer(external: unknown, txId: string): Promise { + if (external === txId) { + return(true); + } + if (typeof external !== 'string' || external === '') { + return(false); + } + + let decoded; + try { + decoded = await AnchorExternal.fromPlainExternal(external); + } catch { + return(false); + } + + return(Object.values(decoded.envelope.anchors).some(function(entry) { + return('transactionId' in entry && entry.transactionId === txId); + })); +} + +/** + * Initiate-transfer wrapper simulating an anchor under the construction + * model: KEETA_SEND instructions carry no external, so the client must + * build the correlation envelope itself. + */ +export async function stripKeetaSendExternal(request: Parameters[0], next: InitiateTransferFn): ReturnType { + const response = await next(request); + return({ + ...response, + instructionChoices: response.instructionChoices.map(function(choice) { + if (choice.type === 'KEETA_SEND') { + const rest = { ...choice }; + delete rest.external; + return(rest); + } + + return(choice); + }) + }); +} + +/** + * Build a `KeetaAssetMovementTransaction` record for in-memory test bridges. + * `fromValue`/`toValue` are kept separate so bridges that charge a fee can + * model the asymmetry (e.g. `fromValue = value`, `toValue = value - fee`). + */ +export function buildTxRecord(args: { + id: string; + status: KeetaAssetMovementTransaction['status']; + asset: KeetaAssetMovementTransaction['asset']; + fromLocation: KeetaAssetMovementTransaction['from']['location']; + toLocation: KeetaAssetMovementTransaction['to']['location']; + fromValue: string; + toValue: string; +}): KeetaAssetMovementTransaction { + const now = new Date().toISOString(); + return({ + id: args.id, + status: args.status, + asset: args.asset, + from: { location: args.fromLocation, value: args.fromValue, transactions: { ...EMPTY_FROM_TRANSACTIONS }}, + to: { location: args.toLocation, value: args.toValue, transactions: { ...EMPTY_TO_TRANSACTIONS }}, + fee: null, + createdAt: now, + updatedAt: now + }); +} + +export class TestBankServer extends KeetaNetAssetMovementAnchorHTTPServer { + private readonly _initiateRef: { fn: InitiateTransferFn }; + #defaultInitiateRef: { fn: InitiateTransferFn; }; + private readonly _statusMap: Map; + private readonly _getStatusRef: { interceptor: (() => void) | null }; + + constructor(config: Omit & { + assetMovement: Omit; + client: KeetaNet.UserClient; + }) { + const { client: userClient, ...serverConfig } = config; + + const bankAccount = KeetaNet.lib.Account.fromSeed(KeetaNet.lib.Account.generateRandomSeed(), 0); + + const statusMap = new Map(); + const blockListener = new BlockListener({ client: userClient.client }); + const getStatusRef: { interceptor: (() => void) | null } = { interceptor: null }; + + const initiateRef: { fn: InitiateTransferFn } = { + fn: async (request) => { + const value = BigInt(request.value); + const fee = 10n; + const receive = value - fee; + const txId = `tx-${Date.now()}-${Math.random().toString(36).slice(2)}`; + + const parsedFrom = toAssetLocation(request.from.location); + if (parsedFrom.type === 'chain' && parsedFrom.chain.type === 'keeta') { + const assetPair = toAssetPair(request.asset); + + statusMap.set(txId, buildTxRecord({ + id: txId, + status: 'PENDING', + asset: request.asset, + fromLocation: request.from.location, + toLocation: request.to.location, + fromValue: value.toString(), + toValue: receive.toString() + })); + + let listenerHandle: { remove: () => void } | null = null; + listenerHandle = blockListener.on('block', { + callback: async ({ block }) => { + for (const op of block.operations) { + if (op.type === KeetaNet.lib.Block.OperationType.SEND && await externalReferencesTransfer(op.external, txId)) { + if (op.amount !== value) { + throw(new KeetaAnchorUserError(`Invalid transfer amount: expected ${value}, got ${op.amount}`)); + } + const existing = statusMap.get(txId); + if (existing && existing.status !== 'COMPLETE') { + statusMap.set(txId, { ...existing, status: 'COMPLETE', updatedAt: new Date().toISOString() }); + } + listenerHandle?.remove(); + return({ requiresWork: false }); + } + } + return({ requiresWork: false }); + } + }); + + const tokenAddress = assetPair.from; + if (typeof tokenAddress !== 'string') { + throw(new Error('invalid keeta send asset')); + } + + return({ + id: txId, + instructionChoices: [{ + type: 'KEETA_SEND' as const, + location: request.from.location, + sendToAddress: bankAccount.publicKeyString.get(), + external: txId, + value: value.toString(), + tokenAddress: KeetaNet.lib.Account.fromPublicKeyString(tokenAddress) + .assertKeyType(KeetaNet.lib.Account.AccountKeyAlgorithm.TOKEN) + .publicKeyString.get(), + assetFee: fee.toString(), + totalReceiveAmount: receive.toString() + }] + }); + } else { + statusMap.set(txId, buildTxRecord({ + id: txId, + status: 'COMPLETE', + asset: request.asset, + fromLocation: request.from.location, + toLocation: request.to.location, + fromValue: value.toString(), + toValue: receive.toString() + })); + return({ + id: txId, + instructionChoices: [{ + type: 'ACH', + account: { + type: 'bank-account', + accountType: 'us', + accountNumber: `test-acct-${txId}`, + routingNumber: '021000021', + accountTypeDetail: 'checking', + accountOwner: { type: 'business', businessName: 'TestBank' } + } as const, + value: value.toString(), + assetFee: fee.toString(), + totalReceiveAmount: receive.toString() + }] + }); + } + } + }; + + super({ + ...serverConfig, + assetMovement: { + ...serverConfig.assetMovement, + initiateTransfer: async (request) => { + // Status management is handled inside initiateRef.fn per direction. + return(await initiateRef.fn(request)); + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + getTransferStatus: async (id: string): Promise => { + // Allow tests to arm a one-shot failure for the next status poll. + const interceptor = getStatusRef.interceptor; + if (interceptor) { + getStatusRef.interceptor = null; + interceptor(); + } + // Scan recent blocks to detect any KEETA_SEND that completes a pending transfer. + await blockListener.scan(); + const tx = statusMap.get(id); + if (!tx) {throw(new Error(`Unknown transfer ID: ${id}`));} + return({ transaction: tx }); + } + } + }); + + // Store references to the shared objects so instance methods can mutate them. + this._initiateRef = initiateRef; + this.#defaultInitiateRef = { ...initiateRef }; + this._statusMap = statusMap; + this._getStatusRef = getStatusRef; + } + + setInitiateTransfer(fn: InitiateTransferFn | null): this { + if (!fn) { + fn = this.#defaultInitiateRef.fn; + } + + this._initiateRef.fn = fn; + + return(this); + } + + wrapInitiateTransfer(wrapper: (request: Parameters[0], next: InitiateTransferFn) => ReturnType): this { + const saved = this._initiateRef.fn; + this._initiateRef.fn = async (request) => { + return(await wrapper(request, saved)); + }; + return(this); + } + + setFee(fee: bigint): this { + return(this.setInitiateTransfer(async (request) => { + const value = BigInt(request.value); + const receive = value - fee; + const txId = `tx-${Date.now()}-${Math.random().toString(36).slice(2)}`; + this._statusMap.set(txId, buildTxRecord({ + id: txId, + status: 'COMPLETE', + asset: request.asset, + fromLocation: request.from.location, + toLocation: request.to.location, + fromValue: value.toString(), + toValue: receive.toString() + })); + + if (typeof request.to.recipient !== 'string') { + throw(new Error('invalid keeta send recipient')); + } + + const assetPair = toAssetPair(request.asset); + const tokenAddress = assetPair.from; + if (typeof tokenAddress !== 'string') { + throw(new Error('invalid keeta send asset')); + } + + return({ + id: txId, + instructionChoices: [{ + type: 'KEETA_SEND' as const, + location: request.from.location, + sendToAddress: KeetaNet.lib.Account.fromPublicKeyString(request.to.recipient).publicKeyString.get(), + value: value.toString(), + tokenAddress: KeetaNet.lib.Account.fromPublicKeyString(tokenAddress) + .assertKeyType(KeetaNet.lib.Account.AccountKeyAlgorithm.TOKEN) + .publicKeyString.get(), + assetFee: fee.toString(), + totalReceiveAmount: receive.toString() + }] + }); + })); + } + + /** Arm the server so the next initiateTransfer call throws (then restores). */ + failNextInitiate(message = 'Transfer initiation failed'): this { + const saved = this._initiateRef.fn; + this._initiateRef.fn = async () => { + this._initiateRef.fn = saved; + throw(new KeetaAnchorUserError(message)); + }; + + return(this); + } + + /** Arm the server so the next getTransferStatus call throws (then restores). */ + failNextTransferStatus(message = 'Transfer status check failed'): this { + this._getStatusRef.interceptor = () => { throw(new KeetaAnchorUserError(message)); }; + return(this); + } + + /** Manually update the status of an in-flight transfer. */ + setTransferStatus(id: string, update: Partial>): this { + const existing = this._statusMap.get(id); + if (!existing) {throw(new Error(`Unknown transfer ID: ${id}`));} + this._statusMap.set(id, { ...existing, ...update, updatedAt: new Date().toISOString() }); + return(this); + } +} + +export type TestFXServerConfig = Omit & { + fx: Pick; + giveTokens: (to: GenericAccount, amount: bigint, token: TokenAddress) => Promise; + /** Must be a UserClient so we can read LP balances and mint tokens on demand. */ + client: KeetaNet.UserClient; +}; + +export class TestFXServer extends KeetaNetFXAnchorHTTPServer { + private readonly _rateRef: { fn: RateFn }; + private readonly _giveTokens: (to: GenericAccount, amount: bigint, token: TokenAddress) => Promise; + private readonly _keetaClient: KeetaNet.UserClient; + private readonly _lp: InstanceType; + + constructor(config: TestFXServerConfig) { + // Resolve the LP from the accounts set + const lp = config.accounts?.values().next().value; + if (!lp) { + throw(new Error('TestFXServer requires at least one account in the accounts set')); + } + + const giveTokens = config.giveTokens; + const keetaClient = config.client; + + // Shared rate ref captured by the super() closure + const rateRef: { fn: RateFn } = { + fn: async (request) => { + const rate = request.affinity === 'to' ? 1 / 0.88 : 0.88; + const convertedAmount = BigInt(Math.round(Number(request.amount) * rate)); + const balance = await keetaClient.client.getBalance(lp, request.to); + if (balance < convertedAmount * 2n) { + const token = KeetaNet.lib.Account.fromPublicKeyString(request.to).assertKeyType(KeetaNet.lib.Account.AccountKeyAlgorithm.TOKEN); + await giveTokens(lp, convertedAmount * 2n, token); + } + + return({ + account: lp, + convertedAmount, + cost: { amount: 0n, token: KeetaNet.lib.Account.fromPublicKeyString(request.from).assertKeyType(KeetaNet.lib.Account.AccountKeyAlgorithm.TOKEN) } + }); + } + }; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { giveTokens: _gt, client: _userClient, quoteSigner: _qs, ...baseConfig } = config; + + // Pass the raw client config (not a UserClient) so the server uses the else-branch in + // its processor: it picks up config.signer (= LP) to build an LP-scoped UserClient itself. + const rawClient = { + client: keetaClient.client, + network: keetaClient.network, + networkAlias: keetaClient.config.networkAlias + }; + + super({ + ...baseConfig, + quoteSigner: null, + quoteConfiguration: { requiresQuote: false, validateQuoteBeforeExchange: false, issueQuotes: false }, + client: rawClient, + fx: { + ...config.fx, + getConversionRateAndFee: (request, context) => rateRef.fn(request, context) + } satisfies KeetaAnchorFXServerConfig['fx'] + }); + + this._rateRef = rateRef; + this._giveTokens = giveTokens; + this._keetaClient = keetaClient; + this._lp = lp; + } + + /** Set a fixed exchange rate (forward direction; reverse is 1/rate). */ + setRate(rate: number): this { + const lp = this._lp; + const giveTokens = this._giveTokens; + const keetaClient = this._keetaClient; + this._rateRef.fn = async (request, context) => { + const effectiveRate = request.affinity === 'to' ? 1 / rate : rate; + const convertedAmount = BigInt(Math.round(Number(request.amount) * effectiveRate)); + const balance = await keetaClient.client.getBalance(lp, request.to); + if (context.purpose === 'exchange' || context.purpose === 'quote') { + if (balance < convertedAmount * 2n) { + const token = KeetaNet.lib.Account.fromPublicKeyString(request.to).assertKeyType(KeetaNet.lib.Account.AccountKeyAlgorithm.TOKEN); + await giveTokens(lp, convertedAmount * 2n, token); + } + } + return({ + account: lp, + convertedAmount, + cost: { amount: 0n, token: KeetaNet.lib.Account.fromPublicKeyString(request.from).assertKeyType(KeetaNet.lib.Account.AccountKeyAlgorithm.TOKEN) } + }); + }; + return(this); + } + + /** Replace the full conversion handler. */ + setGetConversionRateAndFee(fn: RateFn): this { + this._rateRef.fn = fn; + return(this); + } + + /** + * Arm so the next estimate-phase call throws during createExchange. + * computeSteps() calls getEstimate BEFORE arming so it succeeds. + * execute() -> createExchange() hits getUnsignedQuoteData(purpose='estimate') + * BEFORE queuing, so the failure propagates immediately to the client. + */ + failNextExchange(message = 'FX exchange failed'): this { + const saved = this._rateRef.fn; + this._rateRef.fn = async (request, context) => { + if (context.purpose === 'estimate') { + this._rateRef.fn = saved; + throw(new KeetaAnchorUserError(message)); + } + return(await saved(request, context)); + }; + return(this); + } +} + +export type PersistentForwardingBridgeAddressMeta = { + sourceLocation: AssetLocationLike; + destinationLocation: AssetLocationLike; + destinationAddress: string; + asset: KeetaAssetMovementTransaction['asset']; +}; + +export type TestPersistentForwardingBridgeServerConfig = Omit & { + assetMovement: Omit< + KeetaAnchorAssetMovementServerConfig['assetMovement'], + 'initiateTransfer' | 'getTransferStatus' | 'simulateTransfer' | 'createPersistentForwarding' | 'listPersistentForwarding' | 'listTransactions' + >; + client: KeetaNet.UserClient; +}; + +/** + * Test bridge for the persistent-forwarding flow used by anchor chaining. + */ +export class TestPersistentForwardingBridgeServer extends KeetaNetAssetMovementAnchorHTTPServer { + readonly bridgeAccount: GenericAccount; + readonly addresses: Map; + readonly transactionsByAddress: Map; + readonly transferStatuses: Map; + + constructor(config: TestPersistentForwardingBridgeServerConfig) { + const { client: userClient, ...serverConfig } = config; + + const bridgeAccount = KeetaNet.lib.Account.fromSeed(KeetaNet.lib.Account.generateRandomSeed(), 0); + const blockListener = new BlockListener({ client: userClient.client }); + + const addresses = new Map(); + const transactionsByAddress = new Map(); + const transferStatuses = new Map(); + + super({ + ...serverConfig, + assetMovement: { + ...serverConfig.assetMovement, + async initiateTransfer(request) { + const parsedFrom = toAssetLocation(request.from.location); + const isKeetaSource = parsedFrom.type === 'chain' && parsedFrom.chain.type === 'keeta'; + if (!isKeetaSource) { + throw(new KeetaAnchorUserError(`initiateTransfer not supported from ${convertAssetLocationToString(request.from.location)}; use createPersistentForwarding instead`)); + } + + const value = BigInt(request.value); + const txId = `tx-${Date.now()}-${Math.random().toString(36).slice(2)}`; + const recipientAddress = typeof request.to.recipient === 'string' ? request.to.recipient : ''; + + transferStatuses.set(txId, buildTxRecord({ + id: txId, + status: 'PENDING', + asset: request.asset, + fromLocation: request.from.location, + toLocation: request.to.location, + fromValue: value.toString(), + toValue: value.toString() + })); + + let handle: { remove: () => void } | null = null; + handle = blockListener.on('block', { + callback: async ({ block }) => { + for (const op of block.operations) { + if (op.type === KeetaNet.lib.Block.OperationType.SEND && op.external === txId) { + if (op.amount !== value) { + throw(new KeetaAnchorUserError(`Invalid transfer amount: expected ${value}, got ${op.amount}`)); + } + + const withdrawTxId = `withdraw-${txId}`; + const existing = transferStatuses.get(txId); + if (existing && existing.status !== 'COMPLETE') { + transferStatuses.set(txId, { + ...existing, + status: 'COMPLETE', + updatedAt: new Date().toISOString(), + to: { + ...existing.to, + transactions: { withdraw: { id: withdrawTxId, nonce: '0' }} + } + }); + } + + /* + * Mimics the bridge's EVM withdrawal landing at the + * persistent forwarding address and auto-forwarding to the + * chain destination. + */ + const meta = addresses.get(recipientAddress); + if (meta) { + const list = transactionsByAddress.get(recipientAddress) ?? []; + const forwarded = buildTxRecord({ + id: `persistentForwarding-tx-${Date.now()}-${Math.random().toString(36).slice(2)}`, + status: 'COMPLETE', + asset: meta.asset, + fromLocation: convertAssetLocationToString(meta.sourceLocation), + toLocation: convertAssetLocationToString(meta.destinationLocation), + fromValue: value.toString(), + toValue: value.toString() + }); + forwarded.from.transactions = { + ...forwarded.from.transactions, + persistentForwarding: { id: withdrawTxId, nonce: '0' } + }; + list.push(forwarded); + transactionsByAddress.set(recipientAddress, list); + } + + handle?.remove(); + return({ requiresWork: false }); + } + } + return({ requiresWork: false }); + } + }); + + const tokenAddress = toAssetPair(request.asset).from; + if (typeof tokenAddress !== 'string') { + throw(new Error('invalid keeta send asset')); + } + + return({ + id: txId, + instructionChoices: [{ + type: 'KEETA_SEND' as const, + location: request.from.location, + sendToAddress: bridgeAccount.publicKeyString.get(), + external: txId, + value: value.toString(), + tokenAddress: KeetaNet.lib.Account.fromPublicKeyString(tokenAddress) + .assertKeyType(KeetaNet.lib.Account.AccountKeyAlgorithm.TOKEN) + .publicKeyString.get(), + assetFee: '0', + totalReceiveAmount: value.toString() + }] + }); + }, + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async getTransferStatus(id: string): Promise { + await blockListener.scan(); + const tx = transferStatuses.get(id); + if (!tx) { + throw(new Error(`Unknown transfer ID: ${id}`)); + } + + return({ transaction: tx }); + }, + + async simulateTransfer(request) { + const value = BigInt(request.value); + const tokenAddress = toAssetPair(request.asset).from; + if (typeof tokenAddress !== 'string') { + throw(new Error('invalid asset for simulate')); + } + + const parsedFrom = toAssetLocation(request.from.location); + const isKeetaSource = parsedFrom.type === 'chain' && parsedFrom.chain.type === 'keeta'; + if (isKeetaSource) { + return({ + instructionChoices: [{ + type: 'KEETA_SEND' as const, + location: request.from.location, + value: value.toString(), + tokenAddress: KeetaNet.lib.Account.fromPublicKeyString(tokenAddress) + .assertKeyType(KeetaNet.lib.Account.AccountKeyAlgorithm.TOKEN) + .publicKeyString.get(), + assetFee: '0', + totalReceiveAmount: value.toString() + }] + }); + } + + /* + * EVM-side simulation for the persistent-forwarding leg. + */ + if (!tokenAddress.startsWith('evm:0x')) { + throw(new Error(`invalid evm asset format for simulate: ${tokenAddress}`)); + } + + /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */ + const evmTokenHex = tokenAddress.slice('evm:'.length) as `0x${string}`; + return({ + instructionChoices: [{ + type: 'EVM_SEND' as const, + location: request.from.location, + value: value.toString(), + tokenAddress: evmTokenHex, + assetFee: '0', + totalReceiveAmount: value.toString() + }] + }); + }, + + async createPersistentForwarding(request) { + if (!('destinationLocation' in request) || !('destinationAddress' in request)) { + throw(new KeetaAnchorUserError('createPersistentForwarding via template is not supported in this test bridge')); + } + if (typeof request.destinationAddress !== 'string') { + throw(new KeetaAnchorUserError('Test bridge only supports string destinationAddress for persistent forwarding')); + } + + const address = `persistentForwarding-${Math.random().toString(36).slice(2)}`; + const meta: PersistentForwardingBridgeAddressMeta = { + sourceLocation: request.sourceLocation, + destinationLocation: request.destinationLocation, + destinationAddress: request.destinationAddress, + asset: request.asset + }; + + addresses.set(address, meta); + + return({ + address, + asset: meta.asset, + sourceLocation: meta.sourceLocation, + destinationLocation: meta.destinationLocation, + destinationAddress: meta.destinationAddress + }); + }, + + async listPersistentForwarding(request) { + const all: KeetaPersistentForwardingAddressDetails[] = []; + for (const [address, meta] of addresses) { + all.push({ + address, + asset: meta.asset, + sourceLocation: meta.sourceLocation, + destinationLocation: meta.destinationLocation, + destinationAddress: meta.destinationAddress + }); + } + + let filtered = all; + const searches = request.search; + if (searches && searches.length > 0) { + filtered = all.filter(addr => searches.some(search => { + if (search.destinationAddress !== undefined && addr.destinationAddress !== search.destinationAddress) { + return(false); + } + return(true); + })); + } + + return({ + addresses: filtered, + total: filtered.length.toString() + }); + }, + + async listTransactions(request) { + const transactions: KeetaAssetMovementTransaction[] = []; + for (const pf of (request.persistentAddresses ?? [])) { + if (!('persistentAddress' in pf) || !pf.persistentAddress) { + continue; + } + const found = transactionsByAddress.get(pf.persistentAddress) ?? []; + transactions.push(...found); + } + + const txFilters = request.transactions; + let filtered = transactions; + if (txFilters && txFilters.length > 0) { + const wantedIds = new Set(txFilters + .map(f => f.transaction.id) + .filter((id): id is string => typeof id === 'string')); + + filtered = transactions.filter(tx => { + const fromIds = [ + tx.from.transactions.persistentForwarding?.id, + tx.from.transactions.deposit?.id, + tx.from.transactions.finalization?.id + ]; + const toIds = [ tx.to.transactions.withdraw?.id ]; + for (const id of [ ...fromIds, ...toIds ]) { + if (id && wantedIds.has(id)) { + return(true); + } + } + return(false); + }); + } + + return({ + transactions: filtered, + total: filtered.length.toString() + }); + } + } + }); + + this.bridgeAccount = bridgeAccount; + this.addresses = addresses; + this.transactionsByAddress = transactionsByAddress; + this.transferStatuses = transferStatuses; + } +} + + +export async function createChainingTestHarness(options: { includeSwapAnchor?: boolean } = {}): Promise { + const account = KeetaNet.lib.Account.fromSeed(KeetaNet.lib.Account.generateRandomSeed(), 0); + const { userClient: client, fees } = await createNodeAndClient(account); + + const makeToken = async () => { + const { account } = await client.generateIdentifier(KeetaNet.lib.Account.AccountKeyAlgorithm.TOKEN); + await client.setInfo( + { name: '', description: '', metadata: '', defaultPermission: new KeetaNet.lib.Permissions(['ACCESS']) }, + { account } + ); + return(account.assertKeyType(KeetaNet.lib.Account.AccountKeyAlgorithm.TOKEN)); + }; + + const giveTokens = async (to: GenericAccount, amount: bigint, token: TokenAddress) => { + await client.modTokenSupplyAndBalance(amount, token, { account: to }); + }; + + const keetaLocation = `chain:keeta:${client.network}` satisfies AssetLocationLike; + const tokens = { USDC: await makeToken(), EURC: await makeToken() }; + + const fxLPOne = KeetaNet.lib.Account.fromSeed(KeetaNet.lib.Account.generateRandomSeed(), 0); + const fxLPTwo = KeetaNet.lib.Account.fromSeed(KeetaNet.lib.Account.generateRandomSeed(), 0); + + const [usBankProviderID, euBankProviderID] = ['BankUS', 'BankEU'] as const; + const bankProviderDisclaimers: { + [bankProviderID in typeof usBankProviderID | typeof euBankProviderID]: Exclude + } = { + [usBankProviderID]: [ + { + purpose: 'general', + content: { + type: 'plaintext', + content: 'This is a legal disclaimer for the US bank server' + } + } + ], + [euBankProviderID]: [ + { + purpose: 'general', + content: { + type: 'plaintext', + content: 'This is a legal disclaimer for the EU bank server' + } + }, + { + purpose: 'general', + content: { + type: 'markdown', + content: 'This is another legal disclaimer for the EU bank server' + } + } + ] + }; + + /* + * Bank entries are signed so providers resolve with a service-entry + * account, which client-side external construction files entries under. + */ + const bankSignerUS = KeetaNet.lib.Account.fromSeed(KeetaNet.lib.Account.generateRandomSeed(), 0); + const bankSignerEU = KeetaNet.lib.Account.fromSeed(KeetaNet.lib.Account.generateRandomSeed(), 0); + const swapSigner = KeetaNet.lib.Account.fromSeed(KeetaNet.lib.Account.generateRandomSeed(), 0); + + const bankServerUS = new TestBankServer({ + ...(DEBUG ? { logger } : {}), + client, + metadataSigner: bankSignerUS, + assetMovement: { + legal: { + disclaimers: bankProviderDisclaimers['BankUS'] + }, + supportedAssets: [{ + asset: [ tokens.USDC.publicKeyString.get(), 'USD' ], + paths: [{ pair: [ + { location: 'bank-account:us', id: 'USD', rails: { common: [ 'ACH' ] }}, + { location: keetaLocation, id: tokens.USDC.publicKeyString.get(), rails: { common: [ 'KEETA_SEND' ] }} + ] }] + }] + } + }); + + const bankServerEU = new TestBankServer({ + ...(DEBUG ? { logger } : {}), + client, + metadataSigner: bankSignerEU, + assetMovement: { + legal: { + disclaimers: bankProviderDisclaimers['BankEU'] + }, + supportedAssets: [{ + asset: [ tokens.EURC.publicKeyString.get(), 'EUR' ], + paths: [{ pair: [ + { location: 'bank-account:iban-swift', id: 'EUR', rails: { common: [ 'SEPA_PUSH' ] }}, + { location: keetaLocation, id: tokens.EURC.publicKeyString.get(), rails: { common: [ 'KEETA_SEND' ] }} + ] }] + }] + } + }); + + /* + * Keeta-to-Keeta token swap anchor (USDC -> EURC). Both rails are + * KEETA_SEND, so chaining it before a bank withdrawal produces two + * user-funded sends in one execution. + */ + const swapServer = new TestBankServer({ + ...(DEBUG ? { logger } : {}), + client, + metadataSigner: swapSigner, + assetMovement: { + supportedAssets: [{ + asset: [ tokens.USDC.publicKeyString.get(), tokens.EURC.publicKeyString.get() ], + paths: [{ pair: [ + { location: keetaLocation, id: tokens.USDC.publicKeyString.get(), rails: { common: [ 'KEETA_SEND' ] }}, + { location: keetaLocation, id: tokens.EURC.publicKeyString.get(), rails: { common: [ 'KEETA_SEND' ] }} + ] }] + }] + } + }); + + const [fxOneProviderID, fxTwoProviderID] = ['FXOne', 'FXTwo'] as const; + const fxProviderDisclaimers: { + [fxProviderID in typeof fxOneProviderID | typeof fxTwoProviderID]: Exclude + } = { + [fxOneProviderID]: [ + { + purpose: 'general', + content: { type: 'plaintext', content: 'This is a legal disclaimer for FX provider One' } + } + ], + [fxTwoProviderID]: [ + { + purpose: 'general', + content: { type: 'plaintext', content: 'This is a legal disclaimer for FX provider Two' } + }, + { + purpose: 'general', + content: { type: 'markdown', content: 'This is another legal disclaimer for FX provider Two' } + } + ] + }; + // fxServerOne: 0.88 rate (primary) + const fxServerOne = new TestFXServer({ + ...(DEBUG ? { logger } : {}), + quoteSigner: KeetaNet.lib.Account.fromSeed(KeetaNet.lib.Account.generateRandomSeed(), 0), + accounts: new KeetaNet.lib.Account.Set([ fxLPOne ]), + signer: fxLPOne, + client, + giveTokens, + fx: { + legal: { disclaimers: fxProviderDisclaimers[fxOneProviderID] }, + from: [{ currencyCodes: [ tokens.USDC.publicKeyString.get(), tokens.EURC.publicKeyString.get() ], to: [ tokens.USDC.publicKeyString.get(), tokens.EURC.publicKeyString.get() ] }] + } + }); + + // fxServerTwo: 0.85 rate (alternative, slightly worse) + const fxServerTwo = new TestFXServer({ + ...(DEBUG ? { logger } : {}), + quoteSigner: KeetaNet.lib.Account.fromSeed(KeetaNet.lib.Account.generateRandomSeed(), 0), + accounts: new KeetaNet.lib.Account.Set([ fxLPTwo ]), + signer: fxLPTwo, + client, + giveTokens, + fx: { + legal: { disclaimers: fxProviderDisclaimers[fxTwoProviderID] }, + from: [{ currencyCodes: [ tokens.USDC.publicKeyString.get(), tokens.EURC.publicKeyString.get() ], to: [ tokens.USDC.publicKeyString.get(), tokens.EURC.publicKeyString.get() ] }] + } + }).setRate(0.85); + + await bankServerUS.start(); + await bankServerEU.start(); + await swapServer.start(); + await fxServerOne.start(); + await fxServerTwo.start(); + + // Make FX LPs fee-free so they don't need KTA to execute exchanges + fees.addFeeFreeAccount(fxLPOne); + fees.addFeeFreeAccount(fxLPTwo); + + /* + * The swap anchor is opt-in: its keeta-to-keeta pair adds round-trip + * paths that would change path counts in unrelated tests. + */ + const assetMovementServices: { [providerID: string]: Awaited> } = { + [usBankProviderID]: await bankServerUS.serviceMetadata(), + [euBankProviderID]: await bankServerEU.serviceMetadata() + }; + if (options.includeSwapAnchor === true) { + assetMovementServices['SwapKeeta'] = await swapServer.serviceMetadata(); + } + + await client.setInfo({ + description: 'Chaining Test', + name: 'TEST', + metadata: Resolver.Metadata.formatMetadata({ + version: 1, + currencyMap: { '$USDC': tokens.USDC.publicKeyString.get(), '$EURC': tokens.EURC.publicKeyString.get() }, + services: { + fx: { + FXOne: await fxServerOne.serviceMetadata(), + FXTwo: await fxServerTwo.serviceMetadata() + }, + assetMovement: assetMovementServices + } + } satisfies ServiceMetadataExternalizable) + }); + + const anchorChaining = new AnchorChaining({ + client, + resolver: new Resolver({ root: client.account, client, trustedCAs: [] }) + }); + + const getPathVia = async (fxProviderID: 'FXOne' | 'FXTwo', affinity: 'to' | 'from' = 'from') => { + const paths = await anchorChaining.getPaths({ + source: { asset: tokens.USDC, location: keetaLocation, rail: 'KEETA_SEND', ...(affinity === 'from' ? { value: 100n } : {}) }, + destination: { asset: 'EUR', location: 'bank-account:iban-swift', recipient: client.account.publicKeyString.get(), rail: 'SEPA_PUSH', ...(affinity === 'to' ? { value: 100n } : {}) } + }); + const path = paths?.find(p => p.path.some(n => n.type === 'fx' && n.providerID === fxProviderID)); + if (!path) { + throw(new Error(`No path found using ${fxProviderID}`)); + } + return(path); + }; + + const getPlanVia = async (fxProviderID: 'FXOne' | 'FXTwo', options?: ComputePlanOptions) => { + const plans = await anchorChaining.getPlans({ + source: { asset: tokens.USDC, location: keetaLocation, value: 100n, rail: 'KEETA_SEND' }, + destination: { asset: 'EUR', location: 'bank-account:iban-swift', recipient: client.account.publicKeyString.get(), rail: 'SEPA_PUSH' } + }, options); + + const plan = plans?.find(p => p.path.some(n => n.type === 'fx' && n.providerID === fxProviderID)); + + if (!plan) { + throw(new Error(`No plan found using ${fxProviderID}`)); + } + + return(plan); + }; + + return({ + client, + fees, + tokens, + keetaLocation, + bankServerUS, + bankServerEU, + swapServer, + bankSignerUS, + bankSignerEU, + swapSigner, + fxServerOne, + fxServerTwo, + anchorChaining, + bankProviderDisclaimers, + euBankProviderID, + usBankProviderID, + fxProviderDisclaimers, + fxOneProviderID, + fxTwoProviderID, + giveTokens, + getPlanVia, + getPathVia, + [Symbol.asyncDispose]: async function() { + await bankServerUS[Symbol.asyncDispose]?.(); + await bankServerEU[Symbol.asyncDispose]?.(); + await swapServer[Symbol.asyncDispose]?.(); + await fxServerOne[Symbol.asyncDispose]?.(); + await fxServerTwo[Symbol.asyncDispose]?.(); + } + }); +} + +export async function createMetadataHarness(): Promise { + const account = KeetaNet.lib.Account.fromSeed(KeetaNet.lib.Account.generateRandomSeed(), 0); + const { userClient: client } = await createNodeAndClient(account); + + const makeToken = async () => { + const { account } = await client.generateIdentifier(KeetaNet.lib.Account.AccountKeyAlgorithm.TOKEN); + return(account.assertKeyType(KeetaNet.lib.Account.AccountKeyAlgorithm.TOKEN)); + }; + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + const evmChainLocation = 'chain:evm:500' as const; + const keetaLocation = `chain:keeta:${client.network}` as const; + const tokens = { USDC: await makeToken() }; + const usdcEvmId: AnchorChainingAsset = 'evm:0xc0634090F2Fe6c6d75e61Be2b949464aBB498973'; + + const bridgeOneMetadata: AnchorTokenLocationMetadata = { + displayName: 'Circle USDC', + decimalPlaces: 6, + ticker: '$USDC', + logoURI: 'example.com/usdc-logo' + }; + + const bridgeTwoMetadata: AnchorTokenLocationMetadata = { + displayName: 'USDC (alt)', + decimalPlaces: 6, + ticker: '$USDC', + logoURI: 'example.com/usdc-logo-2' + }; + + const makeBridge = (metadata: typeof bridgeOneMetadata) => new KeetaNetAssetMovementAnchorHTTPServer({ + ...(logger ? { logger: logger } : {}), + assetMovement: { + supportedAssets: [ + { + asset: tokens.USDC.publicKeyString.get(), + paths: [{ + pair: [ + { location: keetaLocation, id: tokens.USDC.publicKeyString.get(), rails: { common: [ 'KEETA_SEND' ] }}, + { location: evmChainLocation, id: usdcEvmId, rails: { common: [ 'EVM_SEND' ], inbound: [ 'EVM_CALL' ] }} + ] + }] + }, + { + asset: '$USDC', + paths: [ + { + pair: [ + { location: evmChainLocation, id: usdcEvmId, rails: { common: [ 'EVM_SEND' ] }}, + { location: keetaLocation, id: tokens.USDC.publicKeyString.get(), rails: { inbound: [ 'KEETA_SEND' ] }} + ] + } + ] + } + ], + locationMetadata: { + [evmChainLocation]: { + assets: { + [usdcEvmId]: metadata + } + } + }, + async getTransferStatus() { + throw(new Error('getTransferStatus not used in metadata tests')); + }, + async createPersistentForwarding() { + throw(new Error('getTransferStatus not used in metadata tests')); + }, + async initiateTransfer() { + throw(new Error('getTransferStatus not used in metadata tests')); + } + } + }); + + const bridgeOne = makeBridge(bridgeOneMetadata); + const bridgeTwo = makeBridge(bridgeTwoMetadata); + + await bridgeOne.start(); + await bridgeTwo.start(); + + await client.setInfo({ + description: 'Metadata Test', + name: 'TEST', + metadata: Resolver.Metadata.formatMetadata({ + version: 1, + currencyMap: { '$USDC': tokens.USDC.publicKeyString.get() }, + services: { + assetMovement: { + BridgeOne: await bridgeOne.serviceMetadata(), + BridgeTwo: await bridgeTwo.serviceMetadata() + } + } + } satisfies ServiceMetadataExternalizable) + }); + + const anchorChaining = new AnchorChaining({ + client, + resolver: new Resolver({ root: client.account, client, trustedCAs: [] }) + }); + + return({ + client, + tokens, + keetaLocation, + evmChainLocation, + usdcEvmId, + bridgeOneMetadata, + bridgeTwoMetadata, + anchorChaining, + [Symbol.asyncDispose]: async function() { + await bridgeOne[Symbol.asyncDispose]?.(); + await bridgeTwo[Symbol.asyncDispose]?.(); + } + }); +} + +export const PFR_SUPPORTED_OPS = { initiateTransfer: false, createPersistentForwarding: true } as const; + +export function newDestinationAccount(): KeetaAccount { + return(KeetaNet.lib.Account.fromSeed(KeetaNet.lib.Account.generateRandomSeed(), 0)); +} + +export function firstPath(paths: T[] | null | undefined): T { + const found = paths?.[0]; + if (!paths || !found) { + throw(new Error(`No paths found`)); + } + + return(found); +} + +export async function getKeetaUsdcToUsdc2Path(h: PersistentForwardingHarness, value: bigint, recipient: GenericAccount): Promise { + const paths = await h.anchorChaining.getPaths({ + source: { asset: h.tokens.USDC, location: h.keetaLocation, value, rail: 'KEETA_SEND' }, + destination: { asset: h.tokens.USDC2, location: h.keetaLocation, recipient: recipient.publicKeyString.get(), rail: 'KEETA_SEND' } + }); + return(firstPath(paths)); +} + +export async function createPersistentForwardingHarness(): Promise { + const account = KeetaNet.lib.Account.fromSeed(KeetaNet.lib.Account.generateRandomSeed(), 0); + const { userClient: client } = await createNodeAndClient(account); + + const makeToken = async () => { + const { account: tokenAccount } = await client.generateIdentifier(KeetaNet.lib.Account.AccountKeyAlgorithm.TOKEN); + + await client.setInfo( + { name: '', description: '', metadata: '', defaultPermission: new KeetaNet.lib.Permissions(['ACCESS']) }, + { account: tokenAccount } + ); + + return(tokenAccount.assertKeyType(KeetaNet.lib.Account.AccountKeyAlgorithm.TOKEN)); + }; + + const evmChainLocation = 'chain:evm:500' satisfies AssetLocationLike; + const keetaLocation = `chain:keeta:${client.network}` satisfies AssetLocationLike; + const evmUsdcId = 'evm:0xc0634090F2Fe6c6d75e61Be2b949464aBB498973'; + + const tokens = { USDC: await makeToken(), USDC2: await makeToken() }; + + type AssetEntry = KeetaAnchorAssetMovementServerConfig['assetMovement']['supportedAssets'][number]; + const makeAssetEntry = (keetaToken: TokenAddress): AssetEntry => ({ + asset: keetaToken.publicKeyString.get(), + paths: [{ + pair: [ + { location: keetaLocation, id: keetaToken.publicKeyString.get(), rails: { common: [ 'KEETA_SEND' ] }}, + { location: evmChainLocation, id: evmUsdcId, rails: { common: [{ rail: 'EVM_SEND', supportedOperations: PFR_SUPPORTED_OPS }] }} + ] + }] + }); + + const bridgeServer = new TestPersistentForwardingBridgeServer({ + ...(DEBUG ? { logger } : {}), + client, + assetMovement: { + supportedAssets: [ + makeAssetEntry(tokens.USDC), + makeAssetEntry(tokens.USDC2) + ] + } + }); + + await bridgeServer.start(); + + await client.setInfo({ + description: 'Persistent Forwarding Chain Test Root', + name: 'TEST', + metadata: Resolver.Metadata.formatMetadata({ + version: 1, + currencyMap: Object.fromEntries(Object.entries(tokens).map(function([ symbol, token ]) { + return([ `$${symbol}`, token.publicKeyString.get() ]); + })), + services: { + assetMovement: { + PersistentForwardingBridge: await bridgeServer.serviceMetadata() + } + } + } satisfies ServiceMetadataExternalizable) + }); + + const anchorChaining = new AnchorChaining({ + client, + resolver: new Resolver({ root: client.account, client, trustedCAs: [] }) + }); + + return({ + client, + anchorChaining, + tokens, + keetaLocation, + evmChainLocation, + bridgeServer, + [Symbol.asyncDispose]: async function() { + await bridgeServer[Symbol.asyncDispose]?.(); + } + }); +} + +/** + * A 3-leg fiat corridor (USD bank -> Keeta FX -> EUR bank) used to exercise + * path discovery across mixed asset-movement and FX providers. + */ +export async function createAssetMovementPathHarness(): Promise { + const account = KeetaNet.lib.Account.fromSeed(KeetaNet.lib.Account.generateRandomSeed(), 0); + const { userClient: client } = await createNodeAndClient(account); + + const makeTokenAssert = async () => { + const { account: tokenAccount } = await client.generateIdentifier(KeetaNet.lib.Account.AccountKeyAlgorithm.TOKEN); + return(tokenAccount.assertKeyType(KeetaNet.lib.Account.AccountKeyAlgorithm.TOKEN)); + }; + + const evmChainLocation = 'chain:evm:500' satisfies AssetLocationLike; + const keetaLocation = `chain:keeta:${client.network}` satisfies AssetLocationLike; + + const tokens = { + USDC: await makeTokenAssert(), + EURC: await makeTokenAssert(), + USDT: await makeTokenAssert(), + BTC: await makeTokenAssert() + }; + + const baseAnchorAssetMovementServer = new KeetaNetAssetMovementAnchorHTTPServer({ + ...(logger ? { logger } : {}), + assetMovement: { + supportedAssets: [ + { + asset: tokens.USDC.publicKeyString.get(), + paths: [{ + pair: [ + { location: keetaLocation, id: tokens.USDC.publicKeyString.get(), rails: { common: [ { rail: 'KEETA_SEND' } ] }}, + { location: evmChainLocation, id: 'evm:0xc0634090F2Fe6c6d75e61Be2b949464aBB498973', rails: { common: [ 'EVM_SEND' ], inbound: [ 'EVM_CALL' ] }} + ] + }] + }, + { + asset: '$USDC', + paths: [{ + pair: [ + { location: evmChainLocation, id: 'evm:0xc0634090F2Fe6c6d75e61Be2b949464aBB498973', rails: { common: [ 'EVM_SEND' ] }}, + { location: keetaLocation, id: tokens.USDC.publicKeyString.get(), rails: { inbound: [ 'KEETA_SEND' ] }} + ] + }] + } + ], + async createPersistentForwarding() { + throw(new Error('createPersistentForwarding not used in path-discovery fixture')); + }, + async initiateTransfer() { + throw(new Error('initiateTransfer not used in path-discovery fixture')); + }, + async getTransferStatus() { + return({ + transaction: buildTxRecord({ + id: 'tx123', + status: 'PENDING', + asset: tokens.USDC.publicKeyString.get(), + fromLocation: evmChainLocation, + toLocation: keetaLocation, + fromValue: '500', + toValue: '500' + }) + }); + } + } + }); + + const bankAnchorServer = new KeetaNetAssetMovementAnchorHTTPServer({ + ...(logger ? { logger } : {}), + assetMovement: { + supportedAssets: [ + { + asset: [ tokens.USDC.publicKeyString.get(), 'USD' ], + paths: [{ + pair: [ + { location: 'bank-account:us', id: 'USD', rails: { common: [ 'ACH', 'WIRE' ] }}, + { location: keetaLocation, id: tokens.USDC.publicKeyString.get(), rails: { common: [ 'KEETA_SEND' ] }} + ] + }] + }, + { + asset: [ tokens.EURC.publicKeyString.get(), 'EUR' ], + paths: [{ + pair: [ + { location: 'bank-account:iban-swift', id: 'EUR', rails: { common: [ 'SEPA_PUSH' ] }}, + { location: keetaLocation, id: tokens.EURC.publicKeyString.get(), rails: { common: [ 'KEETA_SEND' ] }} + ] + }] + } + ], + async getTransferStatus() { + return({ + transaction: buildTxRecord({ + id: 'tx123', + status: 'PENDING', + asset: tokens.USDC.publicKeyString.get(), + fromLocation: evmChainLocation, + toLocation: keetaLocation, + fromValue: '500', + toValue: '500' + }) + }); + }, + async createPersistentForwarding() { + throw(new Error('createPersistentForwarding not used in path-discovery fixture')); + }, + async initiateTransfer() { + throw(new Error('initiateTransfer not used in path-discovery fixture')); + } + } + }); + + const fxServerLiquidityProvider = KeetaNet.lib.Account.fromSeed(KeetaNet.lib.Account.generateRandomSeed(), 0); + const fxServer = new KeetaNetFXAnchorHTTPServer({ + ...(logger ? { logger } : {}), + quoteSigner: KeetaNet.lib.Account.fromSeed(KeetaNet.lib.Account.generateRandomSeed(), 0), + accounts: new KeetaNet.lib.Account.Set([ fxServerLiquidityProvider ]), + signer: fxServerLiquidityProvider, + client: { client: client.client, network: client.config.network, networkAlias: client.config.networkAlias }, + fx: { + from: [{ + currencyCodes: [ tokens.USDC.publicKeyString.get(), tokens.USDT.publicKeyString.get(), tokens.BTC.publicKeyString.get(), tokens.EURC.publicKeyString.get() ], + to: [ tokens.USDC.publicKeyString.get(), tokens.USDT.publicKeyString.get(), tokens.BTC.publicKeyString.get(), tokens.EURC.publicKeyString.get() ] + }], + getConversionRateAndFee: async function(request) { + let rate = 0.88; + if (request.affinity === 'to') { + rate = 1 / rate; + } + return({ + account: fxServerLiquidityProvider, + convertedAmount: BigInt(request.amount) * BigInt(Math.round(rate * 1000)) / 1000n, + cost: { + amount: 0n, + token: KeetaNet.lib.Account.fromPublicKeyString(request.from).assertKeyType(KeetaNet.lib.Account.AccountKeyAlgorithm.TOKEN) + } + }); + } + } + }); + + await fxServer.start(); + await baseAnchorAssetMovementServer.start(); + await bankAnchorServer.start(); + + await client.setInfo({ + description: 'FX Anchor Test Root', + name: 'TEST', + metadata: Resolver.Metadata.formatMetadata({ + version: 1, + currencyMap: Object.fromEntries(Object.entries(tokens).map(function([ symbol, token ]) { + return([ `$${symbol}`, token.publicKeyString.get() ]); + })), + services: { + fx: { + FXOne: await fxServer.serviceMetadata() + }, + assetMovement: { + BaseAnchor: await baseAnchorAssetMovementServer.serviceMetadata(), + BankAnchor: await bankAnchorServer.serviceMetadata() + } + } + } satisfies ServiceMetadataExternalizable) + }); + + const anchorChaining = new AnchorChaining({ + client, + resolver: new Resolver({ root: client.account, client, trustedCAs: [] }) + }); + + return({ + client, + tokens, + keetaLocation, + evmChainLocation, + anchorChaining, + [Symbol.asyncDispose]: async function() { + await fxServer[Symbol.asyncDispose]?.(); + await baseAnchorAssetMovementServer[Symbol.asyncDispose]?.(); + await bankAnchorServer[Symbol.asyncDispose]?.(); + } + }); +} + +/** + * A record of every event a plan emitted during a run, for behavioral + * assertions without re-deriving listener boilerplate in each test. + */ +export interface ChainEventRecorder { + stateHistory: AnchorChainingPathState['status'][]; + executed: { step: ExecutedStep; index: number }[]; + actions: StepNeededActionEventPayload[]; + completed: AnchorChainingPathExecuteResult | null; + failed: { error: Error; completedSteps: ExecutedStep[]; index: number }[]; +} + +/** + * A consumer-supplied handler for a {@link StepNeededActionEventPayload}. It is + * responsible for eventually calling `markCompleted`/`markFailed`. + */ +export type StepActionHandler = (payload: StepNeededActionEventPayload) => void | Promise; + +/** + * The default action handler: approve sends, acknowledge user-execution + * prompts, and proceed through under-delivery reviews. + */ +export const defaultApproveAction: StepActionHandler = function(payload) { + switch (payload.type) { + case 'keetaSendAuthRequired': + payload.markCompleted({ sent: true }); + break; + case 'assetMovementUserExecutionRequired': + payload.markCompleted(); + break; + case 'underDeliveryReview': + payload.markCompleted({ proceed: true }); + break; + } +}; + +/** + * Attach listeners that record every emitted event into a returned recorder. + */ +export function collectEvents(plan: AnchorChainingPlan): ChainEventRecorder { + const recorder: ChainEventRecorder = { stateHistory: [], executed: [], actions: [], completed: null, failed: [] }; + + plan.on('stateChange', (state) => recorder.stateHistory.push(state.status)); + plan.on('stepExecuted', (step, index) => recorder.executed.push({ step, index })); + plan.on('completed', (result) => { recorder.completed = result; }); + plan.on('failed', (error, completedSteps, index) => recorder.failed.push({ error, completedSteps, index })); + + return(recorder); +} + +/** + * Options governing {@link runChain}. + */ +export interface RunChainOptions { + requireSendAuth?: boolean; + correlationID?: string; + /** + * Per-action handler; defaults to {@link defaultApproveAction}. Every + * action is recorded regardless of the handler. + */ + onAction?: StepActionHandler; +} + +/** + * Execute a plan with event recording and a default action handler, returning + * the result alongside the recorded events. The action handler is invoked for + * every `stepNeedsAction`, after the payload is recorded. + */ +export async function runChain(plan: AnchorChainingPlan, options: RunChainOptions = {}): Promise<{ result: AnchorChainingPathExecuteResult; events: ChainEventRecorder }> { + const events = collectEvents(plan); + const handler = options.onAction ?? defaultApproveAction; + + plan.on('stepNeedsAction', (payload) => { + events.actions.push(payload); + void Promise.resolve(handler(payload)); + }); + + const executeOptions: AnchorChainingPathExecuteOptions = {}; + if (options.requireSendAuth !== undefined) { + executeOptions.requireSendAuth = options.requireSendAuth; + } + if (options.correlationID !== undefined) { + executeOptions.correlationID = options.correlationID; + } + + const result = await plan.execute(executeOptions); + return({ result, events }); +} diff --git a/src/lib/chaining-graph.cli.ts b/src/lib/chaining/graph.cli.ts similarity index 94% rename from src/lib/chaining-graph.cli.ts rename to src/lib/chaining/graph.cli.ts index c0d4041d..89ce0408 100644 --- a/src/lib/chaining-graph.cli.ts +++ b/src/lib/chaining/graph.cli.ts @@ -1,15 +1,15 @@ import type { Networks } from '@keetanetwork/keetanet-client/config/index.js'; import type { GenericAccount, TokenPublicKeyString } from '@keetanetwork/keetanet-client/lib/account.js'; import { createAssert } from 'typia'; -import { assertNever } from './utils/never.js'; +import { assertNever } from '../utils/never.js'; import * as KeetaNet from '@keetanetwork/keetanet-client'; -import { getDefaultResolverConfig } from '../config.js'; -import { Resolver } from './index.js'; -import type { AnchorChainingAsset, GraphNodeLike } from './chaining.js'; -import { AnchorChaining } from './chaining.js'; -import { convertAssetSearchInputToCanonical } from './asset.js'; -import type { AssetLocationLike } from '../services/asset-movement/common.js'; -import { convertAssetLocationToString } from '../services/asset-movement/common.js'; +import { getDefaultResolverConfig } from '../../config.js'; +import { Resolver } from '../index.js'; +import type { AnchorChainingAsset, GraphNodeLike } from './index.js'; +import { AnchorChaining } from './index.js'; +import { convertAssetSearchInputToCanonical } from '../asset.js'; +import type { AssetLocationLike } from '../../services/asset-movement/common.js'; +import { convertAssetLocationToString } from '../../services/asset-movement/common.js'; const assertNetwork = createAssert(); diff --git a/src/lib/chaining/graph.test.ts b/src/lib/chaining/graph.test.ts new file mode 100644 index 00000000..a4761f46 --- /dev/null +++ b/src/lib/chaining/graph.test.ts @@ -0,0 +1,443 @@ +import { test, expect, describe } from 'vitest'; + +import type { AnchorChainingAsset, AnchorChainingAssetInfo, AnchorChainingResolveAssetsFilter, Disclaimer } from './index.js'; +import { KeetaNet } from '../../client/index.js'; +import { convertAssetLocationToString } from '../../services/asset-movement/common.js'; +import { + createChainingTestHarness, + createMetadataHarness, + createPersistentForwardingHarness, + PFR_SUPPORTED_OPS +} from './fixtures.js'; + +/** Stable string key for an asset (token public key or ISO/external code). */ +function assetKey(asset: AnchorChainingAsset): string { + if (KeetaNet.lib.Account.isInstance(asset)) { + return(asset.publicKeyString.get()); + } + + return(String(asset)); +} + +/** Stable `asset@location` key for an asset-info result. */ +function resultKey(item: AnchorChainingAssetInfo): string { + return(`${assetKey(item.asset)}@${convertAssetLocationToString(item.location)}`); +} + +describe('graph.listAssets', function() { + test('onlyAllowFXLike excludes the source token and bank-account destinations', async function() { + await using h = await createChainingTestHarness(); + + const assets = await h.anchorChaining.graph.listAssets({ + from: { asset: h.tokens.USDC, location: h.keetaLocation }, + onlyAllowFXLike: true + }); + expect(assets).toHaveLength(1); + + const eurc = assets[0]; + if (!eurc) { + throw(new Error('Expected to find the EURC asset')); + } + + expect(assetKey(eurc.asset)).toEqual(h.tokens.EURC.publicKeyString.get()); + expect(eurc.location).toEqual(h.keetaLocation); + expect(eurc.rails.inbound).toEqual([ 'KEETA_SEND' ]); + expect(eurc.rails.outbound).toEqual([ 'KEETA_SEND' ]); + }); + + test('a from filter with maxStepCount=1 returns only direct 1-hop destinations', async function() { + await using h = await createChainingTestHarness(); + + const assets = await h.anchorChaining.graph.listAssets({ + from: { asset: h.tokens.USDC, location: h.keetaLocation }, + maxStepCount: 1 + }); + expect(assets).toHaveLength(2); + + const keys = assets.map(resultKey); + expect(keys).toContain(`${h.tokens.EURC.publicKeyString.get()}@${convertAssetLocationToString(h.keetaLocation)}`); + expect(keys).toContain('USD@bank-account:us'); + expect(keys).not.toContain('EUR@bank-account:iban-swift'); + }); + + test('a from filter without maxStepCount finds all reachable assets', async function() { + await using h = await createChainingTestHarness(); + + const assets = await h.anchorChaining.graph.listAssets({ + from: { asset: h.tokens.USDC, location: h.keetaLocation } + }); + expect(assets).toHaveLength(4); + + const keys = assets.map(resultKey); + expect(keys).toContain(`${h.tokens.EURC.publicKeyString.get()}@${convertAssetLocationToString(h.keetaLocation)}`); + expect(keys).toContain('EUR@bank-account:iban-swift'); + expect(keys).toContain('USD@bank-account:us'); + }); + + test('a to filter with maxStepCount=1 returns only direct 1-hop sources', async function() { + await using h = await createChainingTestHarness(); + + const assets = await h.anchorChaining.graph.listAssets({ + to: { location: 'bank-account:us' }, + maxStepCount: 1 + }); + expect(assets).toHaveLength(1); + + const usdc = assets[0]; + if (!usdc) { + throw(new Error('Expected to find the USDC asset')); + } + + expect(assetKey(usdc.asset)).toEqual(h.tokens.USDC.publicKeyString.get()); + expect(usdc.location).toEqual(h.keetaLocation); + expect(usdc.rails.outbound).toContain('KEETA_SEND'); + }); + + test('no filter returns all four distinct asset-location pairs', async function() { + await using h = await createChainingTestHarness(); + + const assets = await h.anchorChaining.graph.listAssets(); + expect(assets).toHaveLength(4); + + const keys = assets.map(resultKey); + expect(keys).toContain(`${h.tokens.USDC.publicKeyString.get()}@${convertAssetLocationToString(h.keetaLocation)}`); + expect(keys).toContain(`${h.tokens.EURC.publicKeyString.get()}@${convertAssetLocationToString(h.keetaLocation)}`); + expect(keys).toContain('USD@bank-account:us'); + expect(keys).toContain('EUR@bank-account:iban-swift'); + }); + + test('a from filter populates distance.pathLength with the shortest hop count', async function() { + await using h = await createChainingTestHarness(); + + const assets = await h.anchorChaining.graph.listAssets({ + from: { asset: h.tokens.USDC, location: h.keetaLocation } + }); + + const distanceByKey = new Map(assets.map(a => [ resultKey(a), a.distance?.pathLength ])); + expect(distanceByKey.get(`${h.tokens.EURC.publicKeyString.get()}@${convertAssetLocationToString(h.keetaLocation)}`)).toEqual(1); + expect(distanceByKey.get('USD@bank-account:us')).toEqual(1); + expect(distanceByKey.get('EUR@bank-account:iban-swift')).toEqual(2); + }); + + test('a to filter populates distance.pathLength', async function() { + await using h = await createChainingTestHarness(); + + const assets = await h.anchorChaining.graph.listAssets({ + to: { location: 'bank-account:us' }, + maxStepCount: 1 + }); + expect(assets).toHaveLength(1); + expect(assets[0]?.distance).toEqual({ pathLength: 1 }); + }); + + test('no filter returns a null distance for every asset', async function() { + await using h = await createChainingTestHarness(); + + const assets = await h.anchorChaining.graph.listAssets(); + for (const asset of assets) { + expect(asset.distance).toBeNull(); + } + }); +}); + +describe('graph.resolveAssets', function() { + type ExpectedAsset = { key: string; distance: number | null }; + type ResolveCase = { + name: string; + args: AnchorChainingResolveAssetsFilter | AnchorChainingResolveAssetsFilter[]; + expected: { from: ExpectedAsset[]; to: ExpectedAsset[] }; + }; + + test('resolves directional reachability and distances under a range of filters', async function() { + await using h = await createChainingTestHarness(); + + const usdcKey = `${h.tokens.USDC.publicKeyString.get()}@${convertAssetLocationToString(h.keetaLocation)}`; + const eurcKey = `${h.tokens.EURC.publicKeyString.get()}@${convertAssetLocationToString(h.keetaLocation)}`; + const usdKey = 'USD@bank-account:us'; + const eurKey = 'EUR@bank-account:iban-swift'; + + const cases: ResolveCase[] = [ + { + name: 'from only', + args: { from: { asset: h.tokens.USDC, location: h.keetaLocation }}, + expected: { + from: [], + to: [ + { key: eurcKey, distance: 1 }, + { key: usdKey, distance: 1 }, + { key: eurKey, distance: 2 }, + { key: usdcKey, distance: 2 } + ] + } + }, + { + name: 'to only with maxStepCount: 1', + args: { to: { location: 'bank-account:us' }, maxStepCount: 1 }, + expected: { from: [ { key: usdcKey, distance: 1 } ], to: [] } + }, + { + name: 'no filter', + args: {}, + expected: { + from: [ + { key: usdcKey, distance: null }, + { key: eurcKey, distance: null }, + { key: usdKey, distance: null }, + { key: eurKey, distance: null } + ], + to: [ + { key: usdcKey, distance: null }, + { key: eurcKey, distance: null }, + { key: usdKey, distance: null }, + { key: eurKey, distance: null } + ] + } + }, + { + name: 'from+to: keeta -> bank-account:us', + args: [ + { from: { location: h.keetaLocation }, to: { location: 'bank-account:us' }}, + { from: { location: h.keetaLocation, rail: 'KEETA_SEND' }, to: { location: 'bank-account:us' }}, + { from: { location: h.keetaLocation }, to: { location: 'bank-account:us', rail: 'ACH' }}, + { from: { location: h.keetaLocation, rail: 'KEETA_SEND' }, to: { location: 'bank-account:us', rail: 'ACH' }} + ], + expected: { + from: [ + { key: usdcKey, distance: 1 }, + { key: eurcKey, distance: 2 } + ], + to: [ { key: usdKey, distance: 1 } ] + } + }, + { + name: 'from+to: invalid rail yields nothing', + args: [ + { from: { location: h.keetaLocation }, to: { location: 'bank-account:us', rail: 'BITCOIN_SEND' }}, + { from: { location: h.keetaLocation, rail: 'ACH' }, to: { location: 'bank-account:us' }} + ], + expected: { from: [], to: [] } + }, + { + name: 'from+to: to.rail SEPA_PUSH filters to the EU corridor', + args: { from: { location: h.keetaLocation }, to: { location: 'bank-account:iban-swift', rail: 'SEPA_PUSH' }}, + expected: { + from: [ + { key: eurcKey, distance: 1 }, + { key: usdcKey, distance: 2 } + ], + to: [ { key: eurKey, distance: 1 } ] + } + }, + { + name: 'from+to: from.rail ACH limits sources to ACH-outbound assets', + args: { from: { rail: 'ACH' }, to: { location: h.keetaLocation }}, + expected: { + from: [ { key: usdKey, distance: 1 } ], + to: [ + { key: usdcKey, distance: 1 }, + { key: eurcKey, distance: 2 } + ] + } + } + ]; + + const toActual = (side: AnchorChainingAssetInfo[]): ExpectedAsset[] => + side.map(a => ({ key: resultKey(a), distance: a.distance?.pathLength ?? null })); + + for (const { name, args, expected } of cases) { + const argsArray = Array.isArray(args) ? args : [ args ]; + for (const argValue of argsArray) { + const result = await h.anchorChaining.graph.resolveAssets(argValue); + expect(toActual(result.from), `${name}: from`).toEqual(expect.arrayContaining(expected.from)); + expect(result.from, `${name}: from length`).toHaveLength(expected.from.length); + expect(toActual(result.to), `${name}: to`).toEqual(expect.arrayContaining(expected.to)); + expect(result.to, `${name}: to length`).toHaveLength(expected.to.length); + } + } + }); +}); + +describe('graph metadata', function() { + test('listAssetsWithMetadata attaches metadata for external-chain assets', async function() { + await using h = await createMetadataHarness(); + const assets = await h.anchorChaining.graph.listAssetsWithMetadata(); + + const evmAsset = assets.find(a => !KeetaNet.lib.Account.isInstance(a.asset) && String(a.asset) === h.usdcEvmId && a.location === h.evmChainLocation); + expect(evmAsset?.metadata).toMatchObject({ ticker: '$USDC', decimalPlaces: 6 }); + }); + + test('listAssetsWithMetadata leaves Keeta-native tokens without metadata', async function() { + await using h = await createMetadataHarness(); + const assets = await h.anchorChaining.graph.listAssetsWithMetadata(); + + const keetaAsset = assets.find(a => KeetaNet.lib.Account.isInstance(a.asset) && a.asset.publicKeyString.get() === h.tokens.USDC.publicKeyString.get()); + expect(keetaAsset).toBeDefined(); + expect(keetaAsset?.metadata).toBeUndefined(); + }); + + test('resolveAssetsWithMetadata attaches metadata on the resolved side only', async function() { + await using h = await createMetadataHarness(); + const result = await h.anchorChaining.graph.resolveAssetsWithMetadata({ + from: { location: h.keetaLocation }, + to: { location: h.evmChainLocation } + }); + + const evmAsset = result.to.find(a => !KeetaNet.lib.Account.isInstance(a.asset) && String(a.asset) === h.usdcEvmId); + expect(evmAsset?.metadata).toMatchObject({ ticker: '$USDC', decimalPlaces: 6 }); + + const keetaAsset = result.from.find(a => KeetaNet.lib.Account.isInstance(a.asset) && a.asset.publicKeyString.get() === h.tokens.USDC.publicKeyString.get()); + expect(keetaAsset).toBeDefined(); + expect(keetaAsset?.metadata).toBeUndefined(); + }); + + test('resolveAssetsWithMetadata returns the requested provider metadata', async function() { + await using h = await createMetadataHarness(); + + const bridgeOne = await h.anchorChaining.graph.resolveAssetsWithMetadata( + { to: { location: h.evmChainLocation }, from: { location: h.keetaLocation }}, + { providerID: 'BridgeOne' } + ); + + const bridgeOneEvm = bridgeOne.to.find(a => !KeetaNet.lib.Account.isInstance(a.asset) && String(a.asset) === h.usdcEvmId && a.location === h.evmChainLocation); + expect(bridgeOneEvm?.metadata).toEqual(h.bridgeOneMetadata); + + const bridgeTwo = await h.anchorChaining.graph.resolveAssetsWithMetadata( + { to: { location: h.evmChainLocation }, from: { location: h.keetaLocation }}, + { providerID: 'BridgeTwo' } + ); + + const bridgeTwoEvm = bridgeTwo.to.find(a => !KeetaNet.lib.Account.isInstance(a.asset) && String(a.asset) === h.usdcEvmId); + expect(bridgeTwoEvm?.metadata).toEqual(h.bridgeTwoMetadata); + }); + + test('listAssetsWithMetadata returns undefined metadata for an unknown provider', async function() { + await using h = await createMetadataHarness(); + const assets = await h.anchorChaining.graph.listAssetsWithMetadata( + { to: { location: h.evmChainLocation }}, + { providerID: 'NonExistentBridge' } + ); + + const evmAsset = assets.find(a => !KeetaNet.lib.Account.isInstance(a.asset) && String(a.asset) === h.usdcEvmId); + expect(evmAsset).toBeDefined(); + expect(evmAsset?.metadata).toBeUndefined(); + }); + + test('getAssetMovementProvidersForAsset returns every provider for an external asset', async function() { + await using h = await createMetadataHarness(); + + const providers = await h.anchorChaining.graph.getAssetMovementProvidersForAsset(h.usdcEvmId, h.evmChainLocation); + expect(Object.keys(providers ?? {}).sort()).toEqual([ 'BridgeOne', 'BridgeTwo' ]); + for (const entry of Object.values(providers ?? {})) { + expect(entry.provider).toBeDefined(); + } + }); + + test('getAssetMovementProvidersForAsset finds providers for Keeta-side assets', async function() { + await using h = await createMetadataHarness(); + + const providers = await h.anchorChaining.graph.getAssetMovementProvidersForAsset(h.tokens.USDC, h.keetaLocation); + expect(Object.keys(providers ?? {}).sort()).toEqual([ 'BridgeOne', 'BridgeTwo' ]); + }); + + test('getAssetMovementProvidersForAsset returns null for an unknown pair', async function() { + await using h = await createMetadataHarness(); + + const providers = await h.anchorChaining.graph.getAssetMovementProvidersForAsset('evm:0x000000000000000000000000000000000000dEaD', h.evmChainLocation); + expect(providers).toBeNull(); + }); + + test('graph nodes carry rail supportedOperations metadata', async function() { + await using h = await createPersistentForwardingHarness(); + const nodes = await h.anchorChaining.graph.computeGraphNodes(); + + const evmSourceNode = nodes.find(n => n.type === 'assetMovement' && n.from.location === h.evmChainLocation && n.from.rail === 'EVM_SEND'); + expect(evmSourceNode).toBeDefined(); + expect(evmSourceNode?.from.supportedOperations).toEqual(PFR_SUPPORTED_OPS); + }); +}); + +describe('getPlans', function() { + test('includeAllOutput preserves failures alongside successes and the default drops them', async function() { + await using h = await createChainingTestHarness(); + + const input = { + source: { asset: h.tokens.USDC, location: h.keetaLocation, value: 100n, rail: 'KEETA_SEND' as const }, + destination: { asset: 'EUR' as const, location: 'bank-account:iban-swift' as const, recipient: h.client.account.publicKeyString.get(), rail: 'SEPA_PUSH' as const } + }; + + const allOk = await h.anchorChaining.getPlans(input, { includeAllOutput: true }); + expect(allOk).toHaveLength(2); + for (const result of allOk ?? []) { + expect(result.success).toBe(true); + if (result.success) { + expect(result.plan).toBeDefined(); + expect(result.path).toBeDefined(); + } + } + + h.fxServerOne.setGetConversionRateAndFee(async () => { + throw(new Error('FXOne rate unavailable')); + }); + + const mixed = await h.anchorChaining.getPlans(input, { includeAllOutput: true }); + expect(mixed).toHaveLength(2); + + const failed = mixed?.find(r => !r.success); + const succeeded = mixed?.find(r => r.success); + if (!failed || failed.success) { + throw(new Error('Expected a failed result')); + } + expect(failed.error).toBeTruthy(); + expect(failed.path).toBeDefined(); + + if (!succeeded || !succeeded.success) { + throw(new Error('Expected a successful result')); + } + + expect(succeeded.plan.preview.steps.some(s => s.type === 'fx' && s.providerID === 'FXTwo')).toBe(true); + + const defaultResults = await h.anchorChaining.getPlans(input); + expect(defaultResults).toHaveLength(1); + expect(defaultResults?.[0]?.preview.steps.some(s => s.type === 'fx' && s.providerID === 'FXTwo')).toBe(true); + }); +}); + +describe('path disclaimers', function() { + test('a path returns each provider leg legal disclaimers in order', async function() { + await using h = await createChainingTestHarness(); + + const expectDisclaimers = async (paths: Awaited>, legCount: number): Promise => { + if (!paths || paths.length === 0) { + throw(new Error('Expected at least one valid path')); + } + + for (const path of paths) { + const expected = path.path.slice(0, legCount).map((step) => { + if (!step.providerID) { + throw(new Error('Expected step to have a provider ID')); + } + const map: { [key: string]: Disclaimer[] } = step.type === 'assetMovement' ? h.bankProviderDisclaimers : h.fxProviderDisclaimers; + return({ providerID: step.providerID, disclaimers: map[step.providerID] }); + }); + + const disclaimers = await path.getProviderLegalDisclaimers(); + expect(disclaimers?.length).toEqual(expected.length); + expect(disclaimers).toEqual(expected); + } + }; + + const recipient = h.client.account.publicKeyString.get(); + + const euBankPaths = await h.anchorChaining.getPaths({ + source: { asset: h.tokens.USDC, location: h.keetaLocation, rail: 'KEETA_SEND', value: 100n }, + destination: { asset: 'EUR', location: 'bank-account:iban-swift', recipient, rail: 'SEPA_PUSH' } + }); + await expectDisclaimers(euBankPaths, euBankPaths?.[0]?.path.length ?? 0); + + const usBankPaths = await h.anchorChaining.getPaths({ + source: { asset: h.tokens.EURC, location: h.keetaLocation, rail: 'KEETA_SEND', value: 100n }, + destination: { asset: 'USD', location: 'bank-account:us', recipient, rail: 'ACH' } + }); + await expectDisclaimers(usBankPaths, usBankPaths?.[0]?.path.length ?? 0); + }); +}); diff --git a/src/lib/chaining/graph.ts b/src/lib/chaining/graph.ts new file mode 100644 index 00000000..73837080 --- /dev/null +++ b/src/lib/chaining/graph.ts @@ -0,0 +1,829 @@ +import type { + AnchorChainingAsset, + AnchorChainingAssetAndLocation, + AnchorChainingAssetInfo, + AnchorChainingAssetInfoWithMetadata, + AnchorChainingListAssetsFilter, + AnchorChainingListAssetsSideFilter, + AnchorChainingPathInput, + AnchorChainingResolveAssetsFilter, + AnchorChainingResolveAssetsResult, + AnchorChainingResolveAssetsWithMetadataResult, + AnchorChainingWithMetadataOptions, + AssetMovementProvider, + AssetMovementResolvedRails, + GraphNodeLike, + RailSupportedOperations, + RailWithSupportedOperations +} from './types.js'; +import type { + AnchorTokenLocationMetadata, + AssetLocationLike, + AssetWithRails, + MovableAssetSearchCanonical, + RailOrRailWithExtendedDetails +} from '../../services/asset-movement/common.js'; +import type { Resolver } from '../index.js'; +import type { ISOCurrencyCode } from '@keetanetwork/currency-info'; +import type { TokenAddress } from '@keetanetwork/keetanet-client/lib/account.js'; +import type { ToValuizable } from '../resolver.js'; +import type { KeetaAssetMovementAnchorProvider } from '../../services/asset-movement/client.js'; +import type { ExternalChainAsset } from '../asset.js'; +import type { Logger } from '../log/index.js'; +import { Currency } from '@keetanetwork/currency-info'; +import { convertAssetLocationToString, convertAssetSearchInputToCanonical } from '../../services/asset-movement/common.js'; +import { isAssetLocationLike } from '../../services/asset-movement/lib/location.generated.js'; +import { isMovableAssetSearchCanonical, isRail } from '../../services/asset-movement/common.generated.js'; +import { isExternalChainAsset } from '../asset.js'; +import { isAnchorChainingAssetEqual, isFXLikeNode, nodeSideSupports } from './types.js'; +import KeetaFXAnchorClient from '../../services/fx/client.js'; +import KeetaAssetMovementAnchorClient from '../../services/asset-movement/client.js'; +import * as KeetaNet from '@keetanetwork/keetanet-client'; + +/** + * Pure topology over FX and asset-movement anchors. Resolves provider service + * metadata into a directed graph of {@link GraphNodeLike} edges and exposes + * path-finding and asset-discovery queries over it. Carries no execution + * concern: it neither initiates transfers nor sends value. + */ +export class AnchorGraph { + client: KeetaNet.UserClient; + resolver: Resolver; + logger?: Logger | undefined; + + readonly assetMovementClient: KeetaAssetMovementAnchorClient; + readonly fxClient: KeetaFXAnchorClient; + readonly #assetMovementProviderCache = new Map(); + readonly #assetNameCache = new Map(); + #graphNodePromise: Promise | null = null; + + constructor(args: { client: KeetaNet.UserClient; resolver: Resolver; logger?: Logger | undefined; }) { + this.resolver = args.resolver; + this.client = args.client; + this.logger = args.logger; + this.assetMovementClient = new KeetaAssetMovementAnchorClient(this.client, { + resolver: this.resolver, + ...(this.logger ? { logger: this.logger } : {}) + }); + this.fxClient = new KeetaFXAnchorClient(this.client, { + resolver: this.resolver, + ...(this.logger ? { logger: this.logger } : {}) + }); + } + + #assetLocationKey = (side: { asset: AnchorChainingAsset; location: AssetLocationLike }) => { + return(`${convertAssetSearchInputToCanonical(side.asset)}@${convertAssetLocationToString(side.location)}`); + }; + + async getAssetMovementProviderById(providerID: string): Promise { + let provider: KeetaAssetMovementAnchorProvider | undefined | null = this.#assetMovementProviderCache.get(providerID); + if (provider === undefined) { + provider = await this.assetMovementClient.getProviderByID(providerID); + } + + this.#assetMovementProviderCache.set(providerID, provider); + + return(provider); + } + + async getAssetMovementProvidersForAsset(asset: AnchorChainingAsset, location: AssetLocationLike): Promise { + let retval: null | { [providerID: string]: { provider: AssetMovementProvider; }} = null; + for (const node of await this.computeGraphNodes()) { + if (node.type !== 'assetMovement') { + continue; + } + + for (const side of [ node.from, node.to ] as const) { + if (!isAnchorChainingAssetEqual(side.asset, asset) || convertAssetLocationToString(side.location) !== convertAssetLocationToString(location)) { + continue; + } + + if (!retval) { + retval = {}; + } + + if (!retval[node.providerID]) { + const provider = await this.getAssetMovementProviderById(node.providerID); + if (!provider) { + this.logger?.debug('AnchorGraph::getAssetMovementProvidersForAsset', `No provider found for providerID ${node.providerID}, although provider was previously known to exist in the graph nodes`); + continue; + } + + retval[node.providerID] = { provider }; + } + } + } + + return(retval); + } + + async #computeFXNodes() { + const fxServices = await this.resolver.lookup('fx', {}); + if (!fxServices) { + return([]); + } + + const networkLocation = `chain:keeta:${this.client.network}` satisfies AssetLocationLike; + const providerLookupResult = await Promise.all(Object.entries(fxServices).map(async ([ providerID, service ]) => { + const fromEntries = await service.from('array'); + + if (!fromEntries) { + return(null); + } + + const operations = await service.operations('object'); + if (!operations.createExchange) { + this.logger?.debug('AnchorGraph::computeFXNodes', `FX service ${providerID} does not support createExchange operation, skipping`); + return(null); + } + + const pathNodes = await Promise.all(fromEntries.map(async function(fromEntry) { + const pathNodesResult: GraphNodeLike[] = []; + + const parsedEntry = await fromEntry('object'); + + const [ fromCodes, toCodes ] = await Promise.all([ + parsedEntry.currencyCodes('array'), + parsedEntry.to('array') + ]); + + for (const from of fromCodes) { + const fromResolved = await from('string'); + if (!fromResolved) { + continue; + } + + const fromAccount = KeetaNet.lib.Account.fromPublicKeyString(fromResolved).assertKeyType(KeetaNet.lib.Account.AccountKeyAlgorithm.TOKEN); + for (const to of toCodes) { + const toResolved = await to('string'); + if (!toResolved) { + continue; + } + + const toAccount = KeetaNet.lib.Account.fromPublicKeyString(toResolved).assertKeyType(KeetaNet.lib.Account.AccountKeyAlgorithm.TOKEN); + if (fromAccount.comparePublicKey(toAccount)) { + continue; + } + + pathNodesResult.push({ + type: 'fx', + providerID: providerID, + from: { asset: fromAccount, location: networkLocation, rail: 'KEETA_SEND' }, + to: { asset: toAccount, location: networkLocation, rail: 'KEETA_SEND' } + }); + } + } + + return(pathNodesResult); + })); + + return(pathNodes.flat()); + })); + + return(providerLookupResult.flat().filter((node): node is GraphNodeLike => !!node)); + } + + async #resolveAssetName(name: MovableAssetSearchCanonical): Promise { + if (KeetaNet.lib.Account.isInstance(name) && name.isToken()) { + return(name); + } + + if (typeof name === 'string') { + try { + return(KeetaNet.lib.Account.fromPublicKeyString(name).assertKeyType(KeetaNet.lib.Account.AccountKeyAlgorithm.TOKEN)); + } catch { + /* ignore error and continue with other resolution methods */ + } + } + + let found = this.#assetNameCache.get(name); + if (found) { + return(found); + } + + if (isExternalChainAsset(name)) { + found = name; + } else if (Currency.isCurrencyCode(name)) { + found = name; + } else if (Currency.isISOCurrencyNumber(name)) { + found = new Currency(name).code; + } else { + const lookupRet = await this.resolver.lookupToken(name); + if (lookupRet) { + found = KeetaNet.lib.Account.toAccount(lookupRet.token); + } + } + + if (!found) { + throw(new Error(`Unable to resolve asset name: ${name}`)); + } + + this.#assetNameCache.set(name, found); + + return(found); + } + + async #computeAssetRails(assetInput: ToValuizable): Promise { + try { + const railResolved = await assetInput('string'); + + if (!isRail(railResolved)) { + throw(new Error(`Invalid rail format: ${railResolved}`)); + } + + return({ rail: railResolved }); + } catch { + /* ignore error */ + } + + const extendedDetailsResolved = await assetInput('object'); + if (!extendedDetailsResolved || typeof extendedDetailsResolved !== 'object' || Array.isArray(extendedDetailsResolved)) { + throw(new Error(`Invalid asset format, expected string or object with extended details`)); + } + if (!('rail' in extendedDetailsResolved)) { + throw(new Error(`Invalid asset format, missing 'rail' field in extended details`)); + } + + const railResolved = await extendedDetailsResolved.rail?.('string'); + if (!isRail(railResolved)) { + throw(new Error(`Invalid rail format in extended details: ${railResolved}`)); + } + + let supportedOperations: RailSupportedOperations | undefined; + if ('supportedOperations' in extendedDetailsResolved && extendedDetailsResolved.supportedOperations) { + const opsResolved = await extendedDetailsResolved.supportedOperations('object'); + if (opsResolved && typeof opsResolved === 'object' && !Array.isArray(opsResolved)) { + const parsed: RailSupportedOperations = {}; + if ('createPersistentForwarding' in opsResolved && opsResolved.createPersistentForwarding) { + const val = await opsResolved.createPersistentForwarding('boolean'); + if (typeof val === 'boolean') { + parsed.createPersistentForwarding = val; + } + } + if ('initiateTransfer' in opsResolved && opsResolved.initiateTransfer) { + const val = await opsResolved.initiateTransfer('boolean'); + if (typeof val === 'boolean') { + parsed.initiateTransfer = val; + } + } + if (Object.keys(parsed).length > 0) { + supportedOperations = parsed; + } + } + } + + const result: RailWithSupportedOperations = { rail: railResolved }; + if (supportedOperations) { + result.supportedOperations = supportedOperations; + } + + return(result); + } + + async #computeAssetMovementPairSide(pairSideInput: ToValuizable): Promise<{ rails: AssetMovementResolvedRails; location: AssetLocationLike; id: AnchorChainingAsset; }> { + const pairSideResolved = await pairSideInput('object'); + + let location: AssetLocationLike; + if (pairSideResolved.location) { + const locationRaw = await pairSideResolved.location('string'); + if (!isAssetLocationLike(locationRaw)) { + throw(new Error(`Invalid location format: ${locationRaw}`)); + } + + location = locationRaw; + } else { + location = `chain:keeta:${this.client.network}`; + } + + const railsResolved = await pairSideResolved.rails('object'); + + const rails: AssetMovementResolvedRails = { + common: await Promise.all((await railsResolved.common?.('array'))?.map(async (commonInput) => { + return(await this.#computeAssetRails(commonInput)); + }) ?? []), + inbound: await Promise.all((await railsResolved.inbound?.('array'))?.map(async (commonInput) => { + return(await this.#computeAssetRails(commonInput)); + }) ?? []), + outbound: await Promise.all((await railsResolved.outbound?.('array'))?.map(async (commonInput) => { + return(await this.#computeAssetRails(commonInput)); + }) ?? []) + }; + + const id = await pairSideResolved.id('string'); + if (!isMovableAssetSearchCanonical(id)) { + throw(new Error(`Invalid asset id format: ${id}`)); + } + + return({ + rails: rails, + location: location, + id: await this.#resolveAssetName(id) + }); + } + + async #computeAssetMovementNodes() { + const assetMovementServices = await this.resolver.lookup('assetMovement', {}); + + if (!assetMovementServices) { + return([]); + } + + const providerResults = await Promise.all(Object.entries(assetMovementServices).map(async ([ providerID, service ]) => { + const supportedOperationsMetadata = await service.operations('object'); + + const supportedAssetsEntries = await service.supportedAssets('array'); + if (!supportedAssetsEntries) { + this.logger?.debug('AnchorGraph::computeAssetMovementNodes', `No supported assets found for provider ${providerID}`); + return(null); + } + + const pathNodesResult = await Promise.all(supportedAssetsEntries.map(async (assetEntry): Promise => { + const parsedEntry = await assetEntry('object'); + const pathsResolved = await parsedEntry.paths('array'); + const pathPromises = await Promise.allSettled(pathsResolved.map(async (pathResolvedInput): Promise => { + const pathResolved = await pathResolvedInput('object'); + const pairResolved = await pathResolved.pair('array'); + const [ fromResolved, toResolved ] = await Promise.all([ + this.#computeAssetMovementPairSide(pairResolved[0]), + this.#computeAssetMovementPairSide(pairResolved[1]) + ]); + + function getProviderSupportedOperationsForRail(railSpecific?: RailSupportedOperations): RailSupportedOperations { + const retval: RailSupportedOperations = { + createPersistentForwarding: supportedOperationsMetadata.createPersistentForwarding !== undefined, + initiateTransfer: supportedOperationsMetadata.initiateTransfer !== undefined + }; + + if (railSpecific) { + retval.createPersistentForwarding = railSpecific.createPersistentForwarding ?? false; + retval.initiateTransfer = railSpecific.initiateTransfer ?? false; + } + + return(retval); + } + + const pathNodes: GraphNodeLike[] = []; + for (const [ src, dest ] of [ + [ fromResolved, toResolved ], + [ toResolved, fromResolved ] + ] as const) { + for (const inboundRail of [ ...(src.rails.common ?? []), ...(src.rails.inbound ?? []) ]) { + /* + * Drop edges whose source rail explicitly cannot + * initiate a transfer and also cannot create a + * persistent forwarding address. + */ + const inboundSupportedOperations = getProviderSupportedOperationsForRail(inboundRail.supportedOperations); + if (inboundSupportedOperations.initiateTransfer === false && inboundSupportedOperations.createPersistentForwarding === false) { + this.logger?.debug('AnchorGraph::computeAssetMovementNodes', `Skipping ${providerID} edge from ${convertAssetLocationToString(src.location)} via rail ${inboundRail.rail}: neither initiateTransfer nor createPersistentForwarding supported`); + continue; + } + + for (const outboundRail of [ ...(dest.rails.common ?? []), ...(dest.rails.outbound ?? []) ]) { + pathNodes.push({ + type: 'assetMovement', + providerID: providerID, + from: { + asset: src.id, + location: src.location, + rail: inboundRail.rail, + supportedOperations: getProviderSupportedOperationsForRail(inboundRail.supportedOperations) + }, + to: { + asset: dest.id, + location: dest.location, + rail: outboundRail.rail, + supportedOperations: getProviderSupportedOperationsForRail(outboundRail.supportedOperations) + } + }); + } + } + + } + + return(pathNodes); + })); + + const allPaths = []; + for (const resolved of pathPromises) { + if (resolved.status === 'rejected') { + this.logger?.debug('AnchorGraph::computeAssetMovementNodes', `error fetching nodes for ... TODO`, resolved.reason); + } else { + allPaths.push(...resolved.value); + } + } + + return(allPaths); + })); + + return(pathNodesResult.flat()); + })); + + return(providerResults.flat().filter((node): node is GraphNodeLike => !!node)); + } + + async computeGraphNodes(): Promise { + if (this.#graphNodePromise === null) { + this.#graphNodePromise = (async () => { + const receivedNodes = await Promise.all([ + this.#computeFXNodes(), + this.#computeAssetMovementNodes() + ]); + + return(receivedNodes.flat()); + })(); + } + + return(await this.#graphNodePromise); + } + + async findPaths(input: AnchorChainingPathInput): Promise { + const graph = await this.computeGraphNodes(); + const nodesWithNext: { node: GraphNodeLike, next: number[] }[] = graph.map(function(node) { + return({ node, next: [] }); + }); + + for (const node of nodesWithNext) { + for (let secondNodeIdx = 0; secondNodeIdx < nodesWithNext.length; secondNodeIdx++) { + const nodeJ = nodesWithNext[secondNodeIdx]; + if (!nodeJ) { + continue; + } + + // We can ignore chaining one fx anchor to itself + if (node.node.type === 'fx') { + if (node.node.type === nodeJ.node.type && node.node.providerID === nodeJ.node.providerID) { + continue; + } + } + + if (nodeSideSupports(node.node.to, nodeJ.node.from)) { + node.next.push(secondNodeIdx); + } + } + } + + const paths: GraphNodeLike[][] = []; + + function getAssetLocationString(input: GraphNodeLike['to'], includeRail = false) { + let railStr = ''; + if (includeRail) { + railStr = `#${input.rail}`; + } + + return(`${convertAssetSearchInputToCanonical(input.asset)}@${convertAssetLocationToString(input.location)}${railStr}`) + } + + function dfs( + currentIndex: number, + target: AnchorChainingAssetAndLocation, + visitedAssets = new Set(), + path: GraphNodeLike[] = [] + ) { + const cur = nodesWithNext[currentIndex]; + if (!cur) { + throw(new Error(`Invalid node index: ${currentIndex}`)); + } + + const assetLocationStr = getAssetLocationString(cur.node.from, true); + if (visitedAssets.has(assetLocationStr)) { + return; + } + + visitedAssets.add(assetLocationStr); + + const newPath = [ ...path, cur.node ]; + + if (nodeSideSupports(cur.node.to, target)) { + paths.push(newPath); + } + + for (const nextIndex of nodesWithNext[currentIndex]?.next ?? []) { + dfs(nextIndex, target, visitedAssets, newPath); + } + + visitedAssets.delete(assetLocationStr); + } + + for (let index = 0; index < nodesWithNext.length; index++) { + const node = nodesWithNext[index]; + if (!node) { + continue; + } + + if (nodeSideSupports(node.node.from, input.source)) { + dfs(index, input.destination); + } + } + + return(paths); + } + + async resolveAssets(filter: AnchorChainingResolveAssetsFilter = {}): Promise { + const { from: fromFilterInput, to: toFilterInput, maxStepCount, onlyAllowFXLike } = filter; + + const keetaNetworkLocation = `chain:keeta:${this.client.network}` satisfies AssetLocationLike; + + // When onlyAllowFXLike, default omitted locations to the Keeta network location + const fromFilter = (onlyAllowFXLike && fromFilterInput !== undefined && fromFilterInput.location === undefined) + ? { ...fromFilterInput, location: keetaNetworkLocation } + : fromFilterInput; + const toFilter = (onlyAllowFXLike && toFilterInput !== undefined && toFilterInput.location === undefined) + ? { ...toFilterInput, location: keetaNetworkLocation } + : toFilterInput; + + const nodes = await this.computeGraphNodes(); + + // Build forward (next) and backward (prev) adjacency in a single pass. + const nodesWithAdj: { node: GraphNodeLike; next: number[]; prev: number[] }[] = nodes.map(node => ({ node, next: [], prev: [] })); + for (let i = 0; i < nodesWithAdj.length; i++) { + for (let j = 0; j < nodesWithAdj.length; j++) { + const ni = nodesWithAdj[i]; + const nj = nodesWithAdj[j]; + if (!ni || !nj) { + throw(new Error(`Invalid node index during adjacency construction: ${i} or ${j}`)); + } + if (ni.node.type === 'fx' && nj.node.type === 'fx' && ni.node.providerID === nj.node.providerID) { + continue; + } + if (nodeSideSupports(ni.node.to, nj.node.from)) { + ni.next.push(j); + nj.prev.push(i); + } + } + } + + const sideMatchesFilter = ( + side: GraphNodeLike['from' | 'to'], + f: AnchorChainingListAssetsSideFilter + ): boolean => { + if (f.location !== undefined && convertAssetLocationToString(side.location) !== convertAssetLocationToString(f.location)) { + return(false); + } + if (f.asset !== undefined && !isAnchorChainingAssetEqual(side.asset, f.asset)) { + return(false); + } + if (f.rail !== undefined && side.rail !== f.rail) { + return(false); + } + + return(true); + }; + + // Separate reachable sets and distance maps for backward (from) and forward (to) traversals. + const fromReachable = new Set(); + const fromDistances = new Map(); + const toReachable = new Set(); + const toDistances = new Map(); + + const makeMarkFn = (reachable: Set, distances: Map) => + (side: GraphNodeLike['from' | 'to'], depth?: number) => { + const key = this.#assetLocationKey(side); + reachable.add(key); + if (depth !== undefined) { + const existing = distances.get(key); + if (existing === undefined || depth < existing) { + distances.set(key, depth); + } + } + }; + + const markFromReachable = makeMarkFn(fromReachable, fromDistances); + const markToReachable = makeMarkFn(toReachable, toDistances); + + const bfs = ( + startCondition: (item: (typeof nodesWithAdj)[number]) => boolean, + adjacency: 'next' | 'prev', + markSide: 'from' | 'to', + markFn: (side: GraphNodeLike['from' | 'to'], depth: number) => void + ) => { + const nodeVisited = new Set(); + const queue: { nodeIdx: number; depth: number }[] = []; + for (let i = 0; i < nodesWithAdj.length; i++) { + const item = nodesWithAdj[i]; + if (!item) { + throw(new Error(`Invalid node index during BFS initialization: ${i}`)); + } + if (startCondition(item) && !nodeVisited.has(i)) { + nodeVisited.add(i); + queue.push({ nodeIdx: i, depth: 1 }); + } + } + + while (queue.length > 0) { + const queueItem = queue.shift(); + if (!queueItem) { + throw(new Error(`Unexpected empty queue during BFS processing`)); + } + + const { nodeIdx, depth } = queueItem; + const item = nodesWithAdj[nodeIdx]; + if (!item) { + throw(new Error(`Invalid node index during BFS processing: ${nodeIdx}`)); + } + if (onlyAllowFXLike && !isFXLikeNode(item.node)) { + continue; + } + + markFn(item.node[markSide], depth); + + if (maxStepCount === undefined || depth < maxStepCount) { + for (const neighborIdx of item[adjacency]) { + if (!nodeVisited.has(neighborIdx)) { + nodeVisited.add(neighborIdx); + queue.push({ nodeIdx: neighborIdx, depth: depth + 1 }); + } + } + } + } + }; + + if (fromFilter) { + bfs(item => sideMatchesFilter(item.node.from, fromFilter), 'next', 'to', markToReachable); + } + if (toFilter) { + bfs(item => sideMatchesFilter(item.node.to, toFilter), 'prev', 'from', markFromReachable); + } + if (!fromFilter && !toFilter) { + for (const { node } of nodesWithAdj) { + if (!onlyAllowFXLike || isFXLikeNode(node)) { + markFromReachable(node.from); + markFromReachable(node.to); + markToReachable(node.from); + markToReachable(node.to); + } + } + } + + // Second pass: build result maps by collecting inbound/outbound rails for every reachable + // (asset, location) pair from ALL graph nodes, not just those on the traversal path. + const buildResultMap = ( + reachable: Set, + distances: Map + ): Map => { + const resultMap = new Map(); + const getOrCreate = (side: { asset: AnchorChainingAsset; location: AssetLocationLike }): AnchorChainingAssetInfo => { + const key = this.#assetLocationKey(side); + let resultObj = resultMap.get(key); + if (!resultObj) { + const distanceValue = distances.get(key); + + resultObj = { + asset: side.asset, + location: side.location, + rails: { inbound: [], outbound: [] }, + distance: distanceValue !== undefined ? { pathLength: distanceValue } : null + }; + + resultMap.set(key, resultObj); + } + return(resultObj); + }; + for (const { node } of nodesWithAdj) { + if (onlyAllowFXLike && !isFXLikeNode(node)) { + continue; + } + if (reachable.has(this.#assetLocationKey(node.to))) { + const entry = getOrCreate(node.to); + if (!entry.rails.inbound.includes(node.to.rail)) { + entry.rails.inbound.push(node.to.rail); + } + } + if (reachable.has(this.#assetLocationKey(node.from))) { + const entry = getOrCreate(node.from); + if (!entry.rails.outbound.includes(node.from.rail)) { + entry.rails.outbound.push(node.from.rail); + } + } + } + + return(resultMap); + }; + + const fromResultMap = buildResultMap(fromReachable, fromDistances); + const toResultMap = buildResultMap(toReachable, toDistances); + + // When onlyAllowFXLike, exclude the filter asset from the result set so that + // "what can USDC be swapped to?" doesn't include USDC itself via a round-trip. + if (onlyAllowFXLike) { + if (fromFilter?.asset !== undefined) { + toResultMap.delete(this.#assetLocationKey({ asset: fromFilter.asset, location: fromFilter.location ?? keetaNetworkLocation })); + } + if (toFilter?.asset !== undefined) { + fromResultMap.delete(this.#assetLocationKey({ asset: toFilter.asset, location: toFilter.location ?? keetaNetworkLocation })); + } + } + + const filterMap = ( + map: Map, + f: AnchorChainingListAssetsSideFilter, + railSide: 'inbound' | 'outbound' + ): AnchorChainingAssetInfo[] => + Array.from(map.values()).filter(info => { + if (f.location !== undefined && convertAssetLocationToString(info.location) !== convertAssetLocationToString(f.location)) { + return(false); + } + if (f.asset !== undefined && !isAnchorChainingAssetEqual(info.asset, f.asset)) { + return(false); + } + if (f.rail !== undefined && !info.rails[railSide].includes(f.rail)) { + return(false); + } + + return(true); + }); + + const fromAssets = (fromFilter !== undefined && toFilter !== undefined) + ? filterMap(fromResultMap, fromFilter, 'outbound') + : Array.from(fromResultMap.values()); + const toAssets = (fromFilter !== undefined && toFilter !== undefined) + ? filterMap(toResultMap, toFilter, 'inbound') + : Array.from(toResultMap.values()); + + return({ from: fromAssets, to: toAssets }); + } + + async listAssets(filter: AnchorChainingListAssetsFilter = {}): Promise { + const result = await this.resolveAssets(filter); + if (filter.from) { + return(result.to); + } else if (filter.to) { + return(result.from); + } else { + return(result.to); + } + } + + async getExternalAssetMetadata( + asset: AnchorChainingAssetInfo['asset'], + location: AnchorChainingAssetInfo['location'], + providerID?: string + ): Promise { + if (!isExternalChainAsset(asset)) { + return(undefined); + } + + const providers = await this.getAssetMovementProvidersForAsset(asset, location); + if (!providers) { + return(undefined); + } + + if (providerID) { + const found = providers[providerID]; + if (!found) { + return(undefined); + } + + const result = found.provider.getAssetMetadataForLocation(location, asset); + return(result ?? undefined); + } + + for (const { provider } of Object.values(providers)) { + const metadata = provider.getAssetMetadataForLocation(location, asset); + if (metadata) { + return(metadata); + } + } + + return(undefined); + } + + async #attachMetadata( + assetInfo: AnchorChainingAssetInfo, + options?: AnchorChainingWithMetadataOptions + ): Promise { + const metadata = await this.getExternalAssetMetadata(assetInfo.asset, assetInfo.location, options?.providerID); + if (!metadata) { + return(assetInfo); + } + + return({ ...assetInfo, metadata }); + } + + async resolveAssetsWithMetadata( + filter: AnchorChainingResolveAssetsFilter = {}, + options?: AnchorChainingWithMetadataOptions + ): Promise { + const result = await this.resolveAssets(filter); + const [from, to] = await Promise.all([ + Promise.all(result.from.map((info) => this.#attachMetadata(info, options))), + Promise.all(result.to.map((info) => this.#attachMetadata(info, options))) + ]); + + return({ from, to }); + } + + async listAssetsWithMetadata( + filter: AnchorChainingListAssetsFilter = {}, + options?: AnchorChainingWithMetadataOptions + ): Promise { + const result = await this.resolveAssetsWithMetadata(filter, options); + if (filter.from) { + return(result.to); + } else if (filter.to) { + return(result.from); + } else { + return(result.to); + } + } +} diff --git a/src/lib/chaining/index.ts b/src/lib/chaining/index.ts new file mode 100644 index 00000000..8345e1a8 --- /dev/null +++ b/src/lib/chaining/index.ts @@ -0,0 +1,15 @@ +/** + * Public surface of the anchor-chaining engine. + * + * Discovery and topology ({@link AnchorGraph}), side-effect-free planning + * ({@link AnchorChainingPlan}), durable actual-driven execution with resume, + * typed coded errors, and the pluggable durability store. + */ + +export * from './types.js'; +export * from './errors.js'; +export * from './retry.js'; +export * from './store.js'; +export * from './graph.js'; +export * from './plan.js'; +export * from './facade.js'; diff --git a/src/lib/chaining/plan.ts b/src/lib/chaining/plan.ts new file mode 100644 index 00000000..447441cc --- /dev/null +++ b/src/lib/chaining/plan.ts @@ -0,0 +1,323 @@ +import type { Logger } from '../log/index.js'; +import type * as KeetaNet from '@keetanetwork/keetanet-client'; +import type { Resolver } from '../index.js'; +import type { AnchorGraph } from './graph.js'; +import type { + AnchorChainingAccountOverrides, + AnchorChainingPathEventMap, + AnchorChainingPathExecuteOptions, + AnchorChainingPathExecuteResult, + AnchorChainingPathInput, + AnchorChainingPathState, + AnchorChainingPreview, + AnchorChainingStepLike, + PlanDisclaimers, + PreviewKnownValue, + PreviewStep, + ProviderDisclaimers +} from './types.js'; +import { AnchorChainingError } from './errors.js'; +import type { StepContext } from './steps/context.js'; +import { classifyForwardedSteps } from './steps/context.js'; +import { createStepExecutor } from './steps/executor.js'; +import { AnchorChainingExecution } from './execution.js'; +import type { AnchorChainingStore } from './store.js'; +import { AnchorChainingStoreMemory } from './store.js'; + +/** + * The minimal surface {@link AnchorChainingPath}/{@link AnchorChainingPlan} + * need from the owning chaining instance. Decouples the plan from the facade so + * there is no import cycle. + */ +export interface ChainingHost { + readonly client: KeetaNet.UserClient; + readonly resolver: Resolver; + readonly logger?: Logger | undefined; + readonly graph: AnchorGraph; +} + +/** + * Options governing plan computation. + */ +export interface ComputePlanOptions { + overrides?: AnchorChainingAccountOverrides; + /** + * Limit the number of plans to calculate, defaults to 3. + */ + limit?: number; + /** + * Per-leg slippage tolerance in basis points used to derive each leg's + * minimum acceptable output. Omitted means no per-leg floor. + */ + slippageBps?: number; + /** + * Durable store backing execution state for resume. Defaults to an + * in-memory store scoped to the plan instance. + */ + store?: AnchorChainingStore; +} + +/** + * Resolve the affinity (whether the source or destination amount is fixed) and + * the fixed amount from a request. + */ +function resolveAffinity(request: AnchorChainingPathInput): { affinity: 'from' | 'to'; amount: bigint } { + if (request.source.value !== undefined && request.destination.value !== undefined) { + throw(new AnchorChainingError('INVALID_REQUEST', 'Must have source.value or destination.value but not both')); + } + + if (request.source.value !== undefined) { + return({ affinity: 'from', amount: request.source.value }); + } + + if (request.destination.value !== undefined) { + return({ affinity: 'to', amount: request.destination.value }); + } + + throw(new AnchorChainingError('INVALID_REQUEST', 'Must have source.value or destination.value')); +} + +/** + * A discovered path between a source and destination. Carries provider-legal + * disclaimers and the context the engine and preview share, but performs no + * irreversible work. + */ +export class AnchorChainingPath { + readonly request: AnchorChainingPathInput; + readonly path: AnchorChainingStepLike[]; + readonly host: ChainingHost; + + constructor(input: { + request: AnchorChainingPathInput; + path: AnchorChainingStepLike[]; + host: ChainingHost; + }) { + this.request = input.request; + this.path = input.path; + this.host = input.host; + } + + get logger(): Logger | undefined { + return(this.host.logger); + } + + /** + * Build the shared {@link StepContext} for this path under the given + * options. Side-effect-free: resolves affinity and forwarded-step + * classification only. + */ + buildContext(options?: ComputePlanOptions): StepContext { + const { affinity, amount } = resolveAffinity(this.request); + + const context: StepContext = { + client: this.host.client, + resolver: this.host.resolver, + logger: this.host.logger, + fxClient: this.host.graph.fxClient, + assetMovementClient: this.host.graph.assetMovementClient, + request: this.request, + path: this.path, + affinity, + affinityAmount: amount, + overrides: options?.overrides, + slippageBps: options?.slippageBps, + forwardedIndexes: classifyForwardedSteps(this.path) + }; + + return(context); + } + + async getProviderLegalDisclaimers(): Promise { + const legalDisclaimerPromises: { key: string; promise: () => Promise }[] = []; + + for (const step of this.path) { + if (step.type === 'keetaSend') { + continue; + } + + const key = `${step.type}:${step.providerID}`; + if (legalDisclaimerPromises.find(entry => entry.key === key)) { + continue; + } + + const promise = async () => { + try { + let disclaimers: ProviderDisclaimers['disclaimers'] | null | undefined = null; + if (step.type === 'assetMovement') { + const provider = await this.host.graph.getAssetMovementProviderById(step.providerID); + disclaimers = provider?.getLegalDisclaimers(); + } else { + disclaimers = await this.host.graph.fxClient.getLegalDisclaimersById(step.providerID); + } + + if (!disclaimers) { + return(null); + } + + return({ providerID: step.providerID, disclaimers }); + } catch (error) { + this.logger?.debug(`AnchorChainingPath::getProviderLegalDisclaimers`, `Error getting provider disclaimers for providerId: ${step.providerID}`, error); + throw(error); + } + }; + + legalDisclaimerPromises.push({ key, promise }); + } + + try { + const disclaimersOrNull = await Promise.all(legalDisclaimerPromises.map((entry) => entry.promise())); + const disclaimers = disclaimersOrNull.filter((entry) => entry !== null); + return(disclaimers); + } catch (error) { + this.logger?.debug(`AnchorChainingPath::getProviderLegalDisclaimers`, 'Error getting legal disclaimers for path', error); + return(null); + } + } +} + +/** + * A path together with its computed, side-effect-free preview. Computing a plan + * estimates each leg's amounts and per-leg output floor; it never initiates a + * transfer, creates an exchange, or reserves a persistent-forwarding address. + */ +export class AnchorChainingPlan extends AnchorChainingPath { + #preview: AnchorChainingPreview | null = null; + readonly #options: ComputePlanOptions | undefined; + readonly #store: AnchorChainingStore; + #execution: AnchorChainingExecution | null = null; + + private constructor(path: AnchorChainingPath, options?: ComputePlanOptions) { + super({ request: path.request, path: path.path, host: path.host }); + this.#options = options; + this.#store = options?.store ?? new AnchorChainingStoreMemory(); + } + + get preview(): AnchorChainingPreview { + if (!this.#preview) { + throw(new AnchorChainingError('INVALID_STATE', `Preview has not been computed yet`)); + } + + return(this.#preview); + } + + get options(): ComputePlanOptions | undefined { + return(this.#options); + } + + /** + * The execution engine bound to this plan's preview and store. Created once + * so event listeners attached via {@link on} observe the same instance that + * {@link execute}/{@link resume} drive. + */ + #getExecution(): AnchorChainingExecution { + if (!this.#execution) { + this.#execution = new AnchorChainingExecution({ + ctx: this.buildContext(this.#options), + preview: this.preview, + store: this.#store + }); + } + + return(this.#execution); + } + + get state(): AnchorChainingPathState { + return(this.#getExecution().state); + } + + on(event: E, listener: (...args: AnchorChainingPathEventMap[E]) => void): void { + this.#getExecution().on(event, listener); + } + + off(event: E, listener: (...args: AnchorChainingPathEventMap[E]) => void): void { + this.#getExecution().off(event, listener); + } + + /** + * Execute the plan, driving each leg from the actual output the prior leg + * delivered. Returns the correlation id (via the result) for {@link resume}. + */ + async execute(options: AnchorChainingPathExecuteOptions = {}): Promise { + return(await this.#getExecution().execute(options)); + } + + /** + * Resume a previously-interrupted execution, skipping settled legs and + * driving the remainder forward. + */ + async resume(correlationID: string, options: AnchorChainingPathExecuteOptions = {}): Promise { + return(await this.#getExecution().resume(correlationID, options)); + } + + async #computePreview(): Promise { + const ctx = this.buildContext(this.#options); + + if (this.path.length === 0) { + throw(new AnchorChainingError('INVALID_PATH', `Cannot compute a preview for an empty path`)); + } + + const executors = this.path.map((_, index) => createStepExecutor(ctx, index)); + const resolved = new Map(); + + if (ctx.affinity === 'from') { + let known: PreviewKnownValue = { side: 'in', value: ctx.affinityAmount }; + for (let index = 0; index < executors.length; index++) { + const executor = executors[index]; + if (!executor) { + throw(new AnchorChainingError('STEP_NOT_DEFINED', `Step ${index} is not defined`)); + } + + const step = await executor.preview(known); + resolved.set(index, step); + known = { side: 'in', value: step.estimatedValueOut }; + } + } else { + let known: PreviewKnownValue = { side: 'out', value: ctx.affinityAmount }; + for (let index = executors.length - 1; index >= 0; index--) { + const executor = executors[index]; + if (!executor) { + throw(new AnchorChainingError('STEP_NOT_DEFINED', `Step ${index} is not defined`)); + } + + const step = await executor.preview(known); + resolved.set(index, step); + known = { side: 'out', value: step.estimatedValueIn }; + } + } + + const previewSteps: PreviewStep[] = []; + for (let index = 0; index < executors.length; index++) { + const step = resolved.get(index); + if (!step) { + throw(new AnchorChainingError('STEP_NOT_DEFINED', `Preview step ${index} was not resolved`)); + } + previewSteps.push(step); + } + + const firstStep = previewSteps[0]; + const lastStep = previewSteps[previewSteps.length - 1]; + if (!firstStep || !lastStep) { + throw(new AnchorChainingError('INVALID_PATH', `Preview produced no steps`)); + } + + if (lastStep.estimatedValueOut <= 0n) { + throw(new AnchorChainingError('INVALID_PATH', `Estimated output for last step must be greater than 0, got ${lastStep.estimatedValueOut}`)); + } + + const minDestinationValue = ctx.affinity === 'to' ? ctx.affinityAmount : lastStep.minOutput; + + return({ + affinity: ctx.affinity, + steps: previewSteps, + totalValueIn: firstStep.estimatedValueIn, + totalValueOut: lastStep.estimatedValueOut, + minDestinationValue + }); + } + + static async create(path: AnchorChainingPath, options?: ComputePlanOptions): Promise { + const instance = new this(path, options); + instance.#preview = await instance.#computePreview(); + return(instance); + } +} diff --git a/src/lib/chaining/retry.test.ts b/src/lib/chaining/retry.test.ts new file mode 100644 index 00000000..7572a109 --- /dev/null +++ b/src/lib/chaining/retry.test.ts @@ -0,0 +1,161 @@ +import { test, expect, describe } from 'vitest'; +import { KeetaNet } from '../../client/index.js'; +import { + AnchorChainingError, + RECOVERABLE_LEDGER_CODES, + isRecoverableLedgerError, + jitteredBackoff, + withRetry +} from './index.js'; + +/** + * A `fn` for {@link withRetry} that throws `error` on its first `failures` + * invocations and then resolves with `value`, recording its call count. + */ +function failingThenSucceed(failures: number, value: T, error: () => unknown): { fn: () => Promise; calls: () => number } { + let calls = 0; + const fn = async (): Promise => { + calls++; + if (calls <= failures) { + throw(error()); + } + + return(value); + }; + + return({ fn, calls: () => calls }); +} + +/** A no-op clock and capturing sleep, so retries are deterministic and instant. */ +function deterministicTiming(): { now: () => number; sleep: (ms: number) => Promise; delays: number[] } { + const delays: number[] = []; + return({ + now: () => 0, + sleep: async (ms: number) => { delays.push(ms); }, + delays + }); +} + +describe('isRecoverableLedgerError', function() { + test.each([ ...RECOVERABLE_LEDGER_CODES ])('treats Keeta ledger error %s as recoverable', function(code) { + expect(isRecoverableLedgerError(new KeetaNet.lib.Error(code, ''))).toBe(true); + }); + + test.each([ + { label: 'a non-recoverable Keeta code', value: new KeetaNet.lib.Error('LEDGER_INVALID_BALANCE', '') }, + { label: 'a plain Error', value: new Error('LEDGER_SUCCESSOR_VOTE_EXISTS') }, + { label: 'a chaining error', value: new AnchorChainingError('RECOVERABLE_SEND_FAILED') }, + { label: 'a bare string', value: 'LEDGER_SUCCESSOR_VOTE_EXISTS' }, + { label: 'undefined', value: undefined }, + { label: 'null', value: null } + ])('does not treat $label as recoverable', function({ value }) { + expect(isRecoverableLedgerError(value)).toBe(false); + }); +}); + +describe('jitteredBackoff', function() { + test.each([ 0, 1, 2, 5, 10 ])('produces an integer delay within the truncated cap at attempt %i', function(attempt) { + const backoff = jitteredBackoff({ baseMs: 500, maxMs: 30_000 }); + const cap = Math.min(30_000, 500 * (2 ** attempt)); + + for (let i = 0; i < 50; i++) { + const delay = backoff(attempt); + expect(Number.isInteger(delay)).toBe(true); + expect(delay).toBeGreaterThanOrEqual(0); + expect(delay).toBeLessThanOrEqual(cap); + } + }); +}); + +describe('withRetry', function() { + test('returns the first result without sleeping when fn succeeds', async function() { + const timing = deterministicTiming(); + const { fn, calls } = failingThenSucceed(0, 'ok', () => new Error('unused')); + + const result = await withRetry(fn, { now: timing.now, sleep: timing.sleep }); + expect(result).toEqual('ok'); + expect(calls()).toEqual(1); + expect(timing.delays).toHaveLength(0); + }); + + test('rethrows a non-retryable error immediately', async function() { + const timing = deterministicTiming(); + const { fn, calls } = failingThenSucceed(1, 'ok', () => new Error('terminal')); + + await expect(withRetry(fn, { now: timing.now, sleep: timing.sleep, isRetryable: () => false })).rejects.toThrow('terminal'); + expect(calls()).toEqual(1); + expect(timing.delays).toHaveLength(0); + }); + + test('retries a retryable error and returns the eventual success', async function() { + const timing = deterministicTiming(); + const { fn, calls } = failingThenSucceed(2, 'recovered', () => new Error('transient')); + + const result = await withRetry(fn, { now: timing.now, sleep: timing.sleep, isRetryable: () => true }); + expect(result).toEqual('recovered'); + expect(calls()).toEqual(3); + expect(timing.delays).toHaveLength(2); + }); + + test('exhausts maxAttempts and throws a RECOVERABLE_SEND_FAILED error', async function() { + const timing = deterministicTiming(); + const { fn, calls } = failingThenSucceed(Number.POSITIVE_INFINITY, 'never', () => new Error('still failing')); + + const error: unknown = await withRetry(fn, { now: timing.now, sleep: timing.sleep, isRetryable: () => true, maxAttempts: 3 }).catch((e: unknown) => e); + expect(AnchorChainingError.isInstance(error)).toBe(true); + if (AnchorChainingError.isInstance(error)) { + expect(error.code).toEqual('RECOVERABLE_SEND_FAILED'); + expect(error.message).toContain('exhausted'); + } + + expect(calls()).toEqual(3); + }); + + test('stops once the maxTotalMs budget is exhausted', async function() { + const delays: number[] = []; + let clock = 0; + const { fn, calls } = failingThenSucceed(Number.POSITIVE_INFINITY, 'never', () => new Error('slow upstream')); + + const error: unknown = await withRetry(fn, { + now: () => clock, + sleep: async (ms: number) => { delays.push(ms); clock += 2_000; }, + isRetryable: () => true, + backoff: () => 10, + maxAttempts: 10, + maxTotalMs: 1_000 + }).catch((e: unknown) => e); + expect(AnchorChainingError.isInstance(error)).toBe(true); + expect(calls()).toEqual(2); + expect(delays).toEqual([ 10 ]); + }); + + test('rejects an invalid maxAttempts before invoking fn', async function() { + const { fn, calls } = failingThenSucceed(0, 'ok', () => new Error('unused')); + + const error: unknown = await withRetry(fn, { maxAttempts: 0 }).catch((e: unknown) => e); + expect(AnchorChainingError.isInstance(error)).toBe(true); + if (AnchorChainingError.isInstance(error)) { + expect(error.code).toEqual('INTERNAL'); + } + expect(calls()).toEqual(0); + }); + + test('honors a retryAfterMs hint over the backoff strategy', async function() { + const timing = deterministicTiming(); + const hinted = () => Object.assign(new Error('rate limited'), { retryAfterMs: 50 }); + const { fn } = failingThenSucceed(1, 'ok', hinted); + + const result = await withRetry(fn, { now: timing.now, sleep: timing.sleep, isRetryable: () => true, backoff: () => 9_999 }); + expect(result).toEqual('ok'); + expect(timing.delays).toEqual([ 50 ]); + }); + + test('uses the backoff strategy when no retryAfterMs hint is present', async function() { + const timing = deterministicTiming(); + const { fn } = failingThenSucceed(1, 'ok', () => new Error('transient')); + + const result = await withRetry(fn, { now: timing.now, sleep: timing.sleep, isRetryable: () => true, backoff: () => 123 }); + expect(result).toEqual('ok'); + expect(timing.delays).toEqual([ 123 ]); + }); +}); diff --git a/src/lib/chaining/retry.ts b/src/lib/chaining/retry.ts new file mode 100644 index 00000000..b4df36ce --- /dev/null +++ b/src/lib/chaining/retry.ts @@ -0,0 +1,264 @@ +import * as KeetaNet from '@keetanetwork/keetanet-client'; +import type { GenericAccount, TokenAddress } from '@keetanetwork/keetanet-client/lib/account.js'; +import type { Logger } from '../log/index.js'; +import { AnchorChainingError } from './errors.js'; + +const DEFAULT_MAX_TOTAL_MS = 30_000; +const DEFAULT_BASE_BACKOFF_MS = 500; +const DEFAULT_MAX_BACKOFF_MS = 30_000; + +/** + * Ledger/vote error codes that indicate a half-published or contended send the + * account can recover and re-publish, rather than a terminal rejection. + */ +export const RECOVERABLE_LEDGER_CODES = [ + 'LEDGER_SUCCESSOR_VOTE_EXISTS', + 'LEDGER_NOT_SUCCESSOR', + 'VOTE_EXPIRED', + 'LEDGER_NOT_EMPTY' +] as const; + +/** + * Strategy for computing the next backoff delay. + */ +export type BackoffStrategy = (attempt: number) => number; + +/** + * Options for {@link withRetry}. + */ +export interface RetryOptions { + maxAttempts?: number; + maxTotalMs?: number; + backoff?: BackoffStrategy; + isRetryable?: (err: unknown) => boolean; + sleep?: (ms: number) => Promise; + now?: () => number; + logger?: Logger | undefined; + loggerContext?: string; +} + +export type PublicRetryOptions = Omit; + +/** + * Truncated exponential backoff with random jitter. + * + * @see {@link https://cloud.google.com/storage/docs/retry-strategy#exponential-backoff | Google Cloud: truncated exponential backoff} + */ +export function jitteredBackoff(input: { baseMs: number; maxMs: number }): BackoffStrategy { + return(function(attempt) { + const cap = Math.min(input.maxMs, input.baseMs * (2 ** attempt)); + const delay = Math.round(Math.random() * cap); + return(delay); + }); +} + +const DEFAULT_BACKOFF: BackoffStrategy = jitteredBackoff({ + baseMs: DEFAULT_BASE_BACKOFF_MS, + maxMs: DEFAULT_MAX_BACKOFF_MS +}); + +function defaultSleep(ms: number): Promise { + return(new Promise(function(resolve) { + setTimeout(resolve, ms); + })); +} + +/** + * Default retry gate: only {@link KeetaAnchorError}s flagged retryable. + */ +function defaultIsRetryable(err: unknown): boolean { + if (AnchorChainingError.isInstance(err)) { + return(err.retryable); + } + + return(false); +} + +/** + * Normalize an unknown thrown value into an `Error` instance. + */ +function toError(err: unknown): Error { + if (err instanceof Error) { + return(err); + } + + return(new AnchorChainingError('INTERNAL', `withRetry: non-Error thrown: ${String(err)}`, { cause: err })); +} + +/** + * Read a numeric `retryAfterMs` hint from a thrown error, if present. + */ +function readRetryAfterMs(err: unknown): number | undefined { + if (err === null || typeof err !== 'object' || !('retryAfterMs' in err)) { + return(undefined); + } + + const candidate: unknown = err.retryAfterMs; + if (typeof candidate !== 'number' || !Number.isFinite(candidate) || candidate < 0) { + return(undefined); + } + + return(candidate); +} + +/** + * Run `fn` with backoff between attempts. Stops on a non-retryable error, + * once `maxTotalMs` is exhausted, or once `maxAttempts` is reached. + */ +export async function withRetry(fn: () => Promise, options: RetryOptions = {}): Promise { + const maxAttempts = options.maxAttempts ?? Number.POSITIVE_INFINITY; + if (Number.isNaN(maxAttempts) || maxAttempts < 1) { + throw(new AnchorChainingError('INTERNAL', `withRetry: maxAttempts must be >= 1 (got ${maxAttempts})`)); + } + + const maxTotalMs = options.maxTotalMs ?? DEFAULT_MAX_TOTAL_MS; + if (!Number.isFinite(maxTotalMs) || maxTotalMs < 0) { + throw(new AnchorChainingError('INTERNAL', `withRetry: maxTotalMs must be >= 0 and finite (got ${maxTotalMs})`)); + } + + const backoff = options.backoff ?? DEFAULT_BACKOFF; + const isRetryable = options.isRetryable ?? defaultIsRetryable; + const sleep = options.sleep ?? defaultSleep; + const now = options.now ?? Date.now; + const logger = options.logger; + const context = options.loggerContext ?? 'withRetry'; + + const startMs = now(); + let lastError: unknown; + let attemptsMade = 0; + for (let attempt = 0; attempt < maxAttempts; attempt++) { + attemptsMade = attempt + 1; + try { + const result = await fn(); + return(result); + } catch (err) { + lastError = err; + + if (!isRetryable(err)) { + throw(toError(err)); + } + + if (attempt >= maxAttempts - 1) { + break; + } + + const elapsedMs = now() - startMs; + const remainingMs = maxTotalMs - elapsedMs; + if (remainingMs <= 0) { + break; + } + + const retryAfterMs = readRetryAfterMs(err); + const baseDelay = retryAfterMs ?? backoff(attempt); + const delay = Math.max(0, Math.min(baseDelay, remainingMs)); + + logger?.debug(context, `Retrying in ${delay}ms (attempt ${attempt + 1}, elapsed ${elapsedMs}ms / budget ${maxTotalMs}ms)`, { err }); + await sleep(delay); + } + } + + const elapsedMs = now() - startMs; + const cause = toError(lastError); + throw(new AnchorChainingError( + 'RECOVERABLE_SEND_FAILED', + `withRetry: exhausted after ${attemptsMade} attempt(s) in ${elapsedMs}ms (budget ${maxTotalMs}ms): ${cause.message}`, + { cause } + )); +} + +/** + * Returns true when `err` is a Keeta ledger/vote error whose code indicates a + * recoverable, re-publishable send (see {@link RECOVERABLE_LEDGER_CODES}). + */ +export function isRecoverableLedgerError(err: unknown): boolean { + if (!KeetaNet.lib.Error.isInstance(err)) { + return(false); + } + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + return(RECOVERABLE_LEDGER_CODES.includes(err.code as (typeof RECOVERABLE_LEDGER_CODES)[number])); +} + +/** + * Extract the first published block hash from a `send`/`publishBuilder` result. + */ +function firstPublishedBlockHash(published: Awaited>): string | undefined { + let publishedBlocks; + if ('blocks' in published) { + publishedBlocks = published.blocks; + } else { + publishedBlocks = published.voteStaple.blocks; + } + + const sendBlock = publishedBlocks[0]; + if (sendBlock === undefined) { + return(undefined); + } + + return(sendBlock.hash.toString()); +} + +/** + * Parameters for a single recoverable Keeta send. + */ +export interface RecoverableSendParams { + to: GenericAccount | string; + value: bigint; + token: TokenAddress | string; + external?: string | undefined; + account: InstanceType; +} + +/** + * Options governing recovery and retry of {@link recoverableSend}. + */ +export interface RecoverableSendOptions { + maxAttempts?: number; + logger?: Logger | undefined; + sleep?: (ms: number) => Promise; +} + +/** + * Publish a Keeta send, recovering and re-publishing on recoverable + * ledger/vote errors. On a recoverable error the account's pending block is + * recovered (published) before the send is retried, so a contended or + * half-published send is driven forward rather than left stranded. + * + * @returns The published send block hash, or `undefined` when the published + * result carried no blocks. + */ +export async function recoverableSend( + client: KeetaNet.UserClient, + params: RecoverableSendParams, + options?: RecoverableSendOptions +): Promise { + const { to, value, token, external, account } = params; + const logger = options?.logger; + + return(await withRetry(async function() { + try { + const published = await client.send(to, value, token, external, { account }); + return(firstPublishedBlockHash(published)); + } catch (err) { + if (isRecoverableLedgerError(err)) { + logger?.debug('recoverableSend', `Recoverable ledger error on send; attempting account recovery`, { err }); + try { + const pending = await client.pendingBlock({ account }); + if (pending) { + await client.recover(true, { account }); + } + } catch (recoverErr) { + logger?.debug('recoverableSend', `Account recovery attempt failed; will retry send`, { recoverErr }); + } + } + + throw(err); + } + }, { + maxAttempts: options?.maxAttempts ?? 3, + isRetryable: isRecoverableLedgerError, + loggerContext: 'recoverableSend', + ...(logger ? { logger } : {}), + ...(options?.sleep ? { sleep: options.sleep } : {}) + })); +} diff --git a/src/lib/chaining/steps/asset-movement.ts b/src/lib/chaining/steps/asset-movement.ts new file mode 100644 index 00000000..332c2988 --- /dev/null +++ b/src/lib/chaining/steps/asset-movement.ts @@ -0,0 +1,245 @@ +import type { AssetMovementGraphNode, AssetMovementProvider, ExecutedStep, PreviewKnownValue, PreviewStep } from '../types.js'; +import type { AssetTransferInstructions, SimulatedAssetTransferInstructions } from '../../../services/asset-movement/common.js'; +import type { StepContext } from './context.js'; +import type { StepRunInput, StepRunResult, WithdrawRef } from './run.js'; +import type { PublishedInputRecord } from '../store.js'; +import { applySlippage, resolveAccountsForAction } from './context.js'; +import { AnchorChainingError } from '../errors.js'; +import { pollTransferStatus } from './poll.js'; +import { buildKeetaSendExternal } from './external.js'; + +/** + * Find the instruction matching a rail in a transfer's instruction set. + */ +function findInstruction( + instructions: AssetTransferInstructions[], + type: R +): Extract { + const found = instructions.find((instr): instr is Extract => instr.type === type); + if (!found) { + throw(new AnchorChainingError('UNSUPPORTED_RAIL', `Expected to find instruction of type ${type} in transfer instructions`)); + } + + return(found); +} + +/** + * Read the delivered amount an instruction promises, preferring the explicit + * total-receive amount over the raw value. + */ +function instructionTotalReceive(instruction: AssetTransferInstructions): bigint | undefined { + let totalReceive: string | undefined = instruction.totalReceiveAmount; + if (totalReceive === undefined && 'value' in instruction) { + totalReceive = instruction.value; + } + + if (totalReceive === undefined) { + return(undefined); + } + + return(BigInt(totalReceive)); +} + +/** + * Resolve the asset-movement provider for a leg, or fail with a typed error. + */ +export async function resolveMovementProvider(ctx: StepContext, node: AssetMovementGraphNode): Promise { + const providers = await ctx.assetMovementClient.getProvidersForTransfer( + { asset: { from: node.from.asset, to: node.to.asset }, from: node.from.location, to: node.to.location }, + { providerIDs: [ node.providerID ] } + ); + + const provider = providers?.[0]; + if (!provider) { + throw(new AnchorChainingError('PROVIDER_UNAVAILABLE', `Could not get asset movement provider ${node.providerID}`)); + } + + return(provider); +} + +/** + * Best-effort, side-effect-free estimate of an asset-movement leg's delivered + * output for a deposit of `amount`: simulate when supported, otherwise assume + * the rail takes no fee. + */ +export async function estimateMovementValueOut( + ctx: StepContext, + node: AssetMovementGraphNode, + provider: AssetMovementProvider, + amount: bigint +): Promise { + if (!await provider.isOperationSupported('simulateTransfer')) { + return(amount); + } + + try { + const { signer } = await resolveAccountsForAction(ctx.client, { + type: 'assetMovement', + providerMethod: 'initiateTransfer', + provider + }, ctx.overrides); + + const simulated = await provider.simulateTransfer({ + account: signer, + asset: { from: node.from.asset, to: node.to.asset }, + from: { location: node.from.location }, + to: { location: node.to.location }, + value: amount + }); + + const simulatedInstruction = simulated.instructions.find((instr): instr is Extract => instr.type === node.from.rail); + let totalReceive: string | undefined = simulatedInstruction?.totalReceiveAmount; + if (totalReceive === undefined && simulatedInstruction && 'value' in simulatedInstruction) { + totalReceive = simulatedInstruction.value; + } + + if (totalReceive !== undefined) { + return(BigInt(totalReceive)); + } + } catch (error) { + ctx.logger?.debug('AssetMovementStep::estimate', `simulateTransfer estimate failed for step ${node.providerID}; falling back to deposit value`, error); + } + + return(amount); +} + +/** + * An asset-movement leg that initiates a managed transfer through a provider. + * Previews via `simulateTransfer` and never initiates. + */ +export class AssetMovementStep { + readonly type = 'assetMovement' as const; + readonly index: number; + readonly #ctx: StepContext; + readonly #node: AssetMovementGraphNode; + + constructor(ctx: StepContext, index: number, node: AssetMovementGraphNode) { + this.#ctx = ctx; + this.index = index; + this.#node = node; + } + + async preview(known: PreviewKnownValue): Promise { + if (this.#ctx.affinity === 'to') { + throw(new AnchorChainingError('UNSUPPORTED_AFFINITY', `Chaining with affinity 'to' is not supported for asset movement steps`)); + } + + const amount = known.value; + const provider = await resolveMovementProvider(this.#ctx, this.#node); + const estimatedValueOut = await estimateMovementValueOut(this.#ctx, this.#node, provider, amount); + + return({ + type: 'assetMovement', + index: this.index, + providerID: this.#node.providerID, + from: this.#node.from, + to: this.#node.to, + estimatedValueIn: amount, + estimatedValueOut, + minOutput: applySlippage(estimatedValueOut, this.#ctx.slippageBps) + }); + } + + async run(input: StepRunInput): Promise { + if (this.#ctx.affinity === 'to') { + throw(new AnchorChainingError('UNSUPPORTED_AFFINITY', `Chaining with affinity 'to' is not supported for asset movement steps`)); + } + + const provider = await resolveMovementProvider(this.#ctx, this.#node); + const { signer } = await resolveAccountsForAction(this.#ctx.client, { + type: 'assetMovement', + providerMethod: 'initiateTransfer', + provider + }, this.#ctx.overrides); + + const { recipient } = await input.resolveRecipient(); + + /* + * Re-initiate from the actual upstream output so the transfer reflects + * what arrived rather than a stale plan amount. + */ + const transfer = await provider.initiateTransfer({ + account: signer, + asset: { from: this.#node.from.asset, to: this.#node.to.asset }, + from: { location: this.#node.from.location }, + to: { location: this.#node.to.location, recipient }, + value: input.actualInput + }); + + const usingInstruction = findInstruction(transfer.instructions, this.#node.from.rail); + const expectedOutput = instructionTotalReceive(usingInstruction) ?? input.actualInput; + + await input.checkFloor(expectedOutput); + + input.record.intent = { + idempotencyKey: input.idempotencyKey, + kind: 'assetMovement', + createdAtMs: Date.now() + }; + input.record.transferID = transfer.transferID; + input.record.status = 'intent'; + await input.persist(); + + const published: PublishedInputRecord[] = []; + + if (usingInstruction.type === 'KEETA_SEND') { + let sentBlockHash = input.record.sendBlockHash; + if (sentBlockHash === undefined) { + let external = usingInstruction.external; + if (external === undefined) { + external = await buildKeetaSendExternal(provider, transfer.transferID, input.publishedInputs); + } + + sentBlockHash = await input.authorizedSend({ + to: usingInstruction.sendToAddress, + value: BigInt(usingInstruction.value), + token: usingInstruction.tokenAddress, + external + }); + + if (sentBlockHash !== undefined) { + input.record.sendBlockHash = sentBlockHash; + await input.persist(); + } + } + + if (sentBlockHash !== undefined) { + published.push({ blockHash: sentBlockHash, operationIndex: 0 }); + } + } else if (this.index === 0) { + await input.awaitAssetMovementExecution(transfer); + } else if (usingInstruction.type === 'EVM_SEND') { + this.#ctx.logger?.debug('AssetMovementStep::run', `EVM_SEND instruction for step ${this.index}; assuming prior step delivered to ${usingInstruction.sendToAddress}`); + } else { + throw(new AnchorChainingError('UNSUPPORTED_INSTRUCTION', `Unsupported instruction type ${usingInstruction.type} for step ${this.index}`)); + } + + const status = await pollTransferStatus(transfer, input.poll); + const actualOutput = BigInt(status.transaction.to.value); + + let withdrawTx: WithdrawRef | null = null; + const withdraw = status.transaction.to.transactions.withdraw; + if (withdraw) { + withdrawTx = { + location: this.#node.to.location, + transaction: { id: withdraw.id } + }; + } + + const executed: ExecutedStep = { + type: 'assetMovement', + index: this.index, + preview: input.preview, + actualValueIn: input.actualInput, + actualValueOut: actualOutput, + transfer + }; + + return({ + actualOutput, + executed, + publishedInputs: published, + withdrawTx + }); + } +} diff --git a/src/lib/chaining/steps/context.ts b/src/lib/chaining/steps/context.ts new file mode 100644 index 00000000..34cc3e38 --- /dev/null +++ b/src/lib/chaining/steps/context.ts @@ -0,0 +1,142 @@ +import type { lib as KeetaNetLib } from '@keetanetwork/keetanet-client'; +import type * as KeetaNet from '@keetanetwork/keetanet-client'; + +import type { Resolver } from '../../index.js'; +import type { Logger } from '../../log/index.js'; +import type KeetaFXAnchorClient from '../../../services/fx/client.js'; +import type KeetaAssetMovementAnchorClient from '../../../services/asset-movement/client.js'; +import type { + AnchorChainingAccountOverrides, + AnchorChainingPathInput, + AnchorChainingStepLike, + GetAccountForActionPayload +} from '../types.js'; +import { AnchorChainingError } from '../errors.js'; + +/** + * Everything a {@link StepExecutor} needs to preview and execute one leg of a + * chain. Resolved once per execution and shared (read-only) across steps. + */ +export interface StepContext { + client: KeetaNet.UserClient; + resolver: Resolver; + logger?: Logger | undefined; + fxClient: KeetaFXAnchorClient; + assetMovementClient: KeetaAssetMovementAnchorClient; + request: AnchorChainingPathInput; + path: AnchorChainingStepLike[]; + affinity: 'from' | 'to'; + affinityAmount: bigint; + overrides?: AnchorChainingAccountOverrides | undefined; + slippageBps?: number | undefined; + /** + * Indexes of asset-movement steps that deposit into a persistent-forwarding + * address rather than initiating a managed transfer. + */ + forwardedIndexes: ReadonlySet; +} + +const BPS_DENOMINATOR = 10_000n; + +/** + * Apply a slippage tolerance (in basis points) to an estimated output to derive + * the per-leg floor. With no tolerance (or a non-positive one) there is no + * floor and `0n` is returned, so drift is absorbed by re-pricing downstream. + */ +export function applySlippage(estimatedValueOut: bigint, slippageBps?: number): bigint { + if (slippageBps === undefined || slippageBps <= 0) { + return(0n); + } + if (slippageBps >= Number(BPS_DENOMINATOR)) { + return(0n); + } + + const keepBps = BPS_DENOMINATOR - BigInt(Math.floor(slippageBps)); + return((estimatedValueOut * keepBps) / BPS_DENOMINATOR); +} + +/** + * Resolve a single account-like value for a provider action, honoring an + * explicit override (value or resolver function) before falling back to the + * client's account or signer. + */ +export async function resolveAccountLike( + client: KeetaNet.UserClient, + action: GetAccountForActionPayload, + override?: AnchorChainingAccountOverrides['account'] +): Promise> { + let found: InstanceType | undefined = undefined; + + if (client.account.isAccount()) { + found = client.account; + } else if (client.signer !== null) { + found = client.signer; + } + + if (override) { + if (typeof override === 'function') { + found = await override(action); + } else { + found = override; + } + } + + if (!found) { + throw(new AnchorChainingError('INVALID_REQUEST', `Could not get account for ${action.type} action ${action.providerMethod}`)); + } + + return(found); +} + +/** + * Resolve both the signer and account for a provider action. + */ +export async function resolveAccountsForAction( + client: KeetaNet.UserClient, + action: GetAccountForActionPayload, + overrides?: AnchorChainingAccountOverrides +): Promise<{ account: InstanceType; signer: InstanceType }> { + const [signer, account] = await Promise.all([ + resolveAccountLike(client, action, overrides?.signer), + resolveAccountLike(client, action, overrides?.account) + ]); + + return({ signer, account }); +} + +/** + * Classify which asset-movement steps deposit into a persistent-forwarding + * address. Pure over the path's resolved rail metadata; performs no I/O and + * creates no forwarding address. + */ +export function classifyForwardedSteps(path: AnchorChainingStepLike[]): Set { + const forwarded = new Set(); + for (let index = 0; index < path.length; index++) { + const step = path[index]; + if (!step || step.type !== 'assetMovement') { + continue; + } + + const priorStep = index > 0 ? path[index - 1] : null; + const isAmpToAmpTransition = priorStep?.type === 'assetMovement'; + const pfrSupported = step.from.supportedOperations?.createPersistentForwarding === true; + const initiateForbidden = step.from.supportedOperations?.initiateTransfer === false; + + const shouldUsePFR = initiateForbidden || (isAmpToAmpTransition && pfrSupported); + if (!shouldUsePFR) { + continue; + } + + if (!pfrSupported) { + throw(new AnchorChainingError('INVALID_PATH', `Asset movement provider ${step.providerID} source rail ${step.from.rail} declares initiateTransfer:false but does not support createPersistentForwarding`)); + } + + if (index !== path.length - 1) { + throw(new AnchorChainingError('INVALID_PATH', `Persistent-forwarding asset movement steps are currently only supported as the last step in a chain (step ${index} of ${path.length})`)); + } + + forwarded.add(index); + } + + return(forwarded); +} diff --git a/src/lib/chaining/steps/executor.ts b/src/lib/chaining/steps/executor.ts new file mode 100644 index 00000000..17232735 --- /dev/null +++ b/src/lib/chaining/steps/executor.ts @@ -0,0 +1,56 @@ +import type { ChainStepType, PreviewKnownValue, PreviewStep } from '../types.js'; +import type { StepContext } from './context.js'; +import type { StepRunInput, StepRunResult } from './run.js'; +import { AnchorChainingError } from '../errors.js'; +import { FXStep } from './fx.js'; +import { AssetMovementStep } from './asset-movement.js'; +import { ForwardedStep } from './forwarded.js'; +import { KeetaSendStep } from './keeta-send.js'; + +/** + * Behavioral contract for one leg of a chain. A step both estimates its + * amounts side-effect-free ({@link StepExecutor.preview}) and, at execution + * time, performs its irreversible work driven by the actual upstream output. + * + * The `run` half is provided by the execution engine in a later phase; the + * preview half is consumed by {@link AnchorChainingPlan} with no side effects. + */ +export interface StepExecutor { + readonly type: ChainStepType; + readonly index: number; + /** + * Produce a side-effect-free estimate for this leg given the known side + * (input value for affinity `from`, output value for affinity `to`). + */ + preview(known: PreviewKnownValue): Promise; + /** + * Perform this leg's irreversible work, priced from the actual upstream + * output, and report the actual delivered output. + */ + run(input: StepRunInput): Promise; +} + +/** + * Build the {@link StepExecutor} for the path step at `index`, dispatching on + * the step type and forwarded-step classification carried by the context. + */ +export function createStepExecutor(ctx: StepContext, index: number): StepExecutor { + const step = ctx.path[index]; + if (!step) { + throw(new AnchorChainingError('STEP_NOT_DEFINED', `Step ${index} is not defined`)); + } + + switch (step.type) { + case 'fx': + return(new FXStep(ctx, index, step)); + case 'assetMovement': + if (ctx.forwardedIndexes.has(index)) { + return(new ForwardedStep(ctx, index, step)); + } + return(new AssetMovementStep(ctx, index, step)); + case 'keetaSend': + return(new KeetaSendStep(ctx, index, step)); + default: + throw(new AnchorChainingError('INVALID_PATH', `Unknown step type at index ${index}`)); + } +} diff --git a/src/lib/chaining/steps/external.ts b/src/lib/chaining/steps/external.ts new file mode 100644 index 00000000..bb5753ba --- /dev/null +++ b/src/lib/chaining/steps/external.ts @@ -0,0 +1,46 @@ +import * as KeetaNet from '@keetanetwork/keetanet-client'; + +import type { AnchorExternalInput } from '../../anchor-external.js'; +import type { PublishedInputRecord } from '../store.js'; +import type { AssetMovementProvider } from '../types.js'; +import { AnchorExternalBuilder } from '../../anchor-external.js'; + +/** + * Project persisted published-input records into the anchor-external input + * shape, preserving the optional operation index. + */ +export function toExternalInputs(records: readonly PublishedInputRecord[]): AnchorExternalInput[] { + return(records.map(function(record) { + if (record.operationIndex !== undefined) { + return({ blockHash: record.blockHash, operationIndex: record.operationIndex }); + } + + return({ blockHash: record.blockHash }); + })); +} + +/** + * Construct the unsigned anchor-correlation external envelope for a + * user-funded KEETA_SEND, linking the prior steps' published operations to the + * anchor's transfer. Returns `undefined` when the provider exposes no anchor + * account to correlate against. + */ +export async function buildKeetaSendExternal( + provider: AssetMovementProvider, + transactionID: string, + inputs: readonly PublishedInputRecord[] +): Promise { + const anchorKey = provider.serviceInfo.account; + if (anchorKey === undefined) { + return(undefined); + } + + const anchor = KeetaNet.lib.Account.fromPublicKeyString(anchorKey); + const builder = new AnchorExternalBuilder().setAnchor(anchor, { transactionId: transactionID }); + + for (const input of inputs) { + builder.addInput(input.blockHash, input.operationIndex); + } + + return(await builder.build()); +} diff --git a/src/lib/chaining/steps/forwarded.ts b/src/lib/chaining/steps/forwarded.ts new file mode 100644 index 00000000..3fad061b --- /dev/null +++ b/src/lib/chaining/steps/forwarded.ts @@ -0,0 +1,159 @@ +import * as KeetaNet from '@keetanetwork/keetanet-client'; +import type { AssetMovementGraphNode, AssetMovementProvider, ExecutedStep, PreviewKnownValue, PreviewStep } from '../types.js'; +import type { KeetaAssetMovementTransaction } from '../../../services/asset-movement/common.js'; +import type { lib as KeetaNetLib } from '@keetanetwork/keetanet-client'; +import type { StepContext } from './context.js'; +import type { PollSettings, StepRunInput, StepRunResult, WithdrawRef } from './run.js'; +import { applySlippage, resolveAccountsForAction } from './context.js'; +import { AnchorChainingError } from '../errors.js'; +import { estimateMovementValueOut, resolveMovementProvider } from './asset-movement.js'; + +const MAX_FORWARDED_BACKOFF_MS = 8_000; + +/** + * Poll a provider for the forwarded transaction it creates after sweeping the + * persistent-forwarding address, correlated to the prior leg's withdraw. + */ +async function pollForwardedTransaction( + provider: AssetMovementProvider, + account: InstanceType, + sourceLocation: AssetMovementGraphNode['from']['location'], + persistentAddress: string, + sourceWithdraw: WithdrawRef, + poll: PollSettings, + logger: StepContext['logger'] +): Promise { + const deadline = Date.now() + poll.timeoutMs; + + for (let attempt = 0; ; attempt++) { + if (poll.abortSignal?.aborted) { + throw(new AnchorChainingError('ABORTED', `Aborted while waiting for forwarded transaction at ${persistentAddress}`)); + } + + let transactions: KeetaAssetMovementTransaction[] = []; + try { + const response = await provider.listTransactions({ + account, + persistentAddresses: [ { location: sourceLocation, persistentAddress } ], + transactions: [ sourceWithdraw ] + }); + + transactions = response.transactions; + } catch (error) { + logger?.debug('ForwardedStep::poll', `listTransactions failed for persistent-forwarding address ${persistentAddress}`, error); + } + + const candidate = transactions.find(tx => tx.status === 'COMPLETE'); + if (candidate) { + return(candidate); + } + + if (Date.now() >= deadline) { + throw(new AnchorChainingError('POLL_TIMEOUT', `Timed out waiting for persistent-forwarding transaction at ${persistentAddress}`)); + } + + const delay = Math.min(MAX_FORWARDED_BACKOFF_MS, Math.round(poll.intervalMs * (1.5 ** attempt))); + await KeetaNet.lib.Utils.Helper.asleep(delay); + } +} + +/** + * An asset-movement leg whose prior step deposits into a persistent-forwarding + * address the provider then sweeps. Previews like a managed transfer but + * creates no forwarding address (deferred to execution). + */ +export class ForwardedStep { + readonly type = 'forwarded' as const; + readonly index: number; + readonly #ctx: StepContext; + readonly #node: AssetMovementGraphNode; + + constructor(ctx: StepContext, index: number, node: AssetMovementGraphNode) { + this.#ctx = ctx; + this.index = index; + this.#node = node; + } + + async preview(known: PreviewKnownValue): Promise { + if (this.#ctx.affinity === 'to') { + throw(new AnchorChainingError('UNSUPPORTED_AFFINITY', `Chaining with affinity 'to' is not supported for forwarded steps`)); + } + + const amount = known.value; + const provider = await resolveMovementProvider(this.#ctx, this.#node); + + if (!await provider.isOperationSupported('createPersistentForwarding')) { + throw(new AnchorChainingError('INVALID_PATH', `Asset movement provider ${this.#node.providerID} does not support createPersistentForwarding required by this leg`)); + } + + const estimatedValueOut = await estimateMovementValueOut(this.#ctx, this.#node, provider, amount); + + return({ + type: 'forwarded', + index: this.index, + providerID: this.#node.providerID, + from: this.#node.from, + to: this.#node.to, + estimatedValueIn: amount, + estimatedValueOut, + minOutput: applySlippage(estimatedValueOut, this.#ctx.slippageBps) + }); + } + + async run(input: StepRunInput): Promise { + if (!input.prevWithdrawTx) { + throw(new AnchorChainingError('INVALID_STATE', `Forwarded step at index ${this.index} requires the prior step to produce a withdraw transaction`)); + } + + const provider = await resolveMovementProvider(this.#ctx, this.#node); + const persistentAddress = await input.ensureForwardedAddress(); + const pfiAddress = persistentAddress.address; + if (typeof pfiAddress !== 'string') { + throw(new AnchorChainingError('INVALID_STATE', `Persistent forwarding address must be a resolved string`)); + } + + const { account } = await resolveAccountsForAction(this.#ctx.client, { + type: 'assetMovement', + providerMethod: 'initiateTransfer', + provider + }, this.#ctx.overrides); + + input.record.intent = { + idempotencyKey: input.idempotencyKey, + kind: 'forwarded', + createdAtMs: Date.now() + }; + input.record.status = 'intent'; + await input.persist(); + + const observed = await pollForwardedTransaction( + provider, + account, + this.#node.from.location, + pfiAddress, + input.prevWithdrawTx, + input.poll, + this.#ctx.logger + ); + + const actualOutput = BigInt(observed.to.value); + + await input.checkFloor(actualOutput); + + const executed: ExecutedStep = { + type: 'forwarded', + index: this.index, + preview: input.preview, + actualValueIn: input.actualInput, + actualValueOut: actualOutput, + observedTransaction: observed + }; + + return({ + actualOutput, + executed, + publishedInputs: [], + withdrawTx: null + }); + } +} diff --git a/src/lib/chaining/steps/fx.ts b/src/lib/chaining/steps/fx.ts new file mode 100644 index 00000000..3f0df75f --- /dev/null +++ b/src/lib/chaining/steps/fx.ts @@ -0,0 +1,124 @@ +import type { ExecutedStep, FXGraphNode, PreviewKnownValue, PreviewStep } from '../types.js'; +import type { StepContext } from './context.js'; +import type { FXQuoteOrEstimate } from '../types.js'; +import type { StepRunInput, StepRunResult } from './run.js'; +import type { PublishedInputRecord } from '../store.js'; +import { applySlippage, resolveAccountsForAction } from './context.js'; +import { AnchorChainingError } from '../errors.js'; +import { pollExchangeStatus } from './poll.js'; +import { toExternalInputs } from './external.js'; + +/** + * An FX leg: a same-location Keeta token-to-token conversion through an FX + * anchor. Previews via the anchor's quote/estimate surface without creating an + * exchange. + */ +export class FXStep { + readonly type = 'fx' as const; + readonly index: number; + readonly #ctx: StepContext; + readonly #node: FXGraphNode; + + constructor(ctx: StepContext, index: number, node: FXGraphNode) { + this.#ctx = ctx; + this.index = index; + this.#node = node; + } + + /** + * Resolve a single quote/estimate for this leg at the given amount and + * affinity, validating it can actually be exchanged. + */ + async #quote(amount: bigint, affinity: 'from' | 'to'): Promise { + const accountOptions = await resolveAccountsForAction(this.#ctx.client, { + type: 'fx', + providerMethod: 'getAccountForAction' + }, this.#ctx.overrides); + + const quotesOrEstimates = await this.#ctx.fxClient.getQuotesOrEstimates( + { from: this.#node.from.asset, to: this.#node.to.asset, amount, affinity }, + accountOptions, + { providerIDs: [ this.#node.providerID ] } + ); + + const result = quotesOrEstimates?.[0]; + if (!result) { + throw(new AnchorChainingError('QUOTE_UNAVAILABLE', `Could not get FX quote/estimate for provider ${this.#node.providerID}`)); + } + + if (!result.isQuote && result.estimate.canPerformExchange === false) { + throw(new AnchorChainingError('QUOTE_UNAVAILABLE', `FX estimate from provider ${this.#node.providerID} indicates exchange cannot be performed`)); + } + + return(result); + } + + async preview(known: PreviewKnownValue): Promise { + const amount = known.value; + const result = await this.#quote(amount, this.#ctx.affinity); + const convertedAmount = result.isQuote ? result.quote.convertedAmount : result.estimate.convertedAmount; + + let estimatedValueIn: bigint; + let estimatedValueOut: bigint; + if (this.#ctx.affinity === 'to') { + estimatedValueOut = amount; + estimatedValueIn = convertedAmount; + } else { + estimatedValueIn = amount; + estimatedValueOut = convertedAmount; + } + + return({ + type: 'fx', + index: this.index, + providerID: this.#node.providerID, + from: this.#node.from, + to: this.#node.to, + estimatedValueIn, + estimatedValueOut, + minOutput: applySlippage(estimatedValueOut, this.#ctx.slippageBps) + }); + } + + async run(input: StepRunInput): Promise { + /* + * Drive forward from the actual upstream output: re-quote at the real + * input so the exchange reflects what arrived, not a stale plan amount. + */ + const result = await this.#quote(input.actualInput, 'from'); + const expectedOutput = result.isQuote ? result.quote.convertedAmount : result.estimate.convertedAmount; + + await input.checkFloor(expectedOutput); + + input.record.intent = { + idempotencyKey: input.idempotencyKey, + kind: 'fx', + createdAtMs: Date.now() + }; + input.record.status = 'intent'; + await input.persist(); + + const exchange = await result.createExchange(undefined, { inputs: toExternalInputs(input.publishedInputs) }); + input.record.exchangeID = exchange.exchange.exchangeID; + await input.persist(); + + const status = await pollExchangeStatus(exchange, input.poll); + const published: PublishedInputRecord[] = [ { blockHash: status.blockhash } ]; + + const executed: ExecutedStep = { + type: 'fx', + index: this.index, + preview: input.preview, + actualValueIn: input.actualInput, + actualValueOut: expectedOutput, + exchange + }; + + return({ + actualOutput: expectedOutput, + executed, + publishedInputs: published, + withdrawTx: null + }); + } +} diff --git a/src/lib/chaining/steps/keeta-send.ts b/src/lib/chaining/steps/keeta-send.ts new file mode 100644 index 00000000..ed048d10 --- /dev/null +++ b/src/lib/chaining/steps/keeta-send.ts @@ -0,0 +1,121 @@ +import * as KeetaNet from '@keetanetwork/keetanet-client'; + +import type { ExecutedStep, KeetaSendStepLike, PreviewKnownValue, PreviewStep } from '../types.js'; +import type { StepContext } from './context.js'; +import type { StepRunInput, StepRunResult } from './run.js'; +import type { PublishedInputRecord } from '../store.js'; +import { AnchorChainingError } from '../errors.js'; +import { applySlippage } from './context.js'; + +/** + * A direct, on-Keeta token send. The only step in its path; input and output + * are identical (no conversion, no rail fee). + */ +export class KeetaSendStep { + readonly type = 'keetaSend' as const; + readonly index: number; + readonly #ctx: StepContext; + readonly #node: KeetaSendStepLike; + + constructor(ctx: StepContext, index: number, node: KeetaSendStepLike) { + this.#ctx = ctx; + this.index = index; + this.#node = node; + } + + async preview(known: PreviewKnownValue): Promise { + if (this.#ctx.path.length !== 1) { + throw(new AnchorChainingError('INVALID_PATH', `Direct Keeta send steps must be the only step in the path`)); + } + + if (!KeetaNet.lib.Account.isInstance(this.#node.from.asset) || !KeetaNet.lib.Account.isInstance(this.#node.to.asset)) { + throw(new AnchorChainingError('INVALID_PATH', `Expected assets to be token accounts for KEETA_SEND rail`)); + } + + if (!this.#node.from.asset.comparePublicKey(this.#node.to.asset)) { + throw(new AnchorChainingError('INVALID_PATH', `For KEETA_SEND step, from and to asset must be the same account`)); + } + + const amount = known.value; + + return({ + type: 'keetaSend', + index: this.index, + providerID: null, + from: this.#node.from, + to: this.#node.to, + estimatedValueIn: amount, + estimatedValueOut: amount, + minOutput: applySlippage(amount, this.#ctx.slippageBps) + }); + } + + async run(input: StepRunInput): Promise { + const token = KeetaNet.lib.Account.toAccount(this.#node.to.asset).assertKeyType(KeetaNet.lib.Account.AccountKeyAlgorithm.TOKEN); + + const recipient = this.#ctx.request.destination.recipient; + let recipientAccount; + if (KeetaNet.lib.Account.isInstance(recipient)) { + recipientAccount = recipient; + } else if (typeof recipient === 'string') { + recipientAccount = KeetaNet.lib.Account.fromPublicKeyString(recipient); + } else { + throw(new AnchorChainingError('INVALID_REQUEST', `Expected destination recipient to be a public key string for KEETA_SEND step`)); + } + + await input.checkFloor(input.actualInput); + + input.record.status = 'intent'; + input.record.intent = { + idempotencyKey: input.idempotencyKey, + kind: 'keetaSend', + send: { + to: recipientAccount.publicKeyString.get(), + value: input.actualInput.toString(), + token: token.publicKeyString.get() + }, + createdAtMs: Date.now() + }; + + await input.persist(); + + /* + * Reconcile before performing: a persisted send hash means the send + * already published on a prior attempt, so do not re-send. + */ + let sentBlockHash = input.record.sendBlockHash; + if (sentBlockHash === undefined) { + sentBlockHash = await input.authorizedSend({ + to: recipientAccount, + value: input.actualInput, + token + }); + + if (sentBlockHash !== undefined) { + input.record.sendBlockHash = sentBlockHash; + await input.persist(); + } + } + + const published: PublishedInputRecord[] = []; + if (sentBlockHash !== undefined) { + published.push({ blockHash: sentBlockHash, operationIndex: 0 }); + } + + const executed: ExecutedStep = { + type: 'keetaSend', + index: this.index, + preview: input.preview, + actualValueIn: input.actualInput, + actualValueOut: input.actualInput, + sendBlockHash: sentBlockHash + }; + + return({ + actualOutput: input.actualInput, + executed, + publishedInputs: published, + withdrawTx: null + }); + } +} diff --git a/src/lib/chaining/steps/poll.ts b/src/lib/chaining/steps/poll.ts new file mode 100644 index 00000000..71baf73b --- /dev/null +++ b/src/lib/chaining/steps/poll.ts @@ -0,0 +1,74 @@ +import * as KeetaNet from '@keetanetwork/keetanet-client'; +import type { AssetMovementTransfer, FXExchange } from '../types.js'; +import type { PollSettings } from './run.js'; +import { AnchorChainingError } from '../errors.js'; + +const MAX_BACKOFF_MS = 8_000; + +/** + * Compute the next poll delay: a mild exponential ramp from the base interval, + * capped, so fast-settling work is observed promptly while slow work does not + * hammer the provider. + */ +function nextDelay(baseMs: number, attempt: number): number { + const grown = baseMs * (1.5 ** attempt); + return(Math.min(MAX_BACKOFF_MS, Math.round(grown))); +} + +function assertNotAborted(poll: PollSettings, what: string): void { + if (poll.abortSignal?.aborted) { + throw(new AnchorChainingError('ABORTED', `Aborted while waiting for ${what}`)); + } +} + +/** + * Poll an FX exchange to completion, failing fast on a terminal `failed` + * status and on deadline. + */ +export async function pollExchangeStatus( + exchange: FXExchange, + poll: PollSettings +): Promise>, { status: 'completed' }>> { + const deadline = Date.now() + poll.timeoutMs; + const exchangeID = exchange.exchange.exchangeID; + for (let attempt = 0; ; attempt++) { + assertNotAborted(poll, `FX exchange ${exchangeID} to complete`); + + const status = await exchange.getExchangeStatus(); + if (status.status === 'completed') { + return(status); + } + if (status.status === 'failed') { + throw(new AnchorChainingError('EXCHANGE_FAILED', `FX exchange ${exchangeID} failed`)); + } + if (Date.now() >= deadline) { + throw(new AnchorChainingError('POLL_TIMEOUT', `Timed out waiting for FX exchange ${exchangeID} to complete`)); + } + + await KeetaNet.lib.Utils.Helper.asleep(nextDelay(poll.intervalMs, attempt)); + } +} + +/** + * Poll a managed asset-movement transfer to a `COMPLETE` status, failing on + * deadline. + */ +export async function pollTransferStatus( + transfer: AssetMovementTransfer, + poll: PollSettings +): Promise>> { + const deadline = Date.now() + poll.timeoutMs; + for (let attempt = 0; ; attempt++) { + assertNotAborted(poll, `transfer ${transfer.transferID} to complete`); + + const status = await transfer.getTransferStatus(); + if (status.transaction.status === 'COMPLETE') { + return(status); + } + if (Date.now() >= deadline) { + throw(new AnchorChainingError('POLL_TIMEOUT', `Timed out waiting for transfer ${transfer.transferID} to complete`)); + } + + await KeetaNet.lib.Utils.Helper.asleep(nextDelay(poll.intervalMs, attempt)); + } +} diff --git a/src/lib/chaining/steps/run.ts b/src/lib/chaining/steps/run.ts new file mode 100644 index 00000000..2a103da5 --- /dev/null +++ b/src/lib/chaining/steps/run.ts @@ -0,0 +1,120 @@ +import type { GenericAccount, TokenAddress } from '@keetanetwork/keetanet-client/lib/account.js'; +import type { AssetLocationLike, KeetaPersistentForwardingAddressDetails, RecipientResolved } from '../../../services/asset-movement/common.js'; +import type { ChainingStepRecord, PublishedInputRecord } from '../store.js'; +import type { + AnchorChainingPathExecuteOptions, + AssetMovementTransfer, + ExecutedStep, + PreviewStep, + SendingToType +} from '../types.js'; + +/** + * A reference to a destination-chain withdraw transaction produced by an + * asset-movement leg, used to correlate a downstream forwarded leg. + */ +export interface WithdrawRef { + location: AssetLocationLike; + transaction: { id: string }; +} + +/** + * The resolved recipient for an asset-movement leg and where it is delivering. + */ +export interface ResolvedRecipient { + recipient: RecipientResolved; + sendingTo: SendingToType; +} + +/** + * Settlement-poll cadence and deadline for a single leg. + */ +export interface PollSettings { + intervalMs: number; + timeoutMs: number; + abortSignal?: AbortSignal | undefined; +} + +/** + * Everything a {@link StepExecutor.run} needs from the engine for one leg. The + * engine owns durability (persistence), the per-leg output floor, user-action + * prompting, cross-step recipient resolution, and forwarding-address creation; + * the step owns the provider-specific irreversible work. + */ +export interface StepRunInput { + /** + * The value actually driven into this leg (the prior leg's real output). + */ + actualInput: bigint; + /** + * This leg's pre-execution estimate. + */ + preview: PreviewStep; + /** + * Stable per-step idempotency key (`correlationID:stepIndex`). + */ + idempotencyKey: string; + /** + * Mutable, engine-persisted write-ahead record for this leg. + */ + record: ChainingStepRecord; + /** + * Chain-level published on-chain operations to thread into this leg's + * external correlation envelope, in publication order. + */ + publishedInputs: readonly PublishedInputRecord[]; + /** + * Destination-chain withdraw produced by the prior leg, if any. + */ + prevWithdrawTx: WithdrawRef | null; + options: AnchorChainingPathExecuteOptions; + poll: PollSettings; + /** + * Persist the current execution state (called after intent writes). + */ + persist(): Promise; + /** + * Gate an irreversible send on the per-leg floor. Resolves to proceed, or + * rejects (aborting before any irreversible work) when the expected output + * falls below the leg minimum and the consumer declines to proceed. + */ + checkFloor(expectedOutput: bigint): Promise; + /** + * Publish a recoverable Keeta send, returning the published block hash. + */ + authorizedSend(args: { + to: string | GenericAccount; + value: bigint; + token: TokenAddress | string; + external?: string | undefined; + }): Promise; + /** + * Await user execution of a provider-managed asset-movement transfer. + */ + awaitAssetMovementExecution(transfer: AssetMovementTransfer): Promise; + /** + * Resolve the recipient and delivery target for this asset-movement leg, + * accounting for the next leg (final destination, in-account hold, next + * forwarded address, or next provider deposit instruction). + */ + resolveRecipient(): Promise; + /** + * Ensure this forwarded leg's persistent-forwarding address exists, + * creating it if needed. + */ + ensureForwardedAddress(): Promise; +} + +/** + * The outcome of running one leg. + */ +export interface StepRunResult { + actualOutput: bigint; + executed: ExecutedStep; + /** + * On-chain operations this leg published, to append to the chain-level + * accumulator for downstream external envelopes. + */ + publishedInputs: PublishedInputRecord[]; + withdrawTx: WithdrawRef | null; +} diff --git a/src/lib/chaining/store.ts b/src/lib/chaining/store.ts new file mode 100644 index 00000000..f99c781b --- /dev/null +++ b/src/lib/chaining/store.ts @@ -0,0 +1,174 @@ +/** + * Durable, resume-forward state for an anchor-chaining execution. + * + * The engine writes intent before each irreversible operation (write-ahead + * logging) and records the actual delivered output after each step settles. + * Every shape here is JSON-serializable so a backing store can persist and + * reload it to resume a partially-completed chain. `bigint` values are carried + * as decimal strings for portability. + */ + +import type { AssetLocationLike } from '../../services/asset-movement/common.js'; + +/** + * Lifecycle status of a single step within an execution. + * + * - `pending` no irreversible work has begun. + * - `intent` intent recorded (WAL); the irreversible op may be in flight. + * - `settled` the step's output has been observed and recorded. + * - `failed` the step terminated without settling. + */ +export type ChainingStepStatus = 'pending' | 'intent' | 'settled' | 'failed'; + +/** + * A reference to a published on-chain operation, used to rebuild the anchor + * `external` correlation envelope on resume. + */ +export interface PublishedInputRecord { + blockHash: string; + operationIndex?: number; +} + +/** + * Write-ahead intent recorded immediately before an irreversible operation, so + * a crash between performing and persisting can be reconciled on resume rather + * than blindly re-performing (which would double-send). + */ +export interface ChainingStepIntent { + /** + * Per-step idempotency key (`correlationID:stepIndex`). Stable across + * resumes; the natural key the engine reconciles against before performing. + */ + idempotencyKey: string; + kind: 'fx' | 'assetMovement' | 'forwarded' | 'keetaSend'; + /** + * Minimal details to reconcile a user-funded Keeta send before re-sending. + */ + send?: { + to: string; + value: string; + token: string; + external?: string; + }; + createdAtMs: number; +} + +/** + * Persisted record of a single chain step. + */ +export interface ChainingStepRecord { + index: number; + type: 'fx' | 'assetMovement' | 'forwarded' | 'keetaSend'; + status: ChainingStepStatus; + intent?: ChainingStepIntent; + /** + * Actual input value driven into this step (decimal string), i.e. the prior + * step's actual delivered output. + */ + actualInput?: string; + /** + * Actual delivered output value of this step (decimal string). + */ + actualOutput?: string; + transferID?: string; + exchangeID?: string; + sendBlockHash?: string; + /** + * Destination-chain withdraw produced by this step, persisted so a resumed + * forwarded step can correlate against it. + */ + withdraw?: { + location: AssetLocationLike; + id: string; + }; + /** + * On-chain operations published by this step, contributing to the chain's + * external-correlation inputs. + */ + publishedInputs: PublishedInputRecord[]; +} + +/** + * Serializable, resumable state for a whole chaining execution. + */ +export interface ExecutionState { + correlationID: string; + status: 'idle' | 'executing' | 'completed' | 'failed'; + currentStepIndex: number; + steps: ChainingStepRecord[]; + /** + * Chain-level accumulator of published inputs threaded into each downstream + * step's external envelope, in publication order. + */ + publishedInputs: PublishedInputRecord[]; + createdAtMs: number; + updatedAtMs: number; + error?: { + code: string; + message: string; + }; +} + +/** + * Derive the stable per-step idempotency key for a correlation. + */ +export function stepIdempotencyKey(correlationID: string, stepIndex: number): string { + return(`${correlationID}:${stepIndex}`); +} + +/** + * Persistence boundary for {@link ExecutionState}. The default backing store is + * in-memory; a durable backend can implement this interface to enable + * cross-session resume without touching the engine. + */ +export interface AnchorChainingStore { + /** + * Load the state for a correlation, or `undefined` when none is stored. + */ + load(correlationID: string): Promise; + /** + * Persist the full state for a correlation. + */ + save(state: ExecutionState): Promise; + /** + * Remove any stored state for a correlation. + */ + delete(correlationID: string): Promise; +} + +/** + * Round-trip a state through JSON to both deep-clone it and enforce the + * serializable contract callers rely on for durability. + */ +function cloneState(state: ExecutionState): ExecutionState { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + return(JSON.parse(JSON.stringify(state)) as ExecutionState); +} + +/** + * Process-lifetime, in-memory {@link AnchorChainingStore}. The default when a + * consumer does not supply a durable backend. Clones on read and write so held + * references cannot mutate persisted state out of band. + */ +export class AnchorChainingStoreMemory implements AnchorChainingStore { + readonly #entries = new Map(); + + load(correlationID: string): Promise { + const found = this.#entries.get(correlationID); + if (found === undefined) { + return(Promise.resolve(undefined)); + } + + return(Promise.resolve(cloneState(found))); + } + + save(state: ExecutionState): Promise { + this.#entries.set(state.correlationID, cloneState(state)); + return(Promise.resolve()); + } + + delete(correlationID: string): Promise { + this.#entries.delete(correlationID); + return(Promise.resolve()); + } +} diff --git a/src/lib/chaining/types.ts b/src/lib/chaining/types.ts new file mode 100644 index 00000000..add565e0 --- /dev/null +++ b/src/lib/chaining/types.ts @@ -0,0 +1,452 @@ +import type { lib as KeetaNetLib } from '@keetanetwork/keetanet-client'; +import * as KeetaNet from '@keetanetwork/keetanet-client'; +import type { AnchorTokenLocationMetadata, AssetLocationLike, PickChainLocation, Rail, RecipientResolved } from '../../services/asset-movement/common.js'; +import { convertAssetLocationToString } from '../../services/asset-movement/common.js'; +import type { Resolver } from '../index.js'; +import type { ISOCurrencyCode } from '@keetanetwork/currency-info'; +import type { Account, GenericAccount, TokenAddress } from '@keetanetwork/keetanet-client/lib/account.js'; +import type { BlockHash } from '@keetanetwork/keetanet-client/lib/block/index.js'; +import type { KeetaAssetMovementTransaction } from '../../services/asset-movement/common.js'; +import type KeetaFXAnchorClient from '../../services/fx/client.js'; +import type KeetaAssetMovementAnchorClient from '../../services/asset-movement/client.js'; +import type { ExternalChainAsset } from '../asset.js'; +import type { Logger } from '../log/index.js'; +import type { AnchorMetadataLegalField } from '../metadata.types.js'; + +/** + * A single FX quote or estimate as returned by the FX anchor client. + */ +export type FXQuoteOrEstimate = NonNullable>>[number]; + +/** + * An asset-movement provider resolved for a particular transfer pair. + */ +export type AssetMovementProvider = NonNullable>>[number]; + +/** + * A managed asset-movement transfer handle returned by `initiateTransfer`. + */ +export type AssetMovementTransfer = Awaited>; + +/** + * A created FX exchange handle returned by `createExchange`. + */ +export type FXExchange = Awaited>; + +/** + * Where an asset-movement step is delivering its output value. + */ +export type SendingToType = 'SELF' | 'NEXT_STEP' | 'FINAL_DESTINATION'; + +/** + * A single legal disclaimer entry attached to a provider. + */ +export type Disclaimer = Exclude[number]; + +/** + * Disclaimers grouped under a single provider. + */ +export interface ProviderDisclaimers { + providerID: string; + disclaimers: Disclaimer[]; +} + +/** + * All provider disclaimers gathered for a path. + */ +export type PlanDisclaimers = ProviderDisclaimers[]; + +/** + * Operations a rail supports, as advertised by the provider metadata. + */ +export interface RailSupportedOperations { + createPersistentForwarding?: boolean; + initiateTransfer?: boolean; +} + +/** + * A rail paired with the operations it supports. + */ +export interface RailWithSupportedOperations { + rail: Rail; + supportedOperations?: RailSupportedOperations; +} + +/** + * A chainable asset: a Keeta token, an ISO currency code, or an external + * (off-chain / other-chain) asset. + */ +export type AnchorChainingAsset = TokenAddress | ISOCurrencyCode | ExternalChainAsset; + +/** + * An asset located on a rail at a location, with an optional value. + */ +export interface AnchorChainingAssetAndLocation { + asset: AssetType; + location: Location; + rail: Rail; + supportedOperations?: RailSupportedOperations; + value?: bigint; +} + +/** + * The terminal destination of a chain, carrying the resolved recipient. + */ +export interface AnchorChainingDestination extends AnchorChainingAssetAndLocation { + recipient: RecipientResolved; +} + +/** + * The user-facing request describing a source and destination to chain between. + */ +export interface AnchorChainingPathInput { + source: AnchorChainingAssetAndLocation; + destination: AnchorChainingDestination; +} + +/** + * Configuration for constructing an {@link AnchorChaining} instance. + */ +export interface AnchorChainingConfig { + client: KeetaNet.UserClient; + resolver?: Resolver; + signer?: InstanceType; + account?: InstanceType; + logger?: Logger; +} + +interface BaseGraphNodeLike { + type: Type; + providerID: string; + + from: AnchorChainingAssetAndLocation; + to: AnchorChainingAssetAndLocation; +} + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface FXGraphNode extends BaseGraphNodeLike<'fx', Exclude> {} +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface AssetMovementGraphNode extends BaseGraphNodeLike<'assetMovement', AnchorChainingAsset> {} +export type GraphNodeLike = FXGraphNode | AssetMovementGraphNode; + +export type KeetaLocationLike = Extract | PickChainLocation<'keeta'>; + +/** + * A direct, on-Keeta send step. Always the only step in a path. + */ +export interface KeetaSendStepLike { + type: 'keetaSend'; + + providerID?: null; + + from: AnchorChainingAssetAndLocation; + to: AnchorChainingAssetAndLocation; +} + +export type AnchorChainingStepLike = GraphNodeLike | KeetaSendStepLike; + +/** + * Returns true when both inputs parse to equal Keeta token accounts. + */ +export function areBothTokenAndEqual(a: string | TokenAddress, b: string | TokenAddress): boolean { + try { + const aParsed = KeetaNet.lib.Account.toAccount(a); + const bParsed = KeetaNet.lib.Account.toAccount(b); + + if (!aParsed.isToken() || !bParsed.isToken()) { + return(false); + } + + return(aParsed.comparePublicKey(bParsed)); + } catch { + return(false); + } +} + +/** + * Compare two chaining assets for equality, handling both string codes and + * token accounts. + */ +export function isAnchorChainingAssetEqual(a: AnchorChainingAsset, b: AnchorChainingAsset): boolean { + if (typeof a === 'string' && typeof b === 'string' && a === b) { + return(true); + } else if (areBothTokenAndEqual(a, b)) { + return(true); + } else { + return(false); + } +} + +/** + * Returns true when a node side satisfies the required asset/location/rail. + */ +export function nodeSideSupports(side: AnchorChainingAssetAndLocation, required: AnchorChainingAssetAndLocation): boolean { + if (side.rail !== required.rail) { + return(false); + } + + if (convertAssetLocationToString(side.location) !== convertAssetLocationToString(required.location)) { + return(false); + } + + if (!isAnchorChainingAssetEqual(side.asset, required.asset)) { + return(false); + } + + return(true); +} + +/** + * Returns true for nodes that keep assets on Keeta: FX nodes, plus + * asset-movement nodes whose from and to share the same Keeta chain location + * (custodial FX anchors that don't actually move funds off-chain). + */ +export function isFXLikeNode(node: GraphNodeLike): boolean { + if (node.type === 'fx') { + return(true); + } + const fromStr = convertAssetLocationToString(node.from.location); + const toStr = convertAssetLocationToString(node.to.location); + return(fromStr === toStr && fromStr.startsWith('chain:keeta:')); +} + +/** + * Inbound/outbound/common rails resolved for one side of an asset-movement + * pair. + */ +export interface AssetMovementResolvedRails { + common: RailWithSupportedOperations[]; + inbound: RailWithSupportedOperations[]; + outbound: RailWithSupportedOperations[]; +} + +export type AnchorChainingListAssetsSideFilter = { + location?: AssetLocationLike | undefined; + asset?: AnchorChainingAsset | undefined; + rail?: Rail | undefined; +}; + +type AnchorChainingListAssetsShared = { + maxStepCount?: number; + onlyAllowFXLike?: boolean; +}; + +export type AnchorChainingListAssetsFilter = + | ({ from: AnchorChainingListAssetsSideFilter; to?: never } & AnchorChainingListAssetsShared) + | ({ to: AnchorChainingListAssetsSideFilter; from?: never } & AnchorChainingListAssetsShared) + | ({ from?: never; to?: never } & AnchorChainingListAssetsShared); + +export type AnchorChainingResolveAssetsFilter = { + from?: AnchorChainingListAssetsSideFilter; + to?: AnchorChainingListAssetsSideFilter; + maxStepCount?: number; + onlyAllowFXLike?: boolean; +}; + +export interface AnchorChainingResolveAssetsResult { + from: AnchorChainingAssetInfo[]; + to: AnchorChainingAssetInfo[]; +} + +export interface AnchorChainingAssetInfo { + asset: AnchorChainingAsset; + location: AssetLocationLike; + rails: { + inbound: Rail[]; + outbound: Rail[]; + }; + + distance: { + pathLength: number; + } | null; +} + +export type AnchorChainingAssetInfoWithMetadata = AnchorChainingAssetInfo & { + metadata?: AnchorTokenLocationMetadata; +}; + +export interface AnchorChainingResolveAssetsWithMetadataResult { + from: AnchorChainingAssetInfoWithMetadata[]; + to: AnchorChainingAssetInfoWithMetadata[]; +} + +export type AnchorChainingWithMetadataOptions = { + providerID?: string; +}; + +/** + * Identifies the provider action an account/signer is being resolved for. + */ +export type GetAccountForActionPayload = { + type: 'assetMovement'; + providerMethod: 'initiateTransfer'; + provider?: AssetMovementProvider; +} | { + type: 'fx'; + providerMethod: 'getAccountForAction'; +}; + +export type AccountLike = InstanceType | undefined | ((providerMethodPayload: GetAccountForActionPayload) => Promise | Account); + +/** + * Per-execution overrides for the signing account and signer. + */ +export interface AnchorChainingAccountOverrides { + account?: AccountLike; + signer?: AccountLike; +} + +/** + * The four kinds of chain step the engine knows how to preview and execute. + */ +export type ChainStepType = 'fx' | 'assetMovement' | 'forwarded' | 'keetaSend'; + +/** + * Which side of a step is known going into a preview: the input value (driven + * from upstream, affinity `from`) or the output value (pulled from downstream, + * affinity `to`). + */ +export type PreviewKnownValue = + | { side: 'in'; value: bigint } + | { side: 'out'; value: bigint }; + +/** + * A single side-effect-free step estimate. Carries projected amounts and a + * per-leg output floor (`minOutput`); it never references an initiated + * transfer or created exchange. + */ +export interface PreviewStep { + type: ChainStepType; + index: number; + providerID: string | null; + from: AnchorChainingAssetAndLocation; + to: AnchorChainingAssetAndLocation; + estimatedValueIn: bigint; + estimatedValueOut: bigint; + /** + * Minimum acceptable delivered output for this leg. Execution aborts before + * an irreversible send when the actual output would fall below this. + */ + minOutput: bigint; +} + +/** + * The full side-effect-free preview of a path: per-step estimates, projected + * totals, and the chain-level minimum the destination must receive. + */ +export interface AnchorChainingPreview { + affinity: 'from' | 'to'; + steps: PreviewStep[]; + totalValueIn: bigint; + totalValueOut: bigint; + minDestinationValue: bigint; +} + +interface ExecutedStepBase { + type: Type; + index: number; + /** + * The leg's pre-execution estimate, for reference against the actual values. + */ + preview: PreviewStep; + /** + * Value actually driven into the leg (the prior leg's real output). + */ + actualValueIn: bigint; + /** + * Value the leg actually delivered. + */ + actualValueOut: bigint; +} + +export interface ExecutedStepFX extends ExecutedStepBase<'fx'> { + exchange: FXExchange; +} + +export interface ExecutedStepAssetMovement extends ExecutedStepBase<'assetMovement'> { + transfer: AssetMovementTransfer; +} + +export interface ExecutedStepForwarded extends ExecutedStepBase<'forwarded'> { + observedTransaction: KeetaAssetMovementTransaction; +} + +export interface ExecutedStepKeetaSend extends ExecutedStepBase<'keetaSend'> { + sendBlockHash?: string | undefined; +} + +export type ExecutedStep = ExecutedStepFX | ExecutedStepAssetMovement | ExecutedStepForwarded | ExecutedStepKeetaSend; + +/** + * The terminal result of a successful execution. + */ +export interface AnchorChainingPathExecuteResult { + steps: ExecutedStep[]; + correlationID: string; + totalValueIn: bigint; + totalValueOut: bigint; +} + +/** + * Options for a single {@link execute} invocation. + */ +export interface AnchorChainingPathExecuteOptions { + requireSendAuth?: boolean; + abortSignal?: AbortSignal; + /** + * Stable correlation id for idempotency and resume. Generated when omitted. + */ + correlationID?: string; + /** + * Per-poll interval and overall deadline for settlement polling. + */ + pollIntervalMs?: number; + pollTimeoutMs?: number; +} + +/** + * The externally-observable execution state machine. + */ +export type AnchorChainingPathState = + | { status: 'idle' } + | { status: 'executing'; completedSteps: ExecutedStep[]; currentStepIndex: number } + | { status: 'completed'; result: AnchorChainingPathExecuteResult } + | { status: 'failed'; error: Error; completedSteps: ExecutedStep[]; failedAtStepIndex: number }; + +interface StepNeededActionEventPayloadBase { + type: ActionType; + + markCompleted: (...args: CompletedPayload) => void; + markFailed: (error?: unknown) => void; + + action: ActionPayload; +} + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface StepNeededActionEventAssetMovement extends StepNeededActionEventPayloadBase<'assetMovementUserExecutionRequired', { assetMovementTransfer: AssetMovementTransfer; }, []> {} + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface StepNeededActionEventKeetaSend extends StepNeededActionEventPayloadBase<'keetaSendAuthRequired', { + sendToAddress: GenericAccount; + value: bigint; + token: TokenAddress; + external?: string; +}, [ { sent: boolean | BlockHash; } ]> {} + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface StepNeededActionEventUnderDelivery extends StepNeededActionEventPayloadBase<'underDeliveryReview', { + index: number; + expectedOutput: bigint; + actualOutput: bigint; + minimumOutput: bigint; +}, [ { proceed: boolean; } ]> {} + +export type StepNeededActionEventPayload = StepNeededActionEventKeetaSend | StepNeededActionEventAssetMovement | StepNeededActionEventUnderDelivery; + +export type AnchorChainingPathEventMap = { + stateChange: [state: AnchorChainingPathState]; + stepExecuted: [step: ExecutedStep, index: number]; + completed: [result: AnchorChainingPathExecuteResult]; + failed: [error: Error, completedSteps: ExecutedStep[], failedAtStepIndex: number]; + stepNeedsAction: [StepNeededActionEventPayload]; +}; diff --git a/src/lib/index.ts b/src/lib/index.ts index 78b8dad9..24faf5d2 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -42,6 +42,26 @@ export type { UserHistoryConfig, UserHistoryListOptions } from './history.js'; +export { + AnchorChaining, + AnchorChainingPath, + AnchorChainingPlan, + AnchorGraph, + AnchorChainingError, + AnchorChainingStoreMemory +} from './chaining/index.js'; +export type { + AnchorChainingConfig, + AnchorChainingPathInput, + AnchorChainingPathExecuteOptions, + AnchorChainingPathExecuteResult, + AnchorChainingPathState, + AnchorChainingFullPlanResult, + AnchorChainingStore, + AnchorChainingErrorCode, + ComputePlanOptions, + ExecutionState +} from './chaining/index.js'; export { Certificates, EncryptedContainer, From 501e57c58d03a0f26d488ef373aaf5b9a45076e6 Mon Sep 17 00:00:00 2001 From: Tanveer Wahid Date: Thu, 25 Jun 2026 09:14:36 -0700 Subject: [PATCH 2/3] fix(sonar): issues --- src/lib/chaining/execution.ts | 65 ++++++++++++++++-------- src/lib/chaining/facade.ts | 64 ++++++++++++++++------- src/lib/chaining/fixtures.ts | 27 +++++----- src/lib/chaining/graph.ts | 10 ++-- src/lib/chaining/plan.ts | 63 ++++++++++++----------- src/lib/chaining/steps/asset-movement.ts | 4 +- src/lib/chaining/steps/context.ts | 9 ++-- src/lib/chaining/steps/fx.ts | 3 +- src/lib/chaining/store.ts | 3 +- src/lib/chaining/types.ts | 3 +- 10 files changed, 145 insertions(+), 106 deletions(-) diff --git a/src/lib/chaining/execution.ts b/src/lib/chaining/execution.ts index 8ee44d0b..e758804e 100644 --- a/src/lib/chaining/execution.ts +++ b/src/lib/chaining/execution.ts @@ -17,7 +17,7 @@ import type { FiatPushRails, KeetaPersistentForwardingAddressDetails, SimulatedA import type { Logger } from '../log/index.js'; import type { AnchorChainingStore, ChainingStepRecord, ExecutionState } from './store.js'; import type { StepContext } from './steps/context.js'; -import type { ResolvedRecipient, StepRunInput, WithdrawRef } from './steps/run.js'; +import type { PollSettings, ResolvedRecipient, StepRunInput, WithdrawRef } from './steps/run.js'; import { AnchorChainingError } from './errors.js'; import { convertAssetLocationToString } from '../../services/asset-movement/common.js'; import { isFiatRail } from '../../services/asset-movement/common.generated.js'; @@ -179,7 +179,7 @@ export class AnchorChainingExecution { sendToAddress: KeetaNet.lib.Account.toAccount(args.to), value: args.value, token: KeetaNet.lib.Account.toAccount(args.token).assertKeyType(KeetaNet.lib.Account.AccountKeyAlgorithm.TOKEN), - ...(args.external !== undefined ? { external: args.external } : {}) + ...(args.external === undefined ? {} : { external: args.external }) } }); } @@ -207,7 +207,7 @@ export class AnchorChainingExecution { } const step = this.#ctx.path[index]; - if (!step || step.type !== 'assetMovement') { + if (step?.type !== 'assetMovement') { throw(new AnchorChainingError('INVALID_PATH', `Step ${index} is not an asset-movement step`)); } @@ -260,15 +260,13 @@ export class AnchorChainingExecution { } } - if (!persistentAddress) { - persistentAddress = await provider.createPersistentForwardingAddress({ - account: signer, - sourceLocation: step.from.location, - destinationLocation: step.to.location, - destinationAddress, - asset: assetPair - }); - } + persistentAddress ??= await provider.createPersistentForwardingAddress({ + account: signer, + sourceLocation: step.from.location, + destinationLocation: step.to.location, + destinationAddress, + asset: assetPair + }); if (typeof persistentAddress.address !== 'string') { throw(new AnchorChainingError('INVALID_STATE', `Persistent forwarding address for step ${index} is not a resolved string`)); @@ -284,7 +282,7 @@ export class AnchorChainingExecution { */ async #resolveRecipient(index: number, actualInput: bigint): Promise { const step = this.#ctx.path[index]; - if (!step || step.type !== 'assetMovement') { + if (step?.type !== 'assetMovement') { throw(new AnchorChainingError('INVALID_PATH', `Step ${index} is not an asset-movement step`)); } @@ -442,15 +440,37 @@ export class AnchorChainingExecution { } /** - * The shared actual-driven loop used by both {@link execute} and - * {@link resume}. + * Derive the settlement-poll cadence and deadline for a run. */ - async #drive(state: ExecutionState, options: AnchorChainingPathExecuteOptions): Promise { - const poll = { + #buildPoll(options: AnchorChainingPathExecuteOptions): PollSettings { + return({ intervalMs: options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS, timeoutMs: options.pollTimeoutMs ?? DEFAULT_POLL_TIMEOUT_MS, ...(options.abortSignal ? { abortSignal: options.abortSignal } : {}) - }; + }); + } + + /** + * Recover an already-settled leg on resume, yielding its actual output and + * the withdraw it produced so the loop can drive the next leg forward + * without re-performing irreversible work. Returns `null` when the leg has + * not settled and must be executed. + */ + #resumeSettledStep(record: ChainingStepRecord): { output: bigint; prevWithdrawTx: WithdrawRef | null } | null { + if (record.status !== 'settled' || record.actualOutput === undefined) { + return(null); + } + + const prevWithdrawTx = record.withdraw ? { location: record.withdraw.location, transaction: { id: record.withdraw.id }} : null; + return({ output: BigInt(record.actualOutput), prevWithdrawTx }); + } + + /** + * The shared actual-driven loop used by both {@link execute} and + * {@link resume}. + */ + async #drive(state: ExecutionState, options: AnchorChainingPathExecuteOptions): Promise { + const poll = this.#buildPoll(options); const executedSteps: ExecutedStep[] = []; this.#setState({ status: 'executing', completedSteps: [], currentStepIndex: state.currentStepIndex }); @@ -476,10 +496,11 @@ export class AnchorChainingExecution { throw(new AnchorChainingError('STEP_NOT_DEFINED', `Step record ${index} is not defined`)); } - if (record.status === 'settled' && record.actualOutput !== undefined) { - actualInput = BigInt(record.actualOutput); - lastActualOutput = actualInput; - prevWithdrawTx = record.withdraw ? { location: record.withdraw.location, transaction: { id: record.withdraw.id }} : null; + const resumed = this.#resumeSettledStep(record); + if (resumed) { + actualInput = resumed.output; + lastActualOutput = resumed.output; + prevWithdrawTx = resumed.prevWithdrawTx; continue; } diff --git a/src/lib/chaining/facade.ts b/src/lib/chaining/facade.ts index 5527f565..b23485c9 100644 --- a/src/lib/chaining/facade.ts +++ b/src/lib/chaining/facade.ts @@ -131,8 +131,20 @@ export class AnchorChaining implements ChainingHost { } const limit = options?.limit ?? DEFAULT_PLAN_LIMIT; - const sortedPaths = paths.sort((a, b) => a.path.length - b.path.length); + const sortedPaths = [...paths]; + sortedPaths.sort((a, b) => a.path.length - b.path.length); + const allOutput = await this.#collectPlanAttempts(sortedPaths, limit, options); + return(this.#materializePlans(allOutput, sortedPaths, options)); + } + + /** + * Attempt plan computation over the shortest paths first, in batches, until + * `limit` plans succeed, no shorter path remains worth trying, or the + * attempt-loop guard trips. Returns every settled outcome attempted, in + * attempt order, for the caller to materialize. + */ + async #collectPlanAttempts(sortedPaths: AnchorChainingPath[], limit: number, options?: ComputePlanOptions & { includeAllOutput?: boolean }): Promise[]> { const allOutput: PromiseSettledResult[] = []; let successCount = 0; let lowestStepsSuccessCount = Infinity; @@ -150,24 +162,42 @@ export class AnchorChaining implements ChainingHost { const currentTry = await Promise.allSettled(pathsToTry.map(path => AnchorChainingPlan.create(path, options))); allOutput.push(...currentTry); - for (let i = 0; i < currentTry.length; i++) { - const result = currentTry[i]; - const path = pathsToTry[i]; - if (!result || !path) { - continue; - } + const tally = this.#tallyBatchSuccesses(currentTry, pathsToTry); + successCount += tally.successCount; + lowestStepsSuccessCount = Math.min(lowestStepsSuccessCount, tally.minSuccessSteps); + lastAttemptedPathIdx += pathsToTry.length; + } - if (result.status === 'fulfilled') { - successCount++; - if (path.path.length < lowestStepsSuccessCount) { - lowestStepsSuccessCount = path.path.length; - } - } + return(allOutput); + } + + /** + * Count the fulfilled plans in one attempt batch and the fewest steps among + * them, so the attempt loop can stop once longer paths cannot improve. + */ + #tallyBatchSuccesses(results: PromiseSettledResult[], paths: AnchorChainingPath[]): { successCount: number; minSuccessSteps: number } { + let successCount = 0; + let minSuccessSteps = Infinity; + for (let i = 0; i < results.length; i++) { + const result = results[i]; + const path = paths[i]; + if (!result || !path || result.status !== 'fulfilled') { + continue; } - lastAttemptedPathIdx += pathsToTry.length; + successCount++; + minSuccessSteps = Math.min(minSuccessSteps, path.path.length); } + return({ successCount, minSuccessSteps }); + } + + /** + * Pair each settled attempt with its path. With `includeAllOutput`, both + * failures and successes are returned; otherwise failures are logged and + * dropped and only the computed plans are returned. + */ + #materializePlans(allOutput: PromiseSettledResult[], sortedPaths: AnchorChainingPath[], options?: ComputePlanOptions & { includeAllOutput?: boolean }): (AnchorChainingPlan | AnchorChainingFullPlanResult)[] { const ret: (AnchorChainingPlan | AnchorChainingFullPlanResult)[] = []; for (let i = 0; i < allOutput.length; i++) { const path = sortedPaths[i]; @@ -177,11 +207,7 @@ export class AnchorChaining implements ChainingHost { } if (options?.includeAllOutput) { - if (plan.status === 'rejected') { - ret.push({ success: false, error: plan.reason, path }); - } else { - ret.push({ success: true, plan: plan.value, path }); - } + ret.push(plan.status === 'rejected' ? { success: false, error: plan.reason, path } : { success: true, plan: plan.value, path }); } else if (plan.status === 'rejected') { this.logger?.debug(`AnchorChaining::getPlans`, `Error computing plan for a path:`, plan.reason); } else { diff --git a/src/lib/chaining/fixtures.ts b/src/lib/chaining/fixtures.ts index f5a732df..d0da6c29 100644 --- a/src/lib/chaining/fixtures.ts +++ b/src/lib/chaining/fixtures.ts @@ -1,4 +1,5 @@ import type { GenericAccount, TokenAddress } from '@keetanetwork/keetanet-client/lib/account.js'; +import { randomUUID } from 'node:crypto'; import type { KeetaAnchorAssetMovementServerConfig } from '../../services/asset-movement/server.js'; import type { AnchorTokenLocationMetadata, AssetLocationLike, KeetaAssetMovementTransaction, KeetaPersistentForwardingAddressDetails } from '../../services/asset-movement/common.js'; @@ -175,7 +176,7 @@ export function buildTxRecord(args: { export class TestBankServer extends KeetaNetAssetMovementAnchorHTTPServer { private readonly _initiateRef: { fn: InitiateTransferFn }; - #defaultInitiateRef: { fn: InitiateTransferFn; }; + readonly #defaultInitiateRef: { fn: InitiateTransferFn; }; private readonly _statusMap: Map; private readonly _getStatusRef: { interceptor: (() => void) | null }; @@ -196,7 +197,7 @@ export class TestBankServer extends KeetaNetAssetMovementAnchorHTTPServer { const value = BigInt(request.value); const fee = 10n; const receive = value - fee; - const txId = `tx-${Date.now()}-${Math.random().toString(36).slice(2)}`; + const txId = `tx-${randomUUID()}`; const parsedFrom = toAssetLocation(request.from.location); if (parsedFrom.type === 'chain' && parsedFrom.chain.type === 'keeta') { @@ -234,7 +235,7 @@ export class TestBankServer extends KeetaNetAssetMovementAnchorHTTPServer { const tokenAddress = assetPair.from; if (typeof tokenAddress !== 'string') { - throw(new Error('invalid keeta send asset')); + throw(new TypeError('invalid keeta send asset')); } return({ @@ -316,9 +317,7 @@ export class TestBankServer extends KeetaNetAssetMovementAnchorHTTPServer { } setInitiateTransfer(fn: InitiateTransferFn | null): this { - if (!fn) { - fn = this.#defaultInitiateRef.fn; - } + fn ??= this.#defaultInitiateRef.fn; this._initiateRef.fn = fn; @@ -337,7 +336,7 @@ export class TestBankServer extends KeetaNetAssetMovementAnchorHTTPServer { return(this.setInitiateTransfer(async (request) => { const value = BigInt(request.value); const receive = value - fee; - const txId = `tx-${Date.now()}-${Math.random().toString(36).slice(2)}`; + const txId = `tx-${randomUUID()}`; this._statusMap.set(txId, buildTxRecord({ id: txId, status: 'COMPLETE', @@ -349,13 +348,13 @@ export class TestBankServer extends KeetaNetAssetMovementAnchorHTTPServer { })); if (typeof request.to.recipient !== 'string') { - throw(new Error('invalid keeta send recipient')); + throw(new TypeError('invalid keeta send recipient')); } const assetPair = toAssetPair(request.asset); const tokenAddress = assetPair.from; if (typeof tokenAddress !== 'string') { - throw(new Error('invalid keeta send asset')); + throw(new TypeError('invalid keeta send asset')); } return({ @@ -566,7 +565,7 @@ export class TestPersistentForwardingBridgeServer extends KeetaNetAssetMovementA } const value = BigInt(request.value); - const txId = `tx-${Date.now()}-${Math.random().toString(36).slice(2)}`; + const txId = `tx-${randomUUID()}`; const recipientAddress = typeof request.to.recipient === 'string' ? request.to.recipient : ''; transferStatuses.set(txId, buildTxRecord({ @@ -611,7 +610,7 @@ export class TestPersistentForwardingBridgeServer extends KeetaNetAssetMovementA if (meta) { const list = transactionsByAddress.get(recipientAddress) ?? []; const forwarded = buildTxRecord({ - id: `persistentForwarding-tx-${Date.now()}-${Math.random().toString(36).slice(2)}`, + id: `persistentForwarding-tx-${randomUUID()}`, status: 'COMPLETE', asset: meta.asset, fromLocation: convertAssetLocationToString(meta.sourceLocation), @@ -637,7 +636,7 @@ export class TestPersistentForwardingBridgeServer extends KeetaNetAssetMovementA const tokenAddress = toAssetPair(request.asset).from; if (typeof tokenAddress !== 'string') { - throw(new Error('invalid keeta send asset')); + throw(new TypeError('invalid keeta send asset')); } return({ @@ -672,7 +671,7 @@ export class TestPersistentForwardingBridgeServer extends KeetaNetAssetMovementA const value = BigInt(request.value); const tokenAddress = toAssetPair(request.asset).from; if (typeof tokenAddress !== 'string') { - throw(new Error('invalid asset for simulate')); + throw(new TypeError('invalid asset for simulate')); } const parsedFrom = toAssetLocation(request.from.location); @@ -721,7 +720,7 @@ export class TestPersistentForwardingBridgeServer extends KeetaNetAssetMovementA throw(new KeetaAnchorUserError('Test bridge only supports string destinationAddress for persistent forwarding')); } - const address = `persistentForwarding-${Math.random().toString(36).slice(2)}`; + const address = `persistentForwarding-${randomUUID()}`; const meta: PersistentForwardingBridgeAddressMeta = { sourceLocation: request.sourceLocation, destinationLocation: request.destinationLocation, diff --git a/src/lib/chaining/graph.ts b/src/lib/chaining/graph.ts index 73837080..8be41a79 100644 --- a/src/lib/chaining/graph.ts +++ b/src/lib/chaining/graph.ts @@ -71,7 +71,7 @@ export class AnchorGraph { }); } - #assetLocationKey = (side: { asset: AnchorChainingAsset; location: AssetLocationLike }) => { + readonly #assetLocationKey = (side: { asset: AnchorChainingAsset; location: AssetLocationLike }) => { return(`${convertAssetSearchInputToCanonical(side.asset)}@${convertAssetLocationToString(side.location)}`); }; @@ -98,9 +98,7 @@ export class AnchorGraph { continue; } - if (!retval) { - retval = {}; - } + retval ??= {}; if (!retval[node.providerID]) { const provider = await this.getAssetMovementProviderById(node.providerID); @@ -228,7 +226,7 @@ export class AnchorGraph { const railResolved = await assetInput('string'); if (!isRail(railResolved)) { - throw(new Error(`Invalid rail format: ${railResolved}`)); + throw(new Error(`Invalid rail format: ${JSON.stringify(railResolved)}`)); } return({ rail: railResolved }); @@ -672,7 +670,7 @@ export class AnchorGraph { asset: side.asset, location: side.location, rails: { inbound: [], outbound: [] }, - distance: distanceValue !== undefined ? { pathLength: distanceValue } : null + distance: distanceValue === undefined ? null : { pathLength: distanceValue } }; resultMap.set(key, resultObj); diff --git a/src/lib/chaining/plan.ts b/src/lib/chaining/plan.ts index 447441cc..c4d3d75e 100644 --- a/src/lib/chaining/plan.ts +++ b/src/lib/chaining/plan.ts @@ -16,12 +16,13 @@ import type { PreviewStep, ProviderDisclaimers } from './types.js'; -import { AnchorChainingError } from './errors.js'; +import type { StepExecutor } from './steps/executor.js'; import type { StepContext } from './steps/context.js'; +import type { AnchorChainingStore } from './store.js'; +import { AnchorChainingError } from './errors.js'; import { classifyForwardedSteps } from './steps/context.js'; import { createStepExecutor } from './steps/executor.js'; import { AnchorChainingExecution } from './execution.js'; -import type { AnchorChainingStore } from './store.js'; import { AnchorChainingStoreMemory } from './store.js'; /** @@ -136,7 +137,7 @@ export class AnchorChainingPath { } const key = `${step.type}:${step.providerID}`; - if (legalDisclaimerPromises.find(entry => entry.key === key)) { + if (legalDisclaimerPromises.some(entry => entry.key === key)) { continue; } @@ -249,6 +250,32 @@ export class AnchorChainingPlan extends AnchorChainingPath { return(await this.#getExecution().resume(correlationID, options)); } + /** + * Resolve every leg's side-effect-free estimate, threading the known value + * along the path. Affinity `from` drives input forward (leg 0 to last); + * affinity `to` drives output backward (last to leg 0), each feeding the + * next leg's known side. + */ + async #resolvePreviewSteps(executors: StepExecutor[], ctx: StepContext): Promise> { + const resolved = new Map(); + const forward = ctx.affinity === 'from'; + const indices = executors.map((_, offset) => forward ? offset : executors.length - 1 - offset); + + let known: PreviewKnownValue = { side: forward ? 'in' : 'out', value: ctx.affinityAmount }; + for (const index of indices) { + const executor = executors[index]; + if (!executor) { + throw(new AnchorChainingError('STEP_NOT_DEFINED', `Step ${index} is not defined`)); + } + + const step = await executor.preview(known); + resolved.set(index, step); + known = forward ? { side: 'in', value: step.estimatedValueOut } : { side: 'out', value: step.estimatedValueIn }; + } + + return(resolved); + } + async #computePreview(): Promise { const ctx = this.buildContext(this.#options); @@ -257,33 +284,7 @@ export class AnchorChainingPlan extends AnchorChainingPath { } const executors = this.path.map((_, index) => createStepExecutor(ctx, index)); - const resolved = new Map(); - - if (ctx.affinity === 'from') { - let known: PreviewKnownValue = { side: 'in', value: ctx.affinityAmount }; - for (let index = 0; index < executors.length; index++) { - const executor = executors[index]; - if (!executor) { - throw(new AnchorChainingError('STEP_NOT_DEFINED', `Step ${index} is not defined`)); - } - - const step = await executor.preview(known); - resolved.set(index, step); - known = { side: 'in', value: step.estimatedValueOut }; - } - } else { - let known: PreviewKnownValue = { side: 'out', value: ctx.affinityAmount }; - for (let index = executors.length - 1; index >= 0; index--) { - const executor = executors[index]; - if (!executor) { - throw(new AnchorChainingError('STEP_NOT_DEFINED', `Step ${index} is not defined`)); - } - - const step = await executor.preview(known); - resolved.set(index, step); - known = { side: 'out', value: step.estimatedValueIn }; - } - } + const resolved = await this.#resolvePreviewSteps(executors, ctx); const previewSteps: PreviewStep[] = []; for (let index = 0; index < executors.length; index++) { @@ -295,7 +296,7 @@ export class AnchorChainingPlan extends AnchorChainingPath { } const firstStep = previewSteps[0]; - const lastStep = previewSteps[previewSteps.length - 1]; + const lastStep = previewSteps.at(-1); if (!firstStep || !lastStep) { throw(new AnchorChainingError('INVALID_PATH', `Preview produced no steps`)); } diff --git a/src/lib/chaining/steps/asset-movement.ts b/src/lib/chaining/steps/asset-movement.ts index 332c2988..8283fbce 100644 --- a/src/lib/chaining/steps/asset-movement.ts +++ b/src/lib/chaining/steps/asset-movement.ts @@ -186,9 +186,7 @@ export class AssetMovementStep { let sentBlockHash = input.record.sendBlockHash; if (sentBlockHash === undefined) { let external = usingInstruction.external; - if (external === undefined) { - external = await buildKeetaSendExternal(provider, transfer.transferID, input.publishedInputs); - } + external ??= await buildKeetaSendExternal(provider, transfer.transferID, input.publishedInputs); sentBlockHash = await input.authorizedSend({ to: usingInstruction.sendToAddress, diff --git a/src/lib/chaining/steps/context.ts b/src/lib/chaining/steps/context.ts index 34cc3e38..32ac8c3c 100644 --- a/src/lib/chaining/steps/context.ts +++ b/src/lib/chaining/steps/context.ts @@ -1,4 +1,3 @@ -import type { lib as KeetaNetLib } from '@keetanetwork/keetanet-client'; import type * as KeetaNet from '@keetanetwork/keetanet-client'; import type { Resolver } from '../../index.js'; @@ -64,8 +63,8 @@ export async function resolveAccountLike( client: KeetaNet.UserClient, action: GetAccountForActionPayload, override?: AnchorChainingAccountOverrides['account'] -): Promise> { - let found: InstanceType | undefined = undefined; +): Promise> { + let found: InstanceType | undefined = undefined; if (client.account.isAccount()) { found = client.account; @@ -95,7 +94,7 @@ export async function resolveAccountsForAction( client: KeetaNet.UserClient, action: GetAccountForActionPayload, overrides?: AnchorChainingAccountOverrides -): Promise<{ account: InstanceType; signer: InstanceType }> { +): Promise<{ account: InstanceType; signer: InstanceType }> { const [signer, account] = await Promise.all([ resolveAccountLike(client, action, overrides?.signer), resolveAccountLike(client, action, overrides?.account) @@ -113,7 +112,7 @@ export function classifyForwardedSteps(path: AnchorChainingStepLike[]): Set(); for (let index = 0; index < path.length; index++) { const step = path[index]; - if (!step || step.type !== 'assetMovement') { + if (step?.type !== 'assetMovement') { continue; } diff --git a/src/lib/chaining/steps/fx.ts b/src/lib/chaining/steps/fx.ts index 3f0df75f..daf62dcc 100644 --- a/src/lib/chaining/steps/fx.ts +++ b/src/lib/chaining/steps/fx.ts @@ -1,6 +1,5 @@ -import type { ExecutedStep, FXGraphNode, PreviewKnownValue, PreviewStep } from '../types.js'; +import type { ExecutedStep, FXGraphNode, FXQuoteOrEstimate, PreviewKnownValue, PreviewStep } from '../types.js'; import type { StepContext } from './context.js'; -import type { FXQuoteOrEstimate } from '../types.js'; import type { StepRunInput, StepRunResult } from './run.js'; import type { PublishedInputRecord } from '../store.js'; import { applySlippage, resolveAccountsForAction } from './context.js'; diff --git a/src/lib/chaining/store.ts b/src/lib/chaining/store.ts index f99c781b..afd5e9e8 100644 --- a/src/lib/chaining/store.ts +++ b/src/lib/chaining/store.ts @@ -141,8 +141,7 @@ export interface AnchorChainingStore { * serializable contract callers rely on for durability. */ function cloneState(state: ExecutionState): ExecutionState { - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - return(JSON.parse(JSON.stringify(state)) as ExecutionState); + return(structuredClone(state)); } /** diff --git a/src/lib/chaining/types.ts b/src/lib/chaining/types.ts index add565e0..ab8c7e10 100644 --- a/src/lib/chaining/types.ts +++ b/src/lib/chaining/types.ts @@ -1,12 +1,11 @@ import type { lib as KeetaNetLib } from '@keetanetwork/keetanet-client'; import * as KeetaNet from '@keetanetwork/keetanet-client'; -import type { AnchorTokenLocationMetadata, AssetLocationLike, PickChainLocation, Rail, RecipientResolved } from '../../services/asset-movement/common.js'; +import type { AnchorTokenLocationMetadata, AssetLocationLike, KeetaAssetMovementTransaction, PickChainLocation, Rail, RecipientResolved } from '../../services/asset-movement/common.js'; import { convertAssetLocationToString } from '../../services/asset-movement/common.js'; import type { Resolver } from '../index.js'; import type { ISOCurrencyCode } from '@keetanetwork/currency-info'; import type { Account, GenericAccount, TokenAddress } from '@keetanetwork/keetanet-client/lib/account.js'; import type { BlockHash } from '@keetanetwork/keetanet-client/lib/block/index.js'; -import type { KeetaAssetMovementTransaction } from '../../services/asset-movement/common.js'; import type KeetaFXAnchorClient from '../../services/fx/client.js'; import type KeetaAssetMovementAnchorClient from '../../services/asset-movement/client.js'; import type { ExternalChainAsset } from '../asset.js'; From 98d964dca021bd6b4569daee252f5fe2b37f6f2e Mon Sep 17 00:00:00 2001 From: Tanveer Wahid Date: Thu, 25 Jun 2026 10:12:18 -0700 Subject: [PATCH 3/3] fix(history): logical merge on list --- src/lib/history.test.ts | 23 ++++++++++------------- src/lib/history.ts | 14 +++++++++++--- 2 files changed, 21 insertions(+), 16 deletions(-) diff --git a/src/lib/history.test.ts b/src/lib/history.test.ts index 2f98beb8..340e0811 100644 --- a/src/lib/history.test.ts +++ b/src/lib/history.test.ts @@ -1372,22 +1372,19 @@ test('surfaces the on-chain input a later FX swap declares and folds the chain i const transactions = await fixture.history.list(fixture.userAccount); const swaps = transactions.filter(transaction => transaction.type === 'swap'); - expect(swaps).toHaveLength(2); + expect(swaps).toHaveLength(1); + + const swap = swaps[0]; + expect(swap?.refs.inputs?.some(input => input.blockHash === fixture.firstBlockHash)).toBe(true); + expect(swap?.refs.blockHashes.includes(fixture.firstBlockHash)).toBe(true); + expect(swap?.send).toEqual({ token: fixture.usdToken, amount: 100n }); + expect(swap?.receive).toEqual({ token: fixture.eurToken, amount: 88n }); /* - * The producer wrote the first hop's settled block into the second hop's - * external; enrichment surfaces it, and it matches the first hop's block - * reference -- the contract the chaining plan and foldChains rely on. + * Re-folding an already-folded result is a no-op. */ - const linked = swaps.find(transaction => transaction.refs.inputs?.some(input => input.blockHash === fixture.firstBlockHash)); - expect(linked).toBeDefined(); - expect(swaps.some(transaction => transaction.refs.blockHashes.includes(fixture.firstBlockHash))).toBe(true); - - const folded = foldChains(transactions); - const foldedSwaps = folded.filter(transaction => transaction.type === 'swap'); - expect(foldedSwaps).toHaveLength(1); - expect(foldedSwaps[0]?.send).toEqual({ token: fixture.usdToken, amount: 100n }); - expect(foldedSwaps[0]?.receive).toEqual({ token: fixture.eurToken, amount: 88n }); + const refolded = foldChains(transactions).filter(transaction => transaction.type === 'swap'); + expect(refolded).toHaveLength(1); }); // #endregion FX diff --git a/src/lib/history.ts b/src/lib/history.ts index d02079a5..667b6054 100644 --- a/src/lib/history.ts +++ b/src/lib/history.ts @@ -1422,15 +1422,23 @@ export class UserHistory { } /** - * Fetch, enrich and fold a user's history into logical transactions. + * Fetch, enrich and fold a user's history into logical transactions, + * grouping linked multi-hop chains into single conversions. */ async list(account: HistoryAccount, options?: UserHistoryListOptions): Promise { + const { limit, ...collectOptions } = options ?? {}; + const transactions: LogicalTransaction[] = []; - for await (const transaction of this.iterate(account, options)) { + for await (const transaction of this.iterate(account, collectOptions)) { transactions.push(transaction); } - return(transactions); + const folded = foldChains(transactions); + if (limit !== undefined) { + return(folded.slice(0, limit)); + } + + return(folded); } /**