From 862a639889c7679b22e3846aa2e38ba02d12c114 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Abadesso?= Date: Fri, 5 Dec 2025 10:01:06 -0300 Subject: [PATCH 1/3] feat(wallet-service): added a new param to the getUtxos endpoint to return utxos that fit inside the requested value --- packages/wallet-service/src/api/txOutputs.ts | 26 ++- packages/wallet-service/src/types.ts | 1 + .../wallet-service/tests/txOutputs.test.ts | 169 ++++++++++++++++++ 3 files changed, 194 insertions(+), 2 deletions(-) diff --git a/packages/wallet-service/src/api/txOutputs.ts b/packages/wallet-service/src/api/txOutputs.ts index 5c840920..6382dd66 100644 --- a/packages/wallet-service/src/api/txOutputs.ts +++ b/packages/wallet-service/src/api/txOutputs.ts @@ -45,11 +45,13 @@ const bodySchema = Joi.object({ // @ts-ignore smallerThan: positiveBigInt.default(constants.MAX_OUTPUT_VALUE + 1n), totalAmount: positiveBigInt.optional(), + maxAmount: positiveBigInt.optional(), maxOutputs: Joi.number().integer().positive().default(constants.MAX_OUTPUTS), skipSpent: Joi.boolean().optional().default(true), txId: Joi.string().optional(), index: Joi.number().optional().min(0), -}).and('txId', 'index'); +}).and('txId', 'index') + .nand('totalAmount', 'maxAmount'); /* * Filter utxos @@ -76,6 +78,7 @@ export const getFilteredUtxos = middy(walletIdProxyHandler(async (walletId, even txId: queryString.txId, index: queryString.index, totalAmount: queryString.totalAmount, + maxAmount: queryString.maxAmount, maxOutputs: queryString.maxOutputs, }; @@ -130,6 +133,7 @@ export const getFilteredTxOutputs = middy(walletIdProxyHandler(async (walletId, txId: queryString.txId, index: queryString.index, totalAmount: queryString.totalAmount, + maxAmount: queryString.maxAmount, maxOutputs: queryString.maxOutputs, }; @@ -197,7 +201,7 @@ const _getFilteredTxOutputs = async (walletId: string, filters: IFilterTxOutput) const txOutputs: DbTxOutput[] = await filterTxOutputs(mysql, newFilters); let finalTxOutputs: DbTxOutput[] = txOutputs; - // Apply totalAmount filter if specified + // Apply totalAmount filter if specified (returns UTXOs summing to at least totalAmount) if (filters.totalAmount) { try { const minimalUtxos = txOutputs.map(tx => ({ @@ -221,6 +225,24 @@ const _getFilteredTxOutputs = async (walletId: string, filters: IFilterTxOutput) } } + // Apply maxAmount filter if specified (returns UTXOs summing to at most maxAmount) + if (filters.maxAmount) { + let accumulatedAmount = 0n; + const selectedTxOutputs: DbTxOutput[] = []; + + // txOutputs are sorted by value DESC from the database, so we iterate + // from smallest to largest to maximize the number of UTXOs within the limit + for (let i = finalTxOutputs.length - 1; i >= 0; i--) { + const txOutput = finalTxOutputs[i]; + if (accumulatedAmount + txOutput.value <= filters.maxAmount) { + selectedTxOutputs.push(txOutput); + accumulatedAmount += txOutput.value; + } + } + + finalTxOutputs = selectedTxOutputs; + } + const txOutputsWithPath: DbTxOutputWithPath[] = mapTxOutputsWithPath(walletAddresses, finalTxOutputs); return { diff --git a/packages/wallet-service/src/types.ts b/packages/wallet-service/src/types.ts index 95e18ede..7ad0d79e 100644 --- a/packages/wallet-service/src/types.ts +++ b/packages/wallet-service/src/types.ts @@ -697,6 +697,7 @@ export interface IFilterTxOutput { txId?: string; index?: number; totalAmount?: bigint; + maxAmount?: bigint; } export enum InputSelectionAlgo { diff --git a/packages/wallet-service/tests/txOutputs.test.ts b/packages/wallet-service/tests/txOutputs.test.ts index b0f80e47..31ff63c8 100644 --- a/packages/wallet-service/tests/txOutputs.test.ts +++ b/packages/wallet-service/tests/txOutputs.test.ts @@ -1165,3 +1165,172 @@ test('filter tx_outputs with totalAmount insufficient funds', async () => { expect(returnBody.success).toBe(true); expect(returnBody.txOutputs).toHaveLength(0); // Should return empty array when insufficient funds }); + +test('filter tx_outputs with maxAmount', 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'; + + // Create UTXOs with values: 50, 100, 200, 300 (total: 650) + 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 maxAmount of 150 - should get UTXOs summing to at most 150 + let event = makeGatewayEventWithAuthorizer('my-wallet', { + tokenId: token1, + maxAmount: '150', + }, 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); + // Should select 50 + 100 = 150 (iterating from smallest) + const totalValue1 = returnBody.txOutputs.reduce((sum, utxo) => sum + utxo.value, 0); + expect(totalValue1).toBeLessThanOrEqual(150); + expect(totalValue1).toBe(150); // Exact match: 50 + 100 + + // Test 2: Request maxAmount of 55 - should get only the 50 UTXO + event = makeGatewayEventWithAuthorizer('my-wallet', { + tokenId: token1, + maxAmount: '55', + }, 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); + expect(returnBody.txOutputs).toHaveLength(1); + expect(returnBody.txOutputs[0].value).toBe(50); + + // Test 3: Request maxAmount of 350 - should get 50 + 100 + 200 = 350 + event = makeGatewayEventWithAuthorizer('my-wallet', { + tokenId: token1, + maxAmount: '350', + }, 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); + const totalValue3 = returnBody.txOutputs.reduce((sum, utxo) => sum + utxo.value, 0); + expect(totalValue3).toBeLessThanOrEqual(350); + expect(totalValue3).toBe(350); // Exact match: 50 + 100 + 200 + + // Test 4: Request maxAmount of 10 - should get no UTXOs (smallest is 50) + event = makeGatewayEventWithAuthorizer('my-wallet', { + tokenId: token1, + maxAmount: '10', + }, 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); + expect(returnBody.txOutputs).toHaveLength(0); +}); + +test('filter tx_outputs with both totalAmount and maxAmount should fail', 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: 1, + }]); + + const event = makeGatewayEventWithAuthorizer('my-wallet', { + tokenId: '00', + totalAmount: '100', + maxAmount: '200', + }, null); + + const result = await getFilteredTxOutputs(event, null, null) as APIGatewayProxyResult; + const returnBody = JSON.parse(result.body as string); + + expect(result.statusCode).toBe(400); + expect(returnBody.success).toBe(false); + expect(returnBody.error).toBe('invalid-payload'); +}); From 44c54802abfb9a332820310c6691c32ddeaf30d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Abadesso?= Date: Fri, 5 Dec 2025 19:23:12 -0300 Subject: [PATCH 2/3] fix(daemon): added TOKEN_CREATED to zod --- packages/daemon/src/types/event.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/daemon/src/types/event.ts b/packages/daemon/src/types/event.ts index fcf70d8f..cbcfa19a 100644 --- a/packages/daemon/src/types/event.ts +++ b/packages/daemon/src/types/event.ts @@ -45,6 +45,7 @@ export enum FullNodeEventTypes { REORG_STARTED = 'REORG_STARTED', REORG_FINISHED = 'REORG_FINISHED', NC_EVENT = 'NC_EVENT', + TOKEN_CREATED = 'TOKEN_CREATED', } /** @@ -229,12 +230,30 @@ export const NcEventSchema = FullNodeEventBaseSchema.extend({ }); export type NcEvent = z.infer; +export const TokenCreatedEventSchema = FullNodeEventBaseSchema.extend({ + event: z.object({ + id: z.number(), + timestamp: z.number(), + type: z.literal('TOKEN_CREATED'), + data: z.object({ + token_uid: z.string(), + nc_exec_info: z.unknown().nullable(), + token_name: z.string(), + token_symbol: z.string(), + token_version: z.number(), + }), + group_id: z.number().nullish(), + }), +}); +export type TokenCreatedEvent = z.infer; + export const FullNodeEventSchema = z.union([ TxDataWithoutMetaFullNodeEventSchema, StandardFullNodeEventSchema, ReorgFullNodeEventSchema, EmptyDataFullNodeEventSchema, NcEventSchema, + TokenCreatedEventSchema, ]); export type FullNodeEvent = z.infer; From 3945546ce1fc377ff0c0975b55a805cd8bc2147f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Abadesso?= Date: Wed, 28 Jan 2026 12:23:40 -0300 Subject: [PATCH 3/3] refactor(wallet-service): maximize utxos instead of minimizing --- packages/daemon/acceptance.md | 29 ------------------- packages/daemon/explanation.md | 1 - packages/wallet-service/src/api/txOutputs.ts | 5 ++-- .../wallet-service/tests/txOutputs.test.ts | 8 ++--- 4 files changed, 6 insertions(+), 37 deletions(-) delete mode 100644 packages/daemon/acceptance.md delete mode 100644 packages/daemon/explanation.md diff --git a/packages/daemon/acceptance.md b/packages/daemon/acceptance.md deleted file mode 100644 index 78717b8b..00000000 --- a/packages/daemon/acceptance.md +++ /dev/null @@ -1,29 +0,0 @@ -### Motivation - -A single `VERTEX_METADATA_CHANGED` event can contain multiple independent changes (e.g., `nc_execution` voided AND `first_block` changed during reorg). Previously `metadataDiff` returned one type and routed to one handler, losing the second change. - -### Acceptance Criteria - -- `metadataDiff` detects all independent metadata changes in a single event and returns them as an array -- A dispatch queue processes each change one-by-one before returning to idle -- `handleNcExecVoided` no longer needs to know about `first_block` — each handler has a single responsibility -- No new dependencies added - -### Checklist -- [ ] If you are requesting a merge into `master`, confirm this code is production-ready and can be included in future releases as soon as it gets merged -- [ ] Make sure either the unit tests and/or the QA tests are capable of testing the new features -- [ ] Make sure you do not include new dependencies in the project unless strictly necessary and do not include dev-dependencies as production ones. More dependencies increase the possibility of one of them being hijacked and affecting us. - -### What changed - -**`metadataDiff`** now returns `{ types: string[], originalEvent }` instead of `{ type, originalEvent }`. Mutually exclusive changes (voided/unvoided/new) still return a single element. Independent changes (`NC_EXEC_VOIDED`, `TX_FIRST_BLOCK`) are collected into the same array. - -**`handlingMetadataChanged`** gains a `dispatching` substate that loops: it reads `context.pendingMetadataChanges[0]`, routes to the matching handler via `always` guards, and shifts the queue. Each handler's `onDone` returns to the dispatcher. When the queue is empty, it falls through to `handlingUnhandledEvent` → idle + sendAck. - -**New actions**: `storeMetadataChanges` (stores the types array and original event on `onDone` from `metadataDiff`) and `shiftMetadataChange` (pops the first element). - -**New guards**: `nextChangeIsVoided`, `nextChangeIsUnvoided`, `nextChangeIsNewTx`, `nextChangeIsFirstBlock`, `nextChangeIsNcExecVoided` — all check `context.pendingMetadataChanges[0]`. - -**`handleNcExecVoided`** simplified: only deletes nano tokens + updates last synced event. The first_block detection/chaining logic is removed since the dispatcher handles it. - -**Removed**: `METADATA_DECIDED` event type, `MetadataDecidedEvent` type, `metadataDecided` raise action, `unwrapEvent` action, all old metadata guards, `ncExecVoidedFirstBlockChanged` guard. diff --git a/packages/daemon/explanation.md b/packages/daemon/explanation.md deleted file mode 100644 index 040769e3..00000000 --- a/packages/daemon/explanation.md +++ /dev/null @@ -1 +0,0 @@ -When a `VERTEX_METADATA_CHANGED` event arrives, the machine invokes `metadataDiff` to compare the event's metadata against the database. It detects all changes and returns them as an array of types. Mutually exclusive changes (TX_NEW, TX_VOIDED, TX_UNVOIDED) return immediately as a single-element array. Independent changes (NC_EXEC_VOIDED, TX_FIRST_BLOCK) are collected together — a single event can carry both. A `dispatching` state then processes the queue one by one: it matches the first element to the corresponding handler, executes it, and loops back to dispatch the next. When the queue is empty, it falls through to `handlingUnhandledEvent`, which updates the last synced event and sends the ACK. diff --git a/packages/wallet-service/src/api/txOutputs.ts b/packages/wallet-service/src/api/txOutputs.ts index 6382dd66..6b39e961 100644 --- a/packages/wallet-service/src/api/txOutputs.ts +++ b/packages/wallet-service/src/api/txOutputs.ts @@ -231,9 +231,8 @@ const _getFilteredTxOutputs = async (walletId: string, filters: IFilterTxOutput) const selectedTxOutputs: DbTxOutput[] = []; // txOutputs are sorted by value DESC from the database, so we iterate - // from smallest to largest to maximize the number of UTXOs within the limit - for (let i = finalTxOutputs.length - 1; i >= 0; i--) { - const txOutput = finalTxOutputs[i]; + // from largest to smallest to minimize the number of UTXOs within the limit + for (const txOutput of finalTxOutputs) { if (accumulatedAmount + txOutput.value <= filters.maxAmount) { selectedTxOutputs.push(txOutput); accumulatedAmount += txOutput.value; diff --git a/packages/wallet-service/tests/txOutputs.test.ts b/packages/wallet-service/tests/txOutputs.test.ts index 31ff63c8..88b6861e 100644 --- a/packages/wallet-service/tests/txOutputs.test.ts +++ b/packages/wallet-service/tests/txOutputs.test.ts @@ -1253,10 +1253,10 @@ test('filter tx_outputs with maxAmount', async () => { expect(result.statusCode).toBe(200); expect(returnBody.success).toBe(true); - // Should select 50 + 100 = 150 (iterating from smallest) + // Should select 100 + 50 = 150 (iterating from largest to smallest to minimize UTXO count) const totalValue1 = returnBody.txOutputs.reduce((sum, utxo) => sum + utxo.value, 0); expect(totalValue1).toBeLessThanOrEqual(150); - expect(totalValue1).toBe(150); // Exact match: 50 + 100 + expect(totalValue1).toBe(150); // Exact match: 100 + 50 // Test 2: Request maxAmount of 55 - should get only the 50 UTXO event = makeGatewayEventWithAuthorizer('my-wallet', { @@ -1272,7 +1272,7 @@ test('filter tx_outputs with maxAmount', async () => { expect(returnBody.txOutputs).toHaveLength(1); expect(returnBody.txOutputs[0].value).toBe(50); - // Test 3: Request maxAmount of 350 - should get 50 + 100 + 200 = 350 + // Test 3: Request maxAmount of 350 - should get 300 + 50 = 350 (minimizing UTXO count) event = makeGatewayEventWithAuthorizer('my-wallet', { tokenId: token1, maxAmount: '350', @@ -1285,7 +1285,7 @@ test('filter tx_outputs with maxAmount', async () => { expect(returnBody.success).toBe(true); const totalValue3 = returnBody.txOutputs.reduce((sum, utxo) => sum + utxo.value, 0); expect(totalValue3).toBeLessThanOrEqual(350); - expect(totalValue3).toBe(350); // Exact match: 50 + 100 + 200 + expect(totalValue3).toBe(350); // Exact match: 300 + 50 // Test 4: Request maxAmount of 10 - should get no UTXOs (smallest is 50) event = makeGatewayEventWithAuthorizer('my-wallet', {