From b8c49e77b97c2d1ceb88f12032cc03bd20a57685 Mon Sep 17 00:00:00 2001 From: bogdanblazhkevych Date: Wed, 6 May 2026 16:12:22 -0400 Subject: [PATCH 1/9] Feature / use account option in FX and AnchorChaining --- src/lib/chaining.test.ts | 100 +++++++++++++++++++++++++++++++++ src/lib/chaining.ts | 34 ++++++++--- src/services/fx/client.test.ts | 58 +++++++++++++++++++ src/services/fx/client.ts | 14 +++-- 4 files changed, 191 insertions(+), 15 deletions(-) diff --git a/src/lib/chaining.test.ts b/src/lib/chaining.test.ts index 38a60943..97f76ac9 100644 --- a/src/lib/chaining.test.ts +++ b/src/lib/chaining.test.ts @@ -1090,6 +1090,106 @@ describe('AnchorChainingPath execute', function() { 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 storageReceiveTokenBalancePre = await h.client.balance(h.tokens.EURC, { account: storageAccount }); + + // Same location FX: storage accounts on AM are not supported + const paths = await h.anchorChaining.getPlans({ + source: { asset: h.tokens.USDC, location: h.keetaLocation, value: 100n, rail: 'KEETA_SEND' }, + destination: { asset: h.tokens.EURC, location: h.keetaLocation, recipient: h.client.account.publicKeyString.get(), rail: 'KEETA_SEND' } + }, { overrides: { account: storageAccount }}); + + const path = paths?.[0]; + if (!path) { + throw(new Error(`No path found`)); + } + + 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(88n); + + const result = await path.execute(); + + // Step structure and server-side verification + expect(result.steps.length).toEqual(1); + const [step0] = 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(); + } + } + + // 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); + const storageReceiveTokenBalancePost = await h.client.balance(h.tokens.EURC, { account: storageAccount }); + + expect(storageSendTokenBalancePre - storageSendTokenBalancePost).toEqual(100n); + expect(storageReceiveTokenBalancePost - storageReceiveTokenBalancePre).toEqual(88n); + 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); diff --git a/src/lib/chaining.ts b/src/lib/chaining.ts index 52144c92..e5044251 100644 --- a/src/lib/chaining.ts +++ b/src/lib/chaining.ts @@ -1,4 +1,4 @@ -import type { lib as KeetaNetLib } from '@keetanetwork/keetanet-client'; +import { type lib as KeetaNetLib } from '@keetanetwork/keetanet-client'; import { KeetaNet } from "../client/index.js"; import type { AssetLocationLike, AssetTransferInstructions, AssetWithRails, FiatPushRails, MovableAssetSearchCanonical, PickChainLocation, Rail, RailOrRailWithExtendedDetails, RecipientResolved } from "../services/asset-movement/common.js"; import { convertAssetLocationToString, convertAssetSearchInputToCanonical, isChainLocation, toAssetLocation } from "../services/asset-movement/common.js"; @@ -53,6 +53,7 @@ type AnchorChainingPathComputedPlan = { steps: ChainStepResolution[]; totalValueIn: bigint; totalValueOut: bigint; + options?: ComputePlanOptions | undefined; }; type ExecutedStepFX = { @@ -277,11 +278,14 @@ export interface AnchorChainingAssetInfo { type GetAccountForActionPayload = { type: 'assetMovement'; providerMethod: 'initiateTransfer'; - provider: AssetMovementProvider; + provider?: AssetMovementProvider; +} | { + type: 'fx'; + providerMethod: 'getAccountForAction'; } interface AnchorChainingAccountOverrides { - account?: Account | undefined | ((providerMethodPayload: GetAccountForActionPayload) => Promise | Account); + account?: InstanceType | undefined | ((providerMethodPayload: GetAccountForActionPayload) => Promise | Account); } class AnchorGraph { @@ -867,7 +871,7 @@ class AnchorGraph { } } -interface ComputePlanOptions { +export interface ComputePlanOptions { overrides?: AnchorChainingAccountOverrides; } @@ -886,8 +890,8 @@ export class AnchorChainingPath { this.parent = input.parent; } - protected async getAccountForAction(action: GetAccountForActionPayload, overrides?: AnchorChainingAccountOverrides): Promise { - let found; + protected async getAccountForAction(action: GetAccountForActionPayload, overrides?: AnchorChainingAccountOverrides): Promise> { + let found: InstanceType | undefined = undefined; if (this.parent['client'].account.isAccount()) { found = this.parent['client'].account; @@ -903,6 +907,10 @@ export class AnchorChainingPath { } } + if (!found) { + throw(new Error(`Could not get account for ${action.type} action ${action.providerMethod}`)); + } + return(found); } } @@ -1011,7 +1019,13 @@ export class AnchorChainingPlan extends AnchorChainingPath { const quotesOrEstimates = await fxClient.getQuotesOrEstimates( { from: step.from.asset, to: step.to.asset, amount, affinity }, - undefined, + { + signer: this.parent['client'].account, + account: await this.getAccountForAction({ + type: 'fx', + providerMethod: 'getAccountForAction' + }, options?.overrides) + }, { providerIDs: [ step.providerID ] } ); @@ -1253,7 +1267,8 @@ export class AnchorChainingPlan extends AnchorChainingPath { return({ steps, totalValueIn: firstStep.valueIn, - totalValueOut: lastStep.valueOut + totalValueOut: lastStep.valueOut, + options }); } @@ -1372,7 +1387,8 @@ export class AnchorChainingPlan extends AnchorChainingPath { }); } - await this.parent['client'].send(sendToAddress, value, token, external); + const account = await this.getAccountForAction({ type: 'assetMovement', providerMethod: 'initiateTransfer' }, this.#_plan?.options?.overrides); + await this.parent['client'].send(sendToAddress, value, token, external, { account: account }); } async #pollTransferStatus( diff --git a/src/services/fx/client.test.ts b/src/services/fx/client.test.ts index 6f36cc63..c480dfc8 100644 --- a/src/services/fx/client.test.ts +++ b/src/services/fx/client.test.ts @@ -1403,6 +1403,64 @@ test('FX Server Estimate to Exchange Test', async function() { expect(sendTokenBalancePre - sendTokenBalancePost).toBe(testCase.expectedChange.sendToken); } } + + { + /** Exchanges can be completed using funds from storage accounts */ + const { account: storageAccount } = await client.generateIdentifier(KeetaNet.lib.Account.AccountKeyAlgorithm.STORAGE); + + await client.setInfo({ + name: '', + description: 'Storage account with permissions from user account', + metadata: '', + defaultPermission: new KeetaNet.lib.Permissions(['STORAGE_CAN_HOLD', 'STORAGE_DEPOSIT']) + }, { account: storageAccount }); + + const request = { from: testCurrencyUSD, to: testCurrencyEUR, amount: 1000n, affinity: 'from' } as const; + const quote = { cost: { token: testCurrencyUSD, amount: 0n }, convertedAmount: 1001n } + + serverDoesNotRequireQuoteReturnValue.convertedAmount = quote.convertedAmount; + serverDoesNotRequireQuoteReturnValue.convertedAmountBound = quote.convertedAmount; + serverDoesNotRequireQuoteReturnValue.cost = quote.cost; + + await client.send(storageAccount, 1000n, testCurrencyUSD); + await client.send(storageAccount, 10n, client.baseToken); + + const estimates = await fxClient.getEstimates( + request, + { account: storageAccount }, + { providerIDs: ['TestDoesNotRequireDoesNotIssueQuote'] } + ); + + const singleEstimate = estimates?.[0]; + if (singleEstimate === undefined) { + throw(new Error('Could not get estimate from TestDoesNotRequireDoesNotIssueQuote')); + } + if (singleEstimate.estimate.canPerformExchange === false || singleEstimate.estimate.requiresQuote !== false) { + throw(new Error('Estimate should not require quote and should be able to perform exchange')); + } + + await client.send(singleEstimate.estimate.account, 10000n, testCurrencyEUR); + + const userFromTokenBalancePre = await client.balance(request.from); + const userToTokenBalancePre = await client.balance(request.to); + const storageFromTokenBalancePre = await client.balance(request.from, { account: storageAccount }); + const storageToTokenBalancePre = await client.balance(request.to, { account: storageAccount }); + + const exchange = await singleEstimate.createExchange(); + const completedStatus = await waitForExchangeToComplete(serverDoesNotRequireQuote, exchange); + expect(completedStatus.status).toBe('completed'); + + const userFromTokenBalancePost = await client.balance(request.from); + const userToTokenBalancePost = await client.balance(request.to); + const storageFromTokenBalancePost = await client.balance(request.from, { account: storageAccount }); + const storageToTokenBalancePost = await client.balance(request.to, { account: storageAccount }); + + expect(storageFromTokenBalancePre - storageFromTokenBalancePost).toBe(request.amount); + expect(storageToTokenBalancePost - storageToTokenBalancePre).toBe(quote.convertedAmount); + + expect(userFromTokenBalancePre).toBe(userFromTokenBalancePost); + expect(userToTokenBalancePre).toBe(userToTokenBalancePost); + } } }); diff --git a/src/services/fx/client.ts b/src/services/fx/client.ts index f9db7c18..33482537 100644 --- a/src/services/fx/client.ts +++ b/src/services/fx/client.ts @@ -231,9 +231,10 @@ class KeetaFXAnchorProviderBase extends KeetaFXAnchorBase { readonly serviceInfo: KeetaFXServiceInfo; readonly providerID: ProviderID; readonly conversion: ConversionInputCanonical; + readonly options: Pick | undefined; private readonly parent: KeetaFXAnchorClient; - constructor(serviceInfo: KeetaFXServiceInfo, providerID: ProviderID, conversion: ConversionInputCanonical, parent: KeetaFXAnchorClient) { + constructor(serviceInfo: KeetaFXServiceInfo, providerID: ProviderID, conversion: ConversionInputCanonical, parent: KeetaFXAnchorClient, options?: Pick) { const parentPrivate = parent._internals(KeetaFXAnchorClientAccessToken); super(parentPrivate); @@ -241,6 +242,7 @@ class KeetaFXAnchorProviderBase extends KeetaFXAnchorBase { this.providerID = providerID; this.conversion = conversion; this.parent = parent; + this.options = options } #parseConversionRequest(input: ConversionInputCanonicalJSON): ConversionInputCanonical { @@ -447,16 +449,16 @@ class KeetaFXAnchorProviderBase extends KeetaFXAnchorBase { if ('quote' in input) { /* If cost is required then send the required amount as well */ if (input.quote.cost.amount > 0) { - builder.send(liquidityProvider, input.quote.cost.amount, input.quote.cost.token); + builder.send(liquidityProvider, input.quote.cost.amount, input.quote.cost.token, undefined, this.options); } } else if ('estimate' in input) { if (input.estimate.expectedCost.max > 0) { - builder.send(liquidityProvider, input.estimate.expectedCost.max, input.estimate.expectedCost.token); + builder.send(liquidityProvider, input.estimate.expectedCost.max, input.estimate.expectedCost.token, undefined, this.options); } } - builder.receive(liquidityProvider, receiveAmount, request.to, request.affinity === 'to'); - builder.send(liquidityProvider, sendAmount, request.from); + builder.receive(liquidityProvider, receiveAmount, request.to, request.affinity === 'to', undefined, this.options); + builder.send(liquidityProvider, sendAmount, request.from, undefined, this.options); const blocks = await builder.computeBlocks(); if (blocks.blocks.length !== 1) { @@ -797,7 +799,7 @@ class KeetaFXAnchorClient extends KeetaFXAnchorBase { } const providers = typedFxServiceEntries(providerEndpoints).map(([providerID, serviceInfo]) => { - return(new KeetaFXAnchorProviderBase(serviceInfo, providerID, conversion, this)); + return(new KeetaFXAnchorProviderBase(serviceInfo, providerID, conversion, this, options)); }); return(providers); From a4e804c3c39a7e2d7a60f89d7d576d9c5b2265c3 Mon Sep 17 00:00:00 2001 From: bogdanblazhkevych Date: Thu, 7 May 2026 07:19:17 -0400 Subject: [PATCH 2/9] increased timeout for test intermittently failing in ci --- src/services/fx/client.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/fx/client.test.ts b/src/services/fx/client.test.ts index c480dfc8..07ce6512 100644 --- a/src/services/fx/client.test.ts +++ b/src/services/fx/client.test.ts @@ -1462,7 +1462,7 @@ test('FX Server Estimate to Exchange Test', async function() { expect(userToTokenBalancePre).toBe(userToTokenBalancePost); } } -}); +}, 10_000); test('FX Server Pricing test', async function() { const userAccount = KeetaNet.lib.Account.fromSeed(KeetaNet.lib.Account.generateRandomSeed(), 0); From c6bc1eb4511b0b466757923572baa75685987a31 Mon Sep 17 00:00:00 2001 From: bogdanblazhkevych Date: Thu, 7 May 2026 15:11:08 -0400 Subject: [PATCH 3/9] removed overrides when passing account to initiateTransfer --- src/lib/chaining.test.ts | 34 +++++++++++++++------------------- src/lib/chaining.ts | 2 +- 2 files changed, 16 insertions(+), 20 deletions(-) diff --git a/src/lib/chaining.test.ts b/src/lib/chaining.test.ts index 97f76ac9..ae73fa62 100644 --- a/src/lib/chaining.test.ts +++ b/src/lib/chaining.test.ts @@ -8,7 +8,7 @@ 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 } from './chaining.js'; +import type { AnchorChainingPathState, ExecutedStep, AnchorChainingAsset, AnchorChainingAssetInfo, AnchorChainingResolveAssetsFilter, ComputePlanOptions } from './chaining.js'; import type { GenericAccount, TokenAddress } from '@keetanetwork/keetanet-client/lib/account.js'; import { KeetaAnchorUserError } from './error.js'; import { BlockListener } from './block-listener.js'; @@ -733,11 +733,11 @@ async function createChainingTestHarness() { return(path); }; - const getPlanVia = async (fxProviderID: 'FXOne' | 'FXTwo') => { + 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)); @@ -1109,18 +1109,8 @@ describe('AnchorChainingPath execute', function() { 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 storageReceiveTokenBalancePre = await h.client.balance(h.tokens.EURC, { account: storageAccount }); - - // Same location FX: storage accounts on AM are not supported - const paths = await h.anchorChaining.getPlans({ - source: { asset: h.tokens.USDC, location: h.keetaLocation, value: 100n, rail: 'KEETA_SEND' }, - destination: { asset: h.tokens.EURC, location: h.keetaLocation, recipient: h.client.account.publicKeyString.get(), rail: 'KEETA_SEND' } - }, { overrides: { account: storageAccount }}); - const path = paths?.[0]; - if (!path) { - throw(new Error(`No path found`)); - } + const path = await h.getPlanVia('FXOne', { overrides: { account: storageAccount }}); expect(path.state.status).toEqual('idle'); @@ -1141,13 +1131,13 @@ describe('AnchorChainingPath execute', function() { // Plan totals from FX rate (0.88 forward) expect(path.plan.totalValueIn).toEqual(100n); - expect(path.plan.totalValueOut).toEqual(88n); + expect(path.plan.totalValueOut).toEqual(78n); const result = await path.execute(); // Step structure and server-side verification - expect(result.steps.length).toEqual(1); - const [step0] = result.steps; + 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(); @@ -1158,6 +1148,14 @@ describe('AnchorChainingPath execute', function() { } } + 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('COMPLETED'); + expect(transferStatus.transaction.to.value).toEqual('78'); + } // State transitions: idle -> executing -> completed expect(path.state.status).toEqual('completed'); expect(stateHistory[0]).toEqual('executing'); @@ -1169,10 +1167,8 @@ describe('AnchorChainingPath execute', function() { 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); - const storageReceiveTokenBalancePost = await h.client.balance(h.tokens.EURC, { account: storageAccount }); expect(storageSendTokenBalancePre - storageSendTokenBalancePost).toEqual(100n); - expect(storageReceiveTokenBalancePost - storageReceiveTokenBalancePre).toEqual(88n); expect(userSendTokenBalancePre).toEqual(userSendTokenBalancePost); expect(userReceiveTokenBalancePre).toEqual(userReceiveTokenBalancePost); diff --git a/src/lib/chaining.ts b/src/lib/chaining.ts index e5044251..61497aa9 100644 --- a/src/lib/chaining.ts +++ b/src/lib/chaining.ts @@ -1142,7 +1142,7 @@ export class AnchorChainingPlan extends AnchorChainingPath { type: 'assetMovement', providerMethod: 'initiateTransfer', provider: providers[0] - }, options?.overrides), + }), asset: assetPair, from: { location: step.from.location }, to: { From fad5658897b70b6eccef87b6332ed49844a4204b Mon Sep 17 00:00:00 2001 From: bogdanblazhkevych Date: Thu, 7 May 2026 16:17:47 -0400 Subject: [PATCH 4/9] added value fallback for totalReceiveAmount --- src/lib/chaining.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/lib/chaining.ts b/src/lib/chaining.ts index 61497aa9..d3f78d06 100644 --- a/src/lib/chaining.ts +++ b/src/lib/chaining.ts @@ -1160,7 +1160,11 @@ export class AnchorChainingPlan extends AnchorChainingPath { const usingInstruction = findInstruction(transfer.instructions, step.from.rail); - if (!usingInstruction.totalReceiveAmount) { + 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`)); } @@ -1171,7 +1175,7 @@ export class AnchorChainingPlan extends AnchorChainingPath { usingInstruction: usingInstruction, transfer: transfer, sendingTo: sendingToType, - valueOut: BigInt(usingInstruction.totalReceiveAmount) + valueOut: BigInt(totalReceiveAmount) }) } else if (step.type === 'keetaSend') { if (this.path.length !== 1) { From e82b2598d0c0183fe57618643c6b69dc54670f42 Mon Sep 17 00:00:00 2001 From: bogdanblazhkevych Date: Thu, 7 May 2026 19:24:12 -0400 Subject: [PATCH 5/9] improved account overrides interface --- src/lib/chaining.ts | 51 +++++++++++++++++++++++++++------------------ 1 file changed, 31 insertions(+), 20 deletions(-) diff --git a/src/lib/chaining.ts b/src/lib/chaining.ts index d3f78d06..f61ffe70 100644 --- a/src/lib/chaining.ts +++ b/src/lib/chaining.ts @@ -284,8 +284,10 @@ type GetAccountForActionPayload = { providerMethod: 'getAccountForAction'; } +type AccountLike = InstanceType | undefined | ((providerMethodPayload: GetAccountForActionPayload) => Promise | Account); interface AnchorChainingAccountOverrides { - account?: InstanceType | undefined | ((providerMethodPayload: GetAccountForActionPayload) => Promise | Account); + account?: AccountLike; + signer?: AccountLike; } class AnchorGraph { @@ -890,7 +892,7 @@ export class AnchorChainingPath { this.parent = input.parent; } - protected async getAccountForAction(action: GetAccountForActionPayload, overrides?: AnchorChainingAccountOverrides): Promise> { + protected async getAccountLike(action: GetAccountForActionPayload, override?: AccountLike): Promise> { let found: InstanceType | undefined = undefined; if (this.parent['client'].account.isAccount()) { @@ -899,11 +901,11 @@ export class AnchorChainingPath { found = this.parent['client'].signer; } - if (overrides?.account) { - if (typeof overrides.account === 'function') { - found = await overrides.account(action); + if (override) { + if (typeof override === 'function') { + found = await override(action); } else { - found = overrides.account; + found = override; } } @@ -913,6 +915,15 @@ export class AnchorChainingPath { return(found); } + + protected async getAccountsForAction(action: GetAccountForActionPayload, overrides?: AnchorChainingAccountOverrides): Promise<{ account: InstanceType; signer: InstanceType }> { + const [signer, account] = await Promise.all([ + await this.getAccountLike(action, overrides?.signer), + await this.getAccountLike(action, overrides?.account) + ]); + + return({ signer, account }); + } } export class AnchorChainingPlan extends AnchorChainingPath { @@ -1017,15 +1028,14 @@ export class AnchorChainingPlan extends AnchorChainingPath { assertNever(affinity); } + const fxAccountOptions = await this.getAccountsForAction({ + type: 'fx', + providerMethod: 'getAccountForAction' + }, options?.overrides); + const quotesOrEstimates = await fxClient.getQuotesOrEstimates( { from: step.from.asset, to: step.to.asset, amount, affinity }, - { - signer: this.parent['client'].account, - account: await this.getAccountForAction({ - type: 'fx', - providerMethod: 'getAccountForAction' - }, options?.overrides) - }, + fxAccountOptions, { providerIDs: [ step.providerID ] } ); @@ -1136,13 +1146,14 @@ export class AnchorChainingPlan extends AnchorChainingPath { depositValue = previous.valueOut; } } + const { signer } = await this.getAccountsForAction({ + type: 'assetMovement', + providerMethod: 'initiateTransfer', + provider: providers[0] + }, options?.overrides); const transfer = await providers[0].initiateTransfer({ - account: await this.getAccountForAction({ - type: 'assetMovement', - providerMethod: 'initiateTransfer', - provider: providers[0] - }), + account: signer, asset: assetPair, from: { location: step.from.location }, to: { @@ -1391,8 +1402,8 @@ export class AnchorChainingPlan extends AnchorChainingPath { }); } - const account = await this.getAccountForAction({ type: 'assetMovement', providerMethod: 'initiateTransfer' }, this.#_plan?.options?.overrides); - await this.parent['client'].send(sendToAddress, value, token, external, { account: account }); + const { account } = await this.getAccountsForAction({ type: 'assetMovement', providerMethod: 'initiateTransfer' }, this.#_plan?.options?.overrides); + await this.parent['client'].send(sendToAddress, value, token, external, { account }); } async #pollTransferStatus( From 692230716885b7a408da5dab179419c193bd5e46 Mon Sep 17 00:00:00 2001 From: bogdanblazhkevych Date: Fri, 8 May 2026 00:00:06 -0400 Subject: [PATCH 6/9] fixed status string --- src/lib/chaining.test.ts | 16 ++++++++-------- src/lib/chaining.ts | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/lib/chaining.test.ts b/src/lib/chaining.test.ts index ae73fa62..61ab2ce5 100644 --- a/src/lib/chaining.test.ts +++ b/src/lib/chaining.test.ts @@ -70,8 +70,8 @@ class TestBankServer extends KeetaNetAssetMovementAnchorHTTPServer { throw(new KeetaAnchorUserError(`Invalid transfer amount: expected ${value}, got ${op.amount}`)); } const existing = statusMap.get(txId); - if (existing && existing.status !== 'COMPLETED') { - statusMap.set(txId, { ...existing, status: 'COMPLETED', updatedAt: new Date().toISOString() }); + if (existing && existing.status !== 'COMPLETE') { + statusMap.set(txId, { ...existing, status: 'COMPLETE', updatedAt: new Date().toISOString() }); } listenerHandle?.remove(); return({ requiresWork: false }); @@ -104,7 +104,7 @@ class TestBankServer extends KeetaNetAssetMovementAnchorHTTPServer { } else { statusMap.set(txId, { id: txId, - status: 'COMPLETED', + status: 'COMPLETE', asset: request.asset, from: { location: request.from.location, value: value.toString(), transactions: { deposit: null, persistentForwarding: null, finalization: null }}, to: { location: request.to.location, value: receive.toString(), transactions: { withdraw: null }}, @@ -182,7 +182,7 @@ class TestBankServer extends KeetaNetAssetMovementAnchorHTTPServer { const txId = `tx-${Date.now()}-${Math.random().toString(36).slice(2)}`; this._statusMap.set(txId, { id: txId, - status: 'COMPLETED', + status: 'COMPLETE', asset: request.asset, from: { location: request.from.location, value: value.toString(), transactions: { deposit: null, persistentForwarding: null, finalization: null }}, to: { location: request.to.location, value: receive.toString(), transactions: { withdraw: null }}, @@ -1064,7 +1064,7 @@ describe('AnchorChainingPath execute', function() { 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('COMPLETED'); + expect(transferStatus.transaction.status).toEqual('COMPLETE'); expect(transferStatus.transaction.to.value).toEqual('78'); } @@ -1153,7 +1153,7 @@ describe('AnchorChainingPath execute', function() { 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('COMPLETED'); + expect(transferStatus.transaction.status).toEqual('COMPLETE'); expect(transferStatus.transaction.to.value).toEqual('78'); } // State transitions: idle -> executing -> completed @@ -1306,7 +1306,7 @@ describe('AnchorChainingPath ACH fiat path', function() { await expect(path.execute()).rejects.toThrow('No listeners for stepNeedsAction'); }); - test('markCompleted signals completion; server records transfer as COMPLETED', async function() { + test('markCompleted signals completion; server records transfer as COMPLETE', async function() { await using h = await createChainingTestHarness(); const path = await getBankUSPath(h); @@ -1324,7 +1324,7 @@ describe('AnchorChainingPath ACH fiat path', function() { 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('COMPLETED'); + expect(transferStatus.transaction.status).toEqual('COMPLETE'); expect(transferStatus.transaction.to.value).toEqual('90'); } }); diff --git a/src/lib/chaining.ts b/src/lib/chaining.ts index f61ffe70..6b64c12e 100644 --- a/src/lib/chaining.ts +++ b/src/lib/chaining.ts @@ -1416,7 +1416,7 @@ export class AnchorChainingPlan extends AnchorChainingPath { while (true) { const status = await transfer.getTransferStatus(); - if (status.transaction.status === 'COMPLETED') { + if (status.transaction.status === 'COMPLETE') { return(status); } if (Date.now() >= deadline) { From 969c640f664fa99c06679ffeea7d68977fdd7e86 Mon Sep 17 00:00:00 2001 From: bogdanblazhkevych Date: Mon, 11 May 2026 13:05:05 -0400 Subject: [PATCH 7/9] storing options in plan class --- src/lib/chaining.ts | 20 ++++++++++---------- src/services/fx/client.ts | 2 +- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/lib/chaining.ts b/src/lib/chaining.ts index 6b64c12e..4b3c6d43 100644 --- a/src/lib/chaining.ts +++ b/src/lib/chaining.ts @@ -53,7 +53,6 @@ type AnchorChainingPathComputedPlan = { steps: ChainStepResolution[]; totalValueIn: bigint; totalValueOut: bigint; - options?: ComputePlanOptions | undefined; }; type ExecutedStepFX = { @@ -932,9 +931,11 @@ export class AnchorChainingPlan extends AnchorChainingPath { #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) { + private constructor(path: AnchorChainingPath, options?: ComputePlanOptions) { super({ ...path }); + this.#options = options; } get plan(): AnchorChainingPathComputedPlan { @@ -945,7 +946,7 @@ export class AnchorChainingPlan extends AnchorChainingPath { return(this.#_plan); } - async #computePlan(options?: ComputePlanOptions) { + async #computePlan() { if (this.#_plan) { throw(new Error(`Steps have already been computed`)); } @@ -1031,7 +1032,7 @@ export class AnchorChainingPlan extends AnchorChainingPath { const fxAccountOptions = await this.getAccountsForAction({ type: 'fx', providerMethod: 'getAccountForAction' - }, options?.overrides); + }, this.#options?.overrides); const quotesOrEstimates = await fxClient.getQuotesOrEstimates( { from: step.from.asset, to: step.to.asset, amount, affinity }, @@ -1150,7 +1151,7 @@ export class AnchorChainingPlan extends AnchorChainingPath { type: 'assetMovement', providerMethod: 'initiateTransfer', provider: providers[0] - }, options?.overrides); + }, this.#options?.overrides); const transfer = await providers[0].initiateTransfer({ account: signer, @@ -1282,14 +1283,13 @@ export class AnchorChainingPlan extends AnchorChainingPath { return({ steps, totalValueIn: firstStep.valueIn, - totalValueOut: lastStep.valueOut, - options + totalValueOut: lastStep.valueOut }); } static async create(path: AnchorChainingPath, options?: ComputePlanOptions): Promise { - const instance = new this(path); - instance.#_plan = await instance.#computePlan(options); + const instance = new this(path, options); + instance.#_plan = await instance.#computePlan(); return(instance); } @@ -1402,7 +1402,7 @@ export class AnchorChainingPlan extends AnchorChainingPath { }); } - const { account } = await this.getAccountsForAction({ type: 'assetMovement', providerMethod: 'initiateTransfer' }, this.#_plan?.options?.overrides); + const { account } = await this.getAccountsForAction({ type: 'assetMovement', providerMethod: 'initiateTransfer' }, this.#options?.overrides); await this.parent['client'].send(sendToAddress, value, token, external, { account }); } diff --git a/src/services/fx/client.ts b/src/services/fx/client.ts index 33482537..0f991a20 100644 --- a/src/services/fx/client.ts +++ b/src/services/fx/client.ts @@ -242,7 +242,7 @@ class KeetaFXAnchorProviderBase extends KeetaFXAnchorBase { this.providerID = providerID; this.conversion = conversion; this.parent = parent; - this.options = options + this.options = options; } #parseConversionRequest(input: ConversionInputCanonicalJSON): ConversionInputCanonical { From 4489cc92e9270a68e8518bd291cd6e1e59957cad Mon Sep 17 00:00:00 2001 From: bogdanblazhkevych Date: Mon, 11 May 2026 13:08:24 -0400 Subject: [PATCH 8/9] passing builder options in static initBuilder method --- src/services/fx/client.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/services/fx/client.ts b/src/services/fx/client.ts index 0f991a20..b972a32f 100644 --- a/src/services/fx/client.ts +++ b/src/services/fx/client.ts @@ -444,21 +444,21 @@ class KeetaFXAnchorProviderBase extends KeetaFXAnchorBase { } /* Construct the required operations for the swap request */ - const builder = this.client.initBuilder(); + const builder = this.client.initBuilder(this.options); if ('quote' in input) { /* If cost is required then send the required amount as well */ if (input.quote.cost.amount > 0) { - builder.send(liquidityProvider, input.quote.cost.amount, input.quote.cost.token, undefined, this.options); + builder.send(liquidityProvider, input.quote.cost.amount, input.quote.cost.token, undefined); } } else if ('estimate' in input) { if (input.estimate.expectedCost.max > 0) { - builder.send(liquidityProvider, input.estimate.expectedCost.max, input.estimate.expectedCost.token, undefined, this.options); + builder.send(liquidityProvider, input.estimate.expectedCost.max, input.estimate.expectedCost.token, undefined); } } - builder.receive(liquidityProvider, receiveAmount, request.to, request.affinity === 'to', undefined, this.options); - builder.send(liquidityProvider, sendAmount, request.from, undefined, this.options); + builder.receive(liquidityProvider, receiveAmount, request.to, request.affinity === 'to', undefined); + builder.send(liquidityProvider, sendAmount, request.from, undefined); const blocks = await builder.computeBlocks(); if (blocks.blocks.length !== 1) { From f3c8e65e1242e18c94da504b58602ee395583563 Mon Sep 17 00:00:00 2001 From: ezra ripps Date: Mon, 11 May 2026 13:28:20 -0400 Subject: [PATCH 9/9] linting/cleanup --- src/lib/chaining.ts | 4 ++-- src/services/fx/client.ts | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/lib/chaining.ts b/src/lib/chaining.ts index 4b3c6d43..51f48fbe 100644 --- a/src/lib/chaining.ts +++ b/src/lib/chaining.ts @@ -917,8 +917,8 @@ export class AnchorChainingPath { protected async getAccountsForAction(action: GetAccountForActionPayload, overrides?: AnchorChainingAccountOverrides): Promise<{ account: InstanceType; signer: InstanceType }> { const [signer, account] = await Promise.all([ - await this.getAccountLike(action, overrides?.signer), - await this.getAccountLike(action, overrides?.account) + this.getAccountLike(action, overrides?.signer), + this.getAccountLike(action, overrides?.account) ]); return({ signer, account }); diff --git a/src/services/fx/client.ts b/src/services/fx/client.ts index b972a32f..a02463be 100644 --- a/src/services/fx/client.ts +++ b/src/services/fx/client.ts @@ -449,16 +449,16 @@ class KeetaFXAnchorProviderBase extends KeetaFXAnchorBase { if ('quote' in input) { /* If cost is required then send the required amount as well */ if (input.quote.cost.amount > 0) { - builder.send(liquidityProvider, input.quote.cost.amount, input.quote.cost.token, undefined); + builder.send(liquidityProvider, input.quote.cost.amount, input.quote.cost.token); } } else if ('estimate' in input) { if (input.estimate.expectedCost.max > 0) { - builder.send(liquidityProvider, input.estimate.expectedCost.max, input.estimate.expectedCost.token, undefined); + builder.send(liquidityProvider, input.estimate.expectedCost.max, input.estimate.expectedCost.token); } } - builder.receive(liquidityProvider, receiveAmount, request.to, request.affinity === 'to', undefined); - builder.send(liquidityProvider, sendAmount, request.from, undefined); + builder.receive(liquidityProvider, receiveAmount, request.to, request.affinity === 'to'); + builder.send(liquidityProvider, sendAmount, request.from); const blocks = await builder.computeBlocks(); if (blocks.blocks.length !== 1) {