diff --git a/CHANGELOG.md b/CHANGELOG.md
index fbf8ff48..a3ddbd49 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). ([#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.
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",
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__/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;
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;
}