From 145a8d7dbf79879a113c44f6b1472a016152456a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Abadesso?= Date: Tue, 5 Aug 2025 08:44:36 -0300 Subject: [PATCH 1/4] feat: totalAmount using wallet-lib's selectUtxos --- packages/wallet-service/src/api/txOutputs.ts | 51 +++++++++++++++++++- packages/wallet-service/src/types.ts | 1 + 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/packages/wallet-service/src/api/txOutputs.ts b/packages/wallet-service/src/api/txOutputs.ts index 7cf3323a..e5241562 100644 --- a/packages/wallet-service/src/api/txOutputs.ts +++ b/packages/wallet-service/src/api/txOutputs.ts @@ -16,7 +16,7 @@ import { } from '@src/types'; import { closeDbAndGetError } from '@src/api/utils'; import { getDbConnection } from '@src/utils'; -import { constants, bigIntUtils } from '@hathor/wallet-lib'; +import { constants, bigIntUtils, transactionUtils } from '@hathor/wallet-lib'; import middy from '@middy/core'; import cors from '@middy/http-cors'; import errorHandler from '@src/api/middlewares/errorHandler'; @@ -44,6 +44,7 @@ const bodySchema = Joi.object({ biggerThan: positiveBigInt.default(0n), // @ts-ignore smallerThan: positiveBigInt.default(constants.MAX_OUTPUT_VALUE + 1n), + totalAmount: positiveBigInt, maxOutputs: Joi.number().integer().positive().default(constants.MAX_OUTPUTS), skipSpent: Joi.boolean().optional().default(true), txId: Joi.string().optional(), @@ -190,7 +191,53 @@ const _getFilteredTxOutputs = async (walletId: string, filters: IFilterTxOutput) } const txOutputs: DbTxOutput[] = await filterTxOutputs(mysql, newFilters); - const txOutputsWithPath: DbTxOutputWithPath[] = mapTxOutputsWithPath(walletAddresses, txOutputs); + let finalTxOutputs: DbTxOutput[] = txOutputs; + + // Apply totalAmount filter if specified + if (filters.totalAmount) { + try { + // Convert DbTxOutput to format expected by selectUtxos + const utxosForSelection = txOutputs.map(txOutput => ({ + txId: txOutput.txId, + index: txOutput.index, + tokenId: txOutput.tokenId, + address: txOutput.address, + value: txOutput.value, + authorities: BigInt(txOutput.authorities), + timelock: txOutput.timelock, + heightlock: txOutput.heightlock, + locked: txOutput.locked, + addressPath: '', // Will be filled by mapTxOutputsWithPath + })); + + const { utxos } = transactionUtils.selectUtxos(utxosForSelection, filters.totalAmount); + + // Convert back to DbTxOutput format + finalTxOutputs = utxos.map(utxo => ({ + txId: utxo.txId, + index: utxo.index, + tokenId: utxo.tokenId, + address: utxo.address, + value: utxo.value, + authorities: Number(utxo.authorities), + timelock: utxo.timelock, + heightlock: utxo.heightlock, + locked: utxo.locked, + spentBy: txOutputs.find(tx => tx.txId === utxo.txId && tx.index === utxo.index)?.spentBy, + txProposalId: txOutputs.find(tx => tx.txId === utxo.txId && tx.index === utxo.index)?.txProposalId, + txProposalIndex: txOutputs.find(tx => tx.txId === utxo.txId && tx.index === utxo.index)?.txProposalIndex, + })); + } catch (error) { + // If we don't have enough utxos, return empty array + if (error.message && error.message.includes("Don't have enough utxos")) { + finalTxOutputs = []; + } else { + throw error; + } + } + } + + const txOutputsWithPath: DbTxOutputWithPath[] = mapTxOutputsWithPath(walletAddresses, finalTxOutputs); return { statusCode: 200, diff --git a/packages/wallet-service/src/types.ts b/packages/wallet-service/src/types.ts index 2081beb0..1ae4f7e2 100644 --- a/packages/wallet-service/src/types.ts +++ b/packages/wallet-service/src/types.ts @@ -688,6 +688,7 @@ export interface IFilterTxOutput { skipSpent?: boolean; txId?: string; index?: number; + totalAmount?: bigint; } export enum InputSelectionAlgo { From 7a83e2e8f7ca885c06613bd26d008be7b8f724ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Abadesso?= Date: Tue, 5 Aug 2025 08:53:13 -0300 Subject: [PATCH 2/4] feat: added test to filter utxos --- packages/wallet-service/src/api/txOutputs.ts | 40 ++-- .../wallet-service/tests/txOutputs.test.ts | 186 ++++++++++++++++++ 2 files changed, 197 insertions(+), 29 deletions(-) diff --git a/packages/wallet-service/src/api/txOutputs.ts b/packages/wallet-service/src/api/txOutputs.ts index e5241562..43eb1f6a 100644 --- a/packages/wallet-service/src/api/txOutputs.ts +++ b/packages/wallet-service/src/api/txOutputs.ts @@ -75,6 +75,7 @@ export const getFilteredUtxos = middy(walletIdProxyHandler(async (walletId, even skipSpent: true, // utxo is always unspent txId: queryString.txId, index: queryString.index, + totalAmount: queryString.totalAmount, }; const { value, error } = bodySchema.validate(eventBody, { @@ -127,6 +128,7 @@ export const getFilteredTxOutputs = middy(walletIdProxyHandler(async (walletId, skipSpent: queryString.skipSpent, txId: queryString.txId, index: queryString.index, + totalAmount: queryString.totalAmount, }; const { value, error } = bodySchema.validate(eventBody, { @@ -196,37 +198,17 @@ const _getFilteredTxOutputs = async (walletId: string, filters: IFilterTxOutput) // Apply totalAmount filter if specified if (filters.totalAmount) { try { - // Convert DbTxOutput to format expected by selectUtxos - const utxosForSelection = txOutputs.map(txOutput => ({ - txId: txOutput.txId, - index: txOutput.index, - tokenId: txOutput.tokenId, - address: txOutput.address, - value: txOutput.value, - authorities: BigInt(txOutput.authorities), - timelock: txOutput.timelock, - heightlock: txOutput.heightlock, - locked: txOutput.locked, - addressPath: '', // Will be filled by mapTxOutputsWithPath + const minimalUtxos = txOutputs.map(tx => ({ + ...tx, + authorities: BigInt(tx.authorities), // Convert for compatibility + addressPath: '', // Required by type, but not used by selectUtxos algorithm })); - const { utxos } = transactionUtils.selectUtxos(utxosForSelection, filters.totalAmount); - - // Convert back to DbTxOutput format - finalTxOutputs = utxos.map(utxo => ({ - txId: utxo.txId, - index: utxo.index, - tokenId: utxo.tokenId, - address: utxo.address, - value: utxo.value, - authorities: Number(utxo.authorities), - timelock: utxo.timelock, - heightlock: utxo.heightlock, - locked: utxo.locked, - spentBy: txOutputs.find(tx => tx.txId === utxo.txId && tx.index === utxo.index)?.spentBy, - txProposalId: txOutputs.find(tx => tx.txId === utxo.txId && tx.index === utxo.index)?.txProposalId, - txProposalIndex: txOutputs.find(tx => tx.txId === utxo.txId && tx.index === utxo.index)?.txProposalIndex, - })); + const { utxos } = transactionUtils.selectUtxos(minimalUtxos, filters.totalAmount); + + // Filter original txOutputs to only include the selected ones + const selectedSet = new Set(utxos.map(u => `${u.txId}:${u.index}`)); + finalTxOutputs = txOutputs.filter(tx => selectedSet.has(`${tx.txId}:${tx.index}`)); } catch (error) { // If we don't have enough utxos, return empty array if (error.message && error.message.includes("Don't have enough utxos")) { diff --git a/packages/wallet-service/tests/txOutputs.test.ts b/packages/wallet-service/tests/txOutputs.test.ts index 20234b2d..9f7caa24 100644 --- a/packages/wallet-service/tests/txOutputs.test.ts +++ b/packages/wallet-service/tests/txOutputs.test.ts @@ -889,3 +889,189 @@ test('get spent tx_output', async () => { expect(returnBody.txOutputs[0]).toStrictEqual(formatTxOutput(txOutputs[1], 0)); expect(returnBody.txOutputs[1]).toStrictEqual(formatTxOutput(txOutputs[0], 0)); }); + +test('filter tx_outputs with totalAmount', async () => { + expect.hasAssertions(); + + await addToWalletTable(mysql, [{ + id: 'my-wallet', + xpubkey: 'xpubkey', + authXpubkey: 'auth_xpubkey', + status: 'ready', + maxGap: 5, + createdAt: 10000, + readyAt: 10001, + }]); + + await addToAddressTable(mysql, [{ + address: ADDRESSES[0], + index: 0, + walletId: 'my-wallet', + transactions: 4, + }, { + address: ADDRESSES[1], + index: 1, + walletId: 'my-wallet', + transactions: 4, + }]); + + const token1 = '00'; + + const txOutputs = [{ + txId: TX_IDS[0], + index: 0, + tokenId: token1, + address: ADDRESSES[0], + value: 50n, + authorities: 0, + timelock: null, + heightlock: null, + locked: false, + spentBy: null, + }, { + txId: TX_IDS[1], + index: 0, + tokenId: token1, + address: ADDRESSES[0], + value: 100n, + authorities: 0, + timelock: null, + heightlock: null, + locked: false, + spentBy: null, + }, { + txId: TX_IDS[2], + index: 0, + tokenId: token1, + address: ADDRESSES[1], + value: 200n, + authorities: 0, + timelock: null, + heightlock: null, + locked: false, + spentBy: null, + }, { + txId: TX_IDS[3], + index: 0, + tokenId: token1, + address: ADDRESSES[0], + value: 300n, + authorities: 0, + timelock: null, + heightlock: null, + locked: false, + spentBy: null, + }]; + + await addToUtxoTable(mysql, txOutputs); + + // Test 1: Request exactly what one UTXO can fulfill + let event = makeGatewayEventWithAuthorizer('my-wallet', { + tokenId: token1, + totalAmount: '200', + }, null); + + let result = await getFilteredTxOutputs(event, null, null) as APIGatewayProxyResult; + let returnBody = JSON.parse(result.body as string); + + expect(result.statusCode).toBe(200); + expect(returnBody.success).toBe(true); + expect(returnBody.txOutputs).toHaveLength(1); + // Should select the 200n UTXO (smallest one that can fulfill the amount) + expect(returnBody.txOutputs[0].txId).toBe(TX_IDS[2]); + expect(returnBody.txOutputs[0].value).toBe(200); + + // Test 2: Request amount that requires multiple UTXOs + event = makeGatewayEventWithAuthorizer('my-wallet', { + tokenId: token1, + totalAmount: '250', + }, null); + + result = await getFilteredTxOutputs(event, null, null) as APIGatewayProxyResult; + returnBody = JSON.parse(result.body as string); + + expect(result.statusCode).toBe(200); + expect(returnBody.success).toBe(true); + // Should select the 300n UTXO (smallest single UTXO that can fulfill) + expect(returnBody.txOutputs).toHaveLength(1); + expect(returnBody.txOutputs[0].txId).toBe(TX_IDS[3]); + expect(returnBody.txOutputs[0].value).toBe(300); + + // Test 3: Request amount that requires combining UTXOs + event = makeGatewayEventWithAuthorizer('my-wallet', { + tokenId: token1, + totalAmount: '450', + }, null); + + result = await getFilteredTxOutputs(event, null, null) as APIGatewayProxyResult; + returnBody = JSON.parse(result.body as string); + + expect(result.statusCode).toBe(200); + expect(returnBody.success).toBe(true); + // Should select multiple UTXOs to fulfill the amount + expect(returnBody.txOutputs.length).toBeGreaterThan(1); + const totalValue = returnBody.txOutputs.reduce((sum, utxo) => sum + utxo.value, 0); + expect(totalValue).toBeGreaterThanOrEqual(450); +}); + +test('filter tx_outputs with totalAmount insufficient funds', async () => { + expect.hasAssertions(); + + await addToWalletTable(mysql, [{ + id: 'my-wallet', + xpubkey: 'xpubkey', + authXpubkey: 'auth_xpubkey', + status: 'ready', + maxGap: 5, + createdAt: 10000, + readyAt: 10001, + }]); + + await addToAddressTable(mysql, [{ + address: ADDRESSES[0], + index: 0, + walletId: 'my-wallet', + transactions: 4, + }]); + + const token1 = '00'; + + const txOutputs = [{ + txId: TX_IDS[0], + index: 0, + tokenId: token1, + address: ADDRESSES[0], + value: 50n, + authorities: 0, + timelock: null, + heightlock: null, + locked: false, + spentBy: null, + }, { + txId: TX_IDS[1], + index: 0, + tokenId: token1, + address: ADDRESSES[0], + value: 100n, + authorities: 0, + timelock: null, + heightlock: null, + locked: false, + spentBy: null, + }]; + + await addToUtxoTable(mysql, txOutputs); + + // Request more than available (150n available, request 200n) + const event = makeGatewayEventWithAuthorizer('my-wallet', { + tokenId: token1, + totalAmount: '200', + }, null); + + const result = await getFilteredTxOutputs(event, null, null) as APIGatewayProxyResult; + const returnBody = JSON.parse(result.body as string); + + expect(result.statusCode).toBe(200); + expect(returnBody.success).toBe(true); + expect(returnBody.txOutputs).toHaveLength(0); // Should return empty array when insufficient funds +}); From 805ebf32d779cbbffd3dade8c8675ddac655fe7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Abadesso?= Date: Tue, 5 Aug 2025 11:16:47 -0300 Subject: [PATCH 3/4] feat: totalAmount must be optional --- packages/wallet-service/src/api/txOutputs.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/wallet-service/src/api/txOutputs.ts b/packages/wallet-service/src/api/txOutputs.ts index 43eb1f6a..882ecc04 100644 --- a/packages/wallet-service/src/api/txOutputs.ts +++ b/packages/wallet-service/src/api/txOutputs.ts @@ -44,7 +44,7 @@ const bodySchema = Joi.object({ biggerThan: positiveBigInt.default(0n), // @ts-ignore smallerThan: positiveBigInt.default(constants.MAX_OUTPUT_VALUE + 1n), - totalAmount: positiveBigInt, + totalAmount: positiveBigInt.optional(), maxOutputs: Joi.number().integer().positive().default(constants.MAX_OUTPUTS), skipSpent: Joi.boolean().optional().default(true), txId: Joi.string().optional(), From a6b73458affaa2f547422534c3356f6589d9f53c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Abadesso?= Date: Tue, 5 Aug 2025 11:19:05 -0300 Subject: [PATCH 4/4] tests: strict check for number of utxos --- packages/wallet-service/tests/txOutputs.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/wallet-service/tests/txOutputs.test.ts b/packages/wallet-service/tests/txOutputs.test.ts index 9f7caa24..baf68a1d 100644 --- a/packages/wallet-service/tests/txOutputs.test.ts +++ b/packages/wallet-service/tests/txOutputs.test.ts @@ -1009,7 +1009,7 @@ test('filter tx_outputs with totalAmount', async () => { expect(result.statusCode).toBe(200); expect(returnBody.success).toBe(true); // Should select multiple UTXOs to fulfill the amount - expect(returnBody.txOutputs.length).toBeGreaterThan(1); + expect(returnBody.txOutputs.length).toBe(2); const totalValue = returnBody.txOutputs.reduce((sum, utxo) => sum + utxo.value, 0); expect(totalValue).toBeGreaterThanOrEqual(450); });