feat(token): add confidential note token core - #743
Conversation
Extract the note-based token core from the exploration draft (#679) onto a clean branch, with a full unit suite. Value lives as notes: `(value, nonce)` owned by `pk = Hf(sk)`, published only as a hiding commitment `cm = H(domain, value, nonce, pk)` in a `HistoricMerkleTree`. Spending publishes `nf = H(domain, nonce)` and proves membership in-circuit, so amounts, sender, and recipient all stay off the public ledger. The ledger carries the tree and the nullifier set and nothing else: no balances, no accounts, no supply. * `token/ConfidentialNoteFungibleToken.compact` — self-gated `transfer` and `burn`, plus the ungated `_mint` / `_mintNote` / `_transfer` / `_burn` / `_consumeNote` building blocks. No roles and no initialization; the composing contract supplies policy. * Domain tags read `OZ:note:*`; the draft's `OZ:cnt:*` is gone. * Mock, simulator, and witness harness. The witnesses close over a mutable wallet rather than simulator private state, so one spec file runs on the dry and live backends alike (`setPrivateState` throws on live). * 55 specs covering value conservation, single-spend, ownership, historical roots, path/commitment binding, nonce hygiene, and the unauthorized-consume primitive behind escrow-free clawback. Refs: #679 Closes #723
|
Important Review skippedAuto incremental reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Advanced Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
WalkthroughAdds a confidential note fungible token with commitment/nullifier ledgers, private mint, transfer, and burn circuits. It also adds simulators, witnesses, compatibility, privacy, invariant, concurrency, compiler metadata, rejection, and published-transaction test utilities. ChangesConfidential note token
Estimated code review effort: 5 (Critical) | ~90+ minutes Possibly related issues
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
I’m a rabbit with notes tucked tight, Comment |
Bring `ConfidentialNoteFungibleToken` up to the documentation convention the other token modules follow. No code change. * `@param` / `@return` on every circuit, `@type` on both ledger fields and all three structs, `@witness` + `@returns` on all four witnesses. * `@circuitInfo` moved ahead of the Requirements list, matching `FungibleToken`. * Requirements lists spell out the ownership and path preconditions the asserts enforce, not just the value ones. * `@warning` on each ungated block, stating what re-exporting it from a deployed contract would mean. Refs: #723
Asserts, per contract, which concurrent calls commute and which conflict
by design. A Compact transaction carries a fixed transcript built against
the state the wallet saw, so a conflict is a divergence between that
state and the state the transcript is applied to, not a wall-clock race.
Building two calls on one snapshot and applying them in order reproduces
it deterministically, with no node and no flake.
* test-utils/concurrency
- `types.ts` holds the vocabulary a spec writes against, so the same
cases can run on a second backend later.
- `DryReplayHarness.ts` applies a transcript through
`QueryContext.runTranscript`, the verifying-mode entry point the node
itself runs. It replays against current state rather than reusing the
state the build produced; reusing it would skip the pinned-read
checks and score every case as landed.
- A failed replay is classified by definition, not by matching error
text: re-replay against the snapshot the transcript was built on. If
it passes there, only the state changed, so it is a conflict; if it
fails there too, the transcript was never valid and the error is
rethrown. The runtime's messages are static data inside the wasm
binary with no exported type, so any pattern would be a copy of a
string we do not own.
- `parties.ts` is contract-agnostic: each party gets its own wallet and
its own contract instance bound to it, while the ledger they share
lives in the harness. Secrets derive from the party name so a failing
case reproduces.
* scope
`run_transcript` forwards only the program and the gas budget to
`query` and ignores the declared effects; the effects check runs in the
ledger after the transcript does. So this covers pinned reads only, and
an effects-divergence claim is inherently live-only.
* ConfidentialNoteFungibleToken
Seven cases, four commuting and three conflicting. The load-bearing one
is a spend landing after a concurrent mint: it guards the choice of
`HistoricMerkleTree` over `MerkleTree`, since plain `checkRoot` pins the
current root and every mint would then invalidate every in-flight
spend. Verified non-vacuous by compiling the mutated variant, which
flips that case to rejected.
Dry for now. The live backend needs the raw unproven-transaction path and
per-party wallet providers, neither of which the simulator exposes.
Refs: #749
Turns the module's privacy claims into executable assertions. The
functional suite asks what a circuit did; this asks what the chain gets
to see.
* four layers, weakest to strongest
- no secret's byte encoding appears in the public transcript,
- the transcript's shape does not vary with the secrets,
- two runs differing only in a secret differ only in hash digests,
- the serialized transaction the indexer stored carries no secret.
Layer 3 is the one that catches a value-dependent branch, the classic
leak in a Compact circuit: the branch bit shows up as a different
operation sequence even when no value is ever disclosed.
* how it reaches the evidence
A `Probe` drives the contract directly rather than through the
simulator, whose proxy discards `proofData` — the per-call record a real
transaction carries. `test-utils/harness/publishedTx.ts` is the other
end of the same claim: the serialized transaction as the indexer stored
it. Kept isolated the way `ledgerEvents.ts` is, one query and `fetch`
only, so an indexer schema change is a one-file fix.
* why the layers split across backends
Presence claims are verifiable live against `Transaction.raw`. The
indistinguishability claims are structurally dry-only: two real
transactions always differ in proof bytes, fees, and wallet nonces, so
there is no live analogue of "these differ only in digests".
* two findings that shaped the assertions
- The tree publishes `H(commitment)`, not the commitment, so the claim
is "one opaque digest, not the commitment itself".
- The runtime zero-trims byte encodings, so searching a transcript for a
32-byte secret's raw hex passes vacuously. Hence `encoded()`, and the
positive control asserting the nullifier IS published — without it the
four `not.toContain` assertions prove nothing.
* what is deliberately public
`ContractCall.entryPoint` names the circuit, so the operation type is
observable. Asserted rather than hidden.
Mutation-checked: a planted `_leakedValue = disclose(value)` was caught by
three tests but by no differential one, because both probes minted the
same amount. Two mint-differential cases close that gap, verified to fail
under the leak. The live layer passed against the local stack; it uses
high-entropy secrets, since short encodings turn up in proof blobs by
coincidence.
Refs: #723
Reads top-down the way Clean Code's stepdown rule prescribes: the entry
points come first in the order a token standard presents them, and each
callee sits directly beneath the circuit that calls it, depth-first.
_mint freshNonce, _mintNote, commitOf
burn _spenderPk, derivePk, _inputNote, _burn, _consumeNote,
nullifierOf
transfer _transfer
`mint / burn / transfer` at the top level mirrors ERC-20 and
`ConfidentialFungibleToken`, so a reader arriving from either finds the
same shape. Depth-first rather than breadth-first keeps a helper next to
its only caller: `derivePk` follows `_spenderPk` because that is where it
is used, not grouped with the other pure circuits.
Pure code motion: 148 insertions and 148 deletions, and the file's lines
compare identical to HEAD once sorted. No line content changed, so every
`@circuitInfo` k and row count still describes the circuit it annotates.
Refs: #723
* order
The mock, the simulator, and the spec's describes now follow the core's
own circuit order, so all four files can be read side by side. The mock
loses its "building blocks" divider, which only made sense while the
blocks were grouped apart from their callers. Every `it` reads as a
claim (`should …` / `should not …`).
* witness-thrown reasons, invisible live
Five negative tests assert `wit_Path: commitment not found in tree`, a
message our witness raises, not the contract. `rejects.toThrow` reads
`error.message` only, and the live backend wraps a circuit failure
twice:
[0] Unexpected error executing scoped transaction '…': …
[1] Error executing circuit '_consumeNote'
[2] wit_Path: commitment not found in tree
so the reason sits at depth 2 and the assertion failed for no
behavioural reason. Both wrappers do keep `cause`
(`midnight-js-contracts` passes `{ cause: err }`, and `compact-js`'s
`ContractRuntimeError.make(message, cause)` retains it), so
`test-utils/assertions/rejection.ts` walks the whole chain and the five
assertion strings stay exactly as they were. It uses `String(err)`
rather than `.message` because an effect `FiberFailure` only renders
what it wraps via `toString`, and it follows `AggregateError.errors`
alongside `cause`. On a miss it prints every layer, so a backend that
wraps differently reports what it produced instead of just failing.
* circuits that cannot be transactions
`_spenderPk` and `_inputNote` read a witness but touch no ledger state,
so their public transcript is empty: the generated artifact lists them
under `ImpureCircuits` (9) and NOT under `ProvableCircuits` (7), the
constructor registers no operation for them, and no verifier key is
emitted. They are callable in-circuit only, which is how `burn` and
`transfer` use them. Their describes are gated with
`skipIf(isLiveBackend())`; the behaviour still runs live inside every
`burn` and `transfer` case.
Verified against the first live run of the suite, which passed 46 of 55.
Two of the remaining failures were a three-hour laptop suspend mid-run,
which no code change addresses.
Refs: #723
Compatibility pins need the contract's published surface: which ledger slots exist at which index, how each is stored, and which circuits a deployed instance will accept. All of that is in the compiler's `contract-info.json`, emitted on every build including `--skip-zk`. No Midnight package describes that file, so the shapes are declared here. The near misses were checked: `CompactType<A>` is a runtime codec with no static shape, and `SparseCompactADT` is tagged with a partial `'cell' | 'set' | 'list' | 'map'` vocabulary for locating contract references. Every variant is instead derived from the 472 compiled artifacts in this monorepo, which also settled three shapes a guess would have got wrong: * `Counter` slots carry no element type and `Map` slots carry `key`/`value` instead of `type`, so `LedgerSlot` is a union discriminated on `storage` rather than one interface with optional members. Reading `.type` off a map slot is now a type error. * `ledger` is absent, not empty, for a contract with no ledger state (64 of the 472). `ledgerSlots()` normalizes that away. * Witnesses key their result as `'result type'` with a space where circuits use `'result-type'`. That is the compiler's inconsistency. `NameOf` and `Exhaustive` bind a pin to the generated `contract/index.d.ts`, the compiler's other output, so a renamed or added circuit is a compile error at the pin site rather than a runtime surprise. The harness test reads no real artifact, since `test:harness` has no `compile` dependency. It writes a throwaway one instead, which still covers the likeliest breakage: the path resolved against this module.
Every other suite compares the module against itself, so all of them stay green when the wire format moves. Rename a domain tag or reorder a hash preimage and every digest moves together, leaving each relative assertion consistent. Verified by mutation: renaming `OZ:note:commit` to `OZ:note:commitment` keeps the functional, privacy, and concurrency suites passing while making every note in a deployed instance unspendable. So this suite pins absolutes instead. A holder rebuilds a commitment and derives a nullifier to spend, and a client reads the ledger by slot and calls circuits by name, so both the digests and the published shape are load-bearing outside this repo. * Digests for `derivePk`, `commitOf`, and `nullifierOf`, plus the mint and change nonces under a fixed seed. The nonce pair deploys, and is worth its cost on live: it proves the deployed bytecode derives what the local artifact does. * The ledger layout whole, so an added or removed slot fails too. This is the only place `HistoricMerkleTree` and `depth: 32` are asserted statically. * The circuit surface, keyed on the generated `Circuits` type so an added, removed, or renamed circuit is a compile error, cross-checked against `ProvableCircuits` and `Ledger` so the compiler's two outputs cannot disagree. Both new pins were verified non-vacuous by mutating the generated metadata: `HistoricMerkleTree` to `MerkleTree` and `_spenderPk.proof` to true each fail on both backends, while the digest tests correctly stay green. Toolchain versions are recorded rather than asserted. Compilers 0.31.0 and 0.31.1 produce byte-identical digests, layout, and surface here, so a pinned `compiler-version` would fail with nothing broken. Refs #752.
The functional suite states each claim at hand-picked points, so `nullifierOf` ignoring the value is shown for 100 and 999. These state the same claims over generated inputs, so they hold where nobody chose and a failure shrinks to the smallest counterexample. Scope is single calls on the three pure circuits: no ledger, no deployment. Sequence claims are a different kind of statement and live in the invariant suite. Generators are pinned to each circuit's declared type rather than a convenient range. Values and nonces span `Uint<128>`, and owner keys are derived through `derivePk` from generated secrets so every one is a valid `Field`. Runs on either backend, since pure circuits evaluate locally even on live and cost no block.
Neither the functional nor the property suite says anything about a sequence: that after any interleaving of mint, transfer, and burn, the ledger still agrees with what a wallet believes. These carry a shadow model of an honest wallet and re-check it after every step, asserting that nullifiers only ever accumulate and that a note the wallet thinks it holds really is committed and unspent. Conservation is checked per operation rather than globally, since the core keeps no supply figure on the ledger: `transfer` splits its input exactly and `burn` destroys exactly what it was asked to. Spends name a percentage of the held note rather than an absolute amount. Absolutes collapse the run onto the insufficient-value guard as the change note shrinks, measured at 106 of 223 steps. Weighting spends above mints and prefixing every sequence with one mint removes a second dead path, spending against an empty wallet, measured at 154 of 306. The fuzzer found two bugs in the model itself while this was written, both now covered: the value guard runs before the witness, and a replay has to re-present the exact spent note so the nullifier check is what fires rather than a missing commitment. Dry only. Each operation is a transaction on live, so these runs would be roughly 120 transactions and 90 minutes.
Both privacy helpers assumed a 32-byte digest is always 64 hex characters. The runtime trims leading zero bytes, so roughly 1 in 256 digests arrives 31 bytes wide, and the suite was already flaky because of it: a run failed and then passed, and replaying the reported seed reproduced a moved value at `len=62`. Each assumption failed differently. `digestsIn` filtered on an exact width, so it silently dropped a trimmed digest and shrank the set a leak would have to hide in. `expectOpaque` asserted the exact width, so it failed outright on a legitimate value. Both now bound rather than equate: at least 56 hex to be a digest at all, at most 64. Verified over six consecutive privacy runs and three full note-core runs.
The shape and differential layers compared hand-picked pairs, which is exactly the arrangement those layers are weakest at. A planted `disclose(value)` survived them once because both probes happened to mint the same amount, so the comparison had nothing to detect. Both sides of every comparison are now generated. Amounts span the full `Uint<128>` where the call allows it, which matters because a leaked amount most often surfaces as a change in byte length rather than a changed byte. Recipients derive through `derivePk` from generated secrets so each is a valid `Field`. Run counts stay low, since every case drives two full circuit executions. Verified non-vacuous by mutation. A leak planted at a threshold of 10^31 passes under the old fixed pair of 1 and 10^30 and fails here, shrinking to `[0, 10^31 + 1]`. An earlier claim that the fixed values missed a plain `disclose(value)` was wrong: re-running that mutation showed the committed version caught it, which is why the threshold case is the one recorded.
The suite covered a chosen handful of operation pairs. The interesting input here is the pair itself, and with five callable operations the space is only 25 ordered pairs, doubled for spend-vs-spend where it matters whether both calls spend the same note. Enumerating a space that small is both complete and deterministic in a way sampling it cannot be, so all 34 cases now run. Each case asserts against a stated conflict model rather than a memorised verdict: two calls built on one snapshot collide only where they pin the same key, and the only pinned key here is a nullifier. A newly added circuit whose pinning differs then fails here instead of quietly widening the gap between the model and the module. Verified non-vacuous by negating the model, which fails 9 cases. The `_mint` by `transfer` row is the load-bearing one: swapping `HistoricMerkleTree` for `MerkleTree` fails it, because plain `checkRoot` pins the current root so every mint invalidates every in-flight spend. Confirmed by compiling that variant. No functional or privacy test notices the one-word change.
Recent additions
This batch added Why New shared module. Fixed a flake ( Filed: #752 (compiler version declared in three places; CI pins 0.31.0, local gives 0.31.1) · #749 · #750 · #751 Verification. Note-token unit 127 passed / 5 skipped · harness 146 / 4 skipped · biome clean on 153 files. Each addition checked non-vacuous by mutation. Two caveats: the other 37 unit files fail to load in this worktree (uncompiled mocks, zero failed tests), and |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (5)
contracts/src/token/test/ConfidentialNoteFungibleToken.compatibility.test.ts (1)
234-250: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueComparator mismatch between the two sorted lists.
circuitSurfacesorts withlocaleCompare(contracts/test-utils/compiler/contractInfo.ts:364-368), while Line 249 compares againstObject.keys(declared).sort(), which sorts by UTF-16 code unit. The two orderings agree for the current underscore-prefixed names, but they can diverge for future names (ICU collation weights_differently from raw code units), producing a confusing failure that isn't a real compatibility break. Using the same comparator on both sides removes the ambiguity.♻️ Align the comparator
- expect(provable).toStrictEqual(Object.keys(declared).sort()); + expect(provable).toStrictEqual( + Object.keys(declared).sort((left, right) => left.localeCompare(right)), + );Line 269 has the same shape; both sides there use the default sort, so it is already self-consistent — but consider
localeComparethere too for uniformity.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@contracts/src/token/test/ConfidentialNoteFungibleToken.compatibility.test.ts` around lines 234 - 250, Align the expected-name ordering in the compatibility tests with circuitSurface by sorting declared keys using localeCompare instead of the default sort, especially in the test around the declared ProvableCircuits names. Apply the same comparator to the corresponding comparison near the other compatibility assertion for consistent ordering.contracts/src/token/test/ConfidentialNoteFungibleToken.privacy.test.ts (1)
604-610: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winThe disclose-site scan is comment-sensitive and duplicate-blind.
Two soft spots in this otherwise valuable pin:
- The filter only excludes lines starting with
*, so a//or/*comment in the core that merely mentionsdisclose(fails the test with nothing actually changed.- Comparing
Sets means an added duplicate of an existing disclose line (e.g. a second_nullifiers.insert(disclose(nf));) passes silently, which is exactly the kind of new disclosure this test exists to catch.♻️ Suggested tightening
it('should disclose only at the reviewed sites', () => { const sites = CORE_SOURCE.split('\n') .map((line) => line.trim()) - .filter((line) => line.includes('disclose(') && !line.startsWith('*')); + .filter( + (line) => + line.includes('disclose(') && + !line.startsWith('*') && + !line.startsWith('//') && + !line.startsWith('/*'), + ) + .sort(); - expect(new Set(sites)).toStrictEqual(new Set(EXPECTED_DISCLOSURES)); + expect(sites).toStrictEqual([...EXPECTED_DISCLOSURES].sort()); });🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@contracts/src/token/test/ConfidentialNoteFungibleToken.privacy.test.ts` around lines 604 - 610, Harden the disclose-site scan in the `should disclose only at the reviewed sites` test: ignore lines that are inside or start with `//` and `/*` comments before matching `disclose(`, and compare the collected sites as ordered or count-preserving arrays rather than `Set`s so duplicate disclosure lines fail the test while retaining the existing expected-site validation.contracts/src/token/test/ConfidentialNoteFungibleToken.invariant.test.ts (1)
30-38: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winIdentity/ledger helpers duplicated with
ConfidentialNoteFungibleToken.test.ts.
secretKey,ALICE/BOBderivation,publicState,commitmentCount,nullifierCount,isSpent,isCommitted, andspendAsare re-implemented identically here and in the functional suite. See the consolidated comment for a proposed shared-helper extraction.Also applies to: 118-139
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@contracts/src/token/test/ConfidentialNoteFungibleToken.invariant.test.ts` around lines 30 - 38, Extract the duplicated identity and ledger helpers from ConfidentialNoteFungibleToken.invariant.test.ts and ConfidentialNoteFungibleToken.test.ts into a shared test helper module. Reuse that module for secretKey, ALICE/BOB derivation, publicState, commitmentCount, nullifierCount, isSpent, isCommitted, and spendAs, preserving their current behavior and exports.contracts/src/token/test/ConfidentialNoteFungibleToken.test.ts (1)
13-58: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winExtract shared test identities/ledger helpers to avoid drift across the note-token suite.
Both files independently re-implement the same deterministic
secretKey,ALICE/BOBderivation, and ledger-reading helpers (spendAs,isCommitted,isSpent,commitmentCount,nullifierCount,publicState). A single shared module (e.g. ahelpers.tsalongside the simulator/witnesses) would remove the duplication and keep future edits (e.g. to how ownership or spend status is read) in one place as more suites (compatibility, privacy, concurrency) are added on top of this cohort.
contracts/src/token/test/ConfidentialNoteFungibleToken.test.ts#L13-L58: movesecretKey,ALICE/BOB/CAROL,spendAs,isCommitted,isSpent,commitmentCount,nullifierCount,publicStateinto a shared helper module and import from there.contracts/src/token/test/ConfidentialNoteFungibleToken.invariant.test.ts#L30-L139: replace the local re-implementations ofsecretKey,ALICE/BOB,publicState,commitmentCount,nullifierCount,isSpent,isCommitted,spendAswith imports from the same shared helper module.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@contracts/src/token/test/ConfidentialNoteFungibleToken.test.ts` around lines 13 - 58, Create a shared helper module for the token tests and move the deterministic identities and ledger helpers (including secretKey, ALICE/BOB/CAROL, spendAs, publicState, isCommitted, isSpent, commitmentCount, and nullifierCount) there; update contracts/src/token/test/ConfidentialNoteFungibleToken.test.ts#L13-L58 and contracts/src/token/test/ConfidentialNoteFungibleToken.invariant.test.ts#L30-L139 to import them and remove their local duplicates, preserving existing behavior.contracts/src/token/test/ConfidentialNoteFungibleToken.concurrency.test.ts (1)
238-266: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winDead branch: third
armcondition is unreachable.
secondParty === aliceonly happens whentestCase.sameNote === true(line 242), andsharings(line 159) only ever producessameNote === truewhen bothSPENDS[first]andSPENDS[second]are true. So the third condition's!SPENDS[testCase.first](line 253) can never hold alongsidesecondParty === alice— this branch never executes. Every reachable combination is already armed by the first twoifblocks.Given the file's stated goal of being an exhaustive, precisely-reasoned matrix ("keep that row if this matrix is trimmed"), leaving unreachable arming logic here risks misleading a future editor into thinking a scenario is covered when it isn't.
🧹 Suggested cleanup
- if ( - SPENDS[testCase.second] && - secondParty === alice && - !SPENDS[testCase.first] - ) { - await arm(alice, ALICE); - } -🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@contracts/src/token/test/ConfidentialNoteFungibleToken.concurrency.test.ts` around lines 238 - 266, Remove the unreachable third arm(alice, ALICE) conditional from the test matrix loop. Keep the first two arming conditions and the existing race invocation and verdict assertion unchanged, since all reachable sameNote cases are already covered by those conditions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@contracts/test-utils/harness/publishedTx.ts`:
- Around line 69-89: Update gql() and its callers, including awaitPublishedTxs,
to issue each fetch with an AbortSignal-based per-request timeout bounded by the
remaining timeoutMs deadline. Ensure an aborted request propagates as the
existing timeout failure rather than hanging indefinitely, while preserving
normal GraphQL response and error handling.
---
Nitpick comments:
In
`@contracts/src/token/test/ConfidentialNoteFungibleToken.compatibility.test.ts`:
- Around line 234-250: Align the expected-name ordering in the compatibility
tests with circuitSurface by sorting declared keys using localeCompare instead
of the default sort, especially in the test around the declared ProvableCircuits
names. Apply the same comparator to the corresponding comparison near the other
compatibility assertion for consistent ordering.
In `@contracts/src/token/test/ConfidentialNoteFungibleToken.concurrency.test.ts`:
- Around line 238-266: Remove the unreachable third arm(alice, ALICE)
conditional from the test matrix loop. Keep the first two arming conditions and
the existing race invocation and verdict assertion unchanged, since all
reachable sameNote cases are already covered by those conditions.
In `@contracts/src/token/test/ConfidentialNoteFungibleToken.invariant.test.ts`:
- Around line 30-38: Extract the duplicated identity and ledger helpers from
ConfidentialNoteFungibleToken.invariant.test.ts and
ConfidentialNoteFungibleToken.test.ts into a shared test helper module. Reuse
that module for secretKey, ALICE/BOB derivation, publicState, commitmentCount,
nullifierCount, isSpent, isCommitted, and spendAs, preserving their current
behavior and exports.
In `@contracts/src/token/test/ConfidentialNoteFungibleToken.privacy.test.ts`:
- Around line 604-610: Harden the disclose-site scan in the `should disclose
only at the reviewed sites` test: ignore lines that are inside or start with
`//` and `/*` comments before matching `disclose(`, and compare the collected
sites as ordered or count-preserving arrays rather than `Set`s so duplicate
disclosure lines fail the test while retaining the existing expected-site
validation.
In `@contracts/src/token/test/ConfidentialNoteFungibleToken.test.ts`:
- Around line 13-58: Create a shared helper module for the token tests and move
the deterministic identities and ledger helpers (including secretKey,
ALICE/BOB/CAROL, spendAs, publicState, isCommitted, isSpent, commitmentCount,
and nullifierCount) there; update
contracts/src/token/test/ConfidentialNoteFungibleToken.test.ts#L13-L58 and
contracts/src/token/test/ConfidentialNoteFungibleToken.invariant.test.ts#L30-L139
to import them and remove their local duplicates, preserving existing behavior.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: cdeec834-99af-475e-a580-29d50e16b99a
📒 Files selected for processing (23)
CHANGELOG.mdcontracts/src/token/ConfidentialNoteFungibleToken.compactcontracts/src/token/test/ConfidentialNoteFungibleToken.compatibility.test.tscontracts/src/token/test/ConfidentialNoteFungibleToken.concurrency.test.tscontracts/src/token/test/ConfidentialNoteFungibleToken.invariant.test.tscontracts/src/token/test/ConfidentialNoteFungibleToken.privacy.test.tscontracts/src/token/test/ConfidentialNoteFungibleToken.property.test.tscontracts/src/token/test/ConfidentialNoteFungibleToken.test.tscontracts/src/token/test/mocks/MockConfidentialNoteFungibleToken.compactcontracts/src/token/test/simulators/ConfidentialNoteFungibleTokenSimulator.tscontracts/src/token/test/witnesses/ConfidentialNoteFungibleTokenWitnesses.tscontracts/test-utils/assertions/rejection.tscontracts/test-utils/assertions/test/rejection.test.tscontracts/test-utils/compiler/contractInfo.tscontracts/test-utils/compiler/test/contractInfo.test.tscontracts/test-utils/concurrency/DryReplayHarness.tscontracts/test-utils/concurrency/backend.tscontracts/test-utils/concurrency/parties.tscontracts/test-utils/concurrency/race.tscontracts/test-utils/concurrency/test/DryReplayHarness.test.tscontracts/test-utils/concurrency/test/parties.test.tscontracts/test-utils/concurrency/types.tscontracts/test-utils/harness/publishedTx.ts
`awaitPublishedTxs` documents "give up after this long" but only checked the deadline between polls, so a stuck indexer outlived it. `fetch` has no total-response timeout, and undici's body timeout is far longer than any budget a caller passes. Bounding each request at a fixed ceiling is not enough on its own: `publishedTxsSince` issues one request per block, so a 10s ceiling still allows an arbitrarily long total. Each request is instead bounded by whatever is left of the caller's deadline, and the deadline is threaded through `indexerHead` and `publishedTxsSince` so the per-block loop stops with it. Timeouts and protocol failures are now distinguished. Slowness is what this function exists to absorb, so it keeps polling while time remains; an HTTP status or a GraphQL error is a real defect and surfaces at once with its original message, rather than being flattened into the poll-exhausted error. Adds the module's first test, `fetch` stubbed, which also closes the one gap in harness test coverage. Verified non-vacuous: removing the abort signal leaves both timeout cases hanging until the runner kills them, which is the reported failure. Raised by CodeRabbit on #743.
andrew-fleming
left a comment
There was a problem hiding this comment.
Very nice work, @0xisk! It's a partial review but I left some comments :)
| * @witness wit_SecretKey | ||
| * @description Returns the caller's spend secret, from which the circuits | ||
| * derive the identity `pk = Hf(sk)` that owns notes. | ||
| * | ||
| * @returns {Bytes<32>} secretKey - A 32-byte cryptographically secure random | ||
| * value. | ||
| */ | ||
| witness wit_SecretKey(): Bytes<32>; |
There was a problem hiding this comment.
followup: I think we should scope the witness with the name of where it's used e.g. wit_ConfidentialNoteSK (or whatever name/acronym we'd like) to avoid the forceful reuse of wit_SecretKey, for example, if two modules have the same witness sig. I didn't include it in the CFT so this is really my bad
There was a problem hiding this comment.
Done in 3a951b02: wit_ConfidentialNoteSK, wit_ConfidentialNoteInputNote, wit_ConfidentialNotePath, wit_ConfidentialNoteNonceRandomness. Rows unchanged; the witness impl, simulator and the one shared test-util fixture that quoted the wit_Path error string move with it.
| * Kept distinct from a protocol failure so {@link awaitPublishedTxs} can poll | ||
| * through transient slowness while a real indexer error still surfaces at once. | ||
| */ | ||
| class IndexerTimeout extends Error {} |
There was a problem hiding this comment.
| class IndexerTimeout extends Error {} | |
| export class IndexerTimeout extends Error {} |
Is it reasonable that a caller (us) might care about seeing this?
There was a problem hiding this comment.
Yes. Exported in eadd4800 so a spec can assert on the cause.
| try { | ||
| seen = await publishedTxsSince(height, undefined, deadline); | ||
| } catch (cause) { | ||
| // Slowness is what this function exists to absorb, so keep polling while | ||
| // time remains. A protocol failure is a real defect: surface it at once. | ||
| if (!(cause instanceof IndexerTimeout)) { | ||
| throw cause; | ||
| } | ||
| } | ||
| if (seen.length >= min) { | ||
| return seen; | ||
| } | ||
| const pause = Math.min(POLL_INTERVAL_MS, deadline - Date.now()); | ||
| if (pause <= 0) { | ||
| break; | ||
| } | ||
| await new Promise((resolve) => setTimeout(resolve, pause)); | ||
| } | ||
|
|
||
| throw new Error( | ||
| `indexer: expected ${min} transaction(s) after block ${height}, saw ${seen.length}`, | ||
| ); |
There was a problem hiding this comment.
Ran this and the indexer indeed got stuck but reported it as a missing tx. When the indexer accepts the connection and never answers, AFAICT gql raises IndexerTimeout and this catch eats it and the terminal shows no cause
There was a problem hiding this comment.
Done in eadd4800: the last IndexerTimeout is kept as cause and named in the message ("timed out waiting for … last request: no response in Nms"). It is cleared on any successful poll, so a stall the indexer recovers from still reports a short window rather than a timeout.
|
|
||
| while (Date.now() < deadline) { | ||
| try { | ||
| seen = await publishedTxsSince(height, undefined, deadline); |
There was a problem hiding this comment.
awaitPublishedTxs doesn't forward contractAddress so it resolves on the first tx in the window from any source including another worker (unit-live defaults to 3). Since a vacuous pass doesn't fail, round 2 never fires thus the mitigation can't reach the case it was written for. If we just run a single file, we wouldn't see this issue bc there's nothing competing
Here's the bad scenario flow as I understand it:
target + other tx fire within the window ->
other tx wins ->
neg privacy assertions scan the other tx ->
the spec passes w/o examining the target
Do you agree? If so, I think we just need to forward the contract address
There was a problem hiding this comment.
Agreed, that flow was possible. Done in b92ca26b: awaitPublishedTxs takes the address as a required parameter and forwards it, so only the target's txs count toward min. The four live call sites read it off the simulator backend. Required rather than defaulted so a caller cannot fall back to the old vacuous wait.
| // The timeout contract | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| describe('awaitPublishedTxs timeout', () => { |
There was a problem hiding this comment.
I'd include this test if we agree on improving the timeout msg
it('should keep the timeout as the cause of the failure', async () => {}
There was a problem hiding this comment.
Added in eadd4800 under that name, plus one for the recovery case: a timed-out request followed by answered polls reports the short window with no cause.
| * Vocabulary: the TOKEN is the asset; a NOTE is its internal value record (a | ||
| * UTXO). A note is `(value, nonce)` owned by `pk = Hf(sk)`, a field-typed hash | ||
| * of the owner's secret; a balance is the sum of one's unspent notes. The | ||
| * commitment `cm = H(domain, value, nonce, pk)` goes in the tree, and the | ||
| * nullifier `nf = H(domain, nonce)` marks the note spent. Spending proves that | ||
| * the note's commitment is in the tree without revealing which leaf. | ||
| * | ||
| * The public ledger therefore holds only the commitment tree and the nullifier | ||
| * set: no balances, no accounts, no supply. A transfer publishes one nullifier | ||
| * and two commitments, and nothing else. | ||
| * | ||
| * The module is deliberately barebones — the note machinery and nothing else. | ||
| * It holds no roles and needs no initialization. `transfer` and `burn` work out | ||
| * of the box because they are self-gated: spending requires the owner's secret |
There was a problem hiding this comment.
Somewhere in here, what do you think about explicitly defining how we're hashing in this module? A quick AI-gen draft:
* @dev NOTATION. `H(...)` is `persistentHash` (SHA-256) over the NAMED STRUCT
* shown, `CommitPreimage` for `cm` and `NullifierPreimage` for `nf`, using
* Compact's struct encoding. It is NOT a byte concatenation of the listed
* fields, and no concatenation reproduces it. `Hf(...)` is that same hash
* composed with `degradeToTransient`, which reduces the digest to a `Field`.
*
* Do NOT reimplement these. `commitOf`, `nullifierOf`, and `derivePk` are
* exported as pure circuits so wallets and auditors can call the exact
* derivations the circuits use. An implementation that gets the encoding wrong
* produces notes you cannot locate and identities you cannot spend from, with
* no recovery path. If you must reimplement, check against the pinned vectors
* in `ConfidentialNoteFungibleToken.compatibility.test.ts`.
There was a problem hiding this comment.
| * @notice Taking both output notes as parameters is what lets a composing | ||
| * contract own its emission policy (audit-derived nonces, delivery records, | ||
| * supply accounting) while the core still enforces conservation. | ||
| * | ||
| * @warning No authorization. Whoever supplies a valid input note and its path | ||
| * can spend it; the composer decides who may call this. | ||
| * | ||
| * Requirements: | ||
| * |
There was a problem hiding this comment.
I'd mention that it's the composer's responsibility to provide nonce uniqueness. Output nonces are not checked for global uniqueness and they can't be
There was a problem hiding this comment.
Two things:
- Great work on separating the tests like this. I think it makes a lot of sense
- Since this particular suite pins the seed (which I agree with here), we need to have a check that the notes are spendable. Alice is currently transferring dead notes to bob :(
- this is fixed with the suggestion to change the tag in
_mint, but it should still be tested nonetheless
- this is fixed with the suggestion to change the tag in
Binding a nonce to its spend narrows the window for a collision but cannot close it: a wallet that repeats a seed across two mints to one recipient still derives one nonce twice, and the second note is committed to a nullifier the first already owns. The tree is append-only and does not deduplicate, so nothing on-chain noticed. Reserving each nonce turns that into a failed transaction. The check has to be its own set. Inserting nf into _nullifiers would spend the note at birth; a member-only check there would disclose the note's future nullifier at mint, linking the mint transaction to the later spend, and would still miss two duplicates that are both unspent; a foreign-domain tag parked in _nullifiers would make that set mean two things to off-chain viewers. A set also keeps mints parallel. The circuit cannot manufacture uniqueness without a pinned read, and reading a counter or the tree's next index would pin one value every mint shares, serializing them. A per-key member pins only the nonce at hand, so a duplicate is rejected and distinct mints in one block still commute. The concurrency suite asserts both halves through the build-then-replay harness. This subsumes the input-nonce and sibling-nonce comparisons suggested on the review: any output nonce equal to the input's or its sibling's is already reserved, and the tree only takes leaves through _mintNote. Refs: #743 (comment)
Depth 32 was hardcoded in three places that have to agree: the ledger type, the path witness, and the root computation. A composer wanting a smaller tree had to edit the module and keep all three in step. Naming it once as a module parameter makes disagreement impossible and lets the composer choose at import time. It is part of the deployed wire format, not a tuning knob applied afterwards: two instantiations at different depths are different contracts and share no notes. The mock instantiates at 32, so every pinned vector, the ledger layout and the measured rows are unchanged. Depth costs 31 rows per level on the spend path and nothing on the mint path, so the choice trades capacity against proving cost without moving k.
The header had grown by accretion: privacy, nonce rules, concurrency and composition were spread across prose paragraphs that each answered part of a question, so a reader had to assemble the threat model themselves. Reorganised into labelled sections, each answering one question. Privacy now states what is public per transaction, what is public as state, what is private, and what a counterparty learns, so an integrator can check a claim against the suite rather than infer it. Security, Concurrency, Off-chain duties, State growth and Scalability get the same treatment. Two corrections rather than restatements. Commitments are not public: the tree publishes a hash of the leaf, so cm itself never reaches the wire. And a mint and its spend are unlinkable to a third party but not to whoever handed the note over, since that party knows the nonce and derives both digests. Comment-only.
The per-circuit blocks restated what the module header now says once: the privacy model, the nonce rules, the concurrency claims, and that every underscore circuit is ungated. A reader met each of those four or five times, which makes the site-specific facts harder to find, not easier. Each block keeps only what is true at that site and drops what the header already covers. Where a fact is genuinely local it stays and gets a title line so it is findable: why the reservation is its own set, why the peek is untrusted, why disclosing the root says nothing about which leaf. The five No authorization warnings collapse to one identical line. Inline comments that narrated the next statement are gone; the ones that explain an ordering dependency or a non-obvious disclosure remain. Comment-only: stripping comments and blank lines from both revisions leaves 139 identical code lines.
The Client-side duties section named the witnesses, note storage and out-of-band delivery, but not the two facts an integrator is most likely to get wrong by analogy with Zswap. The identity is the module's own. pk = Hf(sk) has no relation to the Zswap coin key, so a wallet must derive sk from its seed under a module-specific domain if one backup is to cover it, and pk is the address to share. Recovery is asymmetric. With no memo and no viewing key, a chain rescan recovers nothing: the seed restores who you are, not what you hold. Notes need a backup of their own. Comment-only.
6258951 to
a7a907b
Compare
|
Beyond the threads above, this push also lands:
Row impact: |
The section defines symbols (H, Hf, cm, nf, tag, pk) rather than terms, so Notation is the accurate label and matches the Derivations section that follows it.
Compact merges witnesses by name across imported modules. A contract composing this module with any other that also declares wit_SecretKey gets one witness serving both, so a single implementation would answer for two unrelated secrets and the composer has no way to keep them apart. The clash is silent: it type-checks and compiles. Prefixing each name with the module removes the collision by construction. Witness names are compile-time identifiers, so the measured rows are unchanged. The wit_Path error string reaches assertions in the functional suite and is borrowed as a fixture by the shared rejection test-util, so both move with it. Refs: #743 (comment)
Giving up threw a bare "expected N, saw S". That reads as "the contract published nothing", which is the wrong diagnosis half the time: the indexer may have stopped answering, and the request timeout that says so was caught and discarded on every poll. Keeping the last timeout as `cause`, and naming it in the message, tells those two apart. It is cleared on any successful poll, so a transient stall followed by answered-but-short polls still reports the short window rather than blaming an indexer that recovered. `IndexerTimeout` is exported so a spec can assert on the cause. Refs: #743 (comment) Refs: #743 (comment) Refs: #743 (comment)
It waited on any traffic the indexer saw, so under the three live workers the unit-live project runs, another spec's transactions could satisfy the wait. The privacy specs then scanned a window that need not contain the transaction under test, and a scan that finds no secret in someone else's bytes passes for the wrong reason. `publishedTxsSince` already filtered by address; this only forwards it. The parameter is required rather than defaulted: a caller that forgets it should not silently get the old vacuous behaviour. Call sites read the address off the simulator backend, which carries it on both backends. Refs: #743 (comment) Refs: #743 (comment)
The reader cast the parsed JSON straight to `ContractInfo`. A compiler that emits a `type-name` or a storage kind the unions do not list would have produced no error at all: the value reaches a use site as `undefined` and surfaces as a confusing assertion failure somewhere downstream, or silently passes a pin that meant to check it. The unions are now derived from `as const` arrays, so the runtime check and the type cannot drift. The walk is blanket-recursive because descriptors nest through structs, vectors, tuples, aliases and map values, and a walk keeps working when the compiler adds a position. Validation sits outside the try that produces the "compile first" error, so a tag mismatch is never reported as a missing build. `parseContractInfo` is split out and exported so the validation can be tested on inline objects. The alternative, writing throwaway artifacts, would break the rule that `test:harness` reads no compiled build. The `ledger` key is required rather than optional: it is present on every artifact measured, and the validator now says so loudly instead of letting `ledgerSlots` paper over an absence that would mean the metadata is not what this module thinks it is. Refs: #743 (comment)
Two comments counted the artifacts they were derived from. The number was already wrong and nobody recounts it when the monorepo gains a contract, so it made the surrounding claim look precise while being unverifiable. The claims themselves hold without it, and the validator added alongside now enforces the one that matters.
On a miss the assertion rendered the cause chain into its message and dropped the rejection itself. The text is enough to read the failure but not to interrogate it, and a spec cannot recover the object by catching the call again, since expectRejection already consumed it. Attaching the rejection as `cause` leaves the diagnostic unchanged and makes the original reachable. Refs: #743 (comment)
The helper consumes the call, so a spec that wants both "it rejected for this reason" and a further claim about the object had to choose one or write the try/catch by hand. Returning the rejection lets the second claim follow the first. The seven existing call sites ignore the value and are unaffected. Refs: #743 (comment)
The mismatch message called each position a depth, and the causeChain doc claimed the printed order matched how deeply each entry was nested. That holds only for a linear cause chain. An AggregateError branches, so its children are siblings printed one after another, and reading those labels as depths misreads the shape of the failure. The numbers were always positions in the breadth-first order. Naming them that way costs nothing and stops the diagnostic asserting something untrue about a branching chain. Refs: #743 (comment)
Both returned records are keyed by name, so a repeated name silently left one party standing. A spec that asked for two identities got one, and the race it then ran was an identity against itself: the conflict it was written to detect cannot occur, and the case passes for the wrong reason. A blank name is the same failure by a different route. Rejecting at construction turns both into a loud error naming the offender, at the point the spec can still be fixed. Refs: #743 (comment) Refs: #743 (comment) Refs: #743 (comment)
`race` reduced the attempt to one of two strings and dropped the reason the replay gave. A spec could assert that the second call was rejected but not that it was rejected for the divergence it set up, so a case that started failing for an unrelated reason would keep passing. Each of the five harness steps is now wrapped so a throw names the phase and keeps the original as `cause`. Only `attempt` yields an outcome; the other four are spec or harness bugs, and a bare failure out of `race` gave no way to tell which had broken. `attempt`'s returned rejection is left alone, since that is a result and not an error. Refs: #743 (comment) Refs: #743 (comment)
`race` had no tests of its own. Every claim about it was inferred from contract specs that also exercised a real ledger, so an ordering bug in the scenario would have surfaced as a confusing contract-level failure, if at all. A recording harness pins the parts that are backend-independent: both calls built against one snapshot, the first landed before the second is attempted, the verdict and its reason passed through, and a throw from any phase tagged with that phase rather than scored as an outcome. What is left for a backend to get right is replay, which needs real state.
The harness's own suite drives a stub contract, so nothing exercised the part that matters: re-executing a real transcript in verifying mode against state that has moved. Its header pointed at a `harness invariants` describe in a contract spec to cover that, and no such describe was ever written. These cases pin it directly. The two that would silently break the whole method are `land` replaying against the CURRENT state rather than the build snapshot, which is what lets two independent builds from one snapshot both land, and `classify` rethrowing a transcript that fails against its own build snapshot instead of scoring it as a conflict. Replaying against the build state would score every case as landed, and scoring a broken transcript would report a conflict that never happened. It sits under `src/` rather than beside the harness because the `unit` project globs `src/**/*.test.ts` and is the only project that depends on a compile. The sibling suite's header now points here.
The constructor ran on whichever instance `Object.values` yielded first, so the deploying party was decided by the order names happened to be written in. On a module whose initializer reads a witness, that seeds the shared ledger with one party's secrets and the spec has no way to see it: the choice is invisible at the call site and changes if someone reorders the names. Naming it makes the choice explicit where it matters and an error where it is wrong. The default is unchanged, so nothing existing moves, and the note core has no initializer, so nothing here needed it yet. Dry only. The live backend deploys from the wallet pool's own deployer, which is not a party, so the mapping is a question for whoever builds that seam rather than something to guess at now. Refs: #743 (comment)
Main moved to compiler 0.34 and language 0.26; the note module and its mock were the only two tracked sources left on 0.23. Nothing in them needs a 0.26 feature, but a single language version across the tree is what lets one `compile:token` run build all of it. The compiled artifact carries a runtime version and the runtime refuses a mismatch, so this cannot land without the recompile that follows.
Three moves in the runtime, none of them optional: Circuit calls and the contract constructor became async in 0.18, so the harness cannot deploy from its constructor. `createDryHarness` returns a promise now and the constructor is private. Proof data left `CircuitResults` for `context.callProofDataTrace`, one entry per call in the tree. The trace is depth-first, so the root circuit's entry is the last, not the first. The caller-scoped fields moved onto `callContext`, and `createCircuitContext` gained a leading circuit id. Deploy time is pinned at zero. `createCircuitContext` now defaults it to `Date.now()` where 0.16 gave 0, which would make a deployed ledger differ between runs. The replay core is untouched: `QueryContext.runTranscript`, `CostModel` and `ChargedState` all survive 0.19 unchanged.
Only the privacy suite needed work. It drives the contract directly, to reach the proof data a simulator does not expose, so it owns a `CircuitContextManager` and pays for every runtime move: an async contract constructor, async circuits, and proof data relocated onto the context as a per-call trace. The probe becomes an async factory and its four circuit methods return promises, which makes every case in the file async. The two indistinguishability helpers follow, so the fast-check properties there are now `asyncProperty` with an awaited assert. Everything else in the suite goes through the simulator, which absorbs the same changes behind its own API, so the functional, compatibility, concurrency, property and invariant files are untouched. Deploy time is pinned to zero, as in the harness: the differential layer needs two runs to agree on every input except the one under study, and the runtime now defaults time to `Date.now()`.
Every circuit costs a little less than on 0.31.1, between 0.5% and 1.1%, with no k boundary crossed. `transfer` stays the one worth watching at roughly four fifths of the k=16 budget. The pinned compatibility vectors did not move: 0.34 changes what the circuits cost, not what they hash. So this is not a wire-format change and nothing was regenerated. The module needs no `--feature-zkir-v3`; it has no secp256k1 or ElGamal, and `compile:token` is the one directory script on main that stays on the v2 default.
|
Merged
Note for anyone checking out the branch: the artifact records the runtime version, so run |
Types of changes
What types of changes does your code introduce to OpenZeppelin Midnight Contracts?
Put an `` in the boxes that apply
Fixes #723
The
ConfidentialNoteFungibleTokencore, extracted from the exploration draft #679 onto a clean branch offmain. Core only — no extensions, no presets, no crypto primitives, no concurrency utils. Those are the sibling sub-issues of #722.Value lives as notes:
(value, nonce)owned bypk = Hf(sk), published only as a hiding commitmentcm = H(domain, value, nonce, pk)in aHistoricMerkleTree. Spending publishesnf = H(domain, nonce)and proves membership in-circuit without revealing which leaf, so amounts, sender, and recipient all stay off the public ledger. The ledger carries the tree and the nullifier set and nothing else: no balances, no accounts, no supply.Surface
transfer/burn: spending requires the owner's secret viawit_SecretKey, and output nonces come from the caller's own randomness witness. Created notes return to the caller as a local private result and move out of band._mint/_mintNote/_transfer/_burn/_consumeNotebuilding blocks. No roles, no initialization — the composing contract supplies policy.derivePk/commitOf/nullifierOfso wallets and auditors recompute off-circuit exactly what the circuits do.Costs (compiler's own numbers, matching the
@circuitInfotags)transfer_transferburn_burn_consumeNote_mint_mintNote**Changes from the draft in **#679
OZ:note:*instead ofOZ:cnt:*. These are permanent on-chain constants, so the rename had to land before any deployment; costs are unchanged.main.PR Checklist
Summary by CodeRabbit