Skip to content

MidenClient missing surface forces consumers onto a second WasmWebClient (dual-instance store → synced notes not found) #169

Description

@WiktorStarczewski

Summary

MidenClient doesn't expose enough surface to cover some advanced flows, which forces consumers (e.g. @openzeppelin/miden-multisig-client) to drop down to the low-level WasmWebClient. Because there's no supported way to borrow the WasmWebClient that a MidenClient already owns, consumers reconstruct a second WasmWebClient keyed to the same store identifier. Two live WASM clients over one IndexedDB database don't share in-memory state, so writes made through one client aren't reliably visible through the other.

The user-visible symptom (reported against the multisig wallet): public notes sent to a private multisig account are synced but then not found. The note is fetched + persisted by midenClient.sync() (client A), but read back through the multisig-client's separate raw client (client B), which doesn't see it. The same flow works in pure Rust because there's a single client/store.

Root cause

In @openzeppelin/miden-multisig-client's raw-client.ts:

const rawClient = WasmWebClient.createClient(
  rpcUrl, undefined, undefined,
  client.storeIdentifier(),   // same IndexedDB *name*, brand-new instance
);

This is a reasonable workaround, not the consumer's fault: the multisig-client genuinely needs low-level operations, and the SDK gives it no way to reuse MidenClient's underlying client. getRawMidenClient already reuses a WasmWebClient when handed one directly — but apps hand it a high-level MidenClient, which has no accessor to surrender its raw client, so it always hits the reconstruct path.

Why consumers reach for the raw client

Audit of every raw WasmWebClient call the multisig-client makes vs. the MidenClient surface:

Raw call MidenClient equivalent Status
getAccount accounts.get already covered
newAccount(acc, overwrite) accounts.insert({account, overwrite}) already covered
syncState sync already covered
getInputNote notes.get already covered
compileTxScript compile.txScript already covered
executeForSummary(acc, request) transactions.preview({operation:"custom", account, request}) already covered
getConsumableNotes notes.listAvailable gap: drops noteConsumability() (the consumableAfterBlock filter)
createCodeBuilder().linkModule().compileAccountComponentCode() compile.component({code, slots}) gap: no dependency-module linking

So most calls are already covered — the consumer just hasn't migrated — but two real gaps remain.

Gaps to close on MidenClient

  1. compile.component can't link dependency modules. CompileComponentOptions had no libraries, unlike compile.txScript/compile.noteScript. The multisig auth component imports guardian + multisig MASM libraries, so it was forced onto a raw code builder. Note the correctness constraint: a component's compiled MAST determines the account's code commitment (baked into the account ID), so the linking path must produce a byte-identical component — i.e. static source linking via linkModule, not buildLibrary + linkStaticLibrary.

  2. notes.listAvailable drops consumability metadata. get_consumable_notes returns notes including block-locked ones (NoteConsumptionStatus::ConsumableAfter). listAvailable maps to inputNoteRecord(), discarding noteConsumability(), so callers can't distinguish consumable-now from time-locked notes.

Proposed fix

  • Add libraries?: CompileTxScriptLibrary[] to CompileComponentOptions; compile.component static-links each via builder.linkModule(namespace, code) (identical MAST by construction).
  • Add notes.listConsumable({ account? }) returning ConsumableNoteRecord[] with consumability preserved.

After these, the multisig-client can run entirely on a MidenClient (or a single shared WasmWebClient) — one instance per store — which removes the dual-instance bug at the source.

A follow-up worth considering: a supported accessor for MidenClient's underlying WasmWebClient (or guidance/guardrails against constructing a second client over an existing store id), so consumers don't reconstruct one by store identifier.

Verification (done locally)

Implemented both gaps + tests. Built the napi module and confirmed compile.component({ libraries }) produces a component digest identical to the raw createCodeBuilder().linkModule() path (EQUAL: true), so existing accounts are unaffected. Unit tests (compiler + notes): 48 passing; node integration compile.component group: 4 passing.

Metadata

Metadata

Labels

No labels
No labels

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions