From 94a04e604755f9ddc0cc0ff0435bd3e33577b0a8 Mon Sep 17 00:00:00 2001 From: Wiktor Starczewski Date: Sun, 23 Aug 2026 22:15:09 +0200 Subject: [PATCH 1/6] docs: rename CLAUDE.md to AGENTS.md, add Claude Code import stub --- AGENTS.md | 265 +++++++++++++++++++ CLAUDE.md | 271 +------------------- CONTRIBUTING.md | 2 +- packages/react-sdk/{CLAUDE.md => AGENTS.md} | 15 +- packages/react-sdk/package.json | 2 +- 5 files changed, 289 insertions(+), 266 deletions(-) create mode 100644 AGENTS.md rename packages/react-sdk/{CLAUDE.md => AGENTS.md} (95%) diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..b38df7bd --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,265 @@ +# AGENTS.md — repo notes for AI agents + +Conventions and tooling notes for contributors to `0xMiden/web-sdk`. End-user docs live in [README.md](README.md); per-package usage guides live alongside the packages (e.g. [`packages/react-sdk/AGENTS.md`](packages/react-sdk/AGENTS.md)). + +Those per-package files are aimed at **consumers** of the published npm packages, not at people working in this repo, and they ship inside the published tarballs. This file is the one aimed at you if you are changing code here. + +## What this repo is + +A pnpm monorepo holding the JS / WASM / React bits previously part of [`0xMiden/miden-client`](https://github.com/0xMiden/miden-client). Five published artifacts: + +| Artifact | Path | Registry | +|---|---|---| +| `@miden-sdk/miden-sdk` | `crates/web-client/` (Rust + WASM + JS bindings) | npm | +| `@miden-sdk/react` | `packages/react-sdk/` | npm | +| `@miden-sdk/vite-plugin` | `packages/vite-plugin/` | npm | +| `@miden-sdk/node-{darwin-arm64,darwin-x64,linux-x64-gnu}` | `packages/node-sdk-*` | npm (platform-specific native binaries; consumed via `optionalDependencies` on `@miden-sdk/miden-sdk`) | +| `miden-idxdb-store` | `crates/idxdb-store/` | crates.io | + +The `Cargo.toml` workspace dep `miden-client = "x.y.z"` pins compatibility with the upstream Rust crate. Changes to shared types (Account, Note, gRPC schema, …) usually need a coordinated PR in `0xMiden/rust-sdk` first. + +## Toolchain + +- **Package manager**: pnpm 9 (workspace at `pnpm-workspace.yaml`). **Never** use `yarn` or `npm install` — they will desync the lockfile. +- **Node**: ≥ 20 (`engines.node` in `package.json`, `.nvmrc`). +- **Rust**: stable 1.96.1 + nightly (for `cargo +nightly fmt`, `clippy`, and `fix`). Pinned in `rust-toolchain.toml`. +- **Lefthook** runs pre-commit; `pnpm install` wires it via the `prepare` script. + +## Build / lint / test + +Drive everything through the `Makefile` — never call `cargo fmt` directly (the project requires nightly + an exact prettier/eslint pass that vanilla `cargo fmt` skips). + +```bash +make help # list targets + +# Build +make build-wasm # WASM crates only (wasm32-unknown-unknown) +make build-web-client # WASM + JS bindings + dist +make build-react-sdk # everything @miden-sdk/react needs + +# Lint + format +make format # nightly cargo fmt + prettier write + eslint --fix +make format-check # CI form (no writes) +make clippy-wasm # clippy for both WASM crates +make typos-check # spellcheck +make lint # umbrella: fix-wasm + format + clippy-wasm + typos + checks +make web-client-check-methods # verifies every WASM method is classified in the JS proxy + +# Test +make test-coverage # all coverage gates (react-sdk + idxdb-store + vite-plugin + web-client unit) +make test-react-sdk # vitest unit (jsdom) +make test-web-client-unit # vitest unit (web-client) +make integration-test-web-client # playwright (chromium); accepts SHARD_PARAMETER +make integration-test-web-client-webkit +``` + +CI (`.github/workflows/test.yml`) runs all of the above on every PR. `main` and `next` warm sccache + Swatinem/rust-cache. + +## Coverage thresholds + +`packages/react-sdk/vitest.config.ts` enforces `lines / branches / functions / statements ≥ 95`. Two files are excluded because they require the real WASM binary and are covered by Playwright integration tests: + +- `src/utils/accountBech32.ts` — covered by `test/accountBech32.test.ts` +- `src/hooks/useAssetMetadata.ts` — covered by `test/useAssetMetadata.test.ts` + +**Always run `make test-react-sdk` locally before pushing** — CI will block the merge if any threshold dips. Lowering thresholds is not the right fix; either add tests or move the file to the excluded list with justification. + +## WASM concurrency: `runExclusive` + +The wasm-bindgen `WebClient` is **not** safe under concurrent access. Calls that go through it from multiple call sites must serialize via the AsyncLock exposed by `MidenProvider`: + +```ts +const { runExclusive } = useMiden(); +await runExclusive(async (client) => { /* … */ }); +``` + +Symptom of a violation: `Error: recursive use of an object detected which would lead to unsafe aliasing in rust`. The `crates/web-client/test/sync_lock.test.ts` integration test guards against regressions — if you add a hook that touches the client, route it through `runExclusive` (or one of the existing serialized helpers) or the lock test will fail. + +## Eager vs lazy entry points + +`@miden-sdk/miden-sdk` ships two entry points with identical APIs but different init behaviour: + +| Specifier | When WASM loads | Use when | +|---|---|---| +| `@miden-sdk/miden-sdk` | At import (top-level await) | Vite/Webpack browser bundles where TLA is fine | +| `@miden-sdk/miden-sdk/lazy` | On first `await MidenClient.ready()` (or first awaited SDK method) | SSR (Next.js, Remix, SvelteKit), Capacitor WKWebView hosts, anywhere TLA is unsafe | + +Same split applies to `@miden-sdk/react` (`react/lazy` pulls `miden-sdk/lazy`). The eager/lazy contract is guarded by `crates/web-client/test/eager_entry.test.ts` — if you change the public API in one entry, mirror it in the other and re-run the type-check scripts under `crates/web-client/scripts/`. + +## Releases + +Two long-lived branches: + +- **`main`** → npm `latest` dist-tag. Released on GitHub release events. +- **`next`** → npm `next` dist-tag. Released when a PR merges into `next` carrying the `patch release` label. + +Both branches have protection enabled; required status checks mirror across the two. + +The release-publish gate compares the local `package.json` version against the **npm registry** (not against the previous git commit) — see `scripts/check-{web-client,react-sdk,vite-plugin}-version-release.sh`. So a release tag publishes whichever of the four packages have versions not yet on npm; bumping a single package is a clean release of just that one. + +Release WASM size is gated at 25 MiB for ST and 35 MiB for MT. These limits reject both a `wasm-opt` failure and a skipped MASP debug strip before publishing. + +Crate publishing (`miden-idxdb-store`, `miden-client-web`) goes through `.github/workflows/publish-crates-release.yml` and uses the `CARGO_REGISTRY_TOKEN` org secret. + +## CHANGELOG content + +The root `CHANGELOG.md` is read by **consumers of the SDK** — dApp authors and downstream library maintainers, not the team that ships the SDK. Before adding an entry, imagine that audience opening the file at the moment they upgrade. They want to know: what new API can I call? what behavior changed? what broke? + +What does NOT belong in CHANGELOG: + +- CI plumbing changes ("CI now uses github-hosted runners for publish", "added a chmod fix", "consolidated workflows"). Use the `no changelog` PR label. +- Build-system or tooling changes that don't reach the published bytes ("switched lint runner", "bumped a dev dep"). Same — `no changelog` label. +- Failed release attempts. If `alpha.1` and `alpha.2` had to be skipped before `alpha.3` shipped, the changelog entry is for `alpha.3` and describes the user-visible state. Don't write a postmortem of the misses. +- Internal refactors that don't change the public API surface. + +What DOES belong: + +- New public APIs (with the smallest example or method shape). +- Behavioral changes consumers can observe (e.g. "`account.storage()` now returns a `StorageView` wrapper"). +- Bug fixes that resolve a symptom downstream code might have hit. +- Breaking changes (loud, with migration guidance). + +When in doubt, drop the entry and apply `no changelog`. A missing entry the reviewer can ask about is cheaper than a noisy one the consumer has to skip past. + +## Gotchas worth remembering + +- **No yarn.** The repo migrated from yarn to pnpm. If you see a doc, comment, or script that says `yarn ...`, it's stale — fix it (or flag it). +- **Don't chain `pnpm --filter ... -- arg` through npm-script `&&`.** pnpm's argument forwarding only wires through to the LAST command in the chain. The Makefile splits multi-step playwright invocations across explicit Make recipes for this reason; preserve that pattern (see `integration-test-web-client` in `Makefile`). +- **Test sharding is manually balanced.** `packages/react-sdk/playwright.config.ts` defines four CI shard projects (`ci-shard-1` … `ci-shard-4`) with explicit `testMatch` arrays sized empirically from observed run timings. Rebalance by moving file paths between arrays — no workflow edits needed. Comment block at the top of the config explains the history. +- **Network-bound tests don't belong in CI.** Anything that hits a live RPC node (testnet/devnet) is excluded. If you add such a test, gate it on an env var and skip by default. +- **Account ID display.** Hooks accept hex (`0x…`) and bech32 (`mtst1q…`) interchangeably. Bech32 prefix tracks the active network — `mtst1` for testnet/devnet, `mid1` for mainnet (when it lands). Don't hardcode prefixes. +- **Code comments describe current state, not history.** Don't reference PR review threads, "earlier revisions", "per review feedback", or links to specific comment IDs in source comments — that context rots the moment the PR merges or the thread resolves. State the present-tense rationale a future reader needs ("X is gated behind `testing` so it doesn't ship in production WASM bundles"), and leave the historical "why we changed it" to the commit message and PR description. + +## Cross-repo coordination + +| Concern | Repo | +|---|---| +| Shared Rust types, gRPC schema, `MidenClient` semantics | [`0xMiden/rust-sdk`](https://github.com/0xMiden/rust-sdk) | +| Account compiler, MASM standard library, base protocol types | [`0xMiden/miden-base`](https://github.com/0xMiden/miden-base) | +| MidenFi browser-extension wallet adapter | [`0xMiden/miden-wallet-adapter`](https://github.com/0xMiden/miden-wallet-adapter) | +| Para signer integration | [`0xMiden/miden-para`](https://github.com/0xMiden/miden-para) | +| Turnkey signer integration | [`0xMiden/miden-turnkey`](https://github.com/0xMiden/miden-turnkey) | + +PRs that touch the WASM/JS boundary often need a synchronized PR in rust-sdk — bump the workspace dep and verify the integration tests still pass. + +### Linking a web-sdk PR to an in-flight rust-sdk PR + +**ALWAYS use the `Client PR: #N` marker when opening a web-sdk PR that depends on an unmerged / unreleased rust-sdk change.** It is the load-bearing machine-readable handle — prose mentions ("Companion PR: rust-sdk#N", "depends on …") do NOT trigger the linked-PR pipeline. Put the marker on its own line in the PR description (top or bottom both fine). Both `Client PR: #N` and `Client PR: 0xMiden/rust-sdk#N` are accepted; cross-repo is required when the linked PR comes from a fork. + +When a web-sdk PR depends on Rust changes that haven't been released yet (i.e. the upstream PR on rust-sdk is still open), add a marker line to the web-sdk PR description: + +``` +Client PR: #2080 +``` +or, for forks / cross-repo, +``` +Client PR: 0xMiden/rust-sdk#2080 +``` + +CI picks up the marker via `.github/actions/inject-linked-client-pr`, appends a `[patch]` block to `Cargo.toml` (runner-local — never committed) pointing the workspace `miden-client` dep at the linked PR's head, refreshes `Cargo.lock`, and posts a sticky comment on the web-sdk PR summarizing what was patched. There is at most one such comment per PR (the action deletes it if the marker is later removed). + +Local-dev parity: + +```bash +# Apply the same patch to your working tree (reads the marker from the current branch's PR body): +scripts/dev-with-client-pr.sh + +# Or pass an explicit number / cross-repo target: +scripts/dev-with-client-pr.sh 2080 +scripts/dev-with-client-pr.sh some-fork/rust-sdk#1965 + +# Strip the patch before committing: +scripts/dev-with-client-pr.sh --clear +``` + +The script writes a marker-wrapped `[patch]` block at the bottom of `Cargo.toml`. A pre-commit hook (`lefthook.yml`) blocks any commit while the markers are present, so you can't ship the local override by accident. + +**Mergeability gate.** A separate workflow (`.github/workflows/check-linked-client-pr.yml`) keeps a `linked-client-pr-ready` check on the PR. It stays *pending* while the linked client PR isn't merged-and-reachable from web-sdk's target branch's canonical refs (rust-sdk `next` for `next`-targeted PRs, or the latest rust-sdk release tag for `main`-targeted PRs). It re-evaluates every 15 minutes, so the check goes green automatically once upstream catches up — no need to push to the PR. Configure branch protection to require this check before merge. + +## Documenting public-API changes + +Any change that adds, renames, removes, or alters the observable behavior of a method, type, hook, option field, or return shape on either the `MidenClient` resource surface or `@miden-sdk/react` is a public-API change. Document it in **all** of the surfaces below before merging — the surfaces aren't redundant; each one is read at a different moment in the consumer's workflow (CHANGELOG at upgrade time, narrative docs / README when learning, JSDoc in the IDE, typedoc on the API-reference site). + +### Where the docs are published + +| Surface | URL | How it's built | +|---|---|---| +| **Narrative docs (canonical user-facing site)** | `https://docs.miden.xyz/builder/tools/clients/web-client/` (MidenClient) and `/builder/tools/clients/react-sdk/` (React SDK) | Docusaurus site at [`0xMiden/miden-docs`](https://github.com/0xMiden/miden-docs). The `deploy-docs.yml` workflow there vendors each upstream repo and copies a designated docs subtree (`docs/external/src/*`) into `docs/builder//`. | +| **API reference (typedoc)** | Same site, deeper paths | `crates/web-client/typedoc.json` declares `out: ../../docs/typedoc/web-client`. Generated by `pnpm --filter @miden-sdk/miden-sdk run typedoc` from the curated [`docs-entry.d.ts`](crates/web-client/js/types/docs-entry.d.ts) entry point. | +| **CHANGELOG (upgrade-time reading)** | Root `CHANGELOG.md` — read by dApp authors at upgrade time. | Hand-written. CI ingestion is per-repo: don't expect this file to be aggregated elsewhere. | +| **READMEs (npm landing page)** | `crates/web-client/README.md` and `packages/react-sdk/README.md` are what npm users see on the package page. | Hand-written. Keep narrative aligned with the published Docusaurus site — they share content but the README has the wider audience for first-touch. | + +### Source-of-truth for the published narrative docs + +The Docusaurus site at miden-docs ingests **`docs/external/src/`** from each upstream repo and copies the contents into `docs/builder//`. After the web/WASM split (PR [#1992](https://github.com/0xMiden/miden-client/pull/1992)) miden-client's `docs/external/src/` now contains only Rust-client material; the **MidenClient resource API and React SDK narrative docs need to live in this repo's `docs/external/src/`** and be wired into the deploy-docs workflow. The expected layout (mirrors what miden-client used to ship): + +``` +docs/external/src/ +├── _category_.yml +├── index.md # Builder → Client landing +├── web-client/ # @miden-sdk/miden-sdk +│ ├── _category_.yml +│ ├── get-started/ # install, quick start, send/receive, custom signer +│ ├── library/ # accounts, notes, transactions, sync, prover, compile +│ └── examples.md +└── react-client/ # @miden-sdk/react + ├── _category_.yml + ├── get-started/ + └── library/ # accounts, notes, provider, hooks ... +``` + +If your change adds a public capability and `docs/external/src/` doesn't exist yet (or the relevant subdir is missing), **create the page as part of the same PR**. Don't ship a feature whose only narrative documentation is the README — the README is reference, the Docusaurus page is where consumers actually learn the workflow. + +### Typedoc — regenerated by CI, don't commit + +`docs/typedoc/web-client/` is **build output**, not source. CI regenerates it fresh on every run via `pnpm --filter @miden-sdk/miden-sdk run typedoc`, fed by the curated [`crates/web-client/js/types/docs-entry.d.ts`](crates/web-client/js/types/docs-entry.d.ts) entry point (which re-exports `api-types.d.ts` wholesale plus selected WASM classes). The directory is `.gitignore`d. + +The `Check that web client documentation is up-to-date` step in `.github/workflows/test.yml` runs `git diff --exit-code` over the regenerated tree. With the dir untracked the diff is empty — the step is a **warning-only smoke test** that surfaces typedoc's own warnings during the run. It does not gate merge. + +What this means in practice: keep your JSDoc on `api-types.d.ts` accurate (that's where typedoc reads from), and don't worry about regenerating docs locally. The published API reference picks up the next typedoc run when the docs site rebuilds. + +### MidenClient surface — `crates/web-client/` + +| Surface | What goes there | Trigger | +|---|---|---| +| `crates/web-client/js/types/api-types.d.ts` | TS declaration with full JSDoc on every method, option field, and return shape. Discriminated unions for option variants. The JSDoc IS the typedoc source — be thorough here. | Any addition/change to a `*Resource` interface, `MidenClient` class, or supporting option/result type. | +| `crates/web-client/js/resources/.js` | JSDoc comment on the impl method explaining behavior, inputs, return value, and any non-obvious invariants (locking, atomicity, polling semantics). | Any new method or behavioral change on a resource impl. | +| `crates/web-client/js/types/docs-entry.d.ts` | Add the type to the curated re-exports if it should appear on the typedoc-generated API reference. `api-types` is already re-exported wholesale; only WASM-side classes need explicit listing. | New WASM class becomes part of the public surface. | +| `docs/typedoc/web-client/` (generated, gitignored) | **Don't commit.** Regenerated by CI; the in-repo CI verification step is warning-only. Just keep the JSDoc on `api-types.d.ts` accurate and the rendered API reference will update on the next docs build. | Always covered automatically once the JSDoc is right. | +| `docs/external/src/web-client/` | Narrative Docusaurus page under `library/` (concept reference) or `get-started/` (workflow). Show the happy path; cross-reference singular siblings. Mention V1 constraints if they're non-obvious (single-account, no per-tx ids, etc.). | New high-level capability that a dApp author would reach for. | +| `crates/web-client/README.md` → `## Usage` | Same narrative as the Docusaurus page, condensed. The README is what npm users see on the package landing page. | Same as above. Keep aligned with the Docusaurus copy. | +| Root `CHANGELOG.md` | One bullet under `## (TBD)` → `### Enhancements` (or `### Fixes` / `### Breaking`). Prefix tags: `[FEATURE][web]` for web-only, `[FEATURE][rust,cli,web]` for cross-cutting. Include the *smallest* example or method shape, link the PR (`web-sdk#NN`) and any companion miden-client PR. Don't repeat README copy verbatim — the audience is a consumer who's about to upgrade. **NEVER add an entry to a section whose version has already been published — check `gh api repos/0xMiden/web-sdk/releases/latest` for the latest tag and put new entries under a section whose version is strictly higher and still has `(TBA)` / `(TBD)` next to it. If no such section exists, add one.** The header at the top of `CHANGELOG.md` may lag (a `(TBA)` heading often persists after the release tags out); don't trust the heading alone. | Any user-visible API addition, behavior change, or fix. | + +### React SDK surface — `packages/react-sdk/` + +| Surface | What goes there | Trigger | +|---|---|---| +| `packages/react-sdk/src/hooks/.ts` | JSDoc on the hook export covering the returned object shape (`{action, result, isLoading, stage, error, reset}` for mutations; `{...data, isLoading, error, refetch}` for queries), accepted args, side effects, and concurrency guards. | New hook or change to an existing hook's signature/return. | +| `packages/react-sdk/src/types/*` | TS declarations for any new option/result types the hook surfaces. Mirror the discriminated-union conventions used in the WebClient surface. | New public type emerging from a hook. | +| `docs/external/src/react-client/` | Narrative Docusaurus page (per-hook or per-pattern). The hub is `library/`, deep-link individual hooks under `library//`. | New hook, new pattern, or changed semantics worth a code example. | +| `packages/react-sdk/AGENTS.md` | Per-package hook-by-hook usage guide, shipped to npm consumers. Add a fenced code block under the right section (`## Reading Data`, `## Writing Data`, `## Common Patterns`, `## External Signer Integration`). Show realistic usage, not just the signature. **Mirror the Docusaurus content** — same examples, same prose, this is the npm-landing version. | Same as above. | +| `packages/react-sdk/README.md` → `## Features` | One bullet on the high-level feature list if it's a notable addition (new hook category, new integration). Subordinate hook tweaks don't go here. | A reader scanning the README would want to know this exists. | +| Root `CHANGELOG.md` | One bullet, same format as above, prefixed `[FEATURE][react]` (or `[FIX][react]`, `[BREAKING][react]`). | Any user-visible hook/provider/util change. | + +### Conventions + +- **Match existing tone.** Look at adjacent README/CHANGELOG/Docusaurus entries before writing — they're terse, imperative, and lead with what the consumer can now *do*. Avoid implementation chatter ("we now do X internally") unless it's a behavioral signal that affects how the consumer writes code. +- **Don't write speculative docs.** If the API is part-implemented (e.g. V1 today, V2 planned), document V1 only and call out the constraint inline. The next PR can extend the doc when V2 lands. +- **Cross-link the PRs.** Every CHANGELOG entry needs the PR link at the end. If the change required a coordinated miden-client PR, link both — the consumer's mental model spans both repos. +- **One source of truth per fact.** A V1 constraint ("single-account batch") goes in the Docusaurus narrative *and* the JSDoc. The CHANGELOG mentions it once. Don't repeat the full constraint list across files; cross-reference if it gets long. +- **README ⇄ Docusaurus parity.** READMEs are the npm landing page; Docusaurus is the canonical site. Keep the narrative aligned. If they diverge, the Docusaurus page is the source of truth — fix the README to match. +- **Don't commit typedoc.** `docs/typedoc/web-client/` is build output, regenerated fresh on every CI run. The in-repo verification step is warning-only. Keep JSDoc on `api-types.d.ts` accurate; the rendered API reference picks up changes automatically. +- **Update before commit.** Pre-commit hooks don't enforce doc parity, but reviewers will. Mention "docs updated" in the PR description so reviewers know where to look. + +### Doc-only PRs + +If you find a stale doc (e.g. the API changed but the Docusaurus page or README didn't), fix it as a separate `docs:`-prefixed commit on the same branch — keeps diffs reviewable. The CHANGELOG `no changelog` label exists for these. + +When fixing a stale Docusaurus page that lives downstream at `0xMiden/miden-docs`, push the upstream fix here in `docs/external/src/` and let the next deploy-docs run pick it up; don't edit the Docusaurus repo directly for content that's supposed to be ingested from this repo. + +## Contributing checklist + +1. `make lint` clean. +2. `make test-coverage` clean (and locally verify thresholds before pushing). +3. For changes to public API: every doc surface in the [Documenting public-API changes](#documenting-public-api-changes) section above. Specifically: JSDoc on `api-types.d.ts` + the resource impl, narrative pages under `docs/external/src/`, READMEs, root `CHANGELOG.md`. (`docs/typedoc/web-client/` is regenerated by CI — don't commit it.) The type-check scripts under `crates/web-client/scripts/` may also need updating if you added a forwarder or new method classification. +4. For changes to release flow: cross-check both `publish-web-client-release.yml` (latest channel) and `publish-web-client-next.yml` (next channel) — they intentionally mirror each other. diff --git a/CLAUDE.md b/CLAUDE.md index 9ca90af2..31cc1b31 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,263 +1,8 @@ -# CLAUDE.md — repo notes for AI agents - -Conventions and tooling notes for `0xMiden/web-sdk`. End-user docs live in [README.md](README.md); per-package usage guides live alongside the packages (e.g. [`packages/react-sdk/CLAUDE.md`](packages/react-sdk/CLAUDE.md)). - -## What this repo is - -A pnpm monorepo holding the JS / WASM / React bits previously part of [`0xMiden/miden-client`](https://github.com/0xMiden/miden-client). Five published artifacts: - -| Artifact | Path | Registry | -|---|---|---| -| `@miden-sdk/miden-sdk` | `crates/web-client/` (Rust + WASM + JS bindings) | npm | -| `@miden-sdk/react` | `packages/react-sdk/` | npm | -| `@miden-sdk/vite-plugin` | `packages/vite-plugin/` | npm | -| `@miden-sdk/node-{darwin-arm64,darwin-x64,linux-x64-gnu}` | `packages/node-sdk-*` | npm (platform-specific native binaries; consumed via `optionalDependencies` on `@miden-sdk/miden-sdk`) | -| `miden-idxdb-store` | `crates/idxdb-store/` | crates.io | - -The `Cargo.toml` workspace dep `miden-client = "x.y.z"` pins compatibility with the upstream Rust crate. Changes to shared types (Account, Note, gRPC schema, …) usually need a coordinated PR in `0xMiden/rust-sdk` first. - -## Toolchain - -- **Package manager**: pnpm 9 (workspace at `pnpm-workspace.yaml`). **Never** use `yarn` or `npm install` — they will desync the lockfile. -- **Node**: ≥ 20 (`engines.node` in `package.json`, `.nvmrc`). -- **Rust**: stable 1.96.1 + nightly (for `cargo +nightly fmt`, `clippy`, and `fix`). Pinned in `rust-toolchain.toml`. -- **Lefthook** runs pre-commit; `pnpm install` wires it via the `prepare` script. - -## Build / lint / test - -Drive everything through the `Makefile` — never call `cargo fmt` directly (the project requires nightly + an exact prettier/eslint pass that vanilla `cargo fmt` skips). - -```bash -make help # list targets - -# Build -make build-wasm # WASM crates only (wasm32-unknown-unknown) -make build-web-client # WASM + JS bindings + dist -make build-react-sdk # everything @miden-sdk/react needs - -# Lint + format -make format # nightly cargo fmt + prettier write + eslint --fix -make format-check # CI form (no writes) -make clippy-wasm # clippy for both WASM crates -make typos-check # spellcheck -make lint # umbrella: fix-wasm + format + clippy-wasm + typos + checks -make web-client-check-methods # verifies every WASM method is classified in the JS proxy - -# Test -make test-coverage # all coverage gates (react-sdk + idxdb-store + vite-plugin + web-client unit) -make test-react-sdk # vitest unit (jsdom) -make test-web-client-unit # vitest unit (web-client) -make integration-test-web-client # playwright (chromium); accepts SHARD_PARAMETER -make integration-test-web-client-webkit -``` - -CI (`.github/workflows/test.yml`) runs all of the above on every PR. `main` and `next` warm sccache + Swatinem/rust-cache. - -## Coverage thresholds - -`packages/react-sdk/vitest.config.ts` enforces `lines / branches / functions / statements ≥ 95`. Two files are excluded because they require the real WASM binary and are covered by Playwright integration tests: - -- `src/utils/accountBech32.ts` — covered by `test/accountBech32.test.ts` -- `src/hooks/useAssetMetadata.ts` — covered by `test/useAssetMetadata.test.ts` - -**Always run `make test-react-sdk` locally before pushing** — CI will block the merge if any threshold dips. Lowering thresholds is not the right fix; either add tests or move the file to the excluded list with justification. - -## WASM concurrency: `runExclusive` - -The wasm-bindgen `WebClient` is **not** safe under concurrent access. Calls that go through it from multiple call sites must serialize via the AsyncLock exposed by `MidenProvider`: - -```ts -const { runExclusive } = useMiden(); -await runExclusive(async (client) => { /* … */ }); -``` - -Symptom of a violation: `Error: recursive use of an object detected which would lead to unsafe aliasing in rust`. The `crates/web-client/test/sync_lock.test.ts` integration test guards against regressions — if you add a hook that touches the client, route it through `runExclusive` (or one of the existing serialized helpers) or the lock test will fail. - -## Eager vs lazy entry points - -`@miden-sdk/miden-sdk` ships two entry points with identical APIs but different init behaviour: - -| Specifier | When WASM loads | Use when | -|---|---|---| -| `@miden-sdk/miden-sdk` | At import (top-level await) | Vite/Webpack browser bundles where TLA is fine | -| `@miden-sdk/miden-sdk/lazy` | On first `await MidenClient.ready()` (or first awaited SDK method) | SSR (Next.js, Remix, SvelteKit), Capacitor WKWebView hosts, anywhere TLA is unsafe | - -Same split applies to `@miden-sdk/react` (`react/lazy` pulls `miden-sdk/lazy`). The eager/lazy contract is guarded by `crates/web-client/test/eager_entry.test.ts` — if you change the public API in one entry, mirror it in the other and re-run the type-check scripts under `crates/web-client/scripts/`. - -## Releases - -Two long-lived branches: - -- **`main`** → npm `latest` dist-tag. Released on GitHub release events. -- **`next`** → npm `next` dist-tag. Released when a PR merges into `next` carrying the `patch release` label. - -Both branches have protection enabled; required status checks mirror across the two. - -The release-publish gate compares the local `package.json` version against the **npm registry** (not against the previous git commit) — see `scripts/check-{web-client,react-sdk,vite-plugin}-version-release.sh`. So a release tag publishes whichever of the four packages have versions not yet on npm; bumping a single package is a clean release of just that one. - -Release WASM size is gated at 25 MiB for ST and 35 MiB for MT. These limits reject both a `wasm-opt` failure and a skipped MASP debug strip before publishing. - -Crate publishing (`miden-idxdb-store`, `miden-client-web`) goes through `.github/workflows/publish-crates-release.yml` and uses the `CARGO_REGISTRY_TOKEN` org secret. - -## CHANGELOG content - -The root `CHANGELOG.md` is read by **consumers of the SDK** — dApp authors and downstream library maintainers, not the team that ships the SDK. Before adding an entry, imagine that audience opening the file at the moment they upgrade. They want to know: what new API can I call? what behavior changed? what broke? - -What does NOT belong in CHANGELOG: - -- CI plumbing changes ("CI now uses github-hosted runners for publish", "added a chmod fix", "consolidated workflows"). Use the `no changelog` PR label. -- Build-system or tooling changes that don't reach the published bytes ("switched lint runner", "bumped a dev dep"). Same — `no changelog` label. -- Failed release attempts. If `alpha.1` and `alpha.2` had to be skipped before `alpha.3` shipped, the changelog entry is for `alpha.3` and describes the user-visible state. Don't write a postmortem of the misses. -- Internal refactors that don't change the public API surface. - -What DOES belong: - -- New public APIs (with the smallest example or method shape). -- Behavioral changes consumers can observe (e.g. "`account.storage()` now returns a `StorageView` wrapper"). -- Bug fixes that resolve a symptom downstream code might have hit. -- Breaking changes (loud, with migration guidance). - -When in doubt, drop the entry and apply `no changelog`. A missing entry the reviewer can ask about is cheaper than a noisy one the consumer has to skip past. - -## Gotchas worth remembering - -- **No yarn.** The repo migrated from yarn to pnpm. If you see a doc, comment, or script that says `yarn ...`, it's stale — fix it (or flag it). -- **Don't chain `pnpm --filter ... -- arg` through npm-script `&&`.** pnpm's argument forwarding only wires through to the LAST command in the chain. The Makefile splits multi-step playwright invocations across explicit Make recipes for this reason; preserve that pattern (see `integration-test-web-client` in `Makefile`). -- **Test sharding is manually balanced.** `packages/react-sdk/playwright.config.ts` defines four CI shard projects (`ci-shard-1` … `ci-shard-4`) with explicit `testMatch` arrays sized empirically from observed run timings. Rebalance by moving file paths between arrays — no workflow edits needed. Comment block at the top of the config explains the history. -- **Network-bound tests don't belong in CI.** Anything that hits a live RPC node (testnet/devnet) is excluded. If you add such a test, gate it on an env var and skip by default. -- **Account ID display.** Hooks accept hex (`0x…`) and bech32 (`mtst1q…`) interchangeably. Bech32 prefix tracks the active network — `mtst1` for testnet/devnet, `mid1` for mainnet (when it lands). Don't hardcode prefixes. -- **Code comments describe current state, not history.** Don't reference PR review threads, "earlier revisions", "per review feedback", or links to specific comment IDs in source comments — that context rots the moment the PR merges or the thread resolves. State the present-tense rationale a future reader needs ("X is gated behind `testing` so it doesn't ship in production WASM bundles"), and leave the historical "why we changed it" to the commit message and PR description. - -## Cross-repo coordination - -| Concern | Repo | -|---|---| -| Shared Rust types, gRPC schema, `MidenClient` semantics | [`0xMiden/rust-sdk`](https://github.com/0xMiden/rust-sdk) | -| Account compiler, MASM standard library, base protocol types | [`0xMiden/miden-base`](https://github.com/0xMiden/miden-base) | -| MidenFi browser-extension wallet adapter | [`0xMiden/miden-wallet-adapter`](https://github.com/0xMiden/miden-wallet-adapter) | -| Para signer integration | [`0xMiden/miden-para`](https://github.com/0xMiden/miden-para) | -| Turnkey signer integration | [`0xMiden/miden-turnkey`](https://github.com/0xMiden/miden-turnkey) | - -PRs that touch the WASM/JS boundary often need a synchronized PR in rust-sdk — bump the workspace dep and verify the integration tests still pass. - -### Linking a web-sdk PR to an in-flight rust-sdk PR - -**ALWAYS use the `Client PR: #N` marker when opening a web-sdk PR that depends on an unmerged / unreleased rust-sdk change.** It is the load-bearing machine-readable handle — prose mentions ("Companion PR: rust-sdk#N", "depends on …") do NOT trigger the linked-PR pipeline. Put the marker on its own line in the PR description (top or bottom both fine). Both `Client PR: #N` and `Client PR: 0xMiden/rust-sdk#N` are accepted; cross-repo is required when the linked PR comes from a fork. - -When a web-sdk PR depends on Rust changes that haven't been released yet (i.e. the upstream PR on rust-sdk is still open), add a marker line to the web-sdk PR description: - -``` -Client PR: #2080 -``` -or, for forks / cross-repo, -``` -Client PR: 0xMiden/rust-sdk#2080 -``` - -CI picks up the marker via `.github/actions/inject-linked-client-pr`, appends a `[patch]` block to `Cargo.toml` (runner-local — never committed) pointing the workspace `miden-client` dep at the linked PR's head, refreshes `Cargo.lock`, and posts a sticky comment on the web-sdk PR summarizing what was patched. There is at most one such comment per PR (the action deletes it if the marker is later removed). - -Local-dev parity: - -```bash -# Apply the same patch to your working tree (reads the marker from the current branch's PR body): -scripts/dev-with-client-pr.sh - -# Or pass an explicit number / cross-repo target: -scripts/dev-with-client-pr.sh 2080 -scripts/dev-with-client-pr.sh some-fork/rust-sdk#1965 - -# Strip the patch before committing: -scripts/dev-with-client-pr.sh --clear -``` - -The script writes a marker-wrapped `[patch]` block at the bottom of `Cargo.toml`. A pre-commit hook (`lefthook.yml`) blocks any commit while the markers are present, so you can't ship the local override by accident. - -**Mergeability gate.** A separate workflow (`.github/workflows/check-linked-client-pr.yml`) keeps a `linked-client-pr-ready` check on the PR. It stays *pending* while the linked client PR isn't merged-and-reachable from web-sdk's target branch's canonical refs (rust-sdk `next` for `next`-targeted PRs, or the latest rust-sdk release tag for `main`-targeted PRs). It re-evaluates every 15 minutes, so the check goes green automatically once upstream catches up — no need to push to the PR. Configure branch protection to require this check before merge. - -## Documenting public-API changes - -Any change that adds, renames, removes, or alters the observable behavior of a method, type, hook, option field, or return shape on either the `MidenClient` resource surface or `@miden-sdk/react` is a public-API change. Document it in **all** of the surfaces below before merging — the surfaces aren't redundant; each one is read at a different moment in the consumer's workflow (CHANGELOG at upgrade time, narrative docs / README when learning, JSDoc in the IDE, typedoc on the API-reference site). - -### Where the docs are published - -| Surface | URL | How it's built | -|---|---|---| -| **Narrative docs (canonical user-facing site)** | `https://docs.miden.xyz/builder/tools/clients/web-client/` (MidenClient) and `/builder/tools/clients/react-sdk/` (React SDK) | Docusaurus site at [`0xMiden/miden-docs`](https://github.com/0xMiden/miden-docs). The `deploy-docs.yml` workflow there vendors each upstream repo and copies a designated docs subtree (`docs/external/src/*`) into `docs/builder//`. | -| **API reference (typedoc)** | Same site, deeper paths | `crates/web-client/typedoc.json` declares `out: ../../docs/typedoc/web-client`. Generated by `pnpm --filter @miden-sdk/miden-sdk run typedoc` from the curated [`docs-entry.d.ts`](crates/web-client/js/types/docs-entry.d.ts) entry point. | -| **CHANGELOG (upgrade-time reading)** | Root `CHANGELOG.md` — read by dApp authors at upgrade time. | Hand-written. CI ingestion is per-repo: don't expect this file to be aggregated elsewhere. | -| **READMEs (npm landing page)** | `crates/web-client/README.md` and `packages/react-sdk/README.md` are what npm users see on the package page. | Hand-written. Keep narrative aligned with the published Docusaurus site — they share content but the README has the wider audience for first-touch. | - -### Source-of-truth for the published narrative docs - -The Docusaurus site at miden-docs ingests **`docs/external/src/`** from each upstream repo and copies the contents into `docs/builder//`. After the web/WASM split (PR [#1992](https://github.com/0xMiden/miden-client/pull/1992)) miden-client's `docs/external/src/` now contains only Rust-client material; the **MidenClient resource API and React SDK narrative docs need to live in this repo's `docs/external/src/`** and be wired into the deploy-docs workflow. The expected layout (mirrors what miden-client used to ship): - -``` -docs/external/src/ -├── _category_.yml -├── index.md # Builder → Client landing -├── web-client/ # @miden-sdk/miden-sdk -│ ├── _category_.yml -│ ├── get-started/ # install, quick start, send/receive, custom signer -│ ├── library/ # accounts, notes, transactions, sync, prover, compile -│ └── examples.md -└── react-client/ # @miden-sdk/react - ├── _category_.yml - ├── get-started/ - └── library/ # accounts, notes, provider, hooks ... -``` - -If your change adds a public capability and `docs/external/src/` doesn't exist yet (or the relevant subdir is missing), **create the page as part of the same PR**. Don't ship a feature whose only narrative documentation is the README — the README is reference, the Docusaurus page is where consumers actually learn the workflow. - -### Typedoc — regenerated by CI, don't commit - -`docs/typedoc/web-client/` is **build output**, not source. CI regenerates it fresh on every run via `pnpm --filter @miden-sdk/miden-sdk run typedoc`, fed by the curated [`crates/web-client/js/types/docs-entry.d.ts`](crates/web-client/js/types/docs-entry.d.ts) entry point (which re-exports `api-types.d.ts` wholesale plus selected WASM classes). The directory is `.gitignore`d. - -The `Check that web client documentation is up-to-date` step in `.github/workflows/test.yml` runs `git diff --exit-code` over the regenerated tree. With the dir untracked the diff is empty — the step is a **warning-only smoke test** that surfaces typedoc's own warnings during the run. It does not gate merge. - -What this means in practice: keep your JSDoc on `api-types.d.ts` accurate (that's where typedoc reads from), and don't worry about regenerating docs locally. The published API reference picks up the next typedoc run when the docs site rebuilds. - -### MidenClient surface — `crates/web-client/` - -| Surface | What goes there | Trigger | -|---|---|---| -| `crates/web-client/js/types/api-types.d.ts` | TS declaration with full JSDoc on every method, option field, and return shape. Discriminated unions for option variants. The JSDoc IS the typedoc source — be thorough here. | Any addition/change to a `*Resource` interface, `MidenClient` class, or supporting option/result type. | -| `crates/web-client/js/resources/.js` | JSDoc comment on the impl method explaining behavior, inputs, return value, and any non-obvious invariants (locking, atomicity, polling semantics). | Any new method or behavioral change on a resource impl. | -| `crates/web-client/js/types/docs-entry.d.ts` | Add the type to the curated re-exports if it should appear on the typedoc-generated API reference. `api-types` is already re-exported wholesale; only WASM-side classes need explicit listing. | New WASM class becomes part of the public surface. | -| `docs/typedoc/web-client/` (generated, gitignored) | **Don't commit.** Regenerated by CI; the in-repo CI verification step is warning-only. Just keep the JSDoc on `api-types.d.ts` accurate and the rendered API reference will update on the next docs build. | Always covered automatically once the JSDoc is right. | -| `docs/external/src/web-client/` | Narrative Docusaurus page under `library/` (concept reference) or `get-started/` (workflow). Show the happy path; cross-reference singular siblings. Mention V1 constraints if they're non-obvious (single-account, no per-tx ids, etc.). | New high-level capability that a dApp author would reach for. | -| `crates/web-client/README.md` → `## Usage` | Same narrative as the Docusaurus page, condensed. The README is what npm users see on the package landing page. | Same as above. Keep aligned with the Docusaurus copy. | -| Root `CHANGELOG.md` | One bullet under `## (TBD)` → `### Enhancements` (or `### Fixes` / `### Breaking`). Prefix tags: `[FEATURE][web]` for web-only, `[FEATURE][rust,cli,web]` for cross-cutting. Include the *smallest* example or method shape, link the PR (`web-sdk#NN`) and any companion miden-client PR. Don't repeat README copy verbatim — the audience is a consumer who's about to upgrade. **NEVER add an entry to a section whose version has already been published — check `gh api repos/0xMiden/web-sdk/releases/latest` for the latest tag and put new entries under a section whose version is strictly higher and still has `(TBA)` / `(TBD)` next to it. If no such section exists, add one.** The header at the top of `CHANGELOG.md` may lag (a `(TBA)` heading often persists after the release tags out); don't trust the heading alone. | Any user-visible API addition, behavior change, or fix. | - -### React SDK surface — `packages/react-sdk/` - -| Surface | What goes there | Trigger | -|---|---|---| -| `packages/react-sdk/src/hooks/.ts` | JSDoc on the hook export covering the returned object shape (`{action, result, isLoading, stage, error, reset}` for mutations; `{...data, isLoading, error, refetch}` for queries), accepted args, side effects, and concurrency guards. | New hook or change to an existing hook's signature/return. | -| `packages/react-sdk/src/types/*` | TS declarations for any new option/result types the hook surfaces. Mirror the discriminated-union conventions used in the WebClient surface. | New public type emerging from a hook. | -| `docs/external/src/react-client/` | Narrative Docusaurus page (per-hook or per-pattern). The hub is `library/`, deep-link individual hooks under `library//`. | New hook, new pattern, or changed semantics worth a code example. | -| `packages/react-sdk/CLAUDE.md` | Per-package hook-by-hook usage guide. Add a fenced code block under the right section (`## Reading Data`, `## Writing Data`, `## Common Patterns`, `## External Signer Integration`). Show realistic usage, not just the signature. **Mirror the Docusaurus content** — same examples, same prose, this is the npm-landing version. | Same as above. | -| `packages/react-sdk/README.md` → `## Features` | One bullet on the high-level feature list if it's a notable addition (new hook category, new integration). Subordinate hook tweaks don't go here. | A reader scanning the README would want to know this exists. | -| Root `CHANGELOG.md` | One bullet, same format as above, prefixed `[FEATURE][react]` (or `[FIX][react]`, `[BREAKING][react]`). | Any user-visible hook/provider/util change. | - -### Conventions - -- **Match existing tone.** Look at adjacent README/CHANGELOG/Docusaurus entries before writing — they're terse, imperative, and lead with what the consumer can now *do*. Avoid implementation chatter ("we now do X internally") unless it's a behavioral signal that affects how the consumer writes code. -- **Don't write speculative docs.** If the API is part-implemented (e.g. V1 today, V2 planned), document V1 only and call out the constraint inline. The next PR can extend the doc when V2 lands. -- **Cross-link the PRs.** Every CHANGELOG entry needs the PR link at the end. If the change required a coordinated miden-client PR, link both — the consumer's mental model spans both repos. -- **One source of truth per fact.** A V1 constraint ("single-account batch") goes in the Docusaurus narrative *and* the JSDoc. The CHANGELOG mentions it once. Don't repeat the full constraint list across files; cross-reference if it gets long. -- **README ⇄ Docusaurus parity.** READMEs are the npm landing page; Docusaurus is the canonical site. Keep the narrative aligned. If they diverge, the Docusaurus page is the source of truth — fix the README to match. -- **Don't commit typedoc.** `docs/typedoc/web-client/` is build output, regenerated fresh on every CI run. The in-repo verification step is warning-only. Keep JSDoc on `api-types.d.ts` accurate; the rendered API reference picks up changes automatically. -- **Update before commit.** Pre-commit hooks don't enforce doc parity, but reviewers will. Mention "docs updated" in the PR description so reviewers know where to look. - -### Doc-only PRs - -If you find a stale doc (e.g. the API changed but the Docusaurus page or README didn't), fix it as a separate `docs:`-prefixed commit on the same branch — keeps diffs reviewable. The CHANGELOG `no changelog` label exists for these. - -When fixing a stale Docusaurus page that lives downstream at `0xMiden/miden-docs`, push the upstream fix here in `docs/external/src/` and let the next deploy-docs run pick it up; don't edit the Docusaurus repo directly for content that's supposed to be ingested from this repo. - -## Contributing checklist - -1. `make lint` clean. -2. `make test-coverage` clean (and locally verify thresholds before pushing). -3. For changes to public API: every doc surface in the [Documenting public-API changes](#documenting-public-api-changes) section above. Specifically: JSDoc on `api-types.d.ts` + the resource impl, narrative pages under `docs/external/src/`, READMEs, root `CHANGELOG.md`. (`docs/typedoc/web-client/` is regenerated by CI — don't commit it.) The type-check scripts under `crates/web-client/scripts/` may also need updating if you added a forwarder or new method classification. -4. For changes to release flow: cross-check both `publish-web-client-release.yml` (latest channel) and `publish-web-client-next.yml` (next channel) — they intentionally mirror each other. +@AGENTS.md + + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ebc5099c..1c354477 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,7 +2,7 @@ We welcome PRs. Before opening one: -1. Read [CLAUDE.md](CLAUDE.md) for repo-specific conventions and tooling notes. +1. Read [AGENTS.md](AGENTS.md) for repo-specific conventions and tooling notes. 2. Run `make lint test` locally — CI runs the same suite, but local feedback is faster. 3. For changes that touch the public API surface (hooks, WASM bindings, plugin options), include or update the type tests in `crates/web-client/scripts/check-*-types.js`. diff --git a/packages/react-sdk/CLAUDE.md b/packages/react-sdk/AGENTS.md similarity index 95% rename from packages/react-sdk/CLAUDE.md rename to packages/react-sdk/AGENTS.md index f952265f..f52cf56a 100644 --- a/packages/react-sdk/CLAUDE.md +++ b/packages/react-sdk/AGENTS.md @@ -1,4 +1,17 @@ -# Miden React SDK - Usage Guide +# Miden React SDK — Agent Guide + +**Audience: AI coding agents** writing code against `@miden-sdk/react`. Human +readers are welcome; this is written to be loaded into an agent's context and +followed as a reference. + +This file ships inside the published package, so the copy at +`node_modules/@miden-sdk/react/AGENTS.md` always matches the installed version. +Prefer it over training data, which is likely to be out of date. + +Narrative docs and the full API reference live at +. The core client this +package wraps documents itself at +`node_modules/@miden-sdk/miden-sdk/AGENTS.md`. ## Installation diff --git a/packages/react-sdk/package.json b/packages/react-sdk/package.json index 76f752de..64095c59 100644 --- a/packages/react-sdk/package.json +++ b/packages/react-sdk/package.json @@ -29,7 +29,7 @@ "lazy", "mt", "README.md", - "CLAUDE.md" + "AGENTS.md" ], "scripts": { "build": "tsup", From a1fe857b8f09ff508ee82ee8124f46e2773d7ef5 Mon Sep 17 00:00:00 2001 From: Wiktor Starczewski Date: Sun, 23 Aug 2026 22:19:43 +0200 Subject: [PATCH 2/6] docs: ship agent guides and skills from every published package --- .claude/skills/idxdb-patterns/SKILL.md | 473 ++++++++++++++ .claude/skills/wasm-bridge/SKILL.md | 441 +++++++++++++ AGENTS.md | 37 ++ CHANGELOG.md | 7 + crates/web-client/AGENTS.md | 86 +++ crates/web-client/README.md | 32 + crates/web-client/package.json | 3 + .../skills/frontend-pitfalls/SKILL.md | 202 ++++++ .../skills/signer-integration/SKILL.md | 196 ++++++ .../skills/web-client-usage/SKILL.md | 486 ++++++++++++++ packages/react-sdk/README.md | 31 + packages/react-sdk/package.json | 3 +- .../skills/react-sdk-patterns/SKILL.md | 596 ++++++++++++++++++ .../skills/testing-patterns/SKILL.md | 290 +++++++++ packages/vite-plugin/AGENTS.md | 52 ++ packages/vite-plugin/README.md | 26 + packages/vite-plugin/package.json | 4 +- .../skills/vite-wasm-setup/SKILL.md | 140 ++++ 18 files changed, 3103 insertions(+), 2 deletions(-) create mode 100644 .claude/skills/idxdb-patterns/SKILL.md create mode 100644 .claude/skills/wasm-bridge/SKILL.md create mode 100644 crates/web-client/AGENTS.md create mode 100644 crates/web-client/skills/frontend-pitfalls/SKILL.md create mode 100644 crates/web-client/skills/signer-integration/SKILL.md create mode 100644 crates/web-client/skills/web-client-usage/SKILL.md create mode 100644 packages/react-sdk/skills/react-sdk-patterns/SKILL.md create mode 100644 packages/react-sdk/skills/testing-patterns/SKILL.md create mode 100644 packages/vite-plugin/AGENTS.md create mode 100644 packages/vite-plugin/skills/vite-wasm-setup/SKILL.md diff --git a/.claude/skills/idxdb-patterns/SKILL.md b/.claude/skills/idxdb-patterns/SKILL.md new file mode 100644 index 00000000..62ea4747 --- /dev/null +++ b/.claude/skills/idxdb-patterns/SKILL.md @@ -0,0 +1,473 @@ +--- +name: idxdb-patterns +description: Enforce conventions for the IndexedDB/Dexie persistence layer of the Miden web client, which lives in the web-sdk repo (idxdb-store crate). Use when editing TypeScript in `crates/idxdb-store/src/ts/`, writing Dexie transactions, or modifying the database schema. +--- + +# IndexedDB Store Patterns (idxdb-store) + +This layer lives in the **web-sdk** repo (`github.com/0xMiden/web-sdk`, +crate `crates/idxdb-store`, package `miden-idxdb-store`), not in the +`miden-client` repo. It is a Dexie-backed `Store` implementation for the +WASM web client. + +The schema splits account-related tables into `Latest…` / `Historical…` +pairs to support account-history pruning (`client.pruneAccountHistory()`). +Always check `crates/idxdb-store/src/ts/schema.ts` for the canonical table +list before adding rows or filters — the active set includes `AccountAuth`, +`AccountKeyMapping`, `Addresses`, `Settings`, `ForeignAccountCode`, +`NotesScripts`, `TransactionScripts`, `PartialBlockchainNodes`, +`LatestStorageMapEntries`, `HistoricalStorageMapEntries`, plus the +account-storage / asset / account-header latest/historical pairs. + +## Build Workflow + +The `idxdb-store` has a dual-file workflow: + +- **TypeScript source** lives in `crates/idxdb-store/src/ts/` +- **Generated JavaScript** lives in `crates/idxdb-store/src/js/` +- **Both are committed to git** — the `js` folder is currently *not* + gitignored (the `#js` entry in `src/.gitignore` is commented out) +- The Rust side imports the generated `.js` modules via + `#[wasm_bindgen(module = "/src/js/...")]`, so the JS must be kept in + sync with the TS + +After modifying any `.ts` file, regenerate the JS with the canonical +top-level Make target (which runs the package's `build` script through +**pnpm** — this repo is pnpm-only, there is no yarn): + +```bash +make rust-client-ts-build # == pnpm --filter web_store run build +``` + +The underlying package script is `tsc --build --force ./tsconfig.json` +(`crates/idxdb-store/src/package.json`). Always commit both the `.ts` +source and the regenerated `.js` output together. + +## Database Registry + +There is no JS object pointer on the Rust side, so open databases are +tracked in a module-level `Map` keyed by network name, in +`crates/idxdb-store/src/ts/schema.ts`: + +```typescript +const databaseRegistry = new Map(); + +export function getDatabase(dbId: string): MidenDatabase { + const db = databaseRegistry.get(dbId); + if (!db) { + throw new Error( + `Database not found for id: ${dbId}. Call openDatabase first.` + ); + } + return db; +} + +export async function openDatabase( + network: string, + clientVersion: string +): Promise { + const db = new MidenDatabase(network); + const success = await db.open(clientVersion); + if (!success) { + throw new Error(`Failed to open IndexedDB database: ${network}`); + } + databaseRegistry.set(network, db); + return network; +} +``` + +Rules: +- Every exported store function takes `dbId: string` as its first parameter +- Call `const db = getDatabase(dbId)` at the top of each function — look it + up per call rather than holding a long-lived reference across calls +- The `dbId` is the network name (`"mainnet"`, `"devnet"`, `"testnet"`, or + a custom one); `openDatabase` registers under and returns `network` + +## Schema Interfaces + +Define TypeScript interfaces for each table with the `I` prefix. Use +`Latest…` / `Historical…` pairs for anything that participates in account +history (storage slots, storage map entries, vault assets, and account +headers). The account-header pair uses `IAccount` (latest, keyed on `id`) +and `IHistoricalAccount` (same fields plus `replacedAtNonce`) inside +`latestAccountHeaders` / `historicalAccountHeaders`: + +```typescript +export interface IAccountCode { + root: string; + code: Uint8Array; +} + +export interface ILatestAccountStorage { + accountId: string; + slotName: string; + slotValue: string; + slotType: number; +} + +export interface IHistoricalAccountStorage { + accountId: string; + replacedAtNonce: string; + slotName: string; + oldSlotValue: string | null; + slotType: number; +} + +export interface ILatestAccountAsset { + accountId: string; + vaultKey: string; // ASSET_KEY — see `miden-concepts` skill + asset: string; // ASSET_VALUE serialized +} + +export interface IHistoricalAccountAsset { + accountId: string; + replacedAtNonce: string; + vaultKey: string; + oldAsset: string | null; +} + +export interface IAccount { + id: string; // primary key — NOT `accountId` + codeRoot: string; + storageRoot: string; + vaultRoot: string; + nonce: string; + committed: boolean; + accountSeed?: Uint8Array; + accountCommitment: string; + locked: boolean; + watched: boolean; +} +``` + +Rules: +- Use `string` for hex-encoded values (hashes, IDs, commitments, nonces, + vault keys) +- Use `Uint8Array` for raw binary data +- Use `?` suffix for optional fields, `| null` when the column explicitly + represents the absence of a previous value (e.g. `oldSlotValue`, + `oldAsset`, `oldValue` in the history tables) +- Use `boolean` for flags, `number` for block heights and slot types +- The LATEST account-header table keys on `id`; the HISTORICAL + account-header table keys on `accountCommitment` (with `id` and + `[id+replacedAtNonce]` as secondary indexes). The storage / asset / + map-entry / foreign-code tables key on `accountId`. Don't confuse the two. +- The asset layer is two-word: `vaultKey` is the `ASSET_KEY` and `asset` + is the encoded `ASSET_VALUE`. Don't fold them back into a single hex + string. + +## Table Enum + +Define tables as a TypeScript enum, in the order they appear in +`crates/idxdb-store/src/ts/schema.ts` — the Rust side imports table-backed +JS functions verbatim: + +```typescript +enum Table { + AccountCode = "accountCode", + LatestAccountStorage = "latestAccountStorage", + HistoricalAccountStorage = "historicalAccountStorage", + LatestAccountAssets = "latestAccountAssets", + HistoricalAccountAssets = "historicalAccountAssets", + LatestStorageMapEntries = "latestStorageMapEntries", + HistoricalStorageMapEntries = "historicalStorageMapEntries", + AccountAuth = "accountAuth", + AccountKeyMapping = "accountKeyMapping", + LatestAccountHeaders = "latestAccountHeaders", + HistoricalAccountHeaders = "historicalAccountHeaders", + Addresses = "addresses", + Transactions = "transactions", + TransactionScripts = "transactionScripts", + InputNotes = "inputNotes", + OutputNotes = "outputNotes", + NotesScripts = "notesScripts", + StateSync = "stateSync", + BlockHeaders = "blockHeaders", + PartialBlockchainNodes = "partialBlockchainNodes", + Tags = "tags", + ForeignAccountCode = "foreignAccountCode", + Settings = "settings", +} +``` + +The Dexie store schema is defined once, as the `V1_STORES` constant +applied via `this.dexie.version(1).stores(V1_STORES)` in the +`MidenDatabase` constructor. `V1_STORES` is the frozen baseline: index +strings are built with a small `indexes(...)` helper, e.g. +`[Table.LatestAccountStorage]: indexes("[accountId+slotName]", "accountId")`. + +The migration system is **not currently in use** — the Miden network +resets on every upgrade, so `ensureClientVersion` nukes the DB (close / +`delete` / re-open) when the running client version is a higher major or +minor than the stored one; same-major.minor patch bumps and downgrades +just persist the new version without resetting (see the semver +`sameMajorMinor` / `!semver.gt(...)` guard in `ensureClientVersion`). +A minor-version bump does trigger it. Adding a table or +changing an index today therefore means: +1. Update the `Table` enum + interface(s) in `schema.ts` +2. Add the table/index to `V1_STORES` (additive, since the DB is nuked on + version change; once migrations are enabled, `V1_STORES` must be frozen + and a new `.version(N+1).stores({...}).upgrade(...)` block added instead) +3. Update Rust-side reads/writes, which import the corresponding JS + functions through `#[wasm_bindgen(module = "/src/js/.js")]` + (e.g. account functions from `/src/js/accounts.js`, schema/registry + functions from `/src/js/schema.js`) +4. Run `make rust-client-ts-build` to regenerate the JS, and add a + schema/migration test in `schema.test.ts` + +## Dexie Transactions + +### Atomic Operations + +When multiple tables must be updated together, wrap in a Dexie transaction. +List every table the transaction touches, and use `Promise.all()` to run +independent operations concurrently (from `applyStateSync` in +`crates/idxdb-store/src/ts/sync.ts`): + +```typescript +const tablesToAccess = [ + db.stateSync, + db.inputNotes, + db.outputNotes, + db.notesScripts, + db.transactions, + db.transactionScripts, + db.blockHeaders, + db.partialBlockchainNodes, + db.tags, + db.latestAccountHeaders, + db.historicalAccountHeaders, + // ... plus the latest/historical storage, map-entry and asset tables +]; + +return await db.dexie.transaction("rw", tablesToAccess, async (tx) => { + await Promise.all([ + /* input/output note upserts */, + /* transaction upserts */, + /* per-account applyFullAccountState calls */, + updateSyncHeight(tx, blockNum), + updatePartialBlockchainNodes(tx, serializedNodeIds, serializedNodes), + updateCommittedNoteTags(tx, committedNoteTagSources), + /* block-header writes */, + ]); +}); +``` + +Rules: +- Use `"rw"` for read-write transactions +- List all tables that will be accessed in the `tablesToAccess` array +- Use `Promise.all()` inside transactions to parallelize independent operations +- Pass the `tx` transaction object to helper functions that need table access +- Helper write functions (e.g. `upsertInputNote`) commonly take an optional + `tx?: Transaction`: if supplied they run inside the caller's transaction, + otherwise they open their own `db.dexie.transaction(...)` + +### Table Access Within Transactions + +The Dexie `Transaction` type doesn't statically declare table accessors. +`schema.ts` augments `declare module "dexie"` so `tx.inputNotes` etc. +type-check; where that augmentation isn't in scope, type-cast the +transaction (from `updateSyncHeight` in `sync.ts`): + +```typescript +async function updateSyncHeight(tx: Transaction, blockNum: number) { + try { + const current = await ( + tx as Transaction & { stateSync: Dexie.Table } + ).stateSync.get(1); + if (!current || current.blockNum < blockNum) { + await ( + tx as Transaction & { stateSync: Dexie.Table } + ).stateSync.update(1, { blockNum: blockNum }); + } + } catch (error) { + logWebStoreError(error, "Failed to update sync height"); + } +} +``` + +### Forward-Only Updates + +Only advance the sync height forward (never regress): + +```typescript +if (!current || current.blockNum < blockNum) { + // Update +} +``` + +## Error Handling + +### logWebStoreError + +Use `logWebStoreError()` from `./utils.js` for error logging — it formats +Dexie errors (and walks `error.inner`), then **re-throws** the error: + +```typescript +import { logWebStoreError } from "./utils.js"; + +try { + // database operation +} catch (error) { + logWebStoreError(error, "Error while fetching account headers"); +} +``` + +Because `logWebStoreError` always re-throws, code after a `catch` that +calls it (e.g. a trailing `return []`) is effectively unreachable on the +error path — the surrounding `try` body must return the success value. + +### Reads return optional / empty + +Read functions wrap their body in `try/catch`, returning the queried value +on success. The fallback after the catch (empty array, `null`, or +`undefined`) documents intent but is unreachable because `logWebStoreError` +re-throws (from `getAccountIds` in `crates/idxdb-store/src/ts/accounts.ts`): + +```typescript +export async function getAccountIds(dbId: string) { + try { + const db = getDatabase(dbId); + const records = await db.latestAccountHeaders.toArray(); + return records.map((entry) => entry.id); // header rows key on `id` + } catch (error) { + logWebStoreError(error, "Error while fetching account IDs"); + } + return []; +} +``` + +## Data Operations + +### Querying + +Use Dexie's query API. Patterns actually used in the store: + +```typescript +// Get all records +const records = await db.latestAccountHeaders.toArray(); + +// Get by primary key (e.g. stateSync row id 1) +const current = await db.stateSync.get(1); + +// Look up a header by its `id` index (header PK is `id`) +const record = await db.latestAccountHeaders + .where("id") + .equals(accountId) + .first(); + +// Filter a single-field index, optionally narrowing with `.and(...)` +const slots = await db.latestAccountStorages + .where("accountId") + .equals(accountId) + .and((record) => nameSet.has(record.slotName)) + .toArray(); + +// Match multiple keys against one index +const codes = await db.accountCodes.where("root").anyOf(codeRoots).toArray(); +``` + +For compound indexes, use the **bracket-string** index name and pass the +key parts as an array to `.equals(...)` (from `applyTransactionDelta`): + +```typescript +const oldSlot = await db.latestAccountStorages + .where("[accountId+slotName]") + .equals([accountId, slot.slotName]) + .first(); +``` + +### Latest vs Historical + +For account state, the `latest…` tables hold the current row (keyed by +`accountId`, or the compound `[accountId+slotName]` / `[accountId+vaultKey]` +/ `[accountId+slotName+key]`); the matching `historical…` tables hold the +value that was replaced, keyed by `[accountId+replacedAtNonce…]` with the +prior value in `oldSlotValue` / `oldAsset` / `oldValue` (`null` when no +previous value existed). The write path is **archive-then-replace**: read +the current latest row, `put` it into historical under the new nonce, then +`put` the new value into latest (see `applyTransactionDelta` / +`applyFullAccountState`). + +Undo restores from history back to latest, keyed by the compound nonce +index; a non-null old value overwrites latest, a `null` old value deletes +the latest row (from `restoreSlotsFromHistorical` in `accounts.ts`): + +```typescript +const oldSlots = await db.historicalAccountStorages + .where("[accountId+replacedAtNonce]") + .equals([accountId, nonce]) + .toArray(); + +for (const slot of oldSlots) { + if (slot.oldSlotValue !== null) { + await db.latestAccountStorages.put({ /* ...restore old value... */ }); + } else { + await db.latestAccountStorages + .where("[accountId+slotName]") + .equals([accountId, slot.slotName]) + .delete(); + } +} +``` + +`client.pruneAccountHistory()` (web-client `pruneAccountHistory`, backed by +the JS `pruneAccountHistory` in `accounts.ts`) drops `historical…` rows +whose `replacedAtNonce <= upToNonce` and any orphaned account code. Write +functions must keep the latest row authoritative regardless of how much +history has been pruned. + +### Serialization Conventions + +- Hex strings for cryptographic values (hashes, IDs, commitments, vault keys) +- `uint8ArrayToBase64()` (from `./utils.js`) when a `Uint8Array` must be + returned to Rust as a base64 string (e.g. serialized account code, seeds) +- `Uint8Array` for direct binary storage in a table column +- Default empty strings for optional string fields when reading out: + `record.storageRoot || ""` +- `BigInt()` for nonce comparisons and sorting (nonces are stored as + strings, so lexicographic / index-range ordering would be wrong) + +## Upsert Pattern + +Dexie `Table.put()` is itself an upsert: it inserts or replaces by primary +key. The store builds a plain data object and calls `.put()` — it does not +read-then-branch. Convert `null` to `undefined` so Dexie omits the field +from indexes (a `null` in a compound index is a real value; an absent +field is skipped). From `upsertInputNote` in +`crates/idxdb-store/src/ts/notes.ts`: + +```typescript +export async function upsertInputNote( + dbId: string, + detailsCommitment: string, + noteId: string | undefined, + // ... more params + consumedBlockHeight?: number | null, + consumedTxOrder?: number | null, + consumerAccountId?: string | null, + tx?: Transaction +) { + const db = getDatabase(dbId); + const doWork = async (t: Transaction) => { + try { + const data = { + detailsCommitment, + noteId: noteId ?? undefined, + // null -> undefined so Dexie omits these from compound indexes + consumedBlockHeight: consumedBlockHeight ?? undefined, + consumedTxOrder: consumedTxOrder ?? undefined, + consumerAccountId: consumerAccountId ?? undefined, + // ... remaining fields + }; + await t.inputNotes.put(data); + await t.notesScripts.put({ scriptRoot, serializedNoteScript }); + } catch (error) { + logWebStoreError(error, `Error inserting note: ${detailsCommitment}`); + } + }; + // Run inside the caller's tx if provided, else open one. + if (tx) return doWork(tx); + return db.dexie.transaction("rw", db.inputNotes, db.notesScripts, doWork); +} +``` diff --git a/.claude/skills/wasm-bridge/SKILL.md b/.claude/skills/wasm-bridge/SKILL.md new file mode 100644 index 00000000..860059dd --- /dev/null +++ b/.claude/skills/wasm-bridge/SKILL.md @@ -0,0 +1,441 @@ +--- +name: wasm-bridge +description: Enforce conventions for the Rust<->JavaScript WASM boundary in the web-sdk repo (crate miden-client-web at crates/web-client, split out of miden-client). Use when exposing Rust methods to JS via the #[js_export] proc-macro, creating newtype wrappers, handling errors across the boundary with JsErr, bridging JS Promises to Rust Futures, or layering the public MidenClient resource API on top of the WASM-bound WebClient. +--- + +# WASM Bridge Patterns (web-client / miden-client-web) + +At v0.15 the web client lives in the dedicated **web-sdk** repo +(`github.com/0xMiden/web-sdk`), split out of `miden-client`. The Rust<->JS +boundary crate is `crates/web-client` (cargo package `miden-client-web`). +Companion workspace crates: `crates/js-export-macro` (the `#[js_export]` +proc-macro) and `crates/idxdb-store` (the IndexedDB store). + +The crate dual-targets two binding technologies from one Rust source: +- **browser** (the `browser` feature) via `wasm_bindgen`, error type `JsValue` +- **Node.js** (the `nodejs` feature) via `napi` / `napi-derive`, error type + `napi::Error` + +A platform abstraction layer in `crates/web-client/src/platform.rs` provides +type aliases and helpers so most code is written once. Key aliases: + +- `JsErr` — the platform error type (`wasm_bindgen::JsValue` on browser, + `napi::Error` on nodejs). `from_str_err(msg: &str) -> JsErr` builds one from a + string. +- `JsU64` — `u64` on browser, `napi::bindgen_prelude::BigInt` on nodejs; both + surface as a JS `BigInt`. Convert with `js_u64_to_u64` / `u64_to_js_u64`. +- `JsBytes` — `js_sys::Uint8Array` on browser, `napi::bindgen_prelude::Buffer` + on nodejs. Convert with `bytes_to_js` / `js_to_bytes`. +- `AsyncCell` — interior mutability: `RefCell` on browser, `tokio::sync::Mutex` + on nodejs; `.lock().await` yields a `DerefMut` guard. + +## Exposing Rust Methods to JavaScript + +### Method Annotation — `#[js_export]` + +The public API is exposed with the custom `#[js_export]` proc-macro from the +`js-export-macro` crate, **not** raw `#[wasm_bindgen]`. `#[js_export]` generates +the dual `wasm_bindgen` (browser) and `napi` (Node.js) annotations from one +attribute, forwarding `constructor` / `js_name` / `getter`. When a signature +contains `JsU64`, the macro splits the impl per platform, replacing `JsU64` with +`u64` (browser) or `BigInt` (nodejs) — so `JsU64` is resolved by the macro and +does not need to be imported in the annotated module. Raw `#[wasm_bindgen]` is +reserved for browser-only members (e.g. synchronous getters that cannot be async). + +Apply `#[js_export]` to the struct/enum/impl block, and `#[js_export(js_name = +"camelCase")]` to each method to map snake_case Rust to camelCase JS: + +```rust +use js_export_macro::js_export; + +use crate::models::account_header::AccountHeader; +use crate::platform::{JsErr, from_str_err}; +use crate::{WebClient, js_error_with_context}; + +#[js_export] +impl WebClient { + #[js_export(js_name = "getAccounts")] + pub async fn get_accounts(&self) -> Result, JsErr> { + let mut guard = self.get_mut_inner().await; + let client = guard + .as_mut() + .ok_or_else(|| from_str_err("Client not initialized"))?; + + let result = client + .get_account_headers() + .await + .map_err(|err| js_error_with_context(err, "failed to get accounts"))?; + + Ok(result.into_iter().map(|(header, _)| header.into()).collect()) + } +} +``` + +Rules: +- Annotate with `#[js_export]` (struct/impl) and `#[js_export(js_name = ...)]` + (methods). Use `#[js_export(constructor)]` for constructors, + `#[js_export(getter)]` for getters. Use raw `#[wasm_bindgen]` only for + browser-only items. +- Methods take `&self` (the inner client is behind an `AsyncCell`/lock, so no + `&mut self`). Acquire the client with `let mut guard = + self.get_mut_inner().await;` then `let client = guard.as_mut().ok_or_else(|| + from_str_err("Client not initialized"))?;`. `get_mut_inner` returns a + `DerefMut` guard over `Option>`. +- Return `Result` — never `Result` directly, and never + panic across the boundary. +- Use `.map_err(|err| js_error_with_context(err, "context"))` for all fallible + client calls. +- Convert return types via `.into()` (implement `From` on wrapper types). + +## Error Handling Across the Boundary + +### js_error_with_context + +Use the `js_error_with_context` helper (in `crates/web-client/src/lib.rs`) to +chain error sources and attach hints. It returns `JsErr` and splits per +platform; the browser branch additionally attaches a stable machine-readable +`code`: + +```rust +pub(crate) fn js_error_with_context(err: T, context: &str) -> JsErr +where + T: Error + 'static, +{ + let error_message = build_error_chain(context, &err); + let help = hint_from_error(&err); + + #[cfg(feature = "browser")] + { + let js_error: JsValue = JsError::new(&error_message).into(); + if let Some(help) = help { + let _ = Reflect::set(&js_error, &JsValue::from_str("help"), &JsValue::from_str(&help)); + } + // Stable, machine-readable code for the ClientError variants JS callers + // branch on, so they don't depend on (changeable) message text. + if let Some(code) = code_from_error(&err) { + let _ = Reflect::set(&js_error, &JsValue::from_str("code"), &JsValue::from_str(code)); + } + js_error + } + + #[cfg(feature = "nodejs")] + { + let message = match help { + Some(help) => format!("{error_message} [help: {help}]"), + None => error_message, + }; + napi::Error::from_reason(message) + } +} +``` + +This: +1. Chains all error sources into one message via `build_error_chain(context, + &err)` (walks `err.source()`, writing `context: err1: err2: ...`). +2. Extracts an `ErrorHint` from `ClientError` via `hint_from_error` if available. +3. Browser path: attaches `help` (the hint) and `code` (from `code_from_error`, + which maps the few `ClientError` variants JS callers branch on, e.g. + `ACCOUNT_NOT_FOUND_ON_CHAIN`, `ACCOUNT_ALREADY_TRACKED`) as properties on the + JS `Error` via `Reflect::set`. +4. Node.js path: returns `napi::Error::from_reason(...)` with the help inlined + into the message. + +### Error Pattern in Every Method + +```rust +client + .some_operation() + .await + .map_err(|err| js_error_with_context(err, "failed to "))?; +``` + +The context string should be lowercase and describe the failed operation. For +the not-initialized guard, build the error with `from_str_err("Client not +initialized")` (the platform helper), not `JsValue::from_str(...)`. + +## Newtype Wrappers + +### Pattern + +Wrap native Miden types in thin newtypes for JS exposure, annotated with +`#[js_export]`. Fallible construction returns `Result`: + +```rust +use js_export_macro::js_export; +use miden_client::{Felt as NativeFelt, Word as NativeWord}; +use crate::platform::{JsBytes, JsErr, from_str_err, js_u64_to_u64, u64_to_js_u64}; + +#[derive(Clone)] +#[js_export] +pub struct Word(NativeWord); + +#[js_export] +impl Word { + #[js_export(constructor)] + pub fn new(u64_vec: Vec) -> Result { + if u64_vec.len() != 4 { + return Err(from_str_err(&format!( + "Word requires exactly 4 elements, got {}", + u64_vec.len() + ))); + } + let fixed_array_u64: [u64; 4] = u64_vec + .into_iter() + .map(js_u64_to_u64) + .collect::>() + .try_into() + .expect("length checked above"); + let native_felt_vec: [NativeFelt; 4] = fixed_array_u64 + .iter() + .map(|&v| NativeFelt::new(v)) // fallible on the 0.15 surface + .collect::, _>>() + .map_err(|err| from_str_err(&format!("invalid field element: {err}")))? + .try_into() + .expect("length checked above"); + Ok(Word(native_felt_vec.into())) + } + + #[js_export(js_name = "fromHex")] + pub fn from_hex(hex: String) -> Result { + let native_word = NativeWord::try_from(hex.as_str()) + .map_err(|err| from_str_err(&format!("Error instantiating Word from hex: {err}")))?; + Ok(Word(native_word)) + } +} +``` + +Notes: +- `JsU64` (BigInt-aware) is used for numeric inputs, not `u64`, so full 64-bit + precision survives the JS `Number`/`BigInt` boundary. The `#[js_export]` macro + rewrites `JsU64` per platform, so it is referenced unqualified and is not + imported alongside the `js_u64_to_u64` / `u64_to_js_u64` converters. +- Constructors that can fail (length checks, fallible `Felt::new`) return + `Result<_, JsErr>`; do not paper over failures with `.unwrap()`. +- `from_hex` takes `String` (not `&str`) and returns `Result`. + +### Required Conversions and Accessors + +Implement the `From` conversions, and put the internal `as_native` accessor in a +**plain** `impl` block (not under `#[js_export]`, since it is `pub(crate)`): + +```rust +impl Word { + pub(crate) fn as_native(&self) -> &NativeWord { + &self.0 + } +} + +// Native -> Wrapper (by value and by ref) +impl From for Word { + fn from(native_word: NativeWord) -> Self { Word(native_word) } +} +impl From<&NativeWord> for Word { + fn from(native_word: &NativeWord) -> Self { Word(*native_word) } +} + +// Wrapper -> Native (by value and by ref) +impl From for NativeWord { + fn from(word: Word) -> Self { word.0 } +} +impl From<&Word> for NativeWord { + fn from(word: &Word) -> Self { word.0 } +} +``` + +For wrapper newtypes that must be accepted as by-value or `Vec` parameters on +the Node.js side, also invoke `impl_napi_from_value!(Word);` (defined in +`crates/web-client/src/miden_array.rs`; a no-op under the `browser` feature). It +bridges napi-rs v3's missing `FromNapiValue` for `#[napi]` class types. + +### Factory Methods + +Provide `fromHex()`-style constructors that return `Result` for +user-facing types. + +## Data Transfer Objects + +For complex data that crosses the WASM boundary, use a dual-platform +`getter_with_clone` / `napi(object)` struct (gated with `cfg_attr`), and map +field names with browser-side `js_name`: + +```rust +#[cfg_attr(feature = "browser", wasm_bindgen(getter_with_clone, inspectable))] +#[cfg_attr(feature = "nodejs", napi(object))] +#[derive(Clone)] +pub struct StorageMapEntry { + #[cfg_attr(feature = "browser", wasm_bindgen(js_name = "root"))] + pub root: String, + #[cfg_attr(feature = "browser", wasm_bindgen(js_name = "key"))] + pub key: String, + #[cfg_attr(feature = "browser", wasm_bindgen(js_name = "value"))] + pub value: String, +} +``` + +Rules: +- Use the dual-platform `#[cfg_attr(feature = "browser", wasm_bindgen(...))]` + + `#[cfg_attr(feature = "nodejs", napi(object))]` form — never a bare + `#[wasm_bindgen(getter_with_clone)]`. +- `getter_with_clone` auto-generates JS getters; `inspectable` improves console + inspection. `inspectable` can also stand alone (without `getter_with_clone`) + on an opaque wrapper class, again via the dual form `#[cfg_attr(feature = + "browser", wasm_bindgen(inspectable))]` + `#[cfg_attr(feature = "nodejs", + napi)]` (note: bare `napi`, not `napi(object)`, for a class that wraps a + native handle rather than a plain-data object). +- Field names: snake_case in Rust, camelCase via browser-side `js_name`. +- Serialize complex values to hex strings or `JsBytes`/`Vec` where needed. + +## Promise Handling (idxdb-store pattern) + +When calling JS functions from Rust that return Promises (the IndexedDB store, +in `crates/idxdb-store`), use these helpers (`crates/idxdb-store/src/promise.rs`): + +```rust +/// Awaits a JavaScript Promise and returns the raw JsValue. +pub(crate) async fn await_js_value(promise: Promise, ctx: &str) -> Result { + JsFuture::from(promise) + .await + .map_err(|js_error| StoreError::DatabaseError(format!("{ctx}: {js_error:?}"))) +} + +/// Awaits a JavaScript Promise and deserializes into T. +pub(crate) async fn await_js(promise: Promise, ctx: &str) -> Result +where + T: DeserializeOwned, +{ + let js_value = await_js_value(promise, ctx).await?; + from_value(js_value) + .map_err(|err| StoreError::DatabaseError(format!("failed to deserialize ({ctx}): {err:?}"))) +} + +/// Awaits a JavaScript Promise and discards the result. +pub(crate) async fn await_ok(promise: Promise, ctx: &str) -> Result<(), StoreError> { + let _ = await_js_value(promise, ctx).await?; + Ok(()) +} +``` + +Rules: +- Always provide a context string describing what the await is for. +- Use `await_js::()` when you need to deserialize the result. +- Use `await_ok()` when you only care about success/failure. +- Use `serde_wasm_bindgen::from_value()` for deserialization, not `serde_json`. + (At v0.15 `Promise` is imported via `wasm_bindgen_futures::js_sys::Promise`.) + +## Importing JS Functions from Rust + +Declare external JS functions with `#[wasm_bindgen(module = "...")]` (browser / +idxdb-store side): + +```rust +#[wasm_bindgen(module = "/src/js/utils.js")] +extern "C" { + #[wasm_bindgen(js_name = logWebStoreError)] + fn log_web_store_error(error: JsValue, error_context: alloc::string::String); +} + +#[wasm_bindgen(module = "/src/js/schema.js")] +extern "C" { + /// Opens the database and registers it in the JS registry. + #[wasm_bindgen(js_name = openDatabase)] + fn open_database(network: &str, client_version: &str) -> js_sys::Promise; +} +``` + +Rules: +- Module path is relative to the crate root. +- Function names are snake_case in Rust, mapped via `js_name`. +- Return `js_sys::Promise` for async operations. +- Pass simple types across the boundary: `&str`, `JsValue`, `Vec`, `u32`. + +## JS Wrapper Layer + +The web-client crate ships **two** JS layers under `crates/web-client/js/`: + +1. **`WebClient`** (`js/index.js`) — the WASM-bound class re-exported as + `WasmWebClient` (`export { WebClient as WasmWebClient, MockWebClient as + MockWasmWebClient }`). It wraps the `WebClient` Rust struct and adds JS-side + concerns: + - `_serializeWasmCall` queue that linearizes WASM calls (the inner client is + behind a lock, so the JS side must not interleave async calls). + - `syncState()` is wrapped in the exported `withSyncLock(dbId, methodId, fn)` + helper (`js/syncLock.js`, Web Locks via `navigator.locks`) to coalesce + concurrent syncs and serialize them across tabs: + `return await withSyncLock(dbId, methodId, async () => + this._serializeWasmCall(...))`. + - method-classification sets (`SYNC_METHODS`, `READ_METHODS`, + `WRITE_METHODS`) consumed by the proxy and enforced by + `scripts/check-method-classification.js`. (`SYNC_METHODS` is a historical + misnomer — it groups methods safe to bind raw.) +2. **`MidenClient`** (`js/client.js`) — the public, resource-based wrapper that + owns a `WebClient` instance and exposes typed sub-objects: `client.accounts`, + `client.transactions`, `client.notes`, `client.tags`, `client.settings`, + `client.compile` (a `CompilerResource`, hence the property is `compile` + though the file is `compiler.js`), and `client.keystore`. Each resource lives + under `js/resources/.js`. + +`index.js` injects the WASM constructor and the `getWasm` initializer into +`MidenClient` via static fields to break the import cycle: + +```javascript +MidenClient._WasmWebClient = WebClient; +MidenClient._MockWasmWebClient = MockWebClient; +MidenClient._getWasmOrThrow = getWasmOrThrow; +``` + +There is **no** `safe-arrays.js` module. The wasm-bindgen array wrappers +(`NoteArray`, `OutputNoteArray`, `AccountArray`, `ForeignAccountArray`, ...) are +generated by the `declare_js_miden_arrays!` macro (defined in +`crates/web-client/src/miden_array.rs`, invoked in +`crates/web-client/src/models/mod.rs`), and their constructor **consumes** its +elements. To keep an element usable afterwards, construct the array empty and +`push` by reference instead of passing elements to the constructor: + +```javascript +// NoteArray constructor consumes its elements; use push(¬e) to keep +// `note` valid so it can be returned to the caller. +const ownOutputs = new wasm.NoteArray(); +ownOutputs.push(note); +``` + +### Adding a method + +When extending the SDK, choose the layer based on whether the work is +**Rust-side** or **glue/shape**: + +- **Rust-side logic** (new RPC call, new transaction request type, storage + access): expose a method on the WASM `WebClient` impl with `#[js_export(js_name + = "camelCase")]`, then surface it from the matching resource in + `js/resources/`. Update the method-classification sets in `index.js` so the + linter (`scripts/check-method-classification.js`) accepts it. +- **JS-side ergonomics** (option-bag normalization, account-ref resolution, type + coercion): keep the work in the resource module and call the existing WASM + method. + +Resource methods follow this shape: + +```javascript +// crates/web-client/js/resources/accounts.js +async get(ref) { + this.#client.assertNotTerminated(); + const wasm = await this.#getWasm(); + const id = resolveAccountRef(ref, wasm); // accepts string | AccountId | Account | AccountHeader + const account = await this.#inner.getAccount(id); + return account ?? null; +} +``` + +Rules: + +- Always call `this.#client.assertNotTerminated()` at entry — late callbacks on + a torn-down client otherwise panic with "null pointer passed to rust". +- Resolve account/note/storage refs through the helpers in + `crates/web-client/js/utils.js` (e.g. `resolveAccountRef`, + `resolveStorageMode`), imported from a resource as `../utils.js`, so callers + can pass any natural form (hex, bech32 address, WASM type). (There is no + `utils.js` inside `js/resources/` — that directory holds only the seven + resource files: accounts, compiler, keystore, notes, settings, tags, + transactions.) +- Return WASM-owned objects (e.g. `Account`, `AccountHeader`) directly when + callers will use them again — wrapping them in plain JS DTOs forces another + WASM round-trip and breaks identity for code that compares by reference. diff --git a/AGENTS.md b/AGENTS.md index b38df7bd..8f42ea25 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -188,6 +188,43 @@ Any change that adds, renames, removes, or alters the observable behavior of a m | **API reference (typedoc)** | Same site, deeper paths | `crates/web-client/typedoc.json` declares `out: ../../docs/typedoc/web-client`. Generated by `pnpm --filter @miden-sdk/miden-sdk run typedoc` from the curated [`docs-entry.d.ts`](crates/web-client/js/types/docs-entry.d.ts) entry point. | | **CHANGELOG (upgrade-time reading)** | Root `CHANGELOG.md` — read by dApp authors at upgrade time. | Hand-written. CI ingestion is per-repo: don't expect this file to be aggregated elsewhere. | | **READMEs (npm landing page)** | `crates/web-client/README.md` and `packages/react-sdk/README.md` are what npm users see on the package page. | Hand-written. Keep narrative aligned with the published Docusaurus site — they share content but the README has the wider audience for first-touch. | +| **Agent guides (read by the consumer's AI agent)** | `AGENTS.md` + `skills/` inside each published package. | Hand-written, shipped via each package's `files` array. See [Agent-facing docs](#agent-facing-docs-agentsmd-and-skills) below — this repo is the canonical home for them. | + +### Agent-facing docs: `AGENTS.md` and `skills/` + +Each published package ships an `AGENTS.md` index plus a `skills/` directory, +and consumers' AI agents read them out of `node_modules`. Because they ship in +the tarball they are **version-matched to the code**, which is the whole point: +a consumer on 0.15 gets 0.15 guidance. + +| Package | Ships | +|---|---| +| `@miden-sdk/miden-sdk` (`crates/web-client/`) | `web-client-usage`, `frontend-pitfalls`, `signer-integration` | +| `@miden-sdk/react` (`packages/react-sdk/`) | `react-sdk-patterns`, `testing-patterns` | +| `@miden-sdk/vite-plugin` (`packages/vite-plugin/`) | `vite-wasm-setup` | + +**This repo is canonical for any skill that documents our own API.** These +skills previously lived in [`0xMiden/agent-tools`](https://github.com/0xMiden/agent-tools) +and were copied into [`0xMiden/frontend-template`](https://github.com/0xMiden/frontend-template); +both copies drifted from each other and from the code, because nothing tied a +skill to the API it described. They live here now so that a PR changing the API +and a PR changing its documentation are the same PR. Don't reintroduce a copy +elsewhere — have the other repo consume the published package instead. + +`agent-tools` remains canonical for everything **not** specific to our API: the +MASM family, `rust-sdk-*`, `miden-concepts`, `local-node-validation`, and the +slash commands. If a skill you're writing never mentions `@miden-sdk/*`, it +probably belongs there rather than here. + +Two web-sdk skills are deliberately **not** shipped, because they document +internals rather than the API: `.claude/skills/idxdb-patterns/` and +`.claude/skills/wasm-bridge/`. They're contributor material and stay out of the +`files` array. + +**When you change the public API, update the shipped skill in the same PR.** +It's the surface most likely to be silently wrong, because nothing type-checks +prose — and a stale skill is worse than no skill, since an agent will follow it +confidently. ### Source-of-truth for the published narrative docs diff --git a/CHANGELOG.md b/CHANGELOG.md index 2390a29b..69d7711b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 0.16.0-rc.4 (TBA) + +### Enhancements + +* [FEATURE][web][react] Every published package now ships agent-facing documentation inside its tarball: an `AGENTS.md` index plus a `skills/` directory, readable at `node_modules/@miden-sdk//`. Because they ship with the code they are version-matched to the installed release, so an AI agent working in a consumer's repo gets guidance for the version in that repo's lockfile rather than whatever its training data remembers. `@miden-sdk/miden-sdk` also ships its `README.md` for the first time — it previously published neither a readme nor any documentation. Paste the marker block from any package readme into your project's root `AGENTS.md` to point your agent at them. ([#310](https://github.com/0xMiden/web-sdk/pull/310)) +* [CHANGE] The skills describing this SDK's own API (`web-client-usage`, `react-sdk-patterns`, `vite-wasm-setup`, `frontend-pitfalls`, `signer-integration`, `testing-patterns`) are now canonical in this repo, having previously been maintained in [`0xMiden/agent-tools`](https://github.com/0xMiden/agent-tools) and copied into [`0xMiden/frontend-template`](https://github.com/0xMiden/frontend-template). Both copies had drifted from each other and from the code, since nothing tied a skill to the API it documented; keeping them beside the source makes an API change and its documentation the same PR. `agent-tools` remains canonical for everything not specific to this SDK. ([#310](https://github.com/0xMiden/web-sdk/pull/310)) + ## 0.16.0-rc.3 (2026-08-23) ### Changes diff --git a/crates/web-client/AGENTS.md b/crates/web-client/AGENTS.md new file mode 100644 index 00000000..234ea239 --- /dev/null +++ b/crates/web-client/AGENTS.md @@ -0,0 +1,86 @@ +# @miden-sdk/miden-sdk — Agent Guide + +**Audience: AI coding agents** writing application code against the Miden web +SDK. Humans are welcome to read it, but it is written to be loaded into an +agent's context and followed. + +This file ships inside the published package. The copy at +`node_modules/@miden-sdk/miden-sdk/AGENTS.md` always matches the version you +have installed, so **prefer it over your training data**, which is likely to +describe an older API. Miden is pre-1.0 and the surface still moves between +minor versions. + +## Load the right skill + +Detailed, task-scoped guidance ships alongside this file in +`node_modules/@miden-sdk/miden-sdk/skills/`. Read the one that matches what you +are doing rather than guessing from the type signatures alone. + +| Skill | Load it when | +|---|---| +| `skills/web-client-usage/SKILL.md` | Any code that calls `MidenClient` — initialization, the resource API, sync ordering, type conversions, transaction flows, custom contracts, private note transport. | +| `skills/frontend-pitfalls/SKILL.md` | Before shipping. WASM initialization, concurrent access, cross-origin isolation, `BigInt` at the WASM boundary. These are the failures that survive code review and break in production. | +| `skills/signer-integration/SKILL.md` | Wiring an external signer (Para, Turnkey, a wallet adapter) or implementing a custom one. | + +Building a React app? `@miden-sdk/react` wraps this client in hooks and ships +its own guide at `node_modules/@miden-sdk/react/AGENTS.md`. Prefer the hooks for +anything they cover; drop to this client only for what they don't. + +Configuring the bundler? See `node_modules/@miden-sdk/vite-plugin/AGENTS.md`. + +## The shape of the API + +`MidenClient` is the single entry point. Construct it with a static factory — +never with `new` — and route work through its typed resources: + +```ts +import { MidenClient } from "@miden-sdk/miden-sdk"; + +const client = await MidenClient.createTestnet(); +await client.sync(); +``` + +`create(options)` targets an explicit endpoint; `createTestnet()` and +`createDevnet()` are preconfigured; `createMock()` backs tests with an in-memory +chain and no network. + +State is split across resources rather than living on the client: +`accounts`, `transactions`, `notes`, `tags`, `settings`, `keystore`, `compile` +and `pswap`. Client-level methods cover the lifecycle around them — `sync`, +`syncChain`, `syncNoteTransport`, `getSyncHeight`, `waitForIdle` and +`terminate`. + +## Rules that are easy to get wrong + +**Sync before you read.** Local state is a cache of chain state. Calling +`client.sync()` first is the difference between correct balances and confusing +ones. `skills/web-client-usage/SKILL.md` documents where in each flow it belongs. + +**Amounts are always `BigInt`.** Passing a `number` either throws at the WASM +boundary or silently loses precision above 2^53. Convert at the edges of your +own code, not in the middle of a transaction builder. + +**The WASM client is single-threaded.** Concurrent calls into one client +instance are not safe. Serialize them. Applications that fan out requests from +multiple components need a lock or a queue around the client, and this is the +single most common source of "impossible" runtime errors. + +**Free what you allocate.** WASM-backed objects are not garbage collected the +way plain JS objects are. Call `terminate()` on the client when you are done +with it, and free the object wrappers the skills call out individually. + +## Going deeper + +- Narrative documentation and the full generated API reference: + +- Breaking changes and migration notes, worth reading at upgrade time: + the `CHANGELOG.md` in [`0xMiden/web-sdk`](https://github.com/0xMiden/web-sdk). +- The type declarations shipped in `dist/` are authoritative for signatures. + When this guide and the types disagree, the types are right and this file is + a bug — please report it. + +## Starting a new project rather than adding to one + +If there is no application yet, [`0xMiden/agentic-template`](https://github.com/0xMiden/agentic-template) +scaffolds the full stack: Rust contracts, MockChain tests, local-node +validation, and a React frontend already wired to this SDK. diff --git a/crates/web-client/README.md b/crates/web-client/README.md index 8034334a..f357d1ce 100644 --- a/crates/web-client/README.md +++ b/crates/web-client/README.md @@ -65,6 +65,38 @@ 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) + +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 + +## 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 for the +package you are touching: + +- `node_modules/@miden-sdk/miden-sdk/AGENTS.md` — core client +- `node_modules/@miden-sdk/react/AGENTS.md` — React hooks +- `node_modules/@miden-sdk/vite-plugin/AGENTS.md` — bundler setup + +Each one indexes task-specific skills in its package's `skills/` directory. +Read the relevant skill before implementing, not after. + +``` + ## Entry Points: Eager / Lazy × ST / MT The SDK ships **four** entry points with an identical public API. They vary along two orthogonal axes: diff --git a/crates/web-client/package.json b/crates/web-client/package.json index d5683949..422c4cf0 100644 --- a/crates/web-client/package.json +++ b/crates/web-client/package.json @@ -59,6 +59,9 @@ "js/standalone.js", "js/utils.js", "js/resources", + "README.md", + "AGENTS.md", + "skills", "../LICENSE.md" ], "scripts": { diff --git a/crates/web-client/skills/frontend-pitfalls/SKILL.md b/crates/web-client/skills/frontend-pitfalls/SKILL.md new file mode 100644 index 00000000..f99ede97 --- /dev/null +++ b/crates/web-client/skills/frontend-pitfalls/SKILL.md @@ -0,0 +1,202 @@ +--- +name: frontend-pitfalls +description: Critical pitfalls and safety rules for Miden frontend development. Covers WASM initialization, concurrent access crashes, COOP/COEP headers, BigInt handling, Bech32 network mismatches, IndexedDB state loss, auto-sync side effects, Vite configuration, and React rendering race conditions. Use when reviewing, debugging, or writing Miden frontend code. +--- + +# Miden Frontend Pitfalls + +## FP1: WASM Initialization Race (CRITICAL) + +Components that use Miden hooks before MidenProvider finishes WASM initialization will crash. + +```tsx +// WRONG — renders empty before WASM is ready +function App() { + const { accounts } = useAccounts(); // returns empty arrays before WASM is ready + return
{accounts.length}
; +} + +// CORRECT — use loadingComponent or check isReady +Loading WASM...

} +> + +
+ +// CORRECT — guard with isReady +function App() { + const { isReady } = useMiden(); + if (!isReady) return

