Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions src/services/fx/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -918,6 +918,112 @@ test('FX Client resolves a settled exchange by account and reports its conversio
expect(status.conversion.liquidityProvider).toBe(liquidityProvider.publicKeyString.get());
}, 30_000);

test('createExchange succeeds when FX fee is charged in the send token', async function() {
const account = KeetaNet.lib.Account.fromSeed(KeetaNet.lib.Account.generateRandomSeed(), 0);
const quoteSigner = KeetaNet.lib.Account.fromSeed(KeetaNet.lib.Account.generateRandomSeed(), 0);
const liquidityProvider = KeetaNet.lib.Account.fromSeed(KeetaNet.lib.Account.generateRandomSeed(), 0);

await using nodeAndClient = await createNodeAndClient(account);
const client = nodeAndClient.userClient;
const baseToken = client.baseToken;
const giveTokens = nodeAndClient.give.bind(nodeAndClient);

const { account: testCurrencyUSD } = await client.generateIdentifier(KeetaNet.lib.Account.AccountKeyAlgorithm.TOKEN);
const { account: testCurrencyEUR } = await client.generateIdentifier(KeetaNet.lib.Account.AccountKeyAlgorithm.TOKEN);
if (!testCurrencyUSD.isToken() || !testCurrencyEUR.isToken()) {
throw(new Error('Test currencies not tokens'));
}

const initialUSDBalance = 500000n;
const swapAmount = 100n;
const feeAmount = 5n;

await client.modTokenSupplyAndBalance(initialUSDBalance, testCurrencyUSD);
await giveTokens(client.account, 50n);
await client.modTokenSupplyAndBalance(100000n, testCurrencyEUR, { account: liquidityProvider });
await client.updatePermissions(liquidityProvider, new KeetaNet.lib.Permissions(['ACCESS']), undefined, undefined, { account: testCurrencyEUR });
await client.updatePermissions(liquidityProvider, new KeetaNet.lib.Permissions(['ACCESS']), undefined, undefined, { account: testCurrencyUSD });
await client.send(liquidityProvider, 50n, baseToken);

await using server = new KeetaNetFXAnchorHTTPServer({
logger: logger,
account: liquidityProvider,
metadataSigner: liquidityProvider,
quoteSigner: quoteSigner,
client: { client: client.client, network: client.config.network, networkAlias: client.config.networkAlias },
storage: {
queue: new KeetaAnchorQueueStorageDriverMemory({ id: 'queue' }),
autoRun: false
},
fx: {
from: [{
currencyCodes: [testCurrencyUSD.publicKeyString.get()],
to: [testCurrencyEUR.publicKeyString.get()]
}],
getConversionRateAndFee: async function(request) {
return({
account: liquidityProvider,
convertedAmount: BigInt(request.amount) * 88n / 100n,
cost: { amount: feeAmount, token: testCurrencyUSD }
});
}
}
});

await server.start();

await client.setInfo({
description: 'FX Anchor same-token fee Test',
name: 'TEST',
metadata: KeetaAnchorResolver.Metadata.formatMetadata({
version: 1,
currencyMap: {
USD: testCurrencyUSD.publicKeyString.get(),
EUR: testCurrencyEUR.publicKeyString.get()
},
services: {
fx: { Test: await server.serviceMetadata() }
}
})
});

const fxClient = new KeetaNetAnchor.FX.Client(client, { root: account, signer: account, account: account, logger: logger });
const quotes = await fxClient.getQuotes({ from: 'USD', to: 'EUR', amount: swapAmount, affinity: 'from' });
const quote = quotes?.[0];
if (quote === undefined) {
throw(new Error('Expected a USD to EUR quote'));
}

expect(quote.quote.cost.token.comparePublicKey(testCurrencyUSD)).toBe(true);
expect(quote.quote.cost.amount).toBe(feeAmount);

const usdBefore = await client.balance(testCurrencyUSD);
const eurBefore = await client.balance(testCurrencyEUR);

const exchange = await quote.createExchange();
const completed = await waitForExchangeToComplete(server, exchange);
expect(completed.status).toBe('completed');

const usdAfter = await client.balance(testCurrencyUSD);
const eurAfter = await client.balance(testCurrencyEUR);
expect(usdBefore - usdAfter).toBe(swapAmount + feeAmount);
expect(eurAfter - eurBefore).toBe(88n);

const provider = await fxClient.getProviderByAccount(liquidityProvider.publicKeyString.get(), [ 'getExchangeStatus' ]);
if (provider === null) {
throw(new Error('Expected to resolve the FX provider by liquidity provider account'));
}

const status = await provider.getExchangeStatus(exchange.exchange.exchangeID);
if (status.status !== 'completed' || status.conversion === undefined) {
throw(new Error('Expected a completed exchange with a conversion summary'));
}

expect(status.conversion.from).toEqual({ token: testCurrencyUSD.publicKeyString.get(), amount: String(swapAmount) });
expect(status.conversion.to).toEqual({ token: testCurrencyEUR.publicKeyString.get(), amount: '88' });
expect(status.conversion.cost).toEqual({ token: testCurrencyUSD.publicKeyString.get(), amount: String(feeAmount) });
}, 30_000);

