diff --git a/.github/workflows/test_and_release.yml b/.github/workflows/test_and_release.yml index 9235b7729..2e50c9fe5 100644 --- a/.github/workflows/test_and_release.yml +++ b/.github/workflows/test_and_release.yml @@ -13,17 +13,17 @@ on: jobs: test: name: Run Insight API tests - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - - uses: actions/setup-node@v2 + - uses: actions/setup-node@v4 with: - node-version: '16' + node-version: '20' - name: Enable NPM cache - uses: actions/cache@v2 + uses: actions/cache@v4 with: path: '~/.npm' key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} @@ -36,13 +36,62 @@ jobs: - name: Run tests run: npm run test + functional: + name: Run Insight API functional tests + runs-on: ubuntu-24.04 + timeout-minutes: 15 + env: + DASH_VERSION: 24.0.0 + DASH_SHA256: 1ffce395e035fb2ee617c92e6eeaab139ca3d10db6f83d4e816136b726512c5e + DASHCORE_NODE_REF: 2027c2d72d3f080591457d137e1780dafc95f304 + steps: + - uses: actions/checkout@v4 + + - uses: actions/checkout@v4 + with: + repository: dashpay/dashcore-node + ref: ${{ env.DASHCORE_NODE_REF }} + path: dashcore-node + + - uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Enable NPM cache + uses: actions/cache@v4 + with: + path: '~/.npm' + key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node- + + - name: Install NPM dependencies + run: | + npm ci + npm ci --prefix dashcore-node + echo "DASHCORE_NODE_PATH=$PWD/dashcore-node" >> "$GITHUB_ENV" + + - name: Download Dash Core + run: | + curl -fsSL -o dashcore.tar.gz "https://github.com/dashpay/dash/releases/download/v${DASH_VERSION}/dashcore-${DASH_VERSION}-x86_64-linux-gnu.tar.gz" + echo "${DASH_SHA256} dashcore.tar.gz" | sha256sum -c - + tar xzf dashcore.tar.gz + echo "DASHD_PATH=$PWD/dashcore-${DASH_VERSION}/bin/dashd" >> "$GITHUB_ENV" + + - name: Run functional tests + run: npm run test:functional + release-npm: name: Release NPM package - runs-on: ubuntu-20.04 - needs: test + runs-on: ubuntu-24.04 + needs: [test, functional] if: ${{ github.event_name == 'release' }} steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '20' - name: Check package version matches tag uses: geritol/match-tag-to-package-version@0.1.0 @@ -50,7 +99,7 @@ jobs: TAG_PREFIX: refs/tags/v - name: Enable NPM cache - uses: actions/cache@v2 + uses: actions/cache@v4 with: path: '~/.npm' key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} @@ -61,7 +110,7 @@ jobs: run: npm ci - name: Set release tag - uses: actions/github-script@v3 + uses: actions/github-script@v7 id: tag with: result-encoding: string @@ -71,31 +120,29 @@ jobs: return (tag.includes('dev') ? `${major}.${minor}-dev` : 'latest'); - name: Publish NPM package - uses: JS-DevTools/npm-publish@v1 + uses: JS-DevTools/npm-publish@v3 with: token: ${{ secrets.NPM_TOKEN }} tag: ${{ steps.tag.outputs.result }} release-docker: name: Release Docker image - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 needs: release-npm if: ${{ github.event_name == 'release' }} steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up QEMU - uses: docker/setup-qemu-action@v1 + uses: docker/setup-qemu-action@v3 - name: Set up Docker BuildX - uses: docker/setup-buildx-action@v1 + uses: docker/setup-buildx-action@v3 with: - version: v0.7.0 - install: true driver-opts: image=moby/buildkit:buildx-stable-1 - name: Cache Docker layers - uses: actions/cache@v2 + uses: actions/cache@v4 with: path: /tmp/.buildx-cache key: ${{ runner.os }}-buildx-${{ github.sha }} @@ -103,13 +150,13 @@ jobs: ${{ runner.os }}-buildx- - name: Login to DockerHub - uses: docker/login-action@v1 + uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Set suffix to Docker tags - uses: actions/github-script@v3 + uses: actions/github-script@v7 id: suffix with: result-encoding: string @@ -117,7 +164,7 @@ jobs: - name: Set Docker tags and labels id: docker_meta - uses: docker/metadata-action@v3 + uses: docker/metadata-action@v5 with: images: dashpay/insight-api tags: | @@ -131,7 +178,7 @@ jobs: - name: Build and push Docker image id: docker_build - uses: docker/build-push-action@v2 + uses: docker/build-push-action@v6 with: context: docker file: docker/Dockerfile diff --git a/lib/blocks.js b/lib/blocks.js index b4f5a1eee..b1412195d 100644 --- a/lib/blocks.js +++ b/lib/blocks.js @@ -253,10 +253,21 @@ BlockController.prototype._getBlockSummary = function(hash, moreTimestamp, next) // them all back together to get the binary size of the block. // FIXME: This code might still read the whole block. Fixing that // would require changes in dashcore-node. - var header = dashcore.BlockHeader.fromBufferReader(br); - var info = {}; - var txlength = br.readVarintNum(); - info.transactions = [dashcore.Transaction().fromBufferReader(br)]; + var header; + var txlength; + try { + header = dashcore.BlockHeader.fromBufferReader(br); + txlength = br.readVarintNum(); + } catch (parseErr) { + return next(new Error('Failed to parse block ' + hash + ': ' + parseErr.message)); + } + // The coinbase only feeds poolInfo, so a payload the library cannot read must not hide the block. + var poolInfo = {}; + try { + poolInfo = self.getPoolInfo({transactions: [dashcore.Transaction().fromBufferReader(br)]}); + } catch (parseErr) { + self.node.log.warn('Failed to parse the coinbase of block ' + hash + ': ' + parseErr.message); + } self.node.services.dashd.getBlockHeader(hash, function(err, blockHeader) { if (err) { @@ -270,7 +281,7 @@ BlockController.prototype._getBlockSummary = function(hash, moreTimestamp, next) hash: hash, time: header.time, txlength: txlength, - poolInfo: self.getPoolInfo(info) + poolInfo: poolInfo }; var confirmations = self.node.services.dashd.height - height + 1; @@ -390,24 +401,11 @@ BlockController.prototype.formatTimestamp = function(date) { * Previous blockHeader is used to determine block reward */ BlockController.prototype.getPreviousBlock = function(prevHash, cb) { - var self = this; //On genesis block, the previousHash will be null. - if(prevHash===null) cb(null,null); - else { - self.node.getBlock(prevHash, function (err, block) { - if ((err && err.code === -5) || (err && err.code === -8)) { - return self.common.handleErrors(null, block); - } else if (err) { - return self.common.handleErrors(err, block); - } - self.node.services.dashd.getBlockHeader(prevHash, function (err, info) { - if (err) { - return self.common.handleErrors(err, info); - } - cb(null, info); // return blockHeader - }); - }); + if (prevHash === null) { + return cb(null, null); } + this.node.services.dashd.getBlockHeader(prevHash, cb); }; BlockController.prototype.getBlockReward = function(prevHash, cb) { @@ -418,7 +416,9 @@ BlockController.prototype.getBlockReward = function(prevHash, cb) { // block reward is based on the previous block diff / height self.getPreviousBlock(prevHash, function(err, info) { - if(err) cb(err, null); + if (err) { + return cb(err, null); + } //if we seek for prevHash of genesis, previousBlock return an info being null. if(info===null){ //Genesis reward is 50. diff --git a/lib/index.js b/lib/index.js index 57672c0c4..82eafe096 100644 --- a/lib/index.js +++ b/lib/index.js @@ -312,6 +312,7 @@ InsightAPI.prototype.transactionEventHandler = function(txBuffer) { } catch(error) { console.log(`${error}\nbad txBuffer: ${txBuffer.toString('hex')}`); + return; } var result = this.txController.transformInvTransaction(tx); @@ -325,6 +326,7 @@ InsightAPI.prototype.transactionLockEventHandler = function(txBuffer) { } catch(error) { console.log(`${error}\nbad txBuffer: ${txBuffer.toString('hex')}`); + return; } var result = this.txController.transformInvTransaction(tx, true); diff --git a/package-lock.json b/package-lock.json index 72ab7c809..49ba3b7af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,15 @@ { "name": "@dashevo/insight-api", - "version": "4.0.8", + "version": "4.0.9", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@dashevo/insight-api", - "version": "4.0.8", + "version": "4.0.9", "license": "MIT", "dependencies": { - "@dashevo/dashcore-lib": "^0.22.0", + "@dashevo/dashcore-lib": "^0.26.1", "async": "^2.6.2", "body-parser": "^1.19.0", "compression": "^1.7.4", @@ -126,12 +126,14 @@ } }, "node_modules/@dashevo/dashcore-lib": { - "version": "0.22.0", - "resolved": "https://registry.npmjs.org/@dashevo/dashcore-lib/-/dashcore-lib-0.22.0.tgz", - "integrity": "sha512-9zvTcr8eFE+aeA/SVN5jsDgB/Kg6ozmsOY4u3gJdFJ6kQSU+yVyFNsflaWhHwRakZ7CVlU88P7AuDKneukp3zQ==", + "version": "0.26.1", + "resolved": "https://registry.npmjs.org/@dashevo/dashcore-lib/-/dashcore-lib-0.26.1.tgz", + "integrity": "sha512-lJxRhSUaCSuFbgw7EQd02KlgFjDepfQ3M9ydEtX1lV674556lyMZw5xccXHkRLvEdXDuW/QwOQpmLDHOGRUIHw==", + "license": "MIT", "dependencies": { "@dashevo/bls": "~1.2.9", "@dashevo/x11-hash-js": "^1.0.2", + "@noble/hashes": "^1.8.0", "@types/node": "^12.12.47", "bloom-filter": "^0.2.0", "bn.js": "^4.12.0", @@ -160,6 +162,18 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -3383,12 +3397,13 @@ } }, "@dashevo/dashcore-lib": { - "version": "0.22.0", - "resolved": "https://registry.npmjs.org/@dashevo/dashcore-lib/-/dashcore-lib-0.22.0.tgz", - "integrity": "sha512-9zvTcr8eFE+aeA/SVN5jsDgB/Kg6ozmsOY4u3gJdFJ6kQSU+yVyFNsflaWhHwRakZ7CVlU88P7AuDKneukp3zQ==", + "version": "0.26.1", + "resolved": "https://registry.npmjs.org/@dashevo/dashcore-lib/-/dashcore-lib-0.26.1.tgz", + "integrity": "sha512-lJxRhSUaCSuFbgw7EQd02KlgFjDepfQ3M9ydEtX1lV674556lyMZw5xccXHkRLvEdXDuW/QwOQpmLDHOGRUIHw==", "requires": { "@dashevo/bls": "~1.2.9", "@dashevo/x11-hash-js": "^1.0.2", + "@noble/hashes": "^1.8.0", "@types/node": "^12.12.47", "bloom-filter": "^0.2.0", "bn.js": "^4.12.0", @@ -3414,6 +3429,11 @@ "@sinclair/typebox": "^0.27.8" } }, + "@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==" + }, "@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", diff --git a/package.json b/package.json index 3a88897ee..01cce2380 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@dashevo/insight-api", "description": "A Dash blockchain REST and WebSocket API Service", - "version": "4.0.8", + "version": "4.0.9", "repository": "git://github.com/dashevo/insight-api.git", "contributors": [ { @@ -56,11 +56,12 @@ "node": ">=6" }, "scripts": { - "test": "NODE_ENV=test mocha -R spec --recursive" + "test": "NODE_ENV=test mocha -R spec --recursive --exclude \"test/functional/**\"", + "test:functional": "NODE_ENV=test mocha -R spec --exit test/functional" }, "main": "lib/index.js", "dependencies": { - "@dashevo/dashcore-lib": "^0.22.0", + "@dashevo/dashcore-lib": "^0.26.1", "async": "^2.6.2", "body-parser": "^1.19.0", "compression": "^1.7.4", diff --git a/test/blocks.js b/test/blocks.js index acf85ba89..195b68769 100644 --- a/test/blocks.js +++ b/test/blocks.js @@ -473,4 +473,83 @@ describe('#getHeaders', function(){ blocks.blockHeaders(req, res); }); }); -}); \ No newline at end of file +}); +describe('#_getBlockSummary', function() { + var v24ActivationBlocks = require('./data/testnet-v24-activation-blocks.json'); + + function controllerFor(rawHex, height) { + return new BlockController({ + node: { + log: {warn: sinon.stub()}, + services: { + dashd: { + height: height + 10, + getRawBlock: sinon.stub().callsArgWith(1, null, Buffer.from(rawHex, 'hex')), + getBlockHeader: sinon.stub().callsArgWith(1, null, {height: height}) + } + } + } + }); + } + + [1577999, 1578000].forEach(function(height) { + var fixture = v24ActivationBlocks[height]; + it('summarizes testnet block ' + height + ' with a CbTx v' + fixture.cbTxVersion + ' coinbase', function(done) { + var blocks = controllerFor(fixture.raw, height); + blocks._getBlockSummary(fixture.hash, Infinity, function(err, summary) { + should.not.exist(err); + summary.height.should.equal(height); + summary.hash.should.equal(fixture.hash); + summary.size.should.equal(fixture.raw.length / 2); + summary.txlength.should.equal(dashcore.Block.fromString(fixture.raw).transactions.length); + done(); + }); + }); + }); + + it('gives an error for a block whose header it cannot parse', function(done) { + var fixture = v24ActivationBlocks[1578000]; + var blocks = controllerFor(fixture.raw.slice(0, 100), 1578000); + blocks._getBlockSummary(fixture.hash, Infinity, function(err, summary) { + err.should.be.instanceof(Error); + err.message.should.startWith('Failed to parse block ' + fixture.hash); + should.not.exist(summary); + done(); + }); + }); + + it('still summarizes a block whose coinbase it cannot parse', function(done) { + var fixture = v24ActivationBlocks[1578000]; + var blocks = controllerFor(fixture.raw.slice(0, 300), 1578000); + blocks._getBlockSummary(fixture.hash, Infinity, function(err, summary) { + should.not.exist(err); + summary.hash.should.equal(fixture.hash); + summary.txlength.should.equal(3); + summary.poolInfo.should.eql({}); + blocks.node.log.warn.callCount.should.equal(1); + done(); + }); + }); +}); + +describe('#getBlockReward', function() { + it('passes a header lookup error to the callback once', function(done) { + var blocks = new BlockController({ + node: { + log: sinon.stub(), + services: { + dashd: { + getBlockHeader: sinon.stub().callsArgWith(1, new Error('not found')) + } + } + } + }); + var cb = sinon.spy(); + blocks.getBlockReward('00000000000000000000000000000000000000000000000000000000000000aa', cb); + setImmediate(function() { + cb.callCount.should.equal(1); + cb.args[0][0].message.should.equal('not found'); + done(); + }); + }); +}); diff --git a/test/data/testnet-v24-activation-blocks.json b/test/data/testnet-v24-activation-blocks.json new file mode 100644 index 000000000..67ce1e1a5 --- /dev/null +++ b/test/data/testnet-v24-activation-blocks.json @@ -0,0 +1,16 @@ +{ + "1577999": { + "hash": "0000000d4ae5bbcc2ebdf5de1502b0ac3a82931e5b890cb2ad912028f56d9f33", + "coinbaseTxid": "cc81dd366a965a0fb7d82293b7f0fb8da20ebded047242e94cb919d5d19ff6ed", + "cbTxVersion": 3, + "coinbaseRaw": "03000500010000000000000000000000000000000000000000000000000000000000000000ffffffff016affffffff03dc4c8c03000000001976a914b489115851ca07a26a5ad8bac3cec3c7dbebd83188ac2ed5fd0300000000016a6611a706000000001976a91464f2b2b84f62d68a2cd7f7f5fb2b5aa75ef716d788ac00000000af03000f141800862e14d3f60bd6dd69c31c7b8701dc06a1a9afd4468c036e1d21ea7085763a1c8b4c9add4f6d4802fd5bcf953c9a8adc35b33acd2e38fcb56d6004fe827bf16a0090d6a3e263e616bd4fc9aa6112287b0b7e70a6ecea934f2f2b639743fb6928096a412864f346e3fbcbbf53a92b5aec441346b7c8000740b6a33f43d19e0a56e6c9cd1ec2b71e77d1327f881f4f491102176f7876b6abb49b45c96833032f266b60afe4403c220000", + "raw": "002000206edd6634548acec8bf39e1a58b64e2a48bb41340bb04c4fe5a1d2c5c6a00000045dbb4bfbfa40d915adea189edb76c3749690142b26755458901c6eb824ef3d8fc85db6a50f9001e2fd402000603000500010000000000000000000000000000000000000000000000000000000000000000ffffffff016affffffff03dc4c8c03000000001976a914b489115851ca07a26a5ad8bac3cec3c7dbebd83188ac2ed5fd0300000000016a6611a706000000001976a91464f2b2b84f62d68a2cd7f7f5fb2b5aa75ef716d788ac00000000af03000f141800862e14d3f60bd6dd69c31c7b8701dc06a1a9afd4468c036e1d21ea7085763a1c8b4c9add4f6d4802fd5bcf953c9a8adc35b33acd2e38fcb56d6004fe827bf16a0090d6a3e263e616bd4fc9aa6112287b0b7e70a6ecea934f2f2b639743fb6928096a412864f346e3fbcbbf53a92b5aec441346b7c8000740b6a33f43d19e0a56e6c9cd1ec2b71e77d1327f881f4f491102176f7876b6abb49b45c96833032f266b60afe4403c22000003000000010a4f88d9b0736011961ae371aef836f55697faae482f205d029d832ff8bac488010000006a4730440220237c03aa7b5bdc8917765fcdc762f5f1e9a7913c8968eb7af79ef323a64f5424022033fdc1ac85af4e686911a08f724e49ec86dda21385dff8eabde72c03a07d19f9012103a69ef9640ec7145997ee7bb32c2c9b43f9e20dfade303165b9e7d777bdf16786ffffffff0230c11d00000000001976a914cc1951424e6284a526ccd4dc171790ea210671d688ac2bc18176000000001976a9148a681b6788d77bbceb022516d3bb52a29792c5f188ac00000000030008000112a154da30cea8cddc5ee3fec46e85b402475978f1df528c483b1c03e455978e000000006a473044022016271c6dc6b886cb09e36dfc0660178d94f45a099684047628184608555500ea02203a8dbe1a484f18089d46709e1dc90daa97ecedb69ec45631602d24afe30ea8a401210270401daab92c1bf0a2dd71baa60ac064dab9b744e3329013348f4ae836d25915ffffffff02f0b31a0000000000026a00390c0300000000001976a914b89852218cced8effe2e26aa949e33acff41ac1f88ac00000000240101f0b31a00000000001976a914e8900e12d6705f176d09c1aa2b681143579fee8688ac030000000112a154da30cea8cddc5ee3fec46e85b402475978f1df528c483b1c03e455978e010000006946304302203f6b243bb2e8b4a6ca0a674a9212cb03a564955a87a2b14bf55931d4513546c9021f5d68a0894e13af92e3ffcdc7c30c65294f35a0610449e96ade30d7c71997f801210229f09e0684c215709575215999c90fd2df50306c5515c823252a6ae076684dd4ffffffff02f0b31a00000000001976a914e6e574a2817e42f0c31de1464b239fd3da8f775988ac590c6776000000001976a9141788007d5e3ab79e7b49500a30fc64db5556872488ac000000000300080001f9ab6fb2d8b7a9002f03aa25c85e2c0c31ded0c7d8491a040a33a5c1f77f7c69000000006a47304402201dc2b58cad41317fd3e4a3948275fde31f1243360584bf096e98f6b6fa33dc4802205f708a0e7cc98ad63b64bcc068c4e9c47766c9eab1c772dc777bdb0c606be356012102fb881a84620dc456a7459986efa64a2cafc663d6518373b5bde1605586c9b06dffffffff010bb31a0000000000026a00000000002401010bb31a00000000001976a914ec1cb7dd2fbb112fd75eb65e3efe23d81152ccb188ac0300000001749d3cccf7de89e8aa98806bf36f9e45bae111b8df07be857a8bd671f79dda3f010000006a47304402201d701d87cfee99774c4ac56d442c867c01938f80fb01a559e0ede570e08caeed02205857e610f4ddce34941f8b7b084df74cb886a6810496cda9877d5c55a179b894012102f8f9eeaaa61462b233f388bf8edb5c49226395d57ad91462843ed1a7df77a02fffffffff022e260000000000001976a9149207ba68aff3b27d4322ed1d47192bbcb9d3224088ac29e50200000000001976a9140b2f733319674af34c3896f49e4f4d76d1cbcc0a88ac00000000" + }, + "1578000": { + "hash": "0000009c0b6cbd13862a8049d0a117a4210ae6eeb90d8022cd2276e875fe639c", + "coinbaseTxid": "ab22dcb7082a94cbf8c11091dc3f4ac3aea5aee150177137791da2b33108bacc", + "cbTxVersion": 4, + "coinbaseRaw": "03000500010000000000000000000000000000000000000000000000000000000000000000ffffffff016affffffff03b4558c03000000001976a914b489115851ca07a26a5ad8bac3cec3c7dbebd83188ac2ed5fd0300000000016aed2ba706000000001976a91464f2b2b84f62d68a2cd7f7f5fb2b5aa75ef716d788ac00000000cf040010141800862e14d3f60bd6dd69c31c7b8701dc06a1a9afd4468c036e1d21ea7085763a1c8b4c9add4f6d4802fd5bcf953c9a8adc35b33acd2e38fcb56d6004fe827bf16a00b1b7dae9158b2650024f2fba75c75f1fc4fe202468b620aab445e8732dd3384ead3cf3c7fcdfee23b21d35087de5ce2414f209c0cb5b1c02ebd31d62117a7b10f393e6545d9419cd475b79f46a1526bd9c1a331ff0e0cc3fa328490e86cc019b8e84e2443c2200000000000000000000000000000000000000000000000000000000000000000000", + "raw": "00000020339f6df5282091adb20c895b1e93823aacb00215def5bd2eccbbe54a0d00000032bc5666abc05aa6141581091e46240526824609401d40ac124fcc2e9d04a367ef86db6a75fe001e0a1a0d000303000500010000000000000000000000000000000000000000000000000000000000000000ffffffff016affffffff03b4558c03000000001976a914b489115851ca07a26a5ad8bac3cec3c7dbebd83188ac2ed5fd0300000000016aed2ba706000000001976a91464f2b2b84f62d68a2cd7f7f5fb2b5aa75ef716d788ac00000000cf040010141800862e14d3f60bd6dd69c31c7b8701dc06a1a9afd4468c036e1d21ea7085763a1c8b4c9add4f6d4802fd5bcf953c9a8adc35b33acd2e38fcb56d6004fe827bf16a00b1b7dae9158b2650024f2fba75c75f1fc4fe202468b620aab445e8732dd3384ead3cf3c7fcdfee23b21d35087de5ce2414f209c0cb5b1c02ebd31d62117a7b10f393e6545d9419cd475b79f46a1526bd9c1a331ff0e0cc3fa328490e86cc019b8e84e2443c22000000000000000000000000000000000000000000000000000000000000000000000200000001745c730fbc115c70cc3bf8d333e478b1897c7e1544beee29379a9aa61754f94c000000006a4730440220374ce3e9525f95c2e908523ea0b41c486de618761bdbba54bd778dedb1b24a7302205771ab60c08af627737f13499f6c5bc543f040ca8cd5e7546de5ec63aa2ad097012102c6093b07bc24eef2bcf0d4de05f3ae466539a48e87c3950209ae56af3c279bf3ffffffff01204e0000000000001976a914ada715d631aa849539120efd8f01a41176c2a2f888ac000000000200000001b31a99a4623b46255e4a3afb08ee1557e361c627c7a451378d96318be26d74a4010000006a47304402206c73b711fb88a18fc79d3cdcdbcc420caf5f98843ac61e7fa333a9de1325f043022057b3f4174584a6d6a7889fe3f35d1ffef60bafe8ba839d50a1387f421dffd63901210233814da63da1efdcfb9f556c5d07a725b6deeeb3020b4827ce4fcded31395ac0feffffff02c0c62d00000000001976a914b639b4137669ba2cdc3db3a501a0e82d50cbeefc88ac1f083d00000000001976a9140d597b5e85146459a9959425876f4d8dd60bd2f788ac0f141800" + } +} diff --git a/test/functional/v24-activation.js b/test/functional/v24-activation.js new file mode 100644 index 000000000..39686985f --- /dev/null +++ b/test/functional/v24-activation.js @@ -0,0 +1,486 @@ +'use strict'; + +// Runs Dash Core (DASHD_PATH) on regtest with the indexes Insight needs, +// activates V24 by miner signalling, and checks the Insight API, served by the +// dashcore-node checkout at DASHCORE_NODE_PATH, on blocks and transactions from +// both sides of the activation: coinbases carry CbTx v3 before it and CbTx v4 +// from the activation block on. + +var assert = require('assert'); +var childProcess = require('child_process'); +var EventEmitter = require('events').EventEmitter; +var fs = require('fs'); +var net = require('net'); +var os = require('os'); +var path = require('path'); + +var InsightAPI = require('../../lib'); + +var RPC_USER = 'insight'; +var RPC_PASSWORD = 'insight'; +// DIP3 and v20 activate at height 432 on regtest, so blocks 432-479 carry CbTx v3. +var V24_HEIGHT = 480; + +function freePort() { + return new Promise(function(resolve, reject) { + var server = net.createServer(); + server.unref(); + server.on('error', reject); + server.listen(0, '127.0.0.1', function() { + var port = server.address().port; + server.close(function() { + resolve(port); + }); + }); + }); +} + +function sleep(ms) { + return new Promise(function(resolve) { + setTimeout(resolve, ms); + }); +} + +describe('Insight API across the V24 activation (CbTx v4)', function() { + this.timeout(300000); + + var dashdPath = process.env.DASHD_PATH; + var dashcoreNodePath = process.env.DASHCORE_NODE_PATH; + var DashcoreNode; + var datadir; + var dashd; + var node; + var rpcPort; + var apiUrl; + + var miner; + var addrA; + var addrB; + var pre = {}; + var post = {}; + var txMempool; + var invTxids = []; + var badTxLogs = []; + var consoleLog = console.log; + var failed = false; + + function rpc(method) { + var params = Array.prototype.slice.call(arguments, 1); + return fetch('http://127.0.0.1:' + rpcPort + '/wallet/w', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Basic ' + Buffer.from(RPC_USER + ':' + RPC_PASSWORD).toString('base64') + }, + body: JSON.stringify({jsonrpc: '1.0', id: method, method: method, params: params}) + }).then(function(res) { + return res.json(); + }).then(function(body) { + if (body.error) { + var err = new Error(method + ': ' + body.error.message); + err.code = body.error.code; + throw err; + } + return body.result; + }); + } + + async function api(route, expectedStatus) { + var res = await fetch(apiUrl + route); + var text = await res.text(); + assert.strictEqual(res.status, expectedStatus || 200, route + ' -> ' + res.status + ': ' + text); + return JSON.parse(text); + } + + function mine(count) { + return rpc('generatetoaddress', count, miner); + } + + async function mineTo(height) { + var count = await rpc('getblockcount'); + if (height > count) { + await mine(height - count); + } + } + + async function blockAt(height) { + var hash = await rpc('getblockhash', height); + var block = await rpc('getblock', hash, 2); + return { + hash: hash, + height: height, + time: block.time, + coinbase: block.tx[0].txid, + cbTxVersion: block.cbTx.version + }; + } + + // dashcore-node defers tip updates that arrive within a second of each + // other, so wait until the tip has settled. + async function waitForInsightTip(hash, height) { + var since = null; + for (var i = 0; i < 300; i++) { + var service = node.services.dashd; + if (service.tiphash === hash && service.height === height) { + since = since || Date.now(); + if (Date.now() - since > 1500) { + return; + } + } else { + since = null; + } + await sleep(100); + } + throw new Error('Insight did not settle on tip ' + height + ' ' + hash); + } + + function voutTo(tx, address) { + return tx.vout.filter(function(out) { + var spk = out.scriptPubKey; + return spk.address === address || (spk.addresses || []).indexOf(address) !== -1; + }); + } + + before(async function() { + try { + await setup(); + } catch (e) { + failed = true; + throw e; + } + }); + + async function setup() { + if (!dashdPath || !dashcoreNodePath) { + throw new Error('Set DASHD_PATH to a Dash Core v24 dashd binary and DASHCORE_NODE_PATH to a dashcore-node checkout'); + } + DashcoreNode = require(path.resolve(dashcoreNodePath)); + datadir = fs.mkdtempSync(path.join(os.tmpdir(), 'insight-v24-')); + rpcPort = await freePort(); + var zmqUrl = 'tcp://127.0.0.1:' + (await freePort()); + var httpPort = await freePort(); + apiUrl = 'http://127.0.0.1:' + httpPort + '/insight-api'; + + dashd = childProcess.spawn(dashdPath, [ + '-regtest', + '-datadir=' + datadir, + '-txindex=1', + '-addressindex=1', + '-spentindex=1', + '-timestampindex=1', + '-server=1', + '-listen=0', + '-dnsseed=0', + '-fallbackfee=0.00001', + '-rpcuser=' + RPC_USER, + '-rpcpassword=' + RPC_PASSWORD, + '-rpcport=' + rpcPort, + '-rpcbind=127.0.0.1', + '-rpcallowip=127.0.0.1', + '-zmqpubrawtx=' + zmqUrl, + '-zmqpubrawtxlock=' + zmqUrl, + '-zmqpubhashblock=' + zmqUrl, + '-vbparams=v24:0:999999999999:' + V24_HEIGHT + ':10:8:6:5:0' + ], {stdio: 'ignore'}); + + for (var i = 0; ; i++) { + try { + await rpc('createwallet', 'w'); + break; + } catch (e) { + if (i > 120) { + throw e; + } + await sleep(500); + } + } + + miner = await rpc('getnewaddress'); + addrA = await rpc('getnewaddress'); + addrB = await rpc('getnewaddress'); + await mineTo(440); + + node = new DashcoreNode.Node({ + network: 'regtest', + port: httpPort, + services: [{ + name: 'dashd', + module: DashcoreNode.services.Dash, + config: { + connect: [{ + rpchost: '127.0.0.1', + rpcport: rpcPort, + rpcuser: RPC_USER, + rpcpassword: RPC_PASSWORD, + zmqpubrawtx: zmqUrl + }] + } + }, { + name: 'web', + module: DashcoreNode.services.Web, + config: {port: httpPort} + }, { + name: '@dashevo/insight-api', + module: InsightAPI, + config: {disableRateLimiter: true} + }] + }); + await new Promise(function(resolve, reject) { + node.start(function(err) { + return err ? reject(err) : resolve(); + }); + }); + + // Record what Insight pushes to its websocket subscribers, and any + // transaction it fails to parse. + var inv = new EventEmitter(); + inv.on('tx', function(tx) { + invTxids.push(tx.txid); + }); + node.services['@dashevo/insight-api'].subscribe(inv); + console.log = function(message) { + if (String(message).indexOf('bad txBuffer') !== -1) { + badTxLogs.push(message); + } + return consoleLog.apply(console, arguments); + }; + + // Before activation: fund A, and keep the wallet from spending that output. + pre.tx = await rpc('sendtoaddress', addrA, 1.5); + var preVout = voutTo(await rpc('getrawtransaction', pre.tx, true), addrA)[0].n; + await rpc('lockunspent', false, [{txid: pre.tx, vout: preVout}]); + await mine(1); + pre.block = await blockAt(441); + + await mineTo(V24_HEIGHT); + post.activation = await blockAt(V24_HEIGHT); + + // After activation: fund A again and spend A's pre-activation output to B. + post.tx = await rpc('sendtoaddress', addrA, 2.25); + var postVout = voutTo(await rpc('getrawtransaction', post.tx, true), addrA)[0].n; + var raw = await rpc('createrawtransaction', + [{txid: pre.tx, vout: preVout}], + [{[addrB]: 1.4999}]); + var signed = await rpc('signrawtransactionwithwallet', raw); + post.spend = await rpc('sendrawtransaction', signed.hex); + await mine(1); + post.block = await blockAt(V24_HEIGHT + 1); + + // Fund the mempool transaction from the miner, not from A or B. + await rpc('lockunspent', false, [{txid: post.tx, vout: postVout}, {txid: post.spend, vout: 0}]); + txMempool = await rpc('sendtoaddress', addrA, 0.5); + + await waitForInsightTip(post.block.hash, post.block.height); + + // Websocket events come over the separate ZMQ rawtx path. + post.inv = [pre.tx, pre.block.coinbase, post.activation.coinbase, post.tx, post.spend, post.block.coinbase, txMempool]; + for (var n = 0; n < 100 && !post.inv.every(function(txid) { + return invTxids.indexOf(txid) !== -1; + }); n++) { + await sleep(100); + } + } + + afterEach(function() { + if (this.currentTest.state === 'failed') { + failed = true; + } + }); + + after(async function() { + console.log = consoleLog; + if (failed && datadir) { + var debugLog = path.join(datadir, 'regtest', 'debug.log'); + if (fs.existsSync(debugLog)) { + console.error('--- tail of dashd debug.log ---\n' + fs.readFileSync(debugLog, 'utf8').split('\n').slice(-200).join('\n')); + } + } + if (node) { + await new Promise(function(resolve) { + node.stop(function() { + resolve(); + }); + }); + } + if (dashd && dashd.exitCode === null && dashd.signalCode === null) { + var exited = new Promise(function(resolve) { + dashd.once('exit', resolve); + }); + dashd.kill('SIGTERM'); + await exited; + } + if (datadir) { + fs.rmSync(datadir, {recursive: true, force: true}); + } + }); + + it('dashd activated V24 and switched the coinbase payload from CbTx v3 to v4', async function() { + var info = await rpc('getdeploymentinfo'); + assert.strictEqual(info.deployments.v24.active, true); + assert.strictEqual(info.deployments.v24.height, V24_HEIGHT); + assert.strictEqual(pre.block.cbTxVersion, 3); + assert.strictEqual((await blockAt(V24_HEIGHT - 1)).cbTxVersion, 3); + assert.strictEqual(post.activation.cbTxVersion, 4); + assert.strictEqual(post.block.cbTxVersion, 4); + }); + + it('GET /block/:hash before and after activation', async function() { + var blocks = [ + [pre.block, [pre.tx]], + [post.activation, []], + [post.block, [post.tx, post.spend]] + ]; + for (var i = 0; i < blocks.length; i++) { + var expected = blocks[i][0]; + var block = await api('/block/' + expected.hash); + assert.strictEqual(block.hash, expected.hash); + assert.strictEqual(block.height, expected.height); + assert.strictEqual(block.tx[0], expected.coinbase); + blocks[i][1].forEach(function(txid) { + assert.ok(block.tx.indexOf(txid) !== -1, txid + ' in block ' + expected.height); + }); + } + }); + + it('GET /blocks lists blocks from both sides of the activation', async function() { + var dates = [pre.block, post.block].map(function(b) { + return new Date(b.time * 1000).toISOString().slice(0, 10); + }).filter(function(date, i, all) { + return all.indexOf(date) === i; + }); + var listed = []; + for (var i = 0; i < dates.length; i++) { + listed = listed.concat((await api('/blocks?limit=200&blockDate=' + dates[i])).blocks); + } + var heights = listed.map(function(b) { + return b.height; + }); + [pre.block.height, V24_HEIGHT - 1, V24_HEIGHT, post.block.height].forEach(function(height) { + assert.ok(heights.indexOf(height) !== -1, 'height ' + height + ' listed'); + }); + var summary = listed.find(function(b) { + return b.height === post.block.height; + }); + assert.strictEqual(summary.hash, post.block.hash); + assert.strictEqual(summary.txlength, 3); + }); + + it('GET /block-index/:height, /rawblock/:hash and /block-headers across the boundary', async function() { + assert.strictEqual((await api('/block-index/' + V24_HEIGHT)).blockHash, post.activation.hash); + var raw = await api('/rawblock/' + post.activation.hash); + assert.strictEqual(raw.rawblock, await rpc('getblock', post.activation.hash, 0)); + var headers = await api('/block-headers/' + pre.block.hash + '/50'); + var hashes = headers.headers.map(function(h) { + return h.hash; + }); + assert.ok(hashes.indexOf(post.activation.hash) !== -1); + }); + + it('GET /tx/:txid shows the CbTx v3 and v4 coinbases', async function() { + var cbPre = await api('/tx/' + pre.block.coinbase); + assert.strictEqual(cbPre.type, 5); + assert.strictEqual(cbPre.cbTx.version, 3); + assert.strictEqual(cbPre.blockheight, pre.block.height); + var cbPost = await api('/tx/' + post.block.coinbase); + assert.strictEqual(cbPost.type, 5); + assert.strictEqual(cbPost.cbTx.version, 4); + assert.strictEqual(cbPost.blockheight, post.block.height); + assert.ok(cbPost.isCoinBase); + }); + + it('GET /tx/:txid shows spends across the boundary', async function() { + var preTx = await api('/tx/' + pre.tx); + assert.strictEqual(preTx.blockheight, pre.block.height); + var outA = voutTo(preTx, addrA)[0]; + assert.strictEqual(outA.value, '1.50000000'); + assert.strictEqual(outA.spentTxId, post.spend); + assert.strictEqual(outA.spentHeight, post.block.height); + + var spend = await api('/tx/' + post.spend); + assert.strictEqual(spend.blockheight, post.block.height); + assert.strictEqual(spend.vin[0].txid, pre.tx); + assert.strictEqual(spend.vin[0].addr, addrA); + assert.strictEqual(spend.fees, 0.0001); + }); + + it('GET /txs?block= lists a post-activation block', async function() { + var txids = (await api('/txs?block=' + post.block.hash)).txs.map(function(tx) { + return tx.txid; + }); + assert.deepStrictEqual(txids.slice().sort(), [post.block.coinbase, post.tx, post.spend].sort()); + }); + + it('GET /addr/:addr reports history and balances from both sides', async function() { + var a = await api('/addr/' + addrA); + assert.strictEqual(a.totalReceived, 3.75); + assert.strictEqual(a.totalSent, 1.5); + assert.strictEqual(a.balance, 2.25); + assert.strictEqual(a.unconfirmedBalance, 0.5); + assert.strictEqual(a.txAppearances, 3); + assert.strictEqual(a.unconfirmedAppearances, 1); + assert.deepStrictEqual(a.transactions.slice().sort(), [pre.tx, post.tx, post.spend, txMempool].sort()); + + var b = await api('/addr/' + addrB); + assert.strictEqual(b.balance, 1.4999); + assert.strictEqual(b.txAppearances, 1); + }); + + it('GET /addr/:addr/utxo returns the unspent outputs', async function() { + var utxos = await api('/addr/' + addrA + '/utxo'); + var byTx = {}; + utxos.forEach(function(u) { + byTx[u.txid] = u; + }); + assert.strictEqual(utxos.length, 2); + assert.strictEqual(byTx[post.tx].height, post.block.height); + assert.strictEqual(byTx[post.tx].amount, 2.25); + assert.strictEqual(byTx[txMempool].amount, 0.5); + assert.ok(!byTx[pre.tx], 'spent pre-activation output is not listed'); + }); + + it('GET /addrs/:addrs/txs pages history for several addresses', async function() { + var res = await api('/addrs/' + addrA + ',' + addrB + '/txs'); + assert.strictEqual(res.totalItems, 4); + }); + + it('GET /status and /sync report the post-activation tip', async function() { + var status = await api('/status'); + assert.strictEqual(status.info.blocks, post.block.height); + assert.strictEqual(status.info.version, (await rpc('getnetworkinfo')).version); + assert.strictEqual((await api('/status?q=getBestBlockHash')).bestblockhash, post.block.hash); + var sync = await api('/sync'); + assert.strictEqual(sync.status, 'finished'); + assert.strictEqual(sync.height, post.block.height); + }); + + it('GET /rawtx/:txid returns Core\'s bytes for the CbTx v4 coinbase and a spend', async function() { + var txids = [post.block.coinbase, post.spend]; + for (var i = 0; i < txids.length; i++) { + var raw = await api('/rawtx/' + txids[i]); + assert.strictEqual(raw.rawtx, await rpc('getrawtransaction', txids[i], false)); + } + }); + + it('pushes post-activation transactions to websocket subscribers without parse errors', function() { + post.inv.forEach(function(txid) { + assert.ok(invTxids.indexOf(txid) !== -1, txid + ' pushed'); + }); + assert.deepStrictEqual(badTxLogs, []); + }); + + it('other Core-backed endpoints still answer on v24', async function() { + var routes = [ + '/status?q=getDifficulty', + '/peer', + '/version', + '/sporks', + '/gobject/info', + '/gobject/count', + '/gobject/list', + '/governance/budget/' + post.block.height + ]; + for (var i = 0; i < routes.length; i++) { + await api(routes[i]); + } + }); +}); diff --git a/test/index.js b/test/index.js index 964fe6e50..225928fa7 100644 --- a/test/index.js +++ b/test/index.js @@ -24,6 +24,22 @@ describe('Index', function() { index.disableRateLimiter.should.equal(true); }); }); + describe('#transactionEventHandler', function() { + it('will skip a transaction it cannot parse', function() { + var index = new InsightAPI({node: {}}); + var emitter = {emit: sinon.stub()}; + index.subscriptions.inv.push(emitter); + var log = sinon.stub(console, 'log'); + try { + index.transactionEventHandler(Buffer.from('03000500ff', 'hex')); + index.transactionLockEventHandler(Buffer.from('03000500ff', 'hex')); + } finally { + log.restore(); + } + emitter.emit.callCount.should.equal(0); + log.callCount.should.equal(2); + }); + }); describe('#_getRateLimiter', function() { it('will pass options to rate limiter', function() { var options = {