Skip to content
15 changes: 15 additions & 0 deletions db/migrations/20260123000000-add-first-block-to-transaction.js
Original file line number Diff line number Diff line change
@@ -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');
},
};
76 changes: 74 additions & 2 deletions packages/daemon/__tests__/db/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -1307,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 () => {
Expand All @@ -1325,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 () => {
Expand Down
129 changes: 34 additions & 95 deletions packages/daemon/__tests__/guards/guards.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
import { Context, Event, FullNodeEventTypes, StandardFullNodeEvent } from '../../src/types';
import { Context, Event, FullNodeEventTypes } from '../../src/types';
import {
metadataIgnore,
metadataVoided,
metadataNewTx,
metadataFirstBlock,
metadataNcExecVoided,
hasNextChange,
metadataChanged,
vertexAccepted,
invalidPeerId,
Expand Down Expand Up @@ -89,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);
Expand All @@ -99,99 +97,40 @@ 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('hasNextChange parameterized guard', () => {
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);
const callGuard = (ctx: Context, changeType: string) =>
hasNextChange(ctx, {} as Event, { cond: { type: 'hasNextChange', changeType } });

// 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('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('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('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('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('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('returns false when queue is empty', () => {
expect(callGuard(emptyContext, 'TX_VOIDED')).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);

// 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('returns false when pendingMetadataChanges is undefined', () => {
const ctx = { ...mockContext, pendingMetadataChanges: undefined };
expect(callGuard(ctx, 'TX_VOIDED')).toBe(false);
});
});

Expand All @@ -201,15 +140,15 @@ 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, nonFullNodeEvent)).toThrow('Invalid event type on vertexAccepted guard: WEBSOCKET_EVENT');
});

test('metadataChanged', () => {
expect(metadataChanged(mockContext, generateFullNodeEvent(FullNodeEventTypes.VERTEX_METADATA_CHANGED))).toBe(true);
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, nonFullNodeEvent)).toThrow('Invalid event type on metadataChanged guard: WEBSOCKET_EVENT');
});

test('voided', () => {
Expand All @@ -230,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, generateMetadataDecidedEvent('TX_NEW'))).toThrow('Invalid event type on voided guard: METADATA_DECIDED');
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
Expand All @@ -251,15 +190,15 @@ 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, nonFullNodeEvent)).toThrow('Invalid event type on unchanged guard: WEBSOCKET_EVENT');
});

test('reorgStarted', () => {
expect(reorgStarted(mockContext, generateFullNodeEvent(FullNodeEventTypes.REORG_STARTED))).toBe(true);
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, nonFullNodeEvent)).toThrow('Invalid event type on reorgStarted guard: WEBSOCKET_EVENT');
});

test('tokenCreated', () => {
Expand All @@ -269,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, generateMetadataDecidedEvent('TX_NEW'))).toThrow('Invalid event type on tokenCreated guard: METADATA_DECIDED');
expect(() => tokenCreated(mockContext, nonFullNodeEvent)).toThrow('Invalid event type on tokenCreated guard: WEBSOCKET_EVENT');
});
});

Expand Down
Loading