diff --git a/README.md b/README.md index 03fcc2c..8fe7b18 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Skills are applied automatically when the agent detects relevant tasks. Each ski ### React Frontend Development - **react-sdk-patterns** – Complete `@miden-sdk/react` hook API reference: MidenProvider, query hooks, mutation hooks, transaction stages, signer integration, utilities -- **frontend-pitfalls** – Critical frontend pitfalls: WASM init race, recursive access crash, COOP/COEP headers, BigInt handling, Bech32 mismatch, IndexedDB state loss +- **frontend-pitfalls** – Critical frontend pitfalls: client-readiness gating, multi-step WASM sequences and pointer lifetimes, COOP/COEP headers, BigInt boundaries, Bech32 network prefixes, IndexedDB state loss, the Web Worker shim, and structured error codes - **vite-wasm-setup** – Vite + WASM configuration: required plugins, deployment headers (Nginx, Vercel, Cloudflare), TypeScript config, troubleshooting - **frontend-source-guide** – Advanced frontend development guide: AI practices and miden-client source repository map for discovering patterns beyond basic skills - **signer-integration** – Integrating external signers (Para, Turnkey, MidenFi wallet adapter) and building custom signers for Miden React frontends @@ -29,14 +29,15 @@ Skills are applied automatically when the agent detects relevant tasks. Each ski - **masm-inline-comments** – Inline commenting conventions for .masm files (lowercase, avoid over-commenting) - **masm-doc-comments** – Procedure documentation format (`#!` doc blocks with Inputs, Outputs, Where, Panics, Invocation) - **masm-padding** – Stack padding conventions for `call` vs `exec` procedures -- **masm-formatting** – Orchestrator covering capitalization, `(N)` span notation, cross-repo doc-comment divergences, `Cycles:`, and chained assertion style +- **masm-formatting** – Orchestrator covering capitalization, `(N)` span notation, cross-repo doc-comment divergences, `Cycles:`, chained assertion style, and the `miden-format` formatter +- **masm-proc-type-signatures** – Type-signature conventions for `pub proc`: parameter and return types, semantic type aliases, struct/array/tuple types, and how a signature maps onto the operand stack ### Miden Client (Web SDK & Internals) - **rust-client-patterns** – Rust conventions for the `miden-client` crate: error handling (`thiserror` + `ErrorHint`), `Store` trait (adding methods across `SqliteStore`/`WebStore` with cross-platform `async_trait`), `Client` generic pattern with the `Keystore` super-trait, `no_std` imports (`alloc::`/`core::`), `ClientBuilder` network constructors (`for_testnet()`, `for_devnet()`, `for_localhost()`), and section header formatting (`// ===` top-level, `// ---` subsections) - **wasm-bridge** – Rust↔JS WASM boundary conventions for the `web-client` crate: `#[wasm_bindgen]` method exposure with `js_name`, newtype wrappers with `From` conversions, `js_error_with_context` error chaining with `ErrorHint`, promise handling (`await_js`/`await_ok`/`await_js_value`), data transfer objects with `getter_with_clone`, JS function imports, and the `MidenClient`/`WasmWebClient` (`WebClient`) two-layer JS API - **idxdb-patterns** – IndexedDB/Dexie persistence conventions for the `idxdb-store` crate: Dexie transactions (`db.dexie.transaction("rw", tables, ...)`), schema interfaces (`IAccount`, `IAccountCode`), database registry (`getDatabase`/`openDatabase`), `logWebStoreError` error handling, forward-only state updates, and the TS→JS dual-commit build workflow -- **web-client-usage** – Developer-facing patterns for using the `@miden-sdk/miden-sdk` npm package: `MidenClient.create()` initialization, the resource-based API (`client.accounts`, `client.transactions`, `client.notes`, `client.tags`, `client.settings`, `client.compile`, `client.keystore`), sync ordering, type conversions (`AccountId.fromHex`, `BigInt` amounts, `NoteVisibility`), transaction flows (mint, send, consume, swap, custom contracts), private note transport, querying, import/export, and pitfall avoidance +- **web-client-usage** – Developer-facing patterns for using the `@miden-sdk/miden-sdk` npm package: `MidenClient.create()` initialization, the resource-based API (`client.accounts`, `client.transactions`, `client.notes`, `client.pswap`, `client.tags`, `client.settings`, `client.compile`, `client.keystore`), sync ordering, type conversions (`AccountId.fromHex`, `BigInt` amounts, `NoteVisibility`), transaction flows (mint, send, consume, swap, custom contracts), private note transport, querying, import/export, and pitfall avoidance ## Commands diff --git a/skills/advice-provider-hygiene/SKILL.md b/skills/advice-provider-hygiene/SKILL.md index 6d95e04..878b84e 100644 --- a/skills/advice-provider-hygiene/SKILL.md +++ b/skills/advice-provider-hygiene/SKILL.md @@ -30,7 +30,7 @@ Readers retrieve the entry by recomputing the same hash from data they already t ### 3. Missing advice is an error -A missing advice-map entry, an empty advice stack, or an absent required value is an error — not a default. `adv.push_mapval` / `adv.push_mapvaln` already abort execution when the key is missing (the VM returns `MapKeyNotFound`), so don't paper over it with a fallback. When you branch on presence yourself, surface the failure with `assert.err=ERR_...`. Don't substitute zero / empty / a fallback and continue. +A missing advice-map entry, an empty advice stack, or an absent required value is an error — not a default. Surface it with `assert.err=ERR_...`. Don't substitute zero / empty / a fallback and continue. ## Why @@ -45,10 +45,17 @@ Advice data is tied to a commitment by piping it into memory. There are two mech `adv_pipe`, `adv_loadw`, and `mem::pipe_double_words_to_memory` copy advice data into memory but do *not* check it against any commitment. Hash the loaded region with Poseidon2 yourself and assert it equals a commitment the kernel already trusts. ```masm -# Good: pipe words while hashing, then assert against a trusted commitment. -# This is the input-note-assets path from the transaction prologue: -# pipe_double_words_to_memory runs `adv_pipe exec.poseidon2::permute` internally -# (no commitment check of its own), then you squeeze and assert. +# Good: pipe words while hashing, then assert against the trusted commitment +# (permute rounds abbreviated; the real path pipes the full region before squeezing) +exec.poseidon2::init_no_padding +adv_pipe exec.poseidon2::permute +# ... one permute per piped block ... +exec.poseidon2::squeeze_digest +# => [COMPUTED_COMMITMENT, ...] +exec.memory::get_ref_block_commitment +assert_eqw.err=ERR_PROLOGUE_GLOBAL_INPUTS_PROVIDED_DO_NOT_MATCH_BLOCK_COMMITMENT + +# Good: pipe double words while hashing, then assert against the provided commitment exec.poseidon2::init_no_padding exec.mem::pipe_double_words_to_memory exec.poseidon2::squeeze_digest @@ -56,19 +63,6 @@ exec.poseidon2::squeeze_digest exec.memory::get_input_note_assets_commitment assert_eqw.err=ERR_PROLOGUE_PROVIDED_INPUT_ASSETS_INFO_DOES_NOT_MATCH_ITS_COMMITMENT -# Good: drive the adv_pipe + permute loop yourself, squeeze, then assert. -# (The block-data prologue squeezes a SUB_COMMITMENT here, merges it with the -# trusted NOTE_ROOT to form the block commitment, and only THEN asserts — i.e. -# the squeezed digest is combined with trusted data before the equality check.) -exec.poseidon2::init_no_padding -adv_pipe exec.poseidon2::permute -# ... one `adv_pipe exec.poseidon2::permute` per piped block ... -exec.poseidon2::squeeze_digest -# => [SUB_COMMITMENT, ...] (combine with trusted data as needed, e.g. merge a root) -# ... eventually ... -exec.memory::get_block_commitment -assert_eqw.err=ERR_PROLOGUE_GLOBAL_INPUTS_PROVIDED_DO_NOT_MATCH_BLOCK_COMMITMENT - # Bad: pipe advice into memory and use it without the hash/assert step adv_pipe # ... data could be anything the prover supplied @@ -87,28 +81,21 @@ exec.mem::pipe_preimage_to_memory ### Content-addressed keys and missing entries -An advice-map key is a full word (4 felts) sitting on top of the operand stack. `adv.push_mapval` reads that word as the key and pushes the looked-up value onto the *advice* stack (the operand stack is unchanged), so you typically follow it with `adv_loadw` or a pipe to bring the value into the operand stack or memory. - ```masm # Good: key the advice map entry by the commitment itself -# (NOTE_DATA_COMMITMENT is the trusted word already on the operand-stack top) -adv.push_mapval # value pushed onto the advice stack, keyed by the commitment word -adv_loadw # => [NOTE_DATA, ...] pull the value into the operand stack +push.NOTE_DATA_COMMITMENT +adv.push_mapval -# Bad: hard-coded magic key (a key is a Word — 4 felts — and this one is meaningless) -push.0x0001.0x0000.0x0000.0x1234 +# Bad: hard-coded magic key +push.0x1234_5678_0000_0001 adv.push_mapval -# Good: a missing required entry is an error. -# adv.has_mapkey pushes the presence flag onto the ADVICE stack, so move it to the -# operand stack with adv_push before asserting. -# stack: [KEY, ...] -adv.has_mapkey # advice stack: [has_key, ...]; operand stack unchanged -adv_push # => [has_key, KEY, ...] +# Good: a missing required entry is an error +adv.has_mapkey assert.err=ERR_MISSING_REQUIRED_ADVICE -# (adv.push_mapval itself already aborts with MapKeyNotFound on an absent key — -# never assume it returns zero and continue.) +# Bad: silent zero on missing key +adv.push_mapval # no-op if key absent; proceed as if zero ``` -The Rust analog is to return `Err` on bad or missing external input rather than panicking or silently defaulting. +For the Rust analog (returning `Err` on bad/missing external input rather than panicking or defaulting), see `return-error-not-panic`. diff --git a/skills/cheap-masm-equivalents/SKILL.md b/skills/cheap-masm-equivalents/SKILL.md index 88f17c8..c871e23 100644 --- a/skills/cheap-masm-equivalents/SKILL.md +++ b/skills/cheap-masm-equivalents/SKILL.md @@ -1,6 +1,6 @@ --- name: cheap-masm-equivalents -description: Use when writing or reviewing MASM hot paths — prefer the cheaper equivalent instruction: `neq.0` over `push.0 gt` for non-zero checks, `cdrop` over an `if/else` selecting between two values, `dup.N` over `loc_load` for a value still on the stack, `eqw` over hand-rolled element-wise word comparison, `u32gt`/`u32lt` over generic `gt`/`lt` on known-u32 operands. +description: Use when writing or reviewing MASM hot paths or loops — prefer the cheaper equivalent: loop counters and pointers on the operand stack instead of procedure locals, `neq.0` over `push.0 gt` for non-zero checks, `cdrop` over an `if/else` selecting between two values, `dup.N` over `loc_load` for a value still on the stack, `eqw` over hand-rolled element-wise word comparison, `u32gt`/`u32lt` over generic `gt`/`lt` on known-u32 operands. --- # Prefer Cheap MASM Equivalents @@ -9,44 +9,71 @@ description: Use when writing or reviewing MASM hot paths — prefer the cheaper Several MASM idioms have a cheap and an expensive form. Use the cheap one when both produce the same result on the inputs the procedure can see: -- Non-zero check: `neq.0` (2 cycles) over a comparison-based check like `push.0 gt` (~17 cycles). `neq.0` lowers to `eqz not`; `push.0 gt` does a full field comparison just to learn "not zero". -- Selecting between two values on a flag: `cdrop` (2 cycles) over an `if.true ... else ... end` branch with the same effect. -- Re-fetch a recently-pushed value: `dup.N` (1-3 cycles) over `loc_load.N` (which costs more) when the value is still on the stack. -- Whole-word equality: the single `eqw` instruction (15 cycles) over a hand-rolled element-wise sequence of `eq`/`and`. -- u32-known operands: `u32lt` (3 cycles) / `u32gt` (4 cycles) over generic `lt` (17 cycles) / `gt` (16 cycles). +- Loop variables (counters, pointers, indices): keep them on the operand stack across iterations instead of in procedure locals. See below. +- Non-zero check: `neq.0` (3 cycles) over `gt.0` (16 cycles). +- Selecting between two values on a flag: `cdrop` over an `if.true ... else ... end` branch with the same effect. +- Re-fetch a recently-pushed value: `dup.N` over `loc_load.N` when the value is still on the stack. +- Whole-word equality: `eqw` over element-wise comparisons. +- u32-known operands: `u32gt`/`u32lt` over generic `gt`/`lt`. -Don't apply the cheap form when the operands violate its precondition. `u32lt`/`u32gt` are undefined if either operand is >= 2^32, so the operands must be known (or asserted) to be valid u32s first. - -Also note that `gt.0` is only sugar for `push.0 gt`: it parses, but tests `a > 0` via a full field comparison, not `a != 0`. For a genuine non-zero check use `neq.0`; reach for `push.0 gt` / `gt.0` only when you actually want strictly-greater-than-zero. +Don't apply the cheap form when the operands violate its precondition (e.g. `u32gt` on a value that might exceed `u32::MAX`). ## Why -MASM cycle costs are not uniform — `gt`/`lt` do full 64-bit comparison work that `neq` skips, so a hot path using the expensive form pays for it on every call. The swaps are semantically equivalent under their preconditions, so the saving is free. The protocol does this in practice: a loop in `account_delta.masm` carries the comment `# we use neq instead of lt for efficiency`. The Miden assembly docs make the same point for branches: an `if.true ... else ... end` incurs non-negligible overhead, so when both branches just select a value (no incompatible side effects), compute both and select with `cdrop`. +MASM cycle costs are not uniform — `gt.0` does signed-comparison work that `neq.0` skips, so a hot path using the expensive form pays for it on every call. The swaps are semantically equivalent under their preconditions, so the saving is free. ## Examples ```masm -# Good: non-zero check, 2 cycles (lowers to `eqz not`) +# Good +push.0 neq # non-zero check, 3 cycles +# or simply neq.0 -# Bad: full field comparison just to test "not zero", ~17 cycles -push.0 gt +# Bad +push.0 gt # same answer, 16 cycles ``` ```masm -# Good: cdrop for ternary selection (condition on TOP), 2 cycles -# stack: [cond, b, a] +# Good: cdrop for ternary selection +# stack: [b, a, cond] cdrop -# stack: [b if cond else a] (cond=1 keeps b, cond=0 keeps a; fails if cond > 1) +# stack: [a if cond else b] -# Bad: branchy equivalent for the same condition-on-top layout -# stack: [cond, b, a] +# Bad: branchy equivalent if.true - swap drop # cond true: keep b + drop # drop b, keep a else - drop # cond false: keep a + swap drop # drop a, keep b end -# stack: [b if cond else a] ``` -Note on `cdrop` stack order: the condition is consumed from the top of the stack and `b` (the value just below it) is kept when the condition is 1, `a` when it is 0. `cdrop` fails if the condition is > 1. `if.true`/`if.false` likewise pop their condition from the top of the stack. +## Loop Variables Belong on the Stack + +A procedure local is not a register: `loc_load.i` costs 5 cycles and `loc_store.i` costs 6. Reaching the same value on the stack +with `dup.n`, `swap`, `movup.n` or `movdn.n` (usually) costs 1 cycle. So a loop that keeps its counter and pointer in locals pays 5-11 cycles per access, per iteration, for data the stack could hold for 1. + +Read once, mutate in place: + +```masm +# Good: item_ptr lives on the stack next to the loop counter +# => [items_left, item_ptr, ...] +# 1 cycle: read the pointer +dup.1 +# ... use it ... +# 4 cycles: advance it +swap add.ITEM_NUM_ELEMENTS swap +sub.1 dup neq.0 + +# Bad: same loop through a local +# 5 cycles +loc_load.ITEM_PTR_LOC +# ... use it ... +# 13 cycles +loc_load.ITEM_PTR_LOC add.ITEM_NUM_ELEMENTS loc_store.ITEM_PTR_LOC +sub.1 dup neq.0 +``` + +### Working around `call` + +The reason to reach for a local is a `call`: the callee takes the top 16 elements, so while those 16 slots are being filled, nothing below them is addressable by `dup.n`. Values that only have to *survive* the call are fine on the stack - they sit in the overflow and come back untouched. Only a value that must be re-read *while* the frame is being built has to live in a local, and even then it is one local, not one per loop variable. diff --git a/skills/decouple-component-from-storage/SKILL.md b/skills/decouple-component-from-storage/SKILL.md index 37af5af..00504df 100644 --- a/skills/decouple-component-from-storage/SKILL.md +++ b/skills/decouple-component-from-storage/SKILL.md @@ -3,45 +3,22 @@ name: decouple-component-from-storage description: Use when writing a GENERIC MASM storage utility (one that operates over a caller-chosen slot or map) inside a reusable account component — receive the slot id as a parameter so the utility works against any slot, not one hard-coded one. --- -# Decouple Generic Storage Utilities from a Hard-Coded Slot +# Decouple Component Procedures from Storage Layout ## Rule -A *generic* storage utility — a procedure meant to operate over **any** caller-chosen storage slot or map — must not bake one specific slot id into its body. Take the slot as a parameter — the slot id split into its `slot_id_suffix` / `slot_id_prefix` felts — and pass it into the storage-access procedure (`active_account::get_item` / `get_map_item`, `native_account::set_item` / `set_map_item`). The caller that knows which slot to operate on supplies the slot id. +A reusable account component must not bake a storage-slot index into its procedure bodies. The same component can be installed into many accounts, each mapping it to a different slot, so a hard-coded slot index only works for one layout. -This is the pattern the standard `array` / `double_word_array` data-structure utilities use: `get(slot_id_suffix, slot_id_prefix, index)` takes the slot id as input so one utility can drive many different maps. - -This rule does **not** apply to a component referencing its **own dedicated named slot**. That is already correct and portable — see "When NOT to parameterize" below. +Instead, take the storage slot as a parameter — the slot id, split into its `slot_id_prefix` / `slot_id_suffix` felts — and pass it into the storage-access procedure (`active_account::get_item` / `get_map_item`, `native_account::set_item` / `set_map_item`). The account-level glue procedure that knows the real layout supplies the slot id. ## Why -A generic helper that hard-codes one slot id can only ever serve that one slot — it cannot be reused over a different map even though its logic is identical. Worse, if the caller wants it to act on a slot that is not the hard-coded one, there is no silent misread to detect: `active_account::get_item` / `get_map_item` **panic** if the supplied slot id does not exist in account storage, so a mismatch faults loudly rather than returning a wrong value. Taking the slot id as a parameter makes the utility reusable and lets the caller point it at whichever slot it owns. - -## When NOT to parameterize - -A storage slot id in v0.15 is **name-derived, not positional**: the id is the first two felts of the hash of the slot's name (`word("project::component::slot")`), so the **same slot name produces the same slot id in every account that installs the component**. A component that reads/writes its **own** dedicated named slot is therefore portable by construction — there is nothing to parameterize, because the name fixes the id everywhere. - -The canonical, correct idiom for a component's own slot is a named-`word` constant plus a `[0..2]` slice — do not "fix" this to take a slot parameter: - -```masm -# Correct and portable: the component owns this named slot; the name hashes -# to the same slot id in every account, so no parameter is needed. -pub const AUTHORITY_SLOT = word("miden::standards::access::authority") - -pub proc assert_authorized - push.AUTHORITY_SLOT[0..2] exec.active_account::get_item - # => [authority, role_symbol, 0, 0] - # ... -end -``` - -This is exactly what the standard `authority`, `ownable2step`, `rbac`, `multisig`, `pausable`, and faucet components do for their own slots. `assert_authorized` has a fixed `[] -> []` signature and is invoked with no slot argument by many consumers — parameterizing it would break every call site. +A component installed into different accounts sits at a different storage slot in each. Hard-coding the slot ties the component to one layout and silently misreads storage everywhere else; taking the slot id as a parameter makes the procedure portable. ## Examples ```masm -# Good: a GENERIC array utility takes the slot id and uses it for the storage -# access, so the same code drives any map the caller owns (cf. standard array.masm). +# Good: the component proc takes the slot id and uses it for the storage access pub proc get(slot_id_suffix: felt, slot_id_prefix: felt, index: felt) -> word movup.2 push.0.0.0 # build KEY = [0, 0, 0, index] movup.5 movup.5 # => [slot_id_suffix, slot_id_prefix, KEY] @@ -49,17 +26,12 @@ pub proc get(slot_id_suffix: felt, slot_id_prefix: felt, index: felt) -> word # => [VALUE] end -# The caller, which knows which map to operate on, passes the slot id in: -push.index push.MY_MAP_SLOT_ID_PREFIX push.MY_MAP_SLOT_ID_SUFFIX +# The account-level caller knows the real layout and passes the slot in: +push.index push.MY_SLOT_ID_PREFIX push.MY_SLOT_ID_SUFFIX exec.get -# Bad: a generic array getter hard-codes ONE specific map's slot id, so it can -# only ever serve that single map even though the logic is reusable. -pub proc get_at(index: felt) -> word - push.0.0.0 # build KEY = [0, 0, 0, index] - push.SOME_FIXED_MAP_SLOT[0..2] # baked-in slot id — not a parameter - # => [slot_id_suffix, slot_id_prefix, KEY] - exec.active_account::get_map_item - # => [VALUE] +# Bad: the component hard-codes its own slot, so it only works at that one layout +pub proc get_authority + push.AUTHORITY_SLOT[0..2] exec.active_account::get_item end ``` diff --git a/skills/felt-construction/SKILL.md b/skills/felt-construction/SKILL.md index 0f93244..9e21fbc 100644 --- a/skills/felt-construction/SKILL.md +++ b/skills/felt-construction/SKILL.md @@ -7,18 +7,17 @@ description: Use when constructing a `Felt` from a numeric value in Rust — avo ## Rule -Do not call `Felt::new_unchecked(x)` when `x` could be greater than or equal to the field order. `new_unchecked` stores any `u64` raw without reduction, so an out-of-range value produces a non-canonical `Felt` that no longer equals the original input on a canonical comparison — a classic source of hard-to-attribute bugs. +Do not call `Felt::new(x)` when `x` could exceed the field modulus. `Felt::new` silently truncates oversized values, which produces a valid-looking `Felt` that no longer equals the original input — a classic source of hard-to-attribute bugs. Use one of: -- `Felt::from(x)` where `x` is a `u32`, `u16`, or `u8` (infallible). -- `Felt::new(x)` or `Felt::try_from(x)` for `u64` inputs — both are checked and return `Result`, rejecting values that are `>= Felt::ORDER`. - -If you have independently proven the bound and need the unchecked path, only then reach for `Felt::new_unchecked(x)`, comparing against `Felt::ORDER` (the `u64` field order; there is no `Felt::MODULUS`). +- `Felt::from(x)` where `x` is a `u32` or smaller (infallible). +- `Felt::try_from(x)` for `u64`-and-larger inputs, returning `Result`. +- An explicit `assert!(x < Felt::MODULUS)` before `Felt::new(x)` if you have already proven the bound. ## Why -The field order sits just below `2^64` (`2^64 - 2^32 + 1`), so an out-of-range `u64` is reduced only for a narrow band of large values — most tests pass and production hits the bad input as a value mismatch far from the call. `Felt::from(u32)` cannot exceed the field, and `Felt::new` / `Felt::try_from` force the bound check and surface overflow as a `FeltFromIntError`. +The field modulus sits just below `2^64`, so `Felt::new` truncates only for a narrow band of large values — most tests pass and production hits the bad input as a value mismatch far from the call. `Felt::from(u32)` cannot truncate and `Felt::try_from` forces the bound check. ## Examples @@ -29,10 +28,6 @@ let f = Felt::from(slot_index as u32); // Good: untrusted u64 input, checked conversion let f = Felt::try_from(user_value).map_err(|_| Error::FeltOverflow)?; -// Good: equivalent checked constructor -let f = Felt::new(user_value).map_err(|_| Error::FeltOverflow)?; - -// Bad: stores any u64 raw with no reduction; a value >= Felt::ORDER -// yields a non-canonical Felt that does not equal user_value -let f = Felt::new_unchecked(user_value); +// Bad: silent truncation on any value >= MODULUS +let f = Felt::new(user_value); ``` diff --git a/skills/frontend-pitfalls/SKILL.md b/skills/frontend-pitfalls/SKILL.md index f99ede9..dfdfbbd 100644 --- a/skills/frontend-pitfalls/SKILL.md +++ b/skills/frontend-pitfalls/SKILL.md @@ -1,72 +1,123 @@ --- 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. +description: Critical pitfalls and safety rules for Miden frontend development. Covers client-readiness gating, WASM concurrency and pointer lifetimes, COOP/COEP for the multi-threaded build, BigInt boundaries, Bech32 network prefixes, IndexedDB loss, auto-sync side effects, the Web Worker shim, structured error codes, eager vs lazy entry points, and Vite configuration. Use when reviewing, debugging, or writing Miden frontend code. --- # Miden Frontend Pitfalls -## FP1: WASM Initialization Race (CRITICAL) +Every claim below is verified against `web-sdk` at tag `v0.16.0-rc.7`. Pin exactly: -Components that use Miden hooks before MidenProvider finishes WASM initialization will crash. +```json +{ + "dependencies": { + "@miden-sdk/miden-sdk": "0.16.0-rc.7", + "@miden-sdk/react": "0.16.0-rc.7" + } +} +``` + +Caret/tilde ranges over a plain `0.16.0` (`"^0.16.0"`, `"~0.16.0"`, `"0.16.x"`) do **not** match a prerelease version — npm excludes prereleases from ranges that do not themselves name one. Use the exact string above, or `"^0.16.0-rc.7"` (which is what the SDK's own example wallet uses in `packages/react-sdk/examples/wallet/package.json`). + +## FP1: `useMidenClient()` Throws on Render Before the Client Is Ready (HIGH) + +`MidenProvider` initializes WASM asynchronously and flips `isReady` only when the client is set. Which hooks that hurts is not uniform, and the distinction is the whole point: + +- **Query hooks are safe and self-healing.** `useAccounts`, `useAccount`, `useNotes`, `useTransactionHistory`, … read the Zustand store, and their fetch bodies begin `if (!client || !isReady) return;`. Rendered before readiness they hand back empty arrays / `null` with `error: null` — but their effects are **keyed on `isReady`**, so the moment it flips they refetch on their own. `useAccounts` runs `if (isReady && accounts.length === 0) refetch()` with deps `[isReady, accounts.length, refetch]`; `useNotes` and `useAccount` follow the same shape. You get a brief empty render, not a stuck one, and no `isReady` gate is needed for correctness. (`MidenProvider` in fact fetches accounts *before* calling `setClient`, and its own comment records that `setClient` is what atomically sets `isReady = true`.) +- **Mutation hooks throw only when invoked.** `useSend`, `useMint`, `useConsume`, `useCreateWallet`, `useCreateFaucet`, `useSwap`, … throw `Error("Miden client is not ready")` from inside the mutate call, so an early render is harmless — an early *click* is not. +- **`useMidenClient()` throws on render.** Message: `"Miden client is not ready. Make sure you are inside a MidenProvider and the client has initialized."` This is the one that takes a component down, so any component reaching for the raw client must be gated or mounted below a gate. +- **`useMiden()` throws `"useMiden must be used within a MidenProvider"`** when there is no provider above it. ```tsx -// WRONG — renders empty before WASM is ready +// FINE — renders "0" for one paint, then populates when isReady flips. +// The hook's own effect refetches; no readiness gate is required here. +// Reading isLoading is a UX nicety, not a correctness fix. function App() { - const { accounts } = useAccounts(); // returns empty arrays before WASM is ready + const { accounts } = useAccounts(); return
{accounts.length}
; } -// CORRECT — use loadingComponent or check isReady +// WRONG — throws on the first render pass +function Raw() { + const client = useMidenClient(); // Error: Miden client is not ready + return null; +} + +// CORRECT — let the provider hold the tree back Loading WASM...

} + errorComponent={(err) =>

Init failed: {err.message}

} >
-// CORRECT — guard with isReady +// CORRECT — gate explicitly where you need finer control function App() { - const { isReady } = useMiden(); - if (!isReady) return

Loading...

; + const { isReady, isInitializing, error } = useMiden(); + if (error) return

{error.message}

; + if (!isReady || isInitializing) return

Loading...

; return ; } ``` -## FP2: Recursive WASM Access Crash (CRITICAL) +`loadingComponent` is rendered **only while `isInitializing` is true** and `errorComponent` **only when init failed** — if you pass neither, the provider renders children immediately and you are responsible for the `isReady` gate. -The WASM client is single-threaded. Concurrent calls crash with "recursive use of an object detected". +Source: `packages/react-sdk/src/context/MidenProvider.tsx`, `packages/react-sdk/src/hooks/useAccounts.ts`. -```tsx -// WRONG — two operations running simultaneously -const handleClick = async () => { - sync(); // fires async - await send({ ... }); // runs concurrently — CRASH -}; +## FP2: Interleaving Multi-Step Client Sequences (HIGH) + +**A single concurrent call is safe.** The client serializes itself in layers, so `sync()` racing one `client.getAccount()` does not crash: -// CORRECT — use runExclusive for sequential execution +1. **Layer 1 — in-process call chain.** The `WebClient` wrapper queues WASM calls on a per-instance promise chain (`_serializeWasmCall`). Methods it does not wrap explicitly are still routed onto the chain by its `Proxy` fallback; the only exceptions are the entries in its `SYNC_METHODS` set, which are documented as safe to bind raw. Its own comment names the panic this prevents: `"recursive use of an object detected"` (wasm-bindgen's internal `RefCell`). +2. **Layer 2 — Web Locks.** Exactly three entry points run under `withSyncLock(dbId, methodId, fn)`: `syncState`, `syncChain` and `syncNoteTransport` (on both `WebClient` and `MockWebClient`, so six call sites). It coalesces concurrent calls of the *same* method into one shared promise and serializes *different* methods on the same database — **across browser tabs**. Where the Web Locks API is unavailable it degrades to an in-process per-database promise chain. Note that `fetchPrivateNotes` is **not** among them: it has no JS wrapper, so it gets Layer 1 serialization only, with no Web Lock and no cross-tab coalescing. +3. **Layer 3 — cross-tab state change.** `client.onStateChanged(cb)` (available when `BroadcastChannel` is) fires when another tab mutates the store; `MidenProvider` subscribes and refreshes the Zustand store so the UI re-renders. The client has already auto-synced its own Rust state by then. + +On top of that, the client runs its WASM in a Web Worker by default, and the worker's message queue is itself sequential (see FP10). + +**What still breaks** is a *sequence* of calls you intended to be atomic. The layers serialize individual calls, not your multi-call flow: another caller (auto-sync, a second hook, another tab) can land between two of your calls. That matters most for WASM object lifetimes. The SDK's own diagnostic for this is `MidenError` with `code: "WASM_POINTER_CONSUMED"`, whose message reads: *"WASM object was already consumed. Some WASM-bound objects can only be passed once — if you need to reuse a value, create a fresh instance before each call."* + +`runExclusive` from `useMiden()` is the tool for exactly this. It is an in-React `AsyncLock` (`packages/react-sdk/src/utils/asyncLock.ts`) shared by everything under one `MidenProvider`, and it exists — per its own source comment — "for advanced consumers who need to serialize custom multi-step operations against the client." + +Two habits, both taken from how the SDK's own `useSend` is written: + +1. **Construct WASM objects inside the exclusive block, one fresh instance per call.** `useSend` creates all its `AccountId` objects inside `runExclusive` with the comment "to avoid stale pointers if another exclusive operation runs between creation and consumption," and re-parses the recipient rather than reusing an earlier instance. +2. **Read out primitives before the call that may invalidate an object.** `useSend` saves the transaction id as a hex string *before* `applyTransaction`, noting that the call "consumes the WASM pointer inside `txResult` (and any child objects like `TransactionId`)." + +```tsx +// WRONG — a sync (or another hook, or another tab) can land between the two +// calls, and one WASM object instance is reused across that gap const client = useMidenClient(); +const id = AccountId.fromHex(hex); +const account = await client.getAccount(id); +const notes = await client.getConsumableNotes(id); + +// CORRECT — one exclusive block, one fresh instance per call const { runExclusive } = useMiden(); -await runExclusive(async () => { - await client.syncState(); - // now safe to do next operation -}); +const { account, notes } = await runExclusive(async () => ({ + account: await client.getAccount(AccountId.fromHex(hex)), + notes: await client.getConsumableNotes(AccountId.fromHex(hex)), +})); ``` -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. +Most built-in mutation hooks already route their client calls through this same `runExclusive`, so hook-driven mutations serialize against each other. Reach for `runExclusive` yourself when you mix direct `useMidenClient()` calls with hook mutations, or when a sequence of raw client calls must not be interleaved. + +`MidenProvider.sync()` is deliberately **not** wrapped in `runExclusive` — the client's own layers cover it. + +Source: `crates/web-client/js/index.js`, `crates/web-client/js/syncLock.js`, `packages/react-sdk/src/context/MidenProvider.tsx`, `packages/react-sdk/src/utils/asyncLock.ts`, `packages/react-sdk/src/hooks/useSend.ts`, `packages/react-sdk/src/utils/errors.ts`. ## 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: +Cross-origin isolation is **not** a universal requirement. Both `@miden-sdk/miden-sdk` and `@miden-sdk/react` publish four entry points on two axes (eager/lazy × ST/MT) — `.`, `./lazy`, `./mt`, `./mt/lazy` — and the requirement follows 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. +- The **default** entries (`@miden-sdk/react`, `@miden-sdk/react/lazy`, `@miden-sdk/miden-sdk`, `@miden-sdk/miden-sdk/lazy`) ship **single-threaded (ST)** WASM that, in the SDK README's words, "loads in any browser context" — **no COOP/COEP required**. The SDK's own example wallet runs `MidenProvider` off the default `@miden-sdk/react` with a bare `midenVitePlugin()` and no isolation. +- The **MT** entries (`/mt`, `/mt/lazy`, wasm-bindgen-rayon, ~3–5× faster local proving) load **only** on a page where `self.crossOriginIsolated === true`. Without `Cross-Origin-Opener-Policy: same-origin` + `Cross-Origin-Embedder-Policy: require-corp` the browser refuses to construct shared memory and `__wbg_init` throws `WebAssembly.Memory: shared memory requires crossOriginIsolated` (or a browser-specific variant). -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. +Pick ST (the default) and you need no headers. Opt into MT only for local proving on a host whose headers you control — and then you must also `await initThreadPool(n)` once at startup, or you have shipped multi-threaded WASM that runs single-threaded. -If you DO opt into the MT build, enable isolation via the Vite plugin explicitly on any route that runs the MT client: +If you do opt into MT, request isolation explicitly; the plugin default is `false` (see FP8): ```ts -// in your app's vite.config.ts — only needed for the MT build +// vite.config.ts — only needed for the MT build import { midenVitePlugin } from "@miden-sdk/vite-plugin"; export default defineConfig({ @@ -74,129 +125,258 @@ export default defineConfig({ }); ``` -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()`. +The plugin injects headers into the Vite **dev and preview servers only**. Set the same headers at your production host. See `vite-wasm-setup` for per-host configs. + +**Gotcha (when isolation is on)**: `require-corp` blocks every cross-origin resource that does not carry `Cross-Origin-Resource-Policy: cross-origin` or CORS — remote images, fonts, iframes, third-party scripts — and `same-origin` COOP nullifies `window.opener`, which breaks OAuth popup flows (this is exactly why the SDK's example wallet leaves isolation off: it pairs `midenVitePlugin()` with `paraVitePlugin()`). If you cannot set headers at all, the SDK README points at the `gzuidhof/coi-serviceworker` shim as a deliberate opt-in, not a bundled feature. Do not enable isolation globally as a convenience — scope it to the routes that actually load MT. -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). +Source: `crates/web-client/README.md`, `crates/web-client/package.json`, `packages/react-sdk/package.json`, `packages/react-sdk/examples/wallet/vite.config.ts`. -**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 Low-Level `WasmWebClient` Boundary (HIGH) -## FP4: BigInt at the Raw WASM Boundary (HIGH) +`number` does **not** fail at the React hook layer, and it does not fail at the high-level `MidenClient` layer either: -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. +- React SDK option types take `bigint | number`: `SendOptions.amount`, `MintOptions.amount`, `SwapOptions.offeredAmount` / `requestedAmount`, `CreateFaucetOptions.maxSupply`, … and hooks coerce (`useCreateFaucet` calls `BigInt(options.maxSupply)`). +- The high-level `MidenClient` resource API also declares `number | bigint` and coerces internally: `SendOptions.amount` and `MintOptions.amount` on `client.transactions`, `FaucetCreateOptions.maxSupply` on `client.accounts.create({ type: AccountType.FungibleFaucet, ... })`, and the swap/pswap amounts. + +Strict `bigint` applies only at the **low-level `WasmWebClient`** methods — `newSendTransactionRequest`, `newMintTransactionRequest`, `newSwapTransactionRequest`, etc. — which take a `JsU64` with no coercion. ```tsx -// FINE at the React-SDK hook layer — number is coerced +// FINE — React hooks and the high-level MidenClient coerce number → bigint await send({ from, to, assetId, amount: 1000 }); await createFaucet({ maxSupply: 1000000, ... }); -// ALSO FINE — pass bigint directly (preferred; avoids precision loss above 2^53) +// PREFERRED everywhere — bigint 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) +// REQUIRED — low-level WasmWebClient methods take bigint only. +// (sender, target, faucetId, noteType, amount, recallHeight?, timelockHeight?) +await client.newSendTransactionRequest(fromId, toId, faucetId, noteType, 1000n); -// CORRECT — use parseAssetAmount for user input (decimal string → bigint) +// CORRECT — user input is a decimal string, not a number import { parseAssetAmount } from "@miden-sdk/react"; -const amount = parseAssetAmount(inputValue, 8); // string → bigint +const amount = parseAssetAmount(inputValue, 8); // (input: string, decimals?: number) => 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`. +Prefer `bigint` regardless: a `number` above `2^53` has already lost precision before it reaches the coercion, so large supplies and amounts must be `bigint` or a decimal string through `parseAssetAmount`. Render with `formatAssetAmount`. + +**Gotcha**: `JSON.stringify` cannot serialize `bigint`. Use a replacer or stringify first. The SDK's `StorageResult.toJSON()` returns a string for precisely this reason, and its `valueOf()` throws `RangeError` above `Number.MAX_SAFE_INTEGER`. -**Gotcha**: `JSON.stringify` cannot serialize `bigint`. Use a custom replacer or convert to string first. +Source: `packages/react-sdk/src/types/index.ts`, `packages/react-sdk/src/utils/amounts.ts`, `crates/web-client/js/types/api-types.d.ts`, `crates/web-client/js/types/index.d.ts`. ## FP5: Bech32 Network Mismatch (HIGH) -Bech32-encoded account IDs include the network. A devnet address on testnet points to a different or nonexistent account. +Bech32 account addresses carry a human-readable prefix (HRP) that identifies the network. The real HRPs are: + +| Network | HRP | Example address shape | +|---------|-----|-----------------------| +| Mainnet | `mm` | `mm1...` | +| Testnet | `mtst` | `mtst1...` | +| Devnet | `mdev` | `mdev1...` | +| Custom | operator-chosen prefix | — | + +There is no `miden1...` prefix. A `mdev1...` address used against testnet points at a different or nonexistent account. ```tsx -// WRONG — hardcoding a bech32 address used across networks -const ADMIN = "miden1qy35..."; // this is network-specific! +// WRONG — a network-specific address baked into a constant +const ADMIN = "mtst1..."; // breaks the moment the app points at devnet -// CORRECT — use hex format for cross-network compatibility +// CORRECT — hex for constants, it is network-agnostic const ADMIN = "0x1234567890abcdef"; -// CORRECT — derive bech32 per network -account.bech32id(); // returns correct bech32 for current network +// CORRECT — derive bech32 for display, per network +account.bech32id(); // installed on Account.prototype by @miden-sdk/react ``` -Both hex and bech32 formats work in all hooks. Prefer hex for constants, bech32 for display. +Both hex and bech32 are accepted everywhere hooks take an account reference. Prefer hex for constants, bech32 for display. + +**Gotcha — the HRP is inferred from your `rpcUrl` string, not from the chain.** `bech32id()` lowercases the resolved `rpcUrl` and looks for the substrings `devnet`/`mdev`, `mainnet`, then `testnet`/`mtst`; **anything else falls back to testnet.** `MidenConfig.rpcUrl` resolves the shorthands `"testnet"`, `"devnet"` and `"localhost"`/`"local"` to concrete URLs and passes any other value through verbatim — so a private RPC endpoint whose hostname contains none of those substrings (or `"localhost"`, which resolves to `http://localhost:57291`) will silently render `mtst1...` addresses. If you run a custom or local network, do not treat `bech32id()` output as authoritative; key off hex. + +Source: `protocol:v0.16.0-rc.9:crates/miden-protocol/src/address/network_id.rs`, `crates/web-client/src/models/account_id.rs`, `packages/react-sdk/src/utils/accountBech32.ts`, `packages/react-sdk/src/utils/network.ts`. ## FP6: Auto-Sync Side Effects (MEDIUM) -Default `autoSyncInterval` is 15000ms (15 seconds). Each sync triggers re-renders in useAccounts, useAccount, useNotes, etc. +`DEFAULTS.AUTO_SYNC_INTERVAL` is `15000` (15 seconds). Each sync writes to the Zustand store, and every query hook subscribed to it re-renders. ```tsx -// PROBLEM — form resets every 15 seconds because parent re-renders +// PROBLEM — form state resets every 15s because the parent re-renders - {/* re-renders on every sync */} + -// SOLUTION 1 — preferred: use stable keys and memoization +// SOLUTION 1 — preferred: stable keys and memoization const MemoizedForm = React.memo(SendForm); -// SOLUTION 2 — disable auto-sync for manual control +// SOLUTION 2 — pause sync for the duration of a sensitive interaction +const { pauseSync, resumeSync, isPaused } = useSyncControl(); + +// SOLUTION 3 — last resort: disable auto-sync entirely and drive it yourself ``` +Prefer `useSyncControl()` over `autoSyncInterval: 0` for transient stability: it toggles a store flag, manual `useSyncState().sync()` still works while paused, and you do not have to rebuild your own sync loop. It is also the right lever during long local proving, where sync would otherwise compete for the WASM queue. + +Source: `packages/react-sdk/src/types/index.ts`, `packages/react-sdk/src/hooks/useSyncControl.ts`, `packages/react-sdk/src/store/MidenStore.ts`. + ## 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. +The client persists accounts, keys, notes and transaction history in IndexedDB. Two distinct ways to lose all of it: + +1. **The user or the browser deletes it** — "Clear site data", private browsing, storage pressure. +2. **An SDK version bump deletes it.** On open, `ensureClientVersion` compares the running client version against the one stored in the database. If the running version's **major or minor** is higher, the store closes, `delete()`s and reopens empty. Patch upgrades are preserved and handled by Dexie migrations; a minor upgrade is a deliberate nuke tied to network resets. **Upgrading the SDK across a minor version destroys every locally-stored account, key and note on every user's device.** + +Mitigations: -- 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 +- Warn users that clearing browser data deletes their wallet. +- Ship backup/restore *before* you ship an SDK minor upgrade. The surface is `useExportStore()` / `useImportStore()` in `@miden-sdk/react`, backed by the standalone `exportStore(storeName)` / `importStore(storeName, dump)` from `@miden-sdk/miden-sdk`. Per-object export/import also exists on the high-level client (`accounts.export` / `accounts.import`, `notes.export` / `notes.import`). +- Consider external signers (Para, Turnkey, wallet adapters) for production — the key material lives outside the browser store, so only cached chain state is lost. +- Each signer identity gets its own database (`MidenClientDB_`), so `SignerContextValue.storeName` must be unique per user. -## FP8: Vite Configuration Requirements (MEDIUM) +Source: `crates/idxdb-store/src/ts/schema.ts`, `packages/react-sdk/src/hooks/useExportStore.ts`, `packages/react-sdk/src/hooks/useImportStore.ts`, `packages/react-sdk/src/context/MidenProvider.tsx`. -The `@miden-sdk/vite-plugin` package handles all Miden-specific Vite config. The recommended pattern for any new Miden app is: +## FP8: Vite Configuration (MEDIUM) + +Use `@miden-sdk/vite-plugin` rather than hand-rolling WASM config. The bare call is correct for the default ST build: ```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. +It accepts **four** options: + +| Option | Source default | Purpose | +|--------|----------------|---------| +| `wasmPackages` | `["@miden-sdk/miden-sdk"]` | Packages to alias, dedupe, and exclude from pre-bundling | +| `crossOriginIsolation` | `false` | Emit COOP `same-origin` + COEP `require-corp` on the dev **and** preview servers. Set `true` only for the MT build (FP3) | +| `rpcProxyTarget` | `"https://rpc.testnet.miden.io"` | gRPC-web dev proxy target; `false` disables the proxy | +| `rpcProxyPath` | `"/rpc.Api"` | Path prefix the proxy intercepts | + +**Do not trust the plugin README on `crossOriginIsolation`.** At `v0.16.0-rc.7` the README documents the default as `true`; the executable source is `false`. The source is correct. -| 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 | +Everything else the plugin does — `build.target: "esnext"`, `optimizeDeps.exclude`, `resolve.dedupe` (including React and `@miden-sdk/react`), the esbuild externalization that keeps React context identity intact, worker format — is covered in `vite-wasm-setup`, along with production host configs for Nginx, Vercel and Cloudflare. Go there rather than duplicating it here. -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. +Source: `packages/vite-plugin/src/index.ts`, `packages/vite-plugin/README.md`. ## 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. +React StrictMode double-invokes effects in development (React 18+; the React SDK's peer dep is `react >= 18.0.0`). `MidenProvider` guards against it with an `isInitializedRef` plus a `cancelled` flag, and wraps the whole init in `runExclusive` so a second mount queues behind an in-flight init instead of racing it. Manual low-level `createClient()` calls have no such guard and 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. +Naming, so you know what you are holding: + +- `@miden-sdk/miden-sdk` exports a high-level `MidenClient` — the recommended entry point. +- The low-level client is the class named `WebClient` in source, exported under the alias `WasmWebClient`. Its type declaration is marked `@internal` and says "Low-level WebClient wrapper. Use MidenClient instead." +- The React SDK does its own low-level init via `import { WasmWebClient as WebClient } from "@miden-sdk/miden-sdk"`, and `useMidenClient()` returns that object. ```tsx -// WRONG — manual low-level client creation in useEffect +// WRONG — manual low-level client creation in an effect; runs twice in dev useEffect(() => { - const client = await WasmWebClient.createClient(url); // called twice in dev + const client = await WasmWebClient.createClient(url); }, []); // CORRECT — always use MidenProvider ``` +If you genuinely need the low-level constructors, the signatures are: + +```ts +WasmWebClient.createClient( + rpcUrl?, noteTransportUrl?, seed?, storeName?, logLevel?, useWorker? +): Promise + +WasmWebClient.createClientWithExternalKeystore( + rpcUrl?, noteTransportUrl?, seed?, storeName?, + getKeyCb?, insertKeyCb?, signCb?, logLevel?, useWorker? +): Promise +``` + +There is no debug-mode argument and no `ClientOptions.debugMode`. + +Source: `packages/react-sdk/src/context/MidenProvider.tsx`, `packages/react-sdk/src/index.ts`, `crates/web-client/js/types/index.d.ts`, `packages/react-sdk/package.json`. + +## FP10: The Web Worker Shim Silently Downgrades Callback Provers (HIGH) + +`useWorker` defaults to **`true`**: the client spawns a Web Worker and dispatches WASM calls to it, keeping the main thread responsive. That is the right default in browsers and extensions — but the worker boundary serializes the prover argument via `TransactionProver.serialize()`, and **that format has no encoding for `newCallbackProver(jsFn)`, so it silently downgrades to the local prover.** Your callback never fires and nothing errors. + +```tsx +import { TransactionProver } from "@miden-sdk/miden-sdk"; + +// WRONG — the worker serializes this prover, loses the callback, proves locally +const prover = TransactionProver.newCallbackProver(nativeProveFn); + + +// CORRECT — opt out of the worker so the prover handle reaches WASM intact + +``` + +Set `useWorker: false` when: + +- You pass a prover built with `TransactionProver.newCallbackProver(jsFn)` (a native iOS/Android prover behind a Capacitor plugin, or any JS-side prover bridge). +- You are embedding in a single-WebView native shell (Capacitor host, Tauri, Electron preload) where the UI thread is not competing with WASM anyway. + +Note that a callback prover is **not** expressible through `MidenConfig.prover` / `ClientOptions.proverUrl` — neither accepts one. `ClientOptions.proverUrl` is a **string only** (`"local" | "devnet" | "testnet"` or a raw remote-prover URL); only `MidenConfig.prover` takes the object forms (`{ url, timeoutMs }` / `{ primary, fallback }`), and its target set also includes `"localhost"`. A `CallbackProver` object has to be handed to the prove call directly. + +`MidenConfig.useWorker` is forwarded to both `createClient` and `createClientWithExternalKeystore`. `MidenClient.lastAuthError()` is in the same boat: under the worker shim the sign callback fires against the worker's WASM instance while the accessor reads the main-thread one, so it always returns `null` unless `useWorker: false`. + +Note that `usePreview()` runs the VM on the **main thread** regardless — it is not offloaded to the worker — so it blocks the UI for its duration and queues other client calls behind it. + +Source: `crates/web-client/js/index.js`, `crates/web-client/js/client.js`, `packages/react-sdk/src/types/index.ts`, `packages/react-sdk/src/context/MidenProvider.tsx`, `packages/react-sdk/src/hooks/usePreview.ts`. + +## FP11: Branch on `error.code`, Never on Message Text (MEDIUM) + +Errors carry machine-readable codes. Message strings are not a stable API. + +- Codes assigned by the React SDK (`MidenError`, closed union `MidenErrorCode`): `WASM_CLASS_MISMATCH`, `WASM_POINTER_CONSUMED`, `WASM_NOT_INITIALIZED`, `WASM_SYNC_REQUIRED`, `SEND_BUSY`, `OPERATION_BUSY`, `STALE_CLIENT`, `UNKNOWN`. +- Codes assigned by the Rust client and thrown out of WASM (`WasmErrorCode`): `INVALID_CHAIN_ANCHOR`, `TRANSACTION_ALREADY_AUTHORIZED`. This list is deliberately **not** exhaustive of what the client can emit — `CodedError.code` includes a `(string & {})` arm so codes from a newer client stay assignable. Handle the ones you care about and fall through on the rest. + +```tsx +import type { CodedError } from "@miden-sdk/react"; + +try { + await preview({ ... }); +} catch (e) { + const err = e as CodedError; + if (err.code === "TRANSACTION_ALREADY_AUTHORIZED") { + await execute({ ... }); // nothing to authorize — just submit it + } +} +``` + +**Gotcha — `preview()` is not a dry run of the happy path.** `usePreview()` / `client.transactions.preview()` returns a `TransactionSummary` **only while authorization is still pending** (e.g. a multisig below its threshold, where the auth procedure aborts with the unauthorized event). A fully authorized transaction produces no summary and **rejects** with `code: "TRANSACTION_ALREADY_AUTHORIZED"`. A confirmation screen built on "preview then submit" will hit the rejection on every ordinary single-signature send. Use `useTransaction().execute` to submit. + +**Gotcha — on Node the code prefixes the message** instead of being a property, because the napi bindings cannot attach one. + +`WASM_CLASS_MISMATCH` almost always means multiple copies of `@miden-sdk/miden-sdk` are bundled — fix it with `resolve.dedupe` + `optimizeDeps.exclude` (which `midenVitePlugin()` already does; see FP8). + +Source: `packages/react-sdk/src/utils/errors.ts`, `packages/react-sdk/src/hooks/usePreview.ts`, `crates/web-client/js/types/api-types.d.ts`. + +## FP12: The Eager Entry Hangs Under Capacitor and SSR (MEDIUM) + +The default browser entry (`@miden-sdk/miden-sdk`) awaits WASM at **module top level**, so any wasm-bindgen constructor is safe to call on the next line with no readiness gate. That top-level await is a liability in two hosts: + +- **Capacitor / WKWebView**: the `capacitor://localhost` scheme handler hangs module evaluation on top-level await indefinitely. +- **Next.js / SSR**: top-level await blocks server-side module evaluation. + +Import `@miden-sdk/miden-sdk/lazy` (or `@miden-sdk/react/lazy`) there — identical API surface, no top-level await — and await `MidenClient.ready()` before touching wasm-bindgen types. Under `@miden-sdk/react` the provider's `isReady` already is that gate. + +Source: `crates/web-client/js/eager.js`, `crates/web-client/package.json`. + ## 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 | +| FP1 | Client not ready | HIGH | Query hooks are safe — they self-heal when `isReady` flips. `useMidenClient()` throws on render; gate that one | +| FP2 | Interleaved sequences | HIGH | Single calls self-serialize; wrap multi-call sequences in `runExclusive` and rebuild WASM objects inside it | +| FP3 | COOP/COEP | HIGH | Default ST build needs no headers; required ONLY for the `/mt` entries | +| FP4 | BigInt | HIGH | Hooks and `MidenClient` coerce `number`; strict `bigint` only at low-level `WasmWebClient`. Prefer `bigint` | +| FP5 | Bech32 prefix | HIGH | HRPs are `mm` / `mtst` / `mdev` — never `miden1`. Hex for constants, bech32 for display | +| FP6 | Auto-sync | MEDIUM | Default 15000ms; prefer `useSyncControl()` over `autoSyncInterval: 0` | +| FP7 | IndexedDB loss | MEDIUM | A minor SDK bump wipes the store — ship `useExportStore`/`useImportStore` before upgrading | +| FP8 | Vite config | MEDIUM | `midenVitePlugin()` has four options; `crossOriginIsolation` really defaults to `false` | +| FP9 | StrictMode | LOW | Use `MidenProvider`, not manual `WasmWebClient.createClient()` | +| FP10 | Worker shim | HIGH | `useWorker` defaults `true` and silently downgrades callback provers — set `false` when you supply one | +| FP11 | Error handling | MEDIUM | Branch on `error.code`, never message text; `preview()` rejects on already-authorized transactions | +| FP12 | Eager entry | MEDIUM | Use `/lazy` under Capacitor and SSR — top-level await hangs there | diff --git a/skills/frontend-source-guide/SKILL.md b/skills/frontend-source-guide/SKILL.md index ac6d9fb..8342654 100644 --- a/skills/frontend-source-guide/SKILL.md +++ b/skills/frontend-source-guide/SKILL.md @@ -5,15 +5,17 @@ description: Guide for advanced Miden frontend development using source repo exp # Advanced Miden Frontend Development: Source-Guided Context Engineering +Every path and symbol below is verified against `web-sdk` at tag `v0.16.0-rc.7`. + ## 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 +- Explore React SDK source and the example wallet 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 +- 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. @@ -22,31 +24,37 @@ Rule of thumb: if the task involves custom transactions, external signers, or pa 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: +**Type check loop**: After every file edit, run `npx tsc --noEmit` (this is the shape of the SDK's own `typecheck` script in `packages/react-sdk/package.json`). If types fail: + 1. Read the error message -2. Search the React SDK source for the correct type signature or hook usage +2. Search the React SDK source for the correct type signature or hook usage — `packages/react-sdk/src/types/index.ts` is the single source of truth for option and result types 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: +**Dev server loop**: Run the app 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 +2. Branch on `error.code`, not message text — see `frontend-pitfalls` FP11 for the code sets +3. For WASM load errors: check Vite config and (only if you opted into the `/mt` build) COOP/COEP headers — see `vite-wasm-setup` and `frontend-pitfalls` FP3/FP8 +4. For unexpected behavior: compare your code against the example wallet at `packages/react-sdk/examples/wallet/` 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. +The basic skills (`react-sdk-patterns`, `frontend-pitfalls`, `vite-wasm-setup`, `wasm-bridge`) cover standard patterns. For anything beyond those, 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. +- **Pin your reading to the version you are building against.** The SDK moves fast; a pattern read off the default branch may not exist in your installed version. **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" + +- 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 @@ -55,10 +63,10 @@ The basic skills (react-sdk-patterns, frontend-pitfalls, vite-wasm-setup) cover 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 +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 +5. **Transaction UX** — Stage progress (`TransactionStage`), 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. @@ -67,95 +75,127 @@ When stuck at any stage: search the React SDK source for a similar working patte ## Miden Source Repository Map -Clone this repo alongside your project for reference. Claude will explore it when needed for advanced patterns. +Clone the repo at the tag you build against, alongside your project: ```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 +# Contains the React SDK source (@miden-sdk/react), the WASM client bindings +# (@miden-sdk/miden-sdk), the Vite plugin, and a working example wallet. +git clone --depth 1 --branch v0.16.0-rc.7 https://github.com/0xMiden/web-sdk.git ../web-sdk ``` +Workspace layout at the tag: `crates/` holds `web-client`, `idxdb-store`, `js-export-macro`, `mobile-prover`; `packages/` holds `react-sdk`, `vite-plugin`, and the prebuilt `node-sdk-*` binaries. + ### `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. +- **`packages/react-sdk/src/index.ts`** — The public surface. Read this first: it is the authoritative list of what the package exports, and it groups the hooks for you. +- **`packages/react-sdk/src/hooks/`** — **37 hook implementations**, one file each, all re-exported from `index.ts` (10 query hooks + 27 mutation hooks). Each file is self-contained; read it for exact parameters, error handling, and stage progression. + - *Query*: `useAccounts`, `useAccount`, `useNotes`, `useNoteStream`, `useTransactionHistory`, `useSyncState`, `useAssetMetadata`, `usePswapLineages`, `usePswapLineagesFor`, `usePswapLineage` + - *Mutation*: `useCreateWallet`, `useCreateFaucet`, `useImportAccount`, `useSend`, `useMultiSend`, `useWaitForCommit`, `useWaitForNotes`, `useMint`, `useBridge`, `useConsume`, `useSwap`, `usePswapCreate`, `usePswapConsume`, `usePswapCancel`, `usePswapCancelByOrder`, `useCreateNetworkNote`, `useTransaction`, `useChainAnchor`, `usePreview`, `useExecuteProgram`, `useCompile`, `useSessionAccount`, `useExportStore`, `useImportStore`, `useImportNote`, `useExportNote`, `useSyncControl` +- **`packages/react-sdk/src/context/MidenProvider.tsx`** — Client initialization, sync loop, signer detection, the `runExclusive` `AsyncLock`. Read this to understand initialization order. It defines **two** of the four context hooks that live outside `src/hooks/`: `useMiden()` and `useMidenClient()` (the latter returns the low-level `WasmWebClient`, imported here under the local alias `WebClient`). The other two are `useSigner` in `context/SignerContext.ts` and `useMultiSigner` in `context/MultiSignerProvider.tsx`. +- **`packages/react-sdk/src/context/SignerContext.ts`** — External signer interface (`SignerContextValue`, `SignCallback`) and `useSigner()`. Read this when implementing custom signers. +- **`packages/react-sdk/src/context/MultiSignerProvider.tsx`** — `MultiSignerProvider`, `SignerSlot`, `useMultiSigner()`. Read this when an app must offer several signer providers side by side, as the example wallet does. +- **`packages/react-sdk/src/store/MidenStore.ts`** — Zustand store (`useMidenStore`) plus narrow selector hooks. Read this to understand cached state and what triggers re-renders. +- **`packages/react-sdk/src/types/index.ts`** — All option, result, and configuration interfaces, plus the `DEFAULTS` constant. The single source of truth. +- **`packages/react-sdk/src/utils/`** — 17 utility modules: `accountBech32`, `accountId`, `accountParsing`, `amounts`, `asyncLock`, `bytes`, `errors`, `network`, `noteAttachment`, `noteFilters`, `notes`, `prover`, `runExclusive`, `signerAccount`, `storage`, `transactions`, `walletDetection`. +- **`packages/react-sdk/examples/wallet/`** — Complete working wallet app (`src/main.tsx`, `src/App.tsx`, `src/SignerSelector.tsx`, `vite.config.ts`). The most reliable reference for provider setup, account creation, balances, claiming notes, and sending tokens. +- **`packages/react-sdk/README.md`**, **`packages/react-sdk/CLAUDE.md`** — Package-level prose docs. **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 +### `crates/web-client/` — WASM Client Bindings (`@miden-sdk/miden-sdk`) -The Rust-to-WASM bridge that the React SDK wraps. +The Rust-to-WASM bridge that the React SDK wraps. See the `wasm-bridge` skill for how the JS and Rust layers fit together. -- 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 +- **`crates/web-client/js/index.js`** — The JS `WebClient` wrapper exported as `WasmWebClient`, its method-classification sets, the forwarding `Proxy`, and the WASM call-serialization chain. +- **`crates/web-client/js/client.js`** — The high-level `MidenClient` — the recommended entry point, which composes the resource APIs below. +- **`crates/web-client/js/resources/`** — 8 resource modules that make up the high-level API: `accounts.js`, `compiler.js`, `keystore.js`, `notes.js`, `pswap.js`, `settings.js`, `tags.js`, `transactions.js`. +- **`crates/web-client/js/types/api-types.d.ts`** — TypeScript surface for the high-level `MidenClient` and `ClientOptions`. **`crates/web-client/js/types/index.d.ts`** — the package entry types, including the `@internal` `WasmWebClient` declaration. +- **`crates/web-client/js/syncLock.js`** — `withSyncLock`, the Web Locks coalescing/serialization used by the sync entry points. `crates/web-client/js/webLock.js` (`withWriteLock`) and `crates/web-client/js/asyncLock.js` (`AsyncLock`) are neither published (absent from the package's `files` array) nor imported by any entry module, and their only in-tree callers are their own tests — read them for the pattern, but they are not on the hot path. +- **`crates/web-client/js/eager.js`** — Why the default entry uses top-level await and when to import `/lazy` instead. +- **`crates/web-client/src/models/`** — One Rust file per JS-visible model type (`account_id.rs`, `note.rs`, `transaction_request/`, `provers.rs`, …). This is where JS method names and argument types are actually declared, via `#[js_export(js_name = "...")]`. +- **`crates/web-client/src/rpc_client/`** — The standalone `RpcClient` (`getBlockHeaderByNumber`, `getNotesById`, `getAccountDetails`, `getAccountProof`, `syncNotes`, `getNetworkNoteStatus`, …). Exported separately from `@miden-sdk/miden-sdk`; **not** reachable through `useMidenClient()`. +- **`crates/web-client/README.md`** — Entry-point matrix (ST/MT × eager/lazy), cross-origin isolation guidance, `initThreadPool`. -**Explore when**: A hook doesn't exist for your operation, understanding what WasmWebClient methods are available, debugging WASM-level errors. +**Explore when**: A hook doesn't exist for your operation, working out what methods the client actually exposes, 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. +- **`crates/idxdb-store/src/ts/schema.ts`** — Dexie schema, version blocks, and `ensureClientVersion` (the routine that deletes the database on a major/minor client-version bump — see `frontend-pitfalls` FP7). +- **`crates/idxdb-store/src/ts/`** — Per-table logic: `accounts.ts`, `auth.ts`, `chainData.ts`, `notes.ts`, `settings.ts`, `sync.ts`, `transactions.ts`, plus `export.ts` / `import.ts` for store dumps. + +**Explore when**: Debugging data persistence issues, understanding what's stored in IndexedDB, investigating storage isolation for external signers. See also the `idxdb-patterns` skill. --- ## What to Explore for Each Pattern +Paths are relative to the repo root. + | 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 | +| Basic wallet UI | `packages/react-sdk/examples/wallet/` | `MidenProvider` setup, `useAccounts`, `useSend` | +| Custom transaction | `packages/react-sdk/src/hooks/useTransaction.ts` | Request-factory pattern, the execute → prove → submit → apply pipeline | +| External signer | `packages/react-sdk/src/context/SignerContext.ts` | `SignerContextValue` interface, `signCb`, `storeName` | +| Several signers in one app | `packages/react-sdk/src/context/MultiSignerProvider.tsx` | `SignerSlot`, `useMultiSigner` | +| Note consumption flow | `packages/react-sdk/src/hooks/useConsume.ts` | `NoteId` parsing, `NoteFilter` construction | +| Swap UI | `packages/react-sdk/src/hooks/useSwap.ts` | `SwapOptions`, separate `noteType` and `paybackNoteType` | +| Partial swap (PSWAP) UI | `packages/react-sdk/src/hooks/usePswapCreate.ts`, `usePswapConsume.ts`, `usePswapCancel.ts`, `usePswapCancelByOrder.ts` | Partial-fill swap flow: create, consume, cancel | +| PSWAP order tracking | `packages/react-sdk/src/hooks/usePswapLineage.ts`, `usePswapLineages.ts`, `usePswapLineagesFor.ts` | Lineage records for partially-filled orders | +| Token display | `packages/react-sdk/src/utils/amounts.ts` | `formatAssetAmount`, `parseAssetAmount` | +| Account ID formatting | `packages/react-sdk/src/utils/accountBech32.ts` | `toBech32AccountId`, `bech32id()` prototype install | +| Network / RPC resolution | `packages/react-sdk/src/utils/network.ts` | Which `rpcUrl` shorthands resolve, and which pass through | +| State management | `packages/react-sdk/src/store/MidenStore.ts` | Zustand selectors, cached state | +| Direct `WasmWebClient` usage | `packages/react-sdk/src/context/MidenProvider.tsx` | `useMidenClient()`, `runExclusive` | +| Multi-step workflow | `packages/react-sdk/src/hooks/useWaitForCommit.ts`, `useWaitForNotes.ts` | Polling loops, `timeoutMs` / `intervalMs` defaults | +| Prover selection & fallback | `packages/react-sdk/src/utils/prover.ts` | `resolveTransactionProver`, `proveWithFallback` | +| Structured error handling | `packages/react-sdk/src/utils/errors.ts` | `MidenError`, `MidenErrorCode`, `WasmErrorCode`, `CodedError` | +| Backup / restore | `packages/react-sdk/src/hooks/useExportStore.ts`, `useImportStore.ts` | `exportStore(storeName)`, `importStore(storeName, dump)` | +| Pausing background sync | `packages/react-sdk/src/hooks/useSyncControl.ts` | `pauseSync`, `resumeSync`, `isPaused` | +| Compiling MASM from the browser | `packages/react-sdk/src/hooks/useCompile.ts`, `crates/web-client/js/resources/compiler.js` | `CompilerResource`; `component()` / `txScript()` / `noteScript()` | --- ## 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()`: +### Custom Hooks Wrapping `WasmWebClient` + +For operations not covered by built-in hooks, write a custom hook over `useMidenClient()`. It returns the low-level `WasmWebClient`, so only call methods that exist on it — `crates/web-client/js/index.js` lists many of them in its `SYNC_METHODS` / `WRITE_METHODS` / `READ_METHODS` sets — but those sets are **not** exhaustive. `check-method-classification.js` also accepts a method defined as an explicit wrapper on the JS `WebClient` class, which is how `syncState`, `syncChain`, `syncNoteTransport`, `executeTransaction`, `proveTransaction`, `applyTransaction`, `newWallet`, `newFaucet`, `newAccount` and the `submitNewTransaction*` pair are reachable while appearing in none of the three sets. Check the class body too. + +Wrap **multi-call sequences** in `runExclusive` so nothing interleaves between your calls (see `frontend-pitfalls` FP2); a lone call already serializes itself. + ```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); - }); - }, []); + // getSyncHeight is a READ_METHOD forwarded through the client proxy, + // which serializes it — no runExclusive needed for a single call. + client.getSyncHeight().then(setHeight); + }, [client]); 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: +Some operations are **not** on the client returned by `useMidenClient()` — block headers, for instance. `getBlockHeaderByNumber` lives on the standalone `RpcClient`, 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 +// Endpoint: new Endpoint(url), or Endpoint.testnet() / Endpoint.devnet() +const rpc = new RpcClient(Endpoint.testnet()); + +// getBlockHeaderByNumber(blockNum?: number, includeMmrProof?: boolean) const header = await rpc.getBlockHeaderByNumber(blockNumber, false); ``` ### Multi-Step Workflows -Compose hooks for complex flows (mint → wait for commit → sync → consume): + +Compose hooks for complex flows (mint → wait for commit → wait for notes → consume). `useMint` resolves to `TransactionResult { transactionId: string }`; `waitForConsumableNotes` resolves to `ConsumableNoteRecord[]`, and `ConsumeOptions.notes` accepts `InputNoteRecord` objects, so unwrap each record with `.inputNoteRecord()`: + ```tsx const { mint } = useMint(); const { waitForCommit } = useWaitForCommit(); @@ -163,12 +203,22 @@ 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: [...] }); + const { transactionId } = await mint({ targetAccountId, faucetId, amount: 1000n }); + await waitForCommit(transactionId); // default 10s timeout, 1s poll + const records = await waitForConsumableNotes({ accountId: targetAccountId }); + await consume({ + accountId: targetAccountId, + notes: records.map((r) => r.inputNoteRecord()), + }); }; ``` +Both wait hooks default to `timeoutMs: 10000` and `intervalMs: 1000`; `waitForConsumableNotes` also takes `minCount` (default `1`). Raise the timeout for slow networks rather than looping the hook yourself. + ### 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. + +Implement `SignerContextValue` and wrap `MidenProvider` in your provider. Read `packages/react-sdk/src/context/SignerContext.ts` for the exact contract: `signCb` is required; `getKeyCb` / `insertKeyCb` are optional; `accountConfig`, `storeName`, `name`, `isConnected`, `connect`, and `disconnect` complete the interface. + +`storeName` must be unique per user — `MidenProvider` derives the IndexedDB database name from it (`MidenClientDB_`), so a shared value would let two identities share one store. + +To offer several signers in one app, use `MultiSignerProvider` + `SignerSlot`, as `packages/react-sdk/examples/wallet/src/main.tsx` does. diff --git a/skills/idxdb-patterns/SKILL.md b/skills/idxdb-patterns/SKILL.md index 62ea474..902b21a 100644 --- a/skills/idxdb-patterns/SKILL.md +++ b/skills/idxdb-patterns/SKILL.md @@ -11,13 +11,16 @@ crate `crates/idxdb-store`, package `miden-idxdb-store`), not in 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. +pairs to support account-history pruning. Always check +`crates/idxdb-store/src/ts/schema.ts` for the canonical table list before +adding rows or filters. The full set, in declaration order, is: +`AccountCode`, `LatestAccountStorage`, `HistoricalAccountStorage`, +`LatestAccountAssets`, `HistoricalAccountAssets`, `LatestStorageMapEntries`, +`HistoricalStorageMapEntries`, `AccountAuth`, `AccountKeyMapping`, +`LatestAccountHeaders`, `HistoricalAccountHeaders`, `Addresses`, +`Transactions`, `TransactionScripts`, `InputNotes`, `OutputNotes`, +`NotesScripts`, `BlockchainCheckpoint`, `BlockHeaders`, +`PartialBlockchainNodes`, `Tags`, `ForeignAccountCode`, `Settings`. ## Build Workflow @@ -37,6 +40,8 @@ top-level Make target (which runs the package's `build` script through ```bash make rust-client-ts-build # == pnpm --filter web_store run build +make rust-client-ts-lint # == pnpm --filter web_store run lint +make test-idxdb-store # == pnpm --filter web_store exec vitest run --coverage ``` The underlying package script is `tsc --build --force ./tsconfig.json` @@ -115,8 +120,8 @@ export interface IHistoricalAccountStorage { export interface ILatestAccountAsset { accountId: string; - vaultKey: string; // ASSET_KEY — see `miden-concepts` skill - asset: string; // ASSET_VALUE serialized + vaultKey: string; // holds the asset's AssetId — see below + asset: string; // the encoded asset } export interface IHistoricalAccountAsset { @@ -140,6 +145,24 @@ export interface IAccount { } ``` +The single chain-progress row is `IBlockchainCheckpoint`. There is no +`stateSync` table and no `IStateSync` interface: + +```typescript +export interface IBlockchainCheckpoint { + id: number; + blockNum: number; + partialBlockchainPeaks: Uint8Array; +} +``` + +Dexie's `populate` hook seeds it once, on first database creation only: +`{ id: 1, blockNum: 0, partialBlockchainPeaks: new Uint8Array() }`. +The MMR peaks live on this row, **not** on the tip block header — +`IBlockHeader` is only `{ blockNum, header, hasClientNotes }`. A sync +therefore writes the header and the peaks to two different tables inside +one transaction (`crates/idxdb-store/src/ts/sync.ts`). + Rules: - Use `string` for hex-encoded values (hashes, IDs, commitments, nonces, vault keys) @@ -148,13 +171,21 @@ Rules: 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. +- The LATEST account-header table keys on `&id` (with `accountCommitment` + as a secondary index); the HISTORICAL account-header table keys on + `&accountCommitment` (with `id` and `[id+replacedAtNonce]` as secondary + indexes). The `&` marks a unique index. Of the rest, only + `foreignAccountCode` is keyed on `accountId` alone; the storage, asset and + map-entry tables use **compound** primary keys with `accountId` merely as a + secondary index — `[accountId+slotName]`, `[accountId+vaultKey]` and + `[accountId+slotName+key]` respectively. Don't confuse the two. +- The asset layer is two-column: `vaultKey` holds the asset's **`AssetId`** + (`crates/idxdb-store/src/account/js_bindings.rs` writes + `asset.id().to_string()`, and removals write `asset_id.to_string()` from + `patch.vault().removed_asset_ids()` in + `crates/idxdb-store/src/account/utils.rs`), while `asset` holds the + encoded asset. The column name `vaultKey` is legacy and was deliberately + **not** renamed, so a blind find-replace over it is wrong. ## Table Enum @@ -181,7 +212,7 @@ enum Table { InputNotes = "inputNotes", OutputNotes = "outputNotes", NotesScripts = "notesScripts", - StateSync = "stateSync", + BlockchainCheckpoint = "blockchainCheckpoint", BlockHeaders = "blockHeaders", PartialBlockchainNodes = "partialBlockchainNodes", Tags = "tags", @@ -190,28 +221,53 @@ enum Table { } ``` -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. +## Schema Versioning and Migrations + +The `MidenDatabase` constructor holds a **chain of Dexie version blocks**, +not a single one: + +```typescript +this.dexie.version(1).stores(V1_STORES); + +// v2: data-only fix — no index changes, so .stores({}) is empty. +this.dexie + .version(2) + .stores({}) + .upgrade(async (tx) => { /* prune leaked note tags */ }); +``` + +`V1_STORES` is the **frozen** baseline. Its in-file comment says exactly: +"Version blocks exist below, so V1_STORES is frozen — never modify it; +add a new version block instead." 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: +Migrations coexist with a separate client-version reset. `ensureClientVersion` +closes / `delete`s / re-opens the database 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 (the `sameMajorMinor` / +`!semver.gt(...)` guard). An empty `clientVersion` skips enforcement +entirely, and an unparseable semver on either side forces a reset. The +client version is `CLIENT_VERSION = env!("CARGO_PKG_VERSION")` in +`crates/idxdb-store/src/lib.rs`, persisted under the exported +`CLIENT_VERSION_SETTING_KEY = "clientVersion"`. + +**Consequence to state up front in any upgrade plan:** because a minor +client-version bump triggers the reset, shipping an app across a minor SDK +version destroys every locally-stored account, key and note in the user's +browser. Dexie version blocks only cover stores that survive patch upgrades. + +Adding a table or changing an index 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) +2. Add a new `.version(N+1).stores({…}).upgrade(tx => {…})` block. List + only the tables whose indexes changed — Dexie carries the rest forward. + Set a table to `null` to remove it. Index-only changes may omit + `.upgrade()`. **Never modify `V1_STORES` or any previous version block.** + Note that `populate` fires only on first creation, never during upgrades. 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`) + functions from `/src/js/schema.js`, sync functions from `/src/js/sync.js`) 4. Run `make rust-client-ts-build` to regenerate the JS, and add a schema/migration test in `schema.test.ts` @@ -226,7 +282,7 @@ independent operations concurrently (from `applyStateSync` in ```typescript const tablesToAccess = [ - db.stateSync, + db.blockchainCheckpoint, db.inputNotes, db.outputNotes, db.notesScripts, @@ -237,7 +293,12 @@ const tablesToAccess = [ db.tags, db.latestAccountHeaders, db.historicalAccountHeaders, - // ... plus the latest/historical storage, map-entry and asset tables + db.latestAccountStorages, + db.historicalAccountStorages, + db.latestStorageMapEntries, + db.historicalStorageMapEntries, + db.latestAccountAssets, + db.historicalAccountAssets, ]; return await db.dexie.transaction("rw", tablesToAccess, async (tx) => { @@ -245,7 +306,7 @@ return await db.dexie.transaction("rw", tablesToAccess, async (tx) => { /* input/output note upserts */, /* transaction upserts */, /* per-account applyFullAccountState calls */, - updateSyncHeight(tx, blockNum), + updateSyncHeight(tx, blockNum, newPeaks), updatePartialBlockchainNodes(tx, serializedNodeIds, serializedNodes), updateCommittedNoteTags(tx, committedNoteTagSources), /* block-header writes */, @@ -267,18 +328,31 @@ Rules: 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`): +transaction. Peaks travel with the block number on the same checkpoint row, +so a skipped height update deliberately skips the peaks update too (from +`updateSyncHeight` in `sync.ts`): ```typescript -async function updateSyncHeight(tx: Transaction, blockNum: number) { +async function updateSyncHeight( + tx: Transaction, + blockNum: number, + newPeaks: Uint8Array +) { try { const current = await ( - tx as Transaction & { stateSync: Dexie.Table } - ).stateSync.get(1); + tx as Transaction & { + blockchainCheckpoint: Dexie.Table; + } + ).blockchainCheckpoint.get(1); if (!current || current.blockNum < blockNum) { await ( - tx as Transaction & { stateSync: Dexie.Table } - ).stateSync.update(1, { blockNum: blockNum }); + tx as Transaction & { + blockchainCheckpoint: Dexie.Table; + } + ).blockchainCheckpoint.update(1, { + blockNum: blockNum, + partialBlockchainPeaks: newPeaks, + }); } } catch (error) { logWebStoreError(error, "Failed to update sync height"); @@ -286,6 +360,12 @@ async function updateSyncHeight(tx: Transaction, blockNum: number) { } ``` +Read the peaks back through the exported `getCurrentBlockchainPeaks(dbId)` +in `sync.ts` (bound Rust-side as +`#[wasm_bindgen(js_name = getCurrentBlockchainPeaks)]` in +`crates/idxdb-store/src/sync/js_bindings.rs`), which returns +`{ blockNum, peaks }` with `peaks` base64-encoded. + ### Forward-Only Updates Only advance the sync height forward (never regress): @@ -296,6 +376,29 @@ if (!current || current.blockNum < blockNum) { } ``` +### Never overwrite MMR authentication nodes + +`partialBlockchainNodes` values are immutable once written: an index's node +value is fixed, so a differing later write signals a buggy or malicious sync +path. Never call `put` on that table. Use +`putPartialBlockchainNodesNoOverwrite(table, data)` from `./utils.js`, which +dedups the batch, `bulkGet`s the existing rows, `bulkAdd`s only the missing +indices, accepts writes that match the stored value, and **throws** when an +existing index would receive a different value. + +### Columns added after the fact + +Rows written before a column existed simply lack the property, and a Dexie +`where` equality against `""` never matches them. Filter in JS instead. From +`removeNoteTag` in `sync.ts`, for the `ITag.sourceSubscriptionKey` column: + +```typescript +return await db.tags + .where({ tag: tagBase64, sourceNoteId, sourceAccountId }) + .and((record) => (record.sourceSubscriptionKey ?? "") == subscriptionKey) + .delete(); +``` + ## Error Handling ### logWebStoreError @@ -317,6 +420,12 @@ 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. +Functions with a non-optional return type still need an explicit +`throw error;` after the `logWebStoreError` call so the compiler can see +that no path returns `undefined` (e.g. `removeAccountAddress` in +`accounts.ts`, `removeSetting` in `settings.ts`, both of which return +`Promise` derived from Dexie's `Collection.delete()` count). + ### Reads return optional / empty Read functions wrap their body in `try/catch`, returning the queried value @@ -347,8 +456,8 @@ Use Dexie's query API. Patterns actually used in the store: // 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); +// Get the single chain-progress row (primary key 1) +const current = await db.blockchainCheckpoint.get(1); // Look up a header by its `id` index (header PK is `id`) const record = await db.latestAccountHeaders @@ -365,10 +474,20 @@ const slots = await db.latestAccountStorages // Match multiple keys against one index const codes = await db.accountCodes.where("root").anyOf(codeRoots).toArray(); + +// InputNotes carries a `scriptRoot` index, backing getInputNotesFromScriptRoots +const notes = await db.inputNotes + .where("scriptRoot") + .anyOf(scriptRoots) + .toArray(); ``` +The `InputNotes` index string is +`"detailsCommitment,noteId,nullifier,scriptRoot,stateDiscriminant,[consumedBlockHeight+consumedTxOrder+noteId]"`. + For compound indexes, use the **bracket-string** index name and pass the -key parts as an array to `.equals(...)` (from `applyTransactionDelta`): +key parts as an array to `.equals(...)` (from `applyAccountPatch` in +`accounts.ts`): ```typescript const oldSlot = await db.latestAccountStorages @@ -382,16 +501,51 @@ const oldSlot = await db.latestAccountStorages 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`). +value that was replaced, with the prior value in `oldSlotValue` / +`oldAsset` / `oldValue` (`null` when no previous value existed). Their +primary keys are the fuller compounds +`[accountId+replacedAtNonce+slotName]`, +`[accountId+replacedAtNonce+slotName+key]` and +`[accountId+replacedAtNonce+vaultKey]`, with `[accountId+replacedAtNonce]` +as a secondary index. + +The write path is **archive-then-replace-or-delete**: read the current +latest row, `put` it into historical under the new nonce, then either `put` +the new value into latest or delete the latest row. See `applyAccountPatch` +(incremental, driven by a patch) and `applyFullAccountState` (wholesale +replacement) in `accounts.ts`. The delete branches: + +- A `JsStorageSlot` carries an optional `patchOperation?: number`, produced + Rust-side from `patch_op().as_u8()` + (`crates/idxdb-store/src/account/utils.rs`). Do not assume a full + numeric mapping; only two branches are implemented. +- For a **map** slot (`slotType === STORAGE_SLOT_TYPE_MAP`, the exported + constant `1`) with `patchOperation === 0 || patchOperation === 2`, every + persisted map entry for that slot is archived and the whole latest map + slot is deleted. +- `patchOperation === 2` additionally **deletes** the latest storage-slot + row rather than `put`-ing it. +- For map entries and vault assets, an empty-string value (`""`) means + removal: archive the old value, then delete the latest row. + +`applyAccountPatch` full signature (bound Rust-side as +`#[wasm_bindgen(js_name = applyAccountPatch)]` in +`crates/idxdb-store/src/account/js_bindings.rs`): + +```typescript +applyAccountPatch( + dbId, accountId, nonce, + updatedSlots: JsStorageSlot[], + changedMapEntries: JsStorageMapEntry[], + changedAssets: JsVaultAsset[], + codeRoot, storageRoot, vaultRoot, committed, commitment +) +``` 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`): +the latest row (from the module-private `restoreSlotsFromHistorical(db, +accountId, nonce)` in `accounts.ts`): ```typescript const oldSlots = await db.historicalAccountStorages @@ -411,12 +565,23 @@ for (const slot of oldSlots) { } ``` -`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 +`pruneAccountHistory` (the JS function in `accounts.ts`, reached from the +low-level WASM `WebClient.pruneAccountHistory` — it is **not** a method on +the high-level `MidenClient`) 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. +### The account forest is not in Dexie + +`crates/idxdb-store/src/forest.rs` holds an in-memory `AccountSmtForest` +over a `ForestInMemoryBackend`, with a monotonic `VersionId`. The forest +backend is synchronous while IndexedDB is async, so the forest is rebuilt +from the account tables on store open, is forward-only, and is recovered +via `IdxdbStore::rebuild_account_forest`. Asset and storage-map +**witnesses** are served from the forest, not from a Dexie query — do not +add a table to try to persist them. + ### Serialization Conventions - Hex strings for cryptographic values (hashes, IDs, commitments, vault keys) @@ -454,6 +619,7 @@ export async function upsertInputNote( const data = { detailsCommitment, noteId: noteId ?? undefined, + scriptRoot, // null -> undefined so Dexie omits these from compound indexes consumedBlockHeight: consumedBlockHeight ?? undefined, consumedTxOrder: consumedTxOrder ?? undefined, @@ -464,6 +630,7 @@ export async function upsertInputNote( await t.notesScripts.put({ scriptRoot, serializedNoteScript }); } catch (error) { logWebStoreError(error, `Error inserting note: ${detailsCommitment}`); + throw error; } }; // Run inside the caller's tx if provided, else open one. diff --git a/skills/local-node-validation/SKILL.md b/skills/local-node-validation/SKILL.md index e4446b9..fb17c87 100644 --- a/skills/local-node-validation/SKILL.md +++ b/skills/local-node-validation/SKILL.md @@ -14,21 +14,20 @@ MockChain simplifies execution in ways that hide real-world failures: 1. **No automatic block production** -- MockChain requires explicit `prove_next_block()`. A live node produces blocks on its own schedule. 2. **No network transport** -- MockChain does not simulate the network transaction builder (ntx-builder) that handles network notes. 3. **No RPC latency or timeouts** -- MockChain executes locally and instantly. Live nodes have gRPC round-trips with configurable timeouts. -4. **No version/genesis validation** -- MockChain skips the protocol-version check that a live node negotiates at connect (a mismatched node is rejected). -5. **Account update block numbers not tracked** -- MockChain returns chain tip instead of actual update block number. -6. **No mempool or batching** -- MockChain does not simulate transaction queuing, batch formation, or block inclusion delays. +4. **No version/genesis validation** -- MockChain skips the protocol-version check that a live node negotiates at connect. The live negotiation rides on an `accept` header of the form `application/vnd.miden; version=; genesis=`; a rejection surfaces as `RpcError::AcceptHeaderError`. +5. **Different scheduling** -- MockChain has no block-production cadence, no RPC round-trip, and no rate limiting, so timing-dependent bugs do not reproduce. ## Prerequisites - [ ] Your MockChain integration tests pass (e.g. `cargo test -p --release`) -- [ ] A v0.15 Miden node available locally. The v0.15 node is **not** a single binary -- it is composed of standalone executables (validator, sequencer, ntx-builder, transaction prover). The client's own test infra installs the full set: `miden-validator`, `miden-node`, `miden-ntx-builder`, `miden-remote-prover` (see `scripts/start-test-node.sh` in the `miden-client` repo). Install them from the node source pinned in your client's `Cargo.lock`, or follow the 0xMiden/node v0.15 quickstart for the authoritative install flow. +- [ ] A Miden node available locally. The node is **not** a single binary -- it is composed of standalone executables (validator, sequencer, ntx-builder, transaction prover). The client's own test infra installs the full set: `miden-validator`, `miden-node`, `miden-ntx-builder`, `miden-remote-prover` (see `scripts/start-test-node.sh` in the `miden-client` repo), with `cargo install --locked`. The node version the client is built against resolves from crates.io via your `Cargo.lock`, not from a git source. - [ ] A working network/integration validation binary exists in your project that you can use as the starting template for the localhost variant > The local-node launch CLI lives in the 0xMiden/node repo, not in `miden-client`. The commands below are the topology the client's `make start-node` target (`scripts/start-test-node.sh`) drives; confirm exact flags against your installed node's `--help` for the version you run. ## Step 1: Clean State and Start Local Node -**Every node session must start from clean state.** Stale store files and keystore directories cause conflicts, deserialization errors, and misleading test results. Always wipe before starting. (v0.15 artifacts also do not round-trip across versions, so a fresh store is required after any version change.) +**Every node session must start from clean state.** Stale store files and keystore directories cause conflicts, deserialization errors, and misleading test results. Always wipe before starting. Serialized artifacts do not round-trip across protocol versions -- the MAST wire format is `[0, 0, 4]` and the package format is `[6, 0, 0]` -- so a fresh store is required after any version change. The helper script does `rm -rf "$DATA"` on every start for exactly this reason. The simplest path is the client's `make start-node` target, which runs the bundled `scripts/start-test-node.sh` helper: it installs the node binaries (pinned to your `Cargo.lock`), generates genesis, bootstraps each component, and starts the split topology for you: @@ -44,25 +43,58 @@ This brings up the four-component topology and exposes the RPC on `127.0.0.1:572 If you run the node binaries directly instead of via `make start-node`, the shape is below. Treat it as a reference skeleton, not a copy-paste recipe: it omits details the script handles for you (it does not show generating the genesis config the validator bootstraps from, and it leaves out the shared network-tx auth header that the sequencer and ntx-builder must agree on or the sequencer rejects the ntx-builder's transactions). Verify every subcommand and flag against `--help` for your node version, or just use `make start-node`. ```bash -# 1. Bootstrap each component from a generated genesis block -# (the genesis config/block must be produced first; the helper script -# builds it from the client repo before this step) -miden-validator bootstrap --data-directory /validator \ +# 0. Build the genesis block ONCE with the dedicated `genesis` subcommand. +# (The genesis.toml it consumes is produced by the client's `gen-genesis` binary: +# cargo build --release -p test-node-genesis --bin gen-genesis +# ./target/release/gen-genesis /genesis-config) +miden-validator genesis \ --genesis-block-directory /genesis --accounts-directory /accounts \ - --genesis-config-file /genesis-config/genesis.toml -miden-node bootstrap --data-directory /node --file /genesis/genesis.dat -miden-ntx-builder bootstrap --data-directory /ntx-builder --file /genesis/genesis.dat - -# 2. Start the components (validator, then sequencer with the RPC, prover, ntx-builder). -# The sequencer and ntx-builder additionally need a matching network-tx auth header -# (--rpc.network-tx-auth-header-value / --rpc.auth-header-value in the script); see the script. -miden-validator start --listen 127.0.0.1:50101 --data-directory /validator + --config /genesis-config/genesis.toml + +# 1. Bootstrap each component from that genesis block. All three take --genesis. +# Create each component's data directory first -- they open their SQLite DB +# directly and do not mkdir it for you. +miden-validator bootstrap --data-directory /validator --genesis /genesis/genesis.dat +miden-node bootstrap --data-directory /node --genesis /genesis/genesis.dat +miden-ntx-builder bootstrap --data-directory /ntx-builder --genesis /genesis/genesis.dat + +# 2. Start the components in order: validator, then sequencer (which carries the RPC) +# and prover, then ntx-builder. The sequencer and ntx-builder must agree on the +# network-tx auth header or the sequencer rejects the ntx-builder's transactions. +# The validator requires threshold storage-key material to start at all. +miden-validator start --listen 127.0.0.1:50101 --data-directory /validator \ + --storage-key.epoch <64 hex chars> \ + --storage-key.setup-context /setup-context.wire \ + --storage-key.public-key-set /public-key-set.wire \ + --storage-key.secret-share /secret-share.wire + miden-node sequencer --rpc.listen 127.0.0.1:57291 --data-directory /node \ --validator.url http://127.0.0.1:50101 --ntx-builder.url http://127.0.0.1:50301 \ - --block.interval 3s --batch.interval 1s + --block.interval 3s --batch.interval 1s \ + --rpc.network-tx-auth-header-value "$NETWORK_TX_AUTH" \ + --rpc.rate-limit.burst-size 10000 --rpc.rate-limit.replenish-per-second 10000 + miden-remote-prover --kind=transaction --port=50051 + miden-ntx-builder start --listen 127.0.0.1:50301 --rpc.url http://127.0.0.1:57291 \ - --tx-prover.url http://127.0.0.1:50051 --data-directory /ntx-builder + --tx-prover.url http://127.0.0.1:50051 --data-directory /ntx-builder \ + --rpc.auth-header-value "$NETWORK_TX_AUTH" --max-cycles $((1 << 18)) +``` + +Three things here bite hard if you skip them: + +- **The validator will not start without threshold storage-key material.** The client vendors insecure development fixtures for exactly this at `scripts/testdata/insecure-golden-storage-key/`. Never use those outside a local test node. +- **Without the rate-limit bump, an integration run gets throttled** by the sequencer's default limiter and starts failing in ways that look like network flakiness. +- **Start ordering matters.** The script sleeps ~2s after the validator and again after the sequencer, then polls the RPC socket for up to 60 seconds. + +Tear down with `make stop-node` (`scripts/stop-test-node.sh`), which kills by pid file and falls back to `pkill` on the installed binary paths. + +### Private notes need a separate service + +`ClientBuilder::for_localhost()` configures **no note transport**. Private-note flows against a local node therefore silently do nothing until you both run the transport service (`make start-note-transport`, which installs `miden-note-transport-node` from `0xMiden/miden-note-transport`) and point the client at it: + +```rust +.note_transport(Arc::new(GrpcNoteTransportClient::new(url, timeout_ms))) ``` **This clean-start sequence is mandatory every time.** Do not attempt to reuse state from a previous session. @@ -71,7 +103,7 @@ miden-ntx-builder start --listen 127.0.0.1:50301 --rpc.url http://127.0.0.1:5729 Add a `setup_local_client()` function to whichever helper module in your integration / test harness already hosts the network `setup_client()` equivalent. Name and location are up to you -- adjust to your repo's layout. -`.sqlite_store(..)` is **not** an inherent `ClientBuilder` method in v0.15 -- it comes from an extension trait in the `miden-client-sqlite-store` crate. You must bring it into scope or the call fails to compile (method not found): +`.sqlite_store(..)` is **not** an inherent `ClientBuilder` method -- it comes from an extension trait in the `miden-client-sqlite-store` crate. You must bring it into scope or the call fails to compile (method not found): ```rust use miden_client_sqlite_store::ClientBuilderSqliteExt; // required for .sqlite_store(..) @@ -79,9 +111,13 @@ use miden_client_sqlite_store::ClientBuilderSqliteExt; // required for .sqlite_s ```rust pub async fn setup_local_client() -> Result { - let endpoint = Endpoint::new("http".into(), "localhost".into(), Some(57291)); - let timeout_ms = 10_000; - let rpc_client = Arc::new(GrpcClient::new(&endpoint, timeout_ms)); + let endpoint = Endpoint::localhost(); // http://localhost:57291 + let timeout_ms = 10_000; // DEFAULT_GRPC_TIMEOUT_MS + + // `.rpc()` uses the client AS PROVIDED. Wrap it, or you silently lose + // response verification. (`.grpc_client(&endpoint, Some(timeout_ms))` and the + // `for_*` constructors wrap for you.) + let rpc_client = Arc::new(VerifyingRpcClient::new(GrpcClient::new(&endpoint, timeout_ms))); let keystore_path = std::path::PathBuf::from("../local-keystore"); let keystore = Arc::new(FilesystemKeyStore::new(keystore_path) @@ -93,7 +129,6 @@ pub async fn setup_local_client() -> Result { .rpc(rpc_client) .sqlite_store(store_path) .authenticator(keystore.clone()) - .in_debug_mode(true.into()) .build() .await .context("Failed to build local Miden client")?; @@ -102,6 +137,12 @@ pub async fn setup_local_client() -> Result { } ``` +Imports: `use miden_client::rpc::{Endpoint, GrpcClient, VerifyingRpcClient};`. + +**There is no debug-mode switch.** `ClientBuilder::in_debug_mode`, `Client::in_debug_mode`, the `DebugMode` type, the CLI `--debug` flag and the `MIDEN_DEBUG` environment variable do not exist — a `.in_debug_mode(..)` line will not compile. What replaced them is a debug adapter, and it is narrower than it sounds: the CLI has its own `dap` feature that is **not** enabled by default, so a stock `miden-client` binary exposes nothing. Built with it, `--start-debug-adapter ` (optionally `--record `) is accepted by exactly two commands — `exec` and `consume-notes`. Passing it to `mint`, `transfer`, `swap` or a PSWAP command is an unknown-argument error. MASM print-style debugging goes through the `miden::core::debug` procedures, which print unconditionally, so there is nothing to gate. + +`build()` fails with `ClientInitializationError` if **either** the RPC client or the store is missing — they are two independent checks, so supplying only one is not enough. + Use separate paths (`local-keystore/`, `local-store.sqlite3`) to avoid contaminating testnet state. ## Step 3: Create a local validation binary @@ -110,7 +151,7 @@ Add a local validation binary alongside your existing network/testnet validation The binary must: 1. Call `setup_local_client()` instead of the network setup function -2. Sync state: `client.sync_state().await?` +2. Sync state: `client.sync_state().await?`. This is **mandatory before the first submit**, not just good hygiene: transaction inputs are sealed against chain state, and a client that has not synced genesis and the chain tip cannot resolve the encryption key. 3. Build contracts (same as the existing binary) 4. Create accounts, create notes, submit transactions 5. Sync again after each transaction submission @@ -163,7 +204,11 @@ Look for: | Symptom | Cause | Fix | |---------|-------|-----| | `Unavailable` RPC error | Node not running or wrong port | Start node, verify the sequencer's RPC is listening on 57291 | -| Version mismatch error | Node and client crate versions differ | Run a v0.15 node built from the node source pinned in your client's `Cargo.lock`; the protocol version is negotiated at connect and a mismatch is rejected | +| `RpcError::AcceptHeaderError` / "The node rejected the request due to a version mismatch." | Node and client crate versions differ | Run the node version resolved by your client's `Cargo.lock`. The version and genesis commitment are negotiated at connect via the `accept` header and a mismatch is rejected; there is no mixed-version mode | +| `miden-validator start` exits immediately | Missing threshold storage-key material | Pass all four `--storage-key.*` flags; for a local test node use the vendored `scripts/testdata/insecure-golden-storage-key/` fixtures | +| Unknown-argument error on `bootstrap` | Using the old flag names | Genesis is its own `miden-validator genesis --config ` step, and all three `bootstrap` commands take `--genesis ` (not `--file`, not `--genesis-config-file`) | +| Requests throttled / intermittent failures under load | Sequencer rate limiter at its default | Start the sequencer with `--rpc.rate-limit.burst-size 10000 --rpc.rate-limit.replenish-per-second 10000` | +| Private notes never arrive | No note transport configured | `ClientBuilder::for_localhost()` sets none — run `make start-note-transport` and pass `.note_transport(..)` | | Transaction rejected | Invalid proof or state | Check contract code, reset node data, try again | | Account not found after creation | Haven't synced | Call `sync_state()` after account creation | | Store errors or deserialization failures | Stale state from previous session (or artifacts from an earlier protocol version, which do not round-trip) | Wipe the node data, keystore, and client store, then re-bootstrap from a fresh genesis | diff --git a/skills/masm-constants/SKILL.md b/skills/masm-constants/SKILL.md index d757e96..7a130d1 100644 --- a/skills/masm-constants/SKILL.md +++ b/skills/masm-constants/SKILL.md @@ -17,22 +17,20 @@ Group non-error constants by topic. Use blank lines between sections. Common top - **Slot names** – use `word("path::to::slot")` for slot identifiers - **Memory pointer offsets** – offsets into memory regions -- **Local memory offsets** – offsets within a procedure's local memory - **Magic numbers / values** – domain-specific literals -- **Event identifiers** – `event("...")` constants consumed by `emit` (e.g. `const AUTH_REQUEST_EVENT = event("miden::protocol::auth::request")` used as `emit.AUTH_REQUEST_EVENT`) -- **Trace ids** – plain numeric constants consumed by `trace` (e.g. `const PRINTLN = 0` used as `trace.PRINTLN`); `trace` takes a numeric `u32` id, **not** an `event("...")` constant +- **Event identifiers** – for `emit` / `trace` Order sections by dependency or by usage frequency. Put the most widely used first. ### Errors Section (after constants) -Errors (panic/assert error codes, e.g. `ERR_*`) go in a **dedicated "errors" section**, placed after the constants section. Define them with string values describing the error. (Some files in v0.15 place the errors section before the constants section; constants-first is the more common order and is preferred for new files.) +Errors (panic/assert error codes, e.g. `ERR_*`) go in a **dedicated "errors" section**, placed after the constants section. Define them with string values describing the error. ## Naming ### Memory Pointers -Memory pointer constants describe offsets into memory regions that are shared or global (e.g. layout of a memory region, input/output structure offsets). They are **not** scoped to a single procedure. +Memory pointer constants describe offsets into memory regions that are shared or global (e.g. layout of a memory region, input/output structure offsets). They are **not** scoped to a single procedure and do not use the procedure prefix. ```masm # Good: descriptive, shared usage @@ -45,35 +43,27 @@ const INPUT_PTR_OFF = 0 const INPUT_LEN_OFF = 1 ``` -### Local Memory Offsets +### Memory Locals Offsets -Local memory offsets describe offsets within a procedure's local memory. Name them with **UPPERCASE descriptive names ending in `_LOC` (or `_LOCAL`)**: +Memory locals offsets are **procedure-scoped**: they describe offsets within a procedure's local memory. They must be **prefixed with the procedure name** they belong to: ```masm -# Good: descriptive UPPERCASE name ending in _LOC -const IS_SIGNER_FOUND_LOC = 0 -const CURRENT_SIGNER_INDEX_LOC = 1 -const NUM_OF_APPROVERS_LOC = 0 -``` - -When several procedures in the same file define local offsets, **group each procedure's locals under a section comment** and, where names would otherwise collide or be ambiguous, disambiguate with an UPPERCASE context prefix derived from the procedure name. Not every constant in a group needs the prefix — use it only where it adds clarity: - -```masm -# bridge_out memory locals -const BRIDGE_OUT_BURN_ASSET_LOC = 0 -const DESTINATION_NETWORK_LOC = 13 -const BRIDGE_OUT_IS_NATIVE_LOC = 14 - -# create_burn_note memory locals -const CREATE_BURN_NOTE_BURN_ASSET_LOC = 0 -const ATTACHMENT_LOC = 8 +# Good: procedure-prefixed +const validate_note_NOTE_IDX_LOC = 0 +const validate_note_ASSET_LOC = 1 +const process_input_INPUT_PTR_OFF = 0 + +# Bad: generic, ambiguous +const NOTE_IDX_LOC = 0 +const ASSET_LOC = 1 +const INPUT_PTR_OFF = 0 ``` -Do **not** use a lowercase procedure-name prefix (e.g. `validate_note_NOTE_IDX_LOC`); that form does not appear in the protocol source. Every constant name in v0.15 source begins with an uppercase letter. +This keeps offsets scoped and avoids collisions when multiple procedures use local memory. ## Formatting -Both `const NAME = value` (spaces around `=`) and `const NAME=value` (no spaces) occur in v0.15 source, and the choice varies sharply by area — `miden-protocol` is mostly no-space, while `miden-standards` and `miden-agglayer` are mostly spaced. Globally the two styles are roughly even. **Prefer spaces around the equals sign** for new or edited constants, but do **not** treat no-space definitions in existing files as errors. Keep a file internally consistent with its surrounding style. +Put **spaces around the equals sign**. **Errors** – string value describing the error: ```masm @@ -81,29 +71,16 @@ const ERR_BRIDGE_NOT_MAINNET = "bridge not mainnet" const ERR_UNAUTHORIZED = "unauthorized" ``` -**Slots** – use `word()` with the slot path; mark them `pub const` when the slot is part of a component's public storage layout: +**Slots** – use `word()` with the slot path: ```masm -pub const THRESHOLD_CONFIG_SLOT = word("miden::standards::auth::multisig::threshold_config") -pub const AUTHORITY_SLOT = word("miden::standards::access::authority") +const BRIDGE_ID_SLOT = word("miden::agglayer::faucet") +const SLOT_ACCOUNT_ID = word("account::id") ``` **Offsets / numeric values** – plain numbers: ```masm -const IS_SIGNER_FOUND_LOC = 0 -const CURRENT_SIGNER_INDEX_LOC = 1 -``` - -## Visibility (`pub const`) - -In v0.15 a constant must be declared `pub const` to be referenced from another module. This is **not** limited to storage slots: any constant imported elsewhere via `use path::to::CONST` (or referenced as `module::CONST`) must be `pub`, including magic numbers, memory-pointer offsets, and event identifiers. Keep purely file-local constants as plain `const`. - -```masm -# pub because they are imported by other modules -pub const MAX_ASSETS_PER_NOTE = 64 -pub const AUTH_REQUEST_EVENT = event("miden::protocol::auth::request") - -# file-local only — no pub needed -const IS_SIGNER_FOUND_LOC = 0 +const validate_note_NOTE_IDX_LOC = 0 +const validate_note_ASSET_LOC = 1 ``` ## Example Layout @@ -112,26 +89,21 @@ const IS_SIGNER_FOUND_LOC = 0 # CONSTANTS # ================================================================================================= -# Storage slots -pub const AUTHORITY_SLOT = word("miden::standards::access::authority") - -# Authority modes -const AUTH_CONTROLLED = 0 -const OWNER_CONTROLLED = 1 -const RBAC_CONTROLLED = 2 +# Slots +const BRIDGE_ID_SLOT = word("miden::agglayer::faucet") +const SLOT_ACCOUNT_ID = word("account::id") # Memory pointers const ASSET_OFF = 0 const AMOUNT_OFF = 1 -# bridge_out memory locals -const BRIDGE_OUT_BURN_ASSET_LOC = 0 -const DESTINATION_NETWORK_LOC = 13 -const BRIDGE_OUT_IS_NATIVE_LOC = 14 +# validate_note locals +const validate_note_NOTE_IDX_LOC = 0 +const validate_note_ASSET_LOC = 1 -# create_burn_note memory locals -const CREATE_BURN_NOTE_BURN_ASSET_LOC = 0 -const ATTACHMENT_LOC = 8 +# process_input locals +const process_input_INPUT_PTR_OFF = 0 +const process_input_LEN_OFF = 1 # ERRORS # ================================================================================================= @@ -140,7 +112,7 @@ const ERR_BRIDGE_NOT_MAINNET = "bridge not mainnet" const ERR_UNAUTHORIZED = "unauthorized" const ERR_NOTE_NOT_FOUND = "note not found" -# PROCEDURES +# PUBLIC INTERFACE # ================================================================================================= pub proc validate_note @@ -150,12 +122,10 @@ end ## Validation Checklist -- [ ] All constants defined at top of file (after imports / type aliases) -- [ ] Errors in dedicated section, placed after the constants section (constants-first preferred) +- [ ] Errors in dedicated section, placed after constants +- [ ] All constants defined at top of file - [ ] Non-error constants grouped by topic with blank lines between sections -- [ ] Memory pointers (shared/global) use descriptive names -- [ ] Local memory offsets use UPPERCASE names ending in `_LOC`/`_LOCAL`, grouped under a per-procedure section comment when multiple procedures define locals -- [ ] No lowercase procedure-name prefix on local offset constants -- [ ] Constants referenced from another module are declared `pub const`; file-local constants stay plain `const` -- [ ] Prefer spaces around `=` for new/edited constants (existing files may be no-space; keep file consistent) +- [ ] Memory pointers (shared/global) use descriptive names without procedure prefix +- [ ] Memory locals offsets prefixed with procedure name +- [ ] Spaces around `=` in all constant definitions - [ ] Section comments used to label topic groups diff --git a/skills/masm-doc-comments/SKILL.md b/skills/masm-doc-comments/SKILL.md index 3131072..938e819 100644 --- a/skills/masm-doc-comments/SKILL.md +++ b/skills/masm-doc-comments/SKILL.md @@ -11,9 +11,9 @@ Every public MASM procedure should have a doc comment block using `#!` prefix wi 1. **Description** - What the procedure does 2. **Inputs/Outputs** - Stack state before/after -3. **Where** - Explanation of each stack item +3. **Where** - Explanation of each stack item (omit when the Description already names every item) 4. **Panics if** - Error conditions (when applicable) -5. **Invocation** - How the procedure is called (`exec` or `call`) +5. **Invocation** - How the procedure is called (`exec`, `call`, or `dyncall`; omitted for syscall-invoked kernel procedures) ## Required Format @@ -47,8 +47,13 @@ pub proc my_procedure | Type | Style | Example | |------|-------|---------| | Single felt | lowercase with underscores | `note_index`, `amount`, `balance` | -| Word (4 felts) | UPPERCASE with underscores | `ASSET_KEY`, `ASSET_VALUE`, `RECIPIENT`, `SCRIPT_ROOT` | -| Multi-felt (2-3) | lowercase with `{parts}` suffix | `account_id_{prefix,suffix}` | +| Word (4 felts) | UPPERCASE with underscores | `ASSET`, `RECIPIENT`, `SCRIPT_ROOT` | +| Multi-felt (2-3) | lowercase with `{parts}` suffix | `account_id_{suffix,prefix}` | +| All-zero Word | `EMPTY_WORD` | `[..., EMPTY_WORD, ...]` | + +`EMPTY_WORD` is a naming convention used in stack trackers, `Where:` bullets, and prose comments to denote the all-zero Word `[0, 0, 0, 0]`. + +In composite braces, list parts in **stack-top-first order, no spaces inside the braces**: `account_id_{suffix,prefix}` because the suffix sits on top of the stack and the prefix below it. The same rule applies to other split-128-bit IDs (`sender_{suffix,prefix}`, `faucet_id_{suffix,prefix}`, etc.). ### Stack Order @@ -67,13 +72,22 @@ Use empty brackets for no inputs or outputs: #! Outputs: [result] ``` -### Padding (for `call` procedures only) +### Span notation `(N)` family -Include explicit padding for `call` procedures (see masm-padding skill): +`(N)` after an item name denotes a span of N felts (not a Word). Spans stay lowercase; Words stay UPPERCASE and never take `(N)`. `pad(N)` (see masm-padding skill) is one member of this family; other spans appear in protocol code: ```masm -#! Inputs: [ASSET_KEY, ASSET_VALUE, pad(8)] -#! Outputs: [ASSET_VALUE, pad(12)] +#! Inputs: [first_element, foreign_procedure_inputs(15)] +#! Outputs: [foreign_procedure_outputs(16)] +``` + +### Padding (for `call` and `dyncall` procedures) + +Procedures entered at the stack-depth-16 floor (`call` and `dyncall`) must show explicit padding so Inputs and Outputs each sum to 16 elements (see masm-padding skill): + +```masm +#! Inputs: [ASSET, pad(12)] +#! Outputs: [pad(16)] #! #! Invocation: call ``` @@ -85,9 +99,8 @@ Define every item from Inputs and Outputs: ```masm #! Where: #! - note_index is the index of the input note. -#! - sender_{prefix,suffix} are the prefix and suffix felts of the sender ID. -#! - ASSET_KEY is the vault key of the fungible asset. -#! - ASSET_VALUE is the value of the fungible asset. +#! - sender_{suffix,prefix} are the suffix and prefix felts of the sender ID. +#! - ASSET_ID is the asset ID of the asset [0, 0, faucet_id_suffix, faucet_id_prefix]. #! - balance is the fungible asset balance in the vault. ``` @@ -96,9 +109,32 @@ Define every item from Inputs and Outputs: - Start descriptions lowercase (continues the sentence) - End each line with a period - Group related items (e.g., all inputs, then all outputs) +- Avoid including low-level details, e.g. how a value is computed. + - Good: NOTE_DETAILS_COMMITMENT is the commitment to the note's details. + - Avoid: NOTE_DETAILS_COMMITMENT is the commitment to the note's details computed as `hash(RECIPIENT_DIGEST || ASSETS_COMMITMENT)`. + +### When `Where:` may be omitted + +Omit the `Where:` section entirely when the Description already names every Inputs/Outputs item and adding bullets would just repeat that information. Common case: trivial accessor procedures. + +```masm +#! Returns the maximum supply. +#! +#! Inputs: [pad(16)] +#! Outputs: [max_supply, pad(15)] +#! +#! Invocation: call +pub proc get_max_supply +``` + +No `Where:` is needed: the single named output `max_supply` is already identified by the Description. If you would otherwise write `#! - max_supply is the maximum supply.`, skip it. + +Add `Where:` whenever any item needs description beyond what the Description line conveys — different name, additional constraint, composition (`ASSET_ID = [0, 0, faucet_id_suffix, faucet_id_prefix]`), or anything non-obvious. ## Panics Section +Bullets describe the **condition**, not the error identifier. Write `the nonce has already been incremented.`, not `ERR_ACCOUNT_NONCE_CAN_ONLY_BE_INCREMENTED_ONCE.`. The identifier is an implementation detail of the assert; the doc comment should read as English. + ### Direct Panics List conditions from `assert*` statements in the procedure: @@ -132,12 +168,12 @@ proc another_procedure end ``` -**Complex case (4+ conditions):** Reference the subprocedure's validation rather than re-listing every condition: +**Complex case (4+ conditions):** Reference the subprocedure: ```masm #! Description, inputs, etc. #! Panics if: -#! - another_procedure validation fails. +#! - another_procedure fails to verify. proc sample_procedure # => [flag_1, flag_2, flag_3, flag_4] exec.another_procedure # this procedure may panic @@ -164,28 +200,58 @@ Omit the "Panics if:" section entirely if the procedure cannot panic. ## Invocation Types -Always specify how the procedure should be invoked: +Specify how the procedure should be invoked. The value matches the MASM instruction that user-code callers use to enter the procedure: + +| Value | Used by callers as | When to use | +|---|---|---| +| `exec` | `exec.` | Standard inline call. Shares the caller's stack; no padding requirement. | +| `call` | `call.` | Cross-context call (e.g. into another account). Enters at stack depth 16 — Inputs/Outputs must show `pad(N)` to total 16 (see masm-padding). | +| `dyncall` | `dyncall` from a script | Entry point of a note script or transaction script. Stack-depth-16 floor applies on entry. | ```masm #! Invocation: exec ``` -or - ```masm #! Invocation: call ``` -For existing procedures, a good rule of thumb is to use `call` when other procedures invoke this procedure via `call.`, and `exec` if the procedure is invoked via `exec.`. -`exec` and `call` are by far the most common annotation values. There is also `dynexec` (used for dynamically-dispatched calls) and, rarely, `syscall`; these are less common and should be handled by the programmer. +```masm +#! Invocation: dyncall +``` + +For existing procedures, pick the value that matches how callers invoke them: `call` when invoked via `call.`, `exec` for `exec.`, `dyncall` for note-script and transaction-script entry points. + +### Kernel procedures (syscall-invoked) are exempt + +Kernel procedures under `crates/miden-protocol/asm/kernels/` are invoked by the VM via `syscall.` from user code. They do not carry an `Invocation:` line — the `syscall` invocation model is implied by the procedure's location in a kernel module. Omit the `Invocation:` line entirely for these procs. + +## Prose Conventions + +Doc comments are read by people unfamiliar with the change that introduced them. Keep the prose general, accurate, and consistent with the rest of the codebase. + +### Reuse existing terminology + +Use the vocabulary already established in surrounding modules and doc comments. Do not coin new terms or borrow colloquialisms for a concept that already has a name. For example, a value written to a local is "stored" or "saved" (matching `loc_storew`), not "stashed"; describe what code does plainly rather than labelling it ("load-bearing", "the real check", and similar). + +### Document the procedure, not the change + +Describe the procedure as it currently behaves, for a reader who has never seen the PR that added or modified it. Avoid PR narrative, rationale for a recent fix, and framing such as "this is the X that prevents Y". State what the procedure does and what it guarantees. + +### Stay at this layer's abstraction + +Describe behavior in terms of this procedure and its inputs and outputs. Do not explain how a lower layer (for example the kernel, or a specific syscall) implements or enforces something — that is an implementation detail that may change. Panic bullets in particular state the condition in domain terms ("the asset does not belong to this faucet"), not the mechanism or which layer raises it. ## Validation Checklist -- [ ] Description starts with verb (Returns, Gets, Computes, Burns, etc.) +- [ ] Description starts with a capitalized present-tense verb and the first sentence ends with a period. Canonical verbs observed in protocol source: `Returns`, `Gets`, `Computes`, `Burns`, `Creates`, `Increments`, `Copies`, `Asserts`, `Verifies`, `Hashes`, `Adds`, `Removes`. - [ ] Inputs and Outputs use correct stack notation -- [ ] All stack items defined in Where section +- [ ] Where section defines every stack item that needs description beyond the Description line, and is omitted entirely when the Description alone covers every item - [ ] Words are UPPERCASE, felts are lowercase - [ ] Panics section lists direct asserts and propagated errors -- [ ] Complex panic propagation references the subprocedure (e.g. " validation fails") -- [ ] Invocation type specified (exec or call) -- [ ] For `call`: padding shown in Inputs/Outputs (see masm-padding skill) +- [ ] Complex panic propagation uses "if fails to verify" shorthand +- [ ] Invocation type specified: `exec`, `call`, or `dyncall`. Kernel (syscall-invoked) procedures are exempt — no `Invocation:` line. +- [ ] For `call` and `dyncall`: padding shown in Inputs/Outputs (see masm-padding skill) +- [ ] Prose reuses existing terminology; no coined terms or colloquialisms +- [ ] Describes the procedure's current behavior, not the change that introduced it +- [ ] No lower-layer (kernel/syscall) implementation details; panic bullets describe conditions in domain terms diff --git a/skills/masm-error-constants/SKILL.md b/skills/masm-error-constants/SKILL.md index 1ebcc8f..c326325 100644 --- a/skills/masm-error-constants/SKILL.md +++ b/skills/masm-error-constants/SKILL.md @@ -7,25 +7,14 @@ description: Use when adding or editing MASM `assert*` instructions — give eve ## Rule -Every MASM assertion should carry a descriptive error message. There are two valid forms in v0.15 source: - -- **Named `ERR_*` constant** — preferred for reusable or shared errors, and the dominant style in protocol/standards code: +Every MASM assertion must carry a descriptive error code: ```masm assert.err=ERR_NOTE_NOT_FOUND assert_eqw.err=ERR_COMMITMENT_MISMATCH ``` -- **Inline string literal** with `.err="..."` — valid and common, especially in core-lib/stdlib and for one-off, local checks: - -```masm -u32assert2.err="number of storage map elements should fit into a u32" -assert.err="number of storage map elements must be a multiple of 8" -``` - -Prefer a named constant when the same error is raised in more than one place, when it is part of a module's documented error surface, or when Rust code needs to match on it (named `ERR_*` constants are codegen'd into Rust bindings — see `masm-rust-constant-parity`). Reach for an inline string for a purely local, single-site check where a named constant would only add indirection. - -When you define a named error constant, it must: +The error constant must: - Use the `ERR_` prefix. - Live in the file's dedicated errors section (see `masm-constants` skill). @@ -34,7 +23,7 @@ When you define a named error constant, it must: ## Why -A bare `assert` traps with the default error code `0`, which tells the debugger nothing about which check failed; a descriptive error message (a named `ERR_*` constant or an inline string) ties each trap site to a specific failure mode. (The error string is hashed into a field element; an omitted code defaults to `0`.) Distinct constants per condition also let tests pin the expected error rather than matching a generic failure. +A bare `assert` traps with a generic message that tells the debugger nothing about which check failed; a descriptive `ERR_` constant ties each trap site to a specific failure mode. Distinct constants per condition also let tests pin the expected error (see `assert-specific-error-in-tests`). ## Examples diff --git a/skills/masm-explicit-stack-inputs/SKILL.md b/skills/masm-explicit-stack-inputs/SKILL.md index 3e0db62..f6e87ce 100644 --- a/skills/masm-explicit-stack-inputs/SKILL.md +++ b/skills/masm-explicit-stack-inputs/SKILL.md @@ -24,18 +24,18 @@ Hidden memory inputs make the procedure's signature a lie — a reader of `Input ```masm # Good -#! Inputs: [note_index, ASSET_KEY, ASSET_VALUE] +#! Inputs: [note_index, ASSET] #! Outputs: [] -proc append_asset_to_note +proc add_asset_to_note # ... uses values directly from the stack end # Bad: implicit input via memory location the caller had to populate #! Inputs: [] #! Outputs: [] -proc append_asset_to_note - mem_load.PENDING_NOTE_PTR # caller had to set this first - mem_loadw_le.PENDING_ASSET_PTR +proc add_asset_to_note + mem_load.PENDING_NOTE_PTR # caller had to set this first + mem_loadw.PENDING_ASSET_PTR # ... end @@ -43,7 +43,7 @@ end # pointer to it is an explicit stack input named in the doc block #! Inputs: [proof_ptr, leaf_index, ROOT] #! Outputs: [is_valid] -proc verify_inclusion_proof +proc verify_merkle_proof # the full proof (many words) was written to memory by the caller; # only the pointer, index, and root travel on the stack # ... diff --git a/skills/masm-file-structure/SKILL.md b/skills/masm-file-structure/SKILL.md index b342198..d2c9867 100644 --- a/skills/masm-file-structure/SKILL.md +++ b/skills/masm-file-structure/SKILL.md @@ -1,11 +1,11 @@ --- name: masm-file-structure -description: Enforce file structure and section ordering for Miden Assembly (.masm) files. Use when editing, reviewing, or creating .masm files. +description: Enforce file structure, module-tree declaration, and section ordering for Miden Assembly (.masm) files. Use when editing, reviewing, or creating .masm files. --- # MASM File Structure -MASM files follow a consistent top-level section order. Use section headers with the long separator line: +MASM files must follow a fixed section order. Use section headers with the long separator line: ```masm # SECTION NAME @@ -15,39 +15,36 @@ MASM files follow a consistent top-level section order. Use section headers with ## Section Order 1. **Imports** – `use` statements only; no section header -2. **Type aliases** – `pub type` / `type` definitions (use `pub type` when referenced cross-module) -3. **Constants** – see the masm-constants skill for organization (errors and non-error constants are grouped into separate subsections; source uses both orderings) -4. **Events** – `const ... = event(...)` definitions; appears in kernel/event-emitting modules, after Constants (or after imports when no constants section) and before the procedure sections -5. **Procedures** – the procedure region. Two main forms ship in v0.15 source (plus a rare variant noted below): - - A single **`# PROCEDURES`** section holding all procedures when a module does not separate API from internals. This is a common form in source (about as many files use a lone `# PROCEDURES` as use the split below), e.g. `standards/notes/p2id.masm`, `standards/data_structures/double_word_array.masm`, `standards/data_structures/array.masm`, and `shared_modules/account_id.masm`. (Kernel `account.masm` is *not* this form: it uses `# PROCEDURES` followed by a separate `# HELPER PROCEDURES` section.) - - A **`# PUBLIC INTERFACE`** section (`pub proc` that form the module API) followed by a **`# HELPER PROCEDURES`** section (procedures used internally, mostly non-`pub`)—used only when a module explicitly separates its API from its internals. Note that `pub proc` may still appear under `# HELPER PROCEDURES` when a helper is re-exported or unit-tested. +2. **Type aliases** – `type` definitions +3. **Constants** – see the masm-constants skill for organization (non-error constants first, then errors) +4. **Public interface** – `pub proc` procedures that form the module API +5. **Helper procedures** – `proc` (non-pub) procedures used internally ## Example Structure -The module, constant, type, and procedure names below are illustrative (not a copy of any one source file); the section layout, type shapes, and import namespaces match v0.15 source. - ```masm -use example::leaf::config -use example::leaf::utils +use miden::agglayer::bridge::bridge_config +use miden::agglayer::bridge::leaf_utils use miden::core::mem use miden::core::word # TYPE ALIASES # ================================================================================================= -pub type DoubleWord = struct { word_lo: word, word_hi: word } -pub type MemoryAddress = u32 +type BeWord = struct @bigendian { a: felt, b: felt, c: felt, d: felt } +type DoubleWord = struct { word_lo: BeWord, word_hi: BeWord } +type MemoryAddress = u32 # CONSTANTS # ================================================================================================= const PROOF_DATA_PTR = 0 -const CLAIM_PROOF_DATA_WORD_LEN = 134 +const PROOF_DATA_WORD_LEN = 134 # ERRORS # ================================================================================================= -const ERR_BRIDGE_NOT_MAINNET = "mainnet flag must be 1 for a mainnet deposit" +const ERR_BRIDGE_NOT_MAINNET = "bridge not mainnet" const ERR_LEADING_BITS_NON_ZERO = "leading bits of global index must be zero" # PUBLIC INTERFACE @@ -59,7 +56,7 @@ const ERR_LEADING_BITS_NON_ZERO = "leading bits of global index must be zero" #! Outputs: [pad(16)] #! #! Invocation: call -pub proc verify_leaf_root +pub proc verify_leaf_bridge exec.get_leaf_value exec.verify_leaf end @@ -73,7 +70,7 @@ end #! Outputs: [LEAF_VALUE[8]] #! #! Invocation: exec -proc get_leaf_value(leaf_data_key: word) -> DoubleWord +proc get_leaf_value(leaf_data_key: BeWord) -> DoubleWord ... end @@ -88,38 +85,24 @@ proc verify_leaf end ``` -A module that does not separate its API from its internals collapses the two procedure sections into a single `# PROCEDURES` header instead—e.g. `standards/notes/p2id.masm`, whose `# PROCEDURES` section holds both `pub proc main` and `pub proc new` with no public/helper split. - -> The order of the `# CONSTANTS` and `# ERRORS` subsections is **not fixed** in the v0.15 source: some files place errors first (e.g. the agglayer `bridge_in`, `bridge_config`, and `eth_address` modules), others place non-error constants first (e.g. many `miden-standards` components such as `signature`, `guardian`, `multisig`). Both orders ship even within the same directory (`standards/notes/mint.masm` is constants-first while `standards/notes/p2id.masm` is errors-first). Either order is acceptable—just keep the two grouped separately rather than interleaved. - -> Type aliases are conventionally defined before constants, and v0.15 source is overwhelmingly type-first (e.g. `eth_address.masm`, `types.masm`, kernel `api.masm`/`memory.masm`). This is a convention, not an absolute: a component may place local-memory constants before a type alias—e.g. `standards/data_structures/double_word_array.masm` defines its `SLOT_ID_PREFIX_LOC` / `SLOT_ID_SUFFIX_LOC` / `INDEX_LOC` constants ahead of its in-module `type DoubleWord`. Prefer type-first; don't treat const-before-type as an error. - -> Types referenced cross-module must be marked `pub type` (v0.15 requires `pub` for any constant, procedure, or type referenced from another module). Every cross-module type alias in v0.15 source is `pub type`; only the in-module `type DoubleWord` in `double_word_array.masm` is non-`pub`. The `# EVENTS` subsection holds `const NAME = event("...")` definitions and appears in kernel/event-emitting modules. Where a constants section is present it sits after constants and before the procedure sections (e.g. the kernel `account`, `output_note`, `link_map`, and `prologue` modules); in modules with no constants section it follows the imports directly (e.g. the kernel `main` module and `protocol/auth.masm`, where `# EVENTS` is the first content section). - ## Guidelines -- **Imports**: One `use` per line; group by module/namespace. A blank line MAY separate import groups by namespace (as the kernel `main`/`api`/`note`/`prologue`/`epilogue` modules do), but keeping the whole block unseparated is equally common and dominant in source (e.g. the agglayer modules); the Example Structure above follows the unseparated form. Do not put a blank line between `use` statements within the same group. Use the bare top-level namespace, e.g. `use agglayer::bridge::bridge_config` and `use miden::core::word`—there is no `miden::agglayer::` prefix. Imports normally form a single block at the top; a few source files (e.g. `standards/notes/mint.masm`, `agglayer/bridge/bridge_out.masm`) interleave a stray `use` near the related constants—prefer the single top block, but this is not treated as an error. -- **Type aliases**: Define shared types (e.g. `DoubleWord`, `MemoryAddress`) conventionally before constants. v0.15 source is overwhelmingly type-first, though a component may place local-memory constants ahead of a type alias (e.g. `double_word_array.masm`)—so prefer type-first but don't treat const-before-type as a violation. Mark a type `pub type` when it is referenced from another module (v0.15 requires `pub` for cross-module references); a non-`pub` `type` used only within its own module is valid. -- **Constants**: Defer to the masm-constants skill for *organization*—group non-error constants by topic and keep `ERR_*` in a dedicated errors subsection. The *order* of the errors subsection relative to the non-error constants is not fixed in source; it may come before or after, so don't treat either order as a hard rule. Just don't interleave them. -- **Events**: Kernel/event-emitting modules collect their `const NAME = event("...")` definitions in an `# EVENTS` subsection. It sits after the constants section when one is present, otherwise directly after the imports, and always before the procedure sections. -- **Procedures**: When a module does not separate its API from its internals, put every procedure under a single `# PROCEDURES` section (a common form in v0.15 source, e.g. `standards/notes/p2id.masm`, `standards/data_structures/double_word_array.masm`, `standards/data_structures/array.masm`, `shared_modules/account_id.masm`). Split into `# PUBLIC INTERFACE` and `# HELPER PROCEDURES` only when the module deliberately separates API from internals. (A module may also keep a `# PROCEDURES` header for its main procedures and add a separate `# HELPER PROCEDURES` section, as kernel `account.masm` does.) +- **Imports**: One `use` per line; group by module. No blank lines between imports. +- **Type aliases**: Define shared types (e.g. `DoubleWord`, `MemoryAddress`) before constants or procedures. +- **Constants**: Follow the masm-constants skill. - **Public interface**: Only `pub proc`; these are the module’s API. Order by importance or call flow. -- **Helper procedures**: Procedures that support the public interface, mostly non-`pub`. May include `pub proc` helpers (e.g. a `get_leaf_value` that is used internally, re-exported, or exercised in unit tests). +- **Helper procedures**: Non-pub procedures that support the public interface. May include `pub proc` helpers (e.g. `get_leaf_value`) if they are used internally or re-exported, or used for unit tests. ## When Sections Are Omitted - No imports → start with type aliases or constants - No type aliases → constants follow imports -- No events → procedure sections follow constants directly (most non-kernel modules have no `# EVENTS` section) -- No public/helper split → use a single `# PROCEDURES` section for all procedures - No helpers → public interface is the last section ## Validation Checklist -- [ ] Imports at top (if any), using the bare namespace (no `miden::agglayer::` prefix) -- [ ] Type aliases before procedures; conventionally before constants (type-first is the v0.15 norm, but const-before-type is acceptable in some component files) -- [ ] Cross-module types marked `pub type` -- [ ] Constants before procedures; errors grouped in their own subsection, separate from non-error constants (either order) -- [ ] Events (if any) grouped in an `# EVENTS` subsection after constants (or after imports if no constants section) and before procedures -- [ ] Procedures under a single `# PROCEDURES` section, OR split into `# PUBLIC INTERFACE` (`pub proc`) before `# HELPER PROCEDURES` when the module separates API from internals +- [ ] Imports at top (if any) +- [ ] Type aliases before constants and procedures +- [ ] Constants before procedures; errors subsection after non-error constants +- [ ] Public interface (`pub proc`) before helper procedures - [ ] Section headers use `# SECTION NAME` and `# ===...` separator diff --git a/skills/masm-formatting/SKILL.md b/skills/masm-formatting/SKILL.md index 8d1660a..24ae5df 100644 --- a/skills/masm-formatting/SKILL.md +++ b/skills/masm-formatting/SKILL.md @@ -1,6 +1,6 @@ --- name: masm-formatting -description: Orchestrator for MASM formatting in 0xMiden/protocol and 0xMiden/miden-vm. Consolidates capitalization, the (N) span family, cross-repo doc comment divergences (plural vs singular Inputs/Outputs, Panics if vs # Panics, named vs string assertion errors), the `Cycles:` section, chained u32 assertion guards, and description verbs across the prerequisite MASM skills. Use when editing, reviewing, or creating .masm files. +description: Orchestrator for MASM formatting in 0xMiden/protocol and 0xMiden/miden-vm. Consolidates capitalization, the (N) span family, cross-repo doc comment divergences (plural vs singular Inputs/Outputs, Panics if vs # Panics, named vs string assertion errors), the `Cycles:` section, chained u32 assertion guards, description verbs, and the `miden-format` formatter across the prerequisite MASM skills. Use when editing, reviewing, or creating .masm files. --- # MASM Formatting @@ -11,13 +11,15 @@ This skill is an orchestrator and gap-filler. It depends on the existing MASM sk Consult these skills first. +- `masm-file-structure` – module declarations (`mod` / `pub mod`), section order, and the `@account_procedure` / `@auth_script` / `@note_script` / `@transaction_script` / `@locals(N)` attributes. - `masm-inline-comments` – inline `#` comments: lowercase start, `# => [...]` stack state, avoid commenting obvious operations. - `masm-doc-comments` – `#!` doc block: Description, Inputs, Outputs, Where, Panics if, Invocation; singular `is` for single items, plural `are` for composites. -- `masm-padding` – `pad(N)` rules for `call` vs `exec` procedures. +- `masm-proc-type-signatures` – the typed `pub proc name(a: T) -> U` signature line and its semantic type aliases. +- `masm-padding` – `pad(N)` rules for context-switching vs same-context procedures. ## When to Use -Apply this skill alongside the prerequisites whenever you edit a `.masm` file. Reach for it specifically for capitalization questions, `(N)` span notation beyond `pad(N)`, divergences between protocol style and miden-vm style, the `Cycles:` section, and chained `u32assert2` guards. +Apply this skill alongside the prerequisites whenever you edit a `.masm` file. Reach for it specifically for capitalization questions, `(N)` span notation beyond `pad(N)`, divergences between protocol style and miden-vm style, the `Cycles:` section, chained `u32assert2` guards, and running the `miden-format` formatter. The conventions below are derived from canonical MASM in [protocol](https://github.com/0xMiden/protocol) and [miden-vm](https://github.com/0xMiden/miden-vm). They apply to anyone writing MASM in the Miden ecosystem, including community contracts, tutorial examples, and library MASM, not just code that lives inside those two repos. @@ -25,15 +27,19 @@ The conventions below are derived from canonical MASM in [protocol](https://gith | Kind | Style | Example | |---|---|---| -| Word (4 felts) or Word-shaped commitment / root / constant | `UPPER_SNAKE_CASE` | `ASSET_KEY`, `ASSET_VALUE`, `FOREIGN_PROC_ROOT`, `EMPTY_WORD` | +| Word (4 felts) or Word-shaped commitment / root / constant | `UPPER_SNAKE_CASE` | `ASSET_ID`, `ASSET_VALUE`, `FOREIGN_PROC_ROOT`, `EMPTY_WORD` | | Single felt | `lower_snake_case` | `final_nonce`, `note_idx`, `amount` | | Multi-felt composite grouped in one name | `lower_snake_case_{part1,part2}` | `account_id_{suffix,prefix}`, `faucet_id_{suffix,prefix}`, `sender_{suffix,prefix}` | -Composite items always use `are` in `Where:` since they name a group of felts, per `masm-doc-comments`. Brace spacing and part order are inconsistent in source: both `{suffix,prefix}` and `{suffix, prefix}` (with a space) appear, and both `{suffix,prefix}` (suffix-first) and `{prefix,suffix}` (prefix-first) appear, sometimes within the same file. Match the surrounding file; do not reformat existing braces. +Composite items always use `are` in `Where:` since they name a group of felts, per `masm-doc-comments`. -Use `EMPTY_WORD` to denote the all-zero Word `[0, 0, 0, 0]` in stack trackers, `Where:` bullets, and prose comments (e.g. `# => [..., EMPTY_WORD, ...]`). It is a naming convention used in protocol-style code, not a defined constant in source; the point is to convey *meaning* (absence of data) rather than the literal numeric value. +Part **order** should be stack-top-first, i.e. `{suffix,prefix}`, and source agrees by roughly six to one (82 suffix-first against 14 prefix-first). Write suffix-first in new code; the prefix-first occurrences are legacy. -Canonical references in [protocol](https://github.com/0xMiden/protocol): `crates/miden-protocol/asm/protocol/faucet.masm`, `native_account.masm`, `asset.masm`, `active_note.masm`, `active_account.masm`. +Brace **spacing** is genuinely split — `{suffix, prefix}` (45) and `{suffix,prefix}` (37) are both common, sometimes within the same file. Match the surrounding file; do not reformat existing braces. + +Use `EMPTY_WORD` to denote the all-zero Word `[0, 0, 0, 0]` in stack trackers, `Where:` bullets, and prose comments (e.g. `# => [..., EMPTY_WORD, ...]`). It is primarily a naming convention in stack trackers and prose, conveying *meaning* (absence of data) rather than a literal value — though a few standards modules do define it for real (`const EMPTY_WORD = ZERO_WORD` in `standards/auth/note_script_allowlist.masm`, `standards/auth/tx_script_allowlist.masm` and `standards/fees/fee_manager.masm`). + +Canonical references in [protocol](https://github.com/0xMiden/protocol): `crates/miden-protocol/asm/protocol/src/faucet.masm`, `native_account.masm`, `active_note.masm`, `active_account.masm`. (Note that `protocol/src/asset.masm` is a five-line re-export stub — it demonstrates nothing.) ## 2. Stack span notation: the `(N)` family @@ -46,7 +52,7 @@ Canonical references in [protocol](https://github.com/0xMiden/protocol): `crates | Mixed inline tracker with two `(N)` spans | `# => [pad(16), foreign_procedure_inputs(15)]` | | Variable-length remainder | trailing `, ...` | -Canonical references: `crates/miden-protocol/asm/protocol/tx.masm` in [protocol](https://github.com/0xMiden/protocol) for the `(N)` family in both doc blocks and inline trackers and for the `, ...` trailing-remainder form; `crates/lib/core/asm/crypto/hashes/poseidon2.masm` and `crates/lib/core/asm/stark/deep_queries.masm` in [miden-vm](https://github.com/0xMiden/miden-vm) for further `, ...` examples. +Canonical references: `crates/miden-protocol/asm/protocol/src/tx.masm` in [protocol](https://github.com/0xMiden/protocol) for the `(N)` family in both doc blocks and inline trackers and for the `, ...` trailing-remainder form; `crates/lib/core/asm/crypto/hashes/poseidon2.masm` and `crates/lib/core/asm/stark/deep_queries.masm` in [miden-vm](https://github.com/0xMiden/miden-vm) for further `, ...` examples. ## 3. Doc comment divergences across repos @@ -54,14 +60,14 @@ Canonical references: `crates/miden-protocol/asm/protocol/tx.masm` in [protocol] | Dimension | protocol (dominant) | miden-vm (mixed across files) | |---|---|---| -| Inputs/Outputs heading | Plural `Inputs:` / `Outputs:` dominates; a few procs in `tx.masm` pair `Inputs:` with singular `Output:` | Both are active: plural in `mem.masm`, singular `Input:` / `Output:` in `crypto/hashes/poseidon2.masm` | +| Inputs/Outputs heading | Plural `Inputs:` / `Outputs:` is effectively universal (938 uses; singular `Input:` never appears). Four procs pair `Inputs:` with singular `Output:` — two in `protocol/src/tx.masm`, two in the kernel `transaction/lib/api.masm` | Singular is the *majority* here: `Input:` 144 / `Output:` 148 against `Inputs:` 107. Both are active, sometimes in the same file — `crypto/hashes/poseidon2.masm` uses each form roughly half the time | | Panics heading | `Panics if:` with bullet list | `# Panics` markdown-style heading with prose (`mem.masm`), also `Panics if:` (`math/u128.masm`) | -| `Invocation:` line | Standard (`exec`, `call`, `dynexec`; `syscall` in kernel files) | Present in newer files (`math/u128.masm`), absent in older `crypto/hashes/*.masm` | -| `Cycles:` section | Rare but present (multi-line form in `protocol/note.masm`) | Standard on most public procedures | +| `Invocation:` line | Standard (`exec`, `call`, `dynexec`; `syscall` in kernel files) | Uneven — present in `math/u128.masm` and `crypto/hashes/sha256.masm`, absent from `blake3.masm`, `keccak256.masm` and `poseidon2.masm` | +| `Cycles:` section | Rare but present (multi-line form in `protocol/src/note.masm`) | Common but far from universal — roughly a quarter of `pub proc`s carry one, and the big `math/u64.masm`, `math/u128.masm`, `math/u256.masm` and `stark/constants.masm` modules have none at all | Recommendation: when writing new protocol-style code (contracts, account components, tutorial MASM), follow the protocol dominant style (plural `Inputs:`/`Outputs:`, `Panics if:`, `Invocation:`). Use the `Invocation:` value that matches how the procedure is reached: `call` for public account-component / contract procedures, `exec` for internal library procedures, `dynexec` for dynamically invoked procedures. When editing inside an existing miden-vm file, match that file's local style. -Canonical references: `crates/miden-protocol/asm/protocol/faucet.masm` and `native_account.masm` in [protocol](https://github.com/0xMiden/protocol) for plural headings and `Invocation:`; `crates/miden-standards/asm/standards/wallets/basic.masm` in protocol for `Invocation: call` on public account-component procedures; `crates/lib/core/asm/mem.masm`, `crates/lib/core/asm/crypto/hashes/poseidon2.masm`, and `crates/lib/core/asm/math/u128.masm` in [miden-vm](https://github.com/0xMiden/miden-vm). +Canonical references: `crates/miden-protocol/asm/protocol/src/faucet.masm` and `native_account.masm` in [protocol](https://github.com/0xMiden/protocol) for plural headings and `Invocation:`; `crates/miden-standards/asm/standards/wallets/basic.masm` in protocol for `Invocation: call` on public account-component procedures; `crates/lib/core/asm/mem.masm`, `crates/lib/core/asm/crypto/hashes/poseidon2.masm`, and `crates/lib/core/asm/math/u128.masm` in [miden-vm](https://github.com/0xMiden/miden-vm). ## 4. The `Cycles:` Section @@ -90,7 +96,7 @@ Conditional multi-line block introduced by an empty `#! Cycles:` header, followe A prose variant `Total cycles: ...` is also used in some miden-vm files. -Canonical references: `crates/lib/core/asm/crypto/hashes/poseidon2.masm` in [miden-vm](https://github.com/0xMiden/miden-vm) for all three shapes; `crates/miden-protocol/asm/protocol/note.masm` in [protocol](https://github.com/0xMiden/protocol) for the multi-line form outside of stdlib; `crates/lib/core/asm/mem.masm` in miden-vm for the `Total cycles:` prose variant. +Canonical references: `crates/lib/core/asm/crypto/hashes/poseidon2.masm` in [miden-vm](https://github.com/0xMiden/miden-vm) for all three shapes; `crates/miden-protocol/asm/protocol/src/note.masm` in [protocol](https://github.com/0xMiden/protocol) for the multi-line form outside of stdlib; `crates/lib/core/asm/mem.masm` in miden-vm for the `Total cycles:` prose variant. ## 5. Assertions and Error Messages @@ -102,22 +108,46 @@ Two active forms: Chained guard pattern, called out in `0xMiden/agent-tools#6`: ```masm -u32assert2 u32lte.MAX_LEAF_SIZE assert.err="invalid leaf: larger than maximum size of 8192" +dup u32assert2 u32lte.MAX_LEAF_SIZE assert.err="invalid leaf: larger than maximum size of 8192" ``` Multiple u32 guards are chained on one line followed by a single `assert.err=` attachment. The same pattern with `u32lt` / `u32gte` and other comparators is also common. Doc rule: `Panics if:` bullets describe the condition, not the error identifier. A bullet like `the nonce has already been incremented.` is preferred over `ERR_ACCOUNT_NONCE_CAN_ONLY_BE_INCREMENTED_ONCE.`. -Canonical references: `crates/miden-protocol/asm/protocol/note.masm` and `asset.masm` in [protocol](https://github.com/0xMiden/protocol) for the plain `assert.err=ERR_*` form, and `active_account.masm` for the `assert_eq.err=ERR_*` form; `crates/lib/core/asm/mem.masm`, `crates/lib/core/asm/stark/random_coin.masm`, and `crates/lib/core/asm/collections/smt.masm` in [miden-vm](https://github.com/0xMiden/miden-vm) for inline string forms and the chained guard pattern. +Canonical references: `crates/miden-protocol/asm/protocol/src/note.masm` in [protocol](https://github.com/0xMiden/protocol) for the plain `assert.err=ERR_*` form, `crates/miden-protocol/asm/protocol/src/active_note.masm` for the `assert_eq.err=ERR_*` form, and `crates/miden-agglayer/asm/agglayer/bridge/bridge_in.masm` for both; `crates/lib/core/asm/mem.masm`, `crates/lib/core/asm/stark/random_coin.masm`, and `crates/lib/core/asm/collections/smt.masm` in [miden-vm](https://github.com/0xMiden/miden-vm) for inline string forms and the chained guard pattern. + +## 6. `miden-format`, the MASM formatter + +Mechanical formatting is handled by the `miden-format` binary that ships in `miden-vm` (`crates/miden-format`). Run it over paths or over stdin: + +```bash +miden-format path/to/file.masm # rewrite in place +miden-format --check path/to/dir # exit non-zero if anything would change +miden-format --stdin --stdin-filepath a.masm < a.masm +``` + +When walking a directory it processes only `.masm` files. + +Configuration comes from a `miden-format.toml` in the **current working directory**, with `--config` merged on top of it: + +```toml +indent_width = 4 # alias: indent_size +max_line_length = 100 +overflow_delimited_expr = false +``` + +Those three keys are the entire configuration surface, and the values above are the defaults. The formatter's README also shows a "Hypothetical future configuration" table — those options do not exist; do not put them in a `miden-format.toml`. + +`miden-format` normalizes whitespace and wrapping. It does not enforce anything in this skill or its prerequisites — naming, doc-block section order, `# =>` trackers, and error-message style all remain manual review items. -## 6. Description Verbs +## 7. Description Verbs Doc descriptions start with a capitalized present-tense verb and end the first sentence with a period. Representative canonical verbs used in source: `Returns`, `Creates`, `Increments`, `Computes`, `Copies`, `Asserts`, `Verifies`, `Hashes`, `Adds`, `Removes`. Multi-sentence elaboration continues on following `#!` lines. -Canonical references: `crates/miden-protocol/asm/protocol/native_account.masm` and `asset.masm` in [protocol](https://github.com/0xMiden/protocol); `crates/lib/core/asm/mem.masm` and `crates/lib/core/asm/math/u64.masm` in [miden-vm](https://github.com/0xMiden/miden-vm). +Canonical references: `crates/miden-protocol/asm/protocol/src/native_account.masm` and `asset.masm` in [protocol](https://github.com/0xMiden/protocol); `crates/lib/core/asm/mem.masm` and `crates/lib/core/asm/math/u64.masm` in [miden-vm](https://github.com/0xMiden/miden-vm). -## 7. Inline Stack Tracker Integration +## 8. Inline Stack Tracker Integration `masm-inline-comments` owns the lowercase-start and comment-only-non-obvious rules. This skill adds one integration rule: an inline `# => [...]` tracker uses the exact same item names, capitalization, and `(N)` span notation as the `#!` doc block. @@ -132,9 +162,9 @@ end In the illustration above, the single-felt name `final_nonce` is identical in the doc block and the inline tracker, and the `pad(15)` span follows the `(N)` family rule. The same principle holds in real source: Word-sized items are UPPERCASE in both places, and composite names like `account_id_{suffix,prefix}` decompose into their underlying felts in inline trackers (e.g. `account_id_suffix`, `account_id_prefix`). -Canonical reference: any public proc in `crates/miden-protocol/asm/protocol/native_account.masm` in [protocol](https://github.com/0xMiden/protocol) shows this pattern end-to-end. +Canonical reference: any public proc in `crates/miden-protocol/asm/protocol/src/native_account.masm` in [protocol](https://github.com/0xMiden/protocol) shows this pattern end-to-end. -## 8. Compact Before / After +## 9. Compact Before / After A malformed protocol-style procedure followed by its corrected form. Every fix is justified by the rule it applies. @@ -176,7 +206,7 @@ end Fix log: -- Capitalized description verb and trailing period: §6 (Description Verbs); pattern across procs in `native_account.masm` in [protocol](https://github.com/0xMiden/protocol). +- Capitalized description verb and trailing period: §7 (Description Verbs); pattern across procs in `native_account.masm` in [protocol](https://github.com/0xMiden/protocol). - `Input:`/`Output:` rewritten to plural `Inputs:`/`Outputs:` to match the protocol-dominant plural style: §3 (Doc Comment Divergences); pattern in `faucet.masm` in protocol. - `Final_Nonce` rewritten to `final_nonce` (single felt is `lower_snake_case`): §1 (Capitalization). - `Where:` sentence ends with a period: rule owned by `masm-doc-comments`. @@ -189,13 +219,14 @@ The full set of source files referenced above, by purpose. Repos: [protocol](htt | File | What to study | |---|---| -| `crates/miden-protocol/asm/protocol/native_account.masm` (protocol) | end-to-end protocol-style proc: composite `{suffix,prefix}`, `Panics if:`, `Invocation:`, inline `pad(N)` tracking | -| `crates/miden-protocol/asm/protocol/faucet.masm` (protocol) | Word UPPERCASE in `Inputs:`/`Outputs:`, plural heading style | -| `crates/miden-protocol/asm/protocol/tx.masm` (protocol) | `(N)` span family in both doc blocks and inline trackers; also the singular `Output:` exceptions to the plural-dominant style | +| `crates/miden-protocol/asm/protocol/src/native_account.masm` (protocol) | end-to-end protocol-style proc: composite `{suffix,prefix}`, `Panics if:`, `Invocation:`, inline `pad(N)` tracking | +| `crates/miden-protocol/asm/protocol/src/faucet.masm` (protocol) | Word UPPERCASE in `Inputs:`/`Outputs:`, plural heading style | +| `crates/miden-protocol/asm/protocol/src/tx.masm` (protocol) | `(N)` span family in both doc blocks and inline trackers; also the singular `Output:` exceptions to the plural-dominant style | | `crates/miden-standards/asm/standards/wallets/basic.masm` (protocol) | `Invocation: call` on public account-component procedures | -| `crates/miden-protocol/asm/protocol/asset.masm` (protocol) | plain `assert.err=ERR_*` named-constant style | -| `crates/miden-protocol/asm/protocol/note.masm` (protocol) | plain `assert.err=ERR_*` named-constant style and multi-line `Cycles:` outside of stdlib | -| `crates/miden-protocol/asm/protocol/active_account.masm` (protocol) | brace-spacing variance and `assert_eq.err=ERR_*` named-constant style | +| `crates/miden-protocol/asm/protocol/src/active_note.masm` (protocol) | `assert_eq.err=ERR_*` / `assert_eqw.err=ERR_*` named-constant style | +| `crates/miden-protocol/asm/protocol/src/note.masm` (protocol) | plain `assert.err=ERR_*` named-constant style and multi-line `Cycles:` outside of stdlib | +| `crates/miden-protocol/asm/protocol/src/active_account.masm` (protocol) | brace-spacing variance (this module contains no assertions) | +| `crates/miden-agglayer/asm/agglayer/bridge/bridge_in.masm` (protocol) | `assert_eq.err=ERR_*` named-constant style | | `crates/lib/core/asm/crypto/hashes/poseidon2.masm` (miden-vm) | singular `Input:`/`Output:` and all three `Cycles:` shapes | | `crates/lib/core/asm/mem.masm` (miden-vm) | plural `Inputs:`/`Outputs:` co-existing in vm, `# Panics` heading, `Total cycles:` prose variant | | `crates/lib/core/asm/math/u128.masm` (miden-vm) | `Invocation: exec` present in newer vm files | diff --git a/skills/masm-inline-comments/SKILL.md b/skills/masm-inline-comments/SKILL.md index 02eb01f..9e1a583 100644 --- a/skills/masm-inline-comments/SKILL.md +++ b/skills/masm-inline-comments/SKILL.md @@ -13,10 +13,8 @@ Inline comments (single `#`) should begin with a lowercase letter. ```masm # good: lowercase start -# remove the asset from the account exec.native_account::remove_asset -dropw -# => [ASSET_KEY, ASSET_VALUE, note_idx, pad(7)] +# => [ASSET, note_idx, pad(11)] # Bad: uppercase start (avoid) # Remove the asset from the account @@ -33,7 +31,7 @@ Only apply this rule to new code you write. Do not remove comments that are pres - Standard control flow: `if.true`, `while.true`, `end` **Do comment:** -- Stack state after complex operations: `# => [ASSET_KEY, ASSET_VALUE, note_idx, pad(7)]` +- Stack state after complex operations: `# => [ptr, ASSET, end_ptr]` - Purpose of a code block: `# compute the pointer at which we should stop iterating` - Non-obvious logic or business rules - TODO items and references to external specs @@ -43,7 +41,7 @@ Only apply this rule to new code you write. Do not remove comments that are pres Insert a blank line after a `# => [...]` stack-state tracker, except when the next non-blank line is one of: - `end` (proc / `while.true` / `if.true` / `repeat.N` closing). -- A control-flow keyword such as `else` (note: `else` is always bare; a false-conditioned branch uses `if.false`, not an `else.*` suffix). +- A control-flow keyword such as `else`, `else.true`, or `else.false`. - Another `# =>` line that continues the same multi-line stack state. - A `#` continuation comment that explains the tracker. @@ -52,13 +50,11 @@ This pairs each stack state visually with the operation that produced it and let **Good:** ```masm -dupw.1 dupw.1 -# => [ASSET_KEY, ASSET_VALUE, ASSET_KEY, ASSET_VALUE, note_idx, pad(7)] - -# remove the asset from the account exec.native_account::remove_asset -dropw -# => [ASSET_KEY, ASSET_VALUE, note_idx, pad(7)] +# => [ASSET, note_idx, pad(11)] + +dupw dup.8 movdn.4 +# => [ASSET, note_idx, ASSET, note_idx, pad(11)] ``` **Also OK (no blank line before `end` or control flow):** @@ -68,21 +64,58 @@ dropw end ``` +### 4. Match the doc block + +An inline `# => [...]` tracker uses the same item names, capitalization, and `(N)` span notation as the `#!` doc block for the enclosing procedure (see masm-doc-comments skill): + +- Single-felt names stay lowercase: `note_idx`, `final_nonce`. +- Word names stay UPPERCASE: `ASSET`, `RECIPIENT`. +- `(N)` spans stay lowercase: `pad(12)`, `foreign_procedure_inputs(15)`. + +Composite names like `account_id_{suffix,prefix}` are a doc-block shorthand for a group of felts. In inline trackers they decompose into their individual felts since each felt occupies one stack slot: + +```masm +#! Inputs: [account_id_{suffix,prefix}, amount] +pub proc transfer + # => [account_id_suffix, account_id_prefix, amount] + ... +end +``` + +### 5. Reuse existing terminology + +Use the vocabulary already established in the surrounding code and doc comments. Do not coin new terms or colloquialisms for a concept that already has a name — a value written to a local is "stored", not "stashed". This applies to inline comments and to constant-header comments. + +### 6. Comment the code, not the change + +Inline comments explain what the code does for a future reader, not why a particular PR made a change. Avoid PR narrative and framing such as "this is the X that prevents Y"; describe the operation and its purpose as the code stands. + +### 7. Accessing a word's individual elements + +When accessing individual elements of a word, show the word destructured into elements, grouped with brackets, e.g.: + +``` +# => [ASSET_ID, ASSET_VALUE] +# => [[asset_class_suffix, asset_class_prefix, faucet_id_suffix_and_metadata, faucet_id_prefix], ASSET_VALUE] + +dup +# => [asset_class_suffix, ASSET_ID, ASSET_VALUE] +``` + ## Examples **Good:** ```masm -dupw.1 dupw.1 -# => [ASSET_KEY, ASSET_VALUE, ASSET_KEY, ASSET_VALUE, note_idx, pad(7)] - # remove the asset from the account exec.native_account::remove_asset -dropw -# => [ASSET_KEY, ASSET_VALUE, note_idx, pad(7)] +# => [ASSET, note_idx, pad(11)] + +dupw dup.8 movdn.4 +# => [ASSET, note_idx, ASSET, note_idx, pad(11)] exec.output_note::add_asset -# => [pad(16)] +# => [ASSET, note_idx, pad(11)] ``` **Avoid:** diff --git a/skills/masm-locals-over-globals/SKILL.md b/skills/masm-locals-over-globals/SKILL.md index 75d15ef..2533d45 100644 --- a/skills/masm-locals-over-globals/SKILL.md +++ b/skills/masm-locals-over-globals/SKILL.md @@ -7,30 +7,22 @@ description: Use when a MASM procedure needs temporary scratch storage — keep ## Rule -When a MASM procedure needs scratch storage that lives only for the duration of one invocation, use procedure-local memory rather than allocating in a shared global memory region. - -Declare how many locals the procedure needs with `@locals(N)` directly above the procedure, then read and write them: - -- `loc_store` / `loc_load` for a single element. -- `loc_storew_le` / `loc_loadw_le` (or the `_be` variants) for a full word. - -The bare `loc_storew` / `loc_loadw` instructions have been removed and now raise a deprecation error — use the `_le` / `_be` word variants instead. +When a MASM procedure needs scratch storage that lives only for the duration of one invocation, use procedure-local memory (`loc_store`, `loc_load`, `loc_storew`, `loc_loadw`) rather than allocating in a shared global memory region. Global memory regions are reserved for state that crosses procedure boundaries (kernel inputs, account data, advice-keyed state). Stashing per-call scratch there leaks an implementation detail into a shared namespace and ties the procedure to a fixed address. -## Why +This ranks locals against globals only. Scratch that fits on the operand stack - loop counters, pointers, indices - belongs there rather than in a local; see `cheap-masm-equivalents`. -Procedure locals are addressed relative to the procedure's frame pointer (FMP) and bounded by its declared `@locals(N)` count: the assembler emits a prologue that advances the FMP to allocate the frame on entry and an epilogue that restores it on exit, so two callers of the same procedure can't collide. A hard-coded scratch slot in global memory writes to a fixed absolute address with no per-procedure isolation, so it risks colliding with another procedure using the same address, forces every caller to avoid clobbering it, and locks the layout. +## Why -A procedure with no `@locals` declaration allocates zero locals, so any `loc_store` / `loc_load` in it fails to assemble — always declare the locals you use. +Procedure locals are allocated and freed by the VM, so two callers of the same procedure can't collide. A hard-coded scratch slot in global memory risks colliding with another procedure, forces every caller to avoid clobbering it, and locks the layout. ## Examples ```masm # Good -@locals(2) proc compute_hash - # write the two scratch values into local slots 0 and 1 + # allocate two local slots loc_store.0 loc_store.1 # ... @@ -38,7 +30,7 @@ proc compute_hash loc_load.1 end -# Bad: scratch in a shared global region +# Bad: scratch in a shared region const SCRATCH_PTR = 0x4000 proc compute_hash mem_store.SCRATCH_PTR # collides with anyone else using SCRATCH_PTR diff --git a/skills/masm-padding/SKILL.md b/skills/masm-padding/SKILL.md index 2692d00..8e2f42e 100644 --- a/skills/masm-padding/SKILL.md +++ b/skills/masm-padding/SKILL.md @@ -7,29 +7,23 @@ description: Enforce stack padding conventions for Miden Assembly (.masm) proced ## Overview -Miden assembly has five invocation instructions, which fall into two stack-discipline classes: +Padding requirements differ based on procedure invocation type: -| Class | Instructions | Stack behavior | Padding convention | -|-------|--------------|----------------|--------------------| -| **Same context** (inline) | `exec`, `dynexec` | Share the caller's operand stack; no depth-16 boundary | Follows the caller — no required pad-to-16 | -| **Context switch** | `call`, `dyncall`, `syscall` | New/return context; on entry the visible stack is set to depth 16, and on return the depth must be *exactly* 16 or the VM traps | Inputs/Outputs documented to 16 with `pad(N)` (prevailing convention) | - -The difference between `dynexec` and `dyncall` is the same as the difference between `exec` and `call`: the `dyn` variants only differ from the static ones in that the call target is resolved dynamically from the stack at runtime. `dynexec` does NOT context-switch — it inlines into the caller's context just like `exec`. - -`syscall` is the same as `call` mechanically (it context-switches and truncates the visible stack to 16), except the target must be a procedure in the program's kernel and execution returns to the root context. +| Invocation | Padding Required | Input/Output Elements | +|------------|------------------|----------------------| +| `call` | Explicit padding in comments | Exactly 16 | +| `exec` | No explicit padding | No requirement | ## Stack Depth Floor: 16 Miden VM enforces a minimum operand-stack depth of 16 elements (`MIN_STACK_DEPTH = 16` in the VM core). When an operation would naively shrink the stack below 16, the VM auto-fills the missing positions with zeros via the overflow-table mechanism. The actual depth stays exactly 16; only the visible content shrinks. -This invariant applies at the entry/return boundary of the **context-switching** invocations: +This invariant applies at the entry boundary of: -- `call`, `dyncall`, and `syscall` procedures (the visible stack is set to 16 on entry, and must be exactly 16 on return or the VM traps), +- `call` procedures, - note scripts and transaction scripts (entered via `dyncall` at depth 16). -> Note: for `dyncall`, the stack is shifted left by one element (the consumed MAST-root pointer) before being set to 16. - -It does NOT apply to the **same-context** invocations `exec` and `dynexec`, which share the caller's stack and can drop the visible count below 16 by consuming caller elements (see Danger Zone). +It does NOT apply to mid-chain `exec` procedures, which share the caller's stack and can drop the visible count below 16 by consuming caller elements (see Danger Zone). ### Tracking the floor in inline comments @@ -50,19 +44,16 @@ Not: This shows up most often at the start of note scripts that don't use their input arguments: ```masm -@note_script -pub proc main +begin dropw # => [pad(16)] ... end ``` -Standard note scripts are MASM library procs annotated with `@note_script` (`pub proc main … end`), not bare `begin … end` programs. Bare `begin … end` is still valid MASM, but only at the top-level kernel/tx-script program entry. +## Call Procedures -## Context-Switching Procedures (`call`, `dyncall`, `syscall`) - -The VM hard-requires the visible stack depth to be exactly 16 when a context-switching procedure returns; otherwise the VM traps. To make this explicit, the prevailing convention is to document Inputs/Outputs padded to 16 with `pad(N)`: +Procedures invoked with `call` must have explicit padding in: 1. **Doc comments** (`#!`) for Inputs/Outputs 2. **Inline comments** (`#`) showing stack state @@ -71,55 +62,46 @@ The VM hard-requires the visible stack depth to be exactly 16 when a context-swi Use `pad(N)` notation where N + other elements = 16: ```masm -#! Inputs: [ASSET_KEY, ASSET_VALUE, pad(8)] +#! Inputs: [ASSET, pad(12)] #! Outputs: [pad(16)] #! #! Invocation: call pub proc receive_asset ``` -This is a strong convention, not an absolute documentation rule. Some standard `call` procedures document only their *logical* stack (fewer than 16 elements, no `pad`) — for example multisig accessors document `Inputs: [index]` / `Outputs: [PUB_KEY, scheme_id]`. Prefer `pad(N)`-to-16 for new code, but do not flag logical-only documentation on an existing `call`/`dyncall`/`syscall` proc as a bug. - ### Inline Comment Format Track padding through the procedure: ```masm -exec.native_account::add_asset -# => [ASSET_VALUE', pad(12)] +exec.native_account::set_item +# => [OLD_VALUE, pad(12)] -# drop the final asset dropw -# => [pad(16)] +# => [pad(16)] auto-padded to 16 elements ``` -## Same-Context Procedures (`exec`, `dynexec`) - -Procedures invoked with `exec` or `dynexec` share the caller's stack directly, so their padding follows whatever the caller establishes — there is no depth-16 boundary to satisfy. +## Exec Procedures -Most `exec`/`dynexec` procedures document only their logical inputs/outputs, without explicit padding: +Procedures invoked with `exec` should NOT have explicit padding: ```masm -#! Inputs: [PUB_KEY, scheme_id] +#! Inputs: [PUB_KEY] #! Outputs: [] #! #! Invocation: exec pub proc authenticate_transaction ``` -The same holds for `dynexec` — e.g. the `TokenPolicyManager` faucet policies document logical I/O (`Inputs: [amount, tag, note_type, RECIPIENT]` for a mint policy; `Inputs: [ASSET_KEY, ASSET_VALUE]` / `Outputs: []` for a burn policy), exactly like `exec`. +### Why No Padding for Exec -The exception is when the caller itself works in a padded-to-16 context: then the `exec`/`dynexec` proc documents `pad(N)`-to-16 to match the caller. The kernel transaction API does this — its `dynexec` procedures pad to 16 (e.g. `account_get_initial_commitment` documents `Inputs: [pad(16)]` / `Outputs: [INIT_COMMITMENT, pad(12)]`) because the surrounding caller convention is padded. Padding for same-context procedures is dictated by the caller, not by the invocation instruction. - -### Why Padding Follows the Caller - -`exec` / `dynexec` procedures share the caller's stack directly. Imposing a fixed pad would be misleading because: +`exec` procedures share the caller's stack directly. Explicit padding would be misleading because: - The actual stack may have additional elements from the caller - The procedure may consume caller's stack elements ### Danger Zone -If an `exec` or `dynexec` procedure's stack falls below the elements it expects, it will consume stack items from its caller, potentially leading to unexpected behavior. This is a bug and should be fixed by ensuring the procedure maintains sufficient stack depth and avoiding dropping more stack elements than available. +If an `exec` procedure's stack falls below the specified stack elements, it will consume stack items from its caller, potentially leading to unexpected behavior. This is a bug and should be fixed by ensuring the procedure maintains sufficient stack depth and avoiding dropping more stack elements than available. ### Example of Dangerous Behavior @@ -138,46 +120,41 @@ Inside a procedure, the stack may temporarily exceed 16 elements: # ^--- 18 elements total, must be reduced before return ``` -For context-switching procedures these extra elements must be explicitly dropped before the procedure returns (directly or via called procedures), so the visible depth is exactly 16 on return. +These extra elements must be explicitly dropped before the procedure returns (directly or via called procedures). ## Debugging Stack Depth -When unsure whether the stack matches the depth you expect, use the assembly's debug instructions to inspect it at runtime. These cost zero VM cycles and do not affect the program hash. +When unsure whether the stack matches the depth you expect, use the assembly's debug instructions to inspect it at runtime. These cost zero VM cycles, do not affect the program hash, and are stripped at compile time when the assembler is not in debug mode. - `debug.stack` – print the full operand stack. -- `debug.stack.N` – print only the top N elements. N is a `u8` (syntactic range `0..=255`); the useful range for "top N" is `1..=255`. `debug.stack.0` is accepted and prints the whole operand stack (equivalent to `debug.stack`). +- `debug.stack.N` – print only the top N elements (1 ≤ N < 256). - `sdepth` – push the current stack depth onto the stack as a felt; useful when you need depth as a runtime value, e.g. to assert it: ```masm sdepth push.16 eq assert.err="depth must be 16 here" ``` -Debug instructions run when the program is executed with debug mode enabled. The `miden-vm run` command runs with debug mode enabled by default — just run the program to see their output: - -```bash -miden-vm run program.masm -``` - -Pass `--release` (`-r`) to disable debug instructions (release mode): +Run with the `--debug` flag to see output: ```bash -miden-vm run program.masm --release +miden-vm run program.masm --debug ``` -Debug instructions are compiled into the MAST as decorators (they are not stripped at compile time). When debug mode is disabled they are skipped at execution time — i.e. not executed — rather than removed. Remove or comment out `debug.*` lines before committing production MASM. +Without `--debug`, debug instructions are silently removed. Remove or comment out `debug.*` lines before committing production MASM. ## Validation Checklist For all invocation types: -- [ ] Inline `# =>` trackers reflect the post-auto-pad depth (never below 16) at the context-switch boundaries that enforce the floor (`call`, `dyncall`, `syscall`, note scripts, tx scripts) +- [ ] Inline `# =>` trackers reflect the post-auto-pad depth (never below 16) at boundaries that enforce the floor (`call`, note scripts, tx scripts) - [ ] No `debug.*` instruction is left in production MASM -For context-switching procedures (`call`, `dyncall`, `syscall`): -- [ ] On return, the visible stack depth is exactly 16 (the VM traps otherwise) -- [ ] Inputs/Outputs doc comments prefer `pad(N)`-to-16; logical-only documentation on existing procs is acceptable, not a bug +For `call` procedures: +- [ ] Inputs doc comment shows exactly 16 elements with `pad(N)` +- [ ] Outputs doc comment shows exactly 16 elements with `pad(N)` - [ ] Inline comments use `# =>` format with `pad(N)` notation - [ ] All intermediate states track the full stack including padding -For same-context procedures (`exec`, `dynexec`): -- [ ] Padding follows the caller — logical (un-padded) I/O when the caller is logical; `pad(N)`-to-16 when the caller works in a padded-to-16 context (e.g. kernel API `dynexec` procs) -- [ ] Verify the stack never drops below the elements the procedure expects, so it does not consume the caller's stack +For `exec` procedures: +- [ ] No `pad(N)` in Inputs/Outputs doc comments +- [ ] No explicit padding in inline stack state comments +- [ ] Verify stack never drops below safe depth diff --git a/skills/masm-proc-type-signatures/SKILL.md b/skills/masm-proc-type-signatures/SKILL.md new file mode 100644 index 0000000..7b95d28 --- /dev/null +++ b/skills/masm-proc-type-signatures/SKILL.md @@ -0,0 +1,116 @@ +--- +name: masm-proc-type-signatures +description: Enforce type-signature conventions for public Miden Assembly (.masm) procedures. Use when adding, editing, or reviewing a `pub proc` signature — parameter and return types, semantic type aliases, struct/array/tuple types, and how the signature maps onto the operand stack and the doc-comment Inputs/Outputs. +--- + +# MASM Procedure Type Signatures + +## Overview + +Public MASM procedures carry a type signature on the `pub proc` line, mirroring the Rust API: + +```masm +pub proc get_map_item(slot_id: StorageSlotId, key: StorageMapKey) -> word +``` + +The signature names each stack input as a typed parameter and declares the return type, using semantic aliases (`AccountId`, `NoteRecipient`, `AssetAmount`, …) named after the corresponding Rust types. It is **ABI/AST metadata only**: adding or changing a signature does not change the procedure's behavior, its MAST root, or the transaction-kernel commitment. The signature complements — it does not replace — the doc-comment `Inputs:` / `Outputs:` stack notation. + +## Syntax + +The signature attaches to the procedure name, after any `@`-attributes and after the doc comment: + +```masm +#! ...doc comment... +#! +#! Invocation: exec +pub proc name(param_a: TypeA, param_b: TypeB) -> ReturnType + ... +end +``` + +- **No parameters:** keep the empty parens — `pub proc get_nonce() -> felt`. +- **No return value:** omit the arrow — `pub proc mint(asset: Asset)`. +- **Multiple return values:** a tuple — `pub proc find_attachment(attachment_scheme: u16) -> (Bool, u8)`. +- **Long signatures** may wrap across lines: + +```masm +@locals(6) +pub proc execute_foreign_procedure( + foreign_account_id: AccountId, + foreign_proc_root: AccountProcedureRoot, + foreign_procedure_inputs: [felt; 16] +) -> [felt; 16] +``` + +Attributes (`@locals(N)`, `@auth_script`, `@account_procedure`, …) stay on their own lines immediately before `pub proc`; the signature is part of the `pub proc` line. + +## Type vocabulary + +Prefer the most specific **semantic alias** that fits; fall back to a primitive only when no domain type applies. + +Primitives: +- `felt` — a single field element (generic value: a nonce, a raw commitment element). +- `word` — 4 felts (a generic commitment, hash, or root with no dedicated newtype). +- `u8`, `u16`, `u32` — sized integers (counts, indices, deltas, tags). +- `i1` — a single-bit boolean (aliased as `Bool`). +- `[felt; N]` — a fixed-size span of N felts that is a *real* input/output (not padding). +- `struct { field: T, ... }` — an ordered group of typed fields. +- `(T, U, ...)` — a tuple return of multiple values. + +Semantic aliases (defined in `types.masm`, see below): +- Two-felt identifiers: `AccountId`, `StorageSlotId` (`struct { suffix: felt, prefix: felt }`). +- Word newtypes: `AssetId`, `AssetValue`, `AccountProcedureRoot`, `StorageMapKey`, `NoteRecipient`, `NoteMetadata`, `NoteScriptRoot`, `TransactionScriptRoot`. +- Composite: `Asset` (`struct { id: word, value: word }`), `DoubleWord`. +- Scalars: `AssetAmount` (felt), `NoteTag` / `BlockNumber` / `MemoryAddress` (u32), `NoteType` (u8), `Bool` (i1). + +Use bare `word` / `felt` for generic commitments, hashes, roots, nonces, counts, and timestamps that have no dedicated domain type; use the newtype whenever one exists for the concept. + +## Stack ordering and flattening + +The signature must describe the same stack the doc comment does. Rules: + +- **Parameters are listed top-of-stack first.** The first parameter sits on top of the operand stack, the next below it, and so on. Return values follow the same order (first tuple element on top). +- **Structs flatten field-0-on-top.** `AccountId = struct { suffix, prefix }` puts `suffix` on top and `prefix` below, so `get_id() -> AccountId` yields `[account_id_suffix, account_id_prefix]`. `Asset = struct { id: word, value: word }` puts the id word on top of the value word. +- **Padding is excluded from signatures.** `pad(N)` never appears as a parameter or return type, even though the doc-comment Inputs/Outputs still show it (matching the `tx_prepare_fpi` / `call`-convention precedent). A `call` entrypoint whose doc reads `Inputs: [SCRIPT_ROOT, pad(12)]` has the signature `(script_root: word)`. +- **Real fixed spans are included.** A genuine 16-felt argument is `[felt; 16]` in the signature and `foreign_procedure_inputs(16)` in the doc — that is data, not padding. + +## Relationship to the doc comment + +Signature and doc comment must agree on order and count, but use different naming styles — keep both: + +- Signature parameter/field names are lowercase `snake_case` regardless of width: `recipient: NoteRecipient`, `asset: Asset`, `key: StorageMapKey`. +- Doc-comment stack names follow `masm-doc-comments`: single felts lowercase (`tag`, `note_index`), words UPPERCASE (`RECIPIENT`, `KEY`), split identifiers `account_id_{suffix,prefix}`, spans `name(N)`. + +So `create(tag: NoteTag, note_type: NoteType, recipient: NoteRecipient) -> u16` pairs with `Inputs: [tag, note_type, RECIPIENT]`. The `Where:` bullets still describe every item; the signature does not remove the need for them. + +## Declaring a new type alias + +Add aliases to the protocol type modules rather than inlining `struct { ... }` at each proc: + +- `crates/miden-protocol/asm/protocol/src/types.masm` — protocol-library types, imported via `use ... from miden::protocol::types`. +- `crates/miden-protocol/asm/protocol_utils/src/types.masm` — types shared with the transaction kernel (e.g. `AccountId`), because both the kernel and protocol library reference them. + +Declare with `pub type`, and add a short comment when the stack layout is non-obvious (which felt is on top): + +```masm +# Two-felt identifier (suffix on top of the stack, prefix below). +pub type StorageSlotId = struct { suffix: felt, prefix: felt } + +pub type NoteTag = u32 +``` + +Name the alias after the Rust type it mirrors so a signature reads like the Rust API. When a type already exists, reuse it — do not introduce a second alias for the same concept. + +## Scope + +Every public procedure (`pub proc`) should carry a signature — for both `exec` and `call`/`dyncall` invocation styles (padding is excluded for the latter). Private `proc`s may carry one when it aids clarity but are not required to. Adding signatures to existing untyped `pub proc`s is a safe, root-preserving change. + +## Validation checklist + +- [ ] Every `pub proc` has a signature: `()` when it takes no inputs, no `->` when it returns nothing. +- [ ] Parameters are listed top-of-stack first; return order matches (first item on top). +- [ ] Structs flatten field-0-on-top; the flattened order matches the doc-comment Inputs/Outputs. +- [ ] `pad(N)` does not appear in the signature; real spans use `[felt; N]`. +- [ ] The most specific semantic alias is used; bare `word`/`felt` only for generic commitments/counts with no domain type. +- [ ] New aliases are declared with `pub type` in the appropriate `types.masm`, named after the Rust type, with a layout comment when non-obvious. +- [ ] The doc-comment `Inputs:`/`Outputs:` and `Where:` sections are still present and consistent with the signature (see `masm-doc-comments`). diff --git a/skills/masm-rust-constant-parity/SKILL.md b/skills/masm-rust-constant-parity/SKILL.md index 2d2eb08..7ac216f 100644 --- a/skills/masm-rust-constant-parity/SKILL.md +++ b/skills/masm-rust-constant-parity/SKILL.md @@ -11,11 +11,11 @@ Constants that exist on both the MASM and the Rust side must not drift. Prefer a This repo already does that in `crates/miden-protocol/build.rs`: it scans the MASM sources for `const ERR_... = "..."` and `const X = event("...")` definitions and emits Rust files (`tx_kernel_errors.rs`, `protocol_errors.rs`, `transaction_events.rs`) that are pulled in with `include!(concat!(env!("OUT_DIR"), ...))` (see `src/errors/mod.rs` and `src/transaction/kernel/tx_event_id.rs`). A new error or event constant added in MASM gets its Rust binding automatically. -For constants not covered by codegen (memory offsets, capacity limits, field widths still duplicated in `src/constants.rs` and `src/transaction/kernel/memory.rs`, mirroring `asm/kernels/transaction/lib/memory.masm`), update both sides in the same PR — and prefer extending the generation over adding another hand-copied literal. +For constants not covered by codegen (memory offsets, capacity limits, field widths still duplicated in `src/constants.rs` / `memory.rs`), update both sides in the same PR — and prefer extending the generation over adding another hand-copied literal. ## Why -The kernel reads memory at offsets the Rust host wrote. If one side changes `ACTIVE_INPUT_NOTE_PTR` and the other doesn't, every transaction misreads its state, and the bug stays invisible until a value happens to straddle the changed offset. Generating one side from the other removes the chance to forget. +The kernel reads memory at offsets the Rust host wrote. If one side changes `ACCOUNT_HEADER_LEN` and the other doesn't, every transaction misreads its state, and the bug stays invisible until a value happens to straddle the changed offset. Generating one side from the other removes the chance to forget. ## Examples @@ -40,6 +40,6 @@ pub const MAX_INPUT_NOTES_PER_TX: usize = 1024; ``` ```masm -# crates/miden-protocol/asm/kernels/transaction/lib/constants.masm — must equal the Rust constant +# crates/miden-protocol/asm/.../constants.masm — must equal the Rust constant pub const MAX_INPUT_NOTES_PER_TX = 1024 ``` diff --git a/skills/miden-concepts/SKILL.md b/skills/miden-concepts/SKILL.md index 1eee49c..6900beb 100644 --- a/skills/miden-concepts/SKILL.md +++ b/skills/miden-concepts/SKILL.md @@ -21,7 +21,7 @@ Key properties: | Transactions involve sender + receiver | Transactions involve **one account only** | | Public state by default | **Private by default** | | Validators execute transactions | **Client executes and proves** locally | -| Gas metering | No gas (computational bounds exist) | +| Gas metering | **Fees are paid by the account's own auth procedure**, which funds a public `TX_FEE` note out of the vault — not burned by the kernel. Execution is separately bounded by `MAX_TX_EXECUTION_CYCLES` | | Synchronous contract calls | **Asynchronous** communication via notes | | Accounts are balances + storage | Accounts are **full smart contracts** with code, storage, and vault | @@ -29,45 +29,69 @@ Key properties: ### Accounts Each account is an independent smart contract containing: -- **Code** — Immutable logic compiled from Rust components -- **Storage** — Up to 255 slots exposed in Rust as `StorageValue` or `StorageMap` +- **Code** — Logic compiled from Rust components +- **Storage** — Up to 255 slots (`AccountStorage::MAX_NUM_STORAGE_SLOTS`), exposed in guest Rust as `StorageValue` or `StorageMap`. Slots are **named, not positional**: each is a `StorageSlotName` paired with its content, kept sorted by name, and a duplicate name is rejected (`AccountError::DuplicateStorageSlotName`). The slot id is derived from the name, which is why a component reading its own named slot is portable across accounts without taking the slot as a parameter - **Vault** — Holds fungible and non-fungible assets -- **Nonce** — Incremented with each state change -- **ID** — Unique identifier (prefix + suffix, 2 Felts) +- **Nonce** — Must increase whenever the account's state changes; an account update records the amount it increased by, not just the fact that it did +- **ID** — Unique identifier (prefix + suffix, 2 Felts). `AccountId` does **not** convert into `[Felt; 2]`; reach the parts with `id.prefix().as_felt()` and `id.suffix()` + +Account state changes reach the network as an **`AccountPatch`** (`miden_protocol::account::AccountPatch`), which describes the account's new state. `AccountDelta` still exists and is still *relative* — it records changes rather than final values — and is what a `TransactionSummary` commits to. Don't assume the two are interchangeable: `TransactionSummary::account_delta()` deliberately returns the relative `AccountDelta`. Accounts are composed from **components** — reusable Rust modules annotated with `#[component]`. ### Notes Notes are **UTXO-like messages** for asynchronous inter-account communication. A note contains: - **Script** — Logic that executes when the note is consumed -- **Storage** — Data accessible to the script during execution (`NoteStorage`, backed by `Vec`) -- **Assets** — Fungible/non-fungible tokens attached to the note -- **Metadata** — Sender, tag, note type (public/private) +- **Storage** — Data accessible to the script during execution (`NoteStorage`, backed by `Vec`), capped at `MAX_NOTE_STORAGE_ITEMS = 1024` +- **Assets** — Fungible/non-fungible tokens attached to the note, capped at `MAX_ASSETS_PER_NOTE = 16` +- **Metadata** — Sender, tag, note type (public/private). That trio is the *partial* metadata; the full `NoteMetadata` also carries the attachment headers and their commitment +- **Attachments** — Up to `NoteAttachments::MAX_COUNT = 4` attachments, addressed by scheme rather than position. Each holds 1–`NoteAttachment::MAX_NUM_WORDS` (256) words, capped at 512 words per note across all of them — so this is a real payload channel, not a four-word field Notes are created as **output notes** by one transaction and consumed as **input notes** by another. ### Transactions -A transaction is a **single-account state transition** with 4 phases: -1. Consume input notes (execute their scripts against the account) -2. Execute transaction script (optional, for one-off logic) -3. Update account state (storage, vault, nonce) -4. Produce output notes (for other accounts to consume later) +A transaction is a **single-account state transition**. The kernel runs four phases: +1. **Prologue** — prepare the root context from the transaction inputs +2. **Note processing** — run every input note's script against the account +3. **Transaction script** — optional one-off logic +4. **Epilogue** — run the account's **authentication procedure** (this is where the fee is paid), then compute and validate the final state + +Updating account state and producing output notes are effects of phases 2-3, not phases of their own. The thing people most often leave out of this list is that **authentication and fee payment happen in the epilogue**, after all scripts have run. + +**Transaction summaries are six words.** A `TransactionSummary` is what an account's authentication procedure signs, and its commitment preimage is laid out as: + +```text +[ACCOUNT_DELTA_COMMITMENT, INPUT_NOTES_COMMITMENT, OUTPUT_NOTES_COMMITMENT, + BLOCK_COMMITMENT, [expiration_delta, user_param0, user_param1, user_param2], + [user_param3, user_param4, user_param5, user_param6]] +``` + +The trailing user parameters give an auth procedure a way to bind extra data (a replay-protection salt, a maximum fee) into the same signature. A component that hashes a shorter layout **compiles and fails at runtime**; the MASM side of this constant is `TX_SUMMARY_NUM_ELEMENTS = 24` in the standard auth library. + +**Fees are paid from the authentication procedure.** The auth procedure computes the fee and funds a public `TX_FEE` note out of the account's vault before the summary is created. Callers supply the conversion data with `TransactionRequestBuilder::fee_conversion_info(conversion_info, salt)`; network accounts need a fee policy of their own. **Important**: A two-party transfer (Alice sends Bob tokens) requires TWO transactions: 1. Alice's transaction creates a P2ID note with tokens attached 2. Bob's transaction consumes that note, receiving the tokens ### Assets -- **SDK shape**: `Asset { key: Word, value: Word }` + +An asset is **two words**: an identifier word and a value word. On the operand stack and in MASM doc comments they appear as `ASSET_ID` followed by `ASSET_VALUE`; the protocol type alias is `Asset = struct { id: word, value: word }`. + - **Fungible**: asset amount lives in `asset.value[0]` - **Non-fungible**: Unique token tied to a faucet account - Assets live in account **vaults** and move between accounts via notes -- Created by **faucet accounts** using `faucet::create_fungible_asset()` or `faucet::mint()` +- Minted and destroyed by **faucet accounts** via `faucet::mint(asset)` / `faucet::burn(asset)`, which take an already-built `Asset`. There is no in-transaction asset construction: the kernel exposes no `create_fungible_asset` / `create_non_fungible_asset`. +- A note may carry at most **`MAX_ASSETS_PER_NOTE` = 16** assets. + +**`AssetId` and `AssetClass` are different things, and the names are a trap.** `AssetId` is the *unique identifier of an asset in the vault*; its Word layout is `[asset_class_suffix, asset_class_prefix, faucet_id_suffix|reserved|composition, faucet_id_prefix]`, and `AssetId::hash()` produces the `AssetIdHash` used as the vault SMT key. `AssetClass` is the narrower thing that *distinguishes different assets issued by the same faucet* — two felts, and one component of an `AssetId`. Code that treats an `AssetId` as if it were a per-faucet class (or vice versa) type-checks and is wrong. + +> **Layer note.** The Rust *contract* SDK (the guest `miden` crate) builds against an earlier protocol snapshot than the client/protocol line, and there the guest type is still `Asset { key: Word, value: Word }` with `asset.value[0]` as the fungible amount. The field is named `key`, not `id`, in guest contract code. Read the layer you are actually writing for rather than renaming across the boundary. ### Felt and Word - **Felt**: Field element in the Goldilocks prime field (p = 2^64 - 2^32 + 1). The fundamental data unit. - **Word**: Array of 4 Felts (32 bytes). Used for cryptographic hashes, storage keys, account IDs. -- **Felt constructors** (Rust `miden_field::Felt` — the same type used host-side in clients/tests *and* guest-side inside `#[component]`/`#[note]` contract code, which re-exports it): `Felt::new(u64)` is **fallible** in v0.15 — it returns `Result` and rejects out-of-range values (delegates to `from_canonical_checked`), so callers must `?`/match it (guest code typically `Felt::new(0).unwrap()`). `Felt::new_unchecked(u64)` is the raw, non-reducing constructor (any `u64`, no validation). Always-succeed constructors (return a bare `Felt`): `Felt::from_u8` / `from_u16` / `from_u32`. Non-panicking but fallible: `Felt::from_canonical_checked(u64) -> Option` (returns `None` when out of range). Note the JS/React SDK's `Felt` is a *different* type whose `Felt.new(u64)` is infallible and whose accessor is `.asInt()`. +- **Felt constructors** (Rust `miden_field::Felt` — the same type used host-side in clients/tests *and* guest-side inside `#[component]`/`#[note]` contract code, which re-exports it): `Felt::new(u64)` is **fallible** — it returns `Result` and rejects values at or above `Felt::ORDER` (it delegates to `from_canonical_checked`), so callers must `?`/match it (guest code typically `Felt::new(0).unwrap()`). `Felt::new_unchecked(u64)` is the raw, non-reducing constructor (any `u64`, no validation) — an out-of-range value yields a non-canonical `Felt`. The field order constant is `Felt::ORDER`; there is no `Felt::MODULUS`. Always-succeed constructors (return a bare `Felt`): `Felt::from_u8` / `from_u16` / `from_u32`. Non-panicking but fallible: `Felt::from_canonical_checked(u64) -> Option` (returns `None` when out of range). Note the JS/React SDK's `Felt` is a *different* type whose `Felt.new(u64)` is infallible and whose accessor is `.asInt()`. - **Word constructors**: `Word::new`, `Word::from([u32; 4])`, `Word::from([Felt; 4])`, `Word::try_from([u64; 4])` - **Current accessors**: `felt.as_canonical_u64()`, `word.as_elements()`, `word.into_elements()`, `word.as_bytes()`, `word.to_hex()` @@ -80,17 +104,28 @@ A transaction is a **single-account state transition** with 4 phases: | **P2ID** | Send assets to a specific account | Note script checks consumer's ID matches target | | **P2IDE** | P2ID with expiration | Adds block-height timelock; sender can reclaim after expiry | | **SWAP** | Atomic asset exchange | Note offers asset A, requests asset B; consumer provides B | +| **PSWAP** | Partial-fill swap | A SWAP that can be consumed for part of the offered amount, leaving a remainder note | + +Those are the ones you write by hand. The full `StandardNote` set is larger — it also covers `MINT`, `BURN`, `FEE_SPONSORSHIP`, `TX_FEE`, and the component-configuration notes (`OWNER_CONFIG`, `RBAC_CONFIG`, `PAUSE_CONFIG`, `ALLOWLIST_CONFIG`, `BLOCKLIST_CONFIG`, `NETWORK_ACCOUNT_CONFIG`, `FAUCET_POLICY_CONFIG`, `FAUCET_METADATA_CONFIG`, `CONSTANT_FEE_POLICY_CONFIG`, `MIN_BURN_AMOUNT_CONFIG`). + +Standard notes are built with typed builders rather than a `create(..)` constructor: `P2idNote::builder()…build()?`, with fluent `.asset(..)` / `.assets(..)` / `.attachment(..)` / `.attachments(..)`. `MINT` and `BURN` are unified across faucet kinds — one `MintNote` / `BurnNote` rather than per-faucet-kind scripts. ## Standard Components (miden-standards) | Component | Purpose | |-----------|---------| -| `BasicWallet` | Standard wallet: `receive_asset()`, `move_asset_to_note()` | -| `FungibleFaucet` | Mint/burn fungible tokens; built via `FungibleFaucet::builder()` | -| `NoAuth` | No authentication (for testing) | -| `AuthSingleSig` | Production signature authentication — unified auth component covering both Falcon-512 and ECDSA-K256 key types | +| `BasicWallet` | Standard wallet. Three interface procedures: `receive_asset`, `move_asset_to_note`, `create_note` (roots via `receive_asset_root()`, `move_asset_to_note_root()`, `create_note_root()`) | +| `FungibleFaucet` | Mint/burn fungible tokens (`mint_and_send`, `receive_and_burn`, plus metadata accessors and owner-gated setters); built via `FungibleFaucet::builder()` | +| `NoAuth` | No authentication (for testing) — but it still pays the transaction fee | +| `AuthSingleSig` | Production signature authentication — one component covering both Falcon-512 and ECDSA-K256 key types | -**Auth**: `AuthSingleSig` is a single auth component that dispatches on the key type, so one component handles both Falcon-512 and ECDSA-K256 keys. The Falcon-512 scheme uses Poseidon2 as its hash function and is named `Falcon512Poseidon2`. +`output_note::create` is account-context only, so a transaction or note script cannot create a note directly — it goes through an account component wrapper such as `BasicWallet::create_note`. + +**Auth**: `AuthSingleSig` dispatches on the key type, so one component handles both Falcon-512 and ECDSA-K256 keys. The Falcon-512 scheme uses Poseidon2 as its hash function and is named `Falcon512Poseidon2`. Construct it with `AuthSingleSig::new(approver)` or the typed helpers `falcon512_poseidon2(pk)` / `ecdsa_k256_keccak(pk)` / `from_public_key(pk)`. + +Keys are wrapped in `Approver { pub_key, auth_scheme }`, and multi-signature setups use `ApproverSet { approvers, threshold }`. There is no `AccountBuilder::with_auth_component` and no `AuthMethod` or `AuthSingleSigAcl`: auth components are added with `with_component(s)` like any other component. + +The auth roster is wider than `NoAuth` + `AuthSingleSig` — `miden_standards::account::auth` also exports `AuthMultisig`, `AuthMultisigSmart`, `AuthGuardedMultisig` (each with a matching `*Config` type), and `AuthNetworkAccount`, which takes its parts directly rather than a config struct. **Fungible faucet**: `FungibleFaucet` is the fungible-faucet component, constructed with the `bon`-generated `FungibleFaucet::builder()` (required setters `.name(TokenName::new(..)?)`, `.symbol(TokenSymbol::new(..)?)`, `.decimals(n)`, `.max_supply(AssetAmount)`, then `.build()?`). @@ -105,7 +140,9 @@ Three contract types: - `#[note]` — Note script (executes when consumed) - `#[tx_script]` — One-off transaction logic -Contracts are tested locally with **MockChain** (no network needed) and deployed via the Miden Rust client (**`0xMiden/rust-sdk`**, formerly `miden-client`; the browser client split into `0xMiden/web-sdk`). +Contracts are tested locally with **MockChain** (no network needed) and deployed via the Miden Rust client. That client lives in the **`0xMiden/rust-sdk`** repository (the older `0xMiden/miden-client` URL still redirects there) and is published as the crate `miden-client`; the browser client is a separate repository, `0xMiden/web-sdk`. + +A component's methods are not implicitly part of the account interface. In Rust, mark each callable method with `#[account_procedure]` on the `#[component]` **trait**; in hand-written component MASM, annotate the exported procedure with `@account_procedure` (or `@auth_script` for an authentication component). An unmarked procedure still compiles and is still exported by the package, but is not reachable as an account procedure. ## Key Design Decisions for App Architects diff --git a/skills/react-sdk-patterns/SKILL.md b/skills/react-sdk-patterns/SKILL.md index f016e54..2336cb9 100644 --- a/skills/react-sdk-patterns/SKILL.md +++ b/skills/react-sdk-patterns/SKILL.md @@ -1,13 +1,22 @@ --- 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. +description: Complete guide to building Miden frontends with @miden-sdk/react hooks. Covers MidenProvider setup, query hooks (useAccounts, useAccount, useNotes, useNoteStream, useSyncState, useAssetMetadata, useTransactionHistory, PSWAP lineage queries), mutation hooks (useCreateWallet, useCreateFaucet, useImportAccount, useSend, useMultiSend, useMint, useConsume, useSwap, useBridge, useTransaction, useCreateNetworkNote, PSWAP hooks), chain anchors and previews, transaction stages, signer integration, the error surface, and utility functions. Use when writing, editing, or reviewing Miden React frontend code. --- # Miden React SDK Patterns +## Package pins + +```json +"@miden-sdk/react": "0.16.0-rc.7", +"@miden-sdk/miden-sdk": "0.16.0-rc.7" +``` + +`@miden-sdk/react` declares `@miden-sdk/miden-sdk` as a peer dependency at `^0.16.0-rc.7` and `react` at `>=18.0.0`. Pin exactly, or use a range that itself names a prerelease: npm excludes prereleases from `"0.16"`, `"^0.16.0"`, `"~0.16.0"` and `"0.16.x"`, so those match nothing. + ## 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. +ALWAYS use `@miden-sdk/react` hooks. Only fall back to the raw WASM client (`WasmWebClient`, imported by the React SDK as `WebClient`) via `useMidenClient()` for operations not covered by hooks. The React SDK handles WASM safety, state management (Zustand), auto-sync, and transaction stage tracking. ## MidenProvider Configuration @@ -17,9 +26,13 @@ import { MidenProvider } from "@miden-sdk/react"; } // shown during WASM init errorComponent={(error) => } // function form receives the Error; a static element does not @@ -28,35 +41,46 @@ import { MidenProvider } from "@miden-sdk/react"; ``` -| 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` | +There is **no `storeName` config field**. `storeName` lives on `SignerContextValue`; when a signer is connected the IndexedDB name is derived as `` `MidenClientDB_${signer.storeName}` ``. + +`useWorker` defaults to `true` (WASM calls run off the main thread). Set it to `false` when you pass a `CallbackProver` — the worker boundary serializes the prover with `TransactionProver.serialize()`, which has no encoding for the callback variant and silently downgrades to `"local"` — or when embedding in a single-WebView native shell (Capacitor host, Tauri, Electron preload). + +The object form of `prover` is `{ primary, fallback?, disableFallback?: () => boolean, onFallback?: () => void }`. + +| Network | rpcUrl | Resolves to | +|---------|--------|-------------| +| Testnet | `"testnet"` | `https://rpc.testnet.miden.io` — recommended for new projects | +| Devnet | `"devnet"` | `https://rpc.devnet.miden.io` | +| Localhost | `"localhost"` or `"local"` | `http://localhost:57291` | ## Query Hooks -Each returns its own result shape plus `isLoading`, `error`, `refetch`. +Most return their own result shape plus `isLoading`, `error`, `refetch` — but not uniformly: `useNoteStream` has no `refetch`, `useSyncState` exposes `sync` instead of `refetch`, and `useAccounts` hardcodes `error: null` (fetch failures are only `console.error`-ed). ### 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 `[]` +// wallets — @deprecated, mirrors `accounts` +// faucets — @deprecated, always `[]` +// error — always null ``` -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`). +Faucet-vs-wallet is not encoded in the account id, so `wallets` mirrors `accounts` and `faucets` is always empty. Both are marked `@deprecated`. Use `accounts` and detect faucets **per-account** via `account.isFaucet()` (load the full `Account` with `useAccount`). -### useAccount(accountId: string) +### useAccount(accountId: AccountRef | undefined) ```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 — Account object (.id(), .nonce(), .bech32id(), .isFaucet(), .isNetworkAccount()) +// assets — AssetBalance[] { assetId, amount, symbol?, decimals? } +// getBalance(assetId) — bigint balance for a 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. +`AccountRef = string | AccountId | Account | AccountHeader` — hex, bech32, or a parsed object. + +**`assetId` everywhere in this SDK is a faucet (token) account id string** — `asset.faucetId().toString()`. The React SDK exposes no protocol `AssetId`, `AssetClass` or `AssetVaultKey` type; do not "fix" these names to those. + +`account.id()` and `account.nonce()` are methods (call them, then `.toString()` to render). `bech32id()` is installed on the `Account` prototype by the React SDK at module load (`installAccountBech32()`). ### useNotes(filter?) ```tsx @@ -70,7 +94,7 @@ const { notes, consumableNotes, noteSummaries, consumableNoteSummaries, isLoadin // `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`): +// `accountId` (an AccountRef) filters `consumableNotes` (NOT `notes`): const { consumableNotes } = useNotes({ accountId: "0x..." }); // `sender` filters only the summary arrays (NOT `notes`/`consumableNotes`): const { noteSummaries, consumableNoteSummaries } = useNotes({ sender: "0x..." }); @@ -81,16 +105,17 @@ const { noteSummaries, consumableNoteSummaries } = useNotes({ excludeIds: ["0xno ### useNoteStream(options?) ```tsx const { notes, latest, markHandled, markAllHandled, snapshot, isLoading, error } = useNoteStream(); -// notes — StreamedNote[] (matching filter criteria) -// latest — most recent StreamedNote (convenience) +// notes — StreamedNote[] { id, sender, amount, assets, record, firstSeenAt, attachment } +// latest — most recent StreamedNote (convenience), or null // markHandled(noteId) — exclude a note from future renders // markAllHandled() — exclude all current notes -// snapshot() — capture { ids, timestamp } for cross-phase filtering +// snapshot() — capture { ids: Set, timestamp } for cross-phase filtering +// No refetch. -// Options: +// Options (status defaults to "committed"): 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({ excludeIds: new Set(["0xnote1"]) }); // Set | string[] const { notes } = useNoteStream({ amountFilter: (amount) => amount > 100n }); ``` @@ -100,6 +125,12 @@ const { syncHeight, isSyncing, lastSyncTime, sync, error } = useSyncState(); await sync(); // Manual sync ``` +### useSyncControl() +```tsx +const { pauseSync, resumeSync, isPaused } = useSyncControl(); +``` +Prefer this over `autoSyncInterval: 0` when you only need to suspend background sync during a long-running operation — manual `useSyncState().sync()` still works while paused. + ### useAssetMetadata(assetIds?: string[]) ```tsx const { assetMetadata } = useAssetMetadata([faucetId]); // takes a string[] (NOT a bare string) @@ -110,40 +141,58 @@ const meta = assetMetadata.get(faucetId); // 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`. +Signature is `useAssetMetadata(assetIds: string[] = [])` and the body calls `assetIds.filter(Boolean)`, so a bare string throws a runtime `TypeError`. Pass an array even for a single asset. ### useTransactionHistory(options?) ```tsx const { records, record, status, isLoading, error, refetch } = useTransactionHistory({ id: txId }); // status: "pending" | "committed" | "discarded" | null +// Options: { id?, ids?, filter?, refreshOnSync? } +// - `filter` is a TransactionFilter and overrides id/ids +// - `refreshOnSync` re-fetches after every provider sync. Default: true +``` + +### PSWAP lineage queries +```tsx +const { lineages, isLoading, error, refetch } = usePswapLineages(); +const { lineages } = usePswapLineagesFor(account); // AccountRef | null | undefined +const { lineage, isLoading, error, refetch } = usePswapLineage(orderId); // string | bigint | null | undefined +// lineage(s) — PswapLineageRecord[] / PswapLineageRecord | 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`. +Most return their own action function plus `error` and `reset` — but not all: `useWaitForCommit` returns only `{ waitForCommit }`, `useWaitForNotes` only `{ waitForConsumableNotes }`, `useCompile` `{ component, txScript, noteScript, isReady }`, and `useSyncControl` `{ pauseSync, resumeSync, isPaused }`. The families differ in their loading/progress fields: +- **Transaction hooks** expose `isLoading` and `stage` (a `TransactionStage`): `useSend`, `useMultiSend`, `useMint`, `useBridge`, `useConsume`, `useSwap`, `useTransaction`, `useCreateNetworkNote`, `usePswapCreate`, `usePswapConsume`, `usePswapCancel`, `usePswapCancelByOrder`. +- **No `stage`**: `useCreateWallet` / `useCreateFaucet` (`isCreating`), `useImportAccount` (`isImporting`), `useExportStore` / `useExportNote` (`isExporting`), `useImportStore` / `useImportNote` (`isImporting`), `usePreview` (`isPreviewing`), `useChainAnchor` (`isCapturing`), `useExecuteProgram` (`isLoading`). **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" }`: +Most transaction hooks resolve to `TransactionResult = { transactionId: string }`. The exceptions are `useSend` (`SendResult = { txId: string; note: Note | null }`) and `useCreateNetworkNote` (`NetworkNoteResult = { txId: string; note: Note }`). + +### AuthScheme — pass the numeric value + +The React SDK's own contract is the **WASM numeric enum**. `CreateWalletOptions.authScheme`, `CreateFaucetOptions.authScheme`, the `{type:"seed"}` import option and `useSessionAccount`'s `walletOptions.authScheme` are all typed `AuthScheme`, and `DEFAULTS.AUTH_SCHEME` is `AuthScheme.AuthRpoFalcon512`. The members are: -```tsx -import { AuthScheme } from "@miden-sdk/react"; -// AuthScheme.Falcon === "falcon" | AuthScheme.ECDSA === "ecdsa" ``` +AuthEcdsaK256Keccak = 1 +AuthRpoFalcon512 = 2 +``` + +There is a catch at runtime. On the browser entry of `@miden-sdk/miden-sdk`, a locally declared `export const AuthScheme = Object.freeze({ Falcon: "falcon", ECDSA: "ecdsa" })` **shadows** the generated WASM binding (a local `export const` beats `export * from …` in ESM). `@miden-sdk/react` re-exports that same binding, so in a browser build `AuthScheme.AuthRpoFalcon512` reads as `undefined` — including inside `DEFAULTS.AUTH_SCHEME` — while `client.newWallet(storageMode, authScheme, initSeed)` and `newFaucet(…, authScheme)` want the numeric enum. -> **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`. +**So pass the numeric literal in browser code:** `authScheme: 2` (Falcon) or `authScheme: 1` (ECDSA). The SDK's own test writes `authScheme: 2 as unknown as AuthScheme`. Do **not** write `AuthScheme.Falcon` / `AuthScheme.ECDSA` as React usage — those friendly strings belong to the high-level `MidenClient` resource API (`client.accounts.create`), not to these hooks. Only the Node entry offers an escape hatch, re-exporting the napi class as `AuthSchemeNative`; the browser entry has none. ### 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 + authScheme: 2, // 2 = Falcon (numeric enum — see above) + initSeed: seedBytes, // optional: Uint8Array for a deterministic account id }); ``` +`CreateWalletOptions` is exactly `{ storageMode?, authScheme?, initSeed? }` — there is **no** `mutable` field. ### useCreateFaucet() ```tsx @@ -152,9 +201,9 @@ const account = await createFaucet({ tokenSymbol: "TEST", tokenName: "Test Token", // optional: defaults to tokenSymbol decimals: 8, // Default: 8 - maxSupply: 1000000n, // bigint | number + maxSupply: 1000000n, // bigint | number, coerced with BigInt(...) storageMode: "private", // "private" | "public". Default: "private" - authScheme: 2, // 2 = Falcon; friendly AuthScheme.* not accepted here yet (web-sdk#223) + authScheme: 2, // 2 = Falcon }); ``` @@ -162,40 +211,49 @@ const account = await createFaucet({ ```tsx const { importAccount, account, isImporting, error, reset } = useImportAccount(); -// Import by account ID (network lookup): +// Import by account ID (network lookup) — accountId is an AccountRef: const account = await importAccount({ type: "id", accountId: "0x..." }); -// Import from file: +// Import from file — AccountFile | Uint8Array | ArrayBuffer: 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) + authScheme: 2, // optional; 2 = Falcon }); ``` +This hook calls `assertSignerConnected()` first: with a signer provider mounted but disconnected it throws "Signer is disconnected. Reconnect your wallet to perform transactions." ### useSend() ```tsx const { send, result, isLoading, stage, error, reset } = useSend(); -await send({ - from: senderAccountId, - to: recipientAccountId, - assetId: faucetId, // token faucet ID - amount: 1000n, // bigint! +const { txId, note } = await send({ + from: senderAccountId, // AccountRef + to: recipientAccountId, // AccountRef + assetId: faucetId, // AccountRef — the token faucet id + amount: 1000n, // bigint | number — OPTIONAL, required only when sendAll is falsy 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 + sendAll: true, // optional: send entire balance of this asset (ignores amount) + attachment: [1n, 2n], // optional: bigint[] | Uint8Array | number[] + skipSync: false, // optional: skip the pre-send auto-sync. Default: false + returnNote: false, // optional: build the note in JS and surface it on result.note }); ``` +Combining `attachment` with `recallHeight` or `timelockHeight` **throws**: "recallHeight and timelockHeight are not supported when attachment is provided". + +`SendResult.note` is `null` unless `returnNote: true`. + +**How private sends are delivered:** for `noteType: "private"` the hook waits for the transaction to commit and then calls `client.sendPrivateOutputNote(noteId, targetAddress)` to push the note details to the recipient over the note-transport layer. The same call happens in `useMultiSend` (once per private recipient) and in `useTransaction` when `privateNoteTarget` is set. Without that step a private note is never delivered — a public note needs no such push. + ### useMultiSend() ```tsx const { sendMany, result, isLoading, stage, error, reset } = useMultiSend(); -await sendMany({ +const { transactionId } = await sendMany({ from: senderAccountId, assetId: faucetId, recipients: [ @@ -204,25 +262,27 @@ await sendMany({ { to: recipient3, amount: 200n, attachment: [1n, 2n, 3n] }, // per-recipient attachment ], noteType: "private", // default for all recipients + skipSync: false, // optional }); ``` +Resolves to `{ transactionId }`, not `{ txId, note }`. Also calls `assertSignerConnected()`. ### useMint() ```tsx const { mint, result, isLoading, stage, error, reset } = useMint(); -await mint({ - targetAccountId: recipientId, - faucetId: myFaucetId, - amount: 10000n, // bigint! - noteType: "public", +const { transactionId } = await mint({ + targetAccountId: recipientId, // AccountRef + faucetId: myFaucetId, // AccountRef + amount: 10000n, // bigint | number + noteType: "public", // Default: "private" }); ``` ### useConsume() ```tsx const { consume, result, isLoading, stage, error, reset } = useConsume(); -await consume({ - accountId: myAccountId, +const { transactionId } = await consume({ + accountId: myAccountId, // typed `string` — one of two options not widened to AccountRef notes: [noteId1, noteId2], // accepts: hex string IDs, NoteId, InputNoteRecord, or Note }); ``` @@ -230,7 +290,7 @@ await consume({ ### useSwap() ```tsx const { swap, result, isLoading, stage, error, reset } = useSwap(); -await swap({ +const { transactionId } = await swap({ accountId: myAccountId, offeredFaucetId: tokenA, offeredAmount: 100n, @@ -241,56 +301,178 @@ await swap({ }); ``` +### PSWAP (partial swaps) +A PSWAP note can be filled by multiple consumers, each taking a proportional share and leaving a remainder note. + +```tsx +const { pswapCreate } = usePswapCreate(); +// { accountId, offeredFaucetId, offeredAmount, requestedFaucetId, requestedAmount, noteType?, paybackNoteType? } + +const { pswapConsume } = usePswapConsume(); +await pswapConsume({ + accountId, + note, // hex string | NoteId | InputNoteRecord | Note + fillAmount: 25n, // requested asset supplied from the consumer's own vault + noteFillAmount: 0n // optional: supplied by other in-flight notes routed into the same tx. Default 0 +}); + +const { pswapCancel } = usePswapCancel(); // { accountId, note } +const { pswapCancelByOrder } = usePswapCancelByOrder(); +await pswapCancelByOrder({ orderId: "123456789" }); // string | bigint — a JS `number` is NOT accepted +``` + +A PSWAP order id is `u64`-shaped and routinely exceeds `Number.MAX_SAFE_INTEGER`, which is why `number` is rejected. `pswapCancelByOrder` resolves the creator account and the lineage's current tip note from the locally tracked lineage. + +### useBridge() +```tsx +const { bridge, result, isLoading, stage, error, reset } = useBridge(); +const { transactionId } = await bridge({ + from: senderAccountId, + bridgeAccount: bridgeAccountId, + assetId: faucetId, + amount: 100n, + destinationNetwork: 1, // AggLayer-assigned network id + destinationAddress: "0xabc...", // 0x-prefixed Ethereum address + skipSync: false, // optional +}); +``` +Emits a single public B2AGG (Bridge-to-AggLayer) note that the bridge account consumes, burning the asset so it can be claimed on the destination network. + +### useCreateNetworkNote() +```tsx +const { createNetworkNote, result, isLoading, stage, error, reset } = useCreateNetworkNote(); +const { txId, note } = await createNetworkNote({ + accountId: senderId, // AccountRef — creates, funds and submits the note + target: networkAccountId, // AccountRef — the network account the note targets + script: myNoteScript, // NoteScript — OR `recipient`, exactly one of the two + recipient: myRecipient, // NoteRecipient (advanced) + executionHint, // optional NoteExecutionHint. Defaults to `always` + inputs: [1n, 2n], // optional: note storage / inputs the script reads (used with `script`) + assetId, amount, // optional: a single asset to lock into the note + attachment: [1n, 2n, 3n], // optional: extra payload appended after the NetworkAccountTarget +}); +note.isNetworkNote(); // true +``` +Passing both `recipient` and `script`, or neither, throws. The note is always `NoteType.Public`; the `NetworkAccountTarget` attachment — not the tag — is what a network account matches on. + ### useTransaction() — Escape Hatch ```tsx const { execute, result, isLoading, stage, error, reset } = useTransaction(); -// With pre-built TransactionRequest: -await execute({ accountId, request: txRequest }); +// With a pre-built TransactionRequest: +const { transactionId } = await execute({ accountId, request: txRequest }); -// With factory function (gets access to client): +// With a factory function (gets access to the client): await execute({ accountId, request: (client) => client.newSwapTransactionRequest(/* ... */), }); + +// Full option set: +await execute({ + accountId, // AccountRef + request, // TransactionRequest | (client) => TransactionRequest + skipSync: false, // optional + privateNoteTarget: recipientRef, // optional: push private output notes to this account after commit + anchor, // optional ChainAnchor — routes to client.executeTransactionAt +}); +``` + +### useExecuteProgram() — view call +```tsx +const { execute, result, isLoading, error, reset } = useExecuteProgram(); +const { stack } = await execute({ + accountId, // string | AccountId + script: txScript, // compiled TransactionScript + adviceInputs, // optional AdviceInputs + foreignAccounts, // optional: (string | AccountId | { id, storage? })[] + skipSync: false, +}); +// stack — bigint[] (16 elements). Runs locally; nothing is proven or submitted. +``` + +### useCompile() +```tsx +const { component, txScript, noteScript, isReady } = useCompile(); +// Wraps CompilerResource from @miden-sdk/miden-sdk, so the option shapes are +// identical to MidenClient.compile: CompileComponentOptions, +// CompileTxScriptOptions, CompileNoteScriptOptions. +``` + +### useChainAnchor() and usePreview() +Since protocol 0.16 a signed `TransactionSummary` binds the reference block commitment, so a summary signed at one block only reproduces when re-executed at that block. Anchors are what let a multisig proposer, its co-signers and the eventual executor agree despite different sync heights. + +```tsx +const { captureAnchor, anchor, anchoredRequest, isCapturing, error, reset } = useChainAnchor(); +const { preview, summary, isPreviewing } = usePreview(); + +const chainAnchor = await captureAnchor({ request: buildRequest }); +// ALWAYS preview/execute against `anchoredRequest`, never the value you passed in: +// re-resolving a request factory draws a fresh serial number from the client RNG +// and builds a different transaction than the anchor pins. +const txSummary = await preview({ accountId, request: anchoredRequest!, anchor: chainAnchor }); ``` +`preview` produces a summary **only while authorization is pending** — i.e. 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 the call rejects with `code: "TRANSACTION_ALREADY_AUTHORIZED"`; submit it with `useTransaction` instead. It is not a dry-run confirmation-screen API. + +`captureAnchor` rejects with `code: "OPERATION_BUSY"` (a capture is already running), `"STALE_CLIENT"`, or `"INVALID_CHAIN_ANCHOR"` (a sync landed mid-capture — retry). It runs on the main thread and briefly blocks the UI. The caller owns the anchor: `reset()` does not free it, and since it carries a partial blockchain, call `anchor.free()` when done in a repeated-capture flow. Serialize with `anchor.serialize()` and rebuild with `ChainAnchor.deserialize(bytes)` — importing the class from `@miden-sdk/miden-sdk`, since `@miden-sdk/react` re-exports `ChainAnchor` as a type only. + ### useWaitForCommit() ```tsx const { waitForCommit } = useWaitForCommit(); -await waitForCommit(result.txId, { // useSend returns { txId, note }; other hooks use { transactionId } +await waitForCommit(result.txId, { // useSend returns { txId, note }; most other hooks use { transactionId } timeoutMs: 10000, // Default: 10000 - intervalMs: 1000, // Default: 1000 + intervalMs: 1000, // Default: 1000 }); ``` ### useWaitForNotes() ```tsx const { waitForConsumableNotes } = useWaitForNotes(); -await waitForConsumableNotes({ - accountId: myAccountId, - minCount: 1, // Default: 1 - timeoutMs: 10000, +const records = await waitForConsumableNotes({ + accountId: myAccountId, // AccountRef + minCount: 1, // Default: 1 + timeoutMs: 10000, // Default: 10000 + intervalMs: 1000, // Default: 1000 }); +// resolves to ConsumableNoteRecord[] +``` + +### useExportStore() / useImportStore() / useExportNote() / useImportNote() +```tsx +const { exportStore, isExporting } = useExportStore(); +const snapshot = await exportStore(); // JSON string of the IndexedDB store + +const { importStore, isImporting } = useImportStore(); +await importStore(snapshot, storeName, options); + +const { exportNote } = useExportNote(); +const bytes = await exportNote(noteId); // serialized NoteFile bytes + +const { importNote } = useImportNote(); +const noteId = await importNote(bytes); // returns the note id string ``` ### useSessionAccount(options) ```tsx const { initialize, sessionAccountId, isReady, step, error, reset } = useSessionAccount({ fund: async (sessionId) => { - // Called after session wallet is created — fund it here + // Called after the session wallet is created — fund it here await send({ from: mainWallet, to: sessionId, assetId: faucetId, amount: 100n }); }, - assetId: faucetId, // optional: for note filtering + assetId: faucetId, // optional; reserved for future note filtering — the hook body never reads it walletOptions: { // optional: session wallet creation options - storageMode: "private", // "private" | "public" - authScheme: 2, // 2 = Falcon (web-sdk#223) + storageMode: "public", // "private" | "public". Default: "public" + authScheme: 2, // 2 = Falcon }, - pollIntervalMs: 3000, // optional: funding detection interval. Default: 3000 + pollIntervalMs: 3000, // funding detection interval. Default: 3000 + maxWaitMs: 60000, // max wait for the funding note. Default: 60000 + storagePrefix: "miden-session", // localStorage key prefix. Default: "miden-session" }); // Steps: "idle" → "creating" → "funding" → "consuming" → "ready" // Call initialize() to start the flow. isReady becomes true when fully funded. ``` +The session wallet default storage mode is **`"public"`**, unlike `useCreateWallet`'s `"private"`. The hook persists `${storagePrefix}:accountId` and `${storagePrefix}:ready` in `localStorage` and restores them on mount; `reset()` removes both. ## Transaction Progress UI @@ -315,23 +497,47 @@ function SendButton({ from, to, assetId, amount }) { 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 +`MidenProvider` reads the nearest ancestor `SignerContext`; when one is present and connected it builds the client with `WebClient.createClientWithExternalKeystore(...)` instead of the local keystore. Three signer providers are used by the SDK's example app: + +- `ParaSignerProvider` from `@miden-sdk/use-miden-para-react` +- `TurnkeySignerProvider` from `@miden-sdk/miden-turnkey-react` +- `MidenFiSignerProvider` from `@miden-sdk/miden-wallet-adapter-react` + +All three live in repos outside web-sdk and are not declared in the example's `package.json` dependencies, so confirm names, versions and props against each package's own docs before installing. -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`. +### MultiSignerProvider — the shape the example app uses +For apps offering a choice of signer, wrap everything in `MultiSignerProvider` and mount each signer provider — each containing a `` — as a **sibling** of `MidenProvider`: ```tsx -// Example: Para signer wrapping MidenProvider -import { ParaSignerProvider } from "@miden-sdk/use-miden-para-react"; - - - +import { MidenProvider, MultiSignerProvider, SignerSlot } from "@miden-sdk/react"; + + + + + + + + + + + + + + + ``` +`SignerSlot` is render-less: it registers its nearest ancestor's `SignerContext` value into the registry. `MultiSignerProvider` forwards only the *active* signer down to `MidenProvider`, so before a user picks one the app runs in local-keystore mode and reads work immediately. + +```tsx +const { signers, activeSigner, connectSigner, disconnectSigner } = useMultiSigner() ?? {}; +await connectSigner("Turnkey"); // switches active signer and calls its connect() +await disconnectSigner(); // reverts to local keystore mode +``` +`useMultiSigner()` returns `null` outside a `MultiSignerProvider`. + ### useSigner() — Unified Interface -Returns `SignerContextValue | null` — `null` in local-keystore mode (no signer provider mounted). Guard before destructuring. +Returns `SignerContextValue | null` — `null` in local-keystore mode. Guard before destructuring. ```tsx const signer = useSigner(); if (!signer) return null; // local keystore mode @@ -339,48 +545,113 @@ 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. +Implement `SignerContextValue` via `SignerContext.Provider`. Required: `name`, `storeName` (unique per user for DB isolation), `accountConfig`, `signCb`, `isConnected`, `connect`, `disconnect`. Optional: `getKeyCb`, `insertKeyCb`. See the `signer-integration` skill for the full contract. + +### Wallet-extension detection +```tsx +import { waitForWalletDetection } from "@miden-sdk/react"; +import type { WalletAdapterLike } from "@miden-sdk/react"; + +await waitForWalletDetection(adapter); // default 5000 ms timeout +await waitForWalletDetection(adapter, 10000); +``` +`WalletAdapterLike` is a duck type — `{ readyState: string; on/off("readyStateChange", cb) }` — with no dependency on any wallet-adapter package. It resolves once `readyState === "Installed"`, otherwise rejects with "Wallet extension not detected within …ms". + +## Error Surface + +```tsx +import { MidenError, wrapWasmError } from "@miden-sdk/react"; +import type { CodedError, MidenErrorCode, WasmErrorCode } from "@miden-sdk/react"; +``` + +- `MidenErrorCode` = `"WASM_CLASS_MISMATCH" | "WASM_POINTER_CONSUMED" | "WASM_NOT_INITIALIZED" | "WASM_SYNC_REQUIRED" | "SEND_BUSY" | "OPERATION_BUSY" | "STALE_CLIENT" | "UNKNOWN"` — assigned by this package. +- `WasmErrorCode` = `"INVALID_CHAIN_ANCHOR" | "TRANSACTION_ALREADY_AUTHORIZED"` — assigned by the Rust client. +- `CodedError = Error & { readonly code?: MidenErrorCode | WasmErrorCode | (string & {}) }`. + +Branch on `code`, never on message text. On Node, client-assigned codes arrive as a `"CODE: "` prefix on the message rather than a property, because the napi bindings cannot attach one. ## Utility Functions ```tsx -import { formatAssetAmount, parseAssetAmount, getNoteSummary, formatNoteSummary, toBech32AccountId } from "@miden-sdk/react"; +import { + formatAssetAmount, parseAssetAmount, + getNoteSummary, formatNoteSummary, + toBech32AccountId, installAccountBech32, ensureAccountBech32, + normalizeAccountId, accountIdsEqual, + readNoteAttachment, createNoteAttachment, + bytesToBigInt, bigIntToBytes, concatBytes, + waitForWalletDetection, + migrateStorage, clearMidenStorage, createMidenStorage, + DEFAULTS, +} 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) +parseAssetAmount("0.01", 8) // 1000000n +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. +`getNoteSummary(note, getAssetMetadata?)` takes a `ConsumableNoteRecord | InputNoteRecord` and returns `NoteSummary | null` — **`null`** for a note whose id or metadata is not available yet. + +`formatNoteSummary(summary, formatAsset?)`: if `summary.assets` is empty it returns `summary.id` alone — no asset text and **no sender suffix**, regardless of `sender`. Otherwise it joins assets with `" + "` and appends `" from "` when a sender is present. + +The bech32 HRP is inferred from the configured `rpcUrl` and defaults to testnet: mainnet `mm`, testnet `mtst` (default), devnet `mdev`, plus a `Custom` network type. There is no `miden` HRP. + +`DEFAULTS` is a value export: `{ RPC_URL: undefined, AUTO_SYNC_INTERVAL: 15000, STORAGE_MODE: "private", AUTH_SCHEME: AuthScheme.AuthRpoFalcon512, NOTE_TYPE: "private", FAUCET_DECIMALS: 8 }`. ## Direct Client Access ```tsx const client = useMidenClient(); // throws if not ready -const { runExclusive } = useMiden(); +const { runExclusive, prover, signerAccountId, signerConnected, isReady, isInitializing, error, sync } = useMiden(); // For operations not covered by hooks (use methods on the WebClient itself — -// e.g. getSyncHeight, getAccount, getTransactions; getBlockHeaderByNumber lives on RpcClient, not here): +// e.g. getSyncHeight, getAccounts, getTransactions; getBlockHeaderByNumber lives on RpcClient, not here): await runExclusive(async () => { - const height = await client.getSyncHeight(); + const accounts = await client.getAccounts(); }); ``` +**The built-in hooks route their client calls through `runExclusive` too** — 22 hook files pull it out of `useMiden()` and wrap every call, using the pattern `const runExclusiveSafe = runExclusive ?? runExclusiveDirect;` so they still serialize when no provider-supplied lock is available. `useSend`, `useMint`, `useConsume`, `useSwap`, `useBridge`, `useTransaction`, `usePreview`, `useChainAnchor`, `useCreateWallet`, `useCreateFaucet`, `useExecuteProgram`, the export/import hooks, `useWaitForNotes` and all four PSWAP hooks do this. Use it for your own multi-step sequences for the same reason they do. + +> `MidenProvider.tsx` carries an in-source comment claiming "Built-in hooks no longer use this since the WebClient handles concurrency internally". The hook bodies contradict it — the comment is stale. The WebClient's own serialization is a *separate* layer (see `frontend-pitfalls` FP2), not a replacement for this one. + +## Non-surface — do not invent these + +- **No fee API.** The React SDK exposes no fee configuration, hook, or option. Fees are paid inside the account's auth procedure; nothing in `@miden-sdk/react` names `FeeConversionInfo`, `feeConversionInfo` or `TX_FEE`. +- **No `AccountDelta` / `AccountPatch` re-export.** The only summary-shaped re-export is `TransactionSummary` (used by `usePreview`). +- **No protocol `AssetId` / `AssetClass` / `AssetVaultKey`.** Every `assetId` here is a faucet account reference. +- **Do not treat the package's own `README.md`, `CLAUDE.md` or `ReactSDK.Arena.Findings.md` as authoritative** — they are stale. The README still documents `authScheme: 0`, a `mutable` wallet option, and `storageMode: 'network'`, none of which exist. `src/types/index.ts` plus the hook bodies are the source of truth. ## Type Imports ```tsx -import { AuthScheme } from "@miden-sdk/react"; // value (friendly string const { Falcon, ECDSA }), not just a type +import { AuthScheme, DEFAULTS, MidenError } from "@miden-sdk/react"; // values, not just types 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, + MidenConfig, RpcUrlConfig, ProverConfig, ProverTarget, ProverUrls, MidenState, + QueryResult, MutationResult, TransactionStage, SyncState, + AccountsResult, AccountResult, AssetBalance, AccountRef, + NotesFilter, NotesResult, NoteSummary, NoteAsset, AssetMetadata, + TransactionHistoryOptions, TransactionHistoryResult, TransactionStatus, + CreateWalletOptions, CreateFaucetOptions, ImportAccountOptions, + SendOptions, SendResult, MultiSendOptions, MultiSendRecipient, + MintOptions, BridgeOptions, ConsumeOptions, SwapOptions, + CreateNetworkNoteOptions, NetworkNoteResult, + PswapCreateOptions, PswapConsumeOptions, PswapCancelOptions, + PswapCancelByOrderOptions, PswapLineageResult, PswapLineagesResult, + ExecuteTransactionOptions, CaptureAnchorOptions, PreviewTransactionOptions, + ExecuteProgramOptions, ExecuteProgramResult, TransactionResult, + WaitForCommitOptions, WaitForNotesOptions, + StreamedNote, UseNoteStreamOptions, UseNoteStreamReturn, + UseSessionAccountOptions, UseSessionAccountReturn, SessionAccountStep, + SignerContextValue, SignCallback, SignerAccountConfig, SignerAccountType, + MultiSignerContextValue, + CodedError, MidenErrorCode, WasmErrorCode, + // SDK types re-exported for convenience: + WebClient, Account, AccountHeader, AccountId, AccountFile, + InputNoteRecord, ConsumableNoteRecord, TransactionId, TransactionFilter, + TransactionRecord, TransactionRequest, TransactionSummary, ChainAnchor, + NoteType, Note, AccountStorageMode, PswapLineageRecord, } from "@miden-sdk/react"; ``` + +Note the `…Return` suffix on `UseNoteStreamReturn` and `UseSessionAccountReturn` — every other hook result type uses `…Result` (`UseSendResult`, `UseCreateWalletResult`, `UseChainAnchorResult`, and so on), all exported from the package root. diff --git a/skills/rust-client-patterns/SKILL.md b/skills/rust-client-patterns/SKILL.md index 56d13b3..a29e42d 100644 --- a/skills/rust-client-patterns/SKILL.md +++ b/skills/rust-client-patterns/SKILL.md @@ -6,12 +6,34 @@ description: Enforce coding conventions for the miden-client Rust codebase (rust # Miden Client Rust Patterns The crate ships under `crates/rust-client`, with `crates/sqlite-store` as the -native persistence backend and `crates/testing` for test utilities. The -WASM/IndexedDB store and the JS web client live in the separate -`0xMiden/web-sdk` repository. The MSRV -tracks `rust-toolchain.toml` in the upstream `miden-client` repository — copy -that channel into the consumer's toolchain file rather than hard-coding a -number that drifts. +native persistence backend. Reusable **test utilities** live in +`crates/rust-client/src/test_utils`, re-exported as `miden_client::testing` +behind the `testing` feature; `crates/testing/` holds the test crates +themselves (`miden-client-tests`, `test-node-genesis`), not utilities to depend +on. The WASM/IndexedDB store and the JS web client live in the separate +`0xMiden/web-sdk` repository. + +The upstream repository is `https://github.com/0xMiden/rust-sdk`; the crate is +still published as `miden-client`. The MSRV tracks `rust-toolchain.toml` +there — copy that channel into the consumer's toolchain file rather than +hard-coding a number that drifts. + +Pin the exact pre-release strings; Cargo does not match a pre-release against a +plain `"0.16"` requirement: + +```toml +miden-client = "0.16.0-rc.5" +miden-client-sqlite-store = "0.16.0-rc.5" +miden-protocol = "0.16.0-rc.9" +miden-standards = "0.16.0-rc.9" +miden-tx = "0.16.0-rc.9" +miden-tx-batch = "0.16.0-rc.9" +miden-assembly = "0.29.1" +miden-core = "0.29.1" +miden-processor = "0.29.1" +miden-prover = "0.29.1" +miden-crypto = "0.29.1" +``` ## Section Headers @@ -102,7 +124,7 @@ self.store `.map_err(ClientError::StoreError)` is the canonical way to surface a `StoreError` from a `Store` call inside the client. -Never use `.unwrap()` or `.expect()` in library code. Always propagate with `?` after mapping. +Prefer propagating with `?` after mapping over `.unwrap()`. `.expect()` does appear in the crate for invariants the author has already proven (e.g. `"Default executor's options should always be valid"`), so treat it as reserved for that case and carrying a message that states the invariant — not as a shortcut around a fallible call. ## Store Trait @@ -187,7 +209,7 @@ Rules: ### Impl Block Constraints -Apply the AUTH constraint per impl block, not on the struct. At v0.15 the +Apply the AUTH constraint per impl block, not on the struct. The `Client` impl blocks use these bounds: ```rust @@ -205,15 +227,33 @@ where AUTH: TransactionAuthenticator, { pub fn authenticator(&self) -> Option<&Arc> { ... } - // in_debug_mode, note_screener, rng, prover, source_manager, ... + // code_builder, note_screener, rng, prover, source_manager } // Methods that don't touch AUTH at all use an unconstrained block. impl Client { pub fn store_identifier(&self) -> &str { ... } + pub fn with_transaction_observer(&mut self, observer: Arc) { ... } + pub async fn network_id(&self) -> Result { ... } } ``` +**There is no debug toggle.** `DebugMode`, `ClientBuilder::in_debug_mode`, +`Client::in_debug_mode`, the CLI `--debug` flag and the `MIDEN_DEBUG` +environment variable do not exist. There is nothing to gate: MASM print-style +debugging goes through the `miden::core::debug` procedures, which print +unconditionally. + +What replaced them is a debug adapter, not a flag. The client carries an +optional `dap` feature (`dap = ["dep:miden-debug", "dep:miden-processor", "std"]`) +backing a `DapProgramExecutor`. The CLI has its **own** `dap` feature +(`dap = ["dep:miden-debug", "miden-client/dap"]`) which is **not** in its +`default = []`, so a stock CLI build exposes nothing — enabling the library +feature alone is not enough. When the CLI is built with its `dap` feature, the +flags `--start-debug-adapter ` and `--record ` appear on exactly two +commands: `exec` and `consume-notes`. `mint`, `transfer`, `swap` and the PSWAP +commands do not accept them. + (The sync methods sit in their own block bounded by `AUTH: TransactionAuthenticator + Sync + 'static`.) @@ -296,6 +336,68 @@ let client = ClientBuilder::for_testnet() .await?; ``` +`build()` returns `ClientInitializationError` if no RPC client or no store was +configured. Builder defaults worth knowing: `TX_DISCARD_DELTA = 20`, +`IRRELEVANT_BLOCK_PRUNE_INTERVAL = 1`, `CACHE_PARTIAL_MMR_IN_MEMORY = false`. + +Other builder methods: `grpc_client(&Endpoint, Option)`, `source_manager`, +`irrelevant_block_prune_interval(Option)`, +`cache_partial_mmr_in_memory(bool)`, `tx_graceful_blocks(Option)`, +`note_transport(Arc)`, `endpoint() -> Option<&Endpoint>`, +and — on `ClientBuilder` — `filesystem_keystore(path)`. + +#### `.rpc()` does not verify responses + +`ClientBuilder::rpc()` takes the client **as provided**. Only `grpc_client(..)` +and the `for_testnet` / `for_devnet` / `for_localhost` constructors wrap the +transport in `VerifyingRpcClient`. Handing `.rpc()` a bare `GrpcClient` +compiles, runs, and silently drops response verification: + +```rust +// Wrong: no response verification, and nothing tells you so. +ClientBuilder::new().rpc(Arc::new(GrpcClient::new(&endpoint, timeout))) + +// Right: wrap it, or use grpc_client()/for_*() which wrap for you. +ClientBuilder::new().rpc(Arc::new(VerifyingRpcClient::new(GrpcClient::new(&endpoint, timeout)))) +ClientBuilder::new().grpc_client(&endpoint, Some(timeout)) +``` + +### Account updates: `AccountPatch`, and the `account_delta` exception + +An executed transaction reports the account's new state as an **`AccountPatch`**: +`TransactionResult::account_patch() -> &AccountPatch`. + +`AccountDelta` has not gone away and is not a stale alias. It is *relative* — +it records how much things changed — and it is what a transaction summary +commits to. `TransactionSummary::account_delta() -> &AccountDelta` is +deliberately still a delta. Renaming that call site to `account_patch()`, or +rewriting the code around it to expect absolute values, is a semantic bug that +the compiler will not catch on the summary path. + +### Fees + +```rust +let request = TransactionRequestBuilder::new() + .fee_conversion_info(conversion_info, salt) // salt: Word, mandatory + .build()?; +``` + +`fee_conversion_info` **sets the auth arg**, so it conflicts with a manual +`auth_arg()` — the last call wins, and a manually-set auth arg is silently +overwritten. Only `AuthSingleSig` and `AuthMultisig` accept it; any other auth +component is rejected before execution with +`TransactionRequestError::FeeConversionInfoUnsupported(String)`. The type is +reachable as `miden_client::account::component::FeeConversionInfo` (re-exported +from `miden_standards::account::auth`), **not** from `miden_client::auth`. + +### `AssetId` is the vault key, not the asset class + +`miden_client::asset` re-exports `AssetId` as the vault's unique identifier for +an asset; the per-faucet class within an asset id is a separate type, +`AssetClass`. The names are a trap: code that treats `AssetId` as the asset +class compiles and is wrong. `miden_client::asset` also exposes `AssetAmount`, +`AssetCallbacks`, `AssetComposition`, `AssetWitness`, and `PartialVault`. + ## Lazy Reader Patterns Prefer the lazy readers over loading whole `Account` / `Note` records when @@ -306,7 +408,7 @@ asset vaults that a frontend will not display anyway. // Account fields without loading the full Account let reader = client.account_reader(account_id); let (header, status) = reader.header().await?; -let balance = reader.get_balance(faucet_id).await?; +let balance = reader.get_balance(faucet_id).await?; // AssetAmount, not u64; AssetAmount::ZERO when absent let storage_item = reader.get_storage_item(slot_name).await?; // slot_name: impl Into let nonce = reader.nonce().await?; let vault_root = reader.vault_root().await?; diff --git a/skills/rust-sdk-patterns/SKILL.md b/skills/rust-sdk-patterns/SKILL.md index c52e9d1..9ffb419 100644 --- a/skills/rust-sdk-patterns/SKILL.md +++ b/skills/rust-sdk-patterns/SKILL.md @@ -1,6 +1,6 @@ --- name: rust-sdk-patterns -description: Complete guide to writing Miden smart contracts with the Rust SDK. Covers the three-part #[component_storage]/#[component] account-component pattern, #[note]/#[note_script] notes, #[tx_script] scripts, the #[account(...)] wrapper, storage patterns, native functions, asset handling, cross-component calls, P2ID note creation, and asset receiving via component methods. Use when writing, editing, or reviewing Miden Rust contract code. +description: Complete guide to writing Miden smart contracts with the Rust SDK. Covers the three-part #[component_storage]/#[component] account-component pattern, the #[account_procedure] interface marker, #[note]/#[note_script] notes, #[tx_script] scripts, the #[account(...)] wrapper and its generated traits, storage patterns, native functions, asset handling, cross-component calls, P2ID note creation, and asset receiving via component methods. Use when writing, editing, or reviewing Miden Rust contract code. --- # Miden Rust SDK Patterns @@ -33,7 +33,9 @@ struct BankStorage { #[component] trait Bank { + #[account_procedure] fn initialize(&mut self); + #[account_procedure] fn deposit(&mut self, depositor: AccountId, deposit_asset: Asset); } @@ -46,9 +48,61 @@ impl Bank for BankStorage { Only the trait's methods are exported to WIT. Inherent (`impl BankStorage`) methods stay private to the contract — use them for helpers like key derivation. -See [miden-bank bank-account](https://github.com/0xMiden/tutorials/blob/a255af7959a441d9a027178631c666949b4af086/examples/miden-bank/contracts/bank-account/src/lib.rs) for a complete working example demonstrating the three-part pattern, typed `StorageValue` / `StorageMap`, `get()`/`set()`, felt arithmetic, and private inherent helpers. +Reference: `examples/basic-wallet/src/lib.rs` and `examples/counter-contract/src/lib.rs` in the compiler repo. -**Project metadata for accounts:** See `contracts/bank-account/miden-project.toml` in [miden-bank](https://github.com/0xMiden/tutorials/tree/a255af7959a441d9a027178631c666949b4af086/examples/miden-bank/contracts/bank-account) for the `[lib] kind = "account-component"`, the `namespace`, and the `supported-types`. The `Cargo.toml` only needs `crate-type = ["cdylib"]` and the `miden` dependency. +### `#[account_procedure]`: being exported is not the same as being callable + +A component's methods are **not** implicitly part of the account interface. Mark every method that must be reachable from a transaction script, a note script, foreign procedure invocation (FPI), or a sibling component with `#[account_procedure]`. + +Three rules that catch people out: + +- **It goes on the `#[component]` trait declaration, not on the impl block.** Putting it on the impl does nothing. +- **An unmarked method still compiles and is still exported by the package** — it simply is not an account procedure. There is no error at build time; the call site fails later. +- **`#[account_procedure]` and `#[auth_script]` cannot be combined in one component.** They belong to different component kinds: an ordinary account component versus an authentication component. An authentication component uses `#[auth_script]` alone, and its single method is the interface implicitly. + +`#[account_procedure]` needs no import — the enclosing `#[component]` macro recognises it, exactly as it does `#[auth_script]`. + +Any number of methods may be marked. + +**Project metadata for accounts:** `[lib]` needs `kind`, `namespace`, and an explicit `path`; `[dependencies]` needs `miden-core` and `miden-protocol`: + +```toml +# miden-project.toml +[package] +name = "basic-wallet" +version = "0.1.0" + +[lib] +kind = "account-component" +namespace = "miden:basic-wallet/basic-wallet@0.1.0" +path = "src/lib.rs" + +[dependencies] +miden-core = "*" +miden-protocol = "*" + +[package.metadata.miden] +supported-types = ["RegularAccountUpdatableCode"] +``` + +`supported-types` also accepts `"RegularAccountImmutableCode"` and the faucet kinds `["FungibleFaucet", "NonFungibleFaucet"]`. + +The `Cargo.toml` needs `edition = "2024"`, `crate-type = ["cdylib"]`, and the `miden` dependency. **Pin `miden` with the full pre-release string** — a plain `miden = "0.14"` means `^0.14.0`, which does not match a pre-release and fails to resolve: + +```toml +[package] +name = "basic_wallet" +version = "0.1.0" +edition = "2024" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +miden = "0.14.0" +``` + +Contracts build on the pinned nightly toolchain (`channel = "nightly-2026-04-30"`, `targets = ["wasm32-wasip2"]`); the compiler's MSRV is 1.97. ### Note Script (`#[note]` / `#[note_script]`) Executes when a note is consumed by an account. Can call component methods on the consuming account. @@ -58,31 +112,38 @@ A note is two parts: a `#[note]` struct (the note inputs type) and a `#[note]` ` ```rust #![no_std] #![feature(alloc_error_handler)] -use miden::*; -// The native (active) account this note runs against: exposes the -// bank-account `Bank` component's methods on the wrapper. -#[account(bank_account::Bank)] +use miden::{AccountId, Word, account, active_note, note}; + +/// Native account of the note: exposes the `basic-wallet` component's methods. +#[account(basic_wallet::BasicWallet)] pub struct Wallet; #[note] -struct DepositNote; +struct P2idNote { + target_account_id: AccountId, +} #[note] -impl DepositNote { +impl P2idNote { #[note_script] - fn run(self, _arg: Word, account: &mut Wallet) { - let depositor = active_note::get_sender(); - for asset in active_note::get_assets() { - account.deposit(depositor, asset); + pub fn script(self, _arg: Word, account: &mut Wallet) { + let current_account = account.get_id(); + assert_eq!(current_account, self.target_account_id); + + let assets = active_note::get_initial_assets(); + for asset in assets { + account.receive_asset(asset); } } } ``` -See [miden-bank deposit-note](https://github.com/0xMiden/tutorials/blob/a255af7959a441d9a027178631c666949b4af086/examples/miden-bank/contracts/deposit-note/src/lib.rs) for a working example demonstrating `#[note]`, `#[note_script]`, the `#[account(...)]` wrapper, and a cross-component call. +A `#[note]` struct with fields is auto-decoded from `active_note::get_storage()`. The decoder is strict: it calls `ensure_eof()`, so surplus felts in the note's storage fail with `FeltReprError::TrailingData`. A zero-sized note type skips `get_storage()` entirely. -**Project metadata for notes:** See `contracts/deposit-note/miden-project.toml` in [miden-bank](https://github.com/0xMiden/tutorials/tree/a255af7959a441d9a027178631c666949b4af086/examples/miden-bank/contracts/deposit-note) for `[lib] kind = "note"`, the `namespace`, the path dependency on the called component, and the cross-component `[package.metadata.miden.dependencies]` WIT entry. +Reference: `examples/p2id-note/src/lib.rs`, `examples/p2ide-note/src/lib.rs`, `examples/counter-note/src/lib.rs`. + +**Project metadata for notes:** `[lib] kind = "note"`, plus `namespace` and `path`. Conventional namespace shape is `miden:/miden-@0.1.0`. ### Transaction Script (`#[tx_script]`) One-off logic executed in the context of an account. Used for initialization, admin operations, etc. @@ -94,7 +155,6 @@ One-off logic executed in the context of an account. Used for initialization, ad #![feature(alloc_error_handler)] use miden::*; -// The account this tx-script runs against: the bank-account `Bank` component. #[account(bank_account::Bank)] pub struct Wallet; @@ -104,9 +164,23 @@ fn run(_arg: Word, account: &mut Wallet) { } ``` -See [miden-bank init-tx-script](https://github.com/0xMiden/tutorials/blob/a255af7959a441d9a027178631c666949b4af086/examples/miden-bank/contracts/init-tx-script/src/lib.rs) for the working example. +Reference: `examples/basic-wallet-tx-script/src/lib.rs`. + +**Project metadata for tx scripts:** like a note, but `[lib] kind = "tx-script"` and `namespace = "miden:base/transaction-script@1.0.0"`, plus `path = "src/lib.rs"`. + +## `#[account(...)]` generates one trait per interface + +`#[account(pkg::Interface)]` does **not** generate inherent methods on the wrapper struct. It generates **one trait per referenced interface**, named after the interface and carrying the wrapper's visibility, and implements it for the wrapper. Single-component accounts still call `account.method(..)` unchanged — as long as the generated trait is in scope. -**Project metadata for tx scripts:** Like a note, but `[lib] kind = "tx-script"` and `namespace = "miden:base/transaction-script@1.0.0"`. See `contracts/init-tx-script/miden-project.toml` in [miden-bank](https://github.com/0xMiden/tutorials/tree/a255af7959a441d9a027178631c666949b4af086/examples/miden-bank/contracts/init-tx-script). +The consequences worth knowing before you hit them: + +- **The wrapper struct must not share a name with any generated trait.** `#[account(counter_contract::CounterContract)] struct CounterContract;` is a hard error. Rename the struct (e.g. `Counter`). +- **A call site in a different module than the wrapper must `use` the generated trait.** A same-module `#[note]` / `#[tx_script]` entrypoint sees it automatically. +- **A referenced interface must export at least one method**, or `#[account(...)]` errors. +- **Wrappers are module-scope only.** +- **Clashing method names are disambiguated with UFCS**: `::deposit(account, asset)`. Generated traits are same-module so they need no import — but disambiguating against an `ActiveAccount` built-in does: `use miden::active_account::ActiveAccount;`. +- **Clashing trait names are renamed with `as`**: `#[account(counter_contract::CounterContract as RemoteCounter)]`. The path still selects the interface. +- **A component method named `new` is now legal.** `Wallet::new(id)` resolves to the inherent constructor, `wallet.new()` to the component method. ## Storage Slot Naming @@ -116,9 +190,11 @@ Storage slot names are part of the on-chain storage ABI and are derived as: :::: ``` -The **middle segment is the interface segment of the `[lib].namespace`** in `miden-project.toml` (the part between the last `/` and `@`), snake-cased — **not** the snake-cased struct name. This deliberately decouples slot names from private Rust renames. +The first segment is `[package] name` from **`miden-project.toml`** (character-sanitised, not re-snake-cased). The **middle segment is the interface segment of the `[lib].namespace`** (the part between the last `/` and `@`), snake-cased — **not** the snake-cased struct name. This deliberately decouples slot names from private Rust renames. The version suffix (`@0.1.0`) is ignored so the slot name stays stable, and there is no `slot(...)` attribute. + +Live values from the pinned examples: `counter_contract::counter_contract::count_map`, `auth_component_rpo_falcon512::auth_component::owner_public_key`. -Example: package `bank-account` + `namespace = "miden:bank-account/bank@0.1.0"` + field `balances` derives slot `bank_account::bank::balances` (see [miden-bank deposit_test.rs](https://github.com/0xMiden/tutorials/blob/a255af7959a441d9a027178631c666949b4af086/examples/miden-bank/integration/tests/deposit_test.rs), `bank_storage_slots()`). Note the version suffix (`@0.1.0`) is ignored so the slot name stays stable. Slots are derived from the slot name; there is no `slot(...)` attribute. See the rust-sdk-pitfalls skill (P5) for more on slot naming. +See the rust-sdk-pitfalls skill (P5) for more on slot naming. ## Storage Types @@ -127,26 +203,38 @@ Example: package `bank-account` + `namespace = "miden:bank-account/bank@0.1.0"` | `StorageValue` | Single typed slot (flags, counters, IDs) | `.get() -> T` | `.set(T) -> T` | | `StorageMap` | Typed key-value mapping (balances, records) | `.get(K) -> V` | `.set(K, V) -> V` | +`K: WordKey`, and `T`/`V`: `WordValue`. `WordValue` is implemented for `Word`, `Felt`, `AssetAmount`, `Digest`, `AccountId`, `Recipient`, `Tag`, `NoteIdx`, `NoteType`; `WordKey` for the same set minus `Digest` and `Recipient`. + ## Native Function Modules | Module | Key Functions | Purpose | |--------|--------------|---------| -| `native_account::` | `add_asset(Asset) -> Word`, `remove_asset(Asset) -> Word`, `incr_nonce() -> Felt`, `get_id() -> AccountId` | Modify current account vault/nonce | -| `active_account::` | `get_id() -> AccountId`, `get_balance(Word) -> Felt` | Query current account (`get_balance` takes the asset key word, not an AccountId) | -| `active_note::` | `get_storage() -> Vec`, `get_assets() -> Vec`, `get_sender() -> AccountId` | Query note being consumed | +| `native_account::` | `add_asset(Asset) -> Word`, `remove_asset(Asset) -> Word`, `incr_nonce() -> Nonce`, `get_id() -> AccountId`, `get_initial_asset(Word) -> Word`, `get_initial_commitment() -> Word`, `was_procedure_called(Word) -> bool`, `compute_delta_commitment() -> Word` | Modify / read the native account | +| `active_account::` | `get_id() -> AccountId`, `get_nonce() -> Nonce`, `get_asset(asset_key: Word) -> Word`, `has_asset(asset_id: Word) -> bool`, `get_vault_root() -> Word`, `get_num_procedures() -> u32`, `get_procedure_root(u32) -> Word`, `has_procedure(Word) -> bool` | Query the active account | +| `active_note::` | `get_storage() -> Vec`, `get_initial_assets() -> Vec`, `get_sender() -> AccountId`, `get_recipient() -> Recipient`, `get_metadata() -> NoteMetadata`, `find_attachment(Felt) -> Option`, `write_attachment_to_memory(u32) -> Vec` | Query the note being consumed | | `note::` | `build_recipient(Word, Word, Vec) -> Recipient` | Build note recipients from serial number, script root, and note storage | -| `output_note::` | `create(Tag, NoteType, Recipient) -> NoteIdx`, `add_asset(Asset, NoteIdx)` | Create output notes | -| `faucet::` | `create_fungible_asset(Felt) -> Asset`, `mint(Asset)`, `burn(Asset)` | Asset minting | -| `tx::` | `get_block_number() -> Felt`, `get_block_timestamp() -> Felt` | Transaction context | +| `output_note::` | `create(Tag, NoteType, Recipient) -> NoteIdx`, `add_asset(Asset, NoteIdx)`, the `*_attachment` family | Create output notes | +| `faucet::` | `mint(Asset)`, `burn(Asset)` | Move assets in and out of existence | +| `tx::` | `get_block_number() -> BlockNumber`, `get_block_timestamp() -> u32`, `get_num_input_notes() -> u32`, `get_num_output_notes() -> u32`, `get_expiration_block_delta() -> u16`, `update_expiration_block_delta(u16)`, `execute_foreign_procedure(..)` | Transaction context and FPI | | Intrinsics | `assert(Felt)`, `assertz(Felt)`, `assert_eq(Felt, Felt)` | Validation (`assert` fails unless the felt equals 1; `assertz` fails unless it equals 0) | -## Asset Handling +`add_asset`, `remove_asset` and the `active_account` queries are also trait methods auto-implemented on the `#[component_storage]` struct, so the idiomatic body is `self.add_asset(asset)` rather than the free function. + +### Three context restrictions the compiler will not catch + +- **`native_account::incr_nonce()` may only be called from the account's authentication procedure.** The kernel asserts the caller's origin; calling it from an ordinary component method panics at runtime. +- **`output_note::create` is account-component context only.** A transaction or note script must go through a component method that wraps it — see `create_note` in `examples/basic-wallet/src/lib.rs`. +- **`native_account::add_asset` / `remove_asset` are likewise account-context only** (see pitfall P11). + +### Balances and asset construction -`Asset` is a two-word value (`key` + `value`): +There is no `active_account::get_balance`. Read the asset value word with `active_account::get_asset(asset_key)` (or `native_account::get_initial_asset(asset_key)` for the pre-transaction value) and take the fungible amount from it; test membership with `active_account::has_asset(asset_id)`. -**Constructor**: `Asset::new(key, value)` builds an Asset from its vault key word and value word (the arguments are `impl Into`, so e.g. `Asset::new(key_word, value_word)` or from `[Felt; 4]`). +There is also no in-transaction asset construction: `faucet::create_fungible_asset`, `create_non_fungible_asset`, `has_callbacks` and the whole `asset` module are gone. `faucet::mint` and `faucet::burn` take an already-built `Asset`. -See [miden-bank bank-account](https://github.com/0xMiden/tutorials/blob/a255af7959a441d9a027178631c666949b4af086/examples/miden-bank/contracts/bank-account/src/lib.rs) for complete asset handling patterns including deposit, withdrawal, and balance tracking. +## Asset Handling + +`Asset` is a two-word value: ```rust pub struct Asset { @@ -155,43 +243,65 @@ pub struct Asset { } ``` -For fungible assets, the amount lives in `asset.value[0]`. The asset class / vault identity lives in `asset.key`. +**Constructor**: `Asset::new(key, value)` builds an Asset from its two words (the arguments are `impl Into`). + +The guest field is literally named `key`, but the word it holds is the protocol's **asset ID** — the vault's unique identifier for the asset. Read `asset.key` as "the asset-ID word". + +For fungible assets the amount lives in `asset.value[0]`. Prefer the typed accessors over raw felt maths: ```rust -// Access fungible amount -let amount = asset.value[0]; +// Typed amount: panics if the asset is non-fungible or the amount is out of range +let amount: AssetAmount = asset.amount(); +let fungible: bool = asset.is_fungible(); -// Keep the asset key if you need to persist or compare the asset class -let asset_key = asset.key; +// Raw form, if you need the felt +let amount_felt = asset.value[0]; -// Add asset to account vault (only from component methods, not note scripts — see pitfall P11) -native_account::add_asset(asset); +// Keep the asset-ID word if you need to persist or compare the asset +let asset_id = asset.key; -// Remove asset from account vault (Asset is Copy, no clone needed) -native_account::remove_asset(asset); +// Vault operations (component methods only — see pitfall P11) +self.add_asset(asset); +self.remove_asset(asset); // Asset is Copy, no clone needed ``` +`AssetAmount` is a validated newtype (`MAX_U64 = 2^63 - 2^31`) with integer ordering and add/sub that panic on over/underflow. It is usable in exported signatures and as a storage value type. + ## P2ID Output Note Creation -To send assets to another account, create a P2ID (Pay-to-ID) output note. See [miden-bank bank-account](https://github.com/0xMiden/tutorials/blob/a255af7959a441d9a027178631c666949b4af086/examples/miden-bank/contracts/bank-account/src/lib.rs) `create_p2id_note()` for a complete working implementation (builds the recipient with `note::build_recipient`, creates the note with `output_note::create`, then `native_account::remove_asset` + `output_note::add_asset`). +To send assets to another account, create a P2ID output note **from an account-component method** — both `output_note::create` and `native_account::remove_asset` are account-context only, so a note or tx script cannot do this inline. + +The sequence is `note::build_recipient` → `output_note::create` → `remove_asset` + `output_note::add_asset`. `examples/basic-wallet/src/lib.rs` is the reference: `create_note` wraps `output_note::create`, and `move_asset_to_note` wraps the remove-then-add pair. + +`note::build_recipient` panics if the note storage exceeds `MAX_NOTE_STORAGE_ITEMS` (1024 felts). A note may carry at most `MAX_ASSETS_PER_NOTE` = **16** assets. ## Cross-Component Dependencies -To call another component's methods from a note or tx script, declare the dependency in your `miden-project.toml` in **two places**: +To call another component's methods from a note or tx script, declare the dependency in your **`miden-project.toml`** — never in `Cargo.toml`, which the macros read only for `[package] name` and `description`: + +```toml +[dependencies] +miden-core = "*" +miden-protocol = "*" +basic-wallet = { path = "../basic-wallet" } +``` + +The `[dependencies]` entry is all you need. A component's WIT is embedded in its compiled package, and the embedded copy is authoritative. + +Do **not** add a `[package.metadata.miden.dependencies]..wit` key for such a dependency: when a package embeds WIT and the manifest also sets `wit`, expansion fails with *"embeds component WIT, but miden-project.toml also sets ... remove the `wit` key"* (`sdk/base-macros/src/dependency_package.rs`). The key survives only as a fallback for dependency packages that do **not** embed WIT, for example ones produced by another toolchain. No cross-component example in the compiler ships it. -- `[dependencies]` — a normal path (or registry) dependency on the component crate. -- `[package.metadata.miden.dependencies]` — the generated WIT for the component, e.g. `bank-account = { wit = "../bank-account/target/generated-wit/" }`. The WIT is produced by building the dependency component first. +Then expose the dependency's methods on the consuming account by declaring an `#[account(package::Interface)]` wrapper (e.g. `#[account(basic_wallet::BasicWallet)] pub struct Wallet;`) and calling methods on the injected `account` parameter. The package name is the dependency's Rust-style name (`-` replaced with `_`) and `Interface` is its exported WIT interface in UpperCamelCase. -See `contracts/deposit-note/miden-project.toml` in [miden-bank](https://github.com/0xMiden/tutorials/tree/a255af7959a441d9a027178631c666949b4af086/examples/miden-bank/contracts/deposit-note) for a working example showing both sections. +A component can also declare siblings it calls with `#[component(pkg::Interface)]` on its own trait. The generated traits attach through a blanket impl bound on `NativeAccount`, so only the native account can make intra-account sibling calls. -Then expose the dependency's methods on the consuming account by declaring an `#[account(package::Interface)]` wrapper (e.g. `#[account(bank_account::Bank)] pub struct Wallet;`) and calling methods on the injected `account` parameter. The package name is the dependency's Rust-style name (`-` replaced with `_`) and `Interface` is its exported WIT interface in UpperCamelCase. See `contracts/deposit-note/src/lib.rs` in [miden-bank](https://github.com/0xMiden/tutorials/blob/a255af7959a441d9a027178631c666949b4af086/examples/miden-bank/contracts/deposit-note/src/lib.rs). +There is a second, wrapper-free form: the generated bindings expose free functions, which `examples/counter-note/src/lib.rs` uses directly (`use crate::bindings::miden::counter_contract::counter_contract; counter_contract::get_count();`). ## Common Type Conversions ```rust // Felt from integer let f = felt!(42); // preferred for literals in contract code -let f = Felt::new(42).unwrap(); // fallible: Felt::new returns Result in v0.15 +let f = Felt::new(42).unwrap(); // fallible: Felt::new returns Result let f = Felt::new_unchecked(42); // infallible, non-reducing form let f = Felt::from_u32(42); // infallible (u32 always fits) let f = Felt::from_canonical_checked(42).unwrap(); // returns Option @@ -211,9 +321,21 @@ let hex = w.to_hex(); let n: u64 = f.as_canonical_u64(); ``` +### Kernel scalars are typed, not felts + +Counts are `u32` (`tx::get_num_input_notes`, `tx::get_num_output_notes`, `active_account::get_num_procedures`, and the `num_assets` / `num_storage_items` fields of the note-info structs), so count-driven loops index directly. Block heights are `BlockNumber` (comparable as integers; `BlockNumber::try_from(felt)` validates a height read out of note storage). Block timestamps are `u32` seconds and expiration deltas are `u16`. Account nonces are `Nonce` — use `as_felt()` / `as_u64()` or `Felt::from(nonce)` where a raw value is needed, e.g. when packing a nonce into a `Word`. Attachment lookups return `Option`. + +## Debug Printing + +```rust +miden::println!("checkpoint"); // string literal / &str only — format args are a compile error +miden::debug::println(some_str); +miden::intrinsics::debug::breakpoint(); +``` + ## No-std Requirements -Every contract file must start with `#![no_std]` and `#![feature(alloc_error_handler)]`. See any contract under `contracts/` in [miden-bank](https://github.com/0xMiden/tutorials/tree/a255af7959a441d9a027178631c666949b4af086/examples/miden-bank/contracts) for the pattern. +Every contract file must start with `#![no_std]` and `#![feature(alloc_error_handler)]`. If you need heap allocation (Vec, String, etc.): ```rust @@ -221,24 +343,44 @@ extern crate alloc; use alloc::vec::Vec; ``` -The bank-account contract uses `#[macro_use] extern crate alloc;` so the `vec!` macro is available (it builds note-recipient inputs with `vec![...]`). +Use `#[macro_use] extern crate alloc;` when you want the `vec!` macro available (e.g. for building note-recipient inputs). ## Asset Receiving via Component Methods -Note scripts cannot call `native_account::add_asset()` directly (see pitfall P11). The canonical pattern is for an account component to expose a public (trait) method that wraps `native_account::add_asset()`, and the note script calls that method through the `#[account(...)]` wrapper. +Note scripts cannot call `native_account::add_asset()` directly (see pitfall P11). The canonical pattern is for an account component to expose a trait method — marked `#[account_procedure]` — that wraps `add_asset`, and for the note script to call that method through the `#[account(...)]` wrapper. -See [miden-bank bank-account deposit()](https://github.com/0xMiden/tutorials/blob/a255af7959a441d9a027178631c666949b4af086/examples/miden-bank/contracts/bank-account/src/lib.rs) for the component side: the `deposit()` trait method validates the deposit, updates storage, and calls `native_account::add_asset()`. +Component side (`examples/basic-wallet/src/lib.rs`): + +```rust +#[component] +trait BasicWallet { + #[account_procedure] + fn receive_asset(&mut self, asset: Asset); +} + +#[component] +impl BasicWallet for BasicWalletStorage { + fn receive_asset(&mut self, asset: Asset) { + self.add_asset(asset); + } +} +``` -See [miden-bank deposit-note](https://github.com/0xMiden/tutorials/blob/a255af7959a441d9a027178631c666949b4af086/examples/miden-bank/contracts/deposit-note/src/lib.rs) for the note side: the note declares `#[account(bank_account::Bank)] pub struct Wallet;` and, inside `#[note_script] fn run(self, _arg: Word, account: &mut Wallet)`, calls `account.deposit(depositor, asset)` on that wrapper. It is **not** a free `bank_account::deposit()` call. +Note side (`examples/p2id-note/src/lib.rs`): the note declares `#[account(basic_wallet::BasicWallet)] pub struct Wallet;` and, inside `#[note_script] fn script(self, _arg: Word, account: &mut Wallet)`, calls `account.receive_asset(asset)` on that wrapper. It is **not** a free `basic_wallet::receive_asset()` call. ## Validation Checklist - [ ] `#![no_std]` and `#![feature(alloc_error_handler)]` at top of every contract - [ ] Account components use the three-part pattern: `#[component_storage]` struct + `#[component]` trait + `#[component]` impl (never `#[component]` on a struct) -- [ ] `crate-type = ["cdylib"]` in `Cargo.toml` -- [ ] Correct `[lib] kind` in `miden-project.toml` (`account-component` / `note` / `tx-script`) with the matching `namespace` +- [ ] Every externally-callable trait method carries `#[account_procedure]`, on the **trait**, not the impl +- [ ] `#[account_procedure]` and `#[auth_script]` are not combined in one component +- [ ] The `#[account(...)]` wrapper struct name differs from every generated trait name +- [ ] `edition = "2024"` and `crate-type = ["cdylib"]` in `Cargo.toml`, with the exact pre-release pin `miden = "0.14.0"` +- [ ] `[lib]` in `miden-project.toml` has `kind` (`account-component` / `note` / `tx-script`), `namespace`, **and `path`** +- [ ] `[dependencies]` in `miden-project.toml` carries `miden-core = "*"` and `miden-protocol = "*"` - [ ] Typed storage uses `StorageValue` / `StorageMap` with `get()` / `set()`; slot names derive from `::::` - [ ] Notes/tx-scripts that call a component declare an `#[account(package::Interface)]` wrapper and call methods on the injected `account` -- [ ] Cross-component deps declared in `miden-project.toml` under both `[dependencies]` (path) and `[package.metadata.miden.dependencies]` (wit) +- [ ] Cross-component deps declared in `miden-project.toml` (never `Cargo.toml`) under `[dependencies]`, with no `[package.metadata.miden.dependencies]` `wit` key: embedded WIT is authoritative and a leftover key is an error +- [ ] `incr_nonce()` is called only from an authentication procedure; `output_note::create` and the vault operations only from account-component context - [ ] Felt arithmetic validated before subtraction (see rust-sdk-pitfalls skill) - [ ] Felt comparisons use `.as_canonical_u64()` (see rust-sdk-pitfalls skill) diff --git a/skills/rust-sdk-pitfalls/SKILL.md b/skills/rust-sdk-pitfalls/SKILL.md index 071f585..9ba2b0c 100644 --- a/skills/rust-sdk-pitfalls/SKILL.md +++ b/skills/rust-sdk-pitfalls/SKILL.md @@ -5,6 +5,10 @@ description: Critical pitfalls and safety rules for Miden Rust SDK development. # Miden SDK Pitfalls +Verified against contract SDK `miden` 0.14.0 / compiler + `cargo-miden` 0.10.0 +(`0xMiden/compiler` tag `sdk/v0.14.0`), protocol + `miden-standards` + `miden-testing` +0.16.0-rc.9, `miden-client` 0.16.0-rc.5, and Miden VM 0.29.1. + ## P1: Felt Arithmetic is Modular (SECURITY CRITICAL) **Severity**: Critical — can cause loss of funds @@ -28,6 +32,14 @@ let new_balance = current_balance - withdraw_amount; **Max Felt value**: The maximum valid Felt is `p - 1 = 18446744069414584320`, not `u64::MAX` (`18446744073709551615`). Using `u64::MAX` as a sentinel or boundary value causes silent wraparound. +**Prefer `AssetAmount` for token quantities.** `miden::AssetAmount` is a validated newtype over +`Felt` (`AssetAmount::MAX_U64 = (1 << 63) - (1 << 31)`) whose `Add` / `Sub` impls **panic** on +overflow / underflow rather than wrapping, and whose ordering is canonical-integer ordering. +Constructors and accessors: `AssetAmount::new(u64) -> Result`, +`AssetAmount::max()`, `AssetAmount::ZERO`, `as_u64()`, `as_felt()`. It is a valid `WordKey` and +`WordValue`, so it can be stored directly in `StorageValue` / `StorageMap` and used in exported +signatures. + ## P2: Felt Comparison Operators Are Misleading for Quantity Logic **Severity**: High — silently produces incorrect results @@ -44,21 +56,151 @@ if balance.as_canonical_u64() > threshold.as_canonical_u64() { ... } **Rule**: For quantity/business logic, ALWAYS convert to `.as_canonical_u64()` before using comparison operators. -## P3: Direct Call Boundary Passes At Most 16 Stack Felts (4 Words) +**Exception — typed scalars already compare as integers.** `BlockNumber`, `Nonce` and +`AssetAmount` derive integer ordering, so no conversion is needed. The `p2ide-note` example relies +on this directly: + +```rust +let block_number = tx::get_block_number(); // BlockNumber +let timelock_height = BlockNumber::try_from(inputs[3]).unwrap(); +assert!(block_number >= timelock_height); // integer comparison, correct as written +``` + +## P3: The 16-Felt Cross-Context Boundary — Two Limits, and Exports Differ From FPI Imports + +**Severity**: High — the wrong mental model makes you refactor a signature that would have compiled, +and miss the one that will not + +A cross-context call passes its parameters on the MASM operand stack, whose addressable window is 16 +felts (4 Words, counting the canonical-ABI result pointer when present). Two *different* limits +govern that boundary, and conflating them is the actual pitfall: + +- **`MAX_FLAT_PARAMS = 16`** — a **count** of canonical-ABI flat values. +- **`MAX_DIRECT_STACK_FELTS = 16`** — the **felt budget for a direct wrapper call**, after parameter + widths expand and any canonical-ABI output pointer is included. + +Both live in `compiler:sdk/v0.14.0:frontend/wasm/src/component/types/mod.rs:44-62`, whose own +doc comment spells out the distinction: the felt budget "is a Miden VM constraint, distinct from the +spec's count-based `MAX_FLAT_PARAMS`: a signature can stay within 16 flat values while 64-bit values +expand it past 16 stack felts." + +For the parameter list itself, canonical-ABI flattening replaces all parameters with one tuple +pointer when either the flat-parameter count or the flattened **parameter** width exceeds 16 — +`flat_params_need_tuple` is an **OR** +(`compiler:sdk/v0.14.0:frontend/wasm/src/component/flat.rs:212-217,263-270`): + +```rust +flat_params.len() > MAX_FLAT_PARAMS + || flat_params.iter().map(|param| param.ty.size_in_felts()).sum::() + > MAX_DIRECT_STACK_FELTS +``` + +Result handling happens after this parameter-only decision. For an import with indirect results, +flattening appends the result pointer to the parameter list afterward +(`compiler:sdk/v0.14.0:frontend/wasm/src/component/flat.rs:272-288`). + +What happens to the parameter-tuple pointer is where the two sides of the boundary part ways. + +### FPI imports: the count decides the path, the felt budget can still reject it + +The critical subtlety: `plan_fpi_call` does **not** reuse the OR above. It re-derives the call shape +from the flat-value **count alone** +(`compiler:sdk/v0.14.0:frontend/wasm/src/component/lower_imports.rs:330-351`): + +```rust +let has_arg_ptr = flattened_params.len() > MAX_FLAT_PARAMS; +``` + +So the two parameter classifiers disagree in exactly one band — **count ≤ 16 but parameter felts > +16**. Flattening *has* tupled such a signature, but `plan_fpi_call` still believes it is a direct +call, so it takes the `!has_arg_ptr` path, sums the operand felts, and rejects: + +``` +FPI import `{path}` lowers to {n} operand stack felts after expanding 64-bit +values and result pointers, but direct FPI calls support at most 16 +``` + +An output pointer exposes a separate direct-call edge case: exactly 16 parameter felts do not trigger +parameter tupling, but appending the output pointer makes the direct stack total 17 felts, so +`plan_fpi_call` rejects it without an argument tuple. + +For the parameter-width mismatch, this check runs before lowered-signature comparison so the caller +gets the width diagnostic instead of a confusing tuple/direct shape mismatch. + +```rust +// REJECTED — 13 flat values, so the count-based `has_arg_ptr` is false, but six +// `u64`s expand to two felts each: 6 prefix felts + 12 + 1 + 1 result pointer +// = 20 operand felts. +struct SixU64Record { a: u64, b: u64, c: u64, d: u64, e: u64, f: u64, tag: Felt } +fn echo_six_u64_record(&self, input: SixU64Record) -> SixU64Record; +``` + +That is the compiler's own negative test, +`compiler:sdk/v0.14.0:tests/integration-network/src/mockchain/fpi/note/six_u64_struct.rs:5-13` +(`#[should_panic(expected = "direct FPI calls support at most 16")]`). + +**Above 16 flat values the indirect path is supported** — `has_arg_ptr` is true, the felt-budget +check is skipped entirely, and the wrapper reloads the tuple so the backend still sees a direct, +felt-only call +(`compiler:sdk/v0.14.0:frontend/wasm/src/component/lower_imports.rs:439-508`). A +22-flat-parameter FPI import is a **passing** test +(`compiler:sdk/v0.14.0:tests/integration-network/src/mockchain/fpi/note/sixteen_flattened_params_struct.rs`). + +**Indirect is not unbounded, though.** The FPI executor imposes its own caps, checked after the +shape is settled (`compiler:sdk/v0.14.0:frontend/wasm/src/component/lower_imports.rs:381-399`), +with values from `ExecFpi` (`compiler:sdk/v0.14.0:dialects/hir/src/ops/invoke.rs:160-169`): -**Severity**: High — exceeding the 16-felt call boundary is a compile error +| bound | value | diagnostic | +| --- | --- | --- | +| `PREFIX_FELTS` — account id + procedure root, subtracted before the input check | 6 | `must pass account id and procedure root` | +| `MAX_INPUT_FELTS` — flattened *procedure input* felts, after the prefix | 16 | ``passes {n} flattened procedure input felts, but `execute_foreign_procedure` supports at most 16`` | +| `EXECUTOR_RESULT_FELTS` — result felts | 16 | ``returns {n} result felts, but `execute_foreign_procedure` supports at most 16`` | -A direct cross-context / export / FPI call passes its parameters on the MASM operand stack, whose addressable window is 16 felts (4 Words, counting the canonical-ABI result pointer when present). Passing more than 16 flat felts across that boundary is a **compilation error**: after expanding 64-bit values and any result pointer, the flattened parameters must fit in 16 operand-stack felts. (Indirection for larger payloads via the advice provider is planned but not yet implemented, so today the limit is hard.) +So the tuple pointer buys you past the *stack window*, not past the *protocol*: the payload the +foreign procedure actually receives is still capped at 16 felts. + +**Do not "fix" the felt-budget rejection by padding the signature until the count exceeds 16** just +to trigger the indirect path. It does flip `has_arg_ptr` and skip the stack-window check, but the +executor's 16-felt input cap then rejects the same payload — you have only moved which diagnostic +fires. Reduce what crosses the boundary instead: split the call, or hand over an identifier (a +storage key, a note index, a commitment) and let the callee load the rest itself. + +### Component exports: indirect parameters are not implemented yet + +On the export side the tuple pointer is produced the same way but then refused, so **either** an +over-16 flat-value count **or** an over-16 felt budget fails +(`compiler:sdk/v0.14.0:frontend/wasm/src/component/lift_exports.rs:68-74`): + +``` +component export lifting for '{path}' is not yet implemented for passing the +parameters using the advice provider in the cross-context `call`; +``` ```rust -// COMPILE ERROR — flattens past 16 felts +// REJECTED as a component export — flattens to 20 felt params. fn process(a: Word, b: Word, c: Word, d: Word, e: Word) { ... } -// OK — keep signatures small, or pass aggregates by reference so each lowers to a pointer -fn process(a: &Word, b: &Word, c: &Word, d: &Word, e: &Word) { ... } +// STILL REJECTED — a wrapper struct compresses nothing. Flattening recurses +// into struct fields and concatenates them, so a WordBatch holding those same +// five Words is still 20 felts. +fn process(batch: WordBatch) { ... } + +// OK — reduce what actually crosses the boundary. +fn process(batch_commitment: Word) { ... } ``` -## P4: Storage API Is Typed +Export **return** values are capped separately, at 16 loaded *values* (a count, with no felt-budget +check at all — a record of nine `u64` fields is 9 values but 18 felts and is not caught): +`compiler:sdk/v0.14.0:frontend/wasm/src/component/lift_exports.rs:281-286`. + +### Unrelated, but adjacent + +`&T` parameters are refused before any of this, by the `#[component]` macro rather than the +compiler frontend: `references are not supported in component interfaces or exported types` +(`compiler:sdk/v0.14.0:sdk/base-macros/src/types.rs:102-106`). It applies to exported method +parameters, return types, and exported struct/enum fields alike — `&self` receivers are fine. + +## P4: Storage API Is Typed, and a Component Is Three Parts **Severity**: Medium — the wrong component shape does not compile @@ -69,39 +211,53 @@ Account storage uses typed slots: - `get()` / `set()` methods instead of `.read()` / `.write()` - `K: WordKey`, `T: WordValue`, `V: WordValue` -An account component is written in **three parts**: annotate the storage struct with `#[component_storage]`, the API `trait` with `#[component]`, and the `impl Trait for Storage` block with `#[component]`: +`WordValue` is implemented for `Word`, `Felt`, `AssetAmount`, `Digest`, `AccountId`, `Recipient`, +`Tag`, `NoteIdx`, `NoteType`. `WordKey` is implemented for `Word`, `Felt`, `AssetAmount`, +`AccountId`, `Tag`, `NoteIdx`, `NoteType`. + +An account component is written in **three parts**: annotate the storage struct with `#[component_storage]`, the API `trait` with `#[component]`, and the `impl Trait for Storage` block with `#[component]`. Every trait method that must be callable from outside the component also needs `#[account_procedure]` (see P13): ```rust // 1. Storage struct — annotated #[component_storage], NOT #[component]. // Applying #[component] to a struct is a hard compile error. #[component_storage] struct CounterContractStorage { - #[storage(description = "single typed slot")] - counter: StorageValue, - - #[storage(description = "typed map")] - balances: StorageMap, + #[storage(description = "counter contract storage map")] + count_map: StorageMap, } // 2. API trait — defines the exported interface. #[component] trait CounterContract { + #[account_procedure] fn get_count(&self) -> Felt; + #[account_procedure] fn increment_count(&mut self) -> Felt; } // 3. Implementation — the behavior, wired to the storage struct. +// #[account_procedure] goes on the trait declaration only, never here. #[component] impl CounterContract for CounterContractStorage { - fn get_count(&self) -> Felt { self.counter.get() } + fn get_count(&self) -> Felt { + let key = Word::new([felt!(0), felt!(0), felt!(0), felt!(1)]); + self.count_map.get(key) + } + fn increment_count(&mut self) -> Felt { - let next = self.counter.get() + felt!(1); - self.counter.set(next); - next + let key = Word::new([felt!(0), felt!(0), felt!(0), felt!(1)]); + let current_value: Felt = self.count_map.get(key); + let new_value = current_value + felt!(1); + self.count_map.set(key, new_value); + new_value } } ``` +`#[component_storage]` accepts only unit structs and structs with named fields — a tuple struct +fails with `` `#[component_storage]` only supports unit structs or structs with named fields. `` +Generic storage structs are rejected. + If you need custom keys or values, implement `WordKey` / `WordValue` by converting to and from a single `Word`. ## P5: Storage Slot Naming Convention @@ -114,19 +270,34 @@ Storage slot names follow a strict pattern. Getting it wrong often returns the d **Where the segments come from**: The `#[component_storage]` macro (NOT `#[component]`) processes the `#[storage]` fields and derives slot names. It loads `miden-project.toml` (next to your `Cargo.toml`, NOT `Cargo.toml` itself): -- **First segment** = `[package] name`. -- **Middle segment** = the *interface segment* of the `[lib] namespace` value. The namespace is a fully-qualified component id `namespace:package/interface@version`; the interface segment sits between the last `/` and the `@`. This is deliberately decoupled from the Rust storage-struct name, so renaming the private struct cannot change deployed slot names. The struct name (`BankStorage`, `CounterContractStorage`, …) does NOT appear in the slot name. +- **First segment** = `[package] name` from `miden-project.toml`. +- **Middle segment** = the *interface segment* of the `[lib] namespace` value. The namespace is a fully-qualified component id `namespace:package/interface@version`; the interface segment sits between the last `/` and the `@`. This is deliberately decoupled from the Rust storage-struct name, so renaming the private struct cannot change deployed slot names. The struct name (`CounterContractStorage`, `AuthComponentStorage`, …) does NOT appear in the slot name. - **Last segment** = the `#[storage]` field name. -**Conversion rule**: Each segment is sanitized — any `@version` suffix is stripped, the interface segment is passed through `snake_case`, and characters outside `[A-Za-z0-9_]` are replaced with `_` (an empty or leading-`_` segment is prefixed with `x`). Project package names are conventionally kebab-case (e.g. `counter-contract`, `bank-account`), so the first segment is that name with hyphens replaced by `_` — it does NOT equal the package name verbatim (`counter-contract` → `counter_contract`). +**Conversion rule**: All three segments are character-sanitized — any `@version` suffix is stripped, characters outside `[A-Za-z0-9_]` are replaced with `_`, and an empty or leading-`_` segment is prefixed with `x`. Only the **middle** segment additionally goes through `to_snake_case()`; the package name is *not* snake-cased, it is only character-sanitized (which is why the conventional kebab-case `counter-contract` still lands as `counter_contract`). | `[package] name` | `[lib] namespace` | Field | Storage Slot Name | |------------------|-------------------|-------|-------------------| -| `counter-contract` | `miden:counter-contract/miden-counter-contract@0.1.0` | `count_map` | `counter_contract::miden_counter_contract::count_map` | -| `bank-account` | `miden:bank-account/bank@0.1.0` | `balances` | `bank_account::bank::balances` | -| `bank-account` | `miden:bank-account/bank@0.1.0` | `initialized` | `bank_account::bank::initialized` | - -**Caveat (toolchain-version dependent)**: This naming is a property of the Rust SDK contract macros, which live in the `miden-base-macros` crate (0.13.0, part of the released-compiler-v0.9.0 Rust SDK family alongside `miden` and `miden-base-sys`, all 0.13.0; the separate compiler / `cargo-miden` workspace is versioned 0.9.0). Never conflate these with the protocol/network version (v0.15). The slot-naming algorithm — `package_name::snake_case(interface_segment)::field`, with non-`[A-Za-z0-9_]` mapped to `_` and `@version` stripped — has been stable, but verify against your installed toolchain rather than assuming a protocol version. +| `counter-contract` | `miden:counter-contract/counter-contract@0.1.0` | `count_map` | `counter_contract::counter_contract::count_map` | +| `auth-component-rpo-falcon512` | `miden:auth-component-rpo-falcon512/auth-component@0.1.0` | `owner_public_key` | `auth_component_rpo_falcon512::auth_component::owner_public_key` | +| `storage-example` | `miden:storage-example/foo@1.0.0` | `asset_qty_map` | `storage_example::foo::asset_qty_map` | + +The first two rows are the exact strings the compiler's own MockChain tests assert against, so use +them as the ground truth for the algorithm. + +Omitting the manifest is an error, not a fallback: a `#[component_storage]` struct with `#[storage]` +fields and no `miden-project.toml` fails with `` `#[component_storage]` with `#[storage]` fields +requires a `miden-project.toml` next to the crate's `Cargo.toml`: storage slot names derive from the +`[lib].namespace` interface segment. `` + +**Caveat (toolchain-version dependent)**: this naming is a property of the Rust SDK contract macros +in the `miden-base-macros` crate, which ships at `0.14.0` alongside `miden`, `miden-base`, +`miden-base-sys`, `miden-stdlib-sys` and `miden-sdk-alloc` (all `0.14.0`). The separate +compiler / `midenc` / `cargo-miden` workspace is `0.10.0`. Neither is the protocol/network +version: protocol, `miden-standards` and `miden-testing` are `0.16.0-rc.9`, `miden-client` is +`0.16.0-rc.5`, and the VM crates are `0.29.1`. See P20 for the full version matrix and the +toolchain skew. Verify slot names against your installed toolchain rather than assuming a protocol +version. ## P6: No-std Environment @@ -134,7 +305,18 @@ Storage slot names follow a strict pattern. Getting it wrong often returns the d All contract code must be `#![no_std]`. Forgetting this or using std types causes build failures. -**Required at the top of every contract file:** See any contract under `contracts/` in [project-template](https://github.com/0xMiden/project-template) for the correct pattern (`#![no_std]` + `#![feature(alloc_error_handler)]`). +**Required at the top of every contract file:** + +```rust +#![no_std] +#![feature(alloc_error_handler)] +``` + +Both lines appear before any code in the SDK examples — see +`compiler:sdk/v0.14.0:examples/counter-contract/src/lib.rs`, +`compiler:sdk/v0.14.0:examples/basic-wallet/src/lib.rs` and +`compiler:sdk/v0.14.0:examples/p2id-note/src/lib.rs`. Most of them lead with an explanatory +`// Do not link against libstd ...` comment first, so match the two attributes, not the first line. **For heap allocation (Vec, String, Box):** ```rust @@ -142,7 +324,11 @@ extern crate alloc; use alloc::vec::Vec; ``` -## P7: Rust SDK `Asset` Is Two Words (Key + Value) +**Toolchain**: contract crates pin nightly `2026-04-30` with target `wasm32-wasip2` (see +`compiler:sdk/v0.14.0:examples/counter-contract/rust-toolchain.toml`); the compiler/SDK MSRV is +1.97. `Cargo.toml` needs `edition = "2024"` and `crate-type = ["cdylib"]`. + +## P7: Rust SDK `Asset` Is Two Words (ID + Value) **Severity**: Medium — reconstructing an asset from raw `asset.inner[...]` offsets is wrong @@ -155,17 +341,28 @@ pub struct Asset { } ``` +The field is literally named `key`, but the word it holds is the **asset-ID word** at the protocol +layer (see P17). Construct with `Asset::new(key: impl Into, value: impl Into)`. + ```rust -// Reading the amount from a fungible asset -let amount = asset.value[0]; +// Preferred accessors — validated, and integer-ordered +let amount: AssetAmount = asset.amount(); // panics if non-fungible or out of range +let fungible: bool = asset.is_fungible(); -// Persisting or comparing the asset class -let asset_key = asset.key; +// Raw access when you need the words themselves +let raw_amount: Felt = asset.value[0]; // fungible amount lives here +let asset_id_word: Word = asset.key; // persist or compare the asset class ``` -Use `asset.key` and `asset.value` (or protocol helpers) rather than reconstructing an asset from raw `asset.inner[...]` offsets. +Use `asset.key` / `asset.value` (or the accessors above) rather than reconstructing an asset from raw `asset.inner[...]` offsets. -**SDK vs protocol `Asset`**: the two-word `{key, value}` form is the Rust SDK ABI type. At the protocol layer, `Asset` is an enum `{ Fungible, NonFungible }`, and the vault words are obtained via `to_key_word()` / `to_value_word()`. Reading the fungible amount from `value[0]` is correct on both sides. +**SDK vs protocol `Asset`**: the two-word `{key, value}` form is the Rust SDK ABI type. At the +protocol layer, `Asset` is an enum `{ Fungible(FungibleAsset), NonFungible(NonFungibleAsset) }` and +the vault words come from `Asset::to_id_word()` and `Asset::to_value_word()`. There is no +`to_key_word()` — that name does not exist anywhere in the protocol source. Related protocol +accessors: `Asset::id() -> AssetId`, `Asset::from_id_and_value(AssetId, Word)`, +`Asset::from_id_and_value_words(Word, Word)`, `Asset::as_elements() -> [Felt; 8]`. +`FungibleAsset::amount()` returns `AssetAmount`, not `u64`. ## P8: Build Recipients with `note::build_recipient` (no `Recipient::compute`) @@ -184,7 +381,13 @@ let recipient = note::build_recipient( ); ``` -`note::build_recipient` is the Rust SDK alias for the host function `miden::protocol::note::compute_and_store_recipient`, which computes and stores the recipient in one step. You can call either name. +`note::build_recipient(serial_num: Word, script_root: Word, storage: Vec) -> Recipient` is the +Rust SDK alias for the host function `miden::protocol::note::compute_and_store_recipient`, which +computes and stores the recipient in one step. You can call either name. + +**Storage cap**: note storage is limited to 1024 felts (`MAX_NOTE_STORAGE_ITEMS`). Both +`build_recipient` / `compute_and_store_recipient` and `note::compute_storage_commitment` assert on +it and panic with `note storage cannot contain more than 1024 items`. ## P9: P2ID Note Root — Prefer `script_root()`, Do Not Hardcode @@ -192,7 +395,11 @@ let recipient = note::build_recipient( Creating P2ID output notes requires the MAST root of the P2ID script. The root changes whenever the P2ID script or the assembler/hashing changes, so a hardcoded literal is fragile and unverifiable. -**Source of truth**: Use `P2idNote::script_root()` from `miden-standards` (returns a `NoteScriptRoot`, a `Word` newtype convertible via `.into()`). Derive the root from the dependency rather than embedding a literal, and re-derive after any dependency bump. +**Source of truth**: Use the associated function `P2idNote::script_root() -> NoteScriptRoot` from +`miden-standards` (`NoteScriptRoot` is a `Word` newtype). The same associated function exists on +`P2ideNote`, `SwapNote`, `PswapNote`, `MintNote` and `BurnNote`. From the client, both types are +re-exported as `miden_client::note::P2idNote` and `miden_client::note::NoteScriptRoot`. Derive the +root from the dependency rather than embedding a literal, and re-derive after any dependency bump. ```rust use miden_standards::note::P2idNote; @@ -219,33 +426,378 @@ fn p2id_note_root() -> Word { **Risk**: If miden-standards updates the P2ID script, any hardcoded digest becomes invalid and withdrawals silently fail. -**NoteType for P2ID**: P2ID output notes created in contract code are constructed with `NoteType::from(felt!(...))` — `felt!(0)` for private, `felt!(1)` for public (see P10). In v0.15 the kernel rejects any note type other than `0` (private) or `1` (public) with `ERR_NOTE_INVALID_TYPE`. See [miden-bank withdraw](https://github.com/0xMiden/tutorials/blob/a255af7959a441d9a027178631c666949b4af086/examples/miden-bank/contracts/bank-account/src/lib.rs) for the working pattern (the note type is read from the withdraw-request note's storage and forwarded through `NoteType::from(note_type)`). +**NoteType for P2ID**: P2ID output notes created in contract code are constructed with `NoteType::from(felt!(...))` — `felt!(0)` for private, `felt!(1)` for public (see P10). The kernel rejects any other note type with `ERR_NOTE_INVALID_TYPE` ("invalid note type"). ## P10: NoteType Variants Unavailable in Compiler SDK **Severity**: Critical -- wrong values panic at runtime, named variants cause compilation errors -Named enum variants (`NoteType::Private`, `NoteType::Public`) don't exist in contract code — the SDK `NoteType` is an unvalidated transparent `Felt` wrapper. Construct via `NoteType::from()`: +Named enum variants (`NoteType::Private`, `NoteType::Public`) don't exist in contract code — the guest-side SDK `NoteType` is an unvalidated `#[repr(transparent)]` wrapper around `Felt` with `From`, `From for Word` and `TryFrom`, and no named variants. Construct via `NoteType::from()`: | NoteType | Value | |----------|-------| | Private (default) | `NoteType::from(felt!(0))` | | Public | `NoteType::from(felt!(1))` | -**Note-type encoding**: the note type is 1-bit — `Private = 0` (the protocol default) and `Public = 1`. Only these two values exist; there is no `Encrypted` type. The SDK wrapper does no validation, so an out-of-range value (e.g. `felt!(2)` or `felt!(3)`) is not caught at compile time — the kernel rejects it at execution time with `ERR_NOTE_INVALID_TYPE` (it asserts `note_type <= 1`). +**Note-type encoding**: the note type is 1-bit — `Private = 0` (the protocol default) and `Public = 1`. Only these two values exist; there is no `Encrypted` type. The SDK wrapper does no validation, so an out-of-range value (e.g. `felt!(2)` or `felt!(3)`) is not caught at compile time — the kernel rejects it at execution time with `ERR_NOTE_INVALID_TYPE` (the output-note builder does `u32assert.err=ERR_NOTE_INVALID_TYPE u32lte.NOTE_TYPE_PUBLIC`). -See [miden-bank bank-account](https://github.com/0xMiden/tutorials/blob/a255af7959a441d9a027178631c666949b4af086/examples/miden-bank/contracts/bank-account/src/lib.rs) for `NoteType::from(note_type)` usage. +For a working conversion site, see `compiler:sdk/v0.14.0:examples/basic-wallet-tx-script/src/lib.rs`, +which turns a raw input felt into a note type with `note_type.into()` before calling the wallet's +`create_note`. ## P11: Note Scripts Cannot Call Native Account Functions **Severity**: High -- causes runtime failures -Note scripts cannot call `native_account::add_asset()` or other `native_account::` functions directly. The kernel's `authenticate_account_origin` check rejects these calls from a note context. Instead, note scripts must call an account component method, which then calls `native_account::add_asset()` internally. +Note scripts cannot call `native_account::add_asset()` or other `native_account::` functions +directly. The kernel's `authenticate_account_origin` check rejects these calls from a note context +(`pub proc account_add_asset` runs `exec.memory::assert_native_account` then +`exec.authenticate_account_origin`; `account_remove_asset` does the same). Instead, note scripts +must call an account component method, which then calls `native_account::add_asset()` internally. -See [miden-bank deposit-note](https://github.com/0xMiden/tutorials/blob/a255af7959a441d9a027178631c666949b4af086/examples/miden-bank/contracts/deposit-note/src/lib.rs) for the correct pattern: the note script declares the consuming account via `#[account(bank_account::Bank)] pub struct Wallet;` and, inside `#[note_script] fn run(self, _arg: Word, account: &mut Wallet)`, calls `account.deposit(depositor, asset)` on that wrapper. The `deposit()` component method then calls `native_account::add_asset()` internally. It is NOT a free `bank_account::deposit()` call. +The pattern, split across two pinned examples: + +```rust +// Note side — compiler:sdk/v0.14.0:examples/p2id-note/src/lib.rs +#[account(basic_wallet::BasicWallet)] +pub struct Wallet; + +#[note] +impl P2idNote { + #[note_script] + pub fn script(self, _arg: Word, account: &mut Wallet) { + for asset in active_note::get_initial_assets() { + account.receive_asset(asset); // component method, not a free function + } + } +} + +// Component side — compiler:sdk/v0.14.0:examples/basic-wallet/src/lib.rs +#[component] +trait BasicWallet { + #[account_procedure] + fn receive_asset(&mut self, asset: Asset); +} + +#[component] +impl BasicWallet for BasicWalletStorage { + fn receive_asset(&mut self, asset: Asset) { + self.add_asset(asset); // NativeAccount trait method, the idiomatic form + } +} +``` + +`self.add_asset(asset)` / `self.remove_asset(asset)` are `NativeAccount` trait methods +auto-implemented on the `#[component_storage]` struct; the free functions +`native_account::add_asset(asset)` / `native_account::remove_asset(asset)` are equivalent. + +The alternative to an `#[account(..)]` wrapper is the generated-bindings free-function form, used by +`compiler:sdk/v0.14.0:examples/counter-note/src/lib.rs`: + +```rust +use crate::bindings::miden::counter_contract::counter_contract; +counter_contract::increment_count(); +``` ## P12: Note Inputs Are Immutable After Creation **Severity**: Low -- causes incorrect architecture Note inputs (`active_note::get_storage()`) are baked at note creation time and cannot be modified after creation. Design note input layouts carefully before deployment. + +A `#[note]` struct **with fields** is auto-decoded from that storage: the macro generates a +`TryFrom<&[Felt]>` that decodes each field via `FromFeltRepr` and then calls `ensure_eof()`, so +extra trailing felts are a decode failure (`FeltReprError::TrailingData`), not ignored padding. A +zero-sized `#[note]` struct skips `get_storage()` entirely. Manual slicing is still available — +`compiler:sdk/v0.14.0:examples/p2ide-note/src/lib.rs` reads `active_note::get_storage()` +directly and asserts `inputs.len() == 4` — but the typed form in +`compiler:sdk/v0.14.0:examples/p2id-note/src/lib.rs` (`#[note] struct P2idNote { +target_account_id: AccountId }`) is the shape to prefer. + +## P13: Externally-Callable Methods Must Be Marked `#[account_procedure]` + +**Severity**: Critical — omitting it compiles clean and fails only when something calls the method + +A `#[component]` trait method is part of the account's **interface** only if it carries +`#[account_procedure]`. Without it the method still compiles and is still exported to WIT, but it is +not an account procedure, so any note script, transaction script, FPI call or sibling-component call +that targets it fails. There is no compile-time warning. + +```rust +#[component] +trait Bank { + #[account_procedure] + fn initialize(&mut self); + #[account_procedure] + fn deposit(&mut self, depositor: AccountId, deposit_asset: Asset); +} +``` + +Rules: + +- Placement is on the **trait declaration**, never on the `impl` block. The `impl` methods stay bare. +- No import is needed — the enclosing `#[component]` macro recognises the attribute. Applying it + outside a `#[component]` trait errors with `` `#[account_procedure]` must be applied to a method + inside a `#[component]` `trait` ``, and it takes no arguments. +- `#[account_procedure]` and `#[auth_script]` are **mutually exclusive within one component**: + `a component cannot combine #[auth_script] and #[account_procedure]`. +- Inherent (`impl BankStorage`) methods are not exported at all, and "exported to WIT" is not the + same thing as "is an account procedure". +- The `cargo miden new` scaffolding under `compiler:sdk/v0.14.0:extra/templates/` omits + `#[account_procedure]`, so freshly-generated code is wrong out of the box. Use + `compiler:sdk/v0.14.0:examples/counter-contract/src/lib.rs` and + `compiler:sdk/v0.14.0:examples/basic-wallet/src/lib.rs` as the reference instead. + +**MASM equivalent**: a hand-written or standards MASM component marks its exports with the +`@account_procedure` / `@auth_script` attributes — "a procedure is part of the component interface +if it has either the `@account_procedure` or `@auth_script` attributes". See +`protocol:v0.16.0-rc.9:crates/miden-standards/asm/standards/wallets/basic.masm`. + +## P14: Some Kernel Calls Are Legal Only in a Specific Runtime Context + +**Severity**: Critical — these compile anywhere and panic at execution time + +Three restrictions enforced by the kernel, not the type system: + +| Call | Allowed only from | Kernel enforcement | +|---|---|---| +| `output_note::create(tag, note_type, recipient)` | an account-component procedure | `exec.authenticate_account_origin` + `exec.memory::assert_native_account` | +| `native_account::{add_asset, remove_asset}` | an account-component procedure | `exec.memory::assert_native_account` + `exec.authenticate_account_origin` | +| `native_account::incr_nonce()` / `self.incr_nonce()` | the account's `#[auth_script]` authentication procedure | `exec.memory::assert_native_account` + `exec.assert_auth_procedure_origin` | + +All three are in `protocol:v0.16.0-rc.9:crates/miden-protocol/asm/kernels/transaction/lib/api.masm` +(`pub proc output_note_create`, `pub proc account_add_asset`, `pub proc account_incr_nonce`). + +Consequences: + +- A transaction script or note script that calls `output_note::create` directly compiles and then + fails at execution. Route it through an account-component method — `basic-wallet` exposes + `#[account_procedure] fn create_note(&mut self, tag: Tag, note_type: NoteType, recipient: + Recipient) -> NoteIdx` for exactly this reason. +- Calling `incr_nonce()` from an ordinary component method panics. Only the authentication + component's single `#[auth_script]` method may do it — see + `compiler:sdk/v0.14.0:examples/auth-component-no-auth/src/lib.rs`. + +**Auth components**: exactly one `#[auth_script]` method per `#[component]` trait, and a crate whose +`miden-project.toml` sets `[package.metadata.miden] project-kind = "authentication-component"` must +have exactly one (`authentication components require exactly one #[auth_script] method`); +`#[auth_script]` on a non-account-component target is rejected outright. + +## P15: Bindings That No Longer Exist + +**Severity**: High — a contract carried forward from an earlier SDK will not compile, or will compile against the wrong name + +| Gone | Use instead | +|---|---| +| `active_note::get_assets()` | `active_note::get_initial_assets() -> Vec` | +| `input_note::get_assets(idx)` | `input_note::get_initial_assets(idx)` | +| `input_note::get_assets_info(idx)` | `input_note::get_initial_assets_info(idx)` | +| `active_account::get_balance` / `get_initial_balance` | `active_account::get_asset(asset_key: Word) -> Word` (or `native_account::get_initial_asset(asset_key: Word) -> Word`), then read the amount out of the value word | +| `active_account::has_non_fungible_asset(asset)` | `active_account::has_asset(asset_id: Word) -> bool` | +| `faucet::create_fungible_asset` / `create_non_fungible_asset` / `has_callbacks`, and the whole `asset` module | build the `Asset` outside the transaction; only `faucet::mint(Asset)` and `faucet::burn(Asset)` remain | +| `AttachmentLocation` | `Option` from `find_attachment` | +| `output_note::set_attachment` | shape-specific setters (`set_word_attachment`, `set_array_attachment`) | + +The current `active_account` surface is `get_id() -> AccountId`, `get_nonce() -> Nonce`, +`compute_commitment() -> Word`, `get_code_commitment() -> Word`, `compute_storage_commitment() -> +Word`, `get_asset(Word) -> Word`, `has_asset(Word) -> bool`, `get_vault_root() -> Word`, +`get_num_procedures() -> u32`, `get_procedure_root(u32) -> Word`, `has_procedure(Word) -> bool` — +all also available on the `ActiveAccount` trait. + +Initial-state getters live on `native_account` as free functions: `get_initial_commitment()`, +`get_initial_storage_commitment()`, `get_initial_vault_root()`, `get_initial_asset(Word) -> Word`, +plus `compute_delta_commitment()` and `was_procedure_called(Word) -> bool`. + +## P16: Kernel Scalars Are Typed, Not `Felt` + +**Severity**: Medium — arithmetic and `Word` packing that assumed `Felt` no longer type-checks + +| Binding | Return type | +|---|---| +| `tx::get_block_number()` | `BlockNumber` | +| `tx::get_block_timestamp()` | `u32` (seconds) | +| `tx::get_num_input_notes()` / `get_num_output_notes()` | `u32` | +| `tx::get_expiration_block_delta()` | `u16` (and `update_expiration_block_delta(delta: u16)`) | +| `active_account::get_num_procedures()` | `u32` (and `get_procedure_root(index: u32)`) | +| `active_account::get_nonce()`, `native_account::incr_nonce()` | `Nonce` | +| `active_note::find_attachment(..)`, `output_note::find_attachment(..)` | `Option` | + +`BlockNumber` offers `try_from(Felt)`, `as_u32()`, `as_felt()`, `From` and integer comparison; +`as_u32()` **panics** rather than truncating if the value exceeds the u32 block-height range. +`Nonce` offers `as_u64()`, `as_felt()` and `From for Felt`. + +Packing them back into a `Word` needs an explicit conversion: + +```rust +let ref_block_num = tx::get_block_number(); +let final_nonce = self.incr_nonce(); +let w = Word::from([felt!(0), felt!(0), ref_block_num.into(), final_nonce.into()]); +``` + +## P17: `AssetId` at the Protocol Layer Is the Vault Key, Not the Asset Class + +**Severity**: High — a naive find-and-replace compiles and is silently wrong + +At the protocol layer the vault key type is `AssetId`: + +```rust +pub struct AssetId { + asset_class: AssetClass, // {suffix, prefix}; both zero for fungible assets + faucet_id: AccountId, + composition: AssetComposition, +} +``` + +Word layout: `[asset_class_suffix, asset_class_prefix, [faucet_id_suffix | reserved | composition], +faucet_id_prefix]`. The actual SMT key is `AssetId::hash() -> AssetIdHash`. + +`AssetClass` is a *component of* `AssetId` — it distinguishes assets issued by the same +faucet — not the asset id itself. Treating `AssetId` as the per-faucet class compiles and is +silently wrong. The vault-key accessors are `Asset::id()` and `Asset::to_id_word()`, and the client +re-exports `AssetId` (not `AssetClass`) from `miden_client::asset`. + +There is **no `AssetVaultKey` type** in either the protocol or the client — searching for one is a +dead end, and a type of that name in your code or in generated bindings is stale. The vault-key type +is `AssetId`, declared at +`protocol:v0.16.0-rc.9:crates/miden-protocol/src/asset/vault/asset_id.rs:42` and re-exported by the +client at `miden-client:v0.16.0-rc.5:crates/rust-client/src/lib.rs:195`. + +On the guest side nothing renamed: `miden::Asset` still has a field literally named `key`, and that +word is the asset-ID word (P7). + +## P18: `MAX_ASSETS_PER_NOTE` Is 16 + +**Severity**: Medium — a loop or note builder sized for a larger bound fails + +`pub const MAX_ASSETS_PER_NOTE: usize = 16;` (mirrored by `NoteAssets::MAX_NUM_ASSETS` and by the +kernel's `constants.masm`). Any code that assumed 64 assets per note — fixed-size buffers, batching +logic, test fixtures — needs resizing. + +## P19: A Transaction Summary Is Six Words (24 Felts) + +**Severity**: High — an auth procedure hashing a four-word layout compiles and fails at runtime + +`TransactionSummary::NUM_ELEMENTS` covers six words. The standards MASM matches with +`const TX_SUMMARY_NUM_ELEMENTS = 24` and six word-sized locals +(`SUMMARY_ACCOUNT_DELTA_LOC = 0`, `SUMMARY_INPUT_NOTES_LOC = 4`, `SUMMARY_OUTPUT_NOTES_LOC = 8`, +`SUMMARY_BLOCK_COMMITMENT_LOC = 12`, `SUMMARY_PARAMS_HEAD_LOC = 16`, +`SUMMARY_PARAMS_TAIL_LOC = 20`), and +`pub proc create_tx_summary(user_params: [felt; 7]) -> (word, word, word, word, word, word)`. + +Preimage order: + +```text +[ACCOUNT_DELTA_COMMITMENT, INPUT_NOTES_COMMITMENT, OUTPUT_NOTES_COMMITMENT, + BLOCK_COMMITMENT, [expiration_delta, user_param0..2], [user_param3..6]] +``` + +Sources: `protocol:v0.16.0-rc.9:crates/miden-protocol/src/transaction/tx_summary.rs` and +`protocol:v0.16.0-rc.9:crates/miden-standards/asm/standards/auth/mod.masm`. + +## P20: Version Pins Must Be Exact Pre-Release Strings + +**Severity**: High — a truncated requirement silently fails to resolve + +Cargo's default `^` requirement never matches a pre-release, so `miden = "0.14"`, +`cargo-miden = "0.10"`, `miden-protocol = "0.16"` all fail to resolve against `0.14.0` / +`0.10.0` / `0.16.0-rc.9`. Always write the full string: + +```toml +miden = "0.14.0" # guest SDK crate, in contract crates +cargo-miden = "0.10.0" # build tool +miden-protocol = "0.16.0-rc.9" +miden-standards = "0.16.0-rc.9" +miden-testing = "0.16.0-rc.9" +miden-client = "0.16.0-rc.5" +miden-assembly = "0.29.1" # also miden-core, miden-core-lib, + # miden-processor, miden-prover, miden-crypto, + # miden-mast-package +``` + +**MSRV split** — use the highest applicable: `miden-client` 1.96; protocol and VM 1.96.1; compiler +and contract SDK 1.97, plus the pinned nightly `2026-04-30` with target `wasm32-wasip2` for contract +crates. + +**Accepted toolchain skew.** The contract SDK / compiler line builds against +`miden-protocol = "=0.16.0-alpha.4"` and VM `0.25`, while the protocol/client line is `0.16.0-rc.9` +/ `0.29.1`. That is expected. The consequence: **one Cargo graph cannot hold both +`cargo-miden 0.10.0` and `miden-client 0.16.0-rc.5`** — `cargo-miden` pulls +`miden-protocol =0.16.0-alpha.4` (exact) through `midenc-compile` → `midenc-session`, while +`miden-client 0.16.0-rc.5` requires `miden-protocol 0.16.0-rc.9`; both land in the same `0.16` +compatibility range, so Cargo must pick one version and cannot satisfy both. Split the build tool +and the client into separate crates, or pin the whole stack to one line (the compiler's own +integration tests pin `miden-client = "0.16.0-alpha.1"` / `miden-testing = "0.16.0-alpha.2"`). + +## P21: `miden-project.toml` Requires `[lib] path` + +**Severity**: Medium — a missing key is a parse error, an unknown key is also a parse error + +`[lib]` and every `[[bin]]` target carry a **mandatory** `path` (`Span`, no `serde(default)`), +and both target structs are `deny_unknown_fields` — every other key (`kind`, `namespace`, `name`) is +optional. `kind` exists on `[lib]` only; `[[bin]]` targets are always executables. Accepted `kind` +spellings: `lib` / `library`, `kernel`, `account` / `account-component`, `note`, `tx-script` / +`transaction-script`; an executable kind on `[lib]` errors with `this is not a valid target type for +a library`. + +Full account-component manifest, matching +`compiler:sdk/v0.14.0:examples/counter-contract/miden-project.toml`: + +```toml +[package] +name = "counter-contract" +version = "0.1.0" + +[lib] +kind = "account-component" +namespace = "miden:counter-contract/counter-contract@0.1.0" +path = "src/lib.rs" # mandatory + +[dependencies] +miden-core = "*" +miden-protocol = "*" + +[package.metadata.miden] +supported-types = ["RegularAccountUpdatableCode"] +``` + +`[package.metadata]` is a free-form bag as far as the `miden-project` parser is concerned, but the +SDK macros read `[package.metadata.miden] project-kind` out of it and treat +`project-kind = "authentication-component"` as the switch that requires exactly one `#[auth_script]` +method (P14). Other observed values of `supported-types`: `"RegularAccountImmutableCode"`, and +`["FungibleFaucet", "NonFungibleFaucet"]` for faucets. + +Cross-component dependencies go in `miden-project.toml`'s `[dependencies]` — never in `Cargo.toml`, +which the macros read only for `[package] name` / `description`. The +`[package.metadata.miden.dependencies]..wit` key is an **optional override**; without it the +macro searches ``, `/wit` and `/target/generated-wit`. It becomes +mandatory only when the dependency points at a `.masp` file rather than a directory. + +## P22: MASM-Side Facts That Bite Rust SDK Developers + +**Severity**: Medium — relevant when hand-writing component MASM, or reading the standards / kernel MASM + +- **An undeclared `.masm` file is silently dropped.** A file is compiled only if its parent module + declares it with `mod` / `pub mod`. Module discovery is seeded exclusively from the root module's + `submodules()` and extended from each child's — there is no directory walk, so an undeclared file + is never opened. The *error* direction is a declaration with no matching `.masm` or + `/mod.masm` (`ParsingError::UndefinedSubmodule`; both present is + `AmbiguousSubmoduleLocation`), and a module reaching the linker without a parent declaration is + `LinkerError::UndeclaredSubmodule`. +- **Import syntax**: `use x -> y` is rejected — the parser says *import aliases use `as`; `->` is no + longer supported*. Item imports are `use {a, b} from path::to::module`, with per-item `as` aliases. + `pub use` is legal only for braced item imports — write `pub use {c} from a::b`, not + `pub use a::b::c`. Modules cannot be re-exported at all (use `pub mod`); wildcard and digest + imports are rejected. +- **`debug.*` decorators are gone.** `debug.stack.4`, `debug.mem`, `debug.local.0.2` and + `debug.adv_stack.4` are rejected by the parser. The replacement is the `miden::core::debug` + module (`miden-vm:v0.29.1:crates/lib/core/asm/debug.masm`), exporting `print_stack`, `print_mem`, + `print_mem_addr`, `print_mem_all`, `print_adv_stack`, `print_adv_stack_all`, `print_adv_map_all`, + `print_adv_map_item`. These are **ordinary procedure calls that print unconditionally**, + regardless of debug mode, and the ones taking stack inputs consume them — strip them from + production code. The advice-stack / advice-map printers additionally need host handlers + registered. +- **`.masl` is gone.** The artefact is a `Package` with extension `.masp` (magic `b"MASP\0"`); + `Library` and `KernelLibrary` were deleted. `Assembler::link_package(Arc, Linkage)` and + `Assembler::with_package(..)` are the linking entry points, kernels come in via + `Assembler::with_kernel(source_manager, Arc)`, and `assemble_library` / + `assemble_kernel` / `assemble_program` all return `Box`. diff --git a/skills/rust-sdk-source-guide/SKILL.md b/skills/rust-sdk-source-guide/SKILL.md index a323c7d..7bf532f 100644 --- a/skills/rust-sdk-source-guide/SKILL.md +++ b/skills/rust-sdk-source-guide/SKILL.md @@ -22,20 +22,22 @@ Rule of thumb: if the task involves more than one contract or a pattern not cove This is the single highest-leverage practice for AI-assisted Miden development. -**Build loop**: After every contract edit, run `cargo miden build --manifest-path contracts//Cargo.toml --release` (adjust the path to your project's contract layout). If your project has a build hook configured, it may do this automatically. If the build fails: +**Build loop**: After every contract edit, run `cargo miden build --manifest-path contracts//Cargo.toml --release` (adjust the path to your project's contract layout). `cargo miden` has three subcommands — `new`, `build`, `test` — and `build` forwards its arguments to `midenc`'s compiler parser, so `--manifest-path` and the profile flags (`--release` / `--debug`) are understood. The output is a `.masp` package written under `//`. If your project has a build hook configured, it may run this automatically. If the build fails: 1. Read the error message 2. Translate obvious SDK/compiler errors first: - `.as_u64()` -> `.as_canonical_u64()` - `Recipient::compute(...)` -> `note::build_recipient(...)` - `Value` -> `StorageValue` - `StorageMap` -> `StorageMap` + - `active_note::get_assets()` -> `active_note::get_initial_assets()` + - a trait method that a note or tx script cannot reach -> it is missing `#[account_procedure]` 3. Search the source repos for a working example of the pattern that failed 4. Adapt the working pattern to your use case 5. Rebuild -**Test loop**: Write tests alongside contracts. Run full repo checks with `cargo test` — or `make test` if the repo ships a GNU `Makefile` (the protocol repo's `test` target runs `cargo nextest run`); `cargo make test` requires a `Makefile.toml` configuring the `cargo-make` tool; among the referenced repos only the compiler ships one, so `cargo make test` works there but not in the protocol or rust-sdk repos (use `make test` / `cargo nextest run` for those). For a faster integration-only loop in the miden-bank workspace, use `cargo test -p integration --release` (it defines a package named `integration`). When tests fail: +**Test loop**: Write tests alongside contracts. Run full repo checks with `cargo test` — or `make test` if the repo ships a GNU `Makefile`. In the protocol repo the `test` target runs `cargo nextest run --profile default --cargo-profile test-dev --features concurrent,testing,std` (siblings: `test-dev`, `test-release` / `testr`, `test-docs`, `lint`, `clippy`, `format`, `doc`). `cargo make test` needs a `Makefile.toml` configuring `cargo-make`; among the referenced repos only the compiler ships one — protocol, the client and the VM each ship a GNU `Makefile` and no `Makefile.toml`, while the compiler ships a `Makefile.toml` and no GNU `Makefile`. When tests fail: 1. Check the error — is it a build error, a runtime assertion, or a proof failure? -2. For assertion failures: check felt arithmetic (modular wrapping) and storage slot naming +2. For assertion failures: check felt arithmetic (modular wrapping), storage slot naming, and whether the call is legal in its runtime context (`output_note::create` is account-context-only; `incr_nonce()` is auth-procedure-only) 3. For unexpected behavior: compare your code against the closest working example in source repos Never submit code that doesn't compile and pass tests. The verification loop is your quality guarantee. @@ -49,9 +51,10 @@ The basic skills (rust-sdk-patterns, rust-sdk-testing-patterns, miden-concepts, - Read source files only when you need a specific answer (progressive disclosure) - Look for working examples first, then adapt. Working code that compiles is more reliable than documentation. - When you find a useful pattern in source, extract just what you need — the exact API call, the exact data layout, the exact test setup. +- Start API questions at `compiler/sdk/sdk/MIGRATION.md`. Its `## Unreleased` section is the authoritative, hand-written list of what changed on the Rust contract surface, with before/after code for each break. `compiler/sdk/CHANGELOG.md` is the companion. For an exact signature, go to `compiler/sdk/base-sys/src/bindings/*.rs`. **Using sub-agents for exploration**: -- Launch an explore sub-agent with a specific question: "Find how P2ID output notes are created in the miden-bank example (tutorials/examples/miden-bank)" +- Launch an explore sub-agent with a specific question: "Find how the basic-wallet component creates an output note and moves an asset into it (`compiler/examples/basic-wallet/src/lib.rs`)" - The sub-agent searches, reads the relevant files, and returns a focused summary - Your main context stays clean for implementation @@ -70,89 +73,204 @@ When stuck at any stage: search the source repos for a similar working pattern. --- +## Which Version Is Which + +The three-way version skew is the most confusing thing about this stack. These are four independent +release lines: + +| Line | Crates | Version | MSRV | +|---|---|---|---| +| Contract SDK (guest) | `miden`, `miden-base`, `miden-base-macros`, `miden-base-sys`, `miden-stdlib-sys`, `miden-sdk-alloc` | `0.14.0` | 1.97 + nightly `2026-04-30`, target `wasm32-wasip2` | +| Compiler / build tool | compiler workspace, `midenc`, `cargo-miden` | `0.10.0` | 1.97 | +| Protocol | `miden-protocol`, `miden-standards`, `miden-testing`, `miden-tx`, `miden-tx-batch`, `miden-block-prover`, `miden-agglayer` | `0.16.0-rc.9` | 1.96.1 | +| Client | `miden-client` | `0.16.0-rc.5` | 1.96 | +| VM | `miden-assembly`, `miden-assembly-syntax`, `miden-core`, `miden-core-lib`, `miden-crypto`, `miden-mast-package`, `miden-processor`, `miden-project`, `miden-prover` | `0.29.1` | 1.96.1 | + +**Always write full pre-release strings.** Cargo's `^` requirement never matches a pre-release, so +`miden = "0.14"`, `cargo-miden = "0.10"` and `miden-protocol = "0.16"` all fail to resolve. Write +`miden = "0.14.0"`, `cargo-miden = "0.10.0"`, `miden-protocol = "0.16.0-rc.9"`, +`miden-standards = "0.16.0-rc.9"`, `miden-testing = "0.16.0-rc.9"`, +`miden-client = "0.16.0-rc.5"`, VM crates `"0.29.1"`. + +**Accepted skew, and the one thing it breaks.** The compiler workspace builds against +`miden-protocol = "=0.16.0-alpha.4"` and VM `0.25`, deliberately lagging the rest of the 0.16 line. +That is expected. The consequence is that **a single Cargo graph cannot hold both `cargo-miden +0.10.0` and `miden-client 0.16.0-rc.5`**: `cargo-miden` pulls `miden-protocol =0.16.0-alpha.4` +(exact) through `midenc-compile` → `midenc-session`, while `miden-client 0.16.0-rc.5` requires +`miden-protocol 0.16.0-rc.9`. Both requirements land in the same `0.16` compatibility range, so +Cargo must select one version and cannot satisfy both. Keep the build tool and the client in +separate crates, or pin the whole stack to the alpha line the way the compiler's own +`compiler/tests/integration-network/Cargo.toml` does (`miden-client = "0.16.0-alpha.1"`, +`miden-testing = "0.16.0-alpha.2"`, workspace `miden-protocol = "=0.16.0-alpha.4"`). + +--- + ## Miden Source Repository Map Clone these repos alongside your project for reference. Claude will explore them when needed for advanced patterns. ```bash # Required: protocol layer — standard note types and account components (crate: miden-protocol) -git clone --branch v0.15.3 https://github.com/0xMiden/protocol.git ../protocol +git clone --branch v0.16.0-rc.9 https://github.com/0xMiden/protocol.git ../protocol -# Required: client API for deployment and chain interaction -git clone --branch v0.15.2 https://github.com/0xMiden/rust-sdk.git ../rust-sdk +# Required: client API for deployment and chain interaction (crate: miden-client) +git clone --branch v0.16.0-rc.5 https://github.com/0xMiden/rust-sdk.git ../rust-sdk -# Required: the Rust SDK macros + compiler, released as v0.9.0 (moves the stack to VM v0.23 / -# protocol v0.15 and ships the guest SDK crate `miden` at 0.13, build tool `cargo-miden` at 0.9). -# Clone the release tag directly — no more git-rev pins. -git clone --branch v0.9.0 https://github.com/0xMiden/compiler.git ../compiler +# Required: the Rust SDK macros + compiler. The tags `sdk/v0.14.0`, `v0.10.0` and +# `templates/v0.32.0-rc.1` all point at the same commit (084877ef5, "release: compiler 0.10, +# sdk 0.14, templates 0.32"); the sdk/* tag names the guest SDK version you will depend on. +git clone --branch sdk/v0.14.0 https://github.com/0xMiden/compiler.git ../compiler -# Recommended: complete working banking app with advanced patterns in `examples/miden-bank`. -# Its v0.15 work lives on the migration branch (PR #204) until it lands on the default branch; -# pin the reviewed commit for reproducibility. -git clone --branch kbg/chore/v15-migration https://github.com/0xMiden/tutorials.git ../tutorials -git -C ../tutorials checkout a255af7959a441d9a027178631c666949b4af086 +# Optional: the VM / assembler / package format, when you need MASM or `.masp` internals +git clone --branch v0.29.1 https://github.com/0xMiden/miden-vm.git ../miden-vm ``` -**Note**: The compiler is **released as `v0.9.0`**. Don't conflate the version schemes: the network/protocol is **v0.15**, but the compiler workspace and the `cargo-miden` build tool are **`0.9.0`**, and the guest SDK crates (`miden`, `miden-base-macros`, `miden-base-sys`) are **`0.13.0`** — so contracts depend on `miden = "0.13"` and integration/tooling on `cargo-miden = "0.9"`. The compiler exposes `note::build_recipient` as an SDK-friendly alias for `compute_and_store_recipient`, so the API examples below resolve there. Use the pinned refs above — `compiler` `v0.9.0`, `protocol` `v0.15.3`, `rust-sdk` (client) `v0.15.2`, and `tutorials` pinned at commit `a255af7` on its v0.15 branch — rather than the default branches, since `tutorials`' default branch does not yet carry the v0.15 examples. `--depth 1` is intentionally omitted so you can check out other refs later if needed. - -### `compiler/` — The Rust-to-MASM Compiler - -Contains the SDK that powers `#[component]`, `#[note]`, and `#[tx_script]` macros. - -- **`examples/`** — 12 working examples covering core SDK patterns: account components, note scripts, transaction scripts, authentication components (NoAuth, RPO Falcon512), wallets, and storage. These are the most reliable reference for "how to write X" questions. Note: there is no faucet example here — for faucet reference, use `crates/miden-standards/src/account/faucets/fungible/mod.rs` (the `FungibleFaucet` component) in the protocol repo, or the compiler's `tests/integration/src/sdk/base/faucet.rs` faucet binding test. - -**WARNING**: Stay in `examples/` only. Do NOT explore compiler internals (`sdk/`, `codegen/`, etc.) — they are implementation details that will confuse the agent and lead to incorrect code. - -**Explore when**: Writing any new contract type, finding working code examples for patterns not covered by skills. +`--depth 1` is intentionally omitted so you can check out other refs later if needed. + +### `compiler/` — The Rust-to-MASM Compiler and the Guest SDK + +Contains the SDK that powers the `#[component_storage]`, `#[component]`, `#[account_procedure]`, `#[auth_script]`, `#[account]`, `#[note]`, `#[note_script]` and `#[tx_script]` macros. + +- **`compiler/examples/`** — exactly 12 working example projects, and the most reliable reference for + "how do I write X": + + | Example | Shows | + |---|---| + | `compiler/examples/counter-contract/` | account component with `StorageMap`, `#[account_procedure]`, full `miden-project.toml` | + | `compiler/examples/basic-wallet/` | asset in/out, `output_note::create` wrapped as an account procedure | + | `compiler/examples/storage-example/` | `StorageValue` + `StorageMap`, `miden::generate!()` + `bindings::export!` over a hand-written WIT interface | + | `compiler/examples/auth-component-no-auth/` | minimal `#[auth_script]` auth component, `incr_nonce()` | + | `compiler/examples/auth-component-rpo-falcon512/` | signature-checking auth component, transaction-summary hashing | + | `compiler/examples/p2id-note/` | typed `#[note]` struct decoded from note storage, `#[account(basic_wallet::BasicWallet)]` wrapper | + | `compiler/examples/p2ide-note/` | manual `active_note::get_storage()` parsing, `BlockNumber` timelock comparison | + | `compiler/examples/counter-note/` | note script calling a component through generated bindings, with no `#[account(..)]` wrapper | + | `compiler/examples/basic-wallet-tx-script/` | `#[tx_script]`, advice-provider input loading, calling wallet procedures | + | `compiler/examples/collatz/`, `compiler/examples/fibonacci/`, `compiler/examples/is-prime/` | plain compute programs, no account context | + + There is no faucet example here. For faucet reference use + `protocol/crates/miden-standards/src/account/faucets/fungible/mod.rs` (the `FungibleFaucet` + component) or the compiler's own `compiler/tests/integration/src/sdk/base/faucet.rs` binding test. + +- **`compiler/sdk/`** — the guest SDK, and at v0.16 the single most authoritative API reference. Whitelisted + for exploration: + - `compiler/sdk/sdk/MIGRATION.md` — start here; the `## Unreleased` section is the v0.16 contract-surface + change list with before/after code + - `compiler/sdk/CHANGELOG.md` + - `compiler/sdk/base-sys/src/bindings/` — the exact signature of every kernel binding + (`active_account.rs`, `native_account.rs`, `active_note.rs`, `input_note.rs`, `output_note.rs`, + `note.rs`, `faucet.rs`, `tx.rs`, `types.rs`) + - `compiler/sdk/base/src/types/storage.rs` — `StorageValue` / `StorageMap` / `WordKey` / `WordValue` + - `compiler/sdk/base-macros/src/` — macro behaviour and, importantly, the exact error messages + (`component_macro/mod.rs`, `component_macro/storage.rs`, `component_macro/sibling.rs`, + `foreign_account.rs`, `note.rs`, `script.rs`, `wit_world.rs`) + +- **`compiler/tests/integration-network/src/mockchain/`** — end-to-end multi-contract MockChain flows + (counter contract under three auth components, FPI in many shapes, asset transfer), including + the live storage-slot-name strings in `compiler/tests/integration-network/src/mockchain/support/helpers.rs`. + +**WARNING**: Do NOT explore the compiler's own internals — `compiler/codegen/`, `compiler/hir/`, `compiler/hir-analysis/`, `compiler/hir-transform/`, `compiler/frontend/`, `compiler/midenc-compile/`, `compiler/midenc-session/` — they are implementation details that will confuse the agent and lead to incorrect code. +The one narrow exception is `compiler/frontend/wasm/src/component/` when you need the exact wording of a +call-boundary (16-felt) diagnostic. + +**Explore when**: Writing any new contract type, checking an exact binding signature, or finding working code for a pattern not covered by skills. ### `protocol/` — Protocol Layer and Standard Library -The protocol repo (`github.com/0xMiden/protocol`; primary crate `miden-protocol`). Contains the protocol specification, standard components, and standard note types. - -- **`crates/miden-standards/`** — Standard note types (P2ID, P2IDE, SWAP, PSWAP, BURN, MINT) and standard account components (BasicWallet, FungibleFaucet, authentication components). Explore to understand note flow patterns and data layouts. -- **`crates/miden-protocol/asm/kernels/transaction/`** — The MASM transaction kernel. Every Rust SDK function (e.g., `native_account::add_asset`, `output_note::create`, `faucet::mint`) maps to a procedure defined here. Start with `api.masm` to find the procedure signature and stack contract, then read the implementation in `lib/` (e.g., `lib/output_note.masm`, `lib/account.masm`, `lib/epilogue.masm`). Useful for understanding exactly what happens under the hood -- for example, whether a function touches the vault, what the conservation check compares, or how note assets are tracked. -- **`crates/miden-tx/`** — Rust execution engine (executor, prover, host). Orchestrates transaction execution but rarely needed for understanding contract behavior. Explore only if debugging execution infrastructure or host-level behavior. -- **`crates/miden-testing/`** — MockChain implementation internals. Explore when you need to understand testing infrastructure beyond what the rust-sdk-testing-patterns skill covers. - -**Note**: Standard components (BasicWallet, etc.) are MASM-only and not callable from Rust SDK (see [compiler#936](https://github.com/0xMiden/compiler/issues/936)). Explore miden-standards to understand note flows and data layouts, not for finding callable Rust APIs. +The protocol repo (`github.com/0xMiden/protocol`; primary crate `miden-protocol`). Contains the protocol specification, standard components, and standard note types. Workspace crates: `miden-agglayer`, `miden-block-prover`, `miden-protocol`, `miden-protocol-build-utils`, `miden-standards`, `miden-testing`, `miden-tx`, `miden-tx-batch`. + +- **`protocol/crates/miden-standards/`** — Standard note types (`P2idNote`, `P2ideNote`, `SwapNote`, + `PswapNote`, `BurnNote`, `MintNote` under `protocol/crates/miden-standards/src/note/`) and standard account + components (BasicWallet, FungibleFaucet, authentication components). Standard notes are built with + typed `bon` builders — e.g. + `P2idNote::builder().sender(..).target(..).serial_number(..).asset(..).build()?` — and convert via + `impl From for Note`; there is no `XNote::create(..)`. Each type also exposes the + associated function `script_root() -> NoteScriptRoot`. +- **`protocol/crates/miden-protocol/asm/`** — the MASM. Four things live here, and the split matters: + - `protocol/crates/miden-protocol/asm/kernels/transaction/lib/api.masm` — **the syscall surface**. This + directory contains `api.masm` and nothing else. Start here for a procedure's signature, stack + contract, and its context assertions (`exec.memory::assert_native_account`, + `exec.authenticate_account_origin`, `exec.assert_auth_procedure_origin`). The kernel binaries + are `protocol/crates/miden-protocol/asm/kernels/transaction/bin/main.masm` and + `protocol/crates/miden-protocol/asm/kernels/transaction/bin/tx_script_main.masm`. + - `protocol/crates/miden-protocol/asm/kernels/transaction-core/src/` — **the implementations**: + `mod.masm`, `account.masm`, `account_update.masm`, `asset.masm`, `asset_vault.masm`, + `callbacks.masm`, `constants.masm`, `epilogue.masm`, `faucet.masm`, `fungible_asset.masm`, + `input_note.masm`, `link_map.masm`, `memory.masm`, `non_fungible_asset.masm`, `note.masm`, + `output_note.masm`, `prologue.masm`, `tx.masm`. + - `protocol/crates/miden-protocol/asm/protocol/src/` — the userspace `miden::protocol::*` modules that the + Rust SDK bindings actually map onto: `active_account.masm`, `active_note.masm`, + `native_account.masm`, `note.masm`, `output_note.masm`, `input_note.masm`, `faucet.masm`, + `tx.masm`, `asset.masm`, `auth.masm`, `account_id.masm`, `kernel_proc_offsets.masm`, + `types.masm`, `constants.masm`, `mod.masm`. + - `protocol/crates/miden-protocol/asm/protocol_utils/src/` — shared helpers (`account_id.masm`, + `asset.masm`, `constants.masm`, `mem.masm`, `note.masm`, `types.masm`, `mod.masm`). + + The batch kernel is separate: `protocol/crates/miden-protocol/asm/kernels/batch/src/main.masm`. +- **`protocol/crates/miden-tx/`** — Rust execution engine (executor, prover, host). Orchestrates transaction execution but rarely needed for understanding contract behavior. Explore only if debugging execution infrastructure or host-level behavior. +- **`protocol/crates/miden-testing/`** — MockChain implementation internals: + `protocol/crates/miden-testing/src/mock_chain/chain.rs` (`MockChain`), + `protocol/crates/miden-testing/src/mock_chain/chain_builder.rs` (`MockChainBuilder`), + `protocol/crates/miden-testing/src/mock_transaction/builder.rs` (`MockTransactionBuilder`). + `protocol/crates/miden-testing/src/kernel_tests/tx/` is the + canonical worked usage of the rc.6 API against a MockChain. + +**Note on standard components**: `miden-standards` ships them as MASM +(`protocol/crates/miden-standards/asm/standards/wallets/basic.masm`, +`protocol/crates/miden-standards/asm/standards/notes/p2id.masm`, +`protocol/crates/miden-standards/asm/components/auth/singlesig/singlesig.masm`). A MASM +component participates in the account interface by annotating its exports `@account_procedure` or +`@auth_script`. Separately, the compiler now ships a **Rust** `basic-wallet` account component in +`compiler/examples/basic-wallet/`, and `compiler/examples/p2id-note/`, `compiler/examples/p2ide-note/` and +`compiler/examples/basic-wallet-tx-script/` call it through `#[account(basic_wallet::BasicWallet)]` — so a +Rust-compiled wallet component is callable from Rust. Explore `miden-standards` for note flows, +data layouts, and the canonical MASM shape. **Explore when**: Understanding note flows, P2ID/SWAP/faucet data layouts, or what SDK functions actually do under the hood (via the kernel MASM). -### `rust-sdk/` — Client Library +### `rust-sdk/` — Client Library (crate `miden-client`) -The client repo (`github.com/0xMiden/rust-sdk`). Contains the Rust API for deploying contracts and interacting with the Miden network. +The client repo, whose manifest declares `repository = "https://github.com/0xMiden/rust-sdk"` at the +pin; the published crate is `miden-client`, and the library source lives in `rust-sdk/crates/rust-client/`. - Rust client for building transactions, syncing state, managing accounts and notes -- CLI tool source code for reference on client usage patterns +- Re-exports the protocol types you need at the boundary, e.g. `miden_client::note::P2idNote`, + `miden_client::note::NoteScriptRoot`, `miden_client::asset::AssetId` +- `rust-sdk/bin/miden-cli/` is CLI tool source, useful as a reference for client usage patterns **Explore when**: Deploying contracts to testnet, submitting transactions, syncing state, managing notes on-chain. -### `tutorials/examples/miden-bank/` — Working Example Application - -A complete banking application built with the Rust SDK, located at `examples/miden-bank/` inside the cloned tutorials repo. Demonstrates advanced patterns that go beyond the basic skills. +### `miden-vm/` — VM, Assembler, and Package Format -- Multiple contract types working together (account, deposit note, withdraw note, tx script) -- Advanced patterns: `StorageMap` + `StorageValue` composition, felt arithmetic safety, cross-component calls, P2ID output note creation from within contracts -- Multi-step integration tests with output note verification +Only needed for MASM or artefact-level questions, but authoritative for them: -**Explore when**: Building multi-contract applications, understanding how pieces fit together, seeing a complete working app end-to-end. +- `miden-vm/crates/project/src/ast/target.rs` — the `miden-project.toml` schema (`[lib]` / `[[bin]]` + targets, mandatory `path`, `deny_unknown_fields`) +- `miden-vm/crates/mast-package/src/package/` — the `.masp` package format (`Package`, magic `b"MASP\0"`) +- `miden-vm/crates/assembly/src/assembler.rs` — `Assembler::link_package` / `with_package` / `with_kernel`; + `assemble_library` / `assemble_kernel` / `assemble_program` all return `Box`. `.masl`, + `Library` and `KernelLibrary` no longer exist. +- `miden-vm/crates/lib/core/asm/debug.masm` — the `miden::core::debug` printers that replaced the removed + `debug.*` decorators +- `miden-vm/docs/src/user_docs/assembly/code_organization.md` — module tree, `mod` / `pub mod` declarations, + and the `use {a, b} from path` / `pub use {a} from path` import syntax --- ## What to Explore for Each Contract Type -| Building This | Explore These Repos | What to Look For | +| Building This | Explore These Paths | What to Look For | |---|---|---| -| Account component with storage | `compiler/` examples, `tutorials/examples/miden-bank/` contracts | `StorageMap` / `StorageValue` patterns, pub method signatures | -| Note script | `compiler/` examples, `tutorials/examples/miden-bank/` contracts | `#[note_script]` pattern, cross-component calls, note storage parsing | -| Transaction script | `compiler/` examples, `tutorials/examples/miden-bank/` contracts | `#[tx_script]` pattern, Account binding import | -| Authentication component | `compiler/` examples | Auth component patterns (NoAuth, RPO Falcon512) | -| Faucet (token minting) | `protocol/` standards (`crates/miden-standards/src/account/faucets/fungible/mod.rs`), `compiler/` faucet binding test (`tests/integration/src/sdk/base/faucet.rs`) | `FungibleFaucet` component, `FungibleFaucet::builder()`, mint/burn pattern | -| P2ID output notes | `tutorials/examples/miden-bank/` contracts, `protocol/` standards (data layouts) | `note::build_recipient`, script root, `output_note` creation | -| Swap notes | `protocol/` standards (data layouts) | SwapNote data layout, tag construction, payback flow | -| Multi-step tests | `tutorials/examples/miden-bank/` integration tests | Init → operate → verify flow, output note verification | -| Client deployment | `rust-sdk/` | TransactionRequestBuilder, sync, submit patterns | -| SDK function internals | `protocol/` kernel (`crates/miden-protocol/asm/kernels/transaction/`) | `api.masm` for procedure signatures, `lib/*.masm` for implementations | +| Account component with storage | `compiler/examples/counter-contract/`, `compiler/examples/storage-example/` | `StorageMap` / `StorageValue` patterns, `#[account_procedure]` on the trait, `miden-project.toml` shape | +| Note script | `compiler/examples/p2id-note/`, `compiler/examples/p2ide-note/`, `compiler/examples/counter-note/` | `#[note]` + `#[note_script]`, typed vs manual note-storage parsing, cross-component calls | +| Transaction script | `compiler/examples/basic-wallet-tx-script/` | `#[tx_script]`, `#[account(..)]` wrapper, advice-provider inputs | +| Authentication component | `compiler/examples/auth-component-no-auth/`, `compiler/examples/auth-component-rpo-falcon512/` | exactly one `#[auth_script]`, `project-kind = "authentication-component"`, `incr_nonce()`, tx-summary hashing | +| Faucet (token minting) | `protocol/crates/miden-standards/src/account/faucets/fungible/mod.rs`, `compiler/tests/integration/src/sdk/base/faucet.rs` | `FungibleFaucet::builder()`, `faucet::mint` / `faucet::burn` bindings, `supported-types = ["FungibleFaucet", "NonFungibleFaucet"]` | +| P2ID output notes | `compiler/examples/basic-wallet/src/lib.rs`, `protocol/crates/miden-standards/src/note/p2id.rs` | `note::build_recipient`, `P2idNote::script_root()`, `output_note::create` wrapped as an account procedure | +| Swap notes | `protocol/crates/miden-standards/src/note/swap.rs` | SwapNote data layout, tag construction, payback flow | +| Multi-step / multi-contract tests | `compiler/tests/integration-network/src/mockchain/`, `protocol/crates/miden-testing/src/kernel_tests/tx/` | MockChain setup, init → operate → verify flow, output-note verification, storage-slot names | +| Client deployment | `rust-sdk/crates/rust-client/` | TransactionRequestBuilder, sync, submit patterns | +| SDK binding signatures | `compiler/sdk/base-sys/src/bindings/*.rs`, `compiler/sdk/sdk/MIGRATION.md` | exact Rust signature and return type of every kernel binding | +| SDK function internals | `protocol/crates/miden-protocol/asm/kernels/transaction/lib/api.masm` → `protocol/crates/miden-protocol/asm/kernels/transaction-core/src/*.masm` → `protocol/crates/miden-protocol/asm/protocol/src/*.masm` | `api.masm` for signatures + context assertions, `protocol/crates/miden-protocol/asm/kernels/transaction-core/src/` for implementations, `protocol/crates/miden-protocol/asm/protocol/src/` for the userspace wrappers the bindings call | --- @@ -161,22 +279,54 @@ A complete banking application built with the Rust SDK, located at `examples/mid These patterns go beyond what the basic skills cover. For each, the source repos contain working implementations. ### Multi-Component Accounts -Accounts can include standard components (BasicWallet, authentication) alongside custom logic at account creation time. Standard components are MASM-only (not callable from Rust), but they are composed into accounts via the testing/deployment infrastructure. The `compiler/` examples show how to compose accounts with multiple components. +Accounts compose several components at creation time: custom logic plus an authentication component, and optionally a standard component. Every account needs exactly one authentication component, whose single `#[auth_script]` procedure is the only place `incr_nonce()` may be called. `compiler/tests/integration-network/src/mockchain/counter/` builds the same counter account against three different auth components and is the clearest worked example. Standard components can be MASM (`miden-standards`) or Rust (`compiler/examples/basic-wallet/`); in both cases the interface is defined by `@account_procedure` / `#[account_procedure]` annotations on the exported procedures. ### Output Note Creation from Contracts -Create output notes (like P2ID) from within contract code. Requires building a recipient with `note::build_recipient(serial_num, script_root, storage)` and then using `output_note::create(...)`. The `tutorials/examples/miden-bank/` withdraw pattern demonstrates this end-to-end. +Create output notes (like P2ID) from within contract code: build a recipient with `note::build_recipient(serial_num, script_root, storage)`, call `output_note::create(tag, note_type, recipient)` for the index, then move assets in with `native_account::remove_asset(asset)` + `output_note::add_asset(asset, note_idx)`. + +Both `output_note::create` and `native_account::remove_asset` are **account-context-only at +runtime**, so the whole sequence has to live inside an account-component procedure. A tx script or +note script that calls them directly compiles and then fails during execution. +`compiler/examples/basic-wallet/src/lib.rs` is the reference: it exposes +`#[account_procedure] fn create_note(..) -> NoteIdx` and +`#[account_procedure] fn move_asset_to_note(asset, note_idx)`, and +`compiler/examples/basic-wallet-tx-script/src/lib.rs` drives them from the script side. ### Note Storage Protocol -A note's storage is exposed to its `#[note_script]` as a `Vec` via `active_note::get_storage()`; the script reads and parses the items it needs by index. In `tutorials/examples/miden-bank/` the note structs are markers (not auto-populated from storage) and the script slices explicitly — e.g. the withdraw-request note asserts `storage.len() == 14`, then reconstructs the asset, serial number, tag, and note type from the felts. Attached assets are separate and are read with `active_note::get_assets()`. +A note's storage is exposed to its `#[note_script]` as a `Vec` via `active_note::get_storage()`. Two styles: + +- **Typed (preferred)** — give the `#[note]` struct fields and the macro auto-decodes them: it + generates a `TryFrom<&[Felt]>` that reads each field via `FromFeltRepr` and then calls + `ensure_eof()`, so trailing felts are a decode error rather than ignored padding. A zero-sized + `#[note]` struct skips `get_storage()` entirely. See `compiler/examples/p2id-note/src/lib.rs` + (`#[note] struct P2idNote { target_account_id: AccountId }`). +- **Manual** — read `active_note::get_storage()` and index it yourself, asserting the length. See + `compiler/examples/p2ide-note/src/lib.rs`, which asserts `inputs.len() == 4` and then builds + `AccountId` and `BlockNumber` values out of the felts. + +Attached assets are separate from storage and are read with `active_note::get_initial_assets()`. ### Atomic Swaps -The standard SwapNote in `protocol/` (`crates/miden-standards/src/note/swap.rs`) creates a payback P2ID note automatically when consumed. Explore the SwapNote builder to understand tag construction, storage layout, and the payback mechanism. +The standard SwapNote in `protocol/crates/miden-standards/src/note/swap.rs` creates a payback P2ID note automatically when consumed. Explore the `SwapNote` `bon` builder and `SwapNote::script_root()` to understand tag construction, storage layout, and the payback mechanism. ### Account Initialization -Use `#[tx_script]` to initialize accounts before they accept operations. The `tutorials/examples/miden-bank/` init-tx-script calls `account.initialize()` to set an initialization flag, which is checked before every operation. +Use `#[tx_script]` to run setup or admin operations against an account before or between note flows. The signature is validated by the macro: at most two parameters, the first literally typed `Word`, the second a reference to an `#[account(...)]` wrapper type. `compiler/examples/basic-wallet-tx-script/src/lib.rs` is the reference — `fn run(arg: Word, account: &mut Wallet)`, loading its inputs from the advice provider and then calling account procedures. ### Token Creation (Faucets) -Faucet accounts mint and burn tokens. The `protocol/` `FungibleFaucet` standard component (`crates/miden-standards/src/account/faucets/fungible/mod.rs`) shows how to create and manage fungible tokens; construct it via `FungibleFaucet::builder().name(..).symbol(..).decimals(..).max_supply(..).build()?`. There is no faucet example in `compiler/examples/`; for an SDK-level faucet binding reference use the compiler's `tests/integration/src/sdk/base/faucet.rs`. +Faucet accounts mint and burn tokens. The `FungibleFaucet` standard component +(`protocol/crates/miden-standards/src/account/faucets/fungible/mod.rs`) is built with +`FungibleFaucet::builder().name(..).symbol(..).decimals(..).max_supply(..).build()?`, where the +required setters take `TokenName`, `TokenSymbol`, `u8` and `AssetAmount`, `build()` returns +`Result`, and `MAX_DECIMALS = 12`. Optional setters: +`token_supply` (`AssetAmount`), `description`, `logo_uri`, `external_link`, +`is_description_mutable`, `is_logo_uri_mutable`, `is_external_link_mutable`, +`is_max_supply_mutable`. + +On the contract side only `faucet::mint(Asset)` and `faucet::burn(Asset)` exist — in-transaction +asset construction was removed, so build the `Asset` outside the transaction. There is no faucet +example in `compiler/examples/`; use `compiler/tests/integration/src/sdk/base/faucet.rs`, a +compile-only binding test (it ends at `test.compile_package()`) that also shows the faucet manifest +(`supported-types = ["FungibleFaucet", "NonFungibleFaucet"]`). ### P2ID with Expiration (P2IDE) -Send assets with a deadline — the sender can reclaim after the block height passes. The `compiler/` p2ide-note example and `protocol/` P2IDE standard (`crates/miden-standards/src/note/p2ide.rs`) show the timelock pattern. +Send assets with a deadline — the sender can reclaim after the block height passes. `compiler/examples/p2ide-note/src/lib.rs` and `protocol/crates/miden-standards/src/note/p2ide.rs` show the timelock pattern. Note that the example works in typed `BlockNumber` values (`BlockNumber::try_from(inputs[n]).unwrap()`, then plain `>=` comparison), not raw felts, because `BlockNumber` orders as an integer. diff --git a/skills/rust-sdk-testing-patterns/SKILL.md b/skills/rust-sdk-testing-patterns/SKILL.md index ed94b30..aea6bec 100644 --- a/skills/rust-sdk-testing-patterns/SKILL.md +++ b/skills/rust-sdk-testing-patterns/SKILL.md @@ -1,43 +1,64 @@ --- name: rust-sdk-testing-patterns -description: Guide to testing Miden smart contracts with MockChain (Miden v0.15). Covers test setup, contract building, account/note creation, transaction execution, storage verification, faucet setup, output note verification, block numbering, multi-transaction tests, and asset-bearing notes. Use when writing, editing, or debugging Miden integration tests. +description: Guide to testing Miden smart contracts with MockChain. Covers test setup, contract building, account/note creation, transaction execution, storage verification, faucet setup, output note verification, block numbering, multi-transaction tests, and asset-bearing notes. Use when writing, editing, or debugging Miden integration tests. --- # Miden Testing Patterns (MockChain) -These patterns target Miden **v0.15** (`miden-protocol`/`miden-standards`/`miden-testing` 0.15.x). +These patterns target `miden-protocol` / `miden-standards` / `miden-testing` `0.16.0-rc.9` with +`miden-client` `0.16.0-rc.5`. Write the full pre-release strings — Cargo does not match a +pre-release against a plain `"0.16"` requirement. -The **authoritative working example** is the `miden-bank` tutorial (`examples/miden-bank/integration/tests/{init_test,deposit_test,withdraw_test}.rs` plus `integration/src/helpers.rs` in [miden-bank](https://github.com/0xMiden/tutorials/tree/a255af7959a441d9a027178631c666949b4af086/examples/miden-bank/integration)). The `project-template` counter contract is another working reference. Mirror them for the patterns below. +`MockChain` and its builders live in `miden-testing`, which is also re-exported as +`miden_client::testing` behind the client's optional `testing` feature. A test crate must either +enable that feature or depend on `miden-testing` directly. -## Test File Setup +The canonical worked examples are in the protocol repo itself: +`crates/miden-testing/src/kernel_tests/tx/test_note.rs` for the end-to-end MockChain flow, and +`crates/miden-testing/src/mock_chain/{chain,chain_builder}.rs` for the full builder surface. -Tests go in `integration/tests/`. All tests are async and use MockChain for local execution without a network. +## Test File Setup -See [miden-bank deposit_test.rs](https://github.com/0xMiden/tutorials/blob/a255af7959a441d9a027178631c666949b4af086/examples/miden-bank/integration/tests/deposit_test.rs) for a complete working test covering imports, MockChain setup, contract building, account creation with storage, note creation, transaction execution, and storage verification. The v0.15 imports it relies on are: +All tests are async and use MockChain for local execution without a network. ```rust use miden_client::{ - account::{component::{InitStorageData, StorageValueName}, StorageSlotName}, + account::{ + component::{InitStorageData, StorageValueName}, + AccountBuilder, AccountComponent, AccountType, StorageSlotName, + }, auth::AuthSchemeId, note::NoteAssets, transaction::RawOutputNote, Felt, Word, }; use miden_client::asset::{Asset, FungibleAsset}; -use miden_testing::{Auth, MockChain}; +use miden_testing::{AccountState, Auth, MockChain}; ``` +`StorageValueName` lives under `account::component`, never under `account::` directly. + ## Step-by-Step Test Pattern ### 1. Initialize MockChain Builder -Start from `let mut builder = MockChain::builder();` (see `deposit_test.rs` in [miden-bank](https://github.com/0xMiden/tutorials/blob/a255af7959a441d9a027178631c666949b4af086/examples/miden-bank/integration/tests/deposit_test.rs)). +`let mut builder = MockChain::builder();` ### 2. Create Sender/Wallet Accounts -For a bare wallet use `builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthSchemeId::Falcon512Poseidon2 })`. For wallets with pre-funded assets, use `builder.add_existing_wallet_with_assets(Auth::BasicAuth { auth_scheme: AuthSchemeId::Falcon512Poseidon2 }, [FungibleAsset::new(faucet.id(), 100)?.into()])` (see `deposit_test.rs`). +For a bare wallet, `builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthSchemeId::Falcon512Poseidon2 })`. +For a pre-funded one, `builder.add_existing_wallet_with_assets(auth, [FungibleAsset::new(faucet.id(), 100)?.into()])`. +Both return `anyhow::Result`. -> Auth-scheme naming: `miden_client::auth` re-exports the **same** protocol enum under **two** names — `AuthScheme` (the protocol name) and `AuthSchemeId` (an alias; this is the name the canonical tutorials use). Both compile; the field is `Auth::BasicAuth { auth_scheme }`. The variant `Falcon512Poseidon2` is the same on both. The examples here use `AuthSchemeId::Falcon512Poseidon2` to match the tutorials. +`Auth` also offers `Multisig`, `GuardedMultisig`, `MultisigSmart`, `IncrNonce`, `Noop`, +`Conditional` and `NetworkAccount` variants, plus the shorthands `Auth::default()`, +`Auth::basic_falcon()` and `Auth::basic_ecdsa()`. + +> Auth-scheme naming: `miden_client::auth` re-exports the **same** protocol enum under two names — +> `AuthScheme` (the protocol name) and `AuthSchemeId` (an alias). Both compile; the field is +> `Auth::BasicAuth { auth_scheme }`. The enum is `#[non_exhaustive]` with `Falcon512Poseidon2 = 2` +> and `EcdsaK256Keccak = 1`, and the constants `RPO_FALCON_SCHEME_ID` / +> `ECDSA_K256_KECCAK_SCHEME_ID` name them. ### 3. Set Up Faucets (for fungible assets) ```rust @@ -55,7 +76,17 @@ The 4th argument is `token_supply: Option` (an explicit `None` is treated a ### 4. Build Contracts -Build each project from its directory with the `build_project_in_dir` helper, e.g. `let bank_package = Arc::new(build_project_in_dir(Path::new("../contracts/bank-account"), true)?);` (see `deposit_test.rs` and `integration/src/helpers.rs::build_project_in_dir`). +Build contracts **out of process** with the `cargo miden build` CLI and load the resulting `.masp` +package in the test. + +Do not add `cargo-miden` as a library dependency of a crate that also depends on `miden-client`. +`cargo-miden` pulls `miden-protocol =0.16.0-alpha.4` transitively, and `miden-client 0.16.0-rc.5` +pulls `miden-protocol 0.16.0-rc.9`. Those are the same `0.16` compatibility range with an exact +requirement on one side, so Cargo cannot resolve both in one graph. Building out of process avoids +the conflict entirely. + +Package artefacts: the extension is `.masp` (`Package::EXTENSION`), magic `MASP\0`, package format +version `[6, 0, 0]`, MAST wire version `[0, 0, 4]`. There is no `.masl`. ### 5. Create Account with Storage @@ -64,28 +95,31 @@ Build each project from its directory with the `build_project_in_dir` helper, e. :::: ``` -The slot name is part of the on-chain storage ABI and is derived by the compiler's `#[component_storage]` macro, **not** from the Rust struct name: -- `` is the **bare** package name (`[package].name`), with no `miden:` org prefix. -- `` is the `[lib].namespace` **interface** segment — the text between the last `/` and the `@` in the namespace — snake_cased. Because it comes from the declared namespace, renaming the Rust struct cannot change the deployed slot name. +The slot name is part of the on-chain storage ABI and is derived by the compiler's +`#[component_storage]` macro, **not** from the Rust struct name: +- `` is the **bare** package name (`[package].name` in `miden-project.toml`), with no + `miden:` org prefix. +- `` is the `[lib].namespace` **interface** segment — the text between the last + `/` and the `@` in the namespace — snake_cased. Because it comes from the declared namespace, + renaming the Rust struct cannot change the deployed slot name. - `` is the Rust storage field's identifier (not its `description`). Characters outside `[A-Za-z0-9_]` are replaced with `_` in each segment. -Example: package `bank-account` with `[lib].namespace = "miden:bank-account/bank@0.1.0"` and storage struct `BankStorage` (fields `initialized`, `balances`) yields slots: -- `bank_account::bank::initialized` -- `bank_account::bank::balances` +Live values from the pinned compiler examples: `counter_contract::counter_contract::count_map`, +`auth_component_rpo_falcon512::auth_component::owner_public_key`. -Note the middle segment is `bank` (the interface segment), **not** `bank_storage` (the struct) and **not** `bank_account`, and there is no `miden_` org prefix. +The component's storage is declared with the three-part component macro (`#[component_storage]` +struct + `#[component]` trait + `#[component]` impl); the storage struct, not the trait, carries the +`#[storage]` fields the slot names derive from. Externally-callable trait methods additionally need +`#[account_procedure]`. See the `rust-sdk-patterns` skill for the contract side. -The component's storage is declared with the v0.15 three-part component macro (`#[component_storage]` struct + `#[component]` trait + `#[component]` impl); the storage struct, not the trait, carries the `#[storage]` fields the slot names derive from. See the `rust-sdk-patterns` skill for the contract side. - -**Authoritative pattern** (from `deposit_test.rs`/`init_test.rs`): build a `StorageSlotName`, seed any value slot that has no schema default via `InitStorageData::insert_value`, build the account with `create_testing_account_from_package`, then register it with `builder.add_account(...)`: +Seed any value slot that has no schema default, then register the account: ```rust -// The bank's `initialized` value slot is `StorageValue` with no schema default, -// so `AccountComponent::from_package` requires it to be seeded (here a zero Word = -// uninitialized) or it errors with `InitValueNotProvided`. The `balances` map defaults -// to empty and needs no entry. +// A `StorageValue` slot with no schema default must be seeded, or +// `AccountComponent::from_package` errors with `InitValueNotProvided`. +// A map slot defaults to empty and needs no entry. let initialized_slot = StorageSlotName::new("bank_account::bank::initialized")?; let mut init_storage_data = InitStorageData::default(); @@ -94,32 +128,51 @@ init_storage_data.insert_value( Word::default(), )?; -// `create_testing_account_from_package` builds the AccountComponent from the package + -// init data, then an existing account via `AccountBuilder::new([3u8; 32]) -// .account_type(AccountType::Public).with_component(..).with_auth_component(NoAuth) -// .build_existing()`. See `integration/src/helpers.rs`. -let bank_account = create_testing_account_from_package( - bank_package.clone(), - AccountCreationConfig { init_storage_data, ..Default::default() }, -)?; +let component = AccountComponent::from_package(&bank_package, &init_storage_data)?; + +let account_builder = AccountBuilder::new([3u8; 32]) + .account_type(AccountType::Public) + .with_component(component); -builder.add_account(bank_account.clone())?; +let bank_account = builder.add_account_from_builder( + Auth::BasicAuth { auth_scheme: AuthSchemeId::Falcon512Poseidon2 }, + account_builder, + AccountState::Exists, +)?; ``` -> Storage-seeding footgun: `InitStorageData::insert_value(name, value)` takes `value: impl Into`. The numeric `From` impls (`u8`/`u16`/`u32`/`u64`) produce a `WordValue::Atomic(string)` that the slot's schema parses — **not** a felt-positioned `Word`. Only `From` yields `[felt, 0, 0, 0]` and `From`/`From<[Felt; 4]>`/`From<[u32; 4]>` are fully-typed words. For a `StorageValue` slot (like the bank's `initialized` flag, whose contract reads index `[0]`), seed a `Word` (`Word::default()` for zero). The `insert_value` doc comment claiming `u64` becomes `[0,0,0,felt]` is inaccurate; the code produces an atomic string. +**There is no `AccountBuilder::with_auth_component`.** Auth components are installed like any other +component, via `.with_component(..)` / `.with_components(..)`. The builder's surface is +`new([u8; 32])`, `version`, `account_type`, `with_asset_callbacks`, `with_component(s)`, +`with_assets`, `nonce`, `storage_schemas`, `build`, `build_existing`. + +For map slots, seed entries with `init_storage_data.insert_map_entry(slot_name, key, value)?`. + +> Storage-seeding footgun: `InitStorageData::insert_value(name, value)` takes +> `value: impl Into`. The numeric `From` impls (`u8`/`u16`/`u32`/`u64`) produce a +> `WordValue::Atomic(string)` that the slot's schema parses — **not** a felt-positioned `Word`. Only +> `From` yields `[felt, 0, 0, 0]`, and `From` / `From<[Felt; 4]>` / `From<[u32; 4]>` are +> fully-typed words. For a `StorageValue` slot whose contract reads index `[0]`, seed a `Word` +> (`Word::default()` for zero). The `insert_value` doc comment claiming `u64` becomes `[0,0,0,felt]` +> is inaccurate; the code produces an atomic string. > Account model: -> - `AccountType` is the visibility enum `{ Private, Public }`. -> - Set account visibility via `.account_type(AccountType::Public | ::Private)`. +> - `AccountType` is the visibility enum `{ Private (default), Public }`, with `as_u8()`, +> `is_public()`, `is_private()`. +> - Set visibility via `.account_type(..)`. There is no `.storage_mode(..)` and no +> `AccountStorageMode` on this builder. > - Faucet-ness is determined by the installed components. -If you build the account inline instead of via the helper, `builder.add_account_from_builder(auth, account_builder, AccountState::Exists)` is the v0.15-valid equivalent — it consumes an `AccountBuilder` (configured with `.account_type(AccountType::Public)` and `.with_component(...)`) and registers it. For map slots, seed entries with `init_storage_data.insert_map_entry(slot_name, key, value)?` (three args: `slot_name: impl TryInto`, `key`, `value`). +`MockChainBuilder` also ships ready-made account helpers that remove most of the above: +`create_new_wallet`, `add_existing_note_creator`, `add_existing_non_fungible_faucet`, +`add_existing_network_faucet`, `create_new_faucet`, `add_existing_mock_account` (and its +`_with_storage` / `_with_assets` / `_with_storage_and_assets` variants), +`add_existing_account_from_components`. ### 6. Create Notes -The authoritative tests build notes with the `create_testing_note_from_package(package, sender_id, NoteCreationConfig { .. })` helper, which wraps `NoteBuilder` and derives a deterministic serial number from the note-script digest (see `integration/src/helpers.rs`). +Build a note with `NoteBuilder`, seeding the `RandomCoin` from the note-script root: -If you build a note by hand with `NoteBuilder`, seed the `RandomCoin` from the note-script root: ```rust use miden_client::{asset::FungibleAsset, crypto::RandomCoin, note::NoteScript, Felt, Word}; use miden_standards::testing::note::NoteBuilder; @@ -133,36 +186,83 @@ let note = NoteBuilder::new(sender.id(), &mut note_rng) .build()?; ``` -> `NoteScript::root()` returns a `NoteScriptRoot` newtype. `RandomCoin::new` needs a `Word`, so convert the root explicitly with `Word::from(...root())` (equivalently `...root().into()` or `...root().as_word()`). +`NoteBuilder::new(sender: AccountId, rng: T)` takes the RNG **by value**; `&mut RandomCoin` works +because `&mut T: Rng`. Other builder methods: `package`, `script`, `code`, `note_type`, `tag`, +`add_assets`, `note_storage`, `serial_number`, `attachment`, `advice_map`, +`dynamically_linked_packages`, `source_manager`, `build`. -> `Felt::new(u64)` is **fallible** — it returns `Result`. `note_storage` takes `impl IntoIterator`, so build each felt with the infallible `Felt::from(42_u32)` for in-range literals (`From/From/From` are infallible); for a `u64` use `Felt::new(n)?` or `Felt::new_unchecked(n)` (the form the bank withdraw test uses for note-storage inputs). +> `.tag(..)` takes a **`u32`**, not a `NoteTag`. The idiom is +> `.tag(NoteTag::with_account_target(account.id()).into())`. + +> `NoteScript::from_package(&Package)` requires the package to have exactly one `@note_script` +> export. `NoteScript::root()` returns a `NoteScriptRoot` newtype, and `RandomCoin::new` needs a +> `Word`, so convert explicitly with `Word::from(...root())`. + +> `Felt::new(u64)` is **fallible** — it returns `Result`. `note_storage` +> takes `impl IntoIterator`, so build each felt with the infallible +> `Felt::from(42_u32)` for in-range literals (`From//` are infallible); for a `u64` +> use `Felt::new(n)?` or `Felt::new_unchecked(n)`. + +> A note carries at most `MAX_ASSETS_PER_NOTE` = **16** assets. + +`MockChainBuilder` also has ready-made note constructors that skip `NoteBuilder` entirely: +`add_p2any_note`, `add_p2id_note`, `add_p2ide_note`, `add_swap_note`, `add_spawn_note`, +`add_tx_fee_note`, `add_p2id_note_with_fee`. + +Standard notes built outside the chain builder use typed `bon` builders — +`P2idNote::builder().sender(..).target(..).serial_number(..).asset(..).build()?`, then +`Note::from(p2id)`. There is no `XNote::create(..)`. ### 7. Add to MockChain and Build -Register accounts (`builder.add_account(...)`) and seed notes (`builder.add_output_note(RawOutputNote::Full(note.clone()))`) on the builder, then `let mut mock_chain = builder.build()?;` (see `deposit_test.rs`). +Register accounts (`builder.add_account(account)?`) and seed notes +(`builder.add_output_note(RawOutputNote::Full(note.clone()))`), then `let mut mock_chain = builder.build()?;`. + +> `add_output_note` returns `()`, not a `Result` — no `?`. ### 8. Execute Transaction -The full execution flow is `build_tx_context` -> `execute()` -> `add_pending_executed_transaction()` -> `prove_next_block()` (see `deposit_test.rs`). The bank tests `apply_delta()` onto the in-memory `Account` after each `execute()` so later local reads see the new state; if you instead re-fetch via `mock_chain.committed_account(...)` after the block is proven you can skip `apply_delta()` (see the multi-transaction note below). +```rust +let executed = mock_chain + .build_transaction(account.id()) + .authenticated_input_note(note.id()) + .build()? + .execute() + .await?; + +mock_chain.add_pending_executed_transaction(&executed)?; +mock_chain.prove_next_block()?; +``` + +`build_transaction(input)` takes `impl Into`, which has `From` and +`From`. Pass an `Account` (rather than an id) when chaining transactions against evolving +in-memory state, and for private accounts. + +Input notes are **not** positional arguments. Attach them with `.authenticated_input_note(NoteId)`, +`.authenticated_input_notes(..)`, `.unauthenticated_input_note(Note)` or +`.unauthenticated_input_notes(..)`. + +`build()` is sync and returns `anyhow::Result`; `execute()` is on `MockTransaction`, +is `async`, and returns `Result`. + +Other `MockTransactionBuilder` methods: `tx_script`, `tx_script_args`, `auth_args`, +`extend_note_args`, `reference_block`, `foreign_accounts`, `extend_advice_inputs`, +`add_advice_map_entry`, `authenticator`, `add_signature`, `add_note_script`, `send_notes_script`, +`expected_output_note(s)`, `with_source_manager`. ### 9. Execute with Transaction Script -A compiler project with `kind = "tx-script"` compiles to a `TransactionScript`-kind package, **not** an `Executable`. Because of that, `TransactionScript::from_package` and `Package::unwrap_program` do **not** apply to it: `from_package` calls `package.try_into_program()`, which returns `Err` for a non-executable package, and `unwrap_program` asserts the kind is `Executable` and **panics**. Build the script from the package's MAST forest plus its entry export instead: +`TransactionScript::from_package(&package)?` handles a `kind = "tx-script"` package directly: if the +package is a program it uses the entrypoint, otherwise it looks for the single procedure carrying +the `transaction_script` attribute, which the compiler emits on tx-script exports. ```rust use miden_client::transaction::TransactionScript; -let tx_script_package = Arc::new(build_project_in_dir( - Path::new("../contracts/init-tx-script"), - true, -)?); - -// Locate the entry export ("main"/"run", or the sole export) and build from parts. -// See examples/miden-bank/integration/src/helpers.rs `build_tx_script_from_package`. -let tx_script = build_tx_script_from_package(tx_script_package.as_ref())?; +let tx_script = TransactionScript::from_package(&tx_script_package)?; let executed = mock_chain - .build_tx_context(account.id(), &[], &[])? + .build_transaction(account.id()) .tx_script(tx_script) .build()? .execute() @@ -174,17 +274,30 @@ mock_chain.prove_next_block()?; let updated_account = mock_chain.committed_account(account.id())?; ``` -The helper essentially does `TransactionScript::from_parts(package.mast.mast_forest().clone(), entrypoint)` after finding the entry procedure's root in the MAST forest. - -> Reserve `TransactionScript::from_package(&package)?` (and the `#[doc(hidden)]` `unwrap_program()`) for packages that are genuinely `Executable`. For `kind = "tx-script"` compiler packages (e.g. the bank's `init-tx-script`, whose `miden-project.toml` declares `kind = "tx-script"`), use `from_parts` / the `build_tx_script_from_package` helper as above — `from_package` returns an error and `unwrap_program()` panics on them. +`TransactionScript::from_parts(Arc, MastNodeId)` exists, but it is not the path for +compiler-produced tx-script packages — use the package-based construction shown above. ### 10. Verify Storage State -Read state with `account.storage().get_item(&slot)` / `.get_map_item(&slot, key)` on an in-memory `Account` you keep `apply_delta`-current (the bank tests' approach), or re-fetch the committed account with `mock_chain.committed_account(account.id())?` after `prove_next_block()` and assert on its storage. Map values come back as scalar words in `[value, 0, 0, 0]` layout (see `deposit_test.rs` and `init_test.rs`). +Read state with `account.storage().get_item(&slot)` or +`account.storage().get_map_item(&slot, key)` on an in-memory `Account` you keep patch-current, or +re-fetch the committed account with `mock_chain.committed_account(account.id())?` after +`prove_next_block()`. + +> `get_map_item(&self, slot_name: &StorageSlotName, key: StorageMapKey)` takes a **`StorageMapKey` +> by value**, not a `Word`. Build one with `StorageMapKey::new(word)`, or `StorageMapKey::empty()` / +> `StorageMapKey::from_index(idx)` for the degenerate cases. + +`AccountStorage` exposes scalar felt values in `[felt, 0, 0, 0]` layout. + +`FungibleAsset::amount()` returns an **`AssetAmount`**, not a `u64` — an assertion comparing it to a +bare integer will not compile. Use `AssetAmount::new(expected)?` or `.as_u64()`. ### 11. Verify Output Notes -**Important**: `add_output_note()` is only available on `MockChainBuilder` (before `build()`) — use it to seed the chain with existing notes. To verify output notes from a transaction, use `extend_expected_output_notes()` on `TxContextBuilder`: +`add_output_note()` is only on `MockChainBuilder` (before `build()`) — use it to seed the chain with +existing notes. To assert on notes a transaction *produces*, use `expected_output_note(..)` / +`expected_output_notes(..)` on `MockTransactionBuilder`: ```rust use miden_client::{ @@ -192,71 +305,104 @@ use miden_client::{ transaction::RawOutputNote, }; -// Note::new takes a PartialNoteMetadata (sender + note_type + tag). -// Build it with PartialNoteMetadata::new(sender, note_type), -// then optionally `.with_tag(tag)` (the tag defaults to NoteTag::default()). let partial_metadata = PartialNoteMetadata::new(sender, NoteType::Public).with_tag(tag); let expected_note = Note::new(expected_assets, partial_metadata, expected_recipient); -let tx_context = mock_chain - .build_tx_context(account.id(), &[note.id()], &[])? - .extend_expected_output_notes(vec![RawOutputNote::Full(expected_note)]) - .build()?; - -// execute() will verify output notes match -let executed = tx_context.execute().await?; +let executed = mock_chain + .build_transaction(account.id()) + .authenticated_input_note(note.id()) + .expected_output_note(RawOutputNote::Full(expected_note)) + .build()? + .execute() + .await?; ``` +> Both `expected_output_note(s)` silently **drop** `RawOutputNote::Partial` entries — only `Full` +> notes are retained and checked. + > Note metadata: -> - `Note::new(assets, partial_metadata, recipient)` takes a `PartialNoteMetadata` (sender/type/tag only); there is no `Into` conversion on the parameter. -> - For attachment-bearing notes use `Note::with_attachments(assets, partial_metadata, recipient, attachments)` (attachments are `NoteAttachments`). +> - `Note::new(assets, partial_metadata, recipient)` takes a `PartialNoteMetadata` (sender/type/tag +> only), is infallible, and has no `Into` conversion on that parameter. +> - `PartialNoteMetadata::new(sender, note_type)` defaults the tag to `NoteTag::default()`; set one +> with `.with_tag(tag)` or `set_tag(..)`. +> - For attachment-bearing notes use +> `Note::with_attachments(assets, partial_metadata, recipient, attachments)`. ## Multi-Transaction Test Pattern -For contracts requiring initialization before use, each step usually needs its own `execute()` → `add_pending_executed_transaction()` → `prove_next_block()` cycle. Fetch the committed account or note state from `mock_chain` between steps before building the next context. +For contracts requiring initialization before use, each step usually needs its own `execute()` → +`add_pending_executed_transaction()` → `prove_next_block()` cycle. + +When you keep reading from and reusing the **same in-memory `Account`** across transactions, apply +the account patch after every `execute()` so later local reads see the new state: -`apply_delta()` is needed whenever you keep reading from / reusing the **same in-memory `Account`** across transactions — whether they land in the same block or in separate blocks. The canonical bank tests call `bank_account.apply_delta(&executed.account_delta())?` after every `execute()` (each followed by `add_pending_executed_transaction` + `prove_next_block`) precisely so later local reads like `bank_account.storage().get_map_item(...)` see the latest state. If you instead re-fetch via `mock_chain.committed_account(...)` after `prove_next_block()`, you can skip `apply_delta()`. +```rust +bank_account.apply_patch(executed.account_patch())?; +``` -See [miden-bank init_test.rs](https://github.com/0xMiden/tutorials/blob/a255af7959a441d9a027178631c666949b4af086/examples/miden-bank/integration/tests/init_test.rs) for the init-via-tx-script flow and pre/post storage assertions, and [miden-bank withdraw_test.rs](https://github.com/0xMiden/tutorials/blob/a255af7959a441d9a027178631c666949b4af086/examples/miden-bank/integration/tests/withdraw_test.rs) for a complete multi-transaction test demonstrating: initialize bank → deposit assets → withdraw assets (sequential transactions with state verification between steps, plus expected P2ID output-note verification). +`Account::apply_patch(&AccountPatch)` and `ExecutedTransaction::account_patch() -> &AccountPatch` +are the account-update path. If you instead re-fetch via `mock_chain.committed_account(..)` after +`prove_next_block()`, you can skip the patch entirely. -See [miden-bank deposit_test.rs](https://github.com/0xMiden/tutorials/blob/a255af7959a441d9a027178631c666949b4af086/examples/miden-bank/integration/tests/deposit_test.rs) for an end-to-end asset-bearing note test, including the negative `deposit_exceeds_max_should_fail` / `deposit_without_init_should_fail` cases that assert `tx_context.execute().await.is_err()`. +> **The one exception that catches people out:** `TransactionSummary::account_delta()` still returns +> a relative `AccountDelta` and is deliberately *not* a patch. A blanket rename of `account_delta` +> to `account_patch` breaks that call site. ## MockChain Block Numbering -Genesis is block 0. Each `prove_next_block()` advances the block number by 1. In contract code, `tx::get_block_number()` returns the **reference block** — the last proven block at the time the transaction started, not the block the transaction will be included in. - -## Note Construction +Genesis is block 0. Each `prove_next_block()` advances the block number by 1; `prove_next_block_at(timestamp)` +does the same at a chosen timestamp. In contract code, `tx::get_block_number()` returns the +**reference block** — the last proven block at the time the transaction started, not the block the +transaction will be included in. -Prefer the `create_testing_note_from_package` / `create_note_from_package` helpers (which wrap `NoteBuilder`) for creating notes in tests. If you use `NoteBuilder` directly, start from `NoteBuilder::new(sender.id(), &mut note_rng)`, then configure `.package(...)`, optional `.note_type(...)`, optional `.tag(...)`, optional `.add_assets(...)`, optional `.note_storage(...)?`, optional `.serial_number(...)`, and finally `.build()?`. Seed the `RandomCoin` from `Word::from(NoteScript::from_package(note_package.as_ref())?.root())` (see Step 6). +> `tx::get_block_number()` returns a **`BlockNumber`**, not a `Felt`. Compare it directly against +> another `BlockNumber`; convert a felt read out of note storage with `BlockNumber::try_from(felt)`. ## Asset-Bearing Note Example -To create a note that carries fungible assets in tests: +1. Create a `FungibleAsset` from a faucet ID and amount, e.g. `FungibleAsset::new(faucet.id(), 50)?` + (the amount parameter is still `u64`), and wrap it into `NoteAssets::new(vec![Asset::Fungible(asset)])?` + — or pass it via `NoteBuilder::add_assets`. +2. Seed a `RandomCoin` from `Word::from(NoteScript::from_package(note_package.as_ref())?.root())`. +3. Pass any note inputs into `note_storage(...)?`, building each felt with the infallible + `Felt::from(_u32)` for in-range literals or `Felt::new_unchecked(n)` for `u64` inputs. +4. Finish with `.package((*note_package).clone()).build()?`. -1. Create a `FungibleAsset` from a faucet ID and amount, e.g. `FungibleAsset::new(faucet.id(), 50)?`, and wrap into `NoteAssets::new(vec![Asset::Fungible(asset)])?` (or pass via `NoteBuilder::add_assets`). -2. Seed a `RandomCoin` from `Word::from(NoteScript::from_package(note_package.as_ref())?.root())` (the conversion turns the `NoteScriptRoot` into the `Word` that `RandomCoin::new` expects). -3. Pass the asset into the note and any note inputs into `note_storage(...)?`. `note_storage` wants `Item = Felt`; build each input with the infallible `Felt::from(_u32)` for in-range literals (not `Felt::new(u64)`, which is fallible in v0.15), or `Felt::new_unchecked(n)` for u64 inputs (see Step 6). -4. Finish with `.package((*note_package).clone()).build()?` (or use `create_testing_note_from_package` with a `NoteCreationConfig { assets, storage, .. }`). - -The faucet must be set up first (see Step 3) and the sender wallet must hold sufficient assets (see Step 2). +The faucet must be set up first (see Step 3) and the sender wallet must hold sufficient assets +(see Step 2). ## Key Dependencies -See `integration/Cargo.toml` in [miden-bank](https://github.com/0xMiden/tutorials/blob/a255af7959a441d9a027178631c666949b4af086/examples/miden-bank/integration/Cargo.toml) for the dependency versions. Under the released compiler v0.9.0 the integration crate depends on `cargo-miden = "0.9"` (its `build_project_in_dir` helper calls `cargo_miden::run`) alongside the 0.15 line (`miden-client`/`miden-standards`/`miden-testing` 0.15.x, `miden-mast-package` 0.23.x) — no git-rev/branch pins. The contracts it builds depend on the guest SDK `miden = "0.13"`. +```toml +miden-client = "0.16.0-rc.5" # with features = ["testing"] for miden_client::testing +miden-protocol = "0.16.0-rc.9" +miden-standards = "0.16.0-rc.9" +miden-testing = "0.16.0-rc.9" +``` + +The contracts a test builds depend on the guest SDK `miden = "0.14.0"`, built with +`cargo-miden` / `midenc` `0.10.0` on the pinned nightly (`nightly-2026-04-30`, target +`wasm32-wasip2`). See Step 4 for why `cargo-miden` must not be a library dependency of the test +crate. ## Validation Checklist - [ ] Test function is `async` and uses `#[tokio::test]` -- [ ] Auth uses `AuthSchemeId::Falcon512Poseidon2` (or the equivalent `AuthScheme::Falcon512Poseidon2` — both name the same protocol enum) -- [ ] `AccountBuilder` uses `.account_type(AccountType::Public | ::Private)` and no `.storage_mode(...)` / no `AccountStorageMode` -- [ ] Storage slot names follow `::::` (bare package name, `[lib].namespace` interface segment, e.g. `bank_account::bank::balances`) -- [ ] Value slots without a schema default are seeded via `InitStorageData::insert_value(StorageValueName::from_slot_name(&slot), ..)`; `StorageValue` slots get a `Word` (e.g. `Word::default()`), not a bare integer (numeric `Into` yields an atomic string, not a felt-positioned word) -- [ ] All contracts built before account/note creation -- [ ] `NoteScript::root()` converted with `Word::from(...)` before seeding `RandomCoin` -- [ ] Note-storage felts built with infallible `Felt::from(_u32)` or `Felt::new_unchecked(_u64)` (v0.15 `Felt::new(u64)` returns `Result`, so a bare `[Felt::new(..)]` array does not satisfy `Item = Felt`) -- [ ] `Note::new(...)` is passed a `PartialNoteMetadata` (not `NoteMetadata`) -- [ ] `kind = "tx-script"` packages built with `from_parts` / `build_tx_script_from_package` (not `from_package`/`unwrap_program`, which error/panic on them) +- [ ] `miden-testing` is available — either the client's `testing` feature or a direct dependency +- [ ] Auth uses `AuthSchemeId::Falcon512Poseidon2` (or the equivalent `AuthScheme::Falcon512Poseidon2`) +- [ ] `AccountBuilder` uses `.account_type(..)`, and no `.storage_mode(..)` and no `.with_auth_component(..)` — auth goes through `.with_component(..)` +- [ ] Storage slot names follow `::::` +- [ ] Value slots without a schema default are seeded via `InitStorageData::insert_value(StorageValueName::from_slot_name(&slot), ..)`; `StorageValue` slots get a `Word`, not a bare integer +- [ ] Contracts are built out of process with `cargo miden build`, not by depending on `cargo-miden` +- [ ] `NoteScript::root()` converted with `Word::from(..)` before seeding `RandomCoin` +- [ ] `NoteBuilder::tag(..)` is passed a `u32` +- [ ] Note-storage felts built with infallible `Felt::from(_u32)` or `Felt::new_unchecked(_u64)` +- [ ] `Note::new(..)` is passed a `PartialNoteMetadata` (not `NoteMetadata`) +- [ ] Transaction scripts built with `TransactionScript::from_package(&package)?` +- [ ] Execution goes through `chain.build_transaction(..)` with `.authenticated_input_note(..)` / `.unauthenticated_input_note(..)`, then `.build()?.execute().await?` - [ ] `prove_next_block()` called after `add_pending_executed_transaction()` -- [ ] Post-block assertions read state from `mock_chain.committed_account(...)` (or `account.apply_delta(...)` is called when reusing an in-memory `Account` across transactions) -- [ ] Notes added to `MockChainBuilder` via `add_output_note(RawOutputNote::Full(...))` before `build()` +- [ ] In-memory accounts refreshed with `account.apply_patch(executed.account_patch())?`, and `TransactionSummary::account_delta()` left alone +- [ ] Map reads pass a `StorageMapKey`, not a `Word` +- [ ] `FungibleAsset::amount()` compared as `AssetAmount`, not a bare integer +- [ ] Notes added to `MockChainBuilder` via `add_output_note(RawOutputNote::Full(..))` before `build()` (no `?` — it returns `()`) - [ ] Faucet set up before creating assets diff --git a/skills/signer-integration/SKILL.md b/skills/signer-integration/SKILL.md index 5d774b2..9d3fd52 100644 --- a/skills/signer-integration/SKILL.md +++ b/skills/signer-integration/SKILL.md @@ -1,118 +1,131 @@ --- 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. +description: Guide to integrating external signers (Para, Turnkey, MidenFi wallet adapter) and building custom signers for Miden React frontends. Covers provider setup and nesting, the MultiSignerProvider registry, the unified useSigner interface, custom SignerContext implementation, custom account components, wallet-extension detection, and network-account/network-note targeting. Use when adding wallet connection, authentication, or external key management to a Miden frontend. --- # Miden Signer Integration +```json +"@miden-sdk/react": "0.16.0-rc.7", +"@miden-sdk/miden-sdk": "0.16.0-rc.7" +``` + ## 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. +By default, `MidenProvider` uses a **local keystore** (keys in IndexedDB, no wallet connection needed). For production apps, mount a signer provider so `MidenProvider` builds its client with an external keystore instead. + +`MidenProvider` reads the **nearest ancestor** `SignerContext`. So a single signer provider must wrap it (outer → inner): -Signer providers must wrap MidenProvider (outer → inner): ``` - ← manages keys + auth - ← manages Miden client + ← populates SignerContext: signCb + accountConfig + storeName + ← picks it up and calls WebClient.createClientWithExternalKeystore(...) ``` -## Pre-Built Signer Providers +When a signer context is present, the IndexedDB name becomes `` `MidenClientDB_${signer.storeName}` `` and every write signs through `signer.signCb`. No per-hook wiring is needed — `useSend`, `useConsume` and the rest route through the same client. -### Para (EVM Wallets) -```tsx -import { ParaSignerProvider, useParaSigner } from "@miden-sdk/use-miden-para-react"; +**Init gate to be aware of:** while a signer ancestor exists but reports `isConnected === false`, `MidenProvider`'s init effect returns early and never creates a `WebClient`. On first mount that means `isReady` stays false and `useMidenClient()` throws, so even public reads cannot run until the user connects. If your app must render and read before connect, use the `MultiSignerProvider` arrangement below — it forwards `null` down to `MidenProvider` until a signer is actually selected, which puts the provider in local-keystore mode and lets it initialize immediately. - - - - - +## Pre-Built Signer Providers -const { para, wallet, isConnected } = useParaSigner(); -``` +These three packages live in repos **outside** web-sdk and are not declared in the SDK example's `package.json`. Only their import specifiers and the props the example actually passes can be confirmed from the SDK; check each package's own documentation for its full prop set, exported hooks and version. -### Turnkey (Passkey Authentication) ```tsx +import { ParaSignerProvider } from "@miden-sdk/use-miden-para-react"; import { TurnkeySignerProvider } from "@miden-sdk/miden-turnkey-react"; +import { MidenFiSignerProvider } from "@miden-sdk/miden-wallet-adapter-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`). - - - - - +What the SDK's example app demonstrates: -// Or override the apiBaseUrl default: - - ... - +```tsx +// Para (EVM wallets) + ... + +// Turnkey (passkey authentication) — mounted with no props + ... + +// MidenFi wallet (browser extension) — `network` is a plain string + ... ``` -`TurnkeySignerProvider` also accepts optional `customComponents` and `importAccountId` props, which it forwards into `accountConfig` (see "Custom Account Components"). +Notes on things agents commonly get wrong here: + +- `MidenFiSignerProvider`'s `network` prop takes a **plain string** (`"testnet"`). There is no `WalletAdapterNetwork` enum and no `@miden-sdk/miden-wallet-adapter-base` package anywhere in the SDK. +- There is no `useMidenFiWallet` hook and no `WalletReadyState` enum in the SDK. To gate a connect button on extension availability, use the SDK's own primitive, `waitForWalletDetection` (below). + +## Wallet-extension detection -Connect via passkey: ```tsx -import { useSigner } from "@miden-sdk/react"; -import { useTurnkeySigner } from "@miden-sdk/miden-turnkey-react"; +import { waitForWalletDetection } from "@miden-sdk/react"; +import type { WalletAdapterLike } from "@miden-sdk/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 +// WalletAdapterLike is a duck type with no dependency on any wallet-adapter package: +// { readyState: string; on(e: "readyStateChange", cb): void; off(e: "readyStateChange", cb): void } -// Turnkey-specific extras -const { client, account, setAccount } = useTurnkeySigner(); +await waitForWalletDetection(adapter); // default timeout: 5000 ms +await waitForWalletDetection(adapter, 10000); // custom timeout ``` -### MidenFi Wallet Adapter (Browser Extension) +Resolves immediately when `adapter.readyState === "Installed"`; otherwise it listens for `readyStateChange` and rejects with `Wallet extension not detected within ms. Is the browser extension installed and enabled?`. Use it to show an install prompt instead of blindly calling `connect()`. + +## MultiSignerProvider — offering a choice of signer + +Wrap everything in `MultiSignerProvider` and mount each signer provider — each containing a `` — as a **sibling** of `MidenProvider`: + ```tsx -import { MidenFiSignerProvider } from "@miden-sdk/miden-wallet-adapter-react"; -import { WalletAdapterNetwork } from "@miden-sdk/miden-wallet-adapter-base"; - - - +import { MidenProvider, MultiSignerProvider, SignerSlot, useMultiSigner } from "@miden-sdk/react"; + + + + + + + + + + + + - + ``` -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. +`SignerSlot` renders nothing: it reads its nearest ancestor's `SignerContext` value and registers it into `MultiSignerProvider`'s registry. `MultiSignerProvider` then supplies `MidenProvider`'s `SignerContext` with only the **active** signer — or `null` when none is selected, which is what keeps the app in local-keystore mode (and therefore readable) before the user picks one. -> 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. +```tsx +const multiSigner = useMultiSigner(); // null outside a MultiSignerProvider +const { signers, activeSigner, connectSigner, disconnectSigner } = multiSigner ?? {}; -### Frontend-template-specific MidenFi pattern +await connectSigner("Turnkey"); // sets active by `name`, then calls that signer's connect() +await disconnectSigner(); // clears the active signer → back to local keystore mode +``` -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: +`connectSigner(name)` throws `Signer "" not found` for an unregistered name, disconnects the previous signer fire-and-forget, and reverts the active name to `null` if `connect()` rejects. Switching signers changes `storeName`, which makes `MidenProvider` drop its cached in-memory state and build a fresh client for the new identity; reconnecting the *same* identity hot-swaps `signCb` on the existing client instead. -- **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. +A minimal selector: + +```tsx +function SignerSelector({ onUseLocal }: { onUseLocal: () => void }) { + const multiSigner = useMultiSigner(); + return ( + <> + {multiSigner?.signers.map((s) => ( + + ))} + + + ); +} +``` ## Unified Signer Interface -Works with any signer provider above. `useSigner()` returns `null` in local-keystore mode (no signer provider mounted), so guard before destructuring: +Works with any signer provider. `useSigner()` returns `SignerContextValue | null` — `null` in local-keystore mode (no signer provider mounted), so guard before destructuring: + ```tsx import { useSigner } from "@miden-sdk/react"; @@ -140,10 +153,10 @@ import { AccountStorageMode } from "@miden-sdk/miden-sdk"; isConnected: true, accountConfig: { publicKeyCommitment: userPublicKeyCommitment, // Uint8Array - storageMode: AccountStorageMode.private(), // AccountStorageMode instance, not a string + storageMode: AccountStorageMode.private(), // an AccountStorageMode instance, not a string }, signCb: async (pubKey, signingInputs) => { - // Route to your signing service + // Route to your signing service. Both args are Uint8Array. return signature; // Uint8Array }, connect: async () => { /* trigger wallet connection */ }, @@ -155,33 +168,165 @@ import { AccountStorageMode } from "@miden-sdk/miden-sdk"; ``` -**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 +**`SignerContextValue` fields:** + +| Field | Required | Notes | +|---|---|---| +| `name` | yes | Display name; also the registry key used by `MultiSignerProvider` | +| `storeName` | yes | Unique per user — becomes the `MidenClientDB_` IndexedDB name | +| `isConnected` | yes | `false` blocks client creation on first mount (see the init gate above) | +| `accountConfig` | yes | `SignerAccountConfig` (below); only meaningful when connected | +| `signCb` | yes | `(pubKey: Uint8Array, signingInputs: Uint8Array) => Promise` | +| `connect` / `disconnect` | yes | `() => Promise` session lifecycle | +| `getKeyCb` | no | `(pubKey: Uint8Array) => Promise` — retrieve a secret key by commitment | +| `insertKeyCb` | no | `(pubKey: Uint8Array, secretKey: Uint8Array) => void` — persist a generated key pair | + +**`SignerAccountConfig` fields:** + +| Field | Required | Notes | +|---|---|---| +| `publicKeyCommitment` | yes | `Uint8Array`, deserialized to a `Word` for the auth component | +| `storageMode` | yes | an `AccountStorageMode` **instance** — `AccountStorageMode.private()` / `.public()` | +| `accountSeed` | no | `Uint8Array` for a deterministic account id; otherwise 32 random bytes | +| `customComponents` | no | `AccountComponent[]` appended after the basic wallet component | +| `importAccountId` | no | Skip the builder entirely and import this account id from chain | +| `accountType` | no | **`@deprecated` and ignored** — visibility comes solely from `storageMode`. Omit it. | + +`signCb` is not called directly by `MidenProvider`; it is wrapped in a ref-reading callback so a reconnect of the same identity can hot-swap the callback without rebuilding the client. A wrapped call made after disconnect throws `Signer is disconnected. Cannot sign.`. + +Two hooks additionally hard-block while a signer is mounted but disconnected — `useImportAccount` and `useMultiSend` call `assertSignerConnected()` and throw `Signer is disconnected. Reconnect your wallet to perform transactions.` + +## How the account gets initialized + +`MidenProvider` calls `initializeSignerAccount(client, accountConfig)` right after creating the external-keystore client. Two paths: + +**Fast path — `importAccountId` is set.** The builder is skipped and `client.importAccountById(accountId)` runs. It tolerates exactly two error codes and rethrows everything else: `ACCOUNT_NOT_FOUND_ON_CHAIN` (a brand-new account not yet registered on-chain — the dApp still renders, but the account is *not* tracked locally, so `useAccount` returns `null` and no transaction can be built against it until a later import succeeds) and `ACCOUNT_ALREADY_TRACKED`. Use this path for wallets that mint accounts externally and hand over only an id. + +**Slow path — build from the commitment.** + +```ts +new AccountBuilder(seed) + .withAuthComponent( + AccountComponent.createAuthComponentFromCommitment(commitmentWord, AuthScheme.AuthEcdsaK256Keccak) + ) + .storageMode(config.storageMode) + .withBasicWalletComponent() + // then .withComponent(c) for each entry in customComponents + .build(); +``` + +For a **public** storage mode it first tries `importAccountById` (the account may already exist on-chain). If that **succeeds**, it syncs and returns the account id immediately — `getAccount` and `newAccount` are never reached. Only when the import throws does it fall through to checking `client.getAccount(accountId)` for a local copy and finally creating the account with `client.newAccount(account, false)`. + +Note the hard-coded `AuthScheme.AuthEcdsaK256Keccak` on the auth component: an external signer's commitment is registered as an ECDSA-K256/Keccak key, not Falcon. + +> **Two different things are called `AuthScheme`, and the wrong one is the default import.** The numeric WASM enum (`AuthEcdsaK256Keccak = 1`, `AuthRpoFalcon512 = 2`) is the Rust model. But the `AuthScheme` that `@miden-sdk/miden-sdk` actually exports is a frozen *string* const — `{ Falcon: "falcon", ECDSA: "ecdsa" }` — declared in both the browser and Node entries, and it **shadows** the WASM binding. So `AuthScheme.AuthEcdsaK256Keccak` evaluates to `undefined` against the real package. On Node the WASM class is re-exported under the non-colliding alias `AuthSchemeNative`; the browser entry has no escape hatch, which is why the SDK's own browser test harness restores it by hand (`window.AuthScheme = wasm.AuthScheme`). Pass the numeric value directly, or use `AuthSchemeNative` on Node. + +`withAuthComponent(component)` exists on the JS `AccountBuilder` and is the primary auth path — it forwards to `with_component` internally. Use it; do not look for a replacement. Sibling builder methods: `withComponent`, `withNoAuthComponent`, `withBasicWalletComponent`, `accountType`, `storageMode`, `build`, `buildWithoutSchemaCommitment`. ## Custom Account Components -Attach application-specific `AccountComponent` instances (e.g., DEX logic from `.masp` packages) to accounts created by the signer: +Attach application-specific `AccountComponent` instances (e.g. DEX logic from a compiled `.masp` package) to the account the signer creates: ```tsx import { type SignerAccountConfig } from "@miden-sdk/react"; -import { AccountComponent } from "@miden-sdk/miden-sdk"; +import { AccountComponent, AccountStorageMode } from "@miden-sdk/miden-sdk"; const myDexComponent: AccountComponent = await loadCompiledComponent(); const accountConfig: SignerAccountConfig = { publicKeyCommitment: userPublicKeyCommitment, - storageMode: myStorageMode, // an AccountStorageMode instance (e.g. AccountStorageMode.public()) + storageMode: 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. +Each entry must be a real `AccountComponent` — created via `AccountComponent.compile()`, `.fromPackage()` or `.fromLibrary()`. The initializer duck-checks for a `getProcedures` method and throws otherwise: + +> Each entry in customComponents must be an AccountComponent instance created via AccountComponent.compile(), AccountComponent.fromPackage(), or AccountComponent.fromLibrary(). + +Components are appended to the `AccountBuilder` after the default basic wallet component. The field is optional — omitting it preserves default behavior. `SignerAccountConfig.accountType` is deprecated and ignored; omit it. + +## Network accounts and network notes + +There **is** a network-execution surface. A network note is a Public note carrying a `NetworkAccountTarget` attachment; once it lands on-chain the targeted network account auto-consumes it, with no manual `consume` on the recipient side. + +### Targeting + +```tsx +import { NetworkAccountTarget, NoteExecutionHint } from "@miden-sdk/miden-sdk"; + +const target = new NetworkAccountTarget(networkAccountId, NoteExecutionHint.always()); +// executionHint is optional and defaults to `always`. +// The constructor errors if `accountId` is not a public account. + +target.targetId(); // AccountId +target.executionHint(); // NoteExecutionHint +const attachment = target.toAttachment(); // NoteAttachment +NetworkAccountTarget.fromAttachment(attachment); // decode back; errors if not a target attachment +``` + +### Building the note + +```tsx +// Note.withAttachments(noteAssets, noteMetadata, noteRecipient, attachments) +// Uses the metadata's sender / note type / tag; attachments on the metadata itself are ignored. +const note = Note.withAttachments(noteAssets, metadata, recipient, [target.toAttachment(), extra]); +note.attachments(); // NoteAttachment[] +note.isNetworkNote(); // true — Public + a valid NetworkAccountTarget attachment +``` + +From React: + +```tsx +import { useCreateNetworkNote } from "@miden-sdk/react"; + +const { createNetworkNote, result, isLoading, stage, error, reset } = useCreateNetworkNote(); +const { txId, note } = await createNetworkNote({ + accountId: senderId, // AccountRef — creates, funds and submits the note + target: networkAccountId, // AccountRef + script: myNoteScript, // NoteScript — OR `recipient`, exactly one of the two + executionHint, // optional; defaults to `always` + inputs: [1n, 2n], // optional note storage / inputs (used with `script`) + assetId, amount, // optional single asset to lock into the note + attachment: [1n, 2n, 3n], // optional extra payload appended after the NetworkAccountTarget +}); +``` + +Passing both `recipient` and `script`, or neither, throws. From the raw client the equivalent is `client.transactions.createNetworkNote(options)`, which resolves to `{ txId, note, result }`; There is also a `buildNetworkNote(opts)` that builds the same note without submitting — but at this pin it is **not importable** from `@miden-sdk/miden-sdk`: it exists in `js/standalone.js` and is declared in `api-types.d.ts`, yet neither package entry re-exports it (they re-export only `createP2IDNote`, `createP2IDENote` and `buildSwapTag`). Treat the type declaration as aspirational until an entry exports it. + +### Creating the network account + +A network account is a **public** account carrying the network-account auth component, whose note-script allowlist is what the node's network-transaction builder inspects: + +```tsx +import { AccountBuilder, AccountComponent, AccountStorageMode, NoteScriptFee } from "@miden-sdk/miden-sdk"; + +// Returns AccountComponent[] — the auth component plus the components backing +// its fee policy. Install ALL of them. +const components = AccountComponent.createNetworkAuthComponents( + [new NoteScriptFee(myNoteScript.root(), 0n)], // NoteScriptFee[] — must be non-empty + feeFaucetId, // AccountId — fees are denominated in this faucet's asset + allowedTxScriptRoots // optional Word[] from TransactionScript.root() +); + +const builder = new AccountBuilder(seed) + .storageMode(AccountStorageMode.public()) + .withComponent(myComponent); +for (const component of components) builder.withComponent(component); +const { account } = builder.build(); +``` + +Rules that bite: + +- The allowlist must be **non-empty** — `createNetworkAuthComponents([], ...)` throws, since such an account could never consume a note. +- Every allowlisted script is priced by construction. A fee of `0n` is valid; a script root the account does not price at all aborts fee estimation rather than being treated as free. +- Reuse the *same* compiled note script for the account allowlist and for the note, so the roots match. Targeting a plain wallet instead of a network account fails with `account procedure … is not in the account procedure index map`. +- The canonical expiration transaction script is always allowlisted (the node attaches it to every network transaction). Any other transaction script is forbidden unless its root is passed in the optional third argument — and only allowlist a root whose effect is safe for *every* possible input, since a root pins code but not the submitter-controlled arguments. +- Detect one with `account.isNetworkAccount()`; read the allowed roots with `account.networkNoteAllowlist()` (`Word[]`, or `undefined` for a non-network account). + +### Ordinary attachments -Components are appended to the `AccountBuilder` after the default basic wallet component. The field is optional — omitting it preserves default behavior. +Attachments are not network-specific. `NoteAttachment.fromWord(scheme, word)` / `fromWords(scheme, words)` build one, `.toWords()` reads it back, and `.attachmentScheme()` / `.numWords()` inspect it. The React SDK adds `createNoteAttachment(...)` / `readNoteAttachment(...)` helpers. The JS `NoteMetadata` constructor is attachment-less — `new NoteMetadata(sender, noteType, tag)` — so attach at the note level via `Note.withAttachments(...)`; there is no `metadata.withAttachment()` / `metadata.attachment()` / `metadata.withTag()`. ## Which Signer to Choose @@ -193,4 +338,4 @@ Components are appended to the `AccountBuilder` after the default basic wallet c | 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. +**Key trade-off**: Local keystore requires no setup but keys are lost if the user clears browser data. External signers persist keys elsewhere but add a dependency — and gate client initialization on connect unless you use `MultiSignerProvider`. diff --git a/skills/testing-patterns/SKILL.md b/skills/testing-patterns/SKILL.md index b717283..02df6cc 100644 --- a/skills/testing-patterns/SKILL.md +++ b/skills/testing-patterns/SKILL.md @@ -1,237 +1,223 @@ --- 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. +description: Testing conventions for Miden frontend code — mocking @miden-sdk/react, the real hook return shapes to assert against, transaction-stage simulation, and the @miden-sdk/react mock shapes. Use when writing, running, or debugging tests for Miden React components. --- # Miden Frontend Testing Patterns -## Test Stack +The reference implementation for everything below is the Web SDK's **own** test suite, which ships +in the repository and can be read directly: -- **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 +- `packages/react-sdk/src/__tests__/setup.ts` — the global `vi.mock("@miden-sdk/miden-sdk", …)` +- `packages/react-sdk/src/__tests__/mocks/miden-sdk.ts` — mock factories (`createMockWebClient`, + `createMockAccountHeader`, `createMockAccountId`, …) +- `packages/react-sdk/src/__tests__/mocks/miden-sdk-entry.ts` +- `packages/react-sdk/src/__tests__/mocks/signer-context.ts` +- `packages/react-sdk/src/__tests__/hooks/` — one test file per hook +- `packages/react-sdk/src/__tests__/context/` — provider tests -## Mock Factory: `@miden-sdk/react` +Copy their shapes rather than inventing your own; they are kept in step with the hooks. -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. +## Test Stack -### Usage in test files +The SDK itself runs **Vitest** with `@testing-library/react` and **jsdom**, configured in +`packages/react-sdk/vitest.config.ts`. The react-sdk package's own scripts are +`test` (`vitest run`), `test:coverage` (`vitest run --coverage`) and `typecheck` (`tsc --noEmit`). -```tsx -// 1. Mock the entire module (hoisted to top by vitest) -vi.mock("@miden-sdk/react", () => import("@/__tests__/mocks/miden-sdk-react")); +## Mocking the SDK -// 2. Import hooks you want to override +Components that import from `@miden-sdk/react` need the SDK mocked, because the real package +initializes WASM. **Mock the hooks your component actually calls**, at the package boundary: + +```tsx +import { render, screen } from "@testing-library/react"; import { useAccounts, useSend } from "@miden-sdk/react"; +import { WalletView } from "../WalletView"; -// 3. Override per-test -it("shows empty state", () => { +vi.mock("@miden-sdk/react", () => ({ + useAccounts: vi.fn(), + useSend: vi.fn(), +})); + +beforeEach(() => { vi.mocked(useAccounts).mockReturnValue({ - accounts: [], - wallets: [], - faucets: [], - isLoading: false, - error: null, - refetch: vi.fn(), + accounts: [], wallets: [], faucets: [], + isLoading: false, error: null, refetch: vi.fn(), + }); + vi.mocked(useSend).mockReturnValue({ + send: vi.fn(), result: null, isLoading: false, + stage: "idle", error: null, reset: vi.fn(), }); - render(); +}); + +it("shows the empty state", () => { + render(); + expect(screen.getByText(/no accounts/i)).toBeInTheDocument(); }); ``` -### Default mock return values +Override per test with `vi.mocked(useAccounts).mockReturnValue(...)`. -**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 +**Do not try to control a real hook by mocking `useMiden`.** Replacing only the public `useMiden` +export while leaving the real `useAccounts` in place does nothing: `useAccounts` imports `useMiden` +from its own internal module (`../context/MidenProvider`), not from the package entry point, so the +real provider hook still runs — and with no provider mounted it throws. Mock the hook you are +testing against, not its dependency. -**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 }`. +**If you want the real hook logic**, render inside a real `MidenProvider` and mock the WASM boundary +instead, which is the level the SDK's own `setup.ts` mocks: -### Simulating transaction stages +```tsx +vi.mock("@miden-sdk/miden-sdk", () => ({ /* ...mock client & model classes... */ })); +``` + +> **The SDK's own suite is not a consumer-copyable template.** It mocks *internal relative paths* +> (`vi.mock("../../context/MidenProvider", …)`) and resets `useMidenStore` in `beforeEach`. Neither +> is available to package consumers: `useMidenStore` is not a public export, and the package +> declares only the `.`, `./lazy`, `./mt`, `./mt/lazy` and `./package.json` subpaths — there is no +> way to reach internal modules from outside. Read that suite for mock *shapes*; use the boundary-level patterns +> above for your own app. + +Reset mocks between tests with `vi.clearAllMocks()` in `beforeEach`. (The SDK's own suite also +resets its Zustand store with `useMidenStore.getState().reset()` — that is an internal module and is +not reachable from a consumer app, so if you mock at the hook boundary there is no shared store to +reset anyway.) + +## Hook return shapes to assert against + +These are the contract; getting them wrong is the most common source of tests that pass against a +mock and fail against the real SDK. + +**Query hooks.** `useAccounts()` returns +`{ accounts, wallets, faucets, isLoading, error, refetch }`. Note two things about the real hook: +`wallets` is just `accounts` and `faucets` is always `[]` — both are deprecated, because an account +id does not distinguish a faucet from a wallet, so faucet-ness must be detected per-account from its +components. `error` is hardcoded `null`. + +Query hooks are also **self-healing**: their effects are keyed on `isReady`, so a hook rendered +before the client is ready returns empty and then refetches itself once readiness flips. A test that +asserts "empty forever" is asserting something the hook does not do. + +**Mutation hooks.** `useSend()` returns `{ send, result, isLoading, stage, error, reset }`. Its +result type is `SendResult { txId: string; note: Note | null }` — distinct from the +`TransactionResult { transactionId: string }` that `useMint` / `useConsume` / `useSwap` / +`useMultiSend` / `useTransaction` return. Mixing these two up is the single most common fixture bug. + +`TransactionStage` is `"idle" | "executing" | "proving" | "submitting" | "complete"`. ```tsx -// Show "proving" stage +// mid-flight vi.mocked(useSend).mockReturnValue({ - send: vi.fn(), - result: null, - isLoading: true, - stage: "proving", - error: null, - reset: vi.fn(), + send: vi.fn(), result: null, isLoading: true, + stage: "proving", error: null, reset: vi.fn(), }); -// Show completed transaction — useSend returns SendResult { txId, note } +// completed — 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(), + send: vi.fn(), result: { txId: "0xabc123", note: null }, + isLoading: false, stage: "complete", error: null, reset: vi.fn(), }); -// Other mutation hooks return TransactionResult { transactionId } +// 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(), + mint: vi.fn(), result: { transactionId: "0xdef456" }, + isLoading: false, stage: "complete", error: null, reset: vi.fn(), }); ``` -## Fixtures - -Realistic test data in `src/__tests__/fixtures/`: +**Provider state.** `useMiden()` exposes `client`, `isReady`, `isInitializing`, `error`, `sync`, +`runExclusive`, `prover`, `signerAccountId`, and `signerConnected` (`boolean | null`, where `null` +means no signer provider is mounted). -```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"; -``` +**Sync state.** `useSyncState()` returns +`{ syncHeight: number; isSyncing: boolean; lastSyncTime: number | null; error: Error | null; sync: () => Promise }` — `UseSyncStateResult` extends `SyncState` with `sync`, so a fixture built from the four state fields alone will make a component that calls `sync()` throw. -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 +## Amounts are `bigint` -## Test Patterns (copy-adaptable) +`AssetBalance.amount`, `NoteAsset.amount` and `useAccount().getBalance()` are all `bigint` on the TS +side. Option bags are more forgiving — `SendOptions.amount` is `bigint | number` and optional, +`MintOptions.amount` and `CreateFaucetOptions.maxSupply` are `bigint | number`. -Reference tests in `src/__tests__/patterns/`: +Do not "fix" JS fixtures to the Rust client's `AssetAmount` type; that is a Rust-side concept and +does not cross the WASM boundary. -| 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 | +## Mock shapes that need checking -### Minimum test coverage per component +These shapes type-check against a loosely-typed fixture and then lie at runtime. Check each one against the current API: -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 +- **`debugMode` does not exist.** `MidenConfig` is + `{ rpcUrl?, noteTransportUrl?, autoSyncInterval?, seed?, prover?, proverUrls?, proverTimeoutMs?, useWorker? }`. + Drop any `debugMode` field and any trailing `debugMode` argument to `createClient*`. +- **Transaction results expose `accountPatch()`, not `accountDelta()`**, and there is no + `AccountStorageDelta`. The mirror image still holds: `TransactionSummary.accountDelta()` is + unchanged and still returns a relative delta — do not "fix" that one for consistency. +- **`TransactionSummary` carries `userParams()` rather than a single `salt()`**, and the value is + seven field elements. The whole summary preimage is six words: four leading commitments, then + `expiration_delta` plus seven user params, 24 elements in total. A fixture mocking a four-word + summary is wrong. -## Wallet connection state in tests +Pin `@miden-sdk/miden-sdk` and `@miden-sdk/react` to the **same exact** version — they link against +a shared WASM ABI, and a plain `"0.16"` or `^0.16.0` does not resolve a pre-release: -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. +```json +{ "@miden-sdk/miden-sdk": "0.16.0-rc.7", "@miden-sdk/react": "0.16.0-rc.7" } +``` -Setup at the top of the test file: +Hooks worth mocking that are easy to forget: `useBridge`, `useChainAnchor`, `useCompile`, +`useCreateNetworkNote`, `useExecuteProgram`, `useExportNote`, `useExportStore`, `useImportAccount`, +`useImportNote`, `useImportStore`, `useNoteStream`, `usePreview`, `usePswapCancel`, +`usePswapCancelByOrder`, `usePswapConsume`, `usePswapCreate`, `usePswapLineage` / +`usePswapLineages` / `usePswapLineagesFor`, `useSessionAccount`, `useSigner`, `useSyncControl`, +`useTransactionHistory`, `useWaitForCommit`, `useWaitForNotes`. -```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", - }, -})); +## Wallet connection state in tests -import { useMidenFiWallet } from "@miden-sdk/miden-wallet-adapter-react"; -``` +The only wallet surface that can be grounded against the SDK is what `@miden-sdk/react` itself +exports, so mock that and nothing else: -Per-test overrides match the states the template renders: +- **`useSigner()`** returns `SignerContextValue | null`. Its required members are `signCb`, + `accountConfig`, `storeName`, `name`, `isConnected`, `connect` and `disconnect` (plus optional + `getKeyCb` / `insertKeyCb`) — a fixture built from only the connection fields will not type-check. +- **`useMiden()`** exposes `signerAccountId` and `signerConnected`. +- **`waitForWalletDetection(adapter, timeoutMs = 5000)`** is the SDK's adapter-agnostic detection + primitive. It takes a duck-typed `WalletAdapterLike { readyState: string; on/off("readyStateChange") }`, + resolves once `readyState === "Installed"`, and rejects on timeout. Both it and the + `WalletAdapterLike` type are exported from `@miden-sdk/react`, so a fake adapter object is enough + to drive install-pending / installed states: ```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); +const adapter: WalletAdapterLike = { + readyState: "NotDetected", + on: vi.fn(), + off: vi.fn(), +}; ``` -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. +Write wallet-connect UI against `useSigner()` and this duck type rather than against a specific +adapter package, and the tests stay valid whichever adapter ships. -Vitest config externalizes `@miden-sdk/miden-wallet-adapter-react` to prevent broken transitive resolution. +> **Not covered here:** the concrete signer packages (`@miden-sdk/use-miden-para-react`, +> `@miden-sdk/miden-turnkey-react`, `@miden-sdk/miden-wallet-adapter-react`) live in repositories +> outside the Web SDK, so their props, exported hooks and versions cannot be established from it and +> this skill prescribes nothing about them. The SDK's own example composes signers as siblings under +> `MultiSignerProvider` with a `SignerSlot` each, and passes `MidenFiSignerProvider` a plain string +> (`network="testnet"`), not an enum member. -## Automated Verification Pipeline +## Minimum coverage per component -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) -``` +1. **Success state** — renders correctly with data +2. **Loading state** — shows a loading indicator (`isLoading` / `isInitializing`) +3. **Error state** — shows the error and a recovery action +4. **User interactions** — buttons and forms call the right handler ## 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. +- **Not mocking the SDK.** Components importing `@miden-sdk/react` fail without a `vi.mock`, because + the real provider initializes WASM before rendering children. +- **Confusing `SendResult` with `TransactionResult`.** `useSend` gives `{ txId, note }`; the others + give `{ transactionId }`. +- **Asserting a permanently-empty query hook.** Query hooks refetch when `isReady` flips. +- **Carrying `debugMode` or `accountDelta()` forward** into fixtures. +- **Leaving mock state between tests.** Call `vi.clearAllMocks()` in `beforeEach`. +- **Mocking `useMiden` and expecting a real hook to notice.** `useAccounts` and friends import + `useMiden` from an internal module, not from the package entry — mock the hook under test itself. diff --git a/skills/u32-assert-before-u32-ops/SKILL.md b/skills/u32-assert-before-u32-ops/SKILL.md index d8964e5..ea22f53 100644 --- a/skills/u32-assert-before-u32-ops/SKILL.md +++ b/skills/u32-assert-before-u32-ops/SKILL.md @@ -7,37 +7,33 @@ description: Use when writing MASM `u32*` instructions on values from user input ## Rule -MASM's `u32*` instructions require their operands to be valid `u32` values (i.e. fit in 32 bits, `<= u32::MAX`). Applied to a non-u32 value the behavior is *undefined*: in the current VM such an op typically traps with the generic error (`operation expected u32 values, but got values: ...`) rather than telling you *which* precondition was violated — but trapping is not guaranteed (it may instead wrap/truncate and silently poison the proof). +MASM's `u32*` instructions assume their operands are valid `u32` values (i.e. fit in 32 bits). Operating on a non-u32 value silently produces garbage or traps with a generic message. -Before applying any `u32*` instruction to a value that is not already known to be a valid u32 (e.g. it came from the stack as input, was read from memory, or arose from a non-u32 arithmetic op), assert the bound with a descriptive error: +Before applying any `u32*` instruction to a value that is not already known to be a valid u32 (e.g. it came from the stack as input, was read from memory, or arose from a non-u32 arithmetic op), assert the bound: ```masm -u32assert # assert the one value on top of the stack -u32assert2 # assert the two values on top of the stack -u32assertw # assert the four elements of the word on top of the stack +u32assert # one value +u32assert2 # two top values +u32assert4 # four top values ``` -All three accept a named error via `.err=`, e.g. `u32assert2.err=ERR_NOT_U32`. - If the operand is already known-valid (just produced by another `u32*` op, or a value loaded from a slot whose layout is u32 by construction), skip the assert. ## Why -`u32*` instructions are tuned for the precondition that operands fit in 32 bits. Passing an out-of-range operand to a `u32*` op is undefined behavior: the current VM almost always traps with the generic `operation expected u32 values, but got values: ...` error, but trapping is not guaranteed — it may instead wrap/truncate and silently poison the proof, and even when it traps it does not name the precondition or the call site. Asserting first with `u32assert*.err=` turns this fragile/undefined path into a deterministic, named failure mode, so a non-u32 input fails loudly and diagnosably at the point where the assumption is introduced. +`u32*` instructions are tuned for the precondition that operands fit in 32 bits, and the VM does not check it for you. Skipping `u32assert*` lets a non-u32 input silently produce a wrong result or trap uninformatively; the assert gives the bug a named failure mode. ## Examples ```masm # Good: assert u32 before the u32 op u32assert.err=ERR_VALUE_NOT_U32 -u32overflowing_add +u32add # Good: both operands at once u32assert2.err=ERR_VALUES_NOT_U32 u32lt # Bad: u32 op on untrusted input -u32overflowing_add # if an operand exceeds 2^32 the behavior is undefined; the current - # VM typically traps with the generic "operation expected u32 values" - # error instead of a named one (but may instead wrap and poison the proof) +u32add # one operand could be >2^32; silently wraps or traps ``` diff --git a/skills/vite-wasm-setup/SKILL.md b/skills/vite-wasm-setup/SKILL.md index c8d1d9d..49a61c9 100644 --- a/skills/vite-wasm-setup/SKILL.md +++ b/skills/vite-wasm-setup/SKILL.md @@ -1,10 +1,12 @@ --- 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. +description: Guide to configuring Vite for Miden WASM applications. Covers the midenVitePlugin() setup and its four options, single- vs multi-threaded WASM entry points, COOP/COEP headers, production deployment headers, the Web Worker shim, 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 +Everything below is current for `@miden-sdk/*` `0.16.0-rc.7` (web-sdk repo). + ## Required `vite.config.ts` ```typescript @@ -17,53 +19,227 @@ export default defineConfig({ }); ``` -`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). +`midenVitePlugin` is exported both as a named export and as the default export, +and the plugin declares `enforce: "pre"` so it runs ahead of other plugins' +`config` hooks. It 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 in-repo example app +(`packages/react-sdk/examples/wallet/vite.config.ts`) is the source-of-truth +reference; it calls `midenVitePlugin()` bare alongside the Para plugin: + +```typescript +plugins: [react(), midenVitePlugin(), paraVitePlugin()], +``` + +## Plugin Options + +`MidenVitePluginOptions` has **four** options. All are optional; these are the +real defaults taken from the destructuring in `packages/vite-plugin/src/index.ts`: + +| Option | Type | Default | Purpose | +|---|---|---|---| +| `wasmPackages` | `string[]` | `["@miden-sdk/miden-sdk"]` | Packages to alias, dedupe, and exclude from pre-bundling | +| `crossOriginIsolation` | `boolean` | `false` | Emit COOP/COEP headers on the dev + preview servers | +| `rpcProxyTarget` | `string \| false` | `"https://rpc.testnet.miden.io"` | gRPC-web dev proxy target; `false` disables the proxy | +| `rpcProxyPath` | `string` | `"/rpc.Api"` | Path prefix the dev proxy matches on | + +```typescript +midenVitePlugin({ + wasmPackages: ["@miden-sdk/miden-sdk"], + crossOriginIsolation: false, + rpcProxyTarget: "https://rpc.testnet.miden.io", + rpcProxyPath: "/rpc.Api", +}); +``` + +> **Do not trust `packages/vite-plugin/README.md` for the +> `crossOriginIsolation` default.** At `0.16.0-rc.7` that README still shows +> `crossOriginIsolation: true, // default` and a `**Default:** \`true\`` bullet, +> while the executable source destructures `crossOriginIsolation = false` and a +> unit test asserts the plugin "does not set COOP/COEP headers by default" +> (`packages/vite-plugin/src/__tests__/config.test.ts`). The source wins. + +Don't reach for `crossOriginIsolation: true` unless you have actually opted into +the multi-threaded build. + +## Multi-Threaded (MT) WASM — Opt-In, Two Requirements + +Pass `crossOriginIsolation: true` **only** if you import the multi-threaded WASM +variant — `@miden-sdk/miden-sdk/mt` (or `/mt/lazy`) and `@miden-sdk/react/mt` +(or `/mt/lazy`). All four entry points exist in both packages' `exports` maps. +The MT build uses `wasm-bindgen-rayon` and `SharedArrayBuffer` / +`WebAssembly.Memory({ shared: true })` for ~3–5x faster local proving. + +**1. The page must be cross-origin-isolated** (COOP `same-origin` + COEP +`require-corp`). Without those headers the browser refuses to construct +`WebAssembly.Memory({ shared: true })` and the MT WASM fails to instantiate at +SDK load. `midenVitePlugin({ crossOriginIsolation: true })` covers the Vite dev +and preview servers only — see Production Deployment Headers. + +**2. You must bring up the rayon thread pool yourself.** Every MT entry +re-exports `initThreadPool(n)` from `wasm-bindgen-rayon`. **The React SDK does +NOT call it for you.** Skip it and rayon spawns zero workers, every +`par_iter(...)` falls through to a sequential loop, and you have paid the full +COOP/COEP deployment cost to prove single-threaded anyway. + +```typescript +import { MidenClient, initThreadPool } from "@miden-sdk/miden-sdk/mt/lazy"; + +await MidenClient.ready(); +await initThreadPool(navigator.hardwareConcurrency); // once, at startup +``` + +Under React, gate it on readiness inside the provider tree: + +```tsx +import { useEffect } from "react"; +import { MidenProvider, useMiden } from "@miden-sdk/react/mt/lazy"; +import { initThreadPool } from "@miden-sdk/miden-sdk/mt/lazy"; + +function ThreadPoolBoot() { + const { isReady } = useMiden(); + useEffect(() => { + if (!isReady) return; + void initThreadPool(navigator.hardwareConcurrency); + }, [isReady]); + return null; +} +``` -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. +`initThreadPool` is idempotent — calling it again resolves with the existing +pool. The ST entries do not expose it (there is no thread pool to bring up). -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). +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` — you keep a fully working Miden client and +only forgo MT-accelerated local proving. Enabling `crossOriginIsolation: true` +also breaks OAuth-popup flows (e.g. Para), because `same-origin` COOP nullifies +`window.opener` in popups; this is the plugin's own stated reason for defaulting +the option to `false`. ## 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: +`@miden-sdk/vite-plugin` abstracts Miden-specific Vite configuration. It only +implements the `config` and `configResolved` hooks — it does **not** register a +`.wasm` module loader, because Vite's built-in handling does the actual `.wasm` +import. What the plugin sets up: + +- **WASM dedup / single copy** — `resolve.alias` (an exact-match `^$` + regex, so subpath imports like `/lazy` still resolve through the package's + `exports` map), `resolve.dedupe`, and `resolve.preserveSymlinks: true` force a + single resolved copy of each entry in `wasmPackages`. The alias replacement is + computed with `require.resolve` for pnpm / Yarn PnP portability. The dedupe + list is `[...wasmPackages, "react", "react-dom", "react/jsx-runtime", + "@miden-sdk/react"]` +- **optimizeDeps.exclude** — excludes `wasmPackages` from pre-bundling + (pre-bundling corrupts the WASM binary) +- **Top-level await** — sets `build.target: "esnext"`, and in `configResolved` + also forces `optimizeDeps.esbuildOptions.target = "esnext"` +- **ES-module workers** — sets `worker.format: "es"` and + `worker.rollupOptions.output.format = "es"`, required for the SDK's module + workers +- **COOP/COEP headers (opt-in, MT only)** — guarded by + `if (crossOriginIsolation)`; when enabled it emits `Cross-Origin-Opener-Policy: + same-origin` + `Cross-Origin-Embedder-Policy: require-corp` on **both** + `server.headers` and `preview.headers` +- **gRPC-web dev proxy** — when `rpcProxyTarget !== false && env.command === + "serve"`, proxies `rpcProxyPath` (default `/rpc.Api`) to `rpcProxyTarget` with + `changeOrigin: true`, to bypass CORS in dev +- **React context dedup** — an `externalize-miden-react` esbuild plugin pushed + into `optimizeDeps.esbuildOptions.plugins` marks `@miden-sdk/react` external + during pre-bundling, so signer-provider React contexts share one identity -- **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 +The `configResolved` hook re-applies the esbuild plugin, the esnext target, and +the dedupe entries *after* all plugins' `config` hooks have merged, so other +plugins (e.g. `vite-plugin-node-polyfills`) can't overwrite them. -You don't need to install or configure `vite-plugin-wasm`, `vite-plugin-top-level-await`, or dexie aliases manually. +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. +At `0.16.0-rc.7`, `@miden-sdk/miden-sdk`, `@miden-sdk/react`, and +`@miden-sdk/vite-plugin` all publish at the same version. Pin them exactly: ```json { "dependencies": { - "@miden-sdk/react": "", - "@miden-sdk/miden-sdk": "", - "@miden-sdk/miden-wallet-adapter-react": "" + "@miden-sdk/miden-sdk": "0.16.0-rc.7", + "@miden-sdk/react": "0.16.0-rc.7" }, "devDependencies": { - "@miden-sdk/vite-plugin": "" + "@miden-sdk/vite-plugin": "0.16.0-rc.7" } } ``` 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. + +- **These are prerelease versions, and npm range syntax does not match + prereleases.** `"0.16"`, `"^0.16.0"`, `"~0.16.0"` and `"0.16.x"` will NOT + resolve to `0.16.0-rc.7` — they either fail to resolve or silently drift to a + later stable line. Use the exact string, or the caret-on-a-prerelease form + `"^0.16.0-rc.7"` that the in-repo example + (`packages/react-sdk/examples/wallet/package.json`) uses. +- **`@miden-sdk/react` and `@miden-sdk/miden-sdk` must match.** The React SDK's + peer dependency is `"@miden-sdk/miden-sdk": "^0.16.0-rc.7"`, and the repo + enforces the coupling in CI. Per its `README.md`: "A repo-wide + `scripts/check-react-sdk-sync.js` enforces that React peer ranges and example + dependencies pin to the exact patch version of the WASM client they were + built against." Upgrade them together. +- **The vite-plugin still carries its own version field.** It happens to match + the core SDK at this pin, but it is released through a separate gate + (`scripts/check-vite-plugin-version-release.sh` publishes it only when its + local `package.json` version is not already on npm), so it can drift between + releases. Always defer to your app's `package.json` rather than assuming + `vite-plugin === miden-sdk`. +- **The wallet adapter lives in a separate repo.** The example app imports + `MidenFiSignerProvider` from `@miden-sdk/miden-wallet-adapter-react`, which is + published from [`0xMiden/miden-wallet-adapter`](https://github.com/0xMiden/miden-wallet-adapter), + not the web-sdk repo. Confirm its version against that repo or your app's + `package.json`. +- **When you bump, do a clean install with the package manager the project + actually uses.** The web-sdk itself is pnpm-only (`rm -rf node_modules + pnpm-lock.yaml && pnpm install`); the example app declares + `packageManager: "pnpm@9.15.4"` with `engines.node >= 20` and + `engines.pnpm >= 9`. + +## The Web Worker Shim (`useWorker`) + +By default the SDK spawns a Web Worker and runs WASM calls off the main thread +(`ClientOptions.useWorker` defaults to `true`). This matters for a Vite app +because the worker is loaded via `new Worker(new URL(...), { type: "module" })`, +which is why the plugin sets `worker.format: "es"`. + +Set `useWorker: false` when: + +- You pass a `CallbackProver` via `TransactionProver.newCallbackProver(jsFn)`. + The worker boundary serializes the prover with `TransactionProver.serialize()`, + which has no encoding for the callback variant and silently downgrades it to + `"local"` — your callback would never fire. +- You embed the client in a single-WebView native shell (Capacitor host, Tauri, + Electron preload), where the UI thread isn't competing with the WASM thread + anyway. + +`MidenProvider` forwards `config.useWorker` straight into +`WebClient.createClient(...)` / `WebClient.createClientWithExternalKeystore(...)`, +so React consumers set it on the provider config. ## 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. +These headers apply **only if you ship the MT WASM variant** (`/mt` or +`/mt/lazy`). The default ST build needs none of this. 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. + +`crates/web-client/README.md` ("Setting cross-origin isolation headers") is the +maintained reference and covers more hosts than the snippets below, including +Next.js (`next.config.mjs` `headers()`), Express / generic Node +(`res.setHeader`), and MV3 browser-extension manifests +(`"cross_origin_opener_policy": { "value": "same-origin" }`). ### Nginx ```nginx @@ -93,48 +269,66 @@ add_header Cross-Origin-Embedder-Policy require-corp; 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: +These apply only once you've enabled cross-origin isolation for the MT build — +the default ST build sets no such headers and is unaffected. `require-corp` +blocks any cross-origin resource (images, fonts, iframes, scripts) that doesn't +carry `Cross-Origin-Resource-Policy: cross-origin` or appropriate CORS. In +practice that breaks: + - **Third-party iframes** (YouTube embeds, Twitter embeds, analytics) - **External scripts** without CORS headers -- **OAuth popups** from different origins +- **OAuth popups** from different origins (COOP `same-origin` nullifies + `window.opener`) -Workaround: Use `credentialless` for COEP if you need cross-origin resources: -``` -Cross-Origin-Embedder-Policy: credentialless -``` +Treat it as a deployment decision and opt in only when you understand your +page's resource graph. -Note: `credentialless` provides weaker isolation but allows most cross-origin resources. +If you cannot set the headers at all (CDN, hosting provider that doesn't allow +header injection), the documented escape hatch is the COI service-worker shim +pattern (`gzuidhof/coi-serviceworker`): a small same-origin service worker +intercepts fetches and re-injects the headers on the way back. The SDK +deliberately does not bundle it, because installing a service worker into a +consumer's app is intrusive — adopt it consciously. ## TypeScript Compatibility -Standard Vite-compatible tsconfig settings work with Miden. The only actual constraint is ES2020+ for `bigint` support: +Standard Vite-compatible tsconfig settings work with Miden. The real constraint +is an ES2020+ target, because the SDK's public types use `bigint` (asset +amounts, `Felt` values, and every `JsU64` return are JS `BigInt`s). The shipped +example's tsconfig is the reference: ```json { "compilerOptions": { - "target": "ES2022", - "lib": ["ES2022", "DOM", "DOM.Iterable"], + "target": "ES2020", + "lib": ["ES2020", "DOM", "DOM.Iterable"], "module": "ESNext", - "moduleResolution": "bundler" + "moduleResolution": "bundler", + "useDefineForClassFields": true, + "jsx": "react-jsx", + "isolatedModules": true, + "strict": true, + "noEmit": true } } ``` -`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+. +`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) | +| MT build is no faster than ST | `initThreadPool(n)` was never awaited, so rayon has zero workers | `await initThreadPool(navigator.hardwareConcurrency)` once at startup — nothing else calls it for you | +| WASM module not found | SDK not configured correctly | Ensure `midenVitePlugin()` is in the plugins array | +| "Top-level await not supported" | Missing plugin setup | Ensure `midenVitePlugin()` is in the plugins array (it sets `build.target: "esnext"` and the esbuild target) | +| Module evaluation hangs on load (Capacitor WKWebView, Next.js SSR) | The default eager entry awaits WASM at module top level; TLA blocks SSR module evaluation and hangs in Capacitor's `capacitor://localhost` scheme handler | Import `@miden-sdk/miden-sdk/lazy` (no top-level await) and `await MidenClient.ready()` before touching any wasm-bindgen constructor | +| WASM init hangs in the browser | COEP blocking the WASM fetch | Check the network tab for blocked requests; verify the COOP/COEP headers are actually present | +| "recursive use of an object" | Concurrent WASM access | Use `runExclusive()` from `useMiden()` | +| Double initialization in dev | React StrictMode | Use `MidenProvider` (it queues init through `runExclusive` and handles the StrictMode double mount internally) | +| A VM assertion failure reports only an error code, no message | Production builds strip MASM debug metadata (source spans and `assert.err` text) from the embedded Miden packages, cutting the ST binary from 27.4 MB to 18.8 MB | Reproduce against a dev build (`MIDEN_WEB_DEV=true`), which keeps full diagnostics | diff --git a/skills/wasm-bridge/SKILL.md b/skills/wasm-bridge/SKILL.md index 860059d..3e263d5 100644 --- a/skills/wasm-bridge/SKILL.md +++ b/skills/wasm-bridge/SKILL.md @@ -5,11 +5,13 @@ description: Enforce conventions for the Rust<->JavaScript WASM boundary in the # WASM Bridge Patterns (web-client / miden-client-web) -At v0.15 the web client lives in the dedicated **web-sdk** repo +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). +boundary crate is `crates/web-client` (cargo package `miden-client-web`, +`crate-type = ["cdylib"]`). Companion workspace crates: `crates/js-export-macro` +(the `#[js_export]` proc-macro), `crates/idxdb-store` (`miden-idxdb-store`, the +IndexedDB store), `crates/mobile-prover` (`miden-mobile-prover`, a C-ABI native +prover for iOS/Android Capacitor plugins), and `tools/strip-masp-debug`. The crate dual-targets two binding technologies from one Rust source: - **browser** (the `browser` feature) via `wasm_bindgen`, error type `JsValue` @@ -20,14 +22,19 @@ 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. + `napi::Error` on nodejs). Two constructors: `from_str_err(msg: &str) -> JsErr` + and `from_str_err_with_code(msg: &str, code: &str) -> JsErr`. - `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. + on nodejs; `.lock().await` yields a `DerefMut` guard. The browser branch also + exposes a synchronous `.borrow() -> std::cell::Ref<'_, T>`, used by + `#[wasm_bindgen(getter)]` methods that cannot be async. +- `maybe_wrap_send` — pass-through on browser; on nodejs it unsafely asserts + `Send` on a future so napi's multi-threaded tokio runtime accepts it. Wrap + boxed client futures with it before `.await` in dual-platform methods. ## Exposing Rust Methods to JavaScript @@ -111,7 +118,8 @@ where 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. + // branch on, so they don't have to match the (changeable) message text. + // The worker shim's serializeError forwards both `code` and `help`. if let Some(code) = code_from_error(&err) { let _ = Reflect::set(&js_error, &JsValue::from_str("code"), &JsValue::from_str(code)); } @@ -133,13 +141,46 @@ 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`. +3. Browser path: attaches `help` (the hint) and `code` (from `code_from_error`) + 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. +### Stable Error Codes + +JS callers branch on a `code`, never on message text. Two primitives produce one: + +- `code_from_error` (consulted inside `js_error_with_context`) maps + `ClientError::AccountNotFoundOnChain` to `ACCOUNT_NOT_FOUND_ON_CHAIN` and + `ClientError::AccountAlreadyTracked` to `ACCOUNT_ALREADY_TRACKED`, recursing + through `err.source()`. +- `from_str_err_with_code(msg, code)` (in `platform.rs`) attaches a code to an + error built from a plain string. Currently used for + `TRANSACTION_ALREADY_AUTHORIZED` (raised by `execute_for_summary` / + `execute_for_summary_at` when execution produced no summary because the + transaction was already fully authorized) and `INVALID_CHAIN_ANCHOR` (from + `ClientError::ChainAnchorError`, routed through `map_anchor_err`). + +The two platforms surface the code differently, and consumers must handle both: + +```rust +// browser: a `code` property on the JS Error object +let js_error: wasm_bindgen::JsValue = wasm_bindgen::JsError::new(msg).into(); +let _ = js_sys::Reflect::set( + &js_error, + &wasm_bindgen::JsValue::from_str("code"), + &wasm_bindgen::JsValue::from_str(code), +); + +// nodejs: napi's error `code` is its fixed Status enum, so the stable code is +// prefixed onto the message instead: ": " +napi::Error::from_reason(format!("{code}: {msg}")) +``` + +When you add a code, add it to this vocabulary deliberately — it is a public +contract. Do not document a code for an error path that does not actually reach +it (`map_anchor_err` carries an explicit comment about exactly that). + ### Error Pattern in Every Method ```rust @@ -171,6 +212,11 @@ pub struct Word(NativeWord); #[js_export] impl Word { + /// Creates a word from four numeric values. + /// + /// Each input must be a canonical field element, i.e. strictly less than the + /// field modulus. `Felt::new` errors out on inputs at or beyond the modulus; + /// the error is surfaced to JS. #[js_export(constructor)] pub fn new(u64_vec: Vec) -> Result { if u64_vec.len() != 4 { @@ -187,7 +233,7 @@ impl Word { .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 + .map(|&v| NativeFelt::new(v)) .collect::, _>>() .map_err(|err| from_str_err(&format!("invalid field element: {err}")))? .try_into() @@ -209,10 +255,29 @@ Notes: 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()`. +- `NativeFelt::new` is **fallible** — it returns a `Result` and rejects values at + or beyond the field modulus. Propagate that failure; do not `.unwrap()` it. +- Constructors that can fail (length checks, `Felt::new`) return + `Result<_, JsErr>`. - `from_hex` takes `String` (not `&str`) and returns `Result`. +### Felt on the JS side vs. the Rust side + +The Rust `Felt` newtype (`crates/web-client/src/models/felt.rs`) is +`Felt(NativeFelt)` with `pub fn new(value: JsU64) -> Result`, +`as_int() -> JsU64`, and `to_string() -> String`. On the JS side that surfaces +as a class taking a single **`BigInt`**, not a number and not an array: + +```javascript +const felt = new Felt(42n); // BigInt argument; throws on non-canonical values +const value = felt.asInt(); // BigInt back out +const felts = word.toFelts(); // Felt[] from a Word +const word = Word.newFromFelts([f0, f1, f2, f3]); +``` + +Passing a JS `Number` where `JsU64` is expected is a boundary bug — every +`JsU64` parameter and return is a `BigInt` in JS on both platforms. + ### Required Conversions and Accessors Implement the `From` conversions, and put the internal `as_native` accessor in a @@ -243,15 +308,37 @@ impl From<&Word> for NativeWord { ``` 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. +the Node.js side, also invoke `impl_napi_from_value!(Word);` (defined twice with +`#[cfg]` gates in `crates/web-client/src/miden_array.rs`: the nodejs version +implements `FromNapiValue` by delegating to `FromNapiRef` and cloning, the +browser version expands to nothing). 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. +### Keeping the JS API stable across a Rust rename + +When an upstream Rust API is renamed or its semantics change, keep the JS name +and adapt inside the wrapper rather than breaking JS callers. +`crates/web-client/src/models/account_builder.rs` is the model: + +- `AccountBuilder::build()` calls upstream `build_with_schema_commitment()`, so + the default JS `build()` now merges the storage-schema-commitment component. + `buildWithoutSchemaCommitment()` is exposed for the legacy behaviour. +- `withAuthComponent` is a back-compat shim whose body forwards to + `with_component`, because upstream removed `with_auth_component` and now + identifies the auth component by its `@auth_script` MASM attribute. + +Similarly, when an upstream type is added rather than replaced, wrap both and +keep them. `models/account_patch/{mod,storage,vault}.rs` wrap `AccountPatch` / +`AccountStoragePatch` / `AccountVaultPatch` (absolute post-transaction state), +while `models/account_delta/` survives because `TransactionSummary.accountDelta()` +still returns a relative delta. Both directories coexist deliberately — do not +delete one as "superseded". + ## Data Transfer Objects For complex data that crosses the WASM boundary, use a dual-platform @@ -259,6 +346,7 @@ For complex data that crosses the WASM boundary, use a dual-platform field names with browser-side `js_name`: ```rust +// crates/web-client/src/models/account_storage.rs #[cfg_attr(feature = "browser", wasm_bindgen(getter_with_clone, inspectable))] #[cfg_attr(feature = "nodejs", napi(object))] #[derive(Clone)] @@ -281,7 +369,8 @@ Rules: 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). + native handle rather than a plain-data object). See `models/address.rs`, + `models/code_builder.rs`, and the array wrappers in `miden_array.rs`. - Field names: snake_case in Rust, camelCase via browser-side `js_name`. - Serialize complex values to hex strings or `JsBytes`/`Vec` where needed. @@ -291,14 +380,14 @@ 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. +/// Awaits a JavaScript `Promise` and returns the raw `JsValue` on success. 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. +/// Awaits a JavaScript `Promise` and deserializes the result into `T`. pub(crate) async fn await_js(promise: Promise, ctx: &str) -> Result where T: DeserializeOwned, @@ -308,7 +397,7 @@ where .map_err(|err| StoreError::DatabaseError(format!("failed to deserialize ({ctx}): {err:?}"))) } -/// Awaits a JavaScript Promise and discards the result. +/// 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(()) @@ -320,7 +409,20 @@ Rules: - 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`.) +- Import `Promise` as `use wasm_bindgen_futures::js_sys::Promise;` in this + module (the store re-exports it through `wasm_bindgen_futures` rather than + depending on `js_sys` directly there). + +### The account forest is Rust-side, not Dexie-side + +`crates/idxdb-store/src/forest.rs` holds `AccountForest`: an in-memory +`AccountSmtForest` with a monotonic `VersionId`, rebuilt +from the account tables on every store open. It exists because the forest +`Backend` trait is synchronous while every IndexedDB access from WASM goes +through a JS promise. Asset and storage-map **witnesses** are served from this +forest, not from Dexie. Updates are forward-only — there is no staging or +rollback; recovery is `IdxdbStore::rebuild_account_forest`. Don't add a +promise-backed path for witness reads. ## Importing JS Functions from Rust @@ -348,31 +450,42 @@ Rules: - Return `js_sys::Promise` for async operations. - Pass simple types across the boundary: `&str`, `JsValue`, `Vec`, `u32`. -## JS Wrapper Layer +## JS Layers -The web-client crate ships **two** JS layers under `crates/web-client/js/`: +`crates/web-client/js/` holds several modules. Two are the layers you extend: 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: + MockWasmWebClient, MockWebClient, withSyncLock }`). 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: + - `withSyncLock(dbId, methodId, fn)` (`js/syncLock.js`, Web Locks via + `navigator.locks`; also exports `hasWebLocks`) to coalesce concurrent syncs + and serialize them across tabs. Sync-family methods nest the two: `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.) + `WRITE_METHODS`) enforced by `scripts/check-method-classification.js`. + `SYNC_METHODS` is a historical misnomer — it groups methods forwarded + transparently to the WASM through `createClientProxy`, i.e. that need no + explicit JS-class wrapper; it does not mean "synchronous". + `WRITE_METHODS` / `READ_METHODS` exist solely for the CI lint + (`void WRITE_METHODS; void READ_METHODS;`). + - the Web Worker shim (below). 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`. + owns a `WebClient` instance and exposes **eight** 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`), `client.keystore`, and + `client.pswap` (a `PswapResource`). Each resource lives under + `js/resources/.js`. + +Supporting modules in the same directory: `eager.js` (top-level-await entry), +`wasm.js` (loader), `standalone.js` (tree-shakeable note/tag builders), +`storageView.js`, `constants.js`, `asyncLock.js`, `webLock.js`, `syncLock.js`, +`utils.js`, `node-index.js` + `node/` (the napi entry), and +`workers/web-client-methods-worker.js`. `index.js` injects the WASM constructor and the `getWasm` initializer into `MidenClient` via static fields to break the import cycle: @@ -383,13 +496,20 @@ MidenClient._MockWasmWebClient = MockWebClient; MidenClient._getWasmOrThrow = getWasmOrThrow; ``` -There is **no** `safe-arrays.js` module. The wasm-bindgen array wrappers -(`NoteArray`, `OutputNoteArray`, `AccountArray`, `ForeignAccountArray`, ...) are +### Array wrappers consume their elements + +There is **no** `safe-arrays.js` module. The wasm-bindgen array wrappers 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: +`crates/web-client/src/models/mod.rs`), which produces twelve types: +`AccountArray`, `AccountIdArray`, `ForeignAccountArray`, `NoteRecipientArray`, +`NoteArray`, `OutputNoteArray`, `StorageSlotArray`, +`TransactionScriptInputPairArray`, `FeltArray`, `NoteAndArgsArray`, +`NoteDetailsAndTagArray`, `NoteIdAndArgsArray`. + +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 @@ -398,6 +518,53 @@ const ownOutputs = new wasm.NoteArray(); ownOutputs.push(note); ``` +### The Web Worker shim + +A Web Worker is spawned **by default**: `ClientOptions.useWorker` defaults to +`true`, and the worker runs the WASM off the main thread. Two knobs govern it: + +- `WebClient.workerMode` — a static, default `"auto"`, with values + `"auto" | "module" | "classic"`. `"auto"` picks `classic` on Safari/WKWebView + (where module workers cold-start very slowly) and `module` everywhere else, + by sniffing `navigator.userAgent`. `"module"` forces the `.module.js` + ES-module worker, required for webpack 5 / Next.js consumers so the asset + tracer can see the WASM URL. `"classic"` forces the `.js` classic-script + worker. Set it before the first `WebClient.createClient(...)` call. +- `ClientOptions.useWorker: false` — skips the shim entirely and calls the + wasm-bindgen `WebClient` on the current thread. **Required for callback + provers**: the worker boundary serializes the prover with + `TransactionProver.serialize()`, which has no encoding for + `newCallbackProver(jsFn)` and silently downgrades it to `"local"`, so the + callback never fires. Also the right choice in single-WebView native shells + (Capacitor, Tauri, Electron preload). `lastAuthError()` is likewise meaningful + only with `useWorker: false`, because the worker's keystore lives in the + worker's WASM instance. + +Both `new Worker(new URL("...", import.meta.url), ...)` call sites in +`js/index.js` are spelled out literally and duplicated on purpose: webpack 5's +new-worker detector is purely syntactic, and hoisting either URL into a variable +downgrades it to a plain asset copy, which makes the worker's dynamic +`import("./Cargo-*.js")` 404. Do not refactor that duplication away. + +The worker entry is `js/workers/web-client-methods-worker.js`; its message +vocabulary lives in `js/constants.js` (`WorkerAction`, `CallbackType`, +`MethodName`). On the MT build the worker is also where `wasm.initThreadPool(n)` +is called, via the `INIT` / `INIT_MOCK` actions with `numThreads` plumbed +through. + +### Node entry re-exports and name shadowing + +`js/node-index.js` is generated by `scripts/gen-node-reexports.js` and +CI-checked by `check:node-reexports`. Three names are excluded from generation +because plain-JS frozen-object enum consts shadow the napi classes: +`WebClient` (re-exported wrapped as `WasmWebClient`), `AccountType`, and +`AuthScheme` — for which the napi class is re-exported by hand as +**`AuthSchemeNative`**. The browser entry (`js/index.js`) has the same shadowing +(`AccountType`, `AuthScheme`, `NoteVisibility`, `StorageMode`, `Linking` are all +`Object.freeze({...})` consts) but exposes **no** `AuthSchemeNative` alias. When +you add a JS-side enum const, check whether it collides with a generated class +name and update the generator's exclusion list. + ### Adding a method When extending the SDK, choose the layer based on whether the work is @@ -427,15 +594,57 @@ async get(ref) { Rules: -- Always call `this.#client.assertNotTerminated()` at entry — late callbacks on - a torn-down client otherwise panic with "null pointer passed to rust". +- Always call `this.#client.assertNotTerminated()` at entry. It throws + `Error("Client terminated")` up front, instead of letting a late call on a + torn-down client reach a freed wasm handle — where wasm-bindgen traps with + "null pointer passed to rust", an error that reads like an unrelated bug. + `CompilerResource` uses the optional-chaining form + `this.#client?.assertNotTerminated()` because it can be constructed standalone. - 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.) + `crates/web-client/js/utils.js`, imported from a resource as `../utils.js`, so + callers can pass any natural form (hex, bech32 address, WASM type). The full + set is `resolveAccountRef`, `resolveAddress`, `resolveNoteType`, + `resolveStorageMode`, `resolveAuthScheme`, `resolveNoteIdHex`, + `resolveTransactionIdHex`, and `hashSeed`. (There is no `utils.js` inside + `js/resources/` — that directory holds only the eight resource files: + accounts, compiler, keystore, notes, pswap, 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. + +### `_withInnerWebClient(fn)` — the `@internal` escape hatch + +`MidenClient._withInnerWebClient(fn)` runs `fn` with exclusive access to the +proxied JS `WebClient`, so `fn` can reach lower-level methods +(`executeTransaction`, `proveTransaction[WithProver]`, `submitProvenTransaction`, +`applyTransaction`, `newSendTransactionRequest`, ...). It is intended for +splitting the bundled execute -> prove -> submit -> apply pipeline across +contexts — e.g. an MV3 extension that executes in its service worker, proves in +a `chrome.offscreen` document, then submits and applies back in the SW. + +The callback runs inside `_serializeWasmCall`, and while it runs the client's +`_withInnerLockDepth` counter is bumped so that `_serializeWasmCall` invocations +made *by* `fn` run inline instead of enqueuing behind the outer slot (which is +itself awaiting `fn`) — a re-entrant-lock deadlock otherwise. + +**Safety contract:** callers MUST hold their own external mutex preventing +concurrent access to the same client instance during `fn`. External callers +queue behind the outer slot, but if one runs during an `await` inside `fn` it +sees `_withInnerLockDepth > 0` and runs inline, racing wasm-bindgen's borrow +check. The method is marked `@internal`; the proxied client's shape is not part +of the documented public API. Pin the SDK version if you depend on it. + +## Build Gates + +A bridge change must satisfy the repo's generated-artifact checks, all under +`crates/web-client/scripts/` unless noted: `check-method-classification.js`, +`check-bindgen-types.js`, `check-standalone-types.js`, `build-types.js`, +`gen-node-reexports.js`, `verify-release-mt.mjs`, plus repo-level +`scripts/check-react-sdk-sync.js` and the version-parity scripts. + +Note that production builds run `tools/strip-masp-debug` via +`crates/web-client/scripts/wasm-opt-with-masp-strip.sh`, removing MASM source +spans and `assert.err` message text from the embedded Miden packages (ST binary +27.4 MB -> 18.8 MB). A failed VM assertion in a production build therefore +reports its error code without the human-readable message; build with +`MIDEN_WEB_DEV=true` when you need full diagnostics. diff --git a/skills/web-client-usage/SKILL.md b/skills/web-client-usage/SKILL.md index 426c8a4..a79d35c 100644 --- a/skills/web-client-usage/SKILL.md +++ b/skills/web-client-usage/SKILL.md @@ -1,26 +1,43 @@ --- 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. +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, pswap, keystore, compile), sync ordering, type conversions, transaction flows, chain anchors, 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 +[`0xMiden/web-sdk`](https://github.com/0xMiden/web-sdk). The workspace is +version `0.16.0-rc.7` and builds on the `miden-client` Rust crate at +`0.16.0-rc.5` (`Cargo.toml`). 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. +## Installing + +These are pre-release versions. A range like `"0.16"`, `"^0.16.0"` or +`"0.16.x"` does **not** match a prerelease — npm excludes prereleases from +ranges that do not themselves name one. Pin exactly: + +```json +{ + "dependencies": { + "@miden-sdk/miden-sdk": "0.16.0-rc.7", + "@miden-sdk/react": "0.16.0-rc.7" + } +} +``` + ## API Overview -The SDK exposes a top-level `MidenClient` whose state is split across typed -**resources**: +The SDK exposes a top-level `MidenClient` whose state is split across eight +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.accounts` | Wallets, faucets, custom contracts, listing, import/export, addresses | +| `client.transactions` | `send` / `mint` / `bridge` / `consume` / `consumeAll` / `swap` / `execute` / `createNetworkNote` / `pswapCreate` / `pswapConsume` / `pswapCancel` / `preview` / `submit` / `submitProven` / `executeRequest` / `captureAnchor` / `batch` / `submitBatch` / `executeProgram` / `list` / `waitFor` | | `client.notes` | Listing, fetching, importing/exporting, private-note transport | +| `client.pswap` | Partial-swap lineage queries and cancel-by-order | | `client.tags` | Note-tag subscriptions | | `client.settings` | Persistent client settings | | `client.compile` | Compiling MASM into account components, tx scripts, note scripts | @@ -28,8 +45,35 @@ The SDK exposes a top-level `MidenClient` whose state is split across typed `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. +operations the resource API does not yet wrap. Some low-level methods have no +`MidenClient` equivalent at all (e.g. `pruneAccountHistory`). + +`#inner` is a true JS private field and is unreachable from outside the class. +There is an escape hatch, `client._withInnerWebClient(fn)`, but it is marked +**`@internal`** and comes with a safety contract — read this before using it: + +- **It is not a stable API.** The shape of the proxied client is deliberately + outside the documented public surface and may change between SDK versions. + Pin the SDK and retest the lower-level surface on every upgrade. +- **Its intended use is narrow**: splitting the bundled + execute → prove → submit → apply pipeline across execution contexts — the + motivating case is a Chrome MV3 extension that executes in the service + worker, proves in a `chrome.offscreen` document (where wasm-bindgen-rayon can + spawn real threads), then submits and applies back in the worker. +- **You must hold your own external mutex** around all access to that client + instance for the duration of `fn`. The callback runs inside + `_serializeWasmCall`, and while it runs the client's `_withInnerLockDepth` is + bumped so that calls made *by* `fn` run inline instead of enqueuing (without + that, they would deadlock behind the slot that is awaiting `fn`). The + consequence is the trap: if an unrelated task runs during one of `fn`'s + `await`s and calls into the SDK, it also sees `_withInnerLockDepth > 0`, runs + inline, and races wasm-bindgen's borrow check. The chain does not protect you + there — your own mutex has to. +- **Do not let references escape** the callback; the lock is released when `fn` + settles. + +If you just need a method the resource API hasn't wrapped, prefer asking for it +upstream over building on this. See `wasm-bridge` for the full mechanism. ## Client Initialization @@ -38,7 +82,7 @@ operations the resource API does not yet wrap — reach for it via the wrapped ```typescript import { MidenClient } from "@miden-sdk/miden-sdk"; -// Testnet — autoSync on, testnet RPC + prover + note transport +// Testnet — injects rpcUrl/proverUrl/noteTransportUrl "testnet" and autoSync: true const client = await MidenClient.createTestnet(); // Devnet equivalent @@ -59,12 +103,13 @@ const client = await MidenClient.createTestnet({ ```typescript const client = await MidenClient.create({ - rpcUrl: "https://rpc.testnet.miden.io", // string URL or "testnet"/"devnet"/"localhost" + rpcUrl: "https://rpc.testnet.miden.io", // string URL or "testnet"/"devnet"/"localhost"/"local" noteTransportUrl: "https://transport.miden.io", storeName: "my-store", - seed: new Uint8Array(32), // optional — deterministic key generation + seed: "any string, or a Uint8Array", // optional — see below proverUrl: "testnet", // optional — sets a default prover - autoSync: true, // optional — call sync() after init + autoSync: true, // optional — sync() once after init; ClientOptions default is false + useWorker: true, // optional — default true; see below keystore: { // optional — external HSM/keystore getKey: async (pubKey) => { /* return secretKey or null */ }, insertKey: async (pubKey, secretKey) => { /* persist */ }, @@ -75,6 +120,30 @@ const client = await MidenClient.create({ If `rpcUrl` is omitted, `create()` delegates to `createTestnet()`. +`seed?: string | Uint8Array`. A string is legal — `hashSeed()` +(`crates/web-client/js/utils.js`) SHA-256s it to 32 bytes before it reaches +WASM. A `Uint8Array` is passed through unchanged. + +`autoSync` defaults to `false` in `ClientOptions`; `createTestnet` / +`createDevnet` inject `true`. `create()` only syncs `if (options?.autoSync)`. + +### `useWorker` + +`useWorker` defaults to `true` and runs WASM calls off the main thread. Leave +it on in browsers and extensions so the UI stays responsive. Set it to +`false` when: + +- You pass a `CallbackProver` built with + `TransactionProver.newCallbackProver(jsFn)`. The worker boundary serializes + the prover with `TransactionProver.serialize()`, which has no encoding for + the callback variant and silently downgrades it to `"local"` — the callback + would never fire. +- You embed the client in a single-WebView native shell (Capacitor, Tauri, + Electron preload), where the UI thread isn't competing with WASM anyway. + +`client.lastAuthError()` (the raw JS value the last sign callback threw) is +also only meaningful with `useWorker: false`. + ### Lazy / SSR-safe init Some bundles (Next.js, Capacitor, raw `/lazy` entry) cannot await WASM at @@ -86,14 +155,24 @@ await MidenClient.ready(); const client = await MidenClient.createTestnet(); ``` +### Testing without a node + +```typescript +const client = await MidenClient.createMock({ seed, serializedMockChain }); +await client.proveBlock(); // advance the mock chain one block +const dump = await client.serializeMockChain(); // snapshot/restore +client.usesMockChain(); // boolean +``` + ### Termination ```typescript -client.terminate(); // free WASM resources, close the store handle +client.terminate(); // terminates the underlying Web Worker ``` -After `terminate()`, every method throws — guard against late callbacks on -unmount. +After `terminate()`, nearly every method throws (`assertNotTerminated`); the exceptions are `usesMockChain()`, the `defaultProver` getter, and `terminate()` itself. Guard against late callbacks on +unmount. `MidenClient` also implements `[Symbol.dispose]` / +`[Symbol.asyncDispose]`. ## Sync — Always Sync First @@ -102,7 +181,9 @@ 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 summary = await client.sync(); // NTL fetch, then chain sync; fails fast on either +await client.syncChain(); // on-chain state only, no NTL fetch +await client.syncNoteTransport(); // NTL fetch only const height = await client.getSyncHeight(); // current local block number ``` @@ -111,12 +192,16 @@ 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 + call to let the SDK wait for the tx commit instead of polling manually. + `TransactionOptions` is `{ waitForConfirmation?, timeout?, prover? }` and + `timeout` defaults to `60_000` ms of **wall clock**, not a block height. - 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") + in-memory unlock token after a wallet "lock"). Caveat: a `syncState` + blocked on the Web-Locks sync lock has not reached the internal call + chain, so `waitForIdle` does not await it. -`autoSync: true` (default for `createTestnet`/`createDevnet`) only triggers a +`autoSync: true` (injected by `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. @@ -136,9 +221,14 @@ 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. +AccountHeader | AccountId`, and `resolveAccountRef` only special-cases +objects with an `.id()` method (`Address` exposes `accountId()`, not +`id()`), so call `address.accountId()` first. + +The separate `resolveAddress` — used for the `to` field of +`notes.sendPrivate` / `notes.sendPrivateOutput` — does accept a bare +`AccountId` (it falls through to `Address.fromAccountId(ref, undefined)`), +but still not a pre-parsed `Address`. `AccountId.fromHex` throws on malformed input; wrap in `try/catch` when accepting user input. @@ -152,50 +242,67 @@ 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. +`FaucetCreateOptions.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. + +A PSWAP `orderId` is stricter: it is `string | bigint` only, and a JS +`number` is **rejected** with a `TypeError`, because an order id is +`u64`-shaped and routinely exceeds `Number.MAX_SAFE_INTEGER`. ### Visibility & Account Types ```typescript -import { NoteVisibility, AccountType, AuthScheme, StorageMode } from "@miden-sdk/miden-sdk"; +import { NoteVisibility, AccountType, AuthScheme, StorageMode, Linking } + from "@miden-sdk/miden-sdk"; NoteVisibility.Public // "public" NoteVisibility.Private // "private" -// AccountType is a faucet-kind selector with ONLY two members: +// AccountType (package root) 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 +AuthScheme.Falcon // "falcon" — maps to AuthScheme.AuthRpoFalcon512 +AuthScheme.ECDSA // "ecdsa" — maps to AuthScheme.AuthEcdsaK256Keccak StorageMode.Public StorageMode.Private + +Linking.Dynamic // "dynamic" +Linking.Static // "static" ``` -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. +These are all `Object.freeze`d string/number consts in +`crates/web-client/js/index.js`. + +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. + +**Two different `AccountType`s exist.** The one exported at the package root +is the faucet-kind selector above. The low-level WASM `AccountType` +(`crates/web-client/src/models/account_type.rs`) is a different type whose +members are `{ Private, Public }` — account visibility. Do not mix them. -`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 +The root `AccountType` has 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). +(accessing it yields `undefined`, which silently resolves to private), and +the underlying `AccountStorageMode` rejects the string `"network"`. ## Account Creation ```typescript -// Wallet — the default when no `type` is given (private, Falcon) +// Wallet — the default when no `type` is given (storage "private", Falcon) const wallet = await client.accounts.create(); // Wallet with explicit options — omit `type` (there is no @@ -203,19 +310,23 @@ const wallet = await client.accounts.create(); const wallet = await client.accounts.create({ storage: "private", auth: AuthScheme.Falcon, + seed: "optional string or Uint8Array", }); -// Faucet — selected via AccountType.FungibleFaucet / NonFungibleFaucet +// Faucet — selected via AccountType.FungibleFaucet / NonFungibleFaucet. +// Faucet default storage is "public". const faucet = await client.accounts.create({ type: AccountType.FungibleFaucet, storage: "public", + name: "Dagger", // optional human-readable name; defaults to `symbol` symbol: "DAG", decimals: 8, maxSupply: 10_000_000n, }); // Custom contract — selected by passing `components` (NOT by an AccountType -// member). Requires seed and an AuthSecretKey. +// member). Requires a raw 32-byte seed and an AuthSecretKey. +// Contract default storage is "public". const component = await client.compile.component({ code: contractMasm, slots: [] }); const contract = await client.accounts.create({ seed: new Uint8Array(32), @@ -230,6 +341,14 @@ 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. +`components` must be **non-empty**. An auth-only contract is rejected: +`"Contract accounts require at least one non-auth procedure: pass at least +one entry in \`components\`."` Internally the contract path builds +`new AccountBuilder(seed).storageMode(mode) + .withAuthComponent(AccountComponent.createAuthComponentFromSecretKey(auth))`, +then `.withComponent(c)` per entry, `.build()`, then +`newAccountWithSecretKey(account, auth)`. + ## Transactions The transactions API is option-bag-based and accepts any account ref @@ -237,8 +356,11 @@ The transactions API is option-bag-based and accepts any account ref ### Send +`send` resolves to `{ txId, note, result }`. `note` is `null` unless +`returnNote: true`; `result` is a `TransactionResult`. + ```typescript -const { txId } = await client.transactions.send({ +const { txId, result } = await client.transactions.send({ account: wallet, // sender to: "0xrecipient...", // any account ref token: faucet, // faucet account ref — identifies the asset @@ -258,8 +380,8 @@ 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`. +`returnNote: true` and the call narrows `note` to the constructed `Note` +object — incompatible with `reclaimAfter`/`timelockUntil` (explicit throw). ```typescript const { txId, note } = await client.transactions.send({ @@ -272,15 +394,16 @@ const { txId, note } = await client.transactions.send({ 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..." }); +// Stream the note via the note-transport service. Because this is one of +// this client's own output notes, use sendPrivateOutput — it derives the +// recipient's scan-start block from the note's stored expected height. +await client.notes.sendPrivateOutput({ noteId: note.id(), to: "mtst1..." }); ``` ### Mint +`mint` resolves to `{ txId, result }`. + ```typescript const { txId } = await client.transactions.mint({ account: faucet, // faucet executes the mint @@ -297,20 +420,24 @@ recipient as `account`. ### Consume ```typescript -// Specific notes -await client.transactions.consume({ +// Specific notes — `notes` takes a single NoteInput or an array of them +const { txId, result } = 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({ +const { txId, consumed, remaining, result } = await client.transactions.consumeAll({ account: wallet, maxNotes: 50, // optional cap }); ``` +`ConsumeAllResult` is `{ txId: TransactionId | null, consumed: number, +remaining: number, result?: TransactionResult }` — `txId` is `null` and the +counts are `0` when there was nothing to consume. + ### Swap ```typescript @@ -323,6 +450,88 @@ await client.transactions.swap({ }); ``` +> `paybackType` falls back to **`type`**, not to public: both `swap` and +> `pswapCreate` resolve it as `opts.paybackType ?? opts.type`. Omit it on a +> private swap and the payback note is private too. (The TypeScript JSDoc on +> these options says "Defaults to `public`" — that comment is wrong at this +> pin; the code is the contract.) + +### Partial swaps (PSWAP) + +A PSWAP note can be filled by many consumers; each fill emits a payback note +to the creator and, on a partial fill, a remainder PSWAP note carrying the +unfilled amount. The chain of notes is a *lineage*. + +```typescript +await client.transactions.pswapCreate({ + account: wallet, + offer: { token: tokenA, amount: 100n }, + request: { token: tokenB, amount: 50n }, + type: NoteVisibility.Public, + paybackType: NoteVisibility.Public, // defaults to `type`, NOT to public +}); + +await client.transactions.pswapConsume({ + account: filler, + note: pswapNoteId, + fillAmount: 10n, // requested-asset amount supplied from the consumer's vault + noteFillAmount: 0n, // optional — supplied by other in-flight notes; leave unset normally +}); + +await client.transactions.pswapCancel({ account: wallet, note: pswapNoteId }); +``` + +Lineage queries live on `client.pswap`: + +```typescript +await client.pswap.lineages(); // all tracked lineages +await client.pswap.lineagesFor(wallet); // by creator account +await client.pswap.lineage(orderId); // one lineage, or null +await client.pswap.cancelByOrder({ orderId }); // reclaims the tip; resolves the creator +``` + +`orderId` is `string | bigint` (never `number`). `PswapLineageRecord` exposes +`orderId()`, `creatorAccountId()`, `remainingOffered()`, +`remainingRequested()`, `currentDepth()`, `currentTipNoteId()` and `state()` +(`Active` / `FullyFilled` / `Reclaimed`). `cancelByOrder` throws before +submitting anything when no lineage is tracked or the lineage is terminal. +The read/build/submit steps are not atomic against external fills — if +another consumer advances the tip in between, the kernel rejects the cancel +with a "note already nullified" error; re-read the lineage and retry. + +### Bridge + +`bridge` emits a single public B2AGG (Bridge-to-AggLayer) note that the +bridge account consumes, burning the asset so it can be claimed at the +destination address. It resolves to `{ txId, result }`. + +```typescript +await client.transactions.bridge({ + account: wallet, + bridgeAccount: bridgeAccountId, + token: faucet, + amount: 100n, + destinationNetwork: 1, // AggLayer-assigned network id + destinationAddress: "0xabc...", // 0x-prefixed Ethereum hex +}); +``` + +### Network notes + +`transactions.createNetworkNote(options)` returns `{ txId, note, result }`. +It builds a Public custom-script note carrying a `NetworkAccountTarget` +attachment, so a public network account auto-consumes it. Provide exactly one +of `recipient` or `script`. `Note.isNetworkNote()` reports the attachment. + +The `target` must genuinely be a network account — one built from +`AccountComponent.createNetworkAuthComponents(allowedNoteScriptFees, +feeFaucetId, allowedTxScriptRoots?)`, which returns an `AccountComponent[]` +(install every element via `AccountBuilder.withComponent`) and both +allowlists **and prices** the note's script root. It must already be +committed on-chain at the transaction's reference block. Targeting a plain +wallet fails with `account procedure … is not in the account procedure index +map`. + ### Execute (custom scripts) ```typescript @@ -331,66 +540,297 @@ const script = await client.compile.txScript({ libraries: [{ namespace: "my::lib", code: libMasm, linking: "dynamic" }], }); -await client.transactions.execute({ +const { txId, result } = await client.transactions.execute({ account: contract, script, foreignAccounts: [ - publicAccountId, // public — auto-fetched via RPC - { id: privateContractId, storage: storageRequirements }, + publicForeignAccountId, // storage requirements default to empty + { id: otherPublicId, storage: storageRequirements }, ], waitForConfirmation: true, }); ``` -**Public foreign accounts are auto-fetched** during execution — only private -foreign accounts must be supplied with their storage requirements. +**Every foreign account here is a public one.** The web SDK exposes exactly one +constructor, `ForeignAccount.public(accountId, storageRequirements)`, and both +`execute` and `executeProgram` route every entry — bare id or `{ id, storage }` +wrapper — through it. There is no private foreign-account path on this surface, +and `ForeignAccount.public` rejects a non-public id with +`InvalidForeignAccountId`, so passing a private account's id throws rather than +falling back. + +Storage requirements ride on the **public** account (that is what the `storage` +key is for); pass a bare id when the defaults suffice. + +For a read-only view call, `transactions.executeProgram({ account, script, +adviceInputs?, foreignAccounts? })` returns a `FeltArray` of the resulting +stack. Nothing is proven or submitted. + +### Preview — authorization summaries, NOT a dry run -### Preview (dry run) +`transactions.preview({ operation, ... })` accepts +`"send" | "mint" | "bridge" | "consume" | "swap" | "pswapCreate" | +"pswapConsume" | "pswapCancel" | "custom"`. -`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. +It returns a `TransactionSummary` **only while authorization is pending** — +that is, when the account's auth procedure aborts with the unauthorized +event (e.g. a multisig below its signing threshold). That summary is the +payload out-of-band signing flows need. + +If the transaction is already fully authorized, execution succeeds, **no +summary is produced**, and the call **rejects** with an error whose `code` +is `"TRANSACTION_ALREADY_AUTHORIZED"` (on Node.js the code prefixes the +message instead). Submit it with `execute`/`submit` instead. Do not use +`preview` to power a confirmation screen or estimate a transaction. + +`anchor` is accepted **only** with `operation: "custom"`; passing it with a +built-in operation throws `preview does not accept an anchor for operation +"…"`. + +### Chain anchors + +A signed `TransactionSummary` binds the reference block commitment, so a +summary signed at one block only reproduces when re-executed at that block. +`ChainAnchor` is what lets a multisig proposer, its co-signers, and the +eventual executor agree despite different sync heights. + +```typescript +const anchor = await client.transactions.captureAnchor(request); +const summary = await client.transactions.preview({ + operation: "custom", account, request, anchor, +}); +// ... collect signatures over `summary`, shipping `anchor.serialize()` ... +await client.transactions.submit(account, request, { anchor }); +``` + +- `captureAnchor(request)` pins the current sync height for that specific + request, tracking the creation blocks of its authenticated input notes. +- Verify an untrusted anchor with `anchor.commitment()` against + `summary.blockCommitment()`. +- It throws with `code: "INVALID_CHAIN_ANCHOR"` if a sync lands mid-capture; + retry. +- The caller owns the anchor and it carries a partial blockchain — call + `anchor.free()` in a repeated-capture flow rather than waiting for the + finalizer. +- `ChainAnchor` also has `serialize()` / `deserialize()` / `blockNum()` / + `blockHeader()`. +- Only the request-taking methods accept an anchor: `preview({operation: + "custom"})`, `executeRequest`, and `submit`. Passing `anchor` to `send`, + `execute`, `batch`, `submitBatch` etc. throws loudly rather than silently + executing at the tip. + +### Manual transaction lifecycle + +```typescript +const executed = await client.transactions.executeRequest(account, request, { anchor }); +const proven = await executed.prove({ prover }); +const submitted = await proven.submit(); +await submitted.apply(); +``` + +- `executeRequest` returns a `TransactionExecution` (`.result`, `.id`, + `.prove({ prover? })`) +- `.prove()` returns a `TransactionProof` (`.proof`, `.result`, `.submit()`) +- `.submit()` returns a `TransactionSubmission` (`.blockNumber`, `.result`, + `.apply()`, `.waitForConfirmation(opts)`) +- `transactions.submit(account, request, options?)` runs every stage in one + call and returns `{ txId, result }` +- `submitProven(proof, result)` submits a proof produced by a detached prover + that never saw the local store, returning a `TransactionSubmission` + +**The stages are not atomic as a group.** Awaiting other mutating calls on +the same account between them can interleave state — drive the chain as an +uninterrupted sequence per account. A prover is **consumed** by `prove()`; +build or clone a fresh one per call, or it silently falls back to the +built-in local prover. + +### Batching + +```typescript +// Every operation executes AS `account`. Pick operations one account can perform. +const { blockNumber } = await client.transactions.batch({ + account: wallet, + operations: [ + { kind: "consume", notes: [noteId] }, + { kind: "send", to: other, token: faucet, amount: 10n }, + { kind: "custom", request: prebuiltRequest }, + ], + waitForConfirmation: true, +}); +``` + +`BatchOperation` kinds are `send`, `mint`, `consume`, `swap`, `execute`, +`custom`; each mirrors the singular options minus `account`. + +**V1 is single-account only, and it rewrites every operation's account.** The +builder does `{ ...op, account: opts.account }` for each operation — its own +comment reads "Per-op builders all use the batch-level `account` — V1 only +supports same-account batches". Mixing account roles in one batch therefore +does not produce a helpful error; it builds the wrong request: + +```typescript +// WRONG — a mint must execute on the FAUCET, but the batch rebuilds it as +// `wallet`, i.e. it treats the wallet as the issuing faucet. Setting +// `account` to the faucet instead just breaks the wallet send. +await client.transactions.batch({ + account: wallet, + operations: [ + { kind: "mint", to: wallet, amount: 100n }, + { kind: "send", to: other, token: faucet, amount: 10n }, + ], +}); +``` + +Minting on a faucet and spending from a wallet are two different accounts, so +they are two calls — two batches, or a `mint` followed by a `send`. + +The result is only `{ blockNumber }`, so `waitForConfirmation` polls chain +height rather than per-transaction status. `submitBatch(account, requests[], options?)` +is the pre-built-request counterpart; the V1 batch API has no per-call prover +override. + +### Listing and waiting + +```typescript +await client.transactions.list(); // all +await client.transactions.list({ status: "uncommitted" }); +await client.transactions.list({ ids: [txId] }); +await client.transactions.list({ expiredBefore: 1000 }); + +await client.transactions.waitFor(txId, { + timeout: 60_000, // default; 0 polls indefinitely + interval: 5_000, // default + onProgress: (status) => {}, // "pending" | "submitted" | "committed" +}); +``` + +`TransactionRecord` exposes `id()`, `accountId()`, `blockNum()`, +`submissionHeight()`, `expirationBlockNum()`, `creationTimestamp()`, +`transactionStatus()`, `initAccountState()`, `finalAccountState()`, +`inputNoteNullifiers()` and `outputNotes()`. ## 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 +await client.notes.list(); // all input notes +await client.notes.list({ status: "committed" }); // "consumed" | "committed" | + // "expected" | "processing" | "unverified" +await client.notes.list({ ids: [noteId] }); +await client.notes.list({ scriptRoots: [noteScript.root()] }); // hex strings or Word +await client.notes.get(noteId); // single record, or null +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); +const idHex = await client.notes.import(noteFile); // hex string, NOT a NoteId +const file = await client.notes.export(noteId); +``` + +`NoteQuery` is a discriminated union — `{status}`, `{ids}`, or +`{scriptRoots}`. `listSent` returns `[]` for a `scriptRoots` query, since +script roots are only tracked for received notes. -// 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 +`notes.import` returns a **hex string**: the note id when the file carries +metadata, or the note's *details commitment* for a details-only file. Pass it +to `NoteId.fromHex` if you need a `NoteId` object. + +### Private-note transport + +```typescript +// Pulls anything addressed to tracked accounts, incrementally from the +// stored pagination cursor. +await client.notes.fetchPrivate(); + +// One of THIS client's own output notes — preferred. The recipient's +// scan-start block is derived from the note's stored expected height. +await client.notes.sendPrivateOutput({ noteId, to: "mtst1..." }); + +// Any other note — you must supply the scan hint yourself. +await client.notes.sendPrivate({ + note, + to: "mtst1...", + scanAfterBlockNum: heightWhenSubmitted, +}); ``` +`fetchPrivate()` takes no arguments and only downloads notes past the stored +cursor. Historical notes for a newly tracked tag sit below that cursor and +are backfilled automatically by `sync()`, one tag at a time. + +`sendPrivate` **requires** `scanAfterBlockNum: number` — the block the +recipient scans *forward* from for the note's on-chain commitment. It must be +at or below the commitment block; a hint above it is never scanned back to, +so the recipient silently never receives the note. A safe choice is the chain +tip at the moment the note's transaction was submitted. A missing or negative +value throws a descriptive `Error`. + +For one of this client's own output notes, do not compute the hint yourself +and do not pass the current sync height — use `sendPrivateOutput({ noteId, +to })`, which derives it from the note's stored `expected_height`. A bare +sync-height hint overshoots the commitment once the sender advances past it +(e.g. relaying after waiting for commit) and silently drops delivery. + +For both, `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. + ## Accounts (querying) ```typescript -await client.accounts.list(); // tracked accounts +await client.accounts.list(); // tracked accounts (AccountHeader[]) 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 + +await client.accounts.import(ref); // by id — fetches state from the network +await client.accounts.import({ file }); // from an exported AccountFile +await client.accounts.import({ seed, auth }); // reconstruct a PUBLIC account from its seed +const file = await client.accounts.export(ref); // AccountFile + +await client.accounts.addAddress(ref, "mtst1..."); +await client.accounts.removeAddress(ref, "mtst1..."); ``` `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. +The seed-based import path only works for **public** accounts; use the +account-file workflow for private ones. + 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. +## Storage — slots are named, not indexed + +`StorageSlot` constructors take a slot **name**: + +```typescript +StorageSlot.fromValue(name, word); +StorageSlot.emptyValue(name); +StorageSlot.map(name, storageMap); +``` + +MASM declares the matching name as a word constant and reads through it: + +``` +const COUNTER_SLOT = word("miden::tutorials::counter") +... +push.COUNTER_SLOT[0..2] exec.active_account::get_item +``` + +Read state back through `StorageView`, installed on `Account.prototype.storage()` +at WASM load time: `getItem(slotName)`, `getMapItem(slotName, key)`, +`getMapEntries(slotName)`, `getCommitment(slotName)`, `getSlotNames()`. +`getItem` returns a `StorageResult` supporting `.isMap`, `.entries`, `.word`, +`.toFelts()`, `.toU64s()`, `.toBigInt()`, `.toHex()` and `.valueOf()`. Use +`.toBigInt()` for exact u64 values — `valueOf()` throws `RangeError` above +`Number.MAX_SAFE_INTEGER`. + ## Keystore ```typescript @@ -402,20 +842,77 @@ await client.keystore.getAccountId(pubKeyCommitment); ``` `keystore.insert` is the single call that both stores the key and registers -its commitment with the account. +its commitment with the account. These five are the entire resource. Note that `remove()` is Node-only: on the browser/WASM path it unconditionally throws `"remove() is not supported on this platform"`. ## Compile ```typescript -await client.compile.component({ code, slots, supportAllTypes: true }); +await client.compile.component({ code, namespace, slots, supportAllTypes: true }); await client.compile.txScript({ code, libraries }); await client.compile.noteScript({ code, libraries }); ``` +`compile.component` takes an optional `namespace?: string` — the module path +used to derive procedure identities, routed to +`compileAccountComponentCodeWithPath(namespace, code)`. Use the **same** +namespace when linking that component into a transaction script, or procedure +identities will not match. `slots` defaults to `[]` and `supportAllTypes` +defaults to `true`. + +`libraries` entries can be any of three shapes (`CompileScriptLibrary`): + +```typescript +{ namespace: "my::lib", code: libMasm, linking: "dynamic" } // inline source +{ component: myAccountComponent, linking: "dynamic" } // the exact installed code +prebuiltLibrary // a Library object +``` + +Prefer the `{ component }` form when the script calls into a component you +installed on the account — it links the exact compiled code, so procedure +identities match. Linking defaults to dynamic. + +### MASM annotations are mandatory + +Anything compiled through `client.compile` must carry its annotation, or +compilation fails: + +- account-component procedures: `@account_procedure` +- transaction scripts: `@transaction_script` on `pub proc main` +- note scripts: `@note_script` on `pub proc main` + 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. +## What the web SDK does not expose + +Guards against bad find-replaces and misremembered APIs: + +- There is no JS/TS `AssetId`, `AssetClass` or `AssetVaultKey` type. Every + `assetId`-named field in the React SDK is a faucet account id string. +- There is no fee-configuration API. Fees are paid by the account's auth + procedure; nothing in the web SDK sets conversion info. The only + fee-adjacent JS surface is network-account note pricing (`NoteScriptFee`, + `AccountComponent.createNetworkAuthComponents`, `NoteScript.feeSponsorship()`). +- A note may carry at most **16** assets (`MAX_ASSETS_PER_NOTE` in the + protocol). It is enforced in `NoteAssets::new`, so it surfaces as a runtime + `NoteError`, not a type error. +- `ClientOptions` has no `debugMode`, and the low-level + `createClient(rpcUrl?, noteTransportUrl?, seed?, storeName?, logLevel?, + useWorker?)` / `createClientWithExternalKeystore(rpcUrl?, + noteTransportUrl?, seed?, storeName?, getKeyCb?, insertKeyCb?, signCb?, + logLevel?, useWorker?)` take no trailing debug flag. +- `ExecutedTransaction` and `TransactionStoreUpdate` expose `accountPatch()`, + not `accountDelta()`. (`TransactionSummary.accountDelta()` deliberately + still exists and still returns an `AccountDelta`.) +- `TransactionSummary` exposes `userParams()` (seven field elements), not + `salt()`. +- `notes.fetchPrivate` takes no arguments — there is no `{ mode: "all" }`. +- `FungibleAsset` has no `withCallbacks(flag)`; the flag is an immutable + property of the faucet's account id and is read with `callbacks()`. +- Note scripts call `basic_wallet::move_note_assets_to_account`, not + `add_assets_to_account`. + ## Common Workflows ### Mint and consume (fund a fresh wallet) @@ -466,7 +963,8 @@ while (true) { 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. + Use `bigint` for large amounts — and always for a PSWAP `orderId`, which + rejects `number` outright. 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 @@ -475,12 +973,26 @@ while (true) { 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`. +7. **Private notes without transport.** A private note still has to be + relayed. For your own output note call + `notes.sendPrivateOutput({ noteId, to })`. For any other note call + `notes.sendPrivate({ note, to, scanAfterBlockNum })` and pass a block at or + below the note's commitment block — `sendPrivate` throws without it, and a + too-high hint silently loses the note. +8. **Treating `preview` as a dry run.** On a fully-authorized transaction it + rejects with `code: "TRANSACTION_ALREADY_AUTHORIZED"`. It only yields a + summary while authorization is pending. +9. **Destructuring the wrong result shape.** `send` gives + `{ txId, note, result }`; `mint`/`consume`/`swap`/`execute`/`bridge`/ + `pswap*` give `{ txId, result }`; `consumeAll` gives + `{ txId, consumed, remaining, result? }`; `batch`/`submitBatch` give + `{ blockNumber }`. +10. **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. +11. **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`. +12. **Reusing a `TransactionProver` across `prove()` calls** — it is consumed, + and the second call silently falls back to the built-in local prover.