From 89fdbe1b3e40cfbffffb8dd6111aa919550d140d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Abadesso?= Date: Wed, 20 Aug 2025 23:55:24 -0300 Subject: [PATCH 01/49] fix: we should unspend transaction tx outputs when the tx spending it is voided --- .../__tests__/services/services.test.ts | 24 +- .../services/voidingWithInputs.test.ts | 488 ++++++++++++++++++ packages/daemon/src/db/index.ts | 47 +- packages/daemon/src/services/index.ts | 10 + 4 files changed, 544 insertions(+), 25 deletions(-) create mode 100644 packages/daemon/__tests__/services/voidingWithInputs.test.ts diff --git a/packages/daemon/__tests__/services/services.test.ts b/packages/daemon/__tests__/services/services.test.ts index 1c87fc9b..5697434d 100644 --- a/packages/daemon/__tests__/services/services.test.ts +++ b/packages/daemon/__tests__/services/services.test.ts @@ -544,11 +544,11 @@ describe('handleVertexAccepted', () => { (getAddressBalanceMap as jest.Mock).mockReturnValue({}); (getUtxosLockedAtHeight as jest.Mock).mockResolvedValue([]); (hashTxData as jest.Mock).mockReturnValue('hashedData'); - (getAddressWalletInfo as jest.Mock).mockResolvedValue({ + (getAddressWalletInfo as jest.Mock).mockResolvedValue({ 'address1': { - walletId: 'wallet1', - xpubkey: 'xpubkey1', - maxGap: 10 + walletId: 'wallet1', + xpubkey: 'xpubkey1', + maxGap: 10 }, }); @@ -602,11 +602,11 @@ describe('handleVertexAccepted', () => { (getAddressBalanceMap as jest.Mock).mockReturnValue({}); (getUtxosLockedAtHeight as jest.Mock).mockResolvedValue([]); (hashTxData as jest.Mock).mockReturnValue('hashedData'); - (getAddressWalletInfo as jest.Mock).mockResolvedValue({ + (getAddressWalletInfo as jest.Mock).mockResolvedValue({ 'address1': { - walletId: 'wallet1', - xpubkey: 'xpubkey1', - maxGap: 10 + walletId: 'wallet1', + xpubkey: 'xpubkey1', + maxGap: 10 }, }); (getWalletBalancesForTx as jest.Mock).mockResolvedValue({ 'mockWallet': {} }); @@ -659,11 +659,11 @@ describe('handleVertexAccepted', () => { (getAddressBalanceMap as jest.Mock).mockReturnValue({}); (getUtxosLockedAtHeight as jest.Mock).mockResolvedValue([]); (hashTxData as jest.Mock).mockReturnValue('hashedData'); - (getAddressWalletInfo as jest.Mock).mockResolvedValue({ + (getAddressWalletInfo as jest.Mock).mockResolvedValue({ 'address1': { - walletId: 'wallet1', - xpubkey: 'xpubkey1', - maxGap: 10 + walletId: 'wallet1', + xpubkey: 'xpubkey1', + maxGap: 10 }, }); diff --git a/packages/daemon/__tests__/services/voidingWithInputs.test.ts b/packages/daemon/__tests__/services/voidingWithInputs.test.ts new file mode 100644 index 00000000..4f424646 --- /dev/null +++ b/packages/daemon/__tests__/services/voidingWithInputs.test.ts @@ -0,0 +1,488 @@ +/** + * Copyright (c) Hathor Labs and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * @jest-environment node + */ + +import { Connection } from 'mysql2/promise'; +import { + getDbConnection, + addOrUpdateTx, + addUtxos, + updateTxOutputSpentBy, + getTxOutput, + unspendUtxos, +} from '../../src/db'; +import { voidTx } from '../../src/services'; +import { + cleanDatabase, + checkUtxoTable, + createOutput, + createInput, + createEventTxInput, +} from '../utils'; +import { DbTxOutput } from '../../src/types'; + +// Use a single mysql connection for all tests +let mysql: Connection; + +beforeAll(async () => { + try { + mysql = await getDbConnection(); + } catch (e) { + console.error('Failed to establish db connection', e); + throw e; + } +}); + +afterAll(async () => { + await mysql.destroy(); +}); + +beforeEach(async () => { + await cleanDatabase(mysql); +}); + +describe('voidTransaction with input unspending', () => { + it('should unspent inputs when voiding a transaction', async () => { + expect.hasAssertions(); + + // Create transaction A that creates an output + const txIdA = 'tx-a'; + const addressA = 'address-a'; + const tokenId = '00'; + const outputValue = 100n; + + await addOrUpdateTx(mysql, txIdA, 0, 1, 1, 100); + + // Add output from transaction A + const outputA = createOutput(0, outputValue, addressA, tokenId); + await addUtxos(mysql, txIdA, [outputA], null); + + // Verify the UTXO is unspent + let utxo = await getTxOutput(mysql, txIdA, 0, true); + expect(utxo).not.toBeNull(); + expect(utxo!.spentBy).toBeNull(); + + // Create transaction B that spends the output from transaction A + const txIdB = 'tx-b'; + const addressB = 'address-b'; + + await addOrUpdateTx(mysql, txIdB, 0, 1, 1, 101); + + // Mark the output from A as spent by B + const inputB = createInput(outputValue, addressA, txIdA, 0, tokenId); + await updateTxOutputSpentBy(mysql, [inputB], txIdB); + + // Verify the UTXO is now spent + utxo = await getTxOutput(mysql, txIdA, 0, false); + expect(utxo).not.toBeNull(); + expect(utxo!.spentBy).toBe(txIdB); + + // Add output from transaction B + const outputB = createOutput(0, outputValue, addressB, tokenId); + await addUtxos(mysql, txIdB, [outputB], null); + + // Now void transaction B using the voidTx service function + const inputs = [createEventTxInput(outputValue, addressA, txIdA, 0)]; + const outputs = [{ + value: outputValue, + locked: false, + decoded: { + type: 'P2PKH' as const, + address: addressB, + timelock: null, + }, + token_data: 0, + script: 'dqkUH70YjKeoKdFwMX2TOYvGVbXOrKaIrA==', + }]; + + await voidTx(mysql, txIdB, inputs, outputs, [tokenId], []); + + // CRITICAL: The voidTx function should unspent the inputs + // This test should PASS because unspending is now implemented in voidTx + + // Check if the UTXO from transaction A is unspent again + utxo = await getTxOutput(mysql, txIdA, 0, true); + expect(utxo).not.toBeNull(); + expect(utxo!.spentBy).toBeNull(); // This should pass - it should be null + }); + + it('should unspent multiple inputs when voiding a transaction with multiple inputs', async () => { + expect.hasAssertions(); + + // Create transactions A and B that create outputs + const txIdA = 'tx-a'; + const txIdB = 'tx-b'; + const txIdC = 'tx-c'; // The transaction we'll void + const address1 = 'address-1'; + const address2 = 'address-2'; + const address3 = 'address-3'; + const tokenId = '00'; + + // Create two UTXOs + await addOrUpdateTx(mysql, txIdA, 0, 1, 1, 100); + await addOrUpdateTx(mysql, txIdB, 0, 1, 1, 101); + + const outputA = createOutput(0, 50n, address1, tokenId); + const outputB = createOutput(0, 75n, address2, tokenId); + + await addUtxos(mysql, txIdA, [outputA], null); + await addUtxos(mysql, txIdB, [outputB], null); + + // Verify both UTXOs are unspent + let utxoA = await getTxOutput(mysql, txIdA, 0, true); + let utxoB = await getTxOutput(mysql, txIdB, 0, true); + expect(utxoA!.spentBy).toBeNull(); + expect(utxoB!.spentBy).toBeNull(); + + // Create transaction C that spends both outputs + await addOrUpdateTx(mysql, txIdC, 0, 1, 1, 102); + + const inputC1 = createInput(50n, address1, txIdA, 0, tokenId); + const inputC2 = createInput(75n, address2, txIdB, 0, tokenId); + await updateTxOutputSpentBy(mysql, [inputC1, inputC2], txIdC); + + // Verify both UTXOs are now spent by C + utxoA = await getTxOutput(mysql, txIdA, 0, false); + utxoB = await getTxOutput(mysql, txIdB, 0, false); + expect(utxoA!.spentBy).toBe(txIdC); + expect(utxoB!.spentBy).toBe(txIdC); + + // Add output from transaction C + const outputC = createOutput(0, 125n, address3, tokenId); + await addUtxos(mysql, txIdC, [outputC], null); + + // Void transaction C using voidTx service function + const inputs = [ + createEventTxInput(50n, address1, txIdA, 0), + createEventTxInput(75n, address2, txIdB, 0), + ]; + const outputs = [{ + value: 125n, + locked: false, + decoded: { + type: 'P2PKH' as const, + address: address3, + timelock: null, + }, + token_data: 0, + script: 'dqkUH70YjKeoKdFwMX2TOYvGVbXOrKaIrA==', + }]; + + await voidTx(mysql, txIdC, inputs, outputs, [tokenId], []); + + // Check if both UTXOs from transactions A and B are unspent again + utxoA = await getTxOutput(mysql, txIdA, 0, true); + utxoB = await getTxOutput(mysql, txIdB, 0, true); + + // These assertions should PASS because unspending is now implemented + expect(utxoA).not.toBeNull(); + expect(utxoA!.spentBy).toBeNull(); // Should pass - should be null + expect(utxoB).not.toBeNull(); + expect(utxoB!.spentBy).toBeNull(); // Should pass - should be null + }); + + it('should handle voiding a transaction that spends already voided outputs', async () => { + expect.hasAssertions(); + + // Create transaction A that creates an output + const txIdA = 'tx-a'; + const txIdB = 'tx-b'; // Will be voided first + const txIdC = 'tx-c'; // Will be voided second + const address1 = 'address-1'; + const address2 = 'address-2'; + const address3 = 'address-3'; + const tokenId = '00'; + + await addOrUpdateTx(mysql, txIdA, 0, 1, 1, 100); + const outputA = createOutput(0, 100n, address1, tokenId); + await addUtxos(mysql, txIdA, [outputA], null); + + // Transaction B spends A's output + await addOrUpdateTx(mysql, txIdB, 0, 1, 1, 101); + const inputB = createInput(100n, address1, txIdA, 0, tokenId); + await updateTxOutputSpentBy(mysql, [inputB], txIdB); + const outputB = createOutput(0, 100n, address2, tokenId); + await addUtxos(mysql, txIdB, [outputB], null); + + // Transaction C spends B's output + await addOrUpdateTx(mysql, txIdC, 0, 1, 1, 102); + const inputC = createInput(100n, address2, txIdB, 0, tokenId); + await updateTxOutputSpentBy(mysql, [inputC], txIdC); + const outputC = createOutput(0, 100n, address3, tokenId); + await addUtxos(mysql, txIdC, [outputC], null); + + // First void transaction C + await voidTx(mysql, txIdC, + [createEventTxInput(100n, address2, txIdB, 0)], + [{ + value: 100n, + locked: false, + decoded: { type: 'P2PKH' as const, address: address3, timelock: null }, + token_data: 0, + script: 'dqkUH70YjKeoKdFwMX2TOYvGVbXOrKaIrA==', + }], + [tokenId], + [] + ); + + // B's output should be unspent now (and it will be with the fix) + let utxoB = await getTxOutput(mysql, txIdB, 0, true); + expect(utxoB).not.toBeNull(); + expect(utxoB!.spentBy).toBeNull(); // Should pass - should be null + + // Now void transaction B + await voidTx(mysql, txIdB, + [createEventTxInput(100n, address1, txIdA, 0)], + [{ + value: 100n, + locked: false, + decoded: { type: 'P2PKH' as const, address: address2, timelock: null }, + token_data: 0, + script: 'dqkUH70YjKeoKdFwMX2TOYvGVbXOrKaIrA==', + }], + [tokenId], + [] + ); + + // A's output should be unspent now + let utxoA = await getTxOutput(mysql, txIdA, 0, true); + expect(utxoA).not.toBeNull(); + expect(utxoA!.spentBy).toBeNull(); // Should pass - should be null + }); + + it('should handle voiding when one input is already spent by another transaction', async () => { + expect.hasAssertions(); + + // This tests an edge case where we try to void a transaction + // but one of its inputs was already spent by another transaction + // (which shouldn't happen in practice but we should handle gracefully) + + const txIdA = 'tx-a'; + const txIdB = 'tx-b'; + const txIdC = 'tx-c'; // Will try to spend A's output after B already spent it + const address1 = 'address-1'; + const address2 = 'address-2'; + const tokenId = '00'; + + // Create UTXO + await addOrUpdateTx(mysql, txIdA, 0, 1, 1, 100); + const outputA = createOutput(0, 100n, address1, tokenId); + await addUtxos(mysql, txIdA, [outputA], null); + + // Transaction B spends it + await addOrUpdateTx(mysql, txIdB, 0, 1, 1, 101); + const inputB = createInput(100n, address1, txIdA, 0, tokenId); + await updateTxOutputSpentBy(mysql, [inputB], txIdB); + + // For this test, we simulate that transaction C also tried to spend it + // (in reality this would be a double-spend, but we're testing edge cases) + await addOrUpdateTx(mysql, txIdC, 0, 1, 1, 102); + + // Add output for transaction C + const outputC = createOutput(0, 100n, address2, tokenId); + await addUtxos(mysql, txIdC, [outputC], null); + + // Now void transaction C which claims to spend an already-spent output + const inputs = [createEventTxInput(100n, address1, txIdA, 0)]; + const outputs = [{ + value: 100n, + locked: false, + decoded: { type: 'P2PKH' as const, address: address2, timelock: null }, + token_data: 0, + script: 'dqkUH70YjKeoKdFwMX2TOYvGVbXOrKaIrA==', + }]; + + await voidTx(mysql, txIdC, inputs, outputs, [tokenId], []); + + // The UTXO should still be spent by B, not unspent + const utxo = await getTxOutput(mysql, txIdA, 0, false); + expect(utxo).not.toBeNull(); + expect(utxo!.spentBy).toBe(txIdB); // Should remain spent by B + }); + + it('should correctly unspent inputs with different token types', async () => { + expect.hasAssertions(); + + const txIdA = 'tx-a'; + const txIdB = 'tx-b'; + const address1 = 'address-1'; + const address2 = 'address-2'; + const hathorToken = '00'; + const customToken = 'custom-token-id'; + + // Create two UTXOs with different tokens + await addOrUpdateTx(mysql, txIdA, 0, 1, 1, 100); + const outputA1 = createOutput(0, 100n, address1, hathorToken); + const outputA2 = createOutput(1, 50n, address1, customToken); + await addUtxos(mysql, txIdA, [outputA1, outputA2], null); + + // Transaction B spends both + await addOrUpdateTx(mysql, txIdB, 0, 1, 1, 101); + const inputB1 = createInput(100n, address1, txIdA, 0, hathorToken); + const inputB2 = createInput(50n, address1, txIdA, 1, customToken); + await updateTxOutputSpentBy(mysql, [inputB1, inputB2], txIdB); + + // Add outputs for transaction B + const outputB1 = createOutput(0, 100n, address2, hathorToken); + const outputB2 = createOutput(1, 50n, address2, customToken); + await addUtxos(mysql, txIdB, [outputB1, outputB2], null); + + // Void transaction B + const inputs = [ + createEventTxInput(100n, address1, txIdA, 0), + createEventTxInput(50n, address1, txIdA, 1), + ]; + const outputs = [ + { + value: 100n, + locked: false, + decoded: { type: 'P2PKH' as const, address: address2, timelock: null }, + token_data: 0, + script: 'dqkUH70YjKeoKdFwMX2TOYvGVbXOrKaIrA==', + }, + { + value: 50n, + locked: false, + decoded: { type: 'P2PKH' as const, address: address2, timelock: null }, + token_data: 0, + script: 'dqkUH70YjKeoKdFwMX2TOYvGVbXOrKaIrA==', + } + ]; + + await voidTx(mysql, txIdB, inputs, outputs, [hathorToken, customToken], []); + + // Both UTXOs should be unspent + const utxo1 = await getTxOutput(mysql, txIdA, 0, true); + const utxo2 = await getTxOutput(mysql, txIdA, 1, true); + + // These should pass with the implementation + expect(utxo1).not.toBeNull(); + expect(utxo1!.spentBy).toBeNull(); + expect(utxo2).not.toBeNull(); + expect(utxo2!.spentBy).toBeNull(); + }); + + it('should verify the complete flow with balance checks', async () => { + expect.hasAssertions(); + + // Complete integration test + const txIdA = 'tx-a'; + const txIdB = 'tx-b'; + const address1 = 'address-1'; + const address2 = 'address-2'; + const tokenId = '00'; + const value = 200n; + + // Setup initial UTXO + await addOrUpdateTx(mysql, txIdA, 0, 1, 1, 100); + const outputA = createOutput(0, value, address1, tokenId); + await addUtxos(mysql, txIdA, [outputA], null); + + // Verify initial state + await expect(checkUtxoTable(mysql, 1, txIdA, 0, tokenId, address1, value, 0, null, null, false, null)).resolves.toBe(true); + + // Create spending transaction + await addOrUpdateTx(mysql, txIdB, 0, 1, 1, 101); + const inputB = createInput(value, address1, txIdA, 0, tokenId); + await updateTxOutputSpentBy(mysql, [inputB], txIdB); + const outputB = createOutput(0, value, address2, tokenId); + await addUtxos(mysql, txIdB, [outputB], null); + + // Verify spent state + const spentUtxo = await getTxOutput(mysql, txIdA, 0, false); + expect(spentUtxo!.spentBy).toBe(txIdB); + + // Void the spending transaction + const inputs = [createEventTxInput(value, address1, txIdA, 0)]; + const outputs = [{ + value, + locked: false, + decoded: { type: 'P2PKH' as const, address: address2, timelock: null }, + token_data: 0, + script: 'dqkUH70YjKeoKdFwMX2TOYvGVbXOrKaIrA==', + }]; + + await voidTx(mysql, txIdB, inputs, outputs, [tokenId], []); + + // Verify the original UTXO is unspent again + const unspentUtxo = await getTxOutput(mysql, txIdA, 0, true); + expect(unspentUtxo).not.toBeNull(); + expect(unspentUtxo!.spentBy).toBeNull(); // This should pass + + // Also verify that B's outputs are marked as voided + const voidedUtxo = await getTxOutput(mysql, txIdB, 0, false); + expect(voidedUtxo).toBeNull(); // Should be null because it's voided + }); +}); + +describe('unspentTxOutputs function', () => { + it('should correctly unspent transaction outputs', async () => { + expect.hasAssertions(); + + // This tests the unspentTxOutputs function directly + const txIdA = 'tx-a'; + const txIdB = 'tx-b'; + const txIdC = 'tx-c'; + const spendingTx = 'spending-tx'; + const address = 'test-address'; + const tokenId = '00'; + + // Create multiple UTXOs + await addOrUpdateTx(mysql, txIdA, 0, 1, 1, 100); + await addOrUpdateTx(mysql, txIdB, 0, 1, 1, 101); + await addOrUpdateTx(mysql, txIdC, 0, 1, 1, 102); + + const outputs = [ + createOutput(0, 50n, address, tokenId), + createOutput(0, 75n, address, tokenId), + createOutput(0, 100n, address, tokenId), + ]; + + await addUtxos(mysql, txIdA, [outputs[0]], null); + await addUtxos(mysql, txIdB, [outputs[1]], null); + await addUtxos(mysql, txIdC, [outputs[2]], null); + + // Mark them all as spent + const inputs = [ + createInput(50n, address, txIdA, 0, tokenId), + createInput(75n, address, txIdB, 0, tokenId), + createInput(100n, address, txIdC, 0, tokenId), + ]; + await updateTxOutputSpentBy(mysql, inputs, spendingTx); + + // Verify they are spent + let utxoA = await getTxOutput(mysql, txIdA, 0, false); + let utxoB = await getTxOutput(mysql, txIdB, 0, false); + let utxoC = await getTxOutput(mysql, txIdC, 0, false); + expect(utxoA!.spentBy).toBe(spendingTx); + expect(utxoB!.spentBy).toBe(spendingTx); + expect(utxoC!.spentBy).toBe(spendingTx); + + // Now unspent them + const txOutputsToUnspent: DbTxOutput[] = [ + { txId: txIdA, index: 0, tokenId, address, value: 50n, authorities: 0, timelock: null, heightlock: null, locked: false, spentBy: spendingTx, txProposalId: null, txProposalIndex: null }, + { txId: txIdB, index: 0, tokenId, address, value: 75n, authorities: 0, timelock: null, heightlock: null, locked: false, spentBy: spendingTx, txProposalId: null, txProposalIndex: null }, + { txId: txIdC, index: 0, tokenId, address, value: 100n, authorities: 0, timelock: null, heightlock: null, locked: false, spentBy: spendingTx, txProposalId: null, txProposalIndex: null }, + ]; + + await unspendUtxos(mysql, txOutputsToUnspent); + + // Verify they are unspent + utxoA = await getTxOutput(mysql, txIdA, 0, true); + utxoB = await getTxOutput(mysql, txIdB, 0, true); + utxoC = await getTxOutput(mysql, txIdC, 0, true); + expect(utxoA).not.toBeNull(); + expect(utxoA!.spentBy).toBeNull(); + expect(utxoB).not.toBeNull(); + expect(utxoB!.spentBy).toBeNull(); + expect(utxoC).not.toBeNull(); + expect(utxoC!.spentBy).toBeNull(); + }); +}); \ No newline at end of file diff --git a/packages/daemon/src/db/index.ts b/packages/daemon/src/db/index.ts index 63aa0696..bce459ee 100644 --- a/packages/daemon/src/db/index.ts +++ b/packages/daemon/src/db/index.ts @@ -245,7 +245,7 @@ export const getTxOutputsFromTx = async ( */ export const getTxOutputs = async ( mysql: any, - inputs: {txId: string, index: number}[], + inputs: { txId: string, index: number }[], ): Promise => { if (inputs.length <= 0) return []; const txIdIndexPair = inputs.map((utxo) => [utxo.txId, utxo.index]); @@ -714,12 +714,12 @@ export const updateAddressLockedBalance = async ( \`unlocked_authorities\` = (unlocked_authorities | ?) WHERE \`address\` = ? AND \`token_id\` = ?`, [ - tokenBalance.unlockedAmount, - tokenBalance.unlockedAmount, - tokenBalance.unlockedAuthorities.toInteger(), - address, - token, - ], + tokenBalance.unlockedAmount, + tokenBalance.unlockedAmount, + tokenBalance.unlockedAuthorities.toInteger(), + address, + token, + ], ); // if any authority has been unlocked, we have to refresh the locked authorities @@ -755,7 +755,7 @@ export const updateAddressLockedBalance = async ( ) WHERE \`address\` = ? AND \`token_id\` = ?`, - [address, token, address, token]); + [address, token, address, token]); } } } @@ -830,7 +830,7 @@ export const updateWalletLockedBalance = async ( WHERE \`wallet_id\` = ? AND \`token_id\` = ?`, [tokenBalance.unlockedAmount, tokenBalance.unlockedAmount, - tokenBalance.unlockedAuthorities.toInteger(), walletId, token], + tokenBalance.unlockedAuthorities.toInteger(), walletId, token], ); // if any authority has been unlocked, we have to refresh the locked authorities @@ -1248,6 +1248,27 @@ export const getTxOutputsBySpent = async ( return utxos; }; +/** + * Get all UTXOs that were spent by a specific transaction + * + * @param mysql - Database connection + * @param spendingTxId - The transaction ID that spent the UTXOs + * @returns A list of DbTxOutput objects that were spent by the transaction + */ +export const getUtxosSpentByTx = async ( + mysql: MysqlConnection, + spendingTxId: string, +): Promise => { + const [results] = await mysql.query( + `SELECT * + FROM \`tx_output\` + WHERE \`spent_by\` = ?`, + [spendingTxId] + ); + + return results.map(mapDbResultToDbTxOutput); +}; + /** * Set a list of tx_outputs as unspent * @@ -1288,7 +1309,7 @@ export const markUtxosAsVoided = async ( UPDATE \`tx_output\` SET \`voided\` = TRUE WHERE \`tx_id\` IN (?)`, - [txIds]); + [txIds]); }; export const updateLastSyncedEvent = async ( @@ -1300,7 +1321,7 @@ export const updateLastSyncedEvent = async ( VALUES (0, ?) ON DUPLICATE KEY UPDATE last_event_id = ?`, - [lastEventId, lastEventId]); + [lastEventId, lastEventId]); }; export const getLastSyncedEvent = async ( @@ -1557,8 +1578,8 @@ export const getTokenSymbols = async ( */ export const getMaxIndicesForWallets = async ( mysql: MysqlConnection, - walletData: Array<{walletId: string, addresses: string[]}> -): Promise> => { + walletData: Array<{ walletId: string, addresses: string[] }> +): Promise> => { if (walletData.length === 0) { return new Map(); } diff --git a/packages/daemon/src/services/index.ts b/packages/daemon/src/services/index.ts index 14ba2053..34938618 100644 --- a/packages/daemon/src/services/index.ts +++ b/packages/daemon/src/services/index.ts @@ -73,6 +73,8 @@ import { getMaxIndicesForWallets, setAddressSeqnum, getAddressSeqnum, + unspendUtxos, + getUtxosSpentByTx, } from '../db'; import getConfig from '../config'; import logger from '../logger'; @@ -537,6 +539,14 @@ export const voidTx = async ( await voidTransaction(mysql, hash, addressBalanceMap); await markUtxosAsVoided(mysql, dbTxOutputs); + // CRITICAL: Unspent the inputs when voiding a transaction + // Get all UTXOs that were spent by this transaction and unspent them + const utxosSpentByThisTx = await getUtxosSpentByTx(mysql, hash); + + if (utxosSpentByThisTx.length > 0) { + await unspendUtxos(mysql, utxosSpentByThisTx); + } + const addresses = Object.keys(addressBalanceMap); await validateAddressBalances(mysql, addresses); }; From dc41675e84046d939fda269f61c5847b5351aa3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Abadesso?= Date: Thu, 21 Aug 2025 00:30:42 -0300 Subject: [PATCH 02/49] fix: wallet_balance not being recalculated after a voided transaction --- .../services/walletBalanceVoiding.test.ts | 349 ++++++++++++++++++ packages/daemon/src/db/index.ts | 92 +++++ packages/daemon/src/services/index.ts | 18 +- 3 files changed, 457 insertions(+), 2 deletions(-) create mode 100644 packages/daemon/__tests__/services/walletBalanceVoiding.test.ts diff --git a/packages/daemon/__tests__/services/walletBalanceVoiding.test.ts b/packages/daemon/__tests__/services/walletBalanceVoiding.test.ts new file mode 100644 index 00000000..1b39b6c8 --- /dev/null +++ b/packages/daemon/__tests__/services/walletBalanceVoiding.test.ts @@ -0,0 +1,349 @@ +/** + * Copyright (c) Hathor Labs and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * @jest-environment node + */ + +import { Connection } from 'mysql2/promise'; +import { + getDbConnection, + addOrUpdateTx, + addUtxos, + updateTxOutputSpentBy, +} from '../../src/db'; +import { voidTx } from '../../src/services'; +import { + cleanDatabase, + createOutput, + createInput, + createEventTxInput, +} from '../utils'; +import { EventTxInput } from '../../src/types'; + +// Use a single mysql connection for all tests +let mysql: Connection; + +beforeAll(async () => { + try { + mysql = await getDbConnection(); + } catch (e) { + console.error('Failed to establish db connection', e); + throw e; + } +}); + +afterAll(async () => { + await mysql.destroy(); +}); + +beforeEach(async () => { + await cleanDatabase(mysql); +}); + +// Helper function to create a wallet and addresses +const setupWallet = async (walletId: string, addresses: string[]) => { + const now = Math.floor(Date.now() / 1000); // Unix timestamp + // Create wallet + await mysql.query( + `INSERT INTO \`wallet\` (id, xpubkey, auth_xpubkey, status, max_gap, created_at, ready_at) + VALUES (?, 'xpub123', 'xpub456', 'ready', 20, ?, ?)`, + [walletId, now, now] + ); + + // Add addresses to the wallet + const addressEntries = addresses.map((address, index) => [address, index, walletId, 1]); + await mysql.query( + `INSERT INTO \`address\` (address, \`index\`, wallet_id, transactions) + VALUES ?`, + [addressEntries] + ); +}; + +// Helper function to get wallet balance +const getWalletBalance = async (walletId: string, tokenId: string) => { + const [results] = await mysql.query( + `SELECT * FROM \`wallet_balance\` WHERE \`wallet_id\` = ? AND \`token_id\` = ?`, + [walletId, tokenId] + ) as [any[], any]; + return results[0] || null; +}; + +// Helper function to manually insert wallet balance (simulating what should happen) +const insertWalletBalance = async (walletId: string, tokenId: string, balance: bigint, transactions: number) => { + await mysql.query( + `INSERT INTO \`wallet_balance\` (wallet_id, token_id, unlocked_balance, locked_balance, + unlocked_authorities, locked_authorities, total_received, + transactions, timelock_expires) + VALUES (?, ?, ?, 0, 0, 0, ?, ?, NULL) + ON DUPLICATE KEY UPDATE + unlocked_balance = unlocked_balance + ?, + total_received = total_received + ?, + transactions = transactions + ?`, + [walletId, tokenId, balance, balance, transactions, balance, balance, transactions] + ); +}; + +// Helper function to get wallet transaction history count +const getWalletTxHistoryCount = async (walletId: string, txId: string) => { + const [results] = await mysql.query( + `SELECT COUNT(*) as count FROM \`wallet_tx_history\` WHERE \`wallet_id\` = ? AND \`tx_id\` = ?`, + [walletId, txId] + ) as [any[], any]; + return results[0].count; +}; + +describe('wallet balance voiding bug', () => { + it('should demonstrate wallet balance not being updated when voiding a transaction', async () => { + expect.hasAssertions(); + + const walletId = 'test-wallet'; + const address = 'test-address'; + const tokenId = '00'; + const txIdA = 'tx-a'; + const txIdB = 'tx-b'; + const initialValue = 100n; + + // Setup wallet and address + await setupWallet(walletId, [address]); + + // Create transaction A that creates an output to our wallet address + await addOrUpdateTx(mysql, txIdA, 0, 1, 1, 100); + const outputA = createOutput(0, initialValue, address, tokenId); + await addUtxos(mysql, txIdA, [outputA], null); + + // Manually insert wallet balance (simulating what updateWalletTablesWithTx would do) + await insertWalletBalance(walletId, tokenId, initialValue, 1); + + // Also insert into wallet_tx_history + await mysql.query( + `INSERT INTO \`wallet_tx_history\` (wallet_id, token_id, tx_id, balance, timestamp) + VALUES (?, ?, ?, ?, ?)`, + [walletId, tokenId, txIdA, initialValue, 100] + ); + + // Verify initial wallet balance + let walletBalance = await getWalletBalance(walletId, tokenId); + expect(walletBalance).not.toBeNull(); + expect(BigInt(walletBalance.unlocked_balance)).toBe(initialValue); + expect(walletBalance.transactions).toBe(1); + + // Create transaction B that spends the output from A + await addOrUpdateTx(mysql, txIdB, 0, 1, 1, 101); + const inputB = createInput(initialValue, address, txIdA, 0, tokenId); + await updateTxOutputSpentBy(mysql, [inputB], txIdB); + + // Add output for transaction B (sending to same address for simplicity) + const outputB = createOutput(0, initialValue, address, tokenId); + await addUtxos(mysql, txIdB, [outputB], null); + + // Update wallet balance for transaction B (net zero change) + await insertWalletBalance(walletId, tokenId, 0n, 1); + + // Add to wallet_tx_history + await mysql.query( + `INSERT INTO \`wallet_tx_history\` (wallet_id, token_id, tx_id, balance, timestamp) + VALUES (?, ?, ?, ?, ?)`, + [walletId, tokenId, txIdB, 0, 101] + ); + + // Verify wallet balance after transaction B + walletBalance = await getWalletBalance(walletId, tokenId); + expect(walletBalance).not.toBeNull(); + expect(BigInt(walletBalance.unlocked_balance)).toBe(initialValue); // Still 100n + expect(walletBalance.transactions).toBe(2); // Now 2 transactions + + // Now void transaction B + const inputs: EventTxInput[] = [createEventTxInput(initialValue, address, txIdA, 0)]; + const outputs = [{ + value: initialValue, + locked: false, + decoded: { + type: 'P2PKH' as const, + address: address, + timelock: null, + }, + token_data: 0, + script: 'dqkUH70YjKeoKdFwMX2TOYvGVbXOrKaIrA==', + }]; + + await voidTx(mysql, txIdB, inputs, outputs, [tokenId], []); + + // CRITICAL BUG: Check wallet balance after voiding + walletBalance = await getWalletBalance(walletId, tokenId); + expect(walletBalance).not.toBeNull(); + + // This will FAIL because wallet balances are not updated during voiding + // The transaction count should decrease from 2 to 1 + expect(walletBalance.transactions).toBe(1); // Should be back to 1 transaction + + // Check if wallet_tx_history was cleaned up + const historyCount = await getWalletTxHistoryCount(walletId, txIdB); + // This will FAIL because wallet_tx_history is not cleaned up during voiding + expect(historyCount).toBe(0); // Should be 0 after voiding + }); + + it('should demonstrate wallet balance inconsistency with multiple wallets', async () => { + expect.hasAssertions(); + + const wallet1Id = 'wallet-1'; + const wallet2Id = 'wallet-2'; + const address1 = 'address-1'; + const address2 = 'address-2'; + const tokenId = '00'; + const txId = 'transfer-tx'; + const amount = 150n; + + // Setup two wallets + await setupWallet(wallet1Id, [address1]); + await setupWallet(wallet2Id, [address2]); + + // Simulate wallet1 already having some balance + await insertWalletBalance(wallet1Id, tokenId, 200n, 1); + + // Create a transaction that transfers money from wallet1 to wallet2 + await addOrUpdateTx(mysql, txId, 0, 1, 1, 100); + + // Transaction sends money from wallet1 to wallet2 + const outputs = [ + createOutput(0, amount, address2, tokenId), // To wallet2 + ]; + await addUtxos(mysql, txId, outputs, null); + + // Simulate updating wallet balances for this transaction + // Wallet1 loses money (net -150n) + await mysql.query( + `UPDATE \`wallet_balance\` + SET unlocked_balance = unlocked_balance - ?, transactions = transactions + 1 + WHERE wallet_id = ? AND token_id = ?`, + [amount, wallet1Id, tokenId] + ); + + // Wallet2 gains money (+150n) + await insertWalletBalance(wallet2Id, tokenId, amount, 1); + + // Add wallet_tx_history entries + await mysql.query( + `INSERT INTO \`wallet_tx_history\` (wallet_id, token_id, tx_id, balance, timestamp) + VALUES (?, ?, ?, ?, ?), (?, ?, ?, ?, ?)`, + [wallet1Id, tokenId, txId, -amount, 100, wallet2Id, tokenId, txId, amount, 100] + ); + + // Verify wallet balances before voiding + let wallet1Balance = await getWalletBalance(wallet1Id, tokenId); + let wallet2Balance = await getWalletBalance(wallet2Id, tokenId); + + expect(wallet1Balance).not.toBeNull(); + expect(BigInt(wallet1Balance.unlocked_balance)).toBe(50n); // 200 - 150 + expect(wallet1Balance.transactions).toBe(2); + + expect(wallet2Balance).not.toBeNull(); + expect(BigInt(wallet2Balance.unlocked_balance)).toBe(amount); + expect(wallet2Balance.transactions).toBe(1); + + // Now void the transaction + const inputs: EventTxInput[] = [createEventTxInput(amount, address1, 'some-previous-tx', 0)]; + const voidOutputs = [{ + value: amount, + locked: false, + decoded: { + type: 'P2PKH' as const, + address: address2, + timelock: null, + }, + token_data: 0, + script: 'dqkUH70YjKeoKdFwMX2TOYvGVbXOrKaIrA==', + }]; + + await voidTx(mysql, txId, inputs, voidOutputs, [tokenId], []); + + // Check wallet balances after voiding + wallet1Balance = await getWalletBalance(wallet1Id, tokenId); + wallet2Balance = await getWalletBalance(wallet2Id, tokenId); + + // These assertions will FAIL because wallet balances are not updated during voiding + + // Wallet1 should have its balance restored (50 + 150 = 200) + expect(BigInt(wallet1Balance.unlocked_balance)).toBe(200n); + expect(wallet1Balance.transactions).toBe(1); // Should decrease back to 1 + + // Wallet2 should have its balance reduced to 0 + if (wallet2Balance) { + expect(BigInt(wallet2Balance.unlocked_balance)).toBe(0n); + expect(wallet2Balance.transactions).toBe(0); // Should be 0 after voiding + } + + // Check wallet_tx_history cleanup + const wallet1HistoryCount = await getWalletTxHistoryCount(wallet1Id, txId); + const wallet2HistoryCount = await getWalletTxHistoryCount(wallet2Id, txId); + + // These will FAIL because wallet_tx_history is not cleaned up + expect(wallet1HistoryCount).toBe(0); + expect(wallet2HistoryCount).toBe(0); + }); + + it('should demonstrate the bug exists even with simple single wallet scenario', async () => { + expect.hasAssertions(); + + const walletId = 'simple-wallet'; + const address = 'simple-address'; + const tokenId = '00'; + const txId = 'simple-tx'; + const value = 50n; + + // Setup wallet + await setupWallet(walletId, [address]); + + // Create transaction + await addOrUpdateTx(mysql, txId, 0, 1, 1, 100); + const output = createOutput(0, value, address, tokenId); + await addUtxos(mysql, txId, [output], null); + + // Simulate wallet balance update + await insertWalletBalance(walletId, tokenId, value, 1); + await mysql.query( + `INSERT INTO \`wallet_tx_history\` (wallet_id, token_id, tx_id, balance, timestamp) + VALUES (?, ?, ?, ?, ?)`, + [walletId, tokenId, txId, value, 100] + ); + + // Verify wallet balance exists + let walletBalance = await getWalletBalance(walletId, tokenId); + expect(walletBalance).not.toBeNull(); + expect(BigInt(walletBalance.unlocked_balance)).toBe(value); + expect(walletBalance.transactions).toBe(1); + + // Void the transaction + const inputs: EventTxInput[] = []; + const outputs = [{ + value: value, + locked: false, + decoded: { + type: 'P2PKH' as const, + address: address, + timelock: null, + }, + token_data: 0, + script: 'dqkUH70YjKeoKdFwMX2TOYvGVbXOrKaIrA==', + }]; + + await voidTx(mysql, txId, inputs, outputs, [tokenId], []); + + // Check wallet balance after voiding + walletBalance = await getWalletBalance(walletId, tokenId); + + // This WILL FAIL because wallet balances are not updated during voiding + if (walletBalance) { + expect(BigInt(walletBalance.unlocked_balance)).toBe(0n); // Should be 0 after voiding + expect(walletBalance.transactions).toBe(0); // Should be 0 after voiding + } + + // Check wallet_tx_history cleanup + const historyCount = await getWalletTxHistoryCount(walletId, txId); + expect(historyCount).toBe(0); // Should be 0 after voiding + }); +}); \ No newline at end of file diff --git a/packages/daemon/src/db/index.ts b/packages/daemon/src/db/index.ts index bce459ee..030409bb 100644 --- a/packages/daemon/src/db/index.ts +++ b/packages/daemon/src/db/index.ts @@ -478,6 +478,98 @@ export const voidTransaction = async ( ); }; +/** + * Void a transaction by updating the related wallet balance and transaction information in the database. + * + * @param mysql - The MySQL connection object + * @param txId - The ID of the transaction to be voided. + * @param walletBalanceMap - A map where the key is a walletId and the value is a map of token balances. + * The TokenBalanceMap contains information about the total amount sent, unlocked and locked amounts, and authorities. + * + * @returns {Promise} - A promise that resolves when the transaction has been voided and the wallet tables updated + * + * This function performs the following steps: + * 1. Iterates over the walletBalanceMap to update the `wallet_balance` table by reversing the transaction's balance changes. + * 2. Deletes the transaction entry from the `wallet_tx_history` table. + * 3. Updates authority columns correctly when authorities are removed. + * + * The function ensures that wallet balances are correctly reverted and transaction counts are decremented. + */ +export const voidWalletTransaction = async ( + mysql: MysqlConnection, + txId: string, + walletBalanceMap: StringMap, +): Promise => { + for (const [walletId, tokenMap] of Object.entries(walletBalanceMap)) { + for (const [token, tokenBalance] of tokenMap.iterator()) { + // Update wallet_balance table by reversing the transaction's impact + await mysql.query( + `UPDATE \`wallet_balance\` + SET total_received = total_received - ?, + unlocked_balance = unlocked_balance - ?, + locked_balance = locked_balance - ?, + transactions = transactions - 1, + unlocked_authorities = (unlocked_authorities | ?), + locked_authorities = locked_authorities | ? + WHERE wallet_id = ? AND token_id = ?`, + [ + tokenBalance.totalAmountSent, + tokenBalance.unlockedAmount, + tokenBalance.lockedAmount, + tokenBalance.unlockedAuthorities.toUnsignedInteger(), + tokenBalance.lockedAuthorities.toUnsignedInteger(), + walletId, + token + ], + ); + + // If we're removing any of the authorities, we need to refresh the authority columns + // Similar to the address case, we need to recalculate authorities from all addresses in the wallet + if (tokenBalance.unlockedAuthorities.hasNegativeValue()) { + await mysql.query( + `UPDATE \`wallet_balance\` + SET \`unlocked_authorities\` = ( + SELECT BIT_OR(\`unlocked_authorities\`) + FROM \`address_balance\` + WHERE \`address\` IN ( + SELECT \`address\` + FROM \`address\` + WHERE \`wallet_id\` = ?) + AND \`token_id\` = ?) + WHERE \`wallet_id\` = ? + AND \`token_id\` = ?`, + [walletId, token, walletId, token], + ); + } + + // Handle locked authorities refresh if needed + if (tokenBalance.lockedAuthorities.hasNegativeValue && tokenBalance.lockedAuthorities.hasNegativeValue()) { + await mysql.query( + `UPDATE \`wallet_balance\` + SET \`locked_authorities\` = ( + SELECT BIT_OR(\`locked_authorities\`) + FROM \`address_balance\` + WHERE \`address\` IN ( + SELECT \`address\` + FROM \`address\` + WHERE \`wallet_id\` = ?) + AND \`token_id\` = ?) + WHERE \`wallet_id\` = ? + AND \`token_id\` = ?`, + [walletId, token, walletId, token], + ); + } + } + } + + // Delete wallet transaction history entries for the voided transaction + await mysql.query( + `DELETE FROM \`wallet_tx_history\` + WHERE \`tx_id\` = ?`, + [txId], + ); +}; + /** * Update addresses tables with a new transaction. * diff --git a/packages/daemon/src/services/index.ts b/packages/daemon/src/services/index.ts index 34938618..7dfbd3b4 100644 --- a/packages/daemon/src/services/index.ts +++ b/packages/daemon/src/services/index.ts @@ -75,6 +75,7 @@ import { getAddressSeqnum, unspendUtxos, getUtxosSpentByTx, + voidWalletTransaction, } from '../db'; import getConfig from '../config'; import logger from '../logger'; @@ -388,7 +389,7 @@ export const handleVertexAccepted = async (context: Context, _event: Event) => { // prepare the transaction data to be sent to the SQS queue const txData: Transaction = { tx_id: hash, - nonce, + nonce: Number(nonce), timestamp, version, voided: metadata.voided_by.length > 0, @@ -433,7 +434,7 @@ export const handleVertexAccepted = async (context: Context, _event: Event) => { // Call to process the data for NFT handling (if applicable) // This process is not critical, so we run it in a fire-and-forget manner, not waiting for the promise. - NftUtils.processNftEvent(fullNodeData, STAGE, network, logger) + NftUtils.processNftEvent({...fullNodeData, nonce: Number(fullNodeData.nonce)}, STAGE, network, logger) .catch((err: unknown) => logger.error('[ALERT] Error processing NFT event', err)); } @@ -547,6 +548,19 @@ export const voidTx = async ( await unspendUtxos(mysql, utxosSpentByThisTx); } + // CRITICAL: Update wallet balances when voiding a transaction + // Get wallet information for all affected addresses + const addressWalletMap: StringMap = await getAddressWalletInfo(mysql, Object.keys(addressBalanceMap)); + + if (Object.keys(addressWalletMap).length > 0) { + // Build wallet balance map from the address balance changes + const walletBalanceMap: StringMap = getWalletBalanceMap(addressWalletMap, addressBalanceMap); + + if (Object.keys(walletBalanceMap).length > 0) { + await voidWalletTransaction(mysql, hash, walletBalanceMap); + } + } + const addresses = Object.keys(addressBalanceMap); await validateAddressBalances(mysql, addresses); }; From 865a58f3af4b7a5c596b7b2dcaf96dd82726c6dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Abadesso?= Date: Thu, 21 Aug 2025 00:37:57 -0300 Subject: [PATCH 03/49] fix: unlock tx outputs after a failure in tx proposal send --- packages/daemon/src/db/index.ts | 23 +- packages/daemon/src/services/index.ts | 44 ++- .../wallet-service/src/api/txProposalSend.ts | 3 + .../tests/txProposalUtxoUnlock.test.ts | 305 ++++++++++++++++++ 4 files changed, 357 insertions(+), 18 deletions(-) create mode 100644 packages/wallet-service/tests/txProposalUtxoUnlock.test.ts diff --git a/packages/daemon/src/db/index.ts b/packages/daemon/src/db/index.ts index 030409bb..a2a4f3ae 100644 --- a/packages/daemon/src/db/index.ts +++ b/packages/daemon/src/db/index.ts @@ -401,7 +401,7 @@ export const voidTransaction = async ( await mysql.query( `INSERT INTO \`address\`(\`address\`, \`transactions\`) VALUES ? - ON DUPLICATE KEY UPDATE transactions = transactions - 1`, + ON DUPLICATE KEY UPDATE transactions = CASE WHEN transactions > 0 THEN transactions - 1 ELSE 0 END`, [addressEntries], ); } @@ -430,9 +430,9 @@ export const voidTransaction = async ( `INSERT INTO address_balance SET ? ON DUPLICATE KEY - UPDATE total_received = total_received - ?, - unlocked_balance = unlocked_balance - ?, - locked_balance = locked_balance - ?, + UPDATE total_received = CASE WHEN total_received >= ? THEN total_received - ? ELSE 0 END, + unlocked_balance = CASE WHEN unlocked_balance >= ? THEN unlocked_balance - ? ELSE 0 END, + locked_balance = CASE WHEN locked_balance >= ? THEN locked_balance - ? ELSE 0 END, transactions = transactions - 1, timelock_expires = CASE WHEN timelock_expires IS NULL THEN VALUES(timelock_expires) @@ -441,7 +441,14 @@ export const voidTransaction = async ( END, unlocked_authorities = (unlocked_authorities | VALUES(unlocked_authorities)), locked_authorities = locked_authorities | VALUES(locked_authorities)`, - [entry, tokenBalance.totalAmountSent, tokenBalance.unlockedAmount, tokenBalance.lockedAmount, address, token], + [ + entry, + tokenBalance.totalAmountSent, tokenBalance.totalAmountSent, // For total_received comparison and subtraction + tokenBalance.unlockedAmount, tokenBalance.unlockedAmount, // For unlocked_balance comparison and subtraction + tokenBalance.lockedAmount, tokenBalance.lockedAmount, // For locked_balance comparison and subtraction + address, + token + ], ); // if we're removing any of the authorities, we need to refresh the authority columns. Unlike the values, @@ -513,12 +520,12 @@ export const voidWalletTransaction = async ( locked_authorities = locked_authorities | ? WHERE wallet_id = ? AND token_id = ?`, [ - tokenBalance.totalAmountSent, - tokenBalance.unlockedAmount, + tokenBalance.totalAmountSent, + tokenBalance.unlockedAmount, tokenBalance.lockedAmount, tokenBalance.unlockedAuthorities.toUnsignedInteger(), tokenBalance.lockedAuthorities.toUnsignedInteger(), - walletId, + walletId, token ], ); diff --git a/packages/daemon/src/services/index.ts b/packages/daemon/src/services/index.ts index 7dfbd3b4..1a464b4a 100644 --- a/packages/daemon/src/services/index.ts +++ b/packages/daemon/src/services/index.ts @@ -74,8 +74,8 @@ import { setAddressSeqnum, getAddressSeqnum, unspendUtxos, - getUtxosSpentByTx, voidWalletTransaction, + getTxOutput, } from '../db'; import getConfig from '../config'; import logger from '../logger'; @@ -389,7 +389,7 @@ export const handleVertexAccepted = async (context: Context, _event: Event) => { // prepare the transaction data to be sent to the SQS queue const txData: Transaction = { tx_id: hash, - nonce: Number(nonce), + nonce: nonce ? BigInt(nonce) : BigInt(0), timestamp, version, voided: metadata.voided_by.length > 0, @@ -434,7 +434,7 @@ export const handleVertexAccepted = async (context: Context, _event: Event) => { // Call to process the data for NFT handling (if applicable) // This process is not critical, so we run it in a fire-and-forget manner, not waiting for the promise. - NftUtils.processNftEvent({...fullNodeData, nonce: Number(fullNodeData.nonce)}, STAGE, network, logger) + NftUtils.processNftEvent({ ...fullNodeData, nonce: fullNodeData.nonce ? BigInt(fullNodeData.nonce) : BigInt(0) }, STAGE, network, logger) .catch((err: unknown) => logger.error('[ALERT] Error processing NFT event', err)); } @@ -541,21 +541,45 @@ export const voidTx = async ( await markUtxosAsVoided(mysql, dbTxOutputs); // CRITICAL: Unspent the inputs when voiding a transaction - // Get all UTXOs that were spent by this transaction and unspent them - const utxosSpentByThisTx = await getUtxosSpentByTx(mysql, hash); + // The inputs of the voided transaction need to be marked as unspent + // But only if they were actually spent by this transaction + if (inputs.length > 0) { + // First, check which inputs were actually spent by this transaction + const inputsSpentByThisTx: DbTxOutput[] = []; + + for (const input of inputs) { + // Get the current state of this output to check if it's spent by our transaction + const currentOutput = await getTxOutput(mysql, input.tx_id, input.index, false); + if (currentOutput && currentOutput.spentBy === hash) { + inputsSpentByThisTx.push({ + txId: input.tx_id, + index: input.index, + tokenId: '', // Not needed for unspending + address: '', // Not needed for unspending + value: BigInt(0), // Not needed for unspending + authorities: 0, // Not needed for unspending + timelock: null, // Not needed for unspending + heightlock: null, // Not needed for unspending + locked: false, // Not needed for unspending + spentBy: hash, // This is what we're unsetting + voided: false, // Not needed for unspending + }); + } + } - if (utxosSpentByThisTx.length > 0) { - await unspendUtxos(mysql, utxosSpentByThisTx); + if (inputsSpentByThisTx.length > 0) { + await unspendUtxos(mysql, inputsSpentByThisTx); + } } // CRITICAL: Update wallet balances when voiding a transaction // Get wallet information for all affected addresses const addressWalletMap: StringMap = await getAddressWalletInfo(mysql, Object.keys(addressBalanceMap)); - + if (Object.keys(addressWalletMap).length > 0) { - // Build wallet balance map from the address balance changes + // Build wallet balance map from the address balance changes const walletBalanceMap: StringMap = getWalletBalanceMap(addressWalletMap, addressBalanceMap); - + if (Object.keys(walletBalanceMap).length > 0) { await voidWalletTransaction(mysql, hash, walletBalanceMap); } diff --git a/packages/wallet-service/src/api/txProposalSend.ts b/packages/wallet-service/src/api/txProposalSend.ts index 0ce4cf23..516062cd 100644 --- a/packages/wallet-service/src/api/txProposalSend.ts +++ b/packages/wallet-service/src/api/txProposalSend.ts @@ -10,6 +10,7 @@ import { getTxProposal, getTxProposalInputs, updateTxProposal, + releaseTxProposalUtxos, } from '@src/db'; import { TxProposalStatus, @@ -140,6 +141,8 @@ export const send: APIGatewayProxyHandler = middy(walletIdProxyHandler(async (wa TxProposalStatus.SEND_ERROR, ); + await releaseTxProposalUtxos(mysql, [txProposalId]); + return closeDbAndGetError(mysql, ApiError.TX_PROPOSAL_SEND_ERROR, { message: e.message, txProposalId, diff --git a/packages/wallet-service/tests/txProposalUtxoUnlock.test.ts b/packages/wallet-service/tests/txProposalUtxoUnlock.test.ts new file mode 100644 index 00000000..166cc930 --- /dev/null +++ b/packages/wallet-service/tests/txProposalUtxoUnlock.test.ts @@ -0,0 +1,305 @@ +/** + * Copyright (c) Hathor Labs and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * @jest-environment node + */ + +import { APIGatewayProxyResult } from 'aws-lambda'; +import hathorLib from '@hathor/wallet-lib'; +import { + create as txProposalCreate, +} from '@src/api/txProposalCreate'; + +import { + send as txProposalSend, +} from '@src/api/txProposalSend'; + +import { + addToWalletTable, + addToAddressTable, + addToUtxoTable, + addToWalletBalanceTable, + ADDRESSES, + cleanDatabase, + makeGatewayEventWithAuthorizer, +} from '@tests/utils'; + +import { + closeDbConnection, + getDbConnection, +} from '@src/utils'; + +import { + getUtxos, + getTxProposal, +} from '@src/db'; + +import { TxProposalStatus } from '@src/types'; + +const mysql = getDbConnection(); + +beforeEach(async () => { + await cleanDatabase(mysql); +}); + +afterAll(async () => { + await closeDbConnection(mysql); +}); + +describe('TxProposal UTXO unlocking on send failure', () => { + test('UTXOs should be released when txProposalSend fails', async () => { + expect.hasAssertions(); + + // Create the spy to mock wallet-lib to force a failure + const spy = jest.spyOn(hathorLib.axios, 'createRequestInstance'); + spy.mockReturnValue({ + post: () => { + throw new Error('Network error - send failed'); + }, + // @ts-ignore + get: () => Promise.resolve({ + data: { + success: true, + version: '0.38.0', + network: 'mainnet', + min_weight: 14, + min_tx_weight: 14, + min_tx_weight_coefficient: 1.6, + min_tx_weight_k: 100, + token_deposit_percentage: 0.01, + reward_spend_min_blocks: 300, + max_number_inputs: 255, + max_number_outputs: 255, + }, + }), + }); + + // Setup wallet + await addToWalletTable(mysql, [{ + id: 'test-wallet', + xpubkey: 'xpubkey', + authXpubkey: 'auth_xpubkey', + status: 'ready', + maxGap: 5, + createdAt: 10000, + readyAt: 10001, + }]); + + await addToAddressTable(mysql, [{ + address: ADDRESSES[0], + index: 0, + walletId: 'test-wallet', + transactions: 1, + }]); + + const tokenId = '00'; + const utxos = [{ + txId: '004d75c1edd4294379e7e5b7ab6c118c53c8b07a506728feb5688c8d26a97e50', + index: 0, + tokenId, + address: ADDRESSES[0], + value: 100n, + authorities: 0, + timelock: null, + heightlock: null, + locked: false, + spentBy: null, + }]; + + await addToUtxoTable(mysql, utxos); + await addToWalletBalanceTable(mysql, [{ + walletId: 'test-wallet', + tokenId, + unlockedBalance: 100n, + lockedBalance: 0n, + unlockedAuthorities: 0, + lockedAuthorities: 0, + timelockExpires: null, + transactions: 1, + }]); + + // Verify UTXO is initially unlocked (no tx_proposal_id) + let utxoResults = await getUtxos(mysql, [{ txId: utxos[0].txId, index: utxos[0].index }]); + expect(utxoResults).toHaveLength(1); + expect(utxoResults[0].txProposalId).toBeNull(); + expect(utxoResults[0].txProposalIndex).toBeNull(); + + // Create transaction + const outputs = [ + new hathorLib.Output( + 100n, + new hathorLib.P2PKH(new hathorLib.Address( + ADDRESSES[0], + { network: new hathorLib.Network(process.env.NETWORK) } + )).createScript(), + { tokenData: 0 } + ), + ]; + const inputs = [new hathorLib.Input(utxos[0].txId, utxos[0].index)]; + const transaction = new hathorLib.Transaction(inputs, outputs); + const txHex = transaction.toHex(); + + // Create tx proposal + const createEvent = makeGatewayEventWithAuthorizer('test-wallet', null, JSON.stringify({ txHex })); + const createResult = await txProposalCreate(createEvent, null, null) as APIGatewayProxyResult; + + expect(createResult.statusCode).toBe(201); + const createBody = JSON.parse(createResult.body as string); + expect(createBody.success).toBe(true); + const txProposalId = createBody.txProposalId; + + // Verify UTXO is now locked with tx proposal ID + utxoResults = await getUtxos(mysql, [{ txId: utxos[0].txId, index: utxos[0].index }]); + expect(utxoResults).toHaveLength(1); + expect(utxoResults[0].txProposalId).toBe(txProposalId); + expect(utxoResults[0].txProposalIndex).toBe(0); + + // Attempt to send the transaction (this will fail due to our mock) + const sendEvent = makeGatewayEventWithAuthorizer( + 'test-wallet', + { txProposalId }, + JSON.stringify({ txHex }) + ); + const sendResult = await txProposalSend(sendEvent, null, null) as APIGatewayProxyResult; + + // Verify send failed and proposal status is SEND_ERROR + expect(sendResult.statusCode).toBe(400); + const sendBody = JSON.parse(sendResult.body as string); + expect(sendBody.success).toBe(false); + + const txProposal = await getTxProposal(mysql, txProposalId); + expect(txProposal!.status).toBe(TxProposalStatus.SEND_ERROR); + + // BUG: UTXO should be released when send fails, but currently it remains locked + utxoResults = await getUtxos(mysql, [{ txId: utxos[0].txId, index: utxos[0].index }]); + expect(utxoResults).toHaveLength(1); + + // THIS ASSERTION WILL FAIL because UTXOs are not released on send failure + expect(utxoResults[0].txProposalId).toBeNull(); // Should be null (released) + expect(utxoResults[0].txProposalIndex).toBeNull(); // Should be null (released) + + spy.mockRestore(); + }); + + test('UTXOs should remain locked when txProposalSend succeeds', async () => { + expect.hasAssertions(); + + // Create the spy to mock wallet-lib to force success + const spy = jest.spyOn(hathorLib.axios, 'createRequestInstance'); + spy.mockReturnValue({ + // @ts-ignore + post: () => Promise.resolve({ + data: { success: true, hash: 'mocked-hash' } + }), + // @ts-ignore + get: () => Promise.resolve({ + data: { + success: true, + version: '0.38.0', + network: 'mainnet', + min_weight: 14, + min_tx_weight: 14, + min_tx_weight_coefficient: 1.6, + min_tx_weight_k: 100, + token_deposit_percentage: 0.01, + reward_spend_min_blocks: 300, + max_number_inputs: 255, + max_number_outputs: 255, + }, + }), + }); + + // Setup wallet (same as above) + await addToWalletTable(mysql, [{ + id: 'test-wallet', + xpubkey: 'xpubkey', + authXpubkey: 'auth_xpubkey', + status: 'ready', + maxGap: 5, + createdAt: 10000, + readyAt: 10001, + }]); + + await addToAddressTable(mysql, [{ + address: ADDRESSES[0], + index: 0, + walletId: 'test-wallet', + transactions: 1, + }]); + + const tokenId = '00'; + const utxos = [{ + txId: '004d75c1edd4294379e7e5b7ab6c118c53c8b07a506728feb5688c8d26a97e50', + index: 0, + tokenId, + address: ADDRESSES[0], + value: 100n, + authorities: 0, + timelock: null, + heightlock: null, + locked: false, + spentBy: null, + }]; + + await addToUtxoTable(mysql, utxos); + await addToWalletBalanceTable(mysql, [{ + walletId: 'test-wallet', + tokenId, + unlockedBalance: 100n, + lockedBalance: 0n, + unlockedAuthorities: 0, + lockedAuthorities: 0, + timelockExpires: null, + transactions: 1, + }]); + + // Create transaction and proposal (same as above) + const outputs = [ + new hathorLib.Output( + 100n, + new hathorLib.P2PKH(new hathorLib.Address( + ADDRESSES[0], + { network: new hathorLib.Network(process.env.NETWORK) } + )).createScript(), + { tokenData: 0 } + ), + ]; + const inputs = [new hathorLib.Input(utxos[0].txId, utxos[0].index)]; + const transaction = new hathorLib.Transaction(inputs, outputs); + const txHex = transaction.toHex(); + + const createEvent = makeGatewayEventWithAuthorizer('test-wallet', null, JSON.stringify({ txHex })); + const createResult = await txProposalCreate(createEvent, null, null) as APIGatewayProxyResult; + const createBody = JSON.parse(createResult.body as string); + const txProposalId = createBody.txProposalId; + + // Send the transaction (this will succeed due to our mock) + const sendEvent = makeGatewayEventWithAuthorizer( + 'test-wallet', + { txProposalId }, + JSON.stringify({ txHex }) + ); + const sendResult = await txProposalSend(sendEvent, null, null) as APIGatewayProxyResult; + + // Verify send succeeded and proposal status is SENT + expect(sendResult.statusCode).toBe(200); + const sendBody = JSON.parse(sendResult.body as string); + expect(sendBody.success).toBe(true); + + const txProposal = await getTxProposal(mysql, txProposalId); + expect(txProposal!.status).toBe(TxProposalStatus.SENT); + + // UTXOs should remain locked when send succeeds (they'll be spent when tx is processed) + const utxoResults = await getUtxos(mysql, [{ txId: utxos[0].txId, index: utxos[0].index }]); + expect(utxoResults).toHaveLength(1); + expect(utxoResults[0].txProposalId).toBe(txProposalId); // Should remain locked + expect(utxoResults[0].txProposalIndex).toBe(0); // Should remain locked + + spy.mockRestore(); + }); +}); From 789d01bf36dae6525352580b38616869c5a8bfa4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Abadesso?= Date: Thu, 4 Sep 2025 09:39:43 -0300 Subject: [PATCH 04/49] fix: updated event tx output schema to match the fullnode message --- packages/daemon/src/types/event.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/daemon/src/types/event.ts b/packages/daemon/src/types/event.ts index 44c595c2..23f1a7c5 100644 --- a/packages/daemon/src/types/event.ts +++ b/packages/daemon/src/types/event.ts @@ -103,6 +103,7 @@ export const EventTxOutputSchema = z.object({ type: z.string(), address: z.string(), timelock: z.number().nullable(), + token_data: z.number().optional(), }).nullable(), z.object({}).strict() ]), From c042b64ba44259d34e0dee0583c3d52142865abd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Abadesso?= Date: Thu, 4 Sep 2025 09:41:14 -0300 Subject: [PATCH 05/49] tests: added tests for the new transaction voiding chain scenario --- .../__tests__/integration/balances.test.ts | 50 +++++- .../daemon/__tests__/integration/config.ts | 4 + .../transaction_voiding_chain.balances.ts | 28 ++++ .../integration/scripts/docker-compose.yml | 10 ++ .../__tests__/integration/utils/index.ts | 58 ++++++- .../utils/voiding-consistency-checks.ts | 157 ++++++++++++++++++ .../services/voidingWithInputs.test.ts | 28 ++-- .../services/walletBalanceVoiding.test.ts | 26 +-- packages/daemon/src/types/event.ts | 31 ++-- 9 files changed, 343 insertions(+), 49 deletions(-) create mode 100644 packages/daemon/__tests__/integration/scenario_configs/transaction_voiding_chain.balances.ts create mode 100644 packages/daemon/__tests__/integration/utils/voiding-consistency-checks.ts diff --git a/packages/daemon/__tests__/integration/balances.test.ts b/packages/daemon/__tests__/integration/balances.test.ts index c15d6f86..79fd6027 100644 --- a/packages/daemon/__tests__/integration/balances.test.ts +++ b/packages/daemon/__tests__/integration/balances.test.ts @@ -10,7 +10,16 @@ import { SyncMachine } from '../../src/machines'; import { interpret } from 'xstate'; import { getDbConnection } from '../../src/db'; import { Connection } from 'mysql2/promise'; -import { cleanDatabase, fetchAddressBalances, transitionUntilEvent, validateBalances } from './utils'; +import { + cleanDatabase, + fetchAddressBalances, + transitionUntilEvent, + validateBalances, + validateBalanceDistribution, + performVoidingConsistencyChecks, + validateVoidingConsistency, + printVoidingConsistencyReport +} from './utils'; import unvoidedScenarioBalances from './scenario_configs/unvoided_transactions.balances'; import reorgScenarioBalances from './scenario_configs/reorg.balances'; import singleChainBlocksAndTransactionsBalances from './scenario_configs/single_chain_blocks_and_transactions.balances'; @@ -18,6 +27,7 @@ import invalidMempoolBalances from './scenario_configs/invalid_mempool_transacti import emptyScriptBalances from './scenario_configs/empty_script.balances'; import customScriptBalances from './scenario_configs/custom_script.balances'; import ncEventsBalances from './scenario_configs/nc_events.balances'; +import transactionVoidingChainBalances from './scenario_configs/transaction_voiding_chain.balances'; import { DB_NAME, @@ -39,6 +49,8 @@ import { EMPTY_SCRIPT_LAST_EVENT, NC_EVENTS_PORT, NC_EVENTS_LAST_EVENT, + TRANSACTION_VOIDING_CHAIN_PORT, + TRANSACTION_VOIDING_CHAIN_LAST_EVENT, } from './config'; jest.mock('../../src/config', () => { @@ -328,3 +340,39 @@ describe('nc events scenario', () => { expect(validateBalances(addressBalances, ncEventsBalances)); }); }); + +describe('transaction voiding chain scenario', () => { + beforeAll(() => { + jest.spyOn(Services, 'fetchMinRewardBlocks').mockImplementation(async () => 300); + }); + + it('should do a full sync and the balances should match after voiding chain', async () => { + // @ts-ignore + getConfig.mockReturnValue({ + NETWORK: 'testnet', + SERVICE_NAME: 'daemon-test', + CONSOLE_LEVEL: 'debug', + TX_CACHE_SIZE: 100, + BLOCK_REWARD_LOCK: 300, + FULLNODE_PEER_ID: 'simulator_peer_id', + STREAM_ID: 'simulator_stream_id', + FULLNODE_NETWORK: 'unittests', + FULLNODE_HOST: `127.0.0.1:${TRANSACTION_VOIDING_CHAIN_PORT}`, + USE_SSL: false, + DB_ENDPOINT, + DB_NAME, + DB_USER, + DB_PASS, + DB_PORT, + }); + + const machine = interpret(SyncMachine); + + // @ts-ignore + await transitionUntilEvent(mysql, machine, TRANSACTION_VOIDING_CHAIN_LAST_EVENT); + const addressBalances = await fetchAddressBalances(mysql); + + // Validate balance distribution + validateBalanceDistribution(addressBalances, transactionVoidingChainBalances); + }, 30000); // 30 second timeout for transaction voiding chain test +}); diff --git a/packages/daemon/__tests__/integration/config.ts b/packages/daemon/__tests__/integration/config.ts index 5eed2bf7..d85a5ff8 100644 --- a/packages/daemon/__tests__/integration/config.ts +++ b/packages/daemon/__tests__/integration/config.ts @@ -37,6 +37,9 @@ export const EMPTY_SCRIPT_LAST_EVENT = 37; export const NC_EVENTS_PORT = 8088; export const NC_EVENTS_LAST_EVENT = 36; +export const TRANSACTION_VOIDING_CHAIN_PORT = 8089; +export const TRANSACTION_VOIDING_CHAIN_LAST_EVENT = 52; + export const SCENARIOS = [ 'UNVOIDED_SCENARIO', 'REORG_SCENARIO', @@ -45,4 +48,5 @@ export const SCENARIOS = [ 'EMPTY_SCRIPT', 'CUSTOM_SCRIPT', 'NC_EVENTS', + 'TRANSACTION_VOIDING_CHAIN', ]; diff --git a/packages/daemon/__tests__/integration/scenario_configs/transaction_voiding_chain.balances.ts b/packages/daemon/__tests__/integration/scenario_configs/transaction_voiding_chain.balances.ts new file mode 100644 index 00000000..de93ffb1 --- /dev/null +++ b/packages/daemon/__tests__/integration/scenario_configs/transaction_voiding_chain.balances.ts @@ -0,0 +1,28 @@ +/** + * Copyright (c) Hathor Labs and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * Expected balances for the TRANSACTION_VOIDING_CHAIN scenario + * + * This scenario tests the wallet service's ability to handle a chain of transactions + * being voided, including proper unspending of inputs and balance recalculation. + * + * Since the scenario uses random seed, addresses may vary between runs. + * We validate the balance distribution pattern instead of exact addresses. + */ + +const EXPECTED = { + // Expected balance distribution from simulator output: exactly these amounts should exist + // Note: Main address includes genesis balance (1000000 * 100) + actual balance (73900) + balanceDistribution: [73900, 5900, 3400, 0], + // Total expected addresses (including the zero-balance address) + totalAddresses: 4, + // Token ID for all balances + tokenId: '00' +}; + +export default EXPECTED; diff --git a/packages/daemon/__tests__/integration/scripts/docker-compose.yml b/packages/daemon/__tests__/integration/scripts/docker-compose.yml index 3d2ce96a..2bdc7c99 100644 --- a/packages/daemon/__tests__/integration/scripts/docker-compose.yml +++ b/packages/daemon/__tests__/integration/scripts/docker-compose.yml @@ -75,5 +75,15 @@ services: ports: - "8088:8080" + transaction_voiding_chain: + image: hathornetwork/hathor-core + command: [ + "events_simulator", + "--scenario", "TRANSACTION_VOIDING_CHAIN", + "--seed", "1" + ] + ports: + - "8089:8080" + networks: database: diff --git a/packages/daemon/__tests__/integration/utils/index.ts b/packages/daemon/__tests__/integration/utils/index.ts index 360ee2f1..44d94aab 100644 --- a/packages/daemon/__tests__/integration/utils/index.ts +++ b/packages/daemon/__tests__/integration/utils/index.ts @@ -60,20 +60,64 @@ export const validateBalances = async ( balancesA: AddressBalance[], balancesB: Record, ): Promise => { - const length = Math.max(balancesA.length, Object.keys(balancesB).length); + // Create a set of all addresses from both sources + const allAddresses = new Set([ + ...balancesA.map(b => b.address), + ...Object.keys(balancesB) + ]); - for (let i = 0; i < length; i++) { - const balanceA = balancesA[i]; - const address = balanceA.address; + for (const address of allAddresses) { + const balanceA = balancesA.find(b => b.address === address); const balanceB = balancesB[address]; - const totalBalanceA = balanceA.lockedBalance + balanceA.unlockedBalance; + + const totalBalanceA = balanceA ? (balanceA.lockedBalance + balanceA.unlockedBalance) : BigInt(0); + const expectedBalance = balanceB || BigInt(0); - if (totalBalanceA !== balanceB) { - throw new Error(`Balances are not equal for address: ${address}, expected: ${balanceB}, received: ${totalBalanceA}`); + if (totalBalanceA !== expectedBalance) { + throw new Error(`Balances are not equal for address: ${address}, expected: ${expectedBalance}, received: ${totalBalanceA}`); } } }; +export const validateBalanceDistribution = ( + balances: AddressBalance[], + expectedConfig: { + balanceDistribution: number[]; + totalAddresses: number; + tokenId: string; + } +): void => { + // Filter balances for the expected token + const tokenBalances = balances.filter(b => b.tokenId === expectedConfig.tokenId); + + // Check total number of addresses + if (tokenBalances.length !== expectedConfig.totalAddresses) { + throw new Error( + `Expected ${expectedConfig.totalAddresses} addresses, but found ${tokenBalances.length}` + ); + } + + // Get actual balance amounts + const actualBalances = tokenBalances + .map(b => Number(b.lockedBalance + b.unlockedBalance)) + .sort((a, b) => b - a); // Sort descending + + // Sort expected balances descending to match + const expectedBalances = [...expectedConfig.balanceDistribution].sort((a, b) => b - a); + + // Check if balance distributions match + for (let i = 0; i < expectedBalances.length; i++) { + if (actualBalances[i] !== expectedBalances[i]) { + throw new Error( + `Balance distribution mismatch at position ${i}: expected ${expectedBalances[i]}, got ${actualBalances[i]}. ` + + `Expected: [${expectedBalances.join(', ')}], Actual: [${actualBalances.join(', ')}]` + ); + } + } +}; + +export * from './voiding-consistency-checks'; + export async function transitionUntilEvent(mysql: Connection, machine: Interpreter, eventId: number) { return await new Promise((resolve) => { machine.onTransition(async (state) => { diff --git a/packages/daemon/__tests__/integration/utils/voiding-consistency-checks.ts b/packages/daemon/__tests__/integration/utils/voiding-consistency-checks.ts new file mode 100644 index 00000000..ddf1e494 --- /dev/null +++ b/packages/daemon/__tests__/integration/utils/voiding-consistency-checks.ts @@ -0,0 +1,157 @@ +/** + * Database consistency checks for transaction voiding scenarios + */ + +import { Connection, RowDataPacket } from 'mysql2/promise'; + +interface TransactionRow extends RowDataPacket { + tx_id: string; + voided: number; // MySQL TINYINT/BOOLEAN comes back as number (0 or 1) +} + +interface UtxoRow extends RowDataPacket { + tx_id: string; + index: number; + value: string; + voided: number; // MySQL TINYINT/BOOLEAN comes back as number (0 or 1) + spent_by: string | null; +} + +export interface VoidingConsistencyCheck { + transactionStatuses: { + txId: string; + voided: boolean; + expected: boolean; + }[]; + utxoStatuses: { + txId: string; + index: number; + value: number; + voided: boolean; + spentBy: string | null; + expectedVoided: boolean; + expectedSpentBy: string | null; + }[]; +} + +export const performVoidingConsistencyChecks = async ( + mysql: Connection, + expectedChecks: { + transactions: { txId: string; expectedVoided: boolean }[]; + utxos: { + txId: string; + index: number; + expectedValue: number; + expectedVoided: boolean; + expectedSpentBy: string | null; + }[]; + } +): Promise => { + const results: VoidingConsistencyCheck = { + transactionStatuses: [], + utxoStatuses: [], + }; + + // Check transaction voiding statuses + for (const expectedTx of expectedChecks.transactions) { + const [rows] = await mysql.query( + 'SELECT tx_id, voided FROM `transaction` WHERE tx_id = ?', + [expectedTx.txId] + ); + + const actualVoided = rows.length > 0 ? rows[0].voided === 1 : false; + results.transactionStatuses.push({ + txId: expectedTx.txId, + voided: actualVoided, + expected: expectedTx.expectedVoided, + }); + } + + // Check UTXO statuses + for (const expectedUtxo of expectedChecks.utxos) { + const [rows] = await mysql.query( + 'SELECT tx_id, `index`, value, voided, spent_by FROM `tx_output` WHERE tx_id = ? AND `index` = ?', + [expectedUtxo.txId, expectedUtxo.index] + ); + + if (rows.length > 0) { + const utxo = rows[0]; + results.utxoStatuses.push({ + txId: expectedUtxo.txId, + index: expectedUtxo.index, + value: parseInt(utxo.value), + voided: utxo.voided === 1, + spentBy: utxo.spent_by, + expectedVoided: expectedUtxo.expectedVoided, + expectedSpentBy: expectedUtxo.expectedSpentBy, + }); + } else { + // UTXO not found + results.utxoStatuses.push({ + txId: expectedUtxo.txId, + index: expectedUtxo.index, + value: 0, + voided: false, + spentBy: null, + expectedVoided: expectedUtxo.expectedVoided, + expectedSpentBy: expectedUtxo.expectedSpentBy, + }); + } + } + + return results; +}; + +export const validateVoidingConsistency = (checks: VoidingConsistencyCheck): void => { + const errors: string[] = []; + + // Validate transaction statuses + for (const txCheck of checks.transactionStatuses) { + if (txCheck.voided !== txCheck.expected) { + errors.push( + `Transaction ${txCheck.txId}: expected voided=${txCheck.expected}, got voided=${txCheck.voided}` + ); + } + } + + // Validate UTXO statuses + for (const utxoCheck of checks.utxoStatuses) { + if (utxoCheck.voided !== utxoCheck.expectedVoided) { + errors.push( + `UTXO ${utxoCheck.txId}:${utxoCheck.index}: expected voided=${utxoCheck.expectedVoided}, got voided=${utxoCheck.voided}` + ); + } + + if (utxoCheck.spentBy !== utxoCheck.expectedSpentBy) { + errors.push( + `UTXO ${utxoCheck.txId}:${utxoCheck.index}: expected spentBy=${utxoCheck.expectedSpentBy}, got spentBy=${utxoCheck.spentBy}` + ); + } + } + + if (errors.length > 0) { + throw new Error(`Voiding consistency check failed:\n${errors.join('\n')}`); + } +}; + +export const printVoidingConsistencyReport = (checks: VoidingConsistencyCheck): string => { + let report = '=== Transaction Voiding Status ===\n'; + + for (const txCheck of checks.transactionStatuses) { + const status = txCheck.voided === txCheck.expected ? '✅' : '❌'; + report += `${status} ${txCheck.txId}: voided = ${txCheck.voided} (expected: ${txCheck.expected})\n`; + } + + report += '\n=== UTXO Status ===\n'; + + for (const utxoCheck of checks.utxoStatuses) { + const voidedStatus = utxoCheck.voided === utxoCheck.expectedVoided ? '✅' : '❌'; + const spentByStatus = utxoCheck.spentBy === utxoCheck.expectedSpentBy ? '✅' : '❌'; + const voidedLabel = utxoCheck.voided ? 'VOIDED' : 'VALID'; + const spentByLabel = utxoCheck.spentBy ? `spent by ${utxoCheck.spentBy}` : 'unspent'; + + report += `${voidedStatus}${spentByStatus} UTXO ${utxoCheck.txId}:${utxoCheck.index} = ${utxoCheck.value} HTR (${voidedLabel}, ${spentByLabel})\n`; + } + + return report; +}; \ No newline at end of file diff --git a/packages/daemon/__tests__/services/voidingWithInputs.test.ts b/packages/daemon/__tests__/services/voidingWithInputs.test.ts index 4f424646..267ecde9 100644 --- a/packages/daemon/__tests__/services/voidingWithInputs.test.ts +++ b/packages/daemon/__tests__/services/voidingWithInputs.test.ts @@ -59,7 +59,7 @@ describe('voidTransaction with input unspending', () => { const outputValue = 100n; await addOrUpdateTx(mysql, txIdA, 0, 1, 1, 100); - + // Add output from transaction A const outputA = createOutput(0, outputValue, addressA, tokenId); await addUtxos(mysql, txIdA, [outputA], null); @@ -72,9 +72,9 @@ describe('voidTransaction with input unspending', () => { // Create transaction B that spends the output from transaction A const txIdB = 'tx-b'; const addressB = 'address-b'; - + await addOrUpdateTx(mysql, txIdB, 0, 1, 1, 101); - + // Mark the output from A as spent by B const inputB = createInput(outputValue, addressA, txIdA, 0, tokenId); await updateTxOutputSpentBy(mysql, [inputB], txIdB); @@ -106,7 +106,7 @@ describe('voidTransaction with input unspending', () => { // CRITICAL: The voidTx function should unspent the inputs // This test should PASS because unspending is now implemented in voidTx - + // Check if the UTXO from transaction A is unspent again utxo = await getTxOutput(mysql, txIdA, 0, true); expect(utxo).not.toBeNull(); @@ -128,10 +128,10 @@ describe('voidTransaction with input unspending', () => { // Create two UTXOs await addOrUpdateTx(mysql, txIdA, 0, 1, 1, 100); await addOrUpdateTx(mysql, txIdB, 0, 1, 1, 101); - + const outputA = createOutput(0, 50n, address1, tokenId); const outputB = createOutput(0, 75n, address2, tokenId); - + await addUtxos(mysql, txIdA, [outputA], null); await addUtxos(mysql, txIdB, [outputB], null); @@ -143,7 +143,7 @@ describe('voidTransaction with input unspending', () => { // Create transaction C that spends both outputs await addOrUpdateTx(mysql, txIdC, 0, 1, 1, 102); - + const inputC1 = createInput(50n, address1, txIdA, 0, tokenId); const inputC2 = createInput(75n, address2, txIdB, 0, tokenId); await updateTxOutputSpentBy(mysql, [inputC1, inputC2], txIdC); @@ -180,7 +180,7 @@ describe('voidTransaction with input unspending', () => { // Check if both UTXOs from transactions A and B are unspent again utxoA = await getTxOutput(mysql, txIdA, 0, true); utxoB = await getTxOutput(mysql, txIdB, 0, true); - + // These assertions should PASS because unspending is now implemented expect(utxoA).not.toBeNull(); expect(utxoA!.spentBy).toBeNull(); // Should pass - should be null @@ -219,7 +219,7 @@ describe('voidTransaction with input unspending', () => { await addUtxos(mysql, txIdC, [outputC], null); // First void transaction C - await voidTx(mysql, txIdC, + await voidTx(mysql, txIdC, [createEventTxInput(100n, address2, txIdB, 0)], [{ value: 100n, @@ -228,7 +228,7 @@ describe('voidTransaction with input unspending', () => { token_data: 0, script: 'dqkUH70YjKeoKdFwMX2TOYvGVbXOrKaIrA==', }], - [tokenId], + [tokenId], [] ); @@ -284,7 +284,7 @@ describe('voidTransaction with input unspending', () => { // For this test, we simulate that transaction C also tried to spend it // (in reality this would be a double-spend, but we're testing edge cases) await addOrUpdateTx(mysql, txIdC, 0, 1, 1, 102); - + // Add output for transaction C const outputC = createOutput(0, 100n, address2, tokenId); await addUtxos(mysql, txIdC, [outputC], null); @@ -361,7 +361,7 @@ describe('voidTransaction with input unspending', () => { // Both UTXOs should be unspent const utxo1 = await getTxOutput(mysql, txIdA, 0, true); const utxo2 = await getTxOutput(mysql, txIdA, 1, true); - + // These should pass with the implementation expect(utxo1).not.toBeNull(); expect(utxo1!.spentBy).toBeNull(); @@ -375,7 +375,7 @@ describe('voidTransaction with input unspending', () => { // Complete integration test const txIdA = 'tx-a'; const txIdB = 'tx-b'; - const address1 = 'address-1'; + const address1 = 'address-1'; const address2 = 'address-2'; const tokenId = '00'; const value = 200n; @@ -485,4 +485,4 @@ describe('unspentTxOutputs function', () => { expect(utxoC).not.toBeNull(); expect(utxoC!.spentBy).toBeNull(); }); -}); \ No newline at end of file +}); diff --git a/packages/daemon/__tests__/services/walletBalanceVoiding.test.ts b/packages/daemon/__tests__/services/walletBalanceVoiding.test.ts index 1b39b6c8..13c8cb80 100644 --- a/packages/daemon/__tests__/services/walletBalanceVoiding.test.ts +++ b/packages/daemon/__tests__/services/walletBalanceVoiding.test.ts @@ -76,11 +76,11 @@ const getWalletBalance = async (walletId: string, tokenId: string) => { // Helper function to manually insert wallet balance (simulating what should happen) const insertWalletBalance = async (walletId: string, tokenId: string, balance: bigint, transactions: number) => { await mysql.query( - `INSERT INTO \`wallet_balance\` (wallet_id, token_id, unlocked_balance, locked_balance, - unlocked_authorities, locked_authorities, total_received, + `INSERT INTO \`wallet_balance\` (wallet_id, token_id, unlocked_balance, locked_balance, + unlocked_authorities, locked_authorities, total_received, transactions, timelock_expires) VALUES (?, ?, ?, 0, 0, 0, ?, ?, NULL) - ON DUPLICATE KEY UPDATE + ON DUPLICATE KEY UPDATE unlocked_balance = unlocked_balance + ?, total_received = total_received + ?, transactions = transactions + ?`, @@ -143,7 +143,7 @@ describe('wallet balance voiding bug', () => { // Update wallet balance for transaction B (net zero change) await insertWalletBalance(walletId, tokenId, 0n, 1); - + // Add to wallet_tx_history await mysql.query( `INSERT INTO \`wallet_tx_history\` (wallet_id, token_id, tx_id, balance, timestamp) @@ -176,11 +176,11 @@ describe('wallet balance voiding bug', () => { // CRITICAL BUG: Check wallet balance after voiding walletBalance = await getWalletBalance(walletId, tokenId); expect(walletBalance).not.toBeNull(); - + // This will FAIL because wallet balances are not updated during voiding // The transaction count should decrease from 2 to 1 expect(walletBalance.transactions).toBe(1); // Should be back to 1 transaction - + // Check if wallet_tx_history was cleaned up const historyCount = await getWalletTxHistoryCount(walletId, txIdB); // This will FAIL because wallet_tx_history is not cleaned up during voiding @@ -207,7 +207,7 @@ describe('wallet balance voiding bug', () => { // Create a transaction that transfers money from wallet1 to wallet2 await addOrUpdateTx(mysql, txId, 0, 1, 1, 100); - + // Transaction sends money from wallet1 to wallet2 const outputs = [ createOutput(0, amount, address2, tokenId), // To wallet2 @@ -223,7 +223,7 @@ describe('wallet balance voiding bug', () => { [amount, wallet1Id, tokenId] ); - // Wallet2 gains money (+150n) + // Wallet2 gains money (+150n) await insertWalletBalance(wallet2Id, tokenId, amount, 1); // Add wallet_tx_history entries @@ -236,7 +236,7 @@ describe('wallet balance voiding bug', () => { // Verify wallet balances before voiding let wallet1Balance = await getWalletBalance(wallet1Id, tokenId); let wallet2Balance = await getWalletBalance(wallet2Id, tokenId); - + expect(wallet1Balance).not.toBeNull(); expect(BigInt(wallet1Balance.unlocked_balance)).toBe(50n); // 200 - 150 expect(wallet1Balance.transactions).toBe(2); @@ -266,7 +266,7 @@ describe('wallet balance voiding bug', () => { wallet2Balance = await getWalletBalance(wallet2Id, tokenId); // These assertions will FAIL because wallet balances are not updated during voiding - + // Wallet1 should have its balance restored (50 + 150 = 200) expect(BigInt(wallet1Balance.unlocked_balance)).toBe(200n); expect(wallet1Balance.transactions).toBe(1); // Should decrease back to 1 @@ -280,7 +280,7 @@ describe('wallet balance voiding bug', () => { // Check wallet_tx_history cleanup const wallet1HistoryCount = await getWalletTxHistoryCount(wallet1Id, txId); const wallet2HistoryCount = await getWalletTxHistoryCount(wallet2Id, txId); - + // These will FAIL because wallet_tx_history is not cleaned up expect(wallet1HistoryCount).toBe(0); expect(wallet2HistoryCount).toBe(0); @@ -335,7 +335,7 @@ describe('wallet balance voiding bug', () => { // Check wallet balance after voiding walletBalance = await getWalletBalance(walletId, tokenId); - + // This WILL FAIL because wallet balances are not updated during voiding if (walletBalance) { expect(BigInt(walletBalance.unlocked_balance)).toBe(0n); // Should be 0 after voiding @@ -346,4 +346,4 @@ describe('wallet balance voiding bug', () => { const historyCount = await getWalletTxHistoryCount(walletId, txId); expect(historyCount).toBe(0); // Should be 0 after voiding }); -}); \ No newline at end of file +}); diff --git a/packages/daemon/src/types/event.ts b/packages/daemon/src/types/event.ts index 23f1a7c5..3b3fbc50 100644 --- a/packages/daemon/src/types/event.ts +++ b/packages/daemon/src/types/event.ts @@ -14,14 +14,14 @@ export type WebSocketEvent = export type WebSocketSendEvent = | { - type: 'START_STREAM'; - window_size: number; - last_ack_event_id?: number; + type: 'START_STREAM'; + window_size: number; + last_ack_event_id?: number; } | { - type: 'ACK'; - window_size: number; - ack_event_id?: number; + type: 'ACK'; + window_size: number; + ack_event_id?: number; }; export type HealthCheckEvent = @@ -43,8 +43,8 @@ export enum FullNodeEventTypes { LOAD_STARTED = 'LOAD_STARTED', LOAD_FINISHED = 'LOAD_FINISHED', REORG_STARTED = 'REORG_STARTED', - REORG_FINISHED= 'REORG_FINISHED', - NC_EVENT= 'NC_EVENT', + REORG_FINISHED = 'REORG_FINISHED', + NC_EVENT = 'NC_EVENT', } /** @@ -76,7 +76,7 @@ export type Event = | { type: EventTypes.FULLNODE_EVENT, event: FullNodeEvent } | { type: EventTypes.METADATA_DECIDED, event: MetadataDecidedEvent } | { type: EventTypes.WEBSOCKET_SEND_EVENT, event: WebSocketSendEvent } - | { type: EventTypes.HEALTHCHECK_EVENT, event: HealthCheckEvent}; + | { type: EventTypes.HEALTHCHECK_EVENT, event: HealthCheckEvent }; export interface VertexRemovedEventData { @@ -105,6 +105,9 @@ export const EventTxOutputSchema = z.object({ timelock: z.number().nullable(), token_data: z.number().optional(), }).nullable(), + z.object({ + token_data: z.number(), + }), z.object({}).strict() ]), }); @@ -118,11 +121,11 @@ export const EventTxInputSchema = z.object({ export type EventTxInput = z.infer; export const EventTxNanoHeaderSchema = z.object({ - id: z.string(), - nc_seqnum: z.number(), - nc_id: z.string(), - nc_method: z.string(), - nc_address: z.string(), + id: z.string(), + nc_seqnum: z.number(), + nc_id: z.string(), + nc_method: z.string(), + nc_address: z.string(), }); export type EventTxNanoHeader = z.infer; From bf6ff571b987eca378f3016c47fc6d839825018a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Abadesso?= Date: Thu, 4 Sep 2025 13:36:51 -0300 Subject: [PATCH 06/49] tests: inconsistent tests --- .../__tests__/services/voidingWithInputs.test.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/daemon/__tests__/services/voidingWithInputs.test.ts b/packages/daemon/__tests__/services/voidingWithInputs.test.ts index 267ecde9..d7d2d2cb 100644 --- a/packages/daemon/__tests__/services/voidingWithInputs.test.ts +++ b/packages/daemon/__tests__/services/voidingWithInputs.test.ts @@ -41,11 +41,15 @@ beforeAll(async () => { }); afterAll(async () => { - await mysql.destroy(); + if (mysql) { + await mysql.destroy(); + } }); beforeEach(async () => { await cleanDatabase(mysql); + // Add a small delay to ensure database operations complete + await new Promise(resolve => setTimeout(resolve, 10)); }); describe('voidTransaction with input unspending', () => { @@ -64,6 +68,9 @@ describe('voidTransaction with input unspending', () => { const outputA = createOutput(0, outputValue, addressA, tokenId); await addUtxos(mysql, txIdA, [outputA], null); + // Ensure database operations complete + await new Promise(resolve => setTimeout(resolve, 10)); + // Verify the UTXO is unspent let utxo = await getTxOutput(mysql, txIdA, 0, true); expect(utxo).not.toBeNull(); @@ -79,6 +86,9 @@ describe('voidTransaction with input unspending', () => { const inputB = createInput(outputValue, addressA, txIdA, 0, tokenId); await updateTxOutputSpentBy(mysql, [inputB], txIdB); + // Ensure database operations complete + await new Promise(resolve => setTimeout(resolve, 10)); + // Verify the UTXO is now spent utxo = await getTxOutput(mysql, txIdA, 0, false); expect(utxo).not.toBeNull(); @@ -88,6 +98,9 @@ describe('voidTransaction with input unspending', () => { const outputB = createOutput(0, outputValue, addressB, tokenId); await addUtxos(mysql, txIdB, [outputB], null); + // Ensure database operations complete before voiding + await new Promise(resolve => setTimeout(resolve, 10)); + // Now void transaction B using the voidTx service function const inputs = [createEventTxInput(outputValue, addressA, txIdA, 0)]; const outputs = [{ From 5077356b4f7882da79f3e06c8d49a9be9f9e22a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Abadesso?= Date: Fri, 5 Sep 2025 12:41:38 -0300 Subject: [PATCH 07/49] chore: off-by-one error on unvoided tx scenario --- .../__tests__/integration/balances.test.ts | 97 ++- .../daemon/__tests__/integration/config.ts | 2 +- .../scenario_configs/reorg.balances.ts | 2 +- .../transaction_voiding_chain.balances.ts | 24 +- .../__tests__/integration/utils/index.ts | 52 +- .../utils/voiding-consistency-checks.ts | 22 - .../__tests__/services/services.test.ts | 3 + .../services/services_with_db.test.ts | 787 +++++++++++++++++- .../services/voidingWithInputs.test.ts | 501 ----------- .../services/walletBalanceVoiding.test.ts | 349 -------- packages/daemon/package.json | 2 +- packages/daemon/src/db/index.ts | 17 +- packages/daemon/src/services/index.ts | 13 +- 13 files changed, 880 insertions(+), 991 deletions(-) delete mode 100644 packages/daemon/__tests__/services/voidingWithInputs.test.ts delete mode 100644 packages/daemon/__tests__/services/walletBalanceVoiding.test.ts diff --git a/packages/daemon/__tests__/integration/balances.test.ts b/packages/daemon/__tests__/integration/balances.test.ts index 79fd6027..89b3d912 100644 --- a/packages/daemon/__tests__/integration/balances.test.ts +++ b/packages/daemon/__tests__/integration/balances.test.ts @@ -10,15 +10,13 @@ import { SyncMachine } from '../../src/machines'; import { interpret } from 'xstate'; import { getDbConnection } from '../../src/db'; import { Connection } from 'mysql2/promise'; -import { - cleanDatabase, - fetchAddressBalances, - transitionUntilEvent, - validateBalances, - validateBalanceDistribution, +import { + cleanDatabase, + fetchAddressBalances, + transitionUntilEvent, + validateBalances, performVoidingConsistencyChecks, validateVoidingConsistency, - printVoidingConsistencyReport } from './utils'; import unvoidedScenarioBalances from './scenario_configs/unvoided_transactions.balances'; import reorgScenarioBalances from './scenario_configs/reorg.balances'; @@ -81,30 +79,26 @@ getConfig.mockReturnValue({ DB_PORT, }); -// Use a single mysql connection for all tests let mysql: Connection; + beforeAll(async () => { - try { - mysql = await getDbConnection(); - } catch (e) { - console.error('Failed to establish db connection', e); - throw e; - } + mysql = await getDbConnection(); + await cleanDatabase(mysql); }); -beforeEach(async () => { - await cleanDatabase(mysql); +afterAll(async () => { + jest.resetAllMocks(); + if (mysql && 'release' in mysql) { + // @ts-expect-error - pooled connection has release method + await mysql.release(); + } }); describe('unvoided transaction scenario', () => { - beforeAll(() => { + beforeAll(async () => { jest.spyOn(Services, 'fetchMinRewardBlocks').mockImplementation(async () => 300); }); - afterAll(() => { - jest.resetAllMocks(); - }); - it('should do a full sync and the balances should match', async () => { // @ts-expect-error getConfig.mockReturnValue({ @@ -130,13 +124,14 @@ describe('unvoided transaction scenario', () => { // @ts-expect-error await transitionUntilEvent(mysql, machine, UNVOIDED_SCENARIO_LAST_EVENT); const addressBalances = await fetchAddressBalances(mysql); - expect(validateBalances(addressBalances, unvoidedScenarioBalances)); + await validateBalances(addressBalances, unvoidedScenarioBalances); }); }); describe('reorg scenario', () => { - beforeAll(() => { + beforeAll(async () => { jest.spyOn(Services, 'fetchMinRewardBlocks').mockImplementation(async () => 300); + await cleanDatabase(mysql); }); it('should do a full sync and the balances should match', async () => { @@ -164,13 +159,14 @@ describe('reorg scenario', () => { // @ts-expect-error await transitionUntilEvent(mysql, machine, REORG_SCENARIO_LAST_EVENT); const addressBalances = await fetchAddressBalances(mysql); - expect(validateBalances(addressBalances, reorgScenarioBalances)); + await validateBalances(addressBalances, reorgScenarioBalances); }); }); describe('single chain blocks and transactions scenario', () => { - beforeAll(() => { + beforeAll(async () => { jest.spyOn(Services, 'fetchMinRewardBlocks').mockImplementation(async () => 300); + await cleanDatabase(mysql); }); it('should do a full sync and the balances should match', async () => { @@ -198,13 +194,14 @@ describe('single chain blocks and transactions scenario', () => { // @ts-expect-error await transitionUntilEvent(mysql, machine, SINGLE_CHAIN_BLOCKS_AND_TRANSACTIONS_LAST_EVENT); const addressBalances = await fetchAddressBalances(mysql); - expect(validateBalances(addressBalances, singleChainBlocksAndTransactionsBalances)); + await validateBalances(addressBalances, singleChainBlocksAndTransactionsBalances); }); }); describe('invalid mempool transactions scenario', () => { - beforeAll(() => { + beforeAll(async () => { jest.spyOn(Services, 'fetchMinRewardBlocks').mockImplementation(async () => 300); + await cleanDatabase(mysql); }); it('should do a full sync and the balances should match', async () => { @@ -232,13 +229,14 @@ describe('invalid mempool transactions scenario', () => { // @ts-expect-error await transitionUntilEvent(mysql, machine, INVALID_MEMPOOL_TRANSACTION_LAST_EVENT); const addressBalances = await fetchAddressBalances(mysql); - expect(validateBalances(addressBalances, invalidMempoolBalances)); + await validateBalances(addressBalances, invalidMempoolBalances); }); }); describe('custom script scenario', () => { - beforeAll(() => { + beforeAll(async () => { jest.spyOn(Services, 'fetchMinRewardBlocks').mockImplementation(async () => 300); + await cleanDatabase(mysql); }); it('should do a full sync and the balances should match', async () => { @@ -266,13 +264,14 @@ describe('custom script scenario', () => { // @ts-expect-error await transitionUntilEvent(mysql, machine, CUSTOM_SCRIPT_LAST_EVENT); const addressBalances = await fetchAddressBalances(mysql); - expect(validateBalances(addressBalances, customScriptBalances)); + await validateBalances(addressBalances, customScriptBalances); }); }); describe('empty script scenario', () => { - beforeAll(() => { + beforeAll(async () => { jest.spyOn(Services, 'fetchMinRewardBlocks').mockImplementation(async () => 300); + await cleanDatabase(mysql); }); it('should do a full sync and the balances should match', async () => { @@ -301,13 +300,14 @@ describe('empty script scenario', () => { await transitionUntilEvent(mysql, machine, EMPTY_SCRIPT_LAST_EVENT); const addressBalances = await fetchAddressBalances(mysql); - expect(validateBalances(addressBalances, emptyScriptBalances)); + await validateBalances(addressBalances, emptyScriptBalances); }); }); describe('nc events scenario', () => { - beforeAll(() => { + beforeAll(async () => { jest.spyOn(Services, 'fetchMinRewardBlocks').mockImplementation(async () => 300); + await cleanDatabase(mysql); }); it('should do a full sync and the balances should match', async () => { @@ -336,14 +336,14 @@ describe('nc events scenario', () => { await transitionUntilEvent(mysql, machine, NC_EVENTS_LAST_EVENT); const addressBalances = await fetchAddressBalances(mysql); - // @ts-ignore - expect(validateBalances(addressBalances, ncEventsBalances)); + await validateBalances(addressBalances, ncEventsBalances); }); }); describe('transaction voiding chain scenario', () => { - beforeAll(() => { + beforeAll(async () => { jest.spyOn(Services, 'fetchMinRewardBlocks').mockImplementation(async () => 300); + await cleanDatabase(mysql); }); it('should do a full sync and the balances should match after voiding chain', async () => { @@ -372,7 +372,28 @@ describe('transaction voiding chain scenario', () => { await transitionUntilEvent(mysql, machine, TRANSACTION_VOIDING_CHAIN_LAST_EVENT); const addressBalances = await fetchAddressBalances(mysql); - // Validate balance distribution - validateBalanceDistribution(addressBalances, transactionVoidingChainBalances); + await validateBalances(addressBalances, transactionVoidingChainBalances); + + // Validate transaction voiding consistency + const voidingChecks = await performVoidingConsistencyChecks(mysql, { + transactions: [ + { txId: '404eeba6f3658028722e665684c42a0c28c3a44b190426971e012c5030bf1903', expectedVoided: false }, // tx1 + { txId: '4412a1f718b51c054193cc0df2d1dd9d16e82684be17760d7169aa6fb22d5ea2', expectedVoided: false }, // tx2 + { txId: '06b20fd4258ae965137203d4e1fd7df7b69e775e5d3f4a4568d1161343b91f02', expectedVoided: true }, // spending_tx (voided) + { txId: 'ada5e728ffd680238306b510899629099d6bbb58a8811b042c249a236f9640cc', expectedVoided: false }, // voiding_tx + ], + utxos: [ + // Spending transaction (voided) UTXOs should be marked as voided + { txId: '06b20fd4258ae965137203d4e1fd7df7b69e775e5d3f4a4568d1161343b91f02', index: 0, expectedValue: 5900, expectedVoided: true, expectedSpentBy: null }, + { txId: '06b20fd4258ae965137203d4e1fd7df7b69e775e5d3f4a4568d1161343b91f02', index: 1, expectedValue: 500, expectedVoided: true, expectedSpentBy: null }, + + // Voiding transaction (valid) UTXOs should not be voided + { txId: 'ada5e728ffd680238306b510899629099d6bbb58a8811b042c249a236f9640cc', index: 0, expectedValue: 5900, expectedVoided: false, expectedSpentBy: null }, + { txId: 'ada5e728ffd680238306b510899629099d6bbb58a8811b042c249a236f9640cc', index: 1, expectedValue: 500, expectedVoided: false, expectedSpentBy: null }, + ], + }); + + // Validate consistency + validateVoidingConsistency(voidingChecks); }, 30000); // 30 second timeout for transaction voiding chain test }); diff --git a/packages/daemon/__tests__/integration/config.ts b/packages/daemon/__tests__/integration/config.ts index d85a5ff8..be742c61 100644 --- a/packages/daemon/__tests__/integration/config.ts +++ b/packages/daemon/__tests__/integration/config.ts @@ -11,7 +11,7 @@ export const UNVOIDED_SCENARIO_PORT = 8081; // Last event is actually 39, but event 39 is ignored by the machine (because // the transaction is already added), and when we ignore an event, we don't store // it in the database. -export const UNVOIDED_SCENARIO_LAST_EVENT = 38; +export const UNVOIDED_SCENARIO_LAST_EVENT = 39; // reorg export const REORG_SCENARIO_PORT = 8082; diff --git a/packages/daemon/__tests__/integration/scenario_configs/reorg.balances.ts b/packages/daemon/__tests__/integration/scenario_configs/reorg.balances.ts index 91bdc3b7..e8a45c36 100644 --- a/packages/daemon/__tests__/integration/scenario_configs/reorg.balances.ts +++ b/packages/daemon/__tests__/integration/scenario_configs/reorg.balances.ts @@ -3,4 +3,4 @@ export default { 'HFyF1jYJP9FXfiC3LRqf3q4768TBL1rxbn': 6400n, 'HMbS5P3NTLQ5oR5TfLNvAkeQ7L8MPn9VM3': 6400n, 'HRQe4CXj8AZXzSmuNztU8iQR74QTQMbnTs': 0n, -} +}; diff --git a/packages/daemon/__tests__/integration/scenario_configs/transaction_voiding_chain.balances.ts b/packages/daemon/__tests__/integration/scenario_configs/transaction_voiding_chain.balances.ts index de93ffb1..0a7e55cf 100644 --- a/packages/daemon/__tests__/integration/scenario_configs/transaction_voiding_chain.balances.ts +++ b/packages/daemon/__tests__/integration/scenario_configs/transaction_voiding_chain.balances.ts @@ -5,24 +5,8 @@ * LICENSE file in the root directory of this source tree. */ -/** - * Expected balances for the TRANSACTION_VOIDING_CHAIN scenario - * - * This scenario tests the wallet service's ability to handle a chain of transactions - * being voided, including proper unspending of inputs and balance recalculation. - * - * Since the scenario uses random seed, addresses may vary between runs. - * We validate the balance distribution pattern instead of exact addresses. - */ - -const EXPECTED = { - // Expected balance distribution from simulator output: exactly these amounts should exist - // Note: Main address includes genesis balance (1000000 * 100) + actual balance (73900) - balanceDistribution: [73900, 5900, 3400, 0], - // Total expected addresses (including the zero-balance address) - totalAddresses: 4, - // Token ID for all balances - tokenId: '00' +export default { + 'HRQe4CXj8AZXzSmuNztU8iQR74QTQMbnTs': 73900n, + 'HQfVqxyxQV4BHwnsMnRXpZGmwPYiNSVmMu': 3400n, + 'HPNvtPZaDF44i6CL91u4BvZPu6z2xPNt26': 5900n, }; - -export default EXPECTED; diff --git a/packages/daemon/__tests__/integration/utils/index.ts b/packages/daemon/__tests__/integration/utils/index.ts index 44d94aab..4fe78f54 100644 --- a/packages/daemon/__tests__/integration/utils/index.ts +++ b/packages/daemon/__tests__/integration/utils/index.ts @@ -60,16 +60,13 @@ export const validateBalances = async ( balancesA: AddressBalance[], balancesB: Record, ): Promise => { - // Create a set of all addresses from both sources - const allAddresses = new Set([ - ...balancesA.map(b => b.address), - ...Object.keys(balancesB) - ]); + // Only validate addresses that are explicitly mentioned in the expected config + const expectedAddresses = Object.keys(balancesB); - for (const address of allAddresses) { + for (const address of expectedAddresses) { const balanceA = balancesA.find(b => b.address === address); const balanceB = balancesB[address]; - + const totalBalanceA = balanceA ? (balanceA.lockedBalance + balanceA.unlockedBalance) : BigInt(0); const expectedBalance = balanceB || BigInt(0); @@ -79,45 +76,6 @@ export const validateBalances = async ( } }; -export const validateBalanceDistribution = ( - balances: AddressBalance[], - expectedConfig: { - balanceDistribution: number[]; - totalAddresses: number; - tokenId: string; - } -): void => { - // Filter balances for the expected token - const tokenBalances = balances.filter(b => b.tokenId === expectedConfig.tokenId); - - // Check total number of addresses - if (tokenBalances.length !== expectedConfig.totalAddresses) { - throw new Error( - `Expected ${expectedConfig.totalAddresses} addresses, but found ${tokenBalances.length}` - ); - } - - // Get actual balance amounts - const actualBalances = tokenBalances - .map(b => Number(b.lockedBalance + b.unlockedBalance)) - .sort((a, b) => b - a); // Sort descending - - // Sort expected balances descending to match - const expectedBalances = [...expectedConfig.balanceDistribution].sort((a, b) => b - a); - - // Check if balance distributions match - for (let i = 0; i < expectedBalances.length; i++) { - if (actualBalances[i] !== expectedBalances[i]) { - throw new Error( - `Balance distribution mismatch at position ${i}: expected ${expectedBalances[i]}, got ${actualBalances[i]}. ` + - `Expected: [${expectedBalances.join(', ')}], Actual: [${actualBalances.join(', ')}]` - ); - } - } -}; - -export * from './voiding-consistency-checks'; - export async function transitionUntilEvent(mysql: Connection, machine: Interpreter, eventId: number) { return await new Promise((resolve) => { machine.onTransition(async (state) => { @@ -134,3 +92,5 @@ export async function transitionUntilEvent(mysql: Connection, machine: Interpret machine.start(); }); } + +export * from './voiding-consistency-checks'; diff --git a/packages/daemon/__tests__/integration/utils/voiding-consistency-checks.ts b/packages/daemon/__tests__/integration/utils/voiding-consistency-checks.ts index ddf1e494..a145eef7 100644 --- a/packages/daemon/__tests__/integration/utils/voiding-consistency-checks.ts +++ b/packages/daemon/__tests__/integration/utils/voiding-consistency-checks.ts @@ -133,25 +133,3 @@ export const validateVoidingConsistency = (checks: VoidingConsistencyCheck): voi throw new Error(`Voiding consistency check failed:\n${errors.join('\n')}`); } }; - -export const printVoidingConsistencyReport = (checks: VoidingConsistencyCheck): string => { - let report = '=== Transaction Voiding Status ===\n'; - - for (const txCheck of checks.transactionStatuses) { - const status = txCheck.voided === txCheck.expected ? '✅' : '❌'; - report += `${status} ${txCheck.txId}: voided = ${txCheck.voided} (expected: ${txCheck.expected})\n`; - } - - report += '\n=== UTXO Status ===\n'; - - for (const utxoCheck of checks.utxoStatuses) { - const voidedStatus = utxoCheck.voided === utxoCheck.expectedVoided ? '✅' : '❌'; - const spentByStatus = utxoCheck.spentBy === utxoCheck.expectedSpentBy ? '✅' : '❌'; - const voidedLabel = utxoCheck.voided ? 'VOIDED' : 'VALID'; - const spentByLabel = utxoCheck.spentBy ? `spent by ${utxoCheck.spentBy}` : 'unspent'; - - report += `${voidedStatus}${spentByStatus} UTXO ${utxoCheck.txId}:${utxoCheck.index} = ${utxoCheck.value} HTR (${voidedLabel}, ${spentByLabel})\n`; - } - - return report; -}; \ No newline at end of file diff --git a/packages/daemon/__tests__/services/services.test.ts b/packages/daemon/__tests__/services/services.test.ts index 5697434d..f18bcb4e 100644 --- a/packages/daemon/__tests__/services/services.test.ts +++ b/packages/daemon/__tests__/services/services.test.ts @@ -14,6 +14,7 @@ import { getLastSyncedEvent, updateLastSyncedEvent as dbUpdateLastSyncedEvent, getTxOutputsFromTx, + getTxOutput, voidTransaction, getTransactionById, getUtxosLockedAtHeight, @@ -65,6 +66,7 @@ jest.mock('../../src/db', () => ({ updateLastSyncedEvent: jest.fn(), addOrUpdateTx: jest.fn(), getTxOutputsFromTx: jest.fn(), + getTxOutput: jest.fn(), voidTransaction: jest.fn(), markUtxosAsVoided: jest.fn(), dbUpdateLastSyncedEvent: jest.fn(), @@ -411,6 +413,7 @@ describe('handleVoidedTx', () => { (prepareInputs as jest.Mock).mockReturnValue([]); (getAddressBalanceMap as jest.Mock).mockReturnValue({}); (getTxOutputsFromTx as jest.Mock).mockResolvedValue([]); + (getAddressWalletInfo as jest.Mock).mockResolvedValue({}); await handleVoidedTx(context as any); diff --git a/packages/daemon/__tests__/services/services_with_db.test.ts b/packages/daemon/__tests__/services/services_with_db.test.ts index 33b7809e..04b8b9f8 100644 --- a/packages/daemon/__tests__/services/services_with_db.test.ts +++ b/packages/daemon/__tests__/services/services_with_db.test.ts @@ -6,13 +6,54 @@ */ import * as db from '../../src/db'; -import { handleVoidedTx } from '../../src/services'; +import { handleVoidedTx, voidTx } from '../../src/services'; import { LRU } from '../../src/utils'; +import { + addOrUpdateTx, + addUtxos, + updateTxOutputSpentBy, + getTxOutput, + unspendUtxos, +} from '../../src/db'; +import { + cleanDatabase, + checkUtxoTable, + createOutput, + createInput, + createEventTxInput, +} from '../utils'; +import { DbTxOutput, EventTxInput } from '../../src/types'; +import { Connection } from 'mysql2/promise'; /** * @jest-environment node */ + +// Use a single mysql connection for all tests +let mysql: Connection; + +beforeAll(async () => { + try { + mysql = await db.getDbConnection(); + } catch (e) { + console.error('Failed to establish db connection', e); + throw e; + } +}); + +afterAll(async () => { + if (mysql) { + await mysql.destroy(); + } +}); + +beforeEach(async () => { + await cleanDatabase(mysql); + // Add a small delay to ensure database operations complete + await new Promise(resolve => setTimeout(resolve, 10)); +}); + describe('handleVoidedTx (db)', () => { beforeEach(() => { jest.clearAllMocks(); @@ -62,3 +103,747 @@ describe('handleVoidedTx (db)', () => { }); }); }); + +describe('voidTransaction with input unspending', () => { + it('should unspent inputs when voiding a transaction', async () => { + expect.hasAssertions(); + + // Create transaction A that creates an output + const txIdA = 'test1-tx-a'; + const addressA = 'test1-address-a'; + const tokenId = '00'; + const outputValue = 100n; + + await addOrUpdateTx(mysql, txIdA, 0, 1, 1, 100); + + // Add output from transaction A + const outputA = createOutput(0, outputValue, addressA, tokenId); + await addUtxos(mysql, txIdA, [outputA], null); + + // Verify the UTXO is unspent + let utxo = await getTxOutput(mysql, txIdA, 0, true); + expect(utxo).not.toBeNull(); + expect(utxo!.spentBy).toBeNull(); + + // Create transaction B that spends the output from transaction A + const txIdB = 'test1-tx-b'; + const addressB = 'test1-address-b'; + + await addOrUpdateTx(mysql, txIdB, 0, 1, 1, 101); + + // Mark the output from A as spent by B + const inputB = createInput(outputValue, addressA, txIdA, 0, tokenId); + await updateTxOutputSpentBy(mysql, [inputB], txIdB); + + // Verify the UTXO is now spent + utxo = await getTxOutput(mysql, txIdA, 0, false); + expect(utxo).not.toBeNull(); + expect(utxo!.spentBy).toBe(txIdB); + + // Add output from transaction B + const outputB = createOutput(0, outputValue, addressB, tokenId); + await addUtxos(mysql, txIdB, [outputB], null); + + + // Now void transaction B using the voidTx service function + const inputs = [createEventTxInput(outputValue, addressA, txIdA, 0)]; + const outputs = [{ + value: outputValue, + locked: false, + decoded: { + type: 'P2PKH' as const, + address: addressB, + timelock: null, + }, + token_data: 0, + script: 'dqkUH70YjKeoKdFwMX2TOYvGVbXOrKaIrA==', + }]; + + await voidTx(mysql, txIdB, inputs, outputs, [tokenId], []); + + // Check if the UTXO from transaction A is unspent again + utxo = await getTxOutput(mysql, txIdA, 0, false); + expect(utxo).not.toBeNull(); + expect(utxo!.spentBy).toBeNull(); + }); + + it('should unspent multiple inputs when voiding a transaction with multiple inputs', async () => { + expect.hasAssertions(); + + // Create transactions A and B that create outputs + const txIdA = 'test2-tx-a'; + const txIdB = 'test2-tx-b'; + const txIdC = 'test2-tx-c'; // The transaction we'll void + const address1 = 'test2-address-1'; + const address2 = 'test2-address-2'; + const address3 = 'test2-address-3'; + const tokenId = '00'; + + // Create two UTXOs + await addOrUpdateTx(mysql, txIdA, 0, 1, 1, 100); + await addOrUpdateTx(mysql, txIdB, 0, 1, 1, 101); + + const outputA = createOutput(0, 50n, address1, tokenId); + const outputB = createOutput(0, 75n, address2, tokenId); + + await addUtxos(mysql, txIdA, [outputA], null); + await addUtxos(mysql, txIdB, [outputB], null); + + // Verify both UTXOs are unspent + let utxoA = await getTxOutput(mysql, txIdA, 0, true); + let utxoB = await getTxOutput(mysql, txIdB, 0, true); + expect(utxoA!.spentBy).toBeNull(); + expect(utxoB!.spentBy).toBeNull(); + + // Create transaction C that spends both outputs + await addOrUpdateTx(mysql, txIdC, 0, 1, 1, 102); + + const inputC1 = createInput(50n, address1, txIdA, 0, tokenId); + const inputC2 = createInput(75n, address2, txIdB, 0, tokenId); + await updateTxOutputSpentBy(mysql, [inputC1, inputC2], txIdC); + + // Verify both UTXOs are now spent by C + utxoA = await getTxOutput(mysql, txIdA, 0, false); + utxoB = await getTxOutput(mysql, txIdB, 0, false); + expect(utxoA!.spentBy).toBe(txIdC); + expect(utxoB!.spentBy).toBe(txIdC); + + // Add output from transaction C + const outputC = createOutput(0, 125n, address3, tokenId); + await addUtxos(mysql, txIdC, [outputC], null); + + // Void transaction C using voidTx service function + const inputs = [ + createEventTxInput(50n, address1, txIdA, 0), + createEventTxInput(75n, address2, txIdB, 0), + ]; + const outputs = [{ + value: 125n, + locked: false, + decoded: { + type: 'P2PKH' as const, + address: address3, + timelock: null, + }, + token_data: 0, + script: 'dqkUH70YjKeoKdFwMX2TOYvGVbXOrKaIrA==', + }]; + + await voidTx(mysql, txIdC, inputs, outputs, [tokenId], []); + + // Check if UTXOs from transactions A and B are unspent again + utxoA = await getTxOutput(mysql, txIdA, 0, true); + utxoB = await getTxOutput(mysql, txIdB, 0, true); + + // Both outputs should be unspent again after voiding C (which was spending them) + expect(utxoA).not.toBeNull(); + expect(utxoA!.spentBy).toBeNull(); // Should pass - should be null + expect(utxoB).not.toBeNull(); + expect(utxoB!.spentBy).toBeNull(); // Should pass - should be null + + // The output from transaction C should be voided (not accessible with getTxOutput) + const utxoC = await getTxOutput(mysql, txIdC, 0, false); + expect(utxoC).toBeNull(); // Should be null because it's voided + }); + + it('should handle voiding a transaction that spends already voided outputs', async () => { + expect.hasAssertions(); + + // Create transaction A that creates an output + const txIdA = 'test3-tx-a'; + const txIdB = 'test3-tx-b'; // Will be voided first + const txIdC = 'test3-tx-c'; // Will be voided second + const address1 = 'test3-address-1'; + const address2 = 'test3-address-2'; + const address3 = 'test3-address-3'; + const tokenId = '00'; + + await addOrUpdateTx(mysql, txIdA, 0, 1, 1, 100); + const outputA = createOutput(0, 100n, address1, tokenId); + await addUtxos(mysql, txIdA, [outputA], null); + + // Transaction B spends A's output + await addOrUpdateTx(mysql, txIdB, 0, 1, 1, 101); + const inputB = createInput(100n, address1, txIdA, 0, tokenId); + await updateTxOutputSpentBy(mysql, [inputB], txIdB); + const outputB = createOutput(0, 100n, address2, tokenId); + await addUtxos(mysql, txIdB, [outputB], null); + + // Transaction C spends B's output + await addOrUpdateTx(mysql, txIdC, 0, 1, 1, 102); + const inputC = createInput(100n, address2, txIdB, 0, tokenId); + await updateTxOutputSpentBy(mysql, [inputC], txIdC); + const outputC = createOutput(0, 100n, address3, tokenId); + await addUtxos(mysql, txIdC, [outputC], null); + + // First void transaction C + await voidTx(mysql, txIdC, + [createEventTxInput(100n, address2, txIdB, 0)], + [{ + value: 100n, + locked: false, + decoded: { type: 'P2PKH' as const, address: address3, timelock: null }, + token_data: 0, + script: 'dqkUH70YjKeoKdFwMX2TOYvGVbXOrKaIrA==', + }], + [tokenId], + [] + ); + + // B's output should be unspent now (and it will be with the fix) + let utxoB = await getTxOutput(mysql, txIdB, 0, true); + expect(utxoB).not.toBeNull(); + expect(utxoB!.spentBy).toBeNull(); // Should pass - should be null + + // Now void transaction B + await voidTx(mysql, txIdB, + [createEventTxInput(100n, address1, txIdA, 0)], + [{ + value: 100n, + locked: false, + decoded: { type: 'P2PKH' as const, address: address2, timelock: null }, + token_data: 0, + script: 'dqkUH70YjKeoKdFwMX2TOYvGVbXOrKaIrA==', + }], + [tokenId], + [] + ); + + // A's output should be unspent now + let utxoA = await getTxOutput(mysql, txIdA, 0, true); + expect(utxoA).not.toBeNull(); + expect(utxoA!.spentBy).toBeNull(); // Should pass - should be null + }); + + it('should handle voiding when one input is already spent by another transaction', async () => { + expect.hasAssertions(); + + // This tests a double-spend scenario where we void a transaction + // that claims to spend UTXOs already spent by another transaction. + // This can happen during reorgs, network partitions, or double-spend attacks. + + const txIdA = 'test4-tx-a'; + const txIdB = 'test4-tx-b'; + const txIdC = 'test4-tx-c'; // Will try to spend A's output after B already spent it + const address1 = 'test4-address-1'; + const address2 = 'test4-address-2'; + const tokenId = '00'; + + // Create UTXO + await addOrUpdateTx(mysql, txIdA, 0, 1, 1, 100); + const outputA = createOutput(0, 100n, address1, tokenId); + await addUtxos(mysql, txIdA, [outputA], null); + + // Transaction B spends it + await addOrUpdateTx(mysql, txIdB, 0, 1, 1, 101); + const inputB = createInput(100n, address1, txIdA, 0, tokenId); + await updateTxOutputSpentBy(mysql, [inputB], txIdB); + + // For this test, we simulate that transaction C also tried to spend it + // (in reality this would be a double-spend, but we're testing edge cases) + await addOrUpdateTx(mysql, txIdC, 0, 1, 1, 102); + + // Add output for transaction C + const outputC = createOutput(0, 100n, address2, tokenId); + await addUtxos(mysql, txIdC, [outputC], null); + + // Now void transaction C which claims to spend an already-spent output + const inputs = [createEventTxInput(100n, address1, txIdA, 0)]; + const outputs = [{ + value: 100n, + locked: false, + decoded: { type: 'P2PKH' as const, address: address2, timelock: null }, + token_data: 0, + script: 'dqkUH70YjKeoKdFwMX2TOYvGVbXOrKaIrA==', + }]; + + await voidTx(mysql, txIdC, inputs, outputs, [tokenId], []); + + // The UTXO should still be spent by B, not unspent + const utxo = await getTxOutput(mysql, txIdA, 0, false); + expect(utxo).not.toBeNull(); + expect(utxo!.spentBy).toBe(txIdB); // Should remain spent by B + }); + + it('should correctly unspent inputs with different token types', async () => { + expect.hasAssertions(); + + const txIdA = 'test5-tx-a'; + const txIdB = 'test5-tx-b'; + const address1 = 'test5-address-1'; + const address2 = 'test5-address-2'; + const hathorToken = '00'; + const customToken = 'custom-token-id'; + + // Create two UTXOs with different tokens + await addOrUpdateTx(mysql, txIdA, 0, 1, 1, 100); + const outputA1 = createOutput(0, 100n, address1, hathorToken); + const outputA2 = createOutput(1, 50n, address1, customToken); + await addUtxos(mysql, txIdA, [outputA1, outputA2], null); + + // Transaction B spends both + await addOrUpdateTx(mysql, txIdB, 0, 1, 1, 101); + const inputB1 = createInput(100n, address1, txIdA, 0, hathorToken); + const inputB2 = createInput(50n, address1, txIdA, 1, customToken); + await updateTxOutputSpentBy(mysql, [inputB1, inputB2], txIdB); + + // Add outputs for transaction B + const outputB1 = createOutput(0, 100n, address2, hathorToken); + const outputB2 = createOutput(1, 50n, address2, customToken); + await addUtxos(mysql, txIdB, [outputB1, outputB2], null); + + // Void transaction B + const inputs = [ + createEventTxInput(100n, address1, txIdA, 0), + createEventTxInput(50n, address1, txIdA, 1), + ]; + const outputs = [ + { + value: 100n, + locked: false, + decoded: { type: 'P2PKH' as const, address: address2, timelock: null }, + token_data: 0, + script: 'dqkUH70YjKeoKdFwMX2TOYvGVbXOrKaIrA==', + }, + { + value: 50n, + locked: false, + decoded: { type: 'P2PKH' as const, address: address2, timelock: null }, + token_data: 0, + script: 'dqkUH70YjKeoKdFwMX2TOYvGVbXOrKaIrA==', + } + ]; + + await voidTx(mysql, txIdB, inputs, outputs, [hathorToken, customToken], []); + + // Both UTXOs should be unspent + const utxo1 = await getTxOutput(mysql, txIdA, 0, true); + const utxo2 = await getTxOutput(mysql, txIdA, 1, true); + + // These should pass with the implementation + expect(utxo1).not.toBeNull(); + expect(utxo1!.spentBy).toBeNull(); + expect(utxo2).not.toBeNull(); + expect(utxo2!.spentBy).toBeNull(); + }); + + it('should verify the complete flow with balance checks', async () => { + expect.hasAssertions(); + + // Complete integration test + const txIdA = 'test6-tx-a'; + const txIdB = 'test6-tx-b'; + const address1 = 'test6-address-1'; + const address2 = 'test6-address-2'; + const tokenId = '00'; + const value = 200n; + + // Setup initial UTXO + await addOrUpdateTx(mysql, txIdA, 0, 1, 1, 100); + const outputA = createOutput(0, value, address1, tokenId); + await addUtxos(mysql, txIdA, [outputA], null); + + // Verify initial state + await expect(checkUtxoTable(mysql, 1, txIdA, 0, tokenId, address1, value, 0, null, null, false, null)).resolves.toBe(true); + + // Create spending transaction + await addOrUpdateTx(mysql, txIdB, 0, 1, 1, 101); + const inputB = createInput(value, address1, txIdA, 0, tokenId); + await updateTxOutputSpentBy(mysql, [inputB], txIdB); + const outputB = createOutput(0, value, address2, tokenId); + await addUtxos(mysql, txIdB, [outputB], null); + + // Verify spent state + const spentUtxo = await getTxOutput(mysql, txIdA, 0, false); + expect(spentUtxo!.spentBy).toBe(txIdB); + + // Void the spending transaction + const inputs = [createEventTxInput(value, address1, txIdA, 0)]; + const outputs = [{ + value, + locked: false, + decoded: { type: 'P2PKH' as const, address: address2, timelock: null }, + token_data: 0, + script: 'dqkUH70YjKeoKdFwMX2TOYvGVbXOrKaIrA==', + }]; + + await voidTx(mysql, txIdB, inputs, outputs, [tokenId], []); + + // Verify the original UTXO is unspent again + const unspentUtxo = await getTxOutput(mysql, txIdA, 0, true); + expect(unspentUtxo).not.toBeNull(); + expect(unspentUtxo!.spentBy).toBeNull(); // This should pass + + // Also verify that B's outputs are marked as voided + const voidedUtxo = await getTxOutput(mysql, txIdB, 0, false); + expect(voidedUtxo).toBeNull(); // Should be null because it's voided + }); +}); + +describe('unspentTxOutputs function', () => { + it('should correctly unspent transaction outputs', async () => { + expect.hasAssertions(); + + // This tests the unspentTxOutputs function directly + const txIdA = 'test7-tx-a'; + const txIdB = 'test7-tx-b'; + const txIdC = 'test7-tx-c'; + const spendingTx = 'test7-spending-tx'; + const address = 'test7-address'; + const tokenId = '00'; + + // Create multiple UTXOs + await addOrUpdateTx(mysql, txIdA, 0, 1, 1, 100); + await addOrUpdateTx(mysql, txIdB, 0, 1, 1, 101); + await addOrUpdateTx(mysql, txIdC, 0, 1, 1, 102); + + const outputs = [ + createOutput(0, 50n, address, tokenId), + createOutput(0, 75n, address, tokenId), + createOutput(0, 100n, address, tokenId), + ]; + + await addUtxos(mysql, txIdA, [outputs[0]], null); + await addUtxos(mysql, txIdB, [outputs[1]], null); + await addUtxos(mysql, txIdC, [outputs[2]], null); + + // Mark them all as spent + const inputs = [ + createInput(50n, address, txIdA, 0, tokenId), + createInput(75n, address, txIdB, 0, tokenId), + createInput(100n, address, txIdC, 0, tokenId), + ]; + await updateTxOutputSpentBy(mysql, inputs, spendingTx); + + // Verify they are spent + let utxoA = await getTxOutput(mysql, txIdA, 0, false); + let utxoB = await getTxOutput(mysql, txIdB, 0, false); + let utxoC = await getTxOutput(mysql, txIdC, 0, false); + expect(utxoA!.spentBy).toBe(spendingTx); + expect(utxoB!.spentBy).toBe(spendingTx); + expect(utxoC!.spentBy).toBe(spendingTx); + + // Now unspent them + const txOutputsToUnspent: DbTxOutput[] = [ + { txId: txIdA, index: 0, tokenId, address, value: 50n, authorities: 0, timelock: null, heightlock: null, locked: false, spentBy: spendingTx, txProposalId: null, txProposalIndex: null }, + { txId: txIdB, index: 0, tokenId, address, value: 75n, authorities: 0, timelock: null, heightlock: null, locked: false, spentBy: spendingTx, txProposalId: null, txProposalIndex: null }, + { txId: txIdC, index: 0, tokenId, address, value: 100n, authorities: 0, timelock: null, heightlock: null, locked: false, spentBy: spendingTx, txProposalId: null, txProposalIndex: null }, + ]; + + await unspendUtxos(mysql, txOutputsToUnspent); + + // Verify they are unspent + utxoA = await getTxOutput(mysql, txIdA, 0, true); + utxoB = await getTxOutput(mysql, txIdB, 0, true); + utxoC = await getTxOutput(mysql, txIdC, 0, true); + expect(utxoA).not.toBeNull(); + expect(utxoA!.spentBy).toBeNull(); + expect(utxoB).not.toBeNull(); + expect(utxoB!.spentBy).toBeNull(); + expect(utxoC).not.toBeNull(); + expect(utxoC!.spentBy).toBeNull(); + }); +}); + +// Helper function to create a wallet and addresses +const setupWallet = async (walletId: string, addresses: string[]) => { + const now = Math.floor(Date.now() / 1000); // Unix timestamp + // Create wallet + await mysql.query( + `INSERT INTO \`wallet\` (id, xpubkey, auth_xpubkey, status, max_gap, created_at, ready_at) + VALUES (?, 'xpub123', 'xpub456', 'ready', 20, ?, ?)`, + [walletId, now, now] + ); + + // Add addresses to the wallet + const addressEntries = addresses.map((address, index) => [address, index, walletId, 1]); + await mysql.query( + `INSERT INTO \`address\` (address, \`index\`, wallet_id, transactions) + VALUES ?`, + [addressEntries] + ); +}; + +// Helper function to get wallet balance +const getWalletBalance = async (walletId: string, tokenId: string) => { + const [results] = await mysql.query( + `SELECT * FROM \`wallet_balance\` WHERE \`wallet_id\` = ? AND \`token_id\` = ?`, + [walletId, tokenId] + ) as [any[], any]; + return results[0] || null; +}; + +// Helper function to manually insert wallet balance (simulating what should happen) +const insertWalletBalance = async (walletId: string, tokenId: string, balance: bigint, transactions: number) => { + await mysql.query( + `INSERT INTO \`wallet_balance\` (wallet_id, token_id, unlocked_balance, locked_balance, + unlocked_authorities, locked_authorities, total_received, + transactions, timelock_expires) + VALUES (?, ?, ?, 0, 0, 0, ?, ?, NULL) + ON DUPLICATE KEY UPDATE + unlocked_balance = unlocked_balance + ?, + total_received = total_received + ?, + transactions = transactions + ?`, + [walletId, tokenId, balance, balance, transactions, balance, balance, transactions] + ); +}; + +// Helper function to get wallet transaction history count +const getWalletTxHistoryCount = async (walletId: string, txId: string) => { + const [results] = await mysql.query( + `SELECT COUNT(*) as count FROM \`wallet_tx_history\` WHERE \`wallet_id\` = ? AND \`tx_id\` = ?`, + [walletId, txId] + ) as [any[], any]; + return results[0].count; +}; + +describe('wallet balance voiding bug', () => { + it('should demonstrate wallet balance not being updated when voiding a transaction', async () => { + expect.hasAssertions(); + + const walletId = 'test-wallet'; + const address = 'test-address'; + const tokenId = '00'; + const txIdA = 'tx-a'; + const txIdB = 'tx-b'; + const initialValue = 100n; + + // Setup wallet and address + await setupWallet(walletId, [address]); + + // Create transaction A that creates an output to our wallet address + await addOrUpdateTx(mysql, txIdA, 0, 1, 1, 100); + const outputA = createOutput(0, initialValue, address, tokenId); + await addUtxos(mysql, txIdA, [outputA], null); + + // Manually insert wallet balance (simulating what updateWalletTablesWithTx would do) + await insertWalletBalance(walletId, tokenId, initialValue, 1); + + // Also insert into wallet_tx_history + await mysql.query( + `INSERT INTO \`wallet_tx_history\` (wallet_id, token_id, tx_id, balance, timestamp) + VALUES (?, ?, ?, ?, ?)`, + [walletId, tokenId, txIdA, initialValue, 100] + ); + + // Verify initial wallet balance + let walletBalance = await getWalletBalance(walletId, tokenId); + expect(walletBalance).not.toBeNull(); + expect(BigInt(walletBalance.unlocked_balance)).toBe(initialValue); + expect(walletBalance.transactions).toBe(1); + + // Create transaction B that spends the output from A + await addOrUpdateTx(mysql, txIdB, 0, 1, 1, 101); + const inputB = createInput(initialValue, address, txIdA, 0, tokenId); + await updateTxOutputSpentBy(mysql, [inputB], txIdB); + + // Add output for transaction B (sending to same address for simplicity) + const outputB = createOutput(0, initialValue, address, tokenId); + await addUtxos(mysql, txIdB, [outputB], null); + + // Update wallet balance for transaction B (net zero change) + await insertWalletBalance(walletId, tokenId, 0n, 1); + + // Add to wallet_tx_history + await mysql.query( + `INSERT INTO \`wallet_tx_history\` (wallet_id, token_id, tx_id, balance, timestamp) + VALUES (?, ?, ?, ?, ?)`, + [walletId, tokenId, txIdB, 0, 101] + ); + + // Verify wallet balance after transaction B + walletBalance = await getWalletBalance(walletId, tokenId); + expect(walletBalance).not.toBeNull(); + expect(BigInt(walletBalance.unlocked_balance)).toBe(initialValue); // Still 100n + expect(walletBalance.transactions).toBe(2); // Now 2 transactions + + // Now void transaction B + const inputs: EventTxInput[] = [createEventTxInput(initialValue, address, txIdA, 0)]; + const outputs = [{ + value: initialValue, + locked: false, + decoded: { + type: 'P2PKH' as const, + address: address, + timelock: null, + }, + token_data: 0, + script: 'dqkUH70YjKeoKdFwMX2TOYvGVbXOrKaIrA==', + }]; + + await voidTx(mysql, txIdB, inputs, outputs, [tokenId], []); + + // CRITICAL BUG: Check wallet balance after voiding + walletBalance = await getWalletBalance(walletId, tokenId); + expect(walletBalance).not.toBeNull(); + + // This will FAIL because wallet balances are not updated during voiding + // The transaction count should decrease from 2 to 1 + expect(walletBalance.transactions).toBe(1); // Should be back to 1 transaction + + // Check if wallet_tx_history was cleaned up + const historyCount = await getWalletTxHistoryCount(walletId, txIdB); + // This will FAIL because wallet_tx_history is not cleaned up during voiding + expect(historyCount).toBe(0); // Should be 0 after voiding + }); + + it('should demonstrate wallet balance inconsistency with multiple wallets', async () => { + expect.hasAssertions(); + + const wallet1Id = 'wallet-1'; + const wallet2Id = 'wallet-2'; + const address1 = 'address-1'; + const address2 = 'address-2'; + const tokenId = '00'; + const txId = 'transfer-tx'; + const amount = 150n; + + // Setup two wallets + await setupWallet(wallet1Id, [address1]); + await setupWallet(wallet2Id, [address2]); + + // Simulate wallet1 already having some balance + await insertWalletBalance(wallet1Id, tokenId, 200n, 1); + + // Create a transaction that transfers money from wallet1 to wallet2 + await addOrUpdateTx(mysql, txId, 0, 1, 1, 100); + + // Transaction sends money from wallet1 to wallet2 + const outputs = [ + createOutput(0, amount, address2, tokenId), // To wallet2 + ]; + await addUtxos(mysql, txId, outputs, null); + + // Simulate updating wallet balances for this transaction + // Wallet1 loses money (net -150n) + await mysql.query( + `UPDATE \`wallet_balance\` + SET unlocked_balance = unlocked_balance - ?, transactions = transactions + 1 + WHERE wallet_id = ? AND token_id = ?`, + [amount, wallet1Id, tokenId] + ); + + // Wallet2 gains money (+150n) + await insertWalletBalance(wallet2Id, tokenId, amount, 1); + + // Add wallet_tx_history entries + await mysql.query( + `INSERT INTO \`wallet_tx_history\` (wallet_id, token_id, tx_id, balance, timestamp) + VALUES (?, ?, ?, ?, ?), (?, ?, ?, ?, ?)`, + [wallet1Id, tokenId, txId, -amount, 100, wallet2Id, tokenId, txId, amount, 100] + ); + + // Verify wallet balances before voiding + let wallet1Balance = await getWalletBalance(wallet1Id, tokenId); + let wallet2Balance = await getWalletBalance(wallet2Id, tokenId); + + expect(wallet1Balance).not.toBeNull(); + expect(BigInt(wallet1Balance.unlocked_balance)).toBe(50n); // 200 - 150 + expect(wallet1Balance.transactions).toBe(2); + + expect(wallet2Balance).not.toBeNull(); + expect(BigInt(wallet2Balance.unlocked_balance)).toBe(amount); + expect(wallet2Balance.transactions).toBe(1); + + // Now void the transaction + const inputs: EventTxInput[] = [createEventTxInput(amount, address1, 'some-previous-tx', 0)]; + const voidOutputs = [{ + value: amount, + locked: false, + decoded: { + type: 'P2PKH' as const, + address: address2, + timelock: null, + }, + token_data: 0, + script: 'dqkUH70YjKeoKdFwMX2TOYvGVbXOrKaIrA==', + }]; + + await voidTx(mysql, txId, inputs, voidOutputs, [tokenId], []); + + // Check wallet balances after voiding + wallet1Balance = await getWalletBalance(wallet1Id, tokenId); + wallet2Balance = await getWalletBalance(wallet2Id, tokenId); + + // These assertions will FAIL because wallet balances are not updated during voiding + + // Wallet1 should have its balance restored (50 + 150 = 200) + expect(BigInt(wallet1Balance.unlocked_balance)).toBe(200n); + expect(wallet1Balance.transactions).toBe(1); // Should decrease back to 1 + + // Wallet2 should have its balance reduced to 0 + if (wallet2Balance) { + expect(BigInt(wallet2Balance.unlocked_balance)).toBe(0n); + expect(wallet2Balance.transactions).toBe(0); // Should be 0 after voiding + } + + // Check wallet_tx_history cleanup + const wallet1HistoryCount = await getWalletTxHistoryCount(wallet1Id, txId); + const wallet2HistoryCount = await getWalletTxHistoryCount(wallet2Id, txId); + + // These will FAIL because wallet_tx_history is not cleaned up + expect(wallet1HistoryCount).toBe(0); + expect(wallet2HistoryCount).toBe(0); + }); + + it('should demonstrate the bug exists even with simple single wallet scenario', async () => { + expect.hasAssertions(); + + const walletId = 'simple-wallet'; + const address = 'simple-address'; + const tokenId = '00'; + const txId = 'simple-tx'; + const value = 50n; + + // Setup wallet + await setupWallet(walletId, [address]); + + // Create transaction + await addOrUpdateTx(mysql, txId, 0, 1, 1, 100); + const output = createOutput(0, value, address, tokenId); + await addUtxos(mysql, txId, [output], null); + + // Simulate wallet balance update + await insertWalletBalance(walletId, tokenId, value, 1); + await mysql.query( + `INSERT INTO \`wallet_tx_history\` (wallet_id, token_id, tx_id, balance, timestamp) + VALUES (?, ?, ?, ?, ?)`, + [walletId, tokenId, txId, value, 100] + ); + + // Verify wallet balance exists + let walletBalance = await getWalletBalance(walletId, tokenId); + expect(walletBalance).not.toBeNull(); + expect(BigInt(walletBalance.unlocked_balance)).toBe(value); + expect(walletBalance.transactions).toBe(1); + + // Void the transaction + const inputs: EventTxInput[] = []; + const outputs = [{ + value: value, + locked: false, + decoded: { + type: 'P2PKH' as const, + address: address, + timelock: null, + }, + token_data: 0, + script: 'dqkUH70YjKeoKdFwMX2TOYvGVbXOrKaIrA==', + }]; + + await voidTx(mysql, txId, inputs, outputs, [tokenId], []); + + // Check wallet balance after voiding + walletBalance = await getWalletBalance(walletId, tokenId); + + // This WILL FAIL because wallet balances are not updated during voiding + if (walletBalance) { + expect(BigInt(walletBalance.unlocked_balance)).toBe(0n); // Should be 0 after voiding + expect(walletBalance.transactions).toBe(0); // Should be 0 after voiding + } + + // Check wallet_tx_history cleanup + const historyCount = await getWalletTxHistoryCount(walletId, txId); + expect(historyCount).toBe(0); // Should be 0 after voiding + }); +}); diff --git a/packages/daemon/__tests__/services/voidingWithInputs.test.ts b/packages/daemon/__tests__/services/voidingWithInputs.test.ts deleted file mode 100644 index d7d2d2cb..00000000 --- a/packages/daemon/__tests__/services/voidingWithInputs.test.ts +++ /dev/null @@ -1,501 +0,0 @@ -/** - * Copyright (c) Hathor Labs and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -/** - * @jest-environment node - */ - -import { Connection } from 'mysql2/promise'; -import { - getDbConnection, - addOrUpdateTx, - addUtxos, - updateTxOutputSpentBy, - getTxOutput, - unspendUtxos, -} from '../../src/db'; -import { voidTx } from '../../src/services'; -import { - cleanDatabase, - checkUtxoTable, - createOutput, - createInput, - createEventTxInput, -} from '../utils'; -import { DbTxOutput } from '../../src/types'; - -// Use a single mysql connection for all tests -let mysql: Connection; - -beforeAll(async () => { - try { - mysql = await getDbConnection(); - } catch (e) { - console.error('Failed to establish db connection', e); - throw e; - } -}); - -afterAll(async () => { - if (mysql) { - await mysql.destroy(); - } -}); - -beforeEach(async () => { - await cleanDatabase(mysql); - // Add a small delay to ensure database operations complete - await new Promise(resolve => setTimeout(resolve, 10)); -}); - -describe('voidTransaction with input unspending', () => { - it('should unspent inputs when voiding a transaction', async () => { - expect.hasAssertions(); - - // Create transaction A that creates an output - const txIdA = 'tx-a'; - const addressA = 'address-a'; - const tokenId = '00'; - const outputValue = 100n; - - await addOrUpdateTx(mysql, txIdA, 0, 1, 1, 100); - - // Add output from transaction A - const outputA = createOutput(0, outputValue, addressA, tokenId); - await addUtxos(mysql, txIdA, [outputA], null); - - // Ensure database operations complete - await new Promise(resolve => setTimeout(resolve, 10)); - - // Verify the UTXO is unspent - let utxo = await getTxOutput(mysql, txIdA, 0, true); - expect(utxo).not.toBeNull(); - expect(utxo!.spentBy).toBeNull(); - - // Create transaction B that spends the output from transaction A - const txIdB = 'tx-b'; - const addressB = 'address-b'; - - await addOrUpdateTx(mysql, txIdB, 0, 1, 1, 101); - - // Mark the output from A as spent by B - const inputB = createInput(outputValue, addressA, txIdA, 0, tokenId); - await updateTxOutputSpentBy(mysql, [inputB], txIdB); - - // Ensure database operations complete - await new Promise(resolve => setTimeout(resolve, 10)); - - // Verify the UTXO is now spent - utxo = await getTxOutput(mysql, txIdA, 0, false); - expect(utxo).not.toBeNull(); - expect(utxo!.spentBy).toBe(txIdB); - - // Add output from transaction B - const outputB = createOutput(0, outputValue, addressB, tokenId); - await addUtxos(mysql, txIdB, [outputB], null); - - // Ensure database operations complete before voiding - await new Promise(resolve => setTimeout(resolve, 10)); - - // Now void transaction B using the voidTx service function - const inputs = [createEventTxInput(outputValue, addressA, txIdA, 0)]; - const outputs = [{ - value: outputValue, - locked: false, - decoded: { - type: 'P2PKH' as const, - address: addressB, - timelock: null, - }, - token_data: 0, - script: 'dqkUH70YjKeoKdFwMX2TOYvGVbXOrKaIrA==', - }]; - - await voidTx(mysql, txIdB, inputs, outputs, [tokenId], []); - - // CRITICAL: The voidTx function should unspent the inputs - // This test should PASS because unspending is now implemented in voidTx - - // Check if the UTXO from transaction A is unspent again - utxo = await getTxOutput(mysql, txIdA, 0, true); - expect(utxo).not.toBeNull(); - expect(utxo!.spentBy).toBeNull(); // This should pass - it should be null - }); - - it('should unspent multiple inputs when voiding a transaction with multiple inputs', async () => { - expect.hasAssertions(); - - // Create transactions A and B that create outputs - const txIdA = 'tx-a'; - const txIdB = 'tx-b'; - const txIdC = 'tx-c'; // The transaction we'll void - const address1 = 'address-1'; - const address2 = 'address-2'; - const address3 = 'address-3'; - const tokenId = '00'; - - // Create two UTXOs - await addOrUpdateTx(mysql, txIdA, 0, 1, 1, 100); - await addOrUpdateTx(mysql, txIdB, 0, 1, 1, 101); - - const outputA = createOutput(0, 50n, address1, tokenId); - const outputB = createOutput(0, 75n, address2, tokenId); - - await addUtxos(mysql, txIdA, [outputA], null); - await addUtxos(mysql, txIdB, [outputB], null); - - // Verify both UTXOs are unspent - let utxoA = await getTxOutput(mysql, txIdA, 0, true); - let utxoB = await getTxOutput(mysql, txIdB, 0, true); - expect(utxoA!.spentBy).toBeNull(); - expect(utxoB!.spentBy).toBeNull(); - - // Create transaction C that spends both outputs - await addOrUpdateTx(mysql, txIdC, 0, 1, 1, 102); - - const inputC1 = createInput(50n, address1, txIdA, 0, tokenId); - const inputC2 = createInput(75n, address2, txIdB, 0, tokenId); - await updateTxOutputSpentBy(mysql, [inputC1, inputC2], txIdC); - - // Verify both UTXOs are now spent by C - utxoA = await getTxOutput(mysql, txIdA, 0, false); - utxoB = await getTxOutput(mysql, txIdB, 0, false); - expect(utxoA!.spentBy).toBe(txIdC); - expect(utxoB!.spentBy).toBe(txIdC); - - // Add output from transaction C - const outputC = createOutput(0, 125n, address3, tokenId); - await addUtxos(mysql, txIdC, [outputC], null); - - // Void transaction C using voidTx service function - const inputs = [ - createEventTxInput(50n, address1, txIdA, 0), - createEventTxInput(75n, address2, txIdB, 0), - ]; - const outputs = [{ - value: 125n, - locked: false, - decoded: { - type: 'P2PKH' as const, - address: address3, - timelock: null, - }, - token_data: 0, - script: 'dqkUH70YjKeoKdFwMX2TOYvGVbXOrKaIrA==', - }]; - - await voidTx(mysql, txIdC, inputs, outputs, [tokenId], []); - - // Check if both UTXOs from transactions A and B are unspent again - utxoA = await getTxOutput(mysql, txIdA, 0, true); - utxoB = await getTxOutput(mysql, txIdB, 0, true); - - // These assertions should PASS because unspending is now implemented - expect(utxoA).not.toBeNull(); - expect(utxoA!.spentBy).toBeNull(); // Should pass - should be null - expect(utxoB).not.toBeNull(); - expect(utxoB!.spentBy).toBeNull(); // Should pass - should be null - }); - - it('should handle voiding a transaction that spends already voided outputs', async () => { - expect.hasAssertions(); - - // Create transaction A that creates an output - const txIdA = 'tx-a'; - const txIdB = 'tx-b'; // Will be voided first - const txIdC = 'tx-c'; // Will be voided second - const address1 = 'address-1'; - const address2 = 'address-2'; - const address3 = 'address-3'; - const tokenId = '00'; - - await addOrUpdateTx(mysql, txIdA, 0, 1, 1, 100); - const outputA = createOutput(0, 100n, address1, tokenId); - await addUtxos(mysql, txIdA, [outputA], null); - - // Transaction B spends A's output - await addOrUpdateTx(mysql, txIdB, 0, 1, 1, 101); - const inputB = createInput(100n, address1, txIdA, 0, tokenId); - await updateTxOutputSpentBy(mysql, [inputB], txIdB); - const outputB = createOutput(0, 100n, address2, tokenId); - await addUtxos(mysql, txIdB, [outputB], null); - - // Transaction C spends B's output - await addOrUpdateTx(mysql, txIdC, 0, 1, 1, 102); - const inputC = createInput(100n, address2, txIdB, 0, tokenId); - await updateTxOutputSpentBy(mysql, [inputC], txIdC); - const outputC = createOutput(0, 100n, address3, tokenId); - await addUtxos(mysql, txIdC, [outputC], null); - - // First void transaction C - await voidTx(mysql, txIdC, - [createEventTxInput(100n, address2, txIdB, 0)], - [{ - value: 100n, - locked: false, - decoded: { type: 'P2PKH' as const, address: address3, timelock: null }, - token_data: 0, - script: 'dqkUH70YjKeoKdFwMX2TOYvGVbXOrKaIrA==', - }], - [tokenId], - [] - ); - - // B's output should be unspent now (and it will be with the fix) - let utxoB = await getTxOutput(mysql, txIdB, 0, true); - expect(utxoB).not.toBeNull(); - expect(utxoB!.spentBy).toBeNull(); // Should pass - should be null - - // Now void transaction B - await voidTx(mysql, txIdB, - [createEventTxInput(100n, address1, txIdA, 0)], - [{ - value: 100n, - locked: false, - decoded: { type: 'P2PKH' as const, address: address2, timelock: null }, - token_data: 0, - script: 'dqkUH70YjKeoKdFwMX2TOYvGVbXOrKaIrA==', - }], - [tokenId], - [] - ); - - // A's output should be unspent now - let utxoA = await getTxOutput(mysql, txIdA, 0, true); - expect(utxoA).not.toBeNull(); - expect(utxoA!.spentBy).toBeNull(); // Should pass - should be null - }); - - it('should handle voiding when one input is already spent by another transaction', async () => { - expect.hasAssertions(); - - // This tests an edge case where we try to void a transaction - // but one of its inputs was already spent by another transaction - // (which shouldn't happen in practice but we should handle gracefully) - - const txIdA = 'tx-a'; - const txIdB = 'tx-b'; - const txIdC = 'tx-c'; // Will try to spend A's output after B already spent it - const address1 = 'address-1'; - const address2 = 'address-2'; - const tokenId = '00'; - - // Create UTXO - await addOrUpdateTx(mysql, txIdA, 0, 1, 1, 100); - const outputA = createOutput(0, 100n, address1, tokenId); - await addUtxos(mysql, txIdA, [outputA], null); - - // Transaction B spends it - await addOrUpdateTx(mysql, txIdB, 0, 1, 1, 101); - const inputB = createInput(100n, address1, txIdA, 0, tokenId); - await updateTxOutputSpentBy(mysql, [inputB], txIdB); - - // For this test, we simulate that transaction C also tried to spend it - // (in reality this would be a double-spend, but we're testing edge cases) - await addOrUpdateTx(mysql, txIdC, 0, 1, 1, 102); - - // Add output for transaction C - const outputC = createOutput(0, 100n, address2, tokenId); - await addUtxos(mysql, txIdC, [outputC], null); - - // Now void transaction C which claims to spend an already-spent output - const inputs = [createEventTxInput(100n, address1, txIdA, 0)]; - const outputs = [{ - value: 100n, - locked: false, - decoded: { type: 'P2PKH' as const, address: address2, timelock: null }, - token_data: 0, - script: 'dqkUH70YjKeoKdFwMX2TOYvGVbXOrKaIrA==', - }]; - - await voidTx(mysql, txIdC, inputs, outputs, [tokenId], []); - - // The UTXO should still be spent by B, not unspent - const utxo = await getTxOutput(mysql, txIdA, 0, false); - expect(utxo).not.toBeNull(); - expect(utxo!.spentBy).toBe(txIdB); // Should remain spent by B - }); - - it('should correctly unspent inputs with different token types', async () => { - expect.hasAssertions(); - - const txIdA = 'tx-a'; - const txIdB = 'tx-b'; - const address1 = 'address-1'; - const address2 = 'address-2'; - const hathorToken = '00'; - const customToken = 'custom-token-id'; - - // Create two UTXOs with different tokens - await addOrUpdateTx(mysql, txIdA, 0, 1, 1, 100); - const outputA1 = createOutput(0, 100n, address1, hathorToken); - const outputA2 = createOutput(1, 50n, address1, customToken); - await addUtxos(mysql, txIdA, [outputA1, outputA2], null); - - // Transaction B spends both - await addOrUpdateTx(mysql, txIdB, 0, 1, 1, 101); - const inputB1 = createInput(100n, address1, txIdA, 0, hathorToken); - const inputB2 = createInput(50n, address1, txIdA, 1, customToken); - await updateTxOutputSpentBy(mysql, [inputB1, inputB2], txIdB); - - // Add outputs for transaction B - const outputB1 = createOutput(0, 100n, address2, hathorToken); - const outputB2 = createOutput(1, 50n, address2, customToken); - await addUtxos(mysql, txIdB, [outputB1, outputB2], null); - - // Void transaction B - const inputs = [ - createEventTxInput(100n, address1, txIdA, 0), - createEventTxInput(50n, address1, txIdA, 1), - ]; - const outputs = [ - { - value: 100n, - locked: false, - decoded: { type: 'P2PKH' as const, address: address2, timelock: null }, - token_data: 0, - script: 'dqkUH70YjKeoKdFwMX2TOYvGVbXOrKaIrA==', - }, - { - value: 50n, - locked: false, - decoded: { type: 'P2PKH' as const, address: address2, timelock: null }, - token_data: 0, - script: 'dqkUH70YjKeoKdFwMX2TOYvGVbXOrKaIrA==', - } - ]; - - await voidTx(mysql, txIdB, inputs, outputs, [hathorToken, customToken], []); - - // Both UTXOs should be unspent - const utxo1 = await getTxOutput(mysql, txIdA, 0, true); - const utxo2 = await getTxOutput(mysql, txIdA, 1, true); - - // These should pass with the implementation - expect(utxo1).not.toBeNull(); - expect(utxo1!.spentBy).toBeNull(); - expect(utxo2).not.toBeNull(); - expect(utxo2!.spentBy).toBeNull(); - }); - - it('should verify the complete flow with balance checks', async () => { - expect.hasAssertions(); - - // Complete integration test - const txIdA = 'tx-a'; - const txIdB = 'tx-b'; - const address1 = 'address-1'; - const address2 = 'address-2'; - const tokenId = '00'; - const value = 200n; - - // Setup initial UTXO - await addOrUpdateTx(mysql, txIdA, 0, 1, 1, 100); - const outputA = createOutput(0, value, address1, tokenId); - await addUtxos(mysql, txIdA, [outputA], null); - - // Verify initial state - await expect(checkUtxoTable(mysql, 1, txIdA, 0, tokenId, address1, value, 0, null, null, false, null)).resolves.toBe(true); - - // Create spending transaction - await addOrUpdateTx(mysql, txIdB, 0, 1, 1, 101); - const inputB = createInput(value, address1, txIdA, 0, tokenId); - await updateTxOutputSpentBy(mysql, [inputB], txIdB); - const outputB = createOutput(0, value, address2, tokenId); - await addUtxos(mysql, txIdB, [outputB], null); - - // Verify spent state - const spentUtxo = await getTxOutput(mysql, txIdA, 0, false); - expect(spentUtxo!.spentBy).toBe(txIdB); - - // Void the spending transaction - const inputs = [createEventTxInput(value, address1, txIdA, 0)]; - const outputs = [{ - value, - locked: false, - decoded: { type: 'P2PKH' as const, address: address2, timelock: null }, - token_data: 0, - script: 'dqkUH70YjKeoKdFwMX2TOYvGVbXOrKaIrA==', - }]; - - await voidTx(mysql, txIdB, inputs, outputs, [tokenId], []); - - // Verify the original UTXO is unspent again - const unspentUtxo = await getTxOutput(mysql, txIdA, 0, true); - expect(unspentUtxo).not.toBeNull(); - expect(unspentUtxo!.spentBy).toBeNull(); // This should pass - - // Also verify that B's outputs are marked as voided - const voidedUtxo = await getTxOutput(mysql, txIdB, 0, false); - expect(voidedUtxo).toBeNull(); // Should be null because it's voided - }); -}); - -describe('unspentTxOutputs function', () => { - it('should correctly unspent transaction outputs', async () => { - expect.hasAssertions(); - - // This tests the unspentTxOutputs function directly - const txIdA = 'tx-a'; - const txIdB = 'tx-b'; - const txIdC = 'tx-c'; - const spendingTx = 'spending-tx'; - const address = 'test-address'; - const tokenId = '00'; - - // Create multiple UTXOs - await addOrUpdateTx(mysql, txIdA, 0, 1, 1, 100); - await addOrUpdateTx(mysql, txIdB, 0, 1, 1, 101); - await addOrUpdateTx(mysql, txIdC, 0, 1, 1, 102); - - const outputs = [ - createOutput(0, 50n, address, tokenId), - createOutput(0, 75n, address, tokenId), - createOutput(0, 100n, address, tokenId), - ]; - - await addUtxos(mysql, txIdA, [outputs[0]], null); - await addUtxos(mysql, txIdB, [outputs[1]], null); - await addUtxos(mysql, txIdC, [outputs[2]], null); - - // Mark them all as spent - const inputs = [ - createInput(50n, address, txIdA, 0, tokenId), - createInput(75n, address, txIdB, 0, tokenId), - createInput(100n, address, txIdC, 0, tokenId), - ]; - await updateTxOutputSpentBy(mysql, inputs, spendingTx); - - // Verify they are spent - let utxoA = await getTxOutput(mysql, txIdA, 0, false); - let utxoB = await getTxOutput(mysql, txIdB, 0, false); - let utxoC = await getTxOutput(mysql, txIdC, 0, false); - expect(utxoA!.spentBy).toBe(spendingTx); - expect(utxoB!.spentBy).toBe(spendingTx); - expect(utxoC!.spentBy).toBe(spendingTx); - - // Now unspent them - const txOutputsToUnspent: DbTxOutput[] = [ - { txId: txIdA, index: 0, tokenId, address, value: 50n, authorities: 0, timelock: null, heightlock: null, locked: false, spentBy: spendingTx, txProposalId: null, txProposalIndex: null }, - { txId: txIdB, index: 0, tokenId, address, value: 75n, authorities: 0, timelock: null, heightlock: null, locked: false, spentBy: spendingTx, txProposalId: null, txProposalIndex: null }, - { txId: txIdC, index: 0, tokenId, address, value: 100n, authorities: 0, timelock: null, heightlock: null, locked: false, spentBy: spendingTx, txProposalId: null, txProposalIndex: null }, - ]; - - await unspendUtxos(mysql, txOutputsToUnspent); - - // Verify they are unspent - utxoA = await getTxOutput(mysql, txIdA, 0, true); - utxoB = await getTxOutput(mysql, txIdB, 0, true); - utxoC = await getTxOutput(mysql, txIdC, 0, true); - expect(utxoA).not.toBeNull(); - expect(utxoA!.spentBy).toBeNull(); - expect(utxoB).not.toBeNull(); - expect(utxoB!.spentBy).toBeNull(); - expect(utxoC).not.toBeNull(); - expect(utxoC!.spentBy).toBeNull(); - }); -}); diff --git a/packages/daemon/__tests__/services/walletBalanceVoiding.test.ts b/packages/daemon/__tests__/services/walletBalanceVoiding.test.ts deleted file mode 100644 index 13c8cb80..00000000 --- a/packages/daemon/__tests__/services/walletBalanceVoiding.test.ts +++ /dev/null @@ -1,349 +0,0 @@ -/** - * Copyright (c) Hathor Labs and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -/** - * @jest-environment node - */ - -import { Connection } from 'mysql2/promise'; -import { - getDbConnection, - addOrUpdateTx, - addUtxos, - updateTxOutputSpentBy, -} from '../../src/db'; -import { voidTx } from '../../src/services'; -import { - cleanDatabase, - createOutput, - createInput, - createEventTxInput, -} from '../utils'; -import { EventTxInput } from '../../src/types'; - -// Use a single mysql connection for all tests -let mysql: Connection; - -beforeAll(async () => { - try { - mysql = await getDbConnection(); - } catch (e) { - console.error('Failed to establish db connection', e); - throw e; - } -}); - -afterAll(async () => { - await mysql.destroy(); -}); - -beforeEach(async () => { - await cleanDatabase(mysql); -}); - -// Helper function to create a wallet and addresses -const setupWallet = async (walletId: string, addresses: string[]) => { - const now = Math.floor(Date.now() / 1000); // Unix timestamp - // Create wallet - await mysql.query( - `INSERT INTO \`wallet\` (id, xpubkey, auth_xpubkey, status, max_gap, created_at, ready_at) - VALUES (?, 'xpub123', 'xpub456', 'ready', 20, ?, ?)`, - [walletId, now, now] - ); - - // Add addresses to the wallet - const addressEntries = addresses.map((address, index) => [address, index, walletId, 1]); - await mysql.query( - `INSERT INTO \`address\` (address, \`index\`, wallet_id, transactions) - VALUES ?`, - [addressEntries] - ); -}; - -// Helper function to get wallet balance -const getWalletBalance = async (walletId: string, tokenId: string) => { - const [results] = await mysql.query( - `SELECT * FROM \`wallet_balance\` WHERE \`wallet_id\` = ? AND \`token_id\` = ?`, - [walletId, tokenId] - ) as [any[], any]; - return results[0] || null; -}; - -// Helper function to manually insert wallet balance (simulating what should happen) -const insertWalletBalance = async (walletId: string, tokenId: string, balance: bigint, transactions: number) => { - await mysql.query( - `INSERT INTO \`wallet_balance\` (wallet_id, token_id, unlocked_balance, locked_balance, - unlocked_authorities, locked_authorities, total_received, - transactions, timelock_expires) - VALUES (?, ?, ?, 0, 0, 0, ?, ?, NULL) - ON DUPLICATE KEY UPDATE - unlocked_balance = unlocked_balance + ?, - total_received = total_received + ?, - transactions = transactions + ?`, - [walletId, tokenId, balance, balance, transactions, balance, balance, transactions] - ); -}; - -// Helper function to get wallet transaction history count -const getWalletTxHistoryCount = async (walletId: string, txId: string) => { - const [results] = await mysql.query( - `SELECT COUNT(*) as count FROM \`wallet_tx_history\` WHERE \`wallet_id\` = ? AND \`tx_id\` = ?`, - [walletId, txId] - ) as [any[], any]; - return results[0].count; -}; - -describe('wallet balance voiding bug', () => { - it('should demonstrate wallet balance not being updated when voiding a transaction', async () => { - expect.hasAssertions(); - - const walletId = 'test-wallet'; - const address = 'test-address'; - const tokenId = '00'; - const txIdA = 'tx-a'; - const txIdB = 'tx-b'; - const initialValue = 100n; - - // Setup wallet and address - await setupWallet(walletId, [address]); - - // Create transaction A that creates an output to our wallet address - await addOrUpdateTx(mysql, txIdA, 0, 1, 1, 100); - const outputA = createOutput(0, initialValue, address, tokenId); - await addUtxos(mysql, txIdA, [outputA], null); - - // Manually insert wallet balance (simulating what updateWalletTablesWithTx would do) - await insertWalletBalance(walletId, tokenId, initialValue, 1); - - // Also insert into wallet_tx_history - await mysql.query( - `INSERT INTO \`wallet_tx_history\` (wallet_id, token_id, tx_id, balance, timestamp) - VALUES (?, ?, ?, ?, ?)`, - [walletId, tokenId, txIdA, initialValue, 100] - ); - - // Verify initial wallet balance - let walletBalance = await getWalletBalance(walletId, tokenId); - expect(walletBalance).not.toBeNull(); - expect(BigInt(walletBalance.unlocked_balance)).toBe(initialValue); - expect(walletBalance.transactions).toBe(1); - - // Create transaction B that spends the output from A - await addOrUpdateTx(mysql, txIdB, 0, 1, 1, 101); - const inputB = createInput(initialValue, address, txIdA, 0, tokenId); - await updateTxOutputSpentBy(mysql, [inputB], txIdB); - - // Add output for transaction B (sending to same address for simplicity) - const outputB = createOutput(0, initialValue, address, tokenId); - await addUtxos(mysql, txIdB, [outputB], null); - - // Update wallet balance for transaction B (net zero change) - await insertWalletBalance(walletId, tokenId, 0n, 1); - - // Add to wallet_tx_history - await mysql.query( - `INSERT INTO \`wallet_tx_history\` (wallet_id, token_id, tx_id, balance, timestamp) - VALUES (?, ?, ?, ?, ?)`, - [walletId, tokenId, txIdB, 0, 101] - ); - - // Verify wallet balance after transaction B - walletBalance = await getWalletBalance(walletId, tokenId); - expect(walletBalance).not.toBeNull(); - expect(BigInt(walletBalance.unlocked_balance)).toBe(initialValue); // Still 100n - expect(walletBalance.transactions).toBe(2); // Now 2 transactions - - // Now void transaction B - const inputs: EventTxInput[] = [createEventTxInput(initialValue, address, txIdA, 0)]; - const outputs = [{ - value: initialValue, - locked: false, - decoded: { - type: 'P2PKH' as const, - address: address, - timelock: null, - }, - token_data: 0, - script: 'dqkUH70YjKeoKdFwMX2TOYvGVbXOrKaIrA==', - }]; - - await voidTx(mysql, txIdB, inputs, outputs, [tokenId], []); - - // CRITICAL BUG: Check wallet balance after voiding - walletBalance = await getWalletBalance(walletId, tokenId); - expect(walletBalance).not.toBeNull(); - - // This will FAIL because wallet balances are not updated during voiding - // The transaction count should decrease from 2 to 1 - expect(walletBalance.transactions).toBe(1); // Should be back to 1 transaction - - // Check if wallet_tx_history was cleaned up - const historyCount = await getWalletTxHistoryCount(walletId, txIdB); - // This will FAIL because wallet_tx_history is not cleaned up during voiding - expect(historyCount).toBe(0); // Should be 0 after voiding - }); - - it('should demonstrate wallet balance inconsistency with multiple wallets', async () => { - expect.hasAssertions(); - - const wallet1Id = 'wallet-1'; - const wallet2Id = 'wallet-2'; - const address1 = 'address-1'; - const address2 = 'address-2'; - const tokenId = '00'; - const txId = 'transfer-tx'; - const amount = 150n; - - // Setup two wallets - await setupWallet(wallet1Id, [address1]); - await setupWallet(wallet2Id, [address2]); - - // Simulate wallet1 already having some balance - await insertWalletBalance(wallet1Id, tokenId, 200n, 1); - - // Create a transaction that transfers money from wallet1 to wallet2 - await addOrUpdateTx(mysql, txId, 0, 1, 1, 100); - - // Transaction sends money from wallet1 to wallet2 - const outputs = [ - createOutput(0, amount, address2, tokenId), // To wallet2 - ]; - await addUtxos(mysql, txId, outputs, null); - - // Simulate updating wallet balances for this transaction - // Wallet1 loses money (net -150n) - await mysql.query( - `UPDATE \`wallet_balance\` - SET unlocked_balance = unlocked_balance - ?, transactions = transactions + 1 - WHERE wallet_id = ? AND token_id = ?`, - [amount, wallet1Id, tokenId] - ); - - // Wallet2 gains money (+150n) - await insertWalletBalance(wallet2Id, tokenId, amount, 1); - - // Add wallet_tx_history entries - await mysql.query( - `INSERT INTO \`wallet_tx_history\` (wallet_id, token_id, tx_id, balance, timestamp) - VALUES (?, ?, ?, ?, ?), (?, ?, ?, ?, ?)`, - [wallet1Id, tokenId, txId, -amount, 100, wallet2Id, tokenId, txId, amount, 100] - ); - - // Verify wallet balances before voiding - let wallet1Balance = await getWalletBalance(wallet1Id, tokenId); - let wallet2Balance = await getWalletBalance(wallet2Id, tokenId); - - expect(wallet1Balance).not.toBeNull(); - expect(BigInt(wallet1Balance.unlocked_balance)).toBe(50n); // 200 - 150 - expect(wallet1Balance.transactions).toBe(2); - - expect(wallet2Balance).not.toBeNull(); - expect(BigInt(wallet2Balance.unlocked_balance)).toBe(amount); - expect(wallet2Balance.transactions).toBe(1); - - // Now void the transaction - const inputs: EventTxInput[] = [createEventTxInput(amount, address1, 'some-previous-tx', 0)]; - const voidOutputs = [{ - value: amount, - locked: false, - decoded: { - type: 'P2PKH' as const, - address: address2, - timelock: null, - }, - token_data: 0, - script: 'dqkUH70YjKeoKdFwMX2TOYvGVbXOrKaIrA==', - }]; - - await voidTx(mysql, txId, inputs, voidOutputs, [tokenId], []); - - // Check wallet balances after voiding - wallet1Balance = await getWalletBalance(wallet1Id, tokenId); - wallet2Balance = await getWalletBalance(wallet2Id, tokenId); - - // These assertions will FAIL because wallet balances are not updated during voiding - - // Wallet1 should have its balance restored (50 + 150 = 200) - expect(BigInt(wallet1Balance.unlocked_balance)).toBe(200n); - expect(wallet1Balance.transactions).toBe(1); // Should decrease back to 1 - - // Wallet2 should have its balance reduced to 0 - if (wallet2Balance) { - expect(BigInt(wallet2Balance.unlocked_balance)).toBe(0n); - expect(wallet2Balance.transactions).toBe(0); // Should be 0 after voiding - } - - // Check wallet_tx_history cleanup - const wallet1HistoryCount = await getWalletTxHistoryCount(wallet1Id, txId); - const wallet2HistoryCount = await getWalletTxHistoryCount(wallet2Id, txId); - - // These will FAIL because wallet_tx_history is not cleaned up - expect(wallet1HistoryCount).toBe(0); - expect(wallet2HistoryCount).toBe(0); - }); - - it('should demonstrate the bug exists even with simple single wallet scenario', async () => { - expect.hasAssertions(); - - const walletId = 'simple-wallet'; - const address = 'simple-address'; - const tokenId = '00'; - const txId = 'simple-tx'; - const value = 50n; - - // Setup wallet - await setupWallet(walletId, [address]); - - // Create transaction - await addOrUpdateTx(mysql, txId, 0, 1, 1, 100); - const output = createOutput(0, value, address, tokenId); - await addUtxos(mysql, txId, [output], null); - - // Simulate wallet balance update - await insertWalletBalance(walletId, tokenId, value, 1); - await mysql.query( - `INSERT INTO \`wallet_tx_history\` (wallet_id, token_id, tx_id, balance, timestamp) - VALUES (?, ?, ?, ?, ?)`, - [walletId, tokenId, txId, value, 100] - ); - - // Verify wallet balance exists - let walletBalance = await getWalletBalance(walletId, tokenId); - expect(walletBalance).not.toBeNull(); - expect(BigInt(walletBalance.unlocked_balance)).toBe(value); - expect(walletBalance.transactions).toBe(1); - - // Void the transaction - const inputs: EventTxInput[] = []; - const outputs = [{ - value: value, - locked: false, - decoded: { - type: 'P2PKH' as const, - address: address, - timelock: null, - }, - token_data: 0, - script: 'dqkUH70YjKeoKdFwMX2TOYvGVbXOrKaIrA==', - }]; - - await voidTx(mysql, txId, inputs, outputs, [tokenId], []); - - // Check wallet balance after voiding - walletBalance = await getWalletBalance(walletId, tokenId); - - // This WILL FAIL because wallet balances are not updated during voiding - if (walletBalance) { - expect(BigInt(walletBalance.unlocked_balance)).toBe(0n); // Should be 0 after voiding - expect(walletBalance.transactions).toBe(0); // Should be 0 after voiding - } - - // Check wallet_tx_history cleanup - const historyCount = await getWalletTxHistoryCount(walletId, txId); - expect(historyCount).toBe(0); // Should be 0 after voiding - }); -}); diff --git a/packages/daemon/package.json b/packages/daemon/package.json index 78085898..44da1f29 100644 --- a/packages/daemon/package.json +++ b/packages/daemon/package.json @@ -21,7 +21,7 @@ "test_images_wait_for_db": "yarn dlx ts-node ./__tests__/integration/scripts/wait-for-db-up.ts", "test_images_wait_for_ws": "yarn dlx ts-node ./__tests__/integration/scripts/wait-for-ws-up.ts", "test_images_setup_database": "yarn dlx ts-node ./__tests__/integration/scripts/setup-database.ts", - "test": "jest --coverage", + "test": "jest --coverage --runInBand", "test_integration": "yarn run test_images_up && yarn run test_images_wait_for_db && yarn run test_images_wait_for_ws && yarn run test_images_setup_database && yarn run test_images_migrate && yarn run test_images_integration && yarn run test_images_down" }, "name": "sync-daemon", diff --git a/packages/daemon/src/db/index.ts b/packages/daemon/src/db/index.ts index a2a4f3ae..0c8461de 100644 --- a/packages/daemon/src/db/index.ts +++ b/packages/daemon/src/db/index.ts @@ -141,6 +141,7 @@ export const addUtxos = async ( output.decoded?.timelock, heightlock, output.locked, + null, // spent_by - initially null for new UTXOs ]; }, ); @@ -149,7 +150,7 @@ export const addUtxos = async ( await mysql.query( `INSERT INTO \`tx_output\` (\`tx_id\`, \`index\`, \`token_id\`, \`value\`, \`authorities\`, \`address\`, - \`timelock\`, \`heightlock\`, \`locked\`) + \`timelock\`, \`heightlock\`, \`locked\`, \`spent_by\`) VALUES ? ON DUPLICATE KEY UPDATE tx_id=tx_id`, [entries], @@ -401,7 +402,7 @@ export const voidTransaction = async ( await mysql.query( `INSERT INTO \`address\`(\`address\`, \`transactions\`) VALUES ? - ON DUPLICATE KEY UPDATE transactions = CASE WHEN transactions > 0 THEN transactions - 1 ELSE 0 END`, + ON DUPLICATE KEY UPDATE transactions = transactions - 1`, [addressEntries], ); } @@ -430,9 +431,9 @@ export const voidTransaction = async ( `INSERT INTO address_balance SET ? ON DUPLICATE KEY - UPDATE total_received = CASE WHEN total_received >= ? THEN total_received - ? ELSE 0 END, - unlocked_balance = CASE WHEN unlocked_balance >= ? THEN unlocked_balance - ? ELSE 0 END, - locked_balance = CASE WHEN locked_balance >= ? THEN locked_balance - ? ELSE 0 END, + UPDATE total_received = total_received - ?, + unlocked_balance = unlocked_balance - ?, + locked_balance = locked_balance - ?, transactions = transactions - 1, timelock_expires = CASE WHEN timelock_expires IS NULL THEN VALUES(timelock_expires) @@ -443,9 +444,9 @@ export const voidTransaction = async ( locked_authorities = locked_authorities | VALUES(locked_authorities)`, [ entry, - tokenBalance.totalAmountSent, tokenBalance.totalAmountSent, // For total_received comparison and subtraction - tokenBalance.unlockedAmount, tokenBalance.unlockedAmount, // For unlocked_balance comparison and subtraction - tokenBalance.lockedAmount, tokenBalance.lockedAmount, // For locked_balance comparison and subtraction + tokenBalance.totalAmountSent, // For total_received subtraction + tokenBalance.unlockedAmount, // For unlocked_balance subtraction + tokenBalance.lockedAmount, // For locked_balance subtraction address, token ], diff --git a/packages/daemon/src/services/index.ts b/packages/daemon/src/services/index.ts index 1a464b4a..1f21657e 100644 --- a/packages/daemon/src/services/index.ts +++ b/packages/daemon/src/services/index.ts @@ -81,6 +81,7 @@ import getConfig from '../config'; import logger from '../logger'; import { invokeOnTxPushNotificationRequestedLambda } from '../utils'; import { addAlert, Severity } from '@wallet-service/common'; +import { JSONBigInt } from '@hathor/wallet-lib/lib/utils/bigint'; export const METADATA_DIFF_EVENT_TYPES = { IGNORE: 'IGNORE', @@ -218,6 +219,7 @@ export const handleVertexAccepted = async (context: Context, _event: Event) => { const isNano = isNanoContract(headers); + const dbTx: DbTransaction | null = await getTransactionById(mysql, hash); if (dbTx) { @@ -389,7 +391,7 @@ export const handleVertexAccepted = async (context: Context, _event: Event) => { // prepare the transaction data to be sent to the SQS queue const txData: Transaction = { tx_id: hash, - nonce: nonce ? BigInt(nonce) : BigInt(0), + nonce, timestamp, version, voided: metadata.voided_by.length > 0, @@ -434,7 +436,7 @@ export const handleVertexAccepted = async (context: Context, _event: Event) => { // Call to process the data for NFT handling (if applicable) // This process is not critical, so we run it in a fire-and-forget manner, not waiting for the promise. - NftUtils.processNftEvent({ ...fullNodeData, nonce: fullNodeData.nonce ? BigInt(fullNodeData.nonce) : BigInt(0) }, STAGE, network, logger) + NftUtils.processNftEvent(fullNodeData, STAGE, network, logger) .catch((err: unknown) => logger.error('[ALERT] Error processing NFT event', err)); } @@ -488,6 +490,8 @@ export const handleVertexRemoved = async (context: Context, _event: Event) => { } logger.info(`[VertexRemoved] Voiding tx: ${hash}`); + + await voidTx( mysql, hash, @@ -540,6 +544,7 @@ export const voidTx = async ( await voidTransaction(mysql, hash, addressBalanceMap); await markUtxosAsVoided(mysql, dbTxOutputs); + // CRITICAL: Unspent the inputs when voiding a transaction // The inputs of the voided transaction need to be marked as unspent // But only if they were actually spent by this transaction @@ -550,6 +555,8 @@ export const voidTx = async ( for (const input of inputs) { // Get the current state of this output to check if it's spent by our transaction const currentOutput = await getTxOutput(mysql, input.tx_id, input.index, false); + + if (currentOutput && currentOutput.spentBy === hash) { inputsSpentByThisTx.push({ txId: input.tx_id, @@ -703,7 +710,7 @@ export const updateLastSyncedEvent = async (context: Context) => { && lastDbSyncedEvent.last_event_id > lastEventId) { logger.error('Tried to store an event lower than the one on the database', { lastEventId, - lastDbSyncedEvent: JSON.stringify(lastDbSyncedEvent), + lastDbSyncedEvent: JSONBigInt.stringify(lastDbSyncedEvent), }); mysql.destroy(); throw new Error('Event lower than stored one.'); From 4622f4119d98bfe80416c36c30db7cb73c8502d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Abadesso?= Date: Tue, 9 Sep 2025 10:27:49 -0300 Subject: [PATCH 08/49] refactor(daemon): moved utils to the utils file --- .../services/services_with_db.test.ts | 99 +++++-------------- packages/daemon/__tests__/utils.ts | 52 ++++++++++ 2 files changed, 78 insertions(+), 73 deletions(-) diff --git a/packages/daemon/__tests__/services/services_with_db.test.ts b/packages/daemon/__tests__/services/services_with_db.test.ts index 04b8b9f8..14f66243 100644 --- a/packages/daemon/__tests__/services/services_with_db.test.ts +++ b/packages/daemon/__tests__/services/services_with_db.test.ts @@ -21,6 +21,10 @@ import { createOutput, createInput, createEventTxInput, + setupWallet, + getWalletBalance, + insertWalletBalance, + getWalletTxHistoryCount, } from '../utils'; import { DbTxOutput, EventTxInput } from '../../src/types'; import { Connection } from 'mysql2/promise'; @@ -545,57 +549,6 @@ describe('unspentTxOutputs function', () => { }); }); -// Helper function to create a wallet and addresses -const setupWallet = async (walletId: string, addresses: string[]) => { - const now = Math.floor(Date.now() / 1000); // Unix timestamp - // Create wallet - await mysql.query( - `INSERT INTO \`wallet\` (id, xpubkey, auth_xpubkey, status, max_gap, created_at, ready_at) - VALUES (?, 'xpub123', 'xpub456', 'ready', 20, ?, ?)`, - [walletId, now, now] - ); - - // Add addresses to the wallet - const addressEntries = addresses.map((address, index) => [address, index, walletId, 1]); - await mysql.query( - `INSERT INTO \`address\` (address, \`index\`, wallet_id, transactions) - VALUES ?`, - [addressEntries] - ); -}; - -// Helper function to get wallet balance -const getWalletBalance = async (walletId: string, tokenId: string) => { - const [results] = await mysql.query( - `SELECT * FROM \`wallet_balance\` WHERE \`wallet_id\` = ? AND \`token_id\` = ?`, - [walletId, tokenId] - ) as [any[], any]; - return results[0] || null; -}; - -// Helper function to manually insert wallet balance (simulating what should happen) -const insertWalletBalance = async (walletId: string, tokenId: string, balance: bigint, transactions: number) => { - await mysql.query( - `INSERT INTO \`wallet_balance\` (wallet_id, token_id, unlocked_balance, locked_balance, - unlocked_authorities, locked_authorities, total_received, - transactions, timelock_expires) - VALUES (?, ?, ?, 0, 0, 0, ?, ?, NULL) - ON DUPLICATE KEY UPDATE - unlocked_balance = unlocked_balance + ?, - total_received = total_received + ?, - transactions = transactions + ?`, - [walletId, tokenId, balance, balance, transactions, balance, balance, transactions] - ); -}; - -// Helper function to get wallet transaction history count -const getWalletTxHistoryCount = async (walletId: string, txId: string) => { - const [results] = await mysql.query( - `SELECT COUNT(*) as count FROM \`wallet_tx_history\` WHERE \`wallet_id\` = ? AND \`tx_id\` = ?`, - [walletId, txId] - ) as [any[], any]; - return results[0].count; -}; describe('wallet balance voiding bug', () => { it('should demonstrate wallet balance not being updated when voiding a transaction', async () => { @@ -609,7 +562,7 @@ describe('wallet balance voiding bug', () => { const initialValue = 100n; // Setup wallet and address - await setupWallet(walletId, [address]); + await setupWallet(mysql, walletId, [address]); // Create transaction A that creates an output to our wallet address await addOrUpdateTx(mysql, txIdA, 0, 1, 1, 100); @@ -617,7 +570,7 @@ describe('wallet balance voiding bug', () => { await addUtxos(mysql, txIdA, [outputA], null); // Manually insert wallet balance (simulating what updateWalletTablesWithTx would do) - await insertWalletBalance(walletId, tokenId, initialValue, 1); + await insertWalletBalance(mysql, walletId, tokenId, initialValue, 1); // Also insert into wallet_tx_history await mysql.query( @@ -627,7 +580,7 @@ describe('wallet balance voiding bug', () => { ); // Verify initial wallet balance - let walletBalance = await getWalletBalance(walletId, tokenId); + let walletBalance = await getWalletBalance(mysql, walletId, tokenId); expect(walletBalance).not.toBeNull(); expect(BigInt(walletBalance.unlocked_balance)).toBe(initialValue); expect(walletBalance.transactions).toBe(1); @@ -642,7 +595,7 @@ describe('wallet balance voiding bug', () => { await addUtxos(mysql, txIdB, [outputB], null); // Update wallet balance for transaction B (net zero change) - await insertWalletBalance(walletId, tokenId, 0n, 1); + await insertWalletBalance(mysql, walletId, tokenId, 0n, 1); // Add to wallet_tx_history await mysql.query( @@ -652,7 +605,7 @@ describe('wallet balance voiding bug', () => { ); // Verify wallet balance after transaction B - walletBalance = await getWalletBalance(walletId, tokenId); + walletBalance = await getWalletBalance(mysql, walletId, tokenId); expect(walletBalance).not.toBeNull(); expect(BigInt(walletBalance.unlocked_balance)).toBe(initialValue); // Still 100n expect(walletBalance.transactions).toBe(2); // Now 2 transactions @@ -674,7 +627,7 @@ describe('wallet balance voiding bug', () => { await voidTx(mysql, txIdB, inputs, outputs, [tokenId], []); // CRITICAL BUG: Check wallet balance after voiding - walletBalance = await getWalletBalance(walletId, tokenId); + walletBalance = await getWalletBalance(mysql, walletId, tokenId); expect(walletBalance).not.toBeNull(); // This will FAIL because wallet balances are not updated during voiding @@ -682,7 +635,7 @@ describe('wallet balance voiding bug', () => { expect(walletBalance.transactions).toBe(1); // Should be back to 1 transaction // Check if wallet_tx_history was cleaned up - const historyCount = await getWalletTxHistoryCount(walletId, txIdB); + const historyCount = await getWalletTxHistoryCount(mysql, walletId, txIdB); // This will FAIL because wallet_tx_history is not cleaned up during voiding expect(historyCount).toBe(0); // Should be 0 after voiding }); @@ -699,11 +652,11 @@ describe('wallet balance voiding bug', () => { const amount = 150n; // Setup two wallets - await setupWallet(wallet1Id, [address1]); - await setupWallet(wallet2Id, [address2]); + await setupWallet(mysql, wallet1Id, [address1]); + await setupWallet(mysql, wallet2Id, [address2]); // Simulate wallet1 already having some balance - await insertWalletBalance(wallet1Id, tokenId, 200n, 1); + await insertWalletBalance(mysql, wallet1Id, tokenId, 200n, 1); // Create a transaction that transfers money from wallet1 to wallet2 await addOrUpdateTx(mysql, txId, 0, 1, 1, 100); @@ -724,7 +677,7 @@ describe('wallet balance voiding bug', () => { ); // Wallet2 gains money (+150n) - await insertWalletBalance(wallet2Id, tokenId, amount, 1); + await insertWalletBalance(mysql, wallet2Id, tokenId, amount, 1); // Add wallet_tx_history entries await mysql.query( @@ -734,8 +687,8 @@ describe('wallet balance voiding bug', () => { ); // Verify wallet balances before voiding - let wallet1Balance = await getWalletBalance(wallet1Id, tokenId); - let wallet2Balance = await getWalletBalance(wallet2Id, tokenId); + let wallet1Balance = await getWalletBalance(mysql, wallet1Id, tokenId); + let wallet2Balance = await getWalletBalance(mysql, wallet2Id, tokenId); expect(wallet1Balance).not.toBeNull(); expect(BigInt(wallet1Balance.unlocked_balance)).toBe(50n); // 200 - 150 @@ -762,8 +715,8 @@ describe('wallet balance voiding bug', () => { await voidTx(mysql, txId, inputs, voidOutputs, [tokenId], []); // Check wallet balances after voiding - wallet1Balance = await getWalletBalance(wallet1Id, tokenId); - wallet2Balance = await getWalletBalance(wallet2Id, tokenId); + wallet1Balance = await getWalletBalance(mysql, wallet1Id, tokenId); + wallet2Balance = await getWalletBalance(mysql, wallet2Id, tokenId); // These assertions will FAIL because wallet balances are not updated during voiding @@ -778,8 +731,8 @@ describe('wallet balance voiding bug', () => { } // Check wallet_tx_history cleanup - const wallet1HistoryCount = await getWalletTxHistoryCount(wallet1Id, txId); - const wallet2HistoryCount = await getWalletTxHistoryCount(wallet2Id, txId); + const wallet1HistoryCount = await getWalletTxHistoryCount(mysql, wallet1Id, txId); + const wallet2HistoryCount = await getWalletTxHistoryCount(mysql, wallet2Id, txId); // These will FAIL because wallet_tx_history is not cleaned up expect(wallet1HistoryCount).toBe(0); @@ -796,7 +749,7 @@ describe('wallet balance voiding bug', () => { const value = 50n; // Setup wallet - await setupWallet(walletId, [address]); + await setupWallet(mysql, walletId, [address]); // Create transaction await addOrUpdateTx(mysql, txId, 0, 1, 1, 100); @@ -804,7 +757,7 @@ describe('wallet balance voiding bug', () => { await addUtxos(mysql, txId, [output], null); // Simulate wallet balance update - await insertWalletBalance(walletId, tokenId, value, 1); + await insertWalletBalance(mysql, walletId, tokenId, value, 1); await mysql.query( `INSERT INTO \`wallet_tx_history\` (wallet_id, token_id, tx_id, balance, timestamp) VALUES (?, ?, ?, ?, ?)`, @@ -812,7 +765,7 @@ describe('wallet balance voiding bug', () => { ); // Verify wallet balance exists - let walletBalance = await getWalletBalance(walletId, tokenId); + let walletBalance = await getWalletBalance(mysql, walletId, tokenId); expect(walletBalance).not.toBeNull(); expect(BigInt(walletBalance.unlocked_balance)).toBe(value); expect(walletBalance.transactions).toBe(1); @@ -834,7 +787,7 @@ describe('wallet balance voiding bug', () => { await voidTx(mysql, txId, inputs, outputs, [tokenId], []); // Check wallet balance after voiding - walletBalance = await getWalletBalance(walletId, tokenId); + walletBalance = await getWalletBalance(mysql, walletId, tokenId); // This WILL FAIL because wallet balances are not updated during voiding if (walletBalance) { @@ -843,7 +796,7 @@ describe('wallet balance voiding bug', () => { } // Check wallet_tx_history cleanup - const historyCount = await getWalletTxHistoryCount(walletId, txId); + const historyCount = await getWalletTxHistoryCount(mysql, walletId, txId); expect(historyCount).toBe(0); // Should be 0 after voiding }); }); diff --git a/packages/daemon/__tests__/utils.ts b/packages/daemon/__tests__/utils.ts index 0b5fbf91..8a4e35f9 100644 --- a/packages/daemon/__tests__/utils.ts +++ b/packages/daemon/__tests__/utils.ts @@ -789,3 +789,55 @@ export const generateFullNodeEvent = (event: any) => ({ data: event.data, }, }); + +// Helper function to create a wallet and addresses +export const setupWallet = async (mysql: MysqlConnection, walletId: string, addresses: string[]) => { + const now = Math.floor(Date.now() / 1000); // Unix timestamp + // Create wallet + await mysql.query( + `INSERT INTO \`wallet\` (id, xpubkey, auth_xpubkey, status, max_gap, created_at, ready_at) + VALUES (?, 'xpub123', 'xpub456', 'ready', 20, ?, ?)`, + [walletId, now, now] + ); + + // Add addresses to the wallet + const addressEntries = addresses.map((address, index) => [address, index, walletId, 1]); + await mysql.query( + `INSERT INTO \`address\` (address, \`index\`, wallet_id, transactions) + VALUES ?`, + [addressEntries] + ); +}; + +// Helper function to get wallet balance +export const getWalletBalance = async (mysql: MysqlConnection, walletId: string, tokenId: string) => { + const [results] = await mysql.query( + `SELECT * FROM \`wallet_balance\` WHERE \`wallet_id\` = ? AND \`token_id\` = ?`, + [walletId, tokenId] + ) as [any[], any]; + return results[0] || null; +}; + +// Helper function to manually insert wallet balance (simulating what should happen) +export const insertWalletBalance = async (mysql: MysqlConnection, walletId: string, tokenId: string, balance: bigint, transactions: number) => { + await mysql.query( + `INSERT INTO \`wallet_balance\` (wallet_id, token_id, unlocked_balance, locked_balance, + unlocked_authorities, locked_authorities, total_received, + transactions, timelock_expires) + VALUES (?, ?, ?, 0, 0, 0, ?, ?, NULL) + ON DUPLICATE KEY UPDATE + unlocked_balance = unlocked_balance + ?, + total_received = total_received + ?, + transactions = transactions + ?`, + [walletId, tokenId, balance, balance, transactions, balance, balance, transactions] + ); +}; + +// Helper function to get wallet transaction history count +export const getWalletTxHistoryCount = async (mysql: MysqlConnection, walletId: string, txId: string) => { + const [results] = await mysql.query( + `SELECT COUNT(*) as count FROM \`wallet_tx_history\` WHERE \`wallet_id\` = ? AND \`tx_id\` = ?`, + [walletId, txId] + ) as [any[], any]; + return results[0].count; +}; From 629752482ee7b64f9e47d05e19d796f5173ceb01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Abadesso?= Date: Tue, 9 Sep 2025 10:49:10 -0300 Subject: [PATCH 09/49] refactor(daemon): more strict balance checking and safer null check --- .../__tests__/integration/utils/index.ts | 16 ++++-- .../services/services_with_db.test.ts | 54 ++++++++++--------- 2 files changed, 41 insertions(+), 29 deletions(-) diff --git a/packages/daemon/__tests__/integration/utils/index.ts b/packages/daemon/__tests__/integration/utils/index.ts index 4fe78f54..4b9d8e3e 100644 --- a/packages/daemon/__tests__/integration/utils/index.ts +++ b/packages/daemon/__tests__/integration/utils/index.ts @@ -60,15 +60,23 @@ export const validateBalances = async ( balancesA: AddressBalance[], balancesB: Record, ): Promise => { - // Only validate addresses that are explicitly mentioned in the expected config - const expectedAddresses = Object.keys(balancesB); + const expectedAddresses = new Set(Object.keys(balancesB)); + // Check for unexpected addresses with non-zero balances + for (const balance of balancesA) { + const totalBalance = balance.lockedBalance + balance.unlockedBalance; + + if (!expectedAddresses.has(balance.address) && totalBalance !== BigInt(0)) { + throw new Error(`Unexpected address with non-zero balance: ${balance.address}, balance: ${totalBalance}`); + } + } + + // Validate all expected addresses for (const address of expectedAddresses) { const balanceA = balancesA.find(b => b.address === address); - const balanceB = balancesB[address]; + const expectedBalance = balancesB[address]; const totalBalanceA = balanceA ? (balanceA.lockedBalance + balanceA.unlockedBalance) : BigInt(0); - const expectedBalance = balanceB || BigInt(0); if (totalBalanceA !== expectedBalance) { throw new Error(`Balances are not equal for address: ${address}, expected: ${expectedBalance}, received: ${totalBalanceA}`); diff --git a/packages/daemon/__tests__/services/services_with_db.test.ts b/packages/daemon/__tests__/services/services_with_db.test.ts index 14f66243..c2da185e 100644 --- a/packages/daemon/__tests__/services/services_with_db.test.ts +++ b/packages/daemon/__tests__/services/services_with_db.test.ts @@ -127,7 +127,7 @@ describe('voidTransaction with input unspending', () => { // Verify the UTXO is unspent let utxo = await getTxOutput(mysql, txIdA, 0, true); expect(utxo).not.toBeNull(); - expect(utxo!.spentBy).toBeNull(); + expect(utxo?.spentBy).toBeNull(); // Create transaction B that spends the output from transaction A const txIdB = 'test1-tx-b'; @@ -142,7 +142,7 @@ describe('voidTransaction with input unspending', () => { // Verify the UTXO is now spent utxo = await getTxOutput(mysql, txIdA, 0, false); expect(utxo).not.toBeNull(); - expect(utxo!.spentBy).toBe(txIdB); + expect(utxo?.spentBy).toBe(txIdB); // Add output from transaction B const outputB = createOutput(0, outputValue, addressB, tokenId); @@ -168,7 +168,7 @@ describe('voidTransaction with input unspending', () => { // Check if the UTXO from transaction A is unspent again utxo = await getTxOutput(mysql, txIdA, 0, false); expect(utxo).not.toBeNull(); - expect(utxo!.spentBy).toBeNull(); + expect(utxo?.spentBy).toBeNull(); }); it('should unspent multiple inputs when voiding a transaction with multiple inputs', async () => { @@ -196,8 +196,8 @@ describe('voidTransaction with input unspending', () => { // Verify both UTXOs are unspent let utxoA = await getTxOutput(mysql, txIdA, 0, true); let utxoB = await getTxOutput(mysql, txIdB, 0, true); - expect(utxoA!.spentBy).toBeNull(); - expect(utxoB!.spentBy).toBeNull(); + expect(utxoA?.spentBy).toBeNull(); + expect(utxoB?.spentBy).toBeNull(); // Create transaction C that spends both outputs await addOrUpdateTx(mysql, txIdC, 0, 1, 1, 102); @@ -209,8 +209,8 @@ describe('voidTransaction with input unspending', () => { // Verify both UTXOs are now spent by C utxoA = await getTxOutput(mysql, txIdA, 0, false); utxoB = await getTxOutput(mysql, txIdB, 0, false); - expect(utxoA!.spentBy).toBe(txIdC); - expect(utxoB!.spentBy).toBe(txIdC); + expect(utxoA?.spentBy).toBe(txIdC); + expect(utxoB?.spentBy).toBe(txIdC); // Add output from transaction C const outputC = createOutput(0, 125n, address3, tokenId); @@ -241,9 +241,9 @@ describe('voidTransaction with input unspending', () => { // Both outputs should be unspent again after voiding C (which was spending them) expect(utxoA).not.toBeNull(); - expect(utxoA!.spentBy).toBeNull(); // Should pass - should be null + expect(utxoA?.spentBy).toBeNull(); // Should pass - should be null expect(utxoB).not.toBeNull(); - expect(utxoB!.spentBy).toBeNull(); // Should pass - should be null + expect(utxoB?.spentBy).toBeNull(); // Should pass - should be null // The output from transaction C should be voided (not accessible with getTxOutput) const utxoC = await getTxOutput(mysql, txIdC, 0, false); @@ -297,7 +297,7 @@ describe('voidTransaction with input unspending', () => { // B's output should be unspent now (and it will be with the fix) let utxoB = await getTxOutput(mysql, txIdB, 0, true); expect(utxoB).not.toBeNull(); - expect(utxoB!.spentBy).toBeNull(); // Should pass - should be null + expect(utxoB?.spentBy).toBeNull(); // Should pass - should be null // Now void transaction B await voidTx(mysql, txIdB, @@ -316,7 +316,7 @@ describe('voidTransaction with input unspending', () => { // A's output should be unspent now let utxoA = await getTxOutput(mysql, txIdA, 0, true); expect(utxoA).not.toBeNull(); - expect(utxoA!.spentBy).toBeNull(); // Should pass - should be null + expect(utxoA?.spentBy).toBeNull(); // Should pass - should be null }); it('should handle voiding when one input is already spent by another transaction', async () => { @@ -343,11 +343,15 @@ describe('voidTransaction with input unspending', () => { const inputB = createInput(100n, address1, txIdA, 0, tokenId); await updateTxOutputSpentBy(mysql, [inputB], txIdB); - // For this test, we simulate that transaction C also tried to spend it - // (in reality this would be a double-spend, but we're testing edge cases) + // Add output for transaction B + const outputB = createOutput(0, 100n, address2, tokenId); + await addUtxos(mysql, txIdB, [outputB], null); + + // Transaction C also tries to spend the same UTXO (double-spend scenario) + // In reality, this would be detected and prevented, but we're testing edge cases await addOrUpdateTx(mysql, txIdC, 0, 1, 1, 102); - // Add output for transaction C + // Add output for transaction C (this transaction exists but its input reference is invalid) const outputC = createOutput(0, 100n, address2, tokenId); await addUtxos(mysql, txIdC, [outputC], null); @@ -366,7 +370,7 @@ describe('voidTransaction with input unspending', () => { // The UTXO should still be spent by B, not unspent const utxo = await getTxOutput(mysql, txIdA, 0, false); expect(utxo).not.toBeNull(); - expect(utxo!.spentBy).toBe(txIdB); // Should remain spent by B + expect(utxo?.spentBy).toBe(txIdB); // Should remain spent by B }); it('should correctly unspent inputs with different token types', async () => { @@ -426,9 +430,9 @@ describe('voidTransaction with input unspending', () => { // These should pass with the implementation expect(utxo1).not.toBeNull(); - expect(utxo1!.spentBy).toBeNull(); + expect(utxo1?.spentBy).toBeNull(); expect(utxo2).not.toBeNull(); - expect(utxo2!.spentBy).toBeNull(); + expect(utxo2?.spentBy).toBeNull(); }); it('should verify the complete flow with balance checks', async () => { @@ -459,7 +463,7 @@ describe('voidTransaction with input unspending', () => { // Verify spent state const spentUtxo = await getTxOutput(mysql, txIdA, 0, false); - expect(spentUtxo!.spentBy).toBe(txIdB); + expect(spentUtxo?.spentBy).toBe(txIdB); // Void the spending transaction const inputs = [createEventTxInput(value, address1, txIdA, 0)]; @@ -476,7 +480,7 @@ describe('voidTransaction with input unspending', () => { // Verify the original UTXO is unspent again const unspentUtxo = await getTxOutput(mysql, txIdA, 0, true); expect(unspentUtxo).not.toBeNull(); - expect(unspentUtxo!.spentBy).toBeNull(); // This should pass + expect(unspentUtxo?.spentBy).toBeNull(); // This should pass // Also verify that B's outputs are marked as voided const voidedUtxo = await getTxOutput(mysql, txIdB, 0, false); @@ -523,9 +527,9 @@ describe('unspentTxOutputs function', () => { let utxoA = await getTxOutput(mysql, txIdA, 0, false); let utxoB = await getTxOutput(mysql, txIdB, 0, false); let utxoC = await getTxOutput(mysql, txIdC, 0, false); - expect(utxoA!.spentBy).toBe(spendingTx); - expect(utxoB!.spentBy).toBe(spendingTx); - expect(utxoC!.spentBy).toBe(spendingTx); + expect(utxoA?.spentBy).toBe(spendingTx); + expect(utxoB?.spentBy).toBe(spendingTx); + expect(utxoC?.spentBy).toBe(spendingTx); // Now unspent them const txOutputsToUnspent: DbTxOutput[] = [ @@ -541,11 +545,11 @@ describe('unspentTxOutputs function', () => { utxoB = await getTxOutput(mysql, txIdB, 0, true); utxoC = await getTxOutput(mysql, txIdC, 0, true); expect(utxoA).not.toBeNull(); - expect(utxoA!.spentBy).toBeNull(); + expect(utxoA?.spentBy).toBeNull(); expect(utxoB).not.toBeNull(); - expect(utxoB!.spentBy).toBeNull(); + expect(utxoB?.spentBy).toBeNull(); expect(utxoC).not.toBeNull(); - expect(utxoC!.spentBy).toBeNull(); + expect(utxoC?.spentBy).toBeNull(); }); }); From 864c58a48a4f598c4e698e5a932f5947b626485a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Abadesso?= Date: Wed, 10 Sep 2025 08:34:06 -0300 Subject: [PATCH 10/49] tests(daemon): tests should expect not to throw --- .../__tests__/integration/balances.test.ts | 16 ++++++++-------- .../__tests__/services/services_with_db.test.ts | 8 ++++++-- packages/daemon/src/services/index.ts | 2 +- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/packages/daemon/__tests__/integration/balances.test.ts b/packages/daemon/__tests__/integration/balances.test.ts index 89b3d912..63be6186 100644 --- a/packages/daemon/__tests__/integration/balances.test.ts +++ b/packages/daemon/__tests__/integration/balances.test.ts @@ -124,7 +124,7 @@ describe('unvoided transaction scenario', () => { // @ts-expect-error await transitionUntilEvent(mysql, machine, UNVOIDED_SCENARIO_LAST_EVENT); const addressBalances = await fetchAddressBalances(mysql); - await validateBalances(addressBalances, unvoidedScenarioBalances); + await expect(validateBalances(addressBalances, unvoidedScenarioBalances)).resolves.not.toThrow(); }); }); @@ -159,7 +159,7 @@ describe('reorg scenario', () => { // @ts-expect-error await transitionUntilEvent(mysql, machine, REORG_SCENARIO_LAST_EVENT); const addressBalances = await fetchAddressBalances(mysql); - await validateBalances(addressBalances, reorgScenarioBalances); + await expect(validateBalances(addressBalances, reorgScenarioBalances)).resolves.not.toThrow(); }); }); @@ -194,7 +194,7 @@ describe('single chain blocks and transactions scenario', () => { // @ts-expect-error await transitionUntilEvent(mysql, machine, SINGLE_CHAIN_BLOCKS_AND_TRANSACTIONS_LAST_EVENT); const addressBalances = await fetchAddressBalances(mysql); - await validateBalances(addressBalances, singleChainBlocksAndTransactionsBalances); + await expect(validateBalances(addressBalances, singleChainBlocksAndTransactionsBalances)).resolves.not.toThrow(); }); }); @@ -229,7 +229,7 @@ describe('invalid mempool transactions scenario', () => { // @ts-expect-error await transitionUntilEvent(mysql, machine, INVALID_MEMPOOL_TRANSACTION_LAST_EVENT); const addressBalances = await fetchAddressBalances(mysql); - await validateBalances(addressBalances, invalidMempoolBalances); + await expect(validateBalances(addressBalances, invalidMempoolBalances)).resolves.not.toThrow(); }); }); @@ -264,7 +264,7 @@ describe('custom script scenario', () => { // @ts-expect-error await transitionUntilEvent(mysql, machine, CUSTOM_SCRIPT_LAST_EVENT); const addressBalances = await fetchAddressBalances(mysql); - await validateBalances(addressBalances, customScriptBalances); + await expect(validateBalances(addressBalances, customScriptBalances)).resolves.not.toThrow(); }); }); @@ -300,7 +300,7 @@ describe('empty script scenario', () => { await transitionUntilEvent(mysql, machine, EMPTY_SCRIPT_LAST_EVENT); const addressBalances = await fetchAddressBalances(mysql); - await validateBalances(addressBalances, emptyScriptBalances); + await expect(validateBalances(addressBalances, emptyScriptBalances)).resolves.not.toThrow(); }); }); @@ -336,7 +336,7 @@ describe('nc events scenario', () => { await transitionUntilEvent(mysql, machine, NC_EVENTS_LAST_EVENT); const addressBalances = await fetchAddressBalances(mysql); - await validateBalances(addressBalances, ncEventsBalances); + await expect(validateBalances(addressBalances, ncEventsBalances)).resolves.not.toThrow(); }); }); @@ -372,7 +372,7 @@ describe('transaction voiding chain scenario', () => { await transitionUntilEvent(mysql, machine, TRANSACTION_VOIDING_CHAIN_LAST_EVENT); const addressBalances = await fetchAddressBalances(mysql); - await validateBalances(addressBalances, transactionVoidingChainBalances); + await expect(validateBalances(addressBalances, transactionVoidingChainBalances)).resolves.not.toThrow(); // Validate transaction voiding consistency const voidingChecks = await performVoidingConsistencyChecks(mysql, { diff --git a/packages/daemon/__tests__/services/services_with_db.test.ts b/packages/daemon/__tests__/services/services_with_db.test.ts index c2da185e..0ff8abaa 100644 --- a/packages/daemon/__tests__/services/services_with_db.test.ts +++ b/packages/daemon/__tests__/services/services_with_db.test.ts @@ -255,8 +255,8 @@ describe('voidTransaction with input unspending', () => { // Create transaction A that creates an output const txIdA = 'test3-tx-a'; - const txIdB = 'test3-tx-b'; // Will be voided first - const txIdC = 'test3-tx-c'; // Will be voided second + const txIdB = 'test3-tx-b'; // Will be voided second + const txIdC = 'test3-tx-c'; // Will be voided first const address1 = 'test3-address-1'; const address2 = 'test3-address-2'; const address3 = 'test3-address-3'; @@ -317,6 +317,10 @@ describe('voidTransaction with input unspending', () => { let utxoA = await getTxOutput(mysql, txIdA, 0, true); expect(utxoA).not.toBeNull(); expect(utxoA?.spentBy).toBeNull(); // Should pass - should be null + + // B's output should be voided (not accessible with getTxOutput) + utxoB = await getTxOutput(mysql, txIdB, 0, false); + expect(utxoB).toBeNull(); // Should be null because it's voided }); it('should handle voiding when one input is already spent by another transaction', async () => { diff --git a/packages/daemon/src/services/index.ts b/packages/daemon/src/services/index.ts index 1f21657e..c46bba64 100644 --- a/packages/daemon/src/services/index.ts +++ b/packages/daemon/src/services/index.ts @@ -545,7 +545,7 @@ export const voidTx = async ( await markUtxosAsVoided(mysql, dbTxOutputs); - // CRITICAL: Unspent the inputs when voiding a transaction + // CRITICAL: Unspend the inputs when voiding a transaction // The inputs of the voided transaction need to be marked as unspent // But only if they were actually spent by this transaction if (inputs.length > 0) { From a33b986517bc6f407d88a9513c7a151adb4f0058 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Abadesso?= Date: Wed, 10 Sep 2025 10:12:02 -0300 Subject: [PATCH 11/49] refactor(daemon): better comment on unlocked authorities calculation and removed locked authorities recalculation --- .../services/services_with_db.test.ts | 8 +----- packages/daemon/src/db/index.ts | 27 +++++-------------- 2 files changed, 8 insertions(+), 27 deletions(-) diff --git a/packages/daemon/__tests__/services/services_with_db.test.ts b/packages/daemon/__tests__/services/services_with_db.test.ts index 0ff8abaa..969715db 100644 --- a/packages/daemon/__tests__/services/services_with_db.test.ts +++ b/packages/daemon/__tests__/services/services_with_db.test.ts @@ -634,17 +634,15 @@ describe('wallet balance voiding bug', () => { await voidTx(mysql, txIdB, inputs, outputs, [tokenId], []); - // CRITICAL BUG: Check wallet balance after voiding + // Check wallet balance after voiding walletBalance = await getWalletBalance(mysql, walletId, tokenId); expect(walletBalance).not.toBeNull(); - // This will FAIL because wallet balances are not updated during voiding // The transaction count should decrease from 2 to 1 expect(walletBalance.transactions).toBe(1); // Should be back to 1 transaction // Check if wallet_tx_history was cleaned up const historyCount = await getWalletTxHistoryCount(mysql, walletId, txIdB); - // This will FAIL because wallet_tx_history is not cleaned up during voiding expect(historyCount).toBe(0); // Should be 0 after voiding }); @@ -726,8 +724,6 @@ describe('wallet balance voiding bug', () => { wallet1Balance = await getWalletBalance(mysql, wallet1Id, tokenId); wallet2Balance = await getWalletBalance(mysql, wallet2Id, tokenId); - // These assertions will FAIL because wallet balances are not updated during voiding - // Wallet1 should have its balance restored (50 + 150 = 200) expect(BigInt(wallet1Balance.unlocked_balance)).toBe(200n); expect(wallet1Balance.transactions).toBe(1); // Should decrease back to 1 @@ -742,7 +738,6 @@ describe('wallet balance voiding bug', () => { const wallet1HistoryCount = await getWalletTxHistoryCount(mysql, wallet1Id, txId); const wallet2HistoryCount = await getWalletTxHistoryCount(mysql, wallet2Id, txId); - // These will FAIL because wallet_tx_history is not cleaned up expect(wallet1HistoryCount).toBe(0); expect(wallet2HistoryCount).toBe(0); }); @@ -797,7 +792,6 @@ describe('wallet balance voiding bug', () => { // Check wallet balance after voiding walletBalance = await getWalletBalance(mysql, walletId, tokenId); - // This WILL FAIL because wallet balances are not updated during voiding if (walletBalance) { expect(BigInt(walletBalance.unlocked_balance)).toBe(0n); // Should be 0 after voiding expect(walletBalance.transactions).toBe(0); // Should be 0 after voiding diff --git a/packages/daemon/src/db/index.ts b/packages/daemon/src/db/index.ts index 0c8461de..3cd5c6be 100644 --- a/packages/daemon/src/db/index.ts +++ b/packages/daemon/src/db/index.ts @@ -531,8 +531,13 @@ export const voidWalletTransaction = async ( ], ); - // If we're removing any of the authorities, we need to refresh the authority columns - // Similar to the address case, we need to recalculate authorities from all addresses in the wallet + // If we're removing any of the authorities, we need to refresh the + // authority columns because we might have more than one, so we need to + // calculate the complete state from the complete wallet point of view, + // not just from a single transaction balance point of view. + + // NOTE: No need to do the same for locked authorities as they can't be + // spent before being unlocked and we trust the fullnode if (tokenBalance.unlockedAuthorities.hasNegativeValue()) { await mysql.query( `UPDATE \`wallet_balance\` @@ -549,24 +554,6 @@ export const voidWalletTransaction = async ( [walletId, token, walletId, token], ); } - - // Handle locked authorities refresh if needed - if (tokenBalance.lockedAuthorities.hasNegativeValue && tokenBalance.lockedAuthorities.hasNegativeValue()) { - await mysql.query( - `UPDATE \`wallet_balance\` - SET \`locked_authorities\` = ( - SELECT BIT_OR(\`locked_authorities\`) - FROM \`address_balance\` - WHERE \`address\` IN ( - SELECT \`address\` - FROM \`address\` - WHERE \`wallet_id\` = ?) - AND \`token_id\` = ?) - WHERE \`wallet_id\` = ? - AND \`token_id\` = ?`, - [walletId, token, walletId, token], - ); - } } } From 179605aa2aa93a6f07f8c816ab0246b2af124dc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Abadesso?= Date: Wed, 10 Sep 2025 10:31:07 -0300 Subject: [PATCH 12/49] refactor(daemon): removed outdated comments --- packages/wallet-service/tests/txProposalUtxoUnlock.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/wallet-service/tests/txProposalUtxoUnlock.test.ts b/packages/wallet-service/tests/txProposalUtxoUnlock.test.ts index 166cc930..dba0e8ed 100644 --- a/packages/wallet-service/tests/txProposalUtxoUnlock.test.ts +++ b/packages/wallet-service/tests/txProposalUtxoUnlock.test.ts @@ -175,11 +175,9 @@ describe('TxProposal UTXO unlocking on send failure', () => { const txProposal = await getTxProposal(mysql, txProposalId); expect(txProposal!.status).toBe(TxProposalStatus.SEND_ERROR); - // BUG: UTXO should be released when send fails, but currently it remains locked utxoResults = await getUtxos(mysql, [{ txId: utxos[0].txId, index: utxos[0].index }]); expect(utxoResults).toHaveLength(1); - // THIS ASSERTION WILL FAIL because UTXOs are not released on send failure expect(utxoResults[0].txProposalId).toBeNull(); // Should be null (released) expect(utxoResults[0].txProposalIndex).toBeNull(); // Should be null (released) From f7f41a68d5bb42c72b55fde3d5e8eb38b2e90388 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Abadesso?= Date: Thu, 11 Sep 2025 15:29:28 -0300 Subject: [PATCH 13/49] tests(daemon): changed test structure to test wallet and address balances, including authorities --- .../__tests__/integration/balances.test.ts | 91 +++++++++- .../daemon/__tests__/integration/config.ts | 4 + .../custom_script.balances.ts | 33 ++-- .../scenario_configs/empty_script.balances.ts | 33 ++-- .../invalid_mempool_transaction.balances.ts | 31 ++-- .../scenario_configs/nc_events.balances.ts | 39 ++-- .../scenario_configs/reorg.balances.ts | 13 +- ..._chain_blocks_and_transactions.balances.ts | 33 ++-- .../transaction_voiding_chain.balances.ts | 49 ++++- .../unvoided_transactions.balances.ts | 33 ++-- .../integration/scripts/docker-compose.yml | 10 + .../__tests__/integration/utils/index.ts | 171 ++++++++++++++++-- .../__tests__/services/services.test.ts | 1 - 13 files changed, 427 insertions(+), 114 deletions(-) diff --git a/packages/daemon/__tests__/integration/balances.test.ts b/packages/daemon/__tests__/integration/balances.test.ts index 63be6186..03ed78aa 100644 --- a/packages/daemon/__tests__/integration/balances.test.ts +++ b/packages/daemon/__tests__/integration/balances.test.ts @@ -13,10 +13,13 @@ import { Connection } from 'mysql2/promise'; import { cleanDatabase, fetchAddressBalances, + fetchWalletBalances, transitionUntilEvent, validateBalances, + validateWalletBalances, performVoidingConsistencyChecks, validateVoidingConsistency, + initializeWallet, } from './utils'; import unvoidedScenarioBalances from './scenario_configs/unvoided_transactions.balances'; import reorgScenarioBalances from './scenario_configs/reorg.balances'; @@ -26,6 +29,7 @@ import emptyScriptBalances from './scenario_configs/empty_script.balances'; import customScriptBalances from './scenario_configs/custom_script.balances'; import ncEventsBalances from './scenario_configs/nc_events.balances'; import transactionVoidingChainBalances from './scenario_configs/transaction_voiding_chain.balances'; +import voidedTokenAuthorityBalances from './scenario_configs/voided_token_authority.balances'; import { DB_NAME, @@ -49,6 +53,8 @@ import { NC_EVENTS_LAST_EVENT, TRANSACTION_VOIDING_CHAIN_PORT, TRANSACTION_VOIDING_CHAIN_LAST_EVENT, + VOIDED_TOKEN_AUTHORITY_PORT, + VOIDED_TOKEN_AUTHORITY_LAST_EVENT, } from './config'; jest.mock('../../src/config', () => { @@ -58,6 +64,13 @@ jest.mock('../../src/config', () => { }; }); +jest.mock('../../src/utils/aws', () => { + return { + sendRealtimeTx: jest.fn(), + invokeOnTxPushNotificationRequestedLambda: jest.fn(), + }; +}); + import getConfig from '../../src/config'; // @ts-expect-error @@ -124,7 +137,7 @@ describe('unvoided transaction scenario', () => { // @ts-expect-error await transitionUntilEvent(mysql, machine, UNVOIDED_SCENARIO_LAST_EVENT); const addressBalances = await fetchAddressBalances(mysql); - await expect(validateBalances(addressBalances, unvoidedScenarioBalances)).resolves.not.toThrow(); + await expect(validateBalances(addressBalances, unvoidedScenarioBalances.addressBalances)).resolves.not.toThrow(); }); }); @@ -159,7 +172,7 @@ describe('reorg scenario', () => { // @ts-expect-error await transitionUntilEvent(mysql, machine, REORG_SCENARIO_LAST_EVENT); const addressBalances = await fetchAddressBalances(mysql); - await expect(validateBalances(addressBalances, reorgScenarioBalances)).resolves.not.toThrow(); + await expect(validateBalances(addressBalances, reorgScenarioBalances.addressBalances)).resolves.not.toThrow(); }); }); @@ -194,7 +207,7 @@ describe('single chain blocks and transactions scenario', () => { // @ts-expect-error await transitionUntilEvent(mysql, machine, SINGLE_CHAIN_BLOCKS_AND_TRANSACTIONS_LAST_EVENT); const addressBalances = await fetchAddressBalances(mysql); - await expect(validateBalances(addressBalances, singleChainBlocksAndTransactionsBalances)).resolves.not.toThrow(); + await expect(validateBalances(addressBalances, singleChainBlocksAndTransactionsBalances.addressBalances)).resolves.not.toThrow(); }); }); @@ -229,7 +242,7 @@ describe('invalid mempool transactions scenario', () => { // @ts-expect-error await transitionUntilEvent(mysql, machine, INVALID_MEMPOOL_TRANSACTION_LAST_EVENT); const addressBalances = await fetchAddressBalances(mysql); - await expect(validateBalances(addressBalances, invalidMempoolBalances)).resolves.not.toThrow(); + await expect(validateBalances(addressBalances, invalidMempoolBalances.addressBalances)).resolves.not.toThrow(); }); }); @@ -264,7 +277,7 @@ describe('custom script scenario', () => { // @ts-expect-error await transitionUntilEvent(mysql, machine, CUSTOM_SCRIPT_LAST_EVENT); const addressBalances = await fetchAddressBalances(mysql); - await expect(validateBalances(addressBalances, customScriptBalances)).resolves.not.toThrow(); + await expect(validateBalances(addressBalances, customScriptBalances.addressBalances)).resolves.not.toThrow(); }); }); @@ -300,7 +313,7 @@ describe('empty script scenario', () => { await transitionUntilEvent(mysql, machine, EMPTY_SCRIPT_LAST_EVENT); const addressBalances = await fetchAddressBalances(mysql); - await expect(validateBalances(addressBalances, emptyScriptBalances)).resolves.not.toThrow(); + await expect(validateBalances(addressBalances, emptyScriptBalances.addressBalances)).resolves.not.toThrow(); }); }); @@ -336,7 +349,7 @@ describe('nc events scenario', () => { await transitionUntilEvent(mysql, machine, NC_EVENTS_LAST_EVENT); const addressBalances = await fetchAddressBalances(mysql); - await expect(validateBalances(addressBalances, ncEventsBalances)).resolves.not.toThrow(); + await expect(validateBalances(addressBalances, ncEventsBalances.addressBalances)).resolves.not.toThrow(); }); }); @@ -372,7 +385,11 @@ describe('transaction voiding chain scenario', () => { await transitionUntilEvent(mysql, machine, TRANSACTION_VOIDING_CHAIN_LAST_EVENT); const addressBalances = await fetchAddressBalances(mysql); - await expect(validateBalances(addressBalances, transactionVoidingChainBalances)).resolves.not.toThrow(); + await expect(validateBalances(addressBalances, transactionVoidingChainBalances.addressBalances)).resolves.not.toThrow(); + + // Validate wallet balances + const walletBalances = await fetchWalletBalances(mysql); + await expect(validateWalletBalances(walletBalances, transactionVoidingChainBalances.walletBalances)).resolves.not.toThrow(); // Validate transaction voiding consistency const voidingChecks = await performVoidingConsistencyChecks(mysql, { @@ -397,3 +414,61 @@ describe('transaction voiding chain scenario', () => { validateVoidingConsistency(voidingChecks); }, 30000); // 30 second timeout for transaction voiding chain test }); + +describe('voided token authority scenario', () => { + beforeAll(async () => { + jest.spyOn(Services, 'fetchMinRewardBlocks').mockImplementation(async () => 300); + await cleanDatabase(mysql); + }); + + it.only('should do a full sync and the balances should match after voiding token authority', async () => { + // @ts-ignore + getConfig.mockReturnValue({ + NETWORK: 'testnet', + SERVICE_NAME: 'daemon-test', + CONSOLE_LEVEL: 'debug', + TX_CACHE_SIZE: 100, + BLOCK_REWARD_LOCK: 300, + FULLNODE_PEER_ID: 'simulator_peer_id', + STREAM_ID: 'simulator_stream_id', + FULLNODE_NETWORK: 'unittests', + FULLNODE_HOST: `127.0.0.1:${VOIDED_TOKEN_AUTHORITY_PORT}`, + USE_SSL: false, + DB_ENDPOINT, + DB_NAME, + DB_USER, + DB_PASS, + DB_PORT, + }); + + // Initialize wallet before processing events + await initializeWallet(mysql); + + const machine = interpret(SyncMachine); + + // @ts-ignore + await transitionUntilEvent(mysql, machine, VOIDED_TOKEN_AUTHORITY_LAST_EVENT); + const addressBalances = await fetchAddressBalances(mysql); + const walletBalances = await fetchWalletBalances(mysql); + + await expect(validateBalances(addressBalances, voidedTokenAuthorityBalances.addressBalances)).resolves.not.toThrow(); + + // Validate wallet balances + await expect(validateWalletBalances(walletBalances, voidedTokenAuthorityBalances.walletBalances)).resolves.not.toThrow(); + + // Validate transaction voiding consistency + const voidingChecks = await performVoidingConsistencyChecks(mysql, { + transactions: [ + { txId: 'efb08b3e79e0ddaa6bc288183f66fe49a07ba0b7b2595861000478cc56447539', expectedVoided: false }, + { txId: 'deabafb4b3e87a98ee60c5190c63de10809811818f663c040b29eaa0a92463af', expectedVoided: true }, + { txId: '4f5625892f602e191c22fd0aa533bea7764e93e3a03dc498d30cb23932eb462c', expectedVoided: false }, + ], + utxos: [ + // No specific UTXO checks needed for this scenario + ], + }); + + // Validate consistency + validateVoidingConsistency(voidingChecks); + }, 30000); // 30 second timeout for voided token authority test +}); diff --git a/packages/daemon/__tests__/integration/config.ts b/packages/daemon/__tests__/integration/config.ts index be742c61..5705276f 100644 --- a/packages/daemon/__tests__/integration/config.ts +++ b/packages/daemon/__tests__/integration/config.ts @@ -40,6 +40,9 @@ export const NC_EVENTS_LAST_EVENT = 36; export const TRANSACTION_VOIDING_CHAIN_PORT = 8089; export const TRANSACTION_VOIDING_CHAIN_LAST_EVENT = 52; +export const VOIDED_TOKEN_AUTHORITY_PORT = 8090; +export const VOIDED_TOKEN_AUTHORITY_LAST_EVENT = 66; + export const SCENARIOS = [ 'UNVOIDED_SCENARIO', 'REORG_SCENARIO', @@ -49,4 +52,5 @@ export const SCENARIOS = [ 'CUSTOM_SCRIPT', 'NC_EVENTS', 'TRANSACTION_VOIDING_CHAIN', + 'VOIDED_TOKEN_AUTHORITY', ]; diff --git a/packages/daemon/__tests__/integration/scenario_configs/custom_script.balances.ts b/packages/daemon/__tests__/integration/scenario_configs/custom_script.balances.ts index 8010f858..08deeeb2 100644 --- a/packages/daemon/__tests__/integration/scenario_configs/custom_script.balances.ts +++ b/packages/daemon/__tests__/integration/scenario_configs/custom_script.balances.ts @@ -1,16 +1,21 @@ export default { - 'HRXVDmLVdq8pgok1BCUKpiFWdAVAy4a5AJ': 100000000000n, - 'HJQbEERnD5Ak3f2dsi8zAmsZrCWTT8FZns': 6400n, - 'HRSYchTEsFFpZAkgSTMsohNGQ6eLPyhXvJ': 6400n, - 'HQfVqxyxQV4BHwnsMnRXpZGmwPYiNSVmMu': 6400n, - 'HPnkpR2vnBuCoZCEnRZNHMBtf8ygeSidbW': 6400n, - 'HPNvtPZaDF44i6CL91u4BvZPu6z2xPNt26': 6400n, - 'HQijr325t63VJFdc4vYkaTyd87oeBLpSed': 6400n, - 'H8fCNrYGkj4B6VzKgtRiHBgoSxM31d65JR': 6400n, - 'HAqrADnn7GyyT68fSX8zmtsRNFyabPzoRQ': 6400n, - 'HRTH6uGo7zn3LWrosBYn7eXkwAeHAHTRh8': 6400n, - 'HJPSMHCFv2dRb78wZPMsAzwLQHSkBpfuLn': 6400n, - 'HFtz2f59Lms4p3Jfgtsr73s97MbJHsRENh': 1000n, - 'H9hHteu9QdAS5p6X743Mpfue6G19rV9GeY': 5400n, - 'HSd6PqXesUmHHv6MoN24aUiMuw7Pdcxrwk': 6400n, + addressBalances: { + 'HRXVDmLVdq8pgok1BCUKpiFWdAVAy4a5AJ:00': { unlockedBalance: 100000000000n, lockedBalance: 0n }, + 'HJQbEERnD5Ak3f2dsi8zAmsZrCWTT8FZns:00': { unlockedBalance: 6400n, lockedBalance: 0n }, + 'HRSYchTEsFFpZAkgSTMsohNGQ6eLPyhXvJ:00': { unlockedBalance: 6400n, lockedBalance: 0n }, + 'HQfVqxyxQV4BHwnsMnRXpZGmwPYiNSVmMu:00': { unlockedBalance: 6400n, lockedBalance: 0n }, + 'HPnkpR2vnBuCoZCEnRZNHMBtf8ygeSidbW:00': { unlockedBalance: 6400n, lockedBalance: 0n }, + 'HPNvtPZaDF44i6CL91u4BvZPu6z2xPNt26:00': { unlockedBalance: 6400n, lockedBalance: 0n }, + 'HQijr325t63VJFdc4vYkaTyd87oeBLpSed:00': { unlockedBalance: 6400n, lockedBalance: 0n }, + 'H8fCNrYGkj4B6VzKgtRiHBgoSxM31d65JR:00': { unlockedBalance: 6400n, lockedBalance: 0n }, + 'HAqrADnn7GyyT68fSX8zmtsRNFyabPzoRQ:00': { unlockedBalance: 6400n, lockedBalance: 0n }, + 'HRTH6uGo7zn3LWrosBYn7eXkwAeHAHTRh8:00': { unlockedBalance: 6400n, lockedBalance: 0n }, + 'HJPSMHCFv2dRb78wZPMsAzwLQHSkBpfuLn:00': { unlockedBalance: 6400n, lockedBalance: 0n }, + 'HFtz2f59Lms4p3Jfgtsr73s97MbJHsRENh:00': { unlockedBalance: 1000n, lockedBalance: 0n }, + 'H9hHteu9QdAS5p6X743Mpfue6G19rV9GeY:00': { unlockedBalance: 5400n, lockedBalance: 0n }, + 'HSd6PqXesUmHHv6MoN24aUiMuw7Pdcxrwk:00': { unlockedBalance: 6400n, lockedBalance: 0n }, + }, + walletBalances: { + // Add wallet balances when needed + }, } diff --git a/packages/daemon/__tests__/integration/scenario_configs/empty_script.balances.ts b/packages/daemon/__tests__/integration/scenario_configs/empty_script.balances.ts index d686baa7..50bea13f 100644 --- a/packages/daemon/__tests__/integration/scenario_configs/empty_script.balances.ts +++ b/packages/daemon/__tests__/integration/scenario_configs/empty_script.balances.ts @@ -1,16 +1,21 @@ export default { - 'HRXVDmLVdq8pgok1BCUKpiFWdAVAy4a5AJ': 100000000000n, - 'HFtz2f59Lms4p3Jfgtsr73s97MbJHsRENh': 6400n, - 'HJQbEERnD5Ak3f2dsi8zAmsZrCWTT8FZns': 6400n, - 'HRSYchTEsFFpZAkgSTMsohNGQ6eLPyhXvJ': 6400n, - 'HQfVqxyxQV4BHwnsMnRXpZGmwPYiNSVmMu': 6400n, - 'HPnkpR2vnBuCoZCEnRZNHMBtf8ygeSidbW': 6400n, - 'HPNvtPZaDF44i6CL91u4BvZPu6z2xPNt26': 6400n, - 'HQijr325t63VJFdc4vYkaTyd87oeBLpSed': 6400n, - 'H8fCNrYGkj4B6VzKgtRiHBgoSxM31d65JR': 6400n, - 'HAqrADnn7GyyT68fSX8zmtsRNFyabPzoRQ': 6400n, - 'HRTH6uGo7zn3LWrosBYn7eXkwAeHAHTRh8': 6400n, - 'HRQe4CXj8AZXzSmuNztU8iQR74QTQMbnTs': 1000n, - 'HNqTfEASfdx7H4vMUGzfD2HyD3GeKuxjTJ': 5400n, - 'HRH8Wbmr1A3BrLswSBhvVE4hhsv4jUdyVA': 6400n, + addressBalances: { + 'HRXVDmLVdq8pgok1BCUKpiFWdAVAy4a5AJ:00': { unlockedBalance: 100000000000n, lockedBalance: 0n }, + 'HFtz2f59Lms4p3Jfgtsr73s97MbJHsRENh:00': { unlockedBalance: 6400n, lockedBalance: 0n }, + 'HJQbEERnD5Ak3f2dsi8zAmsZrCWTT8FZns:00': { unlockedBalance: 6400n, lockedBalance: 0n }, + 'HRSYchTEsFFpZAkgSTMsohNGQ6eLPyhXvJ:00': { unlockedBalance: 6400n, lockedBalance: 0n }, + 'HQfVqxyxQV4BHwnsMnRXpZGmwPYiNSVmMu:00': { unlockedBalance: 6400n, lockedBalance: 0n }, + 'HPnkpR2vnBuCoZCEnRZNHMBtf8ygeSidbW:00': { unlockedBalance: 6400n, lockedBalance: 0n }, + 'HPNvtPZaDF44i6CL91u4BvZPu6z2xPNt26:00': { unlockedBalance: 6400n, lockedBalance: 0n }, + 'HQijr325t63VJFdc4vYkaTyd87oeBLpSed:00': { unlockedBalance: 6400n, lockedBalance: 0n }, + 'H8fCNrYGkj4B6VzKgtRiHBgoSxM31d65JR:00': { unlockedBalance: 6400n, lockedBalance: 0n }, + 'HAqrADnn7GyyT68fSX8zmtsRNFyabPzoRQ:00': { unlockedBalance: 6400n, lockedBalance: 0n }, + 'HRTH6uGo7zn3LWrosBYn7eXkwAeHAHTRh8:00': { unlockedBalance: 6400n, lockedBalance: 0n }, + 'HRQe4CXj8AZXzSmuNztU8iQR74QTQMbnTs:00': { unlockedBalance: 1000n, lockedBalance: 0n }, + 'HNqTfEASfdx7H4vMUGzfD2HyD3GeKuxjTJ:00': { unlockedBalance: 5400n, lockedBalance: 0n }, + 'HRH8Wbmr1A3BrLswSBhvVE4hhsv4jUdyVA:00': { unlockedBalance: 6400n, lockedBalance: 0n }, + }, + walletBalances: { + // Add wallet balances when needed + }, }; diff --git a/packages/daemon/__tests__/integration/scenario_configs/invalid_mempool_transaction.balances.ts b/packages/daemon/__tests__/integration/scenario_configs/invalid_mempool_transaction.balances.ts index 2f81b0fb..5044baf0 100644 --- a/packages/daemon/__tests__/integration/scenario_configs/invalid_mempool_transaction.balances.ts +++ b/packages/daemon/__tests__/integration/scenario_configs/invalid_mempool_transaction.balances.ts @@ -1,15 +1,20 @@ export default { - 'HNqTfEASfdx7H4vMUGzfD2HyD3GeKuxjTJ': 0n, - 'HRXVDmLVdq8pgok1BCUKpiFWdAVAy4a5AJ': 100000000000n, - 'HFtz2f59Lms4p3Jfgtsr73s97MbJHsRENh': 6400n, - 'HJQbEERnD5Ak3f2dsi8zAmsZrCWTT8FZns': 6400n, - 'HRSYchTEsFFpZAkgSTMsohNGQ6eLPyhXvJ': 6400n, - 'HQfVqxyxQV4BHwnsMnRXpZGmwPYiNSVmMu': 6400n, - 'HPnkpR2vnBuCoZCEnRZNHMBtf8ygeSidbW': 6400n, - 'HPNvtPZaDF44i6CL91u4BvZPu6z2xPNt26': 6400n, - 'HQijr325t63VJFdc4vYkaTyd87oeBLpSed': 6400n, - 'H8fCNrYGkj4B6VzKgtRiHBgoSxM31d65JR': 6400n, - 'HRQe4CXj8AZXzSmuNztU8iQR74QTQMbnTs': 6400n, - 'HAqrADnn7GyyT68fSX8zmtsRNFyabPzoRQ': 0n, - 'HRTH6uGo7zn3LWrosBYn7eXkwAeHAHTRh8': 0n, + addressBalances: { + 'HNqTfEASfdx7H4vMUGzfD2HyD3GeKuxjTJ:00': { unlockedBalance: 0n, lockedBalance: 0n }, + 'HRXVDmLVdq8pgok1BCUKpiFWdAVAy4a5AJ:00': { unlockedBalance: 100000000000n, lockedBalance: 0n }, + 'HFtz2f59Lms4p3Jfgtsr73s97MbJHsRENh:00': { unlockedBalance: 6400n, lockedBalance: 0n }, + 'HJQbEERnD5Ak3f2dsi8zAmsZrCWTT8FZns:00': { unlockedBalance: 6400n, lockedBalance: 0n }, + 'HRSYchTEsFFpZAkgSTMsohNGQ6eLPyhXvJ:00': { unlockedBalance: 6400n, lockedBalance: 0n }, + 'HQfVqxyxQV4BHwnsMnRXpZGmwPYiNSVmMu:00': { unlockedBalance: 6400n, lockedBalance: 0n }, + 'HPnkpR2vnBuCoZCEnRZNHMBtf8ygeSidbW:00': { unlockedBalance: 6400n, lockedBalance: 0n }, + 'HPNvtPZaDF44i6CL91u4BvZPu6z2xPNt26:00': { unlockedBalance: 6400n, lockedBalance: 0n }, + 'HQijr325t63VJFdc4vYkaTyd87oeBLpSed:00': { unlockedBalance: 6400n, lockedBalance: 0n }, + 'H8fCNrYGkj4B6VzKgtRiHBgoSxM31d65JR:00': { unlockedBalance: 6400n, lockedBalance: 0n }, + 'HRQe4CXj8AZXzSmuNztU8iQR74QTQMbnTs:00': { unlockedBalance: 6400n, lockedBalance: 0n }, + 'HAqrADnn7GyyT68fSX8zmtsRNFyabPzoRQ:00': { unlockedBalance: 0n, lockedBalance: 0n }, + 'HRTH6uGo7zn3LWrosBYn7eXkwAeHAHTRh8:00': { unlockedBalance: 0n, lockedBalance: 0n }, + }, + walletBalances: { + // Add wallet balances when needed + }, } diff --git a/packages/daemon/__tests__/integration/scenario_configs/nc_events.balances.ts b/packages/daemon/__tests__/integration/scenario_configs/nc_events.balances.ts index 696dcf59..93c076b6 100644 --- a/packages/daemon/__tests__/integration/scenario_configs/nc_events.balances.ts +++ b/packages/daemon/__tests__/integration/scenario_configs/nc_events.balances.ts @@ -1,19 +1,24 @@ export default { - "H92QQ83Ldm8Sgj6kT8ebu2CmqtZrvhZb6k": 0n, - "HAoH1xLZXdDByVsBRUcz9t5GeGDoEfMF2H": 1n, - "HEV1qK3dZDuXZn4rUTHZvfsU3L78usLh6u": 6400n, - "HF6XLDMZVA5KjJejBxDNeg1j8isXpPUVpU": 6400n, - "HFnTiBtKmriJ4iFG9VBDZv6Te134b9DMmZ": 6400n, - "HHZsdpy6U6vy4DPaAwBT5jUWLvYbSefQ7Z": 0n, - "HKpb6ABejTCFWG5nFrAVEvVcSrRuFgFnB4": 99999999996n, - "HKZHo7yZK49P1EqT43awxqSwiEbH1SsQVZ": 1n, - "HM8RTLw6yfyi7ie7o89LNWhKhh8muocLXQ": 0n, - "HNt5kvtThfTkffh1Xj8fxZNWkzTbQTjfvi": 0n, - "HPYZgEMAy1yEwk8e6ESxEaszyLF58Keqy5": 0n, - "HRhC8zuNtN8BLgGyK8NqYM24HwjA9w45UQ": 1n, - "HRXVDmLVdq8pgok1BCUKpiFWdAVAy4a5AJ": 0n, - "HRZPAkRv8SAmSaQi2aSr6rfWQCUqoSydgr": 0n, - "HUxVygk7LBT5HeRxeneEoAWFNGCMg382ZD": 0n, - "HVcaqHL5e471jp7gRm7sBYmSMr3K1YQVp1": 0n, - "HVCGqDBHXkdhghtaS2v4XKqfixui3HeYs1": 1n, + addressBalances: { + "H92QQ83Ldm8Sgj6kT8ebu2CmqtZrvhZb6k:00": { unlockedBalance: 0n, lockedBalance: 0n }, + "HAoH1xLZXdDByVsBRUcz9t5GeGDoEfMF2H:00": { unlockedBalance: 1n, lockedBalance: 0n }, + "HEV1qK3dZDuXZn4rUTHZvfsU3L78usLh6u:00": { unlockedBalance: 6400n, lockedBalance: 0n }, + "HF6XLDMZVA5KjJejBxDNeg1j8isXpPUVpU:00": { unlockedBalance: 6400n, lockedBalance: 0n }, + "HFnTiBtKmriJ4iFG9VBDZv6Te134b9DMmZ:00": { unlockedBalance: 6400n, lockedBalance: 0n }, + "HHZsdpy6U6vy4DPaAwBT5jUWLvYbSefQ7Z:00": { unlockedBalance: 0n, lockedBalance: 0n }, + "HKpb6ABejTCFWG5nFrAVEvVcSrRuFgFnB4:00": { unlockedBalance: 99999999996n, lockedBalance: 0n }, + "HKZHo7yZK49P1EqT43awxqSwiEbH1SsQVZ:00": { unlockedBalance: 1n, lockedBalance: 0n }, + "HM8RTLw6yfyi7ie7o89LNWhKhh8muocLXQ:00": { unlockedBalance: 0n, lockedBalance: 0n }, + "HNt5kvtThfTkffh1Xj8fxZNWkzTbQTjfvi:00": { unlockedBalance: 0n, lockedBalance: 0n }, + "HPYZgEMAy1yEwk8e6ESxEaszyLF58Keqy5:00": { unlockedBalance: 0n, lockedBalance: 0n }, + "HRhC8zuNtN8BLgGyK8NqYM24HwjA9w45UQ:00": { unlockedBalance: 1n, lockedBalance: 0n }, + "HRXVDmLVdq8pgok1BCUKpiFWdAVAy4a5AJ:00": { unlockedBalance: 0n, lockedBalance: 0n }, + "HRZPAkRv8SAmSaQi2aSr6rfWQCUqoSydgr:00": { unlockedBalance: 0n, lockedBalance: 0n }, + "HUxVygk7LBT5HeRxeneEoAWFNGCMg382ZD:00": { unlockedBalance: 0n, lockedBalance: 0n }, + "HVcaqHL5e471jp7gRm7sBYmSMr3K1YQVp1:00": { unlockedBalance: 0n, lockedBalance: 0n }, + "HVCGqDBHXkdhghtaS2v4XKqfixui3HeYs1:00": { unlockedBalance: 1n, lockedBalance: 0n }, + }, + walletBalances: { + // Add wallet balances when needed + }, }; diff --git a/packages/daemon/__tests__/integration/scenario_configs/reorg.balances.ts b/packages/daemon/__tests__/integration/scenario_configs/reorg.balances.ts index e8a45c36..c3c3a6bf 100644 --- a/packages/daemon/__tests__/integration/scenario_configs/reorg.balances.ts +++ b/packages/daemon/__tests__/integration/scenario_configs/reorg.balances.ts @@ -1,6 +1,11 @@ export default { - 'HRXVDmLVdq8pgok1BCUKpiFWdAVAy4a5AJ': 100000000000n, - 'HFyF1jYJP9FXfiC3LRqf3q4768TBL1rxbn': 6400n, - 'HMbS5P3NTLQ5oR5TfLNvAkeQ7L8MPn9VM3': 6400n, - 'HRQe4CXj8AZXzSmuNztU8iQR74QTQMbnTs': 0n, + addressBalances: { + 'HRXVDmLVdq8pgok1BCUKpiFWdAVAy4a5AJ:00': { unlockedBalance: 100000000000n, lockedBalance: 0n }, + 'HFyF1jYJP9FXfiC3LRqf3q4768TBL1rxbn:00': { unlockedBalance: 6400n, lockedBalance: 0n }, + 'HMbS5P3NTLQ5oR5TfLNvAkeQ7L8MPn9VM3:00': { unlockedBalance: 6400n, lockedBalance: 0n }, + 'HRQe4CXj8AZXzSmuNztU8iQR74QTQMbnTs:00': { unlockedBalance: 0n, lockedBalance: 0n }, + }, + walletBalances: { + // Add wallet balances when needed + }, }; diff --git a/packages/daemon/__tests__/integration/scenario_configs/single_chain_blocks_and_transactions.balances.ts b/packages/daemon/__tests__/integration/scenario_configs/single_chain_blocks_and_transactions.balances.ts index 23b4306c..a03c2a86 100644 --- a/packages/daemon/__tests__/integration/scenario_configs/single_chain_blocks_and_transactions.balances.ts +++ b/packages/daemon/__tests__/integration/scenario_configs/single_chain_blocks_and_transactions.balances.ts @@ -1,16 +1,21 @@ export default { - 'HFtz2f59Lms4p3Jfgtsr73s97MbJHsRENh': 6400n, - 'HJQbEERnD5Ak3f2dsi8zAmsZrCWTT8FZns': 6400n, - 'HRSYchTEsFFpZAkgSTMsohNGQ6eLPyhXvJ': 6400n, - 'HQfVqxyxQV4BHwnsMnRXpZGmwPYiNSVmMu': 6400n, - 'HPnkpR2vnBuCoZCEnRZNHMBtf8ygeSidbW': 6400n, - 'HPNvtPZaDF44i6CL91u4BvZPu6z2xPNt26': 6400n, - 'HQijr325t63VJFdc4vYkaTyd87oeBLpSed': 6400n, - 'H8fCNrYGkj4B6VzKgtRiHBgoSxM31d65JR': 6400n, - 'HAqrADnn7GyyT68fSX8zmtsRNFyabPzoRQ': 6400n, - 'HRTH6uGo7zn3LWrosBYn7eXkwAeHAHTRh8': 6400n, - 'HRQe4CXj8AZXzSmuNztU8iQR74QTQMbnTs': 3000n, - 'HRH8Wbmr1A3BrLswSBhvVE4hhsv4jUdyVA': 3400n, - 'HSd6PqXesUmHHv6MoN24aUiMuw7Pdcxrwk': 6400n, - 'HNqTfEASfdx7H4vMUGzfD2HyD3GeKuxjTJ': 0n, + addressBalances: { + 'HFtz2f59Lms4p3Jfgtsr73s97MbJHsRENh:00': { unlockedBalance: 6400n, lockedBalance: 0n }, + 'HJQbEERnD5Ak3f2dsi8zAmsZrCWTT8FZns:00': { unlockedBalance: 6400n, lockedBalance: 0n }, + 'HRSYchTEsFFpZAkgSTMsohNGQ6eLPyhXvJ:00': { unlockedBalance: 6400n, lockedBalance: 0n }, + 'HQfVqxyxQV4BHwnsMnRXpZGmwPYiNSVmMu:00': { unlockedBalance: 6400n, lockedBalance: 0n }, + 'HPnkpR2vnBuCoZCEnRZNHMBtf8ygeSidbW:00': { unlockedBalance: 6400n, lockedBalance: 0n }, + 'HPNvtPZaDF44i6CL91u4BvZPu6z2xPNt26:00': { unlockedBalance: 6400n, lockedBalance: 0n }, + 'HQijr325t63VJFdc4vYkaTyd87oeBLpSed:00': { unlockedBalance: 6400n, lockedBalance: 0n }, + 'H8fCNrYGkj4B6VzKgtRiHBgoSxM31d65JR:00': { unlockedBalance: 6400n, lockedBalance: 0n }, + 'HAqrADnn7GyyT68fSX8zmtsRNFyabPzoRQ:00': { unlockedBalance: 6400n, lockedBalance: 0n }, + 'HRTH6uGo7zn3LWrosBYn7eXkwAeHAHTRh8:00': { unlockedBalance: 6400n, lockedBalance: 0n }, + 'HRQe4CXj8AZXzSmuNztU8iQR74QTQMbnTs:00': { unlockedBalance: 3000n, lockedBalance: 0n }, + 'HRH8Wbmr1A3BrLswSBhvVE4hhsv4jUdyVA:00': { unlockedBalance: 3400n, lockedBalance: 0n }, + 'HSd6PqXesUmHHv6MoN24aUiMuw7Pdcxrwk:00': { unlockedBalance: 6400n, lockedBalance: 0n }, + 'HNqTfEASfdx7H4vMUGzfD2HyD3GeKuxjTJ:00': { unlockedBalance: 0n, lockedBalance: 0n }, + }, + walletBalances: { + // Add wallet balances when needed + }, } diff --git a/packages/daemon/__tests__/integration/scenario_configs/transaction_voiding_chain.balances.ts b/packages/daemon/__tests__/integration/scenario_configs/transaction_voiding_chain.balances.ts index 0a7e55cf..b37df7cc 100644 --- a/packages/daemon/__tests__/integration/scenario_configs/transaction_voiding_chain.balances.ts +++ b/packages/daemon/__tests__/integration/scenario_configs/transaction_voiding_chain.balances.ts @@ -6,7 +6,50 @@ */ export default { - 'HRQe4CXj8AZXzSmuNztU8iQR74QTQMbnTs': 73900n, - 'HQfVqxyxQV4BHwnsMnRXpZGmwPYiNSVmMu': 3400n, - 'HPNvtPZaDF44i6CL91u4BvZPu6z2xPNt26': 5900n, + addressBalances: { + // HFtz2f59Lms4p3Jfgtsr73s97MbJHsRENh - token efb08b...: unlocked=0, locked=0, authorities: 0 unlocked + 0 locked + 'HFtz2f59Lms4p3Jfgtsr73s97MbJHsRENh:efb08b3e79e0ddaa6bc288183f66fe49a07ba0b7b2595861000478cc56447539': { + unlockedBalance: 0n, + lockedBalance: 0n, + authorities: { locked: 0, unlocked: 0 } + }, + // HJQbEERnD5Ak3f2dsi8zAmsZrCWTT8FZns - token efb08b...: unlocked=0, locked=0, authorities: 1 unlocked + 0 locked + 'HJQbEERnD5Ak3f2dsi8zAmsZrCWTT8FZns:efb08b3e79e0ddaa6bc288183f66fe49a07ba0b7b2595861000478cc56447539': { + unlockedBalance: 0n, + lockedBalance: 0n, + authorities: { locked: 0, unlocked: 1 } + }, + // HRQe4CXj8AZXzSmuNztU8iQR74QTQMbnTs - HTR (00): unlocked=6390, locked=115200 + 'HRQe4CXj8AZXzSmuNztU8iQR74QTQMbnTs:00': { + unlockedBalance: 6390n, + lockedBalance: 115200n + }, + // HRQe4CXj8AZXzSmuNztU8iQR74QTQMbnTs - token efb08b...: unlocked=1000, locked=0, authorities: 2 unlocked + 0 locked + 'HRQe4CXj8AZXzSmuNztU8iQR74QTQMbnTs:efb08b3e79e0ddaa6bc288183f66fe49a07ba0b7b2595861000478cc56447539': { + unlockedBalance: 1000n, + lockedBalance: 0n, + authorities: { locked: 0, unlocked: 2 } + }, + // HRXVDmLVdq8pgok1BCUKpiFWdAVAy4a5AJ - HTR (00): unlocked=0, locked=100000000000 + 'HRXVDmLVdq8pgok1BCUKpiFWdAVAy4a5AJ:00': { + unlockedBalance: 0n, + lockedBalance: 100000000000n + }, + }, + walletBalances: { + // deafbeef wallet HTR balance: unlocked=6390, locked=100000115200 + 'deafbeef:00': { unlockedBalance: 6390n, lockedBalance: 100000115200n }, + // deafbeef wallet token efb08b... balance: unlocked=1000, locked=0, authorities: 2 unlocked + 0 locked + 'deafbeef:efb08b3e79e0ddaa6bc288183f66fe49a07ba0b7b2595861000478cc56447539': { + unlockedBalance: 1000n, + lockedBalance: 0n, + authorities: { locked: 0, unlocked: 2 } + }, + // cafecafe wallet token efb08b... balance: unlocked=0, locked=0, authorities: 1 unlocked + 0 locked + 'cafecafe:efb08b3e79e0ddaa6bc288183f66fe49a07ba0b7b2595861000478cc56447539': { + unlockedBalance: 0n, + lockedBalance: 0n, + authorities: { locked: 0, unlocked: 1 } + }, + }, }; diff --git a/packages/daemon/__tests__/integration/scenario_configs/unvoided_transactions.balances.ts b/packages/daemon/__tests__/integration/scenario_configs/unvoided_transactions.balances.ts index bad3a376..c673db23 100644 --- a/packages/daemon/__tests__/integration/scenario_configs/unvoided_transactions.balances.ts +++ b/packages/daemon/__tests__/integration/scenario_configs/unvoided_transactions.balances.ts @@ -1,16 +1,21 @@ export default { - 'HFtz2f59Lms4p3Jfgtsr73s97MbJHsRENh': 6400n, - 'HJQbEERnD5Ak3f2dsi8zAmsZrCWTT8FZns': 6400n, - 'HRSYchTEsFFpZAkgSTMsohNGQ6eLPyhXvJ': 6400n, - 'HQfVqxyxQV4BHwnsMnRXpZGmwPYiNSVmMu': 6400n, - 'HPnkpR2vnBuCoZCEnRZNHMBtf8ygeSidbW': 6400n, - 'HPNvtPZaDF44i6CL91u4BvZPu6z2xPNt26': 6400n, - 'HQijr325t63VJFdc4vYkaTyd87oeBLpSed': 6400n, - 'H8fCNrYGkj4B6VzKgtRiHBgoSxM31d65JR': 6400n, - 'HAqrADnn7GyyT68fSX8zmtsRNFyabPzoRQ': 6400n, - 'HRTH6uGo7zn3LWrosBYn7eXkwAeHAHTRh8': 6400n, - 'H9hHteu9QdAS5p6X743Mpfue6G19rV9GeY': 6400n, - 'HNqTfEASfdx7H4vMUGzfD2HyD3GeKuxjTJ': 5400n, - 'HRQe4CXj8AZXzSmuNztU8iQR74QTQMbnTs': 1000n, - 'HRXVDmLVdq8pgok1BCUKpiFWdAVAy4a5AJ': 100000000000n, + addressBalances: { + 'HFtz2f59Lms4p3Jfgtsr73s97MbJHsRENh:00': { unlockedBalance: 6400n, lockedBalance: 0n }, + 'HJQbEERnD5Ak3f2dsi8zAmsZrCWTT8FZns:00': { unlockedBalance: 6400n, lockedBalance: 0n }, + 'HRSYchTEsFFpZAkgSTMsohNGQ6eLPyhXvJ:00': { unlockedBalance: 6400n, lockedBalance: 0n }, + 'HQfVqxyxQV4BHwnsMnRXpZGmwPYiNSVmMu:00': { unlockedBalance: 6400n, lockedBalance: 0n }, + 'HPnkpR2vnBuCoZCEnRZNHMBtf8ygeSidbW:00': { unlockedBalance: 6400n, lockedBalance: 0n }, + 'HPNvtPZaDF44i6CL91u4BvZPu6z2xPNt26:00': { unlockedBalance: 6400n, lockedBalance: 0n }, + 'HQijr325t63VJFdc4vYkaTyd87oeBLpSed:00': { unlockedBalance: 6400n, lockedBalance: 0n }, + 'H8fCNrYGkj4B6VzKgtRiHBgoSxM31d65JR:00': { unlockedBalance: 6400n, lockedBalance: 0n }, + 'HAqrADnn7GyyT68fSX8zmtsRNFyabPzoRQ:00': { unlockedBalance: 6400n, lockedBalance: 0n }, + 'HRTH6uGo7zn3LWrosBYn7eXkwAeHAHTRh8:00': { unlockedBalance: 6400n, lockedBalance: 0n }, + 'H9hHteu9QdAS5p6X743Mpfue6G19rV9GeY:00': { unlockedBalance: 6400n, lockedBalance: 0n }, + 'HNqTfEASfdx7H4vMUGzfD2HyD3GeKuxjTJ:00': { unlockedBalance: 5400n, lockedBalance: 0n }, + 'HRQe4CXj8AZXzSmuNztU8iQR74QTQMbnTs:00': { unlockedBalance: 1000n, lockedBalance: 0n }, + 'HRXVDmLVdq8pgok1BCUKpiFWdAVAy4a5AJ:00': { unlockedBalance: 100000000000n, lockedBalance: 0n }, + }, + walletBalances: { + // Add wallet balances when needed + }, }; diff --git a/packages/daemon/__tests__/integration/scripts/docker-compose.yml b/packages/daemon/__tests__/integration/scripts/docker-compose.yml index 2bdc7c99..6770ad9c 100644 --- a/packages/daemon/__tests__/integration/scripts/docker-compose.yml +++ b/packages/daemon/__tests__/integration/scripts/docker-compose.yml @@ -85,5 +85,15 @@ services: ports: - "8089:8080" + voided_token_authority: + image: hathornetwork/hathor-core + command: [ + "events_simulator", + "--scenario", "VOIDED_TOKEN_AUTHORITY", + "--seed", "1" + ] + ports: + - "8090:8080" + networks: database: diff --git a/packages/daemon/__tests__/integration/utils/index.ts b/packages/daemon/__tests__/integration/utils/index.ts index 4b9d8e3e..b8becc6e 100644 --- a/packages/daemon/__tests__/integration/utils/index.ts +++ b/packages/daemon/__tests__/integration/utils/index.ts @@ -7,7 +7,19 @@ import { Connection } from 'mysql2/promise'; import { Interpreter } from 'xstate'; import { getLastSyncedEvent } from '../../../src/db'; -import { AddressBalance, AddressBalanceRow, Context, Event } from '../../../src/types'; +import { AddressBalance, AddressBalanceRow, Context, Event, WalletBalanceRow } from '../../../src/types'; + +export interface WalletBalance { + walletId: string; + tokenId: string; + unlockedBalance: bigint; + lockedBalance: bigint; + unlockedAuthorities: number; + lockedAuthorities: number; + timelockExpires: number | null; + transactions: number; + totalReceived: bigint; +} export const cleanDatabase = async (mysql: Connection): Promise => { const TABLES = [ @@ -56,30 +68,116 @@ export const fetchAddressBalances = async ( })); }; +export const fetchWalletBalances = async ( + mysql: Connection +): Promise => { + const [results] = await mysql.query( + `SELECT * + FROM \`wallet_balance\` + ORDER BY \`wallet_id\`, \`token_id\``, + ); + + return results.map((result): WalletBalance => ({ + walletId: result.wallet_id as string, + tokenId: result.token_id as string, + unlockedBalance: BigInt(result.unlocked_balance), + lockedBalance: BigInt(result.locked_balance), + unlockedAuthorities: result.unlocked_authorities as number, + lockedAuthorities: result.locked_authorities as number, + timelockExpires: result.timelock_expires as number | null, + transactions: result.transactions as number, + totalReceived: BigInt(result.total_received), + })); +}; + export const validateBalances = async ( balancesA: AddressBalance[], - balancesB: Record, + expectedBalances: Record, ): Promise => { - const expectedAddresses = new Set(Object.keys(balancesB)); + const expectedAddressTokenKeys = new Set(Object.keys(expectedBalances)); - // Check for unexpected addresses with non-zero balances + // Check for unexpected addresses with non-zero balances or authorities for (const balance of balancesA) { + const addressTokenKey = `${balance.address}:${balance.tokenId}`; const totalBalance = balance.lockedBalance + balance.unlockedBalance; + const totalAuthorities = balance.lockedAuthorities + balance.unlockedAuthorities; - if (!expectedAddresses.has(balance.address) && totalBalance !== BigInt(0)) { - throw new Error(`Unexpected address with non-zero balance: ${balance.address}, balance: ${totalBalance}`); + if (!expectedAddressTokenKeys.has(addressTokenKey) && (totalBalance !== BigInt(0) || totalAuthorities !== 0)) { + throw new Error(`Unexpected address:token with non-zero balance or authorities: ${addressTokenKey}, balance: ${totalBalance}, authorities: ${totalAuthorities}`); } } // Validate all expected addresses - for (const address of expectedAddresses) { - const balanceA = balancesA.find(b => b.address === address); - const expectedBalance = balancesB[address]; + for (const addressTokenKey of expectedAddressTokenKeys) { + const [address, tokenId] = addressTokenKey.split(':'); + const balanceA = balancesA.find(b => b.address === address && b.tokenId === tokenId); + const expected = expectedBalances[addressTokenKey]; + + const actualUnlockedBalance = balanceA ? balanceA.unlockedBalance : BigInt(0); + const actualLockedBalance = balanceA ? balanceA.lockedBalance : BigInt(0); + + if (actualUnlockedBalance !== expected.unlockedBalance) { + throw new Error(`Unlocked balance mismatch for address:token ${addressTokenKey}, expected: ${expected.unlockedBalance}, received: ${actualUnlockedBalance}`); + } + + if (actualLockedBalance !== expected.lockedBalance) { + throw new Error(`Locked balance mismatch for address:token ${addressTokenKey}, expected: ${expected.lockedBalance}, received: ${actualLockedBalance}`); + } + + // Validate authorities if specified + if (expected.authorities && balanceA) { + if (balanceA.lockedAuthorities !== expected.authorities.locked) { + throw new Error(`Locked authorities mismatch for address:token ${addressTokenKey}, expected: ${expected.authorities.locked}, received: ${balanceA.lockedAuthorities}`); + } + if (balanceA.unlockedAuthorities !== expected.authorities.unlocked) { + throw new Error(`Unlocked authorities mismatch for address:token ${addressTokenKey}, expected: ${expected.authorities.unlocked}, received: ${balanceA.unlockedAuthorities}`); + } + } + } +}; - const totalBalanceA = balanceA ? (balanceA.lockedBalance + balanceA.unlockedBalance) : BigInt(0); +export const validateWalletBalances = async ( + walletBalances: WalletBalance[], + expectedWalletBalances: Record, +): Promise => { + for (const [walletTokenKey, expected] of Object.entries(expectedWalletBalances)) { + const [walletId, tokenId] = walletTokenKey.split(':'); + + const walletBalance = walletBalances.find( + b => b.walletId === walletId && b.tokenId === tokenId + ); - if (totalBalanceA !== expectedBalance) { - throw new Error(`Balances are not equal for address: ${address}, expected: ${expectedBalance}, received: ${totalBalanceA}`); + const actualUnlockedBalance = walletBalance ? walletBalance.unlockedBalance : BigInt(0); + const actualLockedBalance = walletBalance ? walletBalance.lockedBalance : BigInt(0); + + if (actualUnlockedBalance !== expected.unlockedBalance) { + throw new Error( + `Wallet unlocked balance mismatch for wallet ${walletId} token ${tokenId}: expected ${expected.unlockedBalance}, received ${actualUnlockedBalance}` + ); + } + + if (actualLockedBalance !== expected.lockedBalance) { + throw new Error( + `Wallet locked balance mismatch for wallet ${walletId} token ${tokenId}: expected ${expected.lockedBalance}, received ${actualLockedBalance}` + ); + } + + // Validate authorities if specified + if (expected.authorities && walletBalance) { + if (walletBalance.lockedAuthorities !== expected.authorities.locked) { + throw new Error(`Wallet locked authorities mismatch for wallet ${walletId} token ${tokenId}: expected ${expected.authorities.locked}, received ${walletBalance.lockedAuthorities}`); + } + if (walletBalance.unlockedAuthorities !== expected.authorities.unlocked) { + throw new Error(`Wallet unlocked authorities mismatch for wallet ${walletId} token ${tokenId}: expected ${expected.authorities.unlocked}, received ${walletBalance.unlockedAuthorities}`); + } } } }; @@ -101,4 +199,53 @@ export async function transitionUntilEvent(mysql: Connection, machine: Interpret }); } +export const initializeWallet = async (mysql: Connection): Promise => { + // Insert wallet records + const walletSQL = ` + INSERT INTO wallet ( + id, + xpubkey, + status, + max_gap, + created_at, + ready_at, + retry_count, + auth_xpubkey, + last_used_address_index + ) VALUES + ( + 'deafbeef', + 'xpub6F81iNtH5HVknoJ65cK2XAGA5F3okdJK7WHwVAAPZnSir2sfwbhvB9ffNKQ4wLor75QxPe9p12tqt8xUZSG8i8AAPMpkFho7fbWkBJQ5s1x', + 'ready', + 20, + UNIX_TIMESTAMP(), + UNIX_TIMESTAMP(), + 0, + 'xpub6F81iNtH5HVknoJ65cK2XAGA5F3okdJK7WHwVAAPZnSir2sfwbhvB9ffNKQ4wLor75QxPe9p12tqt8xUZSG8i8AAPMpkFho7fbWkBJQ5s1x', + -1 + ), + ( + 'cafecafe', + 'xpub6F81iNtH5HVknoJ65cK2XAGA5F3okdJK7WHwVAAPZnSir2sfwbhvB9ffNKQ4wLor75QxPe9p12tqt8xUZSG8i8AAPMpkFho7fbWkBJQ5s1x', + 'ready', + 20, + UNIX_TIMESTAMP(), + UNIX_TIMESTAMP(), + 0, + 'xpub6F81iNtH5HVknoJ65cK2XAGA5F3okdJK7WHwVAAPZnSir2sfwbhvB9ffNKQ4wLor75QxPe9p12tqt8xUZSG8i8AAPMpkFho7fbWkBJQ5s1x', + -1 + )`; + + // Insert address records - all addresses with the same wallet_id + const addressSQL = ` + INSERT INTO address (address, \`index\`, wallet_id, transactions, seqnum) VALUES + ('HFtz2f59Lms4p3Jfgtsr73s97MbJHsRENh', 0, 'deafbeef', 0, 0), + ('HJQbEERnD5Ak3f2dsi8zAmsZrCWTT8FZns', 0, 'cafecafe', 1, 0), + ('HRQe4CXj8AZXzSmuNztU8iQR74QTQMbnTs', 1, 'deafbeef', 21, 0), + ('HRXVDmLVdq8pgok1BCUKpiFWdAVAy4a5AJ', 2, 'deafbeef', 1, 0)`; + + await mysql.query(walletSQL); + await mysql.query(addressSQL); +}; + export * from './voiding-consistency-checks'; diff --git a/packages/daemon/__tests__/services/services.test.ts b/packages/daemon/__tests__/services/services.test.ts index f18bcb4e..d983cf1c 100644 --- a/packages/daemon/__tests__/services/services.test.ts +++ b/packages/daemon/__tests__/services/services.test.ts @@ -14,7 +14,6 @@ import { getLastSyncedEvent, updateLastSyncedEvent as dbUpdateLastSyncedEvent, getTxOutputsFromTx, - getTxOutput, voidTransaction, getTransactionById, getUtxosLockedAtHeight, From 9d5ef7901690f417f6e63060c122f6b3fd23d261 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Abadesso?= Date: Thu, 11 Sep 2025 15:30:10 -0300 Subject: [PATCH 14/49] fix(daemon): bug when voiding transactions that ADD authorities --- packages/daemon/src/db/index.ts | 4 ++-- packages/daemon/src/services/index.ts | 6 ++---- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/daemon/src/db/index.ts b/packages/daemon/src/db/index.ts index 3cd5c6be..11e2a51c 100644 --- a/packages/daemon/src/db/index.ts +++ b/packages/daemon/src/db/index.ts @@ -456,7 +456,7 @@ export const voidTransaction = async ( // we cannot only sum/subtract, as authorities are binary: you have it or you don't. We might be spending // an authority output in this tx without creating a new one, but it doesn't mean this address does not // have this authority anymore, as it might have other authority outputs - if (tokenBalance.unlockedAuthorities.hasNegativeValue()) { + if (!tokenBalance.unlockedAuthorities.hasNegativeValue()) { await mysql.query( `UPDATE \`address_balance\` SET \`unlocked_authorities\` = ( @@ -538,7 +538,7 @@ export const voidWalletTransaction = async ( // NOTE: No need to do the same for locked authorities as they can't be // spent before being unlocked and we trust the fullnode - if (tokenBalance.unlockedAuthorities.hasNegativeValue()) { + if (!tokenBalance.unlockedAuthorities.hasNegativeValue()) { await mysql.query( `UPDATE \`wallet_balance\` SET \`unlocked_authorities\` = ( diff --git a/packages/daemon/src/services/index.ts b/packages/daemon/src/services/index.ts index c46bba64..1bcfe20a 100644 --- a/packages/daemon/src/services/index.ts +++ b/packages/daemon/src/services/index.ts @@ -541,9 +541,9 @@ export const voidTx = async ( }); const addressBalanceMap: StringMap = getAddressBalanceMap(txInputs, txOutputsWithLocked, headers); - await voidTransaction(mysql, hash, addressBalanceMap); - await markUtxosAsVoided(mysql, dbTxOutputs); + await markUtxosAsVoided(mysql, dbTxOutputs); + await voidTransaction(mysql, hash, addressBalanceMap); // CRITICAL: Unspend the inputs when voiding a transaction // The inputs of the voided transaction need to be marked as unspent @@ -620,8 +620,6 @@ export const handleVoidedTx = async (context: Context) => { tokens, headers, ); - logger.debug(`Voided tx ${hash}`); - await mysql.commit(); await dbUpdateLastSyncedEvent(mysql, fullNodeEvent.event.id); } catch (e) { From 32f1bafc30b433b9050b4567173889bb6056c821 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Abadesso?= Date: Thu, 11 Sep 2025 15:31:33 -0300 Subject: [PATCH 15/49] tests(daemon): added the voided_token authority simulator balances --- .../voided_token_authority.balances.ts | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 packages/daemon/__tests__/integration/scenario_configs/voided_token_authority.balances.ts diff --git a/packages/daemon/__tests__/integration/scenario_configs/voided_token_authority.balances.ts b/packages/daemon/__tests__/integration/scenario_configs/voided_token_authority.balances.ts new file mode 100644 index 00000000..7728088d --- /dev/null +++ b/packages/daemon/__tests__/integration/scenario_configs/voided_token_authority.balances.ts @@ -0,0 +1,42 @@ +/** + * Copyright (c) Hathor Labs and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +export default { + addressBalances: { + 'HRQe4CXj8AZXzSmuNztU8iQR74QTQMbnTs:00': { unlockedBalance: 6390n, lockedBalance: 115200n }, + 'HRQe4CXj8AZXzSmuNztU8iQR74QTQMbnTs:efb08b3e79e0ddaa6bc288183f66fe49a07ba0b7b2595861000478cc56447539': { + unlockedBalance: 1000n, + lockedBalance: 0n, + authorities: { unlocked: 2, locked: 0 } + }, + 'HRXVDmLVdq8pgok1BCUKpiFWdAVAy4a5AJ:00': { unlockedBalance: 0n, lockedBalance: 100000000000n }, + 'HJQbEERnD5Ak3f2dsi8zAmsZrCWTT8FZns:efb08b3e79e0ddaa6bc288183f66fe49a07ba0b7b2595861000478cc56447539': { + unlockedBalance: 0n, + lockedBalance: 0n, + authorities: { unlocked: 1, locked: 0 } + }, + 'HFtz2f59Lms4p3Jfgtsr73s97MbJHsRENh:efb08b3e79e0ddaa6bc288183f66fe49a07ba0b7b2595861000478cc56447539': { + unlockedBalance: 0n, + lockedBalance: 0n, + authorities: { unlocked: 0, locked: 0 } + } + }, + walletBalances: { + // HTH balance: 6390 unlocked + 115200 locked (HRQe4CXj8AZXzSmuNztU8iQR74QTQMbnTs) + 0 unlocked + 100000000000 locked (HRXVDmLVdq8pgok1BCUKpiFWdAVAy4a5AJ) + 'deafbeef:00': { unlockedBalance: 6390n, lockedBalance: 100000115200n }, + 'deafbeef:efb08b3e79e0ddaa6bc288183f66fe49a07ba0b7b2595861000478cc56447539': { + unlockedBalance: 1000n, + lockedBalance: 0n, + authorities: { unlocked: 2, locked: 0 } + }, + 'deadbeef:efb08b3e79e0ddaa6bc288183f66fe49a07ba0b7b2595861000478cc56447539': { + unlockedBalance: 0n, + lockedBalance: 0n, + authorities: { unlocked: 0, locked: 0 } + } + }, +}; From b255a297547b48a2ae0b2dbd7167103d5943dc19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Abadesso?= Date: Thu, 11 Sep 2025 18:32:37 -0300 Subject: [PATCH 16/49] tests(daemon): updated tests with correct locked balances --- .../__tests__/integration/balances.test.ts | 26 +++++----- .../custom_script.balances.ts | 28 +++++----- .../scenario_configs/empty_script.balances.ts | 28 +++++----- .../invalid_mempool_transaction.balances.ts | 22 ++++---- .../scenario_configs/nc_events.balances.ts | 10 ++-- .../scenario_configs/reorg.balances.ts | 10 ++-- ..._chain_blocks_and_transactions.balances.ts | 27 +++++----- .../transaction_voiding_chain.balances.ts | 51 +++++++++---------- .../unvoided_transactions.balances.ts | 24 ++++----- .../__tests__/integration/utils/index.ts | 18 +++---- 10 files changed, 115 insertions(+), 129 deletions(-) diff --git a/packages/daemon/__tests__/integration/balances.test.ts b/packages/daemon/__tests__/integration/balances.test.ts index 03ed78aa..56bb6c7b 100644 --- a/packages/daemon/__tests__/integration/balances.test.ts +++ b/packages/daemon/__tests__/integration/balances.test.ts @@ -110,6 +110,7 @@ afterAll(async () => { describe('unvoided transaction scenario', () => { beforeAll(async () => { jest.spyOn(Services, 'fetchMinRewardBlocks').mockImplementation(async () => 300); + await cleanDatabase(mysql); }); it('should do a full sync and the balances should match', async () => { @@ -138,7 +139,7 @@ describe('unvoided transaction scenario', () => { await transitionUntilEvent(mysql, machine, UNVOIDED_SCENARIO_LAST_EVENT); const addressBalances = await fetchAddressBalances(mysql); await expect(validateBalances(addressBalances, unvoidedScenarioBalances.addressBalances)).resolves.not.toThrow(); - }); + }, 30000); }); describe('reorg scenario', () => { @@ -173,7 +174,7 @@ describe('reorg scenario', () => { await transitionUntilEvent(mysql, machine, REORG_SCENARIO_LAST_EVENT); const addressBalances = await fetchAddressBalances(mysql); await expect(validateBalances(addressBalances, reorgScenarioBalances.addressBalances)).resolves.not.toThrow(); - }); + }, 30000); }); describe('single chain blocks and transactions scenario', () => { @@ -208,7 +209,7 @@ describe('single chain blocks and transactions scenario', () => { await transitionUntilEvent(mysql, machine, SINGLE_CHAIN_BLOCKS_AND_TRANSACTIONS_LAST_EVENT); const addressBalances = await fetchAddressBalances(mysql); await expect(validateBalances(addressBalances, singleChainBlocksAndTransactionsBalances.addressBalances)).resolves.not.toThrow(); - }); + }, 30000); }); describe('invalid mempool transactions scenario', () => { @@ -243,7 +244,7 @@ describe('invalid mempool transactions scenario', () => { await transitionUntilEvent(mysql, machine, INVALID_MEMPOOL_TRANSACTION_LAST_EVENT); const addressBalances = await fetchAddressBalances(mysql); await expect(validateBalances(addressBalances, invalidMempoolBalances.addressBalances)).resolves.not.toThrow(); - }); + }, 30000); }); describe('custom script scenario', () => { @@ -278,7 +279,7 @@ describe('custom script scenario', () => { await transitionUntilEvent(mysql, machine, CUSTOM_SCRIPT_LAST_EVENT); const addressBalances = await fetchAddressBalances(mysql); await expect(validateBalances(addressBalances, customScriptBalances.addressBalances)).resolves.not.toThrow(); - }); + }, 30000); }); describe('empty script scenario', () => { @@ -314,7 +315,7 @@ describe('empty script scenario', () => { const addressBalances = await fetchAddressBalances(mysql); await expect(validateBalances(addressBalances, emptyScriptBalances.addressBalances)).resolves.not.toThrow(); - }); + }, 30000); }); describe('nc events scenario', () => { @@ -350,7 +351,7 @@ describe('nc events scenario', () => { const addressBalances = await fetchAddressBalances(mysql); await expect(validateBalances(addressBalances, ncEventsBalances.addressBalances)).resolves.not.toThrow(); - }); + }, 30000); }); describe('transaction voiding chain scenario', () => { @@ -387,10 +388,6 @@ describe('transaction voiding chain scenario', () => { await expect(validateBalances(addressBalances, transactionVoidingChainBalances.addressBalances)).resolves.not.toThrow(); - // Validate wallet balances - const walletBalances = await fetchWalletBalances(mysql); - await expect(validateWalletBalances(walletBalances, transactionVoidingChainBalances.walletBalances)).resolves.not.toThrow(); - // Validate transaction voiding consistency const voidingChecks = await performVoidingConsistencyChecks(mysql, { transactions: [ @@ -421,7 +418,12 @@ describe('voided token authority scenario', () => { await cleanDatabase(mysql); }); - it.only('should do a full sync and the balances should match after voiding token authority', async () => { + afterAll(async () => { + // Clean up wallet data after this test to prevent affecting other tests + await cleanDatabase(mysql); + }); + + it('should do a full sync and the balances should match after voiding token authority', async () => { // @ts-ignore getConfig.mockReturnValue({ NETWORK: 'testnet', diff --git a/packages/daemon/__tests__/integration/scenario_configs/custom_script.balances.ts b/packages/daemon/__tests__/integration/scenario_configs/custom_script.balances.ts index 08deeeb2..e5a5ca5c 100644 --- a/packages/daemon/__tests__/integration/scenario_configs/custom_script.balances.ts +++ b/packages/daemon/__tests__/integration/scenario_configs/custom_script.balances.ts @@ -1,21 +1,19 @@ export default { addressBalances: { - 'HRXVDmLVdq8pgok1BCUKpiFWdAVAy4a5AJ:00': { unlockedBalance: 100000000000n, lockedBalance: 0n }, - 'HJQbEERnD5Ak3f2dsi8zAmsZrCWTT8FZns:00': { unlockedBalance: 6400n, lockedBalance: 0n }, - 'HRSYchTEsFFpZAkgSTMsohNGQ6eLPyhXvJ:00': { unlockedBalance: 6400n, lockedBalance: 0n }, - 'HQfVqxyxQV4BHwnsMnRXpZGmwPYiNSVmMu:00': { unlockedBalance: 6400n, lockedBalance: 0n }, - 'HPnkpR2vnBuCoZCEnRZNHMBtf8ygeSidbW:00': { unlockedBalance: 6400n, lockedBalance: 0n }, - 'HPNvtPZaDF44i6CL91u4BvZPu6z2xPNt26:00': { unlockedBalance: 6400n, lockedBalance: 0n }, - 'HQijr325t63VJFdc4vYkaTyd87oeBLpSed:00': { unlockedBalance: 6400n, lockedBalance: 0n }, - 'H8fCNrYGkj4B6VzKgtRiHBgoSxM31d65JR:00': { unlockedBalance: 6400n, lockedBalance: 0n }, - 'HAqrADnn7GyyT68fSX8zmtsRNFyabPzoRQ:00': { unlockedBalance: 6400n, lockedBalance: 0n }, - 'HRTH6uGo7zn3LWrosBYn7eXkwAeHAHTRh8:00': { unlockedBalance: 6400n, lockedBalance: 0n }, - 'HJPSMHCFv2dRb78wZPMsAzwLQHSkBpfuLn:00': { unlockedBalance: 6400n, lockedBalance: 0n }, + 'HRXVDmLVdq8pgok1BCUKpiFWdAVAy4a5AJ:00': { unlockedBalance: 0n, lockedBalance: 100000000000n }, + 'HJQbEERnD5Ak3f2dsi8zAmsZrCWTT8FZns:00': { unlockedBalance: 0n, lockedBalance: 6400n }, + 'HRSYchTEsFFpZAkgSTMsohNGQ6eLPyhXvJ:00': { unlockedBalance: 0n, lockedBalance: 6400n }, + 'HQfVqxyxQV4BHwnsMnRXpZGmwPYiNSVmMu:00': { unlockedBalance: 0n, lockedBalance: 6400n }, + 'HPnkpR2vnBuCoZCEnRZNHMBtf8ygeSidbW:00': { unlockedBalance: 0n, lockedBalance: 6400n }, + 'HPNvtPZaDF44i6CL91u4BvZPu6z2xPNt26:00': { unlockedBalance: 0n, lockedBalance: 6400n }, + 'HQijr325t63VJFdc4vYkaTyd87oeBLpSed:00': { unlockedBalance: 0n, lockedBalance: 6400n }, + 'H8fCNrYGkj4B6VzKgtRiHBgoSxM31d65JR:00': { unlockedBalance: 0n, lockedBalance: 6400n }, + 'HAqrADnn7GyyT68fSX8zmtsRNFyabPzoRQ:00': { unlockedBalance: 0n, lockedBalance: 6400n }, + 'HRTH6uGo7zn3LWrosBYn7eXkwAeHAHTRh8:00': { unlockedBalance: 0n, lockedBalance: 6400n }, + 'HJPSMHCFv2dRb78wZPMsAzwLQHSkBpfuLn:00': { unlockedBalance: 0n, lockedBalance: 6400n }, 'HFtz2f59Lms4p3Jfgtsr73s97MbJHsRENh:00': { unlockedBalance: 1000n, lockedBalance: 0n }, 'H9hHteu9QdAS5p6X743Mpfue6G19rV9GeY:00': { unlockedBalance: 5400n, lockedBalance: 0n }, - 'HSd6PqXesUmHHv6MoN24aUiMuw7Pdcxrwk:00': { unlockedBalance: 6400n, lockedBalance: 0n }, - }, - walletBalances: { - // Add wallet balances when needed + 'HSd6PqXesUmHHv6MoN24aUiMuw7Pdcxrwk:00': { unlockedBalance: 0n, lockedBalance: 6400n }, }, + walletBalances: {}, } diff --git a/packages/daemon/__tests__/integration/scenario_configs/empty_script.balances.ts b/packages/daemon/__tests__/integration/scenario_configs/empty_script.balances.ts index 50bea13f..a6343581 100644 --- a/packages/daemon/__tests__/integration/scenario_configs/empty_script.balances.ts +++ b/packages/daemon/__tests__/integration/scenario_configs/empty_script.balances.ts @@ -1,21 +1,19 @@ export default { addressBalances: { - 'HRXVDmLVdq8pgok1BCUKpiFWdAVAy4a5AJ:00': { unlockedBalance: 100000000000n, lockedBalance: 0n }, - 'HFtz2f59Lms4p3Jfgtsr73s97MbJHsRENh:00': { unlockedBalance: 6400n, lockedBalance: 0n }, - 'HJQbEERnD5Ak3f2dsi8zAmsZrCWTT8FZns:00': { unlockedBalance: 6400n, lockedBalance: 0n }, - 'HRSYchTEsFFpZAkgSTMsohNGQ6eLPyhXvJ:00': { unlockedBalance: 6400n, lockedBalance: 0n }, - 'HQfVqxyxQV4BHwnsMnRXpZGmwPYiNSVmMu:00': { unlockedBalance: 6400n, lockedBalance: 0n }, - 'HPnkpR2vnBuCoZCEnRZNHMBtf8ygeSidbW:00': { unlockedBalance: 6400n, lockedBalance: 0n }, - 'HPNvtPZaDF44i6CL91u4BvZPu6z2xPNt26:00': { unlockedBalance: 6400n, lockedBalance: 0n }, - 'HQijr325t63VJFdc4vYkaTyd87oeBLpSed:00': { unlockedBalance: 6400n, lockedBalance: 0n }, - 'H8fCNrYGkj4B6VzKgtRiHBgoSxM31d65JR:00': { unlockedBalance: 6400n, lockedBalance: 0n }, - 'HAqrADnn7GyyT68fSX8zmtsRNFyabPzoRQ:00': { unlockedBalance: 6400n, lockedBalance: 0n }, - 'HRTH6uGo7zn3LWrosBYn7eXkwAeHAHTRh8:00': { unlockedBalance: 6400n, lockedBalance: 0n }, + 'HRXVDmLVdq8pgok1BCUKpiFWdAVAy4a5AJ:00': { unlockedBalance: 0n, lockedBalance: 100000000000n }, + 'HFtz2f59Lms4p3Jfgtsr73s97MbJHsRENh:00': { unlockedBalance: 0n, lockedBalance: 6400n }, + 'HJQbEERnD5Ak3f2dsi8zAmsZrCWTT8FZns:00': { unlockedBalance: 0n, lockedBalance: 6400n }, + 'HRSYchTEsFFpZAkgSTMsohNGQ6eLPyhXvJ:00': { unlockedBalance: 0n, lockedBalance: 6400n }, + 'HQfVqxyxQV4BHwnsMnRXpZGmwPYiNSVmMu:00': { unlockedBalance: 0n, lockedBalance: 6400n }, + 'HPnkpR2vnBuCoZCEnRZNHMBtf8ygeSidbW:00': { unlockedBalance: 0n, lockedBalance: 6400n }, + 'HPNvtPZaDF44i6CL91u4BvZPu6z2xPNt26:00': { unlockedBalance: 0n, lockedBalance: 6400n }, + 'HQijr325t63VJFdc4vYkaTyd87oeBLpSed:00': { unlockedBalance: 0n, lockedBalance: 6400n }, + 'H8fCNrYGkj4B6VzKgtRiHBgoSxM31d65JR:00': { unlockedBalance: 0n, lockedBalance: 6400n }, + 'HAqrADnn7GyyT68fSX8zmtsRNFyabPzoRQ:00': { unlockedBalance: 0n, lockedBalance: 6400n }, + 'HRTH6uGo7zn3LWrosBYn7eXkwAeHAHTRh8:00': { unlockedBalance: 0n, lockedBalance: 6400n }, 'HRQe4CXj8AZXzSmuNztU8iQR74QTQMbnTs:00': { unlockedBalance: 1000n, lockedBalance: 0n }, 'HNqTfEASfdx7H4vMUGzfD2HyD3GeKuxjTJ:00': { unlockedBalance: 5400n, lockedBalance: 0n }, - 'HRH8Wbmr1A3BrLswSBhvVE4hhsv4jUdyVA:00': { unlockedBalance: 6400n, lockedBalance: 0n }, - }, - walletBalances: { - // Add wallet balances when needed + 'HRH8Wbmr1A3BrLswSBhvVE4hhsv4jUdyVA:00': { unlockedBalance: 0n, lockedBalance: 6400n }, }, + walletBalances: {}, }; diff --git a/packages/daemon/__tests__/integration/scenario_configs/invalid_mempool_transaction.balances.ts b/packages/daemon/__tests__/integration/scenario_configs/invalid_mempool_transaction.balances.ts index 5044baf0..13725a9d 100644 --- a/packages/daemon/__tests__/integration/scenario_configs/invalid_mempool_transaction.balances.ts +++ b/packages/daemon/__tests__/integration/scenario_configs/invalid_mempool_transaction.balances.ts @@ -1,20 +1,18 @@ export default { addressBalances: { 'HNqTfEASfdx7H4vMUGzfD2HyD3GeKuxjTJ:00': { unlockedBalance: 0n, lockedBalance: 0n }, - 'HRXVDmLVdq8pgok1BCUKpiFWdAVAy4a5AJ:00': { unlockedBalance: 100000000000n, lockedBalance: 0n }, - 'HFtz2f59Lms4p3Jfgtsr73s97MbJHsRENh:00': { unlockedBalance: 6400n, lockedBalance: 0n }, - 'HJQbEERnD5Ak3f2dsi8zAmsZrCWTT8FZns:00': { unlockedBalance: 6400n, lockedBalance: 0n }, - 'HRSYchTEsFFpZAkgSTMsohNGQ6eLPyhXvJ:00': { unlockedBalance: 6400n, lockedBalance: 0n }, - 'HQfVqxyxQV4BHwnsMnRXpZGmwPYiNSVmMu:00': { unlockedBalance: 6400n, lockedBalance: 0n }, - 'HPnkpR2vnBuCoZCEnRZNHMBtf8ygeSidbW:00': { unlockedBalance: 6400n, lockedBalance: 0n }, - 'HPNvtPZaDF44i6CL91u4BvZPu6z2xPNt26:00': { unlockedBalance: 6400n, lockedBalance: 0n }, - 'HQijr325t63VJFdc4vYkaTyd87oeBLpSed:00': { unlockedBalance: 6400n, lockedBalance: 0n }, - 'H8fCNrYGkj4B6VzKgtRiHBgoSxM31d65JR:00': { unlockedBalance: 6400n, lockedBalance: 0n }, + 'HRXVDmLVdq8pgok1BCUKpiFWdAVAy4a5AJ:00': { unlockedBalance: 0n, lockedBalance: 100000000000n }, + 'HFtz2f59Lms4p3Jfgtsr73s97MbJHsRENh:00': { unlockedBalance: 0n, lockedBalance: 6400n }, + 'HJQbEERnD5Ak3f2dsi8zAmsZrCWTT8FZns:00': { unlockedBalance: 0n, lockedBalance: 6400n }, + 'HRSYchTEsFFpZAkgSTMsohNGQ6eLPyhXvJ:00': { unlockedBalance: 0n, lockedBalance: 6400n }, + 'HQfVqxyxQV4BHwnsMnRXpZGmwPYiNSVmMu:00': { unlockedBalance: 0n, lockedBalance: 6400n }, + 'HPnkpR2vnBuCoZCEnRZNHMBtf8ygeSidbW:00': { unlockedBalance: 0n, lockedBalance: 6400n }, + 'HPNvtPZaDF44i6CL91u4BvZPu6z2xPNt26:00': { unlockedBalance: 0n, lockedBalance: 6400n }, + 'HQijr325t63VJFdc4vYkaTyd87oeBLpSed:00': { unlockedBalance: 0n, lockedBalance: 6400n }, + 'H8fCNrYGkj4B6VzKgtRiHBgoSxM31d65JR:00': { unlockedBalance: 0n, lockedBalance: 6400n }, 'HRQe4CXj8AZXzSmuNztU8iQR74QTQMbnTs:00': { unlockedBalance: 6400n, lockedBalance: 0n }, 'HAqrADnn7GyyT68fSX8zmtsRNFyabPzoRQ:00': { unlockedBalance: 0n, lockedBalance: 0n }, 'HRTH6uGo7zn3LWrosBYn7eXkwAeHAHTRh8:00': { unlockedBalance: 0n, lockedBalance: 0n }, }, - walletBalances: { - // Add wallet balances when needed - }, + walletBalances: {}, } diff --git a/packages/daemon/__tests__/integration/scenario_configs/nc_events.balances.ts b/packages/daemon/__tests__/integration/scenario_configs/nc_events.balances.ts index 93c076b6..1f620012 100644 --- a/packages/daemon/__tests__/integration/scenario_configs/nc_events.balances.ts +++ b/packages/daemon/__tests__/integration/scenario_configs/nc_events.balances.ts @@ -2,9 +2,9 @@ export default { addressBalances: { "H92QQ83Ldm8Sgj6kT8ebu2CmqtZrvhZb6k:00": { unlockedBalance: 0n, lockedBalance: 0n }, "HAoH1xLZXdDByVsBRUcz9t5GeGDoEfMF2H:00": { unlockedBalance: 1n, lockedBalance: 0n }, - "HEV1qK3dZDuXZn4rUTHZvfsU3L78usLh6u:00": { unlockedBalance: 6400n, lockedBalance: 0n }, - "HF6XLDMZVA5KjJejBxDNeg1j8isXpPUVpU:00": { unlockedBalance: 6400n, lockedBalance: 0n }, - "HFnTiBtKmriJ4iFG9VBDZv6Te134b9DMmZ:00": { unlockedBalance: 6400n, lockedBalance: 0n }, + "HEV1qK3dZDuXZn4rUTHZvfsU3L78usLh6u:00": { unlockedBalance: 0n, lockedBalance: 6400n }, + "HF6XLDMZVA5KjJejBxDNeg1j8isXpPUVpU:00": { unlockedBalance: 0n, lockedBalance: 6400n }, + "HFnTiBtKmriJ4iFG9VBDZv6Te134b9DMmZ:00": { unlockedBalance: 0n, lockedBalance: 6400n }, "HHZsdpy6U6vy4DPaAwBT5jUWLvYbSefQ7Z:00": { unlockedBalance: 0n, lockedBalance: 0n }, "HKpb6ABejTCFWG5nFrAVEvVcSrRuFgFnB4:00": { unlockedBalance: 99999999996n, lockedBalance: 0n }, "HKZHo7yZK49P1EqT43awxqSwiEbH1SsQVZ:00": { unlockedBalance: 1n, lockedBalance: 0n }, @@ -18,7 +18,5 @@ export default { "HVcaqHL5e471jp7gRm7sBYmSMr3K1YQVp1:00": { unlockedBalance: 0n, lockedBalance: 0n }, "HVCGqDBHXkdhghtaS2v4XKqfixui3HeYs1:00": { unlockedBalance: 1n, lockedBalance: 0n }, }, - walletBalances: { - // Add wallet balances when needed - }, + walletBalances: {}, }; diff --git a/packages/daemon/__tests__/integration/scenario_configs/reorg.balances.ts b/packages/daemon/__tests__/integration/scenario_configs/reorg.balances.ts index c3c3a6bf..fe048735 100644 --- a/packages/daemon/__tests__/integration/scenario_configs/reorg.balances.ts +++ b/packages/daemon/__tests__/integration/scenario_configs/reorg.balances.ts @@ -1,11 +1,9 @@ export default { addressBalances: { - 'HRXVDmLVdq8pgok1BCUKpiFWdAVAy4a5AJ:00': { unlockedBalance: 100000000000n, lockedBalance: 0n }, - 'HFyF1jYJP9FXfiC3LRqf3q4768TBL1rxbn:00': { unlockedBalance: 6400n, lockedBalance: 0n }, - 'HMbS5P3NTLQ5oR5TfLNvAkeQ7L8MPn9VM3:00': { unlockedBalance: 6400n, lockedBalance: 0n }, + 'HRXVDmLVdq8pgok1BCUKpiFWdAVAy4a5AJ:00': { unlockedBalance: 0n, lockedBalance: 100000000000n }, + 'HFyF1jYJP9FXfiC3LRqf3q4768TBL1rxbn:00': { unlockedBalance: 0n, lockedBalance: 6400n }, + 'HMbS5P3NTLQ5oR5TfLNvAkeQ7L8MPn9VM3:00': { unlockedBalance: 0n, lockedBalance: 6400n }, 'HRQe4CXj8AZXzSmuNztU8iQR74QTQMbnTs:00': { unlockedBalance: 0n, lockedBalance: 0n }, }, - walletBalances: { - // Add wallet balances when needed - }, + walletBalances: {}, }; diff --git a/packages/daemon/__tests__/integration/scenario_configs/single_chain_blocks_and_transactions.balances.ts b/packages/daemon/__tests__/integration/scenario_configs/single_chain_blocks_and_transactions.balances.ts index a03c2a86..b603bf5a 100644 --- a/packages/daemon/__tests__/integration/scenario_configs/single_chain_blocks_and_transactions.balances.ts +++ b/packages/daemon/__tests__/integration/scenario_configs/single_chain_blocks_and_transactions.balances.ts @@ -1,21 +1,20 @@ export default { addressBalances: { - 'HFtz2f59Lms4p3Jfgtsr73s97MbJHsRENh:00': { unlockedBalance: 6400n, lockedBalance: 0n }, - 'HJQbEERnD5Ak3f2dsi8zAmsZrCWTT8FZns:00': { unlockedBalance: 6400n, lockedBalance: 0n }, - 'HRSYchTEsFFpZAkgSTMsohNGQ6eLPyhXvJ:00': { unlockedBalance: 6400n, lockedBalance: 0n }, - 'HQfVqxyxQV4BHwnsMnRXpZGmwPYiNSVmMu:00': { unlockedBalance: 6400n, lockedBalance: 0n }, - 'HPnkpR2vnBuCoZCEnRZNHMBtf8ygeSidbW:00': { unlockedBalance: 6400n, lockedBalance: 0n }, - 'HPNvtPZaDF44i6CL91u4BvZPu6z2xPNt26:00': { unlockedBalance: 6400n, lockedBalance: 0n }, - 'HQijr325t63VJFdc4vYkaTyd87oeBLpSed:00': { unlockedBalance: 6400n, lockedBalance: 0n }, - 'H8fCNrYGkj4B6VzKgtRiHBgoSxM31d65JR:00': { unlockedBalance: 6400n, lockedBalance: 0n }, - 'HAqrADnn7GyyT68fSX8zmtsRNFyabPzoRQ:00': { unlockedBalance: 6400n, lockedBalance: 0n }, - 'HRTH6uGo7zn3LWrosBYn7eXkwAeHAHTRh8:00': { unlockedBalance: 6400n, lockedBalance: 0n }, + 'HFtz2f59Lms4p3Jfgtsr73s97MbJHsRENh:00': { unlockedBalance: 0n, lockedBalance: 6400n }, + 'HJQbEERnD5Ak3f2dsi8zAmsZrCWTT8FZns:00': { unlockedBalance: 0n, lockedBalance: 6400n }, + 'HRSYchTEsFFpZAkgSTMsohNGQ6eLPyhXvJ:00': { unlockedBalance: 0n, lockedBalance: 6400n }, + 'HQfVqxyxQV4BHwnsMnRXpZGmwPYiNSVmMu:00': { unlockedBalance: 0n, lockedBalance: 6400n }, + 'HPnkpR2vnBuCoZCEnRZNHMBtf8ygeSidbW:00': { unlockedBalance: 0n, lockedBalance: 6400n }, + 'HPNvtPZaDF44i6CL91u4BvZPu6z2xPNt26:00': { unlockedBalance: 0n, lockedBalance: 6400n }, + 'HQijr325t63VJFdc4vYkaTyd87oeBLpSed:00': { unlockedBalance: 0n, lockedBalance: 6400n }, + 'H8fCNrYGkj4B6VzKgtRiHBgoSxM31d65JR:00': { unlockedBalance: 0n, lockedBalance: 6400n }, + 'HAqrADnn7GyyT68fSX8zmtsRNFyabPzoRQ:00': { unlockedBalance: 0n, lockedBalance: 6400n }, + 'HRTH6uGo7zn3LWrosBYn7eXkwAeHAHTRh8:00': { unlockedBalance: 0n, lockedBalance: 6400n }, 'HRQe4CXj8AZXzSmuNztU8iQR74QTQMbnTs:00': { unlockedBalance: 3000n, lockedBalance: 0n }, 'HRH8Wbmr1A3BrLswSBhvVE4hhsv4jUdyVA:00': { unlockedBalance: 3400n, lockedBalance: 0n }, - 'HSd6PqXesUmHHv6MoN24aUiMuw7Pdcxrwk:00': { unlockedBalance: 6400n, lockedBalance: 0n }, + 'HSd6PqXesUmHHv6MoN24aUiMuw7Pdcxrwk:00': { unlockedBalance: 0n, lockedBalance: 6400n }, 'HNqTfEASfdx7H4vMUGzfD2HyD3GeKuxjTJ:00': { unlockedBalance: 0n, lockedBalance: 0n }, + 'HRXVDmLVdq8pgok1BCUKpiFWdAVAy4a5AJ:00': { unlockedBalance: 0n, lockedBalance: 100000000000n }, }, - walletBalances: { - // Add wallet balances when needed - }, + walletBalances: {}, } diff --git a/packages/daemon/__tests__/integration/scenario_configs/transaction_voiding_chain.balances.ts b/packages/daemon/__tests__/integration/scenario_configs/transaction_voiding_chain.balances.ts index b37df7cc..b87657c1 100644 --- a/packages/daemon/__tests__/integration/scenario_configs/transaction_voiding_chain.balances.ts +++ b/packages/daemon/__tests__/integration/scenario_configs/transaction_voiding_chain.balances.ts @@ -8,48 +8,43 @@ export default { addressBalances: { // HFtz2f59Lms4p3Jfgtsr73s97MbJHsRENh - token efb08b...: unlocked=0, locked=0, authorities: 0 unlocked + 0 locked - 'HFtz2f59Lms4p3Jfgtsr73s97MbJHsRENh:efb08b3e79e0ddaa6bc288183f66fe49a07ba0b7b2595861000478cc56447539': { - unlockedBalance: 0n, + 'HFtz2f59Lms4p3Jfgtsr73s97MbJHsRENh:efb08b3e79e0ddaa6bc288183f66fe49a07ba0b7b2595861000478cc56447539': { + unlockedBalance: 0n, lockedBalance: 0n, authorities: { locked: 0, unlocked: 0 } }, - // HJQbEERnD5Ak3f2dsi8zAmsZrCWTT8FZns - token efb08b...: unlocked=0, locked=0, authorities: 1 unlocked + 0 locked - 'HJQbEERnD5Ak3f2dsi8zAmsZrCWTT8FZns:efb08b3e79e0ddaa6bc288183f66fe49a07ba0b7b2595861000478cc56447539': { - unlockedBalance: 0n, + // HJQbEERnD5Ak3f2dsi8zAmsZrCWTT8FZns - token efb08b...: unlocked=0, locked=0, authorities: 1 unlocked + 0 locked + 'HJQbEERnD5Ak3f2dsi8zAmsZrCWTT8FZns:efb08b3e79e0ddaa6bc288183f66fe49a07ba0b7b2595861000478cc56447539': { + unlockedBalance: 0n, lockedBalance: 0n, authorities: { locked: 0, unlocked: 1 } }, - // HRQe4CXj8AZXzSmuNztU8iQR74QTQMbnTs - HTR (00): unlocked=6390, locked=115200 - 'HRQe4CXj8AZXzSmuNztU8iQR74QTQMbnTs:00': { - unlockedBalance: 6390n, - lockedBalance: 115200n + // HRQe4CXj8AZXzSmuNztU8iQR74QTQMbnTs - HTR (00): unlocked=3500, locked=70400 + 'HRQe4CXj8AZXzSmuNztU8iQR74QTQMbnTs:00': { + unlockedBalance: 3500n, + lockedBalance: 70400n }, - // HRQe4CXj8AZXzSmuNztU8iQR74QTQMbnTs - token efb08b...: unlocked=1000, locked=0, authorities: 2 unlocked + 0 locked + // HRQe4CXj8AZXzSmuNztU8iQR74QTQMbnTs - token efb08b...: unlocked=0, locked=0, authorities: 2 unlocked + 0 locked 'HRQe4CXj8AZXzSmuNztU8iQR74QTQMbnTs:efb08b3e79e0ddaa6bc288183f66fe49a07ba0b7b2595861000478cc56447539': { - unlockedBalance: 1000n, + unlockedBalance: 0n, lockedBalance: 0n, authorities: { locked: 0, unlocked: 2 } }, // HRXVDmLVdq8pgok1BCUKpiFWdAVAy4a5AJ - HTR (00): unlocked=0, locked=100000000000 - 'HRXVDmLVdq8pgok1BCUKpiFWdAVAy4a5AJ:00': { - unlockedBalance: 0n, - lockedBalance: 100000000000n + 'HRXVDmLVdq8pgok1BCUKpiFWdAVAy4a5AJ:00': { + unlockedBalance: 0n, + lockedBalance: 100000000000n }, - }, - walletBalances: { - // deafbeef wallet HTR balance: unlocked=6390, locked=100000115200 - 'deafbeef:00': { unlockedBalance: 6390n, lockedBalance: 100000115200n }, - // deafbeef wallet token efb08b... balance: unlocked=1000, locked=0, authorities: 2 unlocked + 0 locked - 'deafbeef:efb08b3e79e0ddaa6bc288183f66fe49a07ba0b7b2595861000478cc56447539': { - unlockedBalance: 1000n, - lockedBalance: 0n, - authorities: { locked: 0, unlocked: 2 } + // HPNvtPZaDF44i6CL91u4BvZPu6z2xPNt26 - HTR (00): unlocked=5900, locked=0 + 'HPNvtPZaDF44i6CL91u4BvZPu6z2xPNt26:00': { + unlockedBalance: 5900n, + lockedBalance: 0n }, - // cafecafe wallet token efb08b... balance: unlocked=0, locked=0, authorities: 1 unlocked + 0 locked - 'cafecafe:efb08b3e79e0ddaa6bc288183f66fe49a07ba0b7b2595861000478cc56447539': { - unlockedBalance: 0n, - lockedBalance: 0n, - authorities: { locked: 0, unlocked: 1 } + // HQfVqxyxQV4BHwnsMnRXpZGmwPYiNSVmMu - HTR (00): unlocked=3400, locked=0 + 'HQfVqxyxQV4BHwnsMnRXpZGmwPYiNSVmMu:00': { + unlockedBalance: 3400n, + lockedBalance: 0n }, }, + walletBalances: {}, }; diff --git a/packages/daemon/__tests__/integration/scenario_configs/unvoided_transactions.balances.ts b/packages/daemon/__tests__/integration/scenario_configs/unvoided_transactions.balances.ts index c673db23..f9b04db6 100644 --- a/packages/daemon/__tests__/integration/scenario_configs/unvoided_transactions.balances.ts +++ b/packages/daemon/__tests__/integration/scenario_configs/unvoided_transactions.balances.ts @@ -1,19 +1,19 @@ export default { addressBalances: { - 'HFtz2f59Lms4p3Jfgtsr73s97MbJHsRENh:00': { unlockedBalance: 6400n, lockedBalance: 0n }, - 'HJQbEERnD5Ak3f2dsi8zAmsZrCWTT8FZns:00': { unlockedBalance: 6400n, lockedBalance: 0n }, - 'HRSYchTEsFFpZAkgSTMsohNGQ6eLPyhXvJ:00': { unlockedBalance: 6400n, lockedBalance: 0n }, - 'HQfVqxyxQV4BHwnsMnRXpZGmwPYiNSVmMu:00': { unlockedBalance: 6400n, lockedBalance: 0n }, - 'HPnkpR2vnBuCoZCEnRZNHMBtf8ygeSidbW:00': { unlockedBalance: 6400n, lockedBalance: 0n }, - 'HPNvtPZaDF44i6CL91u4BvZPu6z2xPNt26:00': { unlockedBalance: 6400n, lockedBalance: 0n }, - 'HQijr325t63VJFdc4vYkaTyd87oeBLpSed:00': { unlockedBalance: 6400n, lockedBalance: 0n }, - 'H8fCNrYGkj4B6VzKgtRiHBgoSxM31d65JR:00': { unlockedBalance: 6400n, lockedBalance: 0n }, - 'HAqrADnn7GyyT68fSX8zmtsRNFyabPzoRQ:00': { unlockedBalance: 6400n, lockedBalance: 0n }, - 'HRTH6uGo7zn3LWrosBYn7eXkwAeHAHTRh8:00': { unlockedBalance: 6400n, lockedBalance: 0n }, - 'H9hHteu9QdAS5p6X743Mpfue6G19rV9GeY:00': { unlockedBalance: 6400n, lockedBalance: 0n }, + 'HFtz2f59Lms4p3Jfgtsr73s97MbJHsRENh:00': { unlockedBalance: 0n, lockedBalance: 6400n }, + 'HJQbEERnD5Ak3f2dsi8zAmsZrCWTT8FZns:00': { unlockedBalance: 0n, lockedBalance: 6400n }, + 'HRSYchTEsFFpZAkgSTMsohNGQ6eLPyhXvJ:00': { unlockedBalance: 0n, lockedBalance: 6400n }, + 'HQfVqxyxQV4BHwnsMnRXpZGmwPYiNSVmMu:00': { unlockedBalance: 0n, lockedBalance: 6400n }, + 'HPnkpR2vnBuCoZCEnRZNHMBtf8ygeSidbW:00': { unlockedBalance: 0n, lockedBalance: 6400n }, + 'HPNvtPZaDF44i6CL91u4BvZPu6z2xPNt26:00': { unlockedBalance: 0n, lockedBalance: 6400n }, + 'HQijr325t63VJFdc4vYkaTyd87oeBLpSed:00': { unlockedBalance: 0n, lockedBalance: 6400n }, + 'H8fCNrYGkj4B6VzKgtRiHBgoSxM31d65JR:00': { unlockedBalance: 0n, lockedBalance: 6400n }, + 'HAqrADnn7GyyT68fSX8zmtsRNFyabPzoRQ:00': { unlockedBalance: 0n, lockedBalance: 6400n }, + 'HRTH6uGo7zn3LWrosBYn7eXkwAeHAHTRh8:00': { unlockedBalance: 0n, lockedBalance: 6400n }, + 'H9hHteu9QdAS5p6X743Mpfue6G19rV9GeY:00': { unlockedBalance: 0n, lockedBalance: 6400n }, 'HNqTfEASfdx7H4vMUGzfD2HyD3GeKuxjTJ:00': { unlockedBalance: 5400n, lockedBalance: 0n }, 'HRQe4CXj8AZXzSmuNztU8iQR74QTQMbnTs:00': { unlockedBalance: 1000n, lockedBalance: 0n }, - 'HRXVDmLVdq8pgok1BCUKpiFWdAVAy4a5AJ:00': { unlockedBalance: 100000000000n, lockedBalance: 0n }, + 'HRXVDmLVdq8pgok1BCUKpiFWdAVAy4a5AJ:00': { unlockedBalance: 0n, lockedBalance: 100000000000n }, }, walletBalances: { // Add wallet balances when needed diff --git a/packages/daemon/__tests__/integration/utils/index.ts b/packages/daemon/__tests__/integration/utils/index.ts index b8becc6e..8691fd87 100644 --- a/packages/daemon/__tests__/integration/utils/index.ts +++ b/packages/daemon/__tests__/integration/utils/index.ts @@ -92,10 +92,10 @@ export const fetchWalletBalances = async ( export const validateBalances = async ( balancesA: AddressBalance[], - expectedBalances: Record, ): Promise => { const expectedAddressTokenKeys = new Set(Object.keys(expectedBalances)); @@ -142,15 +142,15 @@ export const validateBalances = async ( export const validateWalletBalances = async ( walletBalances: WalletBalance[], - expectedWalletBalances: Record, ): Promise => { for (const [walletTokenKey, expected] of Object.entries(expectedWalletBalances)) { const [walletId, tokenId] = walletTokenKey.split(':'); - + const walletBalance = walletBalances.find( b => b.walletId === walletId && b.tokenId === tokenId ); From 01a7a236084f2a62be39d31adab01f4ec5919d2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Abadesso?= Date: Thu, 11 Sep 2025 19:08:21 -0300 Subject: [PATCH 17/49] tests(daemon): refactor sql insert for the transction voiding chain scenario --- .../__tests__/integration/balances.test.ts | 51 ++++++++++++++++++- .../__tests__/integration/utils/index.ts | 48 ----------------- 2 files changed, 50 insertions(+), 49 deletions(-) diff --git a/packages/daemon/__tests__/integration/balances.test.ts b/packages/daemon/__tests__/integration/balances.test.ts index 56bb6c7b..ac382bcb 100644 --- a/packages/daemon/__tests__/integration/balances.test.ts +++ b/packages/daemon/__tests__/integration/balances.test.ts @@ -19,7 +19,6 @@ import { validateWalletBalances, performVoidingConsistencyChecks, validateVoidingConsistency, - initializeWallet, } from './utils'; import unvoidedScenarioBalances from './scenario_configs/unvoided_transactions.balances'; import reorgScenarioBalances from './scenario_configs/reorg.balances'; @@ -413,6 +412,56 @@ describe('transaction voiding chain scenario', () => { }); describe('voided token authority scenario', () => { + + const initializeWallet = async (mysql: Connection): Promise => { + // Insert wallet records + const walletSQL = ` + INSERT INTO wallet ( + id, + xpubkey, + status, + max_gap, + created_at, + ready_at, + retry_count, + auth_xpubkey, + last_used_address_index + ) VALUES + ( + 'deafbeef', + 'xpub6F81iNtH5HVknoJ65cK2XAGA5F3okdJK7WHwVAAPZnSir2sfwbhvB9ffNKQ4wLor75QxPe9p12tqt8xUZSG8i8AAPMpkFho7fbWkBJQ5s1x', + 'ready', + 20, + UNIX_TIMESTAMP(), + UNIX_TIMESTAMP(), + 0, + 'xpub6F81iNtH5HVknoJ65cK2XAGA5F3okdJK7WHwVAAPZnSir2sfwbhvB9ffNKQ4wLor75QxPe9p12tqt8xUZSG8i8AAPMpkFho7fbWkBJQ5s1x', + -1 + ), + ( + 'cafecafe', + 'xpub6F81iNtH5HVknoJ65cK2XAGA5F3okdJK7WHwVAAPZnSir2sfwbhvB9ffNKQ4wLor75QxPe9p12tqt8xUZSG8i8AAPMpkFho7fbWkBJQ5s1x', + 'ready', + 20, + UNIX_TIMESTAMP(), + UNIX_TIMESTAMP(), + 0, + 'xpub6F81iNtH5HVknoJ65cK2XAGA5F3okdJK7WHwVAAPZnSir2sfwbhvB9ffNKQ4wLor75QxPe9p12tqt8xUZSG8i8AAPMpkFho7fbWkBJQ5s1x', + -1 + )`; + + // Insert address records - all addresses with the same wallet_id + const addressSQL = ` + INSERT INTO address (address, \`index\`, wallet_id, transactions, seqnum) VALUES + ('HFtz2f59Lms4p3Jfgtsr73s97MbJHsRENh', 0, 'deafbeef', 0, 0), + ('HJQbEERnD5Ak3f2dsi8zAmsZrCWTT8FZns', 0, 'cafecafe', 1, 0), + ('HRQe4CXj8AZXzSmuNztU8iQR74QTQMbnTs', 1, 'deafbeef', 21, 0), + ('HRXVDmLVdq8pgok1BCUKpiFWdAVAy4a5AJ', 2, 'deafbeef', 1, 0)`; + + await mysql.query(walletSQL); + await mysql.query(addressSQL); + }; + beforeAll(async () => { jest.spyOn(Services, 'fetchMinRewardBlocks').mockImplementation(async () => 300); await cleanDatabase(mysql); diff --git a/packages/daemon/__tests__/integration/utils/index.ts b/packages/daemon/__tests__/integration/utils/index.ts index 8691fd87..f637a36d 100644 --- a/packages/daemon/__tests__/integration/utils/index.ts +++ b/packages/daemon/__tests__/integration/utils/index.ts @@ -199,53 +199,5 @@ export async function transitionUntilEvent(mysql: Connection, machine: Interpret }); } -export const initializeWallet = async (mysql: Connection): Promise => { - // Insert wallet records - const walletSQL = ` - INSERT INTO wallet ( - id, - xpubkey, - status, - max_gap, - created_at, - ready_at, - retry_count, - auth_xpubkey, - last_used_address_index - ) VALUES - ( - 'deafbeef', - 'xpub6F81iNtH5HVknoJ65cK2XAGA5F3okdJK7WHwVAAPZnSir2sfwbhvB9ffNKQ4wLor75QxPe9p12tqt8xUZSG8i8AAPMpkFho7fbWkBJQ5s1x', - 'ready', - 20, - UNIX_TIMESTAMP(), - UNIX_TIMESTAMP(), - 0, - 'xpub6F81iNtH5HVknoJ65cK2XAGA5F3okdJK7WHwVAAPZnSir2sfwbhvB9ffNKQ4wLor75QxPe9p12tqt8xUZSG8i8AAPMpkFho7fbWkBJQ5s1x', - -1 - ), - ( - 'cafecafe', - 'xpub6F81iNtH5HVknoJ65cK2XAGA5F3okdJK7WHwVAAPZnSir2sfwbhvB9ffNKQ4wLor75QxPe9p12tqt8xUZSG8i8AAPMpkFho7fbWkBJQ5s1x', - 'ready', - 20, - UNIX_TIMESTAMP(), - UNIX_TIMESTAMP(), - 0, - 'xpub6F81iNtH5HVknoJ65cK2XAGA5F3okdJK7WHwVAAPZnSir2sfwbhvB9ffNKQ4wLor75QxPe9p12tqt8xUZSG8i8AAPMpkFho7fbWkBJQ5s1x', - -1 - )`; - - // Insert address records - all addresses with the same wallet_id - const addressSQL = ` - INSERT INTO address (address, \`index\`, wallet_id, transactions, seqnum) VALUES - ('HFtz2f59Lms4p3Jfgtsr73s97MbJHsRENh', 0, 'deafbeef', 0, 0), - ('HJQbEERnD5Ak3f2dsi8zAmsZrCWTT8FZns', 0, 'cafecafe', 1, 0), - ('HRQe4CXj8AZXzSmuNztU8iQR74QTQMbnTs', 1, 'deafbeef', 21, 0), - ('HRXVDmLVdq8pgok1BCUKpiFWdAVAy4a5AJ', 2, 'deafbeef', 1, 0)`; - - await mysql.query(walletSQL); - await mysql.query(addressSQL); -}; export * from './voiding-consistency-checks'; From 5708200c3800795ce7fa967a7453bedb7172bbd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Abadesso?= Date: Thu, 11 Sep 2025 21:30:15 -0300 Subject: [PATCH 18/49] refactor(daemon): better handling of voided transactions --- packages/daemon/src/db/index.ts | 134 +++++++++++++++++--------- packages/daemon/src/services/index.ts | 17 +--- 2 files changed, 95 insertions(+), 56 deletions(-) diff --git a/packages/daemon/src/db/index.ts b/packages/daemon/src/db/index.ts index 11e2a51c..5f9485f8 100644 --- a/packages/daemon/src/db/index.ts +++ b/packages/daemon/src/db/index.ts @@ -28,6 +28,7 @@ import { TxOutputWithIndex, } from '@wallet-service/common'; import { isAuthority } from '@wallet-service/common'; +import { getWalletBalanceMap } from '../utils/wallet'; import { AddressBalanceRow, AddressTxHistorySumRow, @@ -363,39 +364,27 @@ export const getTxOutputsAtHeight = async ( }; /** - * Void a transaction by updating the related address and balance information in the database. + * Void address-related information when voiding a transaction. * * @param mysql - The MySQL connection object * @param txId - The ID of the transaction to be voided. * @param addressBalanceMap - A map where the key is an address and the value is a map of token balances. * The TokenBalanceMap contains information about the total amount sent, unlocked and locked amounts, and authorities. * - * @returns {Promise} - A promise that resolves when the transaction has been voided and the database updated + * @returns {Promise} - A promise that resolves when the address-related data has been updated * * This function performs the following steps: * 1. Inserts addresses with a transaction count of 0 into the `address` table or subtracts 1 from the transaction count if they already exist * 2. Iterates over the addressBalanceMap to update the `address_balance` table with the received token balances. * 3. Deletes the transaction entry from the `address_tx_history` table. - * 4. Updates the transaction entry in the `transaction` table to mark it as voided. * * The function ensures that the authorities are correctly updated and the smallest timelock expiration value is preserved. */ -export const voidTransaction = async ( +export const voidAddressTransaction = async ( mysql: any, txId: string, addressBalanceMap: StringMap, ): Promise => { - const [result]: [ResultSetHeader] = await mysql.query( - `UPDATE \`transaction\` - SET \`voided\` = TRUE - WHERE \`tx_id\` = ?`, - [txId], - ); - - if (result.affectedRows !== 1) { - throw new Error('Tried to void a transaction that is not in the database.'); - } - const addressEntries = Object.keys(addressBalanceMap).map((address) => [address, 0]); if (addressEntries.length > 0) { @@ -426,32 +415,47 @@ export const voidTransaction = async ( transactions: 1, }; - // save the smaller value of timelock_expires, when not null - await mysql.query( - `INSERT INTO address_balance - SET ? - ON DUPLICATE KEY - UPDATE total_received = total_received - ?, - unlocked_balance = unlocked_balance - ?, - locked_balance = locked_balance - ?, - transactions = transactions - 1, - timelock_expires = CASE - WHEN timelock_expires IS NULL THEN VALUES(timelock_expires) - WHEN VALUES(timelock_expires) IS NULL THEN timelock_expires - ELSE LEAST(timelock_expires, VALUES(timelock_expires)) - END, - unlocked_authorities = (unlocked_authorities | VALUES(unlocked_authorities)), - locked_authorities = locked_authorities | VALUES(locked_authorities)`, - [ - entry, - tokenBalance.totalAmountSent, // For total_received subtraction - tokenBalance.unlockedAmount, // For unlocked_balance subtraction - tokenBalance.lockedAmount, // For locked_balance subtraction - address, - token - ], + // Check if address_balance entry exists first + const [existingRows] = await mysql.query( + 'SELECT total_received FROM address_balance WHERE address = ? AND token_id = ?', + [address, token] ); + if (existingRows.length > 0) { + // Entry exists, perform UPDATE to subtract values + await mysql.query( + `UPDATE address_balance + SET total_received = total_received - ?, + unlocked_balance = unlocked_balance - ?, + locked_balance = locked_balance - ?, + transactions = transactions - 1, + timelock_expires = CASE + WHEN timelock_expires IS NULL THEN ? + WHEN ? IS NULL THEN timelock_expires + ELSE LEAST(timelock_expires, ?) + END, + unlocked_authorities = (unlocked_authorities | ?), + locked_authorities = locked_authorities | ? + WHERE address = ? AND token_id = ?`, + [ + tokenBalance.totalAmountSent, + tokenBalance.unlockedAmount, + tokenBalance.lockedAmount, + tokenBalance.lockExpires, + tokenBalance.lockExpires, + tokenBalance.lockExpires, + tokenBalance.unlockedAuthorities.toUnsignedInteger(), + tokenBalance.lockedAuthorities.toUnsignedInteger(), + address, + token + ] + ); + } else { + // Entry doesn't exist, this means the balance was never added in the first place + // This can happen in tests that don't simulate the complete transaction flow + console.log(`Warning: Trying to void transaction for address ${address} token ${token} but no balance entry exists`); + } + // if we're removing any of the authorities, we need to refresh the authority columns. Unlike the values, // we cannot only sum/subtract, as authorities are binary: you have it or you don't. We might be spending // an authority output in this tx without creating a new one, but it doesn't mean this address does not @@ -486,28 +490,70 @@ export const voidTransaction = async ( ); }; +/** + * Void a transaction by updating the transaction table to mark it as voided. + * + * @param mysql - The MySQL connection object + * @param txId - The ID of the transaction to be voided. + * + * @returns {Promise} - A promise that resolves when the transaction has been marked as voided + */ +export const voidTransaction = async ( + mysql: any, + txId: string, +): Promise => { + const [result]: [ResultSetHeader] = await mysql.query( + `UPDATE \`transaction\` + SET \`voided\` = TRUE + WHERE \`tx_id\` = ?`, + [txId], + ); + + if (result.affectedRows !== 1) { + throw new Error('Tried to void a transaction that is not in the database.'); + } +}; + /** * Void a transaction by updating the related wallet balance and transaction information in the database. * * @param mysql - The MySQL connection object * @param txId - The ID of the transaction to be voided. - * @param walletBalanceMap - A map where the key is a walletId and the value is a map of token balances. + * @param addressBalanceMap - A map where the key is an address and the value is a map of token balances. * The TokenBalanceMap contains information about the total amount sent, unlocked and locked amounts, and authorities. * * @returns {Promise} - A promise that resolves when the transaction has been voided and the wallet tables updated * * This function performs the following steps: - * 1. Iterates over the walletBalanceMap to update the `wallet_balance` table by reversing the transaction's balance changes. - * 2. Deletes the transaction entry from the `wallet_tx_history` table. - * 3. Updates authority columns correctly when authorities are removed. + * 1. Gets wallet information for all affected addresses + * 2. Builds wallet balance map from the address balance changes + * 3. Iterates over the walletBalanceMap to update the `wallet_balance` table by reversing the transaction's balance changes. + * 4. Deletes the transaction entry from the `wallet_tx_history` table. + * 5. Updates authority columns correctly when authorities are removed. * * The function ensures that wallet balances are correctly reverted and transaction counts are decremented. */ export const voidWalletTransaction = async ( mysql: MysqlConnection, txId: string, - walletBalanceMap: StringMap, + addressBalanceMap: StringMap, ): Promise => { + // Get wallet information for all affected addresses + const addressWalletMap: StringMap = await getAddressWalletInfo(mysql, Object.keys(addressBalanceMap)); + + if (Object.keys(addressWalletMap).length === 0) { + // No wallets to update + return; + } + + // Build wallet balance map from the address balance changes + const walletBalanceMap: StringMap = getWalletBalanceMap(addressWalletMap, addressBalanceMap); + + if (Object.keys(walletBalanceMap).length === 0) { + // No wallet balances to update + return; + } + for (const [walletId, tokenMap] of Object.entries(walletBalanceMap)) { for (const [token, tokenBalance] of tokenMap.iterator()) { // Update wallet_balance table by reversing the transaction's impact diff --git a/packages/daemon/src/services/index.ts b/packages/daemon/src/services/index.ts index 1bcfe20a..bbb89550 100644 --- a/packages/daemon/src/services/index.ts +++ b/packages/daemon/src/services/index.ts @@ -65,6 +65,7 @@ import { addNewAddresses, updateWalletTablesWithTx, voidTransaction, + voidAddressTransaction, updateLastSyncedEvent as dbUpdateLastSyncedEvent, getLastSyncedEvent, getTxOutputsFromTx, @@ -542,8 +543,9 @@ export const voidTx = async ( const addressBalanceMap: StringMap = getAddressBalanceMap(txInputs, txOutputsWithLocked, headers); + await voidTransaction(mysql, hash); await markUtxosAsVoided(mysql, dbTxOutputs); - await voidTransaction(mysql, hash, addressBalanceMap); + await voidAddressTransaction(mysql, hash, addressBalanceMap); // CRITICAL: Unspend the inputs when voiding a transaction // The inputs of the voided transaction need to be marked as unspent @@ -580,17 +582,7 @@ export const voidTx = async ( } // CRITICAL: Update wallet balances when voiding a transaction - // Get wallet information for all affected addresses - const addressWalletMap: StringMap = await getAddressWalletInfo(mysql, Object.keys(addressBalanceMap)); - - if (Object.keys(addressWalletMap).length > 0) { - // Build wallet balance map from the address balance changes - const walletBalanceMap: StringMap = getWalletBalanceMap(addressWalletMap, addressBalanceMap); - - if (Object.keys(walletBalanceMap).length > 0) { - await voidWalletTransaction(mysql, hash, walletBalanceMap); - } - } + await voidWalletTransaction(mysql, hash, addressBalanceMap); const addresses = Object.keys(addressBalanceMap); await validateAddressBalances(mysql, addresses); @@ -620,6 +612,7 @@ export const handleVoidedTx = async (context: Context) => { tokens, headers, ); + logger.debug(`Voided tx ${hash}`); await mysql.commit(); await dbUpdateLastSyncedEvent(mysql, fullNodeEvent.event.id); } catch (e) { From cffc902891a4dd59dca673fa73acf9ad94bef5f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Abadesso?= Date: Thu, 11 Sep 2025 21:30:33 -0300 Subject: [PATCH 19/49] tests(daemon): updated tests with new refactored voided transaction handling --- packages/daemon/__tests__/db/index.test.ts | 10 ++++++---- packages/daemon/__tests__/integration/balances.test.ts | 2 +- packages/daemon/__tests__/services/services.test.ts | 6 +++++- .../daemon/__tests__/services/services_with_db.test.ts | 9 ++++++++- 4 files changed, 20 insertions(+), 7 deletions(-) diff --git a/packages/daemon/__tests__/db/index.test.ts b/packages/daemon/__tests__/db/index.test.ts index 98762b12..b139d386 100644 --- a/packages/daemon/__tests__/db/index.test.ts +++ b/packages/daemon/__tests__/db/index.test.ts @@ -40,7 +40,8 @@ import { updateTxOutputSpentBy, updateWalletLockedBalance, updateWalletTablesWithTx, - voidTransaction + voidTransaction, + voidAddressTransaction } from '../../src/db'; import { Connection } from 'mysql2/promise'; import { @@ -1223,7 +1224,8 @@ describe('voidTransaction', () => { }), }; - await voidTransaction(mysql, txId, addressBalance); + await voidTransaction(mysql, txId); + await voidAddressTransaction(mysql, txId, addressBalance); await expect(checkAddressBalanceTable(mysql, 2, addr1, token2, 1n, 0n, null, 3)).resolves.toBe(true); await expect(checkAddressBalanceTable(mysql, 2, addr1, token1, 1n, 0n, null, 4)).resolves.toBe(true); @@ -1247,7 +1249,7 @@ describe('voidTransaction', () => { const addressBalance: StringMap = {}; - await expect(voidTransaction(mysql, txId, addressBalance)).resolves.not.toThrow(); + await expect(voidTransaction(mysql, txId)).resolves.not.toThrow(); // Tx should be voided await expect(checkTransactionTable(mysql, 1, txId, 0, constants.BLOCK_VERSION, true, 1)).resolves.toBe(true); }); @@ -1255,7 +1257,7 @@ describe('voidTransaction', () => { it('should throw an error if the transaction is not found in the database', async () => { expect.hasAssertions(); - await expect(voidTransaction(mysql, 'mysterious-transaction', {})).rejects.toThrow('Tried to void a transaction that is not in the database.'); + await expect(voidTransaction(mysql, 'mysterious-transaction')).rejects.toThrow('Tried to void a transaction that is not in the database.'); }); }); diff --git a/packages/daemon/__tests__/integration/balances.test.ts b/packages/daemon/__tests__/integration/balances.test.ts index ac382bcb..81a8e18b 100644 --- a/packages/daemon/__tests__/integration/balances.test.ts +++ b/packages/daemon/__tests__/integration/balances.test.ts @@ -472,7 +472,7 @@ describe('voided token authority scenario', () => { await cleanDatabase(mysql); }); - it('should do a full sync and the balances should match after voiding token authority', async () => { + it.only('should do a full sync and the balances should match after voiding token authority', async () => { // @ts-ignore getConfig.mockReturnValue({ NETWORK: 'testnet', diff --git a/packages/daemon/__tests__/services/services.test.ts b/packages/daemon/__tests__/services/services.test.ts index d983cf1c..e7dab97e 100644 --- a/packages/daemon/__tests__/services/services.test.ts +++ b/packages/daemon/__tests__/services/services.test.ts @@ -15,6 +15,7 @@ import { updateLastSyncedEvent as dbUpdateLastSyncedEvent, getTxOutputsFromTx, voidTransaction, + voidAddressTransaction, getTransactionById, getUtxosLockedAtHeight, addOrUpdateTx, @@ -67,7 +68,10 @@ jest.mock('../../src/db', () => ({ getTxOutputsFromTx: jest.fn(), getTxOutput: jest.fn(), voidTransaction: jest.fn(), + voidAddressTransaction: jest.fn(), + voidWalletTransaction: jest.fn(), markUtxosAsVoided: jest.fn(), + unspendUtxos: jest.fn(), dbUpdateLastSyncedEvent: jest.fn(), getTransactionById: jest.fn(), getUtxosLockedAtHeight: jest.fn(), @@ -416,7 +420,7 @@ describe('handleVoidedTx', () => { await handleVoidedTx(context as any); - expect(voidTransaction).toHaveBeenCalledWith(expect.any(Object), 'hashValue', {}); + expect(voidTransaction).toHaveBeenCalledWith(expect.any(Object), 'hashValue'); expect(logger.debug).toHaveBeenCalledWith('Will handle voided tx for hashValue'); expect(logger.debug).toHaveBeenCalledWith('Voided tx hashValue'); expect(mockDb.beginTransaction).toHaveBeenCalled(); diff --git a/packages/daemon/__tests__/services/services_with_db.test.ts b/packages/daemon/__tests__/services/services_with_db.test.ts index 969715db..30676784 100644 --- a/packages/daemon/__tests__/services/services_with_db.test.ts +++ b/packages/daemon/__tests__/services/services_with_db.test.ts @@ -98,7 +98,6 @@ describe('handleVoidedTx (db)', () => { expect(db.voidTransaction).toHaveBeenCalledWith( expect.any(Object), 'random-hash', - expect.any(Object), ); expect(lastEvent).toStrictEqual({ id: expect.any(Number), @@ -272,6 +271,10 @@ describe('voidTransaction with input unspending', () => { await updateTxOutputSpentBy(mysql, [inputB], txIdB); const outputB = createOutput(0, 100n, address2, tokenId); await addUtxos(mysql, txIdB, [outputB], null); + + // Only update address transaction counts (not balances) to prevent negative decrements + await mysql.query('INSERT INTO address (address, transactions) VALUES (?, 1) ON DUPLICATE KEY UPDATE transactions = transactions + 1', [address1]); + await mysql.query('INSERT INTO address (address, transactions) VALUES (?, 1) ON DUPLICATE KEY UPDATE transactions = transactions + 1', [address2]); // Transaction C spends B's output await addOrUpdateTx(mysql, txIdC, 0, 1, 1, 102); @@ -279,6 +282,10 @@ describe('voidTransaction with input unspending', () => { await updateTxOutputSpentBy(mysql, [inputC], txIdC); const outputC = createOutput(0, 100n, address3, tokenId); await addUtxos(mysql, txIdC, [outputC], null); + + // Only update address transaction counts (not balances) to prevent negative decrements + await mysql.query('INSERT INTO address (address, transactions) VALUES (?, 1) ON DUPLICATE KEY UPDATE transactions = transactions + 1', [address2]); + await mysql.query('INSERT INTO address (address, transactions) VALUES (?, 1) ON DUPLICATE KEY UPDATE transactions = transactions + 1', [address3]); // First void transaction C await voidTx(mysql, txIdC, From dea6e4952d022b56a93f7df7ff2ff4eb539f3f5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Abadesso?= Date: Thu, 11 Sep 2025 21:32:10 -0300 Subject: [PATCH 20/49] docs(daemon): added critical doc --- packages/daemon/src/services/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/daemon/src/services/index.ts b/packages/daemon/src/services/index.ts index bbb89550..2f53534e 100644 --- a/packages/daemon/src/services/index.ts +++ b/packages/daemon/src/services/index.ts @@ -544,6 +544,9 @@ export const voidTx = async ( const addressBalanceMap: StringMap = getAddressBalanceMap(txInputs, txOutputsWithLocked, headers); await voidTransaction(mysql, hash); + // CRITICAL: markUtxosAsVoided must be called before voidAddressTransaction + // and voidWalletTransaction as those methods recalculate balances based on + // the UTXOs table. await markUtxosAsVoided(mysql, dbTxOutputs); await voidAddressTransaction(mysql, hash, addressBalanceMap); From 0380f76543708905385647f60321f75ab4e2417c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Abadesso?= Date: Fri, 12 Sep 2025 13:14:37 -0300 Subject: [PATCH 21/49] tests(daemon): expect cafecafe balance --- .../scenario_configs/voided_token_authority.balances.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/daemon/__tests__/integration/scenario_configs/voided_token_authority.balances.ts b/packages/daemon/__tests__/integration/scenario_configs/voided_token_authority.balances.ts index 7728088d..f3459ebd 100644 --- a/packages/daemon/__tests__/integration/scenario_configs/voided_token_authority.balances.ts +++ b/packages/daemon/__tests__/integration/scenario_configs/voided_token_authority.balances.ts @@ -37,6 +37,11 @@ export default { unlockedBalance: 0n, lockedBalance: 0n, authorities: { unlocked: 0, locked: 0 } + }, + 'cafecafe:efb08b3e79e0ddaa6bc288183f66fe49a07ba0b7b2595861000478cc56447539': { + unlockedBalance: 0n, + lockedBalance: 0n, + authorities: { unlocked: 1, locked: 0 } } }, }; From 84ccea2ae1c9d89bd08e5318ea0ded4d24fcf1b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Abadesso?= Date: Fri, 12 Sep 2025 14:37:38 -0300 Subject: [PATCH 22/49] tests(daemon): removed .only --- packages/daemon/__tests__/integration/balances.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/daemon/__tests__/integration/balances.test.ts b/packages/daemon/__tests__/integration/balances.test.ts index 81a8e18b..ac382bcb 100644 --- a/packages/daemon/__tests__/integration/balances.test.ts +++ b/packages/daemon/__tests__/integration/balances.test.ts @@ -472,7 +472,7 @@ describe('voided token authority scenario', () => { await cleanDatabase(mysql); }); - it.only('should do a full sync and the balances should match after voiding token authority', async () => { + it('should do a full sync and the balances should match after voiding token authority', async () => { // @ts-ignore getConfig.mockReturnValue({ NETWORK: 'testnet', From ee47926db724f650dc19ea41d158c5559a01ead7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Abadesso?= Date: Fri, 12 Sep 2025 14:40:49 -0300 Subject: [PATCH 23/49] refactor(daemon): changed console.log to console.warn --- packages/daemon/src/db/index.ts | 6 +++--- packages/daemon/src/services/index.ts | 2 -- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/daemon/src/db/index.ts b/packages/daemon/src/db/index.ts index 5f9485f8..cdde6a81 100644 --- a/packages/daemon/src/db/index.ts +++ b/packages/daemon/src/db/index.ts @@ -452,8 +452,8 @@ export const voidAddressTransaction = async ( ); } else { // Entry doesn't exist, this means the balance was never added in the first place - // This can happen in tests that don't simulate the complete transaction flow - console.log(`Warning: Trying to void transaction for address ${address} token ${token} but no balance entry exists`); + // This shouldn't happen since we receive events in order + console.warn(`warning: Trying to void transaction for address ${address} token ${token} but no balance entry exists`); } // if we're removing any of the authorities, we need to refresh the authority columns. Unlike the values, @@ -492,7 +492,7 @@ export const voidAddressTransaction = async ( /** * Void a transaction by updating the transaction table to mark it as voided. - * + * * @param mysql - The MySQL connection object * @param txId - The ID of the transaction to be voided. * diff --git a/packages/daemon/src/services/index.ts b/packages/daemon/src/services/index.ts index 2f53534e..4ceef710 100644 --- a/packages/daemon/src/services/index.ts +++ b/packages/daemon/src/services/index.ts @@ -220,7 +220,6 @@ export const handleVertexAccepted = async (context: Context, _event: Event) => { const isNano = isNanoContract(headers); - const dbTx: DbTransaction | null = await getTransactionById(mysql, hash); if (dbTx) { @@ -492,7 +491,6 @@ export const handleVertexRemoved = async (context: Context, _event: Event) => { logger.info(`[VertexRemoved] Voiding tx: ${hash}`); - await voidTx( mysql, hash, From 13def6fef60437abf9bd51aaf37e458c1c5e111a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Abadesso?= Date: Fri, 12 Sep 2025 15:12:16 -0300 Subject: [PATCH 24/49] tests(daemon): forceExit and other test cleanups --- packages/daemon/__tests__/actors/HealthCheckActor.test.ts | 1 + packages/daemon/__tests__/services/services_with_db.test.ts | 3 --- packages/daemon/jest.config.js | 3 ++- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/daemon/__tests__/actors/HealthCheckActor.test.ts b/packages/daemon/__tests__/actors/HealthCheckActor.test.ts index 1b393e95..5ec26085 100644 --- a/packages/daemon/__tests__/actors/HealthCheckActor.test.ts +++ b/packages/daemon/__tests__/actors/HealthCheckActor.test.ts @@ -15,6 +15,7 @@ describe('HealthCheckActor', () => { afterAll(() => { jest.clearAllMocks(); + jest.useRealTimers(); }); it('should not start pinging on initialization', () => { diff --git a/packages/daemon/__tests__/services/services_with_db.test.ts b/packages/daemon/__tests__/services/services_with_db.test.ts index 30676784..a6b4b0af 100644 --- a/packages/daemon/__tests__/services/services_with_db.test.ts +++ b/packages/daemon/__tests__/services/services_with_db.test.ts @@ -54,8 +54,6 @@ afterAll(async () => { beforeEach(async () => { await cleanDatabase(mysql); - // Add a small delay to ensure database operations complete - await new Promise(resolve => setTimeout(resolve, 10)); }); describe('handleVoidedTx (db)', () => { @@ -91,7 +89,6 @@ describe('handleVoidedTx (db)', () => { }, }; - const mysql = await db.getDbConnection(); await expect(handleVoidedTx(context as any)).resolves.not.toThrow(); const lastEvent = await db.getLastSyncedEvent(mysql); diff --git a/packages/daemon/jest.config.js b/packages/daemon/jest.config.js index d23f73ed..8707fee2 100644 --- a/packages/daemon/jest.config.js +++ b/packages/daemon/jest.config.js @@ -10,5 +10,6 @@ module.exports = { }] }, testPathIgnorePatterns: ['/__tests__/integration/'], - moduleFileExtensions: ["ts", "js", "json", "node"] + moduleFileExtensions: ["ts", "js", "json", "node"], + forceExit: true }; From fcf0ea55aaaade25494b970766cd6211991777b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Abadesso?= Date: Fri, 12 Sep 2025 16:36:35 -0300 Subject: [PATCH 25/49] tests(daemon): using experimental docker image for new tests --- .../daemon/__tests__/integration/scripts/docker-compose.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/daemon/__tests__/integration/scripts/docker-compose.yml b/packages/daemon/__tests__/integration/scripts/docker-compose.yml index 6770ad9c..46cbfe79 100644 --- a/packages/daemon/__tests__/integration/scripts/docker-compose.yml +++ b/packages/daemon/__tests__/integration/scripts/docker-compose.yml @@ -76,7 +76,7 @@ services: - "8088:8080" transaction_voiding_chain: - image: hathornetwork/hathor-core + image: hathornetwork/hathor-core:sha-9828383b command: [ "events_simulator", "--scenario", "TRANSACTION_VOIDING_CHAIN", @@ -86,7 +86,7 @@ services: - "8089:8080" voided_token_authority: - image: hathornetwork/hathor-core + image: hathornetwork/hathor-core:sha-9828383b command: [ "events_simulator", "--scenario", "VOIDED_TOKEN_AUTHORITY", From ce6a19cbaa20221cb0ce2467532ac408369e8558 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Abadesso?= Date: Fri, 12 Sep 2025 17:29:33 -0300 Subject: [PATCH 26/49] tests(daemon): temporarily disabled genesis tx check --- packages/daemon/__tests__/integration/balances.test.ts | 4 ++++ .../single_chain_blocks_and_transactions.balances.ts | 2 +- .../transaction_voiding_chain.balances.ts | 10 +++++----- packages/daemon/__tests__/integration/utils/index.ts | 4 ++++ 4 files changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/daemon/__tests__/integration/balances.test.ts b/packages/daemon/__tests__/integration/balances.test.ts index ac382bcb..72361a7f 100644 --- a/packages/daemon/__tests__/integration/balances.test.ts +++ b/packages/daemon/__tests__/integration/balances.test.ts @@ -98,6 +98,10 @@ beforeAll(async () => { await cleanDatabase(mysql); }); +beforeEach(async () => { + await cleanDatabase(mysql); +}); + afterAll(async () => { jest.resetAllMocks(); if (mysql && 'release' in mysql) { diff --git a/packages/daemon/__tests__/integration/scenario_configs/single_chain_blocks_and_transactions.balances.ts b/packages/daemon/__tests__/integration/scenario_configs/single_chain_blocks_and_transactions.balances.ts index b603bf5a..f76ab7f4 100644 --- a/packages/daemon/__tests__/integration/scenario_configs/single_chain_blocks_and_transactions.balances.ts +++ b/packages/daemon/__tests__/integration/scenario_configs/single_chain_blocks_and_transactions.balances.ts @@ -14,7 +14,7 @@ export default { 'HRH8Wbmr1A3BrLswSBhvVE4hhsv4jUdyVA:00': { unlockedBalance: 3400n, lockedBalance: 0n }, 'HSd6PqXesUmHHv6MoN24aUiMuw7Pdcxrwk:00': { unlockedBalance: 0n, lockedBalance: 6400n }, 'HNqTfEASfdx7H4vMUGzfD2HyD3GeKuxjTJ:00': { unlockedBalance: 0n, lockedBalance: 0n }, - 'HRXVDmLVdq8pgok1BCUKpiFWdAVAy4a5AJ:00': { unlockedBalance: 0n, lockedBalance: 100000000000n }, + // 'HRXVDmLVdq8pgok1BCUKpiFWdAVAy4a5AJ:00': { unlockedBalance: 0n, lockedBalance: 100000000000n }, }, walletBalances: {}, } diff --git a/packages/daemon/__tests__/integration/scenario_configs/transaction_voiding_chain.balances.ts b/packages/daemon/__tests__/integration/scenario_configs/transaction_voiding_chain.balances.ts index b87657c1..64ea0176 100644 --- a/packages/daemon/__tests__/integration/scenario_configs/transaction_voiding_chain.balances.ts +++ b/packages/daemon/__tests__/integration/scenario_configs/transaction_voiding_chain.balances.ts @@ -30,11 +30,11 @@ export default { lockedBalance: 0n, authorities: { locked: 0, unlocked: 2 } }, - // HRXVDmLVdq8pgok1BCUKpiFWdAVAy4a5AJ - HTR (00): unlocked=0, locked=100000000000 - 'HRXVDmLVdq8pgok1BCUKpiFWdAVAy4a5AJ:00': { - unlockedBalance: 0n, - lockedBalance: 100000000000n - }, + // HRXVDmLVdq8pgok1BCUKpiFWdAVAy4a5AJ - HTR (00): unlocked=100000000000, locked=0 + /* 'HRXVDmLVdq8pgok1BCUKpiFWdAVAy4a5AJ:00': { + unlockedBalance: 100000000000n, + lockedBalance: 0n + }, */ // HPNvtPZaDF44i6CL91u4BvZPu6z2xPNt26 - HTR (00): unlocked=5900, locked=0 'HPNvtPZaDF44i6CL91u4BvZPu6z2xPNt26:00': { unlockedBalance: 5900n, diff --git a/packages/daemon/__tests__/integration/utils/index.ts b/packages/daemon/__tests__/integration/utils/index.ts index f637a36d..43822aef 100644 --- a/packages/daemon/__tests__/integration/utils/index.ts +++ b/packages/daemon/__tests__/integration/utils/index.ts @@ -45,6 +45,10 @@ export const cleanDatabase = async (mysql: Connection): Promise => { } await mysql.query('SET FOREIGN_KEY_CHECKS = 1'); + + // Ensure all changes are committed and flushed + await mysql.query('COMMIT'); + await mysql.query('FLUSH TABLES'); }; export const fetchAddressBalances = async ( From d38d2bede7ad7199d894b2ea48fd51d908cd9c32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Abadesso?= Date: Mon, 15 Sep 2025 12:43:47 -0300 Subject: [PATCH 27/49] tests(daemon): restore genesis balances --- packages/daemon/__tests__/integration/balances.test.ts | 4 ++-- .../single_chain_blocks_and_transactions.balances.ts | 2 +- .../transaction_voiding_chain.balances.ts | 10 +++++----- packages/daemon/src/machines/SyncMachine.ts | 3 ++- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/packages/daemon/__tests__/integration/balances.test.ts b/packages/daemon/__tests__/integration/balances.test.ts index 72361a7f..72b246ff 100644 --- a/packages/daemon/__tests__/integration/balances.test.ts +++ b/packages/daemon/__tests__/integration/balances.test.ts @@ -207,7 +207,7 @@ describe('single chain blocks and transactions scenario', () => { }); const machine = interpret(SyncMachine); - + // @ts-expect-error await transitionUntilEvent(mysql, machine, SINGLE_CHAIN_BLOCKS_AND_TRANSACTIONS_LAST_EVENT); const addressBalances = await fetchAddressBalances(mysql); @@ -384,7 +384,7 @@ describe('transaction voiding chain scenario', () => { }); const machine = interpret(SyncMachine); - + // @ts-ignore await transitionUntilEvent(mysql, machine, TRANSACTION_VOIDING_CHAIN_LAST_EVENT); const addressBalances = await fetchAddressBalances(mysql); diff --git a/packages/daemon/__tests__/integration/scenario_configs/single_chain_blocks_and_transactions.balances.ts b/packages/daemon/__tests__/integration/scenario_configs/single_chain_blocks_and_transactions.balances.ts index f76ab7f4..b603bf5a 100644 --- a/packages/daemon/__tests__/integration/scenario_configs/single_chain_blocks_and_transactions.balances.ts +++ b/packages/daemon/__tests__/integration/scenario_configs/single_chain_blocks_and_transactions.balances.ts @@ -14,7 +14,7 @@ export default { 'HRH8Wbmr1A3BrLswSBhvVE4hhsv4jUdyVA:00': { unlockedBalance: 3400n, lockedBalance: 0n }, 'HSd6PqXesUmHHv6MoN24aUiMuw7Pdcxrwk:00': { unlockedBalance: 0n, lockedBalance: 6400n }, 'HNqTfEASfdx7H4vMUGzfD2HyD3GeKuxjTJ:00': { unlockedBalance: 0n, lockedBalance: 0n }, - // 'HRXVDmLVdq8pgok1BCUKpiFWdAVAy4a5AJ:00': { unlockedBalance: 0n, lockedBalance: 100000000000n }, + 'HRXVDmLVdq8pgok1BCUKpiFWdAVAy4a5AJ:00': { unlockedBalance: 0n, lockedBalance: 100000000000n }, }, walletBalances: {}, } diff --git a/packages/daemon/__tests__/integration/scenario_configs/transaction_voiding_chain.balances.ts b/packages/daemon/__tests__/integration/scenario_configs/transaction_voiding_chain.balances.ts index 64ea0176..a94a2a70 100644 --- a/packages/daemon/__tests__/integration/scenario_configs/transaction_voiding_chain.balances.ts +++ b/packages/daemon/__tests__/integration/scenario_configs/transaction_voiding_chain.balances.ts @@ -30,11 +30,11 @@ export default { lockedBalance: 0n, authorities: { locked: 0, unlocked: 2 } }, - // HRXVDmLVdq8pgok1BCUKpiFWdAVAy4a5AJ - HTR (00): unlocked=100000000000, locked=0 - /* 'HRXVDmLVdq8pgok1BCUKpiFWdAVAy4a5AJ:00': { - unlockedBalance: 100000000000n, - lockedBalance: 0n - }, */ + // HRXVDmLVdq8pgok1BCUKpiFWdAVAy4a5AJ - HTR (00): unlocked=0, locked=100000000000 (genesis tx) + 'HRXVDmLVdq8pgok1BCUKpiFWdAVAy4a5AJ:00': { + unlockedBalance: 0n, + lockedBalance: 100000000000n + }, // HPNvtPZaDF44i6CL91u4BvZPu6z2xPNt26 - HTR (00): unlocked=5900, locked=0 'HPNvtPZaDF44i6CL91u4BvZPu6z2xPNt26:00': { unlockedBalance: 5900n, diff --git a/packages/daemon/src/machines/SyncMachine.ts b/packages/daemon/src/machines/SyncMachine.ts index 668a002a..e3bc6a2e 100644 --- a/packages/daemon/src/machines/SyncMachine.ts +++ b/packages/daemon/src/machines/SyncMachine.ts @@ -92,11 +92,12 @@ export const SyncMachine = Machine({ retryAttempt: 0, event: null, initialEventId: null, - txCache: new LRU(TX_CACHE_SIZE), + txCache: null as any, // Will be set on entry }, states: { [SYNC_MACHINE_STATES.INITIALIZING]: { entry: assign({ + txCache: () => new LRU(TX_CACHE_SIZE), healthcheck: () => spawn(HealthCheckActor), }), invoke: { From cd1c869d433ed13ffc9ee32207b61c6660044edc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Abadesso?= Date: Mon, 15 Sep 2025 12:45:10 -0300 Subject: [PATCH 28/49] refactor(daemon): creating LRU cache on initializing state --- packages/daemon/src/machines/SyncMachine.ts | 2 +- packages/daemon/src/types/machine.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/daemon/src/machines/SyncMachine.ts b/packages/daemon/src/machines/SyncMachine.ts index e3bc6a2e..7c112266 100644 --- a/packages/daemon/src/machines/SyncMachine.ts +++ b/packages/daemon/src/machines/SyncMachine.ts @@ -92,7 +92,7 @@ export const SyncMachine = Machine({ retryAttempt: 0, event: null, initialEventId: null, - txCache: null as any, // Will be set on entry + txCache: null, }, states: { [SYNC_MACHINE_STATES.INITIALIZING]: { diff --git a/packages/daemon/src/types/machine.ts b/packages/daemon/src/types/machine.ts index 75265b69..f987a163 100644 --- a/packages/daemon/src/types/machine.ts +++ b/packages/daemon/src/types/machine.ts @@ -15,6 +15,6 @@ export interface Context { retryAttempt: number; event?: FullNodeEvent | null; initialEventId: null | number; - txCache: LRU; + txCache: LRU | null; rewardMinBlocks?: number | null; } From 7016f917503446ac568067e01ea06120a8845357 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Abadesso?= Date: Mon, 15 Sep 2025 13:11:29 -0300 Subject: [PATCH 29/49] tests(daemon): properly mocking LRU cache --- .../__tests__/machines/SyncMachine.test.ts | 48 ++++++++++++------- packages/daemon/src/actions/index.ts | 6 ++- packages/daemon/src/guards/index.ts | 10 ++-- 3 files changed, 42 insertions(+), 22 deletions(-) diff --git a/packages/daemon/__tests__/machines/SyncMachine.test.ts b/packages/daemon/__tests__/machines/SyncMachine.test.ts index abb203d6..5fe88259 100644 --- a/packages/daemon/__tests__/machines/SyncMachine.test.ts +++ b/packages/daemon/__tests__/machines/SyncMachine.test.ts @@ -69,7 +69,7 @@ describe('machine initialization', () => { it('should fetch initial state, connect to websocket and validate network before transitioning to idle', () => { const MockedFetchMachine = SyncMachine.withConfig({ actions: { - startStream: () => {}, + startStream: () => { }, }, }); @@ -118,8 +118,10 @@ describe('machine initialization', () => { currentState = MockedFetchMachine.transition(currentState, { type: EventTypes.WEBSOCKET_EVENT, - event: { type: 'DISCONNECTED', - }}); + event: { + type: 'DISCONNECTED', + } + }); expect(currentState.matches(SYNC_MACHINE_STATES.RECONNECTING)).toBeTruthy(); }); @@ -163,7 +165,7 @@ describe('machine initialization', () => { it('should transition to RECONNECTING to reconnect after a failure', () => { const MockedFetchMachine = SyncMachine.withConfig({ actions: { - startStream: () => {}, + startStream: () => { }, }, }); @@ -215,18 +217,22 @@ describe('Event handling', () => { }); it('should validate the peerid on every message', () => { + process.env.FULLNODE_PEER_ID = 'invalidPeerId'; + const MockedFetchMachine = SyncMachine.withConfig({ guards: { invalidPeerId, - invalidStreamId: () => { - return false; - } + invalidStreamId: () => false, + invalidNetwork: () => false, }, }); let currentState = untilIdle(MockedFetchMachine); - process.env.FULLNODE_PEER_ID = 'invalidPeerId'; + // Manually initialize txCache since untilIdle doesn't execute entry actions + if (!currentState.context.txCache) { + currentState.context.txCache = new LRU(TX_CACHE_SIZE); + } currentState = MockedFetchMachine.transition(currentState, { type: EventTypes.FULLNODE_EVENT, @@ -237,15 +243,22 @@ describe('Event handling', () => { }); it('should validate the stream id on every message', () => { + process.env.STREAM_ID = 'invalidStreamId'; + const MockedFetchMachine = SyncMachine.withConfig({ guards: { + invalidPeerId: () => false, invalidStreamId, + invalidNetwork: () => false, }, }); let currentState = untilIdle(MockedFetchMachine); - process.env.STREAM_ID = 'invalidStreamId'; + // Manually initialize txCache since untilIdle doesn't execute entry actions + if (!currentState.context.txCache) { + currentState.context.txCache = new LRU(TX_CACHE_SIZE); + } currentState = MockedFetchMachine.transition(currentState, { type: EventTypes.FULLNODE_EVENT, @@ -268,23 +281,22 @@ describe('Event handling', () => { invalidNetwork: () => false, unchanged: unchangedMock, }, - }).withContext({ - event: null, - socket: null, - healthcheck: null, - retryAttempt: 0, - initialEventId: 0, - txCache: TxCache, }); unchangedMock.mockImplementation(unchanged); let currentState = untilIdle(MockedFetchMachine); + // Manually initialize txCache since untilIdle doesn't execute entry actions + if (!currentState.context.txCache) { + currentState.context.txCache = new LRU(TX_CACHE_SIZE); + } + const machineCache = currentState.context.txCache; + expect(currentState.matches(`${SYNC_MACHINE_STATES.CONNECTED}.${CONNECTED_STATES.idle}`)).toBeTruthy(); const hashedTx = hashTxData(VERTEX_METADATA_CHANGED.event.data.metadata); - TxCache.set(VERTEX_METADATA_CHANGED.event.data.hash, hashedTx); + machineCache.set(VERTEX_METADATA_CHANGED.event.data.hash, hashedTx); currentState = MockedFetchMachine.transition(currentState, { type: EventTypes.FULLNODE_EVENT, @@ -301,7 +313,7 @@ describe('Event handling', () => { // @ts-ignore: last event id should be the event we sent expect(currentState.context.event.event.id).toStrictEqual(VERTEX_METADATA_CHANGED.event.id); - TxCache.clear(); + machineCache.clear(); currentState = MockedFetchMachine.transition(currentState, { type: EventTypes.FULLNODE_EVENT, diff --git a/packages/daemon/src/actions/index.ts b/packages/daemon/src/actions/index.ts index 845b10b6..dfd949c4 100644 --- a/packages/daemon/src/actions/index.ts +++ b/packages/daemon/src/actions/index.ts @@ -166,11 +166,15 @@ export const metadataDecided = raise((_context: Context, event: Event) => ({ * Updates the cache with the last processed event (from the context) */ export const updateCache = (context: Context) => { + if (!context.txCache) { + throw new Error('TxCache was not initialized'); + } + const fullNodeEvent = context.event as StandardFullNodeEvent; if (!fullNodeEvent) { return; } - const { metadata, hash } = fullNodeEvent.event.data; + const { metadata, hash } = fullNodeEvent.event.data; const hashedTxData = hashTxData(metadata); context.txCache.set(hash, hashedTxData); diff --git a/packages/daemon/src/guards/index.ts b/packages/daemon/src/guards/index.ts index e6162c4a..6e26444b 100644 --- a/packages/daemon/src/guards/index.ts +++ b/packages/daemon/src/guards/index.ts @@ -195,8 +195,8 @@ export const voided = (_context: Context, event: Event) => { } if (event.event.event.type !== FullNodeEventTypes.VERTEX_METADATA_CHANGED - && event.event.event.type !== FullNodeEventTypes.NEW_VERTEX_ACCEPTED) { - return false; + && event.event.event.type !== FullNodeEventTypes.NEW_VERTEX_ACCEPTED) { + return false; } const fullNodeEvent = event.event.event; @@ -218,7 +218,7 @@ export const unchanged = (context: Context, event: Event) => { } if (event.event.event.type !== FullNodeEventTypes.VERTEX_METADATA_CHANGED - && event.event.event.type !== FullNodeEventTypes.NEW_VERTEX_ACCEPTED) { + && event.event.event.type !== FullNodeEventTypes.NEW_VERTEX_ACCEPTED) { // Not unchanged return false; @@ -227,6 +227,10 @@ export const unchanged = (context: Context, event: Event) => { const { data } = event.event.event; const txCache = context.txCache; + if (!txCache) { + throw new Error('txCache is not initialized in context'); + } + const txHashFromCache = txCache.get(data.hash); // Not on the cache, it's not unchanged. if (!txHashFromCache) { From 31b5cef42046e2b3a902927e7c47a58d275c3911 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Abadesso?= Date: Wed, 17 Sep 2025 10:31:13 -0300 Subject: [PATCH 30/49] tests(daemon): added single_voided_create_token_tx scenario --- .../__tests__/integration/balances.test.ts | 108 +++++++++++++++++- .../daemon/__tests__/integration/config.ts | 4 + ...oided_create_token_transaction.balances.ts | 18 +++ .../integration/scripts/docker-compose.yml | 10 ++ packages/daemon/package.json | 2 +- packages/daemon/src/db/index.ts | 17 --- 6 files changed, 138 insertions(+), 21 deletions(-) create mode 100644 packages/daemon/__tests__/integration/scenario_configs/single_voided_create_token_transaction.balances.ts diff --git a/packages/daemon/__tests__/integration/balances.test.ts b/packages/daemon/__tests__/integration/balances.test.ts index 72b246ff..06a10ac0 100644 --- a/packages/daemon/__tests__/integration/balances.test.ts +++ b/packages/daemon/__tests__/integration/balances.test.ts @@ -29,6 +29,7 @@ import customScriptBalances from './scenario_configs/custom_script.balances'; import ncEventsBalances from './scenario_configs/nc_events.balances'; import transactionVoidingChainBalances from './scenario_configs/transaction_voiding_chain.balances'; import voidedTokenAuthorityBalances from './scenario_configs/voided_token_authority.balances'; +import singleVoidedCreateTokenTransactionBalances from './scenario_configs/single_voided_create_token_transaction.balances'; import { DB_NAME, @@ -54,6 +55,8 @@ import { TRANSACTION_VOIDING_CHAIN_LAST_EVENT, VOIDED_TOKEN_AUTHORITY_PORT, VOIDED_TOKEN_AUTHORITY_LAST_EVENT, + SINGLE_VOIDED_CREATE_TOKEN_TRANSACTION_PORT, + SINGLE_VOIDED_CREATE_TOKEN_TRANSACTION_LAST_EVENT, } from './config'; jest.mock('../../src/config', () => { @@ -207,7 +210,7 @@ describe('single chain blocks and transactions scenario', () => { }); const machine = interpret(SyncMachine); - + // @ts-expect-error await transitionUntilEvent(mysql, machine, SINGLE_CHAIN_BLOCKS_AND_TRANSACTIONS_LAST_EVENT); const addressBalances = await fetchAddressBalances(mysql); @@ -384,7 +387,7 @@ describe('transaction voiding chain scenario', () => { }); const machine = interpret(SyncMachine); - + // @ts-ignore await transitionUntilEvent(mysql, machine, TRANSACTION_VOIDING_CHAIN_LAST_EVENT); const addressBalances = await fetchAddressBalances(mysql); @@ -473,7 +476,7 @@ describe('voided token authority scenario', () => { afterAll(async () => { // Clean up wallet data after this test to prevent affecting other tests - await cleanDatabase(mysql); + // await cleanDatabase(mysql); }); it('should do a full sync and the balances should match after voiding token authority', async () => { @@ -527,3 +530,102 @@ describe('voided token authority scenario', () => { validateVoidingConsistency(voidingChecks); }, 30000); // 30 second timeout for voided token authority test }); + +describe('single voided create token transaction scenario', () => { + beforeAll(async () => { + jest.spyOn(Services, 'fetchMinRewardBlocks').mockImplementation(async () => 300); + await cleanDatabase(mysql); + }); + + it.only('should do a full sync and the balances should match', async () => { + // @ts-expect-error + getConfig.mockReturnValue({ + NETWORK: 'testnet', + SERVICE_NAME: 'daemon-test', + CONSOLE_LEVEL: 'debug', + TX_CACHE_SIZE: 100, + BLOCK_REWARD_LOCK: 300, + FULLNODE_PEER_ID: 'simulator_peer_id', + STREAM_ID: 'simulator_stream_id', + FULLNODE_NETWORK: 'unittests', + FULLNODE_HOST: `127.0.0.1:${SINGLE_VOIDED_CREATE_TOKEN_TRANSACTION_PORT}`, + USE_SSL: false, + DB_ENDPOINT, + DB_NAME, + DB_USER, + DB_PASS, + DB_PORT, + }); + + const machine = interpret(SyncMachine); + + // @ts-expect-error + await transitionUntilEvent(mysql, machine, SINGLE_VOIDED_CREATE_TOKEN_TRANSACTION_LAST_EVENT); + const addressBalances = await fetchAddressBalances(mysql); + await expect(validateBalances(addressBalances, singleVoidedCreateTokenTransactionBalances.addressBalances)).resolves.not.toThrow(); + }, 30000); + + it.only('should expose the address_balance vs address_tx_history length mismatch issue', async () => { + // @ts-expect-error + getConfig.mockReturnValue({ + NETWORK: 'testnet', + SERVICE_NAME: 'daemon-test', + CONSOLE_LEVEL: 'debug', + TX_CACHE_SIZE: 100, + BLOCK_REWARD_LOCK: 300, + FULLNODE_PEER_ID: 'simulator_peer_id', + STREAM_ID: 'simulator_stream_id', + FULLNODE_NETWORK: 'unittests', + FULLNODE_HOST: `127.0.0.1:${SINGLE_VOIDED_CREATE_TOKEN_TRANSACTION_PORT}`, + USE_SSL: false, + DB_ENDPOINT, + DB_NAME, + DB_USER, + DB_PASS, + DB_PORT, + }); + + const machine = interpret(SyncMachine); + + // @ts-expect-error + await transitionUntilEvent(mysql, machine, SINGLE_VOIDED_CREATE_TOKEN_TRANSACTION_LAST_EVENT); + + // Check for addresses that have balances for a specific token + const addressBalanceResults = await mysql.query(` + SELECT token_id, + SUM(total_received) AS total_received, + SUM(unlocked_balance) AS unlocked_balance, + SUM(locked_balance) AS locked_balance, + MIN(timelock_expires) AS timelock_expires, + BIT_OR(unlocked_authorities) AS unlocked_authorities, + BIT_OR(locked_authorities) AS locked_authorities + FROM address_balance + WHERE token_id LIKE '%' -- Get all tokens + GROUP BY token_id + ORDER BY token_id + `); + + // Check for transaction history for the same tokens (excluding voided) + const txHistoryResults = await mysql.query(` + SELECT token_id, + SUM(balance) AS balance, + COUNT(DISTINCT tx_id) AS transactions + FROM address_tx_history + WHERE voided = FALSE + AND token_id LIKE '%' -- Get all tokens + GROUP BY token_id + ORDER BY token_id + `); + + console.log({ + addressBalanceResults, + txHistoryResults + }); + + // Cast to array to access length property + const addressRows = addressBalanceResults[0] as any[]; + const txHistoryRows = txHistoryResults[0] as any[]; + + expect(addressRows.length).toEqual(txHistoryRows.length); + }, 30000); +}); diff --git a/packages/daemon/__tests__/integration/config.ts b/packages/daemon/__tests__/integration/config.ts index 5705276f..45e4b29f 100644 --- a/packages/daemon/__tests__/integration/config.ts +++ b/packages/daemon/__tests__/integration/config.ts @@ -43,6 +43,9 @@ export const TRANSACTION_VOIDING_CHAIN_LAST_EVENT = 52; export const VOIDED_TOKEN_AUTHORITY_PORT = 8090; export const VOIDED_TOKEN_AUTHORITY_LAST_EVENT = 66; +export const SINGLE_VOIDED_CREATE_TOKEN_TRANSACTION_PORT = 8091; +export const SINGLE_VOIDED_CREATE_TOKEN_TRANSACTION_LAST_EVENT = 50; // TODO: Update with actual last event number + export const SCENARIOS = [ 'UNVOIDED_SCENARIO', 'REORG_SCENARIO', @@ -53,4 +56,5 @@ export const SCENARIOS = [ 'NC_EVENTS', 'TRANSACTION_VOIDING_CHAIN', 'VOIDED_TOKEN_AUTHORITY', + 'SINGLE_VOIDED_CREATE_TOKEN_TRANSACTION', ]; diff --git a/packages/daemon/__tests__/integration/scenario_configs/single_voided_create_token_transaction.balances.ts b/packages/daemon/__tests__/integration/scenario_configs/single_voided_create_token_transaction.balances.ts new file mode 100644 index 00000000..61fa8e56 --- /dev/null +++ b/packages/daemon/__tests__/integration/scenario_configs/single_voided_create_token_transaction.balances.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) Hathor Labs and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +export default { + addressBalances: { + 'HFtz2f59Lms4p3Jfgtsr73s97MbJHsRENh:00': { unlockedBalance: 6400n, lockedBalance: 12800n }, + 'HRQe4CXj8AZXzSmuNztU8iQR74QTQMbnTs:00': { unlockedBalance: 0n, lockedBalance: 64000n }, + 'HRQe4CXj8AZXzSmuNztU8iQR74QTQMbnTs:efb08b3e79e0ddaa6bc288183f66fe49a07ba0b7b2595861000478cc56447539': { + unlockedBalance: 0n, + lockedBalance: 0n, + }, + 'HRXVDmLVdq8pgok1BCUKpiFWdAVAy4a5AJ:00': { unlockedBalance: 0n, lockedBalance: 100000000000n }, + }, +}; \ No newline at end of file diff --git a/packages/daemon/__tests__/integration/scripts/docker-compose.yml b/packages/daemon/__tests__/integration/scripts/docker-compose.yml index 46cbfe79..bf443331 100644 --- a/packages/daemon/__tests__/integration/scripts/docker-compose.yml +++ b/packages/daemon/__tests__/integration/scripts/docker-compose.yml @@ -95,5 +95,15 @@ services: ports: - "8090:8080" + single_voided_create_token_transaction: + image: hathornetwork/hathor-core + command: [ + "events_simulator", + "--scenario", "SINGLE_VOIDED_CREATE_TOKEN_TRANSACTION", + "--seed", "1" + ] + ports: + - "8091:8080" + networks: database: diff --git a/packages/daemon/package.json b/packages/daemon/package.json index 44da1f29..e50b20a5 100644 --- a/packages/daemon/package.json +++ b/packages/daemon/package.json @@ -22,7 +22,7 @@ "test_images_wait_for_ws": "yarn dlx ts-node ./__tests__/integration/scripts/wait-for-ws-up.ts", "test_images_setup_database": "yarn dlx ts-node ./__tests__/integration/scripts/setup-database.ts", "test": "jest --coverage --runInBand", - "test_integration": "yarn run test_images_up && yarn run test_images_wait_for_db && yarn run test_images_wait_for_ws && yarn run test_images_setup_database && yarn run test_images_migrate && yarn run test_images_integration && yarn run test_images_down" + "test_integration": "yarn run test_images_up && yarn run test_images_wait_for_db && yarn run test_images_wait_for_ws && yarn run test_images_setup_database && yarn run test_images_migrate && yarn run test_images_integration" }, "name": "sync-daemon", "author": "André Abadesso", diff --git a/packages/daemon/src/db/index.ts b/packages/daemon/src/db/index.ts index cdde6a81..6637a5ad 100644 --- a/packages/daemon/src/db/index.ts +++ b/packages/daemon/src/db/index.ts @@ -398,23 +398,6 @@ export const voidAddressTransaction = async ( for (const [address, tokenMap] of Object.entries(addressBalanceMap)) { for (const [token, tokenBalance] of tokenMap.iterator()) { - // update address_balance table or update balance and transactions if there's an entry already - const entry = { - address, - token_id: token, - // totalAmountSent is the sum of the value of all outputs of this token on the tx being sent to this address - // which means it is the "total_received" for this address - total_received: tokenBalance.totalAmountSent, - // if it's < 0, there must be an entry already, so it will execute "ON DUPLICATE KEY UPDATE" instead of setting it to 0 - unlocked_balance: (tokenBalance.unlockedAmount < 0 ? 0 : tokenBalance.unlockedAmount), - // this is never less than 0, as locked balance only changes when a tx is unlocked - locked_balance: tokenBalance.lockedAmount, - unlocked_authorities: tokenBalance.unlockedAuthorities.toUnsignedInteger(), - locked_authorities: tokenBalance.lockedAuthorities.toUnsignedInteger(), - timelock_expires: tokenBalance.lockExpires, - transactions: 1, - }; - // Check if address_balance entry exists first const [existingRows] = await mysql.query( 'SELECT total_received FROM address_balance WHERE address = ? AND token_id = ?', From 8b99e4396fc4a85b8d298fc4dcc4b025def23b13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Abadesso?= Date: Wed, 17 Sep 2025 12:56:59 -0300 Subject: [PATCH 31/49] fix(daemon): removing address_balance row and token row when token creation tx is voided --- .../__tests__/integration/balances.test.ts | 10 +++++ packages/daemon/src/db/index.ts | 37 ++++++++++++++++++- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/packages/daemon/__tests__/integration/balances.test.ts b/packages/daemon/__tests__/integration/balances.test.ts index 5c738b00..f69a248c 100644 --- a/packages/daemon/__tests__/integration/balances.test.ts +++ b/packages/daemon/__tests__/integration/balances.test.ts @@ -626,5 +626,15 @@ describe('single voided create token transaction scenario', () => { const txHistoryRows = txHistoryResults[0] as any[]; expect(addressRows.length).toEqual(txHistoryRows.length); + + // Verify that the voided token was removed from the token table + const voidedTokenId = 'efb08b3e79e0ddaa6bc288183f66fe49a07ba0b7b2595861000478cc56447539'; + const tokenResults = await mysql.query( + 'SELECT * FROM token WHERE id = ?', + [voidedTokenId] + ); + + // Token should not exist in the database after being voided + expect(tokenResults[0]).toHaveLength(0); }, 30000); }); diff --git a/packages/daemon/src/db/index.ts b/packages/daemon/src/db/index.ts index 6637a5ad..5a7ea562 100644 --- a/packages/daemon/src/db/index.ts +++ b/packages/daemon/src/db/index.ts @@ -40,6 +40,7 @@ import { TxOutputRow, } from '../types'; import getConfig from '../config'; +import { constants } from '@hathor/wallet-lib'; let pool: Pool; @@ -396,11 +397,20 @@ export const voidAddressTransaction = async ( ); } + // Check if this is a token creation transaction + const [txResults] = await mysql.query( + 'SELECT version FROM transaction WHERE tx_id = ?', + [txId] + ); + + const isCreateTokenTx = txResults.length > 0 + && txResults[0].version === constants.CREATE_TOKEN_TX_VERSION; + for (const [address, tokenMap] of Object.entries(addressBalanceMap)) { for (const [token, tokenBalance] of tokenMap.iterator()) { // Check if address_balance entry exists first const [existingRows] = await mysql.query( - 'SELECT total_received FROM address_balance WHERE address = ? AND token_id = ?', + 'SELECT * FROM address_balance WHERE address = ? AND token_id = ?', [address, token] ); @@ -463,6 +473,31 @@ export const voidAddressTransaction = async ( // for locked authorities, it doesn't make sense to perform the same operation. The authority needs to be // unlocked before it can be spent. In case we're just adding new locked authorities, this will be taken // care by the first sql query. + + // If this is a token creation transaction, we must remove the address_balance + // row for that token as it has ceased to exist + if (isCreateTokenTx) { + await mysql.query( + `DELETE FROM address_balance + WHERE address = ? + AND token_id = ? + AND total_received = 0 + AND unlocked_balance = 0 + AND locked_balance = 0 + AND unlocked_authorities = 0 + AND locked_authorities = 0 + AND transactions = 0`, + [address, token] + ); + + // The transaction that created the token was voided, so we can remove + // it from the tokens table as well. + await mysql.query( + `DELETE FROM token + WHERE id = ?`, + [token] + ); + } } } From 396fb1d2521748c09bf70cc11f434492cfcc50a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Abadesso?= Date: Wed, 17 Sep 2025 13:41:07 -0300 Subject: [PATCH 32/49] tests(daemon): validate that wallet_balance for the voided token does not exist --- .../__tests__/integration/balances.test.ts | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/packages/daemon/__tests__/integration/balances.test.ts b/packages/daemon/__tests__/integration/balances.test.ts index f69a248c..2d5a0e12 100644 --- a/packages/daemon/__tests__/integration/balances.test.ts +++ b/packages/daemon/__tests__/integration/balances.test.ts @@ -531,6 +531,41 @@ describe('voided token authority scenario', () => { }); describe('single voided create token transaction scenario', () => { + const initializeWallet = async (mysql: Connection): Promise => { + // Insert wallet records + const walletSQL = ` + INSERT INTO wallet ( + id, + xpubkey, + status, + max_gap, + created_at, + ready_at, + retry_count, + auth_xpubkey, + last_used_address_index + ) VALUES + ( + 'test-wallet-voided-token', + 'xpub6F81iNtH5HVknoJ65cK2XAGA5F3okdJK7WHwVAAPZnSir2sfwbhvB9ffNKQ4wLor75QxPe9p12tqt8xUZSG8i8AAPMpkFho7fbWkBJQ5s1x', + 'ready', + 20, + UNIX_TIMESTAMP(), + UNIX_TIMESTAMP(), + 0, + 'xpub6F81iNtH5HVknoJ65cK2XAGA5F3okdJK7WHwVAAPZnSir2sfwbhvB9ffNKQ4wLor75QxPe9p12tqt8xUZSG8i8AAPMpkFho7fbWkBJQ5s1x', + -1 + )`; + + // Insert address records that will receive the voided token + const addressSQL = ` + INSERT INTO address (address, \`index\`, wallet_id, transactions, seqnum) VALUES + ('HRQe4CXj8AZXzSmuNztU8iQR74QTQMbnTs', 0, 'test-wallet-voided-token', 0, 0)`; + + await mysql.query(walletSQL); + await mysql.query(addressSQL); + }; + beforeAll(async () => { jest.spyOn(Services, 'fetchMinRewardBlocks').mockImplementation(async () => 300); await cleanDatabase(mysql); @@ -584,6 +619,9 @@ describe('single voided create token transaction scenario', () => { DB_PORT, }); + // Initialize wallet before processing events + await initializeWallet(mysql); + const machine = interpret(SyncMachine); // @ts-expect-error @@ -636,5 +674,14 @@ describe('single voided create token transaction scenario', () => { // Token should not exist in the database after being voided expect(tokenResults[0]).toHaveLength(0); + + // Verify that the wallet_balance table doesn't contain the voided token + const walletBalanceResults = await mysql.query( + 'SELECT * FROM wallet_balance WHERE token_id = ?', + [voidedTokenId] + ); + + // Wallet balance should not exist for the voided token + expect(walletBalanceResults[0]).toHaveLength(0); }, 30000); }); From bdf0f664a21be63d9c4bce5c42982d6e9e1f84f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Abadesso?= Date: Wed, 17 Sep 2025 13:54:38 -0300 Subject: [PATCH 33/49] fix(daemon): removing wallet_balance row for voided token creation transactions --- packages/daemon/src/db/index.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/packages/daemon/src/db/index.ts b/packages/daemon/src/db/index.ts index 5a7ea562..78589824 100644 --- a/packages/daemon/src/db/index.ts +++ b/packages/daemon/src/db/index.ts @@ -572,6 +572,14 @@ export const voidWalletTransaction = async ( return; } + // Check if this is a token creation transaction + const [txResults] = await mysql.query( + 'SELECT version FROM transaction WHERE tx_id = ?', + [txId] + ); + const isCreateTokenTx = (txResults as any[]).length > 0 + && (txResults as any[])[0].version === constants.CREATE_TOKEN_TX_VERSION; + for (const [walletId, tokenMap] of Object.entries(walletBalanceMap)) { for (const [token, tokenBalance] of tokenMap.iterator()) { // Update wallet_balance table by reversing the transaction's impact @@ -618,6 +626,22 @@ export const voidWalletTransaction = async ( [walletId, token, walletId, token], ); } + + // If this is a token creation transaction, clean up zeroed entries + if (isCreateTokenTx) { + await mysql.query( + `DELETE FROM wallet_balance + WHERE wallet_id = ? + AND token_id = ? + AND total_received = 0 + AND unlocked_balance = 0 + AND locked_balance = 0 + AND unlocked_authorities = 0 + AND locked_authorities = 0 + AND transactions = 0`, + [walletId, token] + ); + } } } From c39b0dc6e16a38bb5ee48370cc29edadf0d02c35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Abadesso?= Date: Wed, 17 Sep 2025 14:07:56 -0300 Subject: [PATCH 34/49] tests(daemon): restore all tests --- packages/daemon/__tests__/integration/balances.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/daemon/__tests__/integration/balances.test.ts b/packages/daemon/__tests__/integration/balances.test.ts index 2d5a0e12..117fc71e 100644 --- a/packages/daemon/__tests__/integration/balances.test.ts +++ b/packages/daemon/__tests__/integration/balances.test.ts @@ -571,7 +571,7 @@ describe('single voided create token transaction scenario', () => { await cleanDatabase(mysql); }); - it.only('should do a full sync and the balances should match', async () => { + it('should do a full sync and the balances should match', async () => { // @ts-expect-error getConfig.mockReturnValue({ NETWORK: 'testnet', @@ -599,7 +599,7 @@ describe('single voided create token transaction scenario', () => { await expect(validateBalances(addressBalances, singleVoidedCreateTokenTransactionBalances.addressBalances)).resolves.not.toThrow(); }, 30000); - it.only('should expose the address_balance vs address_tx_history length mismatch issue', async () => { + it('should expose the address_balance vs address_tx_history length mismatch issue', async () => { // @ts-expect-error getConfig.mockReturnValue({ NETWORK: 'testnet', From 545c9ec1511f72c38f93222aade02ff86321f3c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Abadesso?= Date: Wed, 17 Sep 2025 14:09:07 -0300 Subject: [PATCH 35/49] refactor(daemon): sending version from the event instead of querying --- packages/daemon/src/db/index.ts | 17 ++++------------- packages/daemon/src/services/index.ts | 9 +++++++-- 2 files changed, 11 insertions(+), 15 deletions(-) diff --git a/packages/daemon/src/db/index.ts b/packages/daemon/src/db/index.ts index 78589824..9aec1ac7 100644 --- a/packages/daemon/src/db/index.ts +++ b/packages/daemon/src/db/index.ts @@ -385,6 +385,7 @@ export const voidAddressTransaction = async ( mysql: any, txId: string, addressBalanceMap: StringMap, + version?: number, ): Promise => { const addressEntries = Object.keys(addressBalanceMap).map((address) => [address, 0]); @@ -398,13 +399,7 @@ export const voidAddressTransaction = async ( } // Check if this is a token creation transaction - const [txResults] = await mysql.query( - 'SELECT version FROM transaction WHERE tx_id = ?', - [txId] - ); - - const isCreateTokenTx = txResults.length > 0 - && txResults[0].version === constants.CREATE_TOKEN_TX_VERSION; + const isCreateTokenTx = version === constants.CREATE_TOKEN_TX_VERSION; for (const [address, tokenMap] of Object.entries(addressBalanceMap)) { for (const [token, tokenBalance] of tokenMap.iterator()) { @@ -555,6 +550,7 @@ export const voidWalletTransaction = async ( mysql: MysqlConnection, txId: string, addressBalanceMap: StringMap, + version?: number, ): Promise => { // Get wallet information for all affected addresses const addressWalletMap: StringMap = await getAddressWalletInfo(mysql, Object.keys(addressBalanceMap)); @@ -573,12 +569,7 @@ export const voidWalletTransaction = async ( } // Check if this is a token creation transaction - const [txResults] = await mysql.query( - 'SELECT version FROM transaction WHERE tx_id = ?', - [txId] - ); - const isCreateTokenTx = (txResults as any[]).length > 0 - && (txResults as any[])[0].version === constants.CREATE_TOKEN_TX_VERSION; + const isCreateTokenTx = version === constants.CREATE_TOKEN_TX_VERSION; for (const [walletId, tokenMap] of Object.entries(walletBalanceMap)) { for (const [token, tokenBalance] of tokenMap.iterator()) { diff --git a/packages/daemon/src/services/index.ts b/packages/daemon/src/services/index.ts index 4ceef710..cc7c4539 100644 --- a/packages/daemon/src/services/index.ts +++ b/packages/daemon/src/services/index.ts @@ -481,6 +481,7 @@ export const handleVertexRemoved = async (context: Context, _event: Event) => { inputs, tokens, headers = [], + version, } = fullNodeEvent.event.data; const dbTx: DbTransaction | null = await getTransactionById(mysql, hash); @@ -498,6 +499,7 @@ export const handleVertexRemoved = async (context: Context, _event: Event) => { outputs, tokens, headers, + version, ); logger.info(`[VertexRemoved] Removing tx from database: ${hash}`); @@ -521,6 +523,7 @@ export const voidTx = async ( outputs: EventTxOutput[], tokens: string[], headers: EventTxHeader[], + version?: number, ) => { const dbTxOutputs: DbTxOutput[] = await getTxOutputsFromTx(mysql, hash); const txOutputs: TxOutputWithIndex[] = prepareOutputs(outputs, tokens); @@ -546,7 +549,7 @@ export const voidTx = async ( // and voidWalletTransaction as those methods recalculate balances based on // the UTXOs table. await markUtxosAsVoided(mysql, dbTxOutputs); - await voidAddressTransaction(mysql, hash, addressBalanceMap); + await voidAddressTransaction(mysql, hash, addressBalanceMap, version); // CRITICAL: Unspend the inputs when voiding a transaction // The inputs of the voided transaction need to be marked as unspent @@ -583,7 +586,7 @@ export const voidTx = async ( } // CRITICAL: Update wallet balances when voiding a transaction - await voidWalletTransaction(mysql, hash, addressBalanceMap); + await voidWalletTransaction(mysql, hash, addressBalanceMap, version); const addresses = Object.keys(addressBalanceMap); await validateAddressBalances(mysql, addresses); @@ -602,6 +605,7 @@ export const handleVoidedTx = async (context: Context) => { inputs, tokens, headers = [], + version, } = fullNodeEvent.event.data; logger.debug(`Will handle voided tx for ${hash}`); @@ -612,6 +616,7 @@ export const handleVoidedTx = async (context: Context) => { outputs, tokens, headers, + version, ); logger.debug(`Voided tx ${hash}`); await mysql.commit(); From fc9614a0f579fc990b1184875216ac88cef98a4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Abadesso?= Date: Wed, 17 Sep 2025 14:12:42 -0300 Subject: [PATCH 36/49] tests(daemon): checking specific token instead of all tokens --- .../daemon/__tests__/integration/balances.test.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/daemon/__tests__/integration/balances.test.ts b/packages/daemon/__tests__/integration/balances.test.ts index 117fc71e..77924628 100644 --- a/packages/daemon/__tests__/integration/balances.test.ts +++ b/packages/daemon/__tests__/integration/balances.test.ts @@ -475,7 +475,7 @@ describe('voided token authority scenario', () => { afterAll(async () => { // Clean up wallet data after this test to prevent affecting other tests - // await cleanDatabase(mysql); + await cleanDatabase(mysql); }); it('should do a full sync and the balances should match after voiding token authority', async () => { @@ -627,6 +627,8 @@ describe('single voided create token transaction scenario', () => { // @ts-expect-error await transitionUntilEvent(mysql, machine, SINGLE_VOIDED_CREATE_TOKEN_TRANSACTION_LAST_EVENT); + const voidedTokenId = 'efb08b3e79e0ddaa6bc288183f66fe49a07ba0b7b2595861000478cc56447539'; + // Check for addresses that have balances for a specific token const addressBalanceResults = await mysql.query(` SELECT token_id, @@ -637,10 +639,10 @@ describe('single voided create token transaction scenario', () => { BIT_OR(unlocked_authorities) AS unlocked_authorities, BIT_OR(locked_authorities) AS locked_authorities FROM address_balance - WHERE token_id LIKE '%' -- Get all tokens + WHERE token_id = ? GROUP BY token_id ORDER BY token_id - `); + `, [voidedTokenId]); // Check for transaction history for the same tokens (excluding voided) const txHistoryResults = await mysql.query(` @@ -649,10 +651,10 @@ describe('single voided create token transaction scenario', () => { COUNT(DISTINCT tx_id) AS transactions FROM address_tx_history WHERE voided = FALSE - AND token_id LIKE '%' -- Get all tokens + AND token_id = ? GROUP BY token_id ORDER BY token_id - `); + `, [voidedTokenId]); console.log({ addressBalanceResults, @@ -666,7 +668,6 @@ describe('single voided create token transaction scenario', () => { expect(addressRows.length).toEqual(txHistoryRows.length); // Verify that the voided token was removed from the token table - const voidedTokenId = 'efb08b3e79e0ddaa6bc288183f66fe49a07ba0b7b2595861000478cc56447539'; const tokenResults = await mysql.query( 'SELECT * FROM token WHERE id = ?', [voidedTokenId] From e4efac8badc890da4537a51d1acd3631273adc7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Abadesso?= Date: Wed, 17 Sep 2025 14:13:23 -0300 Subject: [PATCH 37/49] tests(daemon): removed unused console log and comment --- packages/daemon/__tests__/integration/balances.test.ts | 5 ----- packages/daemon/__tests__/integration/config.ts | 2 +- packages/daemon/package.json | 2 +- 3 files changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/daemon/__tests__/integration/balances.test.ts b/packages/daemon/__tests__/integration/balances.test.ts index 77924628..a0aa7e3f 100644 --- a/packages/daemon/__tests__/integration/balances.test.ts +++ b/packages/daemon/__tests__/integration/balances.test.ts @@ -656,11 +656,6 @@ describe('single voided create token transaction scenario', () => { ORDER BY token_id `, [voidedTokenId]); - console.log({ - addressBalanceResults, - txHistoryResults - }); - // Cast to array to access length property const addressRows = addressBalanceResults[0] as any[]; const txHistoryRows = txHistoryResults[0] as any[]; diff --git a/packages/daemon/__tests__/integration/config.ts b/packages/daemon/__tests__/integration/config.ts index 45e4b29f..3f1706df 100644 --- a/packages/daemon/__tests__/integration/config.ts +++ b/packages/daemon/__tests__/integration/config.ts @@ -44,7 +44,7 @@ export const VOIDED_TOKEN_AUTHORITY_PORT = 8090; export const VOIDED_TOKEN_AUTHORITY_LAST_EVENT = 66; export const SINGLE_VOIDED_CREATE_TOKEN_TRANSACTION_PORT = 8091; -export const SINGLE_VOIDED_CREATE_TOKEN_TRANSACTION_LAST_EVENT = 50; // TODO: Update with actual last event number +export const SINGLE_VOIDED_CREATE_TOKEN_TRANSACTION_LAST_EVENT = 50; export const SCENARIOS = [ 'UNVOIDED_SCENARIO', diff --git a/packages/daemon/package.json b/packages/daemon/package.json index e50b20a5..44da1f29 100644 --- a/packages/daemon/package.json +++ b/packages/daemon/package.json @@ -22,7 +22,7 @@ "test_images_wait_for_ws": "yarn dlx ts-node ./__tests__/integration/scripts/wait-for-ws-up.ts", "test_images_setup_database": "yarn dlx ts-node ./__tests__/integration/scripts/setup-database.ts", "test": "jest --coverage --runInBand", - "test_integration": "yarn run test_images_up && yarn run test_images_wait_for_db && yarn run test_images_wait_for_ws && yarn run test_images_setup_database && yarn run test_images_migrate && yarn run test_images_integration" + "test_integration": "yarn run test_images_up && yarn run test_images_wait_for_db && yarn run test_images_wait_for_ws && yarn run test_images_setup_database && yarn run test_images_migrate && yarn run test_images_integration && yarn run test_images_down" }, "name": "sync-daemon", "author": "André Abadesso", From 107891d5d940c6c3b5f3a5c41fa9cd11ff7dfb0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Abadesso?= Date: Wed, 17 Sep 2025 14:17:45 -0300 Subject: [PATCH 38/49] tests(daemon): using experimental build --- .../daemon/__tests__/integration/scripts/docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/daemon/__tests__/integration/scripts/docker-compose.yml b/packages/daemon/__tests__/integration/scripts/docker-compose.yml index bf443331..40b906cd 100644 --- a/packages/daemon/__tests__/integration/scripts/docker-compose.yml +++ b/packages/daemon/__tests__/integration/scripts/docker-compose.yml @@ -96,7 +96,7 @@ services: - "8090:8080" single_voided_create_token_transaction: - image: hathornetwork/hathor-core + image: hathornetwork/hathor-core:sha-9828383b command: [ "events_simulator", "--scenario", "SINGLE_VOIDED_CREATE_TOKEN_TRANSACTION", From e2a2d2769142a69869480b8fd7b7bb6296dd3e5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Abadesso?= Date: Wed, 17 Sep 2025 16:41:37 -0300 Subject: [PATCH 39/49] tests(daemon): experimental image for new tests --- .../daemon/__tests__/integration/scripts/docker-compose.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/daemon/__tests__/integration/scripts/docker-compose.yml b/packages/daemon/__tests__/integration/scripts/docker-compose.yml index 40b906cd..8f6aca43 100644 --- a/packages/daemon/__tests__/integration/scripts/docker-compose.yml +++ b/packages/daemon/__tests__/integration/scripts/docker-compose.yml @@ -76,7 +76,7 @@ services: - "8088:8080" transaction_voiding_chain: - image: hathornetwork/hathor-core:sha-9828383b + image: hathornetwork/hathor-core:experimental-single-voided-create-token-tx command: [ "events_simulator", "--scenario", "TRANSACTION_VOIDING_CHAIN", @@ -86,7 +86,7 @@ services: - "8089:8080" voided_token_authority: - image: hathornetwork/hathor-core:sha-9828383b + image: hathornetwork/hathor-core:experimental-single-voided-create-token-tx command: [ "events_simulator", "--scenario", "VOIDED_TOKEN_AUTHORITY", @@ -96,7 +96,7 @@ services: - "8090:8080" single_voided_create_token_transaction: - image: hathornetwork/hathor-core:sha-9828383b + image: hathornetwork/hathor-core:experimental-single-voided-create-token-tx command: [ "events_simulator", "--scenario", "SINGLE_VOIDED_CREATE_TOKEN_TRANSACTION", From bba6af6c675cbcfa284aab8c30428c8113e8f0bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Abadesso?= Date: Wed, 17 Sep 2025 16:54:01 -0300 Subject: [PATCH 40/49] tests(daemon): renamed test case to actually reflect what is going on --- packages/daemon/__tests__/integration/balances.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/daemon/__tests__/integration/balances.test.ts b/packages/daemon/__tests__/integration/balances.test.ts index a0aa7e3f..d0175330 100644 --- a/packages/daemon/__tests__/integration/balances.test.ts +++ b/packages/daemon/__tests__/integration/balances.test.ts @@ -599,7 +599,7 @@ describe('single voided create token transaction scenario', () => { await expect(validateBalances(addressBalances, singleVoidedCreateTokenTransactionBalances.addressBalances)).resolves.not.toThrow(); }, 30000); - it('should expose the address_balance vs address_tx_history length mismatch issue', async () => { + it('addresses_balance and address_tx_history row length must match after a void transaction scenario', async () => { // @ts-expect-error getConfig.mockReturnValue({ NETWORK: 'testnet', From 24239d88b2d0b081a5e4df15cc9cc62ea13a3480 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Abadesso?= Date: Thu, 18 Sep 2025 10:16:44 -0300 Subject: [PATCH 41/49] tests(daemon): added voided regular tx scenario failing before fix --- .../__tests__/integration/balances.test.ts | 95 ++++++++++++++++++- .../daemon/__tests__/integration/config.ts | 4 + ...gle_voided_regular_transaction.balances.ts | 15 +++ .../integration/scripts/docker-compose.yml | 10 ++ packages/daemon/package.json | 2 +- 5 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 packages/daemon/__tests__/integration/scenario_configs/single_voided_regular_transaction.balances.ts diff --git a/packages/daemon/__tests__/integration/balances.test.ts b/packages/daemon/__tests__/integration/balances.test.ts index d0175330..0f22406f 100644 --- a/packages/daemon/__tests__/integration/balances.test.ts +++ b/packages/daemon/__tests__/integration/balances.test.ts @@ -30,6 +30,7 @@ import ncEventsBalances from './scenario_configs/nc_events.balances'; import transactionVoidingChainBalances from './scenario_configs/transaction_voiding_chain.balances'; import voidedTokenAuthorityBalances from './scenario_configs/voided_token_authority.balances'; import singleVoidedCreateTokenTransactionBalances from './scenario_configs/single_voided_create_token_transaction.balances'; +import singleVoidedRegularTransactionBalances from './scenario_configs/single_voided_regular_transaction.balances'; import { DB_NAME, @@ -57,6 +58,8 @@ import { VOIDED_TOKEN_AUTHORITY_LAST_EVENT, SINGLE_VOIDED_CREATE_TOKEN_TRANSACTION_PORT, SINGLE_VOIDED_CREATE_TOKEN_TRANSACTION_LAST_EVENT, + SINGLE_VOIDED_REGULAR_TRANSACTION_PORT, + SINGLE_VOIDED_REGULAR_TRANSACTION_LAST_EVENT, } from './config'; jest.mock('../../src/config', () => { @@ -475,7 +478,7 @@ describe('voided token authority scenario', () => { afterAll(async () => { // Clean up wallet data after this test to prevent affecting other tests - await cleanDatabase(mysql); + // await cleanDatabase(mysql); }); it('should do a full sync and the balances should match after voiding token authority', async () => { @@ -681,3 +684,93 @@ describe('single voided create token transaction scenario', () => { expect(walletBalanceResults[0]).toHaveLength(0); }, 30000); }); + +describe('single voided regular transaction scenario', () => { + beforeAll(async () => { + jest.spyOn(Services, 'fetchMinRewardBlocks').mockImplementation(async () => 300); + await cleanDatabase(mysql); + }); + + it('should do a full sync and the balances should match', async () => { + // @ts-expect-error + getConfig.mockReturnValue({ + NETWORK: 'testnet', + SERVICE_NAME: 'daemon-test', + CONSOLE_LEVEL: 'debug', + TX_CACHE_SIZE: 100, + BLOCK_REWARD_LOCK: 300, + FULLNODE_PEER_ID: 'simulator_peer_id', + STREAM_ID: 'simulator_stream_id', + FULLNODE_NETWORK: 'unittests', + FULLNODE_HOST: `127.0.0.1:${SINGLE_VOIDED_REGULAR_TRANSACTION_PORT}`, + USE_SSL: false, + DB_ENDPOINT, + DB_NAME, + DB_USER, + DB_PASS, + DB_PORT, + }); + + const machine = interpret(SyncMachine); + + // @ts-expect-error + await transitionUntilEvent(mysql, machine, SINGLE_VOIDED_REGULAR_TRANSACTION_LAST_EVENT); + const addressBalances = await fetchAddressBalances(mysql); + await expect(validateBalances(addressBalances, singleVoidedRegularTransactionBalances.addressBalances)).resolves.not.toThrow(); + }, 30000); + + it('addresses_balance and address_tx_history row length must match after a void transaction scenario', async () => { + // @ts-expect-error + getConfig.mockReturnValue({ + NETWORK: 'testnet', + SERVICE_NAME: 'daemon-test', + CONSOLE_LEVEL: 'debug', + TX_CACHE_SIZE: 100, + BLOCK_REWARD_LOCK: 300, + FULLNODE_PEER_ID: 'simulator_peer_id', + STREAM_ID: 'simulator_stream_id', + FULLNODE_NETWORK: 'unittests', + FULLNODE_HOST: `127.0.0.1:${SINGLE_VOIDED_REGULAR_TRANSACTION_PORT}`, + USE_SSL: false, + DB_ENDPOINT, + DB_NAME, + DB_USER, + DB_PASS, + DB_PORT, + }); + + const machine = interpret(SyncMachine); + + // @ts-expect-error + await transitionUntilEvent(mysql, machine, SINGLE_VOIDED_REGULAR_TRANSACTION_LAST_EVENT); + + const voidedAddress = 'HFtz2f59Lms4p3Jfgtsr73s97MbJHsRENh'; + + // Check for address balances for the specific address + const addressBalanceResults = await mysql.query(` + SELECT address, + COUNT(*) AS balance_rows + FROM address_balance + WHERE address = ? + GROUP BY address + ORDER BY address + `, [voidedAddress]); + + // Check for transaction history for the same address (excluding voided) + const txHistoryResults = await mysql.query(` + SELECT address, + COUNT(*) AS history_rows + FROM address_tx_history + WHERE voided = FALSE + AND address = ? + GROUP BY address + ORDER BY address + `, [voidedAddress]); + + // Cast to array to access length property + const addressRows = addressBalanceResults[0] as any[]; + const txHistoryRows = txHistoryResults[0] as any[]; + + expect(addressRows.length).toEqual(txHistoryRows.length); + }, 30000); +}); diff --git a/packages/daemon/__tests__/integration/config.ts b/packages/daemon/__tests__/integration/config.ts index 3f1706df..8a77c6be 100644 --- a/packages/daemon/__tests__/integration/config.ts +++ b/packages/daemon/__tests__/integration/config.ts @@ -46,6 +46,9 @@ export const VOIDED_TOKEN_AUTHORITY_LAST_EVENT = 66; export const SINGLE_VOIDED_CREATE_TOKEN_TRANSACTION_PORT = 8091; export const SINGLE_VOIDED_CREATE_TOKEN_TRANSACTION_LAST_EVENT = 50; +export const SINGLE_VOIDED_REGULAR_TRANSACTION_PORT = 8092; +export const SINGLE_VOIDED_REGULAR_TRANSACTION_LAST_EVENT = 60; + export const SCENARIOS = [ 'UNVOIDED_SCENARIO', 'REORG_SCENARIO', @@ -57,4 +60,5 @@ export const SCENARIOS = [ 'TRANSACTION_VOIDING_CHAIN', 'VOIDED_TOKEN_AUTHORITY', 'SINGLE_VOIDED_CREATE_TOKEN_TRANSACTION', + 'SINGLE_VOIDED_REGULAR_TRANSACTION', ]; diff --git a/packages/daemon/__tests__/integration/scenario_configs/single_voided_regular_transaction.balances.ts b/packages/daemon/__tests__/integration/scenario_configs/single_voided_regular_transaction.balances.ts new file mode 100644 index 00000000..4c7a11f9 --- /dev/null +++ b/packages/daemon/__tests__/integration/scenario_configs/single_voided_regular_transaction.balances.ts @@ -0,0 +1,15 @@ +/** + * Copyright (c) Hathor Labs and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +export default { + addressBalances: { + 'HFtz2f59Lms4p3Jfgtsr73s97MbJHsRENh:00': { unlockedBalance: 0n, lockedBalance: 0n }, + 'HJQbEERnD5Ak3f2dsi8zAmsZrCWTT8FZns:00': { unlockedBalance: 6400n, lockedBalance: 44800n }, + 'HRQe4CXj8AZXzSmuNztU8iQR74QTQMbnTs:00': { unlockedBalance: 0n, lockedBalance: 64000n }, + 'HRXVDmLVdq8pgok1BCUKpiFWdAVAy4a5AJ:00': { unlockedBalance: 0n, lockedBalance: 100000000000n }, + }, +}; \ No newline at end of file diff --git a/packages/daemon/__tests__/integration/scripts/docker-compose.yml b/packages/daemon/__tests__/integration/scripts/docker-compose.yml index 8f6aca43..0c8aad92 100644 --- a/packages/daemon/__tests__/integration/scripts/docker-compose.yml +++ b/packages/daemon/__tests__/integration/scripts/docker-compose.yml @@ -105,5 +105,15 @@ services: ports: - "8091:8080" + single_voided_regular_transaction: + image: hathornetwork/hathor-core + command: [ + "events_simulator", + "--scenario", "SINGLE_VOIDED_REGULAR_TRANSACTION", + "--seed", "1" + ] + ports: + - "8092:8080" + networks: database: diff --git a/packages/daemon/package.json b/packages/daemon/package.json index 44da1f29..e50b20a5 100644 --- a/packages/daemon/package.json +++ b/packages/daemon/package.json @@ -22,7 +22,7 @@ "test_images_wait_for_ws": "yarn dlx ts-node ./__tests__/integration/scripts/wait-for-ws-up.ts", "test_images_setup_database": "yarn dlx ts-node ./__tests__/integration/scripts/setup-database.ts", "test": "jest --coverage --runInBand", - "test_integration": "yarn run test_images_up && yarn run test_images_wait_for_db && yarn run test_images_wait_for_ws && yarn run test_images_setup_database && yarn run test_images_migrate && yarn run test_images_integration && yarn run test_images_down" + "test_integration": "yarn run test_images_up && yarn run test_images_wait_for_db && yarn run test_images_wait_for_ws && yarn run test_images_setup_database && yarn run test_images_migrate && yarn run test_images_integration" }, "name": "sync-daemon", "author": "André Abadesso", From 51e6a284477dd6c38069b57535f8ca5feb93c672 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Abadesso?= Date: Thu, 18 Sep 2025 10:23:04 -0300 Subject: [PATCH 42/49] fix(daemon): always remove from address_balance if the transaction was the only one for the address_balance --- packages/daemon/src/db/index.ts | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/packages/daemon/src/db/index.ts b/packages/daemon/src/db/index.ts index 9aec1ac7..895e0942 100644 --- a/packages/daemon/src/db/index.ts +++ b/packages/daemon/src/db/index.ts @@ -469,22 +469,23 @@ export const voidAddressTransaction = async ( // unlocked before it can be spent. In case we're just adding new locked authorities, this will be taken // care by the first sql query. - // If this is a token creation transaction, we must remove the address_balance - // row for that token as it has ceased to exist - if (isCreateTokenTx) { - await mysql.query( - `DELETE FROM address_balance - WHERE address = ? - AND token_id = ? - AND total_received = 0 - AND unlocked_balance = 0 - AND locked_balance = 0 - AND unlocked_authorities = 0 - AND locked_authorities = 0 - AND transactions = 0`, - [address, token] - ); + // If the address_balance is now zeroed and the number of transactions + // is also zero, it means that the transaction was removed from address_tx_history + // so we need to remove it from the `address_balance` table. + await mysql.query( + `DELETE FROM address_balance + WHERE address = ? + AND token_id = ? + AND total_received = 0 + AND unlocked_balance = 0 + AND locked_balance = 0 + AND unlocked_authorities = 0 + AND locked_authorities = 0 + AND transactions = 0`, + [address, token] + ); + if (isCreateTokenTx) { // The transaction that created the token was voided, so we can remove // it from the tokens table as well. await mysql.query( From ed375af40bb5483e3000f47dc93bbcb1c02ea527 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Abadesso?= Date: Thu, 18 Sep 2025 10:39:25 -0300 Subject: [PATCH 43/49] tests(daemon): test checking wallet_tx_history and wallet_history --- .../__tests__/integration/balances.test.ts | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/packages/daemon/__tests__/integration/balances.test.ts b/packages/daemon/__tests__/integration/balances.test.ts index 0f22406f..a8d5bf07 100644 --- a/packages/daemon/__tests__/integration/balances.test.ts +++ b/packages/daemon/__tests__/integration/balances.test.ts @@ -686,6 +686,41 @@ describe('single voided create token transaction scenario', () => { }); describe('single voided regular transaction scenario', () => { + const initializeWallet = async (mysql: Connection): Promise => { + // Insert wallet records + const walletSQL = ` + INSERT INTO wallet ( + id, + xpubkey, + status, + max_gap, + created_at, + ready_at, + retry_count, + auth_xpubkey, + last_used_address_index + ) VALUES + ( + 'test-wallet-voided-regular', + 'xpub6F81iNtH5HVknoJ65cK2XAGA5F3okdJK7WHwVAAPZnSir2sfwbhvB9ffNKQ4wLor75QxPe9p12tqt8xUZSG8i8AAPMpkFho7fbWkBJQ5s1x', + 'ready', + 20, + UNIX_TIMESTAMP(), + UNIX_TIMESTAMP(), + 0, + 'xpub6F81iNtH5HVknoJ65cK2XAGA5F3okdJK7WHwVAAPZnSir2sfwbhvB9ffNKQ4wLor75QxPe9p12tqt8xUZSG8i8AAPMpkFho7fbWkBJQ5s1x', + -1 + )`; + + // Insert address record for the voided transaction address + const addressSQL = ` + INSERT INTO address (address, \`index\`, wallet_id, transactions, seqnum) VALUES + ('HFtz2f59Lms4p3Jfgtsr73s97MbJHsRENh', 0, 'test-wallet-voided-regular', 0, 0)`; + + await mysql.query(walletSQL); + await mysql.query(addressSQL); + }; + beforeAll(async () => { jest.spyOn(Services, 'fetchMinRewardBlocks').mockImplementation(async () => 300); await cleanDatabase(mysql); @@ -773,4 +808,62 @@ describe('single voided regular transaction scenario', () => { expect(addressRows.length).toEqual(txHistoryRows.length); }, 30000); + + it('wallet_balance and wallet_tx_history row length must match after a void transaction scenario', async () => { + // @ts-expect-error + getConfig.mockReturnValue({ + NETWORK: 'testnet', + SERVICE_NAME: 'daemon-test', + CONSOLE_LEVEL: 'debug', + TX_CACHE_SIZE: 100, + BLOCK_REWARD_LOCK: 300, + FULLNODE_PEER_ID: 'simulator_peer_id', + STREAM_ID: 'simulator_stream_id', + FULLNODE_NETWORK: 'unittests', + FULLNODE_HOST: `127.0.0.1:${SINGLE_VOIDED_REGULAR_TRANSACTION_PORT}`, + USE_SSL: false, + DB_ENDPOINT, + DB_NAME, + DB_USER, + DB_PASS, + DB_PORT, + }); + + // Initialize wallet before processing events + await initializeWallet(mysql); + + const machine = interpret(SyncMachine); + + // @ts-expect-error + await transitionUntilEvent(mysql, machine, SINGLE_VOIDED_REGULAR_TRANSACTION_LAST_EVENT); + + const walletId = 'test-wallet-voided-regular'; + + // Check for wallet balances for the specific wallet + const walletBalanceResults = await mysql.query(` + SELECT wallet_id, + COUNT(*) AS balance_rows + FROM wallet_balance + WHERE wallet_id = ? + GROUP BY wallet_id + ORDER BY wallet_id + `, [walletId]); + + // Check for wallet transaction history for the same wallet (excluding voided) + const walletTxHistoryResults = await mysql.query(` + SELECT wallet_id, + COUNT(*) AS history_rows + FROM wallet_tx_history + WHERE voided = FALSE + AND wallet_id = ? + GROUP BY wallet_id + ORDER BY wallet_id + `, [walletId]); + + // Cast to array to access length property + const walletBalanceRows = walletBalanceResults[0] as any[]; + const walletTxHistoryRows = walletTxHistoryResults[0] as any[]; + + expect(walletBalanceRows.length).toEqual(walletTxHistoryRows.length); + }, 30000); }); From f599883ad5ac1b9be7a72555618a3025d29f4497 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Abadesso?= Date: Thu, 18 Sep 2025 10:39:43 -0300 Subject: [PATCH 44/49] fix(daemon): cleanup wallet_balance table when transactions = 0 --- packages/daemon/src/db/index.ts | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/daemon/src/db/index.ts b/packages/daemon/src/db/index.ts index 895e0942..461878d8 100644 --- a/packages/daemon/src/db/index.ts +++ b/packages/daemon/src/db/index.ts @@ -619,21 +619,21 @@ export const voidWalletTransaction = async ( ); } - // If this is a token creation transaction, clean up zeroed entries - if (isCreateTokenTx) { - await mysql.query( - `DELETE FROM wallet_balance - WHERE wallet_id = ? - AND token_id = ? - AND total_received = 0 - AND unlocked_balance = 0 - AND locked_balance = 0 - AND unlocked_authorities = 0 - AND locked_authorities = 0 - AND transactions = 0`, - [walletId, token] - ); - } + // If the number of transactions is zero, it means that this transaction + // was removed from the wallet_tx_history as well, so we must delete the + // row + await mysql.query( + `DELETE FROM wallet_balance + WHERE wallet_id = ? + AND token_id = ? + AND total_received = 0 + AND unlocked_balance = 0 + AND locked_balance = 0 + AND unlocked_authorities = 0 + AND locked_authorities = 0 + AND transactions = 0`, + [walletId, token] + ); } } From f94e475921d154c7160991ba447aafb96bcafdcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Abadesso?= Date: Thu, 18 Sep 2025 12:06:38 -0300 Subject: [PATCH 45/49] chore: using experimental hathor-core --- .../daemon/__tests__/integration/scripts/docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/daemon/__tests__/integration/scripts/docker-compose.yml b/packages/daemon/__tests__/integration/scripts/docker-compose.yml index 0c8aad92..572963c8 100644 --- a/packages/daemon/__tests__/integration/scripts/docker-compose.yml +++ b/packages/daemon/__tests__/integration/scripts/docker-compose.yml @@ -106,7 +106,7 @@ services: - "8091:8080" single_voided_regular_transaction: - image: hathornetwork/hathor-core + image: hathornetwork/hathor-core:experimental-single-voided-create-token-tx-python3.12 command: [ "events_simulator", "--scenario", "SINGLE_VOIDED_REGULAR_TRANSACTION", From 98ccec387c079e679f510f729fa72e0a0d105813 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Abadesso?= Date: Thu, 18 Sep 2025 12:44:52 -0300 Subject: [PATCH 46/49] refactor(daemon): version is no longer needed in voidWalletTransaction --- packages/daemon/src/db/index.ts | 8 ++------ packages/daemon/src/services/index.ts | 4 ++-- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/daemon/src/db/index.ts b/packages/daemon/src/db/index.ts index 461878d8..ea0ee89d 100644 --- a/packages/daemon/src/db/index.ts +++ b/packages/daemon/src/db/index.ts @@ -385,7 +385,7 @@ export const voidAddressTransaction = async ( mysql: any, txId: string, addressBalanceMap: StringMap, - version?: number, + version: number, ): Promise => { const addressEntries = Object.keys(addressBalanceMap).map((address) => [address, 0]); @@ -550,8 +550,7 @@ export const voidTransaction = async ( export const voidWalletTransaction = async ( mysql: MysqlConnection, txId: string, - addressBalanceMap: StringMap, - version?: number, + addressBalanceMap: StringMap ): Promise => { // Get wallet information for all affected addresses const addressWalletMap: StringMap = await getAddressWalletInfo(mysql, Object.keys(addressBalanceMap)); @@ -569,9 +568,6 @@ export const voidWalletTransaction = async ( return; } - // Check if this is a token creation transaction - const isCreateTokenTx = version === constants.CREATE_TOKEN_TX_VERSION; - for (const [walletId, tokenMap] of Object.entries(walletBalanceMap)) { for (const [token, tokenBalance] of tokenMap.iterator()) { // Update wallet_balance table by reversing the transaction's impact diff --git a/packages/daemon/src/services/index.ts b/packages/daemon/src/services/index.ts index cc7c4539..83d4214f 100644 --- a/packages/daemon/src/services/index.ts +++ b/packages/daemon/src/services/index.ts @@ -523,7 +523,7 @@ export const voidTx = async ( outputs: EventTxOutput[], tokens: string[], headers: EventTxHeader[], - version?: number, + version: number, ) => { const dbTxOutputs: DbTxOutput[] = await getTxOutputsFromTx(mysql, hash); const txOutputs: TxOutputWithIndex[] = prepareOutputs(outputs, tokens); @@ -586,7 +586,7 @@ export const voidTx = async ( } // CRITICAL: Update wallet balances when voiding a transaction - await voidWalletTransaction(mysql, hash, addressBalanceMap, version); + await voidWalletTransaction(mysql, hash, addressBalanceMap); const addresses = Object.keys(addressBalanceMap); await validateAddressBalances(mysql, addresses); From 30a16d5a707a273c436e1b45deefee5f2e145308 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Abadesso?= Date: Thu, 18 Sep 2025 13:36:39 -0300 Subject: [PATCH 47/49] tests(daemon): passing tx version on voidTx in tests --- packages/daemon/__tests__/db/index.test.ts | 2 +- .../services/services_with_db.test.ts | 22 ++++++++++--------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/packages/daemon/__tests__/db/index.test.ts b/packages/daemon/__tests__/db/index.test.ts index b139d386..b5c7c375 100644 --- a/packages/daemon/__tests__/db/index.test.ts +++ b/packages/daemon/__tests__/db/index.test.ts @@ -1225,7 +1225,7 @@ describe('voidTransaction', () => { }; await voidTransaction(mysql, txId); - await voidAddressTransaction(mysql, txId, addressBalance); + await voidAddressTransaction(mysql, txId, addressBalance, 1); await expect(checkAddressBalanceTable(mysql, 2, addr1, token2, 1n, 0n, null, 3)).resolves.toBe(true); await expect(checkAddressBalanceTable(mysql, 2, addr1, token1, 1n, 0n, null, 4)).resolves.toBe(true); diff --git a/packages/daemon/__tests__/services/services_with_db.test.ts b/packages/daemon/__tests__/services/services_with_db.test.ts index a6b4b0af..518acb45 100644 --- a/packages/daemon/__tests__/services/services_with_db.test.ts +++ b/packages/daemon/__tests__/services/services_with_db.test.ts @@ -159,7 +159,7 @@ describe('voidTransaction with input unspending', () => { script: 'dqkUH70YjKeoKdFwMX2TOYvGVbXOrKaIrA==', }]; - await voidTx(mysql, txIdB, inputs, outputs, [tokenId], []); + await voidTx(mysql, txIdB, inputs, outputs, [tokenId], [], 1); // Check if the UTXO from transaction A is unspent again utxo = await getTxOutput(mysql, txIdA, 0, false); @@ -229,7 +229,7 @@ describe('voidTransaction with input unspending', () => { script: 'dqkUH70YjKeoKdFwMX2TOYvGVbXOrKaIrA==', }]; - await voidTx(mysql, txIdC, inputs, outputs, [tokenId], []); + await voidTx(mysql, txIdC, inputs, outputs, [tokenId], [], 1); // Check if UTXOs from transactions A and B are unspent again utxoA = await getTxOutput(mysql, txIdA, 0, true); @@ -295,7 +295,8 @@ describe('voidTransaction with input unspending', () => { script: 'dqkUH70YjKeoKdFwMX2TOYvGVbXOrKaIrA==', }], [tokenId], - [] + [], + 1 ); // B's output should be unspent now (and it will be with the fix) @@ -314,7 +315,8 @@ describe('voidTransaction with input unspending', () => { script: 'dqkUH70YjKeoKdFwMX2TOYvGVbXOrKaIrA==', }], [tokenId], - [] + [], + 1 ); // A's output should be unspent now @@ -373,7 +375,7 @@ describe('voidTransaction with input unspending', () => { script: 'dqkUH70YjKeoKdFwMX2TOYvGVbXOrKaIrA==', }]; - await voidTx(mysql, txIdC, inputs, outputs, [tokenId], []); + await voidTx(mysql, txIdC, inputs, outputs, [tokenId], [], 1); // The UTXO should still be spent by B, not unspent const utxo = await getTxOutput(mysql, txIdA, 0, false); @@ -430,7 +432,7 @@ describe('voidTransaction with input unspending', () => { } ]; - await voidTx(mysql, txIdB, inputs, outputs, [hathorToken, customToken], []); + await voidTx(mysql, txIdB, inputs, outputs, [hathorToken, customToken], [], 1); // Both UTXOs should be unspent const utxo1 = await getTxOutput(mysql, txIdA, 0, true); @@ -483,7 +485,7 @@ describe('voidTransaction with input unspending', () => { script: 'dqkUH70YjKeoKdFwMX2TOYvGVbXOrKaIrA==', }]; - await voidTx(mysql, txIdB, inputs, outputs, [tokenId], []); + await voidTx(mysql, txIdB, inputs, outputs, [tokenId], [], 1); // Verify the original UTXO is unspent again const unspentUtxo = await getTxOutput(mysql, txIdA, 0, true); @@ -636,7 +638,7 @@ describe('wallet balance voiding bug', () => { script: 'dqkUH70YjKeoKdFwMX2TOYvGVbXOrKaIrA==', }]; - await voidTx(mysql, txIdB, inputs, outputs, [tokenId], []); + await voidTx(mysql, txIdB, inputs, outputs, [tokenId], [], 1); // Check wallet balance after voiding walletBalance = await getWalletBalance(mysql, walletId, tokenId); @@ -722,7 +724,7 @@ describe('wallet balance voiding bug', () => { script: 'dqkUH70YjKeoKdFwMX2TOYvGVbXOrKaIrA==', }]; - await voidTx(mysql, txId, inputs, voidOutputs, [tokenId], []); + await voidTx(mysql, txId, inputs, voidOutputs, [tokenId], [], 1); // Check wallet balances after voiding wallet1Balance = await getWalletBalance(mysql, wallet1Id, tokenId); @@ -791,7 +793,7 @@ describe('wallet balance voiding bug', () => { script: 'dqkUH70YjKeoKdFwMX2TOYvGVbXOrKaIrA==', }]; - await voidTx(mysql, txId, inputs, outputs, [tokenId], []); + await voidTx(mysql, txId, inputs, outputs, [tokenId], [], 1); // Check wallet balance after voiding walletBalance = await getWalletBalance(mysql, walletId, tokenId); From 47ecc3f24e916e35bc02f1dbc1d830e3aec6edd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Abadesso?= Date: Thu, 18 Sep 2025 14:57:02 -0300 Subject: [PATCH 48/49] tests(daemon): restored removed control mechanism --- packages/daemon/__tests__/integration/balances.test.ts | 2 +- packages/daemon/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/daemon/__tests__/integration/balances.test.ts b/packages/daemon/__tests__/integration/balances.test.ts index a8d5bf07..438e5cdb 100644 --- a/packages/daemon/__tests__/integration/balances.test.ts +++ b/packages/daemon/__tests__/integration/balances.test.ts @@ -478,7 +478,7 @@ describe('voided token authority scenario', () => { afterAll(async () => { // Clean up wallet data after this test to prevent affecting other tests - // await cleanDatabase(mysql); + await cleanDatabase(mysql); }); it('should do a full sync and the balances should match after voiding token authority', async () => { diff --git a/packages/daemon/package.json b/packages/daemon/package.json index e50b20a5..44da1f29 100644 --- a/packages/daemon/package.json +++ b/packages/daemon/package.json @@ -22,7 +22,7 @@ "test_images_wait_for_ws": "yarn dlx ts-node ./__tests__/integration/scripts/wait-for-ws-up.ts", "test_images_setup_database": "yarn dlx ts-node ./__tests__/integration/scripts/setup-database.ts", "test": "jest --coverage --runInBand", - "test_integration": "yarn run test_images_up && yarn run test_images_wait_for_db && yarn run test_images_wait_for_ws && yarn run test_images_setup_database && yarn run test_images_migrate && yarn run test_images_integration" + "test_integration": "yarn run test_images_up && yarn run test_images_wait_for_db && yarn run test_images_wait_for_ws && yarn run test_images_setup_database && yarn run test_images_migrate && yarn run test_images_integration && yarn run test_images_down" }, "name": "sync-daemon", "author": "André Abadesso", From 074dc4c0b4557c2d1352c0a6f5d757e488fda01b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Abadesso?= Date: Thu, 18 Sep 2025 14:58:25 -0300 Subject: [PATCH 49/49] tests(daemon): removed balance check for the removed address_balance --- .../single_voided_regular_transaction.balances.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/daemon/__tests__/integration/scenario_configs/single_voided_regular_transaction.balances.ts b/packages/daemon/__tests__/integration/scenario_configs/single_voided_regular_transaction.balances.ts index 4c7a11f9..f93384d7 100644 --- a/packages/daemon/__tests__/integration/scenario_configs/single_voided_regular_transaction.balances.ts +++ b/packages/daemon/__tests__/integration/scenario_configs/single_voided_regular_transaction.balances.ts @@ -7,9 +7,8 @@ export default { addressBalances: { - 'HFtz2f59Lms4p3Jfgtsr73s97MbJHsRENh:00': { unlockedBalance: 0n, lockedBalance: 0n }, 'HJQbEERnD5Ak3f2dsi8zAmsZrCWTT8FZns:00': { unlockedBalance: 6400n, lockedBalance: 44800n }, 'HRQe4CXj8AZXzSmuNztU8iQR74QTQMbnTs:00': { unlockedBalance: 0n, lockedBalance: 64000n }, 'HRXVDmLVdq8pgok1BCUKpiFWdAVAy4a5AJ:00': { unlockedBalance: 0n, lockedBalance: 100000000000n }, }, -}; \ No newline at end of file +};