From e7004513e2f48a9e8b460f1c5e0add2e0655731c Mon Sep 17 00:00:00 2001 From: Wiktor Starczewski Date: Mon, 29 Jun 2026 19:02:40 +0200 Subject: [PATCH 1/4] feat(web,react): expose client endpoint and derive RPC from it Add `WebClient.endpoint()` and `RpcClient.endpoint()` (backed by miden-client's new `NodeRpcClient::endpoint` / `Client::rpc_endpoint`), returning the node URL the client is configured to talk to. `WebClient` caches it at creation so it can be read synchronously without locking the async `inner` cell. The React SDK now derives the RPC endpoint from the live client instead of a separately-stored `config.rpcUrl` copy: `useAssetMetadata` and `accountBech32` read `client.endpoint()`. `client` exists only once the provider has finished initializing, so a configured (e.g. devnet) app no longer reads an unset URL and falls back to testnet during the init window; addresses are tagged for the right network, falling back to the raw id when the network can't be confirmed. Supersedes the gating approach in #189 (addresses the reviewer's "derive the RPC from the client" feedback). Requires miden-client 0xMiden/miden-client#2291. --- CHANGELOG.md | 1 + crates/web-client/src/lib.rs | 17 +++++++++ crates/web-client/src/rpc_client/mod.rs | 7 ++++ packages/react-sdk/src/__tests__/setup.ts | 1 + .../react-sdk/src/hooks/useAssetMetadata.ts | 11 ++++-- packages/react-sdk/src/utils/accountBech32.ts | 36 ++++++++++++------- 6 files changed, 58 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fbf8ff48..abfd86f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Enhancements +* [FEATURE][web,react] Added `WebClient.endpoint()` and `RpcClient.endpoint()`, returning the node URL the client is configured to talk to. The React SDK now derives the RPC endpoint from the live client instead of a separately-stored config copy: `useAssetMetadata` and `accountBech32` read `client.endpoint()`, so a configured (e.g. devnet) app no longer falls back to testnet during the provider's init window, and addresses are tagged for the right network (raw id when undetermined). ([#PR](https://github.com/0xMiden/web-sdk/pull/PR), companion: [0xMiden/miden-client#2291](https://github.com/0xMiden/miden-client/pull/2291)) * [FEATURE][web,react] The 0.14.x line's mobile/MT proving surface is available on the 0.15 series (forward-ported from `main`): `TransactionProver.newCallbackProver(jsFn)` (route prove to a native iOS/Android plugin; wire format matches `RemoteTransactionProver`), `ClientOptions.useWorker?: boolean` / `MidenConfig.useWorker` (opt out of the Web Worker shim — required for callback provers, whose closure cannot cross the worker boundary), `MidenClient._withInnerWebClient(fn)` with the depth-tracked re-entrancy fix, the multi-threaded WASM build at the `@miden-sdk/miden-sdk/mt` + `/mt/lazy` (and `@miden-sdk/react/mt` + `/mt/lazy`) subpaths, and the `miden-mobile-prover` C-ABI crate — now built against the 0.15 protocol, so its `TransactionInputs`/`ProvenTransaction` wire format requires a matching 0.15 SDK (native binaries built from 0.14 do not interoperate). ([#149](https://github.com/0xMiden/web-sdk/pull/149), [#152](https://github.com/0xMiden/web-sdk/pull/152), [#134](https://github.com/0xMiden/web-sdk/pull/134)) * [FEATURE][web] Added `BlockHeader.feeFaucetId()` — the account ID of the fungible faucet whose assets pay transaction verification fees, read from the block's on-chain fee parameters. This is the 0.15 spelling of the 0.14 line's `BlockHeader.nativeAssetId()` (the underlying protocol field was renamed `native_asset_id` → `fee_faucet_id`); consumers discovering the fee/native asset per network should migrate to the new name. diff --git a/crates/web-client/src/lib.rs b/crates/web-client/src/lib.rs index e3ca7fa7..03366dd3 100644 --- a/crates/web-client/src/lib.rs +++ b/crates/web-client/src/lib.rs @@ -221,6 +221,9 @@ pub struct WebClient { inner: AsyncCell>>, mock_rpc_api: AsyncCell>>, mock_note_transport_api: AsyncCell>>, + // Cached at client creation from the RPC client's endpoint so page-side consumers can read it + // synchronously, without locking the async `inner` cell. + endpoint: std::sync::RwLock>, } // SAFETY: napi-rs with `tokio_rt` uses a multi-threaded tokio runtime, so async napi @@ -268,6 +271,7 @@ impl WebClient { inner: AsyncCell::new(None), mock_rpc_api: AsyncCell::new(None), mock_note_transport_api: AsyncCell::new(None), + endpoint: std::sync::RwLock::new(None), } } @@ -279,6 +283,13 @@ impl WebClient { Ok(client.store_identifier().to_string()) } + /// Returns the node endpoint URL this client is configured to talk to, or `undefined` if the + /// client has not been created yet (or its transport doesn't track an endpoint). + #[js_export(js_name = "endpoint")] + pub fn endpoint(&self) -> Option { + self.endpoint.read().expect("endpoint lock poisoned").clone() + } + #[js_export(js_name = "createCodeBuilder")] pub async fn create_code_builder(&self) -> Result { let guard = self.inner.lock().await; @@ -470,6 +481,9 @@ impl WebClient { note_transport_client: Option>, debug_mode: Option, ) -> Result<(), JsValue> { + *self.endpoint.write().expect("endpoint lock poisoned") = + rpc_client.endpoint().map(str::to_string); + let mut builder = ClientBuilder::new() .rpc(rpc_client) .rng(Box::new(rng)) @@ -564,6 +578,9 @@ impl WebClient { note_transport_client: Option>, debug_mode: Option, ) -> Result<(), JsErr> { + *self.endpoint.write().expect("endpoint lock poisoned") = + rpc_client.endpoint().map(str::to_string); + let client = maybe_wrap_send(async move { let mut builder = ClientBuilder::new() .rpc(rpc_client) diff --git a/crates/web-client/src/rpc_client/mod.rs b/crates/web-client/src/rpc_client/mod.rs index 3b692e5a..b8b33e38 100644 --- a/crates/web-client/src/rpc_client/mod.rs +++ b/crates/web-client/src/rpc_client/mod.rs @@ -51,6 +51,13 @@ impl RpcClient { Ok(RpcClient { inner: rpc_client }) } + /// Returns the endpoint URL this RPC client is configured to talk to, or `undefined` if its + /// transport doesn't track one. + #[js_export(js_name = "endpoint")] + pub fn endpoint(&self) -> Option { + self.inner.endpoint().map(str::to_string) + } + /// Fetches notes by their IDs from the connected Miden node. /// /// @param note_ids - Array of [`NoteId`] objects to fetch diff --git a/packages/react-sdk/src/__tests__/setup.ts b/packages/react-sdk/src/__tests__/setup.ts index e273c323..cdb346db 100644 --- a/packages/react-sdk/src/__tests__/setup.ts +++ b/packages/react-sdk/src/__tests__/setup.ts @@ -75,6 +75,7 @@ vi.mock("@miden-sdk/miden-sdk", () => { | null, setSignCb: vi.fn(), free: vi.fn(), + endpoint: vi.fn(() => "https://rpc.devnet.miden.io"), }; const WebClient = Object.assign( diff --git a/packages/react-sdk/src/hooks/useAssetMetadata.ts b/packages/react-sdk/src/hooks/useAssetMetadata.ts index ccd8a287..14c613f8 100644 --- a/packages/react-sdk/src/hooks/useAssetMetadata.ts +++ b/packages/react-sdk/src/hooks/useAssetMetadata.ts @@ -50,8 +50,15 @@ const fetchAssetMetadata = async ( export function useAssetMetadata(assetIds: string[] = []) { const assetMetadata = useAssetMetadataStore(); const setAssetMetadata = useMidenStore((state) => state.setAssetMetadata); - const rpcUrl = useMidenStore((state) => state.config.rpcUrl); - const rpcClient = useMemo(() => getRpcClient(rpcUrl), [rpcUrl]); + // Derive the endpoint from the live client (single source of truth) rather than a + // separately-stored config copy. `client` exists only once MidenProvider has finished + // initializing, so a configured app never reads an unset URL and falls back to testnet + // during the init window. + const client = useMidenStore((state) => state.client); + const rpcClient = useMemo( + () => (client ? getRpcClient(client.endpoint() ?? undefined) : null), + [client] + ); const uniqueAssetIds = useMemo( () => Array.from(new Set(assetIds.filter(Boolean))), diff --git a/packages/react-sdk/src/utils/accountBech32.ts b/packages/react-sdk/src/utils/accountBech32.ts index b19b275c..1b8acbee 100644 --- a/packages/react-sdk/src/utils/accountBech32.ts +++ b/packages/react-sdk/src/utils/accountBech32.ts @@ -12,13 +12,16 @@ type AccountPrototype = { bech32id?: () => string; }; -const inferNetworkId = (): NetworkId => { - const { rpcUrl } = useMidenStore.getState().config; - if (!rpcUrl) { - return NetworkId.testnet(); +// Derive the bech32 network from the live client's endpoint (single source of truth). +// A real client always carries an endpoint (testnet by default), so the only +// undetermined cases are: no client yet (provider still initializing) or a configured +// custom endpoint we can't map — both return `null` so callers fall back to the raw +// account id rather than tagging it for the wrong network. +const resolveNetworkId = (): NetworkId | null => { + const url = useMidenStore.getState().client?.endpoint()?.toLowerCase(); + if (!url) { + return null; } - - const url = rpcUrl.toLowerCase(); if (url.includes("devnet") || url.includes("mdev")) { return NetworkId.devnet(); } @@ -28,23 +31,30 @@ const inferNetworkId = (): NetworkId => { if (url.includes("testnet") || url.includes("mtst")) { return NetworkId.testnet(); } - - return NetworkId.testnet(); + if (url.includes("localhost") || url.includes("127.0.0.1")) { + // Local nodes run a devnet genesis by default. + return NetworkId.devnet(); + } + return null; }; const toBech32FromAccountId = (id: AccountId): string => { + const networkId = resolveNetworkId(); + // Network not yet determinable (provider initializing, or a custom endpoint): + // return the raw id rather than risk a wrong-network bech32 address. + if (!networkId) { + return id.toString(); + } + try { const address = Address.fromAccountId(id, "BasicWallet"); - return address.toBech32(inferNetworkId()); + return address.toBech32(networkId); } catch { // Fall through to AccountId conversion or string fallback. } try { - const maybeBech32 = id.toBech32?.( - inferNetworkId(), - AccountInterface.BasicWallet - ); + const maybeBech32 = id.toBech32?.(networkId, AccountInterface.BasicWallet); if (typeof maybeBech32 === "string") { return maybeBech32; } From 5e27a03b3d6511f55be9e1d0e61598720368a477 Mon Sep 17 00:00:00 2001 From: Wiktor Starczewski Date: Mon, 29 Jun 2026 19:03:27 +0200 Subject: [PATCH 2/4] docs(changelog): reference PR #212 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index abfd86f1..a3ddbd49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Enhancements -* [FEATURE][web,react] Added `WebClient.endpoint()` and `RpcClient.endpoint()`, returning the node URL the client is configured to talk to. The React SDK now derives the RPC endpoint from the live client instead of a separately-stored config copy: `useAssetMetadata` and `accountBech32` read `client.endpoint()`, so a configured (e.g. devnet) app no longer falls back to testnet during the provider's init window, and addresses are tagged for the right network (raw id when undetermined). ([#PR](https://github.com/0xMiden/web-sdk/pull/PR), companion: [0xMiden/miden-client#2291](https://github.com/0xMiden/miden-client/pull/2291)) +* [FEATURE][web,react] Added `WebClient.endpoint()` and `RpcClient.endpoint()`, returning the node URL the client is configured to talk to. The React SDK now derives the RPC endpoint from the live client instead of a separately-stored config copy: `useAssetMetadata` and `accountBech32` read `client.endpoint()`, so a configured (e.g. devnet) app no longer falls back to testnet during the provider's init window, and addresses are tagged for the right network (raw id when undetermined). ([#212](https://github.com/0xMiden/web-sdk/pull/212), companion: [0xMiden/miden-client#2291](https://github.com/0xMiden/miden-client/pull/2291)) * [FEATURE][web,react] The 0.14.x line's mobile/MT proving surface is available on the 0.15 series (forward-ported from `main`): `TransactionProver.newCallbackProver(jsFn)` (route prove to a native iOS/Android plugin; wire format matches `RemoteTransactionProver`), `ClientOptions.useWorker?: boolean` / `MidenConfig.useWorker` (opt out of the Web Worker shim — required for callback provers, whose closure cannot cross the worker boundary), `MidenClient._withInnerWebClient(fn)` with the depth-tracked re-entrancy fix, the multi-threaded WASM build at the `@miden-sdk/miden-sdk/mt` + `/mt/lazy` (and `@miden-sdk/react/mt` + `/mt/lazy`) subpaths, and the `miden-mobile-prover` C-ABI crate — now built against the 0.15 protocol, so its `TransactionInputs`/`ProvenTransaction` wire format requires a matching 0.15 SDK (native binaries built from 0.14 do not interoperate). ([#149](https://github.com/0xMiden/web-sdk/pull/149), [#152](https://github.com/0xMiden/web-sdk/pull/152), [#134](https://github.com/0xMiden/web-sdk/pull/134)) * [FEATURE][web] Added `BlockHeader.feeFaucetId()` — the account ID of the fungible faucet whose assets pay transaction verification fees, read from the block's on-chain fee parameters. This is the 0.15 spelling of the 0.14 line's `BlockHeader.nativeAssetId()` (the underlying protocol field was renamed `native_asset_id` → `fee_faucet_id`); consumers discovering the fee/native asset per network should migrate to the new name. From 30dee171a9d7f349042fa5332734f2c22b31a5fc Mon Sep 17 00:00:00 2001 From: Wiktor Starczewski Date: Mon, 29 Jun 2026 19:14:08 +0200 Subject: [PATCH 3/4] test(react): drive useAssetMetadata/accountBech32 from the client endpoint Provide a client (with endpoint()) in the useAssetMetadata test and the shared createMockWebClient mock so hooks that derive the RPC endpoint from the live client fetch as expected; add a test that no RPC fires before a client exists. --- .../__tests__/hooks/useAssetMetadata.test.tsx | 18 ++++++++++++++++++ .../react-sdk/src/__tests__/mocks/miden-sdk.ts | 2 ++ 2 files changed, 20 insertions(+) diff --git a/packages/react-sdk/src/__tests__/hooks/useAssetMetadata.test.tsx b/packages/react-sdk/src/__tests__/hooks/useAssetMetadata.test.tsx index 3e84a85b..a8515d88 100644 --- a/packages/react-sdk/src/__tests__/hooks/useAssetMetadata.test.tsx +++ b/packages/react-sdk/src/__tests__/hooks/useAssetMetadata.test.tsx @@ -43,6 +43,11 @@ beforeEach(() => { useMidenStore.getState().reset(); mockGetAccountDetails.mockReset(); mockFromAccount.mockReset(); + // useAssetMetadata derives the RPC endpoint from the live client, so it only fetches + // once a client exists. Provide one (the client carries its own endpoint). + useMidenStore.getState().setClient({ + endpoint: () => "https://rpc.devnet.miden.io", + } as never); }); describe("useAssetMetadata", () => { @@ -151,6 +156,19 @@ describe("useAssetMetadata", () => { expect(meta?.decimals).toBe(2); }); + it("defers fetching until a client exists (no RPC before the provider is ready)", async () => { + useMidenStore.getState().setClient(null); + mockGetAccountDetails.mockResolvedValue({ account: () => ({ id: "x" }) }); + + const { result } = renderHook(() => useAssetMetadata(["0xfaucet7"])); + + // Give the effect a chance to run; with no client it must not fetch. + await new Promise((r) => setTimeout(r, 50)); + + expect(mockGetAccountDetails).not.toHaveBeenCalled(); + expect(result.current.assetMetadata.has("0xfaucet7")).toBe(false); + }); + it("should filter out falsy asset IDs", () => { const { result } = renderHook(() => useAssetMetadata(["", undefined as unknown as string, ""]) diff --git a/packages/react-sdk/src/__tests__/mocks/miden-sdk.ts b/packages/react-sdk/src/__tests__/mocks/miden-sdk.ts index 4bcb00d7..9bb97457 100644 --- a/packages/react-sdk/src/__tests__/mocks/miden-sdk.ts +++ b/packages/react-sdk/src/__tests__/mocks/miden-sdk.ts @@ -203,6 +203,7 @@ export const createMockWebClient = ( const defaultClient: MockWebClientType = { // Initialization createClient: vi.fn().mockResolvedValue(undefined), + endpoint: vi.fn(() => "https://rpc.devnet.miden.io"), // Account methods getAccounts: vi.fn().mockResolvedValue([]), @@ -299,6 +300,7 @@ export const createMockWebClient = ( type MockWebClientType = { createClient: ReturnType; + endpoint: ReturnType; getAccounts: ReturnType; getAccount: ReturnType; newWallet: ReturnType; From e572947e183a04998763a14d720595bfaefb36f2 Mon Sep 17 00:00:00 2001 From: Wiktor Starczewski Date: Mon, 29 Jun 2026 19:47:04 +0200 Subject: [PATCH 4/4] fix(web-client): classify endpoint() as a sync method --- crates/web-client/js/index.js | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/web-client/js/index.js b/crates/web-client/js/index.js index 9030f1ae..c5f6e025 100644 --- a/crates/web-client/js/index.js +++ b/crates/web-client/js/index.js @@ -69,6 +69,7 @@ export { const SYNC_METHODS = new Set([ "buildSwapTag", "createCodeBuilder", + "endpoint", "lastAuthError", "newConsumeTransactionRequest", "newMintTransactionRequest",