Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,6 @@ packages/wallet-service/.warmup
.yarn/
.env.*
*.tsbuildinfo

# Local benchmark output (produced by packages/daemon/src/scripts/bench-void-tx.ts)
bench-results-*.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ module.exports = {
`);

// Only create if it doesn't exist
if (indexes[0].count === 0) {
if (Number(indexes[0].count) === 0) {
await queryInterface.sequelize.query(`
CREATE INDEX idx_tx_output_utxo_lookup
ON tx_output (address, token_id, spent_by, voided, locked, authorities);
Expand All @@ -30,7 +30,7 @@ module.exports = {
AND index_name = 'idx_tx_output_utxo_lookup';
`);

if (indexes[0].count > 0) {
if (Number(indexes[0].count) > 0) {
await queryInterface.sequelize.query(`
DROP INDEX idx_tx_output_utxo_lookup ON tx_output;
`);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ module.exports = {
`);

// Only create if it doesn't exist
if (indexes[0].count === 0) {
if (Number(indexes[0].count) === 0) {
await queryInterface.sequelize.query(`
CREATE INDEX idx_tx_output_locked_heightlock
ON tx_output (locked, heightlock);
Expand All @@ -30,7 +30,7 @@ module.exports = {
AND index_name = 'idx_tx_output_locked_heightlock';
`);

if (indexes[0].count > 0) {
if (Number(indexes[0].count) > 0) {
await queryInterface.sequelize.query(`
DROP INDEX idx_tx_output_locked_heightlock ON tx_output;
`);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ module.exports = {
`);

// Only create if it doesn't exist
if (indexes[0].count === 0) {
if (Number(indexes[0].count) === 0) {
await queryInterface.sequelize.query(`
CREATE INDEX idx_tx_output_locked_timelock
ON tx_output (locked, timelock);
Expand All @@ -30,7 +30,7 @@ module.exports = {
AND index_name = 'idx_tx_output_locked_timelock';
`);

if (indexes[0].count > 0) {
if (Number(indexes[0].count) > 0) {
await queryInterface.sequelize.query(`
DROP INDEX idx_tx_output_locked_timelock ON tx_output;
`);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ module.exports = {
`);

// Only create if it doesn't exist
if (indexes[0].count === 0) {
if (Number(indexes[0].count) === 0) {
await queryInterface.sequelize.query(`
CREATE INDEX idx_address_tx_history_addr_voided_token
ON address_tx_history (address, voided, token_id);
Expand All @@ -30,7 +30,7 @@ module.exports = {
AND index_name = 'idx_address_tx_history_addr_voided_token';
`);

if (indexes[0].count > 0) {
if (Number(indexes[0].count) > 0) {
await queryInterface.sequelize.query(`
DROP INDEX idx_address_tx_history_addr_voided_token ON address_tx_history;
`);
Expand Down
34 changes: 17 additions & 17 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "hathor-wallet-service",
"version": "1.13.0",
"version": "1.14.0",
"workspaces": [
"packages/common",
"packages/daemon",
Expand All @@ -18,17 +18,17 @@
"private": true,
"devDependencies": {
"@types/jest": "29.5.13",
"@typescript-eslint/eslint-plugin": "^7.4.0",
"@typescript-eslint/parser": "^7.4.0",
"dotenv": "^16.4.5",
"eslint": "^8.57.0",
"eslint-config-airbnb-base": "^15.0.0",
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-jest": "^27.9.0",
"mysql2": "^3.9.3",
"sequelize": "^6.37.2",
"sequelize-cli": "^6.6.2",
"typescript": "^5.8.2"
"@typescript-eslint/eslint-plugin": "8.58.2",
"@typescript-eslint/parser": "8.58.2",
"dotenv": "16.4.5",
"eslint": "9.39.4",
"eslint-config-airbnb-base": "15.0.0",
"eslint-plugin-import": "2.29.1",
"eslint-plugin-jest": "28.14.0",
"mysql2": "3.22.0",
"sequelize": "6.37.2",
"sequelize-cli": "6.6.2",
"typescript": "5.8.2"
},
"packageManager": "yarn@4.7.0",
"dependencies": {
Expand All @@ -37,11 +37,11 @@
"@aws-sdk/client-sqs": "3.540.0",
"@hathor/wallet-lib": "2.12.0",
"@wallet-service/common": "1.5.0",
"bip32": "^4.0.0",
"bitcoinjs-lib": "^6.1.5",
"bitcoinjs-message": "^2.2.0",
"jest": "^29.7.0",
"tiny-secp256k1": "^2.2.3",
"bip32": "4.0.0",
"bitcoinjs-lib": "6.1.5",
"bitcoinjs-message": "2.2.0",
"jest": "29.7.0",
"tiny-secp256k1": "2.2.3",
"winston": "3.13.0"
}
}
195 changes: 195 additions & 0 deletions packages/daemon/__tests__/actors/MonitoringActor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import logger from '../../src/logger';
import { EventTypes } from '../../src/types/event';
import getConfig from '../../src/config';
import { addAlert, Severity } from '@wallet-service/common';
import * as db from '../../src/db';

const MONITORING_IDLE_TIMEOUT_EVENT = { type: EventTypes.MONITORING_IDLE_TIMEOUT };

Expand All @@ -24,6 +25,10 @@ jest.mock('@wallet-service/common', () => ({
addAlert: jest.fn().mockResolvedValue(undefined),
}));

jest.mock('../../src/db', () => ({
getDbConnection: jest.fn(),
}));

const mockAddAlert = addAlert as jest.Mock;

describe('MonitoringActor', () => {
Expand All @@ -49,6 +54,10 @@ describe('MonitoringActor', () => {
config['STUCK_PROCESSING_TIMEOUT_MS'] = 5 * 60 * 1000; // 5 min
config['RECONNECTION_STORM_THRESHOLD'] = 3; // low threshold for tests
config['RECONNECTION_STORM_WINDOW_MS'] = 5 * 60 * 1000; // 5 min
config['BALANCE_VALIDATION_ENABLED'] = false;
config['BALANCE_VALIDATION_INTERVAL_MS'] = 5000;
config['BALANCE_VALIDATION_WINDOW_MS'] = 900000;
config['BALANCE_VALIDATION_SAMPLE_LIMIT'] = 100;

mockCallback = jest.fn();
mockReceive = jest.fn().mockImplementation((cb: any) => {
Expand Down Expand Up @@ -330,4 +339,190 @@ describe('MonitoringActor', () => {
);
expect(setInterval).not.toHaveBeenCalled();
});

// ── Balance validation ────────────────────────────────────────────────────

const flushPromises = () => new Promise(jest.requireActual('timers').setImmediate);

describe('balance validation', () => {
let mockMysql: any;

beforeEach(() => {
mockMysql = {
release: jest.fn(),
query: jest.fn().mockResolvedValue([[], []]),
};
(db.getDbConnection as jest.Mock).mockResolvedValue(mockMysql);
});

it('should not start balance validation when disabled', () => {
config['BALANCE_VALIDATION_ENABLED'] = false;
MonitoringActor(mockCallback, mockReceive, config);
sendEvent('CONNECTED');

// Only the idle-check interval should fire; no validation interval.
expect(setInterval).toHaveBeenCalledTimes(1);
});

it('should start the validation interval on CONNECTED when enabled', () => {
config['BALANCE_VALIDATION_ENABLED'] = true;
MonitoringActor(mockCallback, mockReceive, config);
sendEvent('CONNECTED');

// Idle check + balance validation = 2 intervals.
expect(setInterval).toHaveBeenCalledTimes(2);
expect(setInterval).toHaveBeenCalledWith(
expect.any(Function),
config['BALANCE_VALIDATION_INTERVAL_MS'],
);
});

it('should clear the validation interval on DISCONNECTED', () => {
config['BALANCE_VALIDATION_ENABLED'] = true;
MonitoringActor(mockCallback, mockReceive, config);
sendEvent('CONNECTED');
sendEvent('DISCONNECTED');

// Idle check + balance validation = 2 cleared intervals.
expect(clearInterval).toHaveBeenCalledTimes(2);
});

it('should alert when the validation query returns mismatch rows', async () => {
config['BALANCE_VALIDATION_ENABLED'] = true;

const mismatchRow = {
address: 'addr1',
tokenId: 'token1',
balanceSum: '100',
historySum: '200',
};
mockMysql.query.mockResolvedValueOnce([[mismatchRow], []]);

MonitoringActor(mockCallback, mockReceive, config);
sendEvent('CONNECTED');

jest.advanceTimersByTime(config['BALANCE_VALIDATION_INTERVAL_MS']);
await flushPromises();

expect(mockMysql.query).toHaveBeenCalledWith(expect.stringContaining('LEFT JOIN'));
// Scope-by-updated_at is load-bearing for perf (see follow-up #404);
// pin it so a future refactor doesn't silently drop the filter.
expect(mockMysql.query).toHaveBeenCalledWith(expect.stringContaining('ab.updated_at > NOW() - INTERVAL'));
expect(mockAddAlert).toHaveBeenCalledWith(
'Balance validation found mismatches',
expect.stringContaining('1 balance mismatch'),
Severity.MAJOR,
expect.objectContaining({
truncated: false,
samples: [mismatchRow],
}),
expect.anything(),
);
expect(mockMysql.release).toHaveBeenCalled();
});

it('should log info when no mismatches found', async () => {
config['BALANCE_VALIDATION_ENABLED'] = true;
const mockLoggerInfo = jest.spyOn(logger, 'info');

mockMysql.query.mockResolvedValueOnce([[], []]);

MonitoringActor(mockCallback, mockReceive, config);
sendEvent('CONNECTED');

jest.advanceTimersByTime(config['BALANCE_VALIDATION_INTERVAL_MS']);
await flushPromises();

expect(mockAddAlert).not.toHaveBeenCalled();
expect(mockLoggerInfo).toHaveBeenCalledWith(
expect.stringContaining('no mismatches found'),
);
});

it('should mark the alert as truncated when the row count hits the LIMIT', async () => {
config['BALANCE_VALIDATION_ENABLED'] = true;

// The actor's SAMPLE_LIMIT is 100; if exactly that many come back we
// assume more exist and surface "100+" + truncated:true.
const rows = Array.from({ length: 100 }, (_, i) => ({
address: `addr${i}`, tokenId: 'tok', balanceSum: '1', historySum: '0',
}));
mockMysql.query.mockResolvedValueOnce([rows, []]);

MonitoringActor(mockCallback, mockReceive, config);
sendEvent('CONNECTED');

jest.advanceTimersByTime(config['BALANCE_VALIDATION_INTERVAL_MS']);
await flushPromises();

expect(mockAddAlert).toHaveBeenCalledWith(
'Balance validation found mismatches',
expect.stringContaining('100+'),
Severity.MAJOR,
expect.objectContaining({ truncated: true }),
expect.anything(),
);
});

it('should handle DB errors without crashing', async () => {
config['BALANCE_VALIDATION_ENABLED'] = true;
const mockLoggerError = jest.spyOn(logger, 'error');

(db.getDbConnection as jest.Mock).mockRejectedValueOnce(new Error('DB connection failed'));

MonitoringActor(mockCallback, mockReceive, config);
sendEvent('CONNECTED');

jest.advanceTimersByTime(config['BALANCE_VALIDATION_INTERVAL_MS']);
await flushPromises();

expect(mockLoggerError).toHaveBeenCalledWith(
expect.stringContaining('Balance validation error'),
);
});

it('should refuse to schedule validation when interval is NaN', () => {
config['BALANCE_VALIDATION_ENABLED'] = true;
config['BALANCE_VALIDATION_INTERVAL_MS'] = NaN;
const mockLoggerError = jest.spyOn(logger, 'error');

MonitoringActor(mockCallback, mockReceive, config);
sendEvent('CONNECTED');

// Only the idle-check interval should fire; the validation interval
// must NOT be scheduled because the config is invalid.
expect(setInterval).toHaveBeenCalledTimes(1);
expect(mockLoggerError).toHaveBeenCalledWith(
expect.stringContaining('BALANCE_VALIDATION_INTERVAL_MS=NaN is invalid'),
);
});

it('should refuse to schedule validation when interval is below the minimum', () => {
config['BALANCE_VALIDATION_ENABLED'] = true;
config['BALANCE_VALIDATION_INTERVAL_MS'] = 10; // below the 1000ms floor
const mockLoggerError = jest.spyOn(logger, 'error');

MonitoringActor(mockCallback, mockReceive, config);
sendEvent('CONNECTED');

expect(setInterval).toHaveBeenCalledTimes(1);
expect(mockLoggerError).toHaveBeenCalledWith(
expect.stringContaining('is invalid'),
);
});

it('should refuse to schedule validation when sample limit is invalid', () => {
config['BALANCE_VALIDATION_ENABLED'] = true;
config['BALANCE_VALIDATION_SAMPLE_LIMIT'] = 0; // 0 would silently skip every row
const mockLoggerError = jest.spyOn(logger, 'error');

MonitoringActor(mockCallback, mockReceive, config);
sendEvent('CONNECTED');

expect(setInterval).toHaveBeenCalledTimes(1);
expect(mockLoggerError).toHaveBeenCalledWith(
expect.stringContaining('BALANCE_VALIDATION_SAMPLE_LIMIT=0 is invalid'),
);
});
});
});
4 changes: 3 additions & 1 deletion packages/daemon/__tests__/db/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,9 @@ describe('transaction methods', () => {
await addOrUpdateTx(mysql, 'txId1', null, 1, 1, 65.4321);
const tx = await getTransactionById(mysql, 'txId1');

expect(tx?.weight).toStrictEqual(65.4321);
// `weight` is stored as FLOAT (32-bit); prepared statements return the
// true binary value rather than the text-protocol's rounded display.
expect(tx?.weight).toBeCloseTo(65.4321, 4);
});

test('db which is not on our database should return null', async () => {
Expand Down
5 changes: 3 additions & 2 deletions packages/daemon/__tests__/integration/balances.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -468,13 +468,14 @@ describe('voided token authority scenario', () => {
),
(
'cafecafe',
'xpub6F81iNtH5HVknoJ65cK2XAGA5F3okdJK7WHwVAAPZnSir2sfwbhvB9ffNKQ4wLor75QxPe9p12tqt8xUZSG8i8AAPMpkFho7fbWkBJQ5s1x',
-- Distinct xpub from deafbeef so gap discovery doesn't collide on the global address PK.
'xpub6GwCmKUTKBzEWNM9Zt77NTTsu6DNx6uzQP4TJm7yH5UpaEJ2fKioET7MrXNp584rNDyJWHqeNdEAZU5shWzSDQYs8bNtXAbVw1T1HKj4QjW',
'ready',
20,
UNIX_TIMESTAMP(),
UNIX_TIMESTAMP(),
0,
'xpub6F81iNtH5HVknoJ65cK2XAGA5F3okdJK7WHwVAAPZnSir2sfwbhvB9ffNKQ4wLor75QxPe9p12tqt8xUZSG8i8AAPMpkFho7fbWkBJQ5s1x',
'xpub6GwCmKUTKBzEWNM9Zt77NTTsu6DNx6uzQP4TJm7yH5UpaEJ2fKioET7MrXNp584rNDyJWHqeNdEAZU5shWzSDQYs8bNtXAbVw1T1HKj4QjW',
-1
)`;

Expand Down
Loading
Loading