test('Swap Function Negative Tests', async function() {
const account = KeetaNet.lib.Account.fromSeed(seed, 0);
const account2 = KeetaNet.lib.Account.fromSeed(seed, 1);
Expand Down
30 changes: 21 additions & 9 deletions src/services/fx/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -486,15 +486,26 @@
/* Construct the required operations for the swap request */
const builder = this.client.initBuilder(this.options);

let costAmount = 0n;

Check warning on line 489 in src/services/fx/client.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this useless assignment to variable "costAmount".

See more on https://sonarcloud.io/project/issues?id=KeetaPay_anchor&issues=AaBjeVR7R_apWahlIL1E&open=AaBjeVR7R_apWahlIL1E&pullRequest=438
let costToken;
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);
}
} else if ('estimate' in input) {
if (input.estimate.expectedCost.max > 0) {
builder.send(liquidityProvider, input.estimate.expectedCost.max, input.estimate.expectedCost.token);
}
costAmount = input.quote.cost.amount;
costToken = input.quote.cost.token;
} else {
costAmount = input.estimate.expectedCost.max;
costToken = input.estimate.expectedCost.token;
}

/*
* When the fee is charged in the same token as the swap send,
* combine them into a single SEND. The principal send carries an
* external, so the builder will not merge a separate cost send
* of the same token into that operation.
*/
const costUsesSendToken = costAmount > 0n && costToken.comparePublicKey(request.from);

if (costAmount > 0n && !costUsesSendToken) {
builder.send(liquidityProvider, costAmount, costToken);
}

builder.receive(liquidityProvider, receiveAmount, request.to, request.affinity === 'to');
Expand All @@ -510,7 +521,8 @@
}

const external = await externalBuilder.build();
builder.send(liquidityProvider, sendAmount, request.from, external);
const taggedSendAmount = costUsesSendToken ? sendAmount + costAmount : sendAmount;
builder.send(liquidityProvider, taggedSendAmount, request.from, external);