Loading...

; + return ; +} +``` + +## FP2: Recursive WASM Access Crash (CRITICAL) + +The WASM client is single-threaded. Concurrent calls crash with "recursive use of an object detected". + +```tsx +// WRONG — two operations running simultaneously +const handleClick = async () => { + sync(); // fires async + await send({ ... }); // runs concurrently — CRASH +}; + +// CORRECT — use runExclusive for sequential execution +const client = useMidenClient(); +const { runExclusive } = useMiden(); +await runExclusive(async () => { + await client.syncState(); + // now safe to do next operation +}); +``` + +Built-in hooks (useSend, useConsume, etc.) already use runExclusive internally. This pitfall applies when using `useMidenClient()` directly or mixing manual client calls with hook mutations. + +## FP3: COOP/COEP Headers — Only for the Multi-Threaded (MT) Build (HIGH) + +COOP/COEP cross-origin-isolation is **not** a universal requirement. The web SDK ships four entry points along two axes (eager/lazy × ST/MT), and the isolation requirement depends entirely on the threading model: + +- The **default** `@miden-sdk/react` (and `@miden-sdk/react/lazy`) and the **default** `@miden-sdk/miden-sdk` (and `/lazy`) are **single-threaded (ST)**. They ship single-threaded WASM that "loads in any browser context" with **no COOP/COEP requirement**. This is why the SDK's shipped example wallet runs full Miden client code (`MidenProvider`) importing the default `@miden-sdk/react` while using the bare `midenVitePlugin()` with no cross-origin isolation — ST simply does not need it. +- Only the **multi-threaded (MT)** variants — `@miden-sdk/react/mt`, `@miden-sdk/react/mt/lazy`, `@miden-sdk/miden-sdk/mt`, `@miden-sdk/miden-sdk/mt/lazy` (wasm-bindgen-rayon, ~3–5× faster local proving) — **require** the page to be cross-origin-isolated (`self.crossOriginIsolated === true`). Without `Cross-Origin-Opener-Policy: same-origin` + `Cross-Origin-Embedder-Policy: require-corp`, the browser refuses to construct `WebAssembly.Memory({ shared: true })` and the MT WASM fails to instantiate at module load. + +So: pick ST (the default) and you need no headers at all; opt into MT only if you do local proving on a host whose headers you control. + +If you DO opt into the MT build, enable isolation via the Vite plugin explicitly on any route that runs the MT client: + +```ts +// in your app's vite.config.ts — only needed for the MT build +import { midenVitePlugin } from "@miden-sdk/vite-plugin"; + +export default defineConfig({ + plugins: [react(), midenVitePlugin({ crossOriginIsolation: true })], +}); +``` + +Do not rely on the plugin's own default — `@miden-sdk/vite-plugin` defaults `crossOriginIsolation` to `false` (verified false in the executable source across the released tags; note the plugin README incorrectly says the default is `true`). For MT you must pass `true` explicitly. For ST (the default build) leaving it `false` is correct — the example wallet uses bare `midenVitePlugin()` precisely because it is ST, and because `same-origin` COOP would nullify `window.opener` in the Para OAuth popups it pairs with via `paraVitePlugin()`. + +For MT, COOP/COEP must also be set on the production server — the plugin covers only the Vite dev and preview servers, not your real production host. See `vite-wasm-setup` for per-host configs (Nginx, Vercel, Cloudflare). + +**Gotcha (when isolation is on)**: Cross-origin-isolation breaks third-party iframes, external scripts without CORS, and OAuth popups. If a route must host those and cannot satisfy isolation, stay on the default ST subpaths (they need no isolation) or, if you genuinely need MT elsewhere, use `Cross-Origin-Embedder-Policy: credentialless` for weaker isolation that still allows most cross-origin resources, or scope the headers to only the MT routes. Do not enable isolation globally as a convenience. + +## FP4: BigInt at the Raw WASM Boundary (HIGH) + +The React SDK hooks (`useSend`, `useCreateFaucet`, `useMultiSend`, …) accept `bigint | number` for amounts and coerce to `bigint` internally — `SendOptions.amount` and `CreateFaucetOptions.maxSupply` are both typed `bigint | number`, and `useCreateFaucet` calls `BigInt(options.maxSupply)` before forwarding. So `number` does NOT fail at the hook layer. `bigint` is required only at the raw WASM client (`@miden-sdk/miden-sdk`) boundary, where amounts are `bigint` with no coercion. + +```tsx +// FINE at the React-SDK hook layer — number is coerced +await send({ from, to, assetId, amount: 1000 }); +await createFaucet({ maxSupply: 1000000, ... }); + +// ALSO FINE — pass bigint directly (preferred; avoids precision loss above 2^53) +await send({ from, to, assetId, amount: 1000n }); +await createFaucet({ maxSupply: BigInt(1000000), ... }); + +// REQUIRED at the raw WASM client boundary — must be bigint +// (the low-level @miden-sdk/miden-sdk client does not coerce number) + +// CORRECT — use parseAssetAmount for user input (decimal string → bigint) +import { parseAssetAmount } from "@miden-sdk/react"; +const amount = parseAssetAmount(inputValue, 8); // string → bigint +``` + +Prefer `bigint` everywhere anyway: a `number` above `2^53` loses precision before it ever reaches the coercion, so large supplies/amounts must be `bigint` or a decimal string parsed via `parseAssetAmount`. + +**Gotcha**: `JSON.stringify` cannot serialize `bigint`. Use a custom replacer or convert to string first. + +## FP5: Bech32 Network Mismatch (HIGH) + +Bech32-encoded account IDs include the network. A devnet address on testnet points to a different or nonexistent account. + +```tsx +// WRONG — hardcoding a bech32 address used across networks +const ADMIN = "miden1qy35..."; // this is network-specific! + +// CORRECT — use hex format for cross-network compatibility +const ADMIN = "0x1234567890abcdef"; + +// CORRECT — derive bech32 per network +account.bech32id(); // returns correct bech32 for current network +``` + +Both hex and bech32 formats work in all hooks. Prefer hex for constants, bech32 for display. + +## FP6: Auto-Sync Side Effects (MEDIUM) + +Default `autoSyncInterval` is 15000ms (15 seconds). Each sync triggers re-renders in useAccounts, useAccount, useNotes, etc. + +```tsx +// PROBLEM — form resets every 15 seconds because parent re-renders + + {/* re-renders on every sync */} + + +// SOLUTION 1 — preferred: use stable keys and memoization +const MemoizedForm = React.memo(SendForm); + +// SOLUTION 2 — disable auto-sync for manual control + +``` + +## FP7: IndexedDB State Loss (MEDIUM) + +The client persists accounts, keys, and notes in IndexedDB. Browser "Clear site data", private browsing, or storage pressure can delete everything. + +- Warn users that clearing browser data deletes their wallet +- Consider external signers (Para, Turnkey) for production — keys are server-side +- Implement account export/backup for local keystore users + +## FP8: Vite Configuration Requirements (MEDIUM) + +The `@miden-sdk/vite-plugin` package handles all Miden-specific Vite config. The recommended pattern for any new Miden app is: + +```ts +import { midenVitePlugin } from "@miden-sdk/vite-plugin"; + +export default defineConfig({ + // ST (default build): bare plugin is enough — no isolation needed + plugins: [react(), midenVitePlugin()], + + // MT build only: opt into cross-origin isolation + // plugins: [react(), midenVitePlugin({ crossOriginIsolation: true })], +}); +``` + +`midenVitePlugin()` handles WASM loading (esnext build target, top-level await), pre-bundling exclusion (`optimizeDeps.exclude`), package deduplication, a gRPC-web RPC proxy, and — when `crossOriginIsolation: true` is passed — emits the COOP `same-origin` + COEP `require-corp` headers the **MT** build requires for `SharedArrayBuffer` on both the dev `server` and the `preview` server. + +| Option | Plugin source default | When to set `true` | Purpose | +|--------|-----------------------|--------------------|---------| +| `crossOriginIsolation` | `false` | Only when importing the MT variants (`/mt`, `/mt/lazy`) | Emit COOP/COEP headers for SharedArrayBuffer | + +For the **default single-threaded build**, leave `crossOriginIsolation` at its `false` default — the ST WASM loads in any browser context and needs no headers. Pass `crossOriginIsolation: true` **only** when you opt into the multi-threaded variants for local proving; without the headers the MT WASM can't construct shared memory and fails to instantiate. (The plugin README at v0.15.0 incorrectly documents the default as `true`; the executable source default is `false`, unchanged across the released tags. Do not trust the README.) The shipped example wallet uses bare `midenVitePlugin()` because it is ST (and because isolation would break the Para OAuth popups it pairs with via `paraVitePlugin()`) — see FP3. For an MT production deployment, set the same COOP/COEP headers at your real production host — the plugin only injects them into the Vite dev and preview servers. See `vite-wasm-setup` for host-specific configs. + +## FP9: React StrictMode Double-Init (LOW) + +React StrictMode double-invokes effects in development (since React 18; the React SDK's peer dep is `react >= 18.0.0`). MidenProvider guards against this, but direct low-level `createClient()` calls will initialize twice. + +Naming: `@miden-sdk/miden-sdk` exposes a high-level `MidenClient` wrapper class (the recommended entry point) and a low-level client re-exported as `WasmWebClient` — an `@internal` export used mainly by integration tests, whose type declaration explicitly says "Use MidenClient instead." (The class is named `WebClient` in source and re-exported under the alias `WasmWebClient`.) The React SDK does its own low-level init by importing that internal client locally as `WebClient` (`import { WasmWebClient as WebClient } from "@miden-sdk/miden-sdk"`). For manual low-level setup you would call `WasmWebClient.createClient(...)`, but prefer `MidenProvider` (or the high-level `MidenClient`) so init is guarded. + +```tsx +// WRONG — manual low-level client creation in useEffect +useEffect(() => { + const client = await WasmWebClient.createClient(url); // called twice in dev +}, []); + +// CORRECT — always use MidenProvider + +``` + +## Quick Reference + +| # | Pitfall | Severity | One-Line Rule | +|---|---------|----------|---------------| +| FP1 | WASM init race | CRITICAL | Use loadingComponent or check isReady | +| FP2 | Recursive WASM | CRITICAL | Use runExclusive() for all direct client access | +| FP3 | COOP/COEP | HIGH | Default ST build needs no headers; required ONLY for the `/mt` build | +| FP4 | BigInt | HIGH | Hooks accept `bigint \| number` and coerce; prefer bigint, required at the raw WASM boundary | +| FP5 | Bech32 mismatch | HIGH | Match network in rpcUrl and addresses | +| FP6 | Auto-sync | MEDIUM | Set autoSyncInterval: 0 if UI stability matters | +| FP7 | IndexedDB loss | MEDIUM | Warn users; use external signers for production | +| FP8 | Vite config | MEDIUM | Bare `midenVitePlugin()` for ST; pass `crossOriginIsolation: true` only for the `/mt` build | +| FP9 | StrictMode | LOW | Use MidenProvider, not manual client creation | diff --git a/crates/web-client/skills/signer-integration/SKILL.md b/crates/web-client/skills/signer-integration/SKILL.md new file mode 100644 index 00000000..5d774b28 --- /dev/null +++ b/crates/web-client/skills/signer-integration/SKILL.md @@ -0,0 +1,196 @@ +--- +name: signer-integration +description: Guide to integrating external signers (Para, Turnkey, MidenFi wallet adapter) and building custom signers for Miden React frontends. Covers provider setup, passkey authentication, unified signer interface, custom SignerContext implementation, and custom account components. Use when adding wallet connection, authentication, or external key management to a Miden frontend. +--- + +# Miden Signer Integration + +## Overview + +By default, MidenProvider uses a **local keystore** (keys in IndexedDB, no wallet connection needed). For production apps, wrap MidenProvider with a signer provider to use external key management. + +Signer providers must wrap MidenProvider (outer → inner): +``` + ← manages keys + auth + ← manages Miden client + + + +``` + +## Pre-Built Signer Providers + +### Para (EVM Wallets) +```tsx +import { ParaSignerProvider, useParaSigner } from "@miden-sdk/use-miden-para-react"; + + + + + + + +const { para, wallet, isConnected } = useParaSigner(); +``` + +### Turnkey (Passkey Authentication) +```tsx +import { TurnkeySignerProvider } from "@miden-sdk/miden-turnkey-react"; + +// `config` is REQUIRED, and `defaultOrganizationId` is required within it. +// Type: Pick +// & Partial> +// — only the other fields (e.g. `apiBaseUrl`) are optional; `apiBaseUrl` +// defaults to https://api.turnkey.com. There is NO env-var fallback for the +// org id (the provider does not read `VITE_TURNKEY_ORG_ID`). + + + + + + +// Or override the apiBaseUrl default: + + ... + +``` + +`TurnkeySignerProvider` also accepts optional `customComponents` and `importAccountId` props, which it forwards into `accountConfig` (see "Custom Account Components"). + +Connect via passkey: +```tsx +import { useSigner } from "@miden-sdk/react"; +import { useTurnkeySigner } from "@miden-sdk/miden-turnkey-react"; + +// useSigner() returns null in local-keystore mode (no signer provider mounted), +// so guard before destructuring. +const signer = useSigner(); +if (!signer) return null; +const { isConnected, connect, disconnect } = signer; +await connect(); // triggers passkey flow, auto-selects account + +// Turnkey-specific extras +const { client, account, setAccount } = useTurnkeySigner(); +``` + +### MidenFi Wallet Adapter (Browser Extension) +```tsx +import { MidenFiSignerProvider } from "@miden-sdk/miden-wallet-adapter-react"; +import { WalletAdapterNetwork } from "@miden-sdk/miden-wallet-adapter-base"; + + + + + + +``` + +With `MidenFiSignerProvider` in place, use `useSigner()` from the React SDK to manage connection state. The regular React SDK hooks (`useSend`, `useConsume`, etc.) automatically sign via the connected wallet — no additional wiring needed. + +> The provider accepts an `accountType` prop, but it is a no-op: account visibility is determined solely by `storageMode` (`private`/`public`), and the provider always imports the account by ID (`importAccountId`), bypassing the builder path entirely. Omit it. + +### Frontend-template-specific MidenFi pattern + +The [frontend template](https://github.com/0xMiden/frontend-template) (on web-sdk 0.15 — `@miden-sdk/miden-sdk@0.15.3`, `@miden-sdk/react@0.15.3`, wallet adapters `0.15.1`) deviates from the generic patterns above in three places worth knowing when the wallet extension is the primary signer: + +- **Provider order is INVERTED: `MidenProvider` runs OUTSIDE `MidenFiSignerProvider`** — see `src/providers.tsx`. This is the opposite of the canonical signer-outer / Miden-inner nesting at the top of this skill, and it is deliberate. In v0.15, when a signer provider is an *ancestor* of `MidenProvider`, `MidenProvider` treats it as its external keystore and does NOT create the `WebClient` until the signer connects (the init effect sees `signerIsConnected === false` and returns early before building the client). With a wallet that hasn't connected — or any environment without the extension — the app would hang on "Initializing…" and even public reads couldn't run. The template never signs *through* `MidenProvider` (it signs its only write, the counter increment, through the local `WebClient` rather than the wallet), so it runs `MidenProvider` in local-keystore mode (no signer ancestor → it initializes immediately, reads work pre-connect) and keeps `MidenFiSignerProvider` *inside*, purely for the connect button and the wallet's `requestTransaction`. `MidenFiSignerProvider` works standalone (it provides its own `WalletContext` + `SignerContext`; no `MultiSignerProvider` needed). Use this inversion only when you do not sign through `MidenProvider`; if external-keystore signing IS the goal, keep the canonical signer-outer order so `MidenProvider` picks up the signer's `signCb`/`accountConfig`. +- **Wallet button uses `useMidenFiWallet()` + `WalletReadyState`** — see `src/components/AppContent.tsx`. The button gates on `wallet?.readyState` (rendering a disabled "Install MidenFi Wallet" state unless `readyState` is `Installed` or `Loadable`) so it can show install state before the extension is detected. `useSigner().connect()` would silently fall through to the adapter's `window.open(adapter.url, ...)` install fallback; gating on `readyState` avoids that path. +- **The counter increment is a local two-transaction flow, not a wallet-signed tx** — see `src/hooks/useIncrementCounter.ts`. It does not use the wallet at all. It creates a throwaway local sender (`client.newWallet(...)`), publishes a plain increment note as that sender's own output note (`TransactionRequestBuilder().withOwnOutputNotes(...)`), then consumes the note *as the counter* (`client.newConsumeTransactionRequest([note])`). Both transactions are submitted by the local `WebClient` via `submitNewTransactionWithProver(accountId, request, prover)` (remote prover), never by the wallet, so `useWaitForCommit` doesn't apply and the template polls the counter's storage map instead. This mirrors the project-template `increment_count` reference. + - **The note APIs in that hook (use as the reference):** the JS `NoteMetadata` constructor is attachment-less — `new NoteMetadata(sender, noteType, tag)`. Build the note with `new Note(new NoteAssets(), metadata, recipient)`. The increment note carries no attachment and uses tag `0`; the counter is a plain **public `NoAuth`** account, so anyone can consume the note against it with no signature. (Attachments still exist for other uses — `NoteAttachment.fromWord(scheme, word)` / `fromWords(scheme, words)`, read back via `.toWords()`, or `createNoteAttachment(...)` — but the increment does not need one. v0.15 removed the network-account model, so there is no network-execution targeting.) + - **Two hard requirements (don't regress):** (1) the client runs with `useWorker: false` on `MidenProvider`. The default worker shim keeps a separate in-memory SMT forest per thread; consuming against an *imported* (not locally-created) account applies a delta transaction whose apply step looks the account up in the executing (worker) forest, which never contains the late-imported counter, so it fails with `account data wasn't found` ([web-sdk#222](https://github.com/0xMiden/web-sdk/issues/222)). One thread means one forest, which fixes it. (2) Submits go through the remote prover (`submitNewTransactionWithProver`) so the worker-less single thread only pays local execution, not minutes of local proving. The increment works end-to-end on v0.15 (verified on testnet); there is no `INCREMENT_ONCHAIN_BLOCKED` flag. + +## Unified Signer Interface + +Works with any signer provider above. `useSigner()` returns `null` in local-keystore mode (no signer provider mounted), so guard before destructuring: +```tsx +import { useSigner } from "@miden-sdk/react"; + +const signer = useSigner(); +if (!signer) return null; // local keystore mode — no external signer + +const { isConnected, connect, disconnect, name } = signer; + +if (!isConnected) { + return ; +} +``` + +## Building a Custom Signer + +Implement `SignerContextValue` via `SignerContext.Provider`: + +```tsx +import { SignerContext } from "@miden-sdk/react"; +import { AccountStorageMode } from "@miden-sdk/miden-sdk"; + + { + // Route to your signing service + return signature; // Uint8Array + }, + connect: async () => { /* trigger wallet connection */ }, + disconnect: async () => { /* clear session */ }, +}}> + + + + +``` + +**Required fields:** +- `name` — Display name for the signer +- `storeName` — Unique string per user (isolates IndexedDB data between users) +- `accountConfig` — `{ publicKeyCommitment: Uint8Array; storageMode: AccountStorageMode; ... }` (storage mode is an `AccountStorageMode` instance, e.g. `AccountStorageMode.private()`, not a string) +- `signCb` — Callback that signs transaction data with your key management service +- `connect` / `disconnect` — Session lifecycle handlers + +## Custom Account Components + +Attach application-specific `AccountComponent` instances (e.g., DEX logic from `.masp` packages) to accounts created by the signer: + +```tsx +import { type SignerAccountConfig } from "@miden-sdk/react"; +import { AccountComponent } from "@miden-sdk/miden-sdk"; + +const myDexComponent: AccountComponent = await loadCompiledComponent(); + +const accountConfig: SignerAccountConfig = { + publicKeyCommitment: userPublicKeyCommitment, + storageMode: myStorageMode, // an AccountStorageMode instance (e.g. AccountStorageMode.public()) + customComponents: [myDexComponent], +}; +``` + +`SignerAccountConfig` has an `accountType` field, but it is ignored — account kind and code mutability are not encoded in the account, so visibility comes solely from `storageMode`. Omit it. + +Components are appended to the `AccountBuilder` after the default basic wallet component. The field is optional — omitting it preserves default behavior. + +## Which Signer to Choose + +| Signer | Auth Method | Keys Stored | Best For | +|--------|-------------|-------------|----------| +| Local keystore (default) | None | Browser IndexedDB | Development, demos | +| Para | EVM wallet | Para servers | Apps with existing EVM users | +| Turnkey | Passkey (biometric) | Turnkey servers | Consumer apps, no seed phrases | +| MidenFi Wallet | Browser extension | Extension | Power users with MidenFi wallet | +| Custom | Your choice | Your infrastructure | Enterprise, custom auth flows | + +**Key trade-off**: Local keystore requires no setup but keys are lost if the user clears browser data. External signers persist keys server-side but add a dependency. diff --git a/crates/web-client/skills/web-client-usage/SKILL.md b/crates/web-client/skills/web-client-usage/SKILL.md new file mode 100644 index 00000000..426c8a42 --- /dev/null +++ b/crates/web-client/skills/web-client-usage/SKILL.md @@ -0,0 +1,486 @@ +--- +name: web-client-usage +description: Conventions for writing JavaScript/TypeScript code that uses the Miden web SDK (`@miden-sdk/miden-sdk`). Use when building apps on Miden, writing integration tests, or calling MidenClient methods — covers initialization, the resource-based API (accounts, transactions, notes, keystore, compile), sync ordering, type conversions, transaction flows, custom contracts, private note transport, and pitfalls. +--- + +# Web SDK Usage Patterns + +This skill targets the `@miden-sdk/miden-sdk` npm package published from +[`0xMiden/web-sdk`](https://github.com/0xMiden/web-sdk) (the JS web client; in +0.15 it builds on the `miden-client` Rust crate). For React-hook usage, prefer +the `react-sdk-patterns` skill — only fall through to the raw client when a hook +does not cover what you need. + +## API Overview + +The SDK exposes a top-level `MidenClient` whose state is split across typed +**resources**: + +| Resource | What it covers | +|----------|----------------| +| `client.accounts` | Wallets, faucets, custom contracts, listing, import/export | +| `client.transactions` | `send` / `mint` / `consume` / `consumeAll` / `swap` / `execute` / `preview` / `waitFor` | +| `client.notes` | Listing, fetching, importing/exporting, private-note transport | +| `client.tags` | Note-tag subscriptions | +| `client.settings` | Persistent client settings | +| `client.compile` | Compiling MASM into account components, tx scripts, note scripts | +| `client.keystore` | Inserting / fetching / removing secret keys | + +`MidenClient` is the public surface. The underlying WASM-bound class is +exported as `WasmWebClient` (an alias for `WebClient`) for low-level +operations the resource API does not yet wrap — reach for it via the wrapped +`#inner` only when you must. + +## Client Initialization + +### Convenience constructors (recommended) + +```typescript +import { MidenClient } from "@miden-sdk/miden-sdk"; + +// Testnet — autoSync on, testnet RPC + prover + note transport +const client = await MidenClient.createTestnet(); + +// Devnet equivalent +const client = await MidenClient.createDevnet(); +``` + +Both accept the same `ClientOptions` for overrides: + +```typescript +const client = await MidenClient.createTestnet({ + storeName: "my-app-tests", // isolates the IndexedDB store + proverUrl: "local", // prove locally instead of remote + autoSync: false, // disable initial sync +}); +``` + +### Generic constructor + +```typescript +const client = await MidenClient.create({ + rpcUrl: "https://rpc.testnet.miden.io", // string URL or "testnet"/"devnet"/"localhost" + noteTransportUrl: "https://transport.miden.io", + storeName: "my-store", + seed: new Uint8Array(32), // optional — deterministic key generation + proverUrl: "testnet", // optional — sets a default prover + autoSync: true, // optional — call sync() after init + keystore: { // optional — external HSM/keystore + getKey: async (pubKey) => { /* return secretKey or null */ }, + insertKey: async (pubKey, secretKey) => { /* persist */ }, + sign: async (pubKey, signingInputs) => { /* return signature */ }, + }, +}); +``` + +If `rpcUrl` is omitted, `create()` delegates to `createTestnet()`. + +### Lazy / SSR-safe init + +Some bundles (Next.js, Capacitor, raw `/lazy` entry) cannot await WASM at +import time. Use `MidenClient.ready()` to wait for WASM in-band — it is +idempotent and shared across callers: + +```typescript +await MidenClient.ready(); +const client = await MidenClient.createTestnet(); +``` + +### Termination + +```typescript +client.terminate(); // free WASM resources, close the store handle +``` + +After `terminate()`, every method throws — guard against late callbacks on +unmount. + +## Sync — Always Sync First + +The client's view of the chain is only as fresh as its last sync. **Always +call `sync()` before reading account state or building a transaction that +depends on freshly received notes.** + +```typescript +const summary = await client.sync(); // returns SyncSummary +const height = await client.getSyncHeight(); // current local block number +``` + +Common patterns: + +- Sync before consuming notes (notes must be committed on-chain) +- Sync after submitting a transaction to observe the result +- Pass `waitForConfirmation: true` to a `transactions.send/mint/consume/swap` + call to let the SDK wait for the tx commit instead of polling manually +- Use `client.waitForIdle()` to flush all queued WASM calls before doing a + side-effect that must not race with a kernel callback (e.g. clearing an + in-memory unlock token after a wallet "lock") + +`autoSync: true` (default for `createTestnet`/`createDevnet`) only triggers a +single sync at construction time — it is not a polling loop. Use the React +SDK's `useSyncState` or `MidenProvider` `autoSyncInterval` for periodic sync. + +## Type Conversions + +Type confusion across the WASM boundary is the leading source of bugs. + +### `AccountId` + +```typescript +const id = AccountId.fromHex("0xabc123..."); // throws on invalid hex +const id = Address.fromBech32("mtst1abc...").accountId(); +const hex = id.toString(); // "0x..." +``` + +Pass `AccountId` (or any account ref the resource accepts: a hex/bech32 +`string`, `Account`, `AccountHeader`, or `AccountId`) to resource methods — +never raw strings to methods that ask for `AccountId` directly. Note that an +`Address` object is **not** an account ref: `AccountRef = string | Account | +AccountHeader | AccountId`, and the resolver only special-cases objects with an +`.id()` method (`Address` exposes `accountId()`, not `id()`), so call +`address.accountId()` first. + +`AccountId.fromHex` throws on malformed input; wrap in `try/catch` when +accepting user input. + +### Amounts — Always `BigInt` + +```typescript +BigInt(1000) +1000n // numeric literal +BigInt("1000") +``` + +Amount fields accept `number | bigint` (`SendOptions`/`MintOptions.amount`, +`FaucetOptions.maxSupply`) and are coerced internally with `BigInt(...)`, so +an integer `number` works and does **not** throw. The hazard is pre-conversion +precision loss: a numeric literal above `Number.MAX_SAFE_INTEGER` (2^53) loses +precision before it ever reaches `BigInt()`. Use `bigint` for any value that +might exceed 2^53. + +### Visibility & Account Types + +```typescript +import { NoteVisibility, AccountType, AuthScheme, StorageMode } from "@miden-sdk/miden-sdk"; + +NoteVisibility.Public // "public" +NoteVisibility.Private // "private" + +// AccountType is a faucet-kind selector with ONLY two members: +AccountType.FungibleFaucet // 0 +AccountType.NonFungibleFaucet // 1 + +AuthScheme.Falcon // default — Falcon-512 over Poseidon2 +AuthScheme.ECDSA // EcdsaK256Keccak + +StorageMode.Public +StorageMode.Private +``` + +Use `NoteVisibility` strings with the high-level resource APIs — `NoteType` is a +separate enum exported for the low-level WASM APIs and is easy to confuse with +`NoteVisibility`, so do not pass it where a `NoteVisibility` is expected. Use +`AuthScheme.Falcon` for the Poseidon2-based Falcon-512 scheme. + +`AccountType` exposes **only** `FungibleFaucet`/`NonFungibleFaucet`. There is no +`MutableWallet`/`ImmutableWallet`/`MutableContract`/`ImmutableContract` member — +those evaluate to `undefined`. Wallets and contracts are not chosen via +`AccountType`: a wallet is the default (omit `type`), and a contract is any +`accounts.create()` call that passes `components` (or `type: +"MutableContract"`/`"ImmutableContract"` as strings). See "Account Creation". + +`StorageMode` has only `Public`/`Private`. There is no `StorageMode.Network` +(accessing it yields `undefined`, which silently resolves to private). + +## Account Creation + +```typescript +// Wallet — the default when no `type` is given (private, Falcon) +const wallet = await client.accounts.create(); + +// Wallet with explicit options — omit `type` (there is no +// AccountType.*Wallet member; passing one would be undefined → default wallet) +const wallet = await client.accounts.create({ + storage: "private", + auth: AuthScheme.Falcon, +}); + +// Faucet — selected via AccountType.FungibleFaucet / NonFungibleFaucet +const faucet = await client.accounts.create({ + type: AccountType.FungibleFaucet, + storage: "public", + symbol: "DAG", + decimals: 8, + maxSupply: 10_000_000n, +}); + +// Custom contract — selected by passing `components` (NOT by an AccountType +// member). Requires seed and an AuthSecretKey. +const component = await client.compile.component({ code: contractMasm, slots: [] }); +const contract = await client.accounts.create({ + seed: new Uint8Array(32), + auth: secretKey, // AuthSecretKey, not the AuthScheme enum + components: [component], // presence of `components` routes to a contract +}); +``` + +A contract is whatever `accounts.create()` call carries `components` — the +string forms `type: "MutableContract"` / `"ImmutableContract"` also route to a +contract, but the canonical selector is `components`. There is **no** +`AccountType.MutableContract`; `type: AccountType.MutableContract` is +`undefined` and, without `components`, would silently create a wallet. + +## Transactions + +The transactions API is option-bag-based and accepts any account ref +(`Account`, `AccountHeader`, hex string, `AccountId`). + +### Send + +```typescript +const { txId } = await client.transactions.send({ + account: wallet, // sender + to: "0xrecipient...", // any account ref + token: faucet, // faucet account ref — identifies the asset + amount: 100n, + type: NoteVisibility.Public, // optional, but defaults to "public" — see note below + reclaimAfter: 100, // optional — sender can reclaim after this block + timelockUntil: 50, // optional — recipient can consume after this block + waitForConfirmation: true, + timeout: 30_000, +}); +``` + +**`type` defaults to PUBLIC, not private** — for both `send` and `mint`, the +note-type resolver treats an omitted/`undefined` `type` as +`NoteVisibility.Public`. Omitting `type` therefore creates a **public** note (a +privacy hazard). Always pass `type: NoteVisibility.Private` explicitly when a +private note is required. + +For private sends where you also need to deliver the note out-of-band, set +`returnNote: true` and the call returns the constructed `Note` object — +incompatible with `reclaimAfter`/`timelockUntil`. + +```typescript +const { txId, note } = await client.transactions.send({ + account: wallet, + to: "mtst1...", // account ref: hex/bech32 string, Account, + // AccountHeader, or AccountId (not an Address) + token: faucet, + amount: 100n, + type: NoteVisibility.Private, + returnNote: true, +}); + +// Stream the note via the note-transport service. +// `to` accepts a bech32 string, a 0x-hex string, an Account, or an AccountId +// (resolved via resolveAddress). It does NOT accept a pre-parsed Address +// object — that falls through to Address.fromAccountId(addr) and throws. +await client.notes.sendPrivate({ note, to: "mtst1..." }); +``` + +### Mint + +```typescript +const { txId } = await client.transactions.mint({ + account: faucet, // faucet executes the mint + to: targetAccountId, // recipient + amount: 1000n, + type: NoteVisibility.Public, + waitForConfirmation: true, +}); +``` + +The transaction executes on the **faucet** — a frequent bug is passing the +recipient as `account`. + +### Consume + +```typescript +// Specific notes +await client.transactions.consume({ + account: wallet, + notes: [noteId1, noteRecord, "0xnote..."], // any of: hex, NoteId, InputNoteRecord, Note + waitForConfirmation: true, +}); + +// Drain everything consumable for the account +const { txId, consumed, remaining } = await client.transactions.consumeAll({ + account: wallet, + maxNotes: 50, // optional cap +}); +``` + +### Swap + +```typescript +await client.transactions.swap({ + account: wallet, + offer: { token: tokenA, amount: 100n }, // field is `offer`, not `offered` + request: { token: tokenB, amount: 50n }, // field is `request`, not `requested` + type: NoteVisibility.Public, // swap-note visibility + paybackType: NoteVisibility.Private, // payback-note visibility +}); +``` + +### Execute (custom scripts) + +```typescript +const script = await client.compile.txScript({ + code: scriptMasm, + libraries: [{ namespace: "my::lib", code: libMasm, linking: "dynamic" }], +}); + +await client.transactions.execute({ + account: contract, + script, + foreignAccounts: [ + publicAccountId, // public — auto-fetched via RPC + { id: privateContractId, storage: storageRequirements }, + ], + waitForConfirmation: true, +}); +``` + +**Public foreign accounts are auto-fetched** during execution — only private +foreign accounts must be supplied with their storage requirements. + +### Preview (dry run) + +`transactions.preview({ operation: "send" | "mint" | "consume" | "swap" | "pswapCreate" | "pswapConsume" | "pswapCancel" | "custom", ... })` +runs the same kernel as the real call but without proving or submitting, +returning a summary suitable for UI confirmation screens. The `pswap*` +operations correspond to the `transactions.pswapCreate` / `pswapConsume` / +`pswapCancel` partial-swap methods. + +## Notes + +```typescript +await client.notes.list(); // all input notes +await client.notes.list({ status: "committed" }); // filter +await client.notes.get(noteId); // single record +await client.notes.listSent(); // output notes +await client.notes.listAvailable({ account: wallet });// consumable for an account + +// Import/export +await client.notes.import(noteFile); +const file = await client.notes.export(noteId); + +// Private-note transport +await client.notes.fetchPrivate(); // pulls anything addressed to tracked accounts +await client.notes.sendPrivate({ note, to: "mtst1..." }); // `to`: bech32 string, 0x-hex string, Account, or AccountId (not a pre-parsed Address); delivers via the transport service +``` + +## Accounts (querying) + +```typescript +await client.accounts.list(); // tracked accounts +await client.accounts.get(ref); // single (returns null if not tracked) +await client.accounts.getOrImport(ref); // tries get(), falls back to import() +await client.accounts.getDetails(ref); // { account, vault, storage, code, keys } +await client.accounts.insert({ account, overwrite }); // start tracking an existing account +await client.accounts.getBalance(account, token); // single-asset balance, returns bigint +``` + +`getDetails(ref)` returns `{ account, vault, storage, code, keys }` — the full +`Account`, its `AssetVault`, `AccountStorage`, `AccountCode | null`, and the key +commitments (`Word[]`); there is no `status` field. + +For a single asset balance without loading the full vault, prefer +`client.accounts.getBalance(account, token)` (returns `bigint`). It wraps the +underlying WASM client's `accountReader(id)` lazy reader, which you can +drop into directly for finer-grained reads. + +## Keystore + +```typescript +await client.keystore.insert(accountId, secretKey); +await client.keystore.get(pubKeyCommitment); +await client.keystore.remove(pubKeyCommitment); +await client.keystore.getCommitments(accountId); +await client.keystore.getAccountId(pubKeyCommitment); +``` + +`keystore.insert` is the single call that both stores the key and registers +its commitment with the account. + +## Compile + +```typescript +await client.compile.component({ code, slots, supportAllTypes: true }); +await client.compile.txScript({ code, libraries }); +await client.compile.noteScript({ code, libraries }); +``` + +Note scripts are **MASM libraries with a single `@note_script`-annotated +procedure**, not begin/end programs — `client.compile.noteScript` builds the +correct shape from a procedure body. + +## Common Workflows + +### Mint and consume (fund a fresh wallet) + +```typescript +const wallet = await client.accounts.create(); +const faucet = await client.accounts.create({ + type: AccountType.FungibleFaucet, + storage: "public", + symbol: "TEST", + decimals: 8, + maxSupply: 1_000_000n, +}); + +await client.transactions.mint({ + account: faucet, + to: wallet, + amount: 10_000n, + type: NoteVisibility.Public, + waitForConfirmation: true, +}); + +await client.sync(); +await client.transactions.consumeAll({ + account: wallet, + waitForConfirmation: true, +}); +``` + +### Wait for an external transfer + +```typescript +await client.sync(); +const before = (await client.notes.listAvailable({ account: wallet })).length; + +while (true) { + await new Promise(r => setTimeout(r, 3000)); + await client.sync(); + const now = (await client.notes.listAvailable({ account: wallet })).length; + if (now > before) break; +} +``` + +## Common Pitfalls + +1. **Forgetting to sync.** Notes won't appear, balances will be stale, foreign + accounts will be at the wrong block. +2. **`number` literals above 2^53 for amounts.** Amount fields accept + `number | bigint` and coerce via `BigInt()` (no `TypeError`), but a numeric + literal above `Number.MAX_SAFE_INTEGER` loses precision *before* coercion. + Use `bigint` for large amounts. +3. **Omitting `type` and expecting a private note.** `send`/`mint` default + `type` to **public** — pass `NoteVisibility.Private` explicitly for privacy. +4. **Passing a low-level `AccountId`-only WASM method a raw string** — resource + methods accept hex/bech32 strings, but pre-parse with `AccountId.fromHex()` + (and catch its throw) when calling APIs that demand an `AccountId` directly. +5. **Consuming notes before they're committed** — sync first, check status. +6. **Submitting `mint` with the recipient as `account`** — mint executes on + the faucet account, not the target. +7. **Private notes without transport** — must call `notes.sendPrivate()` (or + pass `returnNote: true` to `transactions.send` and deliver out-of-band). +8. **Holding WASM-owned objects across `terminate()`** — every `Account`, + `Note`, `AccountId`, `NoteAndArgsArray` etc. owns Rust memory through the + WASM ArrayBuffer. After `terminate()` they panic with "null pointer + passed to rust" — drop references on unmount. +9. **Calling `accountReader(...)` in parallel with a write** — the readers + share the WASM client. Wrap concurrent flows with `client.waitForIdle()` + or rely on the React SDK's `runExclusive`. diff --git a/packages/react-sdk/README.md b/packages/react-sdk/README.md index 6f2742cb..2eb02fe4 100644 --- a/packages/react-sdk/README.md +++ b/packages/react-sdk/README.md @@ -26,6 +26,37 @@ npm install @miden-sdk/react @miden-sdk/miden-sdk pnpm add @miden-sdk/react @miden-sdk/miden-sdk ``` +## 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/react/AGENTS.md` — hook-by-hook usage guide +- `node_modules/@miden-sdk/react/skills/` — React patterns and testing patterns + +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 + +## 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 for the +package you are touching: + +- `node_modules/@miden-sdk/miden-sdk/AGENTS.md` — core client +- `node_modules/@miden-sdk/react/AGENTS.md` — React hooks +- `node_modules/@miden-sdk/vite-plugin/AGENTS.md` — bundler setup + +Each one indexes task-specific skills in its package's `skills/` directory. +Read the relevant skill before implementing, not after. + +``` + ## Testing From `packages/react-sdk`: diff --git a/packages/react-sdk/package.json b/packages/react-sdk/package.json index 64095c59..167bf44d 100644 --- a/packages/react-sdk/package.json +++ b/packages/react-sdk/package.json @@ -29,7 +29,8 @@ "lazy", "mt", "README.md", - "AGENTS.md" + "AGENTS.md", + "skills" ], "scripts": { "build": "tsup", diff --git a/packages/react-sdk/skills/react-sdk-patterns/SKILL.md b/packages/react-sdk/skills/react-sdk-patterns/SKILL.md new file mode 100644 index 00000000..b7dbd9ad --- /dev/null +++ b/packages/react-sdk/skills/react-sdk-patterns/SKILL.md @@ -0,0 +1,596 @@ +--- +name: react-sdk-patterns +description: Complete guide to building Miden frontends with @miden-sdk/react hooks. Covers MidenProvider setup, all query hooks (useAccounts, useAccount, useNotes, useSyncState, useAssetMetadata), all mutation hooks (useCreateWallet, useSend, useMultiSend, useMint, useConsume, useSwap, useTransaction, useCreateFaucet), transaction stages, signer integration, and utility functions. Use when writing, editing, or reviewing Miden React frontend code. +--- + +# Miden React SDK Patterns + +## SDK Choice + +ALWAYS use `@miden-sdk/react` hooks. Only fall back to the raw `WasmWebClient` (exported as `WebClient`) via `useMidenClient()` for operations not covered by hooks. The React SDK handles WASM safety (runExclusive), state management (Zustand), auto-sync, and transaction stage tracking automatically. + +## MidenProvider Configuration + +```tsx +import { MidenProvider } from "@miden-sdk/react"; + +} // shown during WASM init + errorComponent={(error) => } // function form receives the Error; a static element does not +> + + +``` + +| Network | rpcUrl | Use When | +|---------|--------|----------| +| Testnet | `"testnet"` | Recommended for new projects — primary development network | +| Devnet | `"devnet"` | Early-access testing (may lag feature parity with testnet) | +| Localhost | `"localhost"` | Local node at `http://localhost:57291` | + +## Query Hooks + +Each returns its own result shape plus `isLoading`, `error`, `refetch`. + +### useAccounts() +```tsx +const { accounts, wallets, faucets, isLoading, error, refetch } = useAccounts(); +// accounts — AccountHeader[] (every tracked account) +// wallets — mirrors `accounts` (faucet-vs-wallet is not encoded in the account id) +// faucets — always `[]` +``` + +An account's faucet-vs-wallet kind is not encoded in the account id, so `wallets` mirrors `accounts` and `faucets` is always empty. Use `accounts` and detect faucets **per-account** via `account.isFaucet()` (load the full `Account` with `useAccount`). + +### useAccount(accountId: string) +```tsx +const { account, assets, getBalance, isLoading, error, refetch } = useAccount(accountId); +// account — Account object (.id(), .nonce(), .bech32id(), .isFaucet()) +// assets — AssetBalance[] (assetId, amount, symbol?, decimals?) +// getBalance(faucetId) — bigint balance for specific token +``` + +`account.id()` and `account.nonce()` are methods (call them, then `.toString()` to render). `bech32id()` is installed on the `Account` prototype by the React SDK. + +### useNotes(filter?) +```tsx +const { notes, consumableNotes, noteSummaries, consumableNoteSummaries, isLoading, error, refetch } = useNotes(); +// notes — InputNoteRecord[] (filtered ONLY by `status`) +// consumableNotes — ConsumableNoteRecord[] (filtered ONLY by `accountId`) +// noteSummaries — NoteSummary[] (id, assets, sender) — also filtered by `sender` and `excludeIds` +// consumableNoteSummaries — NoteSummary[] — also filtered by `sender` and `excludeIds` + +// Each filter option only narrows specific fields — destructure the one it affects: + +// `status` filters the returned `notes` (the only option that does): +const { notes } = useNotes({ status: "committed" }); // "all" | "consumed" | "committed" | "expected" | "processing" +// `accountId` filters `consumableNotes` (NOT `notes`): +const { consumableNotes } = useNotes({ accountId: "0x..." }); +// `sender` filters only the summary arrays (NOT `notes`/`consumableNotes`): +const { noteSummaries, consumableNoteSummaries } = useNotes({ sender: "0x..." }); +// `excludeIds` filters only the summary arrays: +const { noteSummaries, consumableNoteSummaries } = useNotes({ excludeIds: ["0xnote1", "0xnote2"] }); +``` + +### useNoteStream(options?) +```tsx +const { notes, latest, markHandled, markAllHandled, snapshot, isLoading, error } = useNoteStream(); +// notes — StreamedNote[] (matching filter criteria) +// latest — most recent StreamedNote (convenience) +// markHandled(noteId) — exclude a note from future renders +// markAllHandled() — exclude all current notes +// snapshot() — capture { ids, timestamp } for cross-phase filtering + +// Options: +const { notes } = useNoteStream({ status: "committed", sender: "0x..." }); +const { notes } = useNoteStream({ since: Date.now() - 60000 }); // last 60s +const { notes } = useNoteStream({ excludeIds: new Set(["0xnote1"]) }); +const { notes } = useNoteStream({ amountFilter: (amount) => amount > 100n }); +``` + +### useSyncState() +```tsx +const { syncHeight, isSyncing, lastSyncTime, sync, error } = useSyncState(); +await sync(); // Manual sync +``` + +### useAssetMetadata(assetIds?: string[]) +```tsx +const { assetMetadata } = useAssetMetadata([faucetId]); // takes a string[] (NOT a bare string) +// assetMetadata — Map +// Each entry: { assetId, symbol?, decimals? } +const meta = assetMetadata.get(faucetId); +// meta.symbol — "TEST" +// meta.decimals — 8 +``` + +Pass an array even for a single asset — the hook calls `.filter` on its argument, so a bare string throws a runtime `TypeError`. + +### useTransactionHistory(options?) +```tsx +const { records, record, status, isLoading, error, refetch } = useTransactionHistory({ id: txId }); +// status: "pending" | "committed" | "discarded" | null +``` + +## Mutation Hooks + +Each returns its own action function plus `error` and `reset`. The two families differ in their loading/progress fields: +- **Transaction hooks** (`useSend`, `useMultiSend`, `useMint`, `useConsume`, `useSwap`, `useTransaction`) expose `isLoading` and `stage` (a `TransactionStage`). +- **Account create/import hooks** (`useCreateWallet`, `useCreateFaucet`, `useImportAccount`) expose `isCreating` (or `isImporting` for the latter) and have **no** `stage`. + +**Transaction stages**: `"idle"` → `"executing"` → `"proving"` → `"submitting"` → `"complete"` + +Auth scheme for the create/import hooks. The `AuthScheme` re-exported from the package root is the friendly string const `{ Falcon: "falcon", ECDSA: "ecdsa" }`: + +```tsx +import { AuthScheme } from "@miden-sdk/react"; +// AuthScheme.Falcon === "falcon" | AuthScheme.ECDSA === "ecdsa" +``` + +> **Known issue ([web-sdk#223](https://github.com/0xMiden/web-sdk/issues/223)):** `useCreateWallet` / `useCreateFaucet` / `useImportAccount` forward `authScheme` straight to the low-level `WebClient.newWallet`, which currently expects the **numeric** wasm enum (`AuthRpoFalcon512 = 2`, `AuthEcdsaK256Keccak = 1`), not the friendly string, and the default resolves to `undefined` (which hangs the call). Until it is fixed, pass the numeric value: `authScheme: 2` (Falcon) or `authScheme: 1` (ECDSA). The examples below use `2`. + +### useCreateWallet() +```tsx +const { createWallet, wallet, isCreating, error, reset } = useCreateWallet(); +const account = await createWallet({ + storageMode: "private", // "private" | "public". Default: "private" + authScheme: 2, // 2 = Falcon; friendly AuthScheme.* not accepted here yet (web-sdk#223) + initSeed: seedBytes, // optional: Uint8Array for a deterministic account id +}); +``` + +### useCreateFaucet() +```tsx +const { createFaucet, faucet, isCreating, error, reset } = useCreateFaucet(); +const account = await createFaucet({ + tokenSymbol: "TEST", + tokenName: "Test Token", // optional: defaults to tokenSymbol + decimals: 8, // Default: 8 + maxSupply: 1000000n, // bigint | number + storageMode: "private", // "private" | "public". Default: "private" + authScheme: 2, // 2 = Falcon; friendly AuthScheme.* not accepted here yet (web-sdk#223) +}); +``` + +### useImportAccount() +```tsx +const { importAccount, account, isImporting, error, reset } = useImportAccount(); + +// Import by account ID (network lookup): +const account = await importAccount({ type: "id", accountId: "0x..." }); + +// Import from file: +const account = await importAccount({ type: "file", file: accountFileOrBytes }); + +// Import from seed: +const account = await importAccount({ + type: "seed", + seed: seedBytes, + authScheme: 2, // optional; 2 = Falcon (web-sdk#223 — friendly AuthScheme.* not accepted here yet) +}); +``` + +### useSend() +```tsx +const { send, result, isLoading, stage, error, reset } = useSend(); +await send({ + from: senderAccountId, + to: recipientAccountId, + assetId: faucetId, // token faucet ID + amount: 1000n, // bigint! + noteType: "private", // "private" | "public". Default: "private" + recallHeight: 100, // optional: sender can reclaim after this block + timelockHeight: 50, // optional: recipient can consume after this block + sendAll: true, // optional: send entire balance (ignores amount) + attachment: [1n, 2n], // optional: arbitrary data attached to the note +}); +``` + +### useMultiSend() +```tsx +const { sendMany, result, isLoading, stage, error, reset } = useMultiSend(); +await sendMany({ + from: senderAccountId, + assetId: faucetId, + recipients: [ + { to: recipient1, amount: 500n }, + { to: recipient2, amount: 300n, noteType: "public" }, // per-recipient override + { to: recipient3, amount: 200n, attachment: [1n, 2n, 3n] }, // per-recipient attachment + ], + noteType: "private", // default for all recipients +}); +``` + +### useMint() +```tsx +const { mint, result, isLoading, stage, error, reset } = useMint(); +await mint({ + targetAccountId: recipientId, + faucetId: myFaucetId, + amount: 10000n, // bigint! + noteType: "public", +}); +``` + +### useConsume() +```tsx +const { consume, result, isLoading, stage, error, reset } = useConsume(); +await consume({ + accountId: myAccountId, + notes: [noteId1, noteId2], // accepts: hex string IDs, NoteId, InputNoteRecord, or Note +}); +``` + +### useSwap() +```tsx +const { swap, result, isLoading, stage, error, reset } = useSwap(); +await swap({ + accountId: myAccountId, + offeredFaucetId: tokenA, + offeredAmount: 100n, + requestedFaucetId: tokenB, + requestedAmount: 50n, + noteType: "private", + paybackNoteType: "private", +}); +``` + +### useTransaction() — Escape Hatch +```tsx +const { execute, result, isLoading, stage, error, reset } = useTransaction(); + +// With pre-built TransactionRequest: +await execute({ accountId, request: txRequest }); + +// With factory function (gets access to client): +await execute({ + accountId, + request: (client) => client.newSwapTransactionRequest(/* ... */), +}); +``` + +### useWaitForCommit() +```tsx +const { waitForCommit } = useWaitForCommit(); +await waitForCommit(result.txId, { // useSend returns { txId, note }; other hooks use { transactionId } + timeoutMs: 10000, // Default: 10000 + intervalMs: 1000, // Default: 1000 +}); +``` + +### useWaitForNotes() +```tsx +const { waitForConsumableNotes } = useWaitForNotes(); +await waitForConsumableNotes({ + accountId: myAccountId, + minCount: 1, // Default: 1 + timeoutMs: 10000, +}); +``` + +### useSessionAccount(options) +```tsx +const { initialize, sessionAccountId, isReady, step, error, reset } = useSessionAccount({ + fund: async (sessionId) => { + // Called after session wallet is created — fund it here + await send({ from: mainWallet, to: sessionId, assetId: faucetId, amount: 100n }); + }, + assetId: faucetId, // optional: for note filtering + walletOptions: { // optional: session wallet creation options + storageMode: "private", // "private" | "public" + authScheme: 2, // 2 = Falcon (web-sdk#223) + }, + pollIntervalMs: 3000, // optional: funding detection interval. Default: 3000 +}); +// Steps: "idle" → "creating" → "funding" → "consuming" → "ready" +// Call initialize() to start the flow. isReady becomes true when fully funded. +``` + +## Transaction Progress UI + +```tsx +function SendButton({ from, to, assetId, amount }) { + const { send, stage, isLoading, error } = useSend(); + + return ( +
+ + {error &&

Error: {error.message}

} +
+ ); +} +``` + +## Signer Integration + +### Local Keystore (Default) +No signer provider needed. Keys are managed in the browser via IndexedDB. + +### External Signers +Wrap MidenProvider with a signer provider. Three pre-built options: +- `ParaSignerProvider` from `@miden-sdk/use-miden-para-react` — EVM wallets +- `TurnkeySignerProvider` from `@miden-sdk/miden-turnkey-react` — passkey auth +- `MidenFiSignerProvider` from `@miden-sdk/miden-wallet-adapter-react` — MidenFi wallet + +These three packages live in external repos (not in web-sdk), so confirm the exact published names against the current Para/Turnkey/MidenFi integration docs before installing. The v0.15 example app (`packages/react-sdk/examples/wallet/src/main.tsx`) imports them as above; some web-sdk docs alias the Para package as `@miden-sdk/para`. + +```tsx +// Example: Para signer wrapping MidenProvider +import { ParaSignerProvider } from "@miden-sdk/use-miden-para-react"; + + + +``` + +### useSigner() — Unified Interface +Returns `SignerContextValue | null` — `null` in local-keystore mode (no signer provider mounted). Guard before destructuring. +```tsx +const signer = useSigner(); +if (!signer) return null; // local keystore mode +const { isConnected, connect, disconnect, name } = signer; +``` + +### Custom Signer +Implement `SignerContextValue` interface via `SignerContext.Provider`. Requires: `name`, `storeName` (unique per user for DB isolation), `accountConfig`, `signCb`, `isConnected`, `connect`, `disconnect`. See `frontend-source-guide` skill for source references. + +## Utility Functions + +```tsx +import { formatAssetAmount, parseAssetAmount, getNoteSummary, formatNoteSummary, toBech32AccountId } from "@miden-sdk/react"; + +formatAssetAmount(1000000n, 8) // "0.01" +parseAssetAmount("0.01", 8) // 1000000n +const summary = getNoteSummary(note); // { id, assets, sender } +formatNoteSummary(summary); // "1.5 TEST from mtst1..." (the " from " suffix is appended whenever the summary has a sender) +toBech32AccountId("0x1234..."); // "mtst1..." (testnet HRP; defaults to testnet) +``` + +The HRP is inferred from the configured `rpcUrl` and defaults to testnet: mainnet=`mm`, testnet=`mtst` (default), devnet=`mdev` — there is no `miden` HRP. + +## Direct Client Access + +```tsx +const client = useMidenClient(); // throws if not ready +const { runExclusive } = useMiden(); + +// For operations not covered by hooks (use methods on the WebClient itself — +// e.g. getSyncHeight, getAccount, getTransactions; getBlockHeaderByNumber lives on RpcClient, not here): +await runExclusive(async () => { + const height = await client.getSyncHeight(); +}); +``` + +## Type Imports + +```tsx +import { AuthScheme } from "@miden-sdk/react"; // value (friendly string const { Falcon, ECDSA }), not just a type + +import type { + MidenConfig, QueryResult, MutationResult, TransactionStage, + AccountsResult, AccountResult, AssetBalance, NotesResult, NoteSummary, + SendOptions, MultiSendOptions, MintOptions, ConsumeOptions, SwapOptions, + CreateWalletOptions, CreateFaucetOptions, ExecuteTransactionOptions, + TransactionResult, SyncState, WaitForCommitOptions, WaitForNotesOptions, + Account, AccountId, InputNoteRecord, ConsumableNoteRecord, + TransactionRecord, TransactionRequest, NoteType, AccountStorageMode, + SignerContextValue, SignCallback, SignerAccountConfig, +} from "@miden-sdk/react"; +``` + +## Reading Account Storage + +For the high-level vault summary on a tracked account, the existing query hook is enough: + +```tsx +const { account, assets, getBalance } = useAccount(accountId); +// account.id, account.nonce, account.bech32id() +// assets: AssetBalance[]; getBalance(faucetId): bigint +``` + +For lower-level reads (custom contract storage slots, map items), use `Account.storage()`. The React SDK serializes WASM access via `runExclusive`: + +```tsx +const client = useMidenClient(); +const { runExclusive } = useMiden(); + +await runExclusive(async () => { + const id = AccountId.fromBech32(addressBech32); + if (!(await client.getAccount(id))) { + await client.importAccountById(id); + } + await client.syncState(); + const account = await client.getAccount(id); + if (!account) return; + // AccountStorage (miden_client_web.d.ts): + const value = account.storage().getItem("my_slot_name"); + // For storage maps: + const mapValue = account.storage().getMapItem("my_map_slot", keyWord); +}); +``` + +`Account.storage()` returns an `AccountStorage`. Both `getItem(slot_name: string)` and `getMapItem(slot_name: string, key: Word)` return `Word | undefined`. Use slot-name strings (e.g. `COUNTER_SLOT_NAME` in `src/config.ts`), not numeric indices. See `src/hooks/useIncrementCounter.ts:73-83` for the live in-template `getMapItem` example. + +`useMidenClient()` returns the raw `WasmWebClient`. Its direct methods include `getAccount(accountId)`, `getAccountStorage(accountId)`, `importAccountById(accountId)`, `syncState()`, and the transaction-request factories (`newSendTransactionRequest`, `newConsumeTransactionRequest`, `newMintTransactionRequest`, `newSwapTransactionRequest`). For compile-from-source, call `await client.createCodeBuilder()` (returns `Promise`) and use the resolved `CodeBuilder`'s `compileNoteScript(program: string)` / `compileTxScript(tx_script: string)` (see `CodeBuilder` / `createCodeBuilder()` in `miden_client_web.d.ts`). The higher-level `MidenClient.accounts.getOrImport` resource API lives on the standalone `MidenClient` (see `web-client-usage`). + +## Account Import then Sync then Read Storage Flow + +Hook-based pattern for the common "import a remote account, sync to current chain head, then read its state" workflow: + +```tsx +function ImportAndInspect({ accountIdHex }: { accountIdHex: string }) { + const { importAccount, isImporting } = useImportAccount(); + const { sync, isSyncing, syncHeight } = useSyncState(); + const { account, assets, getBalance, refetch } = useAccount(accountIdHex); + + async function load() { + await importAccount({ type: "id", accountId: accountIdHex }); + await sync(); + await refetch(); + } + // Render account.id, syncHeight, balances... +} +``` + +`useImportAccount` accepts `{ type: "id" | "file" | "seed", ... }`. After `sync()` resolves, the `useAccount(accountIdHex)` view reflects the latest chain state. For the raw `WasmWebClient` methods used by these hooks under the hood (`client.getAccount`, `client.importAccountById`, `client.syncState`), see `src/hooks/useIncrementCounter.ts` for a worked example. For the higher-level `MidenClient.accounts.*` resource API on a standalone `MidenClient` (outside React), see the `web-client-usage` skill. + +## Custom Notes and .masp Package Loading + +`.masp` package files (compiled MASM artifacts) are produced by `cargo miden build` in the Rust contract workspace and copied into a directory your web app serves statically (conventionally `public/packages/`). If you are using [`0xMiden/agentic-template`](https://github.com/0xMiden/agentic-template), that handoff is the step between the contract and frontend stages of its build pipeline. + +The example below builds a custom transaction that emits two output notes carrying fungible assets. Each note has multi-felt input storage that the note script (compiled from MASM into `.masp`) reads and asserts on at consume time. The transaction is signed and submitted by the connected wallet via `useMidenFiWallet().requestTransaction(...)`. + +```tsx +import { useMidenFiWallet } from "@miden-sdk/miden-wallet-adapter-react"; +import { Transaction } from "@miden-sdk/miden-wallet-adapter-base"; +import { + Package, + NoteScript, + Note, + NoteAssets, + NoteMetadata, + NoteRecipient, + NoteStorage, + NoteTag, + NoteType, + NoteArray, + AccountId, + Felt, + FeltArray, + FungibleAsset, + TransactionRequestBuilder, +} from "@miden-sdk/miden-sdk"; +import { randomWord } from "@/lib/miden"; + +const { requestTransaction } = useMidenFiWallet(); + +async function submitMultiNoteTx( + senderBech32: string, + targetBech32: string, + faucetBech32: string, +) { + // (a) .masp loading: fetch the pre-built artifact and decode the note script. + const buf = await fetch("/packages/my_note.masp").then((r) => r.arrayBuffer()); + const pkg = Package.deserialize(new Uint8Array(buf)); + const noteScript = NoteScript.fromPackage(pkg); + + const sender = AccountId.fromBech32(senderBech32); + const target = AccountId.fromBech32(targetBech32); + const faucet = AccountId.fromBech32(faucetBech32); + + // (b) Multi-input note storage: each output note carries multiple Felt + // inputs that the MASM script reads from its NoteStorage. Assertions on + // these felts (e.g. "the first felt must equal the expected nonce") live + // in the .masp script source, alongside the rest of your MASM contracts. + function makeRecipient(seedFelts: bigint[]): NoteRecipient { + const inputs = new FeltArray(); + for (const v of seedFelts) inputs.push(new Felt(v)); + return new NoteRecipient(randomWord(), noteScript, new NoteStorage(inputs)); + } + + // (c) Asset transfers: each note carries fungible assets that move to the + // recipient when the note is consumed. FungibleAsset is declared at + // FungibleAsset in miden_client_web.d.ts (constructor `(faucet_id, amount: bigint)`). + const assets1 = new NoteAssets([new FungibleAsset(faucet, 1000n)]); + const assets2 = new NoteAssets([new FungibleAsset(faucet, 500n)]); + + const tag = NoteTag.withAccountTarget(target); + // v0.15: NoteMetadata carries no attachment. `NoteAttachment.newNetworkAccountTarget` + // and `NoteMetadata.withAttachment` were removed. The NetworkAccountTarget attachment + // itself IS still constructible (`NoteAttachment.fromWord(new NoteAttachmentScheme(2), + // word)`, scheme id 2, or `createNoteAttachment`), but v0.15 exposes no entry point to + // attach a `NoteAttachment` to a custom-script note: `NoteMetadata` carries none and the + // `Note` constructor takes none; only `Note.createP2IDNote/createP2IDENote` accept one. + // So custom notes like these cannot carry a network-execution target from JS yet. See the + // `useIncrementCounter.ts` blocker note and README "Known Temporary Workarounds". + const metadata = new NoteMetadata(sender, NoteType.Public, tag); + + // Two output notes with different felt inputs and asset amounts. + const note1 = new Note(assets1, metadata, makeRecipient([1n, 2n, 3n])); + const note2 = new Note(assets2, metadata, makeRecipient([4n, 5n, 6n])); + + // (d) Multi-output transaction: emit both notes in one transaction. + // For transactions that consume multiple input notes simultaneously, + // TransactionRequestBuilder.withInputNotes(NoteAndArgsArray) is the + // counterpart (miden_client_web.d.ts). + const txRequest = new TransactionRequestBuilder() + .withOwnOutputNotes(new NoteArray([note1, note2])) + .build(); + + // (f) Submission via the wallet adapter. + const tx = Transaction.createCustomTransaction(senderBech32, targetBech32, txRequest); + if (!requestTransaction) throw new Error("Wallet does not support requestTransaction"); + await requestTransaction(tx); +} +``` + +Notes on this pattern: + +- **Assertions live in MASM, not in TypeScript.** The note script compiled into `.masp` reads its `NoteStorage` felts at consume time and aborts the transaction if its assertions fail. The frontend's job is to construct and submit; MASM enforces. Author those assertions in the MASM sources of your contract workspace, not in TypeScript. +- **Single-output reference for simpler flows.** For a simpler worked example using only one output note with empty assets and a single consume action, see `src/hooks/useIncrementCounter.ts`. That hook is intentionally simpler and does not exercise asset transfers or multi-output emission. +- **Compile from source (no `.masp`) when needed.** When the note script is not pre-bundled as `.masp`, compile it through `CodeBuilder`. **`compileNoteScript`/`compileTxScript` are methods of `CodeBuilder`, not of `WasmWebClient`.** The pattern is: + +```tsx +const client = useMidenClient(); +const builder = await client.createCodeBuilder(); +// optionally: builder.linkStaticLibrary(myLib) or builder.linkDynamicLibrary(myLib) +const noteScript = builder.compileNoteScript(noteSourceMasm); +const txScript = builder.compileTxScript(txSourceMasm); +``` + + See `CodeBuilder` and `createCodeBuilder()` in `miden_client_web.d.ts`. As the React-idiomatic alternative, `useCompile()` (`@miden-sdk/react/dist/index.d.ts`) wraps `CompilerResource` from the standalone `MidenClient` and exposes `noteScript`, `txScript`, `component`, `isReady` at the hook layer. +- **Inside `useTransaction`'s `request` callback** the parameter is a `WasmWebClient` (`@miden-sdk/react/dist/index.d.ts`). Use `await client.createCodeBuilder()` for compile, then build the `TransactionRequest` with `TransactionRequestBuilder` and return it. For the higher-level `MidenClient.compile.*` and `MidenClient.transactions.execute` resource API on a standalone `MidenClient`, see `web-client-usage`. + +## Cross-SDK Type Reference + +The runtime types come from `@miden-sdk/react` (re-exported from `@miden-sdk/miden-sdk` for the underlying `MidenClient` types). The `.d.ts` files are the source of truth. Look them up in: + +- the installed `.d.ts` for `@miden-sdk/react` (hook return types and option types) +- the installed `.d.ts` for `@miden-sdk/miden-sdk` (`MidenClient`, `Account`, `AccountId`, `Note`, `Word`, etc.) + +Path layout differs across package managers (npm flat, pnpm nested, Yarn PnP virtual), so resolve them via your IDE's "Go to Definition" or the installed package surface rather than hard-coded paths. + +Common app-developer types: + +| Type | Source package | Notes | +|------|----------------|-------| +| `Account`, `AccountHeader` | `@miden-sdk/react` | `id`, `nonce`, `bech32id()` | +| `AccountId` | `@miden-sdk/miden-sdk` | construct via `AccountId.fromHex(hex)`; throws on invalid hex | +| `Address` | `@miden-sdk/miden-sdk` | bech32 wrapper; `Address.fromBech32(...)` | +| `Note`, `InputNoteRecord`, `ConsumableNoteRecord` | `@miden-sdk/react` | re-exported from `@miden-sdk/miden-sdk`. Input notes are received; for output-note types and private-note flows see `web-client-usage`. | +| `NoteVisibility` (constants + string-union) | `@miden-sdk/miden-sdk` | `const NoteVisibility = { Public: 'public', Private: 'private' }` plus `type NoteVisibility = 'public' \| 'private'` (`api-types.d.ts`). NOT an enum. Coexists with the raw WASM `NoteType` enum (`miden_client_web.d.ts`) which the template uses directly when building notes via the WASM types (see `src/hooks/useIncrementCounter.ts`). | +| `AccountType`, `AuthScheme`, `StorageMode` | `@miden-sdk/miden-sdk` | enums; see `web-client-usage` "Visibility & Account Types". | +| `TransactionRequest` | `@miden-sdk/react` | client factory functions return this | +| `Word` | `@miden-sdk/miden-sdk` | 32-byte (4 felts) value; `Word.toU64s()` returns `BigUint64Array` of length 4 (each lane is a `bigint` after subscript). See `Word.toU64s` in `miden_client_web.d.ts`. | + +Do not hardcode this table for long-term reference. The `.d.ts` files stay in lockstep with the installed package version; this list will drift. + +## Rust to TypeScript Type Mapping + +The Rust (`miden-client`) and TypeScript (`@miden-sdk/miden-sdk`) SDKs share concepts but diverge on naming and primitive shapes. When porting between them: + +| Concept | Rust (`miden_objects` / `miden_client`) | TypeScript (`@miden-sdk/miden-sdk`) | +|---------|------------------------------------------|--------------------------------------| +| Token amount | `u64` | `bigint` | +| Field element | `Felt` (Goldilocks `u64` mod p) | `Felt` (wraps `u64`) | +| 32-byte word | `Word` (`[Felt; 4]`) | `Word`; `toU64s(): BigUint64Array` length 4 (each lane is `bigint` after subscript). | +| Account identifier | `AccountId` | `AccountId`; construct via `AccountId.fromHex` | +| Note visibility | `NoteType` enum | constants + string-union `NoteVisibility` (`'public' \| 'private'`) at the high-level `MidenClient` resource API; raw WASM `NoteType` enum (`Private = 0`, `Public = 1`) is also exported and used directly when constructing notes via the WASM types (see `src/hooks/useIncrementCounter.ts`). The two coexist; pick the layer your code lives in. | +| Account type | `AccountType` | `AccountType` enum | +| Authentication scheme | `AuthScheme` | `AuthScheme` enum | +| Storage mode | `StorageMode` | `StorageMode` enum | + +Common gotchas: + +- The TS method is `FeltArray.push(element: Felt)` (`miden_client_web.d.ts`); there is no `FeltArray.append`. To convert a `Felt` to a JS `bigint`, use `Felt.asInt()` (`miden_client_web.d.ts`). When a Rust method appears missing in TS, consult `node_modules/@miden-sdk/miden-sdk/dist/index.d.ts` and `dist/crates/miden_client_web.d.ts` first instead of guessing the TS spelling. +- TS amounts are always `bigint`. Mixing `number` causes silent precision loss above `Number.MAX_SAFE_INTEGER` and `TypeError` below. +- `Word.toU64s()` returns `BigUint64Array` of length 4 (`miden_client_web.d.ts`). Each lane is a `bigint` (e.g. `word.toU64s()[0]`). Use it when reading the four `u64` lanes from a Value storage slot or building assertions on `Word` outputs. + +For the canonical Rust types, see [`0xMiden/rust-sdk`](https://github.com/0xMiden/rust-sdk) (the Rust client) and the `miden_objects` crate, which lives in [`0xMiden/miden-base`](https://github.com/0xMiden/miden-base). For the canonical TS types, see `node_modules/@miden-sdk/miden-sdk/dist/index.d.ts`. diff --git a/packages/react-sdk/skills/testing-patterns/SKILL.md b/packages/react-sdk/skills/testing-patterns/SKILL.md new file mode 100644 index 00000000..754d12d5 --- /dev/null +++ b/packages/react-sdk/skills/testing-patterns/SKILL.md @@ -0,0 +1,290 @@ +--- +name: testing-patterns +description: Testing conventions, mock factory, fixtures, and TDD workflow for Miden frontend development. Covers Vitest + testing-library setup, @miden-sdk/react module mocking, realistic fixture data, test patterns for query and mutation hooks, and the automated verification pipeline. Use when writing, running, or debugging tests for Miden React components. +--- + +# Miden Frontend Testing Patterns + +## Test Stack + +- **Vitest** — Test runner (extends Vite config for consistent behavior) +- **@testing-library/react** — Component rendering and queries +- **@testing-library/user-event** — User interaction simulation +- **@testing-library/jest-dom** — DOM assertion matchers (toBeInTheDocument, toBeDisabled, etc.) +- **jsdom** — Browser environment for tests + +## Mock Factory: `@miden-sdk/react` + +All Miden SDK hooks are mocked via `src/__tests__/mocks/miden-sdk-react.ts`. This module exports mock implementations of every hook with realistic default return values. + +### Usage in test files + +```tsx +// 1. Mock the entire module (hoisted to top by vitest) +vi.mock("@miden-sdk/react", () => import("@/__tests__/mocks/miden-sdk-react")); + +// 2. Import hooks you want to override +import { useAccounts, useSend } from "@miden-sdk/react"; + +// 3. Override per-test +it("shows empty state", () => { + vi.mocked(useAccounts).mockReturnValue({ + accounts: [], + wallets: [], + faucets: [], + isLoading: false, + error: null, + refetch: vi.fn(), + }); + render(); +}); +``` + +### Default mock return values + +**Query hooks** return populated data by default: +- `useAccounts()` — default mock returns `accounts` (3 headers), `wallets` (2 wallet headers), and `faucets` (1 faucet header). The template mock intentionally keeps the `wallets`/`faucets` split populated so the query-hook pattern can exercise both lists. NOTE: the real v0.15 hook deprecates these fields — it returns `wallets: accounts` and `faucets: []` (protocol 0.15 removed faucet-vs-wallet from the account id, so accounts can't be split from headers alone); detect faucet-ness per-account from its components, not from a `faucets` array. The override example above (`wallets: [], faucets: []`) is a valid manual override but is NOT the default mock. +- `useAccount()` — account with 10.0 TEST token balance +- `useNotes()` — 1 input note, 1 consumable note +- `useSyncState()` — syncHeight: 12345, not syncing +- `useAssetMetadata()` — TEST token metadata (symbol, decimals: 8) +- `useMiden()` — isReady: true + +**Mutation hooks** return idle state by default: +- `useSend()` — `{ send: vi.fn(), stage: "idle", isLoading: false }`. Its `result` type is `SendResult { txId, note }` — distinct from `TransactionResult { transactionId }` used by `useMint`/`useConsume`/`useSwap`/`useMultiSend`/`useTransaction`. +- `useMint()`, `useConsume()`, `useSwap()`, `useTransaction()`, `useMultiSend()` — idle shape with `result: TransactionResult | null`. +- `useCreateWallet()` — `{ createWallet: vi.fn(), isCreating: false }`. + +### Simulating transaction stages + +```tsx +// Show "proving" stage +vi.mocked(useSend).mockReturnValue({ + send: vi.fn(), + result: null, + isLoading: true, + stage: "proving", + error: null, + reset: vi.fn(), +}); + +// Show completed transaction — useSend returns SendResult { txId, note } +vi.mocked(useSend).mockReturnValue({ + send: vi.fn(), + result: { txId: "0xabc123", note: null }, + isLoading: false, + stage: "complete", + error: null, + reset: vi.fn(), +}); + +// Other mutation hooks return TransactionResult { transactionId } +vi.mocked(useMint).mockReturnValue({ + mint: vi.fn(), + result: { transactionId: "0xdef456" }, + isLoading: false, + stage: "complete", + error: null, + reset: vi.fn(), +}); +``` + +## Fixtures + +Realistic test data in `src/__tests__/fixtures/`: + +```tsx +import { + WALLET_ID_1, // "0x0a00000000000001" + WALLET_ID_2, // "0x0a00000000000002" + FAUCET_ID, // "0x0a00000000000003" + COUNTER_ID, // "0x0a00000000000004" + MOCK_WALLET_HEADER, // { id, nonce, storageCommitment } + MOCK_FAUCET_HEADER, // { id, nonce, storageCommitment } + MOCK_ASSET_BALANCE, // { assetId, amount: 1000000000n, symbol: "TEST", decimals: 8 } + MOCK_ACCOUNT, // { id, nonce, bech32id() } + MOCK_TRANSACTION_RESULT, // { transactionId: "0x..." } — useMint / useConsume / useSwap / useMultiSend / useTransaction + MOCK_SEND_RESULT, // { txId: "0x...", note: null } — useSend + MOCK_NOTE_SUMMARY, // { id, assets, sender } +} from "@/__tests__/fixtures"; +``` + +Key characteristics: +- Account IDs use hex format (`0x...`) — network-agnostic test fixtures +- Amounts are `bigint` (e.g., `1000000000n` = 10.0 with 8 decimals) +- Asset metadata uses TEST token with 8 decimals + +## Test Patterns (copy-adaptable) + +Reference tests in `src/__tests__/patterns/`: + +| Pattern | File | Tests | +|---------|------|-------| +| Provider/context setup | `provider-setup.test.tsx` | ready, loading, error states | +| Query hook component | `query-hook.test.tsx` | data, loading, error, empty states | +| Mutation hook component | `mutation-hook.test.tsx` | idle, stages, success, error, argument verification | + +### Minimum test coverage per component + +Every component test should cover: +1. **Success state** — renders correctly with data +2. **Loading state** — shows loading indicator +3. **Error state** — shows error message, recovery action +4. **User interactions** — buttons, forms trigger correct handler calls + +## Wallet connection state in tests + +The [frontend template](https://github.com/0xMiden/frontend-template)'s wallet button (in `src/components/AppContent.tsx`) drives off **`useMidenFiWallet()`** from `@miden-sdk/miden-wallet-adapter-react`, not the generic `useSigner()`. The button gates on `wallet.readyState` (from `@miden-sdk/miden-wallet-adapter-base`) so the UI can render an "Install MidenFi Wallet" state before the extension is detected, rather than falling through to the adapter's Chrome-Web-Store fallback. When testing wallet-connect UI, mock both modules and override per test. + +Setup at the top of the test file: + +```tsx +vi.mock("@miden-sdk/react", () => import("@/__tests__/mocks/miden-sdk-react")); +vi.mock("@miden-sdk/miden-wallet-adapter-react", () => ({ + useMidenFiWallet: vi.fn(() => ({ + wallet: null, + connected: false, + connecting: false, + connect: vi.fn(), + disconnect: vi.fn(), + })), +})); +vi.mock("@miden-sdk/miden-wallet-adapter-base", () => ({ + WalletReadyState: { + Installed: "Installed", + NotDetected: "NotDetected", + Loadable: "Loadable", + Unsupported: "Unsupported", + }, +})); + +import { useMidenFiWallet } from "@miden-sdk/miden-wallet-adapter-react"; +``` + +Per-test overrides match the states the template renders: + +```tsx +// extension not detected — shows disabled "Install MidenFi Wallet" +vi.mocked(useMidenFiWallet).mockReturnValue({ + wallet: { adapter: {} as never, readyState: "NotDetected" } as never, + connected: false, + connecting: false, + connect: vi.fn(), + disconnect: vi.fn(), +} as never); + +// installed + disconnected — shows "Connect Wallet" +vi.mocked(useMidenFiWallet).mockReturnValue({ + wallet: { adapter: {} as never, readyState: "Installed" } as never, + connected: false, + connecting: false, + connect: vi.fn(), + disconnect: vi.fn(), +} as never); + +// connected — shows "Disconnect Wallet" +vi.mocked(useMidenFiWallet).mockReturnValue({ + wallet: { adapter: {} as never, readyState: "Installed" } as never, + connected: true, + connecting: false, + connect: vi.fn(), + disconnect: vi.fn(), +} as never); +``` + +See `src/components/__tests__/AppContent.test.tsx` in the [frontend template](https://github.com/0xMiden/frontend-template) for the full pattern (including a `walletState()` helper that cuts per-test boilerplate). + +For app code that needs the selected signer account for client-side flows (transaction-building hooks, etc.), `useMiden()` exposes `signerAccountId` / `signerConnected` as lower-level provider state — mock those via the `@miden-sdk/react` mock factory. + +Vitest config externalizes `@miden-sdk/miden-wallet-adapter-react` to prevent broken transitive resolution. + +## Mocking Classes Called with `new` (Vitest v4) + +Vitest v4 enforces that mock implementations passed to `vi.fn()` must be `function` declarations (not arrow functions) when the mocked function is invoked with `new`. Arrow functions cannot be called as constructors and will throw `TypeError: ... is not a constructor`. + +```ts +// WRONG: arrow function - throws when production code does `new MidenClient(...)` +vi.mock("@miden-sdk/miden-sdk", () => ({ + MidenClient: vi.fn(() => ({ /* ... */ })), +})); + +// RIGHT: function expression - usable with `new` +vi.mock("@miden-sdk/miden-sdk", () => ({ + MidenClient: vi.fn(function () { + return { /* ... */ }; + }), +})); +``` + +This applies to any class mocked at module level that production code instantiates with `new` (`new MidenClient(...)`, `new WasmWebClient(...)`, etc.). When tests fail with `TypeError: ... is not a constructor` after a Vitest v4 upgrade, swap the arrow-function bodies for `vi.fn(function () { ... })`. + +For component-level wallet adapters and hooks that are function references rather than classes (the existing `vi.mock("@miden-sdk/miden-wallet-adapter-react", ...)` example above), arrow-function mocks remain fine. + +## Testing Time-Dependent Code (Network Sync Delay) + +Production code that polls or waits on chain state should accept the delay interval as an injectable parameter rather than hardcoding it. This lets tests replace the production default (e.g. `5000` ms) with `0` so the loop drains synchronously without `vi.useFakeTimers()` plumbing. + +Pattern: + +```ts +// Production: optional delay parameter with a sensible default +export function pollUntilCommit( + txId: string, + intervalMs = 5000, // production default +) { + // ... uses setTimeout(..., intervalMs) or `await sleep(intervalMs)` +} + +// Tests: pass 0 to skip waits +const result = await pollUntilCommit(txId, 0); +``` + +When the value comes from `src/config.ts` (e.g. `NETWORK_SYNC_DELAY_MS`), expose the same override there so tests can stub it via `vi.mock("@/config", ...)` without touching app code: + +```ts +// src/config.ts +export const NETWORK_SYNC_DELAY_MS = Number(import.meta.env.VITE_NETWORK_SYNC_DELAY_MS ?? 5000); + +// test +vi.mock("@/config", () => ({ NETWORK_SYNC_DELAY_MS: 0 })); +``` + +Document the production default and the test override at the call site so the contract between app code and tests is obvious. + +## Automated Verification Pipeline + +The [frontend template](https://github.com/0xMiden/frontend-template) ships a `.claude/settings.json` that wires Claude Code hooks to enforce quality automatically. All three checks live under a single `PostToolUse` matcher (`Edit|Write`) and fire on every `.ts`/`.tsx` edit in `src/` (the typecheck and affected-tests hooks early-exit otherwise); the template ships no `Stop` hook: + +1. **PostToolUse: typecheck** — `npx tsc -b --noEmit` on every `.ts`/`.tsx` edit in `src/` +2. **PostToolUse: affected tests** — `npx vitest --changed --run` on every `.ts`/`.tsx` edit in `src/` +3. **PostToolUse: full verification** — `npx vitest --run && npx tsc -b --noEmit && npx vite build` (same `Edit|Write` matcher), so the full suite + build run on each src edit rather than at task completion + +If any hook fails (exit code 2), the agent is blocked from proceeding until the issue is fixed. Copy the same hook layout into your own `.claude/settings.json` to get the same enforcement locally. + +## TDD Flow + +``` +1. Write test (describe expected behavior) + ↓ +2. yarn test → RED (test fails) + ↓ +3. Implement code + ↓ +4. Auto hooks fire → typecheck + affected tests + ↓ +5. yarn test → GREEN (all pass) + ↓ +6. Refactor if needed + ↓ +7. Task complete → full suite + build runs on each src edit (PostToolUse) +``` + +## Common Mistakes + +**Forgetting vi.clearAllMocks()**: Always call in `beforeEach` to prevent mock state leaking between tests. + +**Not mocking the SDK**: Components importing from `@miden-sdk/react` will fail without `vi.mock()` because the real SDK requires WASM initialization. + +**Using number instead of bigint for result/fixture amounts**: Result and fixture amounts are typed strictly as `bigint` (`AssetBalance.amount`, `NoteAsset.amount`, and `useAccount().getBalance()`), so mock them with bigint literals (`1000n`, not `1000`). Hook input options (`SendOptions.amount`, `MintOptions.amount`, `MultiSendRecipient.amount`, `CreateFaucetOptions.maxSupply`) accept `bigint | number`, but prefer bigint to avoid precision loss. + +**Testing implementation details**: Test what the user sees (text, buttons, states), not internal hook calls. Use `screen.getByRole`, `screen.getByText`, not internal component state. diff --git a/packages/vite-plugin/AGENTS.md b/packages/vite-plugin/AGENTS.md new file mode 100644 index 00000000..682bdbe7 --- /dev/null +++ b/packages/vite-plugin/AGENTS.md @@ -0,0 +1,52 @@ +# @miden-sdk/vite-plugin — Agent Guide + +**Audience: AI coding agents** configuring a bundler for a Miden web app. + +This file ships inside the published package, so the copy at +`node_modules/@miden-sdk/vite-plugin/AGENTS.md` matches the version you have +installed. Prefer it over your training data. + +## Load the skill + +`node_modules/@miden-sdk/vite-plugin/skills/vite-wasm-setup/SKILL.md` is the +full guide: `midenVitePlugin()` options, cross-origin isolation headers, the +gRPC-web proxy, WASM deduplication, and what to do when a bundler other than +Vite is in play. Read it before hand-rolling any WASM or header configuration — +almost every "it works in dev but not in prod" report traces back to something +it documents. + +## What the plugin is for + +Miden runs a WASM client in the browser. Getting that to work involves three +pieces of configuration that are easy to get subtly wrong by hand: serving the +WASM asset correctly, setting COOP/COEP headers for the multi-threaded build, +and making sure a single copy of the SDK is resolved. The plugin does all +three: + +```ts +// vite.config.ts +import { defineConfig } from "vite"; +import { midenVitePlugin } from "@miden-sdk/vite-plugin"; + +export default defineConfig({ + plugins: [midenVitePlugin()], +}); +``` + +Zero-config is the intended path. Reach for options only when you have a +concrete reason, and check the skill first — several of the knobs interact. + +## The trap worth stating up front + +Cross-origin isolation headers are needed for the **multi-threaded** build and +change how your whole page behaves: once COOP/COEP are set, third-party +embeds, images and scripts that lack the right CORS headers stop loading. +Adding them "just in case" breaks unrelated parts of an app, and the breakage +usually shows up far from this config. Decide which build you are on first; +the skill spells out the consequences of each. + +## Going deeper + +- Narrative docs: +- The client this configures documents itself at + `node_modules/@miden-sdk/miden-sdk/AGENTS.md`. diff --git a/packages/vite-plugin/README.md b/packages/vite-plugin/README.md index 0a89d66a..5a687fa2 100644 --- a/packages/vite-plugin/README.md +++ b/packages/vite-plugin/README.md @@ -10,6 +10,32 @@ npm install @miden-sdk/vite-plugin --save-dev pnpm add @miden-sdk/vite-plugin --dev ``` +## For AI coding agents + +This package ships `AGENTS.md` and a `skills/vite-wasm-setup/` guide inside the +tarball, version-matched to the code you installed. Agents do not read +`node_modules` unprompted, so paste this into your project's root `AGENTS.md` +or `CLAUDE.md`: + +```markdown + +## 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 for the +package you are touching: + +- `node_modules/@miden-sdk/miden-sdk/AGENTS.md` — core client +- `node_modules/@miden-sdk/react/AGENTS.md` — React hooks +- `node_modules/@miden-sdk/vite-plugin/AGENTS.md` — bundler setup + +Each one indexes task-specific skills in its package's `skills/` directory. +Read the relevant skill before implementing, not after. + +``` + ## Usage ```typescript diff --git a/packages/vite-plugin/package.json b/packages/vite-plugin/package.json index 45f67391..9ec4c51d 100644 --- a/packages/vite-plugin/package.json +++ b/packages/vite-plugin/package.json @@ -20,7 +20,9 @@ }, "files": [ "dist", - "README.md" + "README.md", + "AGENTS.md", + "skills" ], "scripts": { "build": "tsup src/index.ts --format cjs,esm --dts --clean", diff --git a/packages/vite-plugin/skills/vite-wasm-setup/SKILL.md b/packages/vite-plugin/skills/vite-wasm-setup/SKILL.md new file mode 100644 index 00000000..c8d1d9d8 --- /dev/null +++ b/packages/vite-plugin/skills/vite-wasm-setup/SKILL.md @@ -0,0 +1,140 @@ +--- +name: vite-wasm-setup +description: Guide to configuring Vite for Miden WASM applications. Covers the midenVitePlugin() setup, COOP/COEP headers, production deployment headers, TypeScript compatibility, and troubleshooting common Vite + WASM issues. Use when setting up a new Miden frontend, debugging build or runtime errors related to WASM or Vite configuration, or deploying to production. +--- + +# Vite + WASM Configuration for Miden + +## Required `vite.config.ts` + +```typescript +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import { midenVitePlugin } from "@miden-sdk/vite-plugin"; + +export default defineConfig({ + plugins: [react(), midenVitePlugin()], +}); +``` + +`midenVitePlugin()` works with no options for the common case — the default `@miden-sdk/miden-sdk` / `@miden-sdk/react` imports ship **single-threaded (ST)** WASM that loads in any browser context, so the default client runs with no cross-origin isolation. The plugin's `crossOriginIsolation` option defaults to `false` for the same reason, and the v0.15.0 example wallet app calls `midenVitePlugin()` bare. Don't reach for `crossOriginIsolation: true` unless you have actually opted into the multi-threaded build (see below). + +Pass `crossOriginIsolation: true` **only** if you import the **multi-threaded (MT)** WASM variant — `@miden-sdk/miden-sdk/mt` (or `/mt/lazy`) and `@miden-sdk/react/mt` (or `/mt/lazy`). The MT build uses `wasm-bindgen-rayon` and `SharedArrayBuffer` / `WebAssembly.Memory({ shared: true })` for ~3–5x faster local proving, which the browser only constructs when the page is cross-origin-isolated (COOP `same-origin` + COEP `require-corp`). On the default ST imports those headers are unnecessary. The [frontend template](https://github.com/0xMiden/frontend-template)'s `vite.config.ts` is the source-of-truth reference for the current setup. + +If your app must host third-party iframes, OAuth popups, or other cross-origin resources that don't emit `require-corp`, stay on the default ST imports and leave `crossOriginIsolation: false` (the default) — you keep a fully working Miden client and only forgo MT-accelerated local proving on that route. Enabling `crossOriginIsolation: true` also breaks OAuth-popup flows (e.g. Para), because `same-origin` COOP nullifies `window.opener` in popups. If you genuinely need both MT proving and cross-origin resources, embed the latter via `credentialless` COEP as a workaround (see the Gotchas section below). + +## What midenVitePlugin() Handles + +`@miden-sdk/vite-plugin` abstracts Miden-specific Vite configuration. It does **not** register a `.wasm` module loader — Vite's built-in handling does the actual `.wasm` import. What the plugin sets up: + +- **WASM dedup / single copy** — `resolve.alias` (exact-match regex on the WASM package), `resolve.dedupe`, and `resolve.preserveSymlinks` force a single resolved copy of `@miden-sdk/miden-sdk` (avoids WASM class-identity issues across symlinked/monorepo setups) +- **optimizeDeps.exclude** — Excludes `@miden-sdk/miden-sdk` from pre-bundling (pre-bundling corrupts the WASM binary) +- **Top-level await** — Sets `build.target: "esnext"`, which enables the top-level `await` the WASM SDK initialization requires +- **ES-module workers** — Sets `worker.format: "es"`, required for the WASM SDK's module workers +- **COOP/COEP headers (opt-in, MT only)** — `crossOriginIsolation` defaults to `false`. When set to `true`, emits `Cross-Origin-Opener-Policy: same-origin` + `Cross-Origin-Embedder-Policy: require-corp` on **both** the Vite dev server and the Vite preview server (see Production Deployment Headers). Only needed to satisfy the cross-origin-isolation requirement of the MT WASM variant; the default ST build doesn't need these headers +- **gRPC-web dev proxy** — Proxies `/rpc.Api` to `rpcProxyTarget` (default `https://rpc.testnet.miden.io`) during `vite` (serve) to bypass CORS in dev; set `rpcProxyTarget: false` to disable +- **React context dedup** — Externalizes `@miden-sdk/react` during esbuild pre-bundling so signer-provider React contexts share one identity + +You don't need to install or configure `vite-plugin-wasm`, `vite-plugin-top-level-await`, or dexie aliases manually. + +## Required Dependencies + +Two packages move together as the core SDK pair: `@miden-sdk/miden-sdk` (the WASM client) and `@miden-sdk/react` (the React hooks). At v0.15.0 both are `0.15.0` and share a WASM ABI, so they must match. The **vite-plugin and the wallet adapters are versioned independently** and can trail the core SDK by a minor/patch — don't assume they're in lockstep. The [frontend template](https://github.com/0xMiden/frontend-template)'s `package.json` is the reference for the current pin set; re-run your app's full build + end-to-end suite whenever you bump. + +```json +{ + "dependencies": { + "@miden-sdk/react": "", + "@miden-sdk/miden-sdk": "", + "@miden-sdk/miden-wallet-adapter-react": "" + }, + "devDependencies": { + "@miden-sdk/vite-plugin": "" + } +} +``` + +Notes: +- **`@miden-sdk/react` and `@miden-sdk/miden-sdk` must match** — they link against the same WASM ABI, so a mixed pair (e.g. one built against an older WASM ABI, one against the current) won't link. Upgrade them together. +- **The `@miden-sdk/vite-plugin` does NOT track the core SDK version.** At v0.15.0 the plugin trails the core SDK by a minor and is NOT on the same version as `@miden-sdk/miden-sdk@0.15.0`; they only realign later in the 0.15 line. Always defer to your app's `package.json` (or the frontend template's) for the authoritative plugin pin — never assume `vite-plugin === miden-sdk`. +- **The wallet adapters live in a separate repo.** `@miden-sdk/miden-wallet-adapter-react` (and its companion `@miden-sdk/miden-wallet-adapter-base`) are published from [`0xMiden/wallet-adapter`](https://github.com/0xMiden/wallet-adapter), not the web-sdk repo, and are versioned independently. Confirm the exact package names and versions against that repo (or your app's `package.json`); the `-react` adapter's `peerDependencies` pin `@miden-sdk/react` at `^..x`, so a patch-level gap from the core SDK is expected and fine. +- **Always check your app's `package.json` (or the [frontend template](https://github.com/0xMiden/frontend-template)'s) for the authoritative versions** — this skill intentionally doesn't inline them because they shift across SDK releases. +- When you bump, do a clean install with your project's package manager: delete `node_modules` and the lockfile it actually uses, then reinstall. The web-sdk uses pnpm (`rm -rf node_modules pnpm-lock.yaml && pnpm install`). For an app repo, use whatever package manager its lockfile implies — e.g. the frontend template's v0.15 branch ships a `yarn.lock` (`rm -rf node_modules && yarn install`), while another app may use `npm ci` or `pnpm install`. Vite's dep optimizer caches resolved SDK paths, and stale caches can surface as `ERR_BLOCKED_BY_RESPONSE` or spurious `Failed to fetch` errors on module workers. + +## Production Deployment Headers + +These headers apply **only if you ship the MT WASM variant** (`/mt` or `/mt/lazy`). The default ST build needs none of this — skip the whole section if you're on the default imports. If you are on MT, the COOP/COEP headers must be set on the production server: `midenVitePlugin({ crossOriginIsolation: true })` only emits them on the Vite dev server (`vite`) and the Vite preview server (`vite preview`) — it does not touch your real production host. Configure the headers separately on nginx/Vercel/Cloudflare/etc. + +### Nginx +```nginx +add_header Cross-Origin-Opener-Policy same-origin; +add_header Cross-Origin-Embedder-Policy require-corp; +``` + +### Vercel (vercel.json) +```json +{ + "headers": [ + { + "source": "/(.*)", + "headers": [ + { "key": "Cross-Origin-Opener-Policy", "value": "same-origin" }, + { "key": "Cross-Origin-Embedder-Policy", "value": "require-corp" } + ] + } + ] +} +``` + +### Cloudflare Pages (_headers) +``` +/* + Cross-Origin-Opener-Policy: same-origin + Cross-Origin-Embedder-Policy: require-corp +``` + +### WASM MIME Type +Ensure your server serves `.wasm` files with `application/wasm` MIME type. + +## COOP/COEP Gotchas + +These gotchas only apply once you've enabled cross-origin isolation for the MT build — the default ST build sets no such headers and is unaffected. When COOP `same-origin` + COEP `require-corp` are in force, they break: +- **Third-party iframes** (YouTube embeds, Twitter embeds, analytics) +- **External scripts** without CORS headers +- **OAuth popups** from different origins + +Workaround: Use `credentialless` for COEP if you need cross-origin resources: +``` +Cross-Origin-Embedder-Policy: credentialless +``` + +Note: `credentialless` provides weaker isolation but allows most cross-origin resources. + +## TypeScript Compatibility + +Standard Vite-compatible tsconfig settings work with Miden. The only actual constraint is ES2020+ for `bigint` support: + +```json +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler" + } +} +``` + +`module: "ESNext"` and `moduleResolution: "bundler"` are standard Vite defaults, not Miden-specific requirements. If you're using the Vite-generated tsconfig, no changes are needed beyond ensuring `target` is ES2020+. + +## Troubleshooting + +| Issue | Cause | Fix | +|-------|-------|-----| +| "SharedArrayBuffer is not defined" (MT build only) | Importing `/mt` or `/mt/lazy` on a page that isn't cross-origin-isolated | Set `midenVitePlugin({ crossOriginIsolation: true })` and add the COOP/COEP headers on your production host; or switch back to the default ST imports, which don't need them | +| WASM module not found | SDK not configured correctly | Ensure `midenVitePlugin()` is in plugins array | +| "Top-level await not supported" | Missing plugin setup | Ensure `midenVitePlugin()` is in plugins array | +| WASM init hangs | COEP blocking WASM fetch | Check network tab for blocked requests; verify COOP/COEP headers are present | +| Build succeeds but WASM fails at runtime | Wrong MIME type | Serve .wasm as application/wasm | +| "recursive use of an object" | Concurrent WASM access | Use runExclusive() from useMiden() | +| Double initialization in dev | React StrictMode | Use MidenProvider (handles this internally) | From 1adfbd80aa5a75de19e283d07b3ee489bffaf813 Mon Sep 17 00:00:00 2001 From: Wiktor Starczewski Date: Sun, 23 Aug 2026 22:22:17 +0200 Subject: [PATCH 3/6] chore: allow PnP in spellcheck dictionary --- .typos.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.typos.toml b/.typos.toml index 8aa3f6d5..26cf8b76 100644 --- a/.typos.toml +++ b/.typos.toml @@ -1,3 +1,7 @@ # Config file for typos. For more information, see: https://github.com/crate-ci/typos [files] extend-exclude = ["*.d.ts", "*.json", "*.lock", "*.map", "crates/idxdb-store/src/js", "dist"] + +[default.extend-words] +# Yarn Plug'n'Play, otherwise corrected to "OnP". +PnP = "PnP" From 91e923c58643acc632ef9c81b237e0541c843965 Mon Sep 17 00:00:00 2001 From: Wiktor Starczewski Date: Sun, 23 Aug 2026 22:23:00 +0200 Subject: [PATCH 4/6] docs: reword PnP mention to satisfy spellcheck, revert dictionary entry --- .typos.toml | 4 ---- packages/react-sdk/skills/react-sdk-patterns/SKILL.md | 2 +- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/.typos.toml b/.typos.toml index 26cf8b76..8aa3f6d5 100644 --- a/.typos.toml +++ b/.typos.toml @@ -1,7 +1,3 @@ # Config file for typos. For more information, see: https://github.com/crate-ci/typos [files] extend-exclude = ["*.d.ts", "*.json", "*.lock", "*.map", "crates/idxdb-store/src/js", "dist"] - -[default.extend-words] -# Yarn Plug'n'Play, otherwise corrected to "OnP". -PnP = "PnP" diff --git a/packages/react-sdk/skills/react-sdk-patterns/SKILL.md b/packages/react-sdk/skills/react-sdk-patterns/SKILL.md index b7dbd9ad..5d18cdff 100644 --- a/packages/react-sdk/skills/react-sdk-patterns/SKILL.md +++ b/packages/react-sdk/skills/react-sdk-patterns/SKILL.md @@ -555,7 +555,7 @@ The runtime types come from `@miden-sdk/react` (re-exported from `@miden-sdk/mid - the installed `.d.ts` for `@miden-sdk/react` (hook return types and option types) - the installed `.d.ts` for `@miden-sdk/miden-sdk` (`MidenClient`, `Account`, `AccountId`, `Note`, `Word`, etc.) -Path layout differs across package managers (npm flat, pnpm nested, Yarn PnP virtual), so resolve them via your IDE's "Go to Definition" or the installed package surface rather than hard-coded paths. +Path layout differs across package managers — npm flattens, pnpm nests behind symlinks, and Yarn's Plug-n-Play mode serves them from a virtual filesystem with no real directory at all — so resolve them via your IDE's "Go to Definition" or the installed package surface rather than hard-coded paths. Common app-developer types: From f3d8fd20ed6835e96775591c4fc71fffc7a9ebfd Mon Sep 17 00:00:00 2001 From: Wiktor Starczewski Date: Sun, 23 Aug 2026 22:44:25 +0200 Subject: [PATCH 5/6] docs: ship frontend-source-guide from the core package --- AGENTS.md | 2 +- CHANGELOG.md | 2 +- crates/web-client/AGENTS.md | 1 + .../skills/frontend-source-guide/SKILL.md | 174 ++++++++++++++++++ 4 files changed, 177 insertions(+), 2 deletions(-) create mode 100644 crates/web-client/skills/frontend-source-guide/SKILL.md diff --git a/AGENTS.md b/AGENTS.md index 8f42ea25..4ad3e760 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -199,7 +199,7 @@ a consumer on 0.15 gets 0.15 guidance. | Package | Ships | |---|---| -| `@miden-sdk/miden-sdk` (`crates/web-client/`) | `web-client-usage`, `frontend-pitfalls`, `signer-integration` | +| `@miden-sdk/miden-sdk` (`crates/web-client/`) | `web-client-usage`, `frontend-pitfalls`, `signer-integration`, `frontend-source-guide` | | `@miden-sdk/react` (`packages/react-sdk/`) | `react-sdk-patterns`, `testing-patterns` | | `@miden-sdk/vite-plugin` (`packages/vite-plugin/`) | `vite-wasm-setup` | diff --git a/CHANGELOG.md b/CHANGELOG.md index 69d7711b..f2af929f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ ### Enhancements * [FEATURE][web][react] Every published package now ships agent-facing documentation inside its tarball: an `AGENTS.md` index plus a `skills/` directory, readable at `node_modules/@miden-sdk//`. Because they ship with the code they are version-matched to the installed release, so an AI agent working in a consumer's repo gets guidance for the version in that repo's lockfile rather than whatever its training data remembers. `@miden-sdk/miden-sdk` also ships its `README.md` for the first time — it previously published neither a readme nor any documentation. Paste the marker block from any package readme into your project's root `AGENTS.md` to point your agent at them. ([#310](https://github.com/0xMiden/web-sdk/pull/310)) -* [CHANGE] The skills describing this SDK's own API (`web-client-usage`, `react-sdk-patterns`, `vite-wasm-setup`, `frontend-pitfalls`, `signer-integration`, `testing-patterns`) are now canonical in this repo, having previously been maintained in [`0xMiden/agent-tools`](https://github.com/0xMiden/agent-tools) and copied into [`0xMiden/frontend-template`](https://github.com/0xMiden/frontend-template). Both copies had drifted from each other and from the code, since nothing tied a skill to the API it documented; keeping them beside the source makes an API change and its documentation the same PR. `agent-tools` remains canonical for everything not specific to this SDK. ([#310](https://github.com/0xMiden/web-sdk/pull/310)) +* [CHANGE] The skills describing this SDK's own API (`web-client-usage`, `react-sdk-patterns`, `vite-wasm-setup`, `frontend-pitfalls`, `signer-integration`, `testing-patterns`, `frontend-source-guide`) are now canonical in this repo, having previously been maintained in [`0xMiden/agent-tools`](https://github.com/0xMiden/agent-tools) and copied into [`0xMiden/frontend-template`](https://github.com/0xMiden/frontend-template). Both copies had drifted from each other and from the code, since nothing tied a skill to the API it documented; keeping them beside the source makes an API change and its documentation the same PR. `agent-tools` remains canonical for everything not specific to this SDK. ([#310](https://github.com/0xMiden/web-sdk/pull/310)) ## 0.16.0-rc.3 (2026-08-23) diff --git a/crates/web-client/AGENTS.md b/crates/web-client/AGENTS.md index 234ea239..576e6a3c 100644 --- a/crates/web-client/AGENTS.md +++ b/crates/web-client/AGENTS.md @@ -21,6 +21,7 @@ are doing rather than guessing from the type signatures alone. | `skills/web-client-usage/SKILL.md` | Any code that calls `MidenClient` — initialization, the resource API, sync ordering, type conversions, transaction flows, custom contracts, private note transport. | | `skills/frontend-pitfalls/SKILL.md` | Before shipping. WASM initialization, concurrent access, cross-origin isolation, `BigInt` at the WASM boundary. These are the failures that survive code review and break in production. | | `skills/signer-integration/SKILL.md` | Wiring an external signer (Para, Turnkey, a wallet adapter) or implementing a custom one. | +| `skills/frontend-source-guide/SKILL.md` | Anything the other skills don't cover — driving `WasmWebClient` directly, or troubleshooting SDK internals. Maps this repository's source so you can read the implementation instead of guessing. | Building a React app? `@miden-sdk/react` wraps this client in hooks and ships its own guide at `node_modules/@miden-sdk/react/AGENTS.md`. Prefer the hooks for diff --git a/crates/web-client/skills/frontend-source-guide/SKILL.md b/crates/web-client/skills/frontend-source-guide/SKILL.md new file mode 100644 index 00000000..ac6d9fb6 --- /dev/null +++ b/crates/web-client/skills/frontend-source-guide/SKILL.md @@ -0,0 +1,174 @@ +--- +name: frontend-source-guide +description: Guide for advanced Miden frontend development using source repo exploration. Covers AI development practices (Plan Mode, verification-driven development, context engineering, sub-agents) and maps the Miden web-sdk source repository for discovering advanced patterns. Use when building complex applications beyond basic hook usage, implementing custom signers, working with WasmWebClient directly, or troubleshooting SDK internals. +--- + +# Advanced Miden Frontend Development: Source-Guided Context Engineering + +## Development Approach + +### 1. Plan Mode First + +For any non-trivial frontend application, start in Plan Mode before writing code. + +- Explore React SDK source and examples to understand available patterns +- Design the component hierarchy, data flow, and which hooks to use +- Identify which built-in hooks cover your needs vs what requires direct WasmWebClient access +- Map out the user flow: account creation, token operations, note handling + +Rule of thumb: if the task involves custom transactions, external signers, or patterns not covered by the basic skills, plan first. + +### 2. Verification-Driven Development + +This is the single highest-leverage practice for AI-assisted frontend development. + +**Type check loop**: After every file edit, run `npx tsc -b --noEmit`. The project's type check hook does this automatically. If types fail: +1. Read the error message +2. Search the React SDK source for the correct type signature or hook usage +3. Adapt the working pattern to your use case +4. Recheck + +**Dev server loop**: Run `npm run dev` and check the browser. When something fails: +1. Check the browser console for WASM errors, network errors, or React errors +2. For WASM errors: check COOP/COEP headers and Vite config (see frontend-pitfalls skill) +3. For unexpected behavior: compare your code against the example wallet in the React SDK + +Never submit code that doesn't type-check. The verification loop is your quality guarantee. + +### 3. Context Engineering with Source Repos + +The basic skills (react-sdk-patterns, frontend-pitfalls, vite-wasm-setup) cover standard patterns. For anything beyond those patterns, the web-sdk source repository is the knowledge base. + +**How to use source repos effectively**: +- Don't load entire repos into context. Use sub-agents to explore — they search, read relevant files, and summarize findings without filling the main conversation context. +- Read source files only when you need a specific answer (progressive disclosure) +- Look for working examples first, then adapt. The example wallet app is the most reliable reference. +- When you find a useful pattern in source, extract just what you need — the exact hook call, the exact type, the exact provider setup. + +**Using sub-agents for exploration**: +- Launch an explore sub-agent with a specific question: "Find how useSwap handles the payback note type in the React SDK" +- The sub-agent searches, reads the relevant files, and returns a focused summary +- Your main context stays clean for implementation + +### 4. Iterative Frontend Development + +Break complex applications into stages. Complete each before starting the next: + +1. **Design** (Plan Mode) — Component hierarchy, data flow, hook selection +2. **Provider setup** — MidenProvider config, signer integration if needed +3. **Query components** — Account display, balance rendering, note lists +4. **Mutation components** — Send forms, mint buttons, consume flows +5. **Transaction UX** — Stage progress, error handling, loading states +6. **Polish** — Auto-sync tuning, memoization, edge cases + +When stuck at any stage: search the React SDK source for a similar working pattern. Adapt it, don't guess. + +--- + +## Miden Source Repository Map + +Clone this repo alongside your project for reference. Claude will explore it when needed for advanced patterns. + +```bash +# Contains the React SDK source (@miden-sdk/react), the WasmWebClient WASM bindings, and working examples +git clone --depth 1 https://github.com/0xMiden/web-sdk.git ../web-sdk +``` + +### `packages/react-sdk/` — React SDK Source (`@miden-sdk/react`) + +The primary reference for all frontend development. + +- **`src/hooks/`** — All ~29 hook implementations. Each file is self-contained. Read these to understand exact parameters, error handling, and stage progression. +- **`src/context/MidenProvider.tsx`** — Client initialization, sync loop, signer detection, runExclusive lock. Read this to understand initialization order. Note: `useMidenClient()` returns the `WasmWebClient` (aliased `WebClient`). +- **`src/context/SignerContext.ts`** — External signer interface. Read this when implementing custom signers. +- **`src/store/MidenStore.ts`** — Zustand store structure. Read this to understand cached state and what triggers re-renders. +- **`src/utils/`** — Utility implementations (amounts, notes, accountBech32, runExclusive, accountParsing). +- **`src/types/index.ts`** — All TypeScript interfaces. The single source of truth for option types, result types, and configuration. +- **`packages/react-sdk/examples/wallet/`** — Complete working wallet app. The most reliable reference for how to set up MidenProvider, create accounts, display balances, claim notes, and send tokens. + +**Explore when**: Writing any new component, understanding exact hook behavior, finding how a specific feature works, debugging unexpected behavior. + +### `crates/web-client/` — WASM Client Bindings + +The Rust-to-WASM bridge that the React SDK wraps. + +- Contains the `WebClient` WASM struct, exported to JS as the `WasmWebClient` class (which react-sdk re-aliases to `WebClient`, the value returned by `useMidenClient()`) and all methods it exposes to JS +- The standalone `RpcClient` struct (e.g. `getBlockHeaderByNumber`, `getNotesById`) lives here too, in `src/rpc_client/`, and is exported separately from `@miden-sdk/miden-sdk` — it is NOT reachable through `useMidenClient()` +- JavaScript bindings in `js/` directory + +**Explore when**: A hook doesn't exist for your operation, understanding what WasmWebClient methods are available, debugging WASM-level errors. + +### `crates/idxdb-store/` — IndexedDB Persistence + +The browser storage layer for accounts, keys, notes, and transaction history. + +**Explore when**: Debugging data persistence issues, understanding what's stored in IndexedDB, investigating storage isolation for external signers. + +--- + +## What to Explore for Each Pattern + +| Building This | Explore These Paths | What to Look For | +|---|---|---| +| Basic wallet UI | `packages/react-sdk/examples/wallet/` | MidenProvider setup, useAccounts, useSend | +| Custom transaction | `src/hooks/useTransaction.ts` | Request factory pattern, client methods | +| External signer | `src/context/SignerContext.ts` | SignerContextValue interface, signCb | +| Note consumption flow | `src/hooks/useConsume.ts` | NoteId parsing, filter construction | +| Swap UI | `src/hooks/useSwap.ts` | Swap options, dual note types | +| Partial swap (PSWAP) UI | `src/hooks/usePswapCreate.ts`, `usePswapConsume.ts`, `usePswapCancel.ts` | Partial-fill swap flow (new in v0.15): create, consume, cancel | +| Token display | `src/utils/amounts.ts` | formatAssetAmount, parseAssetAmount | +| Account ID formatting | `src/utils/accountBech32.ts` | toBech32AccountId | +| State management | `src/store/MidenStore.ts` | Zustand selectors, cached state | +| Direct WasmWebClient usage | `src/context/MidenProvider.tsx` | useMidenClient(), runExclusive | +| Multi-step workflow | `src/hooks/useWaitForCommit.ts`, `useWaitForNotes.ts` | Polling, timeout patterns | + +--- + +## Common Advanced Patterns + +### Custom Hooks Wrapping WasmWebClient +For operations not covered by built-in hooks, create custom hooks that use `useMidenClient()` and `runExclusive`. `useMidenClient()` returns the `WebClient` (WasmWebClient), so only call methods that exist on it — e.g. `getSyncHeight()`: +```tsx +function useSyncHeight() { + const client = useMidenClient(); + const { runExclusive } = useMiden(); + const [height, setHeight] = useState(null); + useEffect(() => { + // Note: runExclusive() may be simplified in a future SDK version. + // Check SDK changelog when upgrading. + runExclusive(async () => { + const h = await client.getSyncHeight(); + setHeight(h); + }); + }, []); + return height; +} +``` + +Some operations are NOT on the `WebClient` returned by `useMidenClient()` — for example block headers. `getBlockHeaderByNumber` lives on the standalone `RpcClient` (exported from `@miden-sdk/miden-sdk`), which you construct directly with an endpoint: +```tsx +import { RpcClient, Endpoint } from "@miden-sdk/miden-sdk"; + +// signature: getBlockHeaderByNumber(blockNum?: number, includeMmrProof?: boolean) +const rpc = new RpcClient(endpoint); // endpoint: Endpoint +const header = await rpc.getBlockHeaderByNumber(blockNumber, false); +``` + +### Multi-Step Workflows +Compose hooks for complex flows (mint → wait for commit → sync → consume): +```tsx +const { mint } = useMint(); +const { waitForCommit } = useWaitForCommit(); +const { waitForConsumableNotes } = useWaitForNotes(); +const { consume } = useConsume(); + +const mintAndConsume = async () => { + const { transactionId } = await mint({ targetAccountId, faucetId, amount }); + await waitForCommit(transactionId); + await waitForConsumableNotes({ accountId: targetAccountId }); + await consume({ accountId: targetAccountId, notes: [...] }); +}; +``` + +### Custom Signer Implementation +Implement the SignerContextValue interface, wrap MidenProvider in your provider. Reference `src/context/SignerContext.ts` for the exact interface contract. The `storeName` field must be unique per user to ensure IndexedDB isolation. From 86ff9639b9841f0f25e3e8b7df3d1c2d45f0578f Mon Sep 17 00:00:00 2001 From: Wiktor Starczewski Date: Sun, 23 Aug 2026 23:04:18 +0200 Subject: [PATCH 6/6] docs: ship chain-anchored-execution as a skill --- CHANGELOG.md | 2 +- crates/web-client/AGENTS.md | 1 + .../skills/chain-anchored-execution/SKILL.md | 257 ++++++++++++++++++ 3 files changed, 259 insertions(+), 1 deletion(-) create mode 100644 crates/web-client/skills/chain-anchored-execution/SKILL.md diff --git a/CHANGELOG.md b/CHANGELOG.md index f2af929f..214a5afa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ ### Enhancements * [FEATURE][web][react] Every published package now ships agent-facing documentation inside its tarball: an `AGENTS.md` index plus a `skills/` directory, readable at `node_modules/@miden-sdk//`. Because they ship with the code they are version-matched to the installed release, so an AI agent working in a consumer's repo gets guidance for the version in that repo's lockfile rather than whatever its training data remembers. `@miden-sdk/miden-sdk` also ships its `README.md` for the first time — it previously published neither a readme nor any documentation. Paste the marker block from any package readme into your project's root `AGENTS.md` to point your agent at them. ([#310](https://github.com/0xMiden/web-sdk/pull/310)) -* [CHANGE] The skills describing this SDK's own API (`web-client-usage`, `react-sdk-patterns`, `vite-wasm-setup`, `frontend-pitfalls`, `signer-integration`, `testing-patterns`, `frontend-source-guide`) are now canonical in this repo, having previously been maintained in [`0xMiden/agent-tools`](https://github.com/0xMiden/agent-tools) and copied into [`0xMiden/frontend-template`](https://github.com/0xMiden/frontend-template). Both copies had drifted from each other and from the code, since nothing tied a skill to the API it documented; keeping them beside the source makes an API change and its documentation the same PR. `agent-tools` remains canonical for everything not specific to this SDK. ([#310](https://github.com/0xMiden/web-sdk/pull/310)) +* [CHANGE] The skills describing this SDK's own API (`web-client-usage`, `react-sdk-patterns`, `vite-wasm-setup`, `frontend-pitfalls`, `signer-integration`, `testing-patterns`, `frontend-source-guide`) are now canonical in this repo, joined by a new `chain-anchored-execution` skill covering the `ChainAnchor` API added in 0.16.0-rc.3, having previously been maintained in [`0xMiden/agent-tools`](https://github.com/0xMiden/agent-tools) and copied into [`0xMiden/frontend-template`](https://github.com/0xMiden/frontend-template). Both copies had drifted from each other and from the code, since nothing tied a skill to the API it documented; keeping them beside the source makes an API change and its documentation the same PR. `agent-tools` remains canonical for everything not specific to this SDK. ([#310](https://github.com/0xMiden/web-sdk/pull/310)) ## 0.16.0-rc.3 (2026-08-23) diff --git a/crates/web-client/AGENTS.md b/crates/web-client/AGENTS.md index 576e6a3c..db7dce55 100644 --- a/crates/web-client/AGENTS.md +++ b/crates/web-client/AGENTS.md @@ -21,6 +21,7 @@ are doing rather than guessing from the type signatures alone. | `skills/web-client-usage/SKILL.md` | Any code that calls `MidenClient` — initialization, the resource API, sync ordering, type conversions, transaction flows, custom contracts, private note transport. | | `skills/frontend-pitfalls/SKILL.md` | Before shipping. WASM initialization, concurrent access, cross-origin isolation, `BigInt` at the WASM boundary. These are the failures that survive code review and break in production. | | `skills/signer-integration/SKILL.md` | Wiring an external signer (Para, Turnkey, a wallet adapter) or implementing a custom one. | +| `skills/chain-anchored-execution/SKILL.md` | Multisig proposals, offline co-signing — anything where one party signs a transaction summary and another executes it. Read before using `captureAnchor`, or when co-signers' summary commitments never match. | | `skills/frontend-source-guide/SKILL.md` | Anything the other skills don't cover — driving `WasmWebClient` directly, or troubleshooting SDK internals. Maps this repository's source so you can read the implementation instead of guessing. | Building a React app? `@miden-sdk/react` wraps this client in hooks and ships diff --git a/crates/web-client/skills/chain-anchored-execution/SKILL.md b/crates/web-client/skills/chain-anchored-execution/SKILL.md new file mode 100644 index 00000000..6ec66d8f --- /dev/null +++ b/crates/web-client/skills/chain-anchored-execution/SKILL.md @@ -0,0 +1,257 @@ +--- +name: chain-anchored-execution +description: Rules for using ChainAnchor to pin transaction execution to a specific block, required whenever a signature is collected over a transaction summary by one party and the transaction is executed later or by another party — multisig proposals and offline co-signing. Use when writing or reviewing code that calls captureAnchor, preview, executeRequest or submit with an anchor, uses useChainAnchor or usePreview, or when debugging summary commitments that never match between co-signers, INVALID_CHAIN_ANCHOR, OPERATION_BUSY, STALE_CLIENT or TRANSACTION_ALREADY_AUTHORIZED. +--- + +# Chain-Anchored Execution + +**Availability:** `@miden-sdk/miden-sdk` and `@miden-sdk/react` from `0.16.0-rc.3`. +This skill ships inside the package, so if you are reading it from +`node_modules/@miden-sdk/miden-sdk/skills/`, the installed version has these +surfaces. If the symbols are missing anyway, the build is pinned to +`miden-client` 0.16.0-rc.1 or earlier, which does not contain chain-anchored +execution — check the client pin before anything else. + +Source of truth: [web-sdk#301](https://github.com/0xMiden/web-sdk/pull/301) and +[#302](https://github.com/0xMiden/web-sdk/pull/302). + +--- + +## 1. First, decide whether this applies + +Do **not** reach for `ChainAnchor` by default. Apply this test: + +> Will a signature be collected over a transaction summary by one party, and the +> transaction executed later or by a different party? + +- **No** → omit `anchor` entirely. Execution runs at the current tip, exactly as + before. Nothing in this document applies to you. +- **Yes** → you need an anchor. Multisig proposals and offline co-signing are the + canonical cases. + +**Why:** since protocol 0.16 a signed transaction summary binds the reference block +commitment, so a signature authorizes execution **only at that exact block**. Without +an anchor, each party re-executing at their own sync height derives a different summary +and verification can never succeed. + +--- + +## 2. Rules + +Each rule states what to do, then why. Follow them literally. + +### R1 — Execute the exact request object the anchor was captured for + +Never re-resolve a request factory or re-run a builder after capturing. Calling it +again returns a different object, and any builder that creates an output note draws a +fresh serial number from the client's RNG. The result is a materially different +transaction that the anchor does not pin and the co-signers did not approve. + +In React, `useChainAnchor()` returns `anchoredRequest` for precisely this reason. + +```tsx +// CORRECT +const anchor = await captureAnchor({ request: buildRequest }); +await preview({ accountId, request: anchoredRequest!, anchor }); + +// WRONG — buildRequest resolves to a different transaction +const anchor = await captureAnchor({ request: buildRequest }); +await preview({ accountId, request: buildRequest, anchor }); +``` + +### R2 — A verifying co-signer must pass the proposer's anchor to `preview` + +Omitting it derives the summary at the local sync height, producing a different +commitment every time. The comparison then fails permanently and looks like a +verification bug. + +### R3 — Validate an anchor that arrives from an untrusted party + +Malformed anchors are impossible — the chain-length and peak-hash invariants are +enforced natively on construction and on `deserialize`, and trailing bytes are +rejected. What remains possible is an anchor pinned to the **wrong** block, or to a +block that never existed. + +1. Compare `anchor.commitment()` against `summary.blockCommitment()`. +2. Stronger: re-derive the summary at the anchor and compare `toCommitment()`, which + also binds the request and the local account state. +3. Neither detects a **fabricated** block — both invariants hold over an entirely + invented chain. Fetch the header for `anchor.blockNum()` from a node if you need + that guarantee. + +Do not escalate a fabricated-block risk beyond its actual severity: such a transaction +cannot be submitted and its signature cannot be moved onto a real one, so the cost is a +wasted proof rather than funds. The header does, however, supply the block number, +timestamp and fee parameters execution runs against. + +### R4 — Free anchors in repeated-capture flows + +An anchor carries a partial blockchain. Call `anchor.free()` when done rather than +waiting for the finalizer. + +React's `reset()` deliberately does **not** free it: the caller owns the object and may +still hold the handle. Do not "fix" this. + +### R5 — Do not add `anchor` to `send`, `mint`, `consume` or similar + +The option exists only on `preview({ operation: "custom" })`, `executeRequest` and +`submit`. The others build their request internally, so a caller can never hold an +anchor captured for one. This is deliberate, not an oversight to be patched. + +### R6 — Budget for main-thread blocking + +`captureAnchor` and `preview` run in WASM on the main thread, not the worker. They +block the UI for their duration and queue other client calls behind them. Disable the +triggering control while `isCapturing` / `isPreviewing` is true. + +`useTransaction().execute` is offloaded to the worker as usual. + +### R7 — Import the class, not the type, to deserialize in React + +`@miden-sdk/react` re-exports `ChainAnchor` as a **type only**. Calling the static +`ChainAnchor.deserialize(bytes)` requires importing the class from +`@miden-sdk/miden-sdk` directly. + +--- + +## 3. Failure modes → cause + +Map an observed symptom to its cause before proposing a fix. + +| Symptom | Cause | +| --- | --- | +| Co-signer's summary never matches the proposer's | Anchor not passed to `preview` (R2), or the request was re-resolved (R1) | +| `INVALID_CHAIN_ANCHOR` | A sync landed mid-capture and left the anchor inconsistent. **Retry** — this is transient, not a bug to work around | +| `OPERATION_BUSY` | A capture or preview is already running. Await the previous one | +| `STALE_CLIENT` | The client was swapped mid-call. Recapture on the new chain | +| `TRANSACTION_ALREADY_AUTHORIZED` from `preview` | Nothing is awaiting authorization. Submit with `useTransaction` instead | +| Error thrown naming a falsy anchor | `anchor` was passed as `null`/`undefined`-adjacent, typically hook state read before the capture resolved. Await it, or omit the option | +| Generic deserialization failure on a received anchor | SDK version skew between parties. The encoding carries no version tag | +| Execution fails deep in the executor after a network switch | An anchor was carried across a client/chain swap. Anchors are chain-bound | + +**Node.js note:** codes originating in the client (`INVALID_CHAIN_ANCHOR`, +`TRANSACTION_ALREADY_AUTHORIZED`) **prefix the message** instead of appearing as a +property, because the napi bindings cannot attach one. Codes originating in the React +package (`OPERATION_BUSY`, `STALE_CLIENT`) are always properties. Write error handling +that tolerates both shapes. + +--- + +## 4. Semantics you will otherwise get wrong + +- **`expirationDelta()` returning 0 means no expiration was set.** It does not mean the + transaction has expired. Do not write a check that treats 0 as expired. +- **A matching summary proves agreement, not intent.** The commitment covers the + account delta, the note commitments, the reference block, the expiration delta and + the user params. It does **not** cover the transaction script or the advice inputs. + Do not describe a summary match as proof that the transaction does what a user + intended. +- **`preview` only yields a summary while authorization is pending.** The summary is + produced when the account's auth procedure aborts with the unauthorized event, e.g. a + multisig below its signing threshold. A fully authorized transaction produces no + summary and rejects with `TRANSACTION_ALREADY_AUTHORIZED`. +- **Anchors are chain-bound.** Both React hooks clear their state on client change for + this reason. + +--- + +## 5. API reference + +### Core client — `client.transactions` + +```ts +captureAnchor(request: TransactionRequest): Promise + +preview({ operation: "custom", account, request, anchor? }) +executeRequest(account, request, { anchor? }) +submit(account, request, { anchor?, ...txOptions }) +``` + +### `ChainAnchor` + +| Member | Returns | Purpose | +| --- | --- | --- | +| `serialize()` | bytes | Ship alongside a summary awaiting signatures | +| `ChainAnchor.deserialize(bytes)` | `ChainAnchor` | Static; rebuild on the receiving side | +| `blockNum()` | `u32` | Number of the anchored reference block | +| `commitment()` | `Word` | Commitment of the anchored reference block | +| `blockHeader()` | `BlockHeader` | The anchored reference block header | +| `free()` | — | Release the partial blockchain it carries | + +### `TransactionSummary` + +Gained `blockCommitment()` and `expirationDelta()`, alongside the existing +`toCommitment()`, `serialize()` and `deserialize()`. + +### React + +```ts +useChainAnchor() // { captureAnchor, anchor, anchoredRequest, isCapturing, error, reset } +usePreview() // { preview, summary, isPreviewing, error, reset } +useTransaction() // execute({ ..., anchor? }) +``` + +--- + +## 6. Reference implementation + +```ts +// ── Proposer ────────────────────────────────────────────────────────── +const anchor = await client.transactions.captureAnchor(request); +const summary = await client.transactions.preview({ + operation: "custom", account, request, anchor, +}); +ship(anchor.serialize(), summary.serialize()); + +// ── Co-signer — proposer's anchor, and the same request ─────────────── +const anchor = ChainAnchor.deserialize(bytes); +const summary = await client.transactions.preview({ + operation: "custom", account, request, anchor, +}); +if (summary.toCommitment().toHex() === expected.toCommitment().toHex()) { + sign(summary); +} + +// ── Executor ────────────────────────────────────────────────────────── +await client.transactions.submit(account, request, { anchor }); +``` + +React: + +```tsx +function ProposeButton({ accountId, buildRequest }: Props) { + const { captureAnchor, anchoredRequest, isCapturing } = useChainAnchor(); + const { preview } = usePreview(); + + const propose = async () => { + const anchor = await captureAnchor({ request: buildRequest }); + // anchoredRequest, not buildRequest: the anchor pins this exact object. + const summary = await preview({ + accountId, + request: anchoredRequest!, + anchor, + }); + await shipToCosigners(anchor.serialize(), summary.serialize()); + }; + + return ; +} +``` + +--- + +## 7. Pre-ship checklist + +Before considering anchor-related work complete, confirm each of these: + +- [ ] The flow genuinely needs an anchor (§1). If not, `anchor` is absent everywhere. +- [ ] Every `preview` / `executeRequest` / `submit` uses the request the anchor was + captured for, not a re-resolved one (R1). +- [ ] The verifying side passes the proposer's anchor (R2). +- [ ] Anchors from untrusted parties are checked against a trusted commitment (R3). +- [ ] `free()` is called in any flow that captures more than once (R4). +- [ ] Controls are disabled while `isCapturing` / `isPreviewing` (R6). +- [ ] Error handling covers both the property and message-prefix shapes of client + codes (§3). +- [ ] `INVALID_CHAIN_ANCHOR` is retried rather than surfaced as a hard failure (§3). +- [ ] No check treats `expirationDelta() === 0` as expired (§4).