From 266bfdcc6b21dd9e456246c2b65226122ba1fa72 Mon Sep 17 00:00:00 2001 From: pasta Date: Mon, 9 Nov 2026 18:06:47 +0000 Subject: [PATCH 1/4] fix: keep Insight API working after the V24 hard fork (CbTx v4) From V24 activation, every coinbase carries a CbTx v4 payload, which dashcore-lib 0.22 rejects. The block list (/blocks) parses each block's coinbase and failed, and the ZMQ transaction handler logged the parse error and then dereferenced the missing transaction, an uncaught TypeError that stops the explorer at the first post-activation block. Bump @dashevo/dashcore-lib to ^0.26.1, which reads CbTx v4 and the other V24 payloads. A transaction or block the library cannot parse is now skipped or reported as an error instead of crashing the process. Add a functional test that runs Dash Core 24.0.0 on regtest with the Insight indexes, activates V24, and checks the HTTP API on blocks, transactions, spends, address history, balances and UTXOs from both sides of the activation. CI gets a job for it and moves off the retired ubuntu-20.04 runners to Node 20. Version 4.0.9. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/test_and_release.yml | 69 +++- lib/blocks.js | 12 +- lib/index.js | 2 + package-lock.json | 38 +- package.json | 7 +- test/blocks.js | 46 ++- test/data/testnet-v24-activation-blocks.json | 16 + test/functional/v24-activation.js | 398 +++++++++++++++++++ test/index.js | 16 + 9 files changed, 576 insertions(+), 28 deletions(-) create mode 100644 test/data/testnet-v24-activation-blocks.json create mode 100644 test/functional/v24-activation.js diff --git a/.github/workflows/test_and_release.yml b/.github/workflows/test_and_release.yml index 9235b7729..802c318bf 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,58 @@ 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: 6b8d88caf81d8124628c3fea66a03b4c51fbd463 + 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 - name: Check package version matches tag uses: geritol/match-tag-to-package-version@0.1.0 @@ -50,7 +95,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') }} @@ -78,11 +123,11 @@ jobs: 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 @@ -95,7 +140,7 @@ jobs: 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 }} diff --git a/lib/blocks.js b/lib/blocks.js index b4f5a1eee..b1a14e66f 100644 --- a/lib/blocks.js +++ b/lib/blocks.js @@ -253,10 +253,16 @@ 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 header; + var txlength; var info = {}; - var txlength = br.readVarintNum(); - info.transactions = [dashcore.Transaction().fromBufferReader(br)]; + try { + header = dashcore.BlockHeader.fromBufferReader(br); + txlength = br.readVarintNum(); + info.transactions = [dashcore.Transaction().fromBufferReader(br)]; + } catch (parseErr) { + return next(new Error('Failed to parse block ' + hash + ': ' + parseErr.message)); + } self.node.services.dashd.getBlockHeader(hash, function(err, blockHeader) { if (err) { 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..c60dcba72 100644 --- a/test/blocks.js +++ b/test/blocks.js @@ -473,4 +473,48 @@ 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: 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 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) { + err.should.be.instanceof(Error); + err.message.should.startWith('Failed to parse block ' + fixture.hash); + should.not.exist(summary); + 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..a65ea3523 --- /dev/null +++ b/test/functional/v24-activation.js @@ -0,0 +1,398 @@ +'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 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; + + 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); + } + + async 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, coinbase: block.tx[0].txid, cbTxVersion: block.cbTx.version}; + } + + async function waitForInsightTip(hash) { + for (var i = 0; i < 120; i++) { + if (node.services.dashd.tiphash === hash) { + return; + } + await sleep(250); + } + throw new Error('Insight did not reach tip ' + 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() { + 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(); + }); + }); + + // Before activation: fund A. + pre.tx = await rpc('sendtoaddress', addrA, 1.5); + 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 preTx = await rpc('getrawtransaction', pre.tx, true); + var preVout = voutTo(preTx, 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); + + txMempool = await rpc('sendtoaddress', addrA, 0.5); + + await waitForInsightTip(post.block.hash); + }); + + after(async function() { + if (node) { + await new Promise(function(resolve) { + node.stop(function() { + resolve(); + }); + }); + } + if (dashd && dashd.exitCode === 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 res = await api('/blocks?limit=100'); + var heights = res.blocks.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 = res.blocks.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 res = await api('/txs?block=' + post.block.hash); + var txids = res.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('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, + '/rawtx/' + post.spend + ]; + 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 = { From 2e059cd766849ce5cf8a8bf2b9653ea3fe799be7 Mon Sep 17 00:00:00 2001 From: pasta Date: Mon, 9 Nov 2026 18:24:55 +0000 Subject: [PATCH 2/4] fix: keep /block and /blocks up when Core data cannot be parsed GET /block/:hash fetched and parsed the whole previous block just to read its header, and on any error passed undefined as the response to handleErrors, which threw and stopped the process. It now reads the previous block's header and returns errors through the callback. /blocks summarizes each block from its coinbase only for poolInfo, so a coinbase the library cannot parse now leaves poolInfo empty instead of failing the whole list. The functional test now waits for a settled tip, lists blocks by their own UTC date, locks the outputs it spends explicitly, checks /rawtx against Core's bytes for the CbTx v4 coinbase, checks the websocket transaction events, and prints dashd's log on failure. The release jobs move to action versions that still run. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/test_and_release.yml | 24 +++--- lib/blocks.js | 34 ++++---- test/blocks.js | 41 +++++++++- test/functional/v24-activation.js | 107 +++++++++++++++++++++---- 4 files changed, 157 insertions(+), 49 deletions(-) diff --git a/.github/workflows/test_and_release.yml b/.github/workflows/test_and_release.yml index 802c318bf..2e50c9fe5 100644 --- a/.github/workflows/test_and_release.yml +++ b/.github/workflows/test_and_release.yml @@ -43,7 +43,7 @@ jobs: env: DASH_VERSION: 24.0.0 DASH_SHA256: 1ffce395e035fb2ee617c92e6eeaab139ca3d10db6f83d4e816136b726512c5e - DASHCORE_NODE_REF: 6b8d88caf81d8124628c3fea66a03b4c51fbd463 + DASHCORE_NODE_REF: 2027c2d72d3f080591457d137e1780dafc95f304 steps: - uses: actions/checkout@v4 @@ -89,6 +89,10 @@ jobs: steps: - 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 env: @@ -106,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 @@ -116,7 +120,7 @@ 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 }} @@ -130,13 +134,11 @@ jobs: - 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 @@ -148,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 @@ -162,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: | @@ -176,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 b1a14e66f..b1412195d 100644 --- a/lib/blocks.js +++ b/lib/blocks.js @@ -255,14 +255,19 @@ BlockController.prototype._getBlockSummary = function(hash, moreTimestamp, next) // would require changes in dashcore-node. var header; var txlength; - var info = {}; try { header = dashcore.BlockHeader.fromBufferReader(br); txlength = br.readVarintNum(); - info.transactions = [dashcore.Transaction().fromBufferReader(br)]; } 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) { @@ -276,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; @@ -396,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) { @@ -424,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/test/blocks.js b/test/blocks.js index c60dcba72..195b68769 100644 --- a/test/blocks.js +++ b/test/blocks.js @@ -480,7 +480,7 @@ describe('#_getBlockSummary', function() { function controllerFor(rawHex, height) { return new BlockController({ node: { - log: sinon.stub(), + log: {warn: sinon.stub()}, services: { dashd: { height: height + 10, @@ -507,9 +507,9 @@ describe('#_getBlockSummary', function() { }); }); - it('gives an error for a block it cannot parse', function(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, 300), 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); @@ -517,4 +517,39 @@ describe('#_getBlockSummary', function() { 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/functional/v24-activation.js b/test/functional/v24-activation.js index a65ea3523..151c1dce1 100644 --- a/test/functional/v24-activation.js +++ b/test/functional/v24-activation.js @@ -8,6 +8,7 @@ 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'); @@ -58,6 +59,10 @@ describe('Insight API across the V24 activation (CbTx v4)', function() { 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); @@ -101,17 +106,36 @@ describe('Insight API across the V24 activation (CbTx v4)', function() { async function blockAt(height) { var hash = await rpc('getblockhash', height); var block = await rpc('getblock', hash, 2); - return {hash: hash, height: height, coinbase: block.tx[0].txid, cbTxVersion: block.cbTx.version}; + return { + hash: hash, + height: height, + time: block.time, + coinbase: block.tx[0].txid, + cbTxVersion: block.cbTx.version + }; } - async function waitForInsightTip(hash) { - for (var i = 0; i < 120; i++) { - if (node.services.dashd.tiphash === hash) { - return; + // 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(250); + await sleep(100); } - throw new Error('Insight did not reach tip ' + hash); + throw new Error('Insight did not settle on tip ' + height + ' ' + hash); + } + + function utcDate(time) { + return new Date(time * 1000).toISOString().slice(0, 10); } function voutTo(tx, address) { @@ -202,8 +226,24 @@ describe('Insight API across the V24 activation (CbTx v4)', function() { }); }); - // Before activation: fund A. + // 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); @@ -212,8 +252,7 @@ describe('Insight API across the V24 activation (CbTx v4)', function() { // After activation: fund A again and spend A's pre-activation output to B. post.tx = await rpc('sendtoaddress', addrA, 2.25); - var preTx = await rpc('getrawtransaction', pre.tx, true); - var preVout = voutTo(preTx, addrA)[0].n; + 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}]); @@ -222,12 +261,27 @@ describe('Insight API across the V24 activation (CbTx v4)', function() { 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); + await waitForInsightTip(post.block.hash, post.block.height); + }); + + 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() { @@ -235,7 +289,7 @@ describe('Insight API across the V24 activation (CbTx v4)', function() { }); }); } - if (dashd && dashd.exitCode === null) { + if (dashd && dashd.exitCode === null && dashd.signalCode === null) { var exited = new Promise(function(resolve) { dashd.once('exit', resolve); }); @@ -276,7 +330,16 @@ describe('Insight API across the V24 activation (CbTx v4)', function() { }); it('GET /blocks lists blocks from both sides of the activation', async function() { - var res = await api('/blocks?limit=100'); + var dates = [pre.block, post.block].map(function(b) { + return utcDate(b.time); + }).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 res = {blocks: listed}; var heights = res.blocks.map(function(b) { return b.height; }); @@ -379,6 +442,21 @@ describe('Insight API across the V24 activation (CbTx v4)', function() { 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() { + [pre.tx, pre.block.coinbase, post.activation.coinbase, post.tx, post.spend, post.block.coinbase, txMempool].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', @@ -388,8 +466,7 @@ describe('Insight API across the V24 activation (CbTx v4)', function() { '/gobject/info', '/gobject/count', '/gobject/list', - '/governance/budget/' + post.block.height, - '/rawtx/' + post.spend + '/governance/budget/' + post.block.height ]; for (var i = 0; i < routes.length; i++) { await api(routes[i]); From aa50e456565e6c06d9f6f0c18b3072ee97c2b5b6 Mon Sep 17 00:00:00 2001 From: pasta Date: Mon, 9 Nov 2026 18:36:04 +0000 Subject: [PATCH 3/4] test: wait for websocket events and keep dashd's log when setup fails Co-Authored-By: Claude Opus 5.5 --- test/functional/v24-activation.js | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/test/functional/v24-activation.js b/test/functional/v24-activation.js index 151c1dce1..baae3f125 100644 --- a/test/functional/v24-activation.js +++ b/test/functional/v24-activation.js @@ -146,6 +146,15 @@ describe('Insight API across the V24 activation (CbTx v4)', function() { } 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'); } @@ -266,7 +275,15 @@ describe('Insight API across the V24 activation (CbTx v4)', function() { 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') { @@ -451,7 +468,7 @@ describe('Insight API across the V24 activation (CbTx v4)', function() { }); it('pushes post-activation transactions to websocket subscribers without parse errors', function() { - [pre.tx, pre.block.coinbase, post.activation.coinbase, post.tx, post.spend, post.block.coinbase, txMempool].forEach(function(txid) { + post.inv.forEach(function(txid) { assert.ok(invTxids.indexOf(txid) !== -1, txid + ' pushed'); }); assert.deepStrictEqual(badTxLogs, []); From 54885523793de1bbcf93890a09eb157504448176 Mon Sep 17 00:00:00 2001 From: pasta Date: Mon, 9 Nov 2026 18:38:24 +0000 Subject: [PATCH 4/4] test: simplify the V24 functional test Co-Authored-By: Claude Opus 5.5 --- test/functional/v24-activation.js | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/test/functional/v24-activation.js b/test/functional/v24-activation.js index baae3f125..39686985f 100644 --- a/test/functional/v24-activation.js +++ b/test/functional/v24-activation.js @@ -92,7 +92,7 @@ describe('Insight API across the V24 activation (CbTx v4)', function() { return JSON.parse(text); } - async function mine(count) { + function mine(count) { return rpc('generatetoaddress', count, miner); } @@ -134,10 +134,6 @@ describe('Insight API across the V24 activation (CbTx v4)', function() { throw new Error('Insight did not settle on tip ' + height + ' ' + hash); } - function utcDate(time) { - return new Date(time * 1000).toISOString().slice(0, 10); - } - function voutTo(tx, address) { return tx.vout.filter(function(out) { var spk = out.scriptPubKey; @@ -348,7 +344,7 @@ describe('Insight API across the V24 activation (CbTx v4)', function() { it('GET /blocks lists blocks from both sides of the activation', async function() { var dates = [pre.block, post.block].map(function(b) { - return utcDate(b.time); + return new Date(b.time * 1000).toISOString().slice(0, 10); }).filter(function(date, i, all) { return all.indexOf(date) === i; }); @@ -356,14 +352,13 @@ describe('Insight API across the V24 activation (CbTx v4)', function() { for (var i = 0; i < dates.length; i++) { listed = listed.concat((await api('/blocks?limit=200&blockDate=' + dates[i])).blocks); } - var res = {blocks: listed}; - var heights = res.blocks.map(function(b) { + 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 = res.blocks.find(function(b) { + var summary = listed.find(function(b) { return b.height === post.block.height; }); assert.strictEqual(summary.hash, post.block.hash); @@ -409,8 +404,7 @@ describe('Insight API across the V24 activation (CbTx v4)', function() { }); it('GET /txs?block= lists a post-activation block', async function() { - var res = await api('/txs?block=' + post.block.hash); - var txids = res.txs.map(function(tx) { + 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());