@dashevo/wallet-lib
Version:
Light wallet library for Dash
633 lines (513 loc) • 23.4 kB
JavaScript
/* eslint-disable no-unused-expressions */
const EventEmitter = require('events');
const DAPIClient = require('@dashevo/dapi-client');
const { Block } = require('@dashevo/dashcore-lib');
const { expect } = require('chai');
const logger = require('../../../logger');
const { BlockHeadersProvider } = DAPIClient;
const BlockHeadersSyncWorker = require('./BlockHeadersSyncWorker');
const { waitOneTick } = require('../../../test/utils');
const EVENTS = require('../../../EVENTS');
const { mockHeadersChain } = require('../../../test/mocks/dashcore/block');
describe('BlockHeadersSyncWorker', () => {
let blockHeadersSyncWorker;
const chainHeight = 1000;
const spvChainHeaders = mockHeadersChain('testnet', 5);
const createBlockHeadersSyncWorker = (sinon) => {
const worker = new BlockHeadersSyncWorker({
executeOnStart: false,
maxHeadersToKeep: 3,
});
worker.logger = logger;
const blockHeadersProvider = new EventEmitter();
blockHeadersProvider.readHistorical = sinon.spy();
blockHeadersProvider.startContinuousSync = sinon.spy();
blockHeadersProvider.stop = () => {
blockHeadersProvider.emit(BlockHeadersProvider.EVENTS.STOPPED);
};
blockHeadersProvider.spvChain = {
startBlockHeight: 0,
getLongestChain() {
if (!this.longestChain) {
this.longestChain = [...spvChainHeaders];
}
return this.longestChain;
},
addHeaders(headers) {
if (!this.longestChain) {
this.longestChain = [...spvChainHeaders];
}
this.longestChain = [...this.longestChain, ...headers];
},
orphanChunks: [],
prunedHeaders: [],
};
sinon.spy(blockHeadersProvider, 'on');
sinon.spy(blockHeadersProvider, 'once');
sinon.spy(blockHeadersProvider, 'removeListener');
worker.network = 'testnet';
worker.transport = {
client: {
blockHeadersProvider,
},
getBlockByHeight() {
return Block.fromObject({
header: {
prevHash: Buffer.alloc(32),
time: 99999999,
merkleRoot: Buffer.alloc(32),
},
transactions: [],
});
},
};
worker.parentEvents = new EventEmitter();
sinon.spy(worker.parentEvents, 'emit');
sinon.spy(worker.transport, 'getBlockByHeight');
sinon.spy(worker, 'scheduleProgressUpdate');
worker.storage = {
application: {},
scheduleStateSave: sinon.spy(),
saveState: () => {},
getDefaultChainStore() {
if (!this.defaultChainStore) {
this.defaultChainStore = {
state: {
chainHeight,
lastSyncedHeaderHeight: -1,
},
updateLastSyncedHeaderHeight: sinon.spy(),
updateChainHeight: sinon.spy(),
setBlockHeaders: sinon.spy(),
updateHeadersMetadata: sinon.spy(),
};
}
return this.defaultChainStore;
},
};
return worker;
};
describe('#getStartBlockHeight', () => {
beforeEach(function beforeEach() {
blockHeadersSyncWorker = createBlockHeadersSyncWorker(this.sinon);
});
it('should return block 1', () => {
const startBlockHeight = blockHeadersSyncWorker.getStartBlockHeight();
expect(startBlockHeight).to.equal(1);
});
it('should return bestBlockHeight - N in case `skipSynchronization` option is present', () => {
/**
* Mock options
*/
blockHeadersSyncWorker.maxHeadersToKeep = 2000;
const { storage } = blockHeadersSyncWorker;
storage.getDefaultChainStore().state.chainHeight = 1000;
storage.application.syncOptions = {
skipSynchronization: true,
};
let startBlockHeight = blockHeadersSyncWorker.getStartBlockHeight();
expect(startBlockHeight).to.equal(1);
storage.getDefaultChainStore().state.chainHeight = 3000;
startBlockHeight = blockHeadersSyncWorker.getStartBlockHeight();
expect(startBlockHeight).to.equal(1000);
});
it('should return last synced header height if present', () => {
const { storage } = blockHeadersSyncWorker;
storage.getDefaultChainStore().state.lastSyncedHeaderHeight = 1200;
const startBlockHeight = blockHeadersSyncWorker.getStartBlockHeight();
expect(startBlockHeight).to.equal(1200);
});
it('should return `skipSynchronizationBeforeHeight` value', () => {
const { storage } = blockHeadersSyncWorker;
storage.application.syncOptions = {
skipSynchronizationBeforeHeight: 1300,
};
const startBlockHeight = blockHeadersSyncWorker.getStartBlockHeight();
expect(startBlockHeight).to.equal(1300);
});
it('should return last synced header if it\'s greater than `skipSynchronizationBeforeHeight` value', () => {
const { storage } = blockHeadersSyncWorker;
storage.getDefaultChainStore().state.lastSyncedHeaderHeight = 1300;
storage.application.syncOptions = {
skipSynchronizationBeforeHeight: 1200,
};
const startBlockHeight = blockHeadersSyncWorker.getStartBlockHeight();
expect(startBlockHeight).to.equal(1300);
});
it('should return `skipSynchronizationBeforeHeight` value if it\'s greater than last synced header height', () => {
const { storage } = blockHeadersSyncWorker;
storage.getDefaultChainStore().state.lastSyncedHeaderHeight = 1200;
storage.application.syncOptions = {
skipSynchronizationBeforeHeight: 1300,
};
const startBlockHeight = blockHeadersSyncWorker.getStartBlockHeight();
expect(startBlockHeight).to.equal(1300);
});
});
describe('#onStart', () => {
beforeEach(function beforeEach() {
blockHeadersSyncWorker = createBlockHeadersSyncWorker(this.sinon);
});
it('should kickstart reading of historical headers', async () => {
const startPromise = blockHeadersSyncWorker.onStart();
await waitOneTick();
const { blockHeadersProvider } = blockHeadersSyncWorker.transport.client;
expect(blockHeadersSyncWorker.syncState).to
.equal(BlockHeadersSyncWorker.STATES.HISTORICAL_SYNC);
expect(blockHeadersProvider.on).to
.have.been.calledWith(
BlockHeadersProvider.EVENTS.CHAIN_UPDATED,
blockHeadersSyncWorker.historicalChainUpdateHandler,
);
expect(blockHeadersProvider.on).to
.have.been.calledWith(
BlockHeadersProvider.EVENTS.ERROR,
blockHeadersSyncWorker.blockHeadersProviderErrorHandler,
);
expect(blockHeadersProvider.once).to
.have.been.calledWith(
BlockHeadersProvider.EVENTS.HISTORICAL_DATA_OBTAINED,
blockHeadersSyncWorker.historicalDataObtainedHandler,
);
expect(blockHeadersProvider.once).to
.have.been.calledWith(
BlockHeadersProvider.EVENTS.STOPPED,
blockHeadersSyncWorker.blockHeadersProviderStopHandler,
);
expect(blockHeadersProvider.readHistorical).to.have.been.calledOnceWith(1, chainHeight);
blockHeadersProvider.emit(BlockHeadersProvider.EVENTS.HISTORICAL_DATA_OBTAINED);
await startPromise;
});
it('should prepare for continuous sync after historical data is obtained', async function test() {
const { blockHeadersProvider } = blockHeadersSyncWorker.transport.client;
blockHeadersSyncWorker.updateProgress = this.sinon.spy();
const startPromise = blockHeadersSyncWorker.onStart();
blockHeadersProvider.emit(BlockHeadersProvider.EVENTS.HISTORICAL_DATA_OBTAINED);
await startPromise;
expect(blockHeadersSyncWorker.syncState).to
.equal(BlockHeadersSyncWorker.STATES.IDLE);
expect(blockHeadersProvider.removeListener)
.to.have.been.calledWith(
BlockHeadersProvider.EVENTS.CHAIN_UPDATED,
blockHeadersSyncWorker.historicalChainUpdateHandler,
);
expect(blockHeadersProvider.removeListener)
.to.have.been.calledWith(
BlockHeadersProvider.EVENTS.ERROR,
blockHeadersSyncWorker.blockHeadersProviderErrorHandler,
);
expect(blockHeadersSyncWorker.syncCheckpoint).to.equal(chainHeight);
expect(blockHeadersSyncWorker.updateProgress).to.have.been.calledOnce;
});
it('should throw error if best block height is less than 1', async () => {
const { storage } = blockHeadersSyncWorker;
storage.getDefaultChainStore().state.chainHeight = -1;
await expect(blockHeadersSyncWorker.onStart())
.to.be.rejectedWith('Invalid best block height -1');
});
it('should throw error if start block height is greater than best block height', async () => {
const { storage } = blockHeadersSyncWorker;
storage.getDefaultChainStore().state.lastSyncedHeaderHeight = 2000;
await expect(blockHeadersSyncWorker.onStart())
.to.be.rejectedWith('Start block height 2000 is greater than best block height 1000');
});
});
describe('#execute', () => {
beforeEach(function beforeEach() {
blockHeadersSyncWorker = createBlockHeadersSyncWorker(this.sinon);
});
it('should kickstart continuous sync', async () => {
const { blockHeadersProvider } = blockHeadersSyncWorker.transport.client;
blockHeadersSyncWorker.syncCheckpoint = 1000;
await blockHeadersSyncWorker.execute();
expect(blockHeadersSyncWorker.syncState).to
.equal(BlockHeadersSyncWorker.STATES.CONTINUOUS_SYNC);
expect(blockHeadersProvider.on).to
.have.been.calledWith(
BlockHeadersProvider.EVENTS.CHAIN_UPDATED,
blockHeadersSyncWorker.continuousChainUpdateHandler,
);
expect(blockHeadersProvider.on).to
.have.been.calledWith(
BlockHeadersProvider.EVENTS.ERROR,
blockHeadersSyncWorker.blockHeadersProviderErrorHandler,
);
expect(blockHeadersProvider.startContinuousSync).to
.have.been.calledWith(blockHeadersSyncWorker.syncCheckpoint);
});
it('should forward an error from blockHeadersProvider', async function test() {
blockHeadersSyncWorker.syncCheckpoint = chainHeight;
await blockHeadersSyncWorker.execute();
const errorCallback = this.sinon.spy();
blockHeadersSyncWorker.parentEvents.on('error', errorCallback);
const { blockHeadersProvider } = blockHeadersSyncWorker.transport.client;
const error = new Error('Test error');
blockHeadersProvider.emit('error', error);
expect(errorCallback).to.have.been.calledOnceWith(error);
});
});
describe('#onStop', () => {
beforeEach(function beforeEach() {
blockHeadersSyncWorker = createBlockHeadersSyncWorker(this.sinon);
});
it('should stop historical sync', async () => {
const promise = blockHeadersSyncWorker.onStart();
await waitOneTick();
const { blockHeadersProvider } = blockHeadersSyncWorker.transport.client;
blockHeadersSyncWorker.syncCheckpoint = 4;
await blockHeadersSyncWorker.onStop();
await promise;
expect(blockHeadersProvider.removeListener).to
.have.been.calledWith(
BlockHeadersProvider.EVENTS.CHAIN_UPDATED,
blockHeadersSyncWorker.historicalChainUpdateHandler,
);
expect(blockHeadersProvider.removeListener).to
.have.been.calledWith(
BlockHeadersProvider.EVENTS.ERROR,
blockHeadersSyncWorker.blockHeadersProviderErrorHandler,
);
expect(blockHeadersProvider.removeListener).to
.have.been.calledWith(
BlockHeadersProvider.EVENTS.HISTORICAL_DATA_OBTAINED,
blockHeadersSyncWorker.historicalDataObtainedHandler,
);
expect(blockHeadersSyncWorker.syncState).to.equal(BlockHeadersSyncWorker.STATES.IDLE);
expect(blockHeadersSyncWorker.syncCheckpoint).to.equal(4);
});
it('should stop continuous sync', async () => {
blockHeadersSyncWorker.syncCheckpoint = 1000;
await blockHeadersSyncWorker.execute();
await blockHeadersSyncWorker.onStop();
const { blockHeadersProvider } = blockHeadersSyncWorker.transport.client;
expect(blockHeadersProvider.removeListener).to
.have.been.calledWith(
BlockHeadersProvider.EVENTS.CHAIN_UPDATED,
blockHeadersSyncWorker.continuousChainUpdateHandler,
);
expect(blockHeadersProvider.removeListener).to
.have.been.calledWith(
BlockHeadersProvider.EVENTS.ERROR,
blockHeadersSyncWorker.blockHeadersProviderErrorHandler,
);
expect(blockHeadersSyncWorker.syncState).to.equal(BlockHeadersSyncWorker.STATES.IDLE);
});
it('should continue historical sync from checkpoint after the restart', async () => {
let startPromise = blockHeadersSyncWorker.onStart();
const { blockHeadersProvider } = blockHeadersSyncWorker.transport.client;
await waitOneTick();
await blockHeadersSyncWorker.onStop();
await startPromise;
blockHeadersSyncWorker.syncCheckpoint = 980;
startPromise = blockHeadersSyncWorker.onStart();
expect(blockHeadersProvider.readHistorical).to.have.been.calledTwice;
expect(blockHeadersProvider.readHistorical.secondCall)
.to.have.been.calledWith(980, 1000);
blockHeadersProvider.emit(BlockHeadersProvider.EVENTS.HISTORICAL_DATA_OBTAINED);
await startPromise;
});
it('should continue continuous sync from checkpoint after the restart', async () => {
const { blockHeadersProvider } = blockHeadersSyncWorker.transport.client;
blockHeadersSyncWorker.syncCheckpoint = 1000;
await blockHeadersSyncWorker.execute();
blockHeadersSyncWorker.syncCheckpoint = 1200;
await blockHeadersSyncWorker.onStop();
blockHeadersSyncWorker.storage.getDefaultChainStore().state.chainHeight = 1200;
await blockHeadersSyncWorker.execute();
expect(blockHeadersProvider.startContinuousSync.secondCall)
.to.have.been.calledWith(1200);
});
});
describe('#continuousChainUpdateHandler', () => {
beforeEach(function beforeEach() {
blockHeadersSyncWorker = createBlockHeadersSyncWorker(this.sinon);
});
it('should update chain height with a single header', async () => {
const chainStore = blockHeadersSyncWorker.storage.getDefaultChainStore();
const { blockHeadersProvider: { spvChain } } = blockHeadersSyncWorker.transport.client;
const headers = mockHeadersChain('testnet', 1, spvChainHeaders[spvChainHeaders.length - 1]);
const longestChain = spvChain.getLongestChain();
longestChain.push(headers[0]);
const batchHeadHeight = 1010;
await blockHeadersSyncWorker.continuousChainUpdateHandler(
headers,
1010,
);
expect(chainStore.updateChainHeight)
.to.have.been.calledWith(batchHeadHeight);
expect(chainStore.updateLastSyncedHeaderHeight)
.to.have.been.calledWith(batchHeadHeight);
expect(chainStore.setBlockHeaders)
.to.have.been.calledWith(longestChain.slice(-3));
expect(blockHeadersSyncWorker.storage.scheduleStateSave)
.to.have.been.called;
});
it('should update chain height with an array of headers', async () => {
const chainStore = blockHeadersSyncWorker.storage.getDefaultChainStore();
const batchHeadHeight = 1010;
const headers = mockHeadersChain('testnet', 3, spvChainHeaders[spvChainHeaders.length - 1]);
const {
blockHeadersProvider: {
spvChain,
},
} = blockHeadersSyncWorker.transport.client;
spvChain.addHeaders(headers);
await blockHeadersSyncWorker.continuousChainUpdateHandler(
headers,
1010,
);
const newHeight = batchHeadHeight + headers.length - 1;
expect(chainStore.updateChainHeight)
.to.have.been.calledWith(newHeight);
expect(chainStore.updateLastSyncedHeaderHeight)
.to.have.been.calledWith(newHeight);
expect(chainStore.setBlockHeaders)
.to.have.been.calledWith(spvChain.getLongestChain().slice(-3));
expect(blockHeadersSyncWorker.storage.scheduleStateSave)
.to.have.been.called;
});
it('should do nothing if height hasn\'t changed', async () => {
const chainStore = blockHeadersSyncWorker.storage.getDefaultChainStore();
await blockHeadersSyncWorker.continuousChainUpdateHandler(
mockHeadersChain('testnet', 2),
999,
);
expect(chainStore.updateChainHeight).to.have.not.been.called;
expect(chainStore.updateLastSyncedHeaderHeight).to.have.not.been.called;
expect(chainStore.setBlockHeaders).to.have.not.been.called;
expect(blockHeadersSyncWorker.storage.scheduleStateSave)
.to.have.not.been.called;
});
it('should emit error in case headers array is empty', async () => {
const chainStore = blockHeadersSyncWorker.storage.getDefaultChainStore();
const batchHeadHeight = 1010;
blockHeadersSyncWorker.parentEvents.on('error', () => {});
await blockHeadersSyncWorker.continuousChainUpdateHandler(
[],
batchHeadHeight,
);
const { args } = blockHeadersSyncWorker.parentEvents.emit.firstCall;
expect(args[0]).to.equal('error');
expect(args[1].message)
.to.equal(`No new headers received for batch at height ${batchHeadHeight}`);
expect(chainStore.updateChainHeight).to.have.not.been.called;
expect(chainStore.updateLastSyncedHeaderHeight).to.have.not.been.called;
expect(chainStore.setBlockHeaders).to.have.not.been.called;
expect(blockHeadersSyncWorker.storage.scheduleStateSave)
.to.have.not.been.called;
});
it('should emit error in case new height is less than current height', async () => {
const chainStore = blockHeadersSyncWorker.storage.getDefaultChainStore();
const batchHeadHeight = 900;
blockHeadersSyncWorker.parentEvents.on('error', () => {});
await blockHeadersSyncWorker.continuousChainUpdateHandler(
mockHeadersChain('testnet', 1),
batchHeadHeight,
);
const { args } = blockHeadersSyncWorker.parentEvents.emit.firstCall;
expect(args[0]).to.equal('error');
expect(args[1].message)
.to.equal('New chain height 900 is less than latest height 1000');
expect(chainStore.updateChainHeight).to.have.not.been.called;
expect(chainStore.updateLastSyncedHeaderHeight).to.have.not.been.called;
expect(chainStore.setBlockHeaders).to.have.not.been.called;
expect(blockHeadersSyncWorker.storage.scheduleStateSave)
.to.have.not.been.called;
});
it('should emit BLOCKHEIGHT_CHANGED event', async () => {
const batchHeadHeight = 1020;
await blockHeadersSyncWorker.continuousChainUpdateHandler(
mockHeadersChain('testnet', 1),
batchHeadHeight,
);
expect(blockHeadersSyncWorker.parentEvents.emit)
.to.have.been.calledWith(EVENTS.BLOCKHEIGHT_CHANGED, batchHeadHeight);
});
it('should emit error in case something goes wrong', async function test() {
const error = new Error('Chain store was not found');
blockHeadersSyncWorker.storage.getDefaultChainStore = () => {
throw error;
};
const errorHandler = this.sinon.spy();
blockHeadersSyncWorker.parentEvents.on('error', errorHandler);
await blockHeadersSyncWorker.continuousChainUpdateHandler(
mockHeadersChain('testnet', 1),
1020,
);
expect(errorHandler).to.have.been.calledWith(error);
});
});
describe('#historicalChainUpdateHandler', () => {
beforeEach(function beforeEach() {
blockHeadersSyncWorker = createBlockHeadersSyncWorker(this.sinon);
});
it('should update block headers', () => {
const headers = mockHeadersChain('testnet', 3, spvChainHeaders[spvChainHeaders.length - 1]);
const { blockHeadersProvider: { spvChain } } = blockHeadersSyncWorker.transport.client;
const chainStore = blockHeadersSyncWorker.storage.getDefaultChainStore();
chainStore.state.lastSyncedHeaderHeight = 3;
spvChain.addHeaders(headers);
blockHeadersSyncWorker.historicalChainUpdateHandler();
const longestChain = spvChain.getLongestChain();
const newHeight = longestChain.length - 1;
expect(chainStore.setBlockHeaders)
.to.have.been.calledWith(longestChain.slice(-headers.length));
expect(chainStore.updateLastSyncedHeaderHeight).to.have.been.calledWith(newHeight);
const newHeaders = longestChain.slice(-(longestChain.length - headers.length));
expect(chainStore.updateHeadersMetadata)
.to.have.been.calledWith(newHeaders, newHeight);
expect(blockHeadersSyncWorker.syncCheckpoint)
.to.equal(newHeight);
expect(blockHeadersSyncWorker.storage.scheduleStateSave)
.to.have.been.called;
expect(blockHeadersSyncWorker.scheduleProgressUpdate)
.to.have.been.called;
});
it('should do nothing in case amount of total headers hasn\'t changed', () => {
const chainStore = blockHeadersSyncWorker.storage.getDefaultChainStore();
chainStore.state.lastSyncedHeaderHeight = 4;
blockHeadersSyncWorker.historicalChainUpdateHandler();
expect(chainStore.setBlockHeaders).to.have.not.been.called;
expect(chainStore.updateLastSyncedHeaderHeight).to.have.not.been.called;
expect(chainStore.updateHeadersMetadata).to.have.not.been.called;
expect(blockHeadersSyncWorker.storage.scheduleStateSave)
.to.have.not.been.called;
expect(blockHeadersSyncWorker.scheduleProgressUpdate)
.to.have.been.called;
});
it('should emit error in case syncedHeadersCount is bigger than total headers count', function test() {
const chainStore = blockHeadersSyncWorker.storage.getDefaultChainStore();
chainStore.state.lastSyncedHeaderHeight = 5;
const errorCallback = this.sinon.spy();
blockHeadersSyncWorker.parentEvents.on('error', errorCallback);
blockHeadersSyncWorker.historicalChainUpdateHandler();
const { firstCall } = errorCallback;
expect(firstCall.args[0].message)
.to.equal('Synced headers count 5 is greater than total headers count 4.');
expect(chainStore.setBlockHeaders).to.have.not.been.called;
expect(chainStore.updateLastSyncedHeaderHeight).to.have.not.been.called;
expect(chainStore.updateHeadersMetadata).to.have.not.been.called;
expect(blockHeadersSyncWorker.storage.scheduleStateSave)
.to.have.not.been.called;
expect(blockHeadersSyncWorker.scheduleProgressUpdate)
.to.have.not.been.called;
});
});
describe('#updateProgress', () => {
beforeEach(function beforeEach() {
blockHeadersSyncWorker = createBlockHeadersSyncWorker(this.sinon);
});
it('should emit progress event when chain started from genesis', () => {
blockHeadersSyncWorker.updateProgress();
const { firstCall } = blockHeadersSyncWorker.parentEvents.emit;
expect(firstCall).to.have.been.calledWith(EVENTS.HEADERS_SYNC_PROGRESS, {
confirmedProgress: 0.4,
totalProgress: 0.4,
confirmedSyncedCount: 4,
totalSyncedCount: 4,
totalCount: 1000,
});
});
// TODO(spv): cover cases with the orphan chunks?
});
});