From e1d489fadeae185ac9b8e6202c1d4532f99ad10b Mon Sep 17 00:00:00 2001 From: Nodari Chkuaselidze Date: Fri, 13 May 2022 14:37:01 +0400 Subject: [PATCH] test: add SPVNode sync test. Port of https://github.com/bcoin-org/bcoin/pull/755 Co-authored-by: Matthew Zipkin --- test/node-spv-sync-test.js | 289 +++++++++++++++++++++++++++++++++++++ test/util/common.js | 99 ++++++++++++- test/wallet-http-test.js | 2 +- 3 files changed, 382 insertions(+), 8 deletions(-) create mode 100644 test/node-spv-sync-test.js diff --git a/test/node-spv-sync-test.js b/test/node-spv-sync-test.js new file mode 100644 index 0000000000..e6d60f9897 --- /dev/null +++ b/test/node-spv-sync-test.js @@ -0,0 +1,289 @@ +'use strict'; + +const assert = require('bsert'); +const FullNode = require('../lib/node/fullnode'); +const SPVNode = require('../lib/node/spvnode'); +const {forEvent} = require('./util/common'); + +const ports = { + full: { + p2p: 14041, + node: 14042, + wallet: 14043, + nsPort: 25449, + rsPort: 25450 + }, + spv: { + p2p: 14141, + node: 14142, + wallet: 14143, + nsPort: 25549, + rsPort: 25550 + } +}; + +const node = new FullNode({ + network: 'regtest', + workers: true, + listen: true, + bip37: true, + port: ports.full.p2p, + httpPort: ports.full.node, + nsPort: ports.full.nsPort, + rsPort: ports.full.rsPort, + maxOutbound: 1, + seeds: [], + memory: true, + plugins: [require('../lib/wallet/plugin')], + env: { + 'HSD_WALLET_HTTP_PORT': (ports.full.wallet).toString() + } +}); + +const spvnode = new SPVNode({ + network: 'regtest', + workers: true, + listen: true, + port: ports.spv.p2p, + httpPort: ports.spv.node, + nsPort: ports.spv.nsPort, + rsPort: ports.spv.rsPort, + maxOutbound: 1, + seeds: [], + nodes: [`127.0.0.1:${ports.full.p2p}`], + memory: true, + plugins: [require('../lib/wallet/plugin')], + env: { + 'HSD_WALLET_HTTP_PORT': (ports.spv.wallet).toString() + } +}); + +const chain = node.chain; +const miner = node.miner; +const {wdb: fullwdb} = node.require('walletdb'); +const {wdb: spvwdb} = spvnode.require('walletdb'); + +// Test reorg size must be lower than this. +// This is used to set temporary coinbase maturity, +// so reorged coinbases don't end up in the coin selector +// for Full node wallet. +const REORG_MAX = 15; + +let wallet = null; +let spvwallet = null; +let spvaddr = null; +let tip1 = null; +let tip2 = null; + +async function mineBlock(tip) { + const job = await miner.createJob(tip); + return await job.mineAsync(); +} + +describe('SPV Node Sync', function() { + this.timeout(10000); + // back up + const coinbaseMaturity = node.network.coinbaseMaturity; + + if (process.browser) + this.skip(); + + before(async () => { + await node.open(); + await spvnode.open(); + await node.connect(); + await spvnode.connect(); + spvnode.startSync(); + node.network.coinbaseMaturity = REORG_MAX + 1; + }); + + after(async () => { + await node.close(); + await spvnode.close(); + node.network.coinbaseMaturity = coinbaseMaturity; + }); + + it('should check SPV is synced to fullnode', async () => { + assert.deepStrictEqual(node.chain.tip, spvnode.chain.tip); + }); + + it('should open miner and wallets', async () => { + wallet = await fullwdb.create(); + miner.addresses.length = 0; + miner.addAddress(await wallet.receiveAddress()); + + spvwallet = await spvwdb.create(); + spvaddr = await spvwallet.receiveAddress(); + }); + + it('should mine 90 blocks', async () => { + for (let i = 0; i < 90; i++) { + const block = await miner.mineBlock(); + assert(block); + + const spvBlockEvent = forEvent(spvnode, 'block'); + + await chain.add(block); + + // Check SPV & Full nodes are in sync + await spvBlockEvent; + + assert.deepStrictEqual(node.chain.tip, spvnode.chain.tip); + } + // Full node wallet needs to catch up to miner + await fullwdb.rescan(0); + }); + + it('should mine competing chains of 10 blocks', async function () { + for (let i = 0; i < 10; i++) { + const block1 = await mineBlock(tip1); + const block2 = await mineBlock(tip2); + + const spvBlockEvent = forEvent(spvnode, 'block'); + await chain.add(block1); + await chain.add(block2); + + assert.bufferEqual(chain.tip.hash, block1.hash()); + + tip1 = await chain.getEntry(block1.hash()); + tip2 = await chain.getEntry(block2.hash()); + + assert(tip1); + assert(tip2); + + assert(!await chain.isMainChain(tip2)); + + // Check SPV & Full nodes are in sync after every block + await spvBlockEvent; + + assert.deepStrictEqual(node.chain.tip, spvnode.chain.tip); + } + }); + + it('should send a tx from chain 1 to SPV node', async () => { + const balanceEvent = forEvent(spvwallet, 'balance'); + await wallet.send({ + outputs: [{ + value: 1012345678, + address: spvaddr + }] + }); + + await balanceEvent; + const balance = await spvwallet.getBalance(); + assert.strictEqual(balance.unconfirmed, 1012345678); + }); + + it('should mine a block and confirm a tx', async () => { + const blockEvent = forEvent(spvnode, 'block'); + const balanceEvent = forEvent(spvwallet, 'balance'); + + const block = await miner.mineBlock(); + assert(block); + await chain.add(block); + + // Check SPV & Full nodes are in sync + await blockEvent; + assert.deepStrictEqual(node.chain.tip, spvnode.chain.tip); + + // Check SPV wallet balance + await balanceEvent; + const balance = await spvwallet.getBalance(); + assert.strictEqual(balance.confirmed, 1012345678); + }); + + it('should handle a reorg', async () => { + assert.strictEqual(chain.height, 101); + + // Main chain is ahead by 1 block now, catch the alt chain up + const entry = await chain.getEntry(tip2.hash); + const block1 = await miner.mineBlock(entry); + await chain.add(block1); + const entry1 = await chain.getEntry(block1.hash()); + assert(entry1); + + // Tie game! + assert.strictEqual(chain.height, entry1.height); + + // Now reorg main chain by adding a block to alt chain + const block2 = await miner.mineBlock(entry1); + assert(block2); + + const spvReorgedEvent = forEvent(spvnode, 'reorganize'); + const spvResetEvent = forEvent(spvnode, 'reset'); + let spvBlockEvents; + + let forked = false; + let tipHash, competitorHash, forkHash; + + chain.once('reorganize', (tip, competitor, fork) => { + // We will need to wait for competitor.height - fork.height blocks. + spvBlockEvents = forEvent( + spvnode, + 'block', + competitor.height - fork.height + ); + + tipHash = tip.hash; + competitorHash = competitor.hash; + forkHash = fork.hash; + + forked = true; + }); + + await chain.add(block2); + + assert(forked); + assert.bufferEqual(chain.tip.hash, block2.hash()); + assert(chain.tip.chainwork.gt(tip1.chainwork)); + + // Wait for all events. + const [reorgs, resets, blocks] = await Promise.all([ + spvReorgedEvent, + spvResetEvent, + spvBlockEvents + ]); + + { + const [tip, competitor, fork] = reorgs[0].values; + assert.bufferEqual(tip.hash, tipHash); + assert.bufferEqual(competitor.hash, competitorHash); + assert.bufferEqual(fork.hash, forkHash); + } + + { + const [resetToEntry] = resets[0].values; + assert.bufferEqual(resetToEntry.hash, forkHash); + } + + { + const lastBlockHash = blocks.pop().values[0].hash(); + assert.bufferEqual(lastBlockHash, node.chain.tip.hash); + } + + assert.deepStrictEqual(node.chain.tip, spvnode.chain.tip); + }); + + it('should mine a block after a reorg', async () => { + const blockEvent = forEvent(spvnode, 'block'); + const block = await miner.mineBlock(node.chain.tip); + await chain.add(block); + + // Check SPV & Full nodes are in sync + await blockEvent; + + assert.deepStrictEqual(node.chain.tip, spvnode.chain.tip); + + const entry = await chain.getEntry(block.hash()); + assert(entry); + assert.bufferEqual(chain.tip.hash, entry.hash); + + const result = await chain.isMainChain(entry); + assert(result); + }); + + it('should unconfirm tx after reorg', async () => { + const balance = await spvwallet.getBalance(); + assert.strictEqual(balance.unconfirmed, 1012345678); + }); +}); diff --git a/test/util/common.js b/test/util/common.js index 26406d09ac..c2a6e3f062 100644 --- a/test/util/common.js +++ b/test/util/common.js @@ -103,13 +103,7 @@ common.rimraf = async function(p) { return await fs.rimraf(p); }; -common.event = async function event(obj, name) { - return new Promise((resolve) => { - obj.once(name, resolve); - }); -}; - -common.forValue = async function(obj, key, val, timeout = 30000) { +common.forValue = async function forValue(obj, key, val, timeout = 5000) { assert(typeof obj === 'object'); assert(typeof key === 'string'); @@ -131,6 +125,97 @@ common.forValue = async function(obj, key, val, timeout = 30000) { }); }; +common.forEvent = async function forEvent(obj, name, count = 1, timeout = 5000) { + assert(typeof obj === 'object'); + assert(typeof name === 'string'); + assert(typeof count === 'number'); + assert(typeof timeout === 'number'); + + let countdown = count; + const events = []; + + return new Promise((resolve, reject) => { + let timeoutHandler, listener; + + const cleanup = function cleanup() { + clearTimeout(timeoutHandler); + obj.removeListener(name, listener); + }; + + listener = function listener(...args) { + events.push({ + event: name, + values: [...args] + }); + + countdown--; + if (countdown === 0) { + cleanup(); + resolve(events); + return; + } + }; + + timeoutHandler = setTimeout(() => { + cleanup(); + const msg = `Timeout waiting for event ${name} ` + + `(received ${count - countdown}/${count})`; + + reject(new Error(msg)); + return; + }, timeout); + + obj.on(name, listener); + }); +}; + +common.forEventCondition = async function forEventCondition(obj, name, fn, timeout = 5000) { + assert(typeof obj === 'object'); + assert(typeof name === 'string'); + assert(typeof fn === 'function'); + assert(typeof timeout === 'number'); + + return new Promise((resolve, reject) => { + let timeoutHandler, listener; + + const cleanup = function cleanup() { + clearTimeout(timeoutHandler); + obj.removeListener(name, listener); + }; + + listener = async function listener(...args) { + let res = null; + + try { + res = await fn(...args); + } catch (e) { + cleanup(); + reject(e); + return; + } + + if (res) { + cleanup(); + resolve([...args]); + } + }; + + timeoutHandler = setTimeout(() => { + cleanup(); + const msg = `Timeout waiting for event ${name} with condition`; + reject(new Error(msg)); + return; + }, timeout); + + obj.on(name, listener); + }); +}; + +common.sleep = async function sleep(ms) { + assert(typeof ms === 'number'); + return new Promise(r => setTimeout(r, ms)); +}; + common.enableLogger = () => { Logger.global.set({ level: 'debug', diff --git a/test/wallet-http-test.js b/test/wallet-http-test.js index a9f77e5a47..ee9a17aab2 100644 --- a/test/wallet-http-test.js +++ b/test/wallet-http-test.js @@ -280,7 +280,7 @@ describe('Wallet HTTP', function() { }); // wait for tx event on mempool - await common.event(node.mempool, 'tx'); + await common.forEvent(node.mempool, 'tx'); const mempool = await nclient.getMempool();