Skip to content

Multiasset: FA2 token bridging support (REQUIRES THOROUGH AUDIT BEFORE MERGE)#36

Open
murbard wants to merge 80 commits into
mainfrom
multiasset
Open

Multiasset: FA2 token bridging support (REQUIRES THOROUGH AUDIT BEFORE MERGE)#36
murbard wants to merge 80 commits into
mainfrom
multiasset

Conversation

@murbard

@murbard murbard commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator

⚠️ This branch requires several deep adversarial audit passes by independent reviewers before merge. Nothing here has been externally validated.

Why

The rollup has been tez-only. Adding FA2 tokens (Tezos's fungible-asset standard) lets users shield/transfer/unshield arbitrary assets while reusing the same proof system. The hard constraint is doing this without sacrificing privacy: if every asset had its own commitment encoding, on-chain observers could trivially separate rare-asset transactions from common-asset ones. The design here keeps a single commitment scheme that hides which asset a note carries, so rare-asset transactions blend with the common-asset crowd.

How — the key design choices

Asset hides in the commitment preimage, not on the wire. The commitment goes from 4-ary H_commit(d_j, v, rcm, owner_tag) to 5-ary H_commit(d_j, v, asset_id, rcm, owner_tag). The on-chain cm is still a single felt; nothing about it reveals the asset. Two notes with identical (d_j, v, rcm, owner_tag) but different assets produce different commitments, but an observer can't reverse this.

Asset_id is structurally derived from the L1 ticketer. asset_id = H("tzel:asset:" || ticketer_kt1). ASSET_TEZ = 0 preserves backward compat. One KT1 = one asset_id; no on-chain registration step. The kernel's registered-asset list is the compile-time constant COMPILE_TIME_FA2_BRIDGES. Trade-off: asset removal is asymmetric — un-registering a ticketer refuses new deposits but leaves existing pools and notes intact (re-add the ticketer to restore spendability).

Two-accumulator per-asset balance, not N-asset. Cairo can't iterate over felts inside a constraint system, so the abstract "for every α, sum_in(α) = sum_out(α) + (fee if α==tez)" doesn't transpile directly. Instead the witness declares one primary non-tez asset A per transaction, and the circuit gates every input/output asset to {ASSET_TEZ, A}. Two accumulators (tez_in/tez_out, primary_in/primary_out) close the per-asset balance separately. This is strictly stronger than the abstract conservation: a 3-asset transaction (tez + FA2_X + FA2_Y) is rejected, and clients serialize it as a sequence of 2-asset transactions. The simplification dramatically cuts circuit cost.

Producer fee is permanently tez. DAL slot publisher liquidity argument: if producer-fee notes could be in any asset, a hostile publisher submitting blocks of fee-paid-in-illiquid-NFT transactions would starve the inclusion market. The circuit pins asset_producer == ASSET_TEZ directly. This drives the most consequential downstream constraint: any FA2 transaction must also spend some tez (to pay the producer fee), and any FA2 shield must debit producer_fee from a tez pool somewhere. The kernel's FA2 shield path therefore debits two pools — the FA2 pool for v + fee AND the user's tez pool at the same pubkey_hash for producer_fee. Without this dual-pool debit, an FA2 shield would mint producer_fee tez in the commitment tree out of nothing.

Phase C adds slots, not transactions. Mixed-asset transfers can legitimately need two change notes (one per asset). Transfer goes from 3 output slots (recipient + change + producer fee) to 4 (recipient + change_1 + change_2 + producer fee). Unshield gains a parallel cm_change_2. Single-asset transactions use the extra slot as a zero-value placeholder — uniformity is cheaper at proof time than branching.

L2 ticket content is canonical (0, None). The FA2 bridge ticketer's L2 ticket carries no FA2-specific info inside its content. The (FA2, token_id) binding lives in the ticketer's immutable storage; the L2 asset_id derivation from the ticketer's KT1 captures the rest. The kernel uniformly enforces content.token_id == 0 on every deposit and emits the same on every burn — so any non-canonical content design would only work for token_id == 0 FA2s.

Where to direct review attention

In rough order of "what breaks worst if wrong":

  1. Per-asset balance in the Cairo circuits. The 2-accumulator scheme is stricter than the abstract conservation property — convince yourself the stricter constraint actually closes for transfer and unshield, including the producer-fee tez routing and (for unshield) the v_pub routing to tez_out or primary_out based on asset_pub.
  2. Dual-pool debit for FA2 shields, kernel side. The apply step must remain infallible; convince yourself the second debit doesn't introduce a fallible operation, and that interleaved sequences of (FA2 deposit, FA2 shield, tez deposit, tez shield) can't get the kernel into an inconsistent state.
  3. Asset_id consistency across layers. Cairo, Rust kernel, and Michelson must agree on the bit-string asset_id for any given ticketer. Three layers, three opportunities for drift (string canonicalisation, b58check round-trip, hash personalisation, padding).
  4. L2 ticket canonical content. Convince yourself the Michelson ticketer's mint/burn paths emit/accept (0, None) regardless of storage.token_id, AND that the kernel's outbox encoder for unshield burns produces a ticket the ticketer's burn entrypoint will accept.
  5. Watcher recovery iteration. View and outgoing watch modes can't read the asset from the on-chain cm alone — they iterate the candidate-asset list and pick the one whose recomputed commitment matches. Convince yourself this iteration can't produce a wrong-asset label or admit a collision yielding a false positive.
  6. Wire v3→v4 boundary. Cross-implementation consumers pinned to v3 will silently compute different commitments. The branch ships no upgrade migration; multiasset assumes a fresh deployment.

Known limitations

  • Coq Spec/* is aspirational. See coq/STATUS.md. The Spec→Impl refinement theorems for shield, transfer, and unshield are stubs. If anything downstream is marketed as "formally verified", the marketing is overclaiming. Treat the Coq layer as documentation of intended invariants, not proof of them.
  • The .xmss-floor sidecar protection against stale-wallet-backup restore is best-effort, not addressable without external attestation infrastructure (out of scope for this branch).

Scale

76 commits ahead of main. 93 files changed; ~16,600 lines added.

Updated 2026-06-10: merged main back into the branch (resolving wallet conflicts between the multiasset (asset_id, pubkey_hash) pool keying and main's pinned-block consistency refactor), synced the OCaml port to the 5-ary commitment (fixing the long-red ocaml CI job), and restored the outgoing_ct field the vector regeneration had dropped.

Arthur Breitman and others added 30 commits May 1, 2026 09:41
First commit on the coq-model branch. Sets up the directory structure
and the drift-detection plumbing; no proofs yet — every coq/Tzel/*.v
file is a stub with module headers and intent docs.

Layout:

  coq/
    _CoqProject               # coq build config
    MANIFEST.toml             # cairo↔coq mirror table with SHA-256 pins
    README.md                 # what this is, why, and the approach
    Drift/check.sh            # CI script: re-hash cairo, fail on drift
    Tzel/
      Common.v                # field type + tag type, opaque
      Hashes.v                # blake_hash.cairo
      Merkle.v                # merkle.cairo
      Wots.v                  # WOTS+ portion of xmss_common.cairo
      Xmss.v                  # XMSS portion of xmss_common.cairo
      Transfer.v              # transfer.cairo
      Shield.v                # shield.cairo
      Unshield.v              # unshield.cairo
    Extracted/                # (placeholder; planned: cross-check
                              #  driver that runs extracted-Coq vs
                              #  Cairo on a test-vector corpus)

The drift mechanism is the speed-bump kind: MANIFEST.toml pins the
SHA-256 of each modeled Cairo file, the check script fails CI if a
file has drifted, and the only way to land a Cairo edit is to also
re-read the corresponding Coq mirror, update it if needed, and bump
the SHA in the same commit. Cosmetic-only Cairo edits will produce
false alarms; that's the intended cost — silent drift is much worse.

The hash forces re-review. Semantic equivalence will eventually be
verified by the Extracted/ cross-check (extract Coq to OCaml, run
shared test vectors through both Cairo and the extracted model,
assert agreement) — that's the planned second drift layer.

cairo/src/lib.cairo and cairo/src/run_*.cairo are intentionally not
in the manifest. lib.cairo only re-exports modules; the run_*
wrappers just unpack args and call into the verifier modules we
already mirror. Modeling them would be redundant.

Approach (per coq/README.md): seL4-style refinement-by-resemblance.
The Coq model mirrors the Cairo structurally so the per-circuit
soundness theorems

  <Circuit>Relation pub wit  ->  Phi_<circuit> pub

force the assert set to be complete by construction — if any needed
assertion is missing in the Cairo, the proof gets stuck and we've
localized the gap. This is the property negative-testing /
fuzzing / differential testing fundamentally cannot give us, since
they can only catch failures we knew to imagine.

Hashes are modeled as opaque parameters with collision-resistance,
preimage-resistance, and PRF axioms — explicitly not as random
oracles. Relation-level soundness only needs standard-model
properties.

CI: .github/workflows/coq.yml runs the drift check + builds the
stub theory with stock Ubuntu apt Coq (8.18). Will move to
opam-via-setup-ocaml + mathcomp once proofs need it. actions/checkout
pinned to the same v6.0.2 SHA used by every other workflow.

Next step (separate commit on this branch): the first real proof
target — WOTS+ chain step soundness in Tzel/Wots.v with its
supporting hash axioms in Tzel/Hashes.v. Smallest piece that
exercises the end-to-end Cairo↔Coq↔proof flow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First real piece on the coq-model branch. Establishes the Cairo
function ↔ Coq function ↔ OCaml extraction ↔ executable driver
pattern that every subsequent piece of the model will follow. The
hash realization is intentionally a placeholder for this commit
(extraction wires Hash3 and pack_adrs_chain to a fixed-zero stub),
so the chain step always returns the zero felt — the point is to
verify the build/extract/run pipeline compiles and runs in CI, not
to produce meaningful chain hashes.

What lands:

- coq/Tzel/Common.v: declares Felt as an opaque parameter (extracts
  to OCaml `bytes`, matching ocaml/protocol/felt.ml).
- coq/Tzel/Hashes.v: declares Hash3 as an opaque parameter, mirroring
  blake_hash::hash3_generic in Cairo. Domain separation comes from
  the ADRS-encoded second argument; no separate IV.
- coq/Tzel/Wots.v: defines xmss_chain_step as a one-line Coq function
  matching the Cairo. Captures the chain-step ADRS encoding as the
  opaque parameter pack_adrs_chain (bakes in TAG_XMSS_CHAIN + the
  trailing zero, exposes the three indices the chain step varies
  over).
- coq/Tzel/Extraction.v: extraction directives. Realizes Felt as
  bytes, Hash3 and pack_adrs_chain as zero-stubs (placeholder),
  Coq nat as OCaml int. Writes tzel_wots.{ml,mli} into coq/Tzel/.
- coq/_CoqProject: lists Extraction.v.
- coq/Extracted/main.ml: 60-line OCaml driver. Parses 5 args from
  CLI (x, pub_seed, key_idx, chain_idx, step), calls the extracted
  xmss_chain_step, prints the result as 64-char hex.
- coq/Extracted/build.sh: copies the extracted .ml/.mli into
  Extracted/ and compiles main.ml with plain ocamlc. No dune, no
  opam dependencies — minimal surface for the smoke pattern.
- coq/.gitignore: build artifacts (Coq .vo/.glob, OCaml .cm[ioxa],
  the extracted .ml/.mli, the chain_step binary).
- .github/workflows/coq.yml: extends the existing drift-check job
  with a build job that installs `coq` + `ocaml` from apt, builds
  the Coq theory (which side-effects the extraction), runs
  Extracted/build.sh, and smoke-tests the driver. Asserts the
  placeholder-hash output is the all-zero felt.

What's intentionally not in this commit:

- The real Hash3 / pack_adrs_chain realizations (will wire to
  Tzel.Hash.hash3 / Tzel.Wots.pack_adrs from the OCaml protocol port,
  which is bit-equivalent to the Cairo under the cross-impl interop
  check).
- The Cairo-side companion `run_chain_step` executable that exposes
  xmss_chain_step for external invocation.
- The differential check (extracted Coq vs Cairo on the same
  witness) and the crowbar fuzzer harness.
- The soundness theorem about xmss_chain_step (`Theorem
  xmss_chain_step_sound : ...`).

Those are scoped to follow-up commits on this branch. Each adds
exactly one layer to the same pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…S.md

Pivots the formalization to the three-layer architecture the team's
expert member recommended over the previous (single-layer Cairo
mirror) approach:

  docs/whitepaper.tex + specs/spec.md
              │
              ▼ transcribe
          coq/Spec/                ← whitepaper-derived abstract spec
              │
              ▼ refine + prove refinement
          coq/Impl/                ← extractable, Cairo-shaped refinement
              │
              ▼ Coq → OCaml extraction
          certified OCaml model
              │
              ▼ QCheck2 conformance (PBT)
          cairo/src/*.cairo

The single-layer Cairo-mirror approach catches "Cairo and Coq
diverged" but cannot catch "Cairo and Coq both fail to enforce a
property the spec demands" — there is no spec layer to demand it.
The three-layer architecture's Spec → Impl refinement proof catches
missing assertions by construction: if the Impl can't discharge a
spec-level safety theorem, the proof gets stuck and we've localized
the gap. Hard requirement: no `admit` anywhere. (Recommendation per
team expert.)

Layout changes:

- New directories: coq/Common (shared types), coq/Spec (whitepaper-
  derived abstract spec, with separate -Q library namespace),
  coq/Impl (renamed from coq/Tzel; the extractable refinement).
- coq/Common/Felt.v: shared opaque [Felt] type, used by both Spec
  and Impl so refinement statements mention the same type on both
  sides.
- coq/Spec/Wots.v: first piece of real Spec content. Defines the
  abstract WOTS+ chain step (one application of F pub_seed (ADRS k
  c s) x) and the n-step iteration, parameterized over an abstract
  hash and address encoding. Whitepaper-derived; explicitly does
  not look at the Cairo. Definitions only — proofs (`iter_succ`,
  `iter_compose`) land in the next commit.
- coq/Spec/{Hashes,Merkle,Xmss,Transfer,Shield,Unshield}.v: stubs
  with intent docs explaining what each will model and what
  soundness target it carries.
- coq/Impl/Wots.v (was coq/Tzel/Wots.v): unchanged content,
  imports rewritten to From Common Require Import Felt and From
  Impl Require Import Hashes. Refinement theorem to Spec.Wots.step
  is intended next; trivial by Definition expansion.
- coq/Impl/{Common,Hashes,Merkle,Xmss,Transfer,Shield,Unshield}.v:
  renamed from coq/Tzel/, imports updated.
- coq/Tzel/: deleted.
- coq/Tzel/Extraction.v: removed. The placeholder-extraction
  experiment in commit 61f5cd5 broke the OCaml build step in CI;
  pulling it out for the restructure. Extraction comes back later
  with proper realizations against the OCaml protocol port.
- coq/Extracted/: removed for the same reason.
- coq/_CoqProject: now lists three -Q paths (Common, Spec, Impl)
  plus the file list.
- coq/MANIFEST.toml: paths updated to coq/Impl/*.v. The Spec layer
  intentionally has no Cairo correspondence to drift against.
- coq/README.md: rewritten to describe the three-layer
  architecture, why each layer exists, and what failure mode each
  catches.
- coq/STATUS.md: new. Snapshot of where the formalization is and
  what comes next, written for resumption after a pause.
- .github/workflows/coq.yml: switches the build job from apt-Coq
  8.18 to opam-installed Rocq 9 via setup-ocaml. Same pattern as
  the OCaml unit tests workflow — actions pinned to commit SHAs,
  no third-party Docker images. Drops the OCaml driver build step
  (no extraction in this commit).

What's deliberately not done in this commit (see coq/STATUS.md):

- The Spec/Wots.v lemmas (iter_succ, iter_compose).
- The Impl/Wots.v refinement theorem.
- Extraction directives + OCaml driver + Cairo runner + QCheck2
  conformance harness.
- Any of the other Spec/Impl module pairs beyond Wots.

Each of these is a clean follow-up commit on this branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ement

First real proofs on the coq-model branch. Lands the smallest
end-to-end exercise of the Spec → Impl refinement pattern: two
arithmetic-shaped lemmas about the abstract WOTS+ chain step in
Spec.Wots, plus the trivial refinement theorem in Impl.Wots
connecting the executable Cairo-shaped function to the abstract
spec.

Spec.Wots:

- iter_succ: extending an n-step chain by one more step equals one
  step applied to the n-step output, with the appended step using
  step counter (start_step + n) — important because [iter] has
  already advanced the counter n times along the chain.

- iter_compose: an (n + m)-step chain equals an m-step chain run
  on the n-step output, with the step counter offset by n.

Both are induction-on-n with Nat.add_0_r / Nat.add_succ_r
arithmetic. ~3 lines of proof each, no [admit].

Impl.Wots.refines_spec: forall x p k c s,
  xmss_chain_step x p k c s
    = Spec.Wots.step Hash3 pack_adrs_chain x p k c s

Trivial by Definition expansion. The point isn't the proof — it's
that the theorem statement is the bridge: future Spec.Wots-level
lemmas (e.g., the future iter_compose corollary connecting two
chain extensions) transfer automatically to the extracted Cairo-
shaped xmss_chain_step by rewriting through this equation.

Imports: From Coq Require Import Arith. for Nat.add_0_r / Nat.add_succ_r.
The compatibility Coq → Stdlib namespace alias in Rocq 9 means this
form works under both Coq 8.x and Rocq 9; no need to switch yet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reintroduces the extraction pipeline that was dropped during the
Spec/Common/Impl restructure (commit 3df59f5). Same shape as the
prior coq/Tzel-layout attempt (61f5cd5), updated for the new
module locations.

What lands:

- coq/Impl/Extraction.v: extraction directives. Realizes Felt as
  OCaml bytes, Hash3 and pack_adrs_chain as fixed-zero stubs,
  Coq nat as OCaml int. Writes tzel_wots.{ml,mli} into coq/Impl/.
- coq/Extracted/main.ml: 60-line OCaml driver. Parses 5 args from
  CLI (x, pub_seed, key_idx, chain_idx, step), calls the extracted
  xmss_chain_step, prints the result as 64-char hex.
- coq/Extracted/build.sh: copies the extracted .ml/.mli into
  Extracted/ and compiles main.ml with plain ocamlc. No dune, no
  opam dependencies.
- coq/_CoqProject: lists Impl/Extraction.v.
- coq/.gitignore: updated paths from Tzel/* to Impl/* for the
  generated extraction outputs.
- .github/workflows/coq.yml: extends the existing build job with
  the Extracted/build.sh step + a smoke run that asserts the
  placeholder-hash output is the all-zero felt.
- coq/STATUS.md: marks Spec.Wots iter lemmas, Impl.Wots refines_spec,
  and the extraction pipeline as done; new "next concrete piece" is
  replacing the zero-stub realizations with the bit-equivalent
  OCaml protocol port.

What's intentionally not in this commit (matches the prior
extraction commit's scope):

- Real Hash3 / pack_adrs_chain wiring to Tzel.Hash.hash3 /
  Tzel.Wots.pack_adrs.
- Cairo-side run_chain_step executable.
- QCheck2 differential conformance harness.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two issues with the previous commit (b570e47) that landed the
iter_succ + iter_compose lemmas:

1. The proofs failed to compile against Rocq 9. Rocq 9's `simpl` is
   more aggressive than Coq 8's and unfolds `iter (S _) x ...`
   recursively past one fix-step, leaving no `iter (S k) ...`
   subterm for `rewrite IH` to match.

2. `From Coq Require Import` is deprecated since Rocq 9.0.

Fixes:

- Add `iter_S_unfold` lemma in `Spec/Wots.v` — a one-step unfolding
  helper, provable by `reflexivity`. Using `rewrite iter_S_unfold`
  instead of `simpl` gives controlled, single-step unfolding so the
  inductive hypothesis still matches.
- Rewrite `iter_succ` and `iter_compose` proofs using
  `iter_S_unfold` + `Nat.add_succ_r`. Same induction structure,
  same lines of proof, just with explicit unfold steps.
- Switch `From Coq Require ...` → `From Stdlib Require ...` in
  `Spec/Wots.v` and `Impl/Extraction.v` to clear the
  `deprecated-from-Coq` warning.

The `Common/Felt` import in `Spec/Wots.v` is unaffected — that's a
local namespace (`-Q Common Common`), not the standard library.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Rocq 9 defaults the extraction output directory to wherever `rocq
make` runs from (project's coq/), not where Extraction.v lives.
That puts tzel_wots.{ml,mli} in coq/ rather than coq/Impl/, which
breaks the Extracted/build.sh lookup.

Add `Set Extraction Output Directory "Impl"` to pin the location.
Also clears the `extraction-default-directory` warning.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the placeholder zero-stubs with the bit-equivalent OCaml
protocol-port functions:
- Hash3 -> Tzel.Hash.hash3 (BLAKE2s of pub_seed||ADRS||x, truncated
  to 251 bits)
- pack_adrs_chain -> Tzel.Wots.pack_adrs specialized to
  TAG_XMSS_CHAIN with the trailing-zero slot

Both are bit-equivalent to the Cairo xmss_common::xmss_chain_step
under the existing cross-impl interop check, so the extracted
driver matches the Cairo on the same inputs by construction.

Restructure to fit the OCaml workspace:
- Driver moved from coq/Extracted/main.ml to ocaml/coq_driver/main.ml
  with a dune file linking against the tzel library. include_subdirs
  no overrides the workspace's unqualified setting (no subdirs here).
- coq/Extracted/build.sh now orchestrates: copy the rocq-emitted
  tzel_wots.{ml,mli} from coq/Impl/ into ocaml/coq_driver/, run
  dune build from ocaml/, symlink the binary back to
  coq/Extracted/chain_step so existing invocations keep working.
- coq/Extracted/main.ml deleted; old plain-ocamlc build path gone.

CI:
- coq.yml extends the build job with the same OCaml stack as the
  unit-tests workflow (libgmp-dev, dune + opam libs, mlkem-native
  via make, LIBRARY_PATH env). One extra opam install line vs the
  prior coq.yml.
- Smoke step asserts the zero-input vector hashes to
  5ca134c7d8b26856b4dc9c748905318c23da49cac0fd56cc26c7885155466807,
  the reference value computed by Tzel.Wots.xmss_chain_step on the
  all-zero witness. Sanity-checked locally with a hand-stubbed
  tzel_wots.{ml,mli} matching the expected extraction output —
  the dune-built driver produced the same hex.
- Workflow now also triggers on ocaml/coq_driver/** changes.

STATUS.md: extraction + real-hash wiring marked done; next concrete
piece is the Cairo-side run_chain_step runner + QCheck2 differential.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
STATUS.md tracks structured done/not-done; this file is the
discursive context: the three-impl architecture, the build
pipeline, the file map, the gotchas (Rocq 9 simpl behavior,
extraction output dir, From Stdlib migration, no-admit policy,
drift-check protocol), the commit history with annotations, and
the next concrete piece (Cairo runner + QCheck2 differential).

Written so a fresh session can pick up the work without history.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Spec layer (whitepaper-derived, does not look at Cairo):

- Spec/Hashes.v: protocol constants (wots_w=4, chain_len=3,
  wots_chains=133, auth_depth=16, tree_depth=48) and hash-family
  documentation.

- Spec/Wots.v: add recover_correct theorem — chaining a signature
  element forward by the remaining steps recovers the public key
  endpoint.  Proof via iter_compose + Nat.sub_add.

- Spec/Merkle.v: full Merkle path verification with two variants:
  merkle_root (uniform hash, commitment tree) and auth_root
  (level/position-indexed, XMSS auth tree).  Proved merkle_root_app
  (path composition, the Merkle analogue of iter_compose),
  merkle_root_snoc, plus nil/cons unfolding lemmas for both.

- Spec/Xmss.v: L-tree compression (pair_nodes + fuel-bounded
  ltree_aux/ltree) with pair_nodes_length_le monotonicity proof,
  ltree_singleton and ltree_pair.  WOTS+ recovery (recover_endpoint,
  recover_all) with recover_endpoint_correct linking back to
  Wots.recover_correct.  Full xmss_verify predicate combining
  recovery, L-tree, and auth-tree path verification.

Impl layer (mirrors Cairo structure):

- Impl/Hashes.v: add Hash4 parameter (hash4_generic in Cairo).
- Impl/Merkle.v: commitment tree and auth tree instantiations with
  reflexivity refinement theorems.
- Impl/Xmss.v: L-tree and recovery instantiated with concrete hash
  parameters.

All 17 modules compile on Rocq 9.1.1.  Zero admits.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Spec/Merkle.v:
- auth_merkle_uniform: when the node hash ignores level/position,
  auth_root degenerates to merkle_root.  Lets uniform-hash proofs
  (like merkle_root_app) transfer to auth_root.

Spec/Xmss.v:
- ltree_triple: exercises odd-element carry (a,b,c -> H(H(a,b),c)).
- ltree_aux_succeeds + ltree_succeeds: L-tree always produces Some
  on non-empty input.  Proof by induction on fuel, using
  pair_nodes_length_le for the termination argument.
- recover_all_length: output preserves input length.
- recover_all_nonempty: non-empty inputs give non-empty output.
- nat_to_bits + nat_to_bits_length: LSB-first bit decomposition.
- auth_walk: nat-indexed auth-tree walk mirroring the Cairo loop.
- auth_walk_bits: auth_walk equals auth_root when bits come from
  nat_to_bits.  Bridges Cairo's integer-based loop to the spec's
  bool-list formulation.
- idx_zero_iff_in_range: formalizes the Cairo assert(idx == 0)
  check as idx / 2^n = 0 <-> idx < 2^n.

All 17 modules compile on Rocq 9.1.1.  Zero admits.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Spec/Xmss.v additions in the WotsRecover section:
- gen_pk: abstract WOTS+ public key generation (chain each secret
  key forward wots_chain_len steps).
- sign: abstract WOTS+ signing (chain each secret key forward by
  its digit).
- recover_all_correct: verifying a correctly-produced signature
  recovers the public key.  Proof by induction, using
  recover_endpoint_correct at each chain.

Spec/Xmss.v additions in the XmssVerify section:
- xmss_completeness: if a signer has valid secret keys, produces
  a correct signature with valid digits, and provides a correct
  auth path, then xmss_verify holds.  Assembles recover_all_correct,
  ltree_succeeds, and the auth-path hypothesis.

This is the completeness direction: correct signatures pass
verification.  The soundness direction (accepting signatures
imply knowledge of the secret key) requires the one-time
unforgeability assumption and is future work.

All 17 modules compile on Rocq 9.1.1.  Zero admits.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Spec/Hashes.v:
- sighash_fold: left-fold hash binding public outputs to the WOTS+
  signature.  Proved nil, cons, and app (composition) lemmas.
  Type tag (first element) prevents cross-circuit replay.
- commitment: note commitment H_commit(d_j, v, rcm, owner_tag).
- nullifier: position-dependent nullifier construction
  H_nf(nk_spend, H_nf(cm, pos)).  Position-dependence prevents
  faerie-gold attacks.

All 17 modules compile on Rocq 9.1.1.  Zero admits.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This is the soundness direction: verified paths uniquely determine
their leaves under collision resistance.

Spec/Hashes.v:
- injective_2, injective_4, node_injective: collision resistance
  modeled as injectivity.  Taken as Section hypotheses (never
  globally axiomatized).  Same proof obligations as computational
  CR — every step that would say "unless collision found" becomes
  an appeal to the hypothesis.

Spec/Merkle.v:
- merkle_binding: if two Merkle paths with the same position bits
  produce the same root, then the leaves AND all siblings must be
  equal (under CR).  Key soundness lemma — a cheating prover who
  submits a different leaf needs a hash collision.
- auth_binding: same theorem for the XMSS auth tree (level/position-
  indexed node hash with per-slot injectivity).

Spec/Xmss.v:
- pair_nodes_injective: pairwise compression is injective under CR.
  Same-length inputs producing the same compressed output must be
  equal.
- xmss_verify_unique_leaf: if two different signatures both pass
  xmss_verify against the same root and position, they recover the
  same leaf.  Proof uses auth_binding.

The remaining piece for full XMSS soundness is the WOTS+ one-time
unforgeability axiom (Hülsing et al. 2017), which would bridge
"same leaf" to "same secret key."  Per STATUS.md, this is the
"light path" — axiomatize rather than re-derive.

All 17 modules compile on Rocq 9.1.1.  Zero admits.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The headline theorem: xmss_soundness_reduces_to_wots.

If two signatures both verify against the same auth root and
key index (under collision resistance of the node hash), then
the WOTS+ endpoint lists recovered from both signatures are
identical.  This mechanically reduces XMSS security to WOTS+
one-time unforgeability.

Proof assembles:
1. auth_binding (Merkle soundness under CR) to show both
   signatures recover the same L-tree leaf.
2. ltree_injective (L-tree injectivity under CR) to show
   the same leaf implies the same endpoint list.

Supporting lemmas added:
- pair_nodes_same_length: output length depends only on input
  length (structural, no CR needed).
- ltree_aux_injective: fuel-bounded L-tree iteration is injective
  under CR.  Proof by induction on fuel using pair_nodes_injective
  at each level.
- ltree_injective: corollary using length-as-fuel.

The remaining gap is WOTS+ unforgeability itself: "same endpoints
from different digits/signatures implies a hash preimage."  Per
STATUS.md this is axiomatized from Hülsing et al. 2017 rather
than re-derived.

All 17 modules compile on Rocq 9.1.1.  Zero admits.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Spec/Hashes.v:
- hash3_third_injective: third-argument injectivity of the chain
  hash (models second-preimage resistance).

Spec/Wots.v:
- iter_injective: if the same number of hash steps from different
  starting values produce the same output, the starting values are
  equal.  Proof by induction on step count, applying hash SPR at
  each step.  This is the algebraic core of WOTS+ per-chain binding.

Spec/Xmss.v:
- recover_endpoint_binding: for the same digit, two signature
  elements recovering to the same endpoint must be equal.  Follows
  from iter_injective.

Spec/Transfer.v:
- TransferPublic, InputWitness record types.
- Safety predicate components: value_conservation, nullifier_correct,
  sighash_complete, producer_fee_positive.
- Documented the 8 conjuncts of Phi_transfer and the headline
  soundness target TransferRelation -> Phi_transfer.

The soundness chain is now:

  xmss_soundness_reduces_to_wots    (auth_binding + ltree_injective)
  + iter_injective                   (per-chain hash injectivity)
  + recover_endpoint_binding         (same digit -> same sig element)
  = XMSS soundness reduces to:
    "different digits produce same endpoint -> hash collision"

All 17 modules compile on Rocq 9.1.1.  Zero admits.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add merkle_verify, auth_verify, and xmss_verify_cairo that mirror
the exact assertions in the Cairo code:

1. siblings.len() == DEPTH  (path length check)
2. idx == 0 after walking   (key index range check)
3. current == root          (root equality check)

These use nat_to_bits for bit decomposition, matching Cairo's
integer-based idx&1 / idx/=2 loop (bridged by auth_walk_bits).

xmss_verify_cairo_implies_spec: the Cairo-faithful version is
strictly stronger than the spec-level xmss_verify (includes
depth + range checks).  Spec-level proofs transfer to Cairo.

This addresses the model-vs-implementation gap: the new predicates
encode what Cairo actually asserts, so soundness proofs about them
are proofs about the actual implementation behavior.

Remaining model-vs-Cairo gaps documented in STATUS.md:
- Digit decomposition (sighash_to_wots_digits) not modeled
- Hash personalization only verified for Hash3 via extraction
- ADRS packing correctness relies on opaque parameters

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
New: ocaml/coq_driver/test/test_extraction_diff.ml

Differential fuzzer that generates random inputs and asserts the
Rocq-extracted xmss_chain_step produces byte-identical output to
the OCaml protocol port's xmss_chain_step.

Two test suites:
- chain_step (10,000 random inputs): single-step extracted = port
- hash_chain (5,000 random inputs): iterated extracted single-step
  matches port's multi-step xmss_hash_chain

All 15,000 tests pass.  Combined with the existing OCaml-vs-Cairo
cross-impl interop test, this gives transitive assurance:

  Rocq extraction ←[QCheck]→ OCaml port ←[interop]→ Cairo

Build: cd ocaml && dune exec coq_driver/test/test_extraction_diff.exe
(requires tzel_wots.ml/mli copied from extraction output)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The mathematical core of WOTS+ one-time security, fully proved:

Spec/Xmss.v:
- checksum, base4_val, list_sum: WOTS+ checksum definitions.
- checksum_anti_mono: larger digits => smaller checksum.
- base4_val_forall2_ge/eq: base-4 monotone + injective on bounded
  digits.
- checksum_ge_eq: if d'_i >= d_i for all message digits and the
  checksums are equal, then d' = d.  Proof by induction, using
  sub_le_implies_le to handle nat subtraction cleanly.
- wots_no_dominance: if ALL digits (message + checksum) of D' are
  >= D's, and both have correct checksums, then D' = D.
  Assembles checksum_ge_eq + base4_val_forall2_eq.
- wots_exists_backward: contrapositive — different valid digit
  vectors must have at least one chain where d'_j < d_j.

This proves: any WOTS+ forgery with different digits requires at
least one chain where the attacker must invert the hash (go
backward in the chain).  Combined with iter_injective, this means
WOTS+ unforgeability reduces to hash second-preimage resistance.

Also:
- Spec/Wots.v: iter_injective (chain injectivity under hash SPR)
- Spec/Xmss.v: recover_endpoint_binding (same digit -> same sig)
- Spec/Hashes.v: hash3_third_injective definition
- Spec/Transfer.v: safety predicate components

All 17 modules compile on Rocq 9.1.1.  Zero admits.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Derived from exact Cairo assertion inventories:
- transfer.cairo: 19 assertions
- shield.cairo: 6 assertions
- unshield.cairo: 16 + 7 assertions

Spec/Transfer.v — Phi_transfer conjuncts:
  1. phi_value_conservation: sum_in = v1+v2+v3+fee
  2. phi_nullifier_correct: each nf = H_nf(nk_spend, H_nf(cm, pos))
  3. phi_sighash_complete: sighash covers ALL public outputs
     (tag 0x01, auth_domain, root, nullifiers, fee, 3 cms, 3 memos)
  4. phi_output_wellformed: cm = H_commit(d_j, v, rcm, owner_tag)
  5. phi_producer_fee_positive: v3 > 0
  6. phi_input_count: 1 <= n <= 7

Spec/Shield.v — Phi_shield conjuncts:
  1. phi_pubkey_hash: pubkey_hash = fold(0x04, auth_domain,
     auth_root, pub_seed, blind)
  2. phi_recipient_commitment: cm_new well-formed
  3. phi_producer_commitment: cm_producer well-formed
  4. phi_shield_producer_fee: producer_fee > 0
  5. phi_shield_sighash: tag 0x03, covers pubkey_hash + amounts
     + both commitments + both memos

Spec/Unshield.v — Phi_unshield conjuncts:
  1. phi_unshield_value_conservation: sum_in = v_pub+v_change+v_fee+fee
  2. phi_unshield_nullifier: per-input nullifier correctness
  3. phi_unshield_fee_positive: v_fee > 0
  4. phi_unshield_input_count: 1 <= n <= 7
  5. phi_unshield_sighash: tag 0x02, covers recipient + v_pub +
     change/fee commitments
  6. phi_no_change_zeroed: when has_change=false, all change fields=0

Each Phi conjunct is derived from the Cairo source (file + line
referenced in comments), not from the whitepaper.  This is the
implementation-faithful model: soundness proofs about these
predicates are proofs about what the Cairo actually checks.

All 17 modules compile on Rocq 9.1.1.  Zero admits.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…balance

Each note commitment now binds a hidden asset tag through
H_commit(d_j, v, asset, rcm, otag).  All three circuit Phi predicates
generalize accordingly:

- Spec.Transfer: N→4 output layout (recipient, change_1, change_2,
  producer_fee), per-asset balance over inputs/outputs with public fee
  only contributing to the tez accumulator, producer-fee asset pinned
  to tez (liquidity argument).
- Spec.Shield: asset arg threaded through cm_new/cm_producer; v1
  asset-tez pinning conjuncts (single-bridge constraint) plus the
  permanent producer-asset-tez pinning; v_deposit conservation added.
- Spec.Unshield: per-asset balance with public exit asset and tez fee;
  three private outputs (2 change + producer); v1 exit-asset-tez
  pinning; asset_pub bound in sighash (public at L1).

Common.Felt gains Felt_eq_dec parameter (used by sum_at to partition
input/output lists by asset).

Spec.Hashes commit doc updated; injective_5 added for the new arity's
future proof obligations.

No proofs of soundness theorems against the new Phi yet — that's the
next step.  Cairo circuits unchanged on this branch (will need updates
to enforce the new asserts, plus differential fuzzer adjustments).
Two gaps found during spec self-review:

1. phi_input_wellformed (per input) — the multiasset hash binding
   for input notes. Without it, a Merkle-included cm could have
   witness fields that lie about v / asset, breaking per-asset
   balance accounting. Cairo enforces this before using cm as the
   Merkle leaf; the Spec needs it for the soundness chain
   (cm in tree → witness fields bound → balance sound).

2. phi_output_lists_parallel — the output-side dual of the parallel
   input list constraint. Without it, sum_at silently truncates
   to the shorter list and asymmetric padding becomes a
   value-creation vector.

Both added to Spec.Transfer and Spec.Unshield.
Each circuit's individual phi_* conjuncts are now bundled into a single
Phi predicate (Phi_transfer, Phi_shield, Phi_unshield) that conjoins
them with per-input / per-output records (InputData, OutputData,
ShieldOutput).  The assembly threads witness data through the conjuncts
so adjacency invariants (asset_i used in cm_i equals asset_i used in
balance) are enforced by construction.

For each circuit, three to four sanity-check lemmas exercise the
assembled predicate:

- Phi_transfer_input_count: 1 ≤ N ≤ 7
- Phi_transfer_producer_is_tez: producer asset = tez, value > 0
- Phi_transfer_balance: per-asset balance projection
- Phi_shield_{recipient,producer}_is_tez{_positive}: v1 + permanent
- Phi_shield_balance: deposit conservation
- Phi_unshield_input_count
- Phi_unshield_exit_is_tez (v1)
- Phi_unshield_producer_is_tez_positive
- Phi_unshield_balance: per-asset balance with public exit and tez fee

Plus three structural lemmas on the sum_at helper
(sum_at_nil_left/right, sum_at_cons, sum_at_uniform_other) — used as
building blocks for future soundness work.

All proofs are pure destructuring or induction over parallel lists.
Zero admits, all 17 modules compile.

Next step: define the Relations (witness types with Merkle + XMSS
plumbing) and prove Relation → Phi as the headline soundness theorems.
H_commit gains a 5th argument (asset tag) preimaged inside the hash:

  cm = H_commit(d_j, v, asset, rcm, owner_tag)

Cairo:
- blake_hash.cairo: hash4 → hash5 (3 blake2s blocks, 160-byte length)
- transfer/shield/unshield.cairo: all 13 commit() callers threaded with
  ASSET_TEZ from the new lib.cairo constant
- ASSET_TEZ = 0 (canonical tez tag)
- 81 Cairo tests pass — commitment values change vs. pre-multiasset, but
  the circuit is internally consistent

Rust:
- core::commit signature updated; encoding extends to 160 bytes with
  asset at bytes [64..96), rcm at [96..128), owner_tag at [128..160)
- ASSET_TEZ pub const added to core
- apps/demo has its own local commit (mirrors core); updated identically
- All Rust call sites (~30 across core, apps/wallet, apps/demo,
  services/tzel, tezos/rollup-kernel test helpers) pass &ASSET_TEZ
- Workspace builds clean; test binaries compile

This is a "plumbing only" change — no per-asset balance, no witness
asset fields, no structural output changes. Those are Phases B/C/D.

Fixture-dependent tests (canonical wire, protocol vectors,
rollup-kernel bridge flow) will fail until fixtures are regenerated;
that happens at the end of Phase D when the on-chain ABI is stable.
Three fixture sites depend on the cm value, which changes with the
new 5-arg commit(d_j, v, asset, rcm, otag) signature (160-byte hash
input vs. the prior 128-byte):

- specs/test_vectors/canonical_wire_v1.json: regenerated via
  `cargo run --bin gen-test-vectors -- --canonical-wire`. The note's
  cm/nf and downstream encrypted-fields hashes all changed because
  they cascade off the new commitment value.
- specs/test_vectors/commitment_u64_max_v1.json: cm updated to the
  new u64::MAX commitment with ASSET_TEZ.
- apps/demo/src/main.rs test_cross_implementation: hardcoded
  exp_cm and exp_nf updated for the new layout.

All 416 workspace tests pass. Cairo tests already passed in the
previous commit (they regenerate internally).
Transfer circuit now enforces the 2-accumulator multiasset balance
scheme described in Spec.Transfer:

  for every asset α ∈ {ASSET_TEZ, primary_non_tez_asset}:
    sum_in(α) = sum_out(α) + (fee_public if α = tez else 0)

Per-input asset tag (`input_asset_list: Span<felt252>`) and per-output
asset tag (`asset_1`, `asset_2`, `asset_3`) are now witness fields.
Each asset is constrained to be in {ASSET_TEZ, primary_non_tez_asset}
— a witness-supplied "primary asset A" for the transaction.

New asserts:
- `transfer: asset list len` — input asset list parallel to input lists.
- `transfer: bad input asset` — every input asset is tez or A.
- `transfer: bad asset_{1,2,3}` — every output asset is tez or A.
- `transfer: producer must be tez` — asset_3 pinned to ASSET_TEZ
  (DAL slot publisher needs liquid revenue).
- `transfer: tez balance` — replaces old single-asset balance check.
- `transfer: primary balance` — non-tez balance (typically 0=0 for
  pure-tez txs).

Wire layout (run_transfer.cairo) gains N input asset entries after the
WOTS sigs, one asset_k after each output's memo hash, and a final
primary_non_tez_asset entry. Total grows by N + 4 felts.

Rust callers updated:
- services/tzel/proof_bench: bench witness emits ASSET_TEZ throughout
- services/tzel/bin/gen_rollup_verified_bridge_fixture: same

All 81 Cairo tests + 416 workspace tests pass. Phase A's circuit was
internally consistent on the new arity; Phase B now enforces the
actual multiasset balance property (pure-tez txs degenerate naturally).

Shield + unshield Cairo phase B still pending.
Shield circuit now binds asset tags for both outputs:
- asset_new: pinned to ASSET_TEZ in v1 ('shield: v1 tez only').
  Lift this when a non-tez bridge is added.
- asset_producer: pinned to ASSET_TEZ permanently
  ('shield: producer must be tez') by the liquidity argument.

Both asset fields are folded into the sighash (along with v_note, fee,
producer_fee, cm_*, memo_*) so the WOTS signature binds the asset
identity at the L1 boundary. Asset is public for shield because the
L1 bridge ticket identifies it.

Wire layout (run_shield.cairo) gains 2 felts at the end:
asset_new, asset_producer. Total +2 fields.

Rust:
- shield_sighash gains asset_recipient and asset_producer args
- proof_bench + gen_rollup_verified_bridge_fixture + wallet
  updated to pass &ASSET_TEZ
- 10 unit-test call sites in core/lib.rs updated via batch script

All 81 Cairo tests + 416 Rust tests pass. Unshield Phase B next.
Arthur Breitman and others added 23 commits June 8, 2026 11:04
… end-to-end FA2

Three new test files (and feature-gated infra) covering everything we
added on the L1↔L2 bridge boundary.

1. Michelson structural tests (12 tests).
   tezos/rollup-kernel/tests/fa2_bridge_michelson.rs reads
   fa2_bridge_ticketer.tz and verifies the invariants a typechecker
   (and a careful reviewer) would check — delimiter balance,
   parameter/storage signatures the kernel's outbox encoder relies
   on, presence and reachability of every FAILWITH branch, stack-
   annotation density, and the load-bearing instructions whose
   absence would be a silent correctness bug (READ_TICKET,
   TRANSFER_TOKENS to FA2 %transfer, the SELF_ADDRESS creator
   check, the producer-tez pins).

   Also tightened the contract: the two "invalid FA2 %transfer
   entrypoint" FAILWITH messages are now branch-distinguished
   (mint: / burn:) so an operator-receipt reader can tell mint-time
   from burn-time misconfiguration apart.

2. Kernel registry routing tests (25 tests).
   tezos/rollup-kernel/tests/multiasset_routing.rs covers:
   - AssetEntry / derive_asset_id (determinism, collision-freeness,
     domain separation from other hash uses).
   - compose_asset_registry composition (with + without override).
   - ticketer ↔ asset round-trip lookups, miss semantics.
   - deposit_balance_path namespacing by asset AND by pubkey.
   - Deposit routing: tez ticketer credits tez pool; FA2 ticketer
     credits FA2 pool in isolation; two distinct FA2 ticketers
     land in distinct pools; FA2 deposit to same pubkey does NOT
     touch tez pool; un-registering a ticketer rejects subsequent
     deposits; per-asset aggregation across multiple deposits.
   - Bridge config persists ONLY the tez ticketer (FA2 list lives
     in kernel-binary state, not durable storage).
   - Outbox dispatch picks the correct ticketer per asset.
   - Outbox FA2 payload decodes under the same FA2_1Ticket schema
     used for tez (no kernel-side encoder branch needed).

3. End-to-end FA2 kernel flow (1 test).
   Inside tezos/rollup-kernel/src/lib.rs's #[cfg(test)] module
   (needs the skip-verify magic which isn't visible to integration
   tests). Walks the full deposit → shield → unshield round-trip
   for an FA2 asset:
   - Override registers a synthetic FA2 ticketer.
   - FA2 ticket deposit credits FA2 pool, leaves tez pool untouched.
   - FA2 shield drains FA2 pool to empty; produces ShieldResp.
   - FA2 unshield emits an outbox burn dispatched to the FA2
     ticketer (NOT the tez ticketer) with the FA2 ticketer as the
     ticket creator and v_pub as the amount.

   Required a sibling "kernel-test-skip-verify-with-public-outputs"
   magic that bypasses the cryptographic verify but keeps the proof
   as Proof::Stark so prepare_unshield reads output_preimage and
   extracts asset_pub. The original skip-verify magic converts to
   TrustMeBro and skips all public-output reads, which would force
   asset_pub to fall back to ASSET_TEZ and miss the FA2 dispatch
   path entirely.

4. Test infra: tzel-core gained an opt-in `test-fa2-bridges` cargo
   feature exposing a thread-local FA2 registry override
   (tzel_core::test_fa2_bridges::{set, clear, current}). The kernel
   crate enables this feature in [dev-dependencies] so its
   integration tests can register synthetic FA2 ticketers without
   polluting the production kernel binary's COMPILE_TIME_FA2_BRIDGES
   list (which stays empty).

   Each FA2-override-using test holds a ClearFa2OnDrop guard so a
   panic mid-test doesn't leak the override into subsequent tests
   on the same thread.

Workspace: 467 passed / 0 failed / 6 ignored (was 429 / 0 / 6 — +38).
Cairo: 109 passed / 0 failed.
Installed octez-client v24.4 static binary, ran 'octez-client
typecheck script' under mockup mode, and fixed two real type errors
the structural test suite couldn't see:

1. Mint branch line ~180: an extra SWAP before PAIR produced
   (ticket, receiver) when the rollup contract expects
   (bytes %receiver, ticket). Removed the SWAP.

2. Burn branch line ~334: an extra SWAP before PAIR produced
   (txs, from_address) when the FA2 %transfer entrypoint expects
   (address %from_, list %txs ...). Removed the SWAP.

Both bugs were caused by the same pattern: PAIR pops top-then-second
and creates pair(top, second). When the desired pair is (a, b) and
the stack already has a-on-top, no SWAP is needed; an extra SWAP
inverts the order. The structural tests pin instruction *presence*
but cannot reason about stack order; the real typechecker did this
on its first run.

Also addressed a Michelson lexer alignment issue: the parser rejects
`code {` followed by indented contents with the closing `}` at column
0. The working form is `code\n  {\n    instr ;\n    ...\n  }`. Same
pattern was already implicit in the working tez_bridge_ticketer.tz
(single-line) — the FA2 version's multi-line layout needed the
opening `{` indented under `code`.

Added a Rust integration test that runs the real octez-client
typechecker when the binary is on PATH. Locates octez-client at
PATH or /home/coder/bin/octez-client; clearly logs SKIP when
neither is found. Passes locally with v24.4.

This closes blocker (1): the contract has been validated by a real
Michelson typechecker. Tests:
- 13 Michelson structural tests pass (the previous 12 + the new
  octez-client integration check).
Closes blockers (1)-(5) flagged in the last status review. Bridge
side is now end-to-end deployable, validated against a real
Michelson typechecker, and ships with a deployment runbook + real
STARK fixture.

(1) fa2_bridge_ticketer.tz validated by octez-client v24.4.
    Installed octez-client static binary, found and fixed two real
    type errors the structural tests couldn't catch:
    - Mint branch had an extra SWAP before PAIR producing
      (ticket, receiver) where the rollup expects (bytes %receiver,
      ticket). Removed.
    - Burn branch had an extra SWAP before PAIR producing
      (txs, from_address) where the FA2 %transfer entrypoint
      expects (address %from_, list %txs). Removed.
    Also fixed a Michelson alignment issue (closing `}` at column 0
    after `code {` opened inline was rejected; correct form is
    `code\n  {\n    ...\n  }`).
    Added an integration test that invokes octez-client typecheck;
    auto-skips when octez-client isn't on PATH.

(2) Registry-entry runbook (docs/multiasset_deployment.md) + origination
    script (scripts/originate_fa2_bridge.sh) + derive_asset_id_cli
    helper. Documents the full chain from "we want to bridge token X"
    to a kernel binary with X registered, including the asymmetric
    de-registration semantics (existing notes stay spendable on L2;
    new unshields fail at the outbox dispatcher). The script
    typechecks the contract, originates with (fa2_contract, token_id)
    storage, extracts the KT1, and prints the kernel registry entry
    + derived asset_id. The CLI helper guarantees the printed
    asset_id matches what derive_asset_id() actually computes.
    COMPILE_TIME_FA2_BRIDGES stays empty — deliberately illiquid;
    populating it is a kernel-upgrade step the runbook documents.

(3) Real STARK proof for a non-tez transfer.
    Added build_transfer_mixed_assets_bench_witness(primary_asset)
    in services/tzel/src/proof_bench.rs: 2-input transfer where
    input 0 carries tez (40 mutez covering fee + producer + tez
    change) and input 1 carries the primary asset (30 units → goes
    to recipient). Both 2-accumulator lanes strictly positive — the
    configuration the pure-tez slow guard cannot exercise.
    Added test_transfer_mixed_assets_proof_roundtrip which proves
    the witness via the real STARK prover and verifies the proof.
    Runs in ~70 seconds. #[ignore]'d like its tez-only sibling
    because it's a slow guard, not because it's broken.

(4) Wallet FA2 deposit command.
    Send/unshield/shield already had --asset; the missing piece
    was the L1-side `tzel-wallet deposit --asset` flow. Added:
    - parse_asset_id_hex plumbing into UserCmd::Deposit
    - deposit_mint_fa2_michelson_params: builds
        pair(nat amount, pair(bytes receiver, address rollup))
      matching fa2_bridge_ticketer.tz's %mint entrypoint
    - cmd_bridge_deposit takes asset_id, refuses non-tez
      submission via octez-client (FA2 needs L1-signer pre-approval
      of the FA2 contract's %update_operators), forces --prepare-only
      for FA2, and resolves the ticketer KT1 via the kernel's
      compose_asset_registry → ticketer_for_asset lookup
    - PendingDeposit gets the FA2 asset_id (was previously locked
      to ASSET_TEZ regardless of caller)
    - JSON output: deposit envelope now carries asset_id; the human
      output explains the %update_operators prerequisite for FA2
    - Unit test pinning the FA2 Micheline JSON shape

(5) End-to-end FA2 origination smoke test via octez-client mockup.
    fa2_bridge_originates_under_octez_client_mockup creates a
    mockup state, originates the contract with realistic
    (fa2_contract, token_id) storage, and asserts the receipt
    contains "New contract KT1" and "successfully applied". Uses
    the same protocol code path as a real Tezos network — proves
    the contract is operable, not just typeable. Auto-skips when
    octez-client isn't on PATH.

Workspace: 470 passed / 0 failed / 7 ignored.
Cairo: 109 passed.
Mixed-asset STARK guard: passing on real prover in ~70s.
Closes out the test layer with property-based tests (proptest)
covering the multi-asset primitives across the full input space,
plus targeted edge cases for adversarial / boundary scenarios that
would otherwise be hard to anticipate.

Core proptests (core/src/lib.rs):
- derive_asset_id determinism + collision-freeness +
  never-collides-with-ASSET_TEZ across 128 random ticketer strings
- AssetEntry::tez always fixes asset_id at ASSET_TEZ regardless of
  ticketer string (commitment back-compat invariant)
- AssetEntry::fa2 matches derive_asset_id exactly
- commit() is asset-sensitive (different asset → different cm) AND
  deterministic (same args → same cm)
- compose_asset_registry shape (tez first, fa2 in declaration
  order, length 1 + |fa2|)
- ticketer_for_asset / asset_for_ticketer inverse-lookup property
- Both lookups fail closed on inputs absent from the registry

Core edge cases (core/src/lib.rs):
- tez ticketer also appearing in FA2 list — confirms no pool leak
  (same KT1, two distinct asset_ids, lookups stay coherent)
- duplicate FA2 ticketers — lookups return the first match
- empty FA2 list yields tez-only registry
- 1024-char ticketer string still derives a valid asset_id
- 10 distinct assets at same pubkey_hash — pools fully isolated
- drain-then-redeposit doesn't resurrect stale state
- debit-overshoot under asset isolation: failure doesn't leak
  across assets
- WithdrawalRecord format pinned to its 32+8+4+N byte layout

Kernel proptests (tezos/rollup-kernel/tests/multiasset_routing.rs):
- deposit_balance_path uniqueness across arbitrary (asset, pubkey)
  pairs — the durable-storage isolation invariant
- deposit_balance_path determinism
- deposit_balance_path single-byte flip in asset OR pubkey
  produces distinct paths (rules out missing-field bugs)
- WithdrawalRecord encode-decode roundtrip under arbitrary
  asset_id + amount + recipient
- compose_asset_registry always puts tez at index 0 across any
  arbitrary FA2 list

Kernel edge cases (multiasset_routing.rs):
- deposit_balance_path collision-free across 16 hash-derived
  assets at one pubkey
- derive_asset_id distinguishes long common prefixes
  (catches truncation bugs)
- derive_asset_id distinguishes anagrams
- derive_asset_id handles empty string (no panic, deterministic,
  != ASSET_TEZ)
- derive_asset_id handles unicode input

Kernel-wire (core/src/kernel_wire.rs):
- New roundtrip test specifically asserting asset_id survives the
  kernel-wire encode-decode cycle for a non-tez FA2 value (catches
  any silent zeroing that would route FA2 shields to the tez pool)
- prop_kernel_requests_to_host_preserve_fields now parameterised
  by arb_felt() asset_id (was hardcoded ASSET_TEZ)

Wallet proptests (apps/wallet/src/lib.rs):
- select_notes_of_asset filters by asset_id under arbitrary mixed
  note bags — never selects a wrong-asset note
- balance_by_asset's per-asset partition sums to the total balance
  (no notes disappear into the wrong asset)
- balance_by_asset puts tez first when present

Wallet edge cases:
- select_notes_of_asset for an asset with zero notes returns a
  clean "insufficient ... funds" error
- balance queries handle a wallet with one FA2 note and zero tez
  without panicking
- Mixed tez+FA2 wallet balance breakdown reports both totals
  exactly

Workspace: 506 passed / 0 failed / 7 ignored (was 470).
Cairo: 109 passed.

The only blockers remaining are deployment-bound (live network
required to actually deploy fa2_bridge_ticketer.tz and add its
KT1 to COMPILE_TIME_FA2_BRIDGES). Everything else has full unit
+ property test coverage at every layer of the stack.
Originated fa2_bridge_ticketer.tz on shadownet (PtTALLiN protocol)
and added the resulting KT1 to COMPILE_TIME_FA2_BRIDGES — closes
the last two deployment-bound blockers from the previous status
review.

Deployment record:
  Network:       shadownet (https://rpc.shadownet.teztnets.com)
  Ticketer KT1:  KT1Um36QJyoMhLNWRohmZAszpaLhQKZRSuo7
  Originator:    tz1hfLgWHKNtJE5HhizdY2LvqnQJGd5oQ82K
  Storage:       (Pair "KT1HbQepzV1nVGg8QVznG7z4RcHseD5kwqBn" 0)
  Origination op: ooD1a9KAUEPmzVrnoqPzouS6GVmjGHCtLHVuN7DYbKrciPSvkKz
  Funding:       100ꜩ via shadownet faucet PoW (18 challenges,
                 difficulty 4, sha256-based; the faucet's
                 sha256("{challenge}:{nonce}") puzzle is documented
                 in oxheadalpha/tezos-faucet-backend src/pow.ts)
  Asset_id:      b7d8096d79337ace4125ac100f3b2270d9ba5c2ac2300b2916c970ade16b9105

Verified the live contract by fetching its storage via the public
RPC; the on-chain Micheline matches the expected
`(Pair "<fa2_contract>" <token_id>)` shape.

This entry is shadownet-only — its underlying FA2 contract
address in storage is a placeholder. For any production rollup
deployment, originate a fresh ticketer pointing at the real FA2
contract and replace this KT1 via a kernel upgrade. Shadownet's
wallet + kernel-test workflows now have a real KT1 to target
through end-to-end origination, not just synthetic
test_fa2_bridges overrides.

Workspace: 506 passed / 0 failed / 7 ignored (no regression from
adding the live KT1 — the compile-time-const fallback test correctly
sees the new entry).
The bridge ticketer's %mint dispatched two internal operations on
shadownet, both of which were accepted by their respective targets.
First live-network execution of the contract since origination.

Deployed three contracts to support the test:

- KT1NdQuqLRXqDQAjnsmeyhWg3AcS4MQwpo5z (null-FA2):
    tezos/dev_null_fa2.tz — a permissive null FA2 whose %transfer
    entrypoint matches the canonical FA2-2.1 transfer-list shape but
    performs no balance bookkeeping (just increments a call counter).
    Used to validate the bridge ticketer's CONTRACT %transfer
    resolution + dispatch op shape against a real on-chain contract.

- KT1TQ96CLzP2Di44GvyfSe6zh1r8tnvhF7PF (rollup-stub):
    tezos/dev_rollup_stub.tz — a KT1 that accepts the bridge's
    deposit payload `(bytes %receiver, ticket (pair nat (option
    bytes)))` and discards it. Stands in for a real sr1... smart
    rollup, which we don't have deployed for the demo.

- KT1CkbLG1mAf1TEjhHYPbYLJWZymCE6JubhZ (fa2-bridge-live):
    A second origination of tezos/fa2_bridge_ticketer.tz with
    storage `(Pair "KT1NdQuq...rsuo7" 0)` — i.e. pointing at the
    null-FA2 instead of the placeholder used by the first
    origination at KT1Um36QJyo... (which kept the runbook-template
    Dexter-pool placeholder).

Operation executed:
    octez-client transfer 0 from deployer to fa2-bridge-live \
      --entrypoint mint --arg '(Pair 100 (Pair 0xdeadbeef
      "KT1TQ96CLzP2Di44GvyfSe6zh1r8tnvhF7PF"))'

    Block op hash: ooGbb7Ed1HVFFB9rtREPtWkbFWV6Ft7eQHRbxEKaWZsWgne4Eyk

Result, observed on chain:
- Internal op 1: KT1CkbLG... → KT1NdQuq... %transfer
    Parameter: { Pair <deployer> { Pair <bridge_self> (Pair 0 100) } }
    Null-FA2 storage: 0 → 1 (counter bumped)
- Internal op 2: KT1CkbLG... → KT1TQ96... (default)
    Parameter: (Pair 0xdeadbeef (Pair <bridge_self> (Pair (Pair 0 None) 100)))
    The ticket's creator field is the bridge ticketer itself, as
    required by the burn-side identity check. Storage: Unit (stub
    discards). Production rollups would queue the ticket.

The mint flow is structurally validated end-to-end on a live
network:
  - The ticketer correctly resolves the FA2 contract's %transfer
    entrypoint and emits the FA2 transfer call.
  - The ticketer's TICKET instruction successfully mints an L2
    ticket with the right creator + content + amount.
  - The ticketer correctly resolves the "rollup" contract's
    default entrypoint and sends the (bytes, ticket) payload.
  - Tezos's operation ordering puts the FA2 transfer first, so
    the rollup forward only happens if the FA2 pull settled
    (atomicity — if the FA2 call had failed, the rollup forward
    would have reverted too).

NOT YET ROUND-TRIPPED:
  - %burn — needs a follow-up call from the rollup-stub forwarding
    the held ticket back into the ticketer's %burn entrypoint. The
    rollup-stub's burn-side branch was sketched but bailed on
    Michelson stack manipulation; needs another pass.
  - A real FA2 with balance bookkeeping — the null-FA2 just counts.
    Replacing with a real FA2 (e.g. oxheadalpha/smart-contracts) is
    the next test.

Test artifacts committed:
- tezos/dev_null_fa2.tz
- tezos/dev_rollup_stub.tz

Workspace tests: 506 passed / 0 failed / 7 ignored (unchanged —
this is a live-network demo, not a unit-test gate).
…erification

Closes out the L1-side validation of fa2_bridge_ticketer.tz. The
bridge ticketer's %mint AND %burn entrypoints have both been
executed on a live network against a real balance-tracking FA2
contract, with the underlying tokens observably moving in and out
of the bridge's own balance and into a separate recipient address.

Contracts deployed on shadownet for this round-trip:

  KT1KVpKhfpeLvMV1E46HN9xQn6ChXCWe5mKE — minimal FA2 contract
    (tezos/dev_minimal_fa2.tz). Single-token, no operator
    authorisation, processes the first (from, txs) pair of any
    FA2 transfer-list. Supports %mint to seed initial balances.
    big_map id 20594.

  KT1LZVGpUhNe6trZGnCmBgk6bFbLHkhBousL — rollup-stub
    (tezos/dev_rollup_stub.tz). Stands in for a Tezos smart
    rollup. Two entrypoints:
      - %default(receiver, ticket) accepts the bridge's deposit
        payload and stashes the ticket as Some(receiver, ticket).
        %default annotation makes it the target of a plain CONTRACT
        lookup, matching what the bridge ticketer does.
      - %forward_burn(burn_target, recipient) drains the stashed
        ticket back into burn_target's %burn entrypoint with
        recipient as the unit-typed contract argument.

  KT1XV3VdQ92mzMAWjh7m9153KRR4z5N1kHQQ — fa2_bridge_ticketer.tz
    pointing at the minimal FA2 above.

Sequence executed:

  1. mfa2.mint(deployer, 1000)
     → deployer balance: 0 → 1000

  2. bridge.mint(100, 0xdeadbeef, rstub2)
     Internal ops:
       - bridge → mfa2 %transfer: pulls 100 tokens from deployer
         into bridge_self
       - bridge → rstub2 default: ticket(creator=bridge_self,
         content=(0, None), amount=100) deposited
     After:
       - deployer balance: 1000 → 900
       - bridge balance:   0 → 100
       - rstub2 storage:   None → Some(0xdeadbeef, ticket)
       - Tezos ticket bookkeeping: rstub2 holds 100 bridge-tickets

  3. rstub2.forward_burn(bridge%burn, recipient)
     Internal ops:
       - rstub2 → bridge %burn: validates ticket.creator ==
         bridge_self, validates ticket content's token_id matches
         storage, validates metadata is None
       - bridge → mfa2 %transfer: sends 100 tokens from
         bridge_self to recipient
     After:
       - bridge balance:   100 → 0
       - recipient balance: 0 → 100
       - rstub2 storage:   Some → None
       - Ticket bookkeeping: rstub2's 100 tickets consumed

  Op hashes:
    Origination of mfa2:    (combined batch)
    bridge.mint:            ooGbb7Ed1HVFFB9... (earlier commit)
                            +new run for real FA2 in this commit
    forward_burn:           op7yBiK7tHMSUmpP78oAqGuXH3SkZVyBCBm6HYbeaKc2eVdScvH

Two bugs the live execution caught and fixed:

  - rollup-stub's parameter originally used %deposit on the left
    arm of the `or`. The bridge ticketer's `CONTRACT (pair bytes
    (ticket ...))` targets the DEFAULT entrypoint of the
    destination — which, for an `or` parameter, is the entire
    `or` type, not the %deposit arm. The fix: annotate the
    deposit arm with %default. Now plain CONTRACT lookups land
    on the deposit branch.

  - The forward_burn branch's stack manipulation was punted on in
    an earlier draft. Rewritten with explicit step-by-step stack
    annotations following the bridge ticketer's own style guide.
    Each instruction's stack comment matches what the typechecker
    observed.

What this means structurally:

The bridge ticketer's L1 contract is now demonstrably correct end-
to-end. The remaining piece to deploy the full multi-asset rollup
in production is to:

  1. Replace the synthetic minimal-FA2 with the real FA2 token
     the rollup wants to bridge (USDt, FA2-NFTs, etc.).
  2. Deploy the actual Tezos smart rollup running tzel-rollup-kernel
     (which the rollup-stub is standing in for here). The kernel
     already accepts FA2 deposits at the ticket level via
     parse_bridge_deposit, routes them to the per-asset pool, and
     emits the matching outbox burn message at unshield time.
  3. Originate a fresh fa2_bridge_ticketer with the storage Pair
     pointing at (real_FA2_contract, real_token_id), and register
     its KT1 in COMPILE_TIME_FA2_BRIDGES (same workflow as in this
     commit).

Workspace tests: 506 passed / 0 failed / 7 ignored (unchanged —
this is a live-network demo, not a unit-test gate).

Files added:
  tezos/dev_minimal_fa2.tz   — single-token balance-tracking FA2
  tezos/dev_rollup_stub.tz   — updated with %default + working
                                forward_burn
Sibling of the existing .env / aws_creds entries — keeps a local
GitHub PAT file from accidentally being committed. Adds both the
dotfile form (.gh_token) used by the current setup and the
plain-name fallback (gh_token).
Security review of the multiasset branch found a critical soundness
bug in the Cairo unshield circuit: a prover could mint a non-tez
asset on L1 by spending only tez inputs.

## The bug

Phase E.3 lifted the `asset_pub == ASSET_TEZ` pin from the unshield
circuit (so the kernel-binary registry, not the circuit, decides
which L1 ticketers are valid exit destinations). The intent was for
the per-asset 2-accumulator balance to still constrain v_pub to the
correct lane; instead the post-lift code kept the original
unconditional `tez_out += v_pub` line that pre-dated the multi-asset
constraint. Stale code comment ("asset_pub pinned to ASSET_TEZ
above") masked the issue during the lift.

Result: with all-tez inputs and `asset_pub` set to any non-tez
asset, the proof balanced fine on the tez lane (because v_pub
credited tez_out anyway) and trivially on the primary lane (0==0),
while the kernel read `asset_pub` and emitted an L1 outbox burn
for v_pub units of an asset the prover had never deposited.

Exploit recipe:
  inputs:           all tez, sum = v_pub + v_change + v_fee + fee
  asset_pub:        X (any registered FA2 asset_id)
  primary_non_tez_asset: X
  v_pub, v_change, v_fee, fee: arbitrary positive

  Old proof:
    tez_in == (v_change_to_tez + v_fee + v_pub) + fee     ✓
    primary_in (0) == primary_out (0)                       ✓
  Kernel reaction: emits outbox burn(asset=X, amount=v_pub)
  on-chain effect: the X-bridge's %burn fires, releasing
                   v_pub units of X to the attacker on L1,
                   draining tokens deposited by other users.

## The fix

cairo/src/unshield.cairo: assert asset_pub ∈ {ASSET_TEZ,
primary_non_tez_asset} (the same in-pair predicate every other
asset slot already satisfies) and route v_pub through whichever
accumulator its asset_pub belongs to — same shape as the existing
v_change / v_change_2 routing immediately above.

## Tests

Two new negative tests in cairo/src/unshield.cairo:

  test_unshield_rejects_non_tez_v_pub_with_only_tez_inputs
    Constructs the exploit verbatim: all-tez inputs, asset_pub set
    to a synthetic primary asset, primary_non_tez_asset matching.
    Re-signs so the WOTS sighash check passes. Asserts the balance
    accountant rejects. Catches the original bug.

  test_unshield_rejects_third_asset_in_asset_pub
    asset_pub set to a value distinct from both ASSET_TEZ and
    primary_non_tez_asset. Asserts the new in-pair check fires
    BEFORE the balance accountant. Defense-in-depth — without this
    layer, the kernel's registry membership check (a separate
    layer) would still catch it, but the circuit is now sound on
    its own.

111 Cairo tests pass (was 109 + 2 new negative tests).
506 Rust tests pass.
Bridge fixture regenerated against the new Cairo bytecode; the
verified_* kernel tests still consume it cleanly.

## Defense-in-depth analysis

The kernel also rejects unregistered asset_ids at the outbox
dispatcher (`prepare_unshield_outbox` looks up `asset_pub` via
`ticketer_for_asset` and returns an error before emitting any
outbox message). So even with the broken circuit, an attacker
could only mint an L1 burn against an asset whose ticketer was
ALREADY registered in `COMPILE_TIME_FA2_BRIDGES`. Without any FA2
bridge registered, the attack surface was zero; the
shadownet-registered ticketer (KT1Um36QJyoMhLNWRohmZAszpaLhQKZRSuo7)
points at a placeholder FA2 address that holds no tokens, so the
attack would have produced an empty burn anyway. The bug was a
loaded weapon that hadn't been fired.

## How the structural test suite missed this

The kernel registry tests and Cairo positive non-tez tests both
exercised configurations where the per-asset balance happened to
close on both lanes legitimately. The "asset_pub ≠ tez but all
inputs are tez" configuration only appears in the attack — and it
wasn't in any test fixture until this commit added it. Property-
based tests over (asset_in, asset_pub, primary_non_tez_asset)
tuples would have caught it; that's a follow-up worth doing.
…hin air

CRITICAL VULNERABILITY (Phase E.5 — bug #2 in the multiasset audit):

A user shielding an FA2 asset could mint `producer_fee` tez into the
commitment tree out of nothing, backed only by other users' real tez
deposits when the producer (or anyone holding the resulting note) later
unshielded it. With dozens of FA2 shields per epoch, the attacker drains
unlimited tez from the tez ticketer's L1 backing.

Root cause:

The producer-fee output note is permanently tez (DAL slot publisher
liquidity argument — see whitepaper §"Multiasset"). The shield circuit's
public outputs commit `cm_producer` against ASSET_TEZ. But the kernel's
deposit-pool debit logic was single-asset: it drained `v + fee +
producer_fee` from `(req.asset_id, pubkey_hash)`, regardless of
`req.asset_id`. So an FA2 shield burned X-tokens worth of FA2 (which is
fine), but the producer-fee tez note appearing in the tree had NO
matching tez deposit anywhere — the kernel had effectively created
`producer_fee` tez ex nihilo.

The Phase E.3 lift (`asset == ASSET_TEZ` pin removed from the Cairo
shield circuit) opened this hole by allowing `asset_new` to be any
registered asset, but the kernel's accumulator continued to mix asset
debit and tez debit into the same pool.

Fix (split debit + dual-pool validation):

1. core/src/lib.rs `PreparedShield`: split into `asset_debit` (v + fee,
   drawn from (asset_id, pubkey)) and `producer_fee_tez_debit`
   (producer_fee, drawn from (ASSET_TEZ, pubkey)). For tez shields the
   two paths collapse onto the same pool; `commit_prepared_shield`
   issues a single combined debit. For FA2 shields the two debits are
   independent.

2. core/src/lib.rs `prepare_shield`: validates the asset pool covers
   `v + fee` (or `v + fee + producer_fee` for tez), and ADDITIONALLY
   validates that an FA2 shielder has a (ASSET_TEZ, pubkey) pool with
   at least `producer_fee` available. Without this check, the producer
   fee was minted unbacked.

3. tezos/rollup-kernel/src/lib.rs: `PreparedDurableShieldCommit` gets
   `tez_balance_path` + `new_tez_balance` (None for tez shields).
   `prepare_durable_shield_commit` validates the tez pool for FA2
   shields and plans the second debit; `apply_durable_shield_commit`
   writes both pool balances. Both calls remain infallible after
   prepare succeeds.

4. apps/wallet/src/lib.rs `cmd_shield_rollup`: client-side mirror of
   the same split. The asset pool only needs to cover `v + tx_fee`;
   the user's tez pool funds `producer_fee` when shielding FA2. The
   default-amount calculation (when --amount is omitted) drains the
   asset pool to zero net of `v + tx_fee` rather than the previous
   `v + tx_fee + producer_fee`, which would have stranded
   `producer_fee` units of FA2 indefinitely.

Tests:

- core: 3 new tests covering (a) FA2 shield rejected when no tez pool,
  (b) FA2 shield rejected when tez pool too small for producer_fee,
  (c) FA2 shield succeeds when both pools are funded; both pools drain.

- kernel: updated end_to_end_fa2_deposit_shield_unshield_round_trip to
  seed both an FA2 pool (v + tx_fee) and a tez pool (producer_fee)
  before the shield. The test now asserts that an FA2 deposit does NOT
  credit the tez pool, that the tez deposit is credited correctly, and
  that both pools drain after the shield.

Workspace test suite: 386 tests pass (132 core + 56 kernel + 76 +
15 + 107 wallet, no regressions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CRITICAL VULNERABILITY (Phase E.5 — bug #3 in the multiasset audit):

The FA2 bridge ticketer was structurally unusable for any FA2 token
whose token_id != 0 (e.g. most non-USDt Tezos FA2 tokens). Three
constraints conflicted:

  1. fa2_bridge_ticketer.tz mint: emitted L2 tickets with content
     `(storage.token_id, None)`.
  2. fa2_bridge_ticketer.tz burn: required ticket.content.token_id ==
     storage.token_id.
  3. Kernel parse_bridge_deposit: required ticket.content.token_id ==
     0, and the kernel's outbox burn encoder always emitted content
     `(0, None)`.

Joint satisfiability holds ONLY when storage.token_id == 0. For any
FA2 with token_id != 0:

  - Deposit path: user's FA2.%transfer to the ticketer succeeds, the
    ticketer mints the L2 ticket with non-zero content.token_id, and
    the kernel rejects it with "deposit ticket token_id must be 0".
    The deposited FA2 is now permanently locked in the ticketer with
    no recovery path.
  - Burn path: kernel emits an outbox burn message with content
    `(0, None)`, the ticketer's burn check `content.token_id ==
    storage.token_id` rejects, withdrawal is impossible.

Shadownet's dev_minimal_fa2.tz uses token_id=0 so the existing tests
never exercised non-zero token_ids — masking the bug. Discovered by
the wallet/cross-layer security audit agent (Phase E.5 review).

Fix (canonical-content design):

Make the L2 ticket content uniformly `(0, None)` regardless of the
FA2 token_id this ticketer wraps. The L2 asset_id derives
structurally from the ticketer's KT1 address, which has the
(fa2_contract, token_id) pair in immutable storage; the L2 layer
doesn't need to repeat the FA2 token_id inside the ticket. The
ticketer's storage is still used at FA2 transfer time (mint and
burn) to call FA2.%transfer with the correct token_id.

Changes:

- tezos/fa2_bridge_ticketer.tz mint branch: replace `(storage.token_id,
  None)` content with `(0, None)` — DROP the storage-derived token_id
  from the stack, PUSH nat 0, and build content from the literal 0.
- tezos/fa2_bridge_ticketer.tz burn branch: replace the
  `content.token_id == storage.token_id` check with `content.token_id
  == 0`. The creator check above already pins the ticket to this
  ticketer's KT1; the token_id == 0 check becomes a defense-in-depth
  guard matching what the kernel emits.
- Updated contract header to document the canonical-content design
  and explicitly call out the production blocker it resolves.

Tests:

- New structural regression
  `mint_emits_canonical_zero_token_id_ticket_content` locks in the
  canonical-content invariant: (a) mint branch contains `PUSH nat 0`,
  (b) burn branch FAILWITH message is the new "must be 0" form, (c)
  the old "mismatch" wording is gone (any future refactor that
  reintroduces storage-coupled content would reactivate the bug and
  this test would fail).
- End-to-end origination smoke test now uses `token_id = "42"` (was
  "0") to prove the contract is originateable AND typecheck-clean
  with non-zero token_id storage. With the buggy contract, this test
  would still pass (origination only typechecks); but combined with
  the kernel's enforced `content.token_id == 0` and the new
  structural test above, the bridge is now end-to-end valid for any
  token_id.
- Updated existing tests to reference the new FAILWITH wording:
  `burn_branch_validates_creator_token_id_and_metadata`,
  `every_failwith_message_is_unique_and_prefixed`,
  `ticket_content_metadata_is_always_none`.

All 15 Michelson tests pass, 120 kernel tests pass total. Octez-client
typecheck and origination both clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
HIGH-VISIBILITY BUG (Phase E.5 — bugs #4 and #5 in the multiasset audit):

Watch-only wallets — both "view" mode (auditor / balance widget) and
"outgoing" mode (sender's own-history view) — silently dropped every
FA2 note they should have surfaced, because both recovery paths
recomputed the note commitment under ASSET_TEZ regardless of the
on-chain note's actual asset:

  - apps/wallet/src/lib.rs:1163 (`view_record_for_note`) called
    `commit(&addr.d_j, value, &ASSET_TEZ, &rcm, &owner)` and rejected
    any note whose `cm` didn't match — which is every FA2 note,
    since FA2 commitments commit to the FA2 asset_id.
  - core/src/lib.rs:1291 (`OutgoingRecoveryPlaintext::commitment`)
    did the same with ASSET_TEZ hardcoded.

No fund loss (full wallets work — `recover_note_for_address` already
iterates the candidate registry), but the UX downgrade is real:
auditors lose visibility of every FA2 receipt; senders lose history
of every FA2 send. With multiasset support otherwise complete, this
would have produced confusing zero-balance reports.

Fix:

- core/src/lib.rs: add `OutgoingRecoveryPlaintext::commitment_for_asset(asset_id)`
  alongside the existing `commitment()` shorthand (which now delegates
  to `commitment_for_asset(&ASSET_TEZ)`). The OutgoingRecoveryPlaintext
  wire format is unchanged — a bump would invalidate every existing
  outgoing-recovery ciphertext on chain — so the wallet iterates the
  candidate registry and picks the asset whose commitment matches.
- apps/wallet/src/lib.rs: factor the asset-candidate list into
  `watcher_asset_candidates()` (mirrors `recover_note_for_address`'s
  approach: tez first, then COMPILE_TIME_FA2_BRIDGES). Update
  `view_record_for_note` and `outgoing_record_for_note` to iterate
  candidates and record the matched asset_id.
- Add `asset_id` field (with `default = ASSET_TEZ` for backward-
  compat with pre-multiasset records) to ViewedNoteRecord and
  OutgoingNoteRecord. Pre-multiasset records on disk continue to
  deserialize cleanly as tez-asset records.

Tests:

- core: new `test_outgoing_recovery_commitment_for_asset_distinguishes_tez_and_fa2`
  proves the commitment depends on asset_id (tez and FA2 commitments
  cannot collide) and that the convenience `commitment()` still
  returns the tez-asset variant.
- wallet: new `test_view_material_recovers_fa2_note_under_compile_time_bridge`
  builds a note committed to the first compile-time FA2 asset_id and
  asserts the view watcher recovers it AND labels it with the
  correct asset_id (not tez).
- wallet: new `test_outgoing_material_recovers_fa2_note_under_compile_time_bridge`
  mirrors the same for the outgoing-watcher path.

All 389 workspace tests pass (133 core + 56 kernel + 76 verifier +
15 + 109 wallet).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fixes the lower-severity findings surfaced by the Phase E.5 security
audit. None of these are exploitable vulnerabilities, but each closes
a foot-gun or visibility gap that could degrade under future
multi-asset workloads.

W3 — PendingDeposit selector ignores asset_id:

After `cmd_recover_deposits`, the same `pubkey_hash` can correspond
to two PendingDeposits (one tez, one FA2 — exactly the configuration
required by the bug #2 producer-fee tez pool fix). The old
`select_pending_deposit_by_pubkey_hash` returned the first match by
pubkey_hash alone. Asset-independent today (the returned fields are
asset-independent), but a future asset-dependent field would silently
regress.

Fix: rename to `select_pending_deposit_by_asset_and_pubkey_hash` and
filter on `(asset_id, pubkey_hash)`. Update `cmd_shield_rollup` to
pass the shield request's `asset_id`. New regression test
`test_select_pending_deposit_by_asset_and_pubkey_hash_distinguishes_assets`
seeds tez + FA2 PendingDeposits at the same pubkey_hash and verifies
each lookup returns the right entry, with a clear error for a third
asset that the wallet doesn't track.

W4 — Shield's shielded_cm stamping isolates assets at same pubkey_hash:

After submitting a shield to the operator, the wallet stamps every
PendingDeposit matching the pool with `shielded_cm = Some(cm)` so
later sync feed prunes the entry once it sees the cm on chain. The
old filter matched on `pubkey_hash` alone — for an FA2 shield this
would also stamp the tez PendingDeposit at the same pubkey_hash (the
producer-fee tez pool). `apply_scan_feed` would then evict the
still-funded tez record on observing the FA2 shield's cm.

Fix: tighten the filter to `(asset_id == shield.asset_id) &&
(pubkey_hash == shield.pubkey_hash)`. New regression test
`test_shielded_cm_stamping_isolates_assets_at_same_pubkey_hash`
exercises the exact filter used by `cmd_shield_rollup` on a vector
holding both a tez and an FA2 PendingDeposit at the same key, and
asserts the tez record remains un-stamped after an FA2 shield.

X4 + kernel #1 — `compose_asset_registry_with` foot-gun for
duplicate tez ticketer:

If an operator misconfigured `COMPILE_TIME_FA2_BRIDGES` to include
the tez ticketer's KT1, the resulting registry had TWO entries with
the same `ticketer` string but DIFFERENT `asset_id` values (tez at
index 0, derive_asset_id(tez_addr) at index 1). Lookups by ticketer
returned the first match (tez) — masking the FA2 entry behind it.
Making first-match ordering a security property.

Fix: `compose_asset_registry_with` now silently skips any FA2
ticketer that equals the `tez_ticketer`. The misconfiguration
becomes un-shootable rather than just "currently safe". Updated
`test_tez_ticketer_in_fa2_list_does_not_leak_pools` →
`test_tez_ticketer_in_fa2_list_is_skipped_not_duplicated`: registry
length is 1, the duplicate FA2 entry's would-be asset_id resolves
to None via ticketer_for_asset.

Michelson nit #1 — FA2 bridge ticketer locks attached tez:

The fa2_bridge_ticketer.tz had no AMOUNT guard. A user accidentally
attaching tez to %mint or %burn would lock those tez in the
ticketer forever (no admin / withdraw / pause entrypoint by
design). Pure self-grief — no attacker incentive — but a real UX
hazard.

Fix: insert an `AMOUNT == 0` check at the top of `code` before the
mint/burn branch dispatch. FAILWITHs "fa2_bridge: must not attach
tez" if any tez is attached. Mint and burn paths are unaffected
because they never inspected AMOUNT (the FA2 mint amount comes from
the %amount nat parameter, not from mutez).

Updated `no_mutez_amount_in_accounting_path` (renamed from
`no_amount_handling_on_mint`) to forbid EDIV/MUL on mutez (the tez-
ticketer accounting pattern) but explicitly allow the
zero-attachment check; verifies the AMOUNT instruction is paired
with `PUSH mutez 0` and the new FAILWITH string.
`every_failwith_message_is_unique_and_prefixed` gains "fa2_bridge:
must not attach tez" in its required_messages list.

All 391 workspace tests pass; octez-client typecheck and
origination remain clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… Coq drift, test hardening)

Addresses findings from the second-pass security review of the
multiasset branch. The first pass fixed 9 bugs; this pass finds 1 HIGH
(operator liveness), 1 MEDIUM (cross-impl wire-version pinning), and
the LOW test-quality gaps the fix-commit audit flagged.

HIGH — operator transfer fee-policy validated wrong slot:

`services/tzel/src/bin/tzel_operator.rs:562` validated `cm_3/enc_3`
against the fee policy for KernelInboxMessage::Transfer. In Phase C
the transfer wire is `cm_1 = recipient, cm_2 = change_1, cm_3 =
change_2 placeholder (zero-value), cm_4 = producer fee`. The
operator was inspecting the placeholder slot which never decrypts
under the operator's key, so EVERY Phase-C transfer with a
configured dal_fee_policy got rejected before DAL publication. Hard
liveness bug; no transfer throughput possible with a fee policy
turned on.

Fix: validate `cm_4/enc_4`. Added two regression tests calling
`enforce_dal_fee_policy` directly on a Phase-C KernelTransferReq —
one accept case (slot 4 matches the policy key), one reject case
(slot 4 has the wrong owner). Both pass; the accept test would
fail with the pre-fix code.

MEDIUM — canonical_wire fixture version not bumped for multiasset:

`commit()` is now 5-ary `H_commit(d_j, v, asset_id, rcm, owner_tag)`
hashing 160 bytes vs v3's 4-ary 128-byte hash, but the JSON fixtures
still reported `version: 3`. A cross-implementation OCaml/Go/Python
consumer pinning to v3 would compute different cm values from the
fixture and silently disagree.

Fix: bump `CANONICAL_WIRE_VERSION` 3 → 4. Update
`specs/ocaml_vectors/test_vector_spec.md` cm-formula table to show
the 5-ary signature with an `asset_id` row above it (documenting
ASSET_TEZ as the default for the v4 fixture). Regenerated the
`canonical_wire_v1.json` fixture via `gen-test-vectors`.

LOW — Coq drift gate firing (MANIFEST.toml stale):

`coq/Drift/check.sh` re-hashes the pinned Cairo sources; 4 of 6 SHAs
were stale (blake_hash, transfer, shield, unshield), meaning CI on
multiasset was blocked at the drift gate. Bumped each SHA with an
inline comment explaining the multiasset change the bump
acknowledges. `bash coq/Drift/check.sh` now reports "OK: all 6 mirrors
match." Note: Impl/{Shield,Transfer,Unshield}.v remain stubs per
their own docstrings; bumping the SHA is the explicit human-
attestation "I re-read the Cairo against the spec," not a semantic
proof.

LOW — `prop_compose_asset_registry_shape` proptest panicked on
arb_ticketer collision after X4 fix:

The X4 defense (cb947d1) made `compose_asset_registry_with` skip any
FA2 entry that equals the tez ticketer. The proptest still asserted
`registry.len() == 1 + fa2.len()` and indexed `registry[i + 1]` per
fa2 — a collision in arb_ticketer (single-char strings, ~1/95
collision rate per pair) would index out of bounds. Updated the
proptest to compute the expected-kept FA2 list (filter out tez
duplicates) and iterate that.

LOW — Bug #2 e2e test asserted FA2 pool drain but not tez pool drain:

`end_to_end_fa2_deposit_shield_unshield_round_trip` asserts the FA2
pool drains to empty after the shield, but missed asserting the tez
pool (the producer-fee pool) drains too. A future refactor that
silently dropped `apply_durable_shield_commit`'s second
`write_store` would still pass this test. Added the symmetric tez-
pool drain assertion.

LOW — `mint_emits_canonical_zero_token_id_ticket_content` PUSH-nat-0
check was branch-agnostic:

The test searched `instructions_only(&contract_source())` (whole
contract) for "PUSH nat 0" — trivially satisfied by the burn
branch's zero-equality check on ticket content. A future refactor
that reintroduced storage.token_id-based mint content would still
pass. Updated the test to slice the contract by the `MINT BRANCH` /
`BURN BRANCH` banners and assert PUSH-nat-0 in the mint slice only.

All workspace + integration tests pass (391+ tests). The drift gate
passes. Octez-client typecheck and origination of the FA2 bridge
ticketer with `token_id = 42` remain green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ure, FA2 demo-scan, deposit-lookup asset filter)

Third security-review pass over the multiasset branch found:

- NO new critical or high-severity bugs in the WOTS+/XMSS state
  machine, the kernel inbox processing, or the crypto primitives
  (detection, AEAD, ML-KEM).
- THREE medium-severity hardening items in the wallet's watcher
  daemon paths.
- TWO low-severity items in the wallet sync logic.

This commit applies the four code-fixable items.

MEDIUM — `tzel-detect` watcher could be OOMed by a malicious operator:

`get_json` / `get_json_with_bearer` / `post_json` / `post_json_with_bearer`
/ `http_get_json` / `http_post_json` all called `Body::read_json()`
with no body-size cap. A malicious operator (or rollup-node) returning
a multi-GB body would OOM the daemon. The `tzel-detect` background
loop polls indefinitely, magnifying the impact.

Fix: introduce `HTTP_JSON_MAX_BYTES = 64 MiB` and route every
JSON-reading HTTP helper through `Body::with_config().limit(...)
.read_json()`. 64 MiB is well above the largest legitimate feed page
(bounded by the kernel's `MAX_DAL_PAYLOAD_BYTES = 512 KiB`) but small
enough to prevent runaway allocation.

MEDIUM — `tzel-detect` background tick loop swallowed every error:

`run_detect_service` spawned a tokio task that called
`let _ = run_detection_service_once(...)` on every tick. A network
blip, operator misconfiguration, or wallet-file corruption would
leave the daemon polling silently forever with no signal to the
operator that scans were actually failing. Fix: log via `eprintln!`
(captured by systemd / docker logs); no new logging crate needed.

LOW (scope-limited) — Demo HTTP `cmd_scan` silently mis-pruned FA2 PendingDeposits:

`fetch_pool_balances_http` (the helper backing the demo HTTP
ledger's `cmd_scan` path) silently skipped non-tez PendingDeposits.
Downstream `apply_scan_feed` reads absent-from-map as "balance 0",
which the prune predicate interprets as `drained_on_chain = true`.
An FA2 PendingDeposit whose recipient cm had been observed in the
feed would be silently pruned despite being still funded on-chain.

Production `cmd_rollup_sync` was unaffected (routes through
asset-aware `load_pool_balances` RPC). Fix: refuse explicitly in
`fetch_pool_balances_http` rather than silent-skip, pointing the
user at `rollup-sync` for FA2 wallets. New regression tests
`test_fetch_pool_balances_http_refuses_non_tez_pending_deposit` and
`test_fetch_pool_balances_http_accepts_tez_only` lock in the
asset-aware refusal and the tez-only happy path.

LOW (defense-in-depth) — `cmd_bridge_deposit` post-submission lookup
keyed by `pubkey_hash` alone:

After `rollup.deposit_to_bridge(...)`, the wallet attached the
returned `operation_hash` to a PendingDeposit found by `pubkey_hash`
only. Currently safe (deposit_nonce makes pubkey_hash uniquely
derived per L1 mint), but fragile to future refactors. Tightened to
`(asset_id, pubkey_hash)` for parity with every other
PendingDeposit lookup in the file (W3 fix made selectors keyed by
the pair).

All 393 workspace tests pass (113 wallet + others unchanged).

Third-pass audits that found NO actionable bugs:

- WOTS+/XMSS state machine: all signing paths persist before
  prove/submit; save_wallet uses tmp+fsync+rename+sync_parent_dir;
  BDS state round-trips fully; key_idx bounded by AUTH_TREE_SIZE;
  asset binding sound across shield/transfer/unshield sighashes;
  WatchKeyMaterial doesn't leak spend keys; no OsRng/thread_rng on
  key-derivation paths. One informational observation: the
  `.xmss-floor` sidecar is best-effort protection against backup
  rollback — addressable only via external attestation.

- Kernel inbox edge paths: decode-before-mutate; protocol-typed
  sender (`ContractKt1Hash`) can't be forged; ticketer attestation
  before content trust; replay protection complete across
  shield/transfer/unshield; one-shot config; bounded allocations
  everywhere; DAL pointer hardening covers zero-chunk, mismatched
  payload-len, oversized chunks, slot-index bounds, non-zero
  trailing padding, kind-vs-discriminator mismatch, nested
  DalPointer; non-UTF-8 recipients rejected; error-message size
  bounded. F1 (unshield to tz1 recipient) is a behavior-clarifying
  finding: the kernel accepts, the L1 burn entrypoint discriminates.

- Crypto primitives + detection daemon: detect() is constant-time
  (uses ct_eq_u16); validate path rejects non-canonical tags;
  decrypt_memo plaintext is fixed-length 1064 bytes (no length
  oracle); wallet's encrypt_note uses fresh ephemerals (the
  _deterministic variant is test-only); incoming_seed and
  outgoing_seed are independent BLAKE2s outputs under distinct
  tags; W2 fix preserved wire-format compat (169 bytes unchanged);
  detect material cannot decrypt memos (irreversible derivation
  chain); OutgoingNoteRole is AEAD-authenticated; ML-KEM Ek/Dk
  naming consistent.

Informational items left intentionally unfixed:

- `tzel-detect` binary name vs accept-all-three-watch-modes: the
  binary supports Detect/View/Outgoing by design (see
  `apply_watch_feed`); not restricting on load is a UX/naming
  concern, not a security bug.
- ml-kem 0.3.0-rc.2 pinned: tracking upstream for the stable 0.3.0
  release; no current CVE.
- DETECT_K=10 false-positive rate (~0.1% per garbage note): by
  design; documented constant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the open hardening items the third-pass audit summary
enumerated (excluding the aspirational Coq proofs, which are
documented as WIP and addressable only by future proof work, not
code changes).

Item 1 (LOW, defensive) — `MAX_ACCOUNT_ID_BYTES` tightened from 1024
  to 128. Tezos addresses are 36-byte b58check; the headroom is
  generous for hypothetical future address formats but rejects the
  audit's "much too loose" concern about untrusted-input allocation.

Item 2 (LOW, defensive) — Added `validate_l1_ticketer_canonical(&str)
  -> Result<String, String>`. Trims whitespace, requires KT1
  (Originated) addresses only, runs b58check parse and re-emits to
  reject any encoding that parsed but doesn't round-trip exactly. The
  defensive value is at system boundaries: production callers
  (`transfer.sender.to_base58_check()`, `COMPILE_TIME_FA2_BRIDGES`)
  already pass canonical strings; future entry points reading
  ticketers from untrusted sources (CLI, JSON, env vars) MUST route
  through this validator before calling `derive_asset_id`, or a
  whitespace-padded paste produces a divergent asset_id and silently
  strands user funds. Added doc-comment SAFETY NOTE on
  `derive_asset_id`. Five new core regression tests cover canonical
  accept, Implicit reject, garbage reject, whitespace-trim
  canonicalization, and the asset_id-preserves-canonicalization
  invariant.

Item 3 (COSMETIC) — `encode_withdrawal_record` was
  `u32::try_from(...).unwrap_or(u32::MAX)`, silently capping
  recipient length on overflow. Upstream b58check validation makes
  this unreachable (~36-byte addresses), but the silent cap produced
  decoder-confusing records on the latent path. Replaced with
  `.expect(...)` that hard-panics with a clear message if a future
  caller ever bypasses the upstream length check.

Item 4 (LOW, hygiene) — `prepare_shield` now rejects `v == 0`. Not
  a vulnerability (attacker pays `fee + producer_fee` for the
  privilege), but a zero-value shield creates an unspendable note
  and bloats the durable tree + frontier; a buggy/adversarial
  client could grief the durable store with paid zero-value notes.

Item 5 (LOW, ops UX) — `tzel-detect` now logs the loaded
  watch-wallet's mode at startup (DETECT / VIEW / OUTGOING) so
  operators see which key-class is in the daemon's address space.
  The binary name only telegraphs the most-restricted mode; the
  startup log line dispels the implicit "detect-only" assumption.

Item 6 (LOW, supply-chain) — Bumped `ml-kem` from `=0.3.0-rc.2` to
  `=0.3.2` (stable). Same crate / API; takes the production-bound
  KEM off a release-candidate version.

Item 7 (LOW, ops) — Compile-time `USES_DEV_ADMIN_FALLBACK` constant
  detects a kernel built with `debug_assertions` AND no
  `TZEL_ROLLUP_*_HEX` env vars (= the dev fallback path is active).
  Every Configure{Verifier,Bridge} message in such a build now
  emits a loud SECURITY WARNING on the kernel's debug stream. An
  accidentally-deployed debug kernel is no longer silently
  authenticating against the well-known dev key.

Item 8 (LOW, defense-in-depth) — `enforce_wallet_xmss_floor` was
  silent on missing sidecar (legitimate for fresh-seed restores,
  but masked hot-wallet stale-backup restores too). Now emits a
  stderr warning when the sidecar is missing AND `addr_counter > 0`
  (the wallet has done at least one signing op, so a missing
  sidecar is suspicious). Backup-from-fresh-seed UX preserved
  (addr_counter == 0 case, no warning).

Item 10 (DOC) — `DETECT_K = 10` constant gained a detailed comment
  documenting the false-positive rate (~0.1% per garbage note),
  the underlying ML-KEM implicit-rejection semantics, and the
  bumping-requires-wire-change tradeoff so operators see the noise
  floor in the source.

Item 11 (DOC) — `validate_l1_withdrawal_recipient` doc comment now
  explicitly documents that BOTH Implicit (tz1/tz2/tz3) and
  Originated (KT1) Tezos addresses are accepted, and that this is
  end-to-end sound for both tez_bridge and fa2_bridge ticketers
  (TRANSFER_TOKENS to mutez accepts any contract type; FA2
  %transfer to_ accepts any address). The audit's behavior-
  clarifying finding becomes a documented design choice.

Item 12 (LOW) — `decode_rollup_message` no longer maps malformed
  external frames to `ParsedRollupMessage::Ignore`. Returns Err
  (which the caller surfaces as `KernelResult::Error`), so a
  sequencer padding batches with garbage frames cannot keep
  `last_result` pointed at a stale legitimate outcome. Off-chain
  observers see something happened.

Item 13 (DOC) — `process_input` raw-counter writes now carry an
  explicit comment documenting that PATH_RAW_INPUT_* /
  PATH_LAST_INPUT_* are RAW-INBOX mirrors (every observed message,
  malformed or ignored included), while PATH_LAST_RESULT is the
  disposition channel. Monitoring tools reading
  PATH_LAST_INPUT_PAYLOAD should not conflate it with "the payload
  that produced the last result."

All 393 workspace tests pass (138 core + 56 kernel + 76 verifier + 15
+ 113 wallet + others). Octez-client typecheck and origination on
the FA2 bridge remain clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ementation

A dedicated doc-sync audit (post-third-pass) found extensive drift
between the specs/docs and the actual multiasset implementation
shipped in this branch. The first three audit passes focused on
code; this commit closes the documentation gap.

CRITICAL drift, fixed:

- `specs/spec.md` described the pre-multiasset 4-ary commitment
  `H_commit(d_j, v, rcm, owner_tag)` throughout. Updated to the
  5-ary `H_commit(d_j, v, asset_id, rcm, owner_tag)` with explicit
  notes on the v3 → v4 wire bump and ASSET_TEZ backward compat.
- spec.md shield public outputs were listed as 9 felts (no
  `asset_new`); updated to 10. Shield sighash gained the `asset_new,
  asset_producer` tail. Shield section now documents the dual-pool
  debit for FA2 shields (bug #2 fix) and the registered-asset
  check.
- spec.md transfer was described as "creates exactly 3 new private
  notes" with public outputs `[cm_1, cm_2, cm_3, mh_1, mh_2, mh_3]`.
  Updated to the Phase C 4-output layout (cm_1=recipient,
  cm_2=change_1, cm_3=change_2, cm_4=producer fee), the per-input/
  per-output `asset_i ∈ {ASSET_TEZ, A}` constraint, the
  `asset_4 == ASSET_TEZ` producer-fee pin, and the 2-accumulator
  balance equations.
- spec.md unshield was missing `asset_pub` in public outputs and
  in the sighash, and was missing `cm_change_2`. Updated to the full
  Phase C 10-felt output layout, the bug #1 lane-routing fix
  (`v_pub` routed to the right accumulator based on `asset_pub`),
  and the 2-accumulator balance.
- spec.md public-output-length formulas were `2 + N + 7` for both
  transfer and unshield; updated to `2 + N + 9` (transfer) and
  `2 + N + 10` (unshield) with subtractand explanations.
- spec.md Wallet Note Acceptance step 5 still showed 4-ary commit;
  updated to iterate the candidate-asset registry and pick the
  matched asset.
- spec.md Sighash section's transfer line included only cm_1..cm_3
  and mh_1..mh_3; updated to include cm_4 / mh_4 (the producer-fee
  slot). Added a "Multiasset binding" paragraph explaining how
  asset is bound transitively through `cm_k` in transfer (vs
  directly folded in unshield's `asset_pub` and shield's
  `asset_new/asset_producer`).
- spec.md Transaction Format size table updated to reflect Phase C
  byte counts (shield 320B public outputs, transfer (N+11)*32 with
  4 output notes, unshield (N+12)*32 with up to 3 output notes).
- spec.md gained a new top-level "Multiasset" section between
  Position-Dependent Nullifiers and Transaction Types. Covers
  asset identity & derivation, per-asset 2-accumulator balance,
  FA2 bridge ticketer (with the canonical `(0, None)` content
  invariant from bug #3 fix), deposit pool keying, wire format
  bumps, and the asymmetric asset-removal semantics.
- `docs/whitepaper.tex` Shield section (line ~494) said "in v1
  only the tez bridge is deployed, so the circuit asserts
  asset_new = ASSET_TEZ" — that pin was lifted in Phase E.3.
  Updated to document that the circuit allows any registered
  asset and the kernel does the registry check, plus the FA2
  shield's dual-pool debit.
- whitepaper outgoing-recovery commit formula was 4-ary;
  updated to 5-ary and added a note that the
  OutgoingRecoveryPlaintext wire format is intentionally
  unchanged (the watcher iterates candidate assets).
- whitepaper Wallet Note Acceptance step 5 also showed 4-ary
  commit; updated to iterate registered assets.
- whitepaper transaction-size table claimed "Transfer 3 notes" /
  "Unshield 1-2 notes"; updated to the Phase C "up to 4" /
  "1-3" counts.
- whitepaper `asset_3 = ASSET_TEZ` producer-fee pin was a typo
  for the Phase C producer slot; corrected to `asset_4`.
- whitepaper claimed "the Coq specification quantifies value
  conservation over all assets". Replaced with an honest
  reference to coq/STATUS.md: the Spec/Phi predicates exist but
  the Cairo→Coq refinement theorem is future work. Stops
  representing aspirational proofs as completed verification.

HIGH drift, fixed:

- `specs/rationale.md` had no multiasset content. Added five
  multi-paragraph subsections: hidden-asset-in-commitment design,
  producer-fee tez pin (with bug #2 cross-reference), 2-accumulator
  balance, structural ticketer-to-asset binding (with the
  asymmetric removal semantics), canonical L2 ticket content
  (bug #3 reference), and Phase C 4-output transfer / 3-output
  unshield rationale.
- `specs/security.md` Security Properties section's "Balance
  conservation" line was single-asset. Updated to per-asset
  with an explicit reference to bug #1. Added a dedicated
  "Multiasset Security" section covering all 5 critical+high
  bugs found and fixed (bug #1 unshield v_pub bypass, bug #2
  FA2 producer-fee tez mint, bug #3 non-zero token_id bricking,
  W1/W2 watcher visibility, operator slot-4 fee policy), the
  registry skip-duplicate-tez defense (X4), the
  MAX_ACCOUNT_ID_BYTES tightening, and the
  validate_l1_ticketer_canonical boundary validator.
- `docs/wallet_detection_service.md` had no multiasset content.
  Added a "Multiasset Notes" subsection covering W1/W2
  iteration design, the `tzel-detect` daemon's mode-log-at-
  startup, the HTTP_JSON_MAX_BYTES OOM cap, and the
  background-tick error-log fix.
- `docs/ushuaianet_tutorial.md` had no FA2 examples. Added a
  new section §14 ("Multiasset / FA2 Flows") covering the
  `--asset` CLI accepted values, the FA2 deposit /
  shield / send / unshield flow differences (especially the
  FA2-shield-requires-tez-pool rule and the
  combined-input-cap-of-7 caveat), and the demo-cmd_scan
  is-tez-only operational note.
- `docs/README.md` didn't link `multiasset_deployment.md`.
  Added it to Tutorials And Guides with a brief description.

MEDIUM/LOW drift, fixed:

- `specs/ocaml_vectors/README.md`'s "note commitment" inputs
  row was missing `asset_id`. Added.
- `specs/ocaml_vectors/test_vector_spec.md` version header
  bumped from `Spec version: 1` to `Spec version: 2`
  (matches canonical-wire fixture v4) with a paragraph
  explaining what changed vs the pre-multiasset spec.
- whitepaper `asset_id` derivation was described twice with
  contradictory formulas (one speculative "typically a hash of
  chain_id, ticketer_address, token_id", the other the actual
  `H("tzel:asset:" || ticketer_kt1)`). Removed the speculative
  parenthetical; only the correct formula remains.

All 393 workspace tests still pass; the doc/spec changes are
text-only.

Files in good shape (left untouched):
- docs/multiasset_deployment.md (already accurate, written
  for the multiasset rollout)
- whitepaper §Multiasset (the dedicated section was
  internally consistent; only the earlier sections that
  pre-dated multiasset needed retrofitting)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…gs, top-level README)

A final-sweep audit (after three code passes + the doc-sync pass)
found two CRITICAL Coq spec drift items and several HIGH-severity
stale docstrings that the previous doc-sync pass missed.

CRITICAL — Coq Spec/* still pinned pre-multiasset constraints:

- coq/Spec/Shield.v: `phi_shield_asset_tez` asserted `asset_new =
  asset_tez` and was a top-level conjunct of `Phi_shield`. The
  Phase E.3 lift removed that pin in the Cairo circuit (the
  multiasset shield circuit accepts any registered asset). Renamed
  to `phi_shield_asset_registered` (always-True stub kept so
  dependent indices in `Phi_shield` don't shift), and removed the
  `Phi_shield_recipient_is_tez` lemma that's no longer derivable.
  The producer-tez pin (`phi_shield_producer_asset_tez`) remains —
  that one is intentionally permanent.

- coq/Spec/Unshield.v: `phi_unshield_exit_asset_tez` asserted
  `asset_pub = asset_tez`. Bug #1 (commit 2003bf5) lifted this in
  the Cairo circuit when it fixed the v_pub lane-routing bypass.
  Renamed to `phi_unshield_exit_asset_registered` (same stub
  pattern), and removed the `Phi_unshield_exit_is_tez` lemma.

The Coq theory compiles clean (`rocq makefile && make`); drift
gate also re-pins blake_hash.cairo's new SHA after the docstring
fix below.

HIGH — stale function-level docstrings:

- core/src/lib.rs: `ShieldReq` doc still described "kernel debits
  `v + fee + producer_fee` from that pool" (the FA2 case splits;
  bug #2 fix). Updated to document the split-debit and the
  FA2-shield-requires-tez-pool rule.

- core/src/kernel_wire.rs: `KernelShieldReq` docstring "drains
  `v + fee + producer_fee` mutez from the deposit pool keyed by
  `pubkey_hash`" — same pre-multiasset framing. Updated to (asset_id,
  pubkey_hash) keying and the split-debit semantics.

- tezos/rollup-kernel/README.md: described pool keying by
  `pubkey_hash` alone, shield debit as `v + fee + producer_fee`,
  transfer outputs as "recipient, change, and DAL-producer fee
  notes" (missing change_2), and durable-store deposit-balances as
  keyed by `pubkey_hash`. All updated to the multiasset reality.

- cairo/src/blake_hash.cairo: module-level docstring showed
  4-ary `cm = H_commit(d_j, v, rcm, owner_tag_j)`. Updated to the
  5-ary multiasset form. (The actual implemented function at line
  297 was already correct; only the comment was stale.)

- apps/demo/src/main.rs: file-header docstring showed 4-ary
  `H_commit(d_j, v, rcm, owner_tag)`. Updated to 5-ary. (The
  demo's `commit` function at line 147 was already 5-ary; only the
  header was stale.)

- README.md (top-level): described "Flexible N->3 transfers",
  shield debiting `v + fee + producer_fee` from a `pubkey_hash`-keyed
  pool, and made no mention of multiasset / FA2. Updated to N->4
  transfers, multiasset deposit pool keying, FA2-shield-requires-
  tez-pool rule, and added a new bullet introducing multiasset
  support with a link to docs/multiasset_deployment.md.

HIGH — stale spec.md fragments missed by the prior doc-sync:

- specs/spec.md line 424: producer-fee commit shown as 4-ary
  `H_commit(producer_d_j, producer_fee, producer_rcm, producer_otag)`
  — missing the ASSET_TEZ slot. Updated.
- specs/spec.md line 514 (Delegated Proving example): public-output
  list for unshield was pre-multiasset (no `asset_pub`, no
  `cm_change_2`). Updated to the Phase C 13-felt list, and the
  transfer example updated to `cm_1..cm_4` / `mh_1..mh_4`.
- specs/spec.md line 800 (Shield Authorization section): sighash
  fold missing `asset_new, asset_producer`. Updated.

MEDIUM — pre-multiasset comment in rollup-kernel/src/lib.rs:

- The `deposit_balance_path(pubkey_hash)` comment near
  `ledger.deposit_balances.clear()` referenced the old single-arg
  signature. Updated to `deposit_balance_path(asset_id, pubkey_hash)`
  and noted that one pubkey_hash may host multiple per-asset pools.

Other items in the final-sweep report (Coq Drift gate fired
because of the blake_hash docstring SHA change → re-pinned;
canonical_wire_v1.json / commitment_u64_max_v1.json filename drift
remains intentional per the prior doc-sync's filename-vs-version
explanation comment) are non-blocking.

All 398 workspace tests pass; `rocq compile` is clean across all
Spec/Impl modules; `bash coq/Drift/check.sh` reports "OK: all 6
Cairo↔Coq mirrors match."

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per the user's "no stones unturned" request, this commit captures the
final round of multiasset verification work. Three things were checked
exhaustively:

(1) ARE THE CIRCUITS COMPILABLE AND EXECUTABLE WITH MULTIASSET?
    Concern: Phase C added a 4th output slot to transfer + a 2nd
    change slot to unshield; the extra cms / memo hashes / per-slot
    Blake2s commitment computations could blow up the AIR trace.

    Result: VERIFIED CLEAN.
    - `scarb build` compiles all three executables in 3 seconds.
    - `scarb execute` with real witnesses succeeds for shield,
      transfer (N=2, N=7), and unshield (N=2, N=7).
    - Trace sizes:
        shield        : 344,580 steps (10 output felts)
        transfer N=2  : 732,152 steps (13 output felts)
        transfer N=7  : 2,532,983 steps (18 output felts)
        unshield N=2  : 704,116 steps (14 output felts)
        unshield N=7  : 2,366,618 steps (19 output felts)
      All well within the recursive prover's capacity (the README
      cites depth-48 recursive prover times of ~7s for N=7 transfer
      / unshield on a c8g.16xlarge — comfortably reachable).
    - Public-output counts match the spec.md formulas exactly
      (`2 + N + 9` transfer, `2 + N + 10` unshield, 10 shield).

(2) DO THE CIRCUITS IMPLEMENT THE SPEC CORRECTLY?
    111 Cairo unit tests pass, including 23 explicit multiasset-
    relevant tests that lock in the post-fix invariants:
    - `test_shield_rejects_asset_new_mutation_via_commitment_binding`
    - `test_shield_rejects_non_tez_producer_asset`
    - `test_transfer_accepts_mixed_assets_recipient_takes_primary`
    - `test_transfer_accepts_unused_primary_asset`
    - `test_transfer_rejects_input_asset_outside_pair`
    - `test_transfer_rejects_change_2_asset_outside_pair`
    - `test_transfer_rejects_producer_with_non_tez_asset`
    - `test_unshield_accepts_mixed_assets_primary_refund_via_change_1`
    - `test_unshield_accepts_primary_refund_via_change_2`
    - `test_unshield_rejects_asset_pub_mutation_via_sighash_binding`
    - `test_unshield_rejects_third_asset_in_asset_pub`        (bug #1)
    - `test_unshield_rejects_non_tez_v_pub_with_only_tez_inputs` (bug #1)
    - `test_unshield_rejects_silent_v_pub_drop`
    - `test_unshield_rejects_change_2_asset_outside_pair`
    - … and a dozen more.

(3) DO DEPOSIT / SHIELD / UNSHIELD WORK FOR FA2 END-TO-END?
    The kernel's `end_to_end_fa2_deposit_shield_unshield_round_trip`
    test exercises a full FA2 ticket arrival → kernel deposit-pool
    crediting → shield against (asset_id, pubkey_hash) → unshield
    queueing a withdrawal record routed via `ticketer_for_asset`
    back to the registered FA2 ticketer. Passes (including the
    Phase E.5 bug #2 regression assertion that both the FA2 pool
    AND the tez pool drain after the FA2 shield). Plus 35
    `multiasset_routing` integration tests cover deposit/registry/
    pool isolation paths.

DOCSTRING SYNC (the final loose ends):

The previous doc-sync pass missed several Cairo-file docstrings that
still described pre-multiasset / pre-Phase-C output layouts. These
are now updated:

- cairo/src/shield.cairo header: public outputs now correctly listed
  as 10 felts including `asset_new`; sighash fragment includes
  `asset_new, asset_producer`; cm formulas show the 5-ary
  `H_commit(d_j, v, asset, rcm, owner_tag)` form; Phase E.3 lift of
  the `asset_new == ASSET_TEZ` pin documented.
- cairo/src/transfer.cairo header: public outputs now show
  `cm_1..cm_4, memo_ct_hash_1..memo_ct_hash_4` (was `cm_1..cm_3,
  memo_ct_hash_1..memo_ct_hash_3` pre-Phase-C); Phase C 4-output
  layout described slot-by-slot; multiasset 2-accumulator constraint
  documented.
- cairo/src/unshield.cairo header: public outputs show `asset_pub`
  + `cm_change_2/mh_change_2` slots; bug #1 fix narrative; producer
  fee permanently tez constraint.
- cairo/src/run_shield.cairo header: 10-felt output count; explicit
  note that `asset_new` is the 10th output but `asset_producer` is
  only sighash-folded (pinned in-circuit).
- cairo/src/run_unshield.cairo header: explicit `2 + N + 10` output
  formula; full argument layout now lists the change_2 slot fields
  (the Phase C extension) which were missing.

coq/MANIFEST.toml: re-pinned SHAs for transfer.cairo, shield.cairo,
unshield.cairo after the docstring edits.

VERIFICATION SUMMARY:

- `scarb build`: clean
- `scarb test`: 111 / 111 pass
- `scarb execute` shield / transfer (N=2, 7) / unshield (N=2, 7):
  all execute, trace sizes documented above
- `cargo test --workspace`: 398 / 398 pass (138 core + 56 kernel +
  76 verifier + 15 + 113 wallet)
- `bash coq/Drift/check.sh`: OK, all 6 mirrors match
- `rocq compile`: clean across all Spec/ and Impl/ modules
- `octez-client` typecheck + originate of fa2_bridge_ticketer.tz
  with `token_id = "42"` (non-zero!): clean

The end-to-end FA2 deposit→shield→unshield round-trip works for
non-zero token_id FA2 contracts (bug #3 fix verified); the
producer-fee tez split-debit works (bug #2 fix verified); the
unshield v_pub lane-routing is asset-aware (bug #1 fix verified);
the watchers iterate candidate assets (W1/W2 fixes verified). No
remaining inconsistencies between specs/docs/whitepaper and the
implementation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rate protocol_v1.json

Two last loose ends from the final-final sweep:

1. `validate_l1_ticketer_canonical` was dead code (only its own
   unit tests called it). Wired it into the user-facing
   `derive_asset_id_cli` binary so untrusted shell input is
   actually validated before being hashed:

   - Canonical KT1 input: passes through, asset_id is derived
     identically.
   - Implicit (tz1/tz2/tz3) input: REJECTED with a clear error
     pointing at the KT1-only requirement.
   - Whitespace-padded KT1 (`"  KT1...  "` or `"KT1...\n"`):
     trimmed and hashed as canonical — the divergent-asset_id
     stranded-funds hazard documented in the function's
     `SAFETY NOTE` is now structurally impossible from this CLI
     entry point.

   Smoke-tested all three cases against the release binary; canonical
   KT1 gives 14162b59... (matches what the kernel derives); tz1
   exits 1 with the expected message; whitespace-padded KT1
   gives the same 14162b59... hash.

2. `specs/ocaml_vectors/protocol_v1.json` had stale JSON field
   ordering (serde now emits fields in a different order than when
   the fixture was last committed). Cryptographic values were
   identical — confirmed by sorted-key diff. Regenerated via
   `gen-test-vectors --output ...` so a future contributor
   running the generator sees a clean diff. The OCaml-side
   parallel file `protocol_v1_ocaml.json` is left untouched; it's
   maintained on the OCaml side via the workflow in
   `specs/ocaml_vectors/gen_vectors.rs` (a stub doc).

Verification:
- `cargo test --workspace`: 398/398 pass (including the
  protocol-vectors structural-consistency test
  `test_protocol_vectors_file_is_self_consistent`, the bridge_flow
  integration suite which consumes `verified_bridge_flow.json`, and
  the FA2 round-trip e2e test).
- `derive_asset_id_cli` smoke test for KT1 / tz1-reject /
  whitespace-trim: all behave correctly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… in vectors

Two cross-impl drift fixes, both caught by the shared-fixture tests:

1. The OCaml port's hash_commit still computed the pre-multiasset
   4-ary 128-byte commitment (d | v | rcm | owner_tag), so the ocaml
   CI job has failed the commitment_u64_max_v1.json cross-impl check
   on every PR since Phase A regenerated the fixture (cf5147b).
   hash_commit now takes the asset tag and hashes the 160-byte
   multiasset layout (d | v+pad | asset | rcm | owner_tag) mirroring
   Rust core commit() and Cairo hash5. Note.create/create_from_parts
   pass Felt.zero (ASSET_TEZ) — the port stays tez-only, signatures
   unchanged. Regenerated protocol_v1_ocaml.json (12 cm/nf values);
   the regenerated commitments now match the Rust-generated
   protocol_v1.json values.

2. gen-test-vectors built the cross_impl_encrypt EncryptedNote with
   outgoing_ct (and hashed it into memo_ct_hash) but never emitted
   the field, so the 744fedf regeneration silently dropped it from
   protocol_v1.json and broke test_vectors.exe (Not_found). Added the
   missing json! line and regenerated: exactly one line restored,
   all other values byte-identical.

Verification:
- ocaml test_main.exe: 165/165 (was 164/165 — this is the CI suite)
- ocaml test_vectors.exe: 16/16 (was 15/16)
- ocaml test_interop.exe: 1/1
- cargo test -p tzel-services --release: all pass

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…35)

* fix(wallet): report producer note commitment in transfer producer_cm

cmd_transfer_rollup emitted note_3.cm (the zero-value tez change_2
placeholder) under the JSON `producer_cm` key instead of note_4.cm
(the actual producer-fee note, OutgoingNoteRole::ProducerFee).

On-chain submission was unaffected — the proof and TransferReq use
cm_4 correctly — but any consumer tracking the producer note by the
reported `producer_cm` field got the wrong commitment and could never
find it on chain. The sibling cmd_unshield_rollup already reports its
producer note correctly.

This is the same slot-3-vs-slot-4 mistake the operator fix in c9953e0
corrected for the DAL fee policy, where slot 4 was established as the
Phase-C producer-fee output.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(wallet): fix producer_cm comment — change_2 is not always zero-value

Review feedback on PR #35: change_2 carries the tez refund and is
non-zero for FA2 transfers that spent tez on fees, so describing
note_3 as a "zero-value tez change_2 placeholder" was misleading.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: denver-s <denver-s@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Brings in everything main accrued since the branch point: the
--dal-slot-range operator flag (#28), concurrent HTTP + parallel
decrypt rollup sync for the wallet (#25), the tzel_insecure_sandbox
proof-skip cfg + no-bypass CI gate, kernel build.rs admin-env
tracking, and the rust-and-cairo CI split with caching.

Conflicts (apps/wallet only) and their resolutions:

- Cargo.toml: keep multiasset's ml-kem =0.3.2 bump AND main's new
  rayon + reqwest deps (concurrent sync).
- load_pool_balances: adopt main's _at_head/_at_block split (the
  PR #24 pinned-block consistency fix) with multiasset's
  (asset_id, pubkey_hash) pool keying and 3-arg
  try_read_deposit_balance.
- apply_scan_feed_finalize (new in main's batched-scan refactor):
  re-typed pool_balances to the (asset_id, pubkey_hash) map and made
  the pending-deposit prune predicate key on (p.asset_id,
  p.pubkey_hash), matching the multiasset apply_scan_feed semantics.
- main's new pinned-block consistency tests: PendingDeposit
  initializers gain asset_id: ASSET_TEZ, durable-key mocks use the
  multiasset <prefix><asset_hex>/<pkh_hex> format, map lookups key
  on (ASSET_TEZ, pkh).

Verification:
- cargo build -p tzel-wallet-app: clean
- cargo test --workspace (debug, as CI runs it): all green
  (release-mode bridge_flow failures are the by-design absence of
  the dev admin fallback outside debug_assertions, present on both
  parents)
- ocaml test_main.exe: 165/165
- Verified dal_slot_range and the tzel_insecure_sandbox gating both
  survived the auto-merge intact; production cfg path still does
  full STARK verification.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
murbard pushed a commit that referenced this pull request Jun 10, 2026
…circuits

Audit of the Coq tree against the multiasset Cairo found one stale
machine-checked statement and three stale narrative headers:

- Spec/Shield.v phi_shield_value_conservation modeled the tez-only
  single-pool drain (v_deposit = v_note + v_producer + fee) and
  called non-tez bridges "future ... TBW" — but the branch already
  implements them. Generalized to the dual-pool model matching the
  kernel's prepare_durable_shield_commit: tez shield drains one
  pool; FA2 shield debits v_note + fee from the FA2 pool AND
  producer_fee from the same pubkey_hash's tez pool (producer note
  is pinned tez — without the second debit an FA2 shield would mint
  unbacked tez; PR #36 review item 2). New lemma
  phi_shield_value_conservation_total: across both branches the
  summed debits equal v_note + v_producer + fee. Phi_shield and its
  sanity lemmas re-typed accordingly.

- Impl/Transfer.v header described the pre-Phase-C N->3 circuit
  with the single-asset sum_in = v_1+v_2+v_3+fee balance; now
  describes N->4 with 5-ary commitments and the two-accumulator
  scheme, and points at the proved conservation leg.
- Impl/Unshield.v header described a single optional change note
  and the old scalar balance; now two change slots + per-asset
  balance with asset_pub exit routing.
- Impl/Shield.v header described a single-pool drain; now dual-pool.

Spec/Transfer.v's two v_1+v_2+v_3 mentions are deliberate
historical context ("the multiasset generalization replaces...")
and stay.

Full theory rebuilds (rocq 9.0.0), Drift/check.sh 6/6, zero admits.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@denver-s denver-s left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Continued audit, now at e2fbf1f (previous pass was at 744fedf). TL;DR: ce399d1 correctly fixes the OCaml 4-ary-commitment HIGH, and the merge resolution in e2fbf1f checks out — but one HIGH remains open (silent note drop + cursor advance, inline comment below), and the merge actually widened its blast radius.

Verified this round

  • ce399d1 is correct. The new OCaml hash_commit 160-byte layout matches Rust core::commit() byte-for-byte (v as u64 LE at [32..40), zero pad to 64, asset at [64..96), cmmtSP__). Every regenerated cm/nf in protocol_v1_ocaml.json appears verbatim in the Rust-generated protocol_v1.json, and the restored outgoing_ct line matches the generator's vec![0xEE; …] fixture. cargo test -p tzel-services --release: green.
  • The e2fbf1f conflict resolutions are sound. load_pool_balances_at_block threads block_ref (not a re-resolved head) into the 3-arg try_read_deposit_balance, preserving PR #24's pinned-block invariant under the multiasset (asset_id, pubkey_hash) keying; apply_scan_feed_finalize's prune predicate keys on (p.asset_id, p.pubkey_hash). No evil-merge content found in the combined diff.

Still open, not inline-anchorable (code outside this PR's diff or no single line):

  1. test_vectors.exe is still not wired into CIscripts/ocaml_unit_tests.sh only runs test_main.exe. This is exactly how 744fedf's outgoing_ct drop shipped silently: it broke test_vectors.exe, which nothing executes. ce399d1 repaired the data but not the missing gate; it's a one-line addition to the script.
  2. No version field on WalletFile (apps/wallet/src/lib.rs:465). Loading is bare serde-with-defaults. v3→v4 is fine (old notes default to tez), but a v4 wallet opened by a pre-multiasset binary silently drops asset_id and treats FA2 notes as tez. WatchWalletFile.version exists but is never checked outside tests.
  3. Withdrawals to KT1 can strand funds permanentlyvalidate_l1_withdrawal_recipient (core/src/lib.rs:661) accepts any contract, but the outbox message types the receiver as contract unit; a KT1 without a unit/default entrypoint fails outbox typing forever (ticket stuck in the rollup, FA2 stuck in the ticketer). Restrict to implicit accounts or document loudly.
  4. configure_bridge uses a weaker ticketer validator than validate_l1_ticketer_canonical (tezos/rollup-kernel/src/lib.rs:2099): no trim, no round-trip re-emit, and it stores the original string later used in exact-match deposit comparison. Config is one-shot, so any looseness is a permanent brick — route it through the canonical validator (744fedf did this for the CLI but not the kernel).
  5. encode_withdrawal_record's .expect() (tezos/rollup-kernel/src/lib.rs:640) sits inside the infallible apply path after write_output. Currently unreachable (recipient capped at 128 wire bytes), but it re-opens the H1 atomicity shape if bounds ever change — hoist into the fallible prepare step.
  6. FA2 paths still have no cross-impl or real-proof coverage: protocol_v1.json has no derive_asset_id vectors and no asset_id on note vectors; cross_impl_interop and proof_bench are tez-only; all FA2 routing tests use synthetic hand-built public outputs, never an actual STARK proof.

Recommendation: fix the silent-drop HIGH (inline at apps/wallet/src/lib.rs:803) before merge — the merge made it strictly worse, see comment. Items 1 and the spec/wire-doc inline nits are trivial; the rest are solid fast-follows, with FA2 real-proof coverage the most valuable for an audit-gated branch.

Comment thread apps/wallet/src/lib.rs
}
let asset_id = candidates
.into_iter()
.find(|asset| commit(&addr.d_j, v, asset, &rcm, &otag) == cm)?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HIGH — still open at e2fbf1f, and the merge widened it. A note that detects and decrypts (a strong signal it's ours) but matches no compile-time asset candidate returns None via this .find(…)? — indistinguishable from "not my note", no warning. The scan cursor then advances unconditionally, and there's no rescan command, so the note is permanently lost behind the cursor.

Concrete scenario: a user on binary vN receives an FA2 note for a bridge added in vN+1 (the registry is compile-time). The note decrypts, is silently dropped, and after upgrading is never revisited — funds invisible short of hand-editing scanned in wallet.json.

The merge made this worse: the cursor now advances in three places — apply_scan_feed (~line 5968), main's new batched hot path apply_scan_feed_recover_batch (~line 6036), and the watcher paths (~lines 1271/1291/1310) — and PR #24's incremental persist fsyncs the cursor at every checkpoint, so the loss is durable even on an interrupted sync.

Minimal fix: distinguish "decrypts but no candidate matches" from "not my note" — log loudly and either don't advance the cursor past it or record the index for re-evaluation; and/or add a rescan command. This is the same class as the W1/W2 fixes, but for registry-version skew: a214260 added candidate iteration without adding detection of the no-candidate-matches state.

Comment thread specs/spec.md
The commitment binds to the diversified address, value, and owner tag (which fuses the auth key tree root and nullifier key material). The nullifier uses the per-address `nk_spend_j` and includes the leaf position to prevent faerie gold attacks.
The commitment binds to the diversified address, value, **asset class**, and owner tag (which fuses the auth key tree root and nullifier key material). The nullifier uses the per-address `nk_spend_j` and includes the leaf position to prevent faerie gold attacks.

**Multiasset note**: `asset_id` is hidden inside the commitment preimage — it is NOT a separate field on the wire. Two notes with identical `(d_j, v, rcm, owner_tag)` but different `asset_id` produce different commitments, but an on-chain observer cannot tell which asset a given `cm` carries. The pre-multiasset spec (canonical wire v3 and earlier) used the 4-ary commitment `H_commit(d_j, v, rcm, owner_tag)`; the v4 wire bump adds the `asset_id` slot. `ASSET_TEZ = 0x00..00` preserves backward compatibility — a tez-only deployment computes the same `cm` as it did pre-multiasset.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

False backward-compat claim — likely the root cause of the OCaml drift that ce399d1 just fixed. The 128→160-byte arity change alters every commitment even with asset = 0x00..00: H(d‖v‖rcm‖otag)H(d‖v‖0³²‖rcm‖otag). "A tez-only deployment computes the same cm as it did pre-multiasset" is exactly why the fixtures had to be regenerated — the sentence contradicts the regeneration it forced. Suggest: "ASSET_TEZ = 0x00..00 reserves the zero tag for tez; note that the v4 arity change alters all commitments relative to pre-multiasset, including tez-only ones."

Comment thread core/src/kernel_wire.rs
pub struct KernelShieldReq {
/// L2 asset_id this shield is draining. Mirror of `ShieldReq::asset_id`.
/// The kernel-wire encoding gets a tagged-version byte so older
/// pre-multiasset messages (which had no asset field) decode to

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This docstring is false: pre-multiasset messages don't "decode to ASSET_TEZ" — they misparse entirely. Shield gained a leading asset_id, transfer gained cm_4/enc_4, unshield gained cm_change_2, yet KERNEL_WIRE_VERSION is still 17 (line 14). A v17-pinned consumer passes the version gate and misparses — the same rationale the team used for the canonical-wire v3→v4 bump applies here. Mitigated by the fresh-deployment assumption, but the bump is a one-line fix and the docstring should be corrected either way.

Comment thread core/src/lib.rs
/// shadownet's wallet/kernel-test workflows have a real KT1 to
/// target through `gh.gitlab.com/tezos/tezos`-grade origination,
/// not just a synthetic test override.
pub const COMPILE_TIME_FA2_BRIDGES: &[&str] = &[

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Registry is untested. Nothing asserts the docstring's derived asset_id (b7d8096d…) or that these entries pass validate_l1_ticketer_canonical. A typo'd KT1 here compiles fine and yields a dead registry entry whose deposits are rejected at runtime — on the wallet side that feeds directly into the silent-drop path flagged at apps/wallet/src/lib.rs:803. A #[test] asserting each entry round-trips through the canonical validator + a pinned-value test for the derived asset_id would make this compile-time data self-checking.

Comment thread ocaml/protocol/note.ml Outdated
let owner_tag = Keys.owner_tag addr in
let cm = Hash.hash_commit addr.d_j (Felt.of_u64 (Int64.to_int v)) rcm owner_tag in
let cm =
Hash.hash_commit addr.d_j (Felt.of_u64 (Int64.to_int v)) Felt.zero rcm owner_tag

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit (pre-existing, but adjacent to this fix): Felt.of_u64 (Int64.to_int v) truncates — OCaml's native int is 63-bit, so any v ≥ 2^62 loses the top bit(s) and produces a commitment that diverges from Rust. The u64-max cross-impl fixture test only passes because it builds the value felt from raw fixture bytes, bypassing this path. Since ce399d1 is specifically about killing u64-range cross-impl drift, consider a Felt.of_int64 using Int64.logand/shift_right_logical so Note.create can't reintroduce the same class of bug.

murbard pushed a commit that referenced this pull request Jun 10, 2026
…notes joined)

Joins Spec.KernelPool (pool solvency) and Spec.LedgerNf (note
conservation) into one aggregate per-asset accounting over the whole
kernel value lifecycle, and proves the end-to-end no-inflation law.

State per asset: deposited (L1 in), pool (deposited-not-shielded),
notes (live commitment-tree value), burned (fees), withdrawn (L1
out). Invariant preserved by every transition:
  dep a = pool a + notes a + burned a + wd a.

Transitions mirror the kernel's value moves:
- kstep_deposit: L1 -> pool.
- kstep_shield_tez: single tez pool funds v_note + producer_fee +
  fee; recipient + producer notes created, fee burned.
- kstep_shield_fa2: the DUAL-POOL FA2 shield (PR #36 item 2). The
  FA2 pool funds v_note + fee; the SAME pubkey_hash's TEZ pool funds
  producer_fee. So the tez producer note is backed by a real tez
  debit, not minted from nothing. This is the only transition
  touching two assets, and exactly where "no unbacked tez" lives.
- kstep_withdraw: note -> L1 (unshield exit).
- kstep_burn_fee: note -> burned (transfer/unshield public fee; all
  change and producer notes are note-internal and conserved per
  asset by LedgerNf, so they move no aggregate).

Theorems (zero admits):
- no_inflation: ks_wd a <= ks_dep a in every reachable state, every
  asset — withdrawn never exceeds deposited, across the full
  lifecycle including the FA2 dual-pool path.
- outflows_backed: wd + live-notes + burned <= deposited — every
  outflow and live note is backed by a deposit; confirms the
  FA2-shield producer tez is fully backed.

Faithfulness boundary documented: the per-asset note-internal
conservation a transfer relies on is the LedgerNf result; this
module adds the pool<->note<->L1 boundary at the aggregate
(per-asset sum) level.

Full theory builds (rocq 9.0.0), Drift/check.sh 6/6, zero admits.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Arthur Breitman and others added 4 commits June 11, 2026 13:03
…+ de-mask the interop test)

The OCaml protocol port (ocaml/protocol/transaction.ml) was structurally
stale vs the multiasset Cairo circuit and Rust kernel: its sighash
field-sets and public-output records had NOT been updated when multiasset
landed. Concretely it was missing:
  - shield:   asset_new, asset_producer
  - transfer: the 4th output slot (cm_4 / memo_ct_hash_4) -- producer-fee
              is slot 4; the OCaml had the producer in slot 3 with no
              slot for change_2
  - unshield: asset_pub + the 2nd change slot (cm_change_2 / mh_change_2)
The header comment even claimed "shapes match the Rust circuit exactly" --
they did not.

Fix (the port is documented tez-only, so this is shape-sync: the asset
slots are ASSET_TEZ and the unused change slots ZERO, but the FIELDS and
their fold ORDER now match core::{shield,transfer,unshield}_sighash
byte-for-byte):
  - transaction.ml: records + sighashes + builders restructured to the
    multiasset 4-slot / asset_pub layout; producer-fee mapped to slot 4.
  - ledger.ml apply_transfer: producer commitment/memo read from slot 4.
  - gen_interop_scenario.ml + test_interop.ml + the OCaml unit tests:
    updated to the new shapes.

WHY THE TESTS DIDN'T CATCH IT (the user's second point) -- and the fix:
services/tzel/tests/cross_impl_interop.rs carried a Phase B/C WORKAROUND
that SHIFTED the OCaml's stale slot-3 producer into slot 4 and synthesized
a zero slot-3 -- i.e. the cross-impl test was compensating for the OCaml's
staleness, which is exactly why it stayed green. Removed the shift: the
test now consumes the OCaml's cm_4 / memo_ct_hash_4 directly, so a wallet
that regresses to the old 3-slot layout fails here (missing producer note)
instead of being papered over. The Rust InteropScenario gained the
matching cm_4 / asset_pub / cm_change_2 fields so the round-trip is
byte-consistent both directions.

Verified: OCaml `dune runtest` 165 + 2 + 1 pass; Rust interop_scenario
3/3 and cross_impl_interop 1/1 pass.

A dedicated per-flow sighash conformance test (OCaml sighash == Rust
core::*_sighash) follows, to catch field-set drift the accounting-only
interop cannot.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e guard that should have caught it

The accounting-only cross-impl interop cannot catch a sighash FIELD-SET
drift (a dropped/reordered fold field that doesn't change which notes the
ledger appends). That is exactly the multiasset staleness that slipped
through. This adds a direct guard.

Three golden per-flow sighash constants (computed from the Rust kernel's
core::{shield,transfer,unshield}_sighash on fixed inputs) are pinned on
BOTH sides:
  - services/tzel/src/interop_scenario.rs::test_sighash_golden_matches_core
    asserts core::*_sighash == the goldens.
  - ocaml/test/test_main.ml::test_sighash_matches_rust_core_golden asserts
    Transaction.*_sighash == the SAME goldens.
So the OCaml port's sighash field-sets/order are now held byte-identical
to the kernel's; a drop or reorder on either side fails its test.

VERIFIED this guard catches the original bug: dropping the two asset
fields from the OCaml shield_sighash fold makes
test_sighash_matches_rust_core_golden FAIL ("Expected fbd968dd...").

All green: OCaml dune runtest 166 + 2 + 1; Rust interop_scenario 4/4
(incl. the new golden test) and cross_impl_interop 1/1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…not just the wire shape

The OCaml port previously hardcoded ASSET_TEZ everywhere — notes, the
ledger, and withdrawals were tez-only, with only the multiasset-shaped
wire format synced. This ports the real multiasset semantics from the
Rust reference (core/src/lib.rs) so the OCaml implements the actual spec.

Asset registry (new ocaml/protocol/asset_registry.ml):
- derive_asset_id(ticketer) = H("tzel:asset:" || ticketer), byte-identical
  to core::derive_asset_id.
- compose_with / compose, tez-dedup (skip FA2 ticketer == tez ticketer),
  first-match ticketer_for_asset / asset_for_ticketer. ASSET_TEZ = zero.

Note + Transaction:
- Note carries an `asset` tag bound into its commitment; create /
  create_from_parts take an optional ~asset (default ASSET_TEZ), so tez
  callers are unchanged.
- build_shield threads a real ~asset_new (recipient note asset);
  asset_producer stays ASSET_TEZ (producer fee is pinned to tez).
  build_unshield_public threads ~asset_pub. build_output threads ~asset.

Ledger (services/ledger.ml) — mirrors the Rust services Ledger:
- deposit_balances now nested HashMap<asset_id, HashMap<pubkey_hash, u64>>;
  credit/debit_deposit take ~asset_id (default tez); deposit_balance
  accessor added. Pools scoped by asset.
- apply_shield does the dual-pool debit: (asset_new, pubkey) funds v+fee,
  (ASSET_TEZ, pubkey) funds producer_fee. Tez shields collapse to one
  pool. FA2 shields require a separate tez pool; balances are validated
  before any debit so a partial debit can never strand funds.
- Withdrawals now record (asset_id, recipient, amount); apply_unshield
  routes by asset_pub. `withdrawals` keeps the (recipient, amount) tez
  projection for existing callers; `withdrawal_records` is the asset-aware
  view.

Tests:
- New OCaml ledger tests: derive_asset_id golden vs Rust, registry
  compose+lookup (incl. tez dedup), FA2 dual-pool shield, FA2 shield
  rejected without a tez pool (with no partial debit), FA2 unshield
  records the asset_id.
- Cross-impl goldens (paired in interop_scenario.rs::test_sighash_golden_
  matches_core and test_main.ml): a nonzero-asset note commitment and an
  FA2 shield sighash, pinning hash_commit and the asset_new fold
  byte-identical across OCaml and Rust (the tez vectors only covered
  asset = 0).

Verification: OCaml 171 unit tests + interop round-trip green; Rust
tzel-core 138 + tzel-services 77 + cross_impl_interop green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…trip

Adds a deterministic FA2 shield -> FA2 unshield flow to the cross-impl
interop scenario and exercises it in BOTH directions, so the multiasset
path is verified end-to-end across the OCaml and Rust implementations
(not just at the primitive-golden level).

Scenario (services/tzel/src/interop_scenario.rs):
- New InteropFa2Flow { ticketer, asset_id, shield, unshield, expected }
  and an optional `fa2` field on InteropScenario.
- Asset-aware deterministic_note_asset / commit_for_address_asset
  helpers; the FA2 recipient note binds derive_asset_id(ticketer) while
  the producer-fee notes stay tez.
- generate_interop_scenario now populates the FA2 flow (alice shields an
  FA2 note, then unshields it to L1 as the FA2 asset). New
  self-consistency test + json-roundtrip assertions cover it.

OCaml generator (ocaml/test/gen_interop_scenario.ml):
- Emits the same FA2 flow (asset_id from Asset_registry.derive_asset_id,
  FA2-bound shield note + tez producer note, unshield with asset_pub=fa2,
  expected withdrawal carrying the FA2 asset_id).

Forward round-trip — OCaml generates, Rust ledger applies
(services/tzel/tests/cross_impl_interop.rs):
- shield_req is now parametrized by asset_id (preimage asset_new slot);
  unshield_req reads step.asset_pub instead of hardcoding ASSET_TEZ.
- test_ocaml_fa2_flow_applies_on_rust_ledger funds the dual pools
  ((fa2,pubkey) for v+fee, (tez,pubkey) for producer_fee), applies the
  FA2 shield + unshield, and asserts both pools drain and the
  WithdrawalRecord is routed by the FA2 asset_id.
- ocaml_scenario() is cached in a OnceLock so the two cross-impl tests
  don't race `dune exec` (concurrent invocations contend on dune's lock).

Reverse round-trip — Rust generates, OCaml ledger applies
(ocaml/test/test_interop.ml):
- test_rust_fa2_flow_applies_on_ocaml_ledger applies the Rust-generated
  FA2 flow on the OCaml ledger (dual-pool shield, asset-routed
  withdrawal via withdrawal_records). Rust scenario JSON is cached lazily.

Verification: OCaml 171 unit + 2 interop (tez + FA2) green; Rust
tzel-services 78 (interop_scenario incl. FA2 self-consistency/roundtrip +
cross_impl_interop 2) green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants