Skip to content
Open
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## Unreleased

### Fixes

* [FIX][react] Page-side code no longer reaches the testnet default under a non-testnet (e.g. devnet) configuration. `useAssetMetadata` builds its own page-side `RpcClient` from the resolved `config.rpcUrl`, but ran during the window before `MidenProvider` populated the store — falling back to `Endpoint.testnet()` and firing `getAccountDetails` against testnet even when the consumer configured devnet (the WebClient/worker correctly used devnet while page-side asset-metadata calls hit testnet). It now gates `RpcClient` construction on `isReady` (the same pattern the default prover uses). `accountBech32`'s network resolution had the same shape: it tagged addresses for testnet whenever the network couldn't be confirmed (before the provider was ready, or for `localhost`/custom endpoints); it now derives the network from the resolved endpoint, treats local nodes as devnet, and returns the raw account id rather than a wrong-network bech32 string when the network is undetermined. In both cases the testnet default still applies only when no endpoint was configured at all. ([#189](https://github.com/0xMiden/web-sdk/pull/189))

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.

I think the fix might be fine (waiting for the client to be initialized sounds like something you want to do anyway), but honestly falling back to Endpoint.testnet() when nothing is provided sounds like it should probably be an error if it allows for these kinds of race conditions. Also, using the store to persist the RPC config feels like an antipattern as well. Can't we derive the RPC from the client itself?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Yeah so the only place with default is the initialisation of the client - if you just say , it willl go to testnet (mainnet in 2 months). I think this is a worthwhile shortcut. The main issue is other places should never fall back, and this is what I'm doing in this PR.

Re, using store to persist the RPC - that's a good one. A little bit more involved, but I think its worth it. I'll add.

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.

When I mentioned

Can't we derive the RPC from the client itself?

I mostly meant deriving the RPC from the WebClient struct. Because WebClient::create_client() already takes the endpoint as a parameter, we could store that and use that instead?


## 0.15.0 (2026-06-12)

### Enhancements
Expand Down
78 changes: 71 additions & 7 deletions packages/react-sdk/src/__tests__/hooks/useAssetMetadata.test.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { useMidenStore } from "../../store/MidenStore";
import type { MidenConfig } from "../../types";

// Shared mocks hoisted above vi.mock so the factory can reference them
const { mockGetAccountDetails, mockFromAccount } = vi.hoisted(() => ({
mockGetAccountDetails: vi.fn(),
mockFromAccount: vi.fn(),
}));
const { mockGetAccountDetails, mockFromAccount, mockTestnet } = vi.hoisted(
() => ({
mockGetAccountDetails: vi.fn(),
mockFromAccount: vi.fn(),
mockTestnet: vi.fn(),
})
);

// Override the SDK mock for this file so we can control RpcClient behavior
vi.mock("@miden-sdk/miden-sdk", () => {
Expand All @@ -22,9 +26,8 @@ vi.mock("@miden-sdk/miden-sdk", () => {
},
Endpoint: class Endpoint {
constructor(_url?: string) {}
static testnet() {
return new Endpoint();
}
// Surfaced as a spy so a re-introduced hardcoded fallback is caught.
static testnet = mockTestnet;
},
RpcClient: class RpcClient {
constructor(_endpoint: unknown) {}
Expand All @@ -39,10 +42,19 @@ vi.mock("@miden-sdk/miden-sdk", () => {
// Import after mocks are set up
import { useAssetMetadata } from "../../hooks/useAssetMetadata";

// A configured RPC endpoint. Asset metadata only fetches once the app has
// selected an endpoint — the hook must never invent one.
const RPC_URL = "https://rpc.devnet.miden.io";

beforeEach(() => {
useMidenStore.getState().reset();
useMidenStore.getState().setConfig({ rpcUrl: RPC_URL } as MidenConfig);
// A non-null client flips isReady=true (MidenStore.setClient), mirroring a
// fully-initialized provider. The hook gates RPC construction on this.
useMidenStore.getState().setClient({} as never);
mockGetAccountDetails.mockReset();
mockFromAccount.mockReset();
mockTestnet.mockReset();
});

describe("useAssetMetadata", () => {
Expand All @@ -56,6 +68,58 @@ describe("useAssetMetadata", () => {
expect(result.current.assetMetadata.size).toBe(0);
});

it("defers until the client is ready, firing no RPC during init", async () => {
// The reported leak: before MidenProvider finishes initializing, isReady is
// false and the resolved rpcUrl isn't in the store yet. A devnet-configured
// app must not build a page-side client (and hit the testnet fallback) in
// this window.
useMidenStore.getState().setClient(null); // isReady = false
mockGetAccountDetails.mockResolvedValue({ account: () => ({ id: "x" }) });

const { result } = renderHook(() => useAssetMetadata(["0xfaucetInit"]));
await new Promise((r) => setTimeout(r, 50));

expect(mockTestnet).not.toHaveBeenCalled();
expect(mockGetAccountDetails).not.toHaveBeenCalled();
expect(result.current.assetMetadata.has("0xfaucetInit")).toBe(false);
});

it("uses the configured endpoint and never the testnet fallback once ready", async () => {
// rpcUrl is devnet (beforeEach) and the client is ready: metadata resolves
// against the configured endpoint, never Endpoint.testnet().
mockGetAccountDetails.mockResolvedValue({ account: () => ({ id: "x" }) });
mockFromAccount.mockReturnValue({
symbol: () => ({ toString: () => "DEV" }),
decimals: () => 6,
});

const { result } = renderHook(() => useAssetMetadata(["0xfaucetDevnet"]));
await waitFor(() => {
expect(result.current.assetMetadata.get("0xfaucetDevnet")?.symbol).toBe(
"DEV"
);
});

expect(mockGetAccountDetails).toHaveBeenCalled();
expect(mockTestnet).not.toHaveBeenCalled();
});

it("still uses the testnet default when no endpoint is configured", async () => {
// The accepted default: with no rpcUrl configured at all, the page-side
// client matches the WebClient/MidenProvider testnet default rather than
// deferring forever.
useMidenStore.getState().setConfig({} as MidenConfig); // no rpcUrl
mockGetAccountDetails.mockResolvedValue({ account: () => null });

const { result } = renderHook(() => useAssetMetadata(["0xfaucetDefault"]));
await waitFor(() => {
expect(result.current.assetMetadata.has("0xfaucetDefault")).toBe(true);
});

expect(mockTestnet).toHaveBeenCalled();
expect(mockGetAccountDetails).toHaveBeenCalled();
});

it("should fetch metadata via RPC and store symbol and decimals", async () => {
const mockAccount = { id: "mock-account" };
mockGetAccountDetails.mockResolvedValue({
Expand Down
99 changes: 99 additions & 0 deletions packages/react-sdk/src/__tests__/utils/accountBech32.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { useMidenStore } from "../../store/MidenStore";
import type { MidenConfig } from "../../types";

// Network factories surfaced as spies so we can assert which network the
// bech32 path selects from the configured endpoint.
const { mockDevnet, mockTestnet, mainnetSpy } = vi.hoisted(() => ({
mockDevnet: vi.fn(() => ({ network: "devnet" })),
mockTestnet: vi.fn(() => ({ network: "testnet" })),
mainnetSpy: vi.fn(() => ({ network: "mainnet" })),
}));

vi.mock("@miden-sdk/miden-sdk", () => {
const makeId = (hex: string) => ({
toString: () => hex,
// Force the Address.fromAccountId path; this fallback stays unused.
toBech32: undefined,
});
return {
Account: class Account {},
AccountId: {
fromHex: vi.fn((hex: string) => makeId(hex)),
fromBech32: vi.fn((b: string) => makeId(b)),
},
AccountInterface: { BasicWallet: 0 },
Address: {
// toBech32 echoes the selected network so assertions can read it back.
fromAccountId: vi.fn(() => ({
toBech32: (net: { network: string }) => `bech32-${net.network}`,
})),
fromBech32: vi.fn(),
},
NetworkId: {
devnet: mockDevnet,
testnet: mockTestnet,
mainnet: mainnetSpy,
},
};
});

import { toBech32AccountId } from "../../utils/accountBech32";

const ID = "0x1234567890abcdef";

const setNetwork = (rpcUrl: string | undefined, ready: boolean) => {
useMidenStore.getState().setConfig({ rpcUrl } as MidenConfig);
useMidenStore.getState().setClient((ready ? {} : null) as never);
};

beforeEach(() => {
useMidenStore.getState().reset();
mockDevnet.mockClear();
mockTestnet.mockClear();
mainnetSpy.mockClear();
});

describe("accountBech32 network resolution", () => {
it("uses devnet when configured for devnet", () => {
setNetwork("https://rpc.devnet.miden.io", true);
expect(toBech32AccountId(ID)).toBe("bech32-devnet");
expect(mockDevnet).toHaveBeenCalled();
expect(mockTestnet).not.toHaveBeenCalled();
});

it("uses testnet when configured for testnet", () => {
setNetwork("https://rpc.testnet.miden.io", true);
expect(toBech32AccountId(ID)).toBe("bech32-testnet");
expect(mockTestnet).toHaveBeenCalled();
});

it("treats a localhost node as devnet", () => {
setNetwork("http://localhost:57291", true);
expect(toBech32AccountId(ID)).toBe("bech32-devnet");
expect(mockDevnet).toHaveBeenCalled();
expect(mockTestnet).not.toHaveBeenCalled();
});

it("falls back to the testnet default when ready with no endpoint", () => {
setNetwork(undefined, true);
expect(toBech32AccountId(ID)).toBe("bech32-testnet");
expect(mockTestnet).toHaveBeenCalled();
});

it("returns the raw id (never testnet) before the provider is ready", () => {
// The init window: a devnet-configured app must not get a testnet-tagged
// address while config.rpcUrl hasn't landed in the store yet.
setNetwork(undefined, false);
expect(toBech32AccountId(ID)).toBe(ID);
expect(mockDevnet).not.toHaveBeenCalled();
expect(mockTestnet).not.toHaveBeenCalled();
expect(mainnetSpy).not.toHaveBeenCalled();
});

it("returns the raw id for an unrecognized custom endpoint rather than guessing testnet", () => {
setNetwork("https://my-private-node.example", true);
expect(toBech32AccountId(ID)).toBe(ID);
expect(mockTestnet).not.toHaveBeenCalled();
});
});
18 changes: 17 additions & 1 deletion packages/react-sdk/src/hooks/useAssetMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ const inflight = new Map<string, Promise<void>>();
const rpcClients = new Map<string, RpcClient>();

const getRpcClient = (rpcUrl?: string): RpcClient | null => {
// `rpcUrl` is the endpoint MidenProvider resolved from the consumer's config.
// Only when nothing was configured does this fall back to testnet — matching
// the WebClient/MidenProvider default. Callers MUST gate on readiness (see
// `useAssetMetadata`) so this fallback is never reached *before* the resolved
// URL lands in the store, which would make a devnet-configured app hit
// testnet here.
const key = rpcUrl ?? "__default__";
const existing = rpcClients.get(key);
if (existing) return existing;
Expand Down Expand Up @@ -50,8 +56,18 @@ const fetchAssetMetadata = async (
export function useAssetMetadata(assetIds: string[] = []) {
const assetMetadata = useAssetMetadataStore();
const setAssetMetadata = useMidenStore((state) => state.setAssetMetadata);
const isReady = useMidenStore((state) => state.isReady);
const rpcUrl = useMidenStore((state) => state.config.rpcUrl);
const rpcClient = useMemo(() => getRpcClient(rpcUrl), [rpcUrl]);
// Defer until MidenProvider has initialized. Two reasons: (1) before init the
// resolved `config.rpcUrl` isn't in the store yet, so building a client here
// would hit the testnet fallback even when the consumer configured devnet —
// the bug this guards; (2) constructing WASM `Endpoint`/`RpcClient` objects
// before the module is ready can crash (same reason the default prover is
// gated on `isReady`). Once ready, the resolved RPC URL drives the endpoint.
const rpcClient = useMemo(
() => (isReady ? getRpcClient(rpcUrl) : null),
[isReady, rpcUrl]
);

const uniqueAssetIds = useMemo(
() => Array.from(new Set(assetIds.filter(Boolean))),
Expand Down
68 changes: 51 additions & 17 deletions packages/react-sdk/src/utils/accountBech32.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,37 +12,71 @@ type AccountPrototype = {
bech32id?: () => string;
};

const inferNetworkId = (): NetworkId => {
const { rpcUrl } = useMidenStore.getState().config;
if (!rpcUrl) {
return NetworkId.testnet();
type KnownNetwork = "devnet" | "mainnet" | "testnet";

// Pure (no WASM) resolution of the bech32 network from the configured endpoint.
// Returns `null` when the network can't be confirmed, so callers fall back to
// the raw account id rather than tagging it for the wrong network.
const resolveNetworkName = (): KnownNetwork | null => {
const { isReady, config } = useMidenStore.getState();
const url = config.rpcUrl?.toLowerCase();

if (url) {
if (url.includes("devnet") || url.includes("mdev")) {
return "devnet";
}
if (url.includes("mainnet")) {
return "mainnet";
}
if (url.includes("testnet") || url.includes("mtst")) {
return "testnet";
}
if (url.includes("localhost") || url.includes("127.0.0.1")) {
// Local nodes run a devnet genesis (mdev prefix) by default.
return "devnet";
}
// A configured but unrecognized custom endpoint: we can't confirm the
// network, so don't guess testnet — leave it undetermined.
return null;
}

const url = rpcUrl.toLowerCase();
if (url.includes("devnet") || url.includes("mdev")) {
return NetworkId.devnet();
}
if (url.includes("mainnet")) {
return NetworkId.mainnet();
}
if (url.includes("testnet") || url.includes("mtst")) {
return NetworkId.testnet();
}
// No endpoint configured. Before MidenProvider is ready the resolved rpcUrl
// hasn't landed in the store yet, so signal undetermined rather than tagging
// a (possibly devnet) account as testnet. Once ready with nothing configured,
// testnet is the accepted default — matching the WebClient/MidenProvider
// default.
return isReady ? "testnet" : null;
};

return NetworkId.testnet();
const makeNetworkId = (network: KnownNetwork): NetworkId => {
switch (network) {
case "devnet":
return NetworkId.devnet();
case "mainnet":
return NetworkId.mainnet();
case "testnet":
return NetworkId.testnet();
}
};

const toBech32FromAccountId = (id: AccountId): string => {
const network = resolveNetworkName();
// Network not yet determinable (provider initializing, or a custom endpoint):
// return the raw id rather than risk a wrong-network bech32 address.
if (!network) {
return id.toString();
}

try {
const address = Address.fromAccountId(id, "BasicWallet");
return address.toBech32(inferNetworkId());
return address.toBech32(makeNetworkId(network));
} catch {
// Fall through to AccountId conversion or string fallback.
}

try {
const maybeBech32 = id.toBech32?.(
inferNetworkId(),
makeNetworkId(network),
AccountInterface.BasicWallet
);
if (typeof maybeBech32 === "string") {
Expand Down
Loading
Loading