diff --git a/src/services/fx/client.test.ts b/src/services/fx/client.test.ts index e2819fa7..641c7489 100644 --- a/src/services/fx/client.test.ts +++ b/src/services/fx/client.test.ts @@ -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); diff --git a/src/services/fx/client.ts b/src/services/fx/client.ts index b07ccf72..79a8f427 100644 --- a/src/services/fx/client.ts +++ b/src/services/fx/client.ts @@ -486,15 +486,26 @@ export class KeetaFXAnchorProviderBase extends KeetaFXAnchorBase { /* Construct the required operations for the swap request */ const builder = this.client.initBuilder(this.options); + let costAmount = 0n; + 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'); @@ -510,7 +521,8 @@ export class KeetaFXAnchorProviderBase extends KeetaFXAnchorBase { } 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) { diff --git a/src/services/fx/server.ts b/src/services/fx/server.ts index 503ddbdc..647645f5 100644 --- a/src/services/fx/server.ts +++ b/src/services/fx/server.ts @@ -514,7 +514,8 @@ function buildKeetaFXAnchorConversionSummary(expected: NonNullable 0n) { + conversion.cost = { + token: expected.receive.token.publicKeyString.get(), + amount: sameTokenCost.toString() + }; + } + } + return(conversion); } diff --git a/src/services/fx/util.test.ts b/src/services/fx/util.test.ts index 082f2ff3..55dba773 100644 --- a/src/services/fx/util.test.ts +++ b/src/services/fx/util.test.ts @@ -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, @@ -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 } ]; diff --git a/src/services/fx/util.ts b/src/services/fx/util.ts index c0ac1563..ff406277 100644 --- a/src/services/fx/util.ts +++ b/src/services/fx/util.ts @@ -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;