From 3abe91993d67377389d93f000bb4f3fe55ec5e5b Mon Sep 17 00:00:00 2001 From: pasta Date: Tue, 26 May 2026 21:06:47 -0500 Subject: [PATCH 01/10] feat(wasm-sdk): first-class devnet support with trusted-context prefetch Adds prefetchDevnet/prefetchDevnetWithUrl to WasmTrustedContext, a newDevnet factory and "devnet" arm to WasmSdkBuilder, and EvoSDK.devnet/devnetTrusted factories to js-evo-sdk so devnet consumers no longer need to masquerade as testnet to use the SDK. Devnet quorum URLs now follow the testnet/mainnet shape: https://quorums..networks.dash.org (was https://quorums.devnet....). The stale WasmContext non-trusted error message that referenced removed builder names is also corrected. --- packages/js-evo-sdk/src/sdk.ts | 85 +++++++++++++++++-- packages/js-evo-sdk/tests/unit/sdk.spec.ts | 63 ++++++++++++++ .../rs-sdk-trusted-context-provider/README.md | 2 +- .../src/lib.rs | 4 +- .../src/provider.rs | 2 +- packages/wasm-sdk/src/context_provider.rs | 73 +++++++++++++++- packages/wasm-sdk/src/sdk.rs | 24 +++++- 7 files changed, 236 insertions(+), 17 deletions(-) diff --git a/packages/js-evo-sdk/src/sdk.ts b/packages/js-evo-sdk/src/sdk.ts index 8bfeb46574c..696a45b5735 100644 --- a/packages/js-evo-sdk/src/sdk.ts +++ b/packages/js-evo-sdk/src/sdk.ts @@ -30,16 +30,26 @@ export interface ConnectionOptions { } export interface EvoSDKOptions extends ConnectionOptions { - network?: 'testnet' | 'mainnet' | 'local'; + network?: 'testnet' | 'mainnet' | 'local' | 'devnet'; trusted?: boolean; - // Custom masternode addresses. When provided, network and trusted options are ignored. + // Custom masternode addresses to seed the SDK with. `network` still + // controls which Network enum the underlying builder uses (and, for + // trusted mode, which quorums endpoint is prefetched); the addresses + // here replace the network's built-in defaults at seed time. // Example: ['https://127.0.0.1:1443', 'https://192.168.1.100:1443'] addresses?: string[]; + // Short name of the devnet (e.g. 'paloma'). Required when network === 'devnet' + // unless explicit addresses are provided AND trusted is false. + devnetName?: string; + // Optional override for the trusted devnet quorum base URL. When omitted, + // the URL is derived as `https://quorums..networks.dash.org`. + // Only consulted when trusted === true && network === 'devnet'. + quorumUrl?: string; } export class EvoSDK { private wasmSdk?: wasm.WasmSdk; - private options: Required> & ConnectionOptions & { addresses?: string[] }; + private options: Required> & ConnectionOptions & { addresses?: string[]; devnetName?: string; quorumUrl?: string }; public addresses!: AddressesFacade; public documents!: DocumentsFacade; @@ -56,8 +66,27 @@ export class EvoSDK { public shielded!: ShieldedFacade; constructor(options: EvoSDKOptions = {}) { // Apply defaults while preserving any future connection options - const { network = 'testnet', trusted = false, addresses, ...connection } = options; - this.options = { network, trusted, addresses, ...connection }; + const { network = 'testnet', trusted = false, addresses, devnetName, quorumUrl, ...connection } = options; + + if (network === 'devnet') { + const hasAddresses = !!(addresses && addresses.length > 0); + if (!devnetName && !hasAddresses) { + throw new Error("EvoSDK: network 'devnet' requires either devnetName or explicit addresses"); + } + if (trusted && !devnetName) { + throw new Error("EvoSDK: trusted devnet requires devnetName (used to derive the quorum URL)"); + } + if (!trusted && !hasAddresses) { + throw new Error("EvoSDK: non-trusted devnet requires explicit addresses (no addresses can be discovered without a trusted context)"); + } + if (quorumUrl && !trusted) { + throw new Error("EvoSDK: quorumUrl is only meaningful when trusted === true"); + } + } else if (quorumUrl) { + throw new Error("EvoSDK: quorumUrl is only valid when network === 'devnet'"); + } + + this.options = { network, trusted, addresses, devnetName, quorumUrl, ...connection }; this.addresses = new AddressesFacade(this); this.documents = new DocumentsFacade(this); @@ -96,7 +125,7 @@ export class EvoSDK { } await initWasm(); - const { network, trusted, version, proofs, settings, logs, addresses } = this.options; + const { network, trusted, version, proofs, settings, logs, addresses, devnetName, quorumUrl } = this.options; // Prefetch trusted context only when trusted mode is requested let context: wasm.WasmTrustedContext | undefined; @@ -107,6 +136,13 @@ export class EvoSDK { context = await wasm.WasmTrustedContext.prefetchTestnet(); } else if (network === 'local') { context = await wasm.WasmTrustedContext.prefetchLocal(); + } else if (network === 'devnet') { + if (!devnetName) { + throw new Error("EvoSDK: trusted devnet requires devnetName"); + } + context = quorumUrl + ? await wasm.WasmTrustedContext.prefetchDevnetWithUrl(quorumUrl) + : await wasm.WasmTrustedContext.prefetchDevnet(devnetName); } else { throw new Error(`Unknown network: ${network}`); } @@ -122,6 +158,8 @@ export class EvoSDK { builder = wasm.WasmSdkBuilder.testnet(); } else if (network === 'local') { builder = wasm.WasmSdkBuilder.local(); + } else if (network === 'devnet') { + builder = wasm.WasmSdkBuilder.newDevnet(); } else { throw new Error(`Unknown network: ${network}`); } @@ -181,11 +219,42 @@ export class EvoSDK { static local(options: ConnectionOptions = {}): EvoSDK { return new EvoSDK({ network: 'local', ...options }); } static localTrusted(options: ConnectionOptions = {}): EvoSDK { return new EvoSDK({ network: 'local', trusted: true, ...options }); } + /** + * Create an EvoSDK instance configured for a devnet, without trusted-context + * proof verification. Requires either `devnetName` or explicit `addresses` + * in `options`. Proof-bearing queries will fail unless paired with a + * trusted context — for proof verification on devnet, use + * `EvoSDK.devnetTrusted` instead. + */ + static devnet(devnetName: string, options: ConnectionOptions & { addresses?: string[] } = {}): EvoSDK { + return new EvoSDK({ network: 'devnet', devnetName, ...options }); + } + + /** + * Create an EvoSDK instance configured for a devnet with a trusted context. + * + * The trusted context is prefetched from + * `https://quorums..networks.dash.org` by default. Pass + * `quorumUrl` to override (useful when the public DNS is not yet deployed). + * + * @example + * ```typescript + * const sdk = EvoSDK.devnetTrusted('paloma'); + * await sdk.connect(); + * ``` + */ + static devnetTrusted( + devnetName: string, + options: ConnectionOptions & { quorumUrl?: string } = {}, + ): EvoSDK { + return new EvoSDK({ network: 'devnet', devnetName, trusted: true, ...options }); + } + /** * Create an EvoSDK instance configured with specific masternode addresses. * * @param addresses - Array of HTTPS URLs to masternodes (e.g., ['https://127.0.0.1:1443']) - * @param network - Network identifier: 'mainnet', 'testnet' (default: 'testnet') + * @param network - Network identifier: 'mainnet', 'testnet', 'devnet', or 'local' (default: 'testnet') * @param options - Additional connection options * @returns A configured EvoSDK instance (not yet connected - call .connect() to establish connection) * @@ -195,7 +264,7 @@ export class EvoSDK { * await sdk.connect(); * ``` */ - static withAddresses(addresses: string[], network: 'mainnet' | 'testnet' | 'local' = 'testnet', options: ConnectionOptions = {}): EvoSDK { + static withAddresses(addresses: string[], network: 'mainnet' | 'testnet' | 'local' | 'devnet' = 'testnet', options: ConnectionOptions & { devnetName?: string } = {}): EvoSDK { return new EvoSDK({ addresses, network, ...options }); } } diff --git a/packages/js-evo-sdk/tests/unit/sdk.spec.ts b/packages/js-evo-sdk/tests/unit/sdk.spec.ts index 546b13343dd..14f32dc93c8 100644 --- a/packages/js-evo-sdk/tests/unit/sdk.spec.ts +++ b/packages/js-evo-sdk/tests/unit/sdk.spec.ts @@ -220,4 +220,67 @@ describe('EvoSDK', () => { expect(sdk.isConnected).to.equal(false); }); }); + + describe('devnet()', () => { + it('should create non-trusted devnet instance with addresses + devnetName', () => { + const sdk = EvoSDK.devnet('paloma', { addresses: [TEST_ADDRESS_1] }); + expect(sdk).to.be.instanceof(EvoSDK); + expect(sdk.options.network).to.equal('devnet'); + expect(sdk.options.devnetName).to.equal('paloma'); + expect(sdk.options.addresses).to.deep.equal([TEST_ADDRESS_1]); + expect(sdk.options.trusted).to.be.false(); + expect(sdk.isConnected).to.equal(false); + }); + + it('should accept devnet with only addresses (no devnetName)', () => { + const sdk = new EvoSDK({ network: 'devnet', addresses: [TEST_ADDRESS_1] }); + expect(sdk.options.network).to.equal('devnet'); + expect(sdk.options.addresses).to.deep.equal([TEST_ADDRESS_1]); + expect(sdk.options.devnetName).to.be.undefined(); + }); + + it('should reject non-trusted devnet without addresses', () => { + // devnetName alone is not enough — without trusted context, no addresses can be discovered. + expect(() => EvoSDK.devnet('paloma')).to.throw(/addresses/); + }); + + it('should reject network=devnet without devnetName and without addresses', () => { + expect(() => new EvoSDK({ network: 'devnet' })).to.throw(/devnet/); + }); + }); + + describe('devnetTrusted()', () => { + it('should create trusted devnet instance', () => { + const sdk = EvoSDK.devnetTrusted('paloma'); + expect(sdk).to.be.instanceof(EvoSDK); + expect(sdk.options.network).to.equal('devnet'); + expect(sdk.options.devnetName).to.equal('paloma'); + expect(sdk.options.trusted).to.be.true(); + expect(sdk.isConnected).to.equal(false); + }); + + it('should preserve quorumUrl override', () => { + const sdk = EvoSDK.devnetTrusted('paloma', { quorumUrl: 'https://custom.example' }); + expect(sdk.options.quorumUrl).to.equal('https://custom.example'); + expect(sdk.options.trusted).to.be.true(); + }); + + it('should reject trusted devnet without devnetName', () => { + expect(() => new EvoSDK({ network: 'devnet', trusted: true })).to.throw(/devnetName/); + }); + + it('should reject quorumUrl when trusted is false', () => { + expect(() => new EvoSDK({ + network: 'devnet', + devnetName: 'paloma', + addresses: [TEST_ADDRESS_1], + quorumUrl: 'https://custom', + })).to.throw(/quorumUrl/); + }); + + it('should reject quorumUrl on non-devnet networks', () => { + expect(() => new EvoSDK({ network: 'testnet', trusted: true, quorumUrl: 'https://x' } as any)) + .to.throw(/quorumUrl/); + }); + }); }); diff --git a/packages/rs-sdk-trusted-context-provider/README.md b/packages/rs-sdk-trusted-context-provider/README.md index b1c5dadcd13..78aecf3c068 100644 --- a/packages/rs-sdk-trusted-context-provider/README.md +++ b/packages/rs-sdk-trusted-context-provider/README.md @@ -15,7 +15,7 @@ This crate provides a trusted HTTP-based context provider for the Dash SDK that - **Mainnet**: Uses `https://quorums.mainnet.networks.dash.org/` - **Testnet**: Uses `https://quorums.testnet.networks.dash.org/` -- **Devnet**: Uses `https://quorums.devnet..networks.dash.org/` +- **Devnet**: Uses `https://quorums..networks.dash.org/` ## Usage diff --git a/packages/rs-sdk-trusted-context-provider/src/lib.rs b/packages/rs-sdk-trusted-context-provider/src/lib.rs index 3fc2c45c337..3f6043dfdfb 100644 --- a/packages/rs-sdk-trusted-context-provider/src/lib.rs +++ b/packages/rs-sdk-trusted-context-provider/src/lib.rs @@ -6,7 +6,7 @@ //! ## Networks Supported //! - **Mainnet**: Uses `https://quorums.mainnet.networks.dash.org/` //! - **Testnet**: Uses `https://quorums.testnet.networks.dash.org/` -//! - **Devnet**: Uses `https://quorums.devnet..networks.dash.org/` +//! - **Devnet**: Uses `https://quorums..networks.dash.org/` pub mod error; pub mod provider; @@ -44,7 +44,7 @@ pub fn get_quorum_base_url( "Devnet name cannot start or end with a hyphen".to_string(), )); } - Ok(format!("https://quorums.devnet.{}.networks.dash.org", name)) + Ok(format!("https://quorums.{}.networks.dash.org", name)) } else { Err(TrustedContextProviderError::InvalidDevnetName( "Devnet name must be provided for devnet network".to_string(), diff --git a/packages/rs-sdk-trusted-context-provider/src/provider.rs b/packages/rs-sdk-trusted-context-provider/src/provider.rs index fa1dac57665..63a5c0d28c9 100644 --- a/packages/rs-sdk-trusted-context-provider/src/provider.rs +++ b/packages/rs-sdk-trusted-context-provider/src/provider.rs @@ -785,7 +785,7 @@ mod tests { assert_eq!( get_quorum_base_url(Network::Devnet, Some("example")).unwrap(), - "https://quorums.devnet.example.networks.dash.org" + "https://quorums.example.networks.dash.org" ); } diff --git a/packages/wasm-sdk/src/context_provider.rs b/packages/wasm-sdk/src/context_provider.rs index fbb7fc74a70..53870d3e21b 100644 --- a/packages/wasm-sdk/src/context_provider.rs +++ b/packages/wasm-sdk/src/context_provider.rs @@ -18,8 +18,9 @@ pub struct WasmContext {} /// /// Holds pre-fetched quorum keys and discovered masternode addresses for /// proof verification and network connectivity. Create one via the async -/// `prefetchMainnet()`, `prefetchTestnet()`, or `prefetchLocal()` factory -/// methods, then pass it to a builder via `withTrustedContext()`. +/// `prefetchMainnet()`, `prefetchTestnet()`, `prefetchDevnet()`, or +/// `prefetchLocal()` factory methods, then pass it to a builder via +/// `withTrustedContext()`. #[wasm_bindgen] #[derive(Clone)] pub struct WasmTrustedContext { @@ -35,7 +36,7 @@ impl ContextProvider for WasmContext { _core_chain_locked_height: u32, ) -> Result<[u8; 48], ContextProviderError> { Err(ContextProviderError::Generic( - "Non-trusted mode is not supported in WASM. Please use the trusted SDK builders (new_mainnet_trusted or new_testnet_trusted) instead.".to_string() + "Non-trusted mode is not supported in WASM. Please construct a WasmTrustedContext via prefetchMainnet/prefetchTestnet/prefetchDevnet/prefetchLocal and attach it with WasmSdkBuilder.withTrustedContext().".to_string() )) } @@ -166,6 +167,72 @@ impl WasmTrustedContext { }) } + /// Pre-fetch quorum keys and masternode addresses for a devnet. + /// + /// `devnet_name` is the short name of the devnet (e.g. `"paloma"`). The + /// quorum base URL is derived as `https://quorums..networks.dash.org`. + /// + /// Returns a ready-to-use `WasmTrustedContext` that can be passed to + /// `WasmSdkBuilder.newDevnet().withTrustedContext(context)`. + #[wasm_bindgen(js_name = "prefetchDevnet")] + pub async fn prefetch_devnet(devnet_name: String) -> Result { + let inner = rs_sdk_trusted_context_provider::TrustedHttpContextProvider::new( + dash_sdk::dpp::dashcore::Network::Devnet, + Some(devnet_name), + std::num::NonZeroUsize::new(100).unwrap(), + ) + .map_err(|e| WasmSdkError::generic(format!("Failed to create context provider: {}", e)))? + .with_refetch_if_not_found(false); + + let inner = Arc::new(inner); + + inner + .update_quorum_caches() + .await + .map_err(|e| WasmSdkError::generic(format!("Failed to prefetch quorums: {}", e)))?; + + let discovered_addresses = Self::fetch_addresses_from(&inner).await?; + + Ok(WasmTrustedContext { + inner, + discovered_addresses, + }) + } + + /// Pre-fetch quorum keys and masternode addresses for a devnet using a + /// fully-specified quorum base URL. + /// + /// Use this when the default + /// `https://quorums..networks.dash.org` URL produced by + /// `prefetchDevnet` is not yet deployed for a devnet, or when pointing + /// at a non-standard quorums endpoint. + #[wasm_bindgen(js_name = "prefetchDevnetWithUrl")] + pub async fn prefetch_devnet_with_url( + base_url: String, + ) -> Result { + let inner = rs_sdk_trusted_context_provider::TrustedHttpContextProvider::new_with_url( + dash_sdk::dpp::dashcore::Network::Devnet, + base_url, + std::num::NonZeroUsize::new(100).unwrap(), + ) + .map_err(|e| WasmSdkError::generic(format!("Failed to create context provider: {}", e)))? + .with_refetch_if_not_found(false); + + let inner = Arc::new(inner); + + inner + .update_quorum_caches() + .await + .map_err(|e| WasmSdkError::generic(format!("Failed to prefetch quorums: {}", e)))?; + + let discovered_addresses = Self::fetch_addresses_from(&inner).await?; + + Ok(WasmTrustedContext { + inner, + discovered_addresses, + }) + } + /// Pre-fetch quorum keys and masternode addresses for a local network. /// /// Uses the default local quorum sidecar URL (`http://127.0.0.1:22444`). diff --git a/packages/wasm-sdk/src/sdk.rs b/packages/wasm-sdk/src/sdk.rs index d88a83844d8..57801f38475 100644 --- a/packages/wasm-sdk/src/sdk.rs +++ b/packages/wasm-sdk/src/sdk.rs @@ -175,7 +175,7 @@ impl WasmSdkBuilder { /// /// # Arguments /// * `addresses` - Array of HTTPS URLs (e.g., ["https://127.0.0.1:1443"]) - /// * `network` - Network identifier: "mainnet", "testnet" or "local" + /// * `network` - Network identifier: "mainnet", "testnet", "devnet", or "local" #[wasm_bindgen(js_name = "withAddresses")] pub fn new_with_addresses( addresses: Vec, @@ -205,10 +205,11 @@ impl WasmSdkBuilder { let network = match network.to_lowercase().as_str() { "mainnet" => Network::Mainnet, "testnet" => Network::Testnet, + "devnet" => Network::Devnet, "local" => Network::Regtest, _ => { return Err(WasmSdkError::invalid_argument(format!( - "Invalid network '{}'. Expected: mainnet, testnet or local", + "Invalid network '{}'. Expected: mainnet, testnet, devnet, or local", network ))); } @@ -245,6 +246,25 @@ impl WasmSdkBuilder { } } + /// Create a new SdkBuilder preconfigured for a devnet. + /// + /// Devnets have no built-in default address list. The returned builder + /// is expected to be paired with either explicit addresses (via the + /// `withAddresses` variant) or a `WasmTrustedContext` from + /// `WasmTrustedContext.prefetchDevnet(name)`, whose discovered addresses + /// will be substituted via `withTrustedContext`. + #[wasm_bindgen(js_name = "newDevnet")] + pub fn new_devnet() -> Self { + let sdk_builder = SdkBuilder::new(dash_sdk::sdk::AddressList::default()) + .with_network(dash_sdk::dpp::dashcore::Network::Devnet) + .with_context_provider(WasmContext {}); + + Self { + inner: sdk_builder, + trusted_context: None, + } + } + /// Create a new SdkBuilder preconfigured for a local network using default dashmate gateway. #[wasm_bindgen(js_name = "local")] pub fn new_local() -> Self { From df019bb769c122e2c8a0b18a0f3188fb776bf2ea Mon Sep 17 00:00:00 2001 From: pasta Date: Tue, 26 May 2026 21:34:22 -0500 Subject: [PATCH 02/10] fix(wasm-sdk): update builder tests + devnetName docstrings for devnet support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two wasm-sdk unit tests assumed devnet was rejected by WasmSdkBuilder.withAddresses and pinned the old error message — replace with one that verifies devnet is now accepted, and update the remaining assertion to match the new "mainnet, testnet, devnet, or local" list. Tighten the devnetName field comment and EvoSDK.devnet JSDoc to match the constructor's actual rules: devnetName is required only for trusted devnet (used to derive the quorum URL); non-trusted devnet requires explicit addresses. --- packages/js-evo-sdk/src/sdk.ts | 11 +++++++---- .../tests/unit/builder-with-addresses.spec.ts | 18 +++++++----------- 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/packages/js-evo-sdk/src/sdk.ts b/packages/js-evo-sdk/src/sdk.ts index 696a45b5735..e4a0f6d604d 100644 --- a/packages/js-evo-sdk/src/sdk.ts +++ b/packages/js-evo-sdk/src/sdk.ts @@ -39,7 +39,9 @@ export interface EvoSDKOptions extends ConnectionOptions { // Example: ['https://127.0.0.1:1443', 'https://192.168.1.100:1443'] addresses?: string[]; // Short name of the devnet (e.g. 'paloma'). Required when network === 'devnet' - // unless explicit addresses are provided AND trusted is false. + // AND trusted === true (used to derive the quorum URL). When trusted === false, + // explicit `addresses` are mandatory and `devnetName` alone is not sufficient + // — no masternode addresses can be discovered without a trusted context. devnetName?: string; // Optional override for the trusted devnet quorum base URL. When omitted, // the URL is derived as `https://quorums..networks.dash.org`. @@ -221,9 +223,10 @@ export class EvoSDK { /** * Create an EvoSDK instance configured for a devnet, without trusted-context - * proof verification. Requires either `devnetName` or explicit `addresses` - * in `options`. Proof-bearing queries will fail unless paired with a - * trusted context — for proof verification on devnet, use + * proof verification. Requires explicit `addresses` in `options` — + * `devnetName` alone is not sufficient in non-trusted mode, since no + * masternode addresses can be discovered without a trusted context. + * Proof-bearing queries will fail; for proof verification on devnet, use * `EvoSDK.devnetTrusted` instead. */ static devnet(devnetName: string, options: ConnectionOptions & { addresses?: string[] } = {}): EvoSDK { diff --git a/packages/wasm-sdk/tests/unit/builder-with-addresses.spec.ts b/packages/wasm-sdk/tests/unit/builder-with-addresses.spec.ts index 92f27be78ab..a60d2a42f1b 100644 --- a/packages/wasm-sdk/tests/unit/builder-with-addresses.spec.ts +++ b/packages/wasm-sdk/tests/unit/builder-with-addresses.spec.ts @@ -70,16 +70,12 @@ describe('WasmSdkBuilder', () => { }); describe('network validation', () => { - it('should reject devnet', async () => { - try { - sdk.WasmSdkBuilder.withAddresses( - [TEST_ADDRESS_1], - 'devnet', - ); - expect.fail('Should have thrown error for devnet'); - } catch (error) { - expect(error.message).to.include('mainnet, testnet or local'); - } + it('should accept devnet', () => { + const builder = sdk.WasmSdkBuilder.withAddresses( + [TEST_ADDRESS_1], + 'devnet', + ); + expect(builder).to.be.an.instanceof(sdk.WasmSdkBuilder); }); it('should reject invalid network name', async () => { @@ -90,7 +86,7 @@ describe('WasmSdkBuilder', () => { ); expect.fail('Should have thrown error for invalid network'); } catch (error) { - expect(error.message).to.include('mainnet, testnet or local'); + expect(error.message).to.include('mainnet, testnet, devnet, or local'); } }); From d2a7c1b810b62aad12da0b5ac0802b6b94f2ae62 Mon Sep 17 00:00:00 2001 From: pasta Date: Tue, 26 May 2026 21:56:09 -0500 Subject: [PATCH 03/10] feat(wasm-sdk): allow quorumUrl override on mainnet/testnet trusted contexts Custom quorum endpoints are a legitimate testing case for every network, not just devnet. Add prefetchMainnetWithUrl / prefetchTestnetWithUrl mirroring the local/devnet pattern, refactor the four prefetch variants through a shared prefetch_for helper, and let js-evo-sdk's quorumUrl option flow through to whichever network is selected. Constructor guards relaxed: quorumUrl is now valid for any trusted network, and a trusted devnet may be constructed from quorumUrl alone (devnetName is only needed when the URL has to be derived from the name). --- packages/js-evo-sdk/src/sdk.ts | 52 ++++---- packages/js-evo-sdk/tests/unit/sdk.spec.ts | 19 ++- packages/wasm-sdk/src/context_provider.rs | 138 +++++++++------------ 3 files changed, 105 insertions(+), 104 deletions(-) diff --git a/packages/js-evo-sdk/src/sdk.ts b/packages/js-evo-sdk/src/sdk.ts index e4a0f6d604d..5e19e29025a 100644 --- a/packages/js-evo-sdk/src/sdk.ts +++ b/packages/js-evo-sdk/src/sdk.ts @@ -43,9 +43,12 @@ export interface EvoSDKOptions extends ConnectionOptions { // explicit `addresses` are mandatory and `devnetName` alone is not sufficient // — no masternode addresses can be discovered without a trusted context. devnetName?: string; - // Optional override for the trusted devnet quorum base URL. When omitted, - // the URL is derived as `https://quorums..networks.dash.org`. - // Only consulted when trusted === true && network === 'devnet'. + // Optional override for the trusted-context quorum base URL. When omitted, + // the URL is the network's default (e.g. + // `https://quorums..networks.dash.org` for devnet, + // `https://quorums.testnet.networks.dash.org` for testnet, etc.). + // Only consulted when trusted === true. Useful for pointing at a staging, + // self-hosted, or not-yet-deployed quorums endpoint. quorumUrl?: string; } @@ -72,20 +75,16 @@ export class EvoSDK { if (network === 'devnet') { const hasAddresses = !!(addresses && addresses.length > 0); - if (!devnetName && !hasAddresses) { - throw new Error("EvoSDK: network 'devnet' requires either devnetName or explicit addresses"); - } - if (trusted && !devnetName) { - throw new Error("EvoSDK: trusted devnet requires devnetName (used to derive the quorum URL)"); - } - if (!trusted && !hasAddresses) { + if (trusted) { + if (!devnetName && !quorumUrl) { + throw new Error("EvoSDK: trusted devnet requires devnetName (to derive the quorum URL) or an explicit quorumUrl"); + } + } else if (!hasAddresses) { throw new Error("EvoSDK: non-trusted devnet requires explicit addresses (no addresses can be discovered without a trusted context)"); } - if (quorumUrl && !trusted) { - throw new Error("EvoSDK: quorumUrl is only meaningful when trusted === true"); - } - } else if (quorumUrl) { - throw new Error("EvoSDK: quorumUrl is only valid when network === 'devnet'"); + } + if (quorumUrl && !trusted) { + throw new Error("EvoSDK: quorumUrl is only meaningful when trusted === true"); } this.options = { network, trusted, addresses, devnetName, quorumUrl, ...connection }; @@ -133,18 +132,25 @@ export class EvoSDK { let context: wasm.WasmTrustedContext | undefined; if (trusted) { if (network === 'mainnet') { - context = await wasm.WasmTrustedContext.prefetchMainnet(); + context = quorumUrl + ? await wasm.WasmTrustedContext.prefetchMainnetWithUrl(quorumUrl) + : await wasm.WasmTrustedContext.prefetchMainnet(); } else if (network === 'testnet') { - context = await wasm.WasmTrustedContext.prefetchTestnet(); + context = quorumUrl + ? await wasm.WasmTrustedContext.prefetchTestnetWithUrl(quorumUrl) + : await wasm.WasmTrustedContext.prefetchTestnet(); } else if (network === 'local') { - context = await wasm.WasmTrustedContext.prefetchLocal(); + context = quorumUrl + ? await wasm.WasmTrustedContext.prefetchLocalWithUrl(quorumUrl) + : await wasm.WasmTrustedContext.prefetchLocal(); } else if (network === 'devnet') { - if (!devnetName) { - throw new Error("EvoSDK: trusted devnet requires devnetName"); + if (quorumUrl) { + context = await wasm.WasmTrustedContext.prefetchDevnetWithUrl(quorumUrl); + } else if (devnetName) { + context = await wasm.WasmTrustedContext.prefetchDevnet(devnetName); + } else { + throw new Error("EvoSDK: trusted devnet requires devnetName or quorumUrl"); } - context = quorumUrl - ? await wasm.WasmTrustedContext.prefetchDevnetWithUrl(quorumUrl) - : await wasm.WasmTrustedContext.prefetchDevnet(devnetName); } else { throw new Error(`Unknown network: ${network}`); } diff --git a/packages/js-evo-sdk/tests/unit/sdk.spec.ts b/packages/js-evo-sdk/tests/unit/sdk.spec.ts index 14f32dc93c8..a5a14bf1654 100644 --- a/packages/js-evo-sdk/tests/unit/sdk.spec.ts +++ b/packages/js-evo-sdk/tests/unit/sdk.spec.ts @@ -278,9 +278,22 @@ describe('EvoSDK', () => { })).to.throw(/quorumUrl/); }); - it('should reject quorumUrl on non-devnet networks', () => { - expect(() => new EvoSDK({ network: 'testnet', trusted: true, quorumUrl: 'https://x' } as any)) - .to.throw(/quorumUrl/); + it('should accept quorumUrl on trusted testnet (override)', () => { + const sdk = new EvoSDK({ network: 'testnet', trusted: true, quorumUrl: 'https://x' }); + expect(sdk.options.quorumUrl).to.equal('https://x'); + expect(sdk.options.trusted).to.be.true(); + }); + + it('should accept quorumUrl on trusted mainnet (override)', () => { + const sdk = new EvoSDK({ network: 'mainnet', trusted: true, quorumUrl: 'https://x' }); + expect(sdk.options.quorumUrl).to.equal('https://x'); + expect(sdk.options.network).to.equal('mainnet'); + }); + + it('should accept trusted devnet with only quorumUrl (no devnetName)', () => { + const sdk = new EvoSDK({ network: 'devnet', trusted: true, quorumUrl: 'https://x' }); + expect(sdk.options.quorumUrl).to.equal('https://x'); + expect(sdk.options.devnetName).to.be.undefined(); }); }); }); diff --git a/packages/wasm-sdk/src/context_provider.rs b/packages/wasm-sdk/src/context_provider.rs index 53870d3e21b..c86a3074f28 100644 --- a/packages/wasm-sdk/src/context_provider.rs +++ b/packages/wasm-sdk/src/context_provider.rs @@ -115,27 +115,22 @@ impl WasmTrustedContext { /// `WasmSdkBuilder.mainnet().withTrustedContext(context)`. #[wasm_bindgen(js_name = "prefetchMainnet")] pub async fn prefetch_mainnet() -> Result { - let inner = rs_sdk_trusted_context_provider::TrustedHttpContextProvider::new( + Self::prefetch_for(dash_sdk::dpp::dashcore::Network::Mainnet, None, None).await + } + + /// Pre-fetch quorum keys and masternode addresses for mainnet using a + /// fully-specified quorum base URL (useful for testing against a staging + /// or self-hosted quorums endpoint). + #[wasm_bindgen(js_name = "prefetchMainnetWithUrl")] + pub async fn prefetch_mainnet_with_url( + base_url: String, + ) -> Result { + Self::prefetch_for( dash_sdk::dpp::dashcore::Network::Mainnet, None, - std::num::NonZeroUsize::new(100).unwrap(), + Some(base_url), ) - .map_err(|e| WasmSdkError::generic(format!("Failed to create context provider: {}", e)))? - .with_refetch_if_not_found(false); - - let inner = Arc::new(inner); - - inner - .update_quorum_caches() - .await - .map_err(|e| WasmSdkError::generic(format!("Failed to prefetch quorums: {}", e)))?; - - let discovered_addresses = Self::fetch_addresses_from(&inner).await?; - - Ok(WasmTrustedContext { - inner, - discovered_addresses, - }) + .await } /// Pre-fetch quorum keys and masternode addresses for testnet. @@ -144,27 +139,22 @@ impl WasmTrustedContext { /// `WasmSdkBuilder.testnet().withTrustedContext(context)`. #[wasm_bindgen(js_name = "prefetchTestnet")] pub async fn prefetch_testnet() -> Result { - let inner = rs_sdk_trusted_context_provider::TrustedHttpContextProvider::new( + Self::prefetch_for(dash_sdk::dpp::dashcore::Network::Testnet, None, None).await + } + + /// Pre-fetch quorum keys and masternode addresses for testnet using a + /// fully-specified quorum base URL (useful for testing against a staging + /// or self-hosted quorums endpoint). + #[wasm_bindgen(js_name = "prefetchTestnetWithUrl")] + pub async fn prefetch_testnet_with_url( + base_url: String, + ) -> Result { + Self::prefetch_for( dash_sdk::dpp::dashcore::Network::Testnet, None, - std::num::NonZeroUsize::new(100).unwrap(), + Some(base_url), ) - .map_err(|e| WasmSdkError::generic(format!("Failed to create context provider: {}", e)))? - .with_refetch_if_not_found(false); - - let inner = Arc::new(inner); - - inner - .update_quorum_caches() - .await - .map_err(|e| WasmSdkError::generic(format!("Failed to prefetch quorums: {}", e)))?; - - let discovered_addresses = Self::fetch_addresses_from(&inner).await?; - - Ok(WasmTrustedContext { - inner, - discovered_addresses, - }) + .await } /// Pre-fetch quorum keys and masternode addresses for a devnet. @@ -176,27 +166,12 @@ impl WasmTrustedContext { /// `WasmSdkBuilder.newDevnet().withTrustedContext(context)`. #[wasm_bindgen(js_name = "prefetchDevnet")] pub async fn prefetch_devnet(devnet_name: String) -> Result { - let inner = rs_sdk_trusted_context_provider::TrustedHttpContextProvider::new( + Self::prefetch_for( dash_sdk::dpp::dashcore::Network::Devnet, Some(devnet_name), - std::num::NonZeroUsize::new(100).unwrap(), + None, ) - .map_err(|e| WasmSdkError::generic(format!("Failed to create context provider: {}", e)))? - .with_refetch_if_not_found(false); - - let inner = Arc::new(inner); - - inner - .update_quorum_caches() - .await - .map_err(|e| WasmSdkError::generic(format!("Failed to prefetch quorums: {}", e)))?; - - let discovered_addresses = Self::fetch_addresses_from(&inner).await?; - - Ok(WasmTrustedContext { - inner, - discovered_addresses, - }) + .await } /// Pre-fetch quorum keys and masternode addresses for a devnet using a @@ -210,27 +185,12 @@ impl WasmTrustedContext { pub async fn prefetch_devnet_with_url( base_url: String, ) -> Result { - let inner = rs_sdk_trusted_context_provider::TrustedHttpContextProvider::new_with_url( + Self::prefetch_for( dash_sdk::dpp::dashcore::Network::Devnet, - base_url, - std::num::NonZeroUsize::new(100).unwrap(), + None, + Some(base_url), ) - .map_err(|e| WasmSdkError::generic(format!("Failed to create context provider: {}", e)))? - .with_refetch_if_not_found(false); - - let inner = Arc::new(inner); - - inner - .update_quorum_caches() - .await - .map_err(|e| WasmSdkError::generic(format!("Failed to prefetch quorums: {}", e)))?; - - let discovered_addresses = Self::fetch_addresses_from(&inner).await?; - - Ok(WasmTrustedContext { - inner, - discovered_addresses, - }) + .await } /// Pre-fetch quorum keys and masternode addresses for a local network. @@ -250,11 +210,35 @@ impl WasmTrustedContext { pub async fn prefetch_local_with_url( base_url: &str, ) -> Result { - let inner = rs_sdk_trusted_context_provider::TrustedHttpContextProvider::new_with_url( + Self::prefetch_for( dash_sdk::dpp::dashcore::Network::Regtest, - base_url.to_string(), - std::num::NonZeroUsize::new(100).unwrap(), + None, + Some(base_url.to_string()), ) + .await + } +} + +impl WasmTrustedContext { + /// Shared constructor used by every `prefetch*` factory. When `base_url` + /// is `Some`, it overrides the default URL derived from `network` + + /// `devnet_name` (the validator inside `new_with_url` still runs). + async fn prefetch_for( + network: dash_sdk::dpp::dashcore::Network, + devnet_name: Option, + base_url: Option, + ) -> Result { + let cache_size = std::num::NonZeroUsize::new(100).unwrap(); + let inner = match base_url { + Some(url) => rs_sdk_trusted_context_provider::TrustedHttpContextProvider::new_with_url( + network, url, cache_size, + ), + None => rs_sdk_trusted_context_provider::TrustedHttpContextProvider::new( + network, + devnet_name, + cache_size, + ), + } .map_err(|e| WasmSdkError::generic(format!("Failed to create context provider: {}", e)))? .with_refetch_if_not_found(false); @@ -272,9 +256,7 @@ impl WasmTrustedContext { discovered_addresses, }) } -} -impl WasmTrustedContext { /// Fetch masternode addresses from the trusted provider and convert to `Vec
`. async fn fetch_addresses_from( inner: &rs_sdk_trusted_context_provider::TrustedHttpContextProvider, From 586df73c33cc13aaec1e8ca00c0ee3679a90cc25 Mon Sep 17 00:00:00 2001 From: pasta Date: Tue, 26 May 2026 22:15:16 -0500 Subject: [PATCH 04/10] fix(trusted-context): reject reserved devnet names and plaintext trust roots Two guardrails closing security gaps surfaced by review on the flattened devnet URL shape: 1. get_quorum_base_url now rejects reserved devnet names (mainnet/testnet/devnet/local/regtest, case-insensitive). The old https://quorums.devnet.... pattern was structurally namespaced; the new https://quorums.... pattern shares the namespace with non-devnet networks, so without this guard EvoSDK.devnetTrusted('mainnet') would silently fetch real mainnet quorum keys into a Network::Devnet session. 2. new_with_url now requires HTTPS unless the host is loopback (127.0.0.1/localhost/[::1]). The base URL is the SDK's root of trust for proof verification; plaintext over a non-loopback link would let an on-path attacker replace quorum keys and discovered masternode addresses while the UI displayed 'proof verified'. Adds four unit tests covering both rules. --- .../src/lib.rs | 14 +++ .../src/provider.rs | 115 ++++++++++++++++++ 2 files changed, 129 insertions(+) diff --git a/packages/rs-sdk-trusted-context-provider/src/lib.rs b/packages/rs-sdk-trusted-context-provider/src/lib.rs index 3f6043dfdfb..b1ec3f9afd0 100644 --- a/packages/rs-sdk-trusted-context-provider/src/lib.rs +++ b/packages/rs-sdk-trusted-context-provider/src/lib.rs @@ -44,6 +44,20 @@ pub fn get_quorum_base_url( "Devnet name cannot start or end with a hyphen".to_string(), )); } + // Reserved names that would alias the production / non-devnet quorum + // hostnames (e.g. "mainnet" => https://quorums.mainnet.networks.dash.org). + // The URL pattern shares its namespace with mainnet/testnet/local, so + // the validator is the only line of defense against a cross-network + // trust-root mixup labeled `Network::Devnet`. + if matches!( + name.to_ascii_lowercase().as_str(), + "mainnet" | "testnet" | "devnet" | "local" | "regtest" + ) { + return Err(TrustedContextProviderError::InvalidDevnetName(format!( + "Devnet name '{}' is reserved (would alias a non-devnet quorum hostname)", + name + ))); + } Ok(format!("https://quorums.{}.networks.dash.org", name)) } else { Err(TrustedContextProviderError::InvalidDevnetName( diff --git a/packages/rs-sdk-trusted-context-provider/src/provider.rs b/packages/rs-sdk-trusted-context-provider/src/provider.rs index 63a5c0d28c9..d20b29b1a32 100644 --- a/packages/rs-sdk-trusted-context-provider/src/provider.rs +++ b/packages/rs-sdk-trusted-context-provider/src/provider.rs @@ -86,6 +86,44 @@ struct MasternodeDiscoveryResponse { } impl TrustedHttpContextProvider { + /// Reject plaintext HTTP unless the host is a loopback address. + /// + /// The base URL is the SDK's root of trust for proof verification — quorum + /// public keys and discovered masternode addresses are fetched from it. + /// Allowing plaintext on a non-loopback link would let an on-path attacker + /// replace both, so HTTPS is required for any host outside `127.0.0.1` / + /// `localhost` / `[::1]`. + fn verify_secure_or_loopback(url: &str) -> Result<(), TrustedContextProviderError> { + let parsed_url = Url::parse(url).map_err(|e| { + TrustedContextProviderError::NetworkError(format!("Invalid URL: {}", e)) + })?; + let scheme = parsed_url.scheme(); + if scheme == "https" { + return Ok(()); + } + if scheme != "http" { + return Err(TrustedContextProviderError::NetworkError(format!( + "Unsupported URL scheme '{}' (expected https, or http for loopback)", + scheme + ))); + } + let host = parsed_url.host_str().ok_or_else(|| { + TrustedContextProviderError::NetworkError("URL has no host".to_string()) + })?; + let host_lc = host.to_ascii_lowercase(); + let is_loopback = host_lc == "localhost" + || host_lc == "127.0.0.1" + || host_lc == "[::1]" + || host_lc == "::1"; + if !is_loopback { + return Err(TrustedContextProviderError::NetworkError(format!( + "Plaintext HTTP base URL is only permitted for loopback hosts; got '{}'", + host + ))); + } + Ok(()) + } + /// Verify that a URL's domain resolves #[cfg(all(not(target_arch = "wasm32"), not(target_os = "ios")))] fn verify_domain_resolves(url: &str) -> Result<(), TrustedContextProviderError> { @@ -139,6 +177,12 @@ impl TrustedHttpContextProvider { base_url: String, cache_size: NonZeroUsize, ) -> Result { + // The base URL is the SDK's root of trust for proof verification + // (quorum public keys) and network connectivity (discovered masternode + // addresses). Require HTTPS unless the host is loopback — plaintext + // over a non-loopback link lets an on-path attacker replace both. + Self::verify_secure_or_loopback(&base_url)?; + // Verify the domain resolves before proceeding (skip on WASM and iOS) #[cfg(all(not(target_arch = "wasm32"), not(target_os = "ios")))] Self::verify_domain_resolves(&base_url)?; @@ -833,6 +877,77 @@ mod tests { assert!(get_quorum_base_url(Network::Devnet, Some("TEST123")).is_ok()); } + #[test] + fn test_reserved_devnet_names_rejected() { + // Names that would alias non-devnet quorum hostnames must be rejected. + for reserved in ["mainnet", "testnet", "devnet", "local", "regtest"] { + assert!( + matches!( + get_quorum_base_url(Network::Devnet, Some(reserved)), + Err(TrustedContextProviderError::InvalidDevnetName(_)) + ), + "expected '{}' to be rejected as a reserved devnet name", + reserved + ); + } + // Case-insensitive: uppercase variants must also be rejected. + for reserved in ["Mainnet", "TESTNET", "DevNet"] { + assert!( + matches!( + get_quorum_base_url(Network::Devnet, Some(reserved)), + Err(TrustedContextProviderError::InvalidDevnetName(_)) + ), + "expected '{}' to be rejected as a reserved devnet name (case-insensitive)", + reserved + ); + } + } + + #[test] + fn test_new_with_url_rejects_plaintext_non_loopback() { + // Plaintext HTTP to a non-loopback host is rejected on every network. + let result = TrustedHttpContextProvider::new_with_url( + Network::Devnet, + "http://devnet.example.com".to_string(), + NonZeroUsize::new(10).unwrap(), + ); + assert!(matches!( + result, + Err(TrustedContextProviderError::NetworkError(_)) + )); + } + + #[test] + fn test_new_with_url_accepts_plaintext_loopback() { + // Plaintext HTTP to loopback is acceptable (used by local dashmate). + for host in ["127.0.0.1", "localhost", "[::1]"] { + let result = TrustedHttpContextProvider::new_with_url( + Network::Regtest, + format!("http://{}:22444", host), + NonZeroUsize::new(10).unwrap(), + ); + assert!( + result.is_ok(), + "expected http://{} loopback to be accepted, got {:?}", + host, + result.err() + ); + } + } + + #[test] + fn test_new_with_url_rejects_unknown_scheme() { + let result = TrustedHttpContextProvider::new_with_url( + Network::Devnet, + "ftp://example.com".to_string(), + NonZeroUsize::new(10).unwrap(), + ); + assert!(matches!( + result, + Err(TrustedContextProviderError::NetworkError(_)) + )); + } + #[test] fn test_known_contracts() { use dpp::version::PlatformVersion; From 8daa3d5a12a6ee85bb07927d1c13e2a9971a2346 Mon Sep 17 00:00:00 2001 From: pasta Date: Tue, 26 May 2026 22:17:44 -0500 Subject: [PATCH 05/10] revert(trusted-context): drop HTTPS-required check on new_with_url MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit new_with_url is an explicit escape hatch — the caller types the full URL and picks the scheme. The documented use case ("public DNS isn't deployed yet for this devnet") often coincides with "this early-stage devnet doesn't have a cert yet either," so HTTPS-only here actively blocks the legitimate workflow without protecting any naïve-user surface. Keeps the reserved-name guard from the prior commit (mainnet/testnet/devnet/local/regtest), which prevents typo-level cross-network footguns without restricting any legitimate use case. --- .../src/provider.rs | 88 ------------------- 1 file changed, 88 deletions(-) diff --git a/packages/rs-sdk-trusted-context-provider/src/provider.rs b/packages/rs-sdk-trusted-context-provider/src/provider.rs index d20b29b1a32..42fc2e9404e 100644 --- a/packages/rs-sdk-trusted-context-provider/src/provider.rs +++ b/packages/rs-sdk-trusted-context-provider/src/provider.rs @@ -86,44 +86,6 @@ struct MasternodeDiscoveryResponse { } impl TrustedHttpContextProvider { - /// Reject plaintext HTTP unless the host is a loopback address. - /// - /// The base URL is the SDK's root of trust for proof verification — quorum - /// public keys and discovered masternode addresses are fetched from it. - /// Allowing plaintext on a non-loopback link would let an on-path attacker - /// replace both, so HTTPS is required for any host outside `127.0.0.1` / - /// `localhost` / `[::1]`. - fn verify_secure_or_loopback(url: &str) -> Result<(), TrustedContextProviderError> { - let parsed_url = Url::parse(url).map_err(|e| { - TrustedContextProviderError::NetworkError(format!("Invalid URL: {}", e)) - })?; - let scheme = parsed_url.scheme(); - if scheme == "https" { - return Ok(()); - } - if scheme != "http" { - return Err(TrustedContextProviderError::NetworkError(format!( - "Unsupported URL scheme '{}' (expected https, or http for loopback)", - scheme - ))); - } - let host = parsed_url.host_str().ok_or_else(|| { - TrustedContextProviderError::NetworkError("URL has no host".to_string()) - })?; - let host_lc = host.to_ascii_lowercase(); - let is_loopback = host_lc == "localhost" - || host_lc == "127.0.0.1" - || host_lc == "[::1]" - || host_lc == "::1"; - if !is_loopback { - return Err(TrustedContextProviderError::NetworkError(format!( - "Plaintext HTTP base URL is only permitted for loopback hosts; got '{}'", - host - ))); - } - Ok(()) - } - /// Verify that a URL's domain resolves #[cfg(all(not(target_arch = "wasm32"), not(target_os = "ios")))] fn verify_domain_resolves(url: &str) -> Result<(), TrustedContextProviderError> { @@ -177,12 +139,6 @@ impl TrustedHttpContextProvider { base_url: String, cache_size: NonZeroUsize, ) -> Result { - // The base URL is the SDK's root of trust for proof verification - // (quorum public keys) and network connectivity (discovered masternode - // addresses). Require HTTPS unless the host is loopback — plaintext - // over a non-loopback link lets an on-path attacker replace both. - Self::verify_secure_or_loopback(&base_url)?; - // Verify the domain resolves before proceeding (skip on WASM and iOS) #[cfg(all(not(target_arch = "wasm32"), not(target_os = "ios")))] Self::verify_domain_resolves(&base_url)?; @@ -903,50 +859,6 @@ mod tests { } } - #[test] - fn test_new_with_url_rejects_plaintext_non_loopback() { - // Plaintext HTTP to a non-loopback host is rejected on every network. - let result = TrustedHttpContextProvider::new_with_url( - Network::Devnet, - "http://devnet.example.com".to_string(), - NonZeroUsize::new(10).unwrap(), - ); - assert!(matches!( - result, - Err(TrustedContextProviderError::NetworkError(_)) - )); - } - - #[test] - fn test_new_with_url_accepts_plaintext_loopback() { - // Plaintext HTTP to loopback is acceptable (used by local dashmate). - for host in ["127.0.0.1", "localhost", "[::1]"] { - let result = TrustedHttpContextProvider::new_with_url( - Network::Regtest, - format!("http://{}:22444", host), - NonZeroUsize::new(10).unwrap(), - ); - assert!( - result.is_ok(), - "expected http://{} loopback to be accepted, got {:?}", - host, - result.err() - ); - } - } - - #[test] - fn test_new_with_url_rejects_unknown_scheme() { - let result = TrustedHttpContextProvider::new_with_url( - Network::Devnet, - "ftp://example.com".to_string(), - NonZeroUsize::new(10).unwrap(), - ); - assert!(matches!( - result, - Err(TrustedContextProviderError::NetworkError(_)) - )); - } #[test] fn test_known_contracts() { From 1051e4e340e9d0afebfe29815a991c98d109971d Mon Sep 17 00:00:00 2001 From: pasta Date: Wed, 27 May 2026 09:03:18 -0500 Subject: [PATCH 06/10] style: cargo fmt --- packages/rs-sdk-trusted-context-provider/src/provider.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/rs-sdk-trusted-context-provider/src/provider.rs b/packages/rs-sdk-trusted-context-provider/src/provider.rs index 42fc2e9404e..48d7238c19b 100644 --- a/packages/rs-sdk-trusted-context-provider/src/provider.rs +++ b/packages/rs-sdk-trusted-context-provider/src/provider.rs @@ -859,7 +859,6 @@ mod tests { } } - #[test] fn test_known_contracts() { use dpp::version::PlatformVersion; From 3752ed723925194bb9362a10a15150fb041bdcc5 Mon Sep 17 00:00:00 2001 From: pasta Date: Wed, 27 May 2026 09:07:20 -0500 Subject: [PATCH 07/10] fix(trusted-context): require https for mainnet/testnet custom quorum URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR added prefetchMainnetWithUrl/prefetchTestnetWithUrl to support testing against staging or self-hosted quorums. Production networks have HTTPS deployed and there's no legitimate plaintext workflow on that surface — a typo into http:// silently weakens the SDK's root of trust (quorum public keys + discovered masternode addresses) and any MitM can forge proofs the SDK will then accept as verified. Devnet/Regtest keep the plaintext escape hatch (early devnets without certs, loopback sidecars) per the previous revert discussion. --- .../src/provider.rs | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/packages/rs-sdk-trusted-context-provider/src/provider.rs b/packages/rs-sdk-trusted-context-provider/src/provider.rs index 48d7238c19b..6ae9b33f720 100644 --- a/packages/rs-sdk-trusted-context-provider/src/provider.rs +++ b/packages/rs-sdk-trusted-context-provider/src/provider.rs @@ -139,6 +139,22 @@ impl TrustedHttpContextProvider { base_url: String, cache_size: NonZeroUsize, ) -> Result { + // The base URL is the SDK's root of trust for proof verification + // (quorum public keys) and network connectivity (discovered masternode + // addresses). For production networks (mainnet/testnet) there is no + // legitimate plaintext workflow — HTTPS is deployed — so refuse to + // hand a non-TLS quorum URL to the SDK. Devnet/Regtest keep the + // plaintext escape hatch (early-stage devnets without a cert yet, + // local sidecars on loopback). + if matches!(network, Network::Mainnet | Network::Testnet) + && !base_url.starts_with("https://") + { + return Err(TrustedContextProviderError::NetworkError(format!( + "Custom quorum URL for {:?} must use https://; got '{}'", + network, base_url + ))); + } + // Verify the domain resolves before proceeding (skip on WASM and iOS) #[cfg(all(not(target_arch = "wasm32"), not(target_os = "ios")))] Self::verify_domain_resolves(&base_url)?; @@ -833,6 +849,24 @@ mod tests { assert!(get_quorum_base_url(Network::Devnet, Some("TEST123")).is_ok()); } + #[test] + fn test_new_with_url_rejects_plaintext_for_production_networks() { + // Mainnet and testnet have HTTPS deployed; reject plaintext URLs to + // prevent silently weakening the trust root via a typo or misconfig. + for network in [Network::Mainnet, Network::Testnet] { + let result = TrustedHttpContextProvider::new_with_url( + network, + "http://example.com".to_string(), + NonZeroUsize::new(10).unwrap(), + ); + assert!( + matches!(result, Err(TrustedContextProviderError::NetworkError(_))), + "expected http:// to be rejected for {:?}", + network + ); + } + } + #[test] fn test_reserved_devnet_names_rejected() { // Names that would alias non-devnet quorum hostnames must be rejected. From 7c362c4070ae89d62ec65ec98c31a125938c1adb Mon Sep 17 00:00:00 2001 From: pasta Date: Wed, 27 May 2026 11:41:50 -0500 Subject: [PATCH 08/10] fix(js-evo-sdk): reject devnetName on non-devnet networks (typo guard) Symmetric with the existing quorumUrl-without-trusted guard: devnetName has no effect outside network === 'devnet', so silently accepting it masks typos like { network: 'testent', devnetName: 'paloma' } where the user meant 'devnet'. --- packages/js-evo-sdk/src/sdk.ts | 4 ++++ packages/js-evo-sdk/tests/unit/sdk.spec.ts | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/packages/js-evo-sdk/src/sdk.ts b/packages/js-evo-sdk/src/sdk.ts index 5e19e29025a..9cf8aba176f 100644 --- a/packages/js-evo-sdk/src/sdk.ts +++ b/packages/js-evo-sdk/src/sdk.ts @@ -82,6 +82,10 @@ export class EvoSDK { } else if (!hasAddresses) { throw new Error("EvoSDK: non-trusted devnet requires explicit addresses (no addresses can be discovered without a trusted context)"); } + } else if (devnetName) { + // Surface a likely typo (e.g. network: 'testent' + devnetName: 'paloma') + // — devnetName has no effect outside network === 'devnet'. + throw new Error("EvoSDK: devnetName is only valid when network === 'devnet'"); } if (quorumUrl && !trusted) { throw new Error("EvoSDK: quorumUrl is only meaningful when trusted === true"); diff --git a/packages/js-evo-sdk/tests/unit/sdk.spec.ts b/packages/js-evo-sdk/tests/unit/sdk.spec.ts index a5a14bf1654..b9ea275fc0f 100644 --- a/packages/js-evo-sdk/tests/unit/sdk.spec.ts +++ b/packages/js-evo-sdk/tests/unit/sdk.spec.ts @@ -290,6 +290,11 @@ describe('EvoSDK', () => { expect(sdk.options.network).to.equal('mainnet'); }); + it('should reject devnetName on non-devnet networks (typo guard)', () => { + expect(() => new EvoSDK({ network: 'testnet', devnetName: 'paloma' })) + .to.throw(/devnetName/); + }); + it('should accept trusted devnet with only quorumUrl (no devnetName)', () => { const sdk = new EvoSDK({ network: 'devnet', trusted: true, quorumUrl: 'https://x' }); expect(sdk.options.quorumUrl).to.equal('https://x'); From dd8cad309001c84cc6c37e7a9932a852e9a1535e Mon Sep 17 00:00:00 2001 From: pasta Date: Wed, 27 May 2026 11:43:35 -0500 Subject: [PATCH 09/10] fix(trusted-context): parse-based https check, positive tests, string consistency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three nitpicks from review: 1. HTTPS gate now parses with url::Url and compares scheme case-insensitively, accepting 'HTTPS://' and rejecting malformed URLs cleanly instead of a byte-prefix match. 2. Add positive-path tests asserting the gate does NOT reject https on mainnet/testnet or plaintext on devnet/regtest — guards against a future refactor broadening the gate silently. 3. Normalize prefetchLocalWithUrl to take String like the other three sibling factories. --- .../src/provider.rs | 96 ++++++++++++++++--- packages/wasm-sdk/src/context_provider.rs | 6 +- 2 files changed, 85 insertions(+), 17 deletions(-) diff --git a/packages/rs-sdk-trusted-context-provider/src/provider.rs b/packages/rs-sdk-trusted-context-provider/src/provider.rs index 6ae9b33f720..6769890dde6 100644 --- a/packages/rs-sdk-trusted-context-provider/src/provider.rs +++ b/packages/rs-sdk-trusted-context-provider/src/provider.rs @@ -146,13 +146,16 @@ impl TrustedHttpContextProvider { // hand a non-TLS quorum URL to the SDK. Devnet/Regtest keep the // plaintext escape hatch (early-stage devnets without a cert yet, // local sidecars on loopback). - if matches!(network, Network::Mainnet | Network::Testnet) - && !base_url.starts_with("https://") - { - return Err(TrustedContextProviderError::NetworkError(format!( - "Custom quorum URL for {:?} must use https://; got '{}'", - network, base_url - ))); + if matches!(network, Network::Mainnet | Network::Testnet) { + let parsed = Url::parse(&base_url).map_err(|e| { + TrustedContextProviderError::NetworkError(format!("Invalid URL: {}", e)) + })?; + if !parsed.scheme().eq_ignore_ascii_case("https") { + return Err(TrustedContextProviderError::NetworkError(format!( + "Custom quorum URL for {:?} must use https://; got '{}'", + network, base_url + ))); + } } // Verify the domain resolves before proceeding (skip on WASM and iOS) @@ -853,17 +856,82 @@ mod tests { fn test_new_with_url_rejects_plaintext_for_production_networks() { // Mainnet and testnet have HTTPS deployed; reject plaintext URLs to // prevent silently weakening the trust root via a typo or misconfig. - for network in [Network::Mainnet, Network::Testnet] { + // Mixed-case scheme must also be rejected (case-insensitive match). + for url in ["http://example.com", "HTTP://example.com"] { + for network in [Network::Mainnet, Network::Testnet] { + let result = TrustedHttpContextProvider::new_with_url( + network, + url.to_string(), + NonZeroUsize::new(10).unwrap(), + ); + match result { + Ok(_) => panic!("expected {} to be rejected for {:?}", url, network), + Err(e) => { + let msg = e.to_string(); + assert!( + msg.contains("must use https://"), + "expected HTTPS-gate error for {:?} + {}, got: {}", + network, + url, + msg + ); + } + } + } + } + } + + #[test] + fn test_new_with_url_does_not_reject_https_on_production_networks() { + // Positive path: an HTTPS URL must not trip the new HTTPS gate. + // The constructor may still fail downstream (DNS, on non-wasm/non-iOS + // builds), but if so the error must NOT be the HTTPS-gate variant. + // Mixed-case `HTTPS://` must also be accepted by the gate. + for url in [ + "https://example.com", + "HTTPS://example.com", + "https://example.com:8443/sub/path", + ] { + for network in [Network::Mainnet, Network::Testnet] { + let result = TrustedHttpContextProvider::new_with_url( + network, + url.to_string(), + NonZeroUsize::new(10).unwrap(), + ); + if let Err(e) = result { + let msg = e.to_string(); + assert!( + !msg.contains("must use https://"), + "HTTPS gate incorrectly rejected {} for {:?}: {}", + url, + network, + msg + ); + } + } + } + } + + #[test] + fn test_new_with_url_does_not_reject_plaintext_on_devnet_or_regtest() { + // Positive path: plaintext stays acceptable for devnet/regtest (early + // devnets without certs, loopback sidecars). The HTTPS gate must not + // contribute to any failure here. + for network in [Network::Devnet, Network::Regtest] { let result = TrustedHttpContextProvider::new_with_url( network, - "http://example.com".to_string(), + "http://127.0.0.1:22444".to_string(), NonZeroUsize::new(10).unwrap(), ); - assert!( - matches!(result, Err(TrustedContextProviderError::NetworkError(_))), - "expected http:// to be rejected for {:?}", - network - ); + if let Err(e) = result { + let msg = e.to_string(); + assert!( + !msg.contains("must use https://"), + "HTTPS gate incorrectly rejected http:// for {:?}: {}", + network, + msg + ); + } } } diff --git a/packages/wasm-sdk/src/context_provider.rs b/packages/wasm-sdk/src/context_provider.rs index c86a3074f28..fa210ce96d4 100644 --- a/packages/wasm-sdk/src/context_provider.rs +++ b/packages/wasm-sdk/src/context_provider.rs @@ -201,19 +201,19 @@ impl WasmTrustedContext { /// `WasmSdkBuilder.local().withTrustedContext(context)`. #[wasm_bindgen(js_name = "prefetchLocal")] pub async fn prefetch_local() -> Result { - Self::prefetch_local_with_url("http://127.0.0.1:22444").await + Self::prefetch_local_with_url("http://127.0.0.1:22444".to_string()).await } /// Pre-fetch quorum keys and masternode addresses for a local network /// using a custom quorum sidecar URL. #[wasm_bindgen(js_name = "prefetchLocalWithUrl")] pub async fn prefetch_local_with_url( - base_url: &str, + base_url: String, ) -> Result { Self::prefetch_for( dash_sdk::dpp::dashcore::Network::Regtest, None, - Some(base_url.to_string()), + Some(base_url), ) .await } From c32f2466d95ed2d1ea3ac4702148da5b2d371e1a Mon Sep 17 00:00:00 2001 From: pasta Date: Wed, 27 May 2026 12:54:48 -0500 Subject: [PATCH 10/10] docs(evo-sdk): document devnet factories and config options README config table now lists addresses/devnetName/quorumUrl and includes 'devnet' in the network union; factories paragraph adds EvoSDK.devnet/devnetTrusted with usage snippets. Book networks-and-environments page now covers four built-in networks with a dedicated Devnets section showing trusted, custom-URL, and non-trusted variants. --- book/src/evo-sdk/networks-and-environments.md | 38 ++++++++++++++++++- packages/js-evo-sdk/README.md | 19 +++++++++- 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/book/src/evo-sdk/networks-and-environments.md b/book/src/evo-sdk/networks-and-environments.md index 964aa714f06..a900b42e5c0 100644 --- a/book/src/evo-sdk/networks-and-environments.md +++ b/book/src/evo-sdk/networks-and-environments.md @@ -1,6 +1,6 @@ # Networks and Environments -The Evo SDK supports three built-in network configurations plus custom +The Evo SDK supports four built-in network configurations plus custom addresses for private or development networks. ## Built-in networks @@ -9,12 +9,48 @@ addresses for private or development networks. |---------|---------|---------------|----------| | **Testnet** | `EvoSDK.testnetTrusted()` | Automatic via seed nodes | Development and testing | | **Mainnet** | `EvoSDK.mainnetTrusted()` | Automatic via seed nodes | Production applications | +| **Devnet** | `EvoSDK.devnetTrusted(name)` | Automatic via quorums server | Long-lived shared devnets (e.g. `'paloma'`) | | **Local** | `EvoSDK.localTrusted()` | `127.0.0.1:1443` | Docker-based local development | For each network, the SDK discovers DAPI endpoints from seed nodes and rotates between them automatically. Failed nodes are temporarily banned so the SDK retries against healthy nodes. +## Devnets + +Devnets are long-lived shared development networks identified by a short name +(e.g. `'paloma'`). The trusted context derives the quorum base URL from the +name as `https://quorums..networks.dash.org`: + +```typescript +const sdk = EvoSDK.devnetTrusted('paloma'); +await sdk.connect(); +``` + +If the public quorums DNS for a devnet isn't deployed yet, override the URL: + +```typescript +const sdk = EvoSDK.devnetTrusted('paloma', { + quorumUrl: 'https://quorums.staging.example/', +}); +await sdk.connect(); +``` + +For a devnet without any trusted context (no proof verification), supply +explicit DAPI addresses: + +```typescript +const sdk = EvoSDK.devnet('paloma', { + addresses: ['https://10.0.0.5:1443'], +}); +await sdk.connect(); +``` + +Behind the scenes these factories call `WasmTrustedContext.prefetchDevnet(name)` +or `prefetchDevnetWithUrl(url)`; the same shape is available on +`prefetchMainnetWithUrl` / `prefetchTestnetWithUrl` for staging endpoints +(production networks must use `https://`). + ## Local development with Docker When running a local Platform network via diff --git a/packages/js-evo-sdk/README.md b/packages/js-evo-sdk/README.md index 324a6c05413..1eac5c8ac31 100644 --- a/packages/js-evo-sdk/README.md +++ b/packages/js-evo-sdk/README.md @@ -45,14 +45,29 @@ console.log('Current epoch:', epoch.index); | Option | Type | Default | Notes | |--------|------|---------|-------| -| `network` | `'testnet' \| 'mainnet' \| 'local'` | `'testnet'` | Target network. | +| `network` | `'testnet' \| 'mainnet' \| 'local' \| 'devnet'` | `'testnet'` | Target network. | | `trusted` | `boolean` | `false` | When `true`, pre-fetches quorum keys for proof verification. Required for default query methods. | +| `addresses` | `string[]` | — | Seed masternode addresses. Required for non-trusted devnet; optional for other networks (replaces built-in defaults). | +| `devnetName` | `string` | — | Short name of the devnet (e.g. `'paloma'`). Required when `network: 'devnet'` and `trusted: true` (used to derive the quorum URL); ignored otherwise — only valid when `network === 'devnet'`. | +| `quorumUrl` | `string` | — | Override the trusted-context quorum base URL. Only meaningful when `trusted: true`. Useful for staging endpoints or devnets where the public DNS isn't deployed yet. | | `proofs` | `boolean` | `true` | Setting to `false` disables proof requests where supported, but unproved mode is limited — several query paths (e.g. document fetches) force proofs regardless, and some query builders reject the unproved path. Mainly intended for mock/offline replay. | | `version` | `number` | latest | Platform protocol version. | | `logs` | `string` | — | Tracing/log filter for the underlying Wasm SDK. Accepts simple levels (`'info'`, `'debug'`, …) or a full `EnvFilter` string. | | `settings` | `{ connectTimeoutMs?, timeoutMs?, retries?, banFailedAddress? }` | — | DAPI client transport settings. | -Preset factories are available as convenience: `EvoSDK.testnet()`, `EvoSDK.mainnet()`, `EvoSDK.testnetTrusted()`, `EvoSDK.mainnetTrusted()`, `EvoSDK.local()`, and `EvoSDK.localTrusted()` (the last two target a dashmate local node). +Preset factories are available as convenience: `EvoSDK.testnet()`, `EvoSDK.mainnet()`, `EvoSDK.testnetTrusted()`, `EvoSDK.mainnetTrusted()`, `EvoSDK.local()`, `EvoSDK.localTrusted()` (the last two target a dashmate local node), and the devnet factories `EvoSDK.devnet(name, options)` / `EvoSDK.devnetTrusted(name, options)`. + +```typescript +// Trusted devnet — quorum URL auto-derived from the devnet name. +const sdk = EvoSDK.devnetTrusted('paloma'); +await sdk.connect(); + +// Non-trusted devnet — explicit addresses required (no quorum context). +const local = EvoSDK.devnet('paloma', { + addresses: ['https://10.0.0.5:1443'], +}); +await local.connect(); +``` Two static helpers are also exported: