Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,20 @@ 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 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);
if (network && network.regtestEnabled) {
const isLoopback = ['127.0.0.1', 'localhost'].includes(liveAddress.getHost());
const isFromMasternodeList = Boolean(liveAddress.getProRegTxHash());
if (network && network.regtestEnabled && isFromMasternodeList && !isLoopback) {
const randomNodeIndex = Math.floor(Math.random() * liveAddresses.length);

liveAddress.protocol = 'https';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);

Expand All @@ -116,6 +124,50 @@ describe('ListDAPIAddressProvider', () => {
expect(liveAddress.protocol).to.equal('https');
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',
};

// 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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'];

Expand Down
22 changes: 21 additions & 1 deletion packages/wasm-sdk/src/context_provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,27 @@ 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. 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) if network == dash_sdk::dpp::dashcore::Network::Regtest => {
tracing::warn!(
error = %e,
"trusted context: masternode discovery unavailable, continuing without \
discovered addresses (explicitly configured addresses are unaffected)"
);
Vec::new()
}
Err(e) => return Err(e),
};
Comment on lines +269 to +280

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Only suppress the expected empty-discovery error on regtest

The PR describes tolerating the normal No eligible masternode addresses discovered result, but this match suppresses every discovery error on regtest. fetch_masternode_addresses can also fail because of transport errors, non-success HTTP responses, malformed JSON, or a sidecar-declared failure, and fetch_addresses_from can reject malformed URIs or addresses. Those failures currently become an empty discovered list, so a builder without explicit addresses silently retains its preset addresses and can contact an unintended endpoint. Preserve the provider error through this layer, represent the expected no-eligible-addresses result as a dedicated typed variant, and downgrade only that variant on regtest.

source: ['claude']

Comment on lines +269 to +280

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Add deterministic tests for the network-scoped discovery fallback

No committed test executes the new branches in prefetch_for; existing unit tests construct contexts with for_testing and inject discovered_addresses, while the functional local check depends on a live endpoint and does not verify the public-network boundary. Add a deterministic HTTP fixture that serves valid /quorums and /previous responses followed by a /masternodes response with no eligible entries. Assert that regtest returns a context with no discovered addresses while mainnet or testnet propagates the same result, and verify that malformed or HTTP-failure responses remain errors on regtest once the catch is narrowed.

source: ['claude']


Ok(WasmTrustedContext {
inner,
Expand Down
Loading