@dashevo/wallet-lib
Version:
Light wallet library for Dash
1,091 lines (904 loc) • 42.1 kB
JavaScript
const { expect } = require('chai');
const EventEmitter = require('events');
const { Transaction } = require('@dashevo/dashcore-lib');
const TransactionsSyncWorker = require('./TransactionsSyncWorker');
const TransactionsReader = require('./TransactionsReader');
const { waitOneTick } = require('../../../test/utils');
const { mockMerkleBlock } = require('../../../test/mocks/dashcore/block');
const EVENTS = require('../../../EVENTS');
const logger = require('../../../logger');
describe('TransactionsSyncWorker', () => {
let transactionsSyncWorker;
const CHAIN_HEIGHT = 1000;
const ADDRESSES_KEYCHAIN_1 = ['XqbqiYFC45SD1E3V1yRHmVLenZKoF4dwfH', 'Xmz1nb4ikHw374SbEHt81AnRh6kV2vz6LM', 'XwbDo3myVfbRbAQ4fHWrjsFKEBqiK4Sz7P'];
const ADDRESSES_KEYCHAIN_2 = ['XtmEv7XAHJXHBommfR7WhRU28E2oMgUxgJ', 'Xs1GTJEENg4SdJaEoGmgJRiC1L1QBzgfS6'];
const ADDRESSES_KEYCHAIN_3 = ['XbrMntvw3KR1wEmBM9xMbB9hiNi7NNrwEG'];
const ADDRESSES = [
ADDRESSES_KEYCHAIN_1,
ADDRESSES_KEYCHAIN_2,
ADDRESSES_KEYCHAIN_3,
];
const createTransactionsSyncWorker = (sinon) => {
const worker = new TransactionsSyncWorker({
executeOnStart: false,
});
worker.network = 'livenet';
worker.logger = logger;
worker.keyChainStore = {
getKeyChains: () => ADDRESSES.map((addresses) => ({
getWatchedAddresses: () => addresses,
})),
};
worker.parentEvents = new EventEmitter();
sinon.spy(worker.parentEvents, 'emit');
sinon.spy(worker.parentEvents, 'removeListener');
sinon.spy(worker, 'scheduleProgressUpdate');
worker.importTransactions = sinon.stub().returns([]);
const transactionsReader = new EventEmitter();
transactionsReader.startHistoricalSync = sinon.spy();
transactionsReader.startContinuousSync = sinon.spy();
transactionsReader.stopHistoricalSync = sinon.spy();
transactionsReader.stopContinuousSync = sinon.spy();
sinon.spy(transactionsReader, 'on');
sinon.spy(transactionsReader, 'once');
sinon.spy(transactionsReader, 'removeListener');
worker.transactionsReader = transactionsReader;
worker.storage = {
application: {},
scheduleStateSave: sinon.spy(),
saveState: sinon.spy(),
getDefaultChainStore() {
if (!this.defaultChainStore) {
const chainStoreState = {
chainHeight: CHAIN_HEIGHT,
lastSyncedBlockHeight: -1,
headersMetadata: new Map(),
transactions: new Map(),
};
this.defaultChainStore = {
state: chainStoreState,
pruneHeadersMetadata: sinon.spy(),
updateLastSyncedBlockHeight: sinon.spy(),
};
}
return this.defaultChainStore;
},
};
return worker;
};
describe('#getStartBlockHeight', () => {
beforeEach(function beforeEach() {
transactionsSyncWorker = createTransactionsSyncWorker(this.sinon);
});
it('should return block 1 by default', () => {
const startBlockHeight = transactionsSyncWorker.getStartBlockHeight();
expect(startBlockHeight).to.equal(1);
});
it('should return last synced block height + 1 if present', () => {
const { storage } = transactionsSyncWorker;
storage.getDefaultChainStore().state.lastSyncedBlockHeight = 1200;
const startBlockHeight = transactionsSyncWorker.getStartBlockHeight();
expect(startBlockHeight).to.equal(1200);
});
it('should return chain height in case `skipSynchronization` option is present', () => {
/**
* Mock options
*/
const { storage } = transactionsSyncWorker;
storage.getDefaultChainStore().state.chainHeight = CHAIN_HEIGHT;
storage.application.syncOptions = {
skipSynchronization: true,
};
const startBlockHeight = transactionsSyncWorker.getStartBlockHeight();
expect(startBlockHeight).to.equal(CHAIN_HEIGHT);
});
it('should return `skipSynchronizationBeforeHeight` value', () => {
const { storage } = transactionsSyncWorker;
storage.application.syncOptions = {
skipSynchronizationBeforeHeight: 1300,
};
const startBlockHeight = transactionsSyncWorker.getStartBlockHeight();
expect(startBlockHeight).to.equal(1300);
});
it('should return `skipSynchronizationBeforeHeight` value if it\'s greater than last synced header height', () => {
const { storage } = transactionsSyncWorker;
storage.getDefaultChainStore().state.lastSyncedHeaderHeight = 1200;
storage.application.syncOptions = {
skipSynchronizationBeforeHeight: 1300,
};
const startBlockHeight = transactionsSyncWorker.getStartBlockHeight();
expect(startBlockHeight).to.equal(1300);
});
});
describe('#onStart', () => {
beforeEach(function beforeEach() {
transactionsSyncWorker = createTransactionsSyncWorker(this.sinon);
});
context('Startup', () => {
it('should validate input params', async () => {
const { storage } = transactionsSyncWorker;
// Invalid chain height
storage.getDefaultChainStore().state.chainHeight = true;
await expect(transactionsSyncWorker.onStart())
.to.be.rejectedWith('Chain height is not a number: "true"');
storage.getDefaultChainStore().state.chainHeight = 0;
await expect(transactionsSyncWorker.onStart())
.to.be.rejectedWith('Invalid current chain height 0');
storage.getDefaultChainStore().state.chainHeight = CHAIN_HEIGHT;
// lastSyncedBlockHeight exceeds chain height
storage.getDefaultChainStore().state.lastSyncedBlockHeight = CHAIN_HEIGHT * 2;
await expect(transactionsSyncWorker.onStart())
.to.be.rejectedWith('Start block height 2000 is greater than chain height 1000');
});
it('should kickstart reading of historical headers', async () => {
const startPromise = transactionsSyncWorker.onStart();
await waitOneTick();
const { transactionsReader } = transactionsSyncWorker;
expect(transactionsSyncWorker.syncState).to
.equal(TransactionsSyncWorker.STATES.HISTORICAL_SYNC);
expect(transactionsReader.on).to
.have.been.calledWith(
TransactionsReader.EVENTS.HISTORICAL_TRANSACTIONS,
transactionsSyncWorker.historicalTransactionsHandler,
);
expect(transactionsReader.on).to
.have.been.calledWith(
TransactionsReader.EVENTS.MERKLE_BLOCK,
transactionsSyncWorker.historicalMerkleBlockHandler,
);
expect(transactionsReader.on).to
.have.been.calledWith(
TransactionsReader.EVENTS.ERROR,
transactionsSyncWorker.transactionsReaderErrorHandler,
);
expect(transactionsReader.once).to
.have.been.calledWith(
TransactionsReader.EVENTS.HISTORICAL_DATA_OBTAINED,
transactionsSyncWorker.historicalDataObtainedHandler,
);
expect(transactionsReader.once).to
.have.been.calledWith(
TransactionsReader.EVENTS.STOPPED,
transactionsSyncWorker.transactionsReaderStoppedHandler,
);
expect(transactionsReader.startHistoricalSync)
.to.have.been.calledOnceWith(
1,
CHAIN_HEIGHT,
ADDRESSES.reduce((acc, addresses) => acc.concat(addresses), []),
);
transactionsReader.emit(TransactionsReader.EVENTS.HISTORICAL_DATA_OBTAINED);
await startPromise;
});
it('should skip historical sync in case startBlockHeight is equal to chain height', async () => {
const { storage, transactionsReader } = transactionsSyncWorker;
// Invalid chain height
storage.getDefaultChainStore().state.lastSyncedBlockHeight = CHAIN_HEIGHT;
await transactionsSyncWorker.onStart();
const chainStore = storage.getDefaultChainStore();
expect(transactionsSyncWorker.syncCheckpoint).to.equal(CHAIN_HEIGHT);
expect(chainStore.updateLastSyncedBlockHeight).to.have.been.calledWith(CHAIN_HEIGHT);
expect(chainStore.pruneHeadersMetadata).to.have.been.calledWith(CHAIN_HEIGHT);
expect(storage.saveState).to.have.been.calledOnce();
expect(transactionsReader.startHistoricalSync).to.have.not.been.called();
});
});
context('Paused', () => {
it('should handle stopped event from transactionsReader', async () => {
const startPromise = transactionsSyncWorker.onStart();
await waitOneTick();
const tx = new Transaction()
.to(ADDRESSES_KEYCHAIN_1[0], 1000);
transactionsSyncWorker.historicalTransactionsToVerify.set(tx.hash, tx);
const { transactionsReader } = transactionsSyncWorker;
transactionsReader.emit(TransactionsReader.EVENTS.STOPPED);
const {
historicalTransactionsHandler,
historicalMerkleBlockHandler,
historicalDataObtainedHandler,
transactionsReaderErrorHandler,
} = transactionsSyncWorker;
expect(transactionsReader.removeListener)
.to.have.been.calledWith(
TransactionsReader.EVENTS.HISTORICAL_TRANSACTIONS,
historicalTransactionsHandler,
);
expect(transactionsReader.removeListener)
.to.have.been.calledWith(
TransactionsReader.EVENTS.MERKLE_BLOCK,
historicalMerkleBlockHandler,
);
expect(transactionsReader.removeListener)
.to.have.been.calledWith(
TransactionsReader.EVENTS.HISTORICAL_DATA_OBTAINED,
historicalDataObtainedHandler,
);
expect(transactionsReader.removeListener)
.to.have.been.calledWith(
TransactionsReader.EVENTS.ERROR,
transactionsReaderErrorHandler,
);
expect(transactionsSyncWorker.historicalTransactionsToVerify.size).to.equal(0);
await startPromise;
});
it('should start over from the sync checkpoint if historical sync is interrupted', async () => {
const { transactionsReader } = transactionsSyncWorker;
// Start historical sync
let startPromise = transactionsSyncWorker.onStart();
await waitOneTick();
// Put on a pause
const syncCheckpoint = CHAIN_HEIGHT - 500;
transactionsSyncWorker.syncCheckpoint = syncCheckpoint;
transactionsReader.emit(TransactionsReader.EVENTS.STOPPED);
await startPromise;
// Continue historical sync
startPromise = transactionsSyncWorker.onStart();
await waitOneTick();
transactionsReader.emit(TransactionsReader.EVENTS.STOPPED);
await startPromise;
const { secondCall } = transactionsReader.startHistoricalSync;
expect(secondCall.args).to.deep.equal([
syncCheckpoint,
CHAIN_HEIGHT,
ADDRESSES.reduce((acc, addresses) => acc.concat(addresses), []),
]);
});
});
context('Finished', () => {
it('should prepare for continuous sync after historical data is obtained', async function test() {
const { transactionsReader, storage } = transactionsSyncWorker;
const chainStore = storage.getDefaultChainStore();
transactionsSyncWorker.updateProgress = this.sinon.spy();
const startPromise = transactionsSyncWorker.onStart();
await waitOneTick();
transactionsReader.emit(TransactionsReader.EVENTS.HISTORICAL_DATA_OBTAINED);
await startPromise;
expect(transactionsSyncWorker.syncState).to
.equal(TransactionsSyncWorker.STATES.IDLE);
expect(transactionsReader.removeListener)
.to.have.been.calledWith(
TransactionsReader.EVENTS.HISTORICAL_TRANSACTIONS,
transactionsSyncWorker.historicalTransactionsHandler,
);
expect(transactionsReader.removeListener)
.to.have.been.calledWith(
TransactionsReader.EVENTS.MERKLE_BLOCK,
transactionsSyncWorker.historicalMerkleBlockHandler,
);
expect(transactionsReader.removeListener)
.to.have.been.calledWith(
TransactionsReader.EVENTS.ERROR,
transactionsSyncWorker.transactionsReaderErrorHandler,
);
expect(transactionsSyncWorker.syncCheckpoint).to.equal(CHAIN_HEIGHT);
expect(chainStore.updateLastSyncedBlockHeight).to.have.been.calledWith(CHAIN_HEIGHT);
expect(transactionsSyncWorker.updateProgress).to.have.been.calledOnce();
expect(transactionsSyncWorker.storage.saveState).to.have.been.calledOnce();
expect(chainStore.pruneHeadersMetadata).to.have.been.calledOnceWith(CHAIN_HEIGHT);
});
it('should throw an error in case there are transactions to verify left', async () => {
const { transactionsReader } = transactionsSyncWorker;
const tx = new Transaction()
.to(ADDRESSES_KEYCHAIN_1[0], 1000);
transactionsSyncWorker.historicalTransactionsToVerify.set(tx.hash, tx);
const startPromise = transactionsSyncWorker.onStart();
await waitOneTick();
transactionsReader.emit(TransactionsReader.EVENTS.HISTORICAL_DATA_OBTAINED);
await expect(startPromise)
.to.be.rejectedWith('Historical data obtained but there are still transactions to verify');
});
});
context('Error', () => {
it('should handle error event from transactionsReader', async () => {
const { transactionsReader } = transactionsSyncWorker;
const startPromise = transactionsSyncWorker.onStart();
await waitOneTick();
const tx = new Transaction()
.to(ADDRESSES_KEYCHAIN_1[0], 1000);
transactionsSyncWorker.historicalTransactionsToVerify.set(tx.hash, tx);
// Throw an error and interrupt historical sync
const syncError = new Error('Error syncing historical data');
transactionsReader.emit(TransactionsReader.EVENTS.ERROR, syncError);
await expect(startPromise)
.to.be.rejectedWith(syncError);
expect(transactionsSyncWorker.historicalTransactionsToVerify.size)
.to.equal(0);
});
});
});
describe('#execute', () => {
beforeEach(function beforeEach() {
transactionsSyncWorker = createTransactionsSyncWorker(this.sinon);
});
it('should kickstart continuous sync', async () => {
const { transactionsReader } = transactionsSyncWorker;
transactionsSyncWorker.syncCheckpoint = 1200;
await transactionsSyncWorker.execute();
expect(transactionsSyncWorker.syncState).to
.equal(TransactionsSyncWorker.STATES.CONTINUOUS_SYNC);
expect(transactionsReader.on).to
.have.been.calledWith(
TransactionsReader.EVENTS.NEW_TRANSACTIONS,
transactionsSyncWorker.newTransactionsHandler,
);
expect(transactionsReader.on).to
.have.been.calledWith(
TransactionsReader.EVENTS.MERKLE_BLOCK,
transactionsSyncWorker.newMerkleBlockHandler,
);
expect(transactionsReader.on).to
.have.been.calledWith(
TransactionsReader.EVENTS.ERROR,
transactionsSyncWorker.transactionsReaderErrorHandler,
);
expect(transactionsReader.once).to
.have.been.calledWith(
TransactionsReader.EVENTS.STOPPED,
transactionsSyncWorker.transactionsReaderStoppedHandler,
);
expect(transactionsReader.startContinuousSync).to
.have.been.calledWith(
1200,
ADDRESSES.reduce((acc, addresses) => acc.concat(addresses), []),
);
expect(transactionsSyncWorker.syncState)
.to.equal(TransactionsSyncWorker.STATES.CONTINUOUS_SYNC);
});
it('should forward an error from blockHeadersProvider', async function test() {
transactionsSyncWorker.syncCheckpoint = 1200;
await transactionsSyncWorker.execute();
const errorCallback = this.sinon.spy();
transactionsSyncWorker.parentEvents.on('error', errorCallback);
const { transactionsReader } = transactionsSyncWorker;
const error = new Error('Test error');
transactionsReader.emit('error', error);
expect(errorCallback).to.have.been.calledOnceWith(error);
});
it('should not allow multiple executions', async () => {
await transactionsSyncWorker.execute();
await expect(transactionsSyncWorker.execute()).to.be.rejected();
});
it('should handle stopped event from transactionsReader', async () => {
await transactionsSyncWorker.execute();
const { transactionsReader } = transactionsSyncWorker;
transactionsReader.emit(TransactionsReader.EVENTS.STOPPED);
const {
newTransactionsHandler,
newMerkleBlockHandler,
transactionsReaderErrorHandler,
} = transactionsSyncWorker;
expect(transactionsReader.removeListener)
.to.have.been.calledWith(
TransactionsReader.EVENTS.NEW_TRANSACTIONS,
newTransactionsHandler,
);
expect(transactionsReader.removeListener)
.to.have.been.calledWith(
TransactionsReader.EVENTS.MERKLE_BLOCK,
newMerkleBlockHandler,
);
expect(transactionsReader.removeListener)
.to.have.been.calledWith(
TransactionsReader.EVENTS.ERROR,
transactionsReaderErrorHandler,
);
});
it('should start over from the sync checkpoint if continuous sync is interrupted', async () => {
const { transactionsReader } = transactionsSyncWorker;
// Start historical sync
await transactionsSyncWorker.execute();
// Put on a pause
const syncCheckpoint = CHAIN_HEIGHT + 500;
transactionsSyncWorker.syncCheckpoint = syncCheckpoint;
transactionsReader.emit(TransactionsReader.EVENTS.STOPPED);
// Continue historical sync
await transactionsSyncWorker.execute();
transactionsReader.emit(TransactionsReader.EVENTS.STOPPED);
const { secondCall } = transactionsReader.startContinuousSync;
expect(secondCall.args).to.deep.equal([
syncCheckpoint,
ADDRESSES.reduce((acc, addresses) => acc.concat(addresses), []),
]);
});
it('should handle error event from transactionsReader', async () => {
const { transactionsReader } = transactionsSyncWorker;
let emittedError = null;
transactionsSyncWorker.parentEvents.on('error', (e) => {
emittedError = e;
});
// Start continuous sync
await transactionsSyncWorker.execute();
// Throw an error from reader
const syncError = new Error('Error syncing historical data');
transactionsReader.emit(TransactionsReader.EVENTS.ERROR, syncError);
expect(emittedError).to.equal(syncError);
});
});
describe('#onStop', () => {
beforeEach(function beforeEach() {
transactionsSyncWorker = createTransactionsSyncWorker(this.sinon);
});
it('should stop historical sync', async () => {
transactionsSyncWorker.syncState = TransactionsSyncWorker.STATES.HISTORICAL_SYNC;
const { transactionsReader } = transactionsSyncWorker;
await transactionsSyncWorker.onStop();
expect(transactionsReader.stopHistoricalSync).to.have.been.calledOnce();
expect(transactionsReader.stopContinuousSync).to.have.not.been.called();
});
it('should stop continuous sync', async () => {
transactionsSyncWorker.syncState = TransactionsSyncWorker.STATES.CONTINUOUS_SYNC;
const { transactionsReader } = transactionsSyncWorker;
await transactionsSyncWorker.onStop();
expect(transactionsReader.stopContinuousSync).to.have.been.calledOnce();
expect(transactionsReader.stopHistoricalSync).to.have.not.been.called();
});
it('should unsubscribe from blockHeightChanged handler', async function test() {
const handler = this.sinon.spy();
transactionsSyncWorker.blockHeightChangedHandler = handler;
transactionsSyncWorker.parentEvents.on(
EVENTS.BLOCKHEIGHT_CHANGED,
transactionsSyncWorker.blockHeightChangedHandler,
);
await transactionsSyncWorker.onStop();
expect(transactionsSyncWorker.parentEvents.removeListener)
.to.have.been.calledWith(EVENTS.BLOCKHEIGHT_CHANGED, handler);
expect(transactionsSyncWorker.blockHeightChangedHandler).to.equal(null);
});
});
describe('#historicalTransactionsHandler', () => {
beforeEach(function beforeEach() {
transactionsSyncWorker = createTransactionsSyncWorker(this.sinon);
});
it('should add transactions to the verification pool', () => {
const transactions = ADDRESSES_KEYCHAIN_1
.map((address) => new Transaction().to(address, 1000));
transactionsSyncWorker.historicalTransactionsHandler(transactions);
const expectedResult = transactions.reduce((acc, transaction) => {
acc.set(transaction.hash, transaction);
return acc;
}, new Map());
expect(transactionsSyncWorker.historicalTransactionsToVerify)
.to.deep.equal(expectedResult);
});
it('should validate arguments', () => {
expect(() => transactionsSyncWorker.historicalTransactionsHandler([]))
.to.throw('No transactions to process');
});
});
describe('#historicalMerkleBlockHandler', () => {
let storage;
let chainStore;
beforeEach(function beforeEach() {
transactionsSyncWorker = createTransactionsSyncWorker(this.sinon);
({ storage } = transactionsSyncWorker);
chainStore = storage.getDefaultChainStore();
});
context('Accept merkle block', () => {
it('should verify transactions in the pool and accept merkle block', function test() {
// Create transactions
const transactions = [
new Transaction().to(ADDRESSES_KEYCHAIN_1[0], 1000),
new Transaction().to(ADDRESSES_KEYCHAIN_1[1], 2000),
];
// Add transactions to the verification pool
transactions.forEach((tx) => {
transactionsSyncWorker.historicalTransactionsToVerify.set(tx.hash, tx);
});
// Create merkle block
const merkleBlock = mockMerkleBlock(transactions.map((tx) => tx.hash));
const merkleBlockHeight = 500;
const metadata = {
height: merkleBlockHeight,
time: merkleBlock.header.time,
};
// Update chain store
chainStore.state.headersMetadata.set(merkleBlock.header.hash, metadata);
// Simulate addresses gap fill
transactionsSyncWorker.importTransactions.returns({
addressesGenerated: [ADDRESSES_KEYCHAIN_1[2]],
});
// Prepare event handler payload
const dataEventPayload = {
merkleBlock,
acceptMerkleBlock: this.sinon.spy(),
rejectMerkleBlock: this.sinon.spy(),
};
transactionsSyncWorker.historicalMerkleBlockHandler(dataEventPayload);
const expectedMetadata = {
blockHash: merkleBlock.header.hash,
height: metadata.height,
time: new Date(metadata.time * 1000),
instantLocked: false,
chainLocked: false,
};
expect(transactionsSyncWorker.importTransactions)
.to.have.been.calledWith(transactions.map((tx) => [tx, expectedMetadata]));
expect(dataEventPayload.rejectMerkleBlock).to.have.not.been.called();
expect(dataEventPayload.acceptMerkleBlock)
.to.have.been.calledWith(merkleBlockHeight, [ADDRESSES_KEYCHAIN_1[2]]);
expect(transactionsSyncWorker.syncCheckpoint).to.equal(merkleBlockHeight);
expect(chainStore.pruneHeadersMetadata).to.have.been.calledWith(merkleBlockHeight);
expect(chainStore.updateLastSyncedBlockHeight).to.have.been.calledWith(merkleBlockHeight);
expect(storage.scheduleStateSave).to.have.been.called();
expect(transactionsSyncWorker.scheduleProgressUpdate).to.have.been.called();
});
});
context('Reject merkle block', () => {
it('should reject in case header metadata is missing', function test() {
// Create transactions
const transactions = [
new Transaction().to(ADDRESSES_KEYCHAIN_1[0], 1000),
new Transaction().to(ADDRESSES_KEYCHAIN_1[1], 2000),
];
// Add transactions to the verification pool
transactions.forEach((tx) => {
transactionsSyncWorker.historicalTransactionsToVerify.set(tx.hash, tx);
});
const merkleBlock = mockMerkleBlock(transactions.map((tx) => tx.hash));
// Prepare event handler payload
const dataEventPayload = {
merkleBlock,
acceptMerkleBlock: this.sinon.spy(),
rejectMerkleBlock: this.sinon.spy(),
};
transactionsSyncWorker.historicalMerkleBlockHandler(dataEventPayload);
expect(dataEventPayload.acceptMerkleBlock).to.have.not.been.called();
const { args } = dataEventPayload.rejectMerkleBlock.getCall(0);
expect(args[0].message)
.to.equal('Header metadata was not found during the merkle block processing');
expect(chainStore.updateLastSyncedBlockHeight).to.have.not.been.called();
expect(chainStore.pruneHeadersMetadata).to.have.not.been.called();
expect(storage.scheduleStateSave).to.have.not.been.called();
expect(transactionsSyncWorker.scheduleProgressUpdate).to.have.not.been.called();
});
it('should reject in case of invalid header time', function test() {
// Create transactions
const transactions = [
new Transaction().to(ADDRESSES_KEYCHAIN_1[0], 1000),
];
// Add transactions to the verification pool
transactions.forEach((tx) => {
transactionsSyncWorker.historicalTransactionsToVerify.set(tx.hash, tx);
});
const merkleBlock = mockMerkleBlock(transactions.map((tx) => tx.hash));
const merkleBlockHeight = 500;
// Update chain store
chainStore.state.headersMetadata.set(merkleBlock.header.hash, {
height: merkleBlockHeight,
time: 0,
});
// Prepare event handler payload
const dataEventPayload = {
merkleBlock,
acceptMerkleBlock: this.sinon.spy(),
rejectMerkleBlock: this.sinon.spy(),
};
transactionsSyncWorker.historicalMerkleBlockHandler(dataEventPayload);
expect(dataEventPayload.acceptMerkleBlock).to.have.not.been.called();
const { args } = dataEventPayload.rejectMerkleBlock.getCall(0);
expect(args[0].message)
.to.equal('Invalid header time: 0');
expect(chainStore.updateLastSyncedBlockHeight).to.have.not.been.called();
expect(chainStore.pruneHeadersMetadata).to.have.not.been.called();
expect(storage.scheduleStateSave).to.have.not.been.called();
expect(transactionsSyncWorker.scheduleProgressUpdate).to.have.not.been.called();
});
it('should reject in case of invalid header height', function test() {
// Create transactions
const transactions = [
new Transaction().to(ADDRESSES_KEYCHAIN_1[0], 1000),
];
// Add transactions to the verification pool
transactions.forEach((tx) => {
transactionsSyncWorker.historicalTransactionsToVerify.set(tx.hash, tx);
});
const merkleBlock = mockMerkleBlock(transactions.map((tx) => tx.hash));
// Update chain store
chainStore.state.headersMetadata.set(merkleBlock.header.hash, {
height: -1,
time: merkleBlock.header.time,
});
// Prepare event handler payload
const dataEventPayload = {
merkleBlock,
acceptMerkleBlock: this.sinon.spy(),
rejectMerkleBlock: this.sinon.spy(),
};
transactionsSyncWorker.historicalMerkleBlockHandler(dataEventPayload);
expect(dataEventPayload.acceptMerkleBlock).to.have.not.been.called();
const { args } = dataEventPayload.rejectMerkleBlock.getCall(0);
expect(args[0].message)
.to.equal('Invalid header height: -1');
expect(chainStore.updateLastSyncedBlockHeight).to.have.not.been.called();
expect(chainStore.pruneHeadersMetadata).to.have.not.been.called();
expect(storage.scheduleStateSave).to.have.not.been.called();
expect(transactionsSyncWorker.scheduleProgressUpdate).to.have.not.been.called();
});
it('should reject if tx hash from verification pool not present in the merkle block', function test() {
// Create transactions
const transactions = [
new Transaction().to(ADDRESSES_KEYCHAIN_1[0], 1000),
new Transaction().to(ADDRESSES_KEYCHAIN_1[1], 2000),
new Transaction().to(ADDRESSES_KEYCHAIN_1[2], 3000),
];
// Add transactions to the verification pool
transactions.forEach((tx) => {
transactionsSyncWorker.historicalTransactionsToVerify.set(tx.hash, tx);
});
// Create merkle block
const merkleBlock = mockMerkleBlock(transactions.slice(1).map((tx) => tx.hash));
const merkleBlockHeight = 500;
// Update chain store
chainStore.state.headersMetadata.set(merkleBlock.header.hash, {
height: merkleBlockHeight,
time: merkleBlock.header.time,
});
// Prepare event handler payload
const dataEventPayload = {
merkleBlock,
acceptMerkleBlock: this.sinon.spy(),
rejectMerkleBlock: this.sinon.spy(),
};
transactionsSyncWorker.historicalMerkleBlockHandler(dataEventPayload);
expect(dataEventPayload.acceptMerkleBlock).to.have.not.been.called();
const { args } = dataEventPayload.rejectMerkleBlock.getCall(0);
expect(args[0].message)
.to.equal(`Transaction ${transactions[0].hash} was not found in merkle block ${merkleBlock.header.hash}`);
expect(chainStore.updateLastSyncedBlockHeight).to.have.not.been.called();
expect(chainStore.pruneHeadersMetadata).to.have.not.been.called();
expect(storage.scheduleStateSave).to.have.not.been.called();
expect(transactionsSyncWorker.scheduleProgressUpdate).to.have.not.been.called();
});
});
});
describe('#newTransactionHandler', () => {
beforeEach(function beforeEach() {
transactionsSyncWorker = createTransactionsSyncWorker(this.sinon);
});
it('should handle new transactions', function test() {
const transactions = ADDRESSES_KEYCHAIN_1.slice(0, 2)
.map((address) => new Transaction().to(address, 1000));
// Simulate addresses gap fill
const addressesGenerated = ADDRESSES_KEYCHAIN_1.slice(2);
transactionsSyncWorker.importTransactions.returns({
addressesGenerated,
});
// Simulate data event
const dataEventPayload = {
transactions,
handleNewAddresses: this.sinon.spy(),
};
transactionsSyncWorker
.newTransactionsHandler(dataEventPayload);
const expectedResult = transactions.reduce((acc, transaction) => {
acc.set(transaction.hash, transaction);
return acc;
}, new Map());
expect(transactionsSyncWorker.importTransactions)
.to.have.been.calledWith(transactions.map((tx) => [tx]));
expect(transactionsSyncWorker.historicalTransactionsToVerify)
.to.deep.equal(expectedResult);
expect(dataEventPayload.handleNewAddresses)
.to.have.been.calledWith(addressesGenerated);
});
it('should validate arguments', () => {
expect(() => transactionsSyncWorker.newTransactionsHandler({ transactions: [] }))
.to.throw('No new transactions to process');
});
it('should not handle same transactions twice', function test() {
// Make importTransactions memorize transactions
const chainStore = transactionsSyncWorker.storage.getDefaultChainStore();
transactionsSyncWorker.importTransactions.callsFake((txs) => {
txs.forEach(([tx]) => {
chainStore.state.transactions.set(tx.hash, tx);
});
return [];
});
// Generate and handle first set of transactions
const transactions = ADDRESSES_KEYCHAIN_1.slice(0, 2)
.map((address) => new Transaction().to(address, 1000));
transactionsSyncWorker.newTransactionsHandler({
transactions,
handleNewAddresses: this.sinon.spy(),
});
// Generate and handle second set of transactions with duplicated ones
const transactionsWithDuplicates = ADDRESSES_KEYCHAIN_1
.map((address) => new Transaction().to(address, 1000));
transactionsSyncWorker.newTransactionsHandler({
transactions: transactionsWithDuplicates,
handleNewAddresses: this.sinon.spy(),
});
const { firstCall } = transactionsSyncWorker.importTransactions;
const { secondCall } = transactionsSyncWorker.importTransactions;
expect(firstCall.args)
.to.deep.equal([transactions.map((tx) => [tx])]);
expect(secondCall.args[0].length).to.be.greaterThan(0);
const addedTxs = transactionsWithDuplicates.slice(transactions.length);
expect(secondCall.args)
.to.deep.equal([addedTxs.map((tx) => [tx])]);
});
});
describe('#newMerkleBlockHandler', () => {
let storage;
let chainStore;
beforeEach(function beforeEach() {
transactionsSyncWorker = createTransactionsSyncWorker(this.sinon);
({ storage } = transactionsSyncWorker);
chainStore = storage.getDefaultChainStore();
});
it('should not process same merkle block two times', function it() {
const merkleBlock = mockMerkleBlock([]);
const merkleBlockHeight = 500;
// Update chain store
const metadata = {
height: merkleBlockHeight,
time: merkleBlock.header.time,
};
chainStore.state.headersMetadata.set(merkleBlock.header.hash, metadata);
// Prepare event handler payload
const dataEventPayload = {
merkleBlock,
acceptMerkleBlock: this.sinon.spy(),
rejectMerkleBlock: this.sinon.spy(),
};
transactionsSyncWorker.newMerkleBlockHandler(dataEventPayload);
chainStore.state.lastSyncedBlockHeight = merkleBlockHeight;
transactionsSyncWorker.newMerkleBlockHandler(dataEventPayload);
expect(dataEventPayload.acceptMerkleBlock).to.have.been.calledOnce();
});
context('Accept merkle block', () => {
it('should verify transactions in the pool and accept merkle block', function test() {
// Create transactions
const transactions = [
new Transaction().to(ADDRESSES_KEYCHAIN_1[0], 1000),
new Transaction().to(ADDRESSES_KEYCHAIN_1[1], 2000),
];
// Add transactions to the verification pool
transactions.forEach((tx) => {
transactionsSyncWorker.historicalTransactionsToVerify.set(tx.hash, tx);
});
// Create merkle block
const merkleBlock = mockMerkleBlock(transactions.map((tx) => tx.hash));
const merkleBlockHeight = 500;
// Update chain store
const metadata = {
height: merkleBlockHeight,
time: merkleBlock.header.time,
};
chainStore.state.headersMetadata.set(merkleBlock.header.hash, metadata);
// Prepare event handler payload
const dataEventPayload = {
merkleBlock,
acceptMerkleBlock: this.sinon.spy(),
rejectMerkleBlock: this.sinon.spy(),
};
const expectedMetadata = {
blockHash: merkleBlock.header.hash,
height: metadata.height,
time: new Date(metadata.time * 1000),
instantLocked: false,
chainLocked: false,
};
transactionsSyncWorker.newMerkleBlockHandler(dataEventPayload);
expect(transactionsSyncWorker.importTransactions)
.to.have.been.calledWith(transactions.map((tx) => [tx, expectedMetadata]));
expect(dataEventPayload.rejectMerkleBlock).to.have.not.been.called();
expect(dataEventPayload.acceptMerkleBlock)
.to.have.been.calledWith(merkleBlockHeight);
expect(transactionsSyncWorker.syncCheckpoint).to.equal(merkleBlockHeight);
expect(chainStore.updateLastSyncedBlockHeight).to.have.been.calledWith(merkleBlockHeight);
expect(chainStore.pruneHeadersMetadata).to.have.been.calledWith(merkleBlockHeight);
expect(storage.scheduleStateSave).to.have.been.called();
expect(transactionsSyncWorker.parentEvents.emit)
.to.have.been.calledTwice();
const { firstCall, secondCall } = transactionsSyncWorker.parentEvents.emit;
expect(firstCall.args)
.to.deep.equal([EVENTS.CONFIRMED_TRANSACTION, transactions[0]]);
expect(secondCall.args)
.to.deep.equal([EVENTS.CONFIRMED_TRANSACTION, transactions[1]]);
});
it('should verify merkle block if no relevant transactions found', function test() {
// Create merkle block
const merkleBlock = mockMerkleBlock([]);
const merkleBlockHeight = 500;
// Update chain store
chainStore.state.headersMetadata.set(merkleBlock.header.hash, {
height: merkleBlockHeight,
time: merkleBlock.header.time,
});
// Prepare event handler payload
const dataEventPayload = {
merkleBlock,
acceptMerkleBlock: this.sinon.spy(),
rejectMerkleBlock: this.sinon.spy(),
};
transactionsSyncWorker.newMerkleBlockHandler(dataEventPayload);
expect(dataEventPayload.rejectMerkleBlock).to.have.not.been.called();
expect(dataEventPayload.acceptMerkleBlock)
.to.have.been.calledWith(merkleBlockHeight);
expect(transactionsSyncWorker.importTransactions)
.to.have.not.been.called();
expect(transactionsSyncWorker.syncCheckpoint).to.equal(merkleBlockHeight);
expect(chainStore.updateLastSyncedBlockHeight).to.have.been.calledWith(merkleBlockHeight);
expect(chainStore.pruneHeadersMetadata).to.have.been.calledWith(merkleBlockHeight);
expect(storage.scheduleStateSave).to.have.been.called();
});
it('should retry after BLOCKHEIGHT_CHANGED event in case metadata was not found', function test() {
// Create merkle block
const merkleBlock = mockMerkleBlock([]);
const merkleBlockHeight = 500;
// Prepare event handler payload
const dataEventPayload = {
merkleBlock,
acceptMerkleBlock: this.sinon.spy(),
rejectMerkleBlock: this.sinon.spy(),
};
// Emit merkle block data event
transactionsSyncWorker.newMerkleBlockHandler(dataEventPayload);
// Update chain store and emit BLOCKHEIGHT_CHANGED event
chainStore.state.headersMetadata.set(merkleBlock.header.hash, {
height: merkleBlockHeight,
time: merkleBlock.header.time,
});
transactionsSyncWorker.parentEvents
.emit(EVENTS.BLOCKHEIGHT_CHANGED, merkleBlockHeight);
expect(dataEventPayload.rejectMerkleBlock).to.have.not.been.called();
expect(dataEventPayload.acceptMerkleBlock)
.to.have.been.calledOnceWith(merkleBlockHeight);
expect(chainStore.updateLastSyncedBlockHeight)
.to.have.been.calledOnceWith(merkleBlockHeight);
expect(chainStore.pruneHeadersMetadata).to.have.been.calledWith(merkleBlockHeight);
expect(storage.scheduleStateSave).to.have.been.calledOnce();
expect(transactionsSyncWorker.syncCheckpoint).to.equal(merkleBlockHeight);
});
});
context('Reject merkle block', () => {
it('should reject in case of invalid header metadata', function test() {
const merkleBlock = mockMerkleBlock([]);
const merkleBlockHeight = 500;
// Update chain store
chainStore.state.headersMetadata.set(merkleBlock.header.hash, {
height: merkleBlockHeight,
time: 0,
});
// Prepare event handler payload
const dataEventPayload = {
merkleBlock,
acceptMerkleBlock: this.sinon.spy(),
rejectMerkleBlock: this.sinon.spy(),
};
transactionsSyncWorker.newMerkleBlockHandler(dataEventPayload);
expect(dataEventPayload.acceptMerkleBlock).to.have.not.been.called();
const { args } = dataEventPayload.rejectMerkleBlock.getCall(0);
expect(args[0].message)
.to.equal('Invalid header time: 0');
expect(chainStore.updateLastSyncedBlockHeight).to.have.not.been.called();
expect(chainStore.pruneHeadersMetadata).to.have.not.been.called();
expect(storage.scheduleStateSave).to.have.not.been.called();
});
it('should reject in case of invalid header height', function test() {
const merkleBlock = mockMerkleBlock([]);
// Update chain store
chainStore.state.headersMetadata.set(merkleBlock.header.hash, {
height: -1,
time: merkleBlock.header.time,
});
// Prepare event handler payload
const dataEventPayload = {
merkleBlock,
acceptMerkleBlock: this.sinon.spy(),
rejectMerkleBlock: this.sinon.spy(),
};
transactionsSyncWorker.newMerkleBlockHandler(dataEventPayload);
expect(dataEventPayload.acceptMerkleBlock).to.have.not.been.called();
const { args } = dataEventPayload.rejectMerkleBlock.getCall(0);
expect(args[0].message)
.to.equal('Invalid header height: -1');
expect(chainStore.updateLastSyncedBlockHeight).to.have.not.been.called();
expect(chainStore.pruneHeadersMetadata).to.have.not.been.called();
expect(storage.scheduleStateSave).to.have.not.been.called();
});
// TODO: should reject in case merkle block verification failed
});
});
});