Skip to content
116 changes: 106 additions & 10 deletions src/lib/chaining.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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 }},
Expand Down Expand Up @@ -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 }},
Expand Down Expand Up @@ -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));

Expand Down Expand Up @@ -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');
}

Expand All @@ -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<ReturnType<typeof path.execute>> | null = null;
path.on('completed', (result: Awaited<ReturnType<typeof path.execute>>) => { 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);
Expand Down Expand Up @@ -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);

Expand All @@ -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');
}
});
Expand Down
79 changes: 55 additions & 24 deletions src/lib/chaining.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -277,11 +277,16 @@
type GetAccountForActionPayload = {
type: 'assetMovement';
providerMethod: 'initiateTransfer';
provider: AssetMovementProvider;
provider?: AssetMovementProvider;
} | {
type: 'fx';
providerMethod: 'getAccountForAction';
}

type AccountLike = InstanceType<typeof KeetaNetLib.Account> | undefined | ((providerMethodPayload: GetAccountForActionPayload) => Promise<Account> | Account);
interface AnchorChainingAccountOverrides {
account?: Account | undefined | ((providerMethodPayload: GetAccountForActionPayload) => Promise<Account> | Account);
account?: AccountLike;
signer?: AccountLike;
}

class AnchorGraph {
Expand Down Expand Up @@ -867,7 +872,7 @@
}
}

interface ComputePlanOptions {
export interface ComputePlanOptions {
overrides?: AnchorChainingAccountOverrides;
}

Expand All @@ -886,25 +891,38 @@
this.parent = input.parent;
}

protected async getAccountForAction(action: GetAccountForActionPayload, overrides?: AnchorChainingAccountOverrides): Promise<Account | undefined> {
let found;
protected async getAccountLike(action: GetAccountForActionPayload, override?: AccountLike): Promise<InstanceType<typeof KeetaNetLib.Account>> {
let found: InstanceType<typeof KeetaNetLib.Account> | 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 (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<typeof KeetaNetLib.Account>; signer: InstanceType<typeof KeetaNetLib.Account> }> {
const [signer, account] = await Promise.all([
this.getAccountLike(action, overrides?.signer),
this.getAccountLike(action, overrides?.account)
]);

return({ signer, account });
}
}

export class AnchorChainingPlan extends AnchorChainingPath {
Expand All @@ -913,9 +931,11 @@
#state: AnchorChainingPathState = { status: 'idle' };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
#listeners = new Map<string, Set<((...args: any[]) => void)>>();
#options: ComputePlanOptions | undefined = undefined;

Check warning on line 934 in src/lib/chaining.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Member '#options' is never reassigned; mark it as `readonly`.

See more on https://sonarcloud.io/project/issues?id=KeetaPay_anchor&issues=AZ4YCHxYBVtZah--W_DX&open=AZ4YCHxYBVtZah--W_DX&pullRequest=294

private constructor(path: AnchorChainingPath) {
private constructor(path: AnchorChainingPath, options?: ComputePlanOptions) {
super({ ...path });
this.#options = options;
}

get plan(): AnchorChainingPathComputedPlan {
Expand All @@ -926,7 +946,7 @@
return(this.#_plan);
}

async #computePlan(options?: ComputePlanOptions) {
async #computePlan() {

Check failure on line 949 in src/lib/chaining.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 17 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=KeetaPay_anchor&issues=AZ4YCHxYBVtZah--W_DY&open=AZ4YCHxYBVtZah--W_DY&pullRequest=294
if (this.#_plan) {
throw(new Error(`Steps have already been computed`));
}
Expand Down Expand Up @@ -1009,9 +1029,14 @@
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 ] }
);

Expand Down Expand Up @@ -1122,13 +1147,14 @@
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: {
Expand All @@ -1146,7 +1172,11 @@

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`));
}

Expand All @@ -1157,7 +1187,7 @@
usingInstruction: usingInstruction,
transfer: transfer,
sendingTo: sendingToType,
valueOut: BigInt(usingInstruction.totalReceiveAmount)
valueOut: BigInt(totalReceiveAmount)
})
} else if (step.type === 'keetaSend') {
if (this.path.length !== 1) {
Expand Down Expand Up @@ -1258,8 +1288,8 @@
}

static async create(path: AnchorChainingPath, options?: ComputePlanOptions): Promise<AnchorChainingPlan> {
const instance = new this(path);
instance.#_plan = await instance.#computePlan(options);
const instance = new this(path, options);
instance.#_plan = await instance.#computePlan();
return(instance);
}

Expand Down Expand Up @@ -1372,7 +1402,8 @@
});
}

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(
Expand All @@ -1385,7 +1416,7 @@

while (true) {
const status = await transfer.getTransferStatus();
if (status.transaction.status === 'COMPLETED') {
if (status.transaction.status === 'COMPLETE') {
return(status);
}
Comment thread
bogdanblazhkevych marked this conversation as resolved.
if (Date.now() >= deadline) {
Expand Down
Loading
Loading