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
33 changes: 31 additions & 2 deletions packages/wallet-service/src/api/txOutputs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -44,6 +44,7 @@ const bodySchema = Joi.object({
biggerThan: positiveBigInt.default(0n),
// @ts-ignore
smallerThan: positiveBigInt.default(constants.MAX_OUTPUT_VALUE + 1n),
totalAmount: positiveBigInt.optional(),
maxOutputs: Joi.number().integer().positive().default(constants.MAX_OUTPUTS),
skipSpent: Joi.boolean().optional().default(true),
txId: Joi.string().optional(),
Expand Down Expand Up @@ -74,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,
maxOutputs: queryString.maxOutputs,
};

Expand Down Expand Up @@ -127,6 +129,7 @@ export const getFilteredTxOutputs = middy(walletIdProxyHandler(async (walletId,
skipSpent: queryString.skipSpent,
txId: queryString.txId,
index: queryString.index,
totalAmount: queryString.totalAmount,
maxOutputs: queryString.maxOutputs,
};

Expand Down Expand Up @@ -192,7 +195,33 @@ 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 {
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(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")) {
finalTxOutputs = [];
} else {
throw error;
}
}
}

const txOutputsWithPath: DbTxOutputWithPath[] = mapTxOutputsWithPath(walletAddresses, finalTxOutputs);

return {
statusCode: 200,
Expand Down
1 change: 1 addition & 0 deletions packages/wallet-service/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -688,6 +688,7 @@ export interface IFilterTxOutput {
skipSpent?: boolean;
txId?: string;
index?: number;
totalAmount?: bigint;
}

export enum InputSelectionAlgo {
Expand Down
186 changes: 186 additions & 0 deletions packages/wallet-service/tests/txOutputs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -979,3 +979,189 @@ test('filter utxos by addresses and max utxos', async () => {
expect(returnBody3.txOutputs.length).toStrictEqual(1);
expect(returnBody3.txOutputs[0].address).toStrictEqual(ADDRESSES[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).toBe(2);
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
});