From 88817d4dce8a058f4782fa7c434830ffadf128f6 Mon Sep 17 00:00:00 2001 From: pasta Date: Sun, 30 Aug 2026 00:44:15 +0200 Subject: [PATCH 1/7] fix(dapi-client): stop rewriting explicit loopback addresses on regtest The regtest localhost workaround rewrote EVERY live address to 127.0.0.1:2443+i*100 (the stock local gateway ports), including addresses the caller configured explicitly. A local network that moves its ports (the dashmate e2e suites do, to run next to other networks) had every request silently redirected to whatever squats the stock ports on the machine - on a shared dev box, a completely different network. Only rewrite addresses that carry a non-loopback (docker-internal) host, which is the case the workaround exists for. Co-Authored-By: Claude Fable 5 --- .../ListDAPIAddressProvider.js | 10 ++++++++- .../ListDAPIAddressProvider.spec.js | 22 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/packages/js-dapi-client/lib/dapiAddressProvider/ListDAPIAddressProvider.js b/packages/js-dapi-client/lib/dapiAddressProvider/ListDAPIAddressProvider.js index c13217e98d4..c6aa3979974 100644 --- a/packages/js-dapi-client/lib/dapiAddressProvider/ListDAPIAddressProvider.js +++ b/packages/js-dapi-client/lib/dapiAddressProvider/ListDAPIAddressProvider.js @@ -31,8 +31,16 @@ class ListDAPIAddressProvider { // This is a temporary fix for a localhost masternode. // On macOS, internal docker IP is used to register masternode, and it's // not really possible to bind to that address, so that workaround is introduced. + // + // Only addresses carrying such an unreachable docker-internal host are + // rewritten. An explicitly configured loopback address already names the + // exact gateway to talk to — dashmate e2e suites move the stock ports on + // purpose — and clobbering it with the stock local ports silently + // redirects every request to whichever network squats those ports on the + // machine. const network = networks.get(this.options.network); - if (network && network.regtestEnabled) { + const isLoopback = ['127.0.0.1', 'localhost'].includes(liveAddress.getHost()); + if (network && network.regtestEnabled && !isLoopback) { const randomNodeIndex = Math.floor(Math.random() * liveAddresses.length); liveAddress.protocol = 'https'; diff --git a/packages/js-dapi-client/test/unit/dapiAddressProvider/ListDAPIAddressProvider.spec.js b/packages/js-dapi-client/test/unit/dapiAddressProvider/ListDAPIAddressProvider.spec.js index 8b887a4ad78..28d55334152 100644 --- a/packages/js-dapi-client/test/unit/dapiAddressProvider/ListDAPIAddressProvider.spec.js +++ b/packages/js-dapi-client/test/unit/dapiAddressProvider/ListDAPIAddressProvider.spec.js @@ -116,6 +116,28 @@ describe('ListDAPIAddressProvider', () => { expect(liveAddress.protocol).to.equal('https'); expect(liveAddress.allowSelfSignedCertificate).to.be.true(); }); + + it('should not modify an explicitly configured loopback address', async () => { + options = { + network: 'local', + }; + + // A local network that moved its ports off the stock 2443 range + // (dashmate e2e suites do) is addressed explicitly; rewriting the port + // would redirect every request to whatever squats the stock ports. + const loopbackAddress = new DAPIAddress('127.0.0.1:45003:self-signed'); + + listDAPIAddressProvider = new ListDAPIAddressProvider( + [loopbackAddress], + options, + ); + + const liveAddress = await listDAPIAddressProvider.getLiveAddress(); + + expect(liveAddress.host).to.equal('127.0.0.1'); + expect(liveAddress.port).to.equal(45003); + expect(liveAddress.allowSelfSignedCertificate).to.be.true(); + }); }); describe('#hasLiveAddresses', () => { From c8e41879e03d644609622903b80efea27688b74e Mon Sep 17 00:00:00 2001 From: pasta Date: Sun, 30 Aug 2026 23:26:08 +0200 Subject: [PATCH 2/7] fix(dapi-client): only rewrite masternode-list addresses on regtest The regtest docker-IP workaround exempted only the literal hosts 127.0.0.1 and localhost, so any other caller-supplied address (127.0.0.2, a LAN IP, a container hostname) was still clobbered to the stock local gateway ports. Gate the rewrite on address provenance instead: only addresses discovered from the masternode list (they carry a proRegTxHash) can hold an unreachable docker-internal host, so only those are rewritten. Adds a factory-level regression test for a caller-supplied non-default regtest address. Co-Authored-By: Claude Fable 5 --- .../ListDAPIAddressProvider.js | 18 ++++++---- .../ListDAPIAddressProvider.spec.js | 34 +++++++++++++++++-- ...eateDAPIAddressProviderFromOptions.spec.js | 11 ++++++ 3 files changed, 54 insertions(+), 9 deletions(-) diff --git a/packages/js-dapi-client/lib/dapiAddressProvider/ListDAPIAddressProvider.js b/packages/js-dapi-client/lib/dapiAddressProvider/ListDAPIAddressProvider.js index c6aa3979974..50bd5811b60 100644 --- a/packages/js-dapi-client/lib/dapiAddressProvider/ListDAPIAddressProvider.js +++ b/packages/js-dapi-client/lib/dapiAddressProvider/ListDAPIAddressProvider.js @@ -32,15 +32,19 @@ class ListDAPIAddressProvider { // On macOS, internal docker IP is used to register masternode, and it's // not really possible to bind to that address, so that workaround is introduced. // - // Only addresses carrying such an unreachable docker-internal host are - // rewritten. An explicitly configured loopback address already names the - // exact gateway to talk to — dashmate e2e suites move the stock ports on - // purpose — and clobbering it with the stock local ports silently - // redirects every request to whichever network squats those ports on the - // machine. + // Only addresses discovered from the masternode list (they carry the + // masternode's proRegTxHash) can hold such an unreachable docker-internal + // host, so only those are rewritten, and only when the host is not + // already a reachable loopback. A caller-supplied address — a moved-port + // loopback, a secondary loopback like 127.0.0.2, a LAN IP, or a container + // hostname — already names the exact gateway to talk to (dashmate e2e + // suites move the stock ports on purpose), and clobbering it with the + // stock local ports silently redirects every request to whichever network + // squats those ports on the machine. const network = networks.get(this.options.network); const isLoopback = ['127.0.0.1', 'localhost'].includes(liveAddress.getHost()); - if (network && network.regtestEnabled && !isLoopback) { + const isFromMasternodeList = Boolean(liveAddress.getProRegTxHash()); + if (network && network.regtestEnabled && isFromMasternodeList && !isLoopback) { const randomNodeIndex = Math.floor(Math.random() * liveAddresses.length); liveAddress.protocol = 'https'; diff --git a/packages/js-dapi-client/test/unit/dapiAddressProvider/ListDAPIAddressProvider.spec.js b/packages/js-dapi-client/test/unit/dapiAddressProvider/ListDAPIAddressProvider.spec.js index 28d55334152..1843cab5082 100644 --- a/packages/js-dapi-client/test/unit/dapiAddressProvider/ListDAPIAddressProvider.spec.js +++ b/packages/js-dapi-client/test/unit/dapiAddressProvider/ListDAPIAddressProvider.spec.js @@ -100,13 +100,21 @@ describe('ListDAPIAddressProvider', () => { expect(address).to.be.undefined(); }); - it('should return modified address for localhost node', async () => { + it('should return modified address for a masternode-list node on localhost network', async () => { options = { network: 'local', }; + // Addresses discovered from the masternode list carry the masternode's + // proRegTxHash and may hold a docker-internal IP that cannot be reached + // from the host (macOS), so they are rewritten to the local gateway. + const discoveredAddress = new DAPIAddress({ + host: '172.16.0.2', + proRegTxHash: 'a'.repeat(64), + }); + listDAPIAddressProvider = new ListDAPIAddressProvider( - addresses, + [discoveredAddress], options, ); @@ -117,6 +125,28 @@ describe('ListDAPIAddressProvider', () => { expect(liveAddress.allowSelfSignedCertificate).to.be.true(); }); + it('should not modify a caller-supplied non-loopback address', async () => { + options = { + network: 'local', + }; + + // A caller-supplied address (no proRegTxHash — it did not come from the + // masternode list) names the exact gateway to talk to, even when the + // host is a secondary loopback, LAN IP, or container hostname. + const explicitAddress = new DAPIAddress('127.0.0.2:45003:self-signed'); + + listDAPIAddressProvider = new ListDAPIAddressProvider( + [explicitAddress], + options, + ); + + const liveAddress = await listDAPIAddressProvider.getLiveAddress(); + + expect(liveAddress.host).to.equal('127.0.0.2'); + expect(liveAddress.port).to.equal(45003); + expect(liveAddress.allowSelfSignedCertificate).to.be.true(); + }); + it('should not modify an explicitly configured loopback address', async () => { options = { network: 'local', diff --git a/packages/js-dapi-client/test/unit/dapiAddressProvider/createDAPIAddressProviderFromOptions.spec.js b/packages/js-dapi-client/test/unit/dapiAddressProvider/createDAPIAddressProviderFromOptions.spec.js index 52710eec1bb..a4ea52b3dde 100644 --- a/packages/js-dapi-client/test/unit/dapiAddressProvider/createDAPIAddressProviderFromOptions.spec.js +++ b/packages/js-dapi-client/test/unit/dapiAddressProvider/createDAPIAddressProviderFromOptions.spec.js @@ -81,6 +81,17 @@ describe('createDAPIAddressProviderFromOptions', () => { expect(result).to.be.an.instanceOf(ListDAPIAddressProvider); }); + it('should not rewrite a caller-supplied non-default regtest address', async () => { + options.dapiAddresses = ['127.0.0.2:45003:self-signed']; + + const provider = createDAPIAddressProviderFromOptions(options); + + const liveAddress = await provider.getLiveAddress(); + + expect(liveAddress.getHost()).to.equal('127.0.0.2'); + expect(liveAddress.getPort()).to.equal(45003); + }); + it('should throw DAPIClientError if `seeds` option is passed too', async () => { options.seeds = ['127.0.0.1']; From 2555798b74469dc785790b1d9486ea5be3f6ebbf Mon Sep 17 00:00:00 2001 From: pasta Date: Sun, 4 Oct 2026 22:32:12 -0500 Subject: [PATCH 3/7] fix(dapi-client): gate the regtest rewrite on provenance alone The loopback exemption kept a masternode-list entry that already names 127.0.0.1 from being rewritten, so it lost the self-signed flag the local gateway needs. Whether an address came from the masternode list is what decides the rewrite; the host string does not. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../ListDAPIAddressProvider.js | 16 ++++++------ .../ListDAPIAddressProvider.spec.js | 25 +++++++++++++++++++ 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/packages/js-dapi-client/lib/dapiAddressProvider/ListDAPIAddressProvider.js b/packages/js-dapi-client/lib/dapiAddressProvider/ListDAPIAddressProvider.js index 50bd5811b60..3375e99509c 100644 --- a/packages/js-dapi-client/lib/dapiAddressProvider/ListDAPIAddressProvider.js +++ b/packages/js-dapi-client/lib/dapiAddressProvider/ListDAPIAddressProvider.js @@ -34,17 +34,15 @@ class ListDAPIAddressProvider { // // Only addresses discovered from the masternode list (they carry the // masternode's proRegTxHash) can hold such an unreachable docker-internal - // host, so only those are rewritten, and only when the host is not - // already a reachable loopback. A caller-supplied address — a moved-port - // loopback, a secondary loopback like 127.0.0.2, a LAN IP, or a container - // hostname — already names the exact gateway to talk to (dashmate e2e - // suites move the stock ports on purpose), and clobbering it with the - // stock local ports silently redirects every request to whichever network - // squats those ports on the machine. + // host, so only those are rewritten. A caller-supplied address — a + // moved-port loopback, a secondary loopback like 127.0.0.2, a LAN IP, or a + // container hostname — already names the exact gateway to talk to + // (dashmate e2e suites move the stock ports on purpose), and clobbering it + // with the stock local ports silently redirects every request to whichever + // network squats those ports on the machine. const network = networks.get(this.options.network); - const isLoopback = ['127.0.0.1', 'localhost'].includes(liveAddress.getHost()); const isFromMasternodeList = Boolean(liveAddress.getProRegTxHash()); - if (network && network.regtestEnabled && isFromMasternodeList && !isLoopback) { + if (network && network.regtestEnabled && isFromMasternodeList) { const randomNodeIndex = Math.floor(Math.random() * liveAddresses.length); liveAddress.protocol = 'https'; diff --git a/packages/js-dapi-client/test/unit/dapiAddressProvider/ListDAPIAddressProvider.spec.js b/packages/js-dapi-client/test/unit/dapiAddressProvider/ListDAPIAddressProvider.spec.js index 1843cab5082..e63bda05095 100644 --- a/packages/js-dapi-client/test/unit/dapiAddressProvider/ListDAPIAddressProvider.spec.js +++ b/packages/js-dapi-client/test/unit/dapiAddressProvider/ListDAPIAddressProvider.spec.js @@ -125,6 +125,31 @@ describe('ListDAPIAddressProvider', () => { expect(liveAddress.allowSelfSignedCertificate).to.be.true(); }); + it('should rewrite a loopback masternode-list node to the self-signed local gateway', async () => { + options = { + network: 'local', + }; + + // A loopback host from the masternode list still needs the local + // gateway's self-signed TLS, so provenance alone decides the rewrite. + const discoveredAddress = new DAPIAddress({ + host: '127.0.0.1', + port: 20001, + proRegTxHash: 'b'.repeat(64), + }); + + listDAPIAddressProvider = new ListDAPIAddressProvider( + [discoveredAddress], + options, + ); + + const liveAddress = await listDAPIAddressProvider.getLiveAddress(); + + expect(liveAddress.host).to.equal('127.0.0.1'); + expect(liveAddress.port).to.equal(2443); + expect(liveAddress.allowSelfSignedCertificate).to.be.true(); + }); + it('should not modify a caller-supplied non-loopback address', async () => { options = { network: 'local', From 52a248ff8f8c76b2ad6150112db1d64d391a7745 Mon Sep 17 00:00:00 2001 From: pasta Date: Mon, 5 Oct 2026 05:29:44 -0500 Subject: [PATCH 4/7] fix(dapi-client): rewrite regtest addresses only in the masternode-list provider Inferring provenance from proRegTxHash was not enough: an explicit dapiAddresses entry can carry one (a raw object, or a DAPIAddress whose toJSON keeps it) and was still rewritten to 127.0.0.1:2443. The rewrite now lives in SimplifiedMasternodeListDAPIAddressProvider, which is the only source of discovered addresses, so an explicit list is never rewritten. The `local` preset relied on the old blanket rewrite to turn its bare '127.0.0.1' into the local gateway. Name the gateway directly instead, '127.0.0.1:2443:self-signed', as scripts/configure_test_suite.sh does. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../ListDAPIAddressProvider.js | 38 ++--------- ...lifiedMasternodeListDAPIAddressProvider.js | 31 ++++++++- .../createDAPIAddressProviderFromOptions.js | 1 + packages/js-dapi-client/lib/networkConfigs.js | 4 +- .../ListDAPIAddressProvider.spec.js | 51 ++++---------- ...dMasternodeListDAPIAddressProvider.spec.js | 66 ++++++++++++++++++- ...eateDAPIAddressProviderFromOptions.spec.js | 42 ++++++++++++ 7 files changed, 158 insertions(+), 75 deletions(-) diff --git a/packages/js-dapi-client/lib/dapiAddressProvider/ListDAPIAddressProvider.js b/packages/js-dapi-client/lib/dapiAddressProvider/ListDAPIAddressProvider.js index 3375e99509c..9580c6e4ebb 100644 --- a/packages/js-dapi-client/lib/dapiAddressProvider/ListDAPIAddressProvider.js +++ b/packages/js-dapi-client/lib/dapiAddressProvider/ListDAPIAddressProvider.js @@ -1,5 +1,4 @@ const sample = (arr) => arr[Math.floor(Math.random() * arr.length)]; -const networks = require('@dashevo/dashcore-lib/lib/networks'); class ListDAPIAddressProvider { /** @@ -20,38 +19,11 @@ class ListDAPIAddressProvider { * @returns {Promise} */ async getLiveAddress() { - const liveAddresses = this.getLiveAddresses(); - - const liveAddress = sample(liveAddresses); - - if (liveAddress === undefined) { - return undefined; - } - - // This is a temporary fix for a localhost masternode. - // On macOS, internal docker IP is used to register masternode, and it's - // not really possible to bind to that address, so that workaround is introduced. - // - // Only addresses discovered from the masternode list (they carry the - // masternode's proRegTxHash) can hold such an unreachable docker-internal - // host, so only those are rewritten. A caller-supplied address — a - // moved-port loopback, a secondary loopback like 127.0.0.2, a LAN IP, or a - // container hostname — already names the exact gateway to talk to - // (dashmate e2e suites move the stock ports on purpose), and clobbering it - // with the stock local ports silently redirects every request to whichever - // network squats those ports on the machine. - const network = networks.get(this.options.network); - const isFromMasternodeList = Boolean(liveAddress.getProRegTxHash()); - if (network && network.regtestEnabled && isFromMasternodeList) { - const randomNodeIndex = Math.floor(Math.random() * liveAddresses.length); - - liveAddress.protocol = 'https'; - liveAddress.host = '127.0.0.1'; - liveAddress.allowSelfSignedCertificate = true; - liveAddress.port = 2443 + randomNodeIndex * 100; - } - - return liveAddress; + // Addresses are returned as configured. The regtest compatibility rewrite + // for masternode-list addresses lives in + // SimplifiedMasternodeListDAPIAddressProvider, so an explicit list is + // never rewritten. + return sample(this.getLiveAddresses()); } /** diff --git a/packages/js-dapi-client/lib/dapiAddressProvider/SimplifiedMasternodeListDAPIAddressProvider.js b/packages/js-dapi-client/lib/dapiAddressProvider/SimplifiedMasternodeListDAPIAddressProvider.js index c6607e843eb..c10db636a23 100644 --- a/packages/js-dapi-client/lib/dapiAddressProvider/SimplifiedMasternodeListDAPIAddressProvider.js +++ b/packages/js-dapi-client/lib/dapiAddressProvider/SimplifiedMasternodeListDAPIAddressProvider.js @@ -1,3 +1,5 @@ +const networks = require('@dashevo/dashcore-lib/lib/networks'); + const DAPIAddress = require('./DAPIAddress'); class SimplifiedMasternodeListDAPIAddressProvider { @@ -5,9 +7,11 @@ class SimplifiedMasternodeListDAPIAddressProvider { * @param {SimplifiedMasternodeListProvider} smlProvider * @param {ListDAPIAddressProvider} listDAPIAddressProvider * @param {DAPIAddress[]} addressWhiteList + * @param {DAPIClientOptions} [options] */ - constructor(smlProvider, listDAPIAddressProvider, addressWhiteList) { + constructor(smlProvider, listDAPIAddressProvider, addressWhiteList, options = {}) { this.smlProvider = smlProvider; + this.options = options; this.listDAPIAddressProvider = listDAPIAddressProvider; this.addressWhiteStrings = addressWhiteList.map((dapiAddress) => dapiAddress.toString()); } @@ -60,7 +64,30 @@ class SimplifiedMasternodeListDAPIAddressProvider { this.listDAPIAddressProvider.setAddresses(filteredAddresses); - return this.listDAPIAddressProvider.getLiveAddress(); + const liveAddress = await this.listDAPIAddressProvider.getLiveAddress(); + + // This is a temporary fix for a localhost masternode. + // On macOS, internal docker IP is used to register masternode, and it's + // not really possible to bind to that address, so that workaround is introduced. + // + // It lives here, and only here, because only addresses discovered from the + // masternode list can hold such an unreachable docker-internal host. An + // address list the caller supplies (`dapiAddresses`, `seeds`) already names + // the exact gateway to talk to (dashmate e2e suites move the stock ports on + // purpose), and clobbering it with the stock local ports silently redirects + // every request to whichever network squats those ports on the machine. + const network = networks.get(this.options.network); + if (liveAddress && network && network.regtestEnabled) { + const liveAddressCount = this.listDAPIAddressProvider.getLiveAddresses().length; + const randomNodeIndex = Math.floor(Math.random() * liveAddressCount); + + liveAddress.protocol = 'https'; + liveAddress.host = '127.0.0.1'; + liveAddress.allowSelfSignedCertificate = true; + liveAddress.port = 2443 + randomNodeIndex * 100; + } + + return liveAddress; } /** diff --git a/packages/js-dapi-client/lib/dapiAddressProvider/createDAPIAddressProviderFromOptions.js b/packages/js-dapi-client/lib/dapiAddressProvider/createDAPIAddressProviderFromOptions.js index 5a02a00b58d..8e767f4ef27 100644 --- a/packages/js-dapi-client/lib/dapiAddressProvider/createDAPIAddressProviderFromOptions.js +++ b/packages/js-dapi-client/lib/dapiAddressProvider/createDAPIAddressProviderFromOptions.js @@ -94,6 +94,7 @@ function createDAPIAddressProviderFromOptions(options) { smlProvider, listDAPIAddressProvider, dapiAddressesWhiteList.map((rawAddress) => new DAPIAddress(rawAddress)), + options, ); } diff --git a/packages/js-dapi-client/lib/networkConfigs.js b/packages/js-dapi-client/lib/networkConfigs.js index e45953ac3a1..293ecd17240 100644 --- a/packages/js-dapi-client/lib/networkConfigs.js +++ b/packages/js-dapi-client/lib/networkConfigs.js @@ -40,7 +40,9 @@ module.exports = { ], }, local: { - dapiAddresses: ['127.0.0.1'], + // The local dashmate gateway: stock port, self-signed TLS + // (see scripts/configure_test_suite.sh). + dapiAddresses: ['127.0.0.1:2443:self-signed'], network: 'regtest', }, mainnet: { diff --git a/packages/js-dapi-client/test/unit/dapiAddressProvider/ListDAPIAddressProvider.spec.js b/packages/js-dapi-client/test/unit/dapiAddressProvider/ListDAPIAddressProvider.spec.js index e63bda05095..6ad52d20a30 100644 --- a/packages/js-dapi-client/test/unit/dapiAddressProvider/ListDAPIAddressProvider.spec.js +++ b/packages/js-dapi-client/test/unit/dapiAddressProvider/ListDAPIAddressProvider.spec.js @@ -100,54 +100,30 @@ describe('ListDAPIAddressProvider', () => { expect(address).to.be.undefined(); }); - it('should return modified address for a masternode-list node on localhost network', async () => { + it('should not modify an explicit address that carries a proRegTxHash', async () => { options = { network: 'local', }; - // Addresses discovered from the masternode list carry the masternode's - // proRegTxHash and may hold a docker-internal IP that cannot be reached - // from the host (macOS), so they are rewritten to the local gateway. - const discoveredAddress = new DAPIAddress({ - host: '172.16.0.2', + // The regtest rewrite belongs to the masternode-list provider. A list + // the caller supplies is used as given, even when an entry names the + // masternode it belongs to. + const explicitAddress = new DAPIAddress({ + host: '127.0.0.2', + port: 45003, proRegTxHash: 'a'.repeat(64), }); listDAPIAddressProvider = new ListDAPIAddressProvider( - [discoveredAddress], - options, - ); - - const liveAddress = await listDAPIAddressProvider.getLiveAddress(); - - expect(liveAddress.host).to.equal('127.0.0.1'); - expect(liveAddress.protocol).to.equal('https'); - expect(liveAddress.allowSelfSignedCertificate).to.be.true(); - }); - - it('should rewrite a loopback masternode-list node to the self-signed local gateway', async () => { - options = { - network: 'local', - }; - - // A loopback host from the masternode list still needs the local - // gateway's self-signed TLS, so provenance alone decides the rewrite. - const discoveredAddress = new DAPIAddress({ - host: '127.0.0.1', - port: 20001, - proRegTxHash: 'b'.repeat(64), - }); - - listDAPIAddressProvider = new ListDAPIAddressProvider( - [discoveredAddress], + [explicitAddress], options, ); const liveAddress = await listDAPIAddressProvider.getLiveAddress(); - expect(liveAddress.host).to.equal('127.0.0.1'); - expect(liveAddress.port).to.equal(2443); - expect(liveAddress.allowSelfSignedCertificate).to.be.true(); + expect(liveAddress.host).to.equal('127.0.0.2'); + expect(liveAddress.port).to.equal(45003); + expect(liveAddress.allowSelfSignedCertificate).to.be.false(); }); it('should not modify a caller-supplied non-loopback address', async () => { @@ -155,9 +131,8 @@ describe('ListDAPIAddressProvider', () => { network: 'local', }; - // A caller-supplied address (no proRegTxHash — it did not come from the - // masternode list) names the exact gateway to talk to, even when the - // host is a secondary loopback, LAN IP, or container hostname. + // A caller-supplied address names the exact gateway to talk to, even + // when the host is a secondary loopback, LAN IP, or container hostname. const explicitAddress = new DAPIAddress('127.0.0.2:45003:self-signed'); listDAPIAddressProvider = new ListDAPIAddressProvider( diff --git a/packages/js-dapi-client/test/unit/dapiAddressProvider/SimplifiedMasternodeListDAPIAddressProvider.spec.js b/packages/js-dapi-client/test/unit/dapiAddressProvider/SimplifiedMasternodeListDAPIAddressProvider.spec.js index f130795e44e..ada5141f94f 100644 --- a/packages/js-dapi-client/test/unit/dapiAddressProvider/SimplifiedMasternodeListDAPIAddressProvider.spec.js +++ b/packages/js-dapi-client/test/unit/dapiAddressProvider/SimplifiedMasternodeListDAPIAddressProvider.spec.js @@ -3,6 +3,7 @@ const SimplifiedMNListEntry = require('@dashevo/dashcore-lib/lib/deterministicmn const DAPIAddress = require('../../../lib/dapiAddressProvider/DAPIAddress'); const SimplifiedMasternodeListDAPIAddressProvider = require('../../../lib/dapiAddressProvider/SimplifiedMasternodeListDAPIAddressProvider'); +const ListDAPIAddressProvider = require('../../../lib/dapiAddressProvider/ListDAPIAddressProvider'); describe('SimplifiedMasternodeListDAPIAddressProvider', () => { let smlDAPIAddressProvider; @@ -11,9 +12,10 @@ describe('SimplifiedMasternodeListDAPIAddressProvider', () => { let smlMock; let validMasternodeList; let addresses; + let mnListDiffFixture; beforeEach(function beforeEach() { - const mnListDiffFixture = [{ + mnListDiffFixture = [{ proRegTxHash: 'f5ec54aed788c434da2fc535ea6b125ec6fc54e58bc0a00a005d1a8d5e477a90', confirmedHash: '53125505b0e9d11b371cf3e12c92d164296dfa215fde6201d28ea44bed992187', service: '192.168.65.2:20101', @@ -196,6 +198,68 @@ describe('SimplifiedMasternodeListDAPIAddressProvider', () => { }); }); + describe('#getLiveAddress on a regtest network', () => { + const options = { network: 'local' }; + + it('should rewrite a masternode-list address to the local gateway', async () => { + // The fixture masternodes registered a docker-internal IP, which the + // host cannot reach on macOS. + smlDAPIAddressProvider = new SimplifiedMasternodeListDAPIAddressProvider( + smlProviderMock, + new ListDAPIAddressProvider([], options), + [], + options, + ); + + const liveAddress = await smlDAPIAddressProvider.getLiveAddress(); + + expect(liveAddress.getProRegTxHash()).to.be.oneOf( + validMasternodeList.map((smlEntry) => smlEntry.proRegTxHash), + ); + expect(liveAddress.getHost()).to.equal('127.0.0.1'); + expect(liveAddress.getPort()).to.be.oneOf([2443, 2543, 2643]); + expect(liveAddress.getProtocol()).to.equal('https'); + expect(liveAddress.isSelfSignedCertificateAllowed()).to.be.true(); + }); + + it('should rewrite a loopback masternode-list address to the self-signed local gateway', async () => { + // A loopback host still needs the gateway's port and self-signed TLS. + smlMock.getValidMasternodesList.returns([ + new SimplifiedMNListEntry({ ...mnListDiffFixture[2], service: '127.0.0.1:20001' }), + ]); + smlDAPIAddressProvider = new SimplifiedMasternodeListDAPIAddressProvider( + smlProviderMock, + new ListDAPIAddressProvider([], options), + [], + options, + ); + + const liveAddress = await smlDAPIAddressProvider.getLiveAddress(); + + expect(liveAddress.getHost()).to.equal('127.0.0.1'); + expect(liveAddress.getPort()).to.equal(2443); + expect(liveAddress.isSelfSignedCertificateAllowed()).to.be.true(); + }); + + it('should not rewrite a masternode-list address outside regtest', async () => { + // Mainnet: dashcore-lib's regtest is a global flag on its testnet object, + // which the regtest cases above have switched on. + const mainnetOptions = { network: 'mainnet' }; + smlDAPIAddressProvider = new SimplifiedMasternodeListDAPIAddressProvider( + smlProviderMock, + new ListDAPIAddressProvider([], mainnetOptions), + [], + mainnetOptions, + ); + + const liveAddress = await smlDAPIAddressProvider.getLiveAddress(); + + expect(liveAddress.getHost()).to.equal(validMasternodeList[0].getIp()); + expect(liveAddress.getPort()).to.equal(validMasternodeList[0].platformHTTPPort); + expect(liveAddress.isSelfSignedCertificateAllowed()).to.be.false(); + }); + }); + describe('#hasLiveAddresses', () => { it('should return ListAddressProvider#hasLiveAddresses result', async () => { const result = await smlDAPIAddressProvider.hasLiveAddresses(); diff --git a/packages/js-dapi-client/test/unit/dapiAddressProvider/createDAPIAddressProviderFromOptions.spec.js b/packages/js-dapi-client/test/unit/dapiAddressProvider/createDAPIAddressProviderFromOptions.spec.js index a4ea52b3dde..99fc679eb43 100644 --- a/packages/js-dapi-client/test/unit/dapiAddressProvider/createDAPIAddressProviderFromOptions.spec.js +++ b/packages/js-dapi-client/test/unit/dapiAddressProvider/createDAPIAddressProviderFromOptions.spec.js @@ -1,6 +1,7 @@ const createDAPIAddressProviderFromOptions = require( '../../../lib/dapiAddressProvider/createDAPIAddressProviderFromOptions', ); +const DAPIAddress = require('../../../lib/dapiAddressProvider/DAPIAddress'); const ListDAPIAddressProvider = require('../../../lib/dapiAddressProvider/ListDAPIAddressProvider'); const SimplifiedMasternodeListDAPIAddressProvider = require('../../../lib/dapiAddressProvider/SimplifiedMasternodeListDAPIAddressProvider'); @@ -92,6 +93,37 @@ describe('createDAPIAddressProviderFromOptions', () => { expect(liveAddress.getPort()).to.equal(45003); }); + // An explicit list is never rewritten, even when its entries name the + // masternode they belong to. + it('should not rewrite an explicit raw address that carries a proRegTxHash', async () => { + options.dapiAddresses = [{ + host: '127.0.0.2', + port: 45003, + allowSelfSignedCertificate: true, + proRegTxHash: 'c'.repeat(64), + }]; + + const liveAddress = await createDAPIAddressProviderFromOptions(options).getLiveAddress(); + + expect(liveAddress.getHost()).to.equal('127.0.0.2'); + expect(liveAddress.getPort()).to.equal(45003); + expect(liveAddress.isSelfSignedCertificateAllowed()).to.be.true(); + }); + + it('should not rewrite an explicit DAPIAddress that carries a proRegTxHash', async () => { + options.dapiAddresses = [new DAPIAddress({ + host: '10.0.0.5', + port: 45003, + proRegTxHash: 'd'.repeat(64), + })]; + + const liveAddress = await createDAPIAddressProviderFromOptions(options).getLiveAddress(); + + expect(liveAddress.getHost()).to.equal('10.0.0.5'); + expect(liveAddress.getPort()).to.equal(45003); + expect(liveAddress.isSelfSignedCertificateAllowed()).to.be.false(); + }); + it('should throw DAPIClientError if `seeds` option is passed too', async () => { options.seeds = ['127.0.0.1']; @@ -155,6 +187,16 @@ describe('createDAPIAddressProviderFromOptions', () => { expect(result).to.be.an.instanceOf(SimplifiedMasternodeListDAPIAddressProvider); }); + it('should connect the `local` preset to the self-signed local gateway', async () => { + const liveAddress = await createDAPIAddressProviderFromOptions({ network: 'local' }) + .getLiveAddress(); + + expect(liveAddress.getHost()).to.equal('127.0.0.1'); + expect(liveAddress.getPort()).to.equal(2443); + expect(liveAddress.getProtocol()).to.equal('https'); + expect(liveAddress.isSelfSignedCertificateAllowed()).to.be.true(); + }); + it('should throw DAPIClientError if there is no config for a specified network', async () => { options.network = 'unknown'; From 03bd05aa12171a23c07d0c641cb344d8d21a35eb Mon Sep 17 00:00:00 2001 From: pasta Date: Mon, 5 Oct 2026 06:56:13 -0500 Subject: [PATCH 5/7] fix(dapi-client): rewrite the discovered regtest pool before publishing it Rewriting only the address getLiveAddress() returned left the rest of the discovered pool on docker-internal endpoints. The masternode list stream selects from the same list provider directly on every (re)connect, so a reconnect could pick an unreachable entry, and each refresh's setHost put previously rewritten entries back on their internal host. Rewrite every discovered entry to the local gateway (127.0.0.1, 2443 + index * 100, self-signed) before setAddresses. A refresh first resets an entry to its registered endpoint, so the white list still matches it, then rewrites it again; the address objects, and their ban state, are kept. Explicit seeds and dapiAddresses are still never touched. Co-Authored-By: Claude Opus 5.5 (1M context) --- ...lifiedMasternodeListDAPIAddressProvider.js | 58 +++++++++++-------- ...dMasternodeListDAPIAddressProvider.spec.js | 46 +++++++++++++++ 2 files changed, 80 insertions(+), 24 deletions(-) diff --git a/packages/js-dapi-client/lib/dapiAddressProvider/SimplifiedMasternodeListDAPIAddressProvider.js b/packages/js-dapi-client/lib/dapiAddressProvider/SimplifiedMasternodeListDAPIAddressProvider.js index c10db636a23..5ee9531be18 100644 --- a/packages/js-dapi-client/lib/dapiAddressProvider/SimplifiedMasternodeListDAPIAddressProvider.js +++ b/packages/js-dapi-client/lib/dapiAddressProvider/SimplifiedMasternodeListDAPIAddressProvider.js @@ -38,6 +38,23 @@ class SimplifiedMasternodeListDAPIAddressProvider { addressesByRegProTxHashes[address.getProRegTxHash()] = address; }); + // This is a temporary fix for a localhost masternode. + // On macOS, internal docker IP is used to register masternode, and it's + // not really possible to bind to that address, so that workaround is introduced. + // + // It applies here, and only here, because only addresses discovered from + // the masternode list can hold such an unreachable docker-internal host. An + // address list the caller supplies (`dapiAddresses`, `seeds`) already names + // the exact gateway to talk to (dashmate e2e suites move the stock ports on + // purpose), and clobbering it with the stock local ports silently redirects + // every request to whichever network squats those ports on the machine. + // + // The discovered pool is rewritten before it is published, not the address + // this method returns: the masternode list stream selects from the same + // list provider directly on every (re)connect. + const network = networks.get(this.options.network); + const isRegtest = Boolean(network && network.regtestEnabled); + const updatedAddresses = validMasternodeList.map((smlEntry) => { let address = addressesByRegProTxHashes[smlEntry.proRegTxHash]; @@ -50,6 +67,11 @@ class SimplifiedMasternodeListDAPIAddressProvider { }); } else { address.setHost(smlEntry.getIp()); + + if (isRegtest) { + // Undo the last rewrite, so the white list sees the registered endpoint + address.setPort(smlEntry.platformHTTPPort); + } } return address; @@ -62,32 +84,20 @@ class SimplifiedMasternodeListDAPIAddressProvider { )); } - this.listDAPIAddressProvider.setAddresses(filteredAddresses); - - const liveAddress = await this.listDAPIAddressProvider.getLiveAddress(); - - // This is a temporary fix for a localhost masternode. - // On macOS, internal docker IP is used to register masternode, and it's - // not really possible to bind to that address, so that workaround is introduced. - // - // It lives here, and only here, because only addresses discovered from the - // masternode list can hold such an unreachable docker-internal host. An - // address list the caller supplies (`dapiAddresses`, `seeds`) already names - // the exact gateway to talk to (dashmate e2e suites move the stock ports on - // purpose), and clobbering it with the stock local ports silently redirects - // every request to whichever network squats those ports on the machine. - const network = networks.get(this.options.network); - if (liveAddress && network && network.regtestEnabled) { - const liveAddressCount = this.listDAPIAddressProvider.getLiveAddresses().length; - const randomNodeIndex = Math.floor(Math.random() * liveAddressCount); - - liveAddress.protocol = 'https'; - liveAddress.host = '127.0.0.1'; - liveAddress.allowSelfSignedCertificate = true; - liveAddress.port = 2443 + randomNodeIndex * 100; + if (isRegtest) { + filteredAddresses.forEach((address, index) => { + /* eslint-disable no-param-reassign */ + address.protocol = 'https'; + address.host = '127.0.0.1'; + address.allowSelfSignedCertificate = true; + address.port = 2443 + index * 100; + /* eslint-enable no-param-reassign */ + }); } - return liveAddress; + this.listDAPIAddressProvider.setAddresses(filteredAddresses); + + return this.listDAPIAddressProvider.getLiveAddress(); } /** diff --git a/packages/js-dapi-client/test/unit/dapiAddressProvider/SimplifiedMasternodeListDAPIAddressProvider.spec.js b/packages/js-dapi-client/test/unit/dapiAddressProvider/SimplifiedMasternodeListDAPIAddressProvider.spec.js index ada5141f94f..67142d174c0 100644 --- a/packages/js-dapi-client/test/unit/dapiAddressProvider/SimplifiedMasternodeListDAPIAddressProvider.spec.js +++ b/packages/js-dapi-client/test/unit/dapiAddressProvider/SimplifiedMasternodeListDAPIAddressProvider.spec.js @@ -222,6 +222,52 @@ describe('SimplifiedMasternodeListDAPIAddressProvider', () => { expect(liveAddress.isSelfSignedCertificateAllowed()).to.be.true(); }); + // The masternode list stream selects from the underlying list provider on + // every (re)connect, without going through this provider. + it('should publish only rewritten addresses to the underlying list provider', async () => { + const listDAPIAddressProvider = new ListDAPIAddressProvider([], options); + smlDAPIAddressProvider = new SimplifiedMasternodeListDAPIAddressProvider( + smlProviderMock, + listDAPIAddressProvider, + [], + options, + ); + + const expectGatewayPool = () => { + const pool = listDAPIAddressProvider.getAllAddresses(); + + expect(pool.map((address) => address.toJSON())).to.deep.equal( + validMasternodeList.map((smlEntry, index) => ({ + host: '127.0.0.1', + port: 2443 + index * 100, + protocol: 'https', + allowSelfSignedCertificate: true, + proRegTxHash: smlEntry.proRegTxHash, + })), + ); + + return pool; + }; + + await smlDAPIAddressProvider.getLiveAddress(); + + const pool = expectGatewayPool(); + for (let i = 0; i < 20; i++) { + const selected = await listDAPIAddressProvider.getLiveAddress(); + expect(selected.getHost()).to.equal('127.0.0.1'); + expect(selected.isSelfSignedCertificateAllowed()).to.be.true(); + } + + // A refresh resets each entry to its registered endpoint before + // rewriting it again; it must keep the same objects, so their ban state + // survives, and leave none of them on the docker-internal endpoint. + await smlDAPIAddressProvider.getLiveAddress(); + await smlDAPIAddressProvider.getLiveAddress(); + + const refreshedPool = expectGatewayPool(); + refreshedPool.forEach((address, index) => expect(address).to.equal(pool[index])); + }); + it('should rewrite a loopback masternode-list address to the self-signed local gateway', async () => { // A loopback host still needs the gateway's port and self-signed TLS. smlMock.getValidMasternodesList.returns([ From 736a334bbd565a80ae752dcbe3597b103c13ab73 Mon Sep 17 00:00:00 2001 From: pasta Date: Mon, 5 Oct 2026 07:06:15 -0500 Subject: [PATCH 6/7] fix(dapi-client): keep regtest gateway ports stable under a white list Gateway ports were assigned by position after dapiAddressesWhiteList filtering, so white-listing away the first masternode moved the second onto 2443. Derive each port from the masternode's index in the full list. Co-Authored-By: Claude Opus 5.5 (1M context) --- ...lifiedMasternodeListDAPIAddressProvider.js | 12 ++++-- ...dMasternodeListDAPIAddressProvider.spec.js | 37 +++++++++++++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/packages/js-dapi-client/lib/dapiAddressProvider/SimplifiedMasternodeListDAPIAddressProvider.js b/packages/js-dapi-client/lib/dapiAddressProvider/SimplifiedMasternodeListDAPIAddressProvider.js index 5ee9531be18..8138340a2cc 100644 --- a/packages/js-dapi-client/lib/dapiAddressProvider/SimplifiedMasternodeListDAPIAddressProvider.js +++ b/packages/js-dapi-client/lib/dapiAddressProvider/SimplifiedMasternodeListDAPIAddressProvider.js @@ -55,7 +55,11 @@ class SimplifiedMasternodeListDAPIAddressProvider { const network = networks.get(this.options.network); const isRegtest = Boolean(network && network.regtestEnabled); - const updatedAddresses = validMasternodeList.map((smlEntry) => { + // Each masternode's gateway port follows its position in the full list, so + // white-listing some masternodes does not move the others to other ports. + const gatewayPorts = new Map(); + + const updatedAddresses = validMasternodeList.map((smlEntry, index) => { let address = addressesByRegProTxHashes[smlEntry.proRegTxHash]; if (!address) { @@ -74,6 +78,8 @@ class SimplifiedMasternodeListDAPIAddressProvider { } } + gatewayPorts.set(address, 2443 + index * 100); + return address; }); @@ -85,12 +91,12 @@ class SimplifiedMasternodeListDAPIAddressProvider { } if (isRegtest) { - filteredAddresses.forEach((address, index) => { + filteredAddresses.forEach((address) => { /* eslint-disable no-param-reassign */ address.protocol = 'https'; address.host = '127.0.0.1'; address.allowSelfSignedCertificate = true; - address.port = 2443 + index * 100; + address.port = gatewayPorts.get(address); /* eslint-enable no-param-reassign */ }); } diff --git a/packages/js-dapi-client/test/unit/dapiAddressProvider/SimplifiedMasternodeListDAPIAddressProvider.spec.js b/packages/js-dapi-client/test/unit/dapiAddressProvider/SimplifiedMasternodeListDAPIAddressProvider.spec.js index 67142d174c0..fbe6542af15 100644 --- a/packages/js-dapi-client/test/unit/dapiAddressProvider/SimplifiedMasternodeListDAPIAddressProvider.spec.js +++ b/packages/js-dapi-client/test/unit/dapiAddressProvider/SimplifiedMasternodeListDAPIAddressProvider.spec.js @@ -268,6 +268,43 @@ describe('SimplifiedMasternodeListDAPIAddressProvider', () => { refreshedPool.forEach((address, index) => expect(address).to.equal(pool[index])); }); + it('should keep a white-listed masternode on its own gateway port', async () => { + // White-list only the second masternode; dropping the first must not + // move it onto the first one's gateway port. The fixture masternodes + // share one IP, so give each its own to make the white list selective. + const masternodes = mnListDiffFixture.map((entry, index) => ( + new SimplifiedMNListEntry({ ...entry, service: `172.16.0.${11 + index}:20001` }) + )); + smlMock.getValidMasternodesList.returns(masternodes); + + const listDAPIAddressProvider = new ListDAPIAddressProvider([], options); + smlDAPIAddressProvider = new SimplifiedMasternodeListDAPIAddressProvider( + smlProviderMock, + listDAPIAddressProvider, + [new DAPIAddress(`172.16.0.12:${masternodes[1].platformHTTPPort}`)], + options, + ); + + const liveAddress = await smlDAPIAddressProvider.getLiveAddress(); + + expect(liveAddress.getProRegTxHash()).to.equal(masternodes[1].proRegTxHash); + expect(liveAddress.getHost()).to.equal('127.0.0.1'); + expect(liveAddress.getPort()).to.equal(2543); + + // A refresh resets the entry to its registered endpoint, so the white + // list still matches it and it keeps its port. + await smlDAPIAddressProvider.getLiveAddress(); + + expect(listDAPIAddressProvider.getAllAddresses().map((address) => address.toJSON())) + .to.deep.equal([{ + host: '127.0.0.1', + port: 2543, + protocol: 'https', + allowSelfSignedCertificate: true, + proRegTxHash: masternodes[1].proRegTxHash, + }]); + }); + it('should rewrite a loopback masternode-list address to the self-signed local gateway', async () => { // A loopback host still needs the gateway's port and self-signed TLS. smlMock.getValidMasternodesList.returns([ From 1fc32be508ad8554e1731db5e9607bcd62e3c407 Mon Sep 17 00:00:00 2001 From: pasta Date: Mon, 5 Oct 2026 08:02:07 -0500 Subject: [PATCH 7/7] fix(dapi-client): default SML provider options to the list provider's A caller of the original three-argument constructor got options = {} and silently lost the regtest gateway rewrite. Fall back to the list provider's options, which hold the same client options, when none are passed; explicitly passed options still win. Co-Authored-By: Claude Opus 5.5 (1M context) --- ...lifiedMasternodeListDAPIAddressProvider.js | 8 ++++--- ...dMasternodeListDAPIAddressProvider.spec.js | 21 +++++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/packages/js-dapi-client/lib/dapiAddressProvider/SimplifiedMasternodeListDAPIAddressProvider.js b/packages/js-dapi-client/lib/dapiAddressProvider/SimplifiedMasternodeListDAPIAddressProvider.js index 8138340a2cc..c4e615d980e 100644 --- a/packages/js-dapi-client/lib/dapiAddressProvider/SimplifiedMasternodeListDAPIAddressProvider.js +++ b/packages/js-dapi-client/lib/dapiAddressProvider/SimplifiedMasternodeListDAPIAddressProvider.js @@ -7,11 +7,13 @@ class SimplifiedMasternodeListDAPIAddressProvider { * @param {SimplifiedMasternodeListProvider} smlProvider * @param {ListDAPIAddressProvider} listDAPIAddressProvider * @param {DAPIAddress[]} addressWhiteList - * @param {DAPIClientOptions} [options] + * @param {DAPIClientOptions} [options] - defaults to the list provider's options */ - constructor(smlProvider, listDAPIAddressProvider, addressWhiteList, options = {}) { + constructor(smlProvider, listDAPIAddressProvider, addressWhiteList, options) { this.smlProvider = smlProvider; - this.options = options; + // A caller of the original three-argument constructor still gets the + // regtest rewrite: the list provider holds the same client options. + this.options = options ?? listDAPIAddressProvider.options ?? {}; this.listDAPIAddressProvider = listDAPIAddressProvider; this.addressWhiteStrings = addressWhiteList.map((dapiAddress) => dapiAddress.toString()); } diff --git a/packages/js-dapi-client/test/unit/dapiAddressProvider/SimplifiedMasternodeListDAPIAddressProvider.spec.js b/packages/js-dapi-client/test/unit/dapiAddressProvider/SimplifiedMasternodeListDAPIAddressProvider.spec.js index fbe6542af15..ec5f5c6c348 100644 --- a/packages/js-dapi-client/test/unit/dapiAddressProvider/SimplifiedMasternodeListDAPIAddressProvider.spec.js +++ b/packages/js-dapi-client/test/unit/dapiAddressProvider/SimplifiedMasternodeListDAPIAddressProvider.spec.js @@ -268,6 +268,27 @@ describe('SimplifiedMasternodeListDAPIAddressProvider', () => { refreshedPool.forEach((address, index) => expect(address).to.equal(pool[index])); }); + it('should take the network from the list provider when no options are passed', async () => { + // The original three-argument constructor. + const listDAPIAddressProvider = new ListDAPIAddressProvider([], options); + smlDAPIAddressProvider = new SimplifiedMasternodeListDAPIAddressProvider( + smlProviderMock, + listDAPIAddressProvider, + [], + ); + + await smlDAPIAddressProvider.getLiveAddress(); + + expect(listDAPIAddressProvider.getAllAddresses().map((address) => address.toJSON())) + .to.deep.equal(validMasternodeList.map((smlEntry, index) => ({ + host: '127.0.0.1', + port: 2443 + index * 100, + protocol: 'https', + allowSelfSignedCertificate: true, + proRegTxHash: smlEntry.proRegTxHash, + }))); + }); + it('should keep a white-listed masternode on its own gateway port', async () => { // White-list only the second masternode; dropping the first must not // move it onto the first one's gateway port. The fixture masternodes