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
-
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.
-
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.
Summary
MidenClientdoesn't expose enough surface to cover some advanced flows, which forces consumers (e.g.@openzeppelin/miden-multisig-client) to drop down to the low-levelWasmWebClient. Because there's no supported way to borrow theWasmWebClientthat aMidenClientalready owns, consumers reconstruct a secondWasmWebClientkeyed 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'sraw-client.ts: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.getRawMidenClientalready reuses aWasmWebClientwhen handed one directly — but apps hand it a high-levelMidenClient, 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
WasmWebClientcall the multisig-client makes vs. theMidenClientsurface:MidenClientequivalentgetAccountaccounts.getnewAccount(acc, overwrite)accounts.insert({account, overwrite})syncStatesyncgetInputNotenotes.getcompileTxScriptcompile.txScriptexecuteForSummary(acc, request)transactions.preview({operation:"custom", account, request})getConsumableNotesnotes.listAvailablenoteConsumability()(theconsumableAfterBlockfilter)createCodeBuilder().linkModule().compileAccountComponentCode()compile.component({code, slots})So most calls are already covered — the consumer just hasn't migrated — but two real gaps remain.
Gaps to close on
MidenClientcompile.componentcan't link dependency modules.CompileComponentOptionshad nolibraries, unlikecompile.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 vialinkModule, notbuildLibrary+linkStaticLibrary.notes.listAvailabledrops consumability metadata.get_consumable_notesreturns notes including block-locked ones (NoteConsumptionStatus::ConsumableAfter).listAvailablemaps toinputNoteRecord(), discardingnoteConsumability(), so callers can't distinguish consumable-now from time-locked notes.Proposed fix
libraries?: CompileTxScriptLibrary[]toCompileComponentOptions;compile.componentstatic-links each viabuilder.linkModule(namespace, code)(identical MAST by construction).notes.listConsumable({ account? })returningConsumableNoteRecord[]with consumability preserved.After these, the multisig-client can run entirely on a
MidenClient(or a single sharedWasmWebClient) — one instance per store — which removes the dual-instance bug at the source.A follow-up worth considering: a supported accessor for
MidenClient's underlyingWasmWebClient(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 rawcreateCodeBuilder().linkModule()path (EQUAL: true), so existing accounts are unaffected. Unit tests (compiler + notes): 48 passing; node integrationcompile.componentgroup: 4 passing.