const blocks = await builder.computeBlocks();
if (blocks.blocks.length !== 1) {
Expand Down
30 changes: 29 additions & 1 deletion src/services/fx/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -499,7 +499,7 @@
/**
* Build the conversion summary surfaced for FX history correlation.
*/
function buildKeetaFXAnchorConversionSummary(expected: NonNullable<KeetaFXAnchorQueueStage1Request['expected']>, block: KeetaFXAnchorQueueStage1Request['block'], account: KeetaNetAccount, refunds: readonly RefundValue[] = []): KeetaFXAnchorConversionSummary {

Check failure on line 502 in src/services/fx/server.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

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

See more on https://sonarcloud.io/project/issues?id=KeetaPay_anchor&issues=AaBjeVW2R_apWahlIL1F&open=AaBjeVW2R_apWahlIL1F&pullRequest=438
const conversion: KeetaFXAnchorConversionSummary = {
from: {
token: expected.receive.token.publicKeyString.get(),
Expand All @@ -514,7 +514,8 @@

/*
* The user's block funds the swap with the principal (the `from` token)
* and, when charged, a separate cost send in another token. Under a
* and, when charged, a cost send. Cost is usually a separate token; when
* it is the same token it is combined into the principal send. Under a
* variable rate the user may over-send the cost and the anchor refunds the
* excess, so net the refunds against the gross send to report the cost the
* user actually paid.
Expand Down Expand Up @@ -544,6 +545,33 @@
break;
}

if (conversion.cost === undefined) {
let fromTokenSent = 0n;
for (const operation of block.operations) {
if (operation.type !== KeetaNet.lib.Block.OperationType.SEND) {
continue;
}
if (!operation.token.comparePublicKey(expected.receive.token)) {
continue;
}
fromTokenSent += operation.amount;
}

let sameTokenCost = fromTokenSent - expected.receive.amount;
for (const refund of refunds) {
if (refund.token.comparePublicKey(expected.receive.token)) {
sameTokenCost -= refund.amount;
}
}

if (sameTokenCost > 0n) {
conversion.cost = {
token: expected.receive.token.publicKeyString.get(),
amount: sameTokenCost.toString()
};
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused same-token fees kept

Medium Severity

Estimate swaps that charge the fee in the from token fund expectedCost.max on the principal SEND, but excess over the live fee is not refundable when affinity is from. The unused buffer stays with the liquidity provider, and conversion.cost reports that gross overage instead of the fee actually charged.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b128a53. Configure here.


return(conversion);
}

Expand Down
77 changes: 77 additions & 0 deletions src/services/fx/util.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,37 @@ test('assertExchangeBlockParameters', async function() {
]
}).seal())

/*
* Reproduces the client creating a separate cost SEND and a principal
* SEND in the same token (what happens when FX fees are charged in the
* swap `from` token). The operations must be summed, not rejected.
*/
const aSendsTokenATwiceToBBlock = await (new KeetaNet.lib.Block.Builder({
network: networkId,
previous: KeetaNet.lib.Block.NO_PREVIOUS,
signer: accountA,
operations: [
{
type: KeetaNet.lib.Block.OperationType.SEND,
to: accountB,
token: tokenA,
amount: 25n
},
{
type: KeetaNet.lib.Block.OperationType.RECEIVE,
from: accountB,
token: tokenB,
amount: 500n
},
{
type: KeetaNet.lib.Block.OperationType.SEND,
to: accountB,
token: tokenA,
amount: 475n
}
]
}).seal())

const baseQuoteRequest = {
quote: {
convertedAmount: 4000n,
Expand Down Expand Up @@ -251,6 +282,52 @@ test('assertExchangeBlockParameters', async function() {
isQuoteBasedExchange: false
},
pass: false
},
{
args: {
allowedLiquidityAccounts: new KeetaNet.lib.Account.Set([accountB]),
block: aSendsTokenATwiceToBBlock,
liquidityAccount: accountB,
checks: {
...baseQuoteRequest,
quote: {
...baseQuoteRequest.quote,
cost: {
token: tokenA,
amount: 25n
}
},
request: {
...baseQuoteRequest.request,
amount: 475n
}
},
isQuoteBasedExchange: true
},
pass: true
},
{
args: {
allowedLiquidityAccounts: new KeetaNet.lib.Account.Set([accountB]),
block: aSendsTokenATwiceToBBlock,
liquidityAccount: accountB,
checks: {
...baseQuoteRequest,
quote: {
...baseQuoteRequest.quote,
cost: {
token: tokenA,
amount: 25n
}
},
request: {
...baseQuoteRequest.request,
amount: 475n
}
},
isQuoteBasedExchange: false
},
pass: true
}
];

Expand Down
2 changes: 0 additions & 2 deletions src/services/fx/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,6 @@ export function assertExchangeBlockParametersAndComputeRefund(args: {

if (!(userSent[tokenPub])) {
userSent[tokenPub] = 0n;
} else {
throw(new KeetaAnchorUserError(`Multiple send operations for token ${tokenPub} in exchange block are not allowed`));
}

userSent[tokenPub] += operation.amount;
Expand Down