Skip to content

feat: SDK hardening integration + MidenClient singleton unification#189

Open
WiktorStarczewski wants to merge 26 commits intomainfrom
wiktor-rltest
Open

feat: SDK hardening integration + MidenClient singleton unification#189
WiktorStarczewski wants to merge 26 commits intomainfrom
wiktor-rltest

Conversation

@WiktorStarczewski
Copy link
Copy Markdown
Collaborator

Summary

Integrates the SDK hardening improvements into the wallet and unifies the MidenClientSingleton from two instances (dispose-and-recreate per-tx) to one long-lived singleton with a late-binding keystore bridge. Net result: cleaner architecture, 2.5× faster sends, and robust error handling for partial-failure scenarios.

SDK hardening integration

Wallet-side integration for 5 SDK improvements (each has its own SDK PR):

  1. Typed sign-callback failure recovery — when the wallet gets locked mid-transaction, the sign callback throws with { reason: 'locked' }. The TransactionProcessor reads midenClient.lastAuthError() and leaves the tx Queued for retry after unlock, instead of marking it Failed (which previously caused note loss).

  2. ApplyTransactionAfterSubmitFailed handling — when a tx submits on-chain but the local apply step fails, the wallet marks it Completed (not Failed) using the SDK's new errorCode dispatch. The user sees "Transaction sent" instead of a confusing "Transaction failed" for a tx that IS on chain.

  3. Private-note transport retry — if the P2P blob delivery fails after a private send commits on-chain, the wallet marks the note transportPending and retries via resendPrivateNoteById() with exponential backoff in the background.

  4. WASM call serialization + waitForIdle — the wallet's lock() action uses waitForIdle() to drain in-flight WASM operations before clearing the vault key, preventing the sign-callback race that caused 7/7 executeTransaction errors in stress testing.

MidenClient singleton unification

