diff --git a/src/lib/chaining.test.ts b/src/lib/chaining.test.ts index 38a60943..61ab2ce5 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'; @@ -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 }}, @@ -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)); @@ -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'); } @@ -1090,6 +1090,102 @@ 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 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); @@ -1210,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); @@ -1228,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 52144c92..51f48fbe 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"; @@ -277,11 +277,16 @@ export interface AnchorChainingAssetInfo { type GetAccountForActionPayload = { type: 'assetMovement'; providerMethod: 'initiateTransfer'; - provider: AssetMovementProvider; + provider?: AssetMovementProvider; +} | { + type: 'fx'; + providerMethod: 'getAccountForAction'; } +type AccountLike = InstanceType | undefined | ((providerMethodPayload: GetAccountForActionPayload) => Promise | Account); interface AnchorChainingAccountOverrides { - account?: Account | undefined | ((providerMethodPayload: GetAccountForActionPayload) => Promise | Account); + account?: AccountLike; + signer?: AccountLike; } class AnchorGraph { @@ -867,7 +872,7 @@ class AnchorGraph { } } -interface ComputePlanOptions { +export interface ComputePlanOptions { overrides?: AnchorChainingAccountOverrides; } @@ -886,8 +891,8 @@ export class AnchorChainingPath { this.parent = input.parent; } - protected async getAccountForAction(action: GetAccountForActionPayload, overrides?: AnchorChainingAccountOverrides): Promise { - let found; + protected async getAccountLike(action: GetAccountForActionPayload, override?: AccountLike): Promise> { + let found: InstanceType | undefined = undefined; if (this.parent['client'].account.isAccount()) { found = this.parent['client'].account; @@ -895,16 +900,29 @@ 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; } } + 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 }); + } } export class AnchorChainingPlan extends AnchorChainingPath { @@ -913,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 { @@ -926,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`)); } @@ -1009,9 +1029,14 @@ export class AnchorChainingPlan extends AnchorChainingPath { 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 }, - undefined, + fxAccountOptions, { providerIDs: [ step.providerID ] } ); @@ -1122,13 +1147,14 @@ export class AnchorChainingPlan extends AnchorChainingPath { depositValue = previous.valueOut; } } + const { signer } = await this.getAccountsForAction({ + type: 'assetMovement', + providerMethod: 'initiateTransfer', + provider: providers[0] + }, this.#options?.overrides); const transfer = await providers[0].initiateTransfer({ - account: await this.getAccountForAction({ - type: 'assetMovement', - providerMethod: 'initiateTransfer', - provider: providers[0] - }, options?.overrides), + account: signer, asset: assetPair, from: { location: step.from.location }, to: { @@ -1146,7 +1172,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`)); } @@ -1157,7 +1187,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) { @@ -1258,8 +1288,8 @@ export class AnchorChainingPlan extends AnchorChainingPath { } 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); } @@ -1372,7 +1402,8 @@ export class AnchorChainingPlan extends AnchorChainingPath { }); } - await this.parent['client'].send(sendToAddress, value, token, external); + const { account } = await this.getAccountsForAction({ type: 'assetMovement', providerMethod: 'initiateTransfer' }, this.#options?.overrides); + await this.parent['client'].send(sendToAddress, value, token, external, { account }); } async #pollTransferStatus( @@ -1385,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) { diff --git a/src/services/fx/client.test.ts b/src/services/fx/client.test.ts index 6f36cc63..07ce6512 100644 --- a/src/services/fx/client.test.ts +++ b/src/services/fx/client.test.ts @@ -1403,8 +1403,66 @@ 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); + } } -}); +}, 10_000); test('FX Server Pricing test', async function() { const userAccount = KeetaNet.lib.Account.fromSeed(KeetaNet.lib.Account.generateRandomSeed(), 0); diff --git a/src/services/fx/client.ts b/src/services/fx/client.ts index f9db7c18..a02463be 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 { @@ -442,7 +444,7 @@ 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 */ @@ -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);