From 019903a743a204bff02b7ea0b0d79d0e0c951d5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Abadesso?= Date: Fri, 23 Jan 2026 11:40:39 -0300 Subject: [PATCH 01/10] feat: store first_block on transactions --- ...23000000-add-first-block-to-transaction.js | 15 ++ packages/daemon/__tests__/db/index.test.ts | 72 +++++++ .../__tests__/services/services.test.ts | 184 +++++++++++++++++- packages/daemon/__tests__/types.ts | 1 + packages/daemon/__tests__/utils.ts | 31 ++- packages/daemon/src/db/index.ts | 18 +- packages/daemon/src/services/index.ts | 36 ++-- packages/daemon/src/types/transaction.ts | 1 + 8 files changed, 318 insertions(+), 40 deletions(-) create mode 100644 db/migrations/20260123000000-add-first-block-to-transaction.js diff --git a/db/migrations/20260123000000-add-first-block-to-transaction.js b/db/migrations/20260123000000-add-first-block-to-transaction.js new file mode 100644 index 00000000..dd0a91dd --- /dev/null +++ b/db/migrations/20260123000000-add-first-block-to-transaction.js @@ -0,0 +1,15 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn('transaction', 'first_block', { + type: Sequelize.STRING(64), + allowNull: true, + comment: 'Hash of the first block that confirmed this transaction', + }); + }, + + async down(queryInterface) { + await queryInterface.removeColumn('transaction', 'first_block'); + }, +}; diff --git a/packages/daemon/__tests__/db/index.test.ts b/packages/daemon/__tests__/db/index.test.ts index b4539794..5a46004f 100644 --- a/packages/daemon/__tests__/db/index.test.ts +++ b/packages/daemon/__tests__/db/index.test.ts @@ -135,6 +135,78 @@ describe('transaction methods', () => { const bestBlock = await getBestBlockHeight(mysql); expect(bestBlock).toStrictEqual(4); }); + + test('should insert a new tx with first_block', async () => { + expect.hasAssertions(); + + const firstBlock = 'block_hash_123'; + await addOrUpdateTx(mysql, 'txId1', 10, 1, 1, 65.4321, firstBlock); + const tx = await getTransactionById(mysql, 'txId1'); + + expect(tx?.height).toStrictEqual(10); + expect(tx?.first_block).toStrictEqual(firstBlock); + }); + + test('should insert a new tx without first_block (mempool tx)', async () => { + expect.hasAssertions(); + + await addOrUpdateTx(mysql, 'txId1', null, 1, 1, 65.4321, null); + const tx = await getTransactionById(mysql, 'txId1'); + + expect(tx?.height).toBeNull(); + expect(tx?.first_block).toBeNull(); + }); + + test('should update first_block from NULL to value (tx confirmed)', async () => { + expect.hasAssertions(); + + // Insert tx without first_block (in mempool) + await addOrUpdateTx(mysql, 'txId1', null, 1, 1, 65.4321, null); + let tx = await getTransactionById(mysql, 'txId1'); + expect(tx?.first_block).toBeNull(); + expect(tx?.height).toBeNull(); + + // Update tx with first_block (confirmed in a block) + const firstBlock = 'block_hash_456'; + await addOrUpdateTx(mysql, 'txId1', 5, 1, 1, 65.4321, firstBlock); + tx = await getTransactionById(mysql, 'txId1'); + expect(tx?.first_block).toStrictEqual(firstBlock); + expect(tx?.height).toStrictEqual(5); + }); + + test('should update first_block from value to NULL (tx back to mempool after reorg)', async () => { + expect.hasAssertions(); + + // Insert tx with first_block (confirmed) + const firstBlock = 'block_hash_789'; + await addOrUpdateTx(mysql, 'txId1', 10, 1, 1, 65.4321, firstBlock); + let tx = await getTransactionById(mysql, 'txId1'); + expect(tx?.first_block).toStrictEqual(firstBlock); + expect(tx?.height).toStrictEqual(10); + + // Update tx to remove first_block (back to mempool after reorg) + await addOrUpdateTx(mysql, 'txId1', null, 1, 1, 65.4321, null); + tx = await getTransactionById(mysql, 'txId1'); + expect(tx?.first_block).toBeNull(); + expect(tx?.height).toBeNull(); + }); + + test('should update first_block from one value to another (reorg to different block)', async () => { + expect.hasAssertions(); + + // Insert tx with first_block + const firstBlock1 = 'block_hash_aaa'; + await addOrUpdateTx(mysql, 'txId1', 10, 1, 1, 65.4321, firstBlock1); + let tx = await getTransactionById(mysql, 'txId1'); + expect(tx?.first_block).toStrictEqual(firstBlock1); + + // Update tx with different first_block (reorg to different block) + const firstBlock2 = 'block_hash_bbb'; + await addOrUpdateTx(mysql, 'txId1', 11, 1, 1, 65.4321, firstBlock2); + tx = await getTransactionById(mysql, 'txId1'); + expect(tx?.first_block).toStrictEqual(firstBlock2); + expect(tx?.height).toStrictEqual(11); + }); }); describe('tx output methods', () => { diff --git a/packages/daemon/__tests__/services/services.test.ts b/packages/daemon/__tests__/services/services.test.ts index 0170ce18..37828286 100644 --- a/packages/daemon/__tests__/services/services.test.ts +++ b/packages/daemon/__tests__/services/services.test.ts @@ -349,7 +349,7 @@ describe('handleTxFirstBlock', () => { hash: 'hashValue', metadata: { height: 123, - first_block: ['hash2'], + first_block: 'blockHash123', }, timestamp: 'timestampValue', version: 'versionValue', @@ -362,9 +362,67 @@ describe('handleTxFirstBlock', () => { await handleTxFirstBlock(context as any); - expect(addOrUpdateTx).toHaveBeenCalledWith(mockDb, 'hashValue', 123, 'timestampValue', 'versionValue', 'weightValue'); + expect(addOrUpdateTx).toHaveBeenCalledWith(mockDb, 'hashValue', 123, 'timestampValue', 'versionValue', 'weightValue', 'blockHash123'); expect(dbUpdateLastSyncedEvent).toHaveBeenCalledWith(mockDb, 'idValue'); - expect(logger.debug).toHaveBeenCalledWith('Confirmed tx hashValue: idValue'); + expect(logger.debug).toHaveBeenCalledWith('Confirmed tx hashValue in block blockHash123: idValue'); + expect(mockDb.commit).toHaveBeenCalled(); + expect(mockDb.destroy).toHaveBeenCalled(); + }); + + it('should handle tx going back to mempool (first_block is null)', async () => { + const context = { + event: { + event: { + data: { + hash: 'hashValue', + metadata: { + height: 123, // This should be ignored when first_block is null + first_block: null, + }, + timestamp: 'timestampValue', + version: 'versionValue', + weight: 'weightValue', + }, + id: 'idValue', + }, + }, + }; + + await handleTxFirstBlock(context as any); + + // When first_block is null, height should also be null + expect(addOrUpdateTx).toHaveBeenCalledWith(mockDb, 'hashValue', null, 'timestampValue', 'versionValue', 'weightValue', null); + expect(dbUpdateLastSyncedEvent).toHaveBeenCalledWith(mockDb, 'idValue'); + expect(logger.debug).toHaveBeenCalledWith('Tx hashValue back to mempool (first_block=null): idValue'); + expect(mockDb.commit).toHaveBeenCalled(); + expect(mockDb.destroy).toHaveBeenCalled(); + }); + + it('should handle tx going back to mempool (first_block is undefined)', async () => { + const context = { + event: { + event: { + data: { + hash: 'hashValue', + metadata: { + height: 123, + // first_block is undefined + }, + timestamp: 'timestampValue', + version: 'versionValue', + weight: 'weightValue', + }, + id: 'idValue', + }, + }, + }; + + await handleTxFirstBlock(context as any); + + // When first_block is undefined (null), height should also be null + expect(addOrUpdateTx).toHaveBeenCalledWith(mockDb, 'hashValue', null, 'timestampValue', 'versionValue', 'weightValue', null); + expect(dbUpdateLastSyncedEvent).toHaveBeenCalledWith(mockDb, 'idValue'); + expect(logger.debug).toHaveBeenCalledWith('Tx hashValue back to mempool (first_block=null): idValue'); expect(mockDb.commit).toHaveBeenCalled(); expect(mockDb.destroy).toHaveBeenCalled(); }); @@ -379,7 +437,7 @@ describe('handleTxFirstBlock', () => { hash: 'hashValue', metadata: { height: 123, - first_block: ['hash2'], + first_block: 'blockHash123', }, timestamp: 'timestampValue', version: 'versionValue', @@ -768,7 +826,7 @@ describe('handleVertexAccepted', () => { // Verify addMiner was NOT called since there are no outputs expect(addMiner).not.toHaveBeenCalled(); - // Verify the transaction was still processed successfully + // Verify the transaction was still processed successfully (with firstBlock = null for PoA block) expect(addOrUpdateTx).toHaveBeenCalledWith( mockDb, 'poaBlockHash', @@ -776,10 +834,63 @@ describe('handleVertexAccepted', () => { 1762200490, // timestamp POA_BLOCK_VERSION, 2, // weight + null, // firstBlock ); expect(mockDb.commit).toHaveBeenCalled(); expect(mockDb.destroy).toHaveBeenCalled(); }); + + it('should pass first_block when inserting transaction', async () => { + const context = { + event: { + event: { + data: { + hash: 'txHash123', + metadata: { + height: 50, + first_block: 'blockHash456', + voided_by: [], + }, + timestamp: 1234567890, + version: 1, + weight: 17.5, + outputs: [], + inputs: [], + tokens: [], + }, + id: 'eventId123', + }, + }, + rewardMinBlocks: 300, + txCache: { + get: jest.fn(), + set: jest.fn(), + }, + }; + + (addOrUpdateTx as jest.Mock).mockReturnValue(Promise.resolve()); + (getTransactionById as jest.Mock).mockResolvedValue(null); + (prepareOutputs as jest.Mock).mockReturnValue([]); + (prepareInputs as jest.Mock).mockReturnValue([]); + (getAddressBalanceMap as jest.Mock).mockReturnValue({}); + (getUtxosLockedAtHeight as jest.Mock).mockResolvedValue([]); + (hashTxData as jest.Mock).mockReturnValue('hashedData'); + (getAddressWalletInfo as jest.Mock).mockResolvedValue({}); + + await handleVertexAccepted(context as any, {} as any); + + // Verify firstBlock is passed to addOrUpdateTx + expect(addOrUpdateTx).toHaveBeenCalledWith( + mockDb, + 'txHash123', + 50, + 1234567890, + 1, + 17.5, + 'blockHash456', // firstBlock should be passed + ); + expect(mockDb.commit).toHaveBeenCalled(); + }); }); describe('metadataDiff', () => { @@ -883,18 +994,75 @@ describe('metadataDiff', () => { expect(result.type).toBe('TX_FIRST_BLOCK'); }); - it('should ignore transaction with first_block and height in database', async () => { + it('should ignore transaction with first_block and same first_block in database', async () => { const event = { event: { event: { data: { hash: 'mockHash', - metadata: { voided_by: [], first_block: ['mockFirstBlock'] }, + metadata: { voided_by: [], first_block: 'mockFirstBlock' }, + }, + }, + }, + }; + const mockDbTransaction = { height: 1, first_block: 'mockFirstBlock' }; + (getTransactionById as jest.Mock).mockResolvedValue(mockDbTransaction); + + const result = await metadataDiff({} as any, event as any); + expect(result.type).toBe('IGNORE'); + }); + + it('should return TX_FIRST_BLOCK when transaction goes back to mempool (first_block changes to null)', async () => { + const event = { + event: { + event: { + data: { + hash: 'mockHash', + metadata: { voided_by: [], first_block: '' }, // Empty string means null + }, + }, + }, + }; + // Transaction was confirmed but now first_block is null + const mockDbTransaction = { height: 10, first_block: 'originalBlock' }; + (getTransactionById as jest.Mock).mockResolvedValue(mockDbTransaction); + + const result = await metadataDiff({} as any, event as any); + expect(result.type).toBe('TX_FIRST_BLOCK'); + }); + + it('should return TX_FIRST_BLOCK when first_block changes to different block (reorg)', async () => { + const event = { + event: { + event: { + data: { + hash: 'mockHash', + metadata: { voided_by: [], first_block: 'newBlock' }, + }, + }, + }, + }; + // Transaction was in one block, now it's in a different block + const mockDbTransaction = { height: 10, first_block: 'oldBlock' }; + (getTransactionById as jest.Mock).mockResolvedValue(mockDbTransaction); + + const result = await metadataDiff({} as any, event as any); + expect(result.type).toBe('TX_FIRST_BLOCK'); + }); + + it('should ignore transaction with null first_block in both event and database', async () => { + const event = { + event: { + event: { + data: { + hash: 'mockHash', + metadata: { voided_by: [], first_block: null }, }, }, }, }; - const mockDbTransaction = { height: 1 }; + // Transaction is in mempool in both + const mockDbTransaction = { height: null, first_block: null }; (getTransactionById as jest.Mock).mockResolvedValue(mockDbTransaction); const result = await metadataDiff({} as any, event as any); diff --git a/packages/daemon/__tests__/types.ts b/packages/daemon/__tests__/types.ts index f9cf7d1b..608e7b91 100644 --- a/packages/daemon/__tests__/types.ts +++ b/packages/daemon/__tests__/types.ts @@ -13,6 +13,7 @@ export interface TransactionTableEntry { version: number; voided: boolean; height: number; + firstBlock?: string | null; } export interface WalletBalanceEntry { diff --git a/packages/daemon/__tests__/utils.ts b/packages/daemon/__tests__/utils.ts index c87b7604..da6094e7 100644 --- a/packages/daemon/__tests__/utils.ts +++ b/packages/daemon/__tests__/utils.ts @@ -258,12 +258,13 @@ export const addToTransactionTable = async ( entry.version, entry.voided, entry.height, + entry.firstBlock ?? null, ])); await mysql.query(` INSERT INTO \`transaction\` (\`tx_id\`, \`timestamp\`, \`version\`, \`voided\`, - \`height\`) + \`height\`, \`first_block\`) VALUES ?`, [payload]); }; @@ -319,7 +320,8 @@ export const checkTransactionTable = async ( timestamp: number, version: number, voided: boolean, - height: number, + height: number | null, + firstBlock?: string | null, ): Promise> => { // first check the total number of rows in the table let [results] = await mysql.query('SELECT * FROM `transaction`'); @@ -336,22 +338,33 @@ export const checkTransactionTable = async ( if (totalResults === 0) return true; // now fetch the exact entry - - [results] = await mysql.query(` + const baseQuery = ` SELECT * FROM \`transaction\` WHERE \`tx_id\` = ? AND \`timestamp\` = ? AND \`version\` = ? AND \`voided\` = ? - AND \`height\` = ? - `, [txId, timestamp, version, voided, height], - ); + AND \`height\` ${height !== null ? '= ?' : 'IS ?'} + `; + + // Only check first_block if provided (for backwards compatibility) + if (firstBlock !== undefined) { + [results] = await mysql.query( + `${baseQuery} AND \`first_block\` ${firstBlock !== null ? '= ?' : 'IS ?'}`, + [txId, timestamp, version, voided, height, firstBlock], + ); + } else { + [results] = await mysql.query( + baseQuery, + [txId, timestamp, version, voided, height], + ); + } if (results.length !== 1) { return { - error: 'checkAddressTable query', - params: { txId, timestamp, version, voided, height }, + error: 'checkTransactionTable query', + params: { txId, timestamp, version, voided, height, firstBlock }, results, }; } diff --git a/packages/daemon/src/db/index.ts b/packages/daemon/src/db/index.ts index 0b03d29f..98d72dc2 100644 --- a/packages/daemon/src/db/index.ts +++ b/packages/daemon/src/db/index.ts @@ -80,9 +80,11 @@ export const getDbConnection = async (): Promise => { * * @param mysql - Database connection * @param txId - Transaction id + * @param height - The transaction height (null if not confirmed) * @param timestamp - The transaction timestamp * @param version - The transaction version - * @param weight - the transaction weight + * @param weight - The transaction weight + * @param firstBlock - Hash of the first block that confirmed this transaction (null if not confirmed) */ export const addOrUpdateTx = async ( mysql: any, @@ -91,14 +93,15 @@ export const addOrUpdateTx = async ( timestamp: number, version: number, weight: number, + firstBlock: string | null = null, ): Promise => { - const entries = [[txId, height, timestamp, version, weight]]; + const entries = [[txId, height, timestamp, version, weight, firstBlock]]; await mysql.query( - `INSERT INTO \`transaction\` (tx_id, height, timestamp, version, weight) + `INSERT INTO \`transaction\` (tx_id, height, timestamp, version, weight, first_block) VALUES ? - ON DUPLICATE KEY UPDATE height = ?`, - [entries, height], + ON DUPLICATE KEY UPDATE height = ?, first_block = ?`, + [entries, height, firstBlock], ); }; @@ -1451,9 +1454,11 @@ export const updateWalletTablesWithTx = async ( * * @param mysql - Database connection * @param txId - Transaction id + * @param height - The transaction height * @param timestamp - The transaction timestamp * @param version - The transaction version * @param weight - The transaction weight + * @param firstBlock - Hash of the first block that confirmed this transaction */ export const updateTx = async ( mysql: MysqlConnection, @@ -1462,7 +1467,8 @@ export const updateTx = async ( timestamp: number, version: number, weight: number, -): Promise => addOrUpdateTx(mysql, txId, height, timestamp, version, weight); + firstBlock: string | null = null, +): Promise => addOrUpdateTx(mysql, txId, height, timestamp, version, weight, firstBlock); /** * Get a list of tx outputs from their spent_by txId diff --git a/packages/daemon/src/services/index.ts b/packages/daemon/src/services/index.ts index a15373f7..6ad989d2 100644 --- a/packages/daemon/src/services/index.ts +++ b/packages/daemon/src/services/index.ts @@ -163,18 +163,15 @@ export const metadataDiff = async (_context: Context, event: Event) => { }; } - if (first_block - && first_block.length - && first_block.length > 0) { - if (!dbTx.height) { - return { - type: METADATA_DIFF_EVENT_TYPES.TX_FIRST_BLOCK, - originalEvent: event, - }; - } + // Handle first_block changes (NULL -> value OR value -> NULL) + const eventFirstBlock: string | null = (first_block && first_block.length > 0) + ? first_block + : null; + const dbFirstBlock: string | null = dbTx.first_block ?? null; + if (eventFirstBlock !== dbFirstBlock) { return { - type: METADATA_DIFF_EVENT_TYPES.IGNORE, + type: METADATA_DIFF_EVENT_TYPES.TX_FIRST_BLOCK, originalEvent: event, }; } @@ -382,6 +379,7 @@ export const handleVertexAccepted = async (context: Context, _event: Event) => { markLockedOutputs(txOutputs, now, heightlock !== null); // Add the transaction + const firstBlock = metadata.first_block ?? null; logger.debug('Will add the tx with height', height); // TODO: add is_nanocontract to transaction table? await addOrUpdateTx( @@ -391,6 +389,7 @@ export const handleVertexAccepted = async (context: Context, _event: Event) => { timestamp, version, weight, + firstBlock, ); // Add utxos @@ -827,15 +826,18 @@ export const handleTxFirstBlock = async (context: Context) => { weight, } = fullNodeEvent.event.data; - const height: number | null = metadata.height; + const firstBlock: string | null = metadata.first_block ?? null; + // When first_block is null, height should also be null (tx back in mempool) + const height: number | null = firstBlock ? metadata.height : null; - if (!metadata.first_block) { - throw new Error('HandleTxFirstBlock called but no first block on metadata'); - } - - await addOrUpdateTx(mysql, hash, height, timestamp, version, weight); + await addOrUpdateTx(mysql, hash, height, timestamp, version, weight, firstBlock); await dbUpdateLastSyncedEvent(mysql, fullNodeEvent.event.id); - logger.debug(`Confirmed tx ${hash}: ${fullNodeEvent.event.id}`); + + if (firstBlock) { + logger.debug(`Confirmed tx ${hash} in block ${firstBlock}: ${fullNodeEvent.event.id}`); + } else { + logger.debug(`Tx ${hash} back to mempool (first_block=null): ${fullNodeEvent.event.id}`); + } await mysql.commit(); } catch (e) { diff --git a/packages/daemon/src/types/transaction.ts b/packages/daemon/src/types/transaction.ts index 9032da1d..65954fc6 100644 --- a/packages/daemon/src/types/transaction.ts +++ b/packages/daemon/src/types/transaction.ts @@ -28,6 +28,7 @@ export interface DbTransaction { voided: boolean; height?: number | null; weight?: number | null; + first_block?: string | null; created_at: number; updated_at: number; } From 745083f72d3ec2c28b08eadc3cf3b49cf3977524 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Abadesso?= Date: Tue, 27 Jan 2026 08:51:37 -0300 Subject: [PATCH 02/10] refactor: chaining metadata changed handling --- .../__tests__/services/services.test.ts | 153 +++++++++--------- packages/daemon/src/guards/index.ts | 8 + packages/daemon/src/machines/SyncMachine.ts | 14 +- packages/daemon/src/services/index.ts | 57 ++++--- 4 files changed, 132 insertions(+), 100 deletions(-) diff --git a/packages/daemon/__tests__/services/services.test.ts b/packages/daemon/__tests__/services/services.test.ts index 37828286..0e459c30 100644 --- a/packages/daemon/__tests__/services/services.test.ts +++ b/packages/daemon/__tests__/services/services.test.ts @@ -909,7 +909,7 @@ describe('metadataDiff', () => { event: { data: { hash: 'mockHash', - metadata: { voided_by: ['mockVoidedBy'], first_block: [] }, + metadata: { voided_by: ['mockVoidedBy'], first_block: null }, }, }, }, @@ -928,7 +928,7 @@ describe('metadataDiff', () => { event: { data: { hash: 'mockHash', - metadata: { voided_by: [], first_block: [] }, + metadata: { voided_by: [], first_block: null }, }, }, }, @@ -946,7 +946,7 @@ describe('metadataDiff', () => { event: { data: { hash: 'mockHash', - metadata: { voided_by: ['mockVoidedBy'], first_block: [] }, + metadata: { voided_by: ['mockVoidedBy'], first_block: null }, }, }, }, @@ -964,7 +964,7 @@ describe('metadataDiff', () => { event: { data: { hash: 'mockHash', - metadata: { voided_by: ['mockVoidedBy'], first_block: [] }, + metadata: { voided_by: ['mockVoidedBy'], first_block: null }, }, }, }, @@ -976,18 +976,18 @@ describe('metadataDiff', () => { expect(result.type).toBe('IGNORE'); }); - it('should handle transaction with first_block but no height in database', async () => { + it('should handle transaction with first_block but no first_block in database', async () => { const event = { event: { event: { data: { hash: 'mockHash', - metadata: { voided_by: [], first_block: ['mockFirstBlock'] }, + metadata: { voided_by: [], first_block: 'mockFirstBlock' }, }, }, }, }; - const mockDbTransaction = { height: null }; + const mockDbTransaction = { height: null, first_block: null }; (getTransactionById as jest.Mock).mockResolvedValue(mockDbTransaction); const result = await metadataDiff({} as any, event as any); @@ -1018,7 +1018,8 @@ describe('metadataDiff', () => { event: { data: { hash: 'mockHash', - metadata: { voided_by: [], first_block: '' }, // Empty string means null + // nc_execution is 'success' so NC_EXEC_VOIDED is not triggered + metadata: { voided_by: [], first_block: '', nc_execution: 'success' }, // Empty string means null }, }, }, @@ -1037,7 +1038,8 @@ describe('metadataDiff', () => { event: { data: { hash: 'mockHash', - metadata: { voided_by: [], first_block: 'newBlock' }, + // nc_execution is 'success' so NC_EXEC_VOIDED is not triggered + metadata: { voided_by: [], first_block: 'newBlock', nc_execution: 'success' }, }, }, }, @@ -1075,7 +1077,7 @@ describe('metadataDiff', () => { event: { data: { hash: 'mockHash', - metadata: { voided_by: [], first_block: [], nc_execution: 'pending' }, + metadata: { voided_by: [], first_block: null, nc_execution: 'pending' }, }, }, }, @@ -1094,7 +1096,7 @@ describe('metadataDiff', () => { event: { data: { hash: 'mockHash', - metadata: { voided_by: [], first_block: [], nc_execution: 'success' }, + metadata: { voided_by: [], first_block: null, nc_execution: 'success' }, }, }, }, @@ -1115,7 +1117,7 @@ describe('metadataDiff', () => { event: { data: { hash: txHash, - metadata: { voided_by: [], first_block: [], nc_execution: 'pending' }, + metadata: { voided_by: [], first_block: null, nc_execution: 'pending' }, }, }, }, @@ -1137,7 +1139,7 @@ describe('metadataDiff', () => { event: { data: { hash: txHash, - metadata: { voided_by: [], first_block: [], nc_execution: 'pending' }, + metadata: { voided_by: [], first_block: null, nc_execution: 'pending' }, }, }, }, @@ -1158,7 +1160,7 @@ describe('metadataDiff', () => { event: { data: { hash: txHash, - metadata: { voided_by: [], first_block: [], nc_execution: 'pending' }, + metadata: { voided_by: [], first_block: null, nc_execution: 'pending' }, }, }, }, @@ -1179,7 +1181,7 @@ describe('metadataDiff', () => { event: { data: { hash: txHash, - metadata: { voided_by: [], first_block: [], nc_execution: null }, + metadata: { voided_by: [], first_block: null, nc_execution: null }, }, }, }, @@ -1199,7 +1201,7 @@ describe('metadataDiff', () => { id: 123, data: { hash: 'mockHash', - metadata: { voided_by: [], first_block: [] }, + metadata: { voided_by: [], first_block: null }, }, }, }, @@ -1217,7 +1219,7 @@ describe('metadataDiff', () => { event: { data: { hash: 'mockHash', - metadata: { voided_by: [], first_block: [] }, + metadata: { voided_by: [], first_block: null }, }, }, }, @@ -1585,6 +1587,18 @@ describe('handleNcExecVoided', () => { destroy: jest.fn(), }; + const createContext = (txHash: string, firstBlock: string | null = null) => ({ + event: { + event: { + id: 100, + data: { + hash: txHash, + metadata: { first_block: firstBlock }, + }, + }, + }, + }); + beforeEach(() => { jest.clearAllMocks(); (getDbConnection as jest.Mock).mockResolvedValue(mockDb); @@ -1592,23 +1606,17 @@ describe('handleNcExecVoided', () => { it('should not delete any tokens when no tokens exist for the transaction', async () => { const txHash = 'tx-without-tokens'; - const context = { - event: { - event: { - id: 100, - data: { - hash: txHash, - }, - }, - }, - }; + const context = createContext(txHash); (getTokensCreatedByTx as jest.Mock).mockResolvedValue([]); + (getTransactionById as jest.Mock).mockResolvedValue({ first_block: null }); - await handleNcExecVoided(context as any); + const result = await handleNcExecVoided(context as any); expect(getTokensCreatedByTx).toHaveBeenCalledWith(mockDb, txHash); expect(deleteTokens).not.toHaveBeenCalled(); + expect(addOrUpdateTx).not.toHaveBeenCalled(); + expect(result).toEqual({ firstBlockChanged: false }); expect(mockDb.commit).toHaveBeenCalled(); }); @@ -1616,87 +1624,78 @@ describe('handleNcExecVoided', () => { const txHash = 'nano-tx-hash'; const nanoToken1 = 'nano-token-001'; const nanoToken2 = 'nano-token-002'; - const context = { - event: { - event: { - id: 100, - data: { - hash: txHash, - }, - }, - }, - }; + const context = createContext(txHash); - // Nano tokens have token_id != tx_id (getTokensCreatedByTx as jest.Mock).mockResolvedValue([nanoToken1, nanoToken2]); + (getTransactionById as jest.Mock).mockResolvedValue({ first_block: null }); - await handleNcExecVoided(context as any); + const result = await handleNcExecVoided(context as any); - expect(getTokensCreatedByTx).toHaveBeenCalledWith(mockDb, txHash); expect(deleteTokens).toHaveBeenCalledWith(mockDb, [nanoToken1, nanoToken2]); + expect(addOrUpdateTx).not.toHaveBeenCalled(); + expect(result).toEqual({ firstBlockChanged: false }); expect(mockDb.commit).toHaveBeenCalled(); }); it('should NOT delete traditional CREATE_TOKEN_TX tokens (where token_id = tx_id)', async () => { const txHash = 'create-token-tx-hash'; - const context = { - event: { - event: { - id: 100, - data: { - hash: txHash, - }, - }, - }, - }; + const context = createContext(txHash); - // Traditional CREATE_TOKEN_TX has token_id = tx_id (getTokensCreatedByTx as jest.Mock).mockResolvedValue([txHash]); + (getTransactionById as jest.Mock).mockResolvedValue({ first_block: null }); await handleNcExecVoided(context as any); - expect(getTokensCreatedByTx).toHaveBeenCalledWith(mockDb, txHash); - expect(deleteTokens).not.toHaveBeenCalled(); // Should NOT delete traditional token + expect(deleteTokens).not.toHaveBeenCalled(); expect(mockDb.commit).toHaveBeenCalled(); }); it('should delete nano tokens but keep traditional token in hybrid transaction', async () => { const txHash = 'hybrid-tx-hash'; const nanoToken = 'nano-created-token'; - const context = { - event: { - event: { - id: 100, - data: { - hash: txHash, - }, - }, - }, - }; + const context = createContext(txHash); - // Hybrid tx has both: traditional (token_id = tx_id) and nano (token_id != tx_id) (getTokensCreatedByTx as jest.Mock).mockResolvedValue([txHash, nanoToken]); + (getTransactionById as jest.Mock).mockResolvedValue({ first_block: null }); await handleNcExecVoided(context as any); - expect(getTokensCreatedByTx).toHaveBeenCalledWith(mockDb, txHash); - // Should only delete nano token, not the traditional one expect(deleteTokens).toHaveBeenCalledWith(mockDb, [nanoToken]); expect(mockDb.commit).toHaveBeenCalled(); }); + it('should return firstBlockChanged=true when first_block changed', async () => { + const txHash = 'reorg-tx-hash'; + // Event says first_block is null (back to mempool) + const context = createContext(txHash, null); + + (getTokensCreatedByTx as jest.Mock).mockResolvedValue([]); + // DB has a first_block value + (getTransactionById as jest.Mock).mockResolvedValue({ first_block: 'old-block-hash' }); + + const result = await handleNcExecVoided(context as any); + + expect(result).toEqual({ firstBlockChanged: true }); + // Should NOT call dbUpdateLastSyncedEvent since handleTxFirstBlock will + expect(mockDb.commit).toHaveBeenCalled(); + }); + + it('should return firstBlockChanged=false and update last_synced_event when first_block did not change', async () => { + const txHash = 'same-block-tx-hash'; + const context = createContext(txHash, 'block-hash'); + + (getTokensCreatedByTx as jest.Mock).mockResolvedValue([]); + (getTransactionById as jest.Mock).mockResolvedValue({ first_block: 'block-hash' }); + + const result = await handleNcExecVoided(context as any); + + expect(result).toEqual({ firstBlockChanged: false }); + expect(mockDb.commit).toHaveBeenCalled(); + }); + it('should rollback on error and rethrow', async () => { const txHash = 'error-tx-hash'; - const context = { - event: { - event: { - id: 100, - data: { - hash: txHash, - }, - }, - }, - }; + const context = createContext(txHash); const error = new Error('Database error'); (getTokensCreatedByTx as jest.Mock).mockRejectedValue(error); diff --git a/packages/daemon/src/guards/index.ts b/packages/daemon/src/guards/index.ts index 5d3a08a0..7688a9a1 100644 --- a/packages/daemon/src/guards/index.ts +++ b/packages/daemon/src/guards/index.ts @@ -279,6 +279,14 @@ export const hasNewEvents = (_context: Context, event: any) => { return event.data.hasNewEvents === true; }; +/* + * This guard checks if handleNcExecVoided detected that first_block also changed. + * Used on the onDone transition to conditionally chain to handleTxFirstBlock. + */ +export const ncExecVoidedFirstBlockChanged = (_context: Context, event: any) => { + return event.data?.firstBlockChanged === true; +}; + /* * This guard is used to detect if the event is a TOKEN_CREATED event */ diff --git a/packages/daemon/src/machines/SyncMachine.ts b/packages/daemon/src/machines/SyncMachine.ts index 150ec56b..029a1587 100644 --- a/packages/daemon/src/machines/SyncMachine.ts +++ b/packages/daemon/src/machines/SyncMachine.ts @@ -49,6 +49,7 @@ import { reorgStarted, tokenCreated, hasNewEvents, + ncExecVoidedFirstBlockChanged, } from '../guards'; import { storeInitialState, @@ -301,10 +302,16 @@ export const SyncMachine = Machine({ invoke: { src: 'handleNcExecVoided', data: (_context: Context, event: Event) => event, - onDone: { + onDone: [{ + // If first_block also changed, chain to handleTxFirstBlock + target: `#${CONNECTED_STATES.handlingFirstBlock}`, + cond: 'ncExecVoidedFirstBlockChanged', + actions: ['storeEvent'], + }, { + // Otherwise, we're done target: 'idle', - actions: ['storeEvent', 'sendAck'], - }, + actions: ['storeEvent', 'sendAck', 'updateCache'], + }], onError: `#${SYNC_MACHINE_STATES.ERROR}`, }, }, @@ -396,6 +403,7 @@ export const SyncMachine = Machine({ reorgStarted, tokenCreated, hasNewEvents, + ncExecVoidedFirstBlockChanged, }, delays: { BACKOFF_DELAYED_RECONNECT, ACK_TIMEOUT }, actions: { diff --git a/packages/daemon/src/services/index.ts b/packages/daemon/src/services/index.ts index 6ad989d2..2d74a3ae 100644 --- a/packages/daemon/src/services/index.ts +++ b/packages/daemon/src/services/index.ts @@ -99,6 +99,7 @@ export const METADATA_DIFF_EVENT_TYPES = { NC_EXEC_VOIDED: 'NC_EXEC_VOIDED', }; + const DUPLICATE_TX_ALERT_GRACE_PERIOD = 10; // seconds export const metadataDiff = async (_context: Context, event: Event) => { @@ -163,23 +164,13 @@ export const metadataDiff = async (_context: Context, event: Event) => { }; } - // Handle first_block changes (NULL -> value OR value -> NULL) - const eventFirstBlock: string | null = (first_block && first_block.length > 0) - ? first_block - : null; - const dbFirstBlock: string | null = dbTx.first_block ?? null; - - if (eventFirstBlock !== dbFirstBlock) { - return { - type: METADATA_DIFF_EVENT_TYPES.TX_FIRST_BLOCK, - originalEvent: event, - }; - } - // Check if nc_execution changed from 'success' to something else. // If the tx has nano-created tokens in the database (tokens where token_id != tx_id), // those tokens were created when nc_execution was 'success'. // If nc_execution is now NOT 'success', we should delete those tokens. + // IMPORTANT: This check must come BEFORE the first_block check because during reorg, + // both first_block and nc_execution may change, and we need to prioritize deleting + // nano-created tokens when nc_execution is no longer 'success'. if (nc_execution !== 'success') { const tokensCreated = await getTokensCreatedByTx(mysql, hash); const nanoTokens = tokensCreated.filter(tokenId => tokenId !== hash); @@ -192,6 +183,17 @@ export const metadataDiff = async (_context: Context, event: Event) => { } } + // Handle first_block changes (NULL -> value OR value -> NULL) + const eventFirstBlock: string | null = first_block ?? null; + const dbFirstBlock: string | null = dbTx.first_block ?? null; + + if (eventFirstBlock !== dbFirstBlock) { + return { + type: METADATA_DIFF_EVENT_TYPES.TX_FIRST_BLOCK, + originalEvent: event, + }; + } + return { type: METADATA_DIFF_EVENT_TYPES.IGNORE, originalEvent: event, @@ -379,7 +381,7 @@ export const handleVertexAccepted = async (context: Context, _event: Event) => { markLockedOutputs(txOutputs, now, heightlock !== null); // Add the transaction - const firstBlock = metadata.first_block ?? null; + const firstBlock: string | null = metadata.first_block ?? null; logger.debug('Will add the tx with height', height); // TODO: add is_nanocontract to transaction table? await addOrUpdateTx( @@ -856,10 +858,14 @@ export const handleTxFirstBlock = async (context: Context) => { * changes from 'success' to 'pending' or null. When this occurs, any tokens created * by the nano contract execution are no longer valid. * - * This handler deletes all nano-created tokens for the transaction. Traditional - * CREATE_TOKEN_TX tokens (token_id = tx_id) are NOT affected - they remain valid - * because the token creation is inherent to the transaction itself, not dependent - * on nano contract execution. + * This handler deletes nano-created tokens. Traditional CREATE_TOKEN_TX tokens + * (token_id = tx_id) are NOT affected. + * + * Returns { firstBlockChanged: boolean } so the state machine can conditionally + * chain to handleTxFirstBlock if first_block also changed in this event. + * + * When first_block also changed, this handler does NOT call dbUpdateLastSyncedEvent + * because the chained handleTxFirstBlock will do that. */ export const handleNcExecVoided = async (context: Context) => { const mysql = await getDbConnection(); @@ -867,7 +873,7 @@ export const handleNcExecVoided = async (context: Context) => { try { const fullNodeEvent = context.event as StandardFullNodeEvent; - const { hash } = fullNodeEvent.event.data; + const { hash, metadata } = fullNodeEvent.event.data; // Get all tokens created by this transaction const tokensCreated = await getTokensCreatedByTx(mysql, hash); @@ -883,8 +889,19 @@ export const handleNcExecVoided = async (context: Context) => { } } - await dbUpdateLastSyncedEvent(mysql, fullNodeEvent.event.id); + // Check if first_block also changed + const dbTx = await getTransactionById(mysql, hash); + const eventFirstBlock: string | null = metadata.first_block ?? null; + const dbFirstBlock: string | null = dbTx?.first_block ?? null; + const firstBlockChanged = eventFirstBlock !== dbFirstBlock; + + if (!firstBlockChanged) { + // No first_block change, so we handle last_synced_event here + await dbUpdateLastSyncedEvent(mysql, fullNodeEvent.event.id); + } + await mysql.commit(); + return { firstBlockChanged }; } catch (e) { logger.error('handleNcExecVoided error: ', e); await mysql.rollback(); From 55117a0a0c9ecf0c0f5418f52b04d6a70a778dc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Abadesso?= Date: Tue, 27 Jan 2026 09:38:00 -0300 Subject: [PATCH 03/10] refactor(daemon): handling all metadata changes --- .../daemon/__tests__/guards/guards.test.ts | 141 ++++++------------ .../__tests__/machines/SyncMachine.test.ts | 69 +++++---- .../__tests__/services/services.test.ts | 98 ++++++------ packages/daemon/src/actions/index.ts | 43 +++--- packages/daemon/src/guards/index.ts | 85 ++--------- packages/daemon/src/machines/SyncMachine.ts | 85 +++++------ packages/daemon/src/services/index.ts | 57 +++---- packages/daemon/src/types/event.ts | 7 - packages/daemon/src/types/machine.ts | 1 + 9 files changed, 218 insertions(+), 368 deletions(-) diff --git a/packages/daemon/__tests__/guards/guards.test.ts b/packages/daemon/__tests__/guards/guards.test.ts index 0fc49a32..e2f61d04 100644 --- a/packages/daemon/__tests__/guards/guards.test.ts +++ b/packages/daemon/__tests__/guards/guards.test.ts @@ -1,10 +1,10 @@ -import { Context, Event, FullNodeEventTypes, StandardFullNodeEvent } from '../../src/types'; +import { Context, Event, FullNodeEventTypes } from '../../src/types'; import { - metadataIgnore, - metadataVoided, - metadataNewTx, - metadataFirstBlock, - metadataNcExecVoided, + nextChangeIsVoided, + nextChangeIsUnvoided, + nextChangeIsNewTx, + nextChangeIsFirstBlock, + nextChangeIsNcExecVoided, metadataChanged, vertexAccepted, invalidPeerId, @@ -99,99 +99,54 @@ const generateFullNodeEvent = (type: FullNodeEventTypes, data = {} as any): Even return generateStandardFullNodeEvent(type, data); }; -const generateMetadataDecidedEvent = (type: 'TX_VOIDED' | 'TX_UNVOIDED' | 'TX_NEW' | 'TX_FIRST_BLOCK' | 'IGNORE' | 'NC_EXEC_VOIDED'): Event => { - const fullNodeEvent: StandardFullNodeEvent = { - stream_id: '', - peer_id: '', - network: 'mainnet', - type: 'EVENT', - latest_event_id: 0, - event: { - id: 0, - timestamp: 0, - type: FullNodeEventTypes.VERTEX_METADATA_CHANGED, - data: { - hash: 'hash', - timestamp: 0, - version: 1, - weight: 1, - nonce: 1n, - inputs: [], - outputs: [], - parents: [], - tokens: [], - token_name: null, - token_symbol: null, - signal_bits: 1, - metadata: { - hash: 'hash', - voided_by: [], - first_block: null, - height: 1, - }, - }, - }, - }; +describe('metadata dispatch queue guards', () => { + const contextWithChange = (changeType: string): Context => ({ + ...mockContext, + pendingMetadataChanges: [changeType], + }); - return { - type: EventTypes.METADATA_DECIDED, - event: { - type, - originalEvent: fullNodeEvent, - }, + const emptyContext: Context = { + ...mockContext, + pendingMetadataChanges: [], }; -}; -describe('metadata decided tests', () => { - test('metadataIgnore', async () => { - expect(metadataIgnore(mockContext, generateMetadataDecidedEvent('IGNORE'))).toBe(true); - expect(metadataIgnore(mockContext, generateMetadataDecidedEvent('TX_NEW'))).toBe(false); - expect(metadataIgnore(mockContext, generateMetadataDecidedEvent('TX_VOIDED'))).toBe(false); - expect(metadataIgnore(mockContext, generateMetadataDecidedEvent('TX_FIRST_BLOCK'))).toBe(false); - - // Any event other than METADATA_DECIDED should throw an error: - expect(() => metadataIgnore(mockContext, generateFullNodeEvent(FullNodeEventTypes.VERTEX_METADATA_CHANGED))).toThrow('Invalid event type on metadataIgnore guard: FULLNODE_EVENT'); + test('nextChangeIsVoided', () => { + expect(nextChangeIsVoided(contextWithChange('TX_VOIDED'))).toBe(true); + expect(nextChangeIsVoided(contextWithChange('TX_NEW'))).toBe(false); + expect(nextChangeIsVoided(emptyContext)).toBe(false); }); - test('metadataVoided', () => { - expect(metadataVoided(mockContext, generateMetadataDecidedEvent('TX_VOIDED'))).toBe(true); - expect(metadataVoided(mockContext, generateMetadataDecidedEvent('IGNORE'))).toBe(false); - expect(metadataVoided(mockContext, generateMetadataDecidedEvent('TX_NEW'))).toBe(false); - expect(metadataVoided(mockContext, generateMetadataDecidedEvent('TX_FIRST_BLOCK'))).toBe(false); - - // Any event other than METADATA_DECIDED should return false: - expect(() => metadataIgnore(mockContext, generateFullNodeEvent(FullNodeEventTypes.VERTEX_METADATA_CHANGED))).toThrow('Invalid event type on metadataIgnore guard: FULLNODE_EVENT'); + test('nextChangeIsUnvoided', () => { + expect(nextChangeIsUnvoided(contextWithChange('TX_UNVOIDED'))).toBe(true); + expect(nextChangeIsUnvoided(contextWithChange('TX_VOIDED'))).toBe(false); + expect(nextChangeIsUnvoided(emptyContext)).toBe(false); }); - test('metadataNewTx', () => { - expect(metadataNewTx(mockContext, generateMetadataDecidedEvent('TX_NEW'))).toBe(true); - expect(metadataNewTx(mockContext, generateMetadataDecidedEvent('TX_FIRST_BLOCK'))).toBe(false); - expect(metadataNewTx(mockContext, generateMetadataDecidedEvent('TX_VOIDED'))).toBe(false); - expect(metadataNewTx(mockContext, generateMetadataDecidedEvent('IGNORE'))).toBe(false); - - // Any event other than METADATA_DECIDED should return false: - expect(() => metadataIgnore(mockContext, generateFullNodeEvent(FullNodeEventTypes.VERTEX_METADATA_CHANGED))).toThrow('Invalid event type on metadataIgnore guard: FULLNODE_EVENT'); + test('nextChangeIsNewTx', () => { + expect(nextChangeIsNewTx(contextWithChange('TX_NEW'))).toBe(true); + expect(nextChangeIsNewTx(contextWithChange('TX_VOIDED'))).toBe(false); + expect(nextChangeIsNewTx(emptyContext)).toBe(false); }); - test('metadataFirstBlock', () => { - expect(metadataFirstBlock(mockContext, generateMetadataDecidedEvent('TX_FIRST_BLOCK'))).toBe(true); - expect(metadataFirstBlock(mockContext, generateMetadataDecidedEvent('TX_VOIDED'))).toBe(false); - expect(metadataFirstBlock(mockContext, generateMetadataDecidedEvent('IGNORE'))).toBe(false); - expect(metadataFirstBlock(mockContext, generateMetadataDecidedEvent('TX_NEW'))).toBe(false); - - // Any event other than METADATA_DECIDED should return false: - expect(() => metadataIgnore(mockContext, generateFullNodeEvent(FullNodeEventTypes.VERTEX_METADATA_CHANGED))).toThrow('Invalid event type on metadataIgnore guard: FULLNODE_EVENT'); + test('nextChangeIsFirstBlock', () => { + expect(nextChangeIsFirstBlock(contextWithChange('TX_FIRST_BLOCK'))).toBe(true); + expect(nextChangeIsFirstBlock(contextWithChange('TX_VOIDED'))).toBe(false); + expect(nextChangeIsFirstBlock(emptyContext)).toBe(false); }); - test('metadataNcExecVoided', () => { - expect(metadataNcExecVoided(mockContext, generateMetadataDecidedEvent('NC_EXEC_VOIDED'))).toBe(true); - expect(metadataNcExecVoided(mockContext, generateMetadataDecidedEvent('TX_VOIDED'))).toBe(false); - expect(metadataNcExecVoided(mockContext, generateMetadataDecidedEvent('IGNORE'))).toBe(false); - expect(metadataNcExecVoided(mockContext, generateMetadataDecidedEvent('TX_NEW'))).toBe(false); - expect(metadataNcExecVoided(mockContext, generateMetadataDecidedEvent('TX_FIRST_BLOCK'))).toBe(false); + test('nextChangeIsNcExecVoided', () => { + expect(nextChangeIsNcExecVoided(contextWithChange('NC_EXEC_VOIDED'))).toBe(true); + expect(nextChangeIsNcExecVoided(contextWithChange('TX_VOIDED'))).toBe(false); + expect(nextChangeIsNcExecVoided(emptyContext)).toBe(false); + }); - // Any event other than METADATA_DECIDED should throw: - expect(() => metadataNcExecVoided(mockContext, generateFullNodeEvent(FullNodeEventTypes.VERTEX_METADATA_CHANGED))).toThrow('Invalid event type on metadataNcExecVoided guard: FULLNODE_EVENT'); + test('guards return false when pendingMetadataChanges is undefined', () => { + const ctx = { ...mockContext, pendingMetadataChanges: undefined }; + expect(nextChangeIsVoided(ctx)).toBe(false); + expect(nextChangeIsUnvoided(ctx)).toBe(false); + expect(nextChangeIsNewTx(ctx)).toBe(false); + expect(nextChangeIsFirstBlock(ctx)).toBe(false); + expect(nextChangeIsNcExecVoided(ctx)).toBe(false); }); }); @@ -201,7 +156,7 @@ describe('fullnode event guards', () => { expect(vertexAccepted(mockContext, generateFullNodeEvent(FullNodeEventTypes.VERTEX_METADATA_CHANGED))).toBe(false); // Any event other than FULLNODE_EVENT should return false - expect(() => vertexAccepted(mockContext, generateMetadataDecidedEvent('TX_NEW'))).toThrow('Invalid event type on vertexAccepted guard: METADATA_DECIDED'); + expect(() => vertexAccepted(mockContext, { type: EventTypes.WEBSOCKET_EVENT, event: { type: 'CONNECTED' } } as Event)).toThrow('Invalid event type on vertexAccepted guard: WEBSOCKET_EVENT'); }); test('metadataChanged', () => { @@ -209,7 +164,7 @@ describe('fullnode event guards', () => { expect(metadataChanged(mockContext, generateFullNodeEvent(FullNodeEventTypes.NEW_VERTEX_ACCEPTED))).toBe(false); // Any event other than FULLNODE_EVENT should return false - expect(() => metadataChanged(mockContext, generateMetadataDecidedEvent('IGNORE'))).toThrow('Invalid event type on metadataChanged guard: METADATA_DECIDED'); + expect(() => metadataChanged(mockContext, { type: EventTypes.WEBSOCKET_EVENT, event: { type: 'CONNECTED' } } as Event)).toThrow('Invalid event type on metadataChanged guard: WEBSOCKET_EVENT'); }); test('voided', () => { @@ -230,7 +185,7 @@ describe('fullnode event guards', () => { expect(voided(mockContext, fullNodeNotVoidedEvent)).toBe(false); // Any event other than FULLNODE_EVENT should return false - expect(() => voided(mockContext, generateMetadataDecidedEvent('TX_NEW'))).toThrow('Invalid event type on voided guard: METADATA_DECIDED'); + expect(() => voided(mockContext, { type: EventTypes.WEBSOCKET_EVENT, event: { type: 'CONNECTED' } } as Event)).toThrow('Invalid event type on voided guard: WEBSOCKET_EVENT'); // Any fullndode event other VERTEX_METADATA_CHANGED and NEW_VERTEX_ACCEPTED // should return false @@ -251,7 +206,7 @@ describe('fullnode event guards', () => { expect(unchanged(mockContext, fullNodeEvent)).toBe(false); // Any event other than FULLNODE_EVENT should return false - expect(() => unchanged(mockContext, generateMetadataDecidedEvent('TX_NEW'))).toThrow('Invalid event type on unchanged guard: METADATA_DECIDED'); + expect(() => unchanged(mockContext, { type: EventTypes.WEBSOCKET_EVENT, event: { type: 'CONNECTED' } } as Event)).toThrow('Invalid event type on unchanged guard: WEBSOCKET_EVENT'); }); test('reorgStarted', () => { @@ -259,7 +214,7 @@ describe('fullnode event guards', () => { expect(reorgStarted(mockContext, generateFullNodeEvent(FullNodeEventTypes.VERTEX_METADATA_CHANGED))).toBe(false); // Any event other than FULLNODE_EVENT should throw - expect(() => reorgStarted(mockContext, generateMetadataDecidedEvent('TX_NEW'))).toThrow('Invalid event type on reorgStarted guard: METADATA_DECIDED'); + expect(() => reorgStarted(mockContext, { type: EventTypes.WEBSOCKET_EVENT, event: { type: 'CONNECTED' } } as Event)).toThrow('Invalid event type on reorgStarted guard: WEBSOCKET_EVENT'); }); test('tokenCreated', () => { @@ -269,7 +224,7 @@ describe('fullnode event guards', () => { expect(tokenCreated(mockContext, generateFullNodeEvent(FullNodeEventTypes.REORG_STARTED))).toBe(false); // Any event other than FULLNODE_EVENT should throw - expect(() => tokenCreated(mockContext, generateMetadataDecidedEvent('TX_NEW'))).toThrow('Invalid event type on tokenCreated guard: METADATA_DECIDED'); + expect(() => tokenCreated(mockContext, { type: EventTypes.WEBSOCKET_EVENT, event: { type: 'CONNECTED' } } as Event)).toThrow('Invalid event type on tokenCreated guard: WEBSOCKET_EVENT'); }); }); diff --git a/packages/daemon/__tests__/machines/SyncMachine.test.ts b/packages/daemon/__tests__/machines/SyncMachine.test.ts index 2c628a59..d59f116b 100644 --- a/packages/daemon/__tests__/machines/SyncMachine.test.ts +++ b/packages/daemon/__tests__/machines/SyncMachine.test.ts @@ -367,7 +367,7 @@ describe('Event handling', () => { expect(currentState.context.event.event.id).toStrictEqual(VERTEX_METADATA_CHANGED.event.id); }); - it('should transition to handlingVoidedTx if TX_VOIDED action is received from diff detector', () => { + it('should transition to handlingVoidedTx when dispatching with TX_VOIDED in queue', () => { const MockedFetchMachine = SyncMachine.withConfig({ guards: { invalidPeerId: () => false, @@ -385,18 +385,19 @@ describe('Event handling', () => { expect(currentState.matches(`${SYNC_MACHINE_STATES.CONNECTED}.${CONNECTED_STATES.handlingMetadataChanged}.detectingDiff`)).toBeTruthy(); + // Simulate metadataDiff onDone → dispatching with storeMetadataChanges currentState = MockedFetchMachine.transition(currentState, { - type: EventTypes.METADATA_DECIDED, - event: { - type: 'TX_VOIDED', - originalEvent: VERTEX_METADATA_CHANGED as unknown as FullNodeEvent, + type: 'done.invoke.SyncMachine.CONNECTED.handlingMetadataChanged.detectingDiff:invocation[0]', + data: { + types: ['TX_VOIDED'], + originalEvent: { event: VERTEX_METADATA_CHANGED }, }, - }); + } as any); expect(currentState.matches(`${SYNC_MACHINE_STATES.CONNECTED}.${CONNECTED_STATES.handlingVoidedTx}`)).toBeTruthy(); }); - it('should transition to handlingUnvoidedTx if TX_UNVOIDED action is received from diff detector', () => { + it('should transition to handlingUnvoidedTx when dispatching with TX_UNVOIDED in queue', () => { const MockedFetchMachine = SyncMachine.withConfig({ guards: { invalidPeerId: () => false, @@ -415,17 +416,17 @@ describe('Event handling', () => { expect(currentState.matches(`${SYNC_MACHINE_STATES.CONNECTED}.${CONNECTED_STATES.handlingMetadataChanged}.detectingDiff`)).toBeTruthy(); currentState = MockedFetchMachine.transition(currentState, { - type: EventTypes.METADATA_DECIDED, - event: { - type: 'TX_UNVOIDED', - originalEvent: VERTEX_METADATA_CHANGED as unknown as FullNodeEvent, + type: 'done.invoke.SyncMachine.CONNECTED.handlingMetadataChanged.detectingDiff:invocation[0]', + data: { + types: ['TX_UNVOIDED'], + originalEvent: { event: VERTEX_METADATA_CHANGED }, }, - }); + } as any); expect(currentState.matches(`${SYNC_MACHINE_STATES.CONNECTED}.${CONNECTED_STATES.handlingUnvoidedTx}`)).toBeTruthy(); }); - it('should transition to handlingVertexAccepted if TX_NEW action is received from diff detector', () => { + it('should transition to handlingVertexAccepted when dispatching with TX_NEW in queue', () => { const MockedFetchMachine = SyncMachine.withConfig({ guards: { invalidPeerId: () => false, @@ -444,17 +445,17 @@ describe('Event handling', () => { expect(currentState.matches(`${SYNC_MACHINE_STATES.CONNECTED}.${CONNECTED_STATES.handlingMetadataChanged}.detectingDiff`)).toBeTruthy(); currentState = MockedFetchMachine.transition(currentState, { - type: EventTypes.METADATA_DECIDED, - event: { - type: 'TX_NEW', - originalEvent: VERTEX_METADATA_CHANGED as unknown as FullNodeEvent, - } - }); + type: 'done.invoke.SyncMachine.CONNECTED.handlingMetadataChanged.detectingDiff:invocation[0]', + data: { + types: ['TX_NEW'], + originalEvent: { event: VERTEX_METADATA_CHANGED }, + }, + } as any); expect(currentState.matches(`${SYNC_MACHINE_STATES.CONNECTED}.${CONNECTED_STATES.handlingVertexAccepted}`)).toBeTruthy(); }); - it('should transition to handlingFirstBlock if TX_FIRST_BLOCK action is received from diff detector', () => { + it('should transition to handlingFirstBlock when dispatching with TX_FIRST_BLOCK in queue', () => { const MockedFetchMachine = SyncMachine.withConfig({ guards: { invalidPeerId: () => false, @@ -473,18 +474,17 @@ describe('Event handling', () => { expect(currentState.matches(`${SYNC_MACHINE_STATES.CONNECTED}.${CONNECTED_STATES.handlingMetadataChanged}.detectingDiff`)).toBeTruthy(); currentState = MockedFetchMachine.transition(currentState, { - // @ts-ignore - type: EventTypes.METADATA_DECIDED, - event: { - type: 'TX_FIRST_BLOCK', - originalEvent: VERTEX_METADATA_CHANGED as unknown as FullNodeEvent, - } - }); + type: 'done.invoke.SyncMachine.CONNECTED.handlingMetadataChanged.detectingDiff:invocation[0]', + data: { + types: ['TX_FIRST_BLOCK'], + originalEvent: { event: VERTEX_METADATA_CHANGED }, + }, + } as any); expect(currentState.matches(`${SYNC_MACHINE_STATES.CONNECTED}.${CONNECTED_STATES.handlingFirstBlock}`)).toBeTruthy(); }); - it('should transition to handlingUnhandledEvent if IGNORE action is received from diff detector', () => { + it('should transition to handlingUnhandledEvent when dispatching with IGNORE in queue', () => { const MockedFetchMachine = SyncMachine.withConfig({ guards: { invalidPeerId: () => false, @@ -503,13 +503,12 @@ describe('Event handling', () => { expect(currentState.matches(`${SYNC_MACHINE_STATES.CONNECTED}.${CONNECTED_STATES.handlingMetadataChanged}.detectingDiff`)).toBeTruthy(); currentState = MockedFetchMachine.transition(currentState, { - // @ts-ignore - type: EventTypes.METADATA_DECIDED, - event: { - type: 'IGNORE', - originalEvent: VERTEX_METADATA_CHANGED as unknown as FullNodeEvent, - } - }); + type: 'done.invoke.SyncMachine.CONNECTED.handlingMetadataChanged.detectingDiff:invocation[0]', + data: { + types: ['IGNORE'], + originalEvent: { event: VERTEX_METADATA_CHANGED }, + }, + } as any); expect(currentState.matches(`${SYNC_MACHINE_STATES.CONNECTED}.${CONNECTED_STATES.handlingUnhandledEvent}`)).toBeTruthy(); }); diff --git a/packages/daemon/__tests__/services/services.test.ts b/packages/daemon/__tests__/services/services.test.ts index 0e459c30..32d16e14 100644 --- a/packages/daemon/__tests__/services/services.test.ts +++ b/packages/daemon/__tests__/services/services.test.ts @@ -919,7 +919,7 @@ describe('metadataDiff', () => { const result = await metadataDiff({} as any, event as any); - expect(result.type).toBe('IGNORE'); + expect(result.types).toEqual(['IGNORE']); }); it('should handle new transactions', async () => { @@ -937,7 +937,7 @@ describe('metadataDiff', () => { (getTransactionById as jest.Mock).mockResolvedValue(null); const result = await metadataDiff({} as any, event as any); - expect(result.type).toBe('TX_NEW'); + expect(result.types).toEqual(['TX_NEW']); }); it('should handle transaction voided but not voided in database', async () => { @@ -955,7 +955,7 @@ describe('metadataDiff', () => { (getTransactionById as jest.Mock).mockResolvedValue(mockDbTransaction); const result = await metadataDiff({} as any, event as any); - expect(result.type).toBe('TX_VOIDED'); + expect(result.types).toEqual(['TX_VOIDED']); }); it('should ignore transaction voided and also voided in database', async () => { @@ -973,7 +973,7 @@ describe('metadataDiff', () => { (getTransactionById as jest.Mock).mockResolvedValue(mockDbTransaction); const result = await metadataDiff({} as any, event as any); - expect(result.type).toBe('IGNORE'); + expect(result.types).toEqual(['IGNORE']); }); it('should handle transaction with first_block but no first_block in database', async () => { @@ -991,7 +991,7 @@ describe('metadataDiff', () => { (getTransactionById as jest.Mock).mockResolvedValue(mockDbTransaction); const result = await metadataDiff({} as any, event as any); - expect(result.type).toBe('TX_FIRST_BLOCK'); + expect(result.types).toEqual(['TX_FIRST_BLOCK']); }); it('should ignore transaction with first_block and same first_block in database', async () => { @@ -1009,7 +1009,7 @@ describe('metadataDiff', () => { (getTransactionById as jest.Mock).mockResolvedValue(mockDbTransaction); const result = await metadataDiff({} as any, event as any); - expect(result.type).toBe('IGNORE'); + expect(result.types).toEqual(['IGNORE']); }); it('should return TX_FIRST_BLOCK when transaction goes back to mempool (first_block changes to null)', async () => { @@ -1029,7 +1029,7 @@ describe('metadataDiff', () => { (getTransactionById as jest.Mock).mockResolvedValue(mockDbTransaction); const result = await metadataDiff({} as any, event as any); - expect(result.type).toBe('TX_FIRST_BLOCK'); + expect(result.types).toEqual(['TX_FIRST_BLOCK']); }); it('should return TX_FIRST_BLOCK when first_block changes to different block (reorg)', async () => { @@ -1049,7 +1049,7 @@ describe('metadataDiff', () => { (getTransactionById as jest.Mock).mockResolvedValue(mockDbTransaction); const result = await metadataDiff({} as any, event as any); - expect(result.type).toBe('TX_FIRST_BLOCK'); + expect(result.types).toEqual(['TX_FIRST_BLOCK']); }); it('should ignore transaction with null first_block in both event and database', async () => { @@ -1068,7 +1068,7 @@ describe('metadataDiff', () => { (getTransactionById as jest.Mock).mockResolvedValue(mockDbTransaction); const result = await metadataDiff({} as any, event as any); - expect(result.type).toBe('IGNORE'); + expect(result.types).toEqual(['IGNORE']); }); it('should return IGNORE when nc_execution is not success but no nano tokens exist', async () => { @@ -1087,7 +1087,7 @@ describe('metadataDiff', () => { (getTokensCreatedByTx as jest.Mock).mockResolvedValue([]); // No tokens const result = await metadataDiff({} as any, event as any); - expect(result.type).toBe('IGNORE'); + expect(result.types).toEqual(['IGNORE']); }); it('should return IGNORE when nc_execution is success', async () => { @@ -1105,7 +1105,7 @@ describe('metadataDiff', () => { (getTransactionById as jest.Mock).mockResolvedValue(mockDbTransaction); const result = await metadataDiff({} as any, event as any); - expect(result.type).toBe('IGNORE'); + expect(result.types).toEqual(['IGNORE']); // Should not call getTokensCreatedByTx when nc_execution is success expect(getTokensCreatedByTx).not.toHaveBeenCalled(); }); @@ -1128,7 +1128,7 @@ describe('metadataDiff', () => { (getTokensCreatedByTx as jest.Mock).mockResolvedValue(['nano-token-001', 'nano-token-002']); const result = await metadataDiff({} as any, event as any); - expect(result.type).toBe('NC_EXEC_VOIDED'); + expect(result.types).toEqual(['NC_EXEC_VOIDED']); expect(getTokensCreatedByTx).toHaveBeenCalledWith(expect.anything(), txHash); }); @@ -1150,7 +1150,7 @@ describe('metadataDiff', () => { (getTokensCreatedByTx as jest.Mock).mockResolvedValue([txHash]); const result = await metadataDiff({} as any, event as any); - expect(result.type).toBe('IGNORE'); + expect(result.types).toEqual(['IGNORE']); }); it('should return NC_EXEC_VOIDED for hybrid tx with both traditional and nano tokens', async () => { @@ -1171,7 +1171,7 @@ describe('metadataDiff', () => { (getTokensCreatedByTx as jest.Mock).mockResolvedValue([txHash, 'nano-token-001']); const result = await metadataDiff({} as any, event as any); - expect(result.type).toBe('NC_EXEC_VOIDED'); + expect(result.types).toEqual(['NC_EXEC_VOIDED']); }); it('should return NC_EXEC_VOIDED when nc_execution is null and nano tokens exist', async () => { @@ -1191,7 +1191,34 @@ describe('metadataDiff', () => { (getTokensCreatedByTx as jest.Mock).mockResolvedValue(['nano-token-001']); const result = await metadataDiff({} as any, event as any); - expect(result.type).toBe('NC_EXEC_VOIDED'); + expect(result.types).toEqual(['NC_EXEC_VOIDED']); + }); + + it('should return both NC_EXEC_VOIDED and TX_FIRST_BLOCK when both changed in the same event', async () => { + // During a reorg, a single VERTEX_METADATA_CHANGED event can carry BOTH: + // 1. nc_execution changing from 'success' to 'pending' (nano tokens must be deleted) + // 2. first_block changing (tx moved to different block or back to mempool) + // metadataDiff must detect all independent changes, not just the first one. + const txHash = 'reorg-tx-hash'; + const event = { + event: { + event: { + data: { + hash: txHash, + metadata: { voided_by: [], first_block: null, nc_execution: 'pending' }, + }, + }, + }, + }; + // DB has first_block set, event has null → first_block changed + const mockDbTransaction = { height: 1, voided: false, first_block: 'old-block' }; + (getTransactionById as jest.Mock).mockResolvedValue(mockDbTransaction); + (getTokensCreatedByTx as jest.Mock).mockResolvedValue(['nano-token-001']); + + const result = await metadataDiff({} as any, event as any); + // Both changes must be detected — not just the first one + expect(result.types).toEqual(['NC_EXEC_VOIDED', 'TX_FIRST_BLOCK']); + expect(result.types).toHaveLength(2); }); it('should handle errors and destroy the database connection', async () => { @@ -1228,7 +1255,7 @@ describe('metadataDiff', () => { (getTransactionById as jest.Mock).mockResolvedValue(mockDbTransaction); const result = await metadataDiff({} as any, event as any); - expect(result.type).toBe('TX_UNVOIDED'); + expect(result.types).toEqual(['TX_UNVOIDED']); }); }); @@ -1609,14 +1636,12 @@ describe('handleNcExecVoided', () => { const context = createContext(txHash); (getTokensCreatedByTx as jest.Mock).mockResolvedValue([]); - (getTransactionById as jest.Mock).mockResolvedValue({ first_block: null }); - const result = await handleNcExecVoided(context as any); + await handleNcExecVoided(context as any); expect(getTokensCreatedByTx).toHaveBeenCalledWith(mockDb, txHash); expect(deleteTokens).not.toHaveBeenCalled(); expect(addOrUpdateTx).not.toHaveBeenCalled(); - expect(result).toEqual({ firstBlockChanged: false }); expect(mockDb.commit).toHaveBeenCalled(); }); @@ -1627,13 +1652,11 @@ describe('handleNcExecVoided', () => { const context = createContext(txHash); (getTokensCreatedByTx as jest.Mock).mockResolvedValue([nanoToken1, nanoToken2]); - (getTransactionById as jest.Mock).mockResolvedValue({ first_block: null }); - const result = await handleNcExecVoided(context as any); + await handleNcExecVoided(context as any); expect(deleteTokens).toHaveBeenCalledWith(mockDb, [nanoToken1, nanoToken2]); expect(addOrUpdateTx).not.toHaveBeenCalled(); - expect(result).toEqual({ firstBlockChanged: false }); expect(mockDb.commit).toHaveBeenCalled(); }); @@ -1642,7 +1665,6 @@ describe('handleNcExecVoided', () => { const context = createContext(txHash); (getTokensCreatedByTx as jest.Mock).mockResolvedValue([txHash]); - (getTransactionById as jest.Mock).mockResolvedValue({ first_block: null }); await handleNcExecVoided(context as any); @@ -1656,7 +1678,6 @@ describe('handleNcExecVoided', () => { const context = createContext(txHash); (getTokensCreatedByTx as jest.Mock).mockResolvedValue([txHash, nanoToken]); - (getTransactionById as jest.Mock).mockResolvedValue({ first_block: null }); await handleNcExecVoided(context as any); @@ -1664,35 +1685,6 @@ describe('handleNcExecVoided', () => { expect(mockDb.commit).toHaveBeenCalled(); }); - it('should return firstBlockChanged=true when first_block changed', async () => { - const txHash = 'reorg-tx-hash'; - // Event says first_block is null (back to mempool) - const context = createContext(txHash, null); - - (getTokensCreatedByTx as jest.Mock).mockResolvedValue([]); - // DB has a first_block value - (getTransactionById as jest.Mock).mockResolvedValue({ first_block: 'old-block-hash' }); - - const result = await handleNcExecVoided(context as any); - - expect(result).toEqual({ firstBlockChanged: true }); - // Should NOT call dbUpdateLastSyncedEvent since handleTxFirstBlock will - expect(mockDb.commit).toHaveBeenCalled(); - }); - - it('should return firstBlockChanged=false and update last_synced_event when first_block did not change', async () => { - const txHash = 'same-block-tx-hash'; - const context = createContext(txHash, 'block-hash'); - - (getTokensCreatedByTx as jest.Mock).mockResolvedValue([]); - (getTransactionById as jest.Mock).mockResolvedValue({ first_block: 'block-hash' }); - - const result = await handleNcExecVoided(context as any); - - expect(result).toEqual({ firstBlockChanged: false }); - expect(mockDb.commit).toHaveBeenCalled(); - }); - it('should rollback on error and rethrow', async () => { const txHash = 'error-tx-hash'; const context = createContext(txHash); diff --git a/packages/daemon/src/actions/index.ts b/packages/daemon/src/actions/index.ts index dfd949c4..70fe5461 100644 --- a/packages/daemon/src/actions/index.ts +++ b/packages/daemon/src/actions/index.ts @@ -5,7 +5,7 @@ * LICENSE file in the root directory of this source tree. */ -import { assign, AssignAction, raise, sendTo } from 'xstate'; +import { assign, AssignAction, sendTo } from 'xstate'; import { Context, Event, EventTypes, StandardFullNodeEvent } from '../types'; import { get } from 'lodash'; import logger from '../logger'; @@ -28,21 +28,28 @@ export const storeInitialState = assign({ }); /* - * This action is used to set the context event to the event that comes on the - * event. - * - * This is used after the metadataDiff service detects what is the type of the - * event, so the state is transitioned to the right place and the event is set - * to the original event (that initiated the metadata diff check) + * This action stores the metadata change types from metadataDiff into context + * and sets context.event from the original event. */ -export const unwrapEvent = assign({ - // @ts-ignore: The return event.event.originalEvent.event is not the correct type for an event. +export const storeMetadataChanges = assign({ + pendingMetadataChanges: (_context: Context, event: Event) => { + // @ts-ignore + return event.data.types; + }, + // @ts-ignore event: (_context: Context, event: Event) => { - if (event.type !== 'METADATA_DECIDED') { - throw new Error(`Received unhandled ${event.type} on unwrapEvent action`); - } + // @ts-ignore + return event.data.originalEvent.event; + }, +}); - return event.event.originalEvent.event; +/* + * This action removes the first element from pendingMetadataChanges. + */ +export const shiftMetadataChange = assign({ + pendingMetadataChanges: (context: Context) => { + const changes = context.pendingMetadataChanges ?? []; + return changes.slice(1); }, }); @@ -151,16 +158,6 @@ export const sendAck = sendTo(getSocketRefFromContext, } }); -/* - * This action is used to raise the metadataDecided event on the machine. - * This is currently used to indicate that the metadataDiff service finished and - * yielded a result - */ -export const metadataDecided = raise((_context: Context, event: Event) => ({ - type: EventTypes.METADATA_DECIDED, - // @ts-ignore - event: event.data, -})); /* * Updates the cache with the last processed event (from the context) diff --git a/packages/daemon/src/guards/index.ts b/packages/daemon/src/guards/index.ts index 7688a9a1..4a2b8739 100644 --- a/packages/daemon/src/guards/index.ts +++ b/packages/daemon/src/guards/index.ts @@ -12,80 +12,27 @@ import getConfig from '../config'; import logger from '../logger'; /* - * This guard is used during the `handlingMetadataChanged` to check if - * the result was an IGNORE event + * Guards for the metadata change dispatch queue. + * These check context.pendingMetadataChanges[0] to route to the correct handler. */ -export const metadataIgnore = (_context: Context, event: Event) => { - if (event.type !== EventTypes.METADATA_DECIDED) { - throw new Error(`Invalid event type on metadataIgnore guard: ${event.type}`); - } - - return event.event.type === METADATA_DIFF_EVENT_TYPES.IGNORE; -}; - -/* - * This guard is used during the `handlingMetadataChanged` to check if - * the result was a TX_VOIDED event - */ -export const metadataVoided = (_context: Context, event: Event) => { - if (event.type !== EventTypes.METADATA_DECIDED) { - throw new Error(`Invalid event type on metadataVoided guard: ${event.type}`); - } - - return event.event.type === METADATA_DIFF_EVENT_TYPES.TX_VOIDED; +export const nextChangeIsVoided = (context: Context) => { + return context.pendingMetadataChanges?.[0] === METADATA_DIFF_EVENT_TYPES.TX_VOIDED; }; -/* - * This guard is used during the `handlingMetadataChanged` to check if - * the result was a TX_UNVOIDED event, which means the tx was voided - * and then got unvoided - */ -export const metadataUnvoided = (_context: Context, event: Event) => { - if (event.type !== EventTypes.METADATA_DECIDED) { - throw new Error(`Invalid event type on metadataUnvoided guard: ${event.type}`); - } - - return event.event.type === METADATA_DIFF_EVENT_TYPES.TX_UNVOIDED; +export const nextChangeIsUnvoided = (context: Context) => { + return context.pendingMetadataChanges?.[0] === METADATA_DIFF_EVENT_TYPES.TX_UNVOIDED; }; -/* - * This guard is used during the `handlingMetadataChanged` to check if - * the result was a TX_NEW event, which means that we should insert - * this transaction on the database - */ -export const metadataNewTx = (_context: Context, event: Event) => { - if (event.type !== EventTypes.METADATA_DECIDED) { - throw new Error(`Invalid event type on metadataNewTx guard: ${event.type}`); - } - - return event.event.type === METADATA_DIFF_EVENT_TYPES.TX_NEW; +export const nextChangeIsNewTx = (context: Context) => { + return context.pendingMetadataChanges?.[0] === METADATA_DIFF_EVENT_TYPES.TX_NEW; }; -/* - * This guard is used during the `handlingMetadataChanged` to check if - * the result was a TX_FIRST_BLOCK event, which means that we should insert - * the height of this transaction to the database - */ -export const metadataFirstBlock = (_context: Context, event: Event) => { - if (event.type !== EventTypes.METADATA_DECIDED) { - throw new Error(`Invalid event type on metadataFirstBlock guard: ${event.type}`); - } - - return event.event.type === METADATA_DIFF_EVENT_TYPES.TX_FIRST_BLOCK; +export const nextChangeIsFirstBlock = (context: Context) => { + return context.pendingMetadataChanges?.[0] === METADATA_DIFF_EVENT_TYPES.TX_FIRST_BLOCK; }; -/* - * This guard is used during the `handlingMetadataChanged` to check if - * the result was a NC_EXEC_VOIDED event, which means nc_execution changed - * from 'success' to something else (pending, null, etc.) during a reorg. - * We need to delete any nano-created tokens for this transaction. - */ -export const metadataNcExecVoided = (_context: Context, event: Event) => { - if (event.type !== EventTypes.METADATA_DECIDED) { - throw new Error(`Invalid event type on metadataNcExecVoided guard: ${event.type}`); - } - - return event.event.type === METADATA_DIFF_EVENT_TYPES.NC_EXEC_VOIDED; +export const nextChangeIsNcExecVoided = (context: Context) => { + return context.pendingMetadataChanges?.[0] === METADATA_DIFF_EVENT_TYPES.NC_EXEC_VOIDED; }; /* @@ -279,14 +226,6 @@ export const hasNewEvents = (_context: Context, event: any) => { return event.data.hasNewEvents === true; }; -/* - * This guard checks if handleNcExecVoided detected that first_block also changed. - * Used on the onDone transition to conditionally chain to handleTxFirstBlock. - */ -export const ncExecVoidedFirstBlockChanged = (_context: Context, event: any) => { - return event.data?.firstBlockChanged === true; -}; - /* * This guard is used to detect if the event is a TOKEN_CREATED event */ diff --git a/packages/daemon/src/machines/SyncMachine.ts b/packages/daemon/src/machines/SyncMachine.ts index 029a1587..a90052e0 100644 --- a/packages/daemon/src/machines/SyncMachine.ts +++ b/packages/daemon/src/machines/SyncMachine.ts @@ -31,12 +31,11 @@ import { checkForMissedEvents, } from '../services'; import { - metadataIgnore, - metadataVoided, - metadataUnvoided, - metadataNewTx, - metadataFirstBlock, - metadataNcExecVoided, + nextChangeIsVoided, + nextChangeIsUnvoided, + nextChangeIsNewTx, + nextChangeIsFirstBlock, + nextChangeIsNcExecVoided, metadataChanged, vertexAccepted, invalidPeerId, @@ -49,16 +48,15 @@ import { reorgStarted, tokenCreated, hasNewEvents, - ncExecVoidedFirstBlockChanged, } from '../guards'; import { storeInitialState, - unwrapEvent, + storeMetadataChanges, + shiftMetadataChange, startStream, clearSocket, storeEvent, sendAck, - metadataDecided, increaseRetry, logEventError, updateCache, @@ -217,19 +215,24 @@ export const SyncMachine = Machine({ detectingDiff: { invoke: { src: 'metadataDiff', - onDone: { actions: ['metadataDecided'] }, + onDone: { + target: 'dispatching', + actions: ['storeMetadataChanges'], + }, onError: `#${SYNC_MACHINE_STATES.ERROR}`, }, - on: { - METADATA_DECIDED: [ - { target: `#${CONNECTED_STATES.handlingVoidedTx}`, cond: 'metadataVoided', actions: ['unwrapEvent'] }, - { target: `#${CONNECTED_STATES.handlingUnvoidedTx}`, cond: 'metadataUnvoided', actions: ['unwrapEvent'] }, - { target: `#${CONNECTED_STATES.handlingVertexAccepted}`, cond: 'metadataNewTx', actions: ['unwrapEvent'] }, - { target: `#${CONNECTED_STATES.handlingFirstBlock}`, cond: 'metadataFirstBlock', actions: ['unwrapEvent'] }, - { target: `#${CONNECTED_STATES.handlingNcExecVoided}`, cond: 'metadataNcExecVoided', actions: ['unwrapEvent'] }, - { target: `#${CONNECTED_STATES.handlingUnhandledEvent}`, cond: 'metadataIgnore' }, - ], - }, + }, + dispatching: { + id: 'dispatchingMetadataChange', + always: [ + { target: `#${CONNECTED_STATES.handlingVoidedTx}`, cond: 'nextChangeIsVoided', actions: ['shiftMetadataChange'] }, + { target: `#${CONNECTED_STATES.handlingUnvoidedTx}`, cond: 'nextChangeIsUnvoided', actions: ['shiftMetadataChange'] }, + { target: `#${CONNECTED_STATES.handlingVertexAccepted}`, cond: 'nextChangeIsNewTx', actions: ['shiftMetadataChange'] }, + { target: `#${CONNECTED_STATES.handlingFirstBlock}`, cond: 'nextChangeIsFirstBlock', actions: ['shiftMetadataChange'] }, + { target: `#${CONNECTED_STATES.handlingNcExecVoided}`, cond: 'nextChangeIsNcExecVoided', actions: ['shiftMetadataChange'] }, + // Queue empty or unrecognized (including IGNORE) → done + { target: `#${CONNECTED_STATES.handlingUnhandledEvent}` }, + ], }, }, }, @@ -240,8 +243,8 @@ export const SyncMachine = Machine({ src: 'handleVertexAccepted', data: (_context: Context, event: Event) => event, onDone: { - target: 'idle', - actions: ['sendAck', 'storeEvent', 'updateCache'], + target: '#dispatchingMetadataChange', + actions: ['storeEvent', 'updateCache'], }, onError: `#${SYNC_MACHINE_STATES.ERROR}`, }, @@ -264,8 +267,8 @@ export const SyncMachine = Machine({ src: 'handleVoidedTx', data: (_context: Context, event: Event) => event, onDone: { - target: 'idle', - actions: ['storeEvent', 'sendAck', 'updateCache'], + target: '#dispatchingMetadataChange', + actions: ['storeEvent', 'updateCache'], }, onError: `#${SYNC_MACHINE_STATES.ERROR}`, }, @@ -291,8 +294,8 @@ export const SyncMachine = Machine({ src: 'handleTxFirstBlock', data: (_context: Context, event: Event) => event, onDone: { - target: 'idle', - actions: ['storeEvent', 'sendAck', 'updateCache'], + target: '#dispatchingMetadataChange', + actions: ['storeEvent', 'updateCache'], }, onError: `#${SYNC_MACHINE_STATES.ERROR}`, }, @@ -302,16 +305,10 @@ export const SyncMachine = Machine({ invoke: { src: 'handleNcExecVoided', data: (_context: Context, event: Event) => event, - onDone: [{ - // If first_block also changed, chain to handleTxFirstBlock - target: `#${CONNECTED_STATES.handlingFirstBlock}`, - cond: 'ncExecVoidedFirstBlockChanged', - actions: ['storeEvent'], - }, { - // Otherwise, we're done - target: 'idle', - actions: ['storeEvent', 'sendAck', 'updateCache'], - }], + onDone: { + target: '#dispatchingMetadataChange', + actions: ['storeEvent', 'updateCache'], + }, onError: `#${SYNC_MACHINE_STATES.ERROR}`, }, }, @@ -385,12 +382,11 @@ export const SyncMachine = Machine({ checkForMissedEvents, }, guards: { - metadataIgnore, - metadataVoided, - metadataUnvoided, - metadataNewTx, - metadataFirstBlock, - metadataNcExecVoided, + nextChangeIsVoided, + nextChangeIsUnvoided, + nextChangeIsNewTx, + nextChangeIsFirstBlock, + nextChangeIsNcExecVoided, metadataChanged, vertexAccepted, invalidPeerId, @@ -403,17 +399,16 @@ export const SyncMachine = Machine({ reorgStarted, tokenCreated, hasNewEvents, - ncExecVoidedFirstBlockChanged, }, delays: { BACKOFF_DELAYED_RECONNECT, ACK_TIMEOUT }, actions: { storeInitialState, - unwrapEvent, + storeMetadataChanges, + shiftMetadataChange, startStream, clearSocket, storeEvent, sendAck, - metadataDecided, increaseRetry, logEventError, updateCache, diff --git a/packages/daemon/src/services/index.ts b/packages/daemon/src/services/index.ts index 2d74a3ae..ef8cd425 100644 --- a/packages/daemon/src/services/index.ts +++ b/packages/daemon/src/services/index.ts @@ -129,29 +129,30 @@ export const metadataDiff = async (_context: Context, event: Event) => { if (voided_by.length > 0) { // No need to add voided transactions return { - type: METADATA_DIFF_EVENT_TYPES.IGNORE, + types: [METADATA_DIFF_EVENT_TYPES.IGNORE], originalEvent: event, }; } return { - type: METADATA_DIFF_EVENT_TYPES.TX_NEW, + types: [METADATA_DIFF_EVENT_TYPES.TX_NEW], originalEvent: event, }; } + // Mutually exclusive: voided/unvoided/new take priority // Tx is voided if (voided_by.length > 0) { // Was it voided on the database? if (!dbTx.voided) { return { - type: METADATA_DIFF_EVENT_TYPES.TX_VOIDED, + types: [METADATA_DIFF_EVENT_TYPES.TX_VOIDED], originalEvent: event, }; } return { - type: METADATA_DIFF_EVENT_TYPES.IGNORE, + types: [METADATA_DIFF_EVENT_TYPES.IGNORE], originalEvent: event, }; } @@ -159,27 +160,21 @@ export const metadataDiff = async (_context: Context, event: Event) => { // Tx was voided in the database but is not anymore if (dbTx.voided && voided_by.length <= 0) { return { - type: METADATA_DIFF_EVENT_TYPES.TX_UNVOIDED, + types: [METADATA_DIFF_EVENT_TYPES.TX_UNVOIDED], originalEvent: event, }; } + // Independent changes: collect all into array + const types: string[] = []; + // Check if nc_execution changed from 'success' to something else. - // If the tx has nano-created tokens in the database (tokens where token_id != tx_id), - // those tokens were created when nc_execution was 'success'. - // If nc_execution is now NOT 'success', we should delete those tokens. - // IMPORTANT: This check must come BEFORE the first_block check because during reorg, - // both first_block and nc_execution may change, and we need to prioritize deleting - // nano-created tokens when nc_execution is no longer 'success'. if (nc_execution !== 'success') { const tokensCreated = await getTokensCreatedByTx(mysql, hash); const nanoTokens = tokensCreated.filter(tokenId => tokenId !== hash); if (nanoTokens.length > 0) { - return { - type: METADATA_DIFF_EVENT_TYPES.NC_EXEC_VOIDED, - originalEvent: event, - }; + types.push(METADATA_DIFF_EVENT_TYPES.NC_EXEC_VOIDED); } } @@ -188,14 +183,15 @@ export const metadataDiff = async (_context: Context, event: Event) => { const dbFirstBlock: string | null = dbTx.first_block ?? null; if (eventFirstBlock !== dbFirstBlock) { - return { - type: METADATA_DIFF_EVENT_TYPES.TX_FIRST_BLOCK, - originalEvent: event, - }; + types.push(METADATA_DIFF_EVENT_TYPES.TX_FIRST_BLOCK); + } + + if (types.length === 0) { + types.push(METADATA_DIFF_EVENT_TYPES.IGNORE); } return { - type: METADATA_DIFF_EVENT_TYPES.IGNORE, + types, originalEvent: event, }; } finally { @@ -860,12 +856,6 @@ export const handleTxFirstBlock = async (context: Context) => { * * This handler deletes nano-created tokens. Traditional CREATE_TOKEN_TX tokens * (token_id = tx_id) are NOT affected. - * - * Returns { firstBlockChanged: boolean } so the state machine can conditionally - * chain to handleTxFirstBlock if first_block also changed in this event. - * - * When first_block also changed, this handler does NOT call dbUpdateLastSyncedEvent - * because the chained handleTxFirstBlock will do that. */ export const handleNcExecVoided = async (context: Context) => { const mysql = await getDbConnection(); @@ -873,7 +863,7 @@ export const handleNcExecVoided = async (context: Context) => { try { const fullNodeEvent = context.event as StandardFullNodeEvent; - const { hash, metadata } = fullNodeEvent.event.data; + const { hash } = fullNodeEvent.event.data; // Get all tokens created by this transaction const tokensCreated = await getTokensCreatedByTx(mysql, hash); @@ -889,19 +879,8 @@ export const handleNcExecVoided = async (context: Context) => { } } - // Check if first_block also changed - const dbTx = await getTransactionById(mysql, hash); - const eventFirstBlock: string | null = metadata.first_block ?? null; - const dbFirstBlock: string | null = dbTx?.first_block ?? null; - const firstBlockChanged = eventFirstBlock !== dbFirstBlock; - - if (!firstBlockChanged) { - // No first_block change, so we handle last_synced_event here - await dbUpdateLastSyncedEvent(mysql, fullNodeEvent.event.id); - } - + await dbUpdateLastSyncedEvent(mysql, fullNodeEvent.event.id); await mysql.commit(); - return { firstBlockChanged }; } catch (e) { logger.error('handleNcExecVoided error: ', e); await mysql.rollback(); diff --git a/packages/daemon/src/types/event.ts b/packages/daemon/src/types/event.ts index 3a93723c..eebf0f96 100644 --- a/packages/daemon/src/types/event.ts +++ b/packages/daemon/src/types/event.ts @@ -31,7 +31,6 @@ export type HealthCheckEvent = export enum EventTypes { WEBSOCKET_EVENT = 'WEBSOCKET_EVENT', FULLNODE_EVENT = 'FULLNODE_EVENT', - METADATA_DECIDED = 'METADATA_DECIDED', WEBSOCKET_SEND_EVENT = 'WEBSOCKET_SEND_EVENT', HEALTHCHECK_EVENT = 'HEALTHCHECK_EVENT', } @@ -69,15 +68,9 @@ const EmptyDataFullNodeEvents = z.union([ export const FullNodeEventTypesSchema = z.nativeEnum(FullNodeEventTypes); -export type MetadataDecidedEvent = { - type: 'TX_VOIDED' | 'TX_UNVOIDED' | 'TX_NEW' | 'TX_FIRST_BLOCK' | 'IGNORE' | 'NC_EXEC_VOIDED'; - originalEvent: FullNodeEvent; -} - export type Event = | { type: EventTypes.WEBSOCKET_EVENT, event: WebSocketEvent } | { 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 }; diff --git a/packages/daemon/src/types/machine.ts b/packages/daemon/src/types/machine.ts index f987a163..04871e9a 100644 --- a/packages/daemon/src/types/machine.ts +++ b/packages/daemon/src/types/machine.ts @@ -17,4 +17,5 @@ export interface Context { initialEventId: null | number; txCache: LRU | null; rewardMinBlocks?: number | null; + pendingMetadataChanges?: string[]; } From 9cab6491041de389795fffc1725082490ed1c0f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Abadesso?= Date: Tue, 27 Jan 2026 10:09:16 -0300 Subject: [PATCH 04/10] refactor(daemon): single guard for next change detection --- .../daemon/__tests__/guards/guards.test.ts | 52 ++++++------------- packages/daemon/src/guards/index.ts | 28 +++------- packages/daemon/src/machines/SyncMachine.ts | 23 +++----- 3 files changed, 32 insertions(+), 71 deletions(-) diff --git a/packages/daemon/__tests__/guards/guards.test.ts b/packages/daemon/__tests__/guards/guards.test.ts index e2f61d04..5b7134df 100644 --- a/packages/daemon/__tests__/guards/guards.test.ts +++ b/packages/daemon/__tests__/guards/guards.test.ts @@ -1,10 +1,6 @@ import { Context, Event, FullNodeEventTypes } from '../../src/types'; import { - nextChangeIsVoided, - nextChangeIsUnvoided, - nextChangeIsNewTx, - nextChangeIsFirstBlock, - nextChangeIsNcExecVoided, + hasNextChange, metadataChanged, vertexAccepted, invalidPeerId, @@ -99,7 +95,7 @@ const generateFullNodeEvent = (type: FullNodeEventTypes, data = {} as any): Even return generateStandardFullNodeEvent(type, data); }; -describe('metadata dispatch queue guards', () => { +describe('hasNextChange parameterized guard', () => { const contextWithChange = (changeType: string): Context => ({ ...mockContext, pendingMetadataChanges: [changeType], @@ -110,43 +106,29 @@ describe('metadata dispatch queue guards', () => { pendingMetadataChanges: [], }; - test('nextChangeIsVoided', () => { - expect(nextChangeIsVoided(contextWithChange('TX_VOIDED'))).toBe(true); - expect(nextChangeIsVoided(contextWithChange('TX_NEW'))).toBe(false); - expect(nextChangeIsVoided(emptyContext)).toBe(false); - }); - - test('nextChangeIsUnvoided', () => { - expect(nextChangeIsUnvoided(contextWithChange('TX_UNVOIDED'))).toBe(true); - expect(nextChangeIsUnvoided(contextWithChange('TX_VOIDED'))).toBe(false); - expect(nextChangeIsUnvoided(emptyContext)).toBe(false); - }); + const callGuard = (ctx: Context, changeType: string) => + hasNextChange(ctx, {} as Event, { cond: { type: 'hasNextChange', changeType } }); - test('nextChangeIsNewTx', () => { - expect(nextChangeIsNewTx(contextWithChange('TX_NEW'))).toBe(true); - expect(nextChangeIsNewTx(contextWithChange('TX_VOIDED'))).toBe(false); - expect(nextChangeIsNewTx(emptyContext)).toBe(false); + test('matches when pendingMetadataChanges[0] equals changeType', () => { + expect(callGuard(contextWithChange('TX_VOIDED'), 'TX_VOIDED')).toBe(true); + expect(callGuard(contextWithChange('TX_UNVOIDED'), 'TX_UNVOIDED')).toBe(true); + expect(callGuard(contextWithChange('TX_NEW'), 'TX_NEW')).toBe(true); + expect(callGuard(contextWithChange('TX_FIRST_BLOCK'), 'TX_FIRST_BLOCK')).toBe(true); + expect(callGuard(contextWithChange('NC_EXEC_VOIDED'), 'NC_EXEC_VOIDED')).toBe(true); }); - test('nextChangeIsFirstBlock', () => { - expect(nextChangeIsFirstBlock(contextWithChange('TX_FIRST_BLOCK'))).toBe(true); - expect(nextChangeIsFirstBlock(contextWithChange('TX_VOIDED'))).toBe(false); - expect(nextChangeIsFirstBlock(emptyContext)).toBe(false); + test('does not match when changeType differs', () => { + expect(callGuard(contextWithChange('TX_VOIDED'), 'TX_NEW')).toBe(false); + expect(callGuard(contextWithChange('TX_NEW'), 'TX_VOIDED')).toBe(false); }); - test('nextChangeIsNcExecVoided', () => { - expect(nextChangeIsNcExecVoided(contextWithChange('NC_EXEC_VOIDED'))).toBe(true); - expect(nextChangeIsNcExecVoided(contextWithChange('TX_VOIDED'))).toBe(false); - expect(nextChangeIsNcExecVoided(emptyContext)).toBe(false); + test('returns false when queue is empty', () => { + expect(callGuard(emptyContext, 'TX_VOIDED')).toBe(false); }); - test('guards return false when pendingMetadataChanges is undefined', () => { + test('returns false when pendingMetadataChanges is undefined', () => { const ctx = { ...mockContext, pendingMetadataChanges: undefined }; - expect(nextChangeIsVoided(ctx)).toBe(false); - expect(nextChangeIsUnvoided(ctx)).toBe(false); - expect(nextChangeIsNewTx(ctx)).toBe(false); - expect(nextChangeIsFirstBlock(ctx)).toBe(false); - expect(nextChangeIsNcExecVoided(ctx)).toBe(false); + expect(callGuard(ctx, 'TX_VOIDED')).toBe(false); }); }); diff --git a/packages/daemon/src/guards/index.ts b/packages/daemon/src/guards/index.ts index 4a2b8739..0abcf273 100644 --- a/packages/daemon/src/guards/index.ts +++ b/packages/daemon/src/guards/index.ts @@ -7,32 +7,18 @@ import { Context, Event, EventTypes, FullNodeEventTypes } from '../types'; import { hashTxData } from '../utils'; -import { METADATA_DIFF_EVENT_TYPES } from '../services'; import getConfig from '../config'; import logger from '../logger'; /* - * Guards for the metadata change dispatch queue. - * These check context.pendingMetadataChanges[0] to route to the correct handler. + * Parameterized guard for the metadata change dispatch queue. + * Checks if the next pending change matches the given changeType. + * + * Usage in machine config: + * cond: { type: 'hasNextChange', changeType: 'TX_VOIDED' } */ -export const nextChangeIsVoided = (context: Context) => { - return context.pendingMetadataChanges?.[0] === METADATA_DIFF_EVENT_TYPES.TX_VOIDED; -}; - -export const nextChangeIsUnvoided = (context: Context) => { - return context.pendingMetadataChanges?.[0] === METADATA_DIFF_EVENT_TYPES.TX_UNVOIDED; -}; - -export const nextChangeIsNewTx = (context: Context) => { - return context.pendingMetadataChanges?.[0] === METADATA_DIFF_EVENT_TYPES.TX_NEW; -}; - -export const nextChangeIsFirstBlock = (context: Context) => { - return context.pendingMetadataChanges?.[0] === METADATA_DIFF_EVENT_TYPES.TX_FIRST_BLOCK; -}; - -export const nextChangeIsNcExecVoided = (context: Context) => { - return context.pendingMetadataChanges?.[0] === METADATA_DIFF_EVENT_TYPES.NC_EXEC_VOIDED; +export const hasNextChange = (context: Context, _event: Event, meta: any) => { + return context.pendingMetadataChanges?.[0] === meta.cond.changeType; }; /* diff --git a/packages/daemon/src/machines/SyncMachine.ts b/packages/daemon/src/machines/SyncMachine.ts index a90052e0..5caa3632 100644 --- a/packages/daemon/src/machines/SyncMachine.ts +++ b/packages/daemon/src/machines/SyncMachine.ts @@ -31,11 +31,7 @@ import { checkForMissedEvents, } from '../services'; import { - nextChangeIsVoided, - nextChangeIsUnvoided, - nextChangeIsNewTx, - nextChangeIsFirstBlock, - nextChangeIsNcExecVoided, + hasNextChange, metadataChanged, vertexAccepted, invalidPeerId, @@ -49,6 +45,7 @@ import { tokenCreated, hasNewEvents, } from '../guards'; +import { METADATA_DIFF_EVENT_TYPES } from '../services'; import { storeInitialState, storeMetadataChanges, @@ -225,11 +222,11 @@ export const SyncMachine = Machine({ dispatching: { id: 'dispatchingMetadataChange', always: [ - { target: `#${CONNECTED_STATES.handlingVoidedTx}`, cond: 'nextChangeIsVoided', actions: ['shiftMetadataChange'] }, - { target: `#${CONNECTED_STATES.handlingUnvoidedTx}`, cond: 'nextChangeIsUnvoided', actions: ['shiftMetadataChange'] }, - { target: `#${CONNECTED_STATES.handlingVertexAccepted}`, cond: 'nextChangeIsNewTx', actions: ['shiftMetadataChange'] }, - { target: `#${CONNECTED_STATES.handlingFirstBlock}`, cond: 'nextChangeIsFirstBlock', actions: ['shiftMetadataChange'] }, - { target: `#${CONNECTED_STATES.handlingNcExecVoided}`, cond: 'nextChangeIsNcExecVoided', actions: ['shiftMetadataChange'] }, + { target: `#${CONNECTED_STATES.handlingVoidedTx}`, cond: { type: 'hasNextChange', changeType: METADATA_DIFF_EVENT_TYPES.TX_VOIDED }, actions: ['shiftMetadataChange'] }, + { target: `#${CONNECTED_STATES.handlingUnvoidedTx}`, cond: { type: 'hasNextChange', changeType: METADATA_DIFF_EVENT_TYPES.TX_UNVOIDED }, actions: ['shiftMetadataChange'] }, + { target: `#${CONNECTED_STATES.handlingVertexAccepted}`, cond: { type: 'hasNextChange', changeType: METADATA_DIFF_EVENT_TYPES.TX_NEW }, actions: ['shiftMetadataChange'] }, + { target: `#${CONNECTED_STATES.handlingFirstBlock}`, cond: { type: 'hasNextChange', changeType: METADATA_DIFF_EVENT_TYPES.TX_FIRST_BLOCK }, actions: ['shiftMetadataChange'] }, + { target: `#${CONNECTED_STATES.handlingNcExecVoided}`, cond: { type: 'hasNextChange', changeType: METADATA_DIFF_EVENT_TYPES.NC_EXEC_VOIDED }, actions: ['shiftMetadataChange'] }, // Queue empty or unrecognized (including IGNORE) → done { target: `#${CONNECTED_STATES.handlingUnhandledEvent}` }, ], @@ -382,11 +379,7 @@ export const SyncMachine = Machine({ checkForMissedEvents, }, guards: { - nextChangeIsVoided, - nextChangeIsUnvoided, - nextChangeIsNewTx, - nextChangeIsFirstBlock, - nextChangeIsNcExecVoided, + hasNextChange, metadataChanged, vertexAccepted, invalidPeerId, From 1c834e630e9594d72a69ad46bc8d4896c0d239ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Abadesso?= Date: Tue, 27 Jan 2026 10:17:08 -0300 Subject: [PATCH 05/10] refactor(daemon): non fullnode event as a const --- packages/daemon/__tests__/guards/guards.test.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/daemon/__tests__/guards/guards.test.ts b/packages/daemon/__tests__/guards/guards.test.ts index 5b7134df..8546632e 100644 --- a/packages/daemon/__tests__/guards/guards.test.ts +++ b/packages/daemon/__tests__/guards/guards.test.ts @@ -85,6 +85,8 @@ const generateReorgStartedEvent = (data = { }, }); +const nonFullNodeEvent = { type: EventTypes.WEBSOCKET_EVENT, event: { type: 'CONNECTED' } } as Event; + const generateFullNodeEvent = (type: FullNodeEventTypes, data = {} as any): Event => { if (type === FullNodeEventTypes.REORG_STARTED) { return generateReorgStartedEvent(data); @@ -138,7 +140,7 @@ describe('fullnode event guards', () => { expect(vertexAccepted(mockContext, generateFullNodeEvent(FullNodeEventTypes.VERTEX_METADATA_CHANGED))).toBe(false); // Any event other than FULLNODE_EVENT should return false - expect(() => vertexAccepted(mockContext, { type: EventTypes.WEBSOCKET_EVENT, event: { type: 'CONNECTED' } } as Event)).toThrow('Invalid event type on vertexAccepted guard: WEBSOCKET_EVENT'); + expect(() => vertexAccepted(mockContext, nonFullNodeEvent)).toThrow('Invalid event type on vertexAccepted guard: WEBSOCKET_EVENT'); }); test('metadataChanged', () => { @@ -146,7 +148,7 @@ describe('fullnode event guards', () => { expect(metadataChanged(mockContext, generateFullNodeEvent(FullNodeEventTypes.NEW_VERTEX_ACCEPTED))).toBe(false); // Any event other than FULLNODE_EVENT should return false - expect(() => metadataChanged(mockContext, { type: EventTypes.WEBSOCKET_EVENT, event: { type: 'CONNECTED' } } as Event)).toThrow('Invalid event type on metadataChanged guard: WEBSOCKET_EVENT'); + expect(() => metadataChanged(mockContext, nonFullNodeEvent)).toThrow('Invalid event type on metadataChanged guard: WEBSOCKET_EVENT'); }); test('voided', () => { @@ -167,7 +169,7 @@ describe('fullnode event guards', () => { expect(voided(mockContext, fullNodeNotVoidedEvent)).toBe(false); // Any event other than FULLNODE_EVENT should return false - expect(() => voided(mockContext, { type: EventTypes.WEBSOCKET_EVENT, event: { type: 'CONNECTED' } } as Event)).toThrow('Invalid event type on voided guard: WEBSOCKET_EVENT'); + expect(() => voided(mockContext, nonFullNodeEvent)).toThrow('Invalid event type on voided guard: WEBSOCKET_EVENT'); // Any fullndode event other VERTEX_METADATA_CHANGED and NEW_VERTEX_ACCEPTED // should return false @@ -188,7 +190,7 @@ describe('fullnode event guards', () => { expect(unchanged(mockContext, fullNodeEvent)).toBe(false); // Any event other than FULLNODE_EVENT should return false - expect(() => unchanged(mockContext, { type: EventTypes.WEBSOCKET_EVENT, event: { type: 'CONNECTED' } } as Event)).toThrow('Invalid event type on unchanged guard: WEBSOCKET_EVENT'); + expect(() => unchanged(mockContext, nonFullNodeEvent)).toThrow('Invalid event type on unchanged guard: WEBSOCKET_EVENT'); }); test('reorgStarted', () => { @@ -196,7 +198,7 @@ describe('fullnode event guards', () => { expect(reorgStarted(mockContext, generateFullNodeEvent(FullNodeEventTypes.VERTEX_METADATA_CHANGED))).toBe(false); // Any event other than FULLNODE_EVENT should throw - expect(() => reorgStarted(mockContext, { type: EventTypes.WEBSOCKET_EVENT, event: { type: 'CONNECTED' } } as Event)).toThrow('Invalid event type on reorgStarted guard: WEBSOCKET_EVENT'); + expect(() => reorgStarted(mockContext, nonFullNodeEvent)).toThrow('Invalid event type on reorgStarted guard: WEBSOCKET_EVENT'); }); test('tokenCreated', () => { @@ -206,7 +208,7 @@ describe('fullnode event guards', () => { expect(tokenCreated(mockContext, generateFullNodeEvent(FullNodeEventTypes.REORG_STARTED))).toBe(false); // Any event other than FULLNODE_EVENT should throw - expect(() => tokenCreated(mockContext, { type: EventTypes.WEBSOCKET_EVENT, event: { type: 'CONNECTED' } } as Event)).toThrow('Invalid event type on tokenCreated guard: WEBSOCKET_EVENT'); + expect(() => tokenCreated(mockContext, nonFullNodeEvent)).toThrow('Invalid event type on tokenCreated guard: WEBSOCKET_EVENT'); }); }); From 62cb6795522386d8d8bf310c97dcde23ec4f6984 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Abadesso?= Date: Tue, 27 Jan 2026 10:35:24 -0300 Subject: [PATCH 06/10] tests(daemon): removed unnecessary backwards comp --- packages/daemon/__tests__/db/index.test.ts | 4 ++-- packages/daemon/__tests__/utils.ts | 18 +++++------------- 2 files changed, 7 insertions(+), 15 deletions(-) diff --git a/packages/daemon/__tests__/db/index.test.ts b/packages/daemon/__tests__/db/index.test.ts index 5a46004f..998f5159 100644 --- a/packages/daemon/__tests__/db/index.test.ts +++ b/packages/daemon/__tests__/db/index.test.ts @@ -1379,7 +1379,7 @@ describe('voidTransaction', () => { await expect(checkAddressTxHistoryTable(mysql, 0, addr1, txId, token1, -1, 0)).resolves.toBe(true); await expect(checkAddressTxHistoryTable(mysql, 0, addr1, txId, token2, -1, 0)).resolves.toBe(true); - await expect(checkTransactionTable(mysql, 1, txId, 0, constants.BLOCK_VERSION, true, 1)).resolves.toBe(true); + await expect(checkTransactionTable(mysql, 1, txId, 0, constants.BLOCK_VERSION, true, 1, null)).resolves.toBe(true); }); it('should not fail when balances are empty (from a tx with no inputs and outputs)', async () => { @@ -1397,7 +1397,7 @@ describe('voidTransaction', () => { 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); + await expect(checkTransactionTable(mysql, 1, txId, 0, constants.BLOCK_VERSION, true, 1, null)).resolves.toBe(true); }); it('should throw an error if the transaction is not found in the database', async () => { diff --git a/packages/daemon/__tests__/utils.ts b/packages/daemon/__tests__/utils.ts index da6094e7..b7d1915f 100644 --- a/packages/daemon/__tests__/utils.ts +++ b/packages/daemon/__tests__/utils.ts @@ -321,7 +321,7 @@ export const checkTransactionTable = async ( version: number, voided: boolean, height: number | null, - firstBlock?: string | null, + firstBlock: string | null, ): Promise> => { // first check the total number of rows in the table let [results] = await mysql.query('SELECT * FROM `transaction`'); @@ -348,18 +348,10 @@ export const checkTransactionTable = async ( AND \`height\` ${height !== null ? '= ?' : 'IS ?'} `; - // Only check first_block if provided (for backwards compatibility) - if (firstBlock !== undefined) { - [results] = await mysql.query( - `${baseQuery} AND \`first_block\` ${firstBlock !== null ? '= ?' : 'IS ?'}`, - [txId, timestamp, version, voided, height, firstBlock], - ); - } else { - [results] = await mysql.query( - baseQuery, - [txId, timestamp, version, voided, height], - ); - } + [results] = await mysql.query( + `${baseQuery} AND \`first_block\` ${firstBlock !== null ? '= ?' : 'IS ?'}`, + [txId, timestamp, version, voided, height, firstBlock], + ); if (results.length !== 1) { return { From cc30fd540185b1fffb0eeb97968d350ef72872ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Abadesso?= Date: Tue, 27 Jan 2026 10:59:27 -0300 Subject: [PATCH 07/10] tests(daemon): test full cycle of nano tx --- .../__tests__/services/services.test.ts | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/packages/daemon/__tests__/services/services.test.ts b/packages/daemon/__tests__/services/services.test.ts index 32d16e14..6aeddc07 100644 --- a/packages/daemon/__tests__/services/services.test.ts +++ b/packages/daemon/__tests__/services/services.test.ts @@ -1257,6 +1257,69 @@ describe('metadataDiff', () => { const result = await metadataDiff({} as any, event as any); expect(result.types).toEqual(['TX_UNVOIDED']); }); + + it('should detect full nano contract tx lifecycle: mempool → confirmed → reorg', async () => { + const txHash = 'nc-lifecycle-tx'; + + // Event 0: tx enters the mempool (nc_execution and first_block are null) + // DB has no record → TX_NEW + const event0 = { + event: { + event: { + data: { + hash: txHash, + metadata: { voided_by: [], first_block: null, nc_execution: null }, + }, + }, + }, + }; + (getTransactionById as jest.Mock).mockResolvedValue(null); + + const result0 = await metadataDiff({} as any, event0 as any); + expect(result0.types).toEqual(['TX_NEW']); + + // Event 1: tx gets confirmed (first_block set, nc_execution goes to 'success') + // DB has the tx from event 0: first_block = null + const event1 = { + event: { + event: { + data: { + hash: txHash, + metadata: { voided_by: [], first_block: 'block-1', nc_execution: 'success' }, + }, + }, + }, + }; + (getTransactionById as jest.Mock).mockResolvedValue({ + voided: false, + first_block: null, + }); + + const result1 = await metadataDiff({} as any, event1 as any); + expect(result1.types).toEqual(['TX_FIRST_BLOCK']); + + // Event 2: reorg — tx loses first_block and nc_execution reverts to null + // DB reflects the state after event 1: confirmed with first_block + const event2 = { + event: { + event: { + data: { + hash: txHash, + metadata: { voided_by: [], first_block: null, nc_execution: null }, + }, + }, + }, + }; + (getTransactionById as jest.Mock).mockResolvedValue({ + voided: false, + first_block: 'block-1', + }); + (getTokensCreatedByTx as jest.Mock).mockResolvedValue(['nano-token-1']); + + const result2 = await metadataDiff({} as any, event2 as any); + // Both changes detected: nano tokens must be deleted AND first_block must be updated + expect(result2.types).toEqual(['NC_EXEC_VOIDED', 'TX_FIRST_BLOCK']); + }); }); describe('handleReorgStarted', () => { From 9f02b06ca40b0496e0c6a099baa405020420fb46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Abadesso?= Date: Tue, 27 Jan 2026 11:07:02 -0300 Subject: [PATCH 08/10] docs(daemon): restore removed comment --- packages/daemon/src/services/index.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/daemon/src/services/index.ts b/packages/daemon/src/services/index.ts index ef8cd425..c47baf2b 100644 --- a/packages/daemon/src/services/index.ts +++ b/packages/daemon/src/services/index.ts @@ -854,8 +854,10 @@ export const handleTxFirstBlock = async (context: Context) => { * changes from 'success' to 'pending' or null. When this occurs, any tokens created * by the nano contract execution are no longer valid. * - * This handler deletes nano-created tokens. Traditional CREATE_TOKEN_TX tokens - * (token_id = tx_id) are NOT affected. + * This handler deletes all nano-created tokens for the transaction. Traditional + * CREATE_TOKEN_TX tokens (token_id = tx_id) are NOT affected — they remain valid + * because the token creation is inherent to the transaction itself, not dependent + * on nano contract execution. */ export const handleNcExecVoided = async (context: Context) => { const mysql = await getDbConnection(); From 25224c673dacdd4aab7f6d0636e1edfe8b8c43cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Abadesso?= Date: Tue, 27 Jan 2026 11:13:57 -0300 Subject: [PATCH 09/10] test(daemon): added a test for the UNVOIDED case --- .../__tests__/services/services.test.ts | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/packages/daemon/__tests__/services/services.test.ts b/packages/daemon/__tests__/services/services.test.ts index 6aeddc07..079b58ae 100644 --- a/packages/daemon/__tests__/services/services.test.ts +++ b/packages/daemon/__tests__/services/services.test.ts @@ -1320,6 +1320,68 @@ describe('metadataDiff', () => { // Both changes detected: nano tokens must be deleted AND first_block must be updated expect(result2.types).toEqual(['NC_EXEC_VOIDED', 'TX_FIRST_BLOCK']); }); + + it('should detect voided tx becoming unvoided', async () => { + const txHash = 'unvoided-lifecycle-tx'; + + // Event 0: tx enters the mempool + // DB has no record → TX_NEW + const event0 = { + event: { + event: { + data: { + hash: txHash, + metadata: { voided_by: [], first_block: null, nc_execution: null }, + }, + }, + }, + }; + (getTransactionById as jest.Mock).mockResolvedValue(null); + + const result0 = await metadataDiff({} as any, event0 as any); + expect(result0.types).toEqual(['TX_NEW']); + + // Event 1: tx gets voided (conflict) + // DB has the tx from event 0, not voided + const event1 = { + event: { + event: { + data: { + hash: txHash, + metadata: { voided_by: ['conflicting-tx'], first_block: null, nc_execution: null }, + }, + }, + }, + }; + (getTransactionById as jest.Mock).mockResolvedValue({ + voided: false, + first_block: null, + }); + + const result1 = await metadataDiff({} as any, event1 as any); + expect(result1.types).toEqual(['TX_VOIDED']); + + // Event 2: tx gets unvoided (conflict resolved) + // DB has the tx marked as voided + const event2 = { + event: { + event: { + data: { + hash: txHash, + metadata: { voided_by: [], first_block: null, nc_execution: null }, + }, + }, + }, + }; + (getTransactionById as jest.Mock).mockResolvedValue({ + voided: true, + first_block: null, + }); + + const result2 = await metadataDiff({} as any, event2 as any); + // TX_UNVOIDED is mutually exclusive — the machine chains into handlingVertexAccepted to re-add the tx + expect(result2.types).toEqual(['TX_UNVOIDED']); + }); }); describe('handleReorgStarted', () => { From d963ba2758ae0724ad539c6b661fffab7b9355c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Abadesso?= Date: Wed, 28 Jan 2026 11:15:34 -0300 Subject: [PATCH 10/10] docs(daemon): restore removed comment --- 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 c47baf2b..82ebf91d 100644 --- a/packages/daemon/src/services/index.ts +++ b/packages/daemon/src/services/index.ts @@ -169,6 +169,9 @@ export const metadataDiff = async (_context: Context, event: Event) => { const types: string[] = []; // Check if nc_execution changed from 'success' to something else. + // If the tx has nano-created tokens in the database (tokens where token_id != tx_id), + // those tokens were created when nc_execution was 'success'. + // If nc_execution is now NOT 'success', we should delete those tokens. if (nc_execution !== 'success') { const tokensCreated = await getTokensCreatedByTx(mysql, hash); const nanoTokens = tokensCreated.filter(tokenId => tokenId !== hash);