From 1af31af50a8b002cd951148641a6f1f51c3ff424 Mon Sep 17 00:00:00 2001 From: pasta Date: Sun, 30 Aug 2026 00:44:15 +0200 Subject: [PATCH 1/4] 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 d77050c6030e059ffe31374613b246853bba1070 Mon Sep 17 00:00:00 2001 From: pasta Date: Sun, 30 Aug 2026 00:44:29 +0200 Subject: [PATCH 2/4] fix(wasm-sdk): tolerate masternode discovery failure in trusted context prefetch Discovery only feeds the no-explicit-addresses path of withTrustedContext, but a failure made the whole prefetch unusable - and it fails routinely on local networks, where the quorum sidecar's per-masternode version checks reject the gateway's self-signed TLS and report no eligible masternodes. Degrade to a warning and an empty discovered list; SDKs constructed with explicit addresses are unaffected, and the quorum data proof verification needs is fetched before this point. Co-Authored-By: Claude Fable 5 --- packages/wasm-sdk/src/context_provider.rs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/wasm-sdk/src/context_provider.rs b/packages/wasm-sdk/src/context_provider.rs index cd17e74aa3c..a244a62c9fc 100644 --- a/packages/wasm-sdk/src/context_provider.rs +++ b/packages/wasm-sdk/src/context_provider.rs @@ -257,7 +257,24 @@ impl WasmTrustedContext { .await .map_err(|e| WasmSdkError::generic(format!("Failed to prefetch quorums: {}", e)))?; - let discovered_addresses = Self::fetch_addresses_from(&inner).await?; + // Masternode discovery is an optional convenience: it only feeds the + // no-explicit-addresses path in `withTrustedContext`, while the quorum + // data prefetched above is what proof verification actually needs. It + // is also environment-sensitive — the sidecar's per-masternode version + // checks fail against a local gateway's self-signed TLS — so a + // discovery failure must not make the whole trusted context unusable + // for an SDK constructed with explicit addresses. + let discovered_addresses = match Self::fetch_addresses_from(&inner).await { + Ok(addresses) => addresses, + Err(e) => { + tracing::warn!( + error = %e, + "trusted context: masternode discovery unavailable, continuing without \ + discovered addresses (explicitly configured addresses are unaffected)" + ); + Vec::new() + } + }; Ok(WasmTrustedContext { inner, From b03993d420c2d95446b93d1c118f0ff42094ef78 Mon Sep 17 00:00:00 2001 From: pasta Date: Sun, 30 Aug 2026 00:54:06 +0200 Subject: [PATCH 3/4] fix(wasm-sdk): scope trusted-context discovery tolerance to regtest Cherry-picked from the state-sync e2e branch; the companion e2e assertion stays there (the spec file only exists on that branch). Co-Authored-By: Claude Fable 5 --- packages/wasm-sdk/src/context_provider.rs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/wasm-sdk/src/context_provider.rs b/packages/wasm-sdk/src/context_provider.rs index a244a62c9fc..1d1367dd181 100644 --- a/packages/wasm-sdk/src/context_provider.rs +++ b/packages/wasm-sdk/src/context_provider.rs @@ -259,14 +259,16 @@ impl WasmTrustedContext { // Masternode discovery is an optional convenience: it only feeds the // no-explicit-addresses path in `withTrustedContext`, while the quorum - // data prefetched above is what proof verification actually needs. It - // is also environment-sensitive — the sidecar's per-masternode version - // checks fail against a local gateway's self-signed TLS — so a - // discovery failure must not make the whole trusted context unusable - // for an SDK constructed with explicit addresses. + // data prefetched above is what proof verification actually needs. On + // a local network the sidecar's per-masternode version checks reject + // the gateway's self-signed TLS, so discovery failing there is the + // NORMAL case and must not make the whole trusted context unusable + // for an SDK constructed with explicit addresses. On public networks + // the failure stays fatal: it signals a genuine outage of the trusted + // endpoint, and degrading silently would hide it. let discovered_addresses = match Self::fetch_addresses_from(&inner).await { Ok(addresses) => addresses, - Err(e) => { + Err(e) if network == dash_sdk::dpp::dashcore::Network::Regtest => { tracing::warn!( error = %e, "trusted context: masternode discovery unavailable, continuing without \ @@ -274,6 +276,7 @@ impl WasmTrustedContext { ); Vec::new() } + Err(e) => return Err(e), }; Ok(WasmTrustedContext { From c3003da29f30e6f86bb44bae5d419c23af337e3f Mon Sep 17 00:00:00 2001 From: pasta Date: Sun, 30 Aug 2026 23:26:08 +0200 Subject: [PATCH 4/4] 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'];