miden-client-web 0.17.1

Web Client library that facilitates interaction with the Miden network
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
# @miden-sdk/miden-sdk

## Start here

```bash
npm create @miden-sdk@latest
```

Run that once in your project. Miden is pre-1.0 and its API moves between minor
versions, so an AI coding agent working from training data will write code for a
version you are not on. Every `@miden-sdk/*` package ships an `AGENTS.md` and
task-scoped `skills/` inside its tarball, matched to the exact version in your
lockfile - this command is what points your agent at them, by writing the
pointers into your own `AGENTS.md` and `CLAUDE.md`. It is idempotent, so re-run
it after an upgrade.

Prefer to wire it up by hand? The block to paste is [below](#for-ai-coding-agents).

Starting from nothing rather than adding to an existing app?
[`0xMiden/agentic-template`](https://github.com/0xMiden/agentic-template)
scaffolds the whole stack with this already done.

## Overview

The `@miden-sdk/miden-sdk` is a comprehensive software development toolkit (SDK) for interacting with the Miden blockchain and virtual machine from within a web application. It provides developers with everything needed to:

- Interact with the Miden chain (e.g. syncing accounts, submitting transactions)
- Create and manage Miden transactions
- Run the Miden VM to execute programs
- Generate zero-knowledge proofs using the Miden Prover (with support for delegated proving)
- Integrate Miden capabilities seamlessly into browser-based environments

Whether you're building a wallet, dApp, or other blockchain-integrated application, this SDK provides the core functionality to bridge your frontend with Miden's powerful ZK architecture.

> **Note:** This README provides a high-level overview of the web client SDK.
> For more detailed documentation, API references, and usage examples, see <https://docs.miden.xyz/builder/tools/clients/web-client/>.

### SDK Structure and Build Process

This SDK is published as an NPM package, built from the `web-client` crate. The `web-client` crate is a Rust crate targeting WebAssembly (WASM), and it uses `wasm-bindgen` to generate JavaScript bindings. It depends on the lower-level `rust-client` crate, which implements the core functionality for interacting with the Miden chain.

Both a `Cargo.toml` and a `package.json` are present in the `web-client` directory to support Rust compilation and NPM packaging respectively.

The build process is powered by a custom `rollup.config.js` file, which orchestrates three main steps:

1. **WASM Module Build**: Compiles the `web-client` Rust crate into a WASM module using `@wasm-tool/rollup-plugin-rust`, enabling WebAssembly features such as atomics and bulk memory operations.

2. **Worker Build**: Bundles a dedicated web worker file that enables off-main-thread execution for computationally intensive functions.

3. **Main Entry Point Build**: Bundles the top-level JavaScript module (`index.js`) which serves as the main API surface for consumers of the SDK. This module also imports `wasm.js`, which
   provides a function to load the wasm module in an async way. Since there's a [known issue](https://github.com/wasm-tool/rollup-plugin-rust?tab=readme-ov-file#usage-with-vite)
   with vite, there's a check to avoid loading the wasm module when SSR is enabled.

This setup allows the SDK to be seamlessly consumed in JavaScript environments, particularly in web applications.

## Installation

### Stable Version

The stable release tracks the `main` branch and is what most applications want:

```bash
npm i @miden-sdk/miden-sdk
```

Or with pnpm:

```bash
pnpm add @miden-sdk/miden-sdk
```

### Pre-release ("next") Version

A non-stable version is also maintained, tracking the `next` branch of the Miden client repository (essentially the development branch). To install the pre-release version, run:

```bash
npm i @miden-sdk/miden-sdk@next
```

Or with pnpm:

```bash
pnpm add @miden-sdk/miden-sdk@next
```

> **Note:** The `next` version of the SDK must be used in conjunction with a locally running Miden node built from the `next` branch of the `miden-node` repository. This is necessary because the public testnet runs the stable `main` branch, which may not be compatible with the latest development features in `next`. Instructions to run a local node can be found [here](https://github.com/0xMiden/miden-node/tree/next) on the `next` branch of the `miden-node` repository. Additionally, if you plan to leverage delegated proving in your application, you may need to run a local prover (see [Remote prover instructions](https://github.com/0xMiden/miden-node/tree/next/bin/remote-prover)).

## For AI coding agents

This package ships agent-facing documentation inside the tarball, so it is
always version-matched to the code you have installed:

- `node_modules/@miden-sdk/miden-sdk/AGENTS.md` - start here
- `node_modules/@miden-sdk/miden-sdk/skills/` - task-scoped guides: client
  usage, production pitfalls, signer integration, chain-anchored execution
  (multisig and offline co-signing), and a source map of the SDK itself

Agents do not look inside `node_modules` on their own. To make yours read these
automatically, paste this block into the `AGENTS.md` or `CLAUDE.md` at the root
of your project:

```markdown
<!-- BEGIN:miden-agent-rules -->
## Miden

This project uses the Miden web SDK. Your training data is likely out of date:
Miden is pre-1.0 and its API changes between minor versions.

Before writing or reviewing Miden code, read the version-matched guide that
ships inside the package you are touching:

- `node_modules/@miden-sdk/<package>/AGENTS.md`, for any `@miden-sdk/*` package
  you import. Start with `miden-sdk` (core client), `react` (hooks) and
  `vite-plugin` (bundler setup).

Each guide indexes task-specific skills in that package's `skills/` directory.
Read the relevant skill before implementing, not after.

These files ship in the published tarball, so they describe the exact version
you have installed. The version is in the same directory's `package.json`; if a
guide disagrees with what you expected, the guide is right and your assumption
is stale.
<!-- END:miden-agent-rules -->
```

## Entry Points: Eager / Lazy × ST / MT

The SDK ships **four** entry points with an identical public API. They vary along two orthogonal axes:

- **WASM init timing** — _eager_ awaits at module load (top-level `await`); _lazy_ leaves init to an explicit `MidenClient.ready()` or first awaiting SDK method.
- **WASM threading model** — _ST_ (single-threaded) loads in any browser context; _MT_ (multi-threaded, `wasm-bindgen-rayon`) parallelizes proving across hardware threads but **requires the page to be cross-origin-isolated**.

| Import path                         | Timing | Threading | When WASM initializes                | Hosting requirement                    |
| ----------------------------------- | ------ | --------- | ------------------------------------ | -------------------------------------- |
| `@miden-sdk/miden-sdk`              | eager  | ST        | At module evaluation (TLA)           | None — works anywhere                  |
| `@miden-sdk/miden-sdk/lazy`         | lazy   | ST        | On `ready()` / first `await`         | None — works anywhere                  |
| `@miden-sdk/miden-sdk/mt`           | eager  | **MT**    | At module evaluation (TLA)           | Cross-origin isolation (see below)     |
| `@miden-sdk/miden-sdk/mt/lazy`      | lazy   | **MT**    | On `ready()` / first `await`         | Cross-origin isolation (see below)     |

The default subpaths (`/`, `/lazy`) ship the single-threaded WASM and load in any browser context. The `/mt` family enables wasm-bindgen-rayon, which gives ~3–5× faster `proveTransactionWithProver` on commodity laptops at the cost of a hard hosting requirement.

### Threading model — when to pick `/mt`

The MT build can ONLY load on a page where `self.crossOriginIsolated === true`, i.e. the host has set:

```
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
```

Without those response headers, the browser refuses to construct `WebAssembly.Memory({ shared: true })` and the `/mt` WASM fails to instantiate at module load. The default ST subpaths don't depend on shared memory and have no such requirement.

Pick MT when:

- Your dApp does local (non-delegated) proving and you control the hosting headers.
- You're shipping the SDK inside a Chrome extension or other host whose manifest already sets COOP/COEP.

Pick ST when:

- You don't control the response headers (third-party host, CDN that won't set them).
- You're using delegated proving exclusively — the network round-trip dwarfs any local-prove speedup.
- You're targeting Capacitor / native WebViews — they don't expose cross-origin isolation by default.

### Setting cross-origin isolation headers

If you import `/mt` or `/mt/lazy`, the page hosting the SDK must respond with the COOP/COEP headers above. Common setups:

**Vite dev server**

```ts
// vite.config.ts
export default {
  server: {
    headers: {
      "Cross-Origin-Opener-Policy": "same-origin",
      "Cross-Origin-Embedder-Policy": "require-corp",
    },
  },
};
```

**Next.js**

```js
// next.config.mjs
export default {
  async headers() {
    return [
      {
        source: "/(.*)",
        headers: [
          { key: "Cross-Origin-Opener-Policy", value: "same-origin" },
          { key: "Cross-Origin-Embedder-Policy", value: "require-corp" },
        ],
      },
    ];
  },
};
```

**Express / generic Node**

```js
app.use((_, res, next) => {
  res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
  res.setHeader("Cross-Origin-Embedder-Policy", "require-corp");
  next();
});
```

**Chrome / Firefox extension manifests (MV3)**

```json
{
  "cross_origin_opener_policy": { "value": "same-origin" },
  "cross_origin_embedder_policy": { "value": "require-corp" }
}
```

**Caveat — COEP side effects.** `require-corp` blocks any cross-origin resource (images, fonts, iframes, scripts) that doesn't carry `Cross-Origin-Resource-Policy: cross-origin` or appropriate CORS. If your page loads remote avatars, embeds YouTube, pulls fonts from Google, etc., those break unless you serve them from same-origin or add the right headers. This is a deployment decision; opt in only when you understand the resource graph.

If you cannot set these headers (CDN, hosting provider that doesn't allow header injection), the COI service-worker shim pattern (`gzuidhof/coi-serviceworker`) lets a small same-origin SW intercept fetches and re-inject the headers on the way back. We don't bundle this with the SDK because installing a service worker into a consumer's app is intrusive — adopt it deliberately if you need it.

### `initThreadPool(n)` - only for a direct MT client

Every MT entry re-exports `initThreadPool` from wasm-bindgen-rayon, but on the default
worker-backed path you do not call it. The SDK runs every prove inside its own Worker, and
that Worker initializes its own pool: the client forwards `navigator.hardwareConcurrency`
when the page is cross-origin-isolated, and the Worker calls `initThreadPool` before it
constructs its `WebClient`. rayon's global pool is per WASM instance, so a pool you bring
up on the main thread is a different instance from the one that proves, and the call is
inert rather than wrong.

Call it yourself only for a direct MT client on the current thread (`useWorker: false`, or
no Worker support), in that client's own realm:

```ts
import { MidenClient, initThreadPool } from "@miden-sdk/miden-sdk/mt/lazy";

await MidenClient.ready();
await initThreadPool(navigator.hardwareConcurrency); // size to physical threads
const client = await MidenClient.create({ useWorker: false });
```

The ST entries don't expose `initThreadPool` (no thread pool to bring up).

### Timing model — eager vs lazy

The eager entries await WASM at module top level via a small shim, so once an `import` statement resolves, any wasm-bindgen constructor (`new Felt(…)`, `AccountId.fromHex(…)`, `TransactionProver.newLocalProver()`, etc.) is safe to call synchronously on the next line. No `await MidenClient.ready()` is required.

The lazy entries do not run any top-level await. This matters in two environments that hang on TLA:

- **Next.js / SSR** — TLA blocks server-side module evaluation.
- **Capacitor WKWebView hosts (Miden Wallet iOS/Android)** — the custom `capacitor://localhost` scheme handler interacts poorly with TLA in the main WebView. Verified empirically: the same TLA in a dApp WebView (vanilla HTTPS) resolves in <100ms, but hangs indefinitely in the Capacitor host.

On a lazy entry, callers are responsible for awaiting initialization before calling any bare wasm-bindgen constructor. Every async SDK method (`client.accounts.create()`, `client.transactions.send()`, etc.) awaits internally, so you only need to gate on readiness when you're constructing wasm-bindgen types yourself.

### Eager usage (default)

```typescript
// Bundlers resolve `@miden-sdk/miden-sdk` to `./dist/eager.js`.
// The `import` statement awaits WASM; everything below is safe to call sync.
import { MidenClient, AccountId, Felt } from "@miden-sdk/miden-sdk";

const id = AccountId.fromHex("0x…"); // sync, WASM is already initialized
const felt = new Felt(42n); // sync

const client = await MidenClient.createTestnet();
```

`feeFaucetId` is optional. Since 0.17 the chain's fee asset lives in the
protocol configuration, which the client receives from the node when it syncs,
so execution never needs the option: it only sets what `client.feeFaucetId()`
reports before the first sync. Snippets below leave it out.

### Lazy usage (`/lazy`)

```typescript
import { MidenClient, AccountId, Felt } from "@miden-sdk/miden-sdk/lazy";

// Gate any bare wasm-bindgen constructor behind ready():
await MidenClient.ready();
const id = AccountId.fromHex("0x…"); // safe after ready()
const felt = new Felt(42n);

// SDK methods that are already async await internally — no ready() needed:
const client = await MidenClient.createTestnet(); // implicitly initializes WASM
await client.sync();
```

`MidenClient.ready()` is idempotent and safe to call from multiple places — concurrent callers share the same in-flight promise, and post-init callers resolve immediately from a cached module. `MidenProvider`, tutorial helpers, and application code can all call it without any coordination.

### Multi-threaded usage (`/mt` or `/mt/lazy`)

The MT entries enable wasm-bindgen-rayon for ~3–5× faster `proveTransactionWithProver` on hardware-multi-threaded machines. Same shape as ST, plus `initThreadPool` once at startup:

```typescript
// Use the lazy MT entry for environments that hang on TLA (Next.js, Capacitor):
import { MidenClient, initThreadPool } from "@miden-sdk/miden-sdk/mt/lazy";

await MidenClient.ready();
await initThreadPool(navigator.hardwareConcurrency); // bring up the rayon pool ONCE

const client = await MidenClient.createTestnet();
// All subsequent prove calls dispatch across threads automatically.
```

Or eager:

```typescript
import { MidenClient, initThreadPool } from "@miden-sdk/miden-sdk/mt";

await initThreadPool(navigator.hardwareConcurrency);
const client = await MidenClient.createTestnet();
```

Reminder: the `/mt` entries fail to load on pages without cross-origin isolation. See "Setting cross-origin isolation headers" above. If `self.crossOriginIsolated === false` at the time of import, you'll see a `WebAssembly.Memory: shared memory requires crossOriginIsolated` (or similar) thrown out of `__wbg_init`.

### Next.js example

```tsx
// app/page.tsx
"use client";

import { useEffect, useState } from "react";
import { MidenClient } from "@miden-sdk/miden-sdk/lazy";

export default function Page() {
  const [height, setHeight] = useState<number | null>(null);

  useEffect(() => {
    let cancelled = false;
    (async () => {
      await MidenClient.ready(); // optional here — createTestnet awaits internally
      const client = await MidenClient.createTestnet();
      const syncHeight = await client.getSyncHeight();
      if (!cancelled) setHeight(syncHeight);
      client.terminate();
    })();
    return () => {
      cancelled = true;
    };
  }, []);

  return <div>Height: {height ?? "…"}</div>;
}
```

### Capacitor / React Native WebView

Use `/lazy` from anywhere inside a Capacitor iOS/Android host (the main WKWebView). TLA hangs the custom scheme handler; the `MidenClient.ready()` gate is the replacement.

### Framework adapters

`@miden-sdk/react` imports from `/lazy` internally and manages readiness via `isReady`. You can still import wasm-bindgen types from either entry in your own code; see the React SDK README for the recommended pattern.

## Building and Testing the Web Client

If you're interested in contributing to the web client and need to build it locally, you can do so via:

```
pnpm install
pnpm build
```

This will:

- Install all JavaScript dependencies,
- Compile the Rust code to WebAssembly,
- Generate the JavaScript bindings via wasm-bindgen,
- And bundle the SDK into the dist/ directory using Rollup.

To run integration tests after building, use:

```
pnpm test
```

This runs a suite of integration tests to verify the SDK’s functionality in a web context.

### Building the npm package

Follow the steps below to produce the contents that get published to npm (`dist/` plus the license file). All commands are executed from `crates/web-client`.

1. **Install prerequisites**
   - Install the Rust toolchain specified in `rust-toolchain.toml`. Both the pinned nightly and stable are needed: the MT variant uses `-Z build-std` and atomics, and the ST variant is built with stable.
   - Install Node.js >= 20 (see `.nvmrc`) and pnpm 9 (`corepack enable`).
2. **Install dependencies**
   ```bash
   pnpm install
   ```
   This installs both the JavaScript tooling and the `@wasm-tool/rollup-plugin-rust` dependency that compiles the Rust crate.
3. **Build the package**
   ```bash
   pnpm build
   ```
   The `build` script (see `package.json`) performs the following:
   - Removes the previous `dist/` directory (`rimraf dist`).
   - Runs `build-rust-client-js`, which builds the `web_store` TypeScript helper (`crates/idxdb-store/src`) that the SDK imports.
   - Runs Rollup **twice**, once per threading variant: `build-st` (`MIDEN_BUILD_VARIANT=st`, stable toolchain) produces `dist/st/`, and `build-mt` (`MIDEN_BUILD_VARIANT=mt`, pinned nightly plus `build-std` and atomics) produces `dist/mt/`. Both set `RUSTFLAGS="--cfg getrandom_backend=\"wasm_js\""` so the Rust `getrandom` crate targets browser entropy.
   - Runs `build-types`, which copies the generated TypeScript declarations from `js/types` into each dist subdirectory and then runs `node clean.js` to strip the `wasm.js` entry stub.
   - Runs `node ./scripts/post-build.js`.
4. **Inspect the artifacts**
   - `dist/st/eager.js` is the ESM entry point referenced by `"main"`, `"browser"` and `exports["."].import`. `exports["."].node` resolves to `js/node-index.js` instead, so Node gets the napi binding rather than the WASM bundle.
   - `dist/st/index.d.ts` is the TypeScript surface (`"types"`).
   - The `/lazy`, `/mt` and `/mt/lazy` subpaths resolve into `dist/st/` and `dist/mt/` the same way. See [Entry Points](#entry-points-eager--lazy--st--mt) below.
   Use `npm pack --dry-run` if you want to preview the exact file list that would be published.

> Tip: during development you can set `MIDEN_WEB_DEV=true` before running `pnpm build` (or run `npm run build-dev`) to skip the clean step and keep extra debugging metadata in the bundled output. This debugging metadata also includes debug symbols for the generated wasm binary

### Checking the generated TypeScript bindings

The script at `crates/web-client/scripts/check-bindgen-types.js` verifies that every type exported by the generated wasm bindings (`dist/crates/miden_client_web.d.ts`) is re-exported from the public declarations (`js/types/index.d.ts`). Run it after a build with:

```
pnpm check:wasm-types
```

`scripts/check-asset-types.js` type-checks a consumer fixture (with `skipLibCheck`) that imports `VaultAsset` and the `Asset` option type from all four entry points and `NoteAssets` from the root entry. It fails if an entry point stops exporting `VaultAsset`, or if `Asset` stops resolving to the `{ token, amount }` option type, for example because a generated class takes its name:

```
pnpm check:asset-types
```

`WebClient` is intentionally excluded because the wrapper defines its own implementation. If the check reports missing exports, update `js/types/index.d.ts` so consumers get the full generated surface.

## Usage

The following are just a few simple examples to get started. For more details, see the [API Reference](https://docs.miden.xyz/builder/tools/clients/web-client/).

### Quick Start

```typescript
import { MidenClient, FaucetType } from "@miden-sdk/miden-sdk";

// 1. Create client (defaults to testnet, or use createTestnet()/createDevnet())
const client = await MidenClient.createDevnet();

// 2. Create a wallet and a token (faucet account)
const wallet = await client.accounts.create();
const dagToken = await client.accounts.create({
  type: FaucetType.FungibleFaucet, symbol: "DAG", decimals: 8, maxSupply: 10_000_000n
});

// 3. Mint tokens
const mintTxId = await client.transactions.mint({ account: dagToken, to: wallet, amount: 1000n });
await client.transactions.waitFor(mintTxId.toHex());

// 4. Consume the minted note
await client.transactions.consumeAll({ account: wallet });

// 5. Send tokens to another address
await client.transactions.send({
  account: wallet,
  to: "0xBOB",
  token: dagToken,
  amount: 100n
});

// 6. Check balance
const balance = await client.accounts.getBalance(wallet, dagToken);
console.log(`Balance: ${balance}`); // 900n

// 7. Cleanup
client.terminate();
```

### Account Visibility and Faucet Types

`AccountType.Private` and `AccountType.Public` are the native visibility enum
accepted by `AccountBuilder.accountType()` in both browser and Node.js:

```typescript
import { AccountBuilder, AccountType } from "@miden-sdk/miden-sdk";

const builder = new AccountBuilder(new Uint8Array(32))
  .accountType(AccountType.Public);
```

For `client.accounts.create()`, select visibility with `storage: "public"` or
`"private"`, and create a fungible faucet with `type: FaucetType.FungibleFaucet`.
Migrate previous `AccountType.FungibleFaucet` uses to `FaucetType.FungibleFaucet`.
Omit `type` to create a wallet, or pass `components` to create a contract.
The legacy selectors `0`, `1` and `"NonFungibleFaucet"` are still read as
faucet types (non-fungible faucets are not supported yet and are rejected);
`0` and `1` are also `AccountType.Private` / `AccountType.Public`, so never pass
a visibility value as `type`. `create()` throws a `TypeError` for any other
`type`, for faucet fields (`name`, `symbol`, `decimals`, `maxSupply`) without a
faucet type, for `components` on a faucet, and for a faucet missing `symbol`,
`decimals` or `maxSupply`, so a missed migration fails instead of creating a
wallet.

### Create a New Wallet

```typescript
import { MidenClient, AuthScheme } from "@miden-sdk/miden-sdk";

const client = await MidenClient.create();

// Default wallet (private storage, Falcon auth)
const wallet = await client.accounts.create();

// Wallet with options
const wallet2 = await client.accounts.create({
  storage: "public",
  auth: AuthScheme.ECDSA,
  seed: "deterministic"
});

console.log(wallet.id().toString()); // account id as hex
console.log(wallet.isPublic()); // false
console.log(wallet.isPrivate()); // true
```

### Register on an Allowlisted Network

A network that enforces an account allowlist creates an account on chain only once the account is registered with an invitation code from the network operator. Register a new account before its first transaction:

```typescript
const wallet = await client.accounts.create();

if (!(await client.accounts.isAllowed(wallet))) {
  await client.accounts.register({ account: wallet, invitationCode });
}

// When the network funds registered accounts, the funding note arrives on the
// next sync; consuming it is the transaction that creates the account on chain.
await client.sync();
await client.transactions.consumeAll({ account: wallet });
```

`register` fails with code `ACCOUNT_ALREADY_ALLOWED` for an account the node already allows, keeping the code, and a submission that would create an unregistered account fails with `ACCOUNT_NOT_ALLOWLISTED`. `RpcClient.registerAccount` and `RpcClient.isAccountAllowed` expose the node endpoints directly for flows that hold no account state. See [the allowlist guide](https://github.com/0xMiden/web-sdk/blob/main/docs/external/src/web-client/library/allowlist.md) for the full flow.

### Create a Faucet

```typescript
const faucet = await client.accounts.create({
  type: FaucetType.FungibleFaucet,
  symbol: "DAG",
  decimals: 8,
  maxSupply: 10_000_000n
});

console.log(faucet.id().toString());
```

### Read Faucet Metadata

`BasicFungibleFaucetComponent` extracts the on-chain token metadata from a faucet account. The same
component backs both basic and network-style faucets, so it works for either:

```typescript
import { BasicFungibleFaucetComponent } from "@miden-sdk/miden-sdk";

const faucet = BasicFungibleFaucetComponent.fromAccount(account);

faucet.symbol().toString();       // "DAG"
faucet.tokenName();               // "DAG Token"
faucet.decimals();                // 8
faucet.maxSupply().toString();    // "10000000"
faucet.tokenSupply().toString();  // amount minted so far, e.g. "0"
faucet.description();             // string | undefined
faucet.logoUri();                 // string | undefined
faucet.externalLink();            // string | undefined
```

### Send Tokens

```typescript
const txId = await client.transactions.send({
  account: wallet,
  to: "0xBOB",
  token: dagToken,
  amount: 100n
});
```

### Consume Notes

```typescript
// Sync state to discover new notes
await client.sync();

// Consume the notes this account can consume right now. Notes that unlock at a
// later block are left alone, and are counted in neither number below.
const result = await client.transactions.consumeAll({ account: wallet });
console.log(`Consumed ${result.consumed} notes, ${result.remaining} remaining`);

// `remaining === 0` therefore means "nothing consumable now", not "no notes".
// To see block-locked notes too, with their unlock block:
const walletId = wallet.id().toString();
const all = await client.notes.listConsumable({ account: wallet });
for (const record of all) {
  // Match the entry to the account you asked about rather than taking the
  // first: a record can carry one status per account.
  const entry = record
    .noteConsumability()
    .find((nc) => nc.accountId().toString() === walletId);
  const status = entry?.consumptionStatus();
  if (status && !status.isConsumableNow()) {
    console.log(`locked until block ${status.consumableAfterBlock()}`);
  }
}
```

### Check Balance

```typescript
const balance = await client.accounts.getBalance(wallet, dagToken);
console.log(`Balance: ${balance}`);
```

### Read Non-Fungible Assets

```typescript
await client.sync();
const { vault } = await client.accounts.getDetails(wallet);
const assets = vault.nonFungibleAssets().map((asset) => ({
  issuer: asset.faucetId().toString(),
  key: asset.vaultKey().toHex(),
  value: Array.from(asset.intoWord().toU64s()),
}));
```

`nonFungibleAssets()` returns only non-fungible assets from the local vault
snapshot. It returns an empty array when none are present. The order is not
specified. `faucetId()` identifies the issuer, `vaultKey()` returns the complete
asset key, and `intoWord().toU64s()` returns all four value limbs as `bigint`
values. Keep these values as `bigint` or strings to prevent precision loss.

Compare both the complete key and all four value limbs to verify an asset.
The key alone does not contain the complete value. To reconstruct an asset,
use `VaultAsset.nonFungible({ key, value })` with the two `Word` objects.

### Build Notes with Either Asset Type

```typescript
const token = VaultAsset.fungible(faucetId, 100n);
const name = VaultAsset.nonFungible({ key, value });
const assets = new NoteAssets([name]);
assets.push(token);
```

`NoteAssets` accepts one list of 0 to 16 assets. Existing `FungibleAsset`
constructor and `push()` calls remain valid. Duplicate IDs and excess assets
throw catchable errors; a failed push leaves the list unchanged. Inputs remain
usable. `vault.assets()` and `note.assets().assets()` return both variants;
use `kind()`, `asFungible()`, or `asNonFungible()` to inspect them.

For registry publishing, use `Note.withAttachments()` with a single name asset,
public metadata, the registry's approved script and inputs, and
`[new NetworkAccountTarget(registryId).toAttachment()]`. The registry account
must be public. A tag alone does not make a network note. Consume the returned P2ID note to put
the asset back in the vault. See the [non-fungible asset guide](../../docs/external/src/web-client/library/non-fungible-assets.md).

### Batch Operations

Submit multiple operations against a single account as one atomic batch — every transaction in the batch lands together or none does. Each operation builds its own `TransactionRequest` internally; you don't have to assemble or serialize them yourself.

```typescript
const { blockNumber } = await client.transactions.batch({
  account: wallet,
  operations: [
    { kind: "send", to: alice, token: dagToken, amount: 50n, type: "public" },
    { kind: "send", to: bob,   token: dagToken, amount: 30n, type: "public" },
    { kind: "consume", notes: pendingNotes },
  ],
  waitForConfirmation: true,
});
console.log(`Batch landed in block ${blockNumber}`);
```

Operations are discriminated by `kind`: `"send"`, `"mint"`, `"consume"`, `"swap"`, `"execute"`, and `"custom"` (escape hatch for a pre-built `TransactionRequest`). The shape of each operation mirrors the singular options object (`SendOptions`, `MintOptions`, …) minus the `account` field, which is set once at the batch level.

V1 supports only same-account batches — every operation must execute against the `account` passed at the top level. Mixing accounts in one batch is not supported.

For callers that already hold pre-built `TransactionRequest`s, `submitBatch` skips the high-level builders:

```typescript
const { blockNumber } = await client.transactions.submitBatch(wallet, [
  request1,
  request2,
]);
```

The V1 batch primitive returns only the block number — there are no per-tx ids in the result. `waitForConfirmation` polls local sync height until it reaches `blockNumber` (rather than per-tx polling like singular `send` / `consume`).

### Manual Transaction Lifecycle

`client.transactions.submit(account, request)` runs execute → prove → submit → apply in one call. To drive the stages yourself — benchmarking each step or handling errors per stage — `executeRequest` returns a staged handle you advance one step at a time. Each stage carries its own context, so you never re-thread the result or block number:

```typescript
const executed = await client.transactions.executeRequest(wallet, request); // local only
const proven = await executed.prove(); // optional { prover } override
const submitted = await proven.submit(); // network; submitted.blockNumber
await submitted.apply(); // persist + fire observers
```

Nothing is persisted until `apply` runs — stopping after `submit()` leaves the local store unaware of the transaction until the next sync. `submitted.waitForConfirmation()` blocks until the transaction commits on-chain.

Clients sharing a browser database read coherent persisted account state.
Account witnesses refresh when another client changes that state, preserving
untouched vault assets and storage maps. This does not fetch new chain state;
continue to sync before relying on on-chain balances.

For browser stores, `apply` requires the stored account to match the
transaction's execution input. A mismatch rejects before changing account state
or transaction history. A submitted transaction may already be on-chain when
local apply fails; check its status before submitting again.

To submit a proof produced somewhere that shares nothing with this client (a detached prover), pass it back in with `client.transactions.submitProven(proof, result)`, which returns the same submitted handle.

### Paying Transaction Fees

Since protocol 0.16 a chain can charge a verification fee, paid from inside the account's auth procedure rather than by the kernel. `fee::pay_fee` reads the asset and rate out of the transaction's auth argument, which has to be `hash(CONVERSION_INFO || SALT)` with the preimage in the advice map; a procedure that reaches `pay_fee` without that commitment aborts with `ERR_FEE_CONVERSION_INFO_MISSING`.

Fees always settle in the chain's own fee asset at rate 1/1, so there is no conversion info to choose — miden-client builds it and commits it for you while preparing the transaction. The one thing it will not invent is the **salt** the commitment is computed under, because every multisig flavour reuses that salt as its transaction summary's replay guard. So single-sig, no-auth and network accounts need nothing at any base fee (the client commits under a fixed default salt, fixed so a signed summary stays reproducible), while a multisig that declares none fails with `FeeConversionInfoRequired` naming the component. A custom auth procedure that reads conversion info is not recognised, gets nothing committed, and hits the VM abort.

Every `new*TransactionRequest` constructor declares a salt where the executing account needs one, and so does every `client.transactions` operation that builds its own request, so the common cases need no changes. The operations that take a finished request from you — `submit`, `executeRequest`, `submitBatch`, and the `custom` operation of `batch` / `preview` — never do. When you assemble a request from a builder for a multisig, get one that already declares it:

```typescript
const builder = await client.feeAwareTransactionRequestBuilder(wallet);
const request = builder.withCustomScript(script).build();
```

The argument is the account that will **execute** the request — the one whose auth procedure pays. It is a safe drop-in for `new TransactionRequestBuilder()`: for an account that is not a multisig the builder comes back untouched. A zero base fee is not a second condition: since 0.17 a multisig resolves its auth args whatever the chain charges, so a multisig gets them on a fee-free chain too.

To set the salt yourself — which co-signers must do when they need to agree on it without transporting the proposer's bytes — pass it to `feeAwareTransactionRequestBuilder`, together with the block the summary binds: `client.feeAwareTransactionRequestBuilder(multisig, { feeConversionSalt: salt, boundBlockNum: block })`. Both are bound by the summary, so two parties who disagree on either can never derive the same one. Each call consumes the `Word`: it is moved across the WASM boundary, so a second build needs a freshly constructed one, and a spent handle arrives as "no salt given" rather than as an error. A co-signer who has the proposer's serialized request needs neither — it carries the auth argument and its advice-map preimage.

Do **not** reach for `builder.withFeeConversionSalt(salt)` on that builder. `withAuthArg` and `withFeeConversionSalt` are mutually exclusive, and miden-client enforces that by having each setter clear the other, so calling it discards the three-word multisig auth args the builder already carries and the transaction aborts in the auth procedure. On a bare `new TransactionRequestBuilder()` the setter is still a declaration rather than a commitment — `request.feeConversionSalt()` reports it back, `request.authArg()` stays empty, and it survives serialization — which is what a single-sig or custom-auth caller wants. For a custom auth procedure that reads `AUTH_ARGS` as conversion info, compute the commitment yourself and attach it with `withAuthArg` plus `extendAdviceMap` — setting an auth argument opts the request out of the client's fee machinery, which commits only when the request carries none. Declaring a salt against such an account instead is rejected with `FeeConversionInfoUnsupported`.

One path the SDK cannot declare a salt on: `client.pswap.cancelByOrder` builds its request inside miden-client, so there is no builder. An ordinary creator has its conversion info committed and pays normally; a multisig creator fails with `FeeConversionInfoRequired`, so cancel by note with `client.transactions.pswapCancel` there. See the [transactions guide](https://docs.miden.xyz/builder/tools/clients/web-client/library/transactions) for the full narrative.

### Multisig Proposals: Execute at the Tip

Since protocol 0.17 a multisig summary binds a **bound block** named in the multisig auth args, not the reference block the transaction executes at. `feeAwareTransactionRequestBuilder` binds the current sync height and adds that block to the request with `withBlockNumbers`, so the proposer, every co-signer and the executor can all run the proposal at their own current tip and derive the same summary. Each party's client must first have synced to at least the bound block (the largest of `request.blockNumbers()`, by default the proposer's sync height when it built the request); a client below it fails with `requested block N is after transaction reference block M` until it syncs. Do not re-execute a multisig proposal at an anchor: a node keeps account state for only 50 blocks and every fee-paying transaction loads the fee faucet as a foreign account, so anchored re-execution of an older proposal fails with `block N has been pruned`, and a transaction executed at an older reference block expires 20 blocks after it anyway.

```typescript
// Proposer
const request = (await client.feeAwareTransactionRequestBuilder(multisig))
  .withCustomScript(script)
  .build();
const summary = await client.transactions.preview({ operation: "custom", account: multisig, request });
// Co-signer: `await client.sync()`, then preview the proposer's request bytes at
// the local tip and compare `toCommitment()`. Executor:
await client.transactions.submit(multisig, request);
```

Available from `0.17.0-rc.4`. See [the transactions guide](https://github.com/0xMiden/web-sdk/blob/main/docs/external/src/web-client/library/transactions.md#multisig-proposals-bind-a-block-execute-at-the-tip) for verification details.

### Chain-Anchored Execution

For a multisig, use the tip flow above. This section applies when the summary binds the **reference block**, as a single-signature (`signature.masm`) account's does: signatures collected over it only authorize an execution at that exact block, which breaks any flow that collects signatures and executes later.

A `ChainAnchor` pins the reference block so the same summary reproduces on a client at a different sync height:

```typescript
import {
  ChainAnchor,
  TransactionRequest,
  TransactionSummary,
} from "@miden-sdk/miden-sdk";

// Proposer: capture, derive the summary at the anchor, ship all three.
const anchor = await client.transactions.captureAnchor(request);
const summary = await client.transactions.preview({
  operation: "custom",
  account,
  request,
  anchor,
});
await shipToCosigners(
  request.serialize(),
  anchor.serialize(),
  summary.serialize()
);

// Co-signer: re-derive at the proposer's anchor and compare before signing.
// Re-derive from the proposer's request bytes, never from a locally rebuilt
// request: on a fee-charging chain its fee conversion info carries a salt drawn
// fresh on every build, and output notes draw fresh serial numbers, so a rebuilt
// request yields a different summary and the check below fails as if the
// proposal had been tampered with.
const received = ChainAnchor.deserialize(anchorBytes);
const proposed = TransactionSummary.deserialize(summaryBytes);
const proposedRequest = TransactionRequest.deserialize(requestBytes);
const derived = await client.transactions.preview({
  operation: "custom",
  account,
  request: proposedRequest,
  anchor: received,
});
if (derived.toCommitment().toHex() !== proposed.toCommitment().toHex()) {
  throw new Error("proposal does not match the summary presented for signing");
}

// Executor: replay at the same anchor, whatever the local height is by now.
await client.transactions.submit(account, request, { anchor: received });
```

The `anchor` option is available on `preview({ operation: "custom" })`, `executeRequest`, and `submit` — the methods that take a caller-built request.

The re-derivation above proves the request, anchor and summary agree with each other. It does not prove the transaction does what you want — all three came from the proposer, so they agree by construction for any request the proposer chose. A cheap consistency check on top:

```typescript
// A summary that binds the reference block signs that block, so a mismatched
// anchor is detectable without paying for an execution. (A multisig summary
// binds its bound block instead and takes no anchor.)
if (received.commitment().toHex() !== proposed.blockCommitment().toHex()) {
  throw new Error("anchor is not the block this summary was built at");
}
```

Before signing, inspect what the transaction actually does — `summary.accountDelta()`, `summary.inputNotes()`, `summary.outputNotes()`, and `summary.expirationDelta()` for how long the authorization stays live (`0` means no expiration was set, not that it has already expired) — and confirm it matches what you agreed to. `ChainAnchor` enforces only that its header and partial blockchain are consistent with each other, which is computable over an invented chain; fetch the header for `anchor.blockNum()` with `RpcClient.getBlockHeaderByNumber` and compare commitments to confirm the block is real.

An anchor pins the **reference block and chain data only**. Account state and authenticated input-note records still come from each participant's own local store, so every party must also agree on the account state. If the account moved in a way that changes the transaction's effects, the re-derived summary will not match even though the anchor is correct - the most common reason a co-signing flow fails.

A match, however, does not prove the two parties agree on account state. The summary binds the account *delta*, not the state it applies to, so divergence that leaves the delta and note sets unchanged — an unrelated nonce bump, assets arriving, or a change to a multisig's signer set or threshold — yields an identical commitment and passes verification. Signatures gathered under one threshold stay valid after it is lowered. Check the state you care about directly.

See [the transactions guide](https://github.com/0xMiden/web-sdk/blob/main/docs/external/src/web-client/library/transactions.md#chain-anchored-execution) for the full flow.

### Foreign Accounts

A transaction that invokes a procedure on another account declares it as a `ForeignAccount`. Two kinds:

```typescript
import { ForeignAccount, AccountStorageRequirements } from "@miden-sdk/miden-sdk";

// Public — state and code fetched from the network at execution time.
ForeignAccount.public(oracleAccountId, new AccountStorageRequirements());

// Private — the caller supplies the state; only an inclusion proof is fetched.
ForeignAccount.private(account);
```

A public entry's inputs are fetched against the transaction's reference block, and the vault and storage maps the foreign code actually reads are resolved during execution as per-asset and per-key witnesses rather than up front. A transaction pinned to a block the node no longer serves account state for therefore cannot execute: pin it to a recent block instead.

### Partial-Swap (PSWAP) Orders

A partial-swap note offers one asset for another and can be filled by multiple
counterparties over time — each partial fill pays the creator and leaves a
remainder note carrying the unfilled balance. The client tracks that chain as a
**lineage** keyed by a stable `orderId`, advancing it round by round as fills
are discovered on sync.

```typescript
// Offer 100 of token A for 25 of token B.
await client.transactions.pswapCreate({
  account: wallet,
  offer: { token: aToken, amount: 100n },
  request: { token: bToken, amount: 25n }
});
await client.sync();

// The order is tracked as a lineage keyed by a stable order id.
const [lineage] = await client.pswap.lineagesFor(wallet);
const orderId = lineage.orderId();
console.log(lineage.remainingOffered().toString()); // unfilled offered balance

// A counterparty fills part of the order:
//   client.transactions.pswapConsume({ account, note, fillAmount });
// On the next sync the lineage advances, and `remainingOffered()` shrinks.

// Reclaim the unfilled remainder on the current tip, by stable order id.
// `waitForConfirmation` blocks until the cancel commits AND a sync brings
// the consumed-note update down; without it, the call resolves at submit
// time and `pswap.lineage(orderId)` still reads `Active` until the next
// sync. The lineage only transitions to `Reclaimed` once the chain sees
// the cancel land.
await client.pswap.cancelByOrder({ orderId, waitForConfirmation: true });
```

`client.pswap.lineages()` returns every order this client created;
`client.pswap.lineage(orderId)` returns one order's lineage, or `null` if it is
not tracked.

### Bridge out (AggLayer)

`client.transactions.bridge(...)` bridges a fungible asset out to another network via the AggLayer. It emits a single public B2AGG note that the bridge account consumes, burning the asset so it can be claimed at the destination Ethereum address on the AggLayer-assigned network.

```typescript
await client.transactions.bridge({
  account: wallet, // sender (executes the transaction)
  bridgeAccount: bridge, // consumes the note and burns the asset
  token: dagToken, // faucet of the asset being bridged
  amount: 100n,
  destinationNetwork: 1, // AggLayer-assigned network id
  destinationAddress: "0x000000000000000000000000000000000000dEaD"
});
```

The 20-byte destination is also available as an `EthAddress` (`EthAddress.fromHex("0x…")`) for the lower-level builders `Note.createB2AggNote(...)` and `client.newB2AggTransactionRequest(...)`.

### Network Notes

A network note is a Public note carrying a `NetworkAccountTarget` attachment; a public network account auto-consumes it once the note lands on-chain — no manual `consume` call needed on the target side.

```typescript
const { txId, note } = await client.transactions.createNetworkNote({
  account: senderId,
  target: networkAccountId,
  script: myNoteScript, // or: recipient: myRecipient
  waitForConfirmation: true,
});
console.log(note.isNetworkNote()); // true
```

Provide exactly one of `script` or `recipient`. Notes are always Public — the attachment, not the tag, is what a network account matches on. The standalone `buildNetworkNote(opts)` builds the same note without submitting.

To create the receiving account, build a **public** account carrying the network-account auth component — its note-script allowlist tells the node which notes the account may auto-consume:

```typescript
// Each allowed note script carries the fee charged to consume it, in the
// chain's fee asset. Zero is a valid price. The fee faucet must be the chain's
// own: the node never runs network transactions for an account whose fee asset
// differs from the chain's protocol configuration, and says nothing to the
// client - the account's notes are simply never consumed.
const feeFaucetId = await client.feeFaucetId();
const components = AccountComponent.createNetworkAuthComponents(
  [new NoteScriptFee(myNoteScript.root(), 0n)],
  feeFaucetId
);

const builder = new AccountBuilder(seed)
  .storageMode(AccountStorageMode.public())
  .withComponent(myComponent);
// The call returns the auth component plus the components backing its fee
// policy; the account needs all of them.
for (const component of components) builder.withComponent(component);
const { account } = builder.build();
```

The allowlist must be non-empty. The canonical expiration transaction script is always allowlisted, since the node attaches it to every network transaction; any other transaction script is forbidden unless allowlisted via the optional third argument (`TransactionScript.root()`). Deploying the account needs an effect: since 0.17 the auth component asserts the transaction consumed an input note, created an output note, or changed account state before it pays the fee, so an empty transaction aborts. Consume a note the account allowlists, or run an allowlisted transaction script that changes its state. Readback: `account.isNetworkAccount()` and `account.networkNoteAllowlist()`.

### Cleanup

When you're finished using a MidenClient instance, call `terminate()` to release its Web Worker:

```typescript
client.terminate();

// Or use explicit resource management:
{
  using client = await MidenClient.create();
  // ... use client ...
} // client.terminate() called automatically
```

## Observability

The client reports every operation it runs — name, outcome, how long it took — to a callback you register when you construct it:

```typescript
import { MidenClient, type MidenObservation } from "@miden-sdk/miden-sdk";

const client = await MidenClient.create({
  rpcUrl: "testnet",
  observer: (o: MidenObservation) => {
    console.log(o.op, o.outcome, Math.round(o.durationMs));
  },
});
```

`observer` is a field on `ClientOptions`, so it works on `create`, `createTestnet`, and `createDevnet` alike.

**The SDK never transports an observation.** It hands the object to your callback and forgets about it. There is no telemetry dependency in `@miden-sdk/miden-sdk` — not a direct one, not a peer, not an optional one — and the module that delivers observations has no egress primitive in it and imports nothing at all. Both halves are enforced on every CI run by `js/__tests__/no-telemetry-dependency.test.js`, which parses the module rather than grepping it: it asserts the module reaches for no global object, builds no code at runtime, constructs nothing, and calls nothing but your observer. Where the observations go is entirely your decision, made in your code.

If you'd rather not write that forwarding yourself, two opt-in binding packages do it for a vendor you already run — and they hand data to a client or tracer **you** construct and configure, so they are not a transport the SDK owns either:

| Package | Turns observations into |
|---|---|
| [`@miden-sdk/telemetry-sentry`](https://github.com/0xMiden/web-sdk/tree/main/packages/telemetry-sentry) | `captureMessage` calls on a Sentry client you own |
| [`@miden-sdk/telemetry-otel`](https://github.com/0xMiden/web-sdk/tree/main/packages/telemetry-otel) | spans on an OpenTelemetry tracer you own |

Neither package depends on its vendor, not even as a peer — both are typed against the shape they call, so you keep control of the version, the configuration and the lifecycle.

### What an observation contains

```typescript
interface MidenObservation {
  op: string;                             // "syncState", "proveTransaction", …
  outcome: "ok" | "error";
  durationMs: number;                     // wall time the caller waited
  sensitive?: MidenObservationSensitive;  // absent by default — see below
}
```

`op` is the name of the underlying client method, not the name of the high-level call you made. One call into a resource API usually produces **several** observations: `client.transactions.send(...)` builds the request and then runs execute → prove → submit → apply, so it reports `newSendTransactionRequest`, `executeTransaction`, `proveTransaction`, `submitProvenTransaction`, and `applyTransaction` as five separate observations. Aggregate by `op` rather than assuming a one-to-one mapping with your own call sites.

`durationMs` is measured with `performance.now()` around the awaited call, so it is a fractional millisecond value, not an integer. Round it yourself if your backend wants integers.

Two properties are worth relying on:

- **Your observer cannot fail an operation.** It is invoked inside a `try`/`catch` that swallows everything it throws. A broken observer degrades to silence, never to a failed transaction.
- **Your observer cannot slow an operation down or change its shape.** It is called synchronously, after the operation has already settled, on the path that was going to resolve or reject anyway. Nothing is queued, batched, or deferred. Keep the callback cheap — it runs on the caller's critical path.

A mock client (`MidenClient.createMock()`) can neither register an observer nor enable the sensitive channel — `MockOptions` carries neither field, so nothing you pass to `createMock` reaches the sink. It is not silent, though: registration is process-wide (see below), so if a real client registered an observer earlier in the same process, a mock client's operations report to it as well. The three sync methods the mock overrides — `syncState`, `syncChain`, and `syncNoteTransport` — are the exception and emit nothing.

### The sink is process-wide, and there is one of it

Registration is global to the module, not scoped to the client instance. Constructing a second client with an `observer` **replaces** the first one's — both clients then report to the newest callback. If you need to fan out to more than one destination, do it inside your own callback.

There is no public way to unregister: `observer` is a construction-time option, and passing a non-function (including `null`) leaves any previously registered observer in place rather than clearing it. Register the sink you want for the life of the process.

### Sensitive detail — read this before enabling it

By default, the `sensitive` key is **absent from the observation object entirely**. Not `undefined`, not an empty object — absent, so `"sensitive" in observation` is a truthful test of whether the channel is on, and a consumer can distinguish "not enabled" from "enabled but nothing to report".

Passing `observeSensitive: true` at construction turns it on:

```typescript
const client = await MidenClient.create({
  rpcUrl: "testnet",
  observeSensitive: true, // discloses raw error text — read on before setting
  observer: (o) => myErrorReporter(o),
});
```

What that actually exposes:

```typescript
interface MidenObservationSensitive {
  errorMessage?: string; // verbatim error message, exactly as thrown
  errorStack?: string;   // verbatim stack trace
  accountId?: string;    // declared; not currently populated by the SDK
}
```

Be clear-eyed about what "verbatim" means. `errorMessage` is the untouched `error.message` coming out of the client and, below it, the Rust core — it is not classified, redacted, allow-listed, or truncated, and no filter stands between it and your observer. Whatever a failure happens to say about the account, note, or asset it was working on is what you receive, and error text is not a stable interface: a client upgrade can widen it without warning. `errorStack` is the untouched stack. Anything you would be uncomfortable seeing in your telemetry vendor's UI, in its search index, and in its retention window is something you should assume will end up there.

The rest of the shape, so you can plan around it:

- The channel is populated **only on failure**. A successful operation has no `sensitive` key even with the flag on, so this is not a way to see which accounts a user touched — only which ones produced errors.
- `accountId` is declared in the type but the SDK does not currently populate it. Do not write code that depends on it being present; treat it as reserved. (The OTel binding already reads it defensively, so it will start working if a later version fills it in.)
- The safe fields never carry any of this. `op` and `outcome` are drawn from a fixed vocabulary and `durationMs` is a number, so an observation with the channel off is fit to send anywhere.

The flag is deliberately hard to switch on by accident:

- **Only the literal boolean `true` enables it.** A truthy `"true"` from an environment variable, a query string, or a JSON round-trip reads as *off*. An ambiguous value is far likelier to be a wiring mistake than a decision to disclose user data, so it is read the safe way.
- **It is sealed at construction.** The resolved value is written once with `Object.defineProperty` as non-writable and non-configurable, so no later assignment — by your code, by a plugin, or by ours — can turn disclosure on for a client that was built without it. Enabling it has to be a deliberate, greppable act at one call site.
- **It is per-client, while the observer is global.** If you run one client with the flag and one without, observations from both arrive at the same callback and whether `sensitive` is present depends on which client ran that operation.
- **Enabling it logs a console warning**, once per process.

Leave it unset in any application with confidentiality obligations to its users. A wallet, for example, must never enable it.

Both binding packages then require the disclosure a *second* time — `includeSensitive: true` — and drop the channel by default even when the SDK supplies it. Leaving either end alone is enough to keep it out of your vendor.

## License

This project is licensed under the MIT License - see the LICENSE file for details.