Collapses MidenClientSingleton from two instances (instance + instanceWithOptions) to one long-lived singleton:

  • New keystore-bridge.ts — pure callback-slot module (activeInsertKey + activeSignCallback). The SDK's permanent keystore callbacks delegate to these mutable slots.
  • New keystore-wiring.ts — subscribes to Effector unlocked/locked events and re-points the bridge's insert-key slot to the active vault's encryptKeystoreEntry method. Called from all 3 entry points (SW main.ts, mobile-adapter.ts, desktop-adapter.ts).
  • Vault.encryptKeystoreEntry() — new method that encrypts a keypair under the vault's KEK without exposing the key outside the Vault class.
  • getMidenClient() no longer accepts options — tests that need a specific seed call MidenClientInterface.create({seed}) directly.
  • Wallet mutex stays active as belt-and-suspenders (pending SDK-side exploration in #2057).
  • withProverFallback uses defaultProver path — relies on the SDK's proveTransactionWithProver(&prover) by-reference fix (#2062) to keep the JS prover handle alive across calls.

Performance

The singleton eliminates the per-tx client dispose-and-recreate cycle (~1-3s overhead per send). Combined with the by-reference prover fix:

Metric Before (dispose-recreate) After (singleton)
Public send ~3.2s ~1.4s
Private send ~8.5s ~2.2s
Conservation

Stress test results

  • 5-op validation with singleton + by-reference prover: all ops ~1.3-2.2s, 0 failures, conservation held
  • 30-op bisect validation (pre-singleton, pre-flight revert): 30/30, delta=0

SDK dependencies

This PR depends on the following SDK changes (all on 0xMiden/miden-client):

SDK PR Status Required for
#2058 Open lastAuthError() — typed sign failure recovery
#2059 Open ApplyTransactionAfterSubmitFailed error variant
#2060 Open errorCode dispatch (depends on #2059)
#2061 Open resendPrivateNoteById() transport retry
#2062 Open proveTransactionWithProver(&prover) by-reference fix
#2057 Draft waitForIdle() + WASM serialization (exploratory)

The wallet's @miden-sdk/miden-sdk dependency uses file:../miden-client/crates/web-client during development. Once the SDK PRs land and a release is cut, the wallet will pin to the published version.

Files changed

New files (3):

  • src/lib/miden/sdk/keystore-bridge.ts — callback-slot bridge module
  • src/lib/miden/sdk/keystore-bridge.test.ts — 12 unit tests
  • src/lib/miden/back/keystore-wiring.ts — Effector event wiring

Modified files (14):

  • src/lib/miden/sdk/miden-client.ts — singleton simplification
  • src/lib/miden/sdk/miden-client-interface.ts — drop keystore options, wire bridge, defaultProver path
  • src/lib/miden/sdk/miden-client.test.ts — remove dispose-recreate test
  • src/lib/miden/sdk/miden-client-interface.test.ts — update for bridge wiring
  • src/lib/miden/back/vault.tsencryptKeystoreEntry, persistKeystoreEntry helper, export storage keys
  • src/lib/miden/back/vault.test.ts — round-trip test for encryptKeystoreEntry
  • src/lib/miden/back/actions.ts — lock comment update
  • src/lib/miden/back/dapp.ts — comment clarifying signData is not in bridge path
  • src/lib/miden/back/main.ts — wireKeystoreBridge before intercom
  • src/lib/miden/back/main.test.ts — mock keystore-wiring
  • src/lib/miden/activity/transactions.ts — bridge sign callback with try/finally
  • src/lib/intercom/mobile-adapter.ts — wireKeystoreBridge in init
  • src/lib/intercom/mobile-adapter.test.ts — mock keystore-wiring
  • src/lib/intercom/desktop-adapter.ts — wireKeystoreBridge in init

Test plan

  • yarn lint && yarn format — clean
  • yarn test — 1661/1661 pass
  • 5-op stress with singleton + by-reference prover: all ops ~1.3-2.2s
  • 30-op bisect: conservation held (delta=0)
  • Mobile smoke: onboarding + send + lock/unlock cycle
  • Desktop smoke: same checklist

…lock

Depends on miden-client wiktor-test-followups branch via file: ref until
the SDK change is merged and a new version is published.
Transport failures on private-note sends now mark the tx Completed (the
on-chain commit is durable) with a transportPending flag + attempt
counter. A background retry loop in the TransactionProcessor finds
these, backs off exponentially, and calls the SDK's resendPrivateById
until the recipient receives the note blob.

Without this, a transport-side failure left the sender's asset
effectively lost — the recipient has no way to discover private notes
from on-chain data alone. The retry runs on SW startup + every
TransactionProcessor tick; after 20 attempts the tx is flagged Failed
so the user sees a clear terminal state.

Depends on the SDK helper landed on miden-client wiktor-test-followups.
Adds STRESS_TRANSPORT_FAIL_PROB env var. When set (e.g. 0.1), each
private-note send has that probability of having its SendNote gRPC
call intercepted and aborted via Playwright page.route — forcing the
wallet into its transport-pending retry path.

The retry loop inside generateTransactionsLoop should then deliver the
note on the next tick via the SDK's resendPrivateNoteById. Final
balance conservation should still hold; failure to deliver = note loss
= test fail.

Default is 0 so regular stress runs match historical behavior;
passing STRESS_TRANSPORT_FAIL_PROB=0.1 validates the transport retry
end-to-end. Skipped for concurrent ops to keep the signal clean.
…re bridge

Collapse MidenClientSingleton from two instances (instance + instanceWithOptions)
to one. Keystore callbacks are wired permanently at MidenClient.create time via
a late-binding bridge module; Effector unlocked/locked events re-point the
insert-key slot to the active vault. Per-tx sign callbacks go through the
bridge's activeSignCallback slot with a concurrent-set guard.

Key changes:
- New keystore-bridge.ts: pure callback-slot module (insertKey + sign + getKey)
- New keystore-wiring.ts: Effector event subscriptions, called from all 3 entry
  points (SW main.ts, mobile-adapter, desktop-adapter)
- Vault gains encryptKeystoreEntry() method; KEK never leaves the Vault class
- getMidenClient() no longer accepts options; tests use MidenClientInterface.create directly
- withProverFallback builds a fresh TransactionProver.newRemoteProver per-call
  instead of relying on client.defaultProver (which silently falls back to local
  after a single failure and never recovers)
- Wallet mutex stays active as belt-and-suspenders

Validated via stress suite: 5-op run with explicit remote prover shows all ops
completing in ~1.3-2.2s (vs 3.2-8.5s baseline with dispose-recreate per-tx).
Balance conservation held across all test configurations.
The SDK's proveTransactionWithProver now takes &TransactionProver
(by reference), so the JS handle is preserved across calls. Remove
the wallet-side newRemoteProver-per-call workaround and rely on
MidenClient's defaultProver (set from proverUrl at create time).
…pass-through)

The SDK's internal _serializeWasmCall chain + single-instance MidenClient
design handles WASM call serialization. The wallet's AsyncMutex was
belt-and-suspenders from before the singleton unification — needed when
two client instances had independent SDK chains.

Validated by 2 consecutive 5-op stress runs (seeds 301, 302) with mixed
private/public sends, 0 failures, conservation held on both. A third run
failed during faucet deployment (devnet RPC flake, unrelated to wallet code).

Also restores @miden-sdk/miden-sdk to file:../miden-client/crates/web-client.
@0xnullifier
Copy link
Copy Markdown
Collaborator

0xnullifier commented Apr 17, 2026

The singleton eliminates the per-tx client dispose-and-recreate cycle (~1-3s overhead per send). Combined with the by-reference prover fix:

Does it ? I mean surely not doing a simple console.time around like this

export async function getMidenClient(options?: MidenClientCreateOptions): Promise<MidenClientInterface> {
  if (options) {
    console.time('Creating MidenClient with options');
    const client = await midenClientSingleton.getInstanceWithOptions(options);
    console.timeEnd('Creating MidenClient with options');
    return client;
  }
  console.time('Getting MidenClient instance');
  const client = await midenClientSingleton.getInstance();
  console.timeEnd('Getting MidenClient instance');
  return client;
}

it consistently takes less than 50ms so idk if this unification does anything in that regards and if it does take 1-2s just to create the client and destroy instance then there is bigger fish to fry

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants