From 878aa438b98879088f13f0ef96e10311ff020257 Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Wed, 15 Jul 2026 12:39:19 +0200 Subject: [PATCH 1/4] feat(token): add confidential note token draft Draft tier-4 exploration: a note-based confidential token with full graph privacy (amounts - including issuance and burns - sender, and recipient hidden), built as composable pieces: * ConfidentialNoteToken: the core (commitment tree, nullifier single-spend, value conservation, issuer gate) with ungated _-prefixed building blocks for composition * extensions: ConfidentialNoteTokenAudit (mandatory auditor viewing via audit-derived nonces), ConfidentialNoteTokenDelivery (on-chain note delivery for wallet scanning), ConfidentialNoteTokenSupply (homomorphic ElGamal supply with proof-backed attestation) * presets/RegulatedConfidentialNoteToken: the wired deployable token with issuer mint, burns, and escrow-free shared-nullifier seizure * crypto/NoteDelivery: the ECDH note-delivery primitive (7 passing tests) * mocks, simulators, and witness harnesses for the above; design doc and privacy exploration README NOT audited, NOT production. --- CHANGELOG.md | 3 + contracts/privacy_readme.md | 219 +++++++++++ contracts/src/crypto/NoteDelivery.compact | 127 +++++++ .../src/crypto/test/NoteDelivery.test.ts | 71 ++++ .../test/mocks/MockNoteDelivery.compact | 26 ++ .../src/token/ConfidentialNoteToken.compact | 341 ++++++++++++++++++ .../token/docs/hybrid-confidential-token.md | 273 ++++++++++++++ .../ConfidentialNoteTokenAudit.compact | 149 ++++++++ .../ConfidentialNoteTokenDelivery.compact | 63 ++++ .../ConfidentialNoteTokenSupply.compact | 161 +++++++++ .../RegulatedConfidentialNoteToken.compact | 249 +++++++++++++ .../mocks/MockConfidentialNoteToken.compact | 74 ++++ .../MockConfidentialNoteTokenAudit.compact | 26 ++ .../MockConfidentialNoteTokenDelivery.compact | 21 ++ .../MockConfidentialNoteTokenSupply.compact | 41 +++ .../ConfidentialNoteTokenAuditSimulator.ts | 67 ++++ .../ConfidentialNoteTokenDeliverySimulator.ts | 54 +++ .../ConfidentialNoteTokenSimulator.ts | 73 ++++ .../ConfidentialNoteTokenSupplySimulator.ts | 71 ++++ ...RegulatedConfidentialNoteTokenSimulator.ts | 115 ++++++ .../ConfidentialNoteTokenAuditWitnesses.ts | 37 ++ .../ConfidentialNoteTokenDeliveryWitnesses.ts | 36 ++ .../ConfidentialNoteTokenSupplyWitnesses.ts | 44 +++ .../ConfidentialNoteTokenWitnesses.ts | 76 ++++ ...RegulatedConfidentialNoteTokenWitnesses.ts | 93 +++++ 25 files changed, 2510 insertions(+) create mode 100644 contracts/privacy_readme.md create mode 100644 contracts/src/crypto/NoteDelivery.compact create mode 100644 contracts/src/crypto/test/NoteDelivery.test.ts create mode 100644 contracts/src/crypto/test/mocks/MockNoteDelivery.compact create mode 100644 contracts/src/token/ConfidentialNoteToken.compact create mode 100644 contracts/src/token/docs/hybrid-confidential-token.md create mode 100644 contracts/src/token/extensions/ConfidentialNoteTokenAudit.compact create mode 100644 contracts/src/token/extensions/ConfidentialNoteTokenDelivery.compact create mode 100644 contracts/src/token/extensions/ConfidentialNoteTokenSupply.compact create mode 100644 contracts/src/token/presets/RegulatedConfidentialNoteToken.compact create mode 100644 contracts/src/token/test/mocks/MockConfidentialNoteToken.compact create mode 100644 contracts/src/token/test/mocks/MockConfidentialNoteTokenAudit.compact create mode 100644 contracts/src/token/test/mocks/MockConfidentialNoteTokenDelivery.compact create mode 100644 contracts/src/token/test/mocks/MockConfidentialNoteTokenSupply.compact create mode 100644 contracts/src/token/test/simulators/ConfidentialNoteTokenAuditSimulator.ts create mode 100644 contracts/src/token/test/simulators/ConfidentialNoteTokenDeliverySimulator.ts create mode 100644 contracts/src/token/test/simulators/ConfidentialNoteTokenSimulator.ts create mode 100644 contracts/src/token/test/simulators/ConfidentialNoteTokenSupplySimulator.ts create mode 100644 contracts/src/token/test/simulators/RegulatedConfidentialNoteTokenSimulator.ts create mode 100644 contracts/src/token/test/witnesses/ConfidentialNoteTokenAuditWitnesses.ts create mode 100644 contracts/src/token/test/witnesses/ConfidentialNoteTokenDeliveryWitnesses.ts create mode 100644 contracts/src/token/test/witnesses/ConfidentialNoteTokenSupplyWitnesses.ts create mode 100644 contracts/src/token/test/witnesses/ConfidentialNoteTokenWitnesses.ts create mode 100644 contracts/src/token/test/witnesses/RegulatedConfidentialNoteTokenWitnesses.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index cf4759dc0..4deb16726 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add EcdhMask (#655) +- Add NoteDelivery: an encrypted note-delivery channel with a derived-nonce variant (`deliver`/`recover`) and an explicit-nonce variant (`deliverNote`/`recoverNote`) +- Add ConfidentialNoteToken (draft): a note-based confidential token with full graph privacy (amounts — including issuance and burns — sender, and recipient hidden), with extensions for mandatory auditor viewing (audit-derived nonces) and on-chain recipient note deliveries, plus the RegulatedConfidentialNoteToken preset wiring issuer-gated mint, burn, and shared-nullifier seizure. See `contracts/src/token/docs/hybrid-confidential-token.md` +- Add ConfidentialNoteTokenSupply (draft): a standalone extension keeping supply as a homomorphic ElGamal ciphertext updated alongside mint/burn, with `attestSupply` publishing a proof-backed public total at a chosen cadence ## 0.3.0-alpha (2026-06-30) diff --git a/contracts/privacy_readme.md b/contracts/privacy_readme.md new file mode 100644 index 000000000..144a8d12b --- /dev/null +++ b/contracts/privacy_readme.md @@ -0,0 +1,219 @@ +# Confidential token privacy: exploration branch + +**Status: exploratory spike. Unaudited. Not production.** This branch prototypes the +full spectrum of confidential-token privacy on Midnight, end to end in Compact, to +answer the questions that decide the architecture *before* committing a production +track. Everything here compiles and is covered by passing tests, but it is research +scaffolding: some modules are deliberately throwaway (see "What ships vs what was +learning" at the end). + +

+ + + + Confidential token privacy spectrum: an animation cycling through four tiers that progressively hide the amount, recipient, and sender of a transaction, with the constraint-row cost of each. + +

+ +> The animation above cycles the four tiers. Warm fields are exposed on-chain; cool +> fields are encrypted. An **interactive** version (drag the slider yourself) lives at +> [`privacy-spectrum.html`](./privacy-spectrum.html) — open it locally or via GitHub +> Pages; the draggable controls can't run inside a rendered README. + +If you read one thing: the account model and the note model are **two products for +two markets**, not two rungs of a ladder. The account model gives hidden amounts + +public graph + cheap native seizure (institutional/compliance). The note model gives +hidden amounts + hidden graph, and, as we proved here, is also seizable and +regulator-viewable (privacy-first + regulated). Graph privacy is a confirmed +requirement, and only notes deliver it. + +--- + +## How to read this branch + +The work is layered: reusable crypto primitives in `crypto/`, token variants in `token/`. The token variants map to a privacy tier: + +| Tier | Hides | Model | Module | Status | +| ---- | --------------------------- | ---------------- | ----------------------------------------------------- | --------------------------- | +| 1 | amounts | account | `ConfidentialFungibleToken` (+ dual-balance, `_move`) | built, tested | +| 2 | + recipient | account, stealth | `StealthConfidentialToken` | built, working (round-trip) | +| 3 | + sender (weak) | account, ring | `ConfidentialTokenRing` | exhibit only, dominated | +| 4 | amount + sender + recipient | notes | `HybridConfidentialToken` | built, working (9 tests) | + +Supporting crypto: + +| Module | Role | +| ----------------------- | ------------------------------------------------------------------------------------------- | +| `crypto/ElGamal` | homomorphic encrypted balances; `assertDecryptsTo(Scalar)` for spend checks | +| `crypto/EcdhMask` | ECDH one-time-pad memo: delivers a value to a pubkey, direct-decrypt, no discrete-log bound | +| `crypto/StealthAddress` | dual-key stealth one-time addresses (+ witness-assisted mod-l reduction) | +| `crypto/NoteDelivery` | encrypted note-delivery: recovers `(value, nonce)` from chain + the recipient key | + +--- + +## The approach and the hypotheses we tested + +We treated privacy as a spectrum and built one tier at a time, using each build to +test a specific hypothesis in code rather than argue it on paper: + +1. **"Amounts can be hidden cheaply on an account model."** Confirmed. ElGamal balances + + a direct-decrypt memo. Tier 1. +2. **"The recipient can be hidden while keeping the account model."** Confirmed, with a + real UX cost (one-time addresses, scanning, fragmentation). Tier 2 (stealth). +3. **"The sender can be hidden on the account model."** Disproven for any practical + scheme. A debit is a write to a public per-account slot; hiding *which* slot + requires either touch-all-N cover traffic (the ring, dominated) or an unindexed + commitment set with ZK membership + nullifiers (which *is* the note model). Tier 3 + is the exhibit that measures why the ring loses. +4. **"Notes can satisfy the institutional must-haves (seizure, regulator viewing)."** + Confirmed. The "notes aren't seizable" blocker is solved here. Tier 4. + +The through-line: **sender privacy is a conservation law, not a free lunch.** It is +paid for in cover traffic, a trusted party, or note machinery; you can move where you +pay, not eliminate it. The efficient form of "a ring without fetching N candidates" is +a Merkle membership proof, which is exactly the note model. So the account model and +notes are not competitors at different maturity; they are different points on a +custody/privacy spectrum. + +--- + +## Key findings + +- **Sender-hiding converges on notes.** No account-model trick avoids it; the field + (Zcash, Monero rings, Aztec's account-over-notes) confirms it, and so does the + sibling `AuditableShieldedCustody` effort, which reached for notes for the same + reason. +- **Notes are seizable AND auditable, together.** `HybridConfidentialToken` proves a + shielded pool can carry: graph-private transfers, regulated seizure (shared + nullifier + authority branch + re-mint to recovery, no key escrow), and auditor + viewing (per-output ciphertext to an audit key). 9 passing tests. +- **The note core is cheaper than the account transfer.** A pure shielded spend is + ~20.5k rows, about half the account CFT transfer (~43.8k). Merkle membership over a + depth-32 tree is cheap because internal nodes use the transient (Poseidon-class) + hash. +- **The dominant cost everywhere is `persistentHash` (SHA-256).** Memos, commitments, + nullifiers, and the Merkle *leaf* all use it. A stable Poseidon-class hasher is a + ~5x cut across the whole stack and is the single highest-value platform ask. +- **Accumulator choice is settled: Merkle + Poseidon.** KZG/RSA accumulators give small + proofs for *native* verification but explode *in-circuit* (pairings / big-int + modexp are non-native). Witness-verification does not change this: you cannot + witness away a hash (verify = compute) or foreign-field arithmetic. The in-circuit + cost *is* the cost of hiding which leaf; that is irreducible and follows the privacy + boundary (public facts like nullifiers are checked cheaply by the ledger kernel). +- **Total supply is policy, not core safety.** No-overspend is enforced locally + (per-account / per-note). Supply is an accounting/audit layer, so it should be + composable, not baked into the base. + +--- + +## Benchmarks (compiler constraint rows) + +| Circuit | Model | rows | Note | +| -------------------- | ---------------- | ------ | -------------------------------------- | +| `seize` | notes | 20,564 | core shielded spend, no viewing | +| `mint` | notes | 20,594 | + 1 audit viewing | +| `transfer` | notes | 54,271 | + 2 audit viewings (~32k of the total) | +| `transfer` | account (CFT) | 43,821 | baseline | +| `stealthMint` | account, stealth | 28,391 | one-time address + announcement | +| `stealthClaim` | account, stealth | 18,057 | | +| `ringTransfer` (N=4) | account, ring | 71,683 | O(N), size-4 anonymity set | + +Reading them: each `EcdhMask` viewing/delivery ciphertext is ~16k rows, all +`persistentHash`. A runtime `if (enabled)` does **not** save those constraints in ZK +(guarded emissions are always compiled in), so optional viewing/delivery should be +**compile-time variants**, not runtime flags. + +--- + +## Production plan for the tier-1 CFT ("hide amounts") + +The recommended first shippable product, distilled: + +1. **Base off the drafted CFT**, but use the **`EcdhMask` direct-decrypt memo**, not the + original exponential-ElGamal + BSGS memo. The memo is core (it is how a recipient + learns their incoming amount), and `EcdhMask` removes the `2^48` amount cap and the + wallet BSGS table. Sequence the dependency: `EcdhMask` is on its own branch pending + crypto review. +2. **Make the base supply-free**; expose the conserving `_move` / `transfer` surface + only. Supply is a composable layer (No / Public / Confidential). **Ship + `PublicSupply` in the first PR** so the base is fundable and testable; a supply-free + base alone has no way for value to enter. `_mint`/`_burn` (and the burn underflow + asserts) live in the supply layer. +3. **Include the dual-balance griefing fix**, and treat it as behavioral: credits land + in `pending`, the owner `sweep()`s into `spendable`, total = `balanceOf + pendingOf`. + This closes a liveness grief (spam can't invalidate in-flight spends) but changes + the wallet flow and breaks the single-balance tests, so test/doc reconciliation is + part of this step. +4. **Document the stealth (tier-2) plan** rather than build it: separate variant module, + one-time addresses, `EcdhMask` ephemeral doubles as the announcement, real UX cost + (scanning + fragmentation). +5. **Carry the invariants that are easy to lose:** `balanceOf` returns a well-formed + `Enc(0)` (on-curve) for unregistered accounts; `wit_RandomnessSeed` must be fresh + *and secret* (a predictable seed lets observers recover incoming amounts); the + wallet's plaintext cache (rebuilt from credit-only memos) is the authoritative + balance; the witness-identity c2c limitation stands. + +--- + +## Recommendations for the notes/custody design (draft, pending review) + +Cross-pollination with the sibling `AuditableShieldedCustody` / +`EscrowedShieldedCustody` effort, which independently converged on the same note + +custodian-memo + KYC-allowlist + multisig-seize shape. What we would recommend +changing there: + +1. **Replace the lifted-ElGamal value memo with a direct-decrypt (`EcdhMask`) memo** to + delete the `2^48` note-value cap and the BSGS indexer. Highest-value change. +2. **Offer shared-nullifier seizure as an alternative to full key escrow**, so the + authority is constrained to seize-to-recovery rather than able to spend anything + from a master secret. +3. **Harden `_freeze`** to prove the leaf at `index` matches the claimed `ownerId` + (fail closed on a mis-index) instead of trusting off-chain bookkeeping. +4. **Document the `_encUnallocated` genesis window** (publicly decryptable until the + first secret credit). +5. **Elevate the custodian-master-secret compromise to the headline trust risk** with + explicit mitigations. + +What we would adopt **from** them, kept generic. The rule: state/policy becomes +standalone modules, authorization lives in the consumer, things that must sit in the +value-movement circuit and cannot be toggled for free become compile-time variants, +and only spend-soundness goes in the base. The generic base stays lean; a "regulated" +flavor is a variant that composes modules, not a monolith. + +- **Nullifier-bound `rho`** → the base note spend (this is the only one that belongs + baked in; it is spend soundness, not policy). +- **KYC allowlist + tombstone freeze** → a standalone module + a check at the + chokepoint. Set-backed for the account model (we have Allowlist/Blocklist); a + Merkle-allowlist module for notes, because a hidden spender's membership must be + proven in-circuit. +- **`opCommitment` / multisig gating** → the consuming preset, not the module (matches + their own "ungated building blocks for a gating preset" design). +- **Confidential supply** → a supply variant (No / Public / Confidential), not baked in. +- **Owner-carrying viewing** → key management as a module; the per-output emission is a + compile-time variant (it must sit at the credit chokepoint and, being always compiled + into the circuit in ZK, cannot be a free runtime toggle). +- **Invariant-catalog rigor** → a review/documentation practice applied to all of the + above, not code. + +--- + +## Platform requests to Midnight + +1. **Stable Poseidon-class hasher** (high): `persistentHash` (SHA-256) dominates every + expensive path; a stable cheap hasher is a ~5x cut and cheapens memos, commitments, + nullifiers, and the Merkle leaf at once. +2. **Native mod-l / scalar arithmetic** (low): nice to have, not needed; the + witness-assisted reduction we built is ~6 constraints and hash-derived keys avoid it. + +--- + +## What ships vs what was learning + +- **Production track:** `ConfidentialFungibleToken` (tier 1) as the near-term product; + `HybridConfidentialToken` (tier 4) as the graph-privacy track once hardened + (per-user recovery keys, governance-gated authority, full-identity viewing, private + issuance, audit). +- **Standalone-useful:** the `crypto/` primitives (`ElGamal`, `EcdhMask`, + `StealthAddress`, `NoteDelivery`). +- **Exhibit / drop:** `ConfidentialTokenRing` (tier 3) exists only to measure why the + weak ring is dominated by notes. Keep it as evidence, do not invest in it. diff --git a/contracts/src/crypto/NoteDelivery.compact b/contracts/src/crypto/NoteDelivery.compact new file mode 100644 index 000000000..6430004cb --- /dev/null +++ b/contracts/src/crypto/NoteDelivery.compact @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.3.0-alpha (crypto/NoteDelivery.compact) + +pragma language_version >= 0.23.0; + +/** + * @module NoteDelivery + * @description EXPLORATORY tier-4 primitive: the encrypted note-delivery channel for + * a shielded pool. A sender delivers a note's `(value, nonce)` to a recipient's + * ENCRYPTION key `encPk = g^encSk` via one ECDH shared secret, so the recipient + * can detect and spend the note without any out-of-band data. Both fields ride the + * same shared secret, domain-separated: the nonce is DERIVED from it (so it needs + * no separate ciphertext) and the value is one-time-padded. + * + * Construction (sender picks a fresh ephemeral `e`): + * E = g^e + * S = encPk^e = g^{encSk*e} (ECDH shared secret point) + * nonce = KDF(S, "note:nonce") (the note's nonce; deterministic) + * valueCt = value + KDF(S, "note:value") + * Recipient recovers with `encSk`: S = E^encSk, then the same nonce and value. + * + * @dev This is the note-scheme counterpart to a Zcash note ciphertext. It uses the + * recipient's ENCRYPTION key (a Jubjub point), which is separate from the note's + * spend/ownership key `H(spendSk)`; wiring it into the token means each account + * publishes both. `e` must be fresh + secret (same one-time-pad rule as EcdhMask). + * + * @dev No ledger state, no witnesses. Pure circuits for sender + recipient use. + */ +module NoteDelivery { + import CompactStandardLibrary; + + // A note's spent value and its (derived) nonce. + export struct Note { + value: Uint<128>; + nonce: Field; + } + + // The on-chain delivery: announcement ephemeral + the value one-time-pad. + export struct Delivery { + ephemeral: JubjubPoint; + valueCt: Field; + } + + // A delivery for a note whose nonce is fixed elsewhere (e.g. derived from a + // separate audit channel): both fields ride explicit one-time pads. + export struct FullDelivery { + ephemeral: JubjubPoint; + valueCt: Field; + nonceCt: Field; + } + + // The recipient's recovered view (raw fields; caller checks value < 2^128). + export struct Recovered { + value: Field; + nonce: Field; + } + + // Domain-separated KDF from the shared secret point to a Field. + circuit kdf(shared: JubjubPoint, domain: Bytes<32>): Field { + return degradeToTransient( + persistentHash>>([persistentHash(shared), domain])); + } + + /** + * @description Sender-side: derive the note `(value, nonce)` and its delivery for + * `recipientEncPk` under a fresh ephemeral `e`. The nonce is derived from the + * shared secret, so it is committed by the sender and recovered by the recipient + * without a separate ciphertext. + */ + export pure circuit deliver(recipientEncPk: JubjubPoint, value: Uint<128>, e: Field): [Note, Delivery] { + const shared = ecMul(recipientEncPk, e); + const nonce = kdf(shared, pad(32, "note:nonce")); + const valueCt = (value as Field) + kdf(shared, pad(32, "note:value")); + return [ + Note { value: value, nonce: nonce }, + Delivery { ephemeral: ecMulGenerator(e), valueCt: valueCt } + ]; + } + + /** + * @description Recipient-side: recover `(value, nonce)` from a delivery using the + * encryption secret `encSk` (the scalar with `encPk = g^encSk`). + */ + export pure circuit recover(delivery: Delivery, encSk: Field): Recovered { + const shared = ecMul(delivery.ephemeral, encSk); + return Recovered { + value: delivery.valueCt - kdf(shared, pad(32, "note:value")), + nonce: kdf(shared, pad(32, "note:nonce")) + }; + } + + /** + * @description Sender-side variant for a note whose nonce already exists (it + * was derived elsewhere, e.g. from a mandatory audit channel): encrypts BOTH + * `value` and `nonce` to `recipientEncPk` under a fresh ephemeral `e`. + * + * Requirements: + * + * - `recipientEncPk` is not the identity point. + * - `e` is fresh, secret, and non-zero (same one-time-pad rules as `deliver`). + */ + export pure circuit deliverNote(recipientEncPk: JubjubPoint, note: Note, e: Field): FullDelivery { + const identity = ecMulGenerator(0 as Field); + assert(recipientEncPk != identity, "NoteDelivery: identity pk"); + const ephemeral = ecMulGenerator(e); + // Point guard subsumes `e != 0` (see crypto/EcdhMask weak-inputs note). + assert(ephemeral != identity, "NoteDelivery: zero ephemeral"); + const shared = ecMul(recipientEncPk, e); + return FullDelivery { + ephemeral: ephemeral, + valueCt: (note.value as Field) + kdf(shared, pad(32, "note:value")), + nonceCt: note.nonce + kdf(shared, pad(32, "note:nonce:ct")) + }; + } + + /** + * @description Recipient-side: recover `(value, nonce)` from a `FullDelivery` + * using the encryption secret `encSk`. + */ + export pure circuit recoverNote(delivery: FullDelivery, encSk: Field): Recovered { + const shared = ecMul(delivery.ephemeral, encSk); + return Recovered { + value: delivery.valueCt - kdf(shared, pad(32, "note:value")), + nonce: delivery.nonceCt - kdf(shared, pad(32, "note:nonce:ct")) + }; + } +} diff --git a/contracts/src/crypto/test/NoteDelivery.test.ts b/contracts/src/crypto/test/NoteDelivery.test.ts new file mode 100644 index 000000000..8d3a77fb5 --- /dev/null +++ b/contracts/src/crypto/test/NoteDelivery.test.ts @@ -0,0 +1,71 @@ +import { ecMulGenerator } from '@midnight-ntwrk/compact-runtime'; +import { describe, expect, it } from 'vitest'; +import { pureCircuits } from '../../../artifacts/MockNoteDelivery/contract/index.js'; + +// Recipient encryption key: encSk (scalar) -> encPk = g^encSk. +const encSk = 555666777n; +const encPk = ecMulGenerator(encSk); + +describe('NoteDelivery', () => { + it('recipient recovers BOTH value and nonce from the delivery', () => { + const [note, delivery] = pureCircuits.deliver(encPk, 4200n, 314159n); + const recovered = pureCircuits.recover(delivery, encSk); + expect(recovered.value).toBe(note.value); // value delivered + expect(recovered.nonce).toBe(note.nonce); // nonce delivered (derived, matches) + expect(note.value).toBe(4200n); + }); + + it('the derived nonce is what the sender committed', () => { + // The sender commits `note.nonce`; the recipient must derive the identical + // value, else the recovered note would not match any commitment. + const [note, delivery] = pureCircuits.deliver(encPk, 1n, 42n); + expect(pureCircuits.recover(delivery, encSk).nonce).toBe(note.nonce); + }); + + it('a wrong encryption key recovers neither value nor nonce', () => { + const [note, delivery] = pureCircuits.deliver(encPk, 4200n, 314159n); + const wrong = pureCircuits.recover(delivery, 999999n); + expect(wrong.value).not.toBe(note.value); + expect(wrong.nonce).not.toBe(note.nonce); + }); + + it('distinct ephemerals yield distinct notes + deliveries', () => { + const [n1, d1] = pureCircuits.deliver(encPk, 100n, 1n); + const [n2, d2] = pureCircuits.deliver(encPk, 100n, 2n); + expect(n1.nonce).not.toBe(n2.nonce); // fresh nonce per payment + expect(d1.ephemeral).not.toEqual(d2.ephemeral); + expect(d1.valueCt).not.toBe(d2.valueCt); + // Both still recover correctly for the true recipient. + expect(pureCircuits.recover(d1, encSk).value).toBe(100n); + expect(pureCircuits.recover(d2, encSk).value).toBe(100n); + }); +}); + +describe('NoteDelivery: explicit-nonce delivery (deliverNote/recoverNote)', () => { + const note = { value: 4200n, nonce: 271828182845n }; + + it('recipient recovers the exact (value, nonce) of an existing note', () => { + const delivery = pureCircuits.deliverNote(encPk, note, 314159n); + expect(pureCircuits.recoverNote(delivery, encSk)).toStrictEqual({ + value: note.value, + nonce: note.nonce, + }); + }); + + it('a wrong encryption key recovers neither field', () => { + const delivery = pureCircuits.deliverNote(encPk, note, 314159n); + const wrong = pureCircuits.recoverNote(delivery, 999999n); + expect(wrong.value).not.toBe(note.value); + expect(wrong.nonce).not.toBe(note.nonce); + }); + + it('rejects the identity recipient key and a zero ephemeral', () => { + const identity = ecMulGenerator(0n); + expect(() => pureCircuits.deliverNote(identity, note, 314159n)).toThrow( + 'identity pk', + ); + expect(() => pureCircuits.deliverNote(encPk, note, 0n)).toThrow( + 'zero ephemeral', + ); + }); +}); diff --git a/contracts/src/crypto/test/mocks/MockNoteDelivery.compact b/contracts/src/crypto/test/mocks/MockNoteDelivery.compact new file mode 100644 index 000000000..0d6f5eced --- /dev/null +++ b/contracts/src/crypto/test/mocks/MockNoteDelivery.compact @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MIT + +// WARNING: FOR TESTING PURPOSES ONLY. + +pragma language_version >= 0.23.0; + +import CompactStandardLibrary; +import "../../NoteDelivery" prefix ND_; + +export { ND_Note, ND_Delivery, ND_FullDelivery, ND_Recovered } + +export pure circuit deliver(recipientEncPk: JubjubPoint, value: Uint<128>, e: Field): [ND_Note, ND_Delivery] { + return ND_deliver(recipientEncPk, value, e); +} + +export pure circuit recover(delivery: ND_Delivery, encSk: Field): ND_Recovered { + return ND_recover(delivery, encSk); +} + +export pure circuit deliverNote(recipientEncPk: JubjubPoint, note: ND_Note, e: Field): ND_FullDelivery { + return ND_deliverNote(recipientEncPk, note, e); +} + +export pure circuit recoverNote(delivery: ND_FullDelivery, encSk: Field): ND_Recovered { + return ND_recoverNote(delivery, encSk); +} diff --git a/contracts/src/token/ConfidentialNoteToken.compact b/contracts/src/token/ConfidentialNoteToken.compact new file mode 100644 index 000000000..7e0eee76b --- /dev/null +++ b/contracts/src/token/ConfidentialNoteToken.compact @@ -0,0 +1,341 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts (token/ConfidentialNoteToken.compact) + +pragma language_version >= 0.23.0; + +/** + * @module ConfidentialNoteToken + * @description DRAFT tier-4 core: a self-contained note-based confidential + * token with FULL graph privacy (amounts, sender, and recipient hidden). + * + * 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)` (field-typed hash); + * a balance is the sum of one's unspent notes. Commitment + * `cm = H(domain, value, nonce, pk)` goes in the tree; nullifier + * `nf = H(domain, nonce)` marks the note spent. Spending proves membership of + * the note's commitment without revealing which leaf. + * + * The core is a complete token on its own: `initialize` binds the issuer, + * `mint` (issuer-gated), `transfer`, and `burn` work out of the box, deriving + * output nonces from the caller's own randomness witness. Created notes are + * returned to the caller (a local, private result — nothing extra goes + * on-chain), who hands them to recipients out of band. + * + * Compliance and UX are layered on top, not baked in: + * + * - auditor viewing — `extensions/ConfidentialNoteTokenAudit`, + * - on-chain note delivery (recipients discover funds by scanning) — + * `extensions/ConfidentialNoteTokenDelivery`, + * - confidential supply + public attestation — + * `extensions/ConfidentialNoteTokenSupply`, + * - the wired-together deployable token — + * `presets/RegulatedConfidentialNoteToken`. + * + * For that wiring, the `_`-prefixed building blocks (`_mint`, `_transfer`, + * `_burn`, `_consumeNote`) accept caller-built notes, so a composing contract + * can source nonces from its emission policy (e.g. audit-derived) instead of + * the core default. Like all `_` circuits, they carry no authorization — + * the composer gates them. + * + * @dev Nonces are spend-critical: the nullifier preimage is the nonce alone + * (no owner secret), so any party that knows a nonce derives the SAME + * nullifier. This is what makes regulated seizure escrow-free in the preset + * (owner-spend and seizure are mutually exclusive), and why output nonces + * MUST be unique and unpredictable. `wit_NonceRandomness` MUST return a + * fresh, secret seed per invocation. + * + * @dev NOT audited, NOT production. + */ +module ConfidentialNoteToken { + import CompactStandardLibrary; + + // A note's value and its unique nonce. Ownership is a separate pk. + export struct Note { + value: Uint<128>; + nonce: Field; + } + + // Commitment/nullifier hash preimages (domain-separated). + struct CommitPreimage { + domain: Bytes<32>; + value: Uint<128>; + nonce: Field; + pk: Field; + } + struct NullifierPreimage { + domain: Bytes<32>; + nonce: Field; + } + + export ledger _isInitialized: Boolean; + // Mint authorization: `Hf(issuerSecret)`. + export ledger _issuerPk: Field; + export ledger _commitments: HistoricMerkleTree<32, Bytes<32>>; + export ledger _nullifiers: Set>; + + // Owner's spend secret (pk = Hf(sk)). + witness wit_SecretKey(): Bytes<32>; + // The issuer's secret (issuerPk = Hf(issuerSecret)). + witness wit_IssuerSecret(): Bytes<32>; + // The note being consumed, and its Merkle path. + witness wit_InputNote(): Note; + witness wit_Path(cm: Bytes<32>): MerkleTreePath<32, Bytes<32>>; + // Randomness seed for the core's default output nonces. MUST be fresh + + // secret per invocation (see module doc). + witness wit_NonceRandomness(): Bytes<32>; + + /** + * @description Field-typed identity hash: `pk = Hf(sk)`. Exported so + * wallets, auditors, and tests derive identities the way the circuits do. + */ + export pure circuit derivePk(sk: Bytes<32>): Field { + return degradeToTransient(persistentHash>(sk)); + } + + /** + * @description `cm = H(domain, value, nonce, pk)`. Exported so off-chain + * viewers can recompute commitments for notes they recover. + */ + export pure circuit commitOf(note: Note, pk: Field): Bytes<32> { + return persistentHash(CommitPreimage { + domain: pad(32, "OZ:cnt:commit"), + value: note.value, + nonce: note.nonce, + pk: pk + }); + } + + /** + * @description `nf = H(domain, nonce)` — derivable by anyone who knows the + * nonce. Exported so off-chain viewers can watch a note's consumption. + */ + export pure circuit nullifierOf(note: Note): Bytes<32> { + return persistentHash(NullifierPreimage { + domain: pad(32, "OZ:cnt:null"), + nonce: note.nonce + }); + } + + /** + * @description One-shot initialization binding the issuer (the only role + * the core itself needs). + * + * Requirements: + * + * - Module is not already initialized. + * + * @circuitInfo k=6, rows=31 + */ + export circuit initialize(issuerPk: Field): [] { + assert(!_isInitialized, "ConfidentialNoteToken: already initialized"); + _issuerPk = disclose(issuerPk); + _isInitialized = true; + } + + /** + * @description Mints a note of `value` to `recipientPk` and returns it (a + * local private result) so the issuer can hand it to the recipient out of + * band. The amount is NOT written to public state — issuance stays hidden; + * only the hiding commitment appears on-chain. + * + * Requirements: + * + * - Module is initialized. + * - The caller proves the issuer secret (`Hf(secret) == _issuerPk`). + * + * @circuitInfo k=14, rows=13217 + */ + export circuit mint(recipientPk: Field, value: Uint<128>): Note { + _assertIssuer(); + const note = Note { value: value, nonce: freshNonce(pad(32, "OZ:cnt:out")) }; + _mint(note, recipientPk); + // The note returns only to the local caller; revealing it on-chain would + // expose the nonce (spend-critical) and the amount. + return disclose(note); + } + + /** + * @description Fully-private transfer: spends the caller's input note and + * creates a recipient note of `value` plus a change note back to the + * caller, conserving value. Returns `[outNote, changeNote]` (a local + * private result) so the caller's wallet can hand the note over and track + * its change. Sender and recipient are both hidden; the public ledger gains + * one nullifier and two commitments. + * + * Requirements: + * + * - The input note is committed in the tree and unspent. + * - `value <= input.value`. + * + * @circuitInfo k=16, rows=36394 + */ + export circuit transfer(recipientPk: Field, value: Uint<128>): [Note, Note] { + const pk = _spenderPk(); + const input = _inputNote(); + assert(input.value >= value, "ConfidentialNoteToken: insufficient note value"); + + const outNote = Note { value: value, nonce: freshNonce(pad(32, "OZ:cnt:out")) }; + const changeNote = Note { + value: (input.value - value) as Uint<128>, + nonce: freshNonce(pad(32, "OZ:cnt:chg")) + }; + _transfer(pk, recipientPk, outNote, changeNote); + return [disclose(outNote), disclose(changeNote)]; + } + + /** + * @description Burns `value` from the caller's input note: spends the note + * and re-issues only the change (returned as a local private result), so + * `value` leaves circulation. Both the burned amount and the burner stay + * hidden; publicly a burn looks like any other spend. + * + * Requirements: + * + * - The input note is committed in the tree and unspent. + * - `value <= input.value`. + * + * @circuitInfo k=15, rows=25584 + */ + export circuit burn(value: Uint<128>): Note { + const pk = _spenderPk(); + const input = _inputNote(); + assert(input.value >= value, "ConfidentialNoteToken: insufficient note value"); + + const changeNote = Note { + value: (input.value - value) as Uint<128>, + nonce: freshNonce(pad(32, "OZ:cnt:chg")) + }; + _burn(pk, value, changeNote); + return disclose(changeNote); + } + + /** + * @description Building block: the caller's spend identity, + * `Hf(wit_SecretKey())`. Exposed so composing contracts authorize spends + * the way the core does. + */ + export circuit _spenderPk(): Field { + return derivePk(wit_SecretKey()); + } + + /** + * @description Building block: asserts the caller proves the issuer secret. + * + * Requirements: + * + * - Module is initialized. + * - `Hf(wit_IssuerSecret()) == _issuerPk`. + * + * @circuitInfo k=13, rows=2277 + */ + export circuit _assertIssuer(): [] { + assert(_isInitialized, "ConfidentialNoteToken: contract not initialized"); + assert(derivePk(wit_IssuerSecret()) == _issuerPk, + "ConfidentialNoteToken: not the issuer"); + } + + /** + * @description Building block: peek at the input note about to be consumed + * (the same witness `_consumeNote` reads), so a composing contract can size + * the change and run its emission policy BEFORE the spend. Any mismatch + * with what it then passes to `_transfer` / `_burn` is caught by their + * conservation asserts against this same witness. + */ + export circuit _inputNote(): Note { + return wit_InputNote(); + } + + /** + * @description UNGATED building block: commits a caller-built note to + * `ownerPk`. The composer decides who may create value, how the nonce was + * produced, and how the note reaches its owner. + * + * @circuitInfo k=13, rows=6766 + */ + export circuit _mint(note: Note, ownerPk: Field): [] { + // Only the hiding commitment crosses to public state. + _commitments.insert(disclose(commitOf(note, ownerPk))); + } + + /** + * @description UNGATED building block: consumes the input note owned by + * `spenderPk` and commits `outNote` to `recipientPk` plus `changeNote` back + * to the spender, conserving value exactly. + * + * Requirements: + * + * - The input note is committed in the tree and unspent. + * - `input.value == outNote.value + changeNote.value`. + * + * @circuitInfo k=15, rows=25732 + */ + export circuit _transfer(spenderPk: Field, recipientPk: Field, outNote: Note, changeNote: Note): [] { + const input = _consumeNote(spenderPk); + assert(input.value == outNote.value + changeNote.value, + "ConfidentialNoteToken: transfer does not conserve value"); + _mint(outNote, recipientPk); + _mint(changeNote, spenderPk); + } + + /** + * @description UNGATED building block: consumes the input note owned by + * `spenderPk` and re-issues only `changeNote`, so `value` leaves + * circulation. + * + * Requirements: + * + * - The input note is committed in the tree and unspent. + * - `input.value == value + changeNote.value`. + * + * @circuitInfo k=15, rows=19119 + */ + export circuit _burn(spenderPk: Field, value: Uint<128>, changeNote: Note): [] { + const input = _consumeNote(spenderPk); + assert(input.value == value + changeNote.value, + "ConfidentialNoteToken: burn does not conserve value"); + _mint(changeNote, spenderPk); + } + + /** + * @description UNGATED building block: consumes the witness-supplied input + * note owned by `ownerPk` — proves its commitment is in the tree at some + * historical root (without revealing which leaf), checks it is unspent, and + * publishes its nullifier. Returns the note for value accounting. Used by + * `_transfer` / `_burn` and directly by custom spend paths (e.g. seizure, + * where `ownerPk` is the target's key and authorization is the caller's). + * + * Requirements: + * + * - The input note is committed in the tree and unspent. + * + * @circuitInfo k=14, rows=12248 + */ + export circuit _consumeNote(ownerPk: Field): Note { + const input = wit_InputNote(); + + // Membership at some historical root. The root is public; disclosing it + // reveals nothing about which leaf, since the path itself stays witness. + const cm = commitOf(input, ownerPk); + const path = wit_Path(cm); + const root = disclose(merkleTreePathRoot<32, Bytes<32>>(path)); + assert(_commitments.checkRoot(root), + "ConfidentialNoteToken: input root not recognized"); + assert(cm == path.leaf, + "ConfidentialNoteToken: path does not match input commitment"); + + // Single-spend: the shared nullifier can be consumed exactly once. + const nf = nullifierOf(input); + assert(!_nullifiers.member(disclose(nf)), + "ConfidentialNoteToken: note already spent"); + _nullifiers.insert(disclose(nf)); + + return input; + } + + // Fresh output nonce from the caller's randomness witness, domain-separated + // per slot within one invocation. + circuit freshNonce(slot: Bytes<32>): Field { + return degradeToTransient(persistentHash>>( + [wit_NonceRandomness(), pad(32, "OZ:cnt:nonce:core"), slot])); + } +} diff --git a/contracts/src/token/docs/hybrid-confidential-token.md b/contracts/src/token/docs/hybrid-confidential-token.md new file mode 100644 index 000000000..222d04814 --- /dev/null +++ b/contracts/src/token/docs/hybrid-confidential-token.md @@ -0,0 +1,273 @@ +# HybridConfidentialToken — design (tier-4 notes model) + +**Status: draft for review. Unaudited. Not production.** + +A note/UTXO-based confidential token with full graph privacy: amounts, sender, +and recipient are hidden from the public ledger. Compliance is built in, not +bolted on: a mandatory audit channel gives a designated auditor complete +transaction visibility, and a seizure authority can claw back notes without any +key escrow. + +- Core module: `contracts/src/token/HybridConfidentialToken.compact` +- Supply extension: `contracts/src/token/extensions/HybridConfidentialTokenSupply.compact` +- Crypto deps: `crypto/EcdhMask` + `crypto/ElGamal` (merged), `crypto/NoteDelivery` (this PR) +- Origin: tier 4 of the confidential-token privacy exploration + (`save-token-info` spike); this document covers the hardened draft. + +## The auditor question + +> Can a note-based token satisfy a regulator/auditor: who can see what, prove +> what, and do what? + +Answer: yes, by construction. Every output note's nonce is **derived from an +ECDH shared secret with the audit key**, and its owner and value are encrypted +to the same key in-circuit. A transaction that skips or fakes the audit record +cannot exist: the commitment written to the tree binds the same +`(value, nonce, owner)` the audit record encrypts, inside the proof. + +What the **auditor** (audit-key holder) gets, per output: + +| Field | How | +| --- | --- | +| recipient (`ownerPk`) | encrypted in the audit record | +| amount (`value`) | encrypted in the audit record | +| note `nonce` | derived from the audit ECDH — recovered, not trusted | +| sender | derived: the published nullifier identifies the consumed note, whose owner the auditor already knows from that note's own audit record | +| token / timestamp | contract address / transaction metadata (indexer) | + +Because the auditor recovers every nonce, they can independently recompute +every commitment `cm = H(domain, value, nonce, pk)` and every nullifier +`nf = H(domain, nonce)`. That yields: + +- **Viewing**: full amounts + counterparties for every transaction + (the travel-rule data set: sender, receiver, token, quantity, timestamp). +- **Investigation**: full transaction-graph reconstruction — watch any note + from creation (commitment insert) to consumption (nullifier publish). +- **Seizure**: the audit trail supplies exactly the witnesses `seize` needs + (the target note and its owner), so enforcement requires no cooperation + from the owner and no escrowed spend keys. + +What the **public** sees: commitment inserts, nullifiers, ciphertexts, supply +deltas on mint/burn. No amounts, no identities, no linkage. + +What the **auditor cannot do**: spend. The audit key decrypts; it holds no +spend authority. Seizure is a separate, provable authority (below). + +## Compliance requirements mapping + +| Requirement (source) | Mechanism | +| --- | --- | +| Per-tx sender, receiver, token, quantity, timestamp (BitGo travel-rule data set, Jul 14) | audit records, see table above | +| Seize / claw back (MNF Q3 RWA priorities) | `seize`: shared nullifier + authority proof + re-mint to recovery; no key escrow | +| Auditor viewing without spend rights (steering calls) | audit key is decrypt-only | +| Source-of-funds evidence for incoming assets (MPS-0025) | auditor attributes each incoming note to the consumed note's owner; per-custodian selective evidence is Phase 2 (open question below) | +| Selective / request-based review keys (BitGo FIU, Jul 14) | not in this draft — the global audit key is all-seeing; see Open questions | +| No public traceability by default (MPS-0025 req. 3–4) | public ledger carries only commitments, nullifiers, ciphertexts | +| Public, non-inflatable supply (hidden-inflation concern) | supply extension: homomorphic encrypted supply + periodic `attestSupply` proof; per-tx amounts never public | + +## Model + +A note is `(value, nonce)` owned by `pk = Hf(sk)` (field-typed hash). The +commitment `cm = H(domain, value, nonce, pk)` lives in a +`HistoricMerkleTree<32>`; the nullifier `nf = H(domain, nonce)` marks it spent. +A spend proves membership of the input note at a historical root without +revealing which leaf. + +Identity is two keys per account: + +- **spend key** `pk = Hf(sk)` — owns notes (Field, so it can ride the + field-arithmetic ciphertexts). +- **encryption key** `encPk = g^encSk` (Jubjub) — receives note deliveries. + +The **shared nullifier** is the seizure primitive: `nf` depends only on the +nonce, so the owner and the authority derive the *same* nullifier, making +owner-spend and seizure mutually exclusive (first to land wins) with no shared +secrets. + +### Per-output emissions + +Each output note (mint, transfer out, transfer change, burn change) emits, in +one circuit: + +1. **Audit record** (to the global `auditKey`): ephemeral `E_a = g^e_a`, + `S_a = auditKey^e_a`, then + `nonce = KDF(S_a, "nonce")`, `valueCt = value + KDF(S_a, "value")`, + `ownerCt = ownerPk + KDF(S_a, "owner")`. +2. **Recipient delivery** (to the output owner's `encPk`): ephemeral + `E_r = g^e_r`, `S_r = encPk^e_r`, then `valueCt`, `nonceCt` — the recipient + recovers `(value, nonce)` from chain data alone, no out-of-band channel + (`crypto/NoteDelivery`). + +Deriving the nonce from the audit ECDH kills two birds: audit completeness is +structural (no nonce the auditor can't recover), and the "fresh output nonce" +witness footgun disappears — freshness reduces to the already-required +freshness of the ephemeral scalar. + +### Circuit surface + +Core: + +``` +initialize(issuerPk, authorityPk, auditKey) // one-shot; all roles bound at genesis +mint(recipientPk, recipientEncPk, value) // issuer-only; amount hidden +transfer(recipientPk, recipientEncPk, senderEncPk, value) // fully private +burn(senderEncPk, value) // spends a note; amount hidden +seize(targetOwnerPk, recoveryPk, recoveryEncPk) // authority-only clawback +``` + +Supply extension (standalone module; the consuming contract wires it): + +``` +initialize(supplyKey) // one-shot; ElGamal key, attester holds the secret +_addMinted(value) // homomorphic add, paired with every mint +_addBurned(value) // homomorphic subtract, paired with every burn +attestSupply(total) // proves Dec(_encSupply) == total, discloses only total +``` + +- `mint` requires the issuer secret (`Hf(issuerSecret) == _issuerPk`). +- `transfer`/`burn` spend the caller's input note (membership + nullifier + + conservation `in == out + change`); change returns to the sender with its own + audit record + self-delivery. +- `seize` requires the authority secret, consumes the target note via the + shared nullifier, re-mints full value to `recoveryPk`; `_seizureCount` + records it. + +### Supply is a policy layer + +The core writes NO public supply: a disclosed counter would leak every mint +and burn amount as a public delta (each tokenized-deposit position size, +timestamped). Native shielded tokens cannot avoid this (`shieldedMints` is a +protocol effect); the note pool can, because it never touches Zswap coins — +the only channel that would expose issuance amounts is a supply write we +choose not to make. A deployment picks its point on the spectrum: + +| Shape | Public sees | How | +| --- | --- | --- | +| none | nothing | core only; auditor reconstructs supply from the audit trail | +| confidential + attested | proof-backed total at a chosen cadence | `HybridConfidentialTokenSupply` extension (this PR) | +| fully public | every mint/burn delta | compose a disclosed counter alongside `mint`/`burn` | + +The extension keeps `_encSupply` as an exponential-ElGamal ciphertext updated +homomorphically inside the same transaction as the token op (trustlessly +correct, no readable delta; a homomorphic update needs no knowledge of the +running total, so user burns can update it). `attestSupply` proves in-circuit +that the ciphertext decrypts to the claimed total and discloses only that +number — public, non-inflatable supply at attestation cadence, k-anonymous +amounts in between. Mint *events* remain visible either way (a mint has a +distinctive shape: one commitment, no nullifier); only amounts hide. + +### Ledger schema + +Core: + +| Field | Type | Why | +| --- | --- | --- | +| `_isInitialized` | `Boolean` | one-shot role binding | +| `_commitments` | `HistoricMerkleTree<32, Bytes<32>>` | note set; historical roots let proofs lag inserts | +| `_nullifiers` | `Set>` | double-spend prevention | +| `_issuerPk` | `Field` | mint authorization | +| `_authorityPk` | `Field` | seizure authorization | +| `_auditKey` | `JubjubPoint` | mandatory viewing key | +| `_seizureCount` | `Uint<64>` | public audit trail of enforcement | +| `_auditTrail` | `List` | per-output audit ciphertexts (indexer-observable) | +| `_deliveries` | `List` | per-output recipient ciphertexts (indexer-observable) | + +Supply extension: + +| Field | Type | Why | +| --- | --- | --- | +| `_supplyKey` | `JubjubPoint` | ElGamal key the supply is encrypted under | +| `_encSupply` | `ElGamal.Ciphertext` | outstanding supply, homomorphically maintained, no readable deltas | +| `_attestedSupply` | `Uint<128>` | last proof-backed public total | +| `_attestationCount` | `Uint<64>` | attestation freshness for indexers | + +`_auditTrail` and `_deliveries` exist for observation: wallets scan +`_deliveries` (trial-decrypt with `encSk`), auditors scan `_auditTrail`. They +are the module's event substitute. + +## Disclosure boundary + +Everything a circuit writes to the ledger is witness-derived and passes +through explicit `disclose(...)`: + +| Disclosed | Reveals | Safe because | +| --- | --- | --- | +| Merkle root (transfer/burn/seize) | one historical root | path stays witness; root ⇏ leaf | +| `nf` | "some note was spent" | preimage hidden; linkable only with the nonce (auditor-only) | +| `cm` inserts | "a note was created" | hiding commitment (256-bit nonce entropy from ECDH) | +| audit record / delivery ciphertexts | nothing without the keys | ECDH one-time pads (EcdhMask rules: fresh + secret ephemerals) | +| `_encSupply` updates (extension) | "supply changed" | ElGamal ciphertext; delta unreadable without the supply secret | +| `attestSupply` total (extension) | the supply at attestation time | deliberate, proof-backed disclosure at a chosen cadence | +| `_seizureCount` | number of seizures | intended public accountability | + +Transaction *shape* is public: a mint (one commitment, no nullifier) is +distinguishable from a transfer (one nullifier, two commitments) and from a +burn/seize (one nullifier, one commitment). Event counts and timing leak; +amounts and parties do not. + +Witness entropy: spend secrets, issuer/authority secrets, and the randomness +seed are 256-bit; ephemerals expand from the seed, domain-separated per output. +A predictable seed breaks confidentiality (EcdhMask secrecy rule) — same class +of requirement as CFT's `wit_RandomnessSeed`. + +## Invariants (carried into tests) + +1. **Conservation**: `transfer` preserves `in == out + change`; `burn` + removes exactly `in - change` from circulation; `seize` conserves value. +2. **No double spend**: a nullifier can be consumed once, whether by owner + spend or seizure (mutual exclusion both directions). +3. **Membership**: only committed notes are spendable; a bad path or foreign + root reverts. +4. **Authorization**: mint requires the issuer secret; seize requires the + authority secret; a spend requires the note owner's secret (the commitment + binds `pk`). +5. **Audit completeness**: for every output, the audit-key holder recovers + `(ownerPk, value, nonce)` and can recompute the exact `cm` inserted. +6. **Delivery correctness**: for every output, the owner's `encSk` recovers + `(value, nonce)` matching the committed note. +7. **Confidentiality**: a wrong audit/enc key recovers nothing; ciphertexts + for equal values are unlinkable (fresh ephemerals). +8. **One-shot init**: no state-changing circuit is callable before + `initialize`; `initialize` cannot run twice (core and extension alike). +9. **Supply extension**: `_encSupply` equals `Enc(Σ minted − Σ burned)` when + wired 1:1 with token ops; `attestSupply` succeeds only with the supply + secret and the exact total, and discloses nothing else. + +## Costs + +Order-of-magnitude from the spike (compiler rows, dominated by +`persistentHash`): core note spend ~20.5k; each ECDH record ~16k. This draft's +`transfer` carries 2 audit records + 2 deliveries — roughly double the spike's +54k. Known platform lever: a stable Poseidon-class hasher (~5x cut across +memos, commitments, nullifiers, Merkle leaf). Runtime `if` does not save +constraints, so optional viewing/delivery would be compile-time variants, not +flags. + +## Out of scope (this draft) + +- **Request-based / selective review keys** — the global audit key is + all-or-nothing; per-custodian or per-request disclosure is the Phase-2 + design driven by BitGo's FIU verification (Jul 14 action item). +- **Per-user recovery keys + governance-gated authority** — the draft keeps + one global authority key; production wants multisig gating (compose with + `multisig/`) and least-privilege recovery targets. +- **KYC allowlist for notes** — a Merkle-allowlist module composed at the + spend chokepoint (hidden spender ⇒ in-circuit membership), per the spike's + recommendations. +- **Attestation governance** — who may attest, at what cadence, and whether a + stale attestation should block token ops is deployment policy. +- **Multi-asset / family variant** — single asset per contract instance. +- **Fee/gas privacy, wallet scanning UX, proof-server topology** — dApp-layer. + +## Open questions + +1. Selective disclosure shape for Phase 2: per-account review keys emitted as + extra records (compile-time variant), or off-chain re-encryption service + run by the issuer? Depends on BitGo FIU feedback. +2. ~~Should `burn` hide the amount?~~ Resolved: the core hides mint AND burn + amounts (no public supply write); public supply is delivered by the + confidential-supply extension's proof-backed attestation instead. +3. Audit-trail retention: `List` grows unboundedly; fine for the draft, + production wants indexer-side pagination guidance. +4. Naming: keep `HybridConfidentialToken` or align with a future MIP name + before the PR lands. diff --git a/contracts/src/token/extensions/ConfidentialNoteTokenAudit.compact b/contracts/src/token/extensions/ConfidentialNoteTokenAudit.compact new file mode 100644 index 000000000..e6aec76bc --- /dev/null +++ b/contracts/src/token/extensions/ConfidentialNoteTokenAudit.compact @@ -0,0 +1,149 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts (token/extensions/ConfidentialNoteTokenAudit.compact) + +pragma language_version >= 0.23.0; + +/** + * @module ConfidentialNoteTokenAudit + * @description Optional standalone extension that gives a designated auditor + * COMPLETE visibility over a `ConfidentialNoteToken` pool while the public + * sees nothing. It imports no token module. + * + * For each output note, `_emitAuditedOutput`: + * + * - DERIVES the note's nonce from an ECDH shared secret with the audit key + * and returns it (the consumer commits exactly this nonce), and + * - pushes an `AuditRecord` encrypting the note's value and owner to the + * audit key. + * + * Because the nonce comes from the audit ECDH, the auditor recovers + * `(owner, value, nonce)` for every output BY CONSTRUCTION — an output the + * auditor cannot open cannot exist in a pool that routes all note creation + * through this circuit. From the recovered fields the auditor recomputes each + * commitment and nullifier, reconstructing the full transaction graph: + * amounts, counterparties (spends attribute to the consumed note's owner), + * and note lifecycles. The audit key holds no spend authority... with one + * caveat: knowing every nonce means the auditor can derive every nullifier + * preimage, so in a seizure-enabled preset the audit trail is exactly what + * arms the seizure authority (see the preset). + * + * @notice Pairs with `ConfidentialNoteToken`. The consuming contract wires + * every note-creation path through this extension: + * + * circuit emitOutput(ownerPk: Field, value: Uint<128>, slot: Bytes<32>): [] { + * const nonce = Audit__emitAuditedOutput(ownerPk, value, slot); + * CNT__createNote(CNT_Note { value: value, nonce: nonce }, ownerPk); + * } + * + * @warning Audit completeness is a property of the CONSUMER's wiring: a note + * created without `_emitAuditedOutput` is invisible to the auditor. Route + * every `_createNote` through it. + * + * @dev `wit_AuditRandomness` MUST return a fresh, secret seed per invocation; + * ephemerals expand from it (see `crypto/EcdhMask` freshness rules). `slot` + * domain-separates ephemerals when one transaction emits several outputs. + * + * @dev NOT audited, NOT production. + */ +module ConfidentialNoteTokenAudit { + import CompactStandardLibrary; + import "../../crypto/EcdhMask" prefix EcdhMask_; + + // Per-output audit ciphertext: the note's value and owner encrypted to the + // audit key. The note's nonce is derived from the same ECDH shared secret, + // so it needs no ciphertext field. + export struct AuditRecord { + ephemeralPk: JubjubPoint; + valueCt: Field; + ownerCt: Field; + } + + // The auditor's recovered view of one output (see `recoverAuditRecord`). + export struct AuditView { + value: Field; + nonce: Field; + ownerPk: Field; + } + + export ledger _isInitialized: Boolean; + // Mandatory viewing key: every audited output is readable by its holder + // (and no one else). + export ledger _auditKey: JubjubPoint; + // Per-output records, for observation: auditors scan this list. It is this + // extension's event substitute. + export ledger _auditTrail: List; + + // Randomness seed for audit ephemerals. MUST be fresh + secret per + // invocation (see module doc). + witness wit_AuditRandomness(): Bytes<32>; + + /** + * @description One-shot initialization binding the audit key. + * + * Requirements: + * + * - Extension is not already initialized. + * - `auditKey` is not the identity point. + * + * @circuitInfo k=10, rows=615 + */ + export circuit initialize(auditKey: JubjubPoint): [] { + assert(!_isInitialized, "ConfidentialNoteTokenAudit: already initialized"); + assert(auditKey != ecMulGenerator(0 as Field), + "ConfidentialNoteTokenAudit: identity audit key"); + _auditKey = disclose(auditKey); + _isInitialized = true; + } + + /** + * @description Emits the audit record for one output note and returns the + * note's DERIVED nonce, which the consumer must commit verbatim. See the + * module doc for the completeness argument. + * + * Requirements: + * + * - Extension is initialized. + * + * @circuitInfo k=15, rows=31599 + */ + export circuit _emitAuditedOutput(ownerPk: Field, value: Uint<128>, slot: Bytes<32>): Field { + assert(_isInitialized, "ConfidentialNoteTokenAudit: extension not initialized"); + + const ea = degradeToTransient(persistentHash>>( + [wit_AuditRandomness(), pad(32, "OZ:cnt:ea"), slot])); + const eaPk = ecMulGenerator(ea); + // Point guard subsumes `ea != 0` (see crypto/EcdhMask weak-inputs note). + assert(eaPk != ecMulGenerator(0 as Field), + "ConfidentialNoteTokenAudit: zero audit ephemeral"); + const shared = ecMul(_auditKey, ea); + + // The nonce is DERIVED from the audit shared secret: an output the auditor + // cannot open cannot exist, and nonce freshness reduces to ephemeral + // freshness. + const nonce = EcdhMask_kdf(shared, pad(32, "OZ:cnt:nonce")); + + // Audit record: value + owner one-time-padded to the audit key. + _auditTrail.pushFront(disclose(AuditRecord { + ephemeralPk: eaPk, + valueCt: (value as Field) + EcdhMask_kdf(shared, pad(32, "OZ:cnt:a:value")), + ownerCt: ownerPk + EcdhMask_kdf(shared, pad(32, "OZ:cnt:a:owner")) + })); + + return nonce; + } + + /** + * @description Auditor-side: recover one output's `(value, nonce, ownerPk)` + * from an AuditRecord using the audit secret scalar (`auditKey = g^auditSk`). + * Pure and off-chain; feeding the result to the token core's `commitOf` / + * `nullifierOf` reconstructs the note's full lifecycle. + */ + export pure circuit recoverAuditRecord(record: AuditRecord, auditSk: Field): AuditView { + const shared = ecMul(record.ephemeralPk, auditSk); + return AuditView { + value: record.valueCt - EcdhMask_kdf(shared, pad(32, "OZ:cnt:a:value")), + nonce: EcdhMask_kdf(shared, pad(32, "OZ:cnt:nonce")), + ownerPk: record.ownerCt - EcdhMask_kdf(shared, pad(32, "OZ:cnt:a:owner")) + }; + } +} diff --git a/contracts/src/token/extensions/ConfidentialNoteTokenDelivery.compact b/contracts/src/token/extensions/ConfidentialNoteTokenDelivery.compact new file mode 100644 index 000000000..77d2b6eba --- /dev/null +++ b/contracts/src/token/extensions/ConfidentialNoteTokenDelivery.compact @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts (token/extensions/ConfidentialNoteTokenDelivery.compact) + +pragma language_version >= 0.23.0; + +/** + * @module ConfidentialNoteTokenDelivery + * @description Optional standalone extension that delivers each output note's + * `(value, nonce)` to its owner's encryption key on-chain, so a recipient + * discovers incoming notes from chain data alone — no out-of-band channel. + * It imports no token module. + * + * Wallet flow: scan `_deliveries`, trial-decrypt each entry with the account's + * encryption secret (`crypto/NoteDelivery.recoverNote`), and keep the notes + * whose recomputed commitment exists in the token core's tree. + * + * @notice Pairs with `ConfidentialNoteToken`. The consuming contract calls + * `_deliver` alongside every `_createNote` whose owner should be able to find + * the note by scanning (skipping it makes the note reachable only out of + * band — the funds still exist, but only the creator knows the nonce). + * + * @dev Identity is two keys per account: the spend key `pk = Hf(sk)` owns + * notes; the encryption key `encPk = g^encSk` (Jubjub) receives deliveries. + * The circuit cannot bind the two — an account hands them out together, and a + * sender who addresses the delivery to the wrong `encPk` only prevents + * discovery, not the note's existence. + * + * @dev `wit_DeliveryRandomness` MUST return a fresh, secret seed per + * invocation; ephemerals expand from it (see `crypto/EcdhMask` freshness + * rules). `slot` domain-separates ephemerals when one transaction emits + * several outputs. + * + * @dev NOT audited, NOT production. + */ +module ConfidentialNoteTokenDelivery { + import CompactStandardLibrary; + import "../../crypto/NoteDelivery" prefix NoteDelivery_; + + // Per-output deliveries, for observation: wallets trial-decrypt this list. + // It is this extension's event substitute. + export ledger _deliveries: List; + + // Randomness seed for delivery ephemerals. MUST be fresh + secret per + // invocation (see module doc). + witness wit_DeliveryRandomness(): Bytes<32>; + + /** + * @description Encrypts one output note's `(value, nonce)` to `encPk` and + * publishes the ciphertext for wallet scanning. + * + * @circuitInfo k=15, rows=23198 + */ + export circuit _deliver(encPk: JubjubPoint, value: Uint<128>, nonce: Field, slot: Bytes<32>): [] { + const ed = degradeToTransient(persistentHash>>( + [wit_DeliveryRandomness(), pad(32, "OZ:cnt:ed"), slot])); + // Only the ciphertext crosses to public state. + _deliveries.pushFront(disclose(NoteDelivery_deliverNote( + encPk, + NoteDelivery_Note { value: value, nonce: nonce }, + ed + ))); + } +} diff --git a/contracts/src/token/extensions/ConfidentialNoteTokenSupply.compact b/contracts/src/token/extensions/ConfidentialNoteTokenSupply.compact new file mode 100644 index 000000000..e7d60dcc1 --- /dev/null +++ b/contracts/src/token/extensions/ConfidentialNoteTokenSupply.compact @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts (token/extensions/ConfidentialNoteTokenSupply.compact) + +pragma language_version >= 0.23.0; + +/** + * @module ConfidentialNoteTokenSupply + * @description Optional standalone extension that adds CONFIDENTIAL supply + * accounting to a `ConfidentialNoteToken`. It imports no token module. + * + * The base token deliberately writes no public supply: a disclosed counter + * would leak every mint and burn amount as a public delta. This extension + * answers "what is the total supply?" without that leak: + * + * - `_encSupply` holds the outstanding supply as an exponential-ElGamal + * ciphertext under `_supplyKey`. `_addMinted` / `_addBurned` update it + * HOMOMORPHICALLY in the same transaction as the token op, so the ciphertext + * is trustlessly the true running total — yet no per-transaction amount is + * ever public. Homomorphic update needs no knowledge of the running + * plaintext, so user-initiated burns can update it too. + * - `attestSupply` lets the supply-key holder (typically the auditor) publish + * a PROOF-BACKED public total at a chosen cadence: the circuit verifies + * in-proof that `_encSupply` decrypts to the claimed total, then disclose + * only that total. Between attestations, individual amounts stay hidden; + * batching gives issuance amounts k-anonymity at the attestation cadence. + * + * @notice Pairs with `ConfidentialNoteToken`. The consuming contract + * composes the pieces, calling the accounting block alongside the matching + * token op: + * + * export circuit mint(recipientPk: Field, recipientEncPk: JubjubPoint, value: Uint<128>): [] { + * HCT_mint(recipientPk, recipientEncPk, value); + * Supply__addMinted(value); + * } + * + * export circuit burn(senderEncPk: JubjubPoint, value: Uint<128>): [] { + * HCT_burn(senderEncPk, value); + * Supply__addBurned(value); + * } + * + * @warning Pair every mint with `_addMinted` and every burn with `_addBurned` + * on every path; mis-wiring is a security-critical, undetectable error (the + * ciphertext would silently drift from the pool's true value and attestation + * would publish a wrong-but-proven total). Correct wiring also guarantees the + * plaintext never underflows on `_addBurned` (a burn never exceeds the + * outstanding supply), which `ElGamal_subEncrypted` cannot check itself. + * + * @dev The supply key is an ElGamal keypair (`_supplyKey = derivePk(ek)`), + * separate from the token's audit key; a deployment may hand both to the same + * auditor. The attester learns the plaintext total off-chain (e.g. by summing + * the token's audit trail) — exponential-ElGamal decryption via discrete log + * is not required. + * + * @dev `wit_SupplyRandomness` MUST return a fresh, secret seed per invocation + * (ElGamal encryption-randomness rules). `_encSupply` starts as the canonical + * `Enc(0)`, which is publicly recognizable until the first update — supply is + * genuinely 0 at that point, so nothing leaks. + * + * @dev NOT audited, NOT production. + */ +module ConfidentialNoteTokenSupply { + import CompactStandardLibrary; + import "../../crypto/ElGamal" prefix ElGamal_; + + export ledger _isInitialized: Boolean; + // ElGamal public key the supply is encrypted under (attester holds the secret). + export ledger _supplyKey: JubjubPoint; + // Outstanding supply, homomorphically maintained. Hides every delta. + export ledger _encSupply: ElGamal_Ciphertext; + // Last publicly attested total and an attestation counter for indexers. + export ledger _attestedSupply: Uint<128>; + export ledger _attestationCount: Uint<64>; + + // Randomness seed for encryption updates. MUST be fresh + secret per + // invocation (see module doc). + witness wit_SupplyRandomness(): Bytes<32>; + // The supply-key secret (only `attestSupply` consumes it). + witness wit_SupplyKeySecret(): Bytes<32>; + + /** + * @description One-shot initialization: binds the supply key and starts the + * encrypted supply at the canonical `Enc(0)`. + * + * Requirements: + * + * - Extension is not already initialized. + * - `supplyKey` is not the identity point. + * + * @circuitInfo k=11, rows=1167 + */ + export circuit initialize(supplyKey: JubjubPoint): [] { + assert(!_isInitialized, "ConfidentialNoteTokenSupply: already initialized"); + assert(supplyKey != ecMulGenerator(0 as Field), + "ConfidentialNoteTokenSupply: identity supply key"); + _supplyKey = disclose(supplyKey); + _encSupply = ElGamal_encryptZero(); + _isInitialized = true; + } + + /** + * @description Homomorphically adds a minted `value` to the encrypted + * supply. Call once, with the exact minted amount, alongside every token + * mint. The public ledger sees only a re-randomized ciphertext. + * + * Requirements: + * + * - Extension is initialized. + * + * @circuitInfo k=13, rows=6569 + */ + export circuit _addMinted(value: Uint<128>): [] { + assertInitialized(); + const r = ElGamal_expandRandomness(wit_SupplyRandomness(), pad(32, "OZ:cnt:supply:add")); + _encSupply = disclose(ElGamal_addEncrypted(_encSupply, _supplyKey, value, r)); + } + + /** + * @description Homomorphically subtracts a burned `value` from the encrypted + * supply. Call once, with the exact burned amount, alongside every token + * burn. Correct pairing with the token op guarantees no plaintext underflow + * (see the mis-wiring warning). + * + * Requirements: + * + * - Extension is initialized. + * + * @circuitInfo k=13, rows=7683 + */ + export circuit _addBurned(value: Uint<128>): [] { + assertInitialized(); + const r = ElGamal_expandRandomness(wit_SupplyRandomness(), pad(32, "OZ:cnt:supply:sub")); + _encSupply = disclose(ElGamal_subEncrypted(_encSupply, _supplyKey, value, r)); + } + + /** + * @description Publishes a proof-backed public supply total: verifies + * in-circuit that `_encSupply` decrypts to `total` under the supply key, + * then disclose only the total. Run at a chosen cadence (e.g. daily) for + * public, non-inflatable supply while per-transaction amounts stay hidden. + * + * Requirements: + * + * - Extension is initialized. + * - The caller proves the supply-key secret (`derivePk(secret) == _supplyKey`). + * - `_encSupply` decrypts to `total`. + * + * @circuitInfo k=13, rows=4720 + */ + export circuit attestSupply(total: Uint<128>): [] { + assertInitialized(); + ElGamal_assertDecryptsTo(_encSupply, _supplyKey, wit_SupplyKeySecret(), total); + // Only the attested total crosses to public state; the per-transaction + // deltas behind it stay encrypted. + _attestedSupply = disclose(total); + _attestationCount = disclose(_attestationCount + 1 as Uint<64>); + } + + circuit assertInitialized(): [] { + assert(_isInitialized, "ConfidentialNoteTokenSupply: extension not initialized"); + } +} diff --git a/contracts/src/token/presets/RegulatedConfidentialNoteToken.compact b/contracts/src/token/presets/RegulatedConfidentialNoteToken.compact new file mode 100644 index 000000000..992ba2b1b --- /dev/null +++ b/contracts/src/token/presets/RegulatedConfidentialNoteToken.compact @@ -0,0 +1,249 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts (token/presets/RegulatedConfidentialNoteToken.compact) + +pragma language_version >= 0.23.0; + +/** + * @contract RegulatedConfidentialNoteToken + * @description DRAFT plug-and-play contract: the tier-4 confidential note + * token wired for a regulated deployment. Deploy it as-is — the constructor + * binds every role key; there is no separate initialization step. + * + * Guarantees: full graph privacy for users (amounts — including issuance and + * burns — senders, and recipients hidden), complete visibility for a + * designated auditor, escrow-free seizure for a designated authority, and + * confidential-but-attestable supply. + * + * Composition (each piece is independently reusable): + * + * - `ConfidentialNoteToken` — the token core (conservation, single-spend, + * issuer gate); this preset drives its `_`-building blocks so output nonces + * come from the audit channel instead of the core default, + * - `extensions/ConfidentialNoteTokenAudit` — mandatory auditor viewing; each + * output nonce derives from the audit ECDH, so every note this contract + * creates is auditor-recoverable BY CONSTRUCTION, + * - `extensions/ConfidentialNoteTokenDelivery` — on-chain note delivery, so + * recipients discover funds from chain data alone (no out-of-band channel), + * - `extensions/ConfidentialNoteTokenSupply` — homomorphic encrypted supply + * with proof-backed public attestation. + * + * Roles: the ISSUER may mint, the AUTHORITY may seize, the AUDIT key reads + * everything (never spends), the SUPPLY key attests totals. All four bind at + * deploy time. + * + * Seizure needs no key escrow: the core nullifier depends only on the nonce, + * so the owner and the authority derive the SAME nullifier, making owner-spend + * and seizure mutually exclusive (first to land wins). The authority learns + * the target note from the audit trail and re-mints its value to a recovery + * owner, itself audited and delivered. + * + * What the public sees: commitment inserts, nullifiers, ciphertexts, the + * seizure counter, and attested supply totals. Amounts, senders, and + * recipients stay hidden. + * + * See `token/docs/confidential-note-token.md` for the full design and the + * auditor/compliance rationale. + * + * @dev The randomness witnesses (`wit_AuditRandomness`, + * `wit_DeliveryRandomness`, `wit_SupplyRandomness`) MUST each return a fresh, + * secret seed per invocation (see `crypto/EcdhMask` freshness rules). + * + * @dev NOT audited, NOT production. + */ + +import CompactStandardLibrary; +import "../ConfidentialNoteToken" prefix CNT_; +import "../extensions/ConfidentialNoteTokenAudit" prefix Audit_; +import "../extensions/ConfidentialNoteTokenDelivery" prefix Delivery_; +import "../extensions/ConfidentialNoteTokenSupply" prefix Supply_; +import "../../crypto/NoteDelivery" prefix NoteDelivery_; + +// Surface the composed modules' observable state under stable names, for +// wallets (commitment tree, deliveries), auditors (audit trail), and +// indexers (supply): a prefix-only import would keep it out of the generated +// ledger reader. +import { + _commitments, + _nullifiers +} from "../ConfidentialNoteToken"; +import { _auditKey, _auditTrail } from "../extensions/ConfidentialNoteTokenAudit"; +import { _deliveries } from "../extensions/ConfidentialNoteTokenDelivery"; +import { + _supplyKey, + _encSupply, + _attestedSupply, + _attestationCount +} from "../extensions/ConfidentialNoteTokenSupply"; +export { + _commitments, + _nullifiers, + _auditKey, + _auditTrail, + _deliveries, + _supplyKey, + _encSupply, + _attestedSupply, + _attestationCount +}; + +export { CNT_Note, Audit_AuditRecord, Audit_AuditView, NoteDelivery_FullDelivery } + +// Global seizure authority (`Hf(authoritySecret)`; governance-gated in a real +// deployment) and an auditable count of seizures performed. +export ledger _authorityPk: Field; +export ledger _seizureCount: Uint<64>; + +// The seizure authority's secret (authorityPk = Hf(authoritySecret)). +witness wit_AuthoritySecret(): Bytes<32>; + +/** + * @description Binds all four roles at deploy time: the issuer (may mint), + * the seizure authority (may claw back), the audit key (decrypt-only + * viewing), and the supply key (attestation). + */ +constructor( + issuerPk: Field, + authorityPk: Field, + auditKey: JubjubPoint, + supplyKey: JubjubPoint +) { + CNT_initialize(issuerPk); + Audit_initialize(auditKey); + Supply_initialize(supplyKey); + _authorityPk = disclose(authorityPk); +} + +/** + * @description Mints a note of `value` to `recipientPk`, audited and + * delivered. The minted amount is NOT written to public state — issuance + * stays hidden (unlike native shielded tokens, whose `shieldedMints` effect + * publishes it); the encrypted supply absorbs it homomorphically and the + * auditor reads it from the audit record. + * + * Requirements: + * + * - The caller proves the issuer secret. + * + * @circuitInfo k=17, rows=69322 + */ +export circuit mint(recipientPk: Field, recipientEncPk: JubjubPoint, value: Uint<128>): [] { + CNT__assertIssuer(); + const note = emitOutput(recipientPk, recipientEncPk, value, pad(32, "OZ:cnt:out")); + CNT__mint(note, recipientPk); + Supply__addMinted(value); +} + +/** + * @description Fully-private transfer: consumes the caller's input note and + * creates a recipient note of `value` plus a change note back to the sender, + * conserving value — both audited and delivered. Sender and recipient are + * hidden; the public ledger gains one nullifier, two commitments, and their + * ciphertexts. + * + * Requirements: + * + * - The input note is committed in the tree and unspent. + * - `value <= input.value`. + * + * @circuitInfo k=18, rows=135775 + */ +export circuit transfer( + recipientPk: Field, + recipientEncPk: JubjubPoint, + senderEncPk: JubjubPoint, + value: Uint<128> +): [] { + const pk = CNT__spenderPk(); + + // Peek at the input to size the change; the core re-reads the same witness + // and enforces conservation against it. + const input = CNT__inputNote(); + assert(input.value >= value, + "RegulatedConfidentialNoteToken: insufficient note value"); + const changeValue = (input.value - value) as Uint<128>; + + const outNote = emitOutput(recipientPk, recipientEncPk, value, pad(32, "OZ:cnt:out")); + const changeNote = emitOutput(pk, senderEncPk, changeValue, pad(32, "OZ:cnt:chg")); + CNT__transfer(pk, recipientPk, outNote, changeNote); +} + +/** + * @description Burns `value` from the caller's input note: consumes the note + * and re-issues only the change (audited and delivered), so `value` leaves + * circulation. Both the burned amount and the burner stay hidden; publicly a + * burn looks like any other spend. The encrypted supply absorbs the decrease. + * + * Requirements: + * + * - The input note is committed in the tree and unspent. + * - `value <= input.value`. + * + * @circuitInfo k=17, rows=82803 + */ +export circuit burn(senderEncPk: JubjubPoint, value: Uint<128>): [] { + const pk = CNT__spenderPk(); + + const input = CNT__inputNote(); + assert(input.value >= value, + "RegulatedConfidentialNoteToken: insufficient note value"); + const changeValue = (input.value - value) as Uint<128>; + + const changeNote = emitOutput(pk, senderEncPk, changeValue, pad(32, "OZ:cnt:chg")); + CNT__burn(pk, value, changeNote); + Supply__addBurned(value); +} + +/** + * @description Regulated clawback: the authority consumes a target note it + * learned from the audit trail (supplied as the core's input-note witness) + * and re-mints the full value to `recoveryPk`, with the recovery note itself + * audited + delivered. Owner-spend and seizure race on the SAME nullifier, so + * they are mutually exclusive; the authority never needs the owner's spend + * secret. Value is conserved, and `_seizureCount` records the action + * publicly. + * + * In production the authority key is governance-gated (multisig), and + * per-user recovery keys would replace this single global key for least + * privilege. + * + * Requirements: + * + * - The caller proves the authority secret (`Hf(secret) == _authorityPk`). + * - The target note (owner pk + value + nonce) is committed and unspent. + * + * @circuitInfo k=17, rows=75043 + */ +export circuit seize(targetOwnerPk: Field, recoveryPk: Field, recoveryEncPk: JubjubPoint): [] { + assert(CNT_derivePk(wit_AuthoritySecret()) == _authorityPk, + "RegulatedConfidentialNoteToken: not the authority"); + + const target = CNT__consumeNote(targetOwnerPk); + const recoveryNote = emitOutput(recoveryPk, recoveryEncPk, target.value, pad(32, "OZ:cnt:out")); + CNT__mint(recoveryNote, recoveryPk); + + _seizureCount = disclose(_seizureCount + 1 as Uint<64>); +} + +/** + * @description Publishes a proof-backed public supply total (see + * `ConfidentialNoteTokenSupply`). Run at a chosen cadence for public, + * non-inflatable supply while per-transaction amounts stay hidden. + * + * Requirements: + * + * - The caller proves the supply-key secret and the exact total. + * + * @circuitInfo k=13, rows=4720 + */ +export circuit attestSupply(total: Uint<128>): [] { + Supply_attestSupply(total); +} + +// Emission policy for one output note: the audit record derives the nonce, +// the delivery makes the note discoverable. Returns the note for the core to +// commit. +circuit emitOutput(ownerPk: Field, encPk: JubjubPoint, value: Uint<128>, slot: Bytes<32>): CNT_Note { + const nonce = Audit__emitAuditedOutput(ownerPk, value, slot); + Delivery__deliver(encPk, value, nonce, slot); + return CNT_Note { value: value, nonce: nonce }; +} diff --git a/contracts/src/token/test/mocks/MockConfidentialNoteToken.compact b/contracts/src/token/test/mocks/MockConfidentialNoteToken.compact new file mode 100644 index 000000000..952afdf9d --- /dev/null +++ b/contracts/src/token/test/mocks/MockConfidentialNoteToken.compact @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: MIT + +// WARNING: FOR TESTING PURPOSES ONLY. + +pragma language_version >= 0.23.0; + +import CompactStandardLibrary; +import "../../ConfidentialNoteToken" prefix CNT_; + +export { CNT_Note } + +export { CNT__isInitialized, CNT__issuerPk, CNT__commitments, CNT__nullifiers }; + +export circuit initialize(issuerPk: Field): [] { + return CNT_initialize(issuerPk); +} + +export circuit mint(recipientPk: Field, value: Uint<128>): CNT_Note { + return CNT_mint(recipientPk, value); +} + +export circuit transfer(recipientPk: Field, value: Uint<128>): [CNT_Note, CNT_Note] { + return CNT_transfer(recipientPk, value); +} + +export circuit burn(value: Uint<128>): CNT_Note { + return CNT_burn(value); +} + +// Building blocks, exposed for composition-level tests. + +export circuit _spenderPk(): Field { + // Exported-boundary marker: returns only to the local caller. + return disclose(CNT__spenderPk()); +} + +export circuit _assertIssuer(): [] { + return CNT__assertIssuer(); +} + +export circuit _inputNote(): CNT_Note { + // Exported-boundary marker: returns only to the local caller. + return disclose(CNT__inputNote()); +} + +export circuit _mint(note: CNT_Note, ownerPk: Field): [] { + return CNT__mint(note, ownerPk); +} + +export circuit _transfer(spenderPk: Field, recipientPk: Field, outNote: CNT_Note, changeNote: CNT_Note): [] { + return CNT__transfer(spenderPk, recipientPk, outNote, changeNote); +} + +export circuit _burn(spenderPk: Field, value: Uint<128>, changeNote: CNT_Note): [] { + return CNT__burn(spenderPk, value, changeNote); +} + +export circuit _consumeNote(ownerPk: Field): CNT_Note { + // Exported-boundary marker: the consumed note returns only to the local + // caller (who supplied it as a witness in the first place). + return disclose(CNT__consumeNote(ownerPk)); +} + +export pure circuit derivePk(sk: Bytes<32>): Field { + return CNT_derivePk(sk); +} + +export pure circuit commitOf(note: CNT_Note, pk: Field): Bytes<32> { + return CNT_commitOf(note, pk); +} + +export pure circuit nullifierOf(note: CNT_Note): Bytes<32> { + return CNT_nullifierOf(note); +} diff --git a/contracts/src/token/test/mocks/MockConfidentialNoteTokenAudit.compact b/contracts/src/token/test/mocks/MockConfidentialNoteTokenAudit.compact new file mode 100644 index 000000000..204d3891c --- /dev/null +++ b/contracts/src/token/test/mocks/MockConfidentialNoteTokenAudit.compact @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MIT + +// WARNING: FOR TESTING PURPOSES ONLY. + +pragma language_version >= 0.23.0; + +import CompactStandardLibrary; +import "../../extensions/ConfidentialNoteTokenAudit" prefix Audit_; + +export { Audit_AuditRecord, Audit_AuditView } + +export { Audit__isInitialized, Audit__auditKey, Audit__auditTrail }; + +export circuit initialize(auditKey: JubjubPoint): [] { + return Audit_initialize(auditKey); +} + +export circuit _emitAuditedOutput(ownerPk: Field, value: Uint<128>, slot: Bytes<32>): Field { + // Exported-boundary marker: the derived nonce returns only to the local + // caller, which in a real composition commits it in the same proof. + return disclose(Audit__emitAuditedOutput(ownerPk, value, slot)); +} + +export pure circuit recoverAuditRecord(record: Audit_AuditRecord, auditSk: Field): Audit_AuditView { + return Audit_recoverAuditRecord(record, auditSk); +} diff --git a/contracts/src/token/test/mocks/MockConfidentialNoteTokenDelivery.compact b/contracts/src/token/test/mocks/MockConfidentialNoteTokenDelivery.compact new file mode 100644 index 000000000..d310f90e2 --- /dev/null +++ b/contracts/src/token/test/mocks/MockConfidentialNoteTokenDelivery.compact @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: MIT + +// WARNING: FOR TESTING PURPOSES ONLY. + +pragma language_version >= 0.23.0; + +import CompactStandardLibrary; +import "../../extensions/ConfidentialNoteTokenDelivery" prefix Delivery_; +import "../../../crypto/NoteDelivery" prefix NoteDelivery_; + +export { NoteDelivery_FullDelivery, NoteDelivery_Recovered } + +export { Delivery__deliveries }; + +export circuit _deliver(encPk: JubjubPoint, value: Uint<128>, nonce: Field, slot: Bytes<32>): [] { + return Delivery__deliver(encPk, value, nonce, slot); +} + +export pure circuit recoverNote(delivery: NoteDelivery_FullDelivery, encSk: Field): NoteDelivery_Recovered { + return NoteDelivery_recoverNote(delivery, encSk); +} diff --git a/contracts/src/token/test/mocks/MockConfidentialNoteTokenSupply.compact b/contracts/src/token/test/mocks/MockConfidentialNoteTokenSupply.compact new file mode 100644 index 000000000..95fa862e9 --- /dev/null +++ b/contracts/src/token/test/mocks/MockConfidentialNoteTokenSupply.compact @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: MIT + +// WARNING: FOR TESTING PURPOSES ONLY. + +pragma language_version >= 0.23.0; + +import CompactStandardLibrary; +import "../../extensions/ConfidentialNoteTokenSupply" prefix Supply_; +import "../../../crypto/ElGamal" prefix ElGamal_; + +export { ElGamal_Ciphertext } + +export { + Supply__isInitialized, + Supply__supplyKey, + Supply__encSupply, + Supply__attestedSupply, + Supply__attestationCount +}; + +export circuit initialize(supplyKey: JubjubPoint): [] { + return Supply_initialize(supplyKey); +} + +export circuit _addMinted(value: Uint<128>): [] { + return Supply__addMinted(value); +} + +export circuit _addBurned(value: Uint<128>): [] { + return Supply__addBurned(value); +} + +export circuit attestSupply(total: Uint<128>): [] { + return Supply_attestSupply(total); +} + +// Off-chain helper surfaced for tests: derive the supply public key from its +// secret the way the extension's attestation does. +export pure circuit derivePk(ek: Bytes<32>): JubjubPoint { + return ElGamal_derivePk(ek); +} diff --git a/contracts/src/token/test/simulators/ConfidentialNoteTokenAuditSimulator.ts b/contracts/src/token/test/simulators/ConfidentialNoteTokenAuditSimulator.ts new file mode 100644 index 000000000..ff65953c6 --- /dev/null +++ b/contracts/src/token/test/simulators/ConfidentialNoteTokenAuditSimulator.ts @@ -0,0 +1,67 @@ +import type { JubjubPoint } from '@midnight-ntwrk/compact-runtime'; +import { + createSimulator, + type SimulatorOptions, +} from '@openzeppelin/compact-simulator'; +import { + ledger, + Contract as MockAudit, +} from '../../../../artifacts/MockConfidentialNoteTokenAudit/contract/index.js'; +import { + type ConfidentialNoteTokenAuditPrivateState, + ConfidentialNoteTokenAuditWitnesses, + ConfidentialNoteTokenAuditPrivateState as PrivateState, +} from '../witnesses/ConfidentialNoteTokenAuditWitnesses.js'; + +const ConfidentialNoteTokenAuditSimulatorBase = createSimulator< + ConfidentialNoteTokenAuditPrivateState, + ReturnType, + ReturnType, + MockAudit, + readonly [] +>({ + contractFactory: (witnesses) => + new MockAudit(witnesses), + defaultPrivateState: () => PrivateState.generate(), + contractArgs: () => [], + ledgerExtractor: (state) => ledger(state), + witnessesFactory: () => ConfidentialNoteTokenAuditWitnesses(), + artifactName: 'MockConfidentialNoteTokenAudit', +}); + +export class ConfidentialNoteTokenAuditSimulator extends ConfidentialNoteTokenAuditSimulatorBase { + static async create( + options: SimulatorOptions< + ConfidentialNoteTokenAuditPrivateState, + ReturnType + > = {}, + ): Promise { + // biome-ignore lint/complexity/noThisInStatic: super.create keeps subclass `this` + return super.create( + [], + options, + ) as Promise; + } + + public initialize(auditKey: JubjubPoint): Promise<[]> { + return this.circuits.impure.initialize(auditKey); + } + + public emitAuditedOutput( + ownerPk: bigint, + value: bigint, + slot: Uint8Array, + ): Promise { + return this.circuits.impure._emitAuditedOutput(ownerPk, value, slot); + } + + public readonly privateState = { + set: async ( + partial: Partial, + ): Promise => { + const updated = { ...(await this.getPrivateState()), ...partial }; + this.setPrivateState(updated); + return updated; + }, + }; +} diff --git a/contracts/src/token/test/simulators/ConfidentialNoteTokenDeliverySimulator.ts b/contracts/src/token/test/simulators/ConfidentialNoteTokenDeliverySimulator.ts new file mode 100644 index 000000000..d550e3e1e --- /dev/null +++ b/contracts/src/token/test/simulators/ConfidentialNoteTokenDeliverySimulator.ts @@ -0,0 +1,54 @@ +import type { JubjubPoint } from '@midnight-ntwrk/compact-runtime'; +import { + createSimulator, + type SimulatorOptions, +} from '@openzeppelin/compact-simulator'; +import { + ledger, + Contract as MockDelivery, +} from '../../../../artifacts/MockConfidentialNoteTokenDelivery/contract/index.js'; +import { + type ConfidentialNoteTokenDeliveryPrivateState, + ConfidentialNoteTokenDeliveryWitnesses, + ConfidentialNoteTokenDeliveryPrivateState as PrivateState, +} from '../witnesses/ConfidentialNoteTokenDeliveryWitnesses.js'; + +const ConfidentialNoteTokenDeliverySimulatorBase = createSimulator< + ConfidentialNoteTokenDeliveryPrivateState, + ReturnType, + ReturnType, + MockDelivery, + readonly [] +>({ + contractFactory: (witnesses) => + new MockDelivery(witnesses), + defaultPrivateState: () => PrivateState.generate(), + contractArgs: () => [], + ledgerExtractor: (state) => ledger(state), + witnessesFactory: () => ConfidentialNoteTokenDeliveryWitnesses(), + artifactName: 'MockConfidentialNoteTokenDelivery', +}); + +export class ConfidentialNoteTokenDeliverySimulator extends ConfidentialNoteTokenDeliverySimulatorBase { + static async create( + options: SimulatorOptions< + ConfidentialNoteTokenDeliveryPrivateState, + ReturnType + > = {}, + ): Promise { + // biome-ignore lint/complexity/noThisInStatic: super.create keeps subclass `this` + return super.create( + [], + options, + ) as Promise; + } + + public deliver( + encPk: JubjubPoint, + value: bigint, + nonce: bigint, + slot: Uint8Array, + ): Promise<[]> { + return this.circuits.impure._deliver(encPk, value, nonce, slot); + } +} diff --git a/contracts/src/token/test/simulators/ConfidentialNoteTokenSimulator.ts b/contracts/src/token/test/simulators/ConfidentialNoteTokenSimulator.ts new file mode 100644 index 000000000..0663aabd7 --- /dev/null +++ b/contracts/src/token/test/simulators/ConfidentialNoteTokenSimulator.ts @@ -0,0 +1,73 @@ +import { + createSimulator, + type SimulatorOptions, +} from '@openzeppelin/compact-simulator'; +import { + ledger, + Contract as MockCNT, +} from '../../../../artifacts/MockConfidentialNoteToken/contract/index.js'; +import { + type ConfidentialNoteTokenPrivateState, + ConfidentialNoteTokenWitnesses, + type Note, + ConfidentialNoteTokenPrivateState as PrivateState, +} from '../witnesses/ConfidentialNoteTokenWitnesses.js'; + +const ConfidentialNoteTokenSimulatorBase = createSimulator< + ConfidentialNoteTokenPrivateState, + ReturnType, + ReturnType, + MockCNT, + readonly [] +>({ + contractFactory: (witnesses) => + new MockCNT(witnesses), + defaultPrivateState: () => PrivateState.generate(), + contractArgs: () => [], + ledgerExtractor: (state) => ledger(state), + witnessesFactory: () => ConfidentialNoteTokenWitnesses(), + artifactName: 'MockConfidentialNoteToken', +}); + +export class ConfidentialNoteTokenSimulator extends ConfidentialNoteTokenSimulatorBase { + static async create( + options: SimulatorOptions< + ConfidentialNoteTokenPrivateState, + ReturnType + > = {}, + ): Promise { + // biome-ignore lint/complexity/noThisInStatic: super.create keeps subclass `this` + return super.create([], options) as Promise; + } + + public initialize(issuerPk: bigint): Promise<[]> { + return this.circuits.impure.initialize(issuerPk); + } + + public mint(recipientPk: bigint, value: bigint): Promise { + return this.circuits.impure.mint(recipientPk, value); + } + + public transfer(recipientPk: bigint, value: bigint): Promise<[Note, Note]> { + return this.circuits.impure.transfer(recipientPk, value); + } + + public burn(value: bigint): Promise { + return this.circuits.impure.burn(value); + } + + public consumeNote(ownerPk: bigint): Promise { + return this.circuits.impure._consumeNote(ownerPk); + } + + public readonly privateState = { + // Configure the caller's identity and the note being spent next. + set: async ( + partial: Partial, + ): Promise => { + const updated = { ...(await this.getPrivateState()), ...partial }; + this.setPrivateState(updated); + return updated; + }, + }; +} diff --git a/contracts/src/token/test/simulators/ConfidentialNoteTokenSupplySimulator.ts b/contracts/src/token/test/simulators/ConfidentialNoteTokenSupplySimulator.ts new file mode 100644 index 000000000..5a233fc61 --- /dev/null +++ b/contracts/src/token/test/simulators/ConfidentialNoteTokenSupplySimulator.ts @@ -0,0 +1,71 @@ +import type { JubjubPoint } from '@midnight-ntwrk/compact-runtime'; +import { + createSimulator, + type SimulatorOptions, +} from '@openzeppelin/compact-simulator'; +import { + ledger, + Contract as MockSupply, +} from '../../../../artifacts/MockConfidentialNoteTokenSupply/contract/index.js'; +import { + type ConfidentialNoteTokenSupplyPrivateState, + ConfidentialNoteTokenSupplyWitnesses, + ConfidentialNoteTokenSupplyPrivateState as PrivateState, +} from '../witnesses/ConfidentialNoteTokenSupplyWitnesses.js'; + +const ConfidentialNoteTokenSupplySimulatorBase = createSimulator< + ConfidentialNoteTokenSupplyPrivateState, + ReturnType, + ReturnType, + MockSupply, + readonly [] +>({ + contractFactory: (witnesses) => + new MockSupply(witnesses), + defaultPrivateState: () => PrivateState.generate(), + contractArgs: () => [], + ledgerExtractor: (state) => ledger(state), + witnessesFactory: () => ConfidentialNoteTokenSupplyWitnesses(), + artifactName: 'MockConfidentialNoteTokenSupply', +}); + +export class ConfidentialNoteTokenSupplySimulator extends ConfidentialNoteTokenSupplySimulatorBase { + static async create( + options: SimulatorOptions< + ConfidentialNoteTokenSupplyPrivateState, + ReturnType + > = {}, + ): Promise { + // biome-ignore lint/complexity/noThisInStatic: super.create keeps subclass `this` + return super.create( + [], + options, + ) as Promise; + } + + public initialize(supplyKey: JubjubPoint): Promise<[]> { + return this.circuits.impure.initialize(supplyKey); + } + + public addMinted(value: bigint): Promise<[]> { + return this.circuits.impure._addMinted(value); + } + + public addBurned(value: bigint): Promise<[]> { + return this.circuits.impure._addBurned(value); + } + + public attestSupply(total: bigint): Promise<[]> { + return this.circuits.impure.attestSupply(total); + } + + public readonly privateState = { + set: async ( + partial: Partial, + ): Promise => { + const updated = { ...(await this.getPrivateState()), ...partial }; + this.setPrivateState(updated); + return updated; + }, + }; +} diff --git a/contracts/src/token/test/simulators/RegulatedConfidentialNoteTokenSimulator.ts b/contracts/src/token/test/simulators/RegulatedConfidentialNoteTokenSimulator.ts new file mode 100644 index 000000000..c8d731c33 --- /dev/null +++ b/contracts/src/token/test/simulators/RegulatedConfidentialNoteTokenSimulator.ts @@ -0,0 +1,115 @@ +import type { JubjubPoint } from '@midnight-ntwrk/compact-runtime'; +import { + createSimulator, + type SimulatorOptions, +} from '@openzeppelin/compact-simulator'; +import { + ledger, + Contract as RegulatedCNT, +} from '../../../../artifacts/RegulatedConfidentialNoteToken/contract/index.js'; +import { + type Note, + RegulatedConfidentialNoteTokenPrivateState as PrivateState, + type RegulatedConfidentialNoteTokenPrivateState, + RegulatedConfidentialNoteTokenWitnesses, +} from '../witnesses/RegulatedConfidentialNoteTokenWitnesses.js'; + +type RegulatedConfidentialNoteTokenArgs = readonly [ + issuerPk: bigint, + authorityPk: bigint, + auditKey: JubjubPoint, + supplyKey: JubjubPoint, +]; + +const RegulatedConfidentialNoteTokenSimulatorBase = createSimulator< + RegulatedConfidentialNoteTokenPrivateState, + ReturnType, + ReturnType, + RegulatedCNT, + RegulatedConfidentialNoteTokenArgs +>({ + contractFactory: (witnesses) => + new RegulatedCNT(witnesses), + defaultPrivateState: () => PrivateState.generate(), + contractArgs: (issuerPk, authorityPk, auditKey, supplyKey) => [ + issuerPk, + authorityPk, + auditKey, + supplyKey, + ], + ledgerExtractor: (state) => ledger(state), + witnessesFactory: () => RegulatedConfidentialNoteTokenWitnesses(), + artifactName: 'RegulatedConfidentialNoteToken', +}); + +export class RegulatedConfidentialNoteTokenSimulator extends RegulatedConfidentialNoteTokenSimulatorBase { + static async create( + issuerPk: bigint, + authorityPk: bigint, + auditKey: JubjubPoint, + supplyKey: JubjubPoint, + options: SimulatorOptions< + RegulatedConfidentialNoteTokenPrivateState, + ReturnType + > = {}, + ): Promise { + // biome-ignore lint/complexity/noThisInStatic: super.create keeps subclass `this` + return super.create( + [issuerPk, authorityPk, auditKey, supplyKey], + options, + ) as Promise; + } + + public mint( + recipientPk: bigint, + recipientEncPk: JubjubPoint, + value: bigint, + ): Promise<[]> { + return this.circuits.impure.mint(recipientPk, recipientEncPk, value); + } + + public transfer( + recipientPk: bigint, + recipientEncPk: JubjubPoint, + senderEncPk: JubjubPoint, + value: bigint, + ): Promise<[]> { + return this.circuits.impure.transfer( + recipientPk, + recipientEncPk, + senderEncPk, + value, + ); + } + + public burn(senderEncPk: JubjubPoint, value: bigint): Promise<[]> { + return this.circuits.impure.burn(senderEncPk, value); + } + + public seize( + targetOwnerPk: bigint, + recoveryPk: bigint, + recoveryEncPk: JubjubPoint, + ): Promise<[]> { + return this.circuits.impure.seize(targetOwnerPk, recoveryPk, recoveryEncPk); + } + + public attestSupply(total: bigint): Promise<[]> { + return this.circuits.impure.attestSupply(total); + } + + public readonly privateState = { + // Configure the caller's identity and the note being spent next. + set: async ( + partial: Partial, + ): Promise => { + const updated = { ...(await this.getPrivateState()), ...partial }; + this.setPrivateState(updated); + return updated; + }, + }; + + public setInputNote(note: Note) { + return this.privateState.set({ inputNote: note }); + } +} diff --git a/contracts/src/token/test/witnesses/ConfidentialNoteTokenAuditWitnesses.ts b/contracts/src/token/test/witnesses/ConfidentialNoteTokenAuditWitnesses.ts new file mode 100644 index 000000000..6855f518c --- /dev/null +++ b/contracts/src/token/test/witnesses/ConfidentialNoteTokenAuditWitnesses.ts @@ -0,0 +1,37 @@ +// TEST-ONLY WITNESS. NOT FOR PRODUCTION USE. +// Drives ConfidentialNoteTokenAudit (auditor viewing) circuits in off-chain +// tests. + +import { getRandomValues } from 'node:crypto'; +import type { WitnessContext } from '@midnight-ntwrk/compact-runtime'; +import type { Ledger } from '../../../../artifacts/MockConfidentialNoteTokenAudit/contract/index.js'; + +export type ConfidentialNoteTokenAuditPrivateState = { + /** + * Optional fixed randomness seed. Leave undefined for the production-correct + * behavior (a fresh secret seed per witness call); set it only in tests that + * need deterministic ephemerals. + */ + randomnessSeed?: Uint8Array; +}; + +export const ConfidentialNoteTokenAuditPrivateState = { + generate: (): ConfidentialNoteTokenAuditPrivateState => ({}), +}; + +export interface IConfidentialNoteTokenAuditWitnesses

{ + wit_AuditRandomness(context: WitnessContext): [P, Uint8Array]; +} + +export const ConfidentialNoteTokenAuditWitnesses = + (): IConfidentialNoteTokenAuditWitnesses => ({ + // Fresh + secret per call, as the extension requires; a fixed seed is only + // honored when a test explicitly plants one. + wit_AuditRandomness(context) { + return [ + context.privateState, + context.privateState.randomnessSeed ?? + new Uint8Array(getRandomValues(Buffer.alloc(32))), + ]; + }, + }); diff --git a/contracts/src/token/test/witnesses/ConfidentialNoteTokenDeliveryWitnesses.ts b/contracts/src/token/test/witnesses/ConfidentialNoteTokenDeliveryWitnesses.ts new file mode 100644 index 000000000..59e24fb5a --- /dev/null +++ b/contracts/src/token/test/witnesses/ConfidentialNoteTokenDeliveryWitnesses.ts @@ -0,0 +1,36 @@ +// TEST-ONLY WITNESS. NOT FOR PRODUCTION USE. +// Drives ConfidentialNoteTokenDelivery (note delivery) circuits in off-chain +// tests. + +import { getRandomValues } from 'node:crypto'; +import type { WitnessContext } from '@midnight-ntwrk/compact-runtime'; +import type { Ledger } from '../../../../artifacts/MockConfidentialNoteTokenDelivery/contract/index.js'; + +export type ConfidentialNoteTokenDeliveryPrivateState = { + /** + * Optional fixed randomness seed. Leave undefined for the production-correct + * behavior (a fresh secret seed per witness call). + */ + randomnessSeed?: Uint8Array; +}; + +export const ConfidentialNoteTokenDeliveryPrivateState = { + generate: (): ConfidentialNoteTokenDeliveryPrivateState => ({}), +}; + +export interface IConfidentialNoteTokenDeliveryWitnesses

{ + wit_DeliveryRandomness(context: WitnessContext): [P, Uint8Array]; +} + +export const ConfidentialNoteTokenDeliveryWitnesses = + (): IConfidentialNoteTokenDeliveryWitnesses => ({ + // Fresh + secret per call, as the extension requires; a fixed seed is only + // honored when a test explicitly plants one. + wit_DeliveryRandomness(context) { + return [ + context.privateState, + context.privateState.randomnessSeed ?? + new Uint8Array(getRandomValues(Buffer.alloc(32))), + ]; + }, + }); diff --git a/contracts/src/token/test/witnesses/ConfidentialNoteTokenSupplyWitnesses.ts b/contracts/src/token/test/witnesses/ConfidentialNoteTokenSupplyWitnesses.ts new file mode 100644 index 000000000..2933c60c8 --- /dev/null +++ b/contracts/src/token/test/witnesses/ConfidentialNoteTokenSupplyWitnesses.ts @@ -0,0 +1,44 @@ +// TEST-ONLY WITNESS. NOT FOR PRODUCTION USE. +// Drives ConfidentialNoteTokenSupply (confidential supply) circuits in +// off-chain tests. + +import { getRandomValues } from 'node:crypto'; +import type { WitnessContext } from '@midnight-ntwrk/compact-runtime'; +import type { Ledger } from '../../../../artifacts/MockConfidentialNoteTokenSupply/contract/index.js'; + +export type ConfidentialNoteTokenSupplyPrivateState = { + /** Supply-key secret (supplyKey = derivePk(secret)); consumed by attestSupply. */ + supplyKeySecret: Uint8Array; + /** + * Optional fixed randomness seed. Leave undefined for the production-correct + * behavior (a fresh secret seed per witness call). + */ + randomnessSeed?: Uint8Array; +}; + +export const ConfidentialNoteTokenSupplyPrivateState = { + generate: (): ConfidentialNoteTokenSupplyPrivateState => ({ + supplyKeySecret: new Uint8Array(getRandomValues(Buffer.alloc(32))), + }), +}; + +export interface IConfidentialNoteTokenSupplyWitnesses

{ + wit_SupplyRandomness(context: WitnessContext): [P, Uint8Array]; + wit_SupplyKeySecret(context: WitnessContext): [P, Uint8Array]; +} + +export const ConfidentialNoteTokenSupplyWitnesses = + (): IConfidentialNoteTokenSupplyWitnesses => ({ + // Fresh + secret per call, as the extension requires; a fixed seed is only + // honored when a test explicitly plants one. + wit_SupplyRandomness(context) { + return [ + context.privateState, + context.privateState.randomnessSeed ?? + new Uint8Array(getRandomValues(Buffer.alloc(32))), + ]; + }, + wit_SupplyKeySecret(context) { + return [context.privateState, context.privateState.supplyKeySecret]; + }, + }); diff --git a/contracts/src/token/test/witnesses/ConfidentialNoteTokenWitnesses.ts b/contracts/src/token/test/witnesses/ConfidentialNoteTokenWitnesses.ts new file mode 100644 index 000000000..656a9ac25 --- /dev/null +++ b/contracts/src/token/test/witnesses/ConfidentialNoteTokenWitnesses.ts @@ -0,0 +1,76 @@ +// TEST-ONLY WITNESS. NOT FOR PRODUCTION USE. +// Drives ConfidentialNoteToken (core) circuits in off-chain tests. + +import { getRandomValues } from 'node:crypto'; +import type { + MerkleTreePath, + WitnessContext, +} from '@midnight-ntwrk/compact-runtime'; +import type { Ledger } from '../../../../artifacts/MockConfidentialNoteToken/contract/index.js'; + +/** A note as the circuits see it: value + field-typed nonce. */ +export type Note = { value: bigint; nonce: bigint }; + +export type ConfidentialNoteTokenPrivateState = { + /** Owner spend secret; pk = Hf(sk). */ + secretKey: Uint8Array; + /** Issuer secret (issuerPk = Hf(issuerSecret)). */ + issuerSecret: Uint8Array; + /** The input note being spent in a transfer/burn (or consume target). */ + inputNote: Note; + /** + * Optional fixed nonce-randomness seed. Leave undefined for the + * production-correct behavior (a fresh secret seed per witness call). + */ + nonceSeed?: Uint8Array; +}; + +export const ConfidentialNoteTokenPrivateState = { + generate: (): ConfidentialNoteTokenPrivateState => ({ + secretKey: new Uint8Array(getRandomValues(Buffer.alloc(32))), + issuerSecret: new Uint8Array(getRandomValues(Buffer.alloc(32))), + inputNote: { value: 0n, nonce: 0n }, + }), +}; + +export interface IConfidentialNoteTokenWitnesses

{ + wit_SecretKey(context: WitnessContext): [P, Uint8Array]; + wit_IssuerSecret(context: WitnessContext): [P, Uint8Array]; + wit_InputNote(context: WitnessContext): [P, Note]; + wit_Path( + context: WitnessContext, + cm: Uint8Array, + ): [P, MerkleTreePath]; + wit_NonceRandomness(context: WitnessContext): [P, Uint8Array]; +} + +export const ConfidentialNoteTokenWitnesses = + (): IConfidentialNoteTokenWitnesses => ({ + wit_SecretKey(context) { + return [context.privateState, context.privateState.secretKey]; + }, + wit_IssuerSecret(context) { + return [context.privateState, context.privateState.issuerSecret]; + }, + wit_InputNote(context) { + return [context.privateState, context.privateState.inputNote]; + }, + // The circuit passes the input commitment; we return its Merkle path by + // reading the live commitment tree from the ledger. + wit_Path(context, cm) { + const path = context.ledger.CNT__commitments.findPathForLeaf(cm); + if (path === undefined) { + throw new Error('wit_Path: commitment not found in tree'); + } + return [context.privateState, path]; + }, + // Fresh + secret per call, as the module requires; a fixed seed is only + // honored when a test explicitly plants one. + wit_NonceRandomness(context) { + return [ + context.privateState, + context.privateState.nonceSeed ?? + new Uint8Array(getRandomValues(Buffer.alloc(32))), + ]; + }, + }); diff --git a/contracts/src/token/test/witnesses/RegulatedConfidentialNoteTokenWitnesses.ts b/contracts/src/token/test/witnesses/RegulatedConfidentialNoteTokenWitnesses.ts new file mode 100644 index 000000000..deafbd8cf --- /dev/null +++ b/contracts/src/token/test/witnesses/RegulatedConfidentialNoteTokenWitnesses.ts @@ -0,0 +1,93 @@ +// TEST-ONLY WITNESS. NOT FOR PRODUCTION USE. +// Drives the RegulatedConfidentialNoteToken preset contract in off-chain +// tests: the union of the composed modules' witnesses. + +import { getRandomValues } from 'node:crypto'; +import type { + MerkleTreePath, + WitnessContext, +} from '@midnight-ntwrk/compact-runtime'; +import type { Ledger } from '../../../../artifacts/RegulatedConfidentialNoteToken/contract/index.js'; + +/** A note as the circuits see it: value + field-typed nonce. */ +export type Note = { value: bigint; nonce: bigint }; + +export type RegulatedConfidentialNoteTokenPrivateState = { + /** Owner spend secret; pk = Hf(sk). */ + secretKey: Uint8Array; + /** Issuer secret (issuerPk = Hf(issuerSecret)). */ + issuerSecret: Uint8Array; + /** Seizure authority secret (authorityPk = Hf(authoritySecret)). */ + authoritySecret: Uint8Array; + /** Supply-key secret (supplyKey = derivePk(secret)); consumed by attestSupply. */ + supplyKeySecret: Uint8Array; + /** The input note being spent in a transfer/burn (or seize target). */ + inputNote: Note; +}; + +export const RegulatedConfidentialNoteTokenPrivateState = { + generate: (): RegulatedConfidentialNoteTokenPrivateState => ({ + secretKey: new Uint8Array(getRandomValues(Buffer.alloc(32))), + issuerSecret: new Uint8Array(getRandomValues(Buffer.alloc(32))), + authoritySecret: new Uint8Array(getRandomValues(Buffer.alloc(32))), + supplyKeySecret: new Uint8Array(getRandomValues(Buffer.alloc(32))), + inputNote: { value: 0n, nonce: 0n }, + }), +}; + +const freshSeed = (): Uint8Array => + new Uint8Array(getRandomValues(Buffer.alloc(32))); + +export interface IRegulatedConfidentialNoteTokenWitnesses

{ + wit_SecretKey(context: WitnessContext): [P, Uint8Array]; + wit_IssuerSecret(context: WitnessContext): [P, Uint8Array]; + wit_AuthoritySecret(context: WitnessContext): [P, Uint8Array]; + wit_SupplyKeySecret(context: WitnessContext): [P, Uint8Array]; + wit_InputNote(context: WitnessContext): [P, Note]; + wit_Path( + context: WitnessContext, + cm: Uint8Array, + ): [P, MerkleTreePath]; + wit_AuditRandomness(context: WitnessContext): [P, Uint8Array]; + wit_DeliveryRandomness(context: WitnessContext): [P, Uint8Array]; + wit_SupplyRandomness(context: WitnessContext): [P, Uint8Array]; +} + +export const RegulatedConfidentialNoteTokenWitnesses = + (): IRegulatedConfidentialNoteTokenWitnesses => ({ + wit_SecretKey(context) { + return [context.privateState, context.privateState.secretKey]; + }, + wit_IssuerSecret(context) { + return [context.privateState, context.privateState.issuerSecret]; + }, + wit_AuthoritySecret(context) { + return [context.privateState, context.privateState.authoritySecret]; + }, + wit_SupplyKeySecret(context) { + return [context.privateState, context.privateState.supplyKeySecret]; + }, + wit_InputNote(context) { + return [context.privateState, context.privateState.inputNote]; + }, + // The circuit passes the input commitment; we return its Merkle path by + // reading the live commitment tree from the ledger. + wit_Path(context, cm) { + const path = context.ledger._commitments.findPathForLeaf(cm); + if (path === undefined) { + throw new Error('wit_Path: commitment not found in tree'); + } + return [context.privateState, path]; + }, + // All randomness seeds are fresh + secret per call, as the modules + // require. + wit_AuditRandomness(context) { + return [context.privateState, freshSeed()]; + }, + wit_DeliveryRandomness(context) { + return [context.privateState, freshSeed()]; + }, + wit_SupplyRandomness(context) { + return [context.privateState, freshSeed()]; + }, + }); From b3a9a5f1488aa1330f0f62ac1f5dd5eaf5730058 Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Thu, 23 Jul 2026 17:04:54 +0200 Subject: [PATCH 2/4] feat(token): rework confidential note token family * rename the family: ConfidentialNoteToken becomes ConfidentialNoteFungibleToken (asset class in the name, keeping the FungibleToken suffix family); the supply extension becomes ConfidentialNoteFungibleTokenPrivateSupply * split the issuer role out of the core into extensions/ConfidentialNoteFungibleTokenIssuer; the core is now role-free and initialization-free (pure note machinery) with two ungated value-creation blocks: _mint (core-derived fresh nonce) and _mintNote (policy-built note, e.g. audit-derived nonces) * convert presets/RegulatedConfidentialNoteFungibleToken from a top-level contract into a ready-to-use module: a one-shot initialize replaces the constructor, and the composed modules' observable ledgers and artifact types re-export under stable bare names; the new MockRegulatedConfidentialNoteFungibleToken carries the deployable shape for the simulators * drop the CNT_ import prefix (Core_ for the core internally, Token_ for the preset in consumers) * add compliance extensions: Freeze (freeze-before-seize via a frozen nullifier set), Allowlist (ZK Merkle-membership KYC with tombstone revocation), Review (selective disclosure to approved reviewer keys), plus self-rotation blocks for the issuer/audit/supply keys and rotateAuthority in the preset * mocks, simulators, and witness harnesses updated accordingly; verified via direct compact compile (skip-zk plus full keygen for circuit tags), tsc, and biome NOT audited, NOT production. --- CHANGELOG.md | 4 +- ... => ConfidentialNoteFungibleToken.compact} | 140 ++--- ...identialNoteFungibleTokenAllowlist.compact | 100 ++++ ...onfidentialNoteFungibleTokenAudit.compact} | 47 +- ...identialNoteFungibleTokenDelivery.compact} | 10 +- ...onfidentialNoteFungibleTokenFreeze.compact | 79 +++ ...onfidentialNoteFungibleTokenIssuer.compact | 99 ++++ ...ialNoteFungibleTokenPrivateSupply.compact} | 42 +- ...onfidentialNoteFungibleTokenReview.compact | 170 ++++++ ...latedConfidentialNoteFungibleToken.compact | 333 +++++++++++ .../RegulatedConfidentialNoteToken.compact | 249 -------- .../src/token/test/FungibleToken.test.ts | 385 ++++++------ contracts/src/token/test/MultiToken.test.ts | 547 +++++++++--------- .../token/test/NativeShieldedToken.test.ts | 15 +- .../test/NativeShieldedTokenCore.test.ts | 15 +- .../test/NativeShieldedTokenFamily.test.ts | 15 +- .../MockConfidentialNoteFungibleToken.compact | 66 +++ ...identialNoteFungibleTokenAllowlist.compact | 26 + ...onfidentialNoteFungibleTokenAudit.compact} | 6 +- ...identialNoteFungibleTokenDelivery.compact} | 2 +- ...onfidentialNoteFungibleTokenFreeze.compact | 22 + ...onfidentialNoteFungibleTokenIssuer.compact | 22 + ...ialNoteFungibleTokenPrivateSupply.compact} | 6 +- ...onfidentialNoteFungibleTokenReview.compact | 38 ++ .../mocks/MockConfidentialNoteToken.compact | 74 --- ...latedConfidentialNoteFungibleToken.compact | 98 ++++ ...tialNoteFungibleTokenAllowlistSimulator.ts | 58 ++ ...identialNoteFungibleTokenAuditSimulator.ts | 71 +++ ...ntialNoteFungibleTokenDeliverySimulator.ts | 56 ++ ...dentialNoteFungibleTokenFreezeSimulator.ts | 59 ++ ...dentialNoteFungibleTokenIssuerSimulator.ts | 67 +++ ...NoteFungibleTokenPrivateSupplySimulator.ts | 77 +++ ...dentialNoteFungibleTokenReviewSimulator.ts | 79 +++ .../ConfidentialNoteFungibleTokenSimulator.ts | 72 +++ .../ConfidentialNoteTokenAuditSimulator.ts | 67 --- .../ConfidentialNoteTokenDeliverySimulator.ts | 54 -- .../ConfidentialNoteTokenSimulator.ts | 73 --- .../ConfidentialNoteTokenSupplySimulator.ts | 71 --- ...ConfidentialNoteFungibleTokenSimulator.ts} | 62 +- ...tialNoteFungibleTokenAllowlistWitnesses.ts | 38 ++ ...identialNoteFungibleTokenAuditWitnesses.ts | 47 ++ ...tialNoteFungibleTokenDeliveryWitnesses.ts} | 16 +- ...dentialNoteFungibleTokenIssuerWitnesses.ts | 29 + ...oteFungibleTokenPrivateSupplyWitnesses.ts} | 16 +- ...entialNoteFungibleTokenReviewWitnesses.ts} | 22 +- ...ConfidentialNoteFungibleTokenWitnesses.ts} | 25 +- ...ConfidentialNoteFungibleTokenWitnesses.ts} | 29 +- 47 files changed, 2446 insertions(+), 1252 deletions(-) rename contracts/src/token/{ConfidentialNoteToken.compact => ConfidentialNoteFungibleToken.compact} (73%) create mode 100644 contracts/src/token/extensions/ConfidentialNoteFungibleTokenAllowlist.compact rename contracts/src/token/extensions/{ConfidentialNoteTokenAudit.compact => ConfidentialNoteFungibleTokenAudit.compact} (74%) rename contracts/src/token/extensions/{ConfidentialNoteTokenDelivery.compact => ConfidentialNoteFungibleTokenDelivery.compact} (89%) create mode 100644 contracts/src/token/extensions/ConfidentialNoteFungibleTokenFreeze.compact create mode 100644 contracts/src/token/extensions/ConfidentialNoteFungibleTokenIssuer.compact rename contracts/src/token/extensions/{ConfidentialNoteTokenSupply.compact => ConfidentialNoteFungibleTokenPrivateSupply.compact} (78%) create mode 100644 contracts/src/token/extensions/ConfidentialNoteFungibleTokenReview.compact create mode 100644 contracts/src/token/presets/RegulatedConfidentialNoteFungibleToken.compact delete mode 100644 contracts/src/token/presets/RegulatedConfidentialNoteToken.compact create mode 100644 contracts/src/token/test/mocks/MockConfidentialNoteFungibleToken.compact create mode 100644 contracts/src/token/test/mocks/MockConfidentialNoteFungibleTokenAllowlist.compact rename contracts/src/token/test/mocks/{MockConfidentialNoteTokenAudit.compact => MockConfidentialNoteFungibleTokenAudit.compact} (82%) rename contracts/src/token/test/mocks/{MockConfidentialNoteTokenDelivery.compact => MockConfidentialNoteFungibleTokenDelivery.compact} (88%) create mode 100644 contracts/src/token/test/mocks/MockConfidentialNoteFungibleTokenFreeze.compact create mode 100644 contracts/src/token/test/mocks/MockConfidentialNoteFungibleTokenIssuer.compact rename contracts/src/token/test/mocks/{MockConfidentialNoteTokenSupply.compact => MockConfidentialNoteFungibleTokenPrivateSupply.compact} (81%) create mode 100644 contracts/src/token/test/mocks/MockConfidentialNoteFungibleTokenReview.compact delete mode 100644 contracts/src/token/test/mocks/MockConfidentialNoteToken.compact create mode 100644 contracts/src/token/test/mocks/MockRegulatedConfidentialNoteFungibleToken.compact create mode 100644 contracts/src/token/test/simulators/ConfidentialNoteFungibleTokenAllowlistSimulator.ts create mode 100644 contracts/src/token/test/simulators/ConfidentialNoteFungibleTokenAuditSimulator.ts create mode 100644 contracts/src/token/test/simulators/ConfidentialNoteFungibleTokenDeliverySimulator.ts create mode 100644 contracts/src/token/test/simulators/ConfidentialNoteFungibleTokenFreezeSimulator.ts create mode 100644 contracts/src/token/test/simulators/ConfidentialNoteFungibleTokenIssuerSimulator.ts create mode 100644 contracts/src/token/test/simulators/ConfidentialNoteFungibleTokenPrivateSupplySimulator.ts create mode 100644 contracts/src/token/test/simulators/ConfidentialNoteFungibleTokenReviewSimulator.ts create mode 100644 contracts/src/token/test/simulators/ConfidentialNoteFungibleTokenSimulator.ts delete mode 100644 contracts/src/token/test/simulators/ConfidentialNoteTokenAuditSimulator.ts delete mode 100644 contracts/src/token/test/simulators/ConfidentialNoteTokenDeliverySimulator.ts delete mode 100644 contracts/src/token/test/simulators/ConfidentialNoteTokenSimulator.ts delete mode 100644 contracts/src/token/test/simulators/ConfidentialNoteTokenSupplySimulator.ts rename contracts/src/token/test/simulators/{RegulatedConfidentialNoteTokenSimulator.ts => RegulatedConfidentialNoteFungibleTokenSimulator.ts} (52%) create mode 100644 contracts/src/token/test/witnesses/ConfidentialNoteFungibleTokenAllowlistWitnesses.ts create mode 100644 contracts/src/token/test/witnesses/ConfidentialNoteFungibleTokenAuditWitnesses.ts rename contracts/src/token/test/witnesses/{ConfidentialNoteTokenDeliveryWitnesses.ts => ConfidentialNoteFungibleTokenDeliveryWitnesses.ts} (59%) create mode 100644 contracts/src/token/test/witnesses/ConfidentialNoteFungibleTokenIssuerWitnesses.ts rename contracts/src/token/test/witnesses/{ConfidentialNoteTokenSupplyWitnesses.ts => ConfidentialNoteFungibleTokenPrivateSupplyWitnesses.ts} (66%) rename contracts/src/token/test/witnesses/{ConfidentialNoteTokenAuditWitnesses.ts => ConfidentialNoteFungibleTokenReviewWitnesses.ts} (52%) rename contracts/src/token/test/witnesses/{ConfidentialNoteTokenWitnesses.ts => ConfidentialNoteFungibleTokenWitnesses.ts} (70%) rename contracts/src/token/test/witnesses/{RegulatedConfidentialNoteTokenWitnesses.ts => RegulatedConfidentialNoteFungibleTokenWitnesses.ts} (73%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4deb16726..ee11ea57e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add EcdhMask (#655) - Add NoteDelivery: an encrypted note-delivery channel with a derived-nonce variant (`deliver`/`recover`) and an explicit-nonce variant (`deliverNote`/`recoverNote`) -- Add ConfidentialNoteToken (draft): a note-based confidential token with full graph privacy (amounts — including issuance and burns — sender, and recipient hidden), with extensions for mandatory auditor viewing (audit-derived nonces) and on-chain recipient note deliveries, plus the RegulatedConfidentialNoteToken preset wiring issuer-gated mint, burn, and shared-nullifier seizure. See `contracts/src/token/docs/hybrid-confidential-token.md` -- Add ConfidentialNoteTokenSupply (draft): a standalone extension keeping supply as a homomorphic ElGamal ciphertext updated alongside mint/burn, with `attestSupply` publishing a proof-backed public total at a chosen cadence +- Add ConfidentialNoteFungibleToken (draft): a note-based confidential token with full graph privacy (amounts — including issuance and burns — sender, and recipient hidden), with extensions for mandatory auditor viewing (audit-derived nonces) and on-chain recipient note deliveries, plus the RegulatedConfidentialNoteFungibleToken preset wiring issuer-gated mint, burn, and shared-nullifier seizure. See `contracts/src/token/docs/hybrid-confidential-token.md` +- Add ConfidentialNoteFungibleTokenPrivateSupply (draft): a standalone extension keeping supply as a homomorphic ElGamal ciphertext updated alongside mint/burn, with `attestSupply` publishing a proof-backed public total at a chosen cadence ## 0.3.0-alpha (2026-06-30) diff --git a/contracts/src/token/ConfidentialNoteToken.compact b/contracts/src/token/ConfidentialNoteFungibleToken.compact similarity index 73% rename from contracts/src/token/ConfidentialNoteToken.compact rename to contracts/src/token/ConfidentialNoteFungibleToken.compact index 7e0eee76b..a5a49c1c4 100644 --- a/contracts/src/token/ConfidentialNoteToken.compact +++ b/contracts/src/token/ConfidentialNoteFungibleToken.compact @@ -1,10 +1,10 @@ // SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts (token/ConfidentialNoteToken.compact) +// OpenZeppelin Compact Contracts (token/ConfidentialNoteFungibleToken.compact) pragma language_version >= 0.23.0; /** - * @module ConfidentialNoteToken + * @module ConfidentialNoteFungibleToken * @description DRAFT tier-4 core: a self-contained note-based confidential * token with FULL graph privacy (amounts, sender, and recipient hidden). * @@ -15,27 +15,33 @@ pragma language_version >= 0.23.0; * `nf = H(domain, nonce)` marks the note spent. Spending proves membership of * the note's commitment without revealing which leaf. * - * The core is a complete token on its own: `initialize` binds the issuer, - * `mint` (issuer-gated), `transfer`, and `burn` work out of the box, deriving - * output nonces from the caller's own randomness witness. Created notes are - * returned to the caller (a local, private result — nothing extra goes - * on-chain), who hands them to recipients out of band. + * The core is deliberately barebones: the note machinery and nothing else. It + * holds NO roles and needs NO initialization — the ledger is just the + * commitment tree and the nullifier set. `transfer` and `burn` work out of the + * box because they are self-gated (spending requires the owner's secret via + * `wit_SecretKey`), deriving output nonces from the caller's own randomness + * witness. Created notes are returned to the caller (a local, private result — + * nothing extra goes on-chain), who hands them to recipients out of band. * - * Compliance and UX are layered on top, not baked in: + * Value creation carries no default gate: `_mint` / `_mintNote` are ungated + * building blocks, and the composing contract decides who may create value. + * Roles, compliance, and UX are layered on top, not baked in: * - * - auditor viewing — `extensions/ConfidentialNoteTokenAudit`, + * - issuer gating (the single-issuer shape) — + * `extensions/ConfidentialNoteFungibleTokenIssuer`, + * - auditor viewing — `extensions/ConfidentialNoteFungibleTokenAudit`, * - on-chain note delivery (recipients discover funds by scanning) — - * `extensions/ConfidentialNoteTokenDelivery`, + * `extensions/ConfidentialNoteFungibleTokenDelivery`, * - confidential supply + public attestation — - * `extensions/ConfidentialNoteTokenSupply`, + * `extensions/ConfidentialNoteFungibleTokenPrivateSupply`, * - the wired-together deployable token — - * `presets/RegulatedConfidentialNoteToken`. + * `presets/RegulatedConfidentialNoteFungibleToken`. * - * For that wiring, the `_`-prefixed building blocks (`_mint`, `_transfer`, + * For that wiring, the `_`-prefixed building blocks (`_mintNote`, `_transfer`, * `_burn`, `_consumeNote`) accept caller-built notes, so a composing contract - * can source nonces from its emission policy (e.g. audit-derived) instead of - * the core default. Like all `_` circuits, they carry no authorization — - * the composer gates them. + * can source nonces from its emission policy (e.g. audit-derived); `_mint` + * instead derives the core-default nonce. Like all `_` circuits, they carry + * no authorization — the composer gates them. * * @dev Nonces are spend-critical: the nullifier preimage is the nonce alone * (no owner secret), so any party that knows a nonce derives the SAME @@ -46,7 +52,7 @@ pragma language_version >= 0.23.0; * * @dev NOT audited, NOT production. */ -module ConfidentialNoteToken { +module ConfidentialNoteFungibleToken { import CompactStandardLibrary; // A note's value and its unique nonce. Ownership is a separate pk. @@ -67,16 +73,11 @@ module ConfidentialNoteToken { nonce: Field; } - export ledger _isInitialized: Boolean; - // Mint authorization: `Hf(issuerSecret)`. - export ledger _issuerPk: Field; export ledger _commitments: HistoricMerkleTree<32, Bytes<32>>; export ledger _nullifiers: Set>; // Owner's spend secret (pk = Hf(sk)). witness wit_SecretKey(): Bytes<32>; - // The issuer's secret (issuerPk = Hf(issuerSecret)). - witness wit_IssuerSecret(): Bytes<32>; // The note being consumed, and its Merkle path. witness wit_InputNote(): Note; witness wit_Path(cm: Bytes<32>): MerkleTreePath<32, Bytes<32>>; @@ -116,44 +117,6 @@ module ConfidentialNoteToken { }); } - /** - * @description One-shot initialization binding the issuer (the only role - * the core itself needs). - * - * Requirements: - * - * - Module is not already initialized. - * - * @circuitInfo k=6, rows=31 - */ - export circuit initialize(issuerPk: Field): [] { - assert(!_isInitialized, "ConfidentialNoteToken: already initialized"); - _issuerPk = disclose(issuerPk); - _isInitialized = true; - } - - /** - * @description Mints a note of `value` to `recipientPk` and returns it (a - * local private result) so the issuer can hand it to the recipient out of - * band. The amount is NOT written to public state — issuance stays hidden; - * only the hiding commitment appears on-chain. - * - * Requirements: - * - * - Module is initialized. - * - The caller proves the issuer secret (`Hf(secret) == _issuerPk`). - * - * @circuitInfo k=14, rows=13217 - */ - export circuit mint(recipientPk: Field, value: Uint<128>): Note { - _assertIssuer(); - const note = Note { value: value, nonce: freshNonce(pad(32, "OZ:cnt:out")) }; - _mint(note, recipientPk); - // The note returns only to the local caller; revealing it on-chain would - // expose the nonce (spend-critical) and the amount. - return disclose(note); - } - /** * @description Fully-private transfer: spends the caller's input note and * creates a recipient note of `value` plus a change note back to the @@ -172,7 +135,7 @@ module ConfidentialNoteToken { export circuit transfer(recipientPk: Field, value: Uint<128>): [Note, Note] { const pk = _spenderPk(); const input = _inputNote(); - assert(input.value >= value, "ConfidentialNoteToken: insufficient note value"); + assert(input.value >= value, "ConfidentialNoteFungibleToken: insufficient note value"); const outNote = Note { value: value, nonce: freshNonce(pad(32, "OZ:cnt:out")) }; const changeNote = Note { @@ -199,7 +162,7 @@ module ConfidentialNoteToken { export circuit burn(value: Uint<128>): Note { const pk = _spenderPk(); const input = _inputNote(); - assert(input.value >= value, "ConfidentialNoteToken: insufficient note value"); + assert(input.value >= value, "ConfidentialNoteFungibleToken: insufficient note value"); const changeNote = Note { value: (input.value - value) as Uint<128>, @@ -218,22 +181,6 @@ module ConfidentialNoteToken { return derivePk(wit_SecretKey()); } - /** - * @description Building block: asserts the caller proves the issuer secret. - * - * Requirements: - * - * - Module is initialized. - * - `Hf(wit_IssuerSecret()) == _issuerPk`. - * - * @circuitInfo k=13, rows=2277 - */ - export circuit _assertIssuer(): [] { - assert(_isInitialized, "ConfidentialNoteToken: contract not initialized"); - assert(derivePk(wit_IssuerSecret()) == _issuerPk, - "ConfidentialNoteToken: not the issuer"); - } - /** * @description Building block: peek at the input note about to be consumed * (the same witness `_consumeNote` reads), so a composing contract can size @@ -252,11 +199,30 @@ module ConfidentialNoteToken { * * @circuitInfo k=13, rows=6766 */ - export circuit _mint(note: Note, ownerPk: Field): [] { + export circuit _mintNote(note: Note, ownerPk: Field): [] { // Only the hiding commitment crosses to public state. _commitments.insert(disclose(commitOf(note, ownerPk))); } + /** + * @description UNGATED building block: mints a note of `value` to + * `recipientPk` with a core-derived fresh nonce and returns it (a local + * private result) so the caller can hand it to the recipient out of band. + * The amount is NOT written to public state — issuance stays hidden; only + * the hiding commitment appears on-chain. The composer gates who may call + * this (see `extensions/ConfidentialNoteFungibleTokenIssuer` for the + * single-issuer shape). + * + * @circuitInfo k=14, rows=10964 + */ + export circuit _mint(recipientPk: Field, value: Uint<128>): Note { + const note = Note { value: value, nonce: freshNonce(pad(32, "OZ:cnt:out")) }; + _mintNote(note, recipientPk); + // The note returns only to the local caller; revealing it on-chain would + // expose the nonce (spend-critical) and the amount. + return disclose(note); + } + /** * @description UNGATED building block: consumes the input note owned by * `spenderPk` and commits `outNote` to `recipientPk` plus `changeNote` back @@ -272,9 +238,9 @@ module ConfidentialNoteToken { export circuit _transfer(spenderPk: Field, recipientPk: Field, outNote: Note, changeNote: Note): [] { const input = _consumeNote(spenderPk); assert(input.value == outNote.value + changeNote.value, - "ConfidentialNoteToken: transfer does not conserve value"); - _mint(outNote, recipientPk); - _mint(changeNote, spenderPk); + "ConfidentialNoteFungibleToken: transfer does not conserve value"); + _mintNote(outNote, recipientPk); + _mintNote(changeNote, spenderPk); } /** @@ -292,8 +258,8 @@ module ConfidentialNoteToken { export circuit _burn(spenderPk: Field, value: Uint<128>, changeNote: Note): [] { const input = _consumeNote(spenderPk); assert(input.value == value + changeNote.value, - "ConfidentialNoteToken: burn does not conserve value"); - _mint(changeNote, spenderPk); + "ConfidentialNoteFungibleToken: burn does not conserve value"); + _mintNote(changeNote, spenderPk); } /** @@ -319,14 +285,14 @@ module ConfidentialNoteToken { const path = wit_Path(cm); const root = disclose(merkleTreePathRoot<32, Bytes<32>>(path)); assert(_commitments.checkRoot(root), - "ConfidentialNoteToken: input root not recognized"); + "ConfidentialNoteFungibleToken: input root not recognized"); assert(cm == path.leaf, - "ConfidentialNoteToken: path does not match input commitment"); + "ConfidentialNoteFungibleToken: path does not match input commitment"); // Single-spend: the shared nullifier can be consumed exactly once. const nf = nullifierOf(input); assert(!_nullifiers.member(disclose(nf)), - "ConfidentialNoteToken: note already spent"); + "ConfidentialNoteFungibleToken: note already spent"); _nullifiers.insert(disclose(nf)); return input; diff --git a/contracts/src/token/extensions/ConfidentialNoteFungibleTokenAllowlist.compact b/contracts/src/token/extensions/ConfidentialNoteFungibleTokenAllowlist.compact new file mode 100644 index 000000000..082f9c19e --- /dev/null +++ b/contracts/src/token/extensions/ConfidentialNoteFungibleTokenAllowlist.compact @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts (token/extensions/ConfidentialNoteFungibleTokenAllowlist.compact) + +pragma language_version >= 0.23.0; + +/** + * @module ConfidentialNoteFungibleTokenAllowlist + * @description Optional standalone extension adding a KYC allowlist to a + * `ConfidentialNoteFungibleToken` pool. Because spenders are HIDDEN, a plain + * `Set` lookup is unusable (it would disclose who is transacting); membership + * is instead proven in zero knowledge against a Merkle tree of identity + * leaves — an observer learns only "an allowed party", never which one. It + * imports no token module; the composing contract gates `_addAllowed` / + * `_removeAllowed` (admin) and wires `_assertAllowed` at its chokepoints: + * + * export circuit transfer(recipientPk: Field, ...): [] { + * Allowlist__assertAllowed(Core__spenderPk()); // hidden-spender KYC + * Allowlist__assertAllowed(recipientPk); // recipient KYC + * // ... + * } + * + * The tree is PLAIN (not historic) on purpose: `checkRoot` accepts only the + * CURRENT root, so any update — including a tombstone removal — invalidates + * every previously fetched path, making revocation immediate. Wallets must + * refetch their path when the tree changes. + * + * @dev Removal is by leaf index (`_removeAllowed` overwrites the slot with + * the default leaf), which trusts the composer's off-chain index + * bookkeeping. Fail-closed hardening (prove the leaf at `index` matches the + * pk being removed) is a known follow-up. + * + * @dev `wit_AllowlistPath` supplies the prover's own membership path; the + * path stays witness, so which leaf proved membership is never revealed. + * + * @dev NOT audited, NOT production. + */ +module ConfidentialNoteFungibleTokenAllowlist { + import CompactStandardLibrary; + + // Allowlist leaf preimage (domain-separated identity commitment). + struct AllowLeafPreimage { + domain: Bytes<32>; + pk: Field; + } + + // Identity commitments of allowed parties. Plain tree: current-root proofs + // only, so updates revoke stale paths. + export ledger _allowed: MerkleTree<16, Bytes<32>>; + + // The prover's own membership path (fetched from the public tree). + witness wit_AllowlistPath(leaf: Bytes<32>): MerkleTreePath<16, Bytes<32>>; + + /** + * @description Identity leaf: `H(domain, pk)`. Exported so admins and + * wallets derive leaves the way `_assertAllowed` does. + */ + export pure circuit leafOf(pk: Field): Bytes<32> { + return persistentHash(AllowLeafPreimage { + domain: pad(32, "OZ:cnt:allow"), + pk: pk + }); + } + + /** + * @description UNGATED building block: adds `pk` to the allowlist. The + * composer gates who may administer the list. + */ + export circuit _addAllowed(pk: Field): [] { + _allowed.insert(disclose(leafOf(pk))); + } + + /** + * @description UNGATED building block: removes the leaf at `index` by + * overwriting it with the default leaf (a tombstone no identity can match). + * Every outstanding path proof is invalidated by the root change. The + * composer gates who may administer the list; see the module doc for the + * index-bookkeeping caveat. + */ + export circuit _removeAllowed(index: Uint<64>): [] { + _allowed.insertIndexDefault(disclose(index)); + } + + /** + * @description Building block: proves `pk` is on the allowlist without + * revealing which leaf. Place it at the composer's chokepoints (hidden + * spender, disclosed-to-circuit recipient). + * + * Requirements: + * + * - `leafOf(pk)` is a leaf of the CURRENT tree (stale paths fail). + */ + export circuit _assertAllowed(pk: Field): [] { + const leaf = leafOf(pk); + const path = wit_AllowlistPath(leaf); + assert(_allowed.checkRoot(disclose(merkleTreePathRoot<16, Bytes<32>>(path))), + "ConfidentialNoteFungibleTokenAllowlist: not allowed"); + assert(leaf == path.leaf, + "ConfidentialNoteFungibleTokenAllowlist: path does not match identity"); + } +} diff --git a/contracts/src/token/extensions/ConfidentialNoteTokenAudit.compact b/contracts/src/token/extensions/ConfidentialNoteFungibleTokenAudit.compact similarity index 74% rename from contracts/src/token/extensions/ConfidentialNoteTokenAudit.compact rename to contracts/src/token/extensions/ConfidentialNoteFungibleTokenAudit.compact index e6aec76bc..7c8a18435 100644 --- a/contracts/src/token/extensions/ConfidentialNoteTokenAudit.compact +++ b/contracts/src/token/extensions/ConfidentialNoteFungibleTokenAudit.compact @@ -1,12 +1,12 @@ // SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts (token/extensions/ConfidentialNoteTokenAudit.compact) +// OpenZeppelin Compact Contracts (token/extensions/ConfidentialNoteFungibleTokenAudit.compact) pragma language_version >= 0.23.0; /** - * @module ConfidentialNoteTokenAudit + * @module ConfidentialNoteFungibleTokenAudit * @description Optional standalone extension that gives a designated auditor - * COMPLETE visibility over a `ConfidentialNoteToken` pool while the public + * COMPLETE visibility over a `ConfidentialNoteFungibleToken` pool while the public * sees nothing. It imports no token module. * * For each output note, `_emitAuditedOutput`: @@ -27,17 +27,17 @@ pragma language_version >= 0.23.0; * preimage, so in a seizure-enabled preset the audit trail is exactly what * arms the seizure authority (see the preset). * - * @notice Pairs with `ConfidentialNoteToken`. The consuming contract wires + * @notice Pairs with `ConfidentialNoteFungibleToken`. The consuming contract wires * every note-creation path through this extension: * * circuit emitOutput(ownerPk: Field, value: Uint<128>, slot: Bytes<32>): [] { * const nonce = Audit__emitAuditedOutput(ownerPk, value, slot); - * CNT__createNote(CNT_Note { value: value, nonce: nonce }, ownerPk); + * Core__mintNote(Core_Note { value: value, nonce: nonce }, ownerPk); * } * * @warning Audit completeness is a property of the CONSUMER's wiring: a note * created without `_emitAuditedOutput` is invisible to the auditor. Route - * every `_createNote` through it. + * every `_mintNote` through it. * * @dev `wit_AuditRandomness` MUST return a fresh, secret seed per invocation; * ephemerals expand from it (see `crypto/EcdhMask` freshness rules). `slot` @@ -45,7 +45,7 @@ pragma language_version >= 0.23.0; * * @dev NOT audited, NOT production. */ -module ConfidentialNoteTokenAudit { +module ConfidentialNoteFungibleTokenAudit { import CompactStandardLibrary; import "../../crypto/EcdhMask" prefix EcdhMask_; @@ -76,6 +76,9 @@ module ConfidentialNoteTokenAudit { // Randomness seed for audit ephemerals. MUST be fresh + secret per // invocation (see module doc). witness wit_AuditRandomness(): Bytes<32>; + // The audit secret scalar (`_auditKey = g^auditSk`); only `_rotateAuditKey` + // consumes it. + witness wit_AuditKeySecret(): Field; /** * @description One-shot initialization binding the audit key. @@ -88,9 +91,9 @@ module ConfidentialNoteTokenAudit { * @circuitInfo k=10, rows=615 */ export circuit initialize(auditKey: JubjubPoint): [] { - assert(!_isInitialized, "ConfidentialNoteTokenAudit: already initialized"); + assert(!_isInitialized, "ConfidentialNoteFungibleTokenAudit: already initialized"); assert(auditKey != ecMulGenerator(0 as Field), - "ConfidentialNoteTokenAudit: identity audit key"); + "ConfidentialNoteFungibleTokenAudit: identity audit key"); _auditKey = disclose(auditKey); _isInitialized = true; } @@ -107,14 +110,14 @@ module ConfidentialNoteTokenAudit { * @circuitInfo k=15, rows=31599 */ export circuit _emitAuditedOutput(ownerPk: Field, value: Uint<128>, slot: Bytes<32>): Field { - assert(_isInitialized, "ConfidentialNoteTokenAudit: extension not initialized"); + assert(_isInitialized, "ConfidentialNoteFungibleTokenAudit: extension not initialized"); const ea = degradeToTransient(persistentHash>>( [wit_AuditRandomness(), pad(32, "OZ:cnt:ea"), slot])); const eaPk = ecMulGenerator(ea); // Point guard subsumes `ea != 0` (see crypto/EcdhMask weak-inputs note). assert(eaPk != ecMulGenerator(0 as Field), - "ConfidentialNoteTokenAudit: zero audit ephemeral"); + "ConfidentialNoteFungibleTokenAudit: zero audit ephemeral"); const shared = ecMul(_auditKey, ea); // The nonce is DERIVED from the audit shared secret: an output the auditor @@ -132,6 +135,28 @@ module ConfidentialNoteTokenAudit { return nonce; } + /** + * @description Building block: self-rotation — the current audit-key holder + * proves the secret scalar and binds a new audit key. Records already + * published stay readable by the OLD key (ciphertexts cannot be clawed + * back); outputs emitted after rotation derive their nonces from the new + * key. Production deployments gate this behind governance too. + * + * Requirements: + * + * - Extension is initialized. + * - `newKey` is not the identity point. + * - The caller proves the CURRENT audit secret (`g^secret == _auditKey`). + */ + export circuit _rotateAuditKey(newKey: JubjubPoint): [] { + assert(_isInitialized, "ConfidentialNoteFungibleTokenAudit: extension not initialized"); + assert(newKey != ecMulGenerator(0 as Field), + "ConfidentialNoteFungibleTokenAudit: identity audit key"); + assert(ecMulGenerator(wit_AuditKeySecret()) == _auditKey, + "ConfidentialNoteFungibleTokenAudit: not the audit key holder"); + _auditKey = disclose(newKey); + } + /** * @description Auditor-side: recover one output's `(value, nonce, ownerPk)` * from an AuditRecord using the audit secret scalar (`auditKey = g^auditSk`). diff --git a/contracts/src/token/extensions/ConfidentialNoteTokenDelivery.compact b/contracts/src/token/extensions/ConfidentialNoteFungibleTokenDelivery.compact similarity index 89% rename from contracts/src/token/extensions/ConfidentialNoteTokenDelivery.compact rename to contracts/src/token/extensions/ConfidentialNoteFungibleTokenDelivery.compact index 77d2b6eba..638e9c064 100644 --- a/contracts/src/token/extensions/ConfidentialNoteTokenDelivery.compact +++ b/contracts/src/token/extensions/ConfidentialNoteFungibleTokenDelivery.compact @@ -1,10 +1,10 @@ // SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts (token/extensions/ConfidentialNoteTokenDelivery.compact) +// OpenZeppelin Compact Contracts (token/extensions/ConfidentialNoteFungibleTokenDelivery.compact) pragma language_version >= 0.23.0; /** - * @module ConfidentialNoteTokenDelivery + * @module ConfidentialNoteFungibleTokenDelivery * @description Optional standalone extension that delivers each output note's * `(value, nonce)` to its owner's encryption key on-chain, so a recipient * discovers incoming notes from chain data alone — no out-of-band channel. @@ -14,8 +14,8 @@ pragma language_version >= 0.23.0; * encryption secret (`crypto/NoteDelivery.recoverNote`), and keep the notes * whose recomputed commitment exists in the token core's tree. * - * @notice Pairs with `ConfidentialNoteToken`. The consuming contract calls - * `_deliver` alongside every `_createNote` whose owner should be able to find + * @notice Pairs with `ConfidentialNoteFungibleToken`. The consuming contract calls + * `_deliver` alongside every `_mintNote` whose owner should be able to find * the note by scanning (skipping it makes the note reachable only out of * band — the funds still exist, but only the creator knows the nonce). * @@ -32,7 +32,7 @@ pragma language_version >= 0.23.0; * * @dev NOT audited, NOT production. */ -module ConfidentialNoteTokenDelivery { +module ConfidentialNoteFungibleTokenDelivery { import CompactStandardLibrary; import "../../crypto/NoteDelivery" prefix NoteDelivery_; diff --git a/contracts/src/token/extensions/ConfidentialNoteFungibleTokenFreeze.compact b/contracts/src/token/extensions/ConfidentialNoteFungibleTokenFreeze.compact new file mode 100644 index 000000000..c9fb52a91 --- /dev/null +++ b/contracts/src/token/extensions/ConfidentialNoteFungibleTokenFreeze.compact @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts (token/extensions/ConfidentialNoteFungibleTokenFreeze.compact) + +pragma language_version >= 0.23.0; + +/** + * @module ConfidentialNoteFungibleTokenFreeze + * @description Optional standalone extension adding freeze-before-seize to a + * `ConfidentialNoteFungibleToken` pool: a frozen-nullifier set checked at the + * owner-spend chokepoint. It imports no token module and holds no roles; the + * composing contract gates `_freeze` / `_unfreeze` (typically behind the same + * authority that may seize) and wires `_assertNotFrozen` into every + * owner-spend path. + * + * A note is frozen by its NULLIFIER: `nf = H(domain, nonce)` depends only on + * the nonce, so an authority armed by the audit trail derives the target's + * nullifier without consuming the note — freezing is non-destructive and + * reversible, unlike seizure. Wiring, at the spend chokepoint: + * + * export circuit transfer(...): [] { + * Freeze__assertNotFrozen(Core_nullifierOf(Core__inputNote())); + * // ...the core re-reads the same witness, so the checked note IS the + * // spent note... + * } + * + * Do NOT wire the check into `seize`: seizure of a frozen note is the whole + * point of freeze-then-seize. + * + * @dev What freezing publishes: the nullifier itself. Observers learn "some + * specific note is frozen" (and can later link its spend or seizure), but not + * the note's owner or value — the nullifier preimage stays hidden. The size + * of the frozen set is public. + * + * @dev NOT audited, NOT production. + */ +module ConfidentialNoteFungibleTokenFreeze { + import CompactStandardLibrary; + + // Frozen nullifiers: notes whose owner-spend is administratively blocked. + export ledger _frozen: Set>; + + /** + * @description UNGATED building block: freezes the note behind `nf`. The + * composer gates who may freeze (typically the seizure authority, armed + * with the note's nonce from the audit trail). + * + * Requirements: + * + * - `nf` is not already frozen. + */ + export circuit _freeze(nf: Bytes<32>): [] { + assert(!_frozen.member(disclose(nf)), + "ConfidentialNoteFungibleTokenFreeze: already frozen"); + _frozen.insert(disclose(nf)); + } + + /** + * @description UNGATED building block: lifts the freeze on `nf`. The + * composer gates who may unfreeze. + * + * Requirements: + * + * - `nf` is frozen. + */ + export circuit _unfreeze(nf: Bytes<32>): [] { + assert(_frozen.member(disclose(nf)), + "ConfidentialNoteFungibleTokenFreeze: not frozen"); + _frozen.remove(disclose(nf)); + } + + /** + * @description Building block: asserts the note behind `nf` is not frozen. + * Place it at every owner-spend chokepoint (and nowhere near `seize`). + */ + export circuit _assertNotFrozen(nf: Bytes<32>): [] { + assert(!_frozen.member(disclose(nf)), + "ConfidentialNoteFungibleTokenFreeze: note is frozen"); + } +} diff --git a/contracts/src/token/extensions/ConfidentialNoteFungibleTokenIssuer.compact b/contracts/src/token/extensions/ConfidentialNoteFungibleTokenIssuer.compact new file mode 100644 index 000000000..e862a16ec --- /dev/null +++ b/contracts/src/token/extensions/ConfidentialNoteFungibleTokenIssuer.compact @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts (token/extensions/ConfidentialNoteFungibleTokenIssuer.compact) + +pragma language_version >= 0.23.0; + +/** + * @module ConfidentialNoteFungibleTokenIssuer + * @description Optional standalone extension binding a single ISSUER role for + * a `ConfidentialNoteFungibleToken` pool: the issuer is whoever proves the + * secret behind `_issuerPk = Hf(issuerSecret)` in-circuit. It imports no + * token module. + * + * The core is deliberately role-free: its `_mint` / `_mintNote` building + * blocks are ungated, and the composing contract decides who may create + * value. This extension is the out-of-the-box answer for the single-issuer + * shape: + * + * export circuit mint(recipientPk: Field, value: Uint<128>): Core_Note { + * Issuer__assertIssuer(); + * return Core__mint(recipientPk, value); + * } + * + * Alternative issuance policies (multisig-gated, role sets, or none at all + * for a fixed-supply genesis mint) compose against the same blocks the same + * way. + * + * @dev Identity derivation matches the token core's `derivePk` + * (`pk = Hf(sk)`, field-typed), so one keypair works across both modules and + * off-chain code can derive the issuer identity with the core's exported + * circuit. + * + * @dev NOT audited, NOT production. + */ +module ConfidentialNoteFungibleTokenIssuer { + import CompactStandardLibrary; + + export ledger _isInitialized: Boolean; + // Mint authorization: `Hf(issuerSecret)`. + export ledger _issuerPk: Field; + + // The issuer's secret (issuerPk = Hf(issuerSecret)). + witness wit_IssuerSecret(): Bytes<32>; + + /** + * @description One-shot initialization binding the issuer. + * + * Requirements: + * + * - Extension is not already initialized. + * + * @circuitInfo k=6, rows=31 + */ + export circuit initialize(issuerPk: Field): [] { + assert(!_isInitialized, "ConfidentialNoteFungibleTokenIssuer: already initialized"); + _issuerPk = disclose(issuerPk); + _isInitialized = true; + } + + /** + * @description Building block: asserts the caller proves the issuer secret. + * Place it before any ungated value-creation block the deployment reserves + * for the issuer. + * + * Requirements: + * + * - Extension is initialized. + * - `Hf(wit_IssuerSecret()) == _issuerPk`. + * + * @circuitInfo k=13, rows=2277 + */ + export circuit _assertIssuer(): [] { + assert(_isInitialized, "ConfidentialNoteFungibleTokenIssuer: extension not initialized"); + assert(derivePk(wit_IssuerSecret()) == _issuerPk, + "ConfidentialNoteFungibleTokenIssuer: not the issuer"); + } + + /** + * @description Building block: self-rotation — the current issuer proves + * their secret and binds a new issuer key. Same semantics as an + * `Ownable`-style ownership transfer: a compromised key can rotate itself + * away, so production deployments gate this behind governance too. + * + * Requirements: + * + * - Extension is initialized. + * - The caller proves the CURRENT issuer secret. + */ + export circuit _rotateIssuer(newIssuerPk: Field): [] { + _assertIssuer(); + _issuerPk = disclose(newIssuerPk); + } + + // Same construction as the token core's `derivePk`, duplicated so this + // extension stays free of token imports (a module import would share the + // token's ledger state into any consumer of this extension). + circuit derivePk(sk: Bytes<32>): Field { + return degradeToTransient(persistentHash>(sk)); + } +} diff --git a/contracts/src/token/extensions/ConfidentialNoteTokenSupply.compact b/contracts/src/token/extensions/ConfidentialNoteFungibleTokenPrivateSupply.compact similarity index 78% rename from contracts/src/token/extensions/ConfidentialNoteTokenSupply.compact rename to contracts/src/token/extensions/ConfidentialNoteFungibleTokenPrivateSupply.compact index e7d60dcc1..e1fa11de1 100644 --- a/contracts/src/token/extensions/ConfidentialNoteTokenSupply.compact +++ b/contracts/src/token/extensions/ConfidentialNoteFungibleTokenPrivateSupply.compact @@ -1,12 +1,12 @@ // SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts (token/extensions/ConfidentialNoteTokenSupply.compact) +// OpenZeppelin Compact Contracts (token/extensions/ConfidentialNoteFungibleTokenPrivateSupply.compact) pragma language_version >= 0.23.0; /** - * @module ConfidentialNoteTokenSupply + * @module ConfidentialNoteFungibleTokenPrivateSupply * @description Optional standalone extension that adds CONFIDENTIAL supply - * accounting to a `ConfidentialNoteToken`. It imports no token module. + * accounting to a `ConfidentialNoteFungibleToken`. It imports no token module. * * The base token deliberately writes no public supply: a disclosed counter * would leak every mint and burn amount as a public delta. This extension @@ -24,7 +24,7 @@ pragma language_version >= 0.23.0; * only that total. Between attestations, individual amounts stay hidden; * batching gives issuance amounts k-anonymity at the attestation cadence. * - * @notice Pairs with `ConfidentialNoteToken`. The consuming contract + * @notice Pairs with `ConfidentialNoteFungibleToken`. The consuming contract * composes the pieces, calling the accounting block alongside the matching * token op: * @@ -58,7 +58,7 @@ pragma language_version >= 0.23.0; * * @dev NOT audited, NOT production. */ -module ConfidentialNoteTokenSupply { +module ConfidentialNoteFungibleTokenPrivateSupply { import CompactStandardLibrary; import "../../crypto/ElGamal" prefix ElGamal_; @@ -89,9 +89,9 @@ module ConfidentialNoteTokenSupply { * @circuitInfo k=11, rows=1167 */ export circuit initialize(supplyKey: JubjubPoint): [] { - assert(!_isInitialized, "ConfidentialNoteTokenSupply: already initialized"); + assert(!_isInitialized, "ConfidentialNoteFungibleTokenPrivateSupply: already initialized"); assert(supplyKey != ecMulGenerator(0 as Field), - "ConfidentialNoteTokenSupply: identity supply key"); + "ConfidentialNoteFungibleTokenPrivateSupply: identity supply key"); _supplyKey = disclose(supplyKey); _encSupply = ElGamal_encryptZero(); _isInitialized = true; @@ -155,7 +155,33 @@ module ConfidentialNoteTokenSupply { _attestationCount = disclose(_attestationCount + 1 as Uint<64>); } + /** + * @description Building block: self-rotation — the current supply-key + * holder proves the secret AND the exact running total, and the encrypted + * supply is re-encrypted under the new key in the same proof (a ciphertext + * under the old key cannot be updated homomorphically by the new holder). + * `total` is a private circuit argument: it is never disclosed, only bound + * into the new ciphertext. Production deployments gate this behind + * governance too. + * + * Requirements: + * + * - Extension is initialized. + * - `newKey` is not the identity point. + * - The caller proves the CURRENT supply-key secret and `_encSupply` + * decrypts to `total`. + */ + export circuit _rotateSupplyKey(newKey: JubjubPoint, total: Uint<128>): [] { + assertInitialized(); + assert(newKey != ecMulGenerator(0 as Field), + "ConfidentialNoteFungibleTokenPrivateSupply: identity supply key"); + ElGamal_assertDecryptsTo(_encSupply, _supplyKey, wit_SupplyKeySecret(), total); + const r = ElGamal_expandRandomness(wit_SupplyRandomness(), pad(32, "OZ:cnt:supply:rot")); + _encSupply = disclose(ElGamal_encrypt(newKey, total, r)); + _supplyKey = disclose(newKey); + } + circuit assertInitialized(): [] { - assert(_isInitialized, "ConfidentialNoteTokenSupply: extension not initialized"); + assert(_isInitialized, "ConfidentialNoteFungibleTokenPrivateSupply: extension not initialized"); } } diff --git a/contracts/src/token/extensions/ConfidentialNoteFungibleTokenReview.compact b/contracts/src/token/extensions/ConfidentialNoteFungibleTokenReview.compact new file mode 100644 index 000000000..4317245ca --- /dev/null +++ b/contracts/src/token/extensions/ConfidentialNoteFungibleTokenReview.compact @@ -0,0 +1,170 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts (token/extensions/ConfidentialNoteFungibleTokenReview.compact) + +pragma language_version >= 0.23.0; + +/** + * @module ConfidentialNoteFungibleTokenReview + * @description Optional standalone extension adding SELECTIVE disclosure to a + * `ConfidentialNoteFungibleToken` pool: per-output encrypted records to an + * approved REVIEWER key (a custodian or FIU), alongside — not replacing — the + * global audit channel. Where the audit key sees everything, a reviewer sees + * only the outputs explicitly addressed to it. It imports no token module. + * + * The composing contract gates `_addReviewer` / `_removeReviewer` (admin) and + * emits a review record next to its emission policy where its deployment + * rules require one (a compile-time variant — in ZK an optional emission + * still pays its constraints, so it cannot be a runtime flag): + * + * const nonce = Audit__emitAuditedOutput(ownerPk, value, slot); + * Review__emitReviewRecord(reviewerKey, ownerPk, value, nonce, slot); + * Core__mintNote(Core_Note { value: value, nonce: nonce }, ownerPk); + * + * Unlike the audit channel, the note's nonce is NOT derived here (the audit + * ECDH owns nonce derivation); it rides an explicit ciphertext, so the + * reviewer recovers `(value, ownerPk, nonce)` and can recompute the note's + * commitment and nullifier — full visibility over exactly the outputs + * addressed to it. + * + * @dev Disclosure caveat (draft): each record publishes WHICH approved + * reviewer can open it (`reviewerKeyHash`), so custodian affiliation is + * public per output. Hiding the reviewer behind a Merkle membership proof is + * the known Phase-2 refinement, pending the selective-disclosure design + * review. + * + * @dev `wit_ReviewRandomness` MUST return a fresh, secret seed per + * invocation; ephemerals expand from it (see `crypto/EcdhMask` freshness + * rules). `slot` domain-separates ephemerals when one transaction emits + * several outputs. + * + * @dev NOT audited, NOT production. + */ +module ConfidentialNoteFungibleTokenReview { + import CompactStandardLibrary; + import "../../crypto/EcdhMask" prefix EcdhMask_; + + // Per-output review ciphertext: the note's value, owner, and nonce + // encrypted to one approved reviewer key. + export struct ReviewRecord { + reviewerKeyHash: Bytes<32>; + ephemeralPk: JubjubPoint; + valueCt: Field; + ownerCt: Field; + nonceCt: Field; + } + + // The reviewer's recovered view of one output (see `recoverReviewRecord`). + export struct ReviewView { + value: Field; + ownerPk: Field; + nonce: Field; + } + + // Approved reviewer keys, stored as point hashes (see `reviewerKeyHashOf`). + export ledger _reviewers: Set>; + // Per-output records, for observation: reviewers scan this list filtering + // by their own key hash. It is this extension's event substitute. + export ledger _reviewTrail: List; + + // Randomness seed for review ephemerals. MUST be fresh + secret per + // invocation (see module doc). + witness wit_ReviewRandomness(): Bytes<32>; + + /** + * @description Registry key for a reviewer: `H(reviewerKey)`. Exported so + * admins and reviewers derive registry entries and scan filters the way the + * circuits do. + */ + export pure circuit reviewerKeyHashOf(reviewerKey: JubjubPoint): Bytes<32> { + return persistentHash(reviewerKey); + } + + /** + * @description UNGATED building block: approves a reviewer key. The + * composer gates who may administer the registry. + * + * Requirements: + * + * - `reviewerKey` is not the identity point. + * - `reviewerKey` is not already approved. + */ + export circuit _addReviewer(reviewerKey: JubjubPoint): [] { + assert(reviewerKey != ecMulGenerator(0 as Field), + "ConfidentialNoteFungibleTokenReview: identity reviewer key"); + const keyHash = reviewerKeyHashOf(reviewerKey); + assert(!_reviewers.member(disclose(keyHash)), + "ConfidentialNoteFungibleTokenReview: already a reviewer"); + _reviewers.insert(disclose(keyHash)); + } + + /** + * @description UNGATED building block: revokes a reviewer key. Existing + * records stay readable by the revoked key (published ciphertexts cannot be + * clawed back); revocation only stops NEW records. The composer gates who + * may administer the registry. + * + * Requirements: + * + * - `reviewerKey` is an approved reviewer. + */ + export circuit _removeReviewer(reviewerKey: JubjubPoint): [] { + const keyHash = reviewerKeyHashOf(reviewerKey); + assert(_reviewers.member(disclose(keyHash)), + "ConfidentialNoteFungibleTokenReview: not a reviewer"); + _reviewers.remove(disclose(keyHash)); + } + + /** + * @description Emits one output's review record to `reviewerKey`: value, + * owner, and nonce one-time-padded to an ECDH shared secret with the + * reviewer key. The record is only accepted for an APPROVED reviewer. + * + * Requirements: + * + * - `reviewerKey` is an approved reviewer. + */ + export circuit _emitReviewRecord( + reviewerKey: JubjubPoint, + ownerPk: Field, + value: Uint<128>, + nonce: Field, + slot: Bytes<32> + ): [] { + const keyHash = reviewerKeyHashOf(reviewerKey); + assert(_reviewers.member(disclose(keyHash)), + "ConfidentialNoteFungibleTokenReview: not a reviewer"); + + const er = degradeToTransient(persistentHash>>( + [wit_ReviewRandomness(), pad(32, "OZ:cnt:er"), slot])); + const erPk = ecMulGenerator(er); + // Point guard subsumes `er != 0` (see crypto/EcdhMask weak-inputs note); + // the registry's identity-key guard covers the other weak input. + assert(erPk != ecMulGenerator(0 as Field), + "ConfidentialNoteFungibleTokenReview: zero review ephemeral"); + const shared = ecMul(reviewerKey, er); + + _reviewTrail.pushFront(disclose(ReviewRecord { + reviewerKeyHash: keyHash, + ephemeralPk: erPk, + valueCt: (value as Field) + EcdhMask_kdf(shared, pad(32, "OZ:cnt:r:value")), + ownerCt: ownerPk + EcdhMask_kdf(shared, pad(32, "OZ:cnt:r:owner")), + nonceCt: nonce + EcdhMask_kdf(shared, pad(32, "OZ:cnt:r:nonce")) + })); + } + + /** + * @description Reviewer-side: recover one output's `(value, ownerPk, nonce)` + * from a ReviewRecord using the reviewer secret scalar + * (`reviewerKey = g^reviewSk`). Pure and off-chain; feeding the result to + * the token core's `commitOf` / `nullifierOf` reconstructs the note's + * lifecycle. + */ + export pure circuit recoverReviewRecord(record: ReviewRecord, reviewSk: Field): ReviewView { + const shared = ecMul(record.ephemeralPk, reviewSk); + return ReviewView { + value: record.valueCt - EcdhMask_kdf(shared, pad(32, "OZ:cnt:r:value")), + ownerPk: record.ownerCt - EcdhMask_kdf(shared, pad(32, "OZ:cnt:r:owner")), + nonce: record.nonceCt - EcdhMask_kdf(shared, pad(32, "OZ:cnt:r:nonce")) + }; + } +} diff --git a/contracts/src/token/presets/RegulatedConfidentialNoteFungibleToken.compact b/contracts/src/token/presets/RegulatedConfidentialNoteFungibleToken.compact new file mode 100644 index 000000000..999038dbb --- /dev/null +++ b/contracts/src/token/presets/RegulatedConfidentialNoteFungibleToken.compact @@ -0,0 +1,333 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts (token/presets/RegulatedConfidentialNoteFungibleToken.compact) + +pragma language_version >= 0.23.0; + +/** + * @module RegulatedConfidentialNoteFungibleToken + * @description DRAFT ready-to-use module: the tier-4 confidential note token + * wired for a regulated deployment. A consuming contract imports it, binds + * every role key via `initialize` (typically from its constructor), and + * exposes the circuits it wants public: + * + * import "./presets/RegulatedConfidentialNoteFungibleToken" prefix Token_; + * + * constructor(issuerPk: Field, authorityPk: Field, auditKey: JubjubPoint, supplyKey: JubjubPoint) { + * Token_initialize(issuerPk, authorityPk, auditKey, supplyKey); + * } + * + * export circuit mint(recipientPk: Field, recipientEncPk: JubjubPoint, value: Uint<128>): [] { + * return Token_mint(recipientPk, recipientEncPk, value); + * } + * // ...and likewise for transfer, burn, seize, attestSupply. + * + * Guarantees: full graph privacy for users (amounts — including issuance and + * burns — senders, and recipients hidden), complete visibility for a + * designated auditor, escrow-free seizure for a designated authority, and + * confidential-but-attestable supply. + * + * Composition (each piece is independently reusable): + * + * - `ConfidentialNoteFungibleToken` — the role-free token core (conservation, + * single-spend); this preset drives its `_`-building blocks so output nonces + * come from the audit channel instead of the core default, + * - `extensions/ConfidentialNoteFungibleTokenIssuer` — the single-issuer gate + * in front of value creation, + * - `extensions/ConfidentialNoteFungibleTokenAudit` — mandatory auditor viewing; each + * output nonce derives from the audit ECDH, so every note this contract + * creates is auditor-recoverable BY CONSTRUCTION, + * - `extensions/ConfidentialNoteFungibleTokenDelivery` — on-chain note delivery, so + * recipients discover funds from chain data alone (no out-of-band channel), + * - `extensions/ConfidentialNoteFungibleTokenPrivateSupply` — homomorphic encrypted supply + * with proof-backed public attestation. + * + * Roles: the ISSUER may mint, the AUTHORITY may seize, the AUDIT key reads + * everything (never spends), the SUPPLY key attests totals. All four bind in + * one `initialize` call, and each can later SELF-ROTATE by proving its + * current secret (`rotateIssuer` / `rotateAuthority` / `rotateAuditKey` / + * `rotateSupplyKey`); production deployments gate rotation behind governance + * too. + * + * No state-changing circuit is usable before `initialize`: mint asserts the + * issuer extension's initialization, every output emission asserts the audit + * extension's, supply updates assert the supply extension's, and the zero + * authority key has no known preimage. + * + * Seizure needs no key escrow: the core nullifier depends only on the nonce, + * so the owner and the authority derive the SAME nullifier, making owner-spend + * and seizure mutually exclusive (first to land wins). The authority learns + * the target note from the audit trail and re-mints its value to a recovery + * owner, itself audited and delivered. + * + * What the public sees: commitment inserts, nullifiers, ciphertexts, the + * seizure counter, and attested supply totals. Amounts, senders, and + * recipients stay hidden. + * + * See `token/docs/confidential-note-token.md` for the full design and the + * auditor/compliance rationale. + * + * @dev The module re-exports the composed modules' observable ledger state + * and artifact types under stable bare names (`Note`, `AuditRecord`, + * `AuditView`, `FullDelivery`), so a consuming contract surfaces them with a + * single selective import + export. + * + * @dev The randomness witnesses (`wit_AuditRandomness`, + * `wit_DeliveryRandomness`, `wit_SupplyRandomness`) MUST each return a fresh, + * secret seed per invocation (see `crypto/EcdhMask` freshness rules). + * + * @dev NOT audited, NOT production. + */ +module RegulatedConfidentialNoteFungibleToken { + import CompactStandardLibrary; + import "../ConfidentialNoteFungibleToken" prefix Core_; + import "../extensions/ConfidentialNoteFungibleTokenIssuer" prefix Issuer_; + import "../extensions/ConfidentialNoteFungibleTokenAudit" prefix Audit_; + import "../extensions/ConfidentialNoteFungibleTokenDelivery" prefix Delivery_; + import "../extensions/ConfidentialNoteFungibleTokenPrivateSupply" prefix Supply_; + import "../../crypto/NoteDelivery" prefix NoteDelivery_; + + // Surface the composed modules' observable state and artifact types under + // stable bare names, for wallets (commitment tree, deliveries), auditors + // (audit trail), and indexers (issuer key, supply): a prefix-only import + // would keep them out of a consumer's generated ledger reader. + import { Note, _commitments, _nullifiers } from "../ConfidentialNoteFungibleToken"; + import { _issuerPk } from "../extensions/ConfidentialNoteFungibleTokenIssuer"; + import { + AuditRecord, + AuditView, + _auditKey, + _auditTrail + } from "../extensions/ConfidentialNoteFungibleTokenAudit"; + import { _deliveries } from "../extensions/ConfidentialNoteFungibleTokenDelivery"; + import { + _supplyKey, + _encSupply, + _attestedSupply, + _attestationCount + } from "../extensions/ConfidentialNoteFungibleTokenPrivateSupply"; + import { FullDelivery } from "../../crypto/NoteDelivery"; + export { Note, AuditRecord, AuditView, FullDelivery }; + export { + _issuerPk, + _commitments, + _nullifiers, + _auditKey, + _auditTrail, + _deliveries, + _supplyKey, + _encSupply, + _attestedSupply, + _attestationCount + }; + + export ledger _isInitialized: Boolean; + // Global seizure authority (`Hf(authoritySecret)`; governance-gated in a real + // deployment) and an auditable count of seizures performed. + export ledger _authorityPk: Field; + export ledger _seizureCount: Uint<64>; + + // The seizure authority's secret (authorityPk = Hf(authoritySecret)). + witness wit_AuthoritySecret(): Bytes<32>; + + /** + * @description One-shot initialization binding all four roles: the issuer + * (may mint), the seizure authority (may claw back), the audit key + * (decrypt-only viewing), and the supply key (attestation). A consuming + * contract typically calls this from its constructor, so the deployed token + * never exists ungoverned. + * + * Requirements: + * + * - Module is not already initialized. + */ + export circuit initialize( + issuerPk: Field, + authorityPk: Field, + auditKey: JubjubPoint, + supplyKey: JubjubPoint + ): [] { + assert(!_isInitialized, "RegulatedConfidentialNoteFungibleToken: already initialized"); + Issuer_initialize(issuerPk); + Audit_initialize(auditKey); + Supply_initialize(supplyKey); + _authorityPk = disclose(authorityPk); + _isInitialized = true; + } + + /** + * @description Mints a note of `value` to `recipientPk`, audited and + * delivered. The minted amount is NOT written to public state — issuance + * stays hidden (unlike native shielded tokens, whose `shieldedMints` effect + * publishes it); the encrypted supply absorbs it homomorphically and the + * auditor reads it from the audit record. + * + * Requirements: + * + * - The caller proves the issuer secret. + * + * @circuitInfo k=17, rows=69322 + */ + export circuit mint(recipientPk: Field, recipientEncPk: JubjubPoint, value: Uint<128>): [] { + Issuer__assertIssuer(); + const note = emitOutput(recipientPk, recipientEncPk, value, pad(32, "OZ:cnt:out")); + Core__mintNote(note, recipientPk); + Supply__addMinted(value); + } + + /** + * @description Fully-private transfer: consumes the caller's input note and + * creates a recipient note of `value` plus a change note back to the sender, + * conserving value — both audited and delivered. Sender and recipient are + * hidden; the public ledger gains one nullifier, two commitments, and their + * ciphertexts. + * + * Requirements: + * + * - The input note is committed in the tree and unspent. + * - `value <= input.value`. + * + * @circuitInfo k=18, rows=135775 + */ + export circuit transfer( + recipientPk: Field, + recipientEncPk: JubjubPoint, + senderEncPk: JubjubPoint, + value: Uint<128> + ): [] { + const pk = Core__spenderPk(); + + // Peek at the input to size the change; the core re-reads the same witness + // and enforces conservation against it. + const input = Core__inputNote(); + assert(input.value >= value, + "RegulatedConfidentialNoteFungibleToken: insufficient note value"); + const changeValue = (input.value - value) as Uint<128>; + + const outNote = emitOutput(recipientPk, recipientEncPk, value, pad(32, "OZ:cnt:out")); + const changeNote = emitOutput(pk, senderEncPk, changeValue, pad(32, "OZ:cnt:chg")); + Core__transfer(pk, recipientPk, outNote, changeNote); + } + + /** + * @description Burns `value` from the caller's input note: consumes the note + * and re-issues only the change (audited and delivered), so `value` leaves + * circulation. Both the burned amount and the burner stay hidden; publicly a + * burn looks like any other spend. The encrypted supply absorbs the decrease. + * + * Requirements: + * + * - The input note is committed in the tree and unspent. + * - `value <= input.value`. + * + * @circuitInfo k=17, rows=82803 + */ + export circuit burn(senderEncPk: JubjubPoint, value: Uint<128>): [] { + const pk = Core__spenderPk(); + + const input = Core__inputNote(); + assert(input.value >= value, + "RegulatedConfidentialNoteFungibleToken: insufficient note value"); + const changeValue = (input.value - value) as Uint<128>; + + const changeNote = emitOutput(pk, senderEncPk, changeValue, pad(32, "OZ:cnt:chg")); + Core__burn(pk, value, changeNote); + Supply__addBurned(value); + } + + /** + * @description Regulated clawback: the authority consumes a target note it + * learned from the audit trail (supplied as the core's input-note witness) + * and re-mints the full value to `recoveryPk`, with the recovery note itself + * audited + delivered. Owner-spend and seizure race on the SAME nullifier, so + * they are mutually exclusive; the authority never needs the owner's spend + * secret. Value is conserved, and `_seizureCount` records the action + * publicly. + * + * In production the authority key is governance-gated (multisig), and + * per-user recovery keys would replace this single global key for least + * privilege. + * + * Requirements: + * + * - The caller proves the authority secret (`Hf(secret) == _authorityPk`). + * - The target note (owner pk + value + nonce) is committed and unspent. + * + * @circuitInfo k=17, rows=75043 + */ + export circuit seize(targetOwnerPk: Field, recoveryPk: Field, recoveryEncPk: JubjubPoint): [] { + assert(Core_derivePk(wit_AuthoritySecret()) == _authorityPk, + "RegulatedConfidentialNoteFungibleToken: not the authority"); + + const target = Core__consumeNote(targetOwnerPk); + const recoveryNote = emitOutput(recoveryPk, recoveryEncPk, target.value, pad(32, "OZ:cnt:out")); + Core__mintNote(recoveryNote, recoveryPk); + + _seizureCount = disclose(_seizureCount + 1 as Uint<64>); + } + + /** + * @description Publishes a proof-backed public supply total (see + * `ConfidentialNoteFungibleTokenPrivateSupply`). Run at a chosen cadence for public, + * non-inflatable supply while per-transaction amounts stay hidden. + * + * Requirements: + * + * - The caller proves the supply-key secret and the exact total. + * + * @circuitInfo k=13, rows=4720 + */ + export circuit attestSupply(total: Uint<128>): [] { + Supply_attestSupply(total); + } + + /** + * @description Rotates the issuer key: the current issuer proves their + * secret and binds `newIssuerPk` (see the Issuer extension). + */ + export circuit rotateIssuer(newIssuerPk: Field): [] { + Issuer__rotateIssuer(newIssuerPk); + } + + /** + * @description Rotates the seizure authority: the current authority proves + * their secret and binds `newAuthorityPk`. + * + * Requirements: + * + * - The caller proves the CURRENT authority secret. + */ + export circuit rotateAuthority(newAuthorityPk: Field): [] { + assert(Core_derivePk(wit_AuthoritySecret()) == _authorityPk, + "RegulatedConfidentialNoteFungibleToken: not the authority"); + _authorityPk = disclose(newAuthorityPk); + } + + /** + * @description Rotates the audit key: the current holder proves the audit + * secret scalar and binds `newKey`. Prior records stay readable by the old + * key; later outputs derive nonces from the new one (see the Audit + * extension). + */ + export circuit rotateAuditKey(newKey: JubjubPoint): [] { + Audit__rotateAuditKey(newKey); + } + + /** + * @description Rotates the supply key: the current holder proves the secret + * and the exact (undisclosed) running total, and `_encSupply` is + * re-encrypted under `newKey` in the same proof (see the PrivateSupply + * extension). + */ + export circuit rotateSupplyKey(newKey: JubjubPoint, total: Uint<128>): [] { + Supply__rotateSupplyKey(newKey, total); + } + + // Emission policy for one output note: the audit record derives the nonce, + // the delivery makes the note discoverable. Returns the note for the core to + // commit. + circuit emitOutput(ownerPk: Field, encPk: JubjubPoint, value: Uint<128>, slot: Bytes<32>): Core_Note { + const nonce = Audit__emitAuditedOutput(ownerPk, value, slot); + Delivery__deliver(encPk, value, nonce, slot); + return Core_Note { value: value, nonce: nonce }; + } +} diff --git a/contracts/src/token/presets/RegulatedConfidentialNoteToken.compact b/contracts/src/token/presets/RegulatedConfidentialNoteToken.compact deleted file mode 100644 index 992ba2b1b..000000000 --- a/contracts/src/token/presets/RegulatedConfidentialNoteToken.compact +++ /dev/null @@ -1,249 +0,0 @@ -// SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts (token/presets/RegulatedConfidentialNoteToken.compact) - -pragma language_version >= 0.23.0; - -/** - * @contract RegulatedConfidentialNoteToken - * @description DRAFT plug-and-play contract: the tier-4 confidential note - * token wired for a regulated deployment. Deploy it as-is — the constructor - * binds every role key; there is no separate initialization step. - * - * Guarantees: full graph privacy for users (amounts — including issuance and - * burns — senders, and recipients hidden), complete visibility for a - * designated auditor, escrow-free seizure for a designated authority, and - * confidential-but-attestable supply. - * - * Composition (each piece is independently reusable): - * - * - `ConfidentialNoteToken` — the token core (conservation, single-spend, - * issuer gate); this preset drives its `_`-building blocks so output nonces - * come from the audit channel instead of the core default, - * - `extensions/ConfidentialNoteTokenAudit` — mandatory auditor viewing; each - * output nonce derives from the audit ECDH, so every note this contract - * creates is auditor-recoverable BY CONSTRUCTION, - * - `extensions/ConfidentialNoteTokenDelivery` — on-chain note delivery, so - * recipients discover funds from chain data alone (no out-of-band channel), - * - `extensions/ConfidentialNoteTokenSupply` — homomorphic encrypted supply - * with proof-backed public attestation. - * - * Roles: the ISSUER may mint, the AUTHORITY may seize, the AUDIT key reads - * everything (never spends), the SUPPLY key attests totals. All four bind at - * deploy time. - * - * Seizure needs no key escrow: the core nullifier depends only on the nonce, - * so the owner and the authority derive the SAME nullifier, making owner-spend - * and seizure mutually exclusive (first to land wins). The authority learns - * the target note from the audit trail and re-mints its value to a recovery - * owner, itself audited and delivered. - * - * What the public sees: commitment inserts, nullifiers, ciphertexts, the - * seizure counter, and attested supply totals. Amounts, senders, and - * recipients stay hidden. - * - * See `token/docs/confidential-note-token.md` for the full design and the - * auditor/compliance rationale. - * - * @dev The randomness witnesses (`wit_AuditRandomness`, - * `wit_DeliveryRandomness`, `wit_SupplyRandomness`) MUST each return a fresh, - * secret seed per invocation (see `crypto/EcdhMask` freshness rules). - * - * @dev NOT audited, NOT production. - */ - -import CompactStandardLibrary; -import "../ConfidentialNoteToken" prefix CNT_; -import "../extensions/ConfidentialNoteTokenAudit" prefix Audit_; -import "../extensions/ConfidentialNoteTokenDelivery" prefix Delivery_; -import "../extensions/ConfidentialNoteTokenSupply" prefix Supply_; -import "../../crypto/NoteDelivery" prefix NoteDelivery_; - -// Surface the composed modules' observable state under stable names, for -// wallets (commitment tree, deliveries), auditors (audit trail), and -// indexers (supply): a prefix-only import would keep it out of the generated -// ledger reader. -import { - _commitments, - _nullifiers -} from "../ConfidentialNoteToken"; -import { _auditKey, _auditTrail } from "../extensions/ConfidentialNoteTokenAudit"; -import { _deliveries } from "../extensions/ConfidentialNoteTokenDelivery"; -import { - _supplyKey, - _encSupply, - _attestedSupply, - _attestationCount -} from "../extensions/ConfidentialNoteTokenSupply"; -export { - _commitments, - _nullifiers, - _auditKey, - _auditTrail, - _deliveries, - _supplyKey, - _encSupply, - _attestedSupply, - _attestationCount -}; - -export { CNT_Note, Audit_AuditRecord, Audit_AuditView, NoteDelivery_FullDelivery } - -// Global seizure authority (`Hf(authoritySecret)`; governance-gated in a real -// deployment) and an auditable count of seizures performed. -export ledger _authorityPk: Field; -export ledger _seizureCount: Uint<64>; - -// The seizure authority's secret (authorityPk = Hf(authoritySecret)). -witness wit_AuthoritySecret(): Bytes<32>; - -/** - * @description Binds all four roles at deploy time: the issuer (may mint), - * the seizure authority (may claw back), the audit key (decrypt-only - * viewing), and the supply key (attestation). - */ -constructor( - issuerPk: Field, - authorityPk: Field, - auditKey: JubjubPoint, - supplyKey: JubjubPoint -) { - CNT_initialize(issuerPk); - Audit_initialize(auditKey); - Supply_initialize(supplyKey); - _authorityPk = disclose(authorityPk); -} - -/** - * @description Mints a note of `value` to `recipientPk`, audited and - * delivered. The minted amount is NOT written to public state — issuance - * stays hidden (unlike native shielded tokens, whose `shieldedMints` effect - * publishes it); the encrypted supply absorbs it homomorphically and the - * auditor reads it from the audit record. - * - * Requirements: - * - * - The caller proves the issuer secret. - * - * @circuitInfo k=17, rows=69322 - */ -export circuit mint(recipientPk: Field, recipientEncPk: JubjubPoint, value: Uint<128>): [] { - CNT__assertIssuer(); - const note = emitOutput(recipientPk, recipientEncPk, value, pad(32, "OZ:cnt:out")); - CNT__mint(note, recipientPk); - Supply__addMinted(value); -} - -/** - * @description Fully-private transfer: consumes the caller's input note and - * creates a recipient note of `value` plus a change note back to the sender, - * conserving value — both audited and delivered. Sender and recipient are - * hidden; the public ledger gains one nullifier, two commitments, and their - * ciphertexts. - * - * Requirements: - * - * - The input note is committed in the tree and unspent. - * - `value <= input.value`. - * - * @circuitInfo k=18, rows=135775 - */ -export circuit transfer( - recipientPk: Field, - recipientEncPk: JubjubPoint, - senderEncPk: JubjubPoint, - value: Uint<128> -): [] { - const pk = CNT__spenderPk(); - - // Peek at the input to size the change; the core re-reads the same witness - // and enforces conservation against it. - const input = CNT__inputNote(); - assert(input.value >= value, - "RegulatedConfidentialNoteToken: insufficient note value"); - const changeValue = (input.value - value) as Uint<128>; - - const outNote = emitOutput(recipientPk, recipientEncPk, value, pad(32, "OZ:cnt:out")); - const changeNote = emitOutput(pk, senderEncPk, changeValue, pad(32, "OZ:cnt:chg")); - CNT__transfer(pk, recipientPk, outNote, changeNote); -} - -/** - * @description Burns `value` from the caller's input note: consumes the note - * and re-issues only the change (audited and delivered), so `value` leaves - * circulation. Both the burned amount and the burner stay hidden; publicly a - * burn looks like any other spend. The encrypted supply absorbs the decrease. - * - * Requirements: - * - * - The input note is committed in the tree and unspent. - * - `value <= input.value`. - * - * @circuitInfo k=17, rows=82803 - */ -export circuit burn(senderEncPk: JubjubPoint, value: Uint<128>): [] { - const pk = CNT__spenderPk(); - - const input = CNT__inputNote(); - assert(input.value >= value, - "RegulatedConfidentialNoteToken: insufficient note value"); - const changeValue = (input.value - value) as Uint<128>; - - const changeNote = emitOutput(pk, senderEncPk, changeValue, pad(32, "OZ:cnt:chg")); - CNT__burn(pk, value, changeNote); - Supply__addBurned(value); -} - -/** - * @description Regulated clawback: the authority consumes a target note it - * learned from the audit trail (supplied as the core's input-note witness) - * and re-mints the full value to `recoveryPk`, with the recovery note itself - * audited + delivered. Owner-spend and seizure race on the SAME nullifier, so - * they are mutually exclusive; the authority never needs the owner's spend - * secret. Value is conserved, and `_seizureCount` records the action - * publicly. - * - * In production the authority key is governance-gated (multisig), and - * per-user recovery keys would replace this single global key for least - * privilege. - * - * Requirements: - * - * - The caller proves the authority secret (`Hf(secret) == _authorityPk`). - * - The target note (owner pk + value + nonce) is committed and unspent. - * - * @circuitInfo k=17, rows=75043 - */ -export circuit seize(targetOwnerPk: Field, recoveryPk: Field, recoveryEncPk: JubjubPoint): [] { - assert(CNT_derivePk(wit_AuthoritySecret()) == _authorityPk, - "RegulatedConfidentialNoteToken: not the authority"); - - const target = CNT__consumeNote(targetOwnerPk); - const recoveryNote = emitOutput(recoveryPk, recoveryEncPk, target.value, pad(32, "OZ:cnt:out")); - CNT__mint(recoveryNote, recoveryPk); - - _seizureCount = disclose(_seizureCount + 1 as Uint<64>); -} - -/** - * @description Publishes a proof-backed public supply total (see - * `ConfidentialNoteTokenSupply`). Run at a chosen cadence for public, - * non-inflatable supply while per-transaction amounts stay hidden. - * - * Requirements: - * - * - The caller proves the supply-key secret and the exact total. - * - * @circuitInfo k=13, rows=4720 - */ -export circuit attestSupply(total: Uint<128>): [] { - Supply_attestSupply(total); -} - -// Emission policy for one output note: the audit record derives the nonce, -// the delivery makes the note discoverable. Returns the note for the core to -// commit. -circuit emitOutput(ownerPk: Field, encPk: JubjubPoint, value: Uint<128>, slot: Bytes<32>): CNT_Note { - const nonce = Audit__emitAuditedOutput(ownerPk, value, slot); - Delivery__deliver(encPk, value, nonce, slot); - return CNT_Note { value: value, nonce: nonce }; -} diff --git a/contracts/src/token/test/FungibleToken.test.ts b/contracts/src/token/test/FungibleToken.test.ts index 108de8b3e..adcbefc2e 100644 --- a/contracts/src/token/test/FungibleToken.test.ts +++ b/contracts/src/token/test/FungibleToken.test.ts @@ -354,66 +354,70 @@ describe('FungibleToken', () => { }); describe('_unsafeTransfer', () => { - describe.each( - recipientTypes, - )('when the recipient is a %s', (_, recipient) => { - beforeEach(async () => { - await token._mint(OWNER.either, AMOUNT); - expect(await token.balanceOf(OWNER.either)).toEqual(AMOUNT); - expect(await token.balanceOf(recipient)).toEqual(0n); - }); - - afterEach(async () => { - expect(await token.totalSupply()).toEqual(AMOUNT); - }); - - it('should transfer partial', async () => { - await token.privateState.injectSecretKey(OWNER.secretKey); - - const partialAmt = AMOUNT - 1n; - const txSuccess = await token._unsafeTransfer(recipient, partialAmt); + describe.each(recipientTypes)( + 'when the recipient is a %s', + (_, recipient) => { + beforeEach(async () => { + await token._mint(OWNER.either, AMOUNT); + expect(await token.balanceOf(OWNER.either)).toEqual(AMOUNT); + expect(await token.balanceOf(recipient)).toEqual(0n); + }); + + afterEach(async () => { + expect(await token.totalSupply()).toEqual(AMOUNT); + }); + + it('should transfer partial', async () => { + await token.privateState.injectSecretKey(OWNER.secretKey); + + const partialAmt = AMOUNT - 1n; + const txSuccess = await token._unsafeTransfer( + recipient, + partialAmt, + ); - expect(txSuccess).toBe(true); - expect(await token.balanceOf(OWNER.either)).toEqual(1n); - expect(await token.balanceOf(recipient)).toEqual(partialAmt); - }); + expect(txSuccess).toBe(true); + expect(await token.balanceOf(OWNER.either)).toEqual(1n); + expect(await token.balanceOf(recipient)).toEqual(partialAmt); + }); - it('should transfer full', async () => { - await token.privateState.injectSecretKey(OWNER.secretKey); + it('should transfer full', async () => { + await token.privateState.injectSecretKey(OWNER.secretKey); - const txSuccess = await token._unsafeTransfer(recipient, AMOUNT); + const txSuccess = await token._unsafeTransfer(recipient, AMOUNT); - expect(txSuccess).toBe(true); - expect(await token.balanceOf(OWNER.either)).toEqual(0n); - expect(await token.balanceOf(recipient)).toEqual(AMOUNT); - }); + expect(txSuccess).toBe(true); + expect(await token.balanceOf(OWNER.either)).toEqual(0n); + expect(await token.balanceOf(recipient)).toEqual(AMOUNT); + }); - it('should fail with insufficient balance', async () => { - await token.privateState.injectSecretKey(OWNER.secretKey); + it('should fail with insufficient balance', async () => { + await token.privateState.injectSecretKey(OWNER.secretKey); - await expect( - token._unsafeTransfer(recipient, AMOUNT + 1n), - ).rejects.toThrow('FungibleToken: insufficient balance'); - }); + await expect( + token._unsafeTransfer(recipient, AMOUNT + 1n), + ).rejects.toThrow('FungibleToken: insufficient balance'); + }); - it('should allow transfer of 0 tokens', async () => { - await token.privateState.injectSecretKey(OWNER.secretKey); + it('should allow transfer of 0 tokens', async () => { + await token.privateState.injectSecretKey(OWNER.secretKey); - const txSuccess = await token._unsafeTransfer(recipient, 0n); + const txSuccess = await token._unsafeTransfer(recipient, 0n); - expect(txSuccess).toBe(true); - expect(await token.balanceOf(OWNER.either)).toEqual(AMOUNT); - expect(await token.balanceOf(recipient)).toEqual(0n); - }); + expect(txSuccess).toBe(true); + expect(await token.balanceOf(OWNER.either)).toEqual(AMOUNT); + expect(await token.balanceOf(recipient)).toEqual(0n); + }); - it('should handle transfer with empty _balances', async () => { - await token.privateState.injectSecretKey(SPENDER.secretKey); + it('should handle transfer with empty _balances', async () => { + await token.privateState.injectSecretKey(SPENDER.secretKey); - await expect(token._unsafeTransfer(recipient, 1n)).rejects.toThrow( - 'FungibleToken: insufficient balance', - ); - }); - }); + await expect(token._unsafeTransfer(recipient, 1n)).rejects.toThrow( + 'FungibleToken: insufficient balance', + ); + }); + }, + ); it('should fail with transfer to zero (accountId)', async () => { await token._mint(OWNER.either, AMOUNT); @@ -637,89 +641,90 @@ describe('FungibleToken', () => { expect(await token.totalSupply()).toEqual(AMOUNT); }); - describe.each( - recipientTypes, - )('when the recipient is a %s', (_, recipient) => { - it('should transferFrom spender (partial)', async () => { - await token.privateState.injectSecretKey(SPENDER.secretKey); - - const partialAmt = AMOUNT - 1n; - const txSuccess = await token._unsafeTransferFrom( - OWNER.either, - recipient, - partialAmt, - ); - expect(txSuccess).toBe(true); - - expect(await token.balanceOf(OWNER.either)).toEqual(1n); - expect(await token.balanceOf(recipient)).toEqual(partialAmt); - expect(await token.allowance(OWNER.either, SPENDER.either)).toEqual( - 1n, - ); - }); + describe.each(recipientTypes)( + 'when the recipient is a %s', + (_, recipient) => { + it('should transferFrom spender (partial)', async () => { + await token.privateState.injectSecretKey(SPENDER.secretKey); - it('should transferFrom spender (full)', async () => { - await token.privateState.injectSecretKey(SPENDER.secretKey); - - const txSuccess = await token._unsafeTransferFrom( - OWNER.either, - recipient, - AMOUNT, - ); - expect(txSuccess).toBe(true); - - expect(await token.balanceOf(OWNER.either)).toEqual(0n); - expect(await token.balanceOf(recipient)).toEqual(AMOUNT); - expect(await token.allowance(OWNER.either, SPENDER.either)).toEqual( - 0n, - ); - }); - - it('should transferFrom and not consume infinite allowance', async () => { - await token.privateState.injectSecretKey(OWNER.secretKey); - await token.approve(SPENDER.either, MAX_UINT128); - - await token.privateState.injectSecretKey(SPENDER.secretKey); - const txSuccess = await token._unsafeTransferFrom( - OWNER.either, - recipient, - AMOUNT, - ); - expect(txSuccess).toBe(true); - - expect(await token.balanceOf(OWNER.either)).toEqual(0n); - expect(await token.balanceOf(recipient)).toEqual(AMOUNT); - expect(await token.allowance(OWNER.either, SPENDER.either)).toEqual( - MAX_UINT128, - ); - }); - - it('should fail when transfer amount exceeds allowance', async () => { - await token.privateState.injectSecretKey(SPENDER.secretKey); - - await expect( - token._unsafeTransferFrom(OWNER.either, recipient, AMOUNT + 1n), - ).rejects.toThrow('FungibleToken: insufficient allowance'); - }); - - it('should fail when transfer amount exceeds balance', async () => { - await token.privateState.injectSecretKey(OWNER.secretKey); - await token.approve(SPENDER.either, AMOUNT + 1n); + const partialAmt = AMOUNT - 1n; + const txSuccess = await token._unsafeTransferFrom( + OWNER.either, + recipient, + partialAmt, + ); + expect(txSuccess).toBe(true); - await token.privateState.injectSecretKey(SPENDER.secretKey); - await expect( - token._unsafeTransferFrom(OWNER.either, recipient, AMOUNT + 1n), - ).rejects.toThrow('FungibleToken: insufficient balance'); - }); + expect(await token.balanceOf(OWNER.either)).toEqual(1n); + expect(await token.balanceOf(recipient)).toEqual(partialAmt); + expect(await token.allowance(OWNER.either, SPENDER.either)).toEqual( + 1n, + ); + }); - it('should fail when spender does not have allowance', async () => { - await token.privateState.injectSecretKey(UNAUTHORIZED.secretKey); + it('should transferFrom spender (full)', async () => { + await token.privateState.injectSecretKey(SPENDER.secretKey); - await expect( - token._unsafeTransferFrom(OWNER.either, recipient, AMOUNT), - ).rejects.toThrow('FungibleToken: insufficient allowance'); - }); - }); + const txSuccess = await token._unsafeTransferFrom( + OWNER.either, + recipient, + AMOUNT, + ); + expect(txSuccess).toBe(true); + + expect(await token.balanceOf(OWNER.either)).toEqual(0n); + expect(await token.balanceOf(recipient)).toEqual(AMOUNT); + expect(await token.allowance(OWNER.either, SPENDER.either)).toEqual( + 0n, + ); + }); + + it('should transferFrom and not consume infinite allowance', async () => { + await token.privateState.injectSecretKey(OWNER.secretKey); + await token.approve(SPENDER.either, MAX_UINT128); + + await token.privateState.injectSecretKey(SPENDER.secretKey); + const txSuccess = await token._unsafeTransferFrom( + OWNER.either, + recipient, + AMOUNT, + ); + expect(txSuccess).toBe(true); + + expect(await token.balanceOf(OWNER.either)).toEqual(0n); + expect(await token.balanceOf(recipient)).toEqual(AMOUNT); + expect(await token.allowance(OWNER.either, SPENDER.either)).toEqual( + MAX_UINT128, + ); + }); + + it('should fail when transfer amount exceeds allowance', async () => { + await token.privateState.injectSecretKey(SPENDER.secretKey); + + await expect( + token._unsafeTransferFrom(OWNER.either, recipient, AMOUNT + 1n), + ).rejects.toThrow('FungibleToken: insufficient allowance'); + }); + + it('should fail when transfer amount exceeds balance', async () => { + await token.privateState.injectSecretKey(OWNER.secretKey); + await token.approve(SPENDER.either, AMOUNT + 1n); + + await token.privateState.injectSecretKey(SPENDER.secretKey); + await expect( + token._unsafeTransferFrom(OWNER.either, recipient, AMOUNT + 1n), + ).rejects.toThrow('FungibleToken: insufficient balance'); + }); + + it('should fail when spender does not have allowance', async () => { + await token.privateState.injectSecretKey(UNAUTHORIZED.secretKey); + + await expect( + token._unsafeTransferFrom(OWNER.either, recipient, AMOUNT), + ).rejects.toThrow('FungibleToken: insufficient allowance'); + }); + }, + ); it('should fail to transfer to the zero address (accountId)', async () => { await token.privateState.injectSecretKey(SPENDER.secretKey); @@ -771,44 +776,49 @@ describe('FungibleToken', () => { expect(await token.totalSupply()).toEqual(AMOUNT); }); - describe.each( - recipientTypes, - )('when the recipient is a %s', (_, recipient) => { - it('should update balances (partial)', async () => { - const partialAmt = AMOUNT - 1n; - await token._unsafeUncheckedTransfer( - OWNER.either, - recipient, - partialAmt, - ); - - expect(await token.balanceOf(OWNER.either)).toEqual(1n); - expect(await token.balanceOf(recipient)).toEqual(partialAmt); - }); - - it('should update balances (full)', async () => { - await token._unsafeUncheckedTransfer(OWNER.either, recipient, AMOUNT); + describe.each(recipientTypes)( + 'when the recipient is a %s', + (_, recipient) => { + it('should update balances (partial)', async () => { + const partialAmt = AMOUNT - 1n; + await token._unsafeUncheckedTransfer( + OWNER.either, + recipient, + partialAmt, + ); - expect(await token.balanceOf(OWNER.either)).toEqual(0n); - expect(await token.balanceOf(recipient)).toEqual(AMOUNT); - }); + expect(await token.balanceOf(OWNER.either)).toEqual(1n); + expect(await token.balanceOf(recipient)).toEqual(partialAmt); + }); - it('should fail when transfer amount exceeds balance', async () => { - await expect( - token._unsafeUncheckedTransfer( + it('should update balances (full)', async () => { + await token._unsafeUncheckedTransfer( OWNER.either, recipient, - AMOUNT + 1n, - ), - ).rejects.toThrow('FungibleToken: insufficient balance'); - }); - - it('should fail when transfer from zero', async () => { - await expect( - token._unsafeUncheckedTransfer(ZERO_CONTRACT, recipient, AMOUNT), - ).rejects.toThrow('FungibleToken: invalid sender'); - }); - }); + AMOUNT, + ); + + expect(await token.balanceOf(OWNER.either)).toEqual(0n); + expect(await token.balanceOf(recipient)).toEqual(AMOUNT); + }); + + it('should fail when transfer amount exceeds balance', async () => { + await expect( + token._unsafeUncheckedTransfer( + OWNER.either, + recipient, + AMOUNT + 1n, + ), + ).rejects.toThrow('FungibleToken: insufficient balance'); + }); + + it('should fail when transfer from zero', async () => { + await expect( + token._unsafeUncheckedTransfer(ZERO_CONTRACT, recipient, AMOUNT), + ).rejects.toThrow('FungibleToken: invalid sender'); + }); + }, + ); it('should fail when transfer to zero (accountId)', async () => { await expect( @@ -917,31 +927,32 @@ describe('FungibleToken', () => { }); describe('_unsafeMint', () => { - describe.each( - recipientTypes, - )('when the recipient is a %s', (_, recipient) => { - it('should mint and update supply', async () => { - expect(await token.totalSupply()).toEqual(0n); - - await token._unsafeMint(recipient, AMOUNT); - expect(await token.totalSupply()).toEqual(AMOUNT); - expect(await token.balanceOf(recipient)).toEqual(AMOUNT); - }); - - it('should catch mint overflow', async () => { - await token._unsafeMint(recipient, MAX_UINT128); - - await expect(token._unsafeMint(recipient, 1n)).rejects.toThrow( - 'FungibleToken: arithmetic overflow', - ); - }); - - it('should allow mint of 0 tokens', async () => { - await token._unsafeMint(recipient, 0n); - expect(await token.totalSupply()).toEqual(0n); - expect(await token.balanceOf(recipient)).toEqual(0n); - }); - }); + describe.each(recipientTypes)( + 'when the recipient is a %s', + (_, recipient) => { + it('should mint and update supply', async () => { + expect(await token.totalSupply()).toEqual(0n); + + await token._unsafeMint(recipient, AMOUNT); + expect(await token.totalSupply()).toEqual(AMOUNT); + expect(await token.balanceOf(recipient)).toEqual(AMOUNT); + }); + + it('should catch mint overflow', async () => { + await token._unsafeMint(recipient, MAX_UINT128); + + await expect(token._unsafeMint(recipient, 1n)).rejects.toThrow( + 'FungibleToken: arithmetic overflow', + ); + }); + + it('should allow mint of 0 tokens', async () => { + await token._unsafeMint(recipient, 0n); + expect(await token.totalSupply()).toEqual(0n); + expect(await token.balanceOf(recipient)).toEqual(0n); + }); + }, + ); it('should not mint to zero (accountId)', async () => { await expect(token._unsafeMint(ZERO_ACCOUNT, AMOUNT)).rejects.toThrow( diff --git a/contracts/src/token/test/MultiToken.test.ts b/contracts/src/token/test/MultiToken.test.ts index a7376e5ee..04461a34a 100644 --- a/contracts/src/token/test/MultiToken.test.ts +++ b/contracts/src/token/test/MultiToken.test.ts @@ -668,134 +668,139 @@ describe('MultiToken', () => { await token.privateState.injectSecretKey(caller.secretKey); }); - describe.each( - recipientTypes, - )('when the recipient is a %s', (_, recipient) => { - it('should transfer whole', async () => { - await token._unsafeTransferFrom( - OWNER.either, - recipient, - TOKEN_ID, - AMOUNT, - ); - - expect(await token.balanceOf(OWNER.either, TOKEN_ID)).toEqual(0n); - expect(await token.balanceOf(recipient, TOKEN_ID)).toEqual(AMOUNT); - }); - - it('should transfer partial', async () => { - const partialAmt = AMOUNT - 1n; - await token._unsafeTransferFrom( - OWNER.either, - recipient, - TOKEN_ID, - partialAmt, - ); - - expect(await token.balanceOf(OWNER.either, TOKEN_ID)).toEqual( - AMOUNT - partialAmt, - ); - expect(await token.balanceOf(recipient, TOKEN_ID)).toEqual( - partialAmt, - ); - }); - - it('should allow transfer of 0 tokens', async () => { - await token._unsafeTransferFrom( - OWNER.either, - recipient, - TOKEN_ID, - 0n, - ); - - expect(await token.balanceOf(OWNER.either, TOKEN_ID)).toEqual( - AMOUNT, - ); - expect(await token.balanceOf(recipient, TOKEN_ID)).toEqual(0n); - }); + describe.each(recipientTypes)( + 'when the recipient is a %s', + (_, recipient) => { + it('should transfer whole', async () => { + await token._unsafeTransferFrom( + OWNER.either, + recipient, + TOKEN_ID, + AMOUNT, + ); - it('should handle self-transfer', async () => { - await token._unsafeTransferFrom( - OWNER.either, - OWNER.either, - TOKEN_ID, - AMOUNT, - ); - expect(await token.balanceOf(OWNER.either, TOKEN_ID)).toEqual( - AMOUNT, - ); - }); + expect(await token.balanceOf(OWNER.either, TOKEN_ID)).toEqual(0n); + expect(await token.balanceOf(recipient, TOKEN_ID)).toEqual( + AMOUNT, + ); + }); - it('should handle MAX_UINT128 transfer amount', async () => { - await token._mint(OWNER.either, TOKEN_ID, MAX_UINT128 - AMOUNT); + it('should transfer partial', async () => { + const partialAmt = AMOUNT - 1n; + await token._unsafeTransferFrom( + OWNER.either, + recipient, + TOKEN_ID, + partialAmt, + ); - await token._unsafeTransferFrom( - OWNER.either, - recipient, - TOKEN_ID, - MAX_UINT128, - ); - expect(await token.balanceOf(recipient, TOKEN_ID)).toEqual( - MAX_UINT128, - ); - }); + expect(await token.balanceOf(OWNER.either, TOKEN_ID)).toEqual( + AMOUNT - partialAmt, + ); + expect(await token.balanceOf(recipient, TOKEN_ID)).toEqual( + partialAmt, + ); + }); - it('should handle rapid state changes', async () => { - await token.privateState.injectSecretKey(OWNER.secretKey); - await token.setApprovalForAll(SPENDER.either, true); + it('should allow transfer of 0 tokens', async () => { + await token._unsafeTransferFrom( + OWNER.either, + recipient, + TOKEN_ID, + 0n, + ); - await token._unsafeTransferFrom( - OWNER.either, - recipient, - TOKEN_ID, - AMOUNT, - ); - expect(await token.balanceOf(recipient, TOKEN_ID)).toEqual(AMOUNT); + expect(await token.balanceOf(OWNER.either, TOKEN_ID)).toEqual( + AMOUNT, + ); + expect(await token.balanceOf(recipient, TOKEN_ID)).toEqual(0n); + }); - await token.setApprovalForAll(SPENDER.either, false); - expect( - await token.isApprovedForAll(OWNER.either, SPENDER.either), - ).toBe(false); + it('should handle self-transfer', async () => { + await token._unsafeTransferFrom( + OWNER.either, + OWNER.either, + TOKEN_ID, + AMOUNT, + ); + expect(await token.balanceOf(OWNER.either, TOKEN_ID)).toEqual( + AMOUNT, + ); + }); - await token.setApprovalForAll(SPENDER.either, true); - expect( - await token.isApprovedForAll(OWNER.either, SPENDER.either), - ).toBe(true); - }); + it('should handle MAX_UINT128 transfer amount', async () => { + await token._mint(OWNER.either, TOKEN_ID, MAX_UINT128 - AMOUNT); - it('should fail with insufficient balance', async () => { - await expect( - token._unsafeTransferFrom( + await token._unsafeTransferFrom( OWNER.either, recipient, TOKEN_ID, - AMOUNT + 1n, - ), - ).rejects.toThrow('MultiToken: insufficient balance'); - }); - - it('should fail with nonexistent id', async () => { - await expect( - token._unsafeTransferFrom( + MAX_UINT128, + ); + expect(await token.balanceOf(recipient, TOKEN_ID)).toEqual( + MAX_UINT128, + ); + }); + + it('should handle rapid state changes', async () => { + await token.privateState.injectSecretKey(OWNER.secretKey); + await token.setApprovalForAll(SPENDER.either, true); + + await token._unsafeTransferFrom( OWNER.either, recipient, - NONEXISTENT_ID, - AMOUNT, - ), - ).rejects.toThrow('MultiToken: insufficient balance'); - }); - - it('should fail with transfer from zero', async () => { - await expect( - token._unsafeTransferFrom( - ZERO_ACCOUNT, - recipient, TOKEN_ID, AMOUNT, - ), - ).rejects.toThrow('MultiToken: unauthorized operator'); - }); - }); + ); + expect(await token.balanceOf(recipient, TOKEN_ID)).toEqual( + AMOUNT, + ); + + await token.setApprovalForAll(SPENDER.either, false); + expect( + await token.isApprovedForAll(OWNER.either, SPENDER.either), + ).toBe(false); + + await token.setApprovalForAll(SPENDER.either, true); + expect( + await token.isApprovedForAll(OWNER.either, SPENDER.either), + ).toBe(true); + }); + + it('should fail with insufficient balance', async () => { + await expect( + token._unsafeTransferFrom( + OWNER.either, + recipient, + TOKEN_ID, + AMOUNT + 1n, + ), + ).rejects.toThrow('MultiToken: insufficient balance'); + }); + + it('should fail with nonexistent id', async () => { + await expect( + token._unsafeTransferFrom( + OWNER.either, + recipient, + NONEXISTENT_ID, + AMOUNT, + ), + ).rejects.toThrow('MultiToken: insufficient balance'); + }); + + it('should fail with transfer from zero', async () => { + await expect( + token._unsafeTransferFrom( + ZERO_ACCOUNT, + recipient, + TOKEN_ID, + AMOUNT, + ), + ).rejects.toThrow('MultiToken: unauthorized operator'); + }); + }, + ); it('should fail with transfer to zero (id)', async () => { await expect( @@ -921,75 +926,81 @@ describe('MultiToken', () => { await token.privateState.injectSecretKey(UNAUTHORIZED.secretKey); }); - describe.each( - recipientTypes, - )('when recipient is %s', (_, recipient) => { - it('should fail when transfer whole', async () => { - await expect( - token._unsafeTransferFrom( - OWNER.either, - recipient, - TOKEN_ID, - AMOUNT, - ), - ).rejects.toThrow('MultiToken: unauthorized operator'); - }); - - it('should fail when transfer partial', async () => { - const partialAmt = AMOUNT - 1n; - await expect( - token._unsafeTransferFrom( - OWNER.either, - recipient, - TOKEN_ID, - partialAmt, - ), - ).rejects.toThrow('MultiToken: unauthorized operator'); - }); - - it('should fail when transfer zero', async () => { - await expect( - token._unsafeTransferFrom(OWNER.either, recipient, TOKEN_ID, 0n), - ).rejects.toThrow('MultiToken: unauthorized operator'); - }); - - it('should fail with insufficient balance', async () => { - await expect( - token._unsafeTransferFrom( - OWNER.either, - recipient, - TOKEN_ID, - AMOUNT + 1n, - ), - ).rejects.toThrow('MultiToken: unauthorized operator'); - }); - - it('should fail with nonexistent id', async () => { - await expect( - token._unsafeTransferFrom( - OWNER.either, - recipient, - NONEXISTENT_ID, - AMOUNT, - ), - ).rejects.toThrow('MultiToken: unauthorized operator'); - }); - - it('should fail with transfer from zero', async () => { - // With witness-based identity, the caller is H(sk) which is - // always non-zero. Transferring from ZERO_ACCOUNT means - // canonFrom != caller → isApprovedForAll(zeroAccount, caller) → false - // → "unauthorized operator" - await expect( - token._unsafeTransferFrom( - ZERO_ACCOUNT, - recipient, - TOKEN_ID, - AMOUNT, - ), - ).rejects.toThrow('MultiToken: unauthorized operator'); - }); - }); + describe.each(recipientTypes)( + 'when recipient is %s', + (_, recipient) => { + it('should fail when transfer whole', async () => { + await expect( + token._unsafeTransferFrom( + OWNER.either, + recipient, + TOKEN_ID, + AMOUNT, + ), + ).rejects.toThrow('MultiToken: unauthorized operator'); + }); + + it('should fail when transfer partial', async () => { + const partialAmt = AMOUNT - 1n; + await expect( + token._unsafeTransferFrom( + OWNER.either, + recipient, + TOKEN_ID, + partialAmt, + ), + ).rejects.toThrow('MultiToken: unauthorized operator'); + }); + + it('should fail when transfer zero', async () => { + await expect( + token._unsafeTransferFrom( + OWNER.either, + recipient, + TOKEN_ID, + 0n, + ), + ).rejects.toThrow('MultiToken: unauthorized operator'); + }); + + it('should fail with insufficient balance', async () => { + await expect( + token._unsafeTransferFrom( + OWNER.either, + recipient, + TOKEN_ID, + AMOUNT + 1n, + ), + ).rejects.toThrow('MultiToken: unauthorized operator'); + }); + + it('should fail with nonexistent id', async () => { + await expect( + token._unsafeTransferFrom( + OWNER.either, + recipient, + NONEXISTENT_ID, + AMOUNT, + ), + ).rejects.toThrow('MultiToken: unauthorized operator'); + }); + + it('should fail with transfer from zero', async () => { + // With witness-based identity, the caller is H(sk) which is + // always non-zero. Transferring from ZERO_ACCOUNT means + // canonFrom != caller → isApprovedForAll(zeroAccount, caller) → false + // → "unauthorized operator" + await expect( + token._unsafeTransferFrom( + ZERO_ACCOUNT, + recipient, + TOKEN_ID, + AMOUNT, + ), + ).rejects.toThrow('MultiToken: unauthorized operator'); + }); + }, + ); }); }); @@ -1120,79 +1131,82 @@ describe('MultiToken', () => { expect(await token.balanceOf(RECIPIENT.either, TOKEN_ID)).toEqual(0n); }); - describe.each( - recipientTypes, - )('when the recipient is a %s', (_, recipient) => { - it('should transfer whole', async () => { - await token._unsafeTransfer( - OWNER.either, - recipient, - TOKEN_ID, - AMOUNT, - ); - - expect(await token.balanceOf(OWNER.either, TOKEN_ID)).toEqual(0n); - expect(await token.balanceOf(recipient, TOKEN_ID)).toEqual(AMOUNT); - }); - - it('should transfer partial', async () => { - const partialAmt = AMOUNT - 1n; - await token._unsafeTransfer( - OWNER.either, - recipient, - TOKEN_ID, - partialAmt, - ); - - expect(await token.balanceOf(OWNER.either, TOKEN_ID)).toEqual( - AMOUNT - partialAmt, - ); - expect(await token.balanceOf(recipient, TOKEN_ID)).toEqual( - partialAmt, - ); - }); - - it('should allow transfer of 0 tokens', async () => { - await token._unsafeTransfer(OWNER.either, recipient, TOKEN_ID, 0n); - - expect(await token.balanceOf(OWNER.either, TOKEN_ID)).toEqual(AMOUNT); - expect(await token.balanceOf(recipient, TOKEN_ID)).toEqual(0n); - }); - - it('should fail with insufficient balance', async () => { - await expect( - token._unsafeTransfer( + describe.each(recipientTypes)( + 'when the recipient is a %s', + (_, recipient) => { + it('should transfer whole', async () => { + await token._unsafeTransfer( OWNER.either, recipient, TOKEN_ID, - AMOUNT + 1n, - ), - ).rejects.toThrow('MultiToken: insufficient balance'); - }); + AMOUNT, + ); - it('should fail with nonexistent id', async () => { - await expect( - token._unsafeTransfer( + expect(await token.balanceOf(OWNER.either, TOKEN_ID)).toEqual(0n); + expect(await token.balanceOf(recipient, TOKEN_ID)).toEqual(AMOUNT); + }); + + it('should transfer partial', async () => { + const partialAmt = AMOUNT - 1n; + await token._unsafeTransfer( OWNER.either, recipient, - NONEXISTENT_ID, + TOKEN_ID, + partialAmt, + ); + + expect(await token.balanceOf(OWNER.either, TOKEN_ID)).toEqual( + AMOUNT - partialAmt, + ); + expect(await token.balanceOf(recipient, TOKEN_ID)).toEqual( + partialAmt, + ); + }); + + it('should allow transfer of 0 tokens', async () => { + await token._unsafeTransfer(OWNER.either, recipient, TOKEN_ID, 0n); + + expect(await token.balanceOf(OWNER.either, TOKEN_ID)).toEqual( AMOUNT, - ), - ).rejects.toThrow('MultiToken: insufficient balance'); - }); + ); + expect(await token.balanceOf(recipient, TOKEN_ID)).toEqual(0n); + }); - it('should fail when transfer from 0 (id)', async () => { - await expect( - token._unsafeTransfer(ZERO_ACCOUNT, recipient, TOKEN_ID, AMOUNT), - ).rejects.toThrow('MultiToken: invalid sender'); - }); + it('should fail with insufficient balance', async () => { + await expect( + token._unsafeTransfer( + OWNER.either, + recipient, + TOKEN_ID, + AMOUNT + 1n, + ), + ).rejects.toThrow('MultiToken: insufficient balance'); + }); - it('should fail when transfer from 0 (contract address)', async () => { - await expect( - token._unsafeTransfer(ZERO_CONTRACT, recipient, TOKEN_ID, AMOUNT), - ).rejects.toThrow('MultiToken: invalid sender'); - }); - }); + it('should fail with nonexistent id', async () => { + await expect( + token._unsafeTransfer( + OWNER.either, + recipient, + NONEXISTENT_ID, + AMOUNT, + ), + ).rejects.toThrow('MultiToken: insufficient balance'); + }); + + it('should fail when transfer from 0 (id)', async () => { + await expect( + token._unsafeTransfer(ZERO_ACCOUNT, recipient, TOKEN_ID, AMOUNT), + ).rejects.toThrow('MultiToken: invalid sender'); + }); + + it('should fail when transfer from 0 (contract address)', async () => { + await expect( + token._unsafeTransfer(ZERO_CONTRACT, recipient, TOKEN_ID, AMOUNT), + ).rejects.toThrow('MultiToken: invalid sender'); + }); + }, + ); it('should handle non-canonical fromAddress (id)', async () => { const nonCanonical = nonCanonicalLeft(OWNER.accountId); @@ -1363,31 +1377,32 @@ describe('MultiToken', () => { }); describe('_unsafeMint', () => { - describe.each( - recipientTypes, - )('when the recipient is a %s', (_, recipient) => { - it('should update balance when minting', async () => { - await token._unsafeMint(recipient, TOKEN_ID, AMOUNT); + describe.each(recipientTypes)( + 'when the recipient is a %s', + (_, recipient) => { + it('should update balance when minting', async () => { + await token._unsafeMint(recipient, TOKEN_ID, AMOUNT); - expect(await token.balanceOf(recipient, TOKEN_ID)).toEqual(AMOUNT); - }); + expect(await token.balanceOf(recipient, TOKEN_ID)).toEqual(AMOUNT); + }); - it('should update balance with multiple mints', async () => { - for (let i = 0; i < 3; i++) { - await token._unsafeMint(recipient, TOKEN_ID, 1n); - } + it('should update balance with multiple mints', async () => { + for (let i = 0; i < 3; i++) { + await token._unsafeMint(recipient, TOKEN_ID, 1n); + } - expect(await token.balanceOf(recipient, TOKEN_ID)).toEqual(3n); - }); + expect(await token.balanceOf(recipient, TOKEN_ID)).toEqual(3n); + }); - it('should fail when overflowing uint128', async () => { - await token._unsafeMint(recipient, TOKEN_ID, MAX_UINT128); + it('should fail when overflowing uint128', async () => { + await token._unsafeMint(recipient, TOKEN_ID, MAX_UINT128); - await expect( - token._unsafeMint(recipient, TOKEN_ID, 1n), - ).rejects.toThrow('MultiToken: arithmetic overflow'); - }); - }); + await expect( + token._unsafeMint(recipient, TOKEN_ID, 1n), + ).rejects.toThrow('MultiToken: arithmetic overflow'); + }); + }, + ); it('should fail when minting to zero address (id)', async () => { await expect( diff --git a/contracts/src/token/test/NativeShieldedToken.test.ts b/contracts/src/token/test/NativeShieldedToken.test.ts index 962e2fe0f..41cc97ed8 100644 --- a/contracts/src/token/test/NativeShieldedToken.test.ts +++ b/contracts/src/token/test/NativeShieldedToken.test.ts @@ -92,13 +92,14 @@ describe('NativeShieldedToken (Fungible profile)', () => { ], ]; - it.each( - circuitsToFail, - )('should revert %s before initialize', async (method, args) => { - await expect( - (token[method] as (...a: unknown[]) => Promise)(...args), - ).rejects.toThrow('NativeShieldedToken: contract not initialized'); - }); + it.each(circuitsToFail)( + 'should revert %s before initialize', + async (method, args) => { + await expect( + (token[method] as (...a: unknown[]) => Promise)(...args), + ).rejects.toThrow('NativeShieldedToken: contract not initialized'); + }, + ); }); describe('_mint', () => { diff --git a/contracts/src/token/test/NativeShieldedTokenCore.test.ts b/contracts/src/token/test/NativeShieldedTokenCore.test.ts index ea8734e6e..db5e863e9 100644 --- a/contracts/src/token/test/NativeShieldedTokenCore.test.ts +++ b/contracts/src/token/test/NativeShieldedTokenCore.test.ts @@ -135,13 +135,14 @@ describe('NativeShieldedTokenCore (bare base)', () => { ], ]; - it.each( - circuitsToFail, - )('should revert %s before initialize', async (method, args) => { - await expect( - (token[method] as (...a: unknown[]) => Promise)(...args), - ).rejects.toThrow('NativeShieldedToken: contract not initialized'); - }); + it.each(circuitsToFail)( + 'should revert %s before initialize', + async (method, args) => { + await expect( + (token[method] as (...a: unknown[]) => Promise)(...args), + ).rejects.toThrow('NativeShieldedToken: contract not initialized'); + }, + ); }); describe('_mint (per domain)', () => { diff --git a/contracts/src/token/test/NativeShieldedTokenFamily.test.ts b/contracts/src/token/test/NativeShieldedTokenFamily.test.ts index db5ba0cc5..a54d4e92c 100644 --- a/contracts/src/token/test/NativeShieldedTokenFamily.test.ts +++ b/contracts/src/token/test/NativeShieldedTokenFamily.test.ts @@ -78,13 +78,14 @@ describe('NativeShieldedTokenFamily (Family profile)', () => { ], ]; - it.each( - circuitsToFail, - )('should revert %s before initialize', async (method, args) => { - await expect( - (token[method] as (...a: unknown[]) => Promise)(...args), - ).rejects.toThrow('NativeShieldedToken: contract not initialized'); - }); + it.each(circuitsToFail)( + 'should revert %s before initialize', + async (method, args) => { + await expect( + (token[method] as (...a: unknown[]) => Promise)(...args), + ).rejects.toThrow('NativeShieldedToken: contract not initialized'); + }, + ); }); describe('_mint (per domain)', () => { diff --git a/contracts/src/token/test/mocks/MockConfidentialNoteFungibleToken.compact b/contracts/src/token/test/mocks/MockConfidentialNoteFungibleToken.compact new file mode 100644 index 000000000..c94e99f2c --- /dev/null +++ b/contracts/src/token/test/mocks/MockConfidentialNoteFungibleToken.compact @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: MIT + +// WARNING: FOR TESTING PURPOSES ONLY. + +pragma language_version >= 0.23.0; + +import CompactStandardLibrary; +import "../../ConfidentialNoteFungibleToken" prefix Core_; + +export { Core_Note } + +export { Core__commitments, Core__nullifiers }; + +export circuit transfer(recipientPk: Field, value: Uint<128>): [Core_Note, Core_Note] { + return Core_transfer(recipientPk, value); +} + +export circuit burn(value: Uint<128>): Core_Note { + return Core_burn(value); +} + +// Building blocks, exposed for composition-level tests. + +export circuit _spenderPk(): Field { + // Exported-boundary marker: returns only to the local caller. + return disclose(Core__spenderPk()); +} + +export circuit _inputNote(): Core_Note { + // Exported-boundary marker: returns only to the local caller. + return disclose(Core__inputNote()); +} + +export circuit _mintNote(note: Core_Note, ownerPk: Field): [] { + return Core__mintNote(note, ownerPk); +} + +export circuit _mint(recipientPk: Field, value: Uint<128>): Core_Note { + return Core__mint(recipientPk, value); +} + +export circuit _transfer(spenderPk: Field, recipientPk: Field, outNote: Core_Note, changeNote: Core_Note): [] { + return Core__transfer(spenderPk, recipientPk, outNote, changeNote); +} + +export circuit _burn(spenderPk: Field, value: Uint<128>, changeNote: Core_Note): [] { + return Core__burn(spenderPk, value, changeNote); +} + +export circuit _consumeNote(ownerPk: Field): Core_Note { + // Exported-boundary marker: the consumed note returns only to the local + // caller (who supplied it as a witness in the first place). + return disclose(Core__consumeNote(ownerPk)); +} + +export pure circuit derivePk(sk: Bytes<32>): Field { + return Core_derivePk(sk); +} + +export pure circuit commitOf(note: Core_Note, pk: Field): Bytes<32> { + return Core_commitOf(note, pk); +} + +export pure circuit nullifierOf(note: Core_Note): Bytes<32> { + return Core_nullifierOf(note); +} diff --git a/contracts/src/token/test/mocks/MockConfidentialNoteFungibleTokenAllowlist.compact b/contracts/src/token/test/mocks/MockConfidentialNoteFungibleTokenAllowlist.compact new file mode 100644 index 000000000..6866c613c --- /dev/null +++ b/contracts/src/token/test/mocks/MockConfidentialNoteFungibleTokenAllowlist.compact @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MIT + +// WARNING: FOR TESTING PURPOSES ONLY. + +pragma language_version >= 0.23.0; + +import CompactStandardLibrary; +import "../../extensions/ConfidentialNoteFungibleTokenAllowlist" prefix Allow_; + +export { Allow__allowed }; + +export circuit _addAllowed(pk: Field): [] { + return Allow__addAllowed(pk); +} + +export circuit _removeAllowed(index: Uint<64>): [] { + return Allow__removeAllowed(index); +} + +export circuit _assertAllowed(pk: Field): [] { + return Allow__assertAllowed(pk); +} + +export pure circuit leafOf(pk: Field): Bytes<32> { + return Allow_leafOf(pk); +} diff --git a/contracts/src/token/test/mocks/MockConfidentialNoteTokenAudit.compact b/contracts/src/token/test/mocks/MockConfidentialNoteFungibleTokenAudit.compact similarity index 82% rename from contracts/src/token/test/mocks/MockConfidentialNoteTokenAudit.compact rename to contracts/src/token/test/mocks/MockConfidentialNoteFungibleTokenAudit.compact index 204d3891c..298e403cc 100644 --- a/contracts/src/token/test/mocks/MockConfidentialNoteTokenAudit.compact +++ b/contracts/src/token/test/mocks/MockConfidentialNoteFungibleTokenAudit.compact @@ -5,7 +5,7 @@ pragma language_version >= 0.23.0; import CompactStandardLibrary; -import "../../extensions/ConfidentialNoteTokenAudit" prefix Audit_; +import "../../extensions/ConfidentialNoteFungibleTokenAudit" prefix Audit_; export { Audit_AuditRecord, Audit_AuditView } @@ -21,6 +21,10 @@ export circuit _emitAuditedOutput(ownerPk: Field, value: Uint<128>, slot: Bytes< return disclose(Audit__emitAuditedOutput(ownerPk, value, slot)); } +export circuit _rotateAuditKey(newKey: JubjubPoint): [] { + return Audit__rotateAuditKey(newKey); +} + export pure circuit recoverAuditRecord(record: Audit_AuditRecord, auditSk: Field): Audit_AuditView { return Audit_recoverAuditRecord(record, auditSk); } diff --git a/contracts/src/token/test/mocks/MockConfidentialNoteTokenDelivery.compact b/contracts/src/token/test/mocks/MockConfidentialNoteFungibleTokenDelivery.compact similarity index 88% rename from contracts/src/token/test/mocks/MockConfidentialNoteTokenDelivery.compact rename to contracts/src/token/test/mocks/MockConfidentialNoteFungibleTokenDelivery.compact index d310f90e2..914b81e4c 100644 --- a/contracts/src/token/test/mocks/MockConfidentialNoteTokenDelivery.compact +++ b/contracts/src/token/test/mocks/MockConfidentialNoteFungibleTokenDelivery.compact @@ -5,7 +5,7 @@ pragma language_version >= 0.23.0; import CompactStandardLibrary; -import "../../extensions/ConfidentialNoteTokenDelivery" prefix Delivery_; +import "../../extensions/ConfidentialNoteFungibleTokenDelivery" prefix Delivery_; import "../../../crypto/NoteDelivery" prefix NoteDelivery_; export { NoteDelivery_FullDelivery, NoteDelivery_Recovered } diff --git a/contracts/src/token/test/mocks/MockConfidentialNoteFungibleTokenFreeze.compact b/contracts/src/token/test/mocks/MockConfidentialNoteFungibleTokenFreeze.compact new file mode 100644 index 000000000..ed507f806 --- /dev/null +++ b/contracts/src/token/test/mocks/MockConfidentialNoteFungibleTokenFreeze.compact @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: MIT + +// WARNING: FOR TESTING PURPOSES ONLY. + +pragma language_version >= 0.23.0; + +import CompactStandardLibrary; +import "../../extensions/ConfidentialNoteFungibleTokenFreeze" prefix Freeze_; + +export { Freeze__frozen }; + +export circuit _freeze(nf: Bytes<32>): [] { + return Freeze__freeze(nf); +} + +export circuit _unfreeze(nf: Bytes<32>): [] { + return Freeze__unfreeze(nf); +} + +export circuit _assertNotFrozen(nf: Bytes<32>): [] { + return Freeze__assertNotFrozen(nf); +} diff --git a/contracts/src/token/test/mocks/MockConfidentialNoteFungibleTokenIssuer.compact b/contracts/src/token/test/mocks/MockConfidentialNoteFungibleTokenIssuer.compact new file mode 100644 index 000000000..0c425c380 --- /dev/null +++ b/contracts/src/token/test/mocks/MockConfidentialNoteFungibleTokenIssuer.compact @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: MIT + +// WARNING: FOR TESTING PURPOSES ONLY. + +pragma language_version >= 0.23.0; + +import CompactStandardLibrary; +import "../../extensions/ConfidentialNoteFungibleTokenIssuer" prefix Issuer_; + +export { Issuer__isInitialized, Issuer__issuerPk }; + +export circuit initialize(issuerPk: Field): [] { + return Issuer_initialize(issuerPk); +} + +export circuit _assertIssuer(): [] { + return Issuer__assertIssuer(); +} + +export circuit _rotateIssuer(newIssuerPk: Field): [] { + return Issuer__rotateIssuer(newIssuerPk); +} diff --git a/contracts/src/token/test/mocks/MockConfidentialNoteTokenSupply.compact b/contracts/src/token/test/mocks/MockConfidentialNoteFungibleTokenPrivateSupply.compact similarity index 81% rename from contracts/src/token/test/mocks/MockConfidentialNoteTokenSupply.compact rename to contracts/src/token/test/mocks/MockConfidentialNoteFungibleTokenPrivateSupply.compact index 95fa862e9..3557abe69 100644 --- a/contracts/src/token/test/mocks/MockConfidentialNoteTokenSupply.compact +++ b/contracts/src/token/test/mocks/MockConfidentialNoteFungibleTokenPrivateSupply.compact @@ -5,7 +5,7 @@ pragma language_version >= 0.23.0; import CompactStandardLibrary; -import "../../extensions/ConfidentialNoteTokenSupply" prefix Supply_; +import "../../extensions/ConfidentialNoteFungibleTokenPrivateSupply" prefix Supply_; import "../../../crypto/ElGamal" prefix ElGamal_; export { ElGamal_Ciphertext } @@ -34,6 +34,10 @@ export circuit attestSupply(total: Uint<128>): [] { return Supply_attestSupply(total); } +export circuit _rotateSupplyKey(newKey: JubjubPoint, total: Uint<128>): [] { + return Supply__rotateSupplyKey(newKey, total); +} + // Off-chain helper surfaced for tests: derive the supply public key from its // secret the way the extension's attestation does. export pure circuit derivePk(ek: Bytes<32>): JubjubPoint { diff --git a/contracts/src/token/test/mocks/MockConfidentialNoteFungibleTokenReview.compact b/contracts/src/token/test/mocks/MockConfidentialNoteFungibleTokenReview.compact new file mode 100644 index 000000000..3359bc042 --- /dev/null +++ b/contracts/src/token/test/mocks/MockConfidentialNoteFungibleTokenReview.compact @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: MIT + +// WARNING: FOR TESTING PURPOSES ONLY. + +pragma language_version >= 0.23.0; + +import CompactStandardLibrary; +import "../../extensions/ConfidentialNoteFungibleTokenReview" prefix Review_; + +export { Review_ReviewRecord, Review_ReviewView } + +export { Review__reviewers, Review__reviewTrail }; + +export circuit _addReviewer(reviewerKey: JubjubPoint): [] { + return Review__addReviewer(reviewerKey); +} + +export circuit _removeReviewer(reviewerKey: JubjubPoint): [] { + return Review__removeReviewer(reviewerKey); +} + +export circuit _emitReviewRecord( + reviewerKey: JubjubPoint, + ownerPk: Field, + value: Uint<128>, + nonce: Field, + slot: Bytes<32> +): [] { + return Review__emitReviewRecord(reviewerKey, ownerPk, value, nonce, slot); +} + +export pure circuit reviewerKeyHashOf(reviewerKey: JubjubPoint): Bytes<32> { + return Review_reviewerKeyHashOf(reviewerKey); +} + +export pure circuit recoverReviewRecord(record: Review_ReviewRecord, reviewSk: Field): Review_ReviewView { + return Review_recoverReviewRecord(record, reviewSk); +} diff --git a/contracts/src/token/test/mocks/MockConfidentialNoteToken.compact b/contracts/src/token/test/mocks/MockConfidentialNoteToken.compact deleted file mode 100644 index 952afdf9d..000000000 --- a/contracts/src/token/test/mocks/MockConfidentialNoteToken.compact +++ /dev/null @@ -1,74 +0,0 @@ -// SPDX-License-Identifier: MIT - -// WARNING: FOR TESTING PURPOSES ONLY. - -pragma language_version >= 0.23.0; - -import CompactStandardLibrary; -import "../../ConfidentialNoteToken" prefix CNT_; - -export { CNT_Note } - -export { CNT__isInitialized, CNT__issuerPk, CNT__commitments, CNT__nullifiers }; - -export circuit initialize(issuerPk: Field): [] { - return CNT_initialize(issuerPk); -} - -export circuit mint(recipientPk: Field, value: Uint<128>): CNT_Note { - return CNT_mint(recipientPk, value); -} - -export circuit transfer(recipientPk: Field, value: Uint<128>): [CNT_Note, CNT_Note] { - return CNT_transfer(recipientPk, value); -} - -export circuit burn(value: Uint<128>): CNT_Note { - return CNT_burn(value); -} - -// Building blocks, exposed for composition-level tests. - -export circuit _spenderPk(): Field { - // Exported-boundary marker: returns only to the local caller. - return disclose(CNT__spenderPk()); -} - -export circuit _assertIssuer(): [] { - return CNT__assertIssuer(); -} - -export circuit _inputNote(): CNT_Note { - // Exported-boundary marker: returns only to the local caller. - return disclose(CNT__inputNote()); -} - -export circuit _mint(note: CNT_Note, ownerPk: Field): [] { - return CNT__mint(note, ownerPk); -} - -export circuit _transfer(spenderPk: Field, recipientPk: Field, outNote: CNT_Note, changeNote: CNT_Note): [] { - return CNT__transfer(spenderPk, recipientPk, outNote, changeNote); -} - -export circuit _burn(spenderPk: Field, value: Uint<128>, changeNote: CNT_Note): [] { - return CNT__burn(spenderPk, value, changeNote); -} - -export circuit _consumeNote(ownerPk: Field): CNT_Note { - // Exported-boundary marker: the consumed note returns only to the local - // caller (who supplied it as a witness in the first place). - return disclose(CNT__consumeNote(ownerPk)); -} - -export pure circuit derivePk(sk: Bytes<32>): Field { - return CNT_derivePk(sk); -} - -export pure circuit commitOf(note: CNT_Note, pk: Field): Bytes<32> { - return CNT_commitOf(note, pk); -} - -export pure circuit nullifierOf(note: CNT_Note): Bytes<32> { - return CNT_nullifierOf(note); -} diff --git a/contracts/src/token/test/mocks/MockRegulatedConfidentialNoteFungibleToken.compact b/contracts/src/token/test/mocks/MockRegulatedConfidentialNoteFungibleToken.compact new file mode 100644 index 000000000..b4d8651ea --- /dev/null +++ b/contracts/src/token/test/mocks/MockRegulatedConfidentialNoteFungibleToken.compact @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: MIT + +// WARNING: FOR TESTING PURPOSES ONLY. + +pragma language_version >= 0.23.0; + +import CompactStandardLibrary; +import "../../presets/RegulatedConfidentialNoteFungibleToken" prefix Token_; + +// Surface the preset module's re-exported observable state and artifact types +// under their stable bare names (exercising the module's own re-export path). +import { + Note, + AuditRecord, + AuditView, + FullDelivery, + _isInitialized, + _issuerPk, + _authorityPk, + _seizureCount, + _commitments, + _nullifiers, + _auditKey, + _auditTrail, + _deliveries, + _supplyKey, + _encSupply, + _attestedSupply, + _attestationCount +} from "../../presets/RegulatedConfidentialNoteFungibleToken"; + +export { Note, AuditRecord, AuditView, FullDelivery } + +export { + _isInitialized, + _issuerPk, + _authorityPk, + _seizureCount, + _commitments, + _nullifiers, + _auditKey, + _auditTrail, + _deliveries, + _supplyKey, + _encSupply, + _attestedSupply, + _attestationCount +}; + +constructor( + issuerPk: Field, + authorityPk: Field, + auditKey: JubjubPoint, + supplyKey: JubjubPoint +) { + Token_initialize(issuerPk, authorityPk, auditKey, supplyKey); +} + +export circuit mint(recipientPk: Field, recipientEncPk: JubjubPoint, value: Uint<128>): [] { + return Token_mint(recipientPk, recipientEncPk, value); +} + +export circuit transfer( + recipientPk: Field, + recipientEncPk: JubjubPoint, + senderEncPk: JubjubPoint, + value: Uint<128> +): [] { + return Token_transfer(recipientPk, recipientEncPk, senderEncPk, value); +} + +export circuit burn(senderEncPk: JubjubPoint, value: Uint<128>): [] { + return Token_burn(senderEncPk, value); +} + +export circuit seize(targetOwnerPk: Field, recoveryPk: Field, recoveryEncPk: JubjubPoint): [] { + return Token_seize(targetOwnerPk, recoveryPk, recoveryEncPk); +} + +export circuit attestSupply(total: Uint<128>): [] { + return Token_attestSupply(total); +} + +export circuit rotateIssuer(newIssuerPk: Field): [] { + return Token_rotateIssuer(newIssuerPk); +} + +export circuit rotateAuthority(newAuthorityPk: Field): [] { + return Token_rotateAuthority(newAuthorityPk); +} + +export circuit rotateAuditKey(newKey: JubjubPoint): [] { + return Token_rotateAuditKey(newKey); +} + +export circuit rotateSupplyKey(newKey: JubjubPoint, total: Uint<128>): [] { + return Token_rotateSupplyKey(newKey, total); +} diff --git a/contracts/src/token/test/simulators/ConfidentialNoteFungibleTokenAllowlistSimulator.ts b/contracts/src/token/test/simulators/ConfidentialNoteFungibleTokenAllowlistSimulator.ts new file mode 100644 index 000000000..15f8a26ad --- /dev/null +++ b/contracts/src/token/test/simulators/ConfidentialNoteFungibleTokenAllowlistSimulator.ts @@ -0,0 +1,58 @@ +import { + createSimulator, + type SimulatorOptions, +} from '@openzeppelin/compact-simulator'; +import { + ledger, + Contract as MockAllowlist, +} from '../../../../artifacts/MockConfidentialNoteFungibleTokenAllowlist/contract/index.js'; +import { + type ConfidentialNoteFungibleTokenAllowlistPrivateState, + ConfidentialNoteFungibleTokenAllowlistWitnesses, + ConfidentialNoteFungibleTokenAllowlistPrivateState as PrivateState, +} from '../witnesses/ConfidentialNoteFungibleTokenAllowlistWitnesses.js'; + +const ConfidentialNoteFungibleTokenAllowlistSimulatorBase = createSimulator< + ConfidentialNoteFungibleTokenAllowlistPrivateState, + ReturnType, + ReturnType, + MockAllowlist, + readonly [] +>({ + contractFactory: (witnesses) => + new MockAllowlist( + witnesses, + ), + defaultPrivateState: () => PrivateState.generate(), + contractArgs: () => [], + ledgerExtractor: (state) => ledger(state), + witnessesFactory: () => ConfidentialNoteFungibleTokenAllowlistWitnesses(), + artifactName: 'MockConfidentialNoteFungibleTokenAllowlist', +}); + +export class ConfidentialNoteFungibleTokenAllowlistSimulator extends ConfidentialNoteFungibleTokenAllowlistSimulatorBase { + static async create( + options: SimulatorOptions< + ConfidentialNoteFungibleTokenAllowlistPrivateState, + ReturnType + > = {}, + ): Promise { + // biome-ignore lint/complexity/noThisInStatic: super.create keeps subclass `this` + return super.create( + [], + options, + ) as Promise; + } + + public addAllowed(pk: bigint): Promise<[]> { + return this.circuits.impure._addAllowed(pk); + } + + public removeAllowed(index: bigint): Promise<[]> { + return this.circuits.impure._removeAllowed(index); + } + + public assertAllowed(pk: bigint): Promise<[]> { + return this.circuits.impure._assertAllowed(pk); + } +} diff --git a/contracts/src/token/test/simulators/ConfidentialNoteFungibleTokenAuditSimulator.ts b/contracts/src/token/test/simulators/ConfidentialNoteFungibleTokenAuditSimulator.ts new file mode 100644 index 000000000..8c8c1957b --- /dev/null +++ b/contracts/src/token/test/simulators/ConfidentialNoteFungibleTokenAuditSimulator.ts @@ -0,0 +1,71 @@ +import type { JubjubPoint } from '@midnight-ntwrk/compact-runtime'; +import { + createSimulator, + type SimulatorOptions, +} from '@openzeppelin/compact-simulator'; +import { + ledger, + Contract as MockAudit, +} from '../../../../artifacts/MockConfidentialNoteFungibleTokenAudit/contract/index.js'; +import { + type ConfidentialNoteFungibleTokenAuditPrivateState, + ConfidentialNoteFungibleTokenAuditWitnesses, + ConfidentialNoteFungibleTokenAuditPrivateState as PrivateState, +} from '../witnesses/ConfidentialNoteFungibleTokenAuditWitnesses.js'; + +const ConfidentialNoteFungibleTokenAuditSimulatorBase = createSimulator< + ConfidentialNoteFungibleTokenAuditPrivateState, + ReturnType, + ReturnType, + MockAudit, + readonly [] +>({ + contractFactory: (witnesses) => + new MockAudit(witnesses), + defaultPrivateState: () => PrivateState.generate(), + contractArgs: () => [], + ledgerExtractor: (state) => ledger(state), + witnessesFactory: () => ConfidentialNoteFungibleTokenAuditWitnesses(), + artifactName: 'MockConfidentialNoteFungibleTokenAudit', +}); + +export class ConfidentialNoteFungibleTokenAuditSimulator extends ConfidentialNoteFungibleTokenAuditSimulatorBase { + static async create( + options: SimulatorOptions< + ConfidentialNoteFungibleTokenAuditPrivateState, + ReturnType + > = {}, + ): Promise { + // biome-ignore lint/complexity/noThisInStatic: super.create keeps subclass `this` + return super.create( + [], + options, + ) as Promise; + } + + public initialize(auditKey: JubjubPoint): Promise<[]> { + return this.circuits.impure.initialize(auditKey); + } + + public emitAuditedOutput( + ownerPk: bigint, + value: bigint, + slot: Uint8Array, + ): Promise { + return this.circuits.impure._emitAuditedOutput(ownerPk, value, slot); + } + + public rotateAuditKey(newKey: JubjubPoint): Promise<[]> { + return this.circuits.impure._rotateAuditKey(newKey); + } + + public readonly privateState = { + set: async ( + partial: Partial, + ): Promise => { + const updated = { ...(await this.getPrivateState()), ...partial }; + this.setPrivateState(updated); + return updated; + }, + }; +} diff --git a/contracts/src/token/test/simulators/ConfidentialNoteFungibleTokenDeliverySimulator.ts b/contracts/src/token/test/simulators/ConfidentialNoteFungibleTokenDeliverySimulator.ts new file mode 100644 index 000000000..0881337c6 --- /dev/null +++ b/contracts/src/token/test/simulators/ConfidentialNoteFungibleTokenDeliverySimulator.ts @@ -0,0 +1,56 @@ +import type { JubjubPoint } from '@midnight-ntwrk/compact-runtime'; +import { + createSimulator, + type SimulatorOptions, +} from '@openzeppelin/compact-simulator'; +import { + ledger, + Contract as MockDelivery, +} from '../../../../artifacts/MockConfidentialNoteFungibleTokenDelivery/contract/index.js'; +import { + type ConfidentialNoteFungibleTokenDeliveryPrivateState, + ConfidentialNoteFungibleTokenDeliveryWitnesses, + ConfidentialNoteFungibleTokenDeliveryPrivateState as PrivateState, +} from '../witnesses/ConfidentialNoteFungibleTokenDeliveryWitnesses.js'; + +const ConfidentialNoteFungibleTokenDeliverySimulatorBase = createSimulator< + ConfidentialNoteFungibleTokenDeliveryPrivateState, + ReturnType, + ReturnType, + MockDelivery, + readonly [] +>({ + contractFactory: (witnesses) => + new MockDelivery( + witnesses, + ), + defaultPrivateState: () => PrivateState.generate(), + contractArgs: () => [], + ledgerExtractor: (state) => ledger(state), + witnessesFactory: () => ConfidentialNoteFungibleTokenDeliveryWitnesses(), + artifactName: 'MockConfidentialNoteFungibleTokenDelivery', +}); + +export class ConfidentialNoteFungibleTokenDeliverySimulator extends ConfidentialNoteFungibleTokenDeliverySimulatorBase { + static async create( + options: SimulatorOptions< + ConfidentialNoteFungibleTokenDeliveryPrivateState, + ReturnType + > = {}, + ): Promise { + // biome-ignore lint/complexity/noThisInStatic: super.create keeps subclass `this` + return super.create( + [], + options, + ) as Promise; + } + + public deliver( + encPk: JubjubPoint, + value: bigint, + nonce: bigint, + slot: Uint8Array, + ): Promise<[]> { + return this.circuits.impure._deliver(encPk, value, nonce, slot); + } +} diff --git a/contracts/src/token/test/simulators/ConfidentialNoteFungibleTokenFreezeSimulator.ts b/contracts/src/token/test/simulators/ConfidentialNoteFungibleTokenFreezeSimulator.ts new file mode 100644 index 000000000..3116212de --- /dev/null +++ b/contracts/src/token/test/simulators/ConfidentialNoteFungibleTokenFreezeSimulator.ts @@ -0,0 +1,59 @@ +import { + createSimulator, + type SimulatorOptions, +} from '@openzeppelin/compact-simulator'; +import { + ledger, + Contract as MockFreeze, +} from '../../../../artifacts/MockConfidentialNoteFungibleTokenFreeze/contract/index.js'; + +// The freeze extension declares no witnesses and keeps no private state. +export type ConfidentialNoteFungibleTokenFreezePrivateState = Record< + string, + never +>; + +const emptyWitnesses = () => ({}); + +const ConfidentialNoteFungibleTokenFreezeSimulatorBase = createSimulator< + ConfidentialNoteFungibleTokenFreezePrivateState, + ReturnType, + ReturnType, + MockFreeze, + readonly [] +>({ + contractFactory: (witnesses) => + new MockFreeze(witnesses), + defaultPrivateState: () => ({}), + contractArgs: () => [], + ledgerExtractor: (state) => ledger(state), + witnessesFactory: emptyWitnesses, + artifactName: 'MockConfidentialNoteFungibleTokenFreeze', +}); + +export class ConfidentialNoteFungibleTokenFreezeSimulator extends ConfidentialNoteFungibleTokenFreezeSimulatorBase { + static async create( + options: SimulatorOptions< + ConfidentialNoteFungibleTokenFreezePrivateState, + ReturnType + > = {}, + ): Promise { + // biome-ignore lint/complexity/noThisInStatic: super.create keeps subclass `this` + return super.create( + [], + options, + ) as Promise; + } + + public freeze(nf: Uint8Array): Promise<[]> { + return this.circuits.impure._freeze(nf); + } + + public unfreeze(nf: Uint8Array): Promise<[]> { + return this.circuits.impure._unfreeze(nf); + } + + public assertNotFrozen(nf: Uint8Array): Promise<[]> { + return this.circuits.impure._assertNotFrozen(nf); + } +} diff --git a/contracts/src/token/test/simulators/ConfidentialNoteFungibleTokenIssuerSimulator.ts b/contracts/src/token/test/simulators/ConfidentialNoteFungibleTokenIssuerSimulator.ts new file mode 100644 index 000000000..a32455630 --- /dev/null +++ b/contracts/src/token/test/simulators/ConfidentialNoteFungibleTokenIssuerSimulator.ts @@ -0,0 +1,67 @@ +import { + createSimulator, + type SimulatorOptions, +} from '@openzeppelin/compact-simulator'; +import { + ledger, + Contract as MockIssuer, +} from '../../../../artifacts/MockConfidentialNoteFungibleTokenIssuer/contract/index.js'; +import { + type ConfidentialNoteFungibleTokenIssuerPrivateState, + ConfidentialNoteFungibleTokenIssuerWitnesses, + ConfidentialNoteFungibleTokenIssuerPrivateState as PrivateState, +} from '../witnesses/ConfidentialNoteFungibleTokenIssuerWitnesses.js'; + +const ConfidentialNoteFungibleTokenIssuerSimulatorBase = createSimulator< + ConfidentialNoteFungibleTokenIssuerPrivateState, + ReturnType, + ReturnType, + MockIssuer, + readonly [] +>({ + contractFactory: (witnesses) => + new MockIssuer(witnesses), + defaultPrivateState: () => PrivateState.generate(), + contractArgs: () => [], + ledgerExtractor: (state) => ledger(state), + witnessesFactory: () => ConfidentialNoteFungibleTokenIssuerWitnesses(), + artifactName: 'MockConfidentialNoteFungibleTokenIssuer', +}); + +export class ConfidentialNoteFungibleTokenIssuerSimulator extends ConfidentialNoteFungibleTokenIssuerSimulatorBase { + static async create( + options: SimulatorOptions< + ConfidentialNoteFungibleTokenIssuerPrivateState, + ReturnType + > = {}, + ): Promise { + // biome-ignore lint/complexity/noThisInStatic: super.create keeps subclass `this` + return super.create( + [], + options, + ) as Promise; + } + + public initialize(issuerPk: bigint): Promise<[]> { + return this.circuits.impure.initialize(issuerPk); + } + + public assertIssuer(): Promise<[]> { + return this.circuits.impure._assertIssuer(); + } + + public rotateIssuer(newIssuerPk: bigint): Promise<[]> { + return this.circuits.impure._rotateIssuer(newIssuerPk); + } + + public readonly privateState = { + // Configure the issuer secret proven by the next call. + set: async ( + partial: Partial, + ): Promise => { + const updated = { ...(await this.getPrivateState()), ...partial }; + this.setPrivateState(updated); + return updated; + }, + }; +} diff --git a/contracts/src/token/test/simulators/ConfidentialNoteFungibleTokenPrivateSupplySimulator.ts b/contracts/src/token/test/simulators/ConfidentialNoteFungibleTokenPrivateSupplySimulator.ts new file mode 100644 index 000000000..84a1e2f43 --- /dev/null +++ b/contracts/src/token/test/simulators/ConfidentialNoteFungibleTokenPrivateSupplySimulator.ts @@ -0,0 +1,77 @@ +import type { JubjubPoint } from '@midnight-ntwrk/compact-runtime'; +import { + createSimulator, + type SimulatorOptions, +} from '@openzeppelin/compact-simulator'; +import { + ledger, + Contract as MockSupply, +} from '../../../../artifacts/MockConfidentialNoteFungibleTokenPrivateSupply/contract/index.js'; +import { + type ConfidentialNoteFungibleTokenPrivateSupplyPrivateState, + ConfidentialNoteFungibleTokenPrivateSupplyWitnesses, + ConfidentialNoteFungibleTokenPrivateSupplyPrivateState as PrivateState, +} from '../witnesses/ConfidentialNoteFungibleTokenPrivateSupplyWitnesses.js'; + +const ConfidentialNoteFungibleTokenPrivateSupplySimulatorBase = createSimulator< + ConfidentialNoteFungibleTokenPrivateSupplyPrivateState, + ReturnType, + ReturnType, + MockSupply, + readonly [] +>({ + contractFactory: (witnesses) => + new MockSupply( + witnesses, + ), + defaultPrivateState: () => PrivateState.generate(), + contractArgs: () => [], + ledgerExtractor: (state) => ledger(state), + witnessesFactory: () => ConfidentialNoteFungibleTokenPrivateSupplyWitnesses(), + artifactName: 'MockConfidentialNoteFungibleTokenPrivateSupply', +}); + +export class ConfidentialNoteFungibleTokenPrivateSupplySimulator extends ConfidentialNoteFungibleTokenPrivateSupplySimulatorBase { + static async create( + options: SimulatorOptions< + ConfidentialNoteFungibleTokenPrivateSupplyPrivateState, + ReturnType + > = {}, + ): Promise { + // biome-ignore lint/complexity/noThisInStatic: super.create keeps subclass `this` + return super.create( + [], + options, + ) as Promise; + } + + public initialize(supplyKey: JubjubPoint): Promise<[]> { + return this.circuits.impure.initialize(supplyKey); + } + + public addMinted(value: bigint): Promise<[]> { + return this.circuits.impure._addMinted(value); + } + + public addBurned(value: bigint): Promise<[]> { + return this.circuits.impure._addBurned(value); + } + + public attestSupply(total: bigint): Promise<[]> { + return this.circuits.impure.attestSupply(total); + } + + public rotateSupplyKey(newKey: JubjubPoint, total: bigint): Promise<[]> { + return this.circuits.impure._rotateSupplyKey(newKey, total); + } + + public readonly privateState = { + set: async ( + partial: Partial, + ): Promise => { + const updated = { ...(await this.getPrivateState()), ...partial }; + this.setPrivateState(updated); + return updated; + }, + }; +} diff --git a/contracts/src/token/test/simulators/ConfidentialNoteFungibleTokenReviewSimulator.ts b/contracts/src/token/test/simulators/ConfidentialNoteFungibleTokenReviewSimulator.ts new file mode 100644 index 000000000..2e35ff464 --- /dev/null +++ b/contracts/src/token/test/simulators/ConfidentialNoteFungibleTokenReviewSimulator.ts @@ -0,0 +1,79 @@ +import type { JubjubPoint } from '@midnight-ntwrk/compact-runtime'; +import { + createSimulator, + type SimulatorOptions, +} from '@openzeppelin/compact-simulator'; +import { + ledger, + Contract as MockReview, +} from '../../../../artifacts/MockConfidentialNoteFungibleTokenReview/contract/index.js'; +import { + type ConfidentialNoteFungibleTokenReviewPrivateState, + ConfidentialNoteFungibleTokenReviewWitnesses, + ConfidentialNoteFungibleTokenReviewPrivateState as PrivateState, +} from '../witnesses/ConfidentialNoteFungibleTokenReviewWitnesses.js'; + +const ConfidentialNoteFungibleTokenReviewSimulatorBase = createSimulator< + ConfidentialNoteFungibleTokenReviewPrivateState, + ReturnType, + ReturnType, + MockReview, + readonly [] +>({ + contractFactory: (witnesses) => + new MockReview(witnesses), + defaultPrivateState: () => PrivateState.generate(), + contractArgs: () => [], + ledgerExtractor: (state) => ledger(state), + witnessesFactory: () => ConfidentialNoteFungibleTokenReviewWitnesses(), + artifactName: 'MockConfidentialNoteFungibleTokenReview', +}); + +export class ConfidentialNoteFungibleTokenReviewSimulator extends ConfidentialNoteFungibleTokenReviewSimulatorBase { + static async create( + options: SimulatorOptions< + ConfidentialNoteFungibleTokenReviewPrivateState, + ReturnType + > = {}, + ): Promise { + // biome-ignore lint/complexity/noThisInStatic: super.create keeps subclass `this` + return super.create( + [], + options, + ) as Promise; + } + + public addReviewer(reviewerKey: JubjubPoint): Promise<[]> { + return this.circuits.impure._addReviewer(reviewerKey); + } + + public removeReviewer(reviewerKey: JubjubPoint): Promise<[]> { + return this.circuits.impure._removeReviewer(reviewerKey); + } + + public emitReviewRecord( + reviewerKey: JubjubPoint, + ownerPk: bigint, + value: bigint, + nonce: bigint, + slot: Uint8Array, + ): Promise<[]> { + return this.circuits.impure._emitReviewRecord( + reviewerKey, + ownerPk, + value, + nonce, + slot, + ); + } + + public readonly privateState = { + set: async ( + partial: Partial, + ): Promise => { + const updated = { ...(await this.getPrivateState()), ...partial }; + this.setPrivateState(updated); + return updated; + }, + }; +} diff --git a/contracts/src/token/test/simulators/ConfidentialNoteFungibleTokenSimulator.ts b/contracts/src/token/test/simulators/ConfidentialNoteFungibleTokenSimulator.ts new file mode 100644 index 000000000..529a956d4 --- /dev/null +++ b/contracts/src/token/test/simulators/ConfidentialNoteFungibleTokenSimulator.ts @@ -0,0 +1,72 @@ +import { + createSimulator, + type SimulatorOptions, +} from '@openzeppelin/compact-simulator'; +import { + ledger, + Contract as MockCore, +} from '../../../../artifacts/MockConfidentialNoteFungibleToken/contract/index.js'; +import { + type ConfidentialNoteFungibleTokenPrivateState, + ConfidentialNoteFungibleTokenWitnesses, + type Note, + ConfidentialNoteFungibleTokenPrivateState as PrivateState, +} from '../witnesses/ConfidentialNoteFungibleTokenWitnesses.js'; + +const ConfidentialNoteFungibleTokenSimulatorBase = createSimulator< + ConfidentialNoteFungibleTokenPrivateState, + ReturnType, + ReturnType, + MockCore, + readonly [] +>({ + contractFactory: (witnesses) => + new MockCore(witnesses), + defaultPrivateState: () => PrivateState.generate(), + contractArgs: () => [], + ledgerExtractor: (state) => ledger(state), + witnessesFactory: () => ConfidentialNoteFungibleTokenWitnesses(), + artifactName: 'MockConfidentialNoteFungibleToken', +}); + +export class ConfidentialNoteFungibleTokenSimulator extends ConfidentialNoteFungibleTokenSimulatorBase { + static async create( + options: SimulatorOptions< + ConfidentialNoteFungibleTokenPrivateState, + ReturnType + > = {}, + ): Promise { + // biome-ignore lint/complexity/noThisInStatic: super.create keeps subclass `this` + return super.create( + [], + options, + ) as Promise; + } + + public mint(recipientPk: bigint, value: bigint): Promise { + return this.circuits.impure._mint(recipientPk, value); + } + + public transfer(recipientPk: bigint, value: bigint): Promise<[Note, Note]> { + return this.circuits.impure.transfer(recipientPk, value); + } + + public burn(value: bigint): Promise { + return this.circuits.impure.burn(value); + } + + public consumeNote(ownerPk: bigint): Promise { + return this.circuits.impure._consumeNote(ownerPk); + } + + public readonly privateState = { + // Configure the caller's identity and the note being spent next. + set: async ( + partial: Partial, + ): Promise => { + const updated = { ...(await this.getPrivateState()), ...partial }; + this.setPrivateState(updated); + return updated; + }, + }; +} diff --git a/contracts/src/token/test/simulators/ConfidentialNoteTokenAuditSimulator.ts b/contracts/src/token/test/simulators/ConfidentialNoteTokenAuditSimulator.ts deleted file mode 100644 index ff65953c6..000000000 --- a/contracts/src/token/test/simulators/ConfidentialNoteTokenAuditSimulator.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type { JubjubPoint } from '@midnight-ntwrk/compact-runtime'; -import { - createSimulator, - type SimulatorOptions, -} from '@openzeppelin/compact-simulator'; -import { - ledger, - Contract as MockAudit, -} from '../../../../artifacts/MockConfidentialNoteTokenAudit/contract/index.js'; -import { - type ConfidentialNoteTokenAuditPrivateState, - ConfidentialNoteTokenAuditWitnesses, - ConfidentialNoteTokenAuditPrivateState as PrivateState, -} from '../witnesses/ConfidentialNoteTokenAuditWitnesses.js'; - -const ConfidentialNoteTokenAuditSimulatorBase = createSimulator< - ConfidentialNoteTokenAuditPrivateState, - ReturnType, - ReturnType, - MockAudit, - readonly [] ->({ - contractFactory: (witnesses) => - new MockAudit(witnesses), - defaultPrivateState: () => PrivateState.generate(), - contractArgs: () => [], - ledgerExtractor: (state) => ledger(state), - witnessesFactory: () => ConfidentialNoteTokenAuditWitnesses(), - artifactName: 'MockConfidentialNoteTokenAudit', -}); - -export class ConfidentialNoteTokenAuditSimulator extends ConfidentialNoteTokenAuditSimulatorBase { - static async create( - options: SimulatorOptions< - ConfidentialNoteTokenAuditPrivateState, - ReturnType - > = {}, - ): Promise { - // biome-ignore lint/complexity/noThisInStatic: super.create keeps subclass `this` - return super.create( - [], - options, - ) as Promise; - } - - public initialize(auditKey: JubjubPoint): Promise<[]> { - return this.circuits.impure.initialize(auditKey); - } - - public emitAuditedOutput( - ownerPk: bigint, - value: bigint, - slot: Uint8Array, - ): Promise { - return this.circuits.impure._emitAuditedOutput(ownerPk, value, slot); - } - - public readonly privateState = { - set: async ( - partial: Partial, - ): Promise => { - const updated = { ...(await this.getPrivateState()), ...partial }; - this.setPrivateState(updated); - return updated; - }, - }; -} diff --git a/contracts/src/token/test/simulators/ConfidentialNoteTokenDeliverySimulator.ts b/contracts/src/token/test/simulators/ConfidentialNoteTokenDeliverySimulator.ts deleted file mode 100644 index d550e3e1e..000000000 --- a/contracts/src/token/test/simulators/ConfidentialNoteTokenDeliverySimulator.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { JubjubPoint } from '@midnight-ntwrk/compact-runtime'; -import { - createSimulator, - type SimulatorOptions, -} from '@openzeppelin/compact-simulator'; -import { - ledger, - Contract as MockDelivery, -} from '../../../../artifacts/MockConfidentialNoteTokenDelivery/contract/index.js'; -import { - type ConfidentialNoteTokenDeliveryPrivateState, - ConfidentialNoteTokenDeliveryWitnesses, - ConfidentialNoteTokenDeliveryPrivateState as PrivateState, -} from '../witnesses/ConfidentialNoteTokenDeliveryWitnesses.js'; - -const ConfidentialNoteTokenDeliverySimulatorBase = createSimulator< - ConfidentialNoteTokenDeliveryPrivateState, - ReturnType, - ReturnType, - MockDelivery, - readonly [] ->({ - contractFactory: (witnesses) => - new MockDelivery(witnesses), - defaultPrivateState: () => PrivateState.generate(), - contractArgs: () => [], - ledgerExtractor: (state) => ledger(state), - witnessesFactory: () => ConfidentialNoteTokenDeliveryWitnesses(), - artifactName: 'MockConfidentialNoteTokenDelivery', -}); - -export class ConfidentialNoteTokenDeliverySimulator extends ConfidentialNoteTokenDeliverySimulatorBase { - static async create( - options: SimulatorOptions< - ConfidentialNoteTokenDeliveryPrivateState, - ReturnType - > = {}, - ): Promise { - // biome-ignore lint/complexity/noThisInStatic: super.create keeps subclass `this` - return super.create( - [], - options, - ) as Promise; - } - - public deliver( - encPk: JubjubPoint, - value: bigint, - nonce: bigint, - slot: Uint8Array, - ): Promise<[]> { - return this.circuits.impure._deliver(encPk, value, nonce, slot); - } -} diff --git a/contracts/src/token/test/simulators/ConfidentialNoteTokenSimulator.ts b/contracts/src/token/test/simulators/ConfidentialNoteTokenSimulator.ts deleted file mode 100644 index 0663aabd7..000000000 --- a/contracts/src/token/test/simulators/ConfidentialNoteTokenSimulator.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { - createSimulator, - type SimulatorOptions, -} from '@openzeppelin/compact-simulator'; -import { - ledger, - Contract as MockCNT, -} from '../../../../artifacts/MockConfidentialNoteToken/contract/index.js'; -import { - type ConfidentialNoteTokenPrivateState, - ConfidentialNoteTokenWitnesses, - type Note, - ConfidentialNoteTokenPrivateState as PrivateState, -} from '../witnesses/ConfidentialNoteTokenWitnesses.js'; - -const ConfidentialNoteTokenSimulatorBase = createSimulator< - ConfidentialNoteTokenPrivateState, - ReturnType, - ReturnType, - MockCNT, - readonly [] ->({ - contractFactory: (witnesses) => - new MockCNT(witnesses), - defaultPrivateState: () => PrivateState.generate(), - contractArgs: () => [], - ledgerExtractor: (state) => ledger(state), - witnessesFactory: () => ConfidentialNoteTokenWitnesses(), - artifactName: 'MockConfidentialNoteToken', -}); - -export class ConfidentialNoteTokenSimulator extends ConfidentialNoteTokenSimulatorBase { - static async create( - options: SimulatorOptions< - ConfidentialNoteTokenPrivateState, - ReturnType - > = {}, - ): Promise { - // biome-ignore lint/complexity/noThisInStatic: super.create keeps subclass `this` - return super.create([], options) as Promise; - } - - public initialize(issuerPk: bigint): Promise<[]> { - return this.circuits.impure.initialize(issuerPk); - } - - public mint(recipientPk: bigint, value: bigint): Promise { - return this.circuits.impure.mint(recipientPk, value); - } - - public transfer(recipientPk: bigint, value: bigint): Promise<[Note, Note]> { - return this.circuits.impure.transfer(recipientPk, value); - } - - public burn(value: bigint): Promise { - return this.circuits.impure.burn(value); - } - - public consumeNote(ownerPk: bigint): Promise { - return this.circuits.impure._consumeNote(ownerPk); - } - - public readonly privateState = { - // Configure the caller's identity and the note being spent next. - set: async ( - partial: Partial, - ): Promise => { - const updated = { ...(await this.getPrivateState()), ...partial }; - this.setPrivateState(updated); - return updated; - }, - }; -} diff --git a/contracts/src/token/test/simulators/ConfidentialNoteTokenSupplySimulator.ts b/contracts/src/token/test/simulators/ConfidentialNoteTokenSupplySimulator.ts deleted file mode 100644 index 5a233fc61..000000000 --- a/contracts/src/token/test/simulators/ConfidentialNoteTokenSupplySimulator.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type { JubjubPoint } from '@midnight-ntwrk/compact-runtime'; -import { - createSimulator, - type SimulatorOptions, -} from '@openzeppelin/compact-simulator'; -import { - ledger, - Contract as MockSupply, -} from '../../../../artifacts/MockConfidentialNoteTokenSupply/contract/index.js'; -import { - type ConfidentialNoteTokenSupplyPrivateState, - ConfidentialNoteTokenSupplyWitnesses, - ConfidentialNoteTokenSupplyPrivateState as PrivateState, -} from '../witnesses/ConfidentialNoteTokenSupplyWitnesses.js'; - -const ConfidentialNoteTokenSupplySimulatorBase = createSimulator< - ConfidentialNoteTokenSupplyPrivateState, - ReturnType, - ReturnType, - MockSupply, - readonly [] ->({ - contractFactory: (witnesses) => - new MockSupply(witnesses), - defaultPrivateState: () => PrivateState.generate(), - contractArgs: () => [], - ledgerExtractor: (state) => ledger(state), - witnessesFactory: () => ConfidentialNoteTokenSupplyWitnesses(), - artifactName: 'MockConfidentialNoteTokenSupply', -}); - -export class ConfidentialNoteTokenSupplySimulator extends ConfidentialNoteTokenSupplySimulatorBase { - static async create( - options: SimulatorOptions< - ConfidentialNoteTokenSupplyPrivateState, - ReturnType - > = {}, - ): Promise { - // biome-ignore lint/complexity/noThisInStatic: super.create keeps subclass `this` - return super.create( - [], - options, - ) as Promise; - } - - public initialize(supplyKey: JubjubPoint): Promise<[]> { - return this.circuits.impure.initialize(supplyKey); - } - - public addMinted(value: bigint): Promise<[]> { - return this.circuits.impure._addMinted(value); - } - - public addBurned(value: bigint): Promise<[]> { - return this.circuits.impure._addBurned(value); - } - - public attestSupply(total: bigint): Promise<[]> { - return this.circuits.impure.attestSupply(total); - } - - public readonly privateState = { - set: async ( - partial: Partial, - ): Promise => { - const updated = { ...(await this.getPrivateState()), ...partial }; - this.setPrivateState(updated); - return updated; - }, - }; -} diff --git a/contracts/src/token/test/simulators/RegulatedConfidentialNoteTokenSimulator.ts b/contracts/src/token/test/simulators/RegulatedConfidentialNoteFungibleTokenSimulator.ts similarity index 52% rename from contracts/src/token/test/simulators/RegulatedConfidentialNoteTokenSimulator.ts rename to contracts/src/token/test/simulators/RegulatedConfidentialNoteFungibleTokenSimulator.ts index c8d731c33..a4805d044 100644 --- a/contracts/src/token/test/simulators/RegulatedConfidentialNoteTokenSimulator.ts +++ b/contracts/src/token/test/simulators/RegulatedConfidentialNoteFungibleTokenSimulator.ts @@ -5,31 +5,33 @@ import { } from '@openzeppelin/compact-simulator'; import { ledger, - Contract as RegulatedCNT, -} from '../../../../artifacts/RegulatedConfidentialNoteToken/contract/index.js'; + Contract as RegulatedToken, +} from '../../../../artifacts/MockRegulatedConfidentialNoteFungibleToken/contract/index.js'; import { type Note, - RegulatedConfidentialNoteTokenPrivateState as PrivateState, - type RegulatedConfidentialNoteTokenPrivateState, - RegulatedConfidentialNoteTokenWitnesses, -} from '../witnesses/RegulatedConfidentialNoteTokenWitnesses.js'; + RegulatedConfidentialNoteFungibleTokenPrivateState as PrivateState, + type RegulatedConfidentialNoteFungibleTokenPrivateState, + RegulatedConfidentialNoteFungibleTokenWitnesses, +} from '../witnesses/RegulatedConfidentialNoteFungibleTokenWitnesses.js'; -type RegulatedConfidentialNoteTokenArgs = readonly [ +type RegulatedConfidentialNoteFungibleTokenArgs = readonly [ issuerPk: bigint, authorityPk: bigint, auditKey: JubjubPoint, supplyKey: JubjubPoint, ]; -const RegulatedConfidentialNoteTokenSimulatorBase = createSimulator< - RegulatedConfidentialNoteTokenPrivateState, +const RegulatedConfidentialNoteFungibleTokenSimulatorBase = createSimulator< + RegulatedConfidentialNoteFungibleTokenPrivateState, ReturnType, - ReturnType, - RegulatedCNT, - RegulatedConfidentialNoteTokenArgs + ReturnType, + RegulatedToken, + RegulatedConfidentialNoteFungibleTokenArgs >({ contractFactory: (witnesses) => - new RegulatedCNT(witnesses), + new RegulatedToken( + witnesses, + ), defaultPrivateState: () => PrivateState.generate(), contractArgs: (issuerPk, authorityPk, auditKey, supplyKey) => [ issuerPk, @@ -38,26 +40,26 @@ const RegulatedConfidentialNoteTokenSimulatorBase = createSimulator< supplyKey, ], ledgerExtractor: (state) => ledger(state), - witnessesFactory: () => RegulatedConfidentialNoteTokenWitnesses(), - artifactName: 'RegulatedConfidentialNoteToken', + witnessesFactory: () => RegulatedConfidentialNoteFungibleTokenWitnesses(), + artifactName: 'MockRegulatedConfidentialNoteFungibleToken', }); -export class RegulatedConfidentialNoteTokenSimulator extends RegulatedConfidentialNoteTokenSimulatorBase { +export class RegulatedConfidentialNoteFungibleTokenSimulator extends RegulatedConfidentialNoteFungibleTokenSimulatorBase { static async create( issuerPk: bigint, authorityPk: bigint, auditKey: JubjubPoint, supplyKey: JubjubPoint, options: SimulatorOptions< - RegulatedConfidentialNoteTokenPrivateState, - ReturnType + RegulatedConfidentialNoteFungibleTokenPrivateState, + ReturnType > = {}, - ): Promise { + ): Promise { // biome-ignore lint/complexity/noThisInStatic: super.create keeps subclass `this` return super.create( [issuerPk, authorityPk, auditKey, supplyKey], options, - ) as Promise; + ) as Promise; } public mint( @@ -98,11 +100,27 @@ export class RegulatedConfidentialNoteTokenSimulator extends RegulatedConfidenti return this.circuits.impure.attestSupply(total); } + public rotateIssuer(newIssuerPk: bigint): Promise<[]> { + return this.circuits.impure.rotateIssuer(newIssuerPk); + } + + public rotateAuthority(newAuthorityPk: bigint): Promise<[]> { + return this.circuits.impure.rotateAuthority(newAuthorityPk); + } + + public rotateAuditKey(newKey: JubjubPoint): Promise<[]> { + return this.circuits.impure.rotateAuditKey(newKey); + } + + public rotateSupplyKey(newKey: JubjubPoint, total: bigint): Promise<[]> { + return this.circuits.impure.rotateSupplyKey(newKey, total); + } + public readonly privateState = { // Configure the caller's identity and the note being spent next. set: async ( - partial: Partial, - ): Promise => { + partial: Partial, + ): Promise => { const updated = { ...(await this.getPrivateState()), ...partial }; this.setPrivateState(updated); return updated; diff --git a/contracts/src/token/test/witnesses/ConfidentialNoteFungibleTokenAllowlistWitnesses.ts b/contracts/src/token/test/witnesses/ConfidentialNoteFungibleTokenAllowlistWitnesses.ts new file mode 100644 index 000000000..5081b7b8e --- /dev/null +++ b/contracts/src/token/test/witnesses/ConfidentialNoteFungibleTokenAllowlistWitnesses.ts @@ -0,0 +1,38 @@ +// TEST-ONLY WITNESS. NOT FOR PRODUCTION USE. +// Drives ConfidentialNoteFungibleTokenAllowlist (KYC allowlist) circuits in +// off-chain tests. + +import type { + MerkleTreePath, + WitnessContext, +} from '@midnight-ntwrk/compact-runtime'; +import type { Ledger } from '../../../../artifacts/MockConfidentialNoteFungibleTokenAllowlist/contract/index.js'; + +export type ConfidentialNoteFungibleTokenAllowlistPrivateState = Record< + string, + never +>; + +export const ConfidentialNoteFungibleTokenAllowlistPrivateState = { + generate: (): ConfidentialNoteFungibleTokenAllowlistPrivateState => ({}), +}; + +export interface IConfidentialNoteFungibleTokenAllowlistWitnesses

{ + wit_AllowlistPath( + context: WitnessContext, + leaf: Uint8Array, + ): [P, MerkleTreePath]; +} + +export const ConfidentialNoteFungibleTokenAllowlistWitnesses = + (): IConfidentialNoteFungibleTokenAllowlistWitnesses => ({ + // The circuit passes the prover's identity leaf; we return its Merkle path + // by reading the live allowlist tree from the ledger. + wit_AllowlistPath(context, leaf) { + const path = context.ledger.Allow__allowed.findPathForLeaf(leaf); + if (path === undefined) { + throw new Error('wit_AllowlistPath: leaf not found in allowlist'); + } + return [context.privateState, path]; + }, + }); diff --git a/contracts/src/token/test/witnesses/ConfidentialNoteFungibleTokenAuditWitnesses.ts b/contracts/src/token/test/witnesses/ConfidentialNoteFungibleTokenAuditWitnesses.ts new file mode 100644 index 000000000..64e3a123f --- /dev/null +++ b/contracts/src/token/test/witnesses/ConfidentialNoteFungibleTokenAuditWitnesses.ts @@ -0,0 +1,47 @@ +// TEST-ONLY WITNESS. NOT FOR PRODUCTION USE. +// Drives ConfidentialNoteFungibleTokenAudit (auditor viewing) circuits in off-chain +// tests. + +import { getRandomValues } from 'node:crypto'; +import type { WitnessContext } from '@midnight-ntwrk/compact-runtime'; +import type { Ledger } from '../../../../artifacts/MockConfidentialNoteFungibleTokenAudit/contract/index.js'; + +export type ConfidentialNoteFungibleTokenAuditPrivateState = { + /** + * Optional fixed randomness seed. Leave undefined for the production-correct + * behavior (a fresh secret seed per witness call); set it only in tests that + * need deterministic ephemerals. + */ + randomnessSeed?: Uint8Array; + /** + * Audit secret scalar (auditKey = g^auditSk); consumed only by + * `_rotateAuditKey`. Defaults to 0n, which fails the rotation gate — tests + * exercising rotation must set it. + */ + auditKeySecret?: bigint; +}; + +export const ConfidentialNoteFungibleTokenAuditPrivateState = { + generate: (): ConfidentialNoteFungibleTokenAuditPrivateState => ({}), +}; + +export interface IConfidentialNoteFungibleTokenAuditWitnesses

{ + wit_AuditRandomness(context: WitnessContext): [P, Uint8Array]; + wit_AuditKeySecret(context: WitnessContext): [P, bigint]; +} + +export const ConfidentialNoteFungibleTokenAuditWitnesses = + (): IConfidentialNoteFungibleTokenAuditWitnesses => ({ + // Fresh + secret per call, as the extension requires; a fixed seed is only + // honored when a test explicitly plants one. + wit_AuditRandomness(context) { + return [ + context.privateState, + context.privateState.randomnessSeed ?? + new Uint8Array(getRandomValues(Buffer.alloc(32))), + ]; + }, + wit_AuditKeySecret(context) { + return [context.privateState, context.privateState.auditKeySecret ?? 0n]; + }, + }); diff --git a/contracts/src/token/test/witnesses/ConfidentialNoteTokenDeliveryWitnesses.ts b/contracts/src/token/test/witnesses/ConfidentialNoteFungibleTokenDeliveryWitnesses.ts similarity index 59% rename from contracts/src/token/test/witnesses/ConfidentialNoteTokenDeliveryWitnesses.ts rename to contracts/src/token/test/witnesses/ConfidentialNoteFungibleTokenDeliveryWitnesses.ts index 59e24fb5a..9e0311b84 100644 --- a/contracts/src/token/test/witnesses/ConfidentialNoteTokenDeliveryWitnesses.ts +++ b/contracts/src/token/test/witnesses/ConfidentialNoteFungibleTokenDeliveryWitnesses.ts @@ -1,12 +1,12 @@ // TEST-ONLY WITNESS. NOT FOR PRODUCTION USE. -// Drives ConfidentialNoteTokenDelivery (note delivery) circuits in off-chain +// Drives ConfidentialNoteFungibleTokenDelivery (note delivery) circuits in off-chain // tests. import { getRandomValues } from 'node:crypto'; import type { WitnessContext } from '@midnight-ntwrk/compact-runtime'; -import type { Ledger } from '../../../../artifacts/MockConfidentialNoteTokenDelivery/contract/index.js'; +import type { Ledger } from '../../../../artifacts/MockConfidentialNoteFungibleTokenDelivery/contract/index.js'; -export type ConfidentialNoteTokenDeliveryPrivateState = { +export type ConfidentialNoteFungibleTokenDeliveryPrivateState = { /** * Optional fixed randomness seed. Leave undefined for the production-correct * behavior (a fresh secret seed per witness call). @@ -14,16 +14,16 @@ export type ConfidentialNoteTokenDeliveryPrivateState = { randomnessSeed?: Uint8Array; }; -export const ConfidentialNoteTokenDeliveryPrivateState = { - generate: (): ConfidentialNoteTokenDeliveryPrivateState => ({}), +export const ConfidentialNoteFungibleTokenDeliveryPrivateState = { + generate: (): ConfidentialNoteFungibleTokenDeliveryPrivateState => ({}), }; -export interface IConfidentialNoteTokenDeliveryWitnesses

{ +export interface IConfidentialNoteFungibleTokenDeliveryWitnesses

{ wit_DeliveryRandomness(context: WitnessContext): [P, Uint8Array]; } -export const ConfidentialNoteTokenDeliveryWitnesses = - (): IConfidentialNoteTokenDeliveryWitnesses => ({ +export const ConfidentialNoteFungibleTokenDeliveryWitnesses = + (): IConfidentialNoteFungibleTokenDeliveryWitnesses => ({ // Fresh + secret per call, as the extension requires; a fixed seed is only // honored when a test explicitly plants one. wit_DeliveryRandomness(context) { diff --git a/contracts/src/token/test/witnesses/ConfidentialNoteFungibleTokenIssuerWitnesses.ts b/contracts/src/token/test/witnesses/ConfidentialNoteFungibleTokenIssuerWitnesses.ts new file mode 100644 index 000000000..93162e348 --- /dev/null +++ b/contracts/src/token/test/witnesses/ConfidentialNoteFungibleTokenIssuerWitnesses.ts @@ -0,0 +1,29 @@ +// TEST-ONLY WITNESS. NOT FOR PRODUCTION USE. +// Drives ConfidentialNoteFungibleTokenIssuer (issuer gating) circuits in +// off-chain tests. + +import { getRandomValues } from 'node:crypto'; +import type { WitnessContext } from '@midnight-ntwrk/compact-runtime'; +import type { Ledger } from '../../../../artifacts/MockConfidentialNoteFungibleTokenIssuer/contract/index.js'; + +export type ConfidentialNoteFungibleTokenIssuerPrivateState = { + /** Issuer secret (issuerPk = Hf(issuerSecret)). */ + issuerSecret: Uint8Array; +}; + +export const ConfidentialNoteFungibleTokenIssuerPrivateState = { + generate: (): ConfidentialNoteFungibleTokenIssuerPrivateState => ({ + issuerSecret: new Uint8Array(getRandomValues(Buffer.alloc(32))), + }), +}; + +export interface IConfidentialNoteFungibleTokenIssuerWitnesses

{ + wit_IssuerSecret(context: WitnessContext): [P, Uint8Array]; +} + +export const ConfidentialNoteFungibleTokenIssuerWitnesses = + (): IConfidentialNoteFungibleTokenIssuerWitnesses => ({ + wit_IssuerSecret(context) { + return [context.privateState, context.privateState.issuerSecret]; + }, + }); diff --git a/contracts/src/token/test/witnesses/ConfidentialNoteTokenSupplyWitnesses.ts b/contracts/src/token/test/witnesses/ConfidentialNoteFungibleTokenPrivateSupplyWitnesses.ts similarity index 66% rename from contracts/src/token/test/witnesses/ConfidentialNoteTokenSupplyWitnesses.ts rename to contracts/src/token/test/witnesses/ConfidentialNoteFungibleTokenPrivateSupplyWitnesses.ts index 2933c60c8..7a7e343d4 100644 --- a/contracts/src/token/test/witnesses/ConfidentialNoteTokenSupplyWitnesses.ts +++ b/contracts/src/token/test/witnesses/ConfidentialNoteFungibleTokenPrivateSupplyWitnesses.ts @@ -1,12 +1,12 @@ // TEST-ONLY WITNESS. NOT FOR PRODUCTION USE. -// Drives ConfidentialNoteTokenSupply (confidential supply) circuits in +// Drives ConfidentialNoteFungibleTokenPrivateSupply (confidential supply) circuits in // off-chain tests. import { getRandomValues } from 'node:crypto'; import type { WitnessContext } from '@midnight-ntwrk/compact-runtime'; -import type { Ledger } from '../../../../artifacts/MockConfidentialNoteTokenSupply/contract/index.js'; +import type { Ledger } from '../../../../artifacts/MockConfidentialNoteFungibleTokenPrivateSupply/contract/index.js'; -export type ConfidentialNoteTokenSupplyPrivateState = { +export type ConfidentialNoteFungibleTokenPrivateSupplyPrivateState = { /** Supply-key secret (supplyKey = derivePk(secret)); consumed by attestSupply. */ supplyKeySecret: Uint8Array; /** @@ -16,19 +16,19 @@ export type ConfidentialNoteTokenSupplyPrivateState = { randomnessSeed?: Uint8Array; }; -export const ConfidentialNoteTokenSupplyPrivateState = { - generate: (): ConfidentialNoteTokenSupplyPrivateState => ({ +export const ConfidentialNoteFungibleTokenPrivateSupplyPrivateState = { + generate: (): ConfidentialNoteFungibleTokenPrivateSupplyPrivateState => ({ supplyKeySecret: new Uint8Array(getRandomValues(Buffer.alloc(32))), }), }; -export interface IConfidentialNoteTokenSupplyWitnesses

{ +export interface IConfidentialNoteFungibleTokenPrivateSupplyWitnesses

{ wit_SupplyRandomness(context: WitnessContext): [P, Uint8Array]; wit_SupplyKeySecret(context: WitnessContext): [P, Uint8Array]; } -export const ConfidentialNoteTokenSupplyWitnesses = - (): IConfidentialNoteTokenSupplyWitnesses => ({ +export const ConfidentialNoteFungibleTokenPrivateSupplyWitnesses = + (): IConfidentialNoteFungibleTokenPrivateSupplyWitnesses => ({ // Fresh + secret per call, as the extension requires; a fixed seed is only // honored when a test explicitly plants one. wit_SupplyRandomness(context) { diff --git a/contracts/src/token/test/witnesses/ConfidentialNoteTokenAuditWitnesses.ts b/contracts/src/token/test/witnesses/ConfidentialNoteFungibleTokenReviewWitnesses.ts similarity index 52% rename from contracts/src/token/test/witnesses/ConfidentialNoteTokenAuditWitnesses.ts rename to contracts/src/token/test/witnesses/ConfidentialNoteFungibleTokenReviewWitnesses.ts index 6855f518c..c300e1eaf 100644 --- a/contracts/src/token/test/witnesses/ConfidentialNoteTokenAuditWitnesses.ts +++ b/contracts/src/token/test/witnesses/ConfidentialNoteFungibleTokenReviewWitnesses.ts @@ -1,12 +1,12 @@ // TEST-ONLY WITNESS. NOT FOR PRODUCTION USE. -// Drives ConfidentialNoteTokenAudit (auditor viewing) circuits in off-chain -// tests. +// Drives ConfidentialNoteFungibleTokenReview (selective disclosure) circuits +// in off-chain tests. import { getRandomValues } from 'node:crypto'; import type { WitnessContext } from '@midnight-ntwrk/compact-runtime'; -import type { Ledger } from '../../../../artifacts/MockConfidentialNoteTokenAudit/contract/index.js'; +import type { Ledger } from '../../../../artifacts/MockConfidentialNoteFungibleTokenReview/contract/index.js'; -export type ConfidentialNoteTokenAuditPrivateState = { +export type ConfidentialNoteFungibleTokenReviewPrivateState = { /** * Optional fixed randomness seed. Leave undefined for the production-correct * behavior (a fresh secret seed per witness call); set it only in tests that @@ -15,19 +15,19 @@ export type ConfidentialNoteTokenAuditPrivateState = { randomnessSeed?: Uint8Array; }; -export const ConfidentialNoteTokenAuditPrivateState = { - generate: (): ConfidentialNoteTokenAuditPrivateState => ({}), +export const ConfidentialNoteFungibleTokenReviewPrivateState = { + generate: (): ConfidentialNoteFungibleTokenReviewPrivateState => ({}), }; -export interface IConfidentialNoteTokenAuditWitnesses

{ - wit_AuditRandomness(context: WitnessContext): [P, Uint8Array]; +export interface IConfidentialNoteFungibleTokenReviewWitnesses

{ + wit_ReviewRandomness(context: WitnessContext): [P, Uint8Array]; } -export const ConfidentialNoteTokenAuditWitnesses = - (): IConfidentialNoteTokenAuditWitnesses => ({ +export const ConfidentialNoteFungibleTokenReviewWitnesses = + (): IConfidentialNoteFungibleTokenReviewWitnesses => ({ // Fresh + secret per call, as the extension requires; a fixed seed is only // honored when a test explicitly plants one. - wit_AuditRandomness(context) { + wit_ReviewRandomness(context) { return [ context.privateState, context.privateState.randomnessSeed ?? diff --git a/contracts/src/token/test/witnesses/ConfidentialNoteTokenWitnesses.ts b/contracts/src/token/test/witnesses/ConfidentialNoteFungibleTokenWitnesses.ts similarity index 70% rename from contracts/src/token/test/witnesses/ConfidentialNoteTokenWitnesses.ts rename to contracts/src/token/test/witnesses/ConfidentialNoteFungibleTokenWitnesses.ts index 656a9ac25..e0c841666 100644 --- a/contracts/src/token/test/witnesses/ConfidentialNoteTokenWitnesses.ts +++ b/contracts/src/token/test/witnesses/ConfidentialNoteFungibleTokenWitnesses.ts @@ -1,21 +1,19 @@ // TEST-ONLY WITNESS. NOT FOR PRODUCTION USE. -// Drives ConfidentialNoteToken (core) circuits in off-chain tests. +// Drives ConfidentialNoteFungibleToken (core) circuits in off-chain tests. import { getRandomValues } from 'node:crypto'; import type { MerkleTreePath, WitnessContext, } from '@midnight-ntwrk/compact-runtime'; -import type { Ledger } from '../../../../artifacts/MockConfidentialNoteToken/contract/index.js'; +import type { Ledger } from '../../../../artifacts/MockConfidentialNoteFungibleToken/contract/index.js'; /** A note as the circuits see it: value + field-typed nonce. */ export type Note = { value: bigint; nonce: bigint }; -export type ConfidentialNoteTokenPrivateState = { +export type ConfidentialNoteFungibleTokenPrivateState = { /** Owner spend secret; pk = Hf(sk). */ secretKey: Uint8Array; - /** Issuer secret (issuerPk = Hf(issuerSecret)). */ - issuerSecret: Uint8Array; /** The input note being spent in a transfer/burn (or consume target). */ inputNote: Note; /** @@ -25,17 +23,15 @@ export type ConfidentialNoteTokenPrivateState = { nonceSeed?: Uint8Array; }; -export const ConfidentialNoteTokenPrivateState = { - generate: (): ConfidentialNoteTokenPrivateState => ({ +export const ConfidentialNoteFungibleTokenPrivateState = { + generate: (): ConfidentialNoteFungibleTokenPrivateState => ({ secretKey: new Uint8Array(getRandomValues(Buffer.alloc(32))), - issuerSecret: new Uint8Array(getRandomValues(Buffer.alloc(32))), inputNote: { value: 0n, nonce: 0n }, }), }; -export interface IConfidentialNoteTokenWitnesses

{ +export interface IConfidentialNoteFungibleTokenWitnesses

{ wit_SecretKey(context: WitnessContext): [P, Uint8Array]; - wit_IssuerSecret(context: WitnessContext): [P, Uint8Array]; wit_InputNote(context: WitnessContext): [P, Note]; wit_Path( context: WitnessContext, @@ -44,21 +40,18 @@ export interface IConfidentialNoteTokenWitnesses

{ wit_NonceRandomness(context: WitnessContext): [P, Uint8Array]; } -export const ConfidentialNoteTokenWitnesses = - (): IConfidentialNoteTokenWitnesses => ({ +export const ConfidentialNoteFungibleTokenWitnesses = + (): IConfidentialNoteFungibleTokenWitnesses => ({ wit_SecretKey(context) { return [context.privateState, context.privateState.secretKey]; }, - wit_IssuerSecret(context) { - return [context.privateState, context.privateState.issuerSecret]; - }, wit_InputNote(context) { return [context.privateState, context.privateState.inputNote]; }, // The circuit passes the input commitment; we return its Merkle path by // reading the live commitment tree from the ledger. wit_Path(context, cm) { - const path = context.ledger.CNT__commitments.findPathForLeaf(cm); + const path = context.ledger.Core__commitments.findPathForLeaf(cm); if (path === undefined) { throw new Error('wit_Path: commitment not found in tree'); } diff --git a/contracts/src/token/test/witnesses/RegulatedConfidentialNoteTokenWitnesses.ts b/contracts/src/token/test/witnesses/RegulatedConfidentialNoteFungibleTokenWitnesses.ts similarity index 73% rename from contracts/src/token/test/witnesses/RegulatedConfidentialNoteTokenWitnesses.ts rename to contracts/src/token/test/witnesses/RegulatedConfidentialNoteFungibleTokenWitnesses.ts index deafbd8cf..6e115169d 100644 --- a/contracts/src/token/test/witnesses/RegulatedConfidentialNoteTokenWitnesses.ts +++ b/contracts/src/token/test/witnesses/RegulatedConfidentialNoteFungibleTokenWitnesses.ts @@ -1,18 +1,19 @@ // TEST-ONLY WITNESS. NOT FOR PRODUCTION USE. -// Drives the RegulatedConfidentialNoteToken preset contract in off-chain -// tests: the union of the composed modules' witnesses. +// Drives the RegulatedConfidentialNoteFungibleToken preset module (via its +// mock contract) in off-chain tests: the union of the composed modules' +// witnesses. import { getRandomValues } from 'node:crypto'; import type { MerkleTreePath, WitnessContext, } from '@midnight-ntwrk/compact-runtime'; -import type { Ledger } from '../../../../artifacts/RegulatedConfidentialNoteToken/contract/index.js'; +import type { Ledger } from '../../../../artifacts/MockRegulatedConfidentialNoteFungibleToken/contract/index.js'; /** A note as the circuits see it: value + field-typed nonce. */ export type Note = { value: bigint; nonce: bigint }; -export type RegulatedConfidentialNoteTokenPrivateState = { +export type RegulatedConfidentialNoteFungibleTokenPrivateState = { /** Owner spend secret; pk = Hf(sk). */ secretKey: Uint8Array; /** Issuer secret (issuerPk = Hf(issuerSecret)). */ @@ -21,12 +22,18 @@ export type RegulatedConfidentialNoteTokenPrivateState = { authoritySecret: Uint8Array; /** Supply-key secret (supplyKey = derivePk(secret)); consumed by attestSupply. */ supplyKeySecret: Uint8Array; + /** + * Audit secret scalar (auditKey = g^auditSk); consumed only by + * `rotateAuditKey`. Defaults to 0n, which fails the rotation gate — tests + * exercising rotation must set it. + */ + auditKeySecret?: bigint; /** The input note being spent in a transfer/burn (or seize target). */ inputNote: Note; }; -export const RegulatedConfidentialNoteTokenPrivateState = { - generate: (): RegulatedConfidentialNoteTokenPrivateState => ({ +export const RegulatedConfidentialNoteFungibleTokenPrivateState = { + generate: (): RegulatedConfidentialNoteFungibleTokenPrivateState => ({ secretKey: new Uint8Array(getRandomValues(Buffer.alloc(32))), issuerSecret: new Uint8Array(getRandomValues(Buffer.alloc(32))), authoritySecret: new Uint8Array(getRandomValues(Buffer.alloc(32))), @@ -38,7 +45,7 @@ export const RegulatedConfidentialNoteTokenPrivateState = { const freshSeed = (): Uint8Array => new Uint8Array(getRandomValues(Buffer.alloc(32))); -export interface IRegulatedConfidentialNoteTokenWitnesses

{ +export interface IRegulatedConfidentialNoteFungibleTokenWitnesses

{ wit_SecretKey(context: WitnessContext): [P, Uint8Array]; wit_IssuerSecret(context: WitnessContext): [P, Uint8Array]; wit_AuthoritySecret(context: WitnessContext): [P, Uint8Array]; @@ -49,12 +56,13 @@ export interface IRegulatedConfidentialNoteTokenWitnesses

{ cm: Uint8Array, ): [P, MerkleTreePath]; wit_AuditRandomness(context: WitnessContext): [P, Uint8Array]; + wit_AuditKeySecret(context: WitnessContext): [P, bigint]; wit_DeliveryRandomness(context: WitnessContext): [P, Uint8Array]; wit_SupplyRandomness(context: WitnessContext): [P, Uint8Array]; } -export const RegulatedConfidentialNoteTokenWitnesses = - (): IRegulatedConfidentialNoteTokenWitnesses => ({ +export const RegulatedConfidentialNoteFungibleTokenWitnesses = + (): IRegulatedConfidentialNoteFungibleTokenWitnesses => ({ wit_SecretKey(context) { return [context.privateState, context.privateState.secretKey]; }, @@ -84,6 +92,9 @@ export const RegulatedConfidentialNoteTokenWitnesses = wit_AuditRandomness(context) { return [context.privateState, freshSeed()]; }, + wit_AuditKeySecret(context) { + return [context.privateState, context.privateState.auditKeySecret ?? 0n]; + }, wit_DeliveryRandomness(context) { return [context.privateState, freshSeed()]; }, From 02fabb61d200b6cd5025bb22734e6e18dbddbc9a Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Thu, 23 Jul 2026 17:08:08 +0200 Subject: [PATCH 3/4] chore: push the design doc --- confidential-note-token.md | 633 +++++++++++++++++++++++++++++++++++++ 1 file changed, 633 insertions(+) create mode 100644 confidential-note-token.md diff --git a/confidential-note-token.md b/confidential-note-token.md new file mode 100644 index 000000000..210653c3e --- /dev/null +++ b/confidential-note-token.md @@ -0,0 +1,633 @@ +# Confidential Note Fungible Token on Midnight + +> **Status:** educational draft (2026-07-23), destination Notion. A companion to draft PR [#679](https://github.com/OpenZeppelin/compact-contracts/pull/679) (`feat(token): confidential note token draft`), written to be read **side by side with the code**. Each circuit section links to the exact source lines at the PR's pinned commit [`878aa43`](https://github.com/OpenZeppelin/compact-contracts/commit/878aa438b98879088f13f0ef96e10311ff020257). +> +> **Verification.** External definitions in *Concepts* are quoted verbatim from primary sources (Zcash protocol specification, Zerocash paper, Compact language reference at [`LFDT-Minokawa/compact`](https://github.com/LFDT-Minokawa/compact) @ `c06961e`), with links in *References*. Cost figures (`k`, rows) are the compiler's own `@circuitInfo` numbers from the source. The code is a DRAFT: not audited, not production. +> +> **Naming.** Renames adopted after review (2026-07-23), putting the asset class in the name and keeping the `…FungibleToken` suffix family: `ConfidentialNoteToken` → **`ConfidentialNoteFungibleToken`**, `ConfidentialNoteTokenAudit` → `ConfidentialNoteFungibleTokenAudit`, `ConfidentialNoteTokenDelivery` → `ConfidentialNoteFungibleTokenDelivery`, `ConfidentialNoteTokenSupply` → **`ConfidentialNoteFungibleTokenPrivateSupply`**, `RegulatedConfidentialNoteToken` → `RegulatedConfidentialNoteFungibleToken`. This document uses the new names. The pinned code at `878aa43` still carries the old file and identifier names, so the source links and the verbatim code quotes (including the `CNT_` / `Audit_` / `Delivery_` / `Supply_` import prefixes) show the old naming. + +# 0. TODO — working list (2026-07-23) + +Missing pieces identified while hardening the draft, grouped by driver. Compliance modules are being added on this branch; the rest are queued. + +**Functional gaps** + +- [ ] **Note consolidation / multi-input spend** — every spend consumes exactly one input note, so nothing larger than your biggest note is payable and balances fragment forever. Needs a `_join` / two-input `_consumeNote` in the **core** (it needs the tree + nullifiers; not expressible as an extension). Biggest practical hole. +- [ ] **Metadata extension** — no `name`/`symbol`/`decimals` anywhere in the family; NST and CFT both have it. Sealed fields + getters. +- [ ] **Batch outputs** — pay N recipients in one proof (one nullifier, N+1 commitments). Compile-time variant; also reduces transaction-shape leakage. + +**Compliance (in progress on this branch)** + +- [ ] **Freeze extension** — freeze-before-seize: a frozen-nullifier set checked at the owner-spend chokepoint; seizure of frozen notes still works. +- [ ] **KYC allowlist extension** — Merkle allowlist proven in-circuit at spend time (hidden spender ⇒ ZK membership, not a `Set` lookup); tombstone removal against the current root. +- [ ] **Review (selective disclosure) extension** — per-output encrypted records to an approved reviewer key (custodian/FIU), alongside the global audit channel; final shape pending BitGo FIU feedback. +- [ ] **Role rotation** — self-rotation blocks in Issuer/Audit/Supply (prove the current secret, bind the new key; supply rotation re-encrypts `_encSupply` under the new key in-proof) and `rotateAuthority` in the preset. + +**Supply** + +- [ ] **PublicSupply extension** — the third row of the supply spectrum (disclosed counters); agreed, trivial. +- [ ] **Capped supply** — easy on PublicSupply; deferred on the encrypted variant (needs a range proof on a ciphertext). + +**Composition** + +- [ ] **Basic preset** — core + Issuer: gated mint, self-gated transfer/burn, out-of-band notes. The readable entry point next to Regulated. +- [ ] **Multisig-gated roles** — compose issuer/authority with the existing `multisig/` package in a preset; no new module. + +**Housekeeping** + +- [ ] **Doc sweep** — sections below still describe the pre-refactor draft: issuer split out of the core, preset converted to a module (`initialize` instead of constructor), `Core_`/`Token_` prefixes, `_mint`/`_mintNote` naming. +- [ ] **Domain-tag rename** — on-chain tags still read `OZ:cnt:*`; pick the replacement and sweep once. +- [ ] **CHANGELOG update** — issuer extension, module conversion, renames. +- [ ] **Token-level test suites** — the §12 invariants as Vitest specs. +- [ ] **Re-pin source links** — after the refactor commits land, repoint the doc's deep links from `878aa43`. + +# 1. Summary + +The **Confidential Note Fungible Token** is a token whose value records live entirely inside a Compact contract as **notes** (UTXOs, in Bitcoin terms): each note is a `(value, nonce)` pair owned by a key, represented on the public ledger only by a *hiding commitment* in a Merkle tree. Spending a note reveals a *nullifier* (preventing double-spends) and proves, in zero knowledge, that the note exists in the tree, without revealing which one. + +The result is **full graph privacy**: amounts (including issuance and burns), senders, and recipients are all hidden from the public ledger. This is the property neither of the library's other token designs delivers. The account-based `ConfidentialFungibleToken` hides amounts but keeps the account graph public. The native shielded token hides transfers (Zswap does that at the protocol level) but publishes every mint and burn amount as a supply delta. The note model hides all of it, and it is the only model that can: sender privacy fundamentally requires an unindexed commitment set with ZK membership proofs, which *is* the note model (§3). + +The PR builds this as OpenZeppelin-style composable pieces rather than a monolith: + +| Piece | File | Role | +| --- | --- | --- | +| **Core** | [`token/ConfidentialNoteToken.compact`](https://github.com/OpenZeppelin/compact-contracts/blob/878aa438b98879088f13f0ef96e10311ff020257/contracts/src/token/ConfidentialNoteToken.compact) | commitment tree, nullifier single-spend, value conservation, issuer gate. A complete token on its own. | +| **Audit extension** | [`extensions/ConfidentialNoteTokenAudit.compact`](https://github.com/OpenZeppelin/compact-contracts/blob/878aa438b98879088f13f0ef96e10311ff020257/contracts/src/token/extensions/ConfidentialNoteTokenAudit.compact) | auditor viewing, complete by construction: every output's nonce is *derived from* an ECDH with the audit key, so an output the auditor cannot open cannot exist | +| **Delivery extension** | [`extensions/ConfidentialNoteTokenDelivery.compact`](https://github.com/OpenZeppelin/compact-contracts/blob/878aa438b98879088f13f0ef96e10311ff020257/contracts/src/token/extensions/ConfidentialNoteTokenDelivery.compact) | on-chain note delivery: recipients discover incoming funds by scanning chain data, no out-of-band channel | +| **Supply extension** | [`extensions/ConfidentialNoteTokenSupply.compact`](https://github.com/OpenZeppelin/compact-contracts/blob/878aa438b98879088f13f0ef96e10311ff020257/contracts/src/token/extensions/ConfidentialNoteTokenSupply.compact) | confidential supply: homomorphic ElGamal running total plus proof-backed public attestation | +| **Preset** | [`presets/RegulatedConfidentialNoteToken.compact`](https://github.com/OpenZeppelin/compact-contracts/blob/878aa438b98879088f13f0ef96e10311ff020257/contracts/src/token/presets/RegulatedConfidentialNoteToken.compact) | the wired-together deployable token: issuer mint, private transfer/burn, escrow-free seizure, attested supply | +| **Crypto primitive** | [`crypto/NoteDelivery.compact`](https://github.com/OpenZeppelin/compact-contracts/blob/878aa438b98879088f13f0ef96e10311ff020257/contracts/src/crypto/NoteDelivery.compact) | the ECDH note-delivery channel (this PR); builds on the already-merged `crypto/EcdhMask` and `crypto/ElGamal` | + +Three design moves carry most of the system, and each gets a full section below: + +- **Audit-derived nonces** (§6): the note's nonce comes out of the audit ECDH, making auditor visibility *structural* rather than policy. A transaction that skips or fakes its audit record cannot exist, because the commitment binds the same fields the record encrypts, inside one proof. +- **Shared nullifiers** (§5, §9): the nullifier preimage is the nonce alone, no owner secret. Anyone who knows the nonce derives the *same* nullifier, which makes regulated seizure escrow-free: owner-spend and seizure are mutually exclusive, first to land wins, and the authority never holds spend keys. +- **Supply as a policy layer** (§8): the core writes no public supply at all. A deployment chooses none, confidential-plus-attested, or fully public. + +# 2. Concepts, from the sources + +The terms this document relies on, defined by primary sources rather than restated. Quoted text is verbatim. + +- **Zero-knowledge proof.** "'Zero-knowledge' proofs allow one party (the prover) to prove to another (the verifier) that a statement is true, without revealing any information beyond the validity of the statement itself." — Zcash, *What are zk-SNARKs?* [[1]](#ref-1). Every state-changing circuit in this design is such a proof: the ledger learns *that* a valid spend happened, not *what* was spent. +- **UTXO.** "An Unspent Transaction Output (UTXO) that can be spent as an input in a new transaction." — Bitcoin developer glossary [[2]](#ref-2). A note is the shielded analogue of a UTXO: value exists as discrete spendable records, not account balances. +- **Note.** "A note is a representation of value held in a shielded pool. … It represents that a value v is spendable by the recipient who holds the spending key corresponding to a given shielded payment address." — Zcash protocol specification, §3.2 [[3]](#ref-3). Here a note is the struct `Note { value: Uint<128>, nonce: Field }`, owned by whoever's public key `pk` was bound into its commitment. +- **Note commitment.** "When a note is created as an output of a transaction, only a commitment … to the note contents is disclosed publically … This allows the value and recipient to be kept private, while the commitment is used by the zk-SNARK proof when the note is spent, to check that it exists on the block chain." — Zcash protocol specification, §3.2.2 [[4]](#ref-4). Here: `cm = H(domain, value, nonce, pk)` with a SHA-256-class `persistentHash`; the 256-bit nonce provides the hiding entropy. +- **Note commitment tree.** "A note commitment tree is an incremental Merkle tree, of fixed depth …, used to store note commitments … Just as the UTXO (unspent transaction output) set used in Bitcoin, it is used to express the existence of value and the capability to spend it. However, unlike the UTXO set, it is not the job of this tree to protect against double-spending, as it is append-only." — Zcash protocol specification, §3.8 [[5]](#ref-5). +- **Nullifier.** "Nullifiers are enforced to be unique within a valid block chain, in order to prevent double-spends." — Zcash protocol specification, §3.9 [[6]](#ref-6). Zcash's design rationale requires that the "nullifier deterministically depends only on values committed to (directly or indirectly) by the note commitment" [[7]](#ref-7) — a requirement this design satisfies with the *minimal* preimage `nf = H(domain, nonce)`, deliberately omitting any owner secret (§14 explains the trade-off). +- **Graph privacy.** The property Zerocash introduced: "the corresponding transaction hides the payment's origin, destination, and transferred amount." — Ben-Sasson et al., *Zerocash: Decentralized Anonymous Payments from Bitcoin* [[8]](#ref-8). "Graph" refers to the who-paid-whom transaction graph, which stays hidden even though every transaction is public. +- **Witness (Compact).** "A circuit can also access or update private state as it operates via *witnesses*. Witnesses are callback functions provided by the TypeScript driver." — Compact language reference [[9]](#ref-9). Witnesses are how secrets (spend keys, input notes, randomness seeds) enter a circuit without touching the chain. +- **Disclosure (Compact).** "Disclosure of private data (exported circuit arguments, witness return values, and anything derived from private data) must be acknowledged by wrapping an expression whose value contains private data in a `disclose()` wrapper before storing it in the public state." — Compact language reference [[10]](#ref-10). Every `disclose()` in this code marks a deliberate crossing of the privacy boundary; §10 justifies each one. +- **`HistoricMerkleTree` (Compact).** "This ADT is a bounded Merkle tree of depth nat where 2 `<=` nat `<=` 32 containing values of type value_type, with history." Its `checkRoot` "tests if the given Merkle tree root is one of the past roots for this Merkle tree." — Compact ledger ADT reference [[11]](#ref-11). The history matters: a proof built against a slightly stale tree still verifies after later inserts. +- **ECDH (elliptic-curve Diffie–Hellman).** The key-agreement construction of Diffie and Hellman [[12]](#ref-12), on an elliptic curve: from one party's public point `pk = g^k` and the other's secret scalar `e`, both reach the same shared point `S = pk^e = (g^e)^k`. The repo's `crypto/EcdhMask` states the concrete use: "`E = g^e` (ephemeral public key), `S = pk^e` (ECDH shared secret point), `mask = KDF(S)`, `ct = value + mask` (field one-time-pad). Recipient recovers: `S = E^ek`, `mask = KDF(S)`, `value = ct - mask`." [[13]](#ref-13) +- **Exponential (lifted) ElGamal.** ElGamal encryption [[14]](#ref-14) with the plaintext lifted into the exponent. The repo's `crypto/ElGamal` module: "In the lifted variant a value `v` is encrypted as the point `g^v`, so the scheme is additively homomorphic in the exponent: ciphertexts of `a` and `b` combine into a ciphertext of `a + b`." [[15]](#ref-15) This is what lets the supply extension update an encrypted total without anyone decrypting it. +- **Jubjub.** "Jubjub is the twisted Edwards curve `-u^2 + v^2 = 1 + d.u^2.v^2`" defined over the scalar field of BLS12-381 [[16]](#ref-16) — an "embedded" curve whose arithmetic is cheap *inside* a proof system over that field. Compact's standard library exposes it as the opaque type `JubjubPoint` with `ecMul` / `ecMulGenerator` operations [[17]](#ref-17). All encryption keys in this design (audit, delivery, supply) are Jubjub points. + +# 3. Why notes: the model choice + +## 3.1 The privacy spectrum + +The exploration branch behind this PR ([`contracts/privacy_readme.md`](https://github.com/OpenZeppelin/compact-contracts/blob/878aa438b98879088f13f0ef96e10311ff020257/contracts/privacy_readme.md)) built four tiers of confidential token and measured each: + +| Tier | Hides | Model | Verdict | +| --- | --- | --- | --- | +| 1 | amounts | account (ElGamal balances) | shippable; public graph | +| 2 | + recipient | account + stealth addresses | works; real UX cost | +| 3 | + sender (weak) | account + ring | dominated, kept as an exhibit | +| 4 | amounts + sender + recipient | **notes** | this PR | + +The load-bearing finding: **sender privacy on an account model has no cheap trick.** A debit is a write to *some* public per-account slot, and hiding which slot requires either touch-all-N cover traffic (the ring, which loses on cost) or an unindexed commitment set with ZK membership and nullifiers. The latter *is* the note model. So notes are not "the account model, more private"; they are the shape sender privacy converges to, which is also why Zcash, and Aztec's account-over-notes design, sit on the same skeleton. + +## 3.2 Versus native shielded (Zswap) coins + +Midnight already has protocol-level shielded UTXOs (Zswap), and the library has a [Native Shielded Token](https://github.com/OpenZeppelin/compact-contracts/issues/544) standard for issuing them. Why build a second UTXO system inside a contract? + +| | Native shielded token (Zswap coins) | Confidential note token (this PR) | +| --- | --- | --- | +| Transfers | protocol-level, contract not involved | contract circuit per transfer | +| Transfer privacy | hidden by Zswap by construction | hidden by the note pool | +| **Mint/burn amounts** | **public** (the ledger's `shieldedMints` effect and supply deltas) | **hidden** (only a commitment appears) | +| Auditor viewing | not expressible | opt-in extension; once wired, complete by construction (§6) | +| Seizure / clawback | none (bearer instrument) | escrow-free `seize` (§9) | +| Post-issuance control | none in phase one | issuer/authority policy is ordinary contract code | +| Cost | cheap (protocol does the work) | large circuits (§13) | + +The two are complements. Native issuance is the right shape for a plain private bearer asset. The note pool is the shape for a *regulated* confidential asset: hidden issuance amounts, auditor viewing users cannot evade, seizure. It can hide issuance because it never touches Zswap coins; the only channel that would expose amounts is a public supply write, and the core simply never makes one (§8). + +## 3.3 The model in one picture + +A note's life is three events on the public ledger, none of which name amounts or parties: + +``` +create deliver spend +────── ─────── ───── +cm = H(domain,value,nonce,pk) ciphertext of (value,nonce) nf = H(domain,nonce) +inserted into to the owner's encPk, inserted into _nullifiers, +_commitments (Merkle tree) pushed to _deliveries + a Merkle membership proof + against some historical root + "a note exists" "someone can find it" "some note was spent" +``` + +Identity is **two keys per account**, because the two jobs need different math: + +- **spend key** `pk = Hf(sk)`: a Field-typed hash of a 32-byte secret. Owns notes. Field-typed so it can ride the field-arithmetic ciphertexts of the audit and delivery records. +- **encryption key** `encPk = g^encSk`: a Jubjub point. Receives note deliveries via ECDH. + +The circuits cannot bind the two together; an account publishes them as a pair, and a sender who addresses a delivery to the wrong `encPk` only prevents *discovery*, not the note's existence. + +# 4. Architecture: the composition + +``` +RegulatedConfidentialNoteFungibleToken (preset — the deployable contract) + ├── ConfidentialNoteFungibleToken core: tree, nullifiers, conservation, issuer gate + ├── ConfidentialNoteFungibleTokenAudit ext: audit records + DERIVES output nonces + ├── ConfidentialNoteFungibleTokenDelivery ext: on-chain (value,nonce) delivery + │ └── crypto/NoteDelivery pure ECDH delivery primitive + └── ConfidentialNoteFungibleTokenPrivateSupply ext: homomorphic supply + attestation + └── crypto/ElGamal lifted-ElGamal primitives (merged earlier) + (Audit uses crypto/EcdhMask's KDF; merged earlier) +``` + +Four conventions to know before reading any circuit: + +- **`_`-prefixed circuits are ungated building blocks.** `_mint`, `_transfer`, `_burn`, `_consumeNote` carry *no* authorization; the composing contract gates them. This is the same pattern as the library's `ConfidentialFungibleToken`: the module provides mechanisms, the preset provides policy. +- **Modules are private unless composed.** The preset imports every module under a prefix (`CNT_`, `Audit_`, …). A module's exported circuits become callable *by the preset's code*, not public entry points of the deployed contract; only the preset's own `export circuit` declarations are callable externally. So deploying the preset does not expose `CNT__transfer` to the world. +- **The re-export block surfaces observable state.** A prefix-only import keeps a module's ledger fields out of the generated TypeScript `ledger()` reader. The preset therefore explicitly re-imports and re-exports the fields wallets, auditors, and indexers must read ([source](https://github.com/OpenZeppelin/compact-contracts/blob/878aa438b98879088f13f0ef96e10311ff020257/contracts/src/token/presets/RegulatedConfidentialNoteToken.compact#L60-L87)): `_commitments`, `_nullifiers`, `_auditTrail`, `_deliveries`, the supply cells. +- **Extensions import no token module.** Audit, Delivery, and Supply are standalone; the *consumer* wires them to the core. This keeps each piece independently reusable and independently testable, at the price of a wiring obligation the consumer must not get wrong (§6.2, §8.2). + +The wiring itself is one small circuit in the preset, and it is the best single thing to understand in the whole design ([source](https://github.com/OpenZeppelin/compact-contracts/blob/878aa438b98879088f13f0ef96e10311ff020257/contracts/src/token/presets/RegulatedConfidentialNoteToken.compact#L242-L249)): + +```compact +// Emission policy for one output note: the audit record derives the nonce, +// the delivery makes the note discoverable. Returns the note for the core to commit. +circuit emitOutput(ownerPk: Field, encPk: JubjubPoint, value: Uint<128>, slot: Bytes<32>): CNT_Note { + const nonce = Audit__emitAuditedOutput(ownerPk, value, slot); + Delivery__deliver(encPk, value, nonce, slot); + return CNT_Note { value: value, nonce: nonce }; +} +``` + +Every output note the preset ever creates (mint output, transfer output, transfer change, burn change, seizure recovery) flows through this one function: audited first (which *produces* the nonce), delivered second, committed by the core third. That single chokepoint is what makes the compliance properties structural. + +# 5. The core, circuit by circuit + +File: [`token/ConfidentialNoteToken.compact`](https://github.com/OpenZeppelin/compact-contracts/blob/878aa438b98879088f13f0ef96e10311ff020257/contracts/src/token/ConfidentialNoteToken.compact). The core is a complete, self-contained token: `initialize` + `mint` + `transfer` + `burn` work with no extension. In standalone mode, created notes are returned to the caller as a local private result and handed to recipients out of band; the extensions replace that with on-chain delivery. + +## 5.1 State and witnesses + +```compact +export ledger _isInitialized: Boolean; +export ledger _issuerPk: Field; // Hf(issuerSecret) +export ledger _commitments: HistoricMerkleTree<32, Bytes<32>>; +export ledger _nullifiers: Set>; +``` + +Two collections carry the whole model: the append-only commitment tree ("what value exists") and the nullifier set ("what has been spent"). Exactly the Zcash split quoted in §2: the tree proves existence, the set prevents double-spends, and neither reveals amounts or owners. + +```compact +witness wit_SecretKey(): Bytes<32>; // owner's spend secret; pk = Hf(sk) +witness wit_IssuerSecret(): Bytes<32>; // issuer's secret; issuerPk = Hf(secret) +witness wit_InputNote(): Note; // the note being consumed +witness wit_Path(cm): MerkleTreePath<32, Bytes<32>>; // its Merkle path +witness wit_NonceRandomness(): Bytes<32>; // fresh + secret seed per invocation +``` + +These are the private inputs (§2, *Witness*). The wallet supplies them per call; nothing here reaches the chain except through an explicit `disclose()`. + +> **The one rule that keeps the system alive:** nonces are spend-critical. The nullifier preimage is the nonce alone, so *any* party that knows a nonce derives the same nullifier. That is a feature (it is what makes seizure escrow-free, §9.4) and a hard requirement: output nonces MUST be unique and unpredictable, and `wit_NonceRandomness` MUST return a fresh, secret seed per invocation. + +## 5.2 The pure derivations + +Three exported `pure` circuits define the note algebra. They are exported precisely so off-chain code (wallets, auditors, tests) derives identities and watches notes *the same way the circuits do*. + +**`derivePk(sk: Bytes<32>): Field`** ([source](https://github.com/OpenZeppelin/compact-contracts/blob/878aa438b98879088f13f0ef96e10311ff020257/contracts/src/token/ConfidentialNoteToken.compact#L91-L93)) + +```compact +return degradeToTransient(persistentHash>(sk)); +``` + +The identity hash `pk = Hf(sk)`. `persistentHash` is the SHA-256-class hash; `degradeToTransient` maps the digest into a Field so the pk can participate in field arithmetic (needed by the audit/delivery ciphertexts, which one-time-pad Field values). + +**`commitOf(note: Note, pk: Field): Bytes<32>`** ([source](https://github.com/OpenZeppelin/compact-contracts/blob/878aa438b98879088f13f0ef96e10311ff020257/contracts/src/token/ConfidentialNoteToken.compact#L99-L106)) + +`cm = H("OZ:cnt:commit", value, nonce, pk)`. The commitment binds all three fields; the nonce's 256 bits of entropy make it hiding (§2, *Note commitment*). Note that ownership is bound here, in the commitment, not in the nullifier: this is why only someone who can produce the right `pk` can spend the note (§5.6). + +**`nullifierOf(note: Note): Bytes<32>`** ([source](https://github.com/OpenZeppelin/compact-contracts/blob/878aa438b98879088f13f0ef96e10311ff020257/contracts/src/token/ConfidentialNoteToken.compact#L112-L117)) + +`nf = H("OZ:cnt:null", nonce)`. Derivable by anyone who knows the nonce, and by design *only* from the nonce. Compare Zcash, where nullifier derivation involves a per-account nullifier key, so knowing a note's contents does not let third parties track its spend. Here it does, deliberately: the auditor watches consumption, and the authority seizes, through exactly this property. The cost is that nonce secrecy carries all spend protection (§14). + +Both hashes are domain-separated (`OZ:cnt:commit` vs `OZ:cnt:null`), so a commitment can never be replayed as a nullifier or vice versa. + +## 5.3 `initialize(issuerPk: Field): []` · k=6, 31 rows + +[source](https://github.com/OpenZeppelin/compact-contracts/blob/878aa438b98879088f13f0ef96e10311ff020257/contracts/src/token/ConfidentialNoteToken.compact#L129-L133) — One-shot: asserts not already initialized, stores `_issuerPk`, sets the flag. The issuer is the only role the core itself needs; audit/authority/supply keys belong to the extensions and preset. + +## 5.4 The user-facing circuits: `mint`, `transfer`, `burn` + +These three are the core's own out-of-the-box token. Each derives output nonces from the caller's randomness witness via the private helper `freshNonce`, and each returns the created note(s) to the caller as a **local private result**: the return value goes to the calling wallet only, nothing extra on-chain. Revealing a returned note publicly would expose its nonce (spend-critical) and its amount. + +**`mint(recipientPk: Field, value: Uint<128>): Note`** · k=14, 13 217 rows ([source](https://github.com/OpenZeppelin/compact-contracts/blob/878aa438b98879088f13f0ef96e10311ff020257/contracts/src/token/ConfidentialNoteToken.compact#L148-L155)) + +- Gate: `_assertIssuer()` — the caller proves the issuer secret in-circuit. +- Builds `Note { value, nonce: freshNonce("OZ:cnt:out") }` and calls `_mint(note, recipientPk)`. +- Public effect: **one commitment insert. The amount is written nowhere.** Issuance stays hidden; this is the headline difference from native shielded tokens (§3.2). +- Returns the note so the issuer can hand it to the recipient out of band (standalone mode). + +**`transfer(recipientPk: Field, value: Uint<128>): [Note, Note]`** · k=16, 36 394 rows ([source](https://github.com/OpenZeppelin/compact-contracts/blob/878aa438b98879088f13f0ef96e10311ff020257/contracts/src/token/ConfidentialNoteToken.compact#L172-L184)) + +- Reads the caller's identity (`_spenderPk()`) and input note (`_inputNote()`), asserts `input.value >= value`. +- Builds the recipient note (`value`) and a change note (`input.value - value`) with distinct nonce slots (`"OZ:cnt:out"` vs `"OZ:cnt:chg"`, so the two nonces differ even within one invocation). +- Delegates to `_transfer`, which consumes the input and commits both outputs. +- Public effect: **one nullifier, two commitments.** No amounts, no parties. Note the UTXO idiom: there is no partial spend; the input is consumed whole and change returns to the sender as a brand-new note, exactly like Bitcoin change outputs. +- Returns `[outNote, changeNote]` to the caller's wallet. + +**`burn(value: Uint<128>): Note`** · k=15, 25 584 rows ([source](https://github.com/OpenZeppelin/compact-contracts/blob/878aa438b98879088f13f0ef96e10311ff020257/contracts/src/token/ConfidentialNoteToken.compact#L199-L210)) + +- Same shape as `transfer` minus the recipient note: consumes the input, re-issues only the change, so `value` leaves circulation. +- Public effect: one nullifier, one commitment. **A burn is publicly indistinguishable from any other spend**, and the burned amount is hidden. (Transaction *shape* still distinguishes a burn/seize from a transfer; see §10.) + +## 5.5 The gate and the peek + +**`_assertIssuer(): []`** · k=13, 2 277 rows ([source](https://github.com/OpenZeppelin/compact-contracts/blob/878aa438b98879088f13f0ef96e10311ff020257/contracts/src/token/ConfidentialNoteToken.compact#L231-L235)) — asserts initialization and `Hf(wit_IssuerSecret()) == _issuerPk`. Authorization by hash-preimage proof: the secret never leaves the wallet, and there is no signature; the ZK proof itself is the authentication. + +**`_spenderPk(): Field`** ([source](https://github.com/OpenZeppelin/compact-contracts/blob/878aa438b98879088f13f0ef96e10311ff020257/contracts/src/token/ConfidentialNoteToken.compact#L217-L219)) — `Hf(wit_SecretKey())`, the caller's spend identity, exposed so composing contracts authorize spends the way the core does. + +**`_inputNote(): Note`** ([source](https://github.com/OpenZeppelin/compact-contracts/blob/878aa438b98879088f13f0ef96e10311ff020257/contracts/src/token/ConfidentialNoteToken.compact#L244-L246)) — a peek at the same witness `_consumeNote` will read, so a composer can size the change and run its emission policy *before* the spend. Consistency is not trusted: `_transfer`/`_burn` re-read the witness and enforce conservation against it, so a mismatch between the peek and the spend fails the proof. + +## 5.6 `_consumeNote(ownerPk: Field): Note` — the heart · k=14, 12 248 rows + +[source](https://github.com/OpenZeppelin/compact-contracts/blob/878aa438b98879088f13f0ef96e10311ff020257/contracts/src/token/ConfidentialNoteToken.compact#L313-L333) — Everything that makes this a shielded pool happens in these twenty lines. Step by step: + +```compact +const input = wit_InputNote(); // (1) the secret: which note +const cm = commitOf(input, ownerPk); // (2) recompute its commitment +const path = wit_Path(cm); // (3) the secret: where in the tree +const root = disclose(merkleTreePathRoot<32, Bytes<32>>(path)); +assert(_commitments.checkRoot(root), "...input root not recognized"); // (4) +assert(cm == path.leaf, "...path does not match input commitment"); // (5) +const nf = nullifierOf(input); // (6) derive the nullifier +assert(!_nullifiers.member(disclose(nf)), "...note already spent"); // (7) +_nullifiers.insert(disclose(nf)); // (8) mark spent, publicly +return input; // (9) hand back for accounting +``` + +- **(1)–(3): the private inputs.** The note and its Merkle path enter as witnesses. Nobody watching the chain learns which leaf is being spent. +- **(4): membership at a historical root.** The circuit recomputes the path's root and discloses *only the root*, then checks it against the tree's root history (`HistoricMerkleTree.checkRoot`, §2). Disclosing a root reveals nothing about which leaf: every historical root covers all leaves inserted up to that point. The history is a liveness feature, not a privacy one — a proof built moments before someone else's insert still verifies. +- **(5): the binding.** `cm == path.leaf` ties the witness note to the tree. Combined with (2), the prover must know `(value, nonce)` such that `H(domain, value, nonce, ownerPk)` sits in the tree. This is where **ownership** is enforced: the commitment binds `ownerPk`, and the callers of `_consumeNote` decide what `ownerPk` means. The core's `transfer`/`burn` pass `_spenderPk()`, so spending requires the spend secret. The preset's `seize` passes the *target's* pk with the authority's own gate on top (§9.4). +- **(6)–(8): single-spend.** The nullifier is derived, checked absent, and inserted, all in one circuit. Publishing `nf` says "some note died" and nothing else; linking it to a specific note requires knowing that note's nonce (which the auditor does, by design). +- **(9):** the note returns to the caller so `_transfer`/`_burn` can do value accounting on it. + +## 5.7 The ungated building blocks: `_mint`, `_transfer`, `_burn` + +These accept **caller-built notes**, which is the entire composition hook: a composing contract can source nonces from its own emission policy (the preset sources them from the audit ECDH) instead of the core default. They carry no authorization; the composer gates them. + +**`_mint(note: Note, ownerPk: Field): []`** · k=13, 6 766 rows ([source](https://github.com/OpenZeppelin/compact-contracts/blob/878aa438b98879088f13f0ef96e10311ff020257/contracts/src/token/ConfidentialNoteToken.compact#L255-L258)) — one line: insert `disclose(commitOf(note, ownerPk))` into the tree. Only the hiding commitment crosses to public state. The composer decides who may create value, how the nonce was produced, and how the note reaches its owner. + +**`_transfer(spenderPk, recipientPk, outNote, changeNote): []`** · k=15, 25 732 rows ([source](https://github.com/OpenZeppelin/compact-contracts/blob/878aa438b98879088f13f0ef96e10311ff020257/contracts/src/token/ConfidentialNoteToken.compact#L272-L278)) — consume + conserve + re-mint: + +```compact +const input = _consumeNote(spenderPk); +assert(input.value == outNote.value + changeNote.value, "...does not conserve value"); +_mint(outNote, recipientPk); +_mint(changeNote, spenderPk); +``` + +The conservation assert is the token's monetary integrity, checked inside the proof on values nobody outside can see. There is no way to satisfy it while creating value from nothing, because `input` is pinned to a committed note by `_consumeNote`. + +**`_burn(spenderPk, value, changeNote): []`** · k=15, 19 119 rows ([source](https://github.com/OpenZeppelin/compact-contracts/blob/878aa438b98879088f13f0ef96e10311ff020257/contracts/src/token/ConfidentialNoteToken.compact#L292-L297)) — same, with `input.value == value + changeNote.value` and only the change re-minted. `value` simply stops existing. + +## 5.8 `freshNonce(slot: Bytes<32>): Field` (private) + +[source](https://github.com/OpenZeppelin/compact-contracts/blob/878aa438b98879088f13f0ef96e10311ff020257/contracts/src/token/ConfidentialNoteToken.compact#L337-L340) — `Hf(wit_NonceRandomness(), "OZ:cnt:nonce:core", slot)`. The seed is per-invocation; the `slot` tag separates the multiple outputs of a single invocation (out vs change). The module-level domain tag also guarantees core-derived nonces can never collide with the audit-derived nonces of §6, even under a misbehaving seed. + +# 6. Extension: Audit — auditor viewing, complete by construction + +File: [`extensions/ConfidentialNoteTokenAudit.compact`](https://github.com/OpenZeppelin/compact-contracts/blob/878aa438b98879088f13f0ef96e10311ff020257/contracts/src/token/extensions/ConfidentialNoteTokenAudit.compact). The design question this answers: *can a note token satisfy a regulator — who can see what, prove what, and do what?* The answer is yes, and structurally rather than by policy. + +## 6.1 The trick: the nonce IS the audit channel + +For each output note, `_emitAuditedOutput`: + +1. runs an ECDH against the global audit key (`E_a = g^e_a`, `S_a = auditKey^e_a`), +2. **derives the note's nonce from the shared secret**: `nonce = KDF(S_a, "OZ:cnt:nonce")`, +3. publishes an `AuditRecord { ephemeralPk, valueCt, ownerCt }` where value and owner are one-time-padded to the same secret, +4. returns the nonce, which the consumer must commit verbatim. + +Because the nonce *comes out of* the audit ECDH, the auditor recovers `(owner, value, nonce)` for every output **by construction**: an output the auditor cannot open cannot exist in a pool that routes all note creation through this circuit. There is no honest-participation assumption; a transaction that skips or fakes the record cannot exist, because the commitment written to the tree binds the same `(value, nonce, owner)` the record encrypts, inside one proof. + +Deriving the nonce this way also kills the nonce-freshness footgun: freshness reduces to the freshness of the ephemeral scalar, which the ECDH already requires for its own security. + +Scope the word "mandatory" carefully, because it operates at two levels. Composing the extension is the **deployer's** choice: it is an extension, and the core alone has no audit at all. What is not a choice is per-transaction evasion: in a deployment that routes every output through this circuit, as the preset does, a **user** cannot produce a note the auditor can't open. Deployer-optional, user-inescapable. Contrast Zcash viewing keys, where visibility depends on the key holder choosing to share. + +From the recovered fields the auditor recomputes every commitment (`commitOf`) and every nullifier (`nullifierOf`), which yields the full compliance dataset: + +| Capability | How | +| --- | --- | +| amounts + recipients, per output | decrypt the audit record | +| **sender** of a spend | the published nullifier identifies the consumed note, whose owner the auditor already knows from *that* note's own audit record | +| full transaction-graph reconstruction | watch each note from commitment insert to nullifier publish | +| seizure support | the audit trail supplies exactly the witnesses `seize` needs (§9.4) | + +What the audit key cannot do: **spend**. It decrypts; it holds no spend authority (spending needs an owner `sk` or the authority gate). One sharp caveat from the module doc: knowing every nonce means the auditor can derive every nullifier *preimage*, so in a seizure-enabled preset the audit trail is exactly what arms the seizure authority. Handing one party both keys makes that party's compromise equal to full clawback power. + +## 6.2 The circuits + +**`initialize(auditKey: JubjubPoint): []`** · k=10, 615 rows ([source](https://github.com/OpenZeppelin/compact-contracts/blob/878aa438b98879088f13f0ef96e10311ff020257/contracts/src/token/extensions/ConfidentialNoteTokenAudit.compact#L90-L96)) — one-shot; also rejects the identity point as the audit key, since an identity key would make every "ciphertext" trivially openable (the EcdhMask weak-input rule). + +**`_emitAuditedOutput(ownerPk: Field, value: Uint<128>, slot: Bytes<32>): Field`** · k=15, 31 599 rows ([source](https://github.com/OpenZeppelin/compact-contracts/blob/878aa438b98879088f13f0ef96e10311ff020257/contracts/src/token/extensions/ConfidentialNoteTokenAudit.compact#L109-L133)) + +```compact +const ea = degradeToTransient(persistentHash([wit_AuditRandomness(), "OZ:cnt:ea", slot])); +const eaPk = ecMulGenerator(ea); +assert(eaPk != ecMulGenerator(0 as Field), "...zero audit ephemeral"); +const shared = ecMul(_auditKey, ea); +const nonce = EcdhMask_kdf(shared, "OZ:cnt:nonce"); +_auditTrail.pushFront(disclose(AuditRecord { + ephemeralPk: eaPk, + valueCt: (value as Field) + EcdhMask_kdf(shared, "OZ:cnt:a:value"), + ownerCt: ownerPk + EcdhMask_kdf(shared, "OZ:cnt:a:owner") +})); +return nonce; +``` + +Reading notes: the ephemeral scalar expands from a witness seed, domain-separated per `slot` so one transaction's several outputs get independent ephemerals. The identity-point guard subsumes `ea != 0` (a zero ephemeral would zero the shared secret and expose the pads). The three KDF calls are domain-separated so nonce, value pad, and owner pad are independent. Each ciphertext is a field one-time-pad: `ct = plaintext + KDF(S, tag)`, recoverable by subtracting the same pad. + +**`recoverAuditRecord(record: AuditRecord, auditSk: Field): AuditView`** (pure, [source](https://github.com/OpenZeppelin/compact-contracts/blob/878aa438b98879088f13f0ef96e10311ff020257/contracts/src/token/extensions/ConfidentialNoteTokenAudit.compact#L141-L148)) — the auditor's side, off-chain: `S = ephemeralPk^auditSk`, subtract the pads, re-derive the nonce. Feeding the result to `commitOf` / `nullifierOf` reconstructs the note's lifecycle. + +The ledger `_auditTrail: List` is the extension's event substitute: Compact has no events, so observable feeds are append-lists the indexer reads. + +> **Wiring warning (from the module doc):** audit completeness is a property of the *consumer's* wiring. A note created without `_emitAuditedOutput` is invisible to the auditor. Route every note-creation path through it — the preset's `emitOutput` chokepoint (§4) is exactly that. + +# 7. Extension: Delivery — wallets discover notes by scanning + +Files: [`extensions/ConfidentialNoteTokenDelivery.compact`](https://github.com/OpenZeppelin/compact-contracts/blob/878aa438b98879088f13f0ef96e10311ff020257/contracts/src/token/extensions/ConfidentialNoteTokenDelivery.compact) and the primitive [`crypto/NoteDelivery.compact`](https://github.com/OpenZeppelin/compact-contracts/blob/878aa438b98879088f13f0ef96e10311ff020257/contracts/src/crypto/NoteDelivery.compact). + +The problem: the core hands created notes back to the *caller*. The recipient of a transfer is not the caller. Without this extension, note info moves out of band (a real operational burden; the Native Shielded Token doc calls the same issue its "load-bearing operational piece"). With it, a recipient needs only chain data and their own `encSk`. + +**`_deliver(encPk: JubjubPoint, value: Uint<128>, nonce: Field, slot: Bytes<32>): []`** · k=15, 23 198 rows ([source](https://github.com/OpenZeppelin/compact-contracts/blob/878aa438b98879088f13f0ef96e10311ff020257/contracts/src/token/extensions/ConfidentialNoteTokenDelivery.compact#L53-L62)) — expands a fresh ephemeral from `wit_DeliveryRandomness`, then publishes `NoteDelivery_deliverNote(encPk, note, ed)` to the `_deliveries` list. Only the ciphertext crosses to public state. + +The primitive (`crypto/NoteDelivery`, this PR, 7 passing tests) is stateless and witness-free, pure circuits only: + +| Circuit | Direction | Use | +| --- | --- | --- | +| `deliver(encPk, value, e): [Note, Delivery]` | sender | *derives* the nonce from the ECDH itself; a `Delivery` carries only `ephemeral` + `valueCt` | +| `recover(delivery, encSk): Recovered` | recipient | inverse of `deliver` | +| `deliverNote(encPk, note, e): FullDelivery` | sender | for a note whose nonce is fixed **elsewhere** — the token uses this, because the *audit* channel already derived the nonce (§6); both `value` and `nonce` ride explicit pads | +| `recoverNote(delivery, encSk): Recovered` | recipient | inverse of `deliverNote` | + +This is the note-scheme counterpart of a Zcash note ciphertext (the "transmitted note ciphertext" of spec §3.2.1), rebuilt in-circuit: same ECDH-to-the-recipient shape, same trial-decryption discovery model. + +**Wallet flow:** scan `_deliveries`, trial-decrypt each entry with `encSk` via `recoverNote`, recompute `commitOf(note, myPk)` for the recovered `(value, nonce)`, and keep the notes whose commitment exists in the tree. The final commitment check is what filters garbage decryptions (a wrong-key decryption yields random fields whose commitment matches nothing). + +Skipping a delivery does not destroy funds; it only makes the note reachable out of band, since only the creator knows the nonce. + +# 8. Extension: Supply — confidential but attestable + +File: [`extensions/ConfidentialNoteTokenSupply.compact`](https://github.com/OpenZeppelin/compact-contracts/blob/878aa438b98879088f13f0ef96e10311ff020257/contracts/src/token/extensions/ConfidentialNoteTokenSupply.compact). + +## 8.1 Why the core writes no supply + +A disclosed supply counter would leak every mint and burn amount as a public delta (each tokenized-deposit position size, timestamped). Native shielded tokens cannot avoid this: `shieldedMints` is a protocol effect. The note pool can, because it never touches Zswap; the only channel that would expose issuance amounts is a supply write the core chooses not to make. Supply becomes a deployment-policy spectrum: + +| Shape | Public sees | How | +| --- | --- | --- | +| none | nothing | core only; the auditor reconstructs supply from the audit trail | +| confidential + attested | a proof-backed total, at a chosen cadence | this extension | +| fully public | every mint/burn delta | compose a disclosed counter alongside mint/burn | + +The middle row answers the hidden-inflation concern (how do holders know the issuer isn't printing secretly?) without giving up per-transaction amount privacy. + +## 8.2 The circuits + +**`initialize(supplyKey: JubjubPoint): []`** · k=11, 1 167 rows ([source](https://github.com/OpenZeppelin/compact-contracts/blob/878aa438b98879088f13f0ef96e10311ff020257/contracts/src/token/extensions/ConfidentialNoteTokenSupply.compact#L91-L98)) — binds the ElGamal supply key (identity point rejected) and starts `_encSupply` at the canonical `Enc(0)`. That starting ciphertext is publicly recognizable, which leaks nothing: supply genuinely is zero at that moment. + +**`_addMinted(value: Uint<128>): []`** · k=13, 6 569 rows and **`_addBurned(value: Uint<128>): []`** · k=13, 7 683 rows ([source](https://github.com/OpenZeppelin/compact-contracts/blob/878aa438b98879088f13f0ef96e10311ff020257/contracts/src/token/extensions/ConfidentialNoteTokenSupply.compact#L111-L133)) — homomorphically add or subtract `value` inside `_encSupply`, re-randomized with fresh witness-expanded randomness. Two properties do the work here: + +- A homomorphic update needs **no knowledge of the running plaintext**, so any user's burn can update the encrypted total, not just the key holder's transactions. +- Because the update happens in the same transaction as the token op, the ciphertext is *trustlessly* the true running total. The public sees only that the ciphertext changed, never by how much. + +**`attestSupply(total: Uint<128>): []`** · k=13, 4 720 rows ([source](https://github.com/OpenZeppelin/compact-contracts/blob/878aa438b98879088f13f0ef96e10311ff020257/contracts/src/token/extensions/ConfidentialNoteTokenSupply.compact#L149-L156)) — the supply-key holder proves in-circuit that `_encSupply` decrypts to `total` under the supply key (`ElGamal_assertDecryptsTo`), then discloses only that number into `_attestedSupply`. Run daily, weekly, whatever the deployment picks: public, non-inflatable supply at attestation cadence, k-anonymous amounts in between. The attester learns the plaintext total off-chain (e.g. by summing the audit trail), so no discrete-log search is needed despite the lifted encoding. + +> **Wiring warning (from the module doc):** pair every mint with `_addMinted` and every burn with `_addBurned`, on every path. Mis-wiring is security-critical and undetectable on-chain: the ciphertext silently drifts from the pool's true value, and attestation then publishes a wrong-but-proven total. Correct pairing is also what guarantees the plaintext never underflows on `_addBurned` (a burn never exceeds outstanding supply), which the ElGamal layer cannot check itself. + +# 9. Preset: RegulatedConfidentialNoteFungibleToken + +File: [`presets/RegulatedConfidentialNoteToken.compact`](https://github.com/OpenZeppelin/compact-contracts/blob/878aa438b98879088f13f0ef96e10311ff020257/contracts/src/token/presets/RegulatedConfidentialNoteToken.compact). The deployable contract: core + all three extensions, wired through the `emitOutput` chokepoint (§4), plus the one piece of policy no module owns — seizure. + +## 9.1 Constructor: four roles, bound at genesis + +([source](https://github.com/OpenZeppelin/compact-contracts/blob/878aa438b98879088f13f0ef96e10311ff020257/contracts/src/token/presets/RegulatedConfidentialNoteToken.compact#L104-L114)) — `constructor(issuerPk, authorityPk, auditKey, supplyKey)` runs the three module initializers and stores the authority pk. Deploy-and-done, no separate initialization transaction, no window where the contract exists ungoverned. + +| Role | Key type | Power | +| --- | --- | --- | +| Issuer | `Field` (= `Hf(secret)`) | mint | +| Authority | `Field` | seize | +| Audit | `JubjubPoint` | read everything; never spend | +| Supply | `JubjubPoint` | attest totals | + +## 9.2 `mint` · k=17, 69 322 rows + +([source](https://github.com/OpenZeppelin/compact-contracts/blob/878aa438b98879088f13f0ef96e10311ff020257/contracts/src/token/presets/RegulatedConfidentialNoteToken.compact#L129-L134)) + +```compact +CNT__assertIssuer(); +const note = emitOutput(recipientPk, recipientEncPk, value, pad(32, "OZ:cnt:out")); +CNT__mint(note, recipientPk); +Supply__addMinted(value); +``` + +Compare the core's standalone `mint` (§5.4): the preset takes an extra `recipientEncPk` parameter and returns nothing, because delivery is now on-chain; and the note's nonce now comes from the audit channel, not `freshNonce`. One call, four effects: audit record, delivery ciphertext, commitment insert, encrypted supply bump. Publicly: no amount anywhere. + +## 9.3 `transfer` · k=18, 135 775 rows and `burn` · k=17, 82 803 rows + +([transfer](https://github.com/OpenZeppelin/compact-contracts/blob/878aa438b98879088f13f0ef96e10311ff020257/contracts/src/token/presets/RegulatedConfidentialNoteToken.compact#L150-L168), [burn](https://github.com/OpenZeppelin/compact-contracts/blob/878aa438b98879088f13f0ef96e10311ff020257/contracts/src/token/presets/RegulatedConfidentialNoteToken.compact#L183-L194)) — the core flows with emission wired in. `transfer` peeks at the input via `CNT__inputNote()` to size the change, emits both outputs (recipient's, addressed to `recipientEncPk`; change, addressed back to `senderEncPk`), and hands them to `CNT__transfer`, whose conservation assert re-checks everything against the same witness. `burn` emits only the change and pairs `Supply__addBurned(value)`. + +Note what the caller now supplies: the recipient's *two* public keys (`recipientPk` to own the note, `recipientEncPk` to find it) and their own `senderEncPk` (so their change comes back discoverable, making wallet state recoverable from chain data alone). + +## 9.4 `seize` · k=17, 75 043 rows — escrow-free clawback + +([source](https://github.com/OpenZeppelin/compact-contracts/blob/878aa438b98879088f13f0ef96e10311ff020257/contracts/src/token/presets/RegulatedConfidentialNoteToken.compact#L216-L225)) + +```compact +assert(CNT_derivePk(wit_AuthoritySecret()) == _authorityPk, "...not the authority"); +const target = CNT__consumeNote(targetOwnerPk); +const recoveryNote = emitOutput(recoveryPk, recoveryEncPk, target.value, pad(32, "OZ:cnt:out")); +CNT__mint(recoveryNote, recoveryPk); +_seizureCount = disclose(_seizureCount + 1 as Uint<64>); +``` + +How the pieces line up: + +- **Authorization** is the authority's own gate (first line). The owner's spend key is never involved; there is no key escrow anywhere in the system. +- **Capability** comes from the audit trail (§6): the authority learns the target note's `(value, nonce, ownerPk)` from the auditor, supplies it as the core's input-note witness, and computes the Merkle path from the public tree. +- **Mutual exclusion** is the shared nullifier at work. `nf` depends only on the nonce, so the owner's spend and the authority's seizure derive the *same* nullifier. Whichever transaction lands first inserts it; the other fails `_consumeNote`'s already-spent assert. No freeze step, no race window where both succeed. +- **Conservation and audit still hold**: the full seized value re-mints to `recoveryPk`, and the recovery note is itself audited and delivered like any other output. +- **Accountability**: `_seizureCount` increments publicly. Everyone can see *that* seizures happen and how many; nobody outside learns against whom or how much. + +Production hardening named in the source: gate the authority key behind governance (multisig), and replace the single global key with per-user recovery keys for least privilege. + +## 9.5 `attestSupply` · k=13, 4 720 rows + +([source](https://github.com/OpenZeppelin/compact-contracts/blob/878aa438b98879088f13f0ef96e10311ff020257/contracts/src/token/presets/RegulatedConfidentialNoteToken.compact#L238-L240)) — a passthrough to the supply extension (§8.2), exported so the attester can call it on the deployed contract. + +# 10. Privacy and disclosure, stated precisely + +Everything a circuit writes to the ledger passes through an explicit `disclose()` (§2), so the disclosure surface is enumerable: + +| Disclosed | Reveals | Safe because | +| --- | --- | --- | +| Merkle root (any spend) | one historical root | the path stays witness; a root does not identify a leaf | +| nullifier `nf` | "some note was spent" | preimage hidden; linkable to a note only with its nonce (auditor-only) | +| commitment insert `cm` | "a note was created" | hiding commitment (256-bit nonce entropy from the ECDH) | +| audit records / delivery ciphertexts | nothing without the keys | ECDH one-time pads with fresh, secret ephemerals | +| `_encSupply` update | "supply changed" | ElGamal ciphertext; the delta is unreadable without the supply secret | +| `attestSupply` total | the supply, at attestation time | deliberate, proof-backed disclosure at a chosen cadence | +| `_seizureCount` | number of seizures | intended public accountability | +| role keys (constructor) | who governs the contract | intended; keys are public by nature | + +Per-party view of one preset `transfer`: + +| Observer | Learns | +| --- | --- | +| Public / indexer | one nullifier, two commitments, two audit records, two deliveries appeared; nothing else | +| Recipient (`encSk`) | their incoming `(value, nonce)`, hence their new note | +| Sender | everything about their own transaction | +| Auditor (`auditSk`) | amounts, sender (via the consumed note's earlier record), recipient, nonces — the full travel-rule dataset | +| Issuer / authority | nothing extra, until the auditor arms the authority for a specific seizure | + +What *does* leak, and is accepted: **transaction shape and timing.** A mint (one commitment, no nullifier) is distinguishable from a transfer (one nullifier, two commitments) and from a burn or seize (one nullifier, one commitment); event counts and timestamps are public. Amounts and parties are not. Burns and seizures share a shape, so a burn is not distinguishable from a seizure by shape alone (the `_seizureCount` bump distinguishes them at the transaction level). + +Witness discipline underlies all of it: spend secrets, role secrets, and randomness seeds are 256-bit values that must stay fresh and secret. A predictable randomness seed breaks confidentiality outright (one-time-pad reuse opens ciphertexts; a predictable ephemeral lets anyone recompute the shared secret). + +# 11. Worked example: the life of a note + +The scenario that ties every circuit together. Cast: issuer **I**, users **Alice** and **Bob**, auditor **V**, authority **A**. All on the preset. + +**1. Mint.** I calls `mint(alicePk, aliceEncPk, 100)`. +- In-circuit: issuer gate passes; audit ECDH runs, deriving `nonce₁` and encrypting `(100, alicePk)` to V's key; `(100, nonce₁)` is encrypted to Alice's `encPk`; `cm₁ = H(domain, 100, nonce₁, alicePk)` enters the tree; `Enc(supply)` absorbs +100. +- On-chain: `cm₁`, one `AuditRecord`, one `FullDelivery`, a new supply ciphertext. **The number 100 appears nowhere.** +- Alice's wallet scans `_deliveries`, trial-decrypts with `aliceEncSk`, recovers `(100, nonce₁)`, recomputes `cm₁`, finds it in the tree: she owns a 100-note. + +**2. Transfer.** Alice calls `transfer(bobPk, bobEncPk, aliceEncPk, 30)`. +- Her wallet supplies witnesses: her `sk`, the input note `(100, nonce₁)`, its Merkle path. +- In-circuit: `_consumeNote` proves `cm₁` sits under a recognized root and publishes `nf₁ = H(domain, nonce₁)`; conservation pins `100 = 30 + 70`; two new audited-and-delivered notes commit: `(30, nonce₂)` to Bob, `(70, nonce₃)` change to Alice. +- On-chain: `nf₁`, `cm₂`, `cm₃`, two audit records, two deliveries. No amounts, no names, no link from `nf₁` back to `cm₁` for the public. +- V decrypts both audit records (learning Bob got 30, Alice kept 70) and attributes the *sender*: `nf₁` matches the nonce V recovered from step 1's record, whose owner was Alice. + +**3. Burn.** Bob calls `burn(bobEncPk, 10)`: publishes `nf₂`, commits change `(20, nonce₄)`, supply absorbs −10. Publicly it looks like any spend. + +**4. Seizure.** A court order targets Bob's remaining note. V hands A the note data `(20, nonce₄, bobPk)` from the audit trail. A calls `seize(bobPk, recoveryPk, recoveryEncPk)`, supplying that note as the input witness: the authority gate passes, `nf₄` publishes (so Bob's own spend of it can never land afterwards), a recovery note of 20 commits (audited, delivered), `_seizureCount` becomes 1. Bob's key was never touched; Bob's cooperation was never needed. + +**5. Attestation.** At month-end, the supply-key holder computes the true total off-chain (90: minted 100, burned 10; seizure conserved value) and calls `attestSupply(90)`. The circuit verifies `Enc(supply)` really decrypts to 90 and publishes exactly that number. + +# 12. Invariants + +What must always hold, carried into the (planned) test suites: + +1. **Conservation**: `transfer` preserves `in == out + change`; `burn` removes exactly `in − change`; `seize` conserves the full target value. +2. **Single spend**: a nullifier is consumable once, whether by owner-spend or seizure (mutual exclusion in both directions). +3. **Membership**: only committed notes are spendable; a bad path or foreign root fails. +4. **Authorization**: mint needs the issuer secret, seize the authority secret, owner-spend the note owner's secret (the commitment binds `pk`). +5. **Audit completeness**: for every output, the audit key recovers `(ownerPk, value, nonce)` and can recompute the exact committed `cm`. +6. **Delivery correctness**: for every output, the owner's `encSk` recovers `(value, nonce)` matching the committed note. +7. **Confidentiality**: a wrong key recovers nothing; ciphertexts of equal values are unlinkable (fresh ephemerals). +8. **One-shot init**: no state-changing circuit runs before initialization; initialization cannot run twice (core, extensions, preset alike). +9. **Supply correctness**: `_encSupply = Enc(Σ minted − Σ burned)` when wired 1:1 with token ops; `attestSupply` succeeds only with the supply secret and the exact total, and discloses nothing else. + +# 13. Costs + +Compiler figures from `@circuitInfo` (proving cost grows with rows; `k` is the circuit-size exponent): + +| Circuit | k | rows | +| --- | --- | --- | +| core `_consumeNote` | 14 | 12 248 | +| core `transfer` | 16 | 36 394 | +| audit `_emitAuditedOutput` | 15 | 31 599 | +| delivery `_deliver` | 15 | 23 198 | +| supply `_addMinted` / `_addBurned` | 13 | 6 569 / 7 683 | +| preset `mint` | 17 | 69 322 | +| preset `transfer` | 18 | 135 775 | +| preset `burn` | 17 | 82 803 | +| preset `seize` | 17 | 75 043 | + +Two structural facts explain the numbers. First, the dominant cost everywhere is `persistentHash` (SHA-256-class): commitments, nullifiers, Merkle leaves, KDF invocations. A stable Poseidon-class hasher on the platform would cut roughly 5× across the whole stack. Second, a preset `transfer` carries two full emission pipelines (audit + delivery per output) on top of the core spend, which is why it is ~3.7× the core `transfer`. Emission cannot be a runtime flag: in ZK, both branches of an `if` are always paid for, so optional viewing or delivery would have to be compile-time variants. + +For calibration: the pure note spend (~20.5k rows in the spike) is about *half* the account-model CFT transfer (~43.8k). Graph privacy via notes is not intrinsically the expensive option; the compliance channels are what cost. + +# 14. Design decisions + +- **Notes, not accounts.** Sender privacy requires an unindexed commitment set with ZK membership; no account-model trick avoids it (§3.1). +- **A contract-level pool, not Zswap coins.** Buys hidden issuance amounts, evasion-proof auditor viewing, and seizure, none of which native coins can express today (§3.2). Costs: big circuits and a self-managed tree. +- **Nullifier from the nonce alone, no owner secret.** *The* deliberate deviation from Zcash-style derivation. It makes the audit trail sufficient to arm seizure with no key escrow, and owner-spend/seizure mutually exclusive on one nullifier. The price: any nonce leak is a spend-blocking (griefing) leak, so nonces are handled as spend-critical secrets end to end, and nullifier publication is only reachable through gated spend paths. +- **Output nonces derived from the audit ECDH** (preset). Audit completeness becomes structural rather than policy, and nonce freshness reduces to ephemeral freshness, which the encryption already requires (§6.1). +- **Ungated `_` building blocks + a gating preset.** The library's standard composition pattern: mechanisms in modules, policy in the consumer. The core's default `mint`/`transfer`/`burn` still work standalone, so the simplest deployment needs no extensions at all. +- **Extensions import no token module.** Standalone pieces the consumer wires; the `emitOutput` chokepoint makes correct wiring one small, reviewable function (§4). +- **`List` ledgers as event substitutes.** Compact has no events; `_auditTrail` and `_deliveries` are append-only feeds for indexers and wallets. +- **No public supply in the core; supply as a composable layer.** A disclosed counter would leak every issuance amount; ledger layouts are also fixed at deployment, so the choice is per-deployment policy (§8.1). +- **Field-typed spend pk (`Hf(sk)`).** Lets the owner identity ride the field-arithmetic one-time pads of the audit and delivery records. +- **`HistoricMerkleTree` over a plain tree.** Proofs built against a recent root still verify after later inserts; without history, every insert would invalidate every in-flight proof. +- **Domain separation everywhere.** Commit vs nullifier, core vs audit nonces, out vs change slots, value vs owner pads: every hash and pad carries a distinct `OZ:cnt:*` tag, so no derived value can be replayed in another role. + +# 15. Limitations and open questions + +- **The audit key is all-seeing and global.** Selective or request-based disclosure (per-custodian review keys, or an issuer-run re-encryption service) is the known Phase-2 design question. Until then, audit-key compromise is total visibility compromise. +- **One global authority key.** Production wants governance gating (compose with `multisig/`) and per-user recovery keys for least privilege. +- **Auditor + authority collusion equals unilateral clawback.** By construction (the audit trail arms seizure). Deployments should treat the two keys as separation-of-duties roles. +- **`_auditTrail` / `_deliveries` grow unboundedly.** Fine for a draft; production needs indexer-side pagination guidance and possibly retention policy. +- **No KYC allowlist yet.** A Merkle-allowlist module composed at the spend chokepoint (a hidden spender must prove membership in-circuit) per the exploration's recommendation. +- **Wallet UX is real work**: scanning, trial decryption, note management, and change tracking all live off-chain. +- **Naming.** Adopted renames (see the header note): `ConfidentialNoteFungibleToken` family, supply extension as `ConfidentialNoteFungibleTokenPrivateSupply`. Applied across the branch code (modules, mocks, simulators, witnesses, CHANGELOG). Still to sweep: the in-repo design doc carries the earlier working name `HybridConfidentialToken`, and the preset header cites a doc path that predates it. + +# 16. FAQ + +**What does the public ledger actually contain?**
Commitment inserts, nullifiers, ciphertexts (audit + delivery), supply ciphertext updates, the seizure counter, attested totals, and the role keys. No amounts, no senders, no recipients, no balances. + +**How does a recipient find their money?**
Scan `_deliveries`, trial-decrypt with `encSk`, keep entries whose recomputed commitment is in the tree (§7). No out-of-band channel needed on the preset. + +**Can the auditor spend or block my notes?**
No. The audit key decrypts only. Spending requires an owner secret; seizure requires the authority secret; and publishing a nullifier is only possible through those gated paths, so knowing a nonce alone cannot burn a note on-chain. + +**Why can the authority seize without my keys?**
Because the nullifier depends only on the nonce, the authority (armed with the note data from the audit trail) derives the same nullifier you would. Consuming the note and re-minting its value to a recovery key needs no secret of yours (§9.4). + +**Is this Zcash?**
Same skeleton (commitments, nullifiers, Merkle membership; §2 quotes the Zcash definitions this reuses), but rebuilt inside a contract with three deliberate differences: the nullifier omits the owner secret (enabling escrow-free seizure), in the regulated preset every output carries an audit record that cannot be omitted or faked (ECDH-derived nonce), and the pool is one contract's state rather than a protocol-level shielded pool. + +**How is supply honest if nobody can see it?**
The encrypted total is updated homomorphically in the same transaction as each mint/burn, so it cannot drift from the truth (when correctly wired); `attestSupply` then proves the published number is that ciphertext's decryption (§8). + +**Can I deploy just the core?**
Yes. `initialize` + `mint` + `transfer` + `burn` form a complete token; created notes return to the caller and move out of band. Extensions add auditor viewing, on-chain delivery, and supply on top. + +**What happens if a randomness witness repeats a seed?**
Catastrophic loss of confidentiality: one-time-pad reuse lets an observer subtract ciphertexts and recover plaintexts, and predictable ephemerals let anyone recompute shared secrets. Fresh, secret, per-invocation seeds are a hard wallet-side requirement. + +**Why `Uint<128>` values?**
Headroom, and no protocol coupling: unlike native mints (capped at `Uint<64>` by the ledger's effect encoding), note values never touch a protocol effect. + +# 17. Implementation status + +| Component | Status | +| --- | --- | +| `crypto/NoteDelivery` | implemented, 7 passing tests | +| `crypto/EcdhMask`, `crypto/ElGamal` | merged earlier (PR [#655](https://github.com/OpenZeppelin/compact-contracts/pull/655) line) | +| Core + 3 extensions + preset | implemented; compile-verified (`@circuitInfo` present) | +| Mocks, simulators, witness harnesses | in the PR | +| Token-level unit suites | not yet (invariants of §12 are the plan) | +| Security audit | not started; DRAFT, not production | + +# References + +Pinned commits: compact-contracts PR #679 [`878aa43`](https://github.com/OpenZeppelin/compact-contracts/tree/878aa438b98879088f13f0ef96e10311ff020257) · Compact [`c06961e`](https://github.com/LFDT-Minokawa/compact/tree/c06961eb661942f7689c6509d0913326f264e848). + +1. [Zcash: What are zk-SNARKs?](https://z.cash/learn/what-are-zk-snarks/) — zero-knowledge proof and zk-SNARK definitions. +2. [Bitcoin developer glossary](https://developer.bitcoin.org/glossary.html) — UTXO definition. +3. [Zcash protocol specification](https://zips.z.cash/protocol/protocol.pdf), §3.2 *Shielded Pools and Notes* — note definition. +4. Zcash protocol specification, §3.2.2 *Note Commitments*. +5. Zcash protocol specification, §3.8 *Note Commitment Trees*. +6. Zcash protocol specification, §3.9 *Nullifier Sets*. +7. [Orchard design book: Nullifiers](https://zcash.github.io/orchard/design/nullifiers.html) — nullifier derivation requirements. +8. [Ben-Sasson et al., *Zerocash: Decentralized Anonymous Payments from Bitcoin*, IEEE S&P 2014](https://eprint.iacr.org/2014/349) — origin of the note/commitment/nullifier payment scheme. +9. [`compact doc/compact-reference.mdx:1489-1506`](https://github.com/LFDT-Minokawa/compact/blob/c06961eb661942f7689c6509d0913326f264e848/doc/compact-reference.mdx#L1489-L1506) — witnesses. +10. [`compact doc/compact-reference.mdx:3490-3510`](https://github.com/LFDT-Minokawa/compact/blob/c06961eb661942f7689c6509d0913326f264e848/doc/compact-reference.mdx#L3490-L3510) — `disclose()`. +11. [`compact doc/ledger-adt.mdx:595-608`](https://github.com/LFDT-Minokawa/compact/blob/c06961eb661942f7689c6509d0913326f264e848/doc/ledger-adt.mdx#L595-L608) — `HistoricMerkleTree`, `checkRoot`. +12. W. Diffie and M. Hellman, [*New Directions in Cryptography*](https://ee.stanford.edu/~hellman/publications/24.pdf), IEEE Trans. Inf. Theory 22(6), 1976 — the key-agreement construction ECDH instantiates. +13. [`crypto/EcdhMask.compact`](https://github.com/OpenZeppelin/compact-contracts/blob/878aa438b98879088f13f0ef96e10311ff020257/contracts/src/crypto/EcdhMask.compact) — construction, freshness/secrecy rules, weak-input guards. +14. T. ElGamal, [*A Public Key Cryptosystem and a Signature Scheme Based on Discrete Logarithms*](https://ieeexplore.ieee.org/document/1057074), IEEE Trans. Inf. Theory 31(4), 1985. +15. [`crypto/ElGamal.compact`](https://github.com/OpenZeppelin/compact-contracts/blob/878aa438b98879088f13f0ef96e10311ff020257/contracts/src/crypto/ElGamal.compact) — lifted variant, homomorphism, subgroup trust assumption. +16. [zkcrypto/jubjub](https://github.com/zkcrypto/jubjub) — Jubjub curve definition. +17. [`compact compiler/standard-library.compact:47`](https://github.com/LFDT-Minokawa/compact/blob/c06961eb661942f7689c6509d0913326f264e848/compiler/standard-library.compact#L47) — `export new type JubjubPoint`. +18. [`contracts/privacy_readme.md`](https://github.com/OpenZeppelin/compact-contracts/blob/878aa438b98879088f13f0ef96e10311ff020257/contracts/privacy_readme.md) — the four-tier exploration, benchmarks, and findings behind the model choice. +19. [`contracts/src/token/docs/hybrid-confidential-token.md`](https://github.com/OpenZeppelin/compact-contracts/blob/878aa438b98879088f13f0ef96e10311ff020257/contracts/src/token/docs/hybrid-confidential-token.md) — the in-PR design doc (compliance mapping, disclosure boundary, open questions). From bdf8b5cd715467d91164e440d16f2ccd51a31b30 Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Fri, 24 Jul 2026 14:20:42 +0200 Subject: [PATCH 4/4] feat(token): land compliance split and add concurrency track Complete the compliance layer on the confidential note token family and add a concurrency track alongside it. * Compliance: - Split the seizure authority into its own ConfidentialNoteFungibleTokenAuthority extension (key, secret witness, _assertAuthority, self-rotation), mirroring Issuer; the preset keeps only the seize wiring and _seizureCount. - Wire Freeze into the regulated preset: transfer/burn assert not-frozen at the owner-spend chokepoint, seize deliberately does not, plus freeze/unfreeze circuits gated on the authority. - Harden extensions: nonzero issuer-key guards, supply-key custody warning, Review record-correctness and reviewer-selection caveats, audit-scalar range note, unsalted-allowlist privacy caveat, and @circuitInfo annotations across the family. * Concurrency: - Add utils/concurrency building blocks (UintDeltaInbox, ElGamalDeltaInbox, ShardedCounter, RevocableMembershipTree) with per-module docs. - Add the ConfidentialNoteFungibleTokenConcurrentSupply extension and a ConcurrentConfidentialNoteFungibleToken demo preset: encrypted supply deltas commute via the inbox and a permissionless fold, and attestation proves the inbox empty in-circuit. - Document the conflict model, matrix, and fixes in the design doc (new section) and in standalone concurrency.md and cross-contract-calls.md. --- concurrency.md | 368 ++++++++++++ confidential-note-token.md | 102 +++- ...identialNoteFungibleTokenAllowlist.compact | 12 + ...ConfidentialNoteFungibleTokenAudit.compact | 6 +- ...identialNoteFungibleTokenAuthority.compact | 104 ++++ ...lNoteFungibleTokenConcurrentSupply.compact | 185 ++++++ ...onfidentialNoteFungibleTokenFreeze.compact | 6 + ...onfidentialNoteFungibleTokenIssuer.compact | 13 +- ...tialNoteFungibleTokenPrivateSupply.compact | 7 + ...onfidentialNoteFungibleTokenReview.compact | 18 + ...rrentConfidentialNoteFungibleToken.compact | 174 ++++++ ...latedConfidentialNoteFungibleToken.compact | 101 +++- ...latedConfidentialNoteFungibleToken.compact | 10 + .../concurrency/ElGamalDeltaInbox.compact | 178 ++++++ .../RevocableMembershipTree.compact | 114 ++++ .../utils/concurrency/ShardedCounter.compact | 103 ++++ .../utils/concurrency/UintDeltaInbox.compact | 200 +++++++ .../concurrency/docs/elgamal-delta-inbox.md | 124 ++++ .../docs/revocable-membership-tree.md | 124 ++++ .../utils/concurrency/docs/sharded-counter.md | 103 ++++ .../concurrency/docs/uint-delta-inbox.md | 138 +++++ cross-contract-calls.md | 532 ++++++++++++++++++ 22 files changed, 2686 insertions(+), 36 deletions(-) create mode 100644 concurrency.md create mode 100644 contracts/src/token/extensions/ConfidentialNoteFungibleTokenAuthority.compact create mode 100644 contracts/src/token/extensions/ConfidentialNoteFungibleTokenConcurrentSupply.compact create mode 100644 contracts/src/token/presets/ConcurrentConfidentialNoteFungibleToken.compact create mode 100644 contracts/src/utils/concurrency/ElGamalDeltaInbox.compact create mode 100644 contracts/src/utils/concurrency/RevocableMembershipTree.compact create mode 100644 contracts/src/utils/concurrency/ShardedCounter.compact create mode 100644 contracts/src/utils/concurrency/UintDeltaInbox.compact create mode 100644 contracts/src/utils/concurrency/docs/elgamal-delta-inbox.md create mode 100644 contracts/src/utils/concurrency/docs/revocable-membership-tree.md create mode 100644 contracts/src/utils/concurrency/docs/sharded-counter.md create mode 100644 contracts/src/utils/concurrency/docs/uint-delta-inbox.md create mode 100644 cross-contract-calls.md diff --git a/concurrency.md b/concurrency.md new file mode 100644 index 000000000..85b7d6f0c --- /dev/null +++ b/concurrency.md @@ -0,0 +1,368 @@ +# Concurrency on Midnight + +> **Status:** living draft (2026-07-24), destination Notion. Companion to `cross-contract-calls.md`. The Midnight docs site covers this topic only at a high level (and lags the code), so every mechanism claim below is verified against pinned sources: the Compact compiler at [`c06961e`](https://github.com/LFDT-Minokawa/compact/tree/c06961eb661942f7689c6509d0913326f264e848) and midnight-ledger at [`e1edad2`](https://github.com/midnightntwrk/midnight-ledger/tree/e1edad2d7019e1520d173f3e22e9991903225cef). + +# 1. Summary + +How **contract-state concurrency** works on Midnight, and how to design Compact contracts for it. A Compact transaction ships a **fixed recording** (transcript) of its ledger operations, built against a state snapshot; the chain re-executes the recording against **live** state. Exactly one thing makes two transactions conflict in practice: a **pinned read** — any ledger value that entered the circuit is baked into the transcript and must match at replay (`ReadMismatch` otherwise). Everything else commutes. + +The consequences this document develops: + +- Mutations the circuit never reads back (tree append, per-key map/set insert, list push, `Counter.increment`) are replayed against live state and **commute**; `x = x + v` on a shared cell serializes all its writers. +- Plain `MerkleTree.checkRoot` pins the *current* root (any concurrent write kills you); `HistoricMerkleTree.checkRoot` pins `root ∈ history` (concurrent inserts are harmless). +- Every read-modify-write accumulator in this library's token designs is a serialization point: the note token's encrypted supply, the account tokens' **recipient balance cells**, the native shielded token's per-color supply counters (§8). +- The general fix is the pattern the protocol itself uses for Zswap: **split "credit" from "absorb"** — writers append commutative deltas to an inbox; a fold absorbs them (§9). Four generic mechanism modules implement it (§13). +- The pattern's limit is **order-dependent state**: an AMM swap's output is a function of the reserves, so swaps genuinely don't commute — the fix there is a batch auction, not an inbox (§8.5). +- Losing a race costs no fees but costs a full re-prove + finality wait — at ~tens of seconds per retry, hot-cell contention is a real throughput ceiling, not a nuisance (§6). + +**Audience & scope:** contract authors on Midnight (this library's maintainers first). Contract state only — Zswap coin transfers between users have their own protocol-level concurrency story (§7). + +# 2. How a transaction executes + +A contract call is *not* "send the arguments, the chain runs the circuit". The wallet runs the circuit locally against a snapshot of the contract state and records every ledger operation as a small VM program — the **public transcript** [[1]](#c-ref-1)[[13]](#c-ref-13). The transaction is that program plus a ZK proof that it was produced by an honest circuit run. The chain never re-runs the circuit; it re-executes the program. + +```mermaid +flowchart LR + A[wallet: run circuit on snapshot] --> B[transcript: ops + pinned reads] + B --> C[prove] + C --> D[submit] + D --> E[node: re-execute transcript on live state] + E -->|a pinned read differs| F[ReadMismatch - tx fails] + E -->|all reads match| G[state updated] +``` + +Between "prove" and "re-execute", *other* transactions may land and change the contract state. Concurrency on Midnight is entirely about which recordings survive that. + +# 3. The conflict model, stated precisely + +Exactly two things tie a transcript to the snapshot it was built on: + +- **Pinned reads.** Every ledger read the circuit consumed is emitted as a `popeq` op, and the proving runtime bakes the value that was read into the transcript [[12]](#c-ref-12). At application, the VM pops the live value and compares: `if expected != actual → Err(ReadMismatch)` [[14]](#c-ref-14). +- **Declared effects.** The transcript carries an `Effects` object up front ("Effects declares up front what a contract *will* do, and then the longer check that this is correct is deferred" [[16]](#c-ref-16)); after re-execution the ledger requires recomputed == declared [[15]](#c-ref-15). Effects cover Zswap-level side effects (claimed coins, mints, contract calls); they are per-transaction and are not a cross-transaction conflict source for ordinary state. + +Nothing else binds the recording to its snapshot — the proof binds the program, not the contract-state root. Ops that stay inside the VM program are re-executed against live state and succeed under any interleaving. The official guidance states the distinction for counters: "the `increment` will (almost) always succeed, while the read-add-write sequence is prone to failure" [[2]](#c-ref-2). + +Two corollaries worth internalizing: + +- **Conflicts are read-vs-write.** A pinned read breaks only when another transaction *writes* that cell or key. Concurrent reads never collide; concurrent blind writes to different keys never collide. +- **"Did the value cross into my Compact code?" is the whole test.** Assert on it, branch on it, add to it, hash it — pinned. Only mutate it — not pinned. + +# 4. What each ledger operation pins + +Mechanical, from the compiler's ADT→op table [[4]](#c-ref-4): + +| Compact operation | Pins? | Op-level reason | +| --- | --- | --- | +| Cell read (ledger value used in-circuit) | **yes** — the value | ends in `popeq` [[5]](#c-ref-5) | +| Cell write `x = ...` | no | blind `ins` overwrite; old value untouched [[5]](#c-ref-5) | +| `Counter.increment(n)` | no | relative `addi` on the live cell [[6]](#c-ref-6) | +| `Counter.read()` | **yes** | `popeq` [[6]](#c-ref-6) | +| `Set.member` / `Map.lookup` | **yes** — the result | `member`/`idx` + `popeq` [[7]](#c-ref-7)[[8]](#c-ref-8) | +| `Set.insert` / `Set.remove` / `Map.insert` | no | per-key blind write [[7]](#c-ref-7) | +| `List.pushFront` | no | length bump is a relative `addi`; node splice is structural [[9]](#c-ref-9) | +| `MerkleTree.insert` / `insertHash` | no | reads `first_free` onto the VM stack only (never into the circuit), inserts there, bumps with relative `addi` — two concurrent inserts land at successive indices [[10]](#c-ref-10) | +| `MerkleTree.insertIndex*` | index literal only | the explicit index is a program literal; `first_free := max(...)` is stack-side [[10]](#c-ref-10) | +| `MerkleTree.checkRoot(r)` | **yes** — `currentRoot == r` | `root`, `eq`, `popeq`: any concurrent tree write flips it [[10]](#c-ref-10) | +| `HistoricMerkleTree.insert` | no | as `MerkleTree.insert`, plus appends the new root to the history map [[11]](#c-ref-11) | +| `HistoricMerkleTree.checkRoot(r)` | **yes** — `r ∈ history` | `member` over the history map + `popeq`; inserts only ever *add* roots, so the pinned `true` survives concurrent inserts [[11]](#c-ref-11) | +| `HistoricMerkleTree.resetHistory()` | no (but see §9.4) | clears the history map, re-adds the current root — flips *other* transactions' pinned `r ∈ history` [[11]](#c-ref-11) | + +The state shapes behind this: on-chain contract state is just `Null | Cell | Map | Array | BoundedMerkleTree` [[19]](#c-ref-19). `Counter` is a cell, `Set` is a map-to-null, `List` is `[head, tail, length]`, `MerkleTree` is `[tree, firstFreeCell]`, `HistoricMerkleTree` is `[tree, firstFreeCell, historyMap]` [[4]](#c-ref-4)[[11]](#c-ref-11). + +# 5. Worked examples + +## 5.1 `x = x + 1` vs `Counter.increment(1)` + +Compact-level `+` forces the old value through the circuit: + +```text +x = x + 1 Counter.increment(1) +───────────────── ───────────────────── +read x, MUST equal 5 ←pin navigate to counter +write x := 6 addi 1 ←no value recorded +``` + +Two transactions built at `x = 5`: with the left form, the first lands (5→6) and the second is rejected (`live 6 ≠ baked 5`). With the right form both land (5→6→7). Same increment, different compilation, opposite concurrency. The pin on the left is also what keeps it *correct* — without it the second replay would blindly write `6` and lose an increment. + +## 5.2 Two concurrent tree appends + +`_commitments.insert(cm)` records: *read `first_free` onto the VM stack, insert the leaf hash there, `addi 1`*. The leaf hash is a program literal (it came from the proof); the **index is computed at replay time**. Transfer A built at index 41 lands at 41; transfer B, also built at 41, replays after A and lands at 42. No collision — the commitment tree behaves like an append-only log. + +## 5.3 Plain vs historic `checkRoot` + +A spend proves Merkle membership against root `R` and asserts `checkRoot(R)` — the boolean entered the circuit, so it is pinned `true`. + +- **Plain tree**: `checkRoot` = `currentRoot == R`. Any concurrent insert changes the current root → live answer `false` ≠ pinned `true` → rejected. A plain tree at a hot chokepoint couples *every* prover's liveness to *every* writer. +- **Historic tree**: `checkRoot` = `R ∈ historyMap`, and inserts only add entries. The pinned `true` survives any number of concurrent inserts. Only `resetHistory` (an explicit, rare operation) breaks it. + +This is the entire reason the note token's commitment tree is a `HistoricMerkleTree` and the reason a plain tree is the *right* choice only when you **want** writes to invalidate in-flight proofs (revocation semantics). + +## 5.4 Designed conflicts: same-key races + +`Set.member(nf)` pins the answer for that key only. Two spends of the *same* note both pin `nf ∉ _nullifiers` and both insert it: first lands, second gets `ReadMismatch`. That is the double-spend protection working — the conflict model is also the safety model. Same shape: freeze-vs-spend races on one nullifier, one-shot `initialize` races, role-rotation invalidating the old key's in-flight transactions. Do not "fix" these. + +# 6. Failure semantics: what losing a race costs + +Transaction application: guaranteed section first (fees are taken as part of it), then fallible segments. A **guaranteed-section failure rejects the whole transaction with no fees taken**; a **fallible-segment failure rolls back only that segment, fees stand** [[15]](#c-ref-15)[[17]](#c-ref-17). Compact code runs in the guaranteed section unless split with `Kernel.checkpoint` [[20]](#c-ref-20). + +So a losing racer pays nothing on-chain — but pays wallet-side: rebuild the transcript against fresh state, **re-prove, resubmit, wait finality again**. On today's stack that is tens of seconds per retry. Under sustained contention a conflicting class degrades to ~one landed transaction per retry cycle, which is why hot-cell design matters for any high-throughput deployment: the failure mode is not lost money, it is a throughput ceiling and terrible UX. + +# 7. Prior art in the protocol: how Zswap avoids this + +Zswap — Midnight's native shielded token layer — is the existence proof for the patterns in §9. Shielded transfers between users never conflict in contract state because the protocol itself: + +- appends commitments at a ledger-level `first_free` counter it owns and orders (`try_update_hash(first_free, ...); first_free += 1`) [[18]](#c-ref-18), +- keeps a **windowed root history** so proofs against slightly-stale roots stay valid, pruned by time, not count: `past_roots.filter(tblock − 1h)` [[18]](#c-ref-18), +- merges independently-built offers at the transaction level instead of making them race. + +In other words: append-only inbox + tolerant membership + windowed history. A contract-level pool has to rebuild these patterns by hand — which is exactly what §9 proposes. + +# 8. Case studies: where our designs conflict today + +Covers the three token families in/around this repo — the confidential note token (PR [#679](https://github.com/OpenZeppelin/compact-contracts/pull/679)), the account-model fungible tokens, the native shielded token — plus the AMM boundary case. + +## 8.1 Confidential note token (PR #679) + +Full matrix in `confidential-note-token.md` §14. Summary: + +- **Transfers commute** — commitment-tree appends, per-key nullifier inserts, list pushes, historic root check: nothing hot is pinned. The note model is the concurrency-friendly shape. +- **Mints/burns mutually conflict** — the homomorphic supply add runs in-circuit (the VM has no EC ops), so `_encSupply`'s old ciphertext is pinned; every mint/burn is a read-modify-write of one cell. +- **`_seizureCount` / `_attestationCount`** are in-circuit `+ 1` → seizes and attestations self-serialize. +- **Allowlist (plain tree)** — every admin add/remove aborts all in-flight KYC-proven spends. + +## 8.2 Account-model tokens: `FungibleToken` and `ConfidentialFungibleToken` + +The public `FungibleToken` updates balances as read-modify-write: + +```compact +_balances.insert(canonTo, toBal + value) // toBal came from lookup → pinned +_totalSupply = _totalSupply + value // pinned +``` + +([`FungibleToken.compact:532-546`](https://github.com/OpenZeppelin/compact-contracts/blob/02fabb61/contracts/src/token/FungibleToken.compact#L532-L546) [[22]](#c-ref-22).) + +Consequences: + +- **Sender-side serialization** (two transfers from one account conflict) is inherent and fine — it is account-nonce semantics; you cannot spend a balance twice without ordering. +- **Recipient-side serialization is the killer**: crediting pins the *recipient's* cell, so **all inbound payments to one account conflict with each other**. A merchant, exchange deposit address, or treasury receiving N payments per block gets 1 and rejects N−1. Unlike sender ordering, nothing about the asset semantics requires this. +- Every mint/burn serializes on `_totalSupply`. + +`ConfidentialFungibleToken` (PR #602 lineage) shares the account-cell shape with encrypted balances — the homomorphic credit must read the old ciphertext in-circuit, so the recipient hotspot is structurally identical, and unlike the public token the read cannot even be moved into the VM program (no EC ops there). The account model without an inbox is the *worst* concurrency shape of the three families. + +## 8.3 Native shielded token + +- **User↔user transfers never touch the contract** — Zswap moves the coins, offers merge at protocol level (§7). No contract conflict at all. This is the family's structural advantage. +- **Supply accounting conflicts**: `_totalMinted.insert(domain, current + amount)` is a pinned per-color read-modify-write ([`NativeShieldedTokenSupplyCore.compact:83`](https://github.com/OpenZeppelin/compact-contracts/blob/02fabb61/contracts/src/token/extensions/NativeShieldedTokenSupplyCore.compact#L83) [[23]](#c-ref-23)) — concurrent mints of one color serialize. +- **Contract-owned coin cells** (treasury/escrow composers): a contract that keeps its holdings as one `QualifiedShieldedCoinInfo` cell and does receive→merge on deposit pins that cell in every deposit — all deposits serialize, same class as `_encSupply`. + +## 8.4 The cross-family pattern + +Same defect everywhere, different clothing: **a single accumulator cell whose update must read the old value in-circuit**. Note-token supply ciphertext, account balance cells, per-color supply counters, treasury coin cells. That is the thing to design away. + +## 8.5 Beyond tokens: the AMM case (Lunarswap) + +Lunarswap (Uniswap-v2 shape on Midnight) is the case the delta inbox does NOT fix, and it shows the model's semantic limit. Its factory keeps [`pool: Map`](https://github.com/OpenZeppelin/midnight-apps/blob/fd7bfdcda810a19e5b121d21dd2aaf6a7369a7f7/contracts/src/lunarswap/LunarswapFactory.compact#L52) and [`reserves: Map`](https://github.com/OpenZeppelin/midnight-apps/blob/fd7bfdcda810a19e5b121d21dd2aaf6a7369a7f7/contracts/src/lunarswap/LunarswapFactory.compact#L63) [[25]](#c-ref-25) — each reserve is a contract-owned Zswap coin. A swap conflicts twice over: + +- **Price pin (semantic):** the constant-product output is a function of the reserves, so the circuit must read them — any concurrent swap on the pair flips the baked values. +- **Coin pin (mechanical):** spending/merging a contract coin pins its exact `QualifiedShieldedCoinInfo` (value, nonce, mt_index), and every swap replaces both reserve coins — the §8.3 treasury hotspot, per pair. + +Two users swapping the same pair in one block: first lands, second gets `ReadMismatch`. Liquidity add/remove and the per-swap `kLast`/cumulative stats conflict the same way. + +**Why no inbox variant fixes it:** supply deltas commute because they are order-independent (+50 is +50 at any total). A swap's effect *depends on current state* — order sets the price — so two swaps genuinely do not commute, semantically. Any fix must decide who gets which price, not just re-plumb state. There is also a UX bind: an atomic swap must know its exact output *now*, which requires pinning the price; atomic-swap UX and concurrency are directly at odds on this VM. (Perspective: Uniswap on Ethereum also serializes swaps, but the EVM *re-executes* the call at landing time, so a raced swap silently gets a worse price bounded by `minOut`. Kachina replays a fixed transcript, so a raced swap *fails* — the pin is enforced slippage protection with a harsher failure mode, not a Midnight defect.) + +**The fix is a batch swap (frequent batch auction)** — §9.2's credit/absorb architecture with an AMM payload, as shipped by Penumbra [[26]](#c-ref-26) and, economically, CoW Protocol [[27]](#c-ref-27), rooted in Budish et al.'s batch-auction design [[28]](#c-ref-28): + +1. **Submit (commutes):** escrow the input coin in its OWN slot (per-intent map entry keyed by user randomness — never merged into the reserve coin) and append a swap intent (direction, amount, `minOut`, payout key) to an inbox. Any number of users per pair per block. +2. **Settle (serialized, once per pair per batch):** a crank — safely permissionless, like `foldSupply` — drains the intents, **nets buys against sells** (netted volume crosses at the midpoint with zero price impact; only the imbalance walks the curve), merges the escrows, updates the reserves ONCE, and computes one uniform clearing price. +3. **Payout (commutes or single-tx):** the settle transaction sends output coins to every intent's payout key, or settlement commits output NOTES and users claim through the note machinery. + +Beyond unblocking concurrency, the batch upgrades the product: a uniform clearing price makes sandwich attacks structurally impossible (no "before/after" inside a batch), and intents can stay sealed until settlement — MEV resistance that fits a privacy chain. Costs: two-phase UX, a crank, a block or two of latency, and a settlement circuit whose size bounds the per-batch intent count. + +Module implications: the inbox *architecture* reuses (per-intent map, witness-driven drain, completeness assert), but the payload and settlement math are AMM-specific — a future `BatchSwap` module family, not a delta-inbox variant. Sharding the pool is rejected outright (it fragments liquidity and splits the price). + +# 9. Design patterns: the fixes, ranked by generality + +## 9.1 `Counter` for every counter not read in-circuit + +`_seizureCount`, `_attestationCount`, plain analytics counters: swap `Uint` cells for `Counter`. `increment` is a relative op and commutes [[6]](#c-ref-6); the value stays readable off-chain and via `Counter.read` where a circuit genuinely needs it (accepting the pin there). Zero design cost; do it everywhere by default. + +## 9.2 Pending-delta inbox + fold — the general accumulator fix + +Split *credit* (hot, must commute) from *absorb* (cold, may serialize): + +```compact +// Writers: commutative — a blind per-key insert. `id` is derived from the +// writer's fresh randomness witness, NOT from any ledger read. +export ledger _pending: Map, Delta>; +circuit _credit(id: Bytes<32>, delta: Delta): [] { + _pending.insert(disclose(id), disclose(delta)); +} + +// Folder: serialized with itself only. The witness supplies which keys to +// absorb (an indexer knows the map's contents); the circuit pins exactly +// those K entries + the accumulator — never the keys concurrent writers add. +circuit _fold(): [] { + const ids = wit_PendingIds(); // Vector> + for (const id of ids) { + acc = absorb(acc, _pending.lookup(id)); // pins these K entries + _pending.remove(id); + } +} +``` + +Why it works, op by op: writer∥writer touch different map keys (blind inserts — commute); writer∥fold touch disjoint keys (the fold read/removed *old* entries, the writer inserts a *new* one — commute); fold∥fold conflict (one folder role — contained). The conflict surface shrinks from "every writer against every writer" to "the folder against itself". + +Per-family instantiation: + +| Family | Accumulator | `_credit` | `_fold` | Who folds | +| --- | --- | --- | --- | --- | +| Note token | `_encSupply` | mint/burn appends `Enc(±v)` (fresh randomness) | homomorphic-add K ciphertexts | issuer/keeper, or fold-then-attest | +| ConfidentialFungibleToken | per-account balance ciphertext | transfer appends `Enc(v)` to the **recipient's inbox** | recipient absorbs own inbox | the account owner, on their next transaction (they already serialize with themselves) — the Zether pending/epoch pattern [[21]](#c-ref-21) | +| FungibleToken | `_balances` cell | credit row in an inbox map | recipient folds | account owner | +| Native shielded treasury | treasury coin cell | each deposit lands in its own coin slot (keyed by nonce) | keeper merges K coins via send-to-self | keeper | + +Honest costs: an extra circuit + state; reads of the *exact* total (e.g. `attestSupply`, a balance-gated spend) see only the folded part, so exact-total operations become fold-then-read — for the note token that is a natural fit (attestation is periodic anyway); for CFT a spend simply folds the sender's own inbox first, inside the same circuit. Inbox growth is bounded by folding cadence and is indexer-visible. + +Skip-proofing the fold: the witness only *chooses* which entries to drain (amounts come from the ledger; existence is asserted; drained entries are removed), so its one abuse is skipping — a liveness issue, since skipped deltas stay public and drainable. The implemented inboxes close even that: each domain carries a `Counter` backlog (relative increments/decrements — they commute, so maintaining it costs no concurrency), and an `_assertEmpty` circuit lets a checkpoint fold prove **in-circuit** that nothing was skipped (`_consume` then `_assertEmpty` in one circuit — the required prelude to an exact attestation). The emptiness read pins the count, so a checkpoint conflicts with concurrent credits — necessarily: "nothing outstanding" is only meaningful at a serialization point. Routine folds stay barrier-free; and since folding cannot corrupt value, it can be left permissionless, making a censoring indexer routable-around by any honest party. + +## 9.3 Sharded accumulators — when folding cadence is unacceptable + +N accumulator cells; each writer updates one, chosen from its **own randomness witness** (never from a ledger read — that would pin the chooser). Writers conflict only on shard collisions (~1/N per pair); exact-total readers pin all N shards (they conflict with everything, but they did before too). Good fit when writers vastly outnumber exact-readers and a small N (8–16) buys enough headroom. Probabilistic, not eliminative — prefer §9.2 unless the fold role is operationally unwanted. + +## 9.4 Historic membership + `resetHistory` as the revocation lever + +For every membership structure proven in-circuit: use `HistoricMerkleTree`, and make root-history invalidation an explicit *operation*, not a side effect of every write. + +- **Allowlist**: adds append (in-flight spends keep verifying — onboarding stops hurting users); `_removeAllowed` calls `resetHistory()` (instant revocation — deliberately breaks every stale proof). The plain tree's semantics, kept only where they are wanted. +- **Note commitment tree**: never reset in normal operation; treat history growth as state rent and prune only in announced maintenance windows (each reset invalidates in-flight spends). Contract trees get no protocol pruning — unlike Zswap's one-hour window [[18]](#c-ref-18) — so unbounded growth is the default and must be managed deliberately. + +## 9.5 Batching + write-owner ordering — the complement, not the fix + +Batch circuits (N outputs / N credits per proof) raise per-transaction throughput and shrink the conflict window; a role that is already exclusive (single issuer, single folder) should order its own submissions client-side rather than race itself. These compose with §9.1–§9.4; on their own they only help single-writer cells, which is why "the wallet retries" is not an answer for multi-writer hotspots like recipient credits. + +## 9.6 Anti-patterns + +- `x = x + v` on any cell more than one party writes. +- Plain `MerkleTree` at a hot proving chokepoint (unless write-invalidates-proofs is the intended semantics). +- Deriving *any* writer-side choice (shard index, inbox key) from a ledger read — it pins the chooser and reintroduces the conflict. +- `Kernel.checkpoint` to "isolate" a conflicting update whose invariant is coupled to the rest of the call (e.g. supply must move iff the note commits): splitting them trades a conflict for a broken invariant. Checkpoint is for genuinely independent tails [[20]](#c-ref-20). +- Trusting the docs site over the op table: whether something pins is decided by `midnight-ledger.ss`, not prose. + +# 10. Generic modules, or per-use-case design? + +Both — split exactly where the library already splits mechanism from policy: + +- **Generic mechanism modules are worth building** (as `utils/`-style companions): a pending-inbox accumulator (§9.2, one variant per absorb operation — ElGamal ciphertexts, `Uint` sums, coin merges — since Compact generics cannot abstract over the fold arithmetic), a sharded accumulator (§9.3), and a revocable-membership tree (§9.4). The conflict *mechanics* are identical in every consumer, which is the definition of a module. +- **One Compact constraint shapes the API**: a module's ledger state is a **singleton per module file** — importing the same module twice shares one state. A generic inbox module therefore serves multiple accumulators in one contract only by keying its map with a domain tag (the pattern `NativeShieldedTokenSupplyCore` already uses for per-color counters), not by double-import. +- **Policy stays per use case**: who may fold and when, what exact-total reads must see, whether revocation resets history, shard count. That wiring belongs in each family's preset — same as every other extension in this library. + +So: yes to a small set of generic concurrency modules; no to a monolithic "concurrency framework". The analysis in §8 is what stays use-case-specific. The implemented modules and their per-module design docs (each stating intended use cases AND anti-cases) are listed in §13. + +# 11. Risks & open questions + +Priorities follow the library's design-doc convention: **P1** blocks or could invalidate primary conclusions; **P2** limits applicability; **P3** minor, deferrable. + +## P1 — High + +### 1. The conflict matrix is derived, not yet empirically validated + +**Status:** pending experiment. +**Description:** the §4 table and every verdict built on it come from the compiler's op table plus the ledger's replay code — not from observed behavior on a running network. +**Impact:** a misread of the op semantics would invalidate the §8 verdicts and the §9 module designs. +**Mitigation:** a live-stack contention experiment is queued (`confidential-note-token.md` §0): two concurrent note-token transfers (expect both land) vs. two concurrent mints on the direct-supply preset (expect one `ReadMismatch`-class failure). +**Risk level:** 🟠 HIGH — low likelihood (source-verified twice), high consequence. + +## P2 — Medium + +### 2. Sequencer / mempool ordering behavior is unverified + +**Status:** unknown. +**Description:** Kachina's model allows optimizing and reordering conflicting transactions [[1]](#c-ref-1)[[3]](#c-ref-3); whether Midnight's node does any conflict-aware ordering today (vs. naive arrival order) is unverified. +**Impact:** changes how *often* the §8 conflicts fire in practice, not whether they exist; also determines retry-storm dynamics under load. +**Mitigation:** same live experiment; observe failure timing and ordering. +**Risk level:** 🟡 MEDIUM. + +### 3. Mempool visibility & front-running of race-decided operations + +**Status:** open design question. +**Description:** races like freeze-vs-spend are decided by landing order; how much an adversary can observe of pending transactions determines whether procedural orderings ("freeze → finality → seize") suffice. +**Impact:** authority playbooks for the regulated token; batch-swap sealing assumptions (§8.5). +**Mitigation:** document procedures assuming full mempool visibility until shown otherwise. +**Risk level:** 🟡 MEDIUM. + +## P3 — Low + +### 4. `popeq` `cached` variants + +The op table emits `cached #t/#f` variants; whether caching changes any conflict semantics (rather than just gas) is unverified — the replay comparison itself is unconditional [[14]](#c-ref-14). + +### 5. No `List` pop / no native queue + +`List` has `pushFront` but no pop, which is why the inboxes use a `Map` keyed by writer randomness plus a drain witness. A native Queue ADT (stack-side append like `MerkleTree.insert`, cursor-side drain) would make witness-free folds expressible — a Compact feature request. + +# 12. Common questions (FAQ) + +**Why did my transaction fail when someone else's landed first?**
Your transcript baked in a ledger value ("this read must return 100") that their transaction changed before yours applied. The node re-executes your recording against live state, sees the mismatch, and rejects it (`ReadMismatch`, §3). Rebuild against fresh state, re-prove, resubmit. + +**Did the failed transaction cost me fees?**
No. Compact code runs in the guaranteed section, and a guaranteed-section failure rejects the whole transaction before fee-taking (§6). The cost is time: a full re-prove plus another finality wait. + +**Can two payments to the same account land in the same block?**
Depends on the design. Account-model tokens (public or confidential): no — crediting pins the recipient's balance cell, so inbound payments serialize (§8.2) unless the token uses a credit inbox (§9.2). Note-model transfers: yes — nothing hot is pinned (§8.1). + +**Is this pinning behavior a Midnight bug?**
No — it is what makes a fixed transcript sound. Ethereum re-executes your call at landing time, so a raced transaction silently executes at different state (e.g. a worse swap price). Kachina replays exactly what you proved, so a raced transaction fails loudly instead of doing something you didn't prove (§8.5's perspective note). + +**How do I find the hotspots in my own contract?**
Apply the §3 test to every ledger read: did the value cross into Compact code (assert/branch/arithmetic/hash)? If yes, it is pinned — now ask who else *writes* that cell or key. Any pinned read of a cell that multiple parties write is a hotspot. The §4 table gives the per-operation answer. + +**Why not just have the wallet retry?**
Retry works for rare conflicts. For structural ones (every mint vs. every mint), each retry costs a re-prove plus finality (~tens of seconds), so a contended class degrades to roughly one landed transaction per retry cycle — a throughput ceiling, not a UX detail (§6). Design the hotspot away instead (§9). + +**When do I need a folder/crank role?**
Only for the inbox pattern (§9.2), and it is a weak role: folding cannot corrupt value (amounts come from the ledger), so it can be permissionless; a per-domain backlog counter plus `_assertEmpty` makes skipping publicly measurable and provably absent at checkpoints. + +**Do the inbox modules help an AMM swap?**
No. A swap's output depends on the reserves, so order sets the price — swaps don't commute semantically, and no state re-plumbing changes that. The fix is a batch auction: intents commute, settlement happens once per batch at a uniform clearing price (§8.5). + +# 13. Implementation status + +| Component | Status | +| --- | --- | +| Conflict model + op-pinning table (§3–§4) | verified against pinned sources (`c06961e`, `e1edad2`) | +| [`UintDeltaInbox`](./contracts/src/utils/concurrency/docs/uint-delta-inbox.md) (§9.2) | implemented + keygen-verified: `_credit` ≈ 4.8k rows; `_consume` (8 slots) ≈ 38.1k; `_assertEmpty` ≈ 340 | +| [`ElGamalDeltaInbox`](./contracts/src/utils/concurrency/docs/elgamal-delta-inbox.md) (§9.2) | implemented + keygen-verified: `_credit` ≈ 4.6k; `_consume` ≈ 39.8k; `_assertEmpty` ≈ 343 | +| [`ShardedCounter`](./contracts/src/utils/concurrency/docs/sharded-counter.md) (§9.3, add-only) | implemented + keygen-verified: `_add` ≈ 4.6k | +| [`RevocableMembershipTree`](./contracts/src/utils/concurrency/docs/revocable-membership-tree.md) (§9.4) | implemented + keygen-verified: `_add` ≈ 2.3k; `_assertMember` ≈ 3.1k; `_removeAt` ≈ 2.1k | +| Per-module design docs (use cases + anti-cases) | drafted under `contracts/src/utils/concurrency/docs/` | +| First consumer: `…ConcurrentSupply` + `ConcurrentConfidentialNoteFungibleToken` preset | implemented + keygen-verified — mint 27.5k / burn 41k rows commute; transfer = bare core 36.4k; permissionless `foldSupply` 39.5k; `attestSupply` 4.7k proves the inbox empty in-circuit | +| Note-token conflict matrix | `confidential-note-token.md` §14 | +| Live contention experiment | queued (§11.1) | +| `BatchSwap` module family (AMM) | design sketch only (§8.5) | +| Tests / audit | none yet — design phase; DRAFT, not production | + +# References + +Pinned commits: Compact [`c06961e`](https://github.com/LFDT-Minokawa/compact/tree/c06961eb661942f7689c6509d0913326f264e848) · midnight-ledger [`e1edad2`](https://github.com/midnightntwrk/midnight-ledger/tree/e1edad2d7019e1520d173f3e22e9991903225cef) · compact-contracts [`02fabb61`](https://github.com/OpenZeppelin/compact-contracts/tree/02fabb61). All line anchors verified against local clones on 2026-07-24. + +1. [Midnight docs: Kachina](https://docs.midnight.network/concepts/kachina) — "Kachina uses transcripts to record state operations and related queries"; concurrency via optimizing/reordering conflicting transactions. +2. [Midnight docs: Smart contracts on Midnight](https://docs.midnight.network/concepts/how-midnight-works/smart-contracts) — the `increment` vs read-add-write guidance. +3. Kerber, Kiayias, Kohlweiss, [*Kachina — Foundations of Private Smart Contracts*](https://eprint.iacr.org/2020/543), IACR ePrint 2020/543 — the transcript/oracle model Midnight implements. +4. [`compact compiler/midnight-ledger.ss#L105-L109`](https://github.com/LFDT-Minokawa/compact/blob/c06961eb661942f7689c6509d0913326f264e848/compiler/midnight-ledger.ss#L105-L109) — op classes (read/write/update/remove); the file is the full ADT→VM-op table. +5. [`midnight-ledger.ss#L547-L558`](https://github.com/LFDT-Minokawa/compact/blob/c06961eb661942f7689c6509d0913326f264e848/compiler/midnight-ledger.ss#L547-L558) — `Cell.read` ends in `popeq`; `Cell.write` is a blind `ins`. +6. [`midnight-ledger.ss#L589-L606`](https://github.com/LFDT-Minokawa/compact/blob/c06961eb661942f7689c6509d0913326f264e848/compiler/midnight-ledger.ss#L589-L606) — `Counter`: `read` is `popeq`; `increment` is relative `addi`. +7. [`midnight-ledger.ss#L649-L668`](https://github.com/LFDT-Minokawa/compact/blob/c06961eb661942f7689c6509d0913326f264e848/compiler/midnight-ledger.ss#L649-L668) — `Set.member` (`member` + `popeq`), `Set.insert`/`remove` (blind per-key). +8. [`midnight-ledger.ss#L741-L747`](https://github.com/LFDT-Minokawa/compact/blob/c06961eb661942f7689c6509d0913326f264e848/compiler/midnight-ledger.ss#L741-L747) — `Map.lookup` ends in `popeq`. +9. [`midnight-ledger.ss#L885-L915`](https://github.com/LFDT-Minokawa/compact/blob/c06961eb661942f7689c6509d0913326f264e848/compiler/midnight-ledger.ss#L885-L915) — `List.pushFront`: relative `addi` on length, structural splice, no `popeq`. +10. [`midnight-ledger.ss#L973-L1125`](https://github.com/LFDT-Minokawa/compact/blob/c06961eb661942f7689c6509d0913326f264e848/compiler/midnight-ledger.ss#L973-L1125) — `MerkleTree` layout `[tree, firstFreeCell]`, `checkRoot` (`root`+`eq`+`popeq`), `insert` (stack-side `first_free` read + relative `addi`), `insertIndex*` (index literal). +11. [`midnight-ledger.ss#L1129-L1338`](https://github.com/LFDT-Minokawa/compact/blob/c06961eb661942f7689c6509d0913326f264e848/compiler/midnight-ledger.ss#L1129-L1338) — `HistoricMerkleTree` layout `[tree, firstFreeCell, historyMap]`, `checkRoot` = `member` over history + `popeq`, `insert` appends the new root, `resetHistory`. +12. [`compact runtime/src/circuit-context.ts#L456-L505`](https://github.com/LFDT-Minokawa/compact/blob/c06961eb661942f7689c6509d0913326f264e848/runtime/src/circuit-context.ts#L456-L505) — `queryLedgerState` fills each `popeq` op's `result` with the value actually read, into the public transcript. +13. [`midnight-ledger onchain-runtime/src/transcript.rs#L44-L49`](https://github.com/midnightntwrk/midnight-ledger/blob/e1edad2d7019e1520d173f3e22e9991903225cef/onchain-runtime/src/transcript.rs#L44-L49) — `Transcript { gas, effects, program, version }`. +14. [`onchain-vm/src/result_mode.rs#L44-L59`](https://github.com/midnightntwrk/midnight-ledger/blob/e1edad2d7019e1520d173f3e22e9991903225cef/onchain-vm/src/result_mode.rs#L44-L59) — verify-mode `process_read`: `expected != actual → ReadMismatch`; executed by the `Popeq` opcode ([`onchain-vm/src/vm.rs#L585-L596`](https://github.com/midnightntwrk/midnight-ledger/blob/e1edad2d7019e1520d173f3e22e9991903225cef/onchain-vm/src/vm.rs#L585-L596)). +15. [`ledger/src/semantics.rs#L1290-L1308`](https://github.com/midnightntwrk/midnight-ledger/blob/e1edad2d7019e1520d173f3e22e9991903225cef/ledger/src/semantics.rs#L1290-L1308) — guaranteed failure ⇒ whole tx `Failure`, original state returned (no fees); [`#L1397-L1406`](https://github.com/midnightntwrk/midnight-ledger/blob/e1edad2d7019e1520d173f3e22e9991903225cef/ledger/src/semantics.rs#L1397-L1406) — declared-vs-recomputed effects equality; [`#L147-L190`](https://github.com/midnightntwrk/midnight-ledger/blob/e1edad2d7019e1520d173f3e22e9991903225cef/ledger/src/semantics.rs#L147-L190) — fallible segments roll back individually (`PartialSuccess`). +16. [`spec/contracts.md#L105-L127`](https://github.com/midnightntwrk/midnight-ledger/blob/e1edad2d7019e1520d173f3e22e9991903225cef/spec/contracts.md#L105-L127) — guaranteed-then-fallible application, fees before fallible, Effects declared up front. +17. [`spec/intents-transactions.md#L646-L667`](https://github.com/midnightntwrk/midnight-ledger/blob/e1edad2d7019e1520d173f3e22e9991903225cef/spec/intents-transactions.md#L646-L667) — `SucceedEntirely | FailEntirely | SucceedPartially` and per-segment rollback. +18. [`zswap/src/ledger.rs#L42-L48`](https://github.com/midnightntwrk/midnight-ledger/blob/e1edad2d7019e1520d173f3e22e9991903225cef/zswap/src/ledger.rs#L42-L48) — Zswap state with `first_free` + `past_roots: TimeFilterMap`; [`#L105-L123`](https://github.com/midnightntwrk/midnight-ledger/blob/e1edad2d7019e1520d173f3e22e9991903225cef/zswap/src/ledger.rs#L105-L123) — append at `first_free`; [`#L241-L256`](https://github.com/midnightntwrk/midnight-ledger/blob/e1edad2d7019e1520d173f3e22e9991903225cef/zswap/src/ledger.rs#L241-L256) — roots pruned at `tblock − 1h`. +19. [`onchain-state/src/state.rs#L79-L98`](https://github.com/midnightntwrk/midnight-ledger/blob/e1edad2d7019e1520d173f3e22e9991903225cef/onchain-state/src/state.rs#L79-L98) — `StateValue = Null | Cell | Map | Array(≤16) | BoundedMerkleTree(≤32)`. +20. [`midnight-ledger.ss#L212-L215`](https://github.com/LFDT-Minokawa/compact/blob/c06961eb661942f7689c6509d0913326f264e848/compiler/midnight-ledger.ss#L212-L215) — `Kernel.checkpoint`: "Marks all execution up to this point as being a single atomic unit, allowing partial transaction failures to be split across it." +21. Bünz, Agrawal, Zamani, Boneh, [*Zether: Towards Privacy in a Smart Contract World*](https://eprint.iacr.org/2019/191), IACR ePrint 2019/191 — pending-transfers/epoch mechanism: the account-model precedent for splitting credit from absorb. +22. [`contracts/src/token/FungibleToken.compact#L532-L546`](https://github.com/OpenZeppelin/compact-contracts/blob/02fabb61/contracts/src/token/FungibleToken.compact#L532-L546) — balance credit and `_totalSupply` as pinned read-modify-writes. +23. [`contracts/src/token/extensions/NativeShieldedTokenSupplyCore.compact#L83`](https://github.com/OpenZeppelin/compact-contracts/blob/02fabb61/contracts/src/token/extensions/NativeShieldedTokenSupplyCore.compact#L83) — per-color supply counter as pinned read-modify-write. +24. [`confidential-note-token.md`](./confidential-note-token.md) §14 — the note token's full conflict matrix and per-circuit analysis. +25. [`midnight-apps contracts/src/lunarswap/LunarswapFactory.compact#L52-L63`](https://github.com/OpenZeppelin/midnight-apps/blob/fd7bfdcda810a19e5b121d21dd2aaf6a7369a7f7/contracts/src/lunarswap/LunarswapFactory.compact#L52-L63) — `pool` and `reserves` ledgers; reserves are contract-owned Zswap coins. +26. [Penumbra protocol spec: Sealed-Bid Batch Swaps](https://protocol.penumbra.zone/main/zswap.html) — batch execution against concentrated liquidity with sealed inputs and a per-block clearing price; the shipped precedent for private batch swaps. +27. [CoW Protocol docs: Batch auctions](https://docs.cow.fi/cow-protocol/concepts/introduction/batch-auctions) — uniform clearing price per batch and coincidence-of-wants netting, the same economics on Ethereum. +28. Budish, Cramton, Shim, [*The High-Frequency Trading Arms Race: Frequent Batch Auctions as a Market Design Response*](https://academic.oup.com/qje/article/130/4/1547/1916146), QJE 130(4), 2015 — the batch-auction design the above instantiate. diff --git a/confidential-note-token.md b/confidential-note-token.md index 210653c3e..d13fd34ab 100644 --- a/confidential-note-token.md +++ b/confidential-note-token.md @@ -16,12 +16,22 @@ Missing pieces identified while hardening the draft, grouped by driver. Complian - [ ] **Metadata extension** — no `name`/`symbol`/`decimals` anywhere in the family; NST and CFT both have it. Sealed fields + getters. - [ ] **Batch outputs** — pay N recipients in one proof (one nullifier, N+1 commitments). Compile-time variant; also reduces transaction-shape leakage. -**Compliance (in progress on this branch)** +**Compliance (landed on this branch)** -- [ ] **Freeze extension** — freeze-before-seize: a frozen-nullifier set checked at the owner-spend chokepoint; seizure of frozen notes still works. -- [ ] **KYC allowlist extension** — Merkle allowlist proven in-circuit at spend time (hidden spender ⇒ ZK membership, not a `Set` lookup); tombstone removal against the current root. -- [ ] **Review (selective disclosure) extension** — per-output encrypted records to an approved reviewer key (custodian/FIU), alongside the global audit channel; final shape pending BitGo FIU feedback. -- [ ] **Role rotation** — self-rotation blocks in Issuer/Audit/Supply (prove the current secret, bind the new key; supply rotation re-encrypts `_encSupply` under the new key in-proof) and `rotateAuthority` in the preset. +- [x] **Freeze extension** — freeze-before-seize: a frozen-nullifier set checked at the owner-spend chokepoint; seizure of frozen notes still works. +- [x] **KYC allowlist extension** — Merkle allowlist proven in-circuit at spend time (hidden spender ⇒ ZK membership, not a `Set` lookup); tombstone removal against the current root. +- [x] **Review (selective disclosure) extension** — per-output encrypted records to an approved reviewer key (custodian/FIU), alongside the global audit channel; final shape pending BitGo FIU feedback. +- [x] **Role rotation** — self-rotation blocks in Issuer/Authority/Audit/Supply (prove the current secret, bind the new key; supply rotation re-encrypts `_encSupply` under the new key in-proof), all surfaced in the preset. +- [x] **Authority role extension** — seizure-authority gate split out of the preset (mirrors Issuer: key, secret witness, `_assertAuthority`, self-rotation); the seize flow and `_seizureCount` stay preset wiring. + +**Concurrency (from the §14 analysis)** + +- [ ] **`Counter` for `_seizureCount` / `_attestationCount`** — the in-circuit `+ 1` pins the read and serializes seizes/attestations; `Counter.increment` is a relative VM op and commutes. Cheap swap. +- [x] **Mint/burn serialization on `_encSupply`** — solved via the delta inbox: `extensions/…ConcurrentSupply` (credits commute; permissionless fold; `attestSupply` proves the inbox empty in-circuit) + demo preset `presets/ConcurrentConfidentialNoteFungibleToken`. Re-basing the REGULATED preset onto it is a separate open decision (its emission policy must credit the inbox inside `emitOutput`). +- [ ] **Allowlist admin-vs-spend liveness** — every `_addAllowed` aborts all in-flight KYC spends (current-root pin). Evaluate `HistoricMerkleTree` + `resetHistory` on removal: adds stop hurting, removals keep instant revocation. +- [ ] **Commitment-tree history growth** — the past-roots map is append-only and unbounded (no protocol pruning for contract trees, unlike Zswap's 1h window); decide a `resetHistory` cadence + operational window (each reset invalidates in-flight spends). +- [ ] **Freeze/seize race docs** — freeze wins only if it lands first; document the authority sequence freeze → finality → seize in the preset doc. +- [ ] **Live contention experiment** — confirm the §14 matrix empirically on the testkit live stack: two concurrent transfers (expect both land) vs. two concurrent mints (expect one rejected with a read mismatch). **Supply** @@ -73,7 +83,7 @@ The terms this document relies on, defined by primary sources rather than restat - **Note.** "A note is a representation of value held in a shielded pool. … It represents that a value v is spendable by the recipient who holds the spending key corresponding to a given shielded payment address." — Zcash protocol specification, §3.2 [[3]](#ref-3). Here a note is the struct `Note { value: Uint<128>, nonce: Field }`, owned by whoever's public key `pk` was bound into its commitment. - **Note commitment.** "When a note is created as an output of a transaction, only a commitment … to the note contents is disclosed publically … This allows the value and recipient to be kept private, while the commitment is used by the zk-SNARK proof when the note is spent, to check that it exists on the block chain." — Zcash protocol specification, §3.2.2 [[4]](#ref-4). Here: `cm = H(domain, value, nonce, pk)` with a SHA-256-class `persistentHash`; the 256-bit nonce provides the hiding entropy. - **Note commitment tree.** "A note commitment tree is an incremental Merkle tree, of fixed depth …, used to store note commitments … Just as the UTXO (unspent transaction output) set used in Bitcoin, it is used to express the existence of value and the capability to spend it. However, unlike the UTXO set, it is not the job of this tree to protect against double-spending, as it is append-only." — Zcash protocol specification, §3.8 [[5]](#ref-5). -- **Nullifier.** "Nullifiers are enforced to be unique within a valid block chain, in order to prevent double-spends." — Zcash protocol specification, §3.9 [[6]](#ref-6). Zcash's design rationale requires that the "nullifier deterministically depends only on values committed to (directly or indirectly) by the note commitment" [[7]](#ref-7) — a requirement this design satisfies with the *minimal* preimage `nf = H(domain, nonce)`, deliberately omitting any owner secret (§14 explains the trade-off). +- **Nullifier.** "Nullifiers are enforced to be unique within a valid block chain, in order to prevent double-spends." — Zcash protocol specification, §3.9 [[6]](#ref-6). Zcash's design rationale requires that the "nullifier deterministically depends only on values committed to (directly or indirectly) by the note commitment" [[7]](#ref-7) — a requirement this design satisfies with the *minimal* preimage `nf = H(domain, nonce)`, deliberately omitting any owner secret (§15 explains the trade-off). - **Graph privacy.** The property Zerocash introduced: "the corresponding transaction hides the payment's origin, destination, and transferred amount." — Ben-Sasson et al., *Zerocash: Decentralized Anonymous Payments from Bitcoin* [[8]](#ref-8). "Graph" refers to the who-paid-whom transaction graph, which stays hidden even though every transaction is public. - **Witness (Compact).** "A circuit can also access or update private state as it operates via *witnesses*. Witnesses are callback functions provided by the TypeScript driver." — Compact language reference [[9]](#ref-9). Witnesses are how secrets (spend keys, input notes, randomness seeds) enter a circuit without touching the chain. - **Disclosure (Compact).** "Disclosure of private data (exported circuit arguments, witness return values, and anything derived from private data) must be acknowledged by wrapping an expression whose value contains private data in a `disclose()` wrapper before storing it in the public state." — Compact language reference [[10]](#ref-10). Every `disclose()` in this code marks a deliberate crossing of the privacy boundary; §10 justifies each one. @@ -213,7 +223,7 @@ The identity hash `pk = Hf(sk)`. `persistentHash` is the SHA-256-class hash; `de **`nullifierOf(note: Note): Bytes<32>`** ([source](https://github.com/OpenZeppelin/compact-contracts/blob/878aa438b98879088f13f0ef96e10311ff020257/contracts/src/token/ConfidentialNoteToken.compact#L112-L117)) -`nf = H("OZ:cnt:null", nonce)`. Derivable by anyone who knows the nonce, and by design *only* from the nonce. Compare Zcash, where nullifier derivation involves a per-account nullifier key, so knowing a note's contents does not let third parties track its spend. Here it does, deliberately: the auditor watches consumption, and the authority seizes, through exactly this property. The cost is that nonce secrecy carries all spend protection (§14). +`nf = H("OZ:cnt:null", nonce)`. Derivable by anyone who knows the nonce, and by design *only* from the nonce. Compare Zcash, where nullifier derivation involves a per-account nullifier key, so knowing a note's contents does not let third parties track its spend. Here it does, deliberately: the auditor watches consumption, and the authority seizes, through exactly this property. The cost is that nonce secrecy carries all spend protection (§15). Both hashes are domain-separated (`OZ:cnt:commit` vs `OZ:cnt:null`), so a commitment can never be replayed as a nullifier or vice versa. @@ -553,7 +563,73 @@ Two structural facts explain the numbers. First, the dominant cost everywhere is For calibration: the pure note spend (~20.5k rows in the spike) is about *half* the account-model CFT transfer (~43.8k). Graph privacy via notes is not intrinsically the expensive option; the compliance channels are what cost. -# 14. Design decisions +# 14. Concurrency + +How the design behaves when several transactions race on one deployment. The Midnight docs cover this only at a high level, so the mechanics below are verified against the compiler's op table and the ledger sources [[21]](#ref-21)[[22]](#ref-22)[[23]](#ref-23). + +## 14.1 The conflict model + +A contract call ships a fixed *public transcript* — an Impact VM program built at proof time against a snapshot of the contract state — plus the proof. "Kachina uses transcripts to record state operations and related queries." [[20]](#ref-20) On-chain the program is re-executed against the *current* state, and exactly two things couple it to the snapshot it was built on: + +- **Pinned reads.** Every ledger read a circuit performs is emitted as a `popeq` op with the expected value baked into the transcript; at application the VM compares baked vs. live and rejects the transaction on mismatch (`ReadMismatch`) [[22]](#ref-22)[[23]](#ref-23). +- **Declared effects.** The transcript declares its effects up front; the ledger recomputes and requires equality [[23]](#ref-23). + +Nothing else binds the transcript to its snapshot: ops that stay inside the VM program are re-executed against live state and succeed regardless of interleaving. This is the official guidance's distinction, stated for counters: "the `increment` will (almost) always succeed, while the read-add-write sequence is prone to failure." [[21]](#ref-21) + +Which Compact ledger operation pins what is mechanical, from the compiler's op table [[22]](#ref-22): + +| Operation | Pins into the transcript? | +| --- | --- | +| Ledger cell read (value used in-circuit) | **yes** — the value | +| Cell write (`x = ...`) | no — blind overwrite | +| `Counter.increment` | no — relative `addi` on live state | +| `Set.member` / `Map.lookup` | **yes** — the result | +| `Set.insert` / `Set.remove` / `Map.insert` | no — per-key blind write | +| `List.pushFront` | no — length bump is relative | +| `MerkleTree.insert` / `HistoricMerkleTree.insert` | no — appends at the **live** first-free index (relative bump); two concurrent inserts land at successive indices | +| `MerkleTree.checkRoot` | **yes** — `currentRoot == r` | +| `HistoricMerkleTree.checkRoot` | **yes** — `r ∈ history`; inserts only ever *add* to the history map, so the pinned `true` survives concurrent inserts | + +Two consequences to internalize: conflicts are read-vs-write (a pinned read breaks only when another transaction *writes* that cell or key; concurrent reads never collide), and the note machinery's writes — tree append, per-key set insert, list push — are exactly the non-pinning kind. The commitment/nullifier design is the concurrency-friendly shape for this VM, not by luck: it is append-only state addressed by content, the same pattern the ledger itself uses for Zswap. + +## 14.2 The conflict matrix + +Verdicts for the regulated preset, per racing pair: + +| Race | Verdict | Mechanism | +| --- | --- | --- | +| `transfer` ∥ `transfer` (different input notes) | ✅ commute | appends and per-key inserts pin nothing; the historic root check survives new inserts | +| `transfer` ∥ `mint` / `burn` / `seize` (different notes) | ✅ commute | `transfer` touches no supply or counter cell | +| `mint`/`burn` ∥ `mint`/`burn` | ❌ conflict | `_encSupply` is a pinned read + write: the homomorphic add runs in-circuit (the VM has no EC ops), so the old ciphertext is baked into the transcript | +| `seize` ∥ `seize` | ❌ conflict | `_seizureCount + 1` is an in-circuit read (pinned) + write | +| `attestSupply` / `rotateSupplyKey` ∥ `mint`/`burn` | ❌ conflict | both pin `_encSupply`; the mint/burn writes it | +| any rotation ∥ that role's in-flight circuits | ❌ conflict | the gate assert pins the role-key cell — intended: rotation instantly invalidates the old key's pending work | +| two spends of the same note (owner ∥ owner, owner ∥ `seize`) | ❌ first lands, second fails | both pin `nf ∉ _nullifiers`; this is the single-spend / seizure mutual exclusion working as designed | +| `freeze(nf)` ∥ spend of that note | ❌ first lands | the spend pins `nf ∉ _frozen`; freeze wins only if it lands first — the safe authority sequence is freeze → finality → seize | +| `freeze` ∥ `freeze` (different `nf`) | ✅ commute | per-key set writes | +| allowlist `_addAllowed`/`_removeAllowed` ∥ any allowlist-proven spend | ❌ conflict | plain-tree `checkRoot` pins the **current** root and any admin write changes it — intended for removals (instant revocation), collateral for adds (onboarding one user aborts every in-flight KYC spend) | +| audit / delivery / review trail appends among themselves | ✅ commute | `pushFront` pins nothing | + +Summary: **payments scale; the compliance and supply layers serialize.** The core's transfers are structurally concurrent. The serialization points are `_encSupply` (issuance and redemption are effectively one-at-a-time), the two in-circuit counters (`_seizureCount`, `_attestationCount` — fixable with `Counter`), and the plain allowlist tree (admin tempo couples to user liveness). The `_isInitialized` flags are pinned everywhere but written once, so they never conflict after deployment. + +## 14.3 What losing a race costs + +All circuits here run in the transaction's guaranteed segment (no `Kernel.checkpoint`), and a guaranteed-segment failure rejects the whole transaction **before fees are taken** [[23]](#ref-23). A losing racer pays nothing on-chain; the cost is wallet-side — rebuild the transcript against fresh state, re-prove, resubmit, and wait finality again. Under sustained contention a conflicting class (say, bursty mints) degrades to one landed transaction per retry cycle. Native Zswap coins avoid this for transfers because the protocol merges shielded offers itself; a contract-level pool buys its extra properties (§3.2) at the price of these in-contract races. + +## 14.4 Root-history growth + +Every insert appends the new root to the historic tree's history map and nothing ever evicts it — contract trees get no protocol pruning (the ledger's own Zswap tree time-prunes its root history after one hour; a contract's `HistoricMerkleTree` grows forever) [[22]](#ref-22)[[23]](#ref-23). `resetHistory` exists, but calling it invalidates every in-flight spend (their pinned `r ∈ history` flips to false), so pruning must be an announced operational window, not routine hygiene. Until then the history map is unbounded state growth, same class as the `_auditTrail` concern (§16). + +## 14.5 Fixing the serialization points + +The fixes are general (the same hotspot shape recurs in the account-model and native shielded families) and are designed in the standalone doc [`concurrency.md`](./concurrency.md) §9. For this token, the mapping is: + +- `_seizureCount` / `_attestationCount` → `Counter` (commutative increment; `concurrency.md` §9.1). +- `_encSupply` mint/burn serialization → pending-delta inbox + fold: mints/burns append encrypted deltas to a map keyed by writer randomness (commutes); a fold circuit absorbs them; `attestSupply` becomes fold-then-attest (§9.2). IMPLEMENTED as `extensions/ConfidentialNoteFungibleTokenConcurrentSupply` (PrivateSupply untouched) with the `ConcurrentConfidentialNoteFungibleToken` demo preset; attestation proves the inbox empty in-circuit, so a skipped delta cannot hide. +- Allowlist admin-vs-spend → `HistoricMerkleTree` with `resetHistory` only on removal: adds stop aborting in-flight spends, revocation stays instant (§9.4). +- Not fixed on purpose: same-note races and rotation-vs-in-flight are the intended mutual-exclusion semantics (§9.6 of the same doc explains why). + +# 15. Design decisions - **Notes, not accounts.** Sender privacy requires an unindexed commitment set with ZK membership; no account-model trick avoids it (§3.1). - **A contract-level pool, not Zswap coins.** Buys hidden issuance amounts, evasion-proof auditor viewing, and seizure, none of which native coins can express today (§3.2). Costs: big circuits and a self-managed tree. @@ -567,7 +643,7 @@ For calibration: the pure note spend (~20.5k rows in the spike) is about *half* - **`HistoricMerkleTree` over a plain tree.** Proofs built against a recent root still verify after later inserts; without history, every insert would invalidate every in-flight proof. - **Domain separation everywhere.** Commit vs nullifier, core vs audit nonces, out vs change slots, value vs owner pads: every hash and pad carries a distinct `OZ:cnt:*` tag, so no derived value can be replayed in another role. -# 15. Limitations and open questions +# 16. Limitations and open questions - **The audit key is all-seeing and global.** Selective or request-based disclosure (per-custodian review keys, or an issuer-run re-encryption service) is the known Phase-2 design question. Until then, audit-key compromise is total visibility compromise. - **One global authority key.** Production wants governance gating (compose with `multisig/`) and per-user recovery keys for least privilege. @@ -577,7 +653,7 @@ For calibration: the pure note spend (~20.5k rows in the spike) is about *half* - **Wallet UX is real work**: scanning, trial decryption, note management, and change tracking all live off-chain. - **Naming.** Adopted renames (see the header note): `ConfidentialNoteFungibleToken` family, supply extension as `ConfidentialNoteFungibleTokenPrivateSupply`. Applied across the branch code (modules, mocks, simulators, witnesses, CHANGELOG). Still to sweep: the in-repo design doc carries the earlier working name `HybridConfidentialToken`, and the preset header cites a doc path that predates it. -# 16. FAQ +# 17. FAQ **What does the public ledger actually contain?**
Commitment inserts, nullifiers, ciphertexts (audit + delivery), supply ciphertext updates, the seizure counter, attested totals, and the role keys. No amounts, no senders, no recipients, no balances. @@ -597,7 +673,7 @@ For calibration: the pure note spend (~20.5k rows in the spike) is about *half* **Why `Uint<128>` values?**
Headroom, and no protocol coupling: unlike native mints (capped at `Uint<64>` by the ledger's effect encoding), note values never touch a protocol effect. -# 17. Implementation status +# 18. Implementation status | Component | Status | | --- | --- | @@ -631,3 +707,7 @@ Pinned commits: compact-contracts PR #679 [`878aa43`](https://github.com/OpenZep 17. [`compact compiler/standard-library.compact:47`](https://github.com/LFDT-Minokawa/compact/blob/c06961eb661942f7689c6509d0913326f264e848/compiler/standard-library.compact#L47) — `export new type JubjubPoint`. 18. [`contracts/privacy_readme.md`](https://github.com/OpenZeppelin/compact-contracts/blob/878aa438b98879088f13f0ef96e10311ff020257/contracts/privacy_readme.md) — the four-tier exploration, benchmarks, and findings behind the model choice. 19. [`contracts/src/token/docs/hybrid-confidential-token.md`](https://github.com/OpenZeppelin/compact-contracts/blob/878aa438b98879088f13f0ef96e10311ff020257/contracts/src/token/docs/hybrid-confidential-token.md) — the in-PR design doc (compliance mapping, disclosure boundary, open questions). +20. [Midnight docs: Kachina](https://docs.midnight.network/concepts/kachina) — transcripts as recorded state operations; concurrency via reordering. Background: Kerber, Kiayias, Kohlweiss, [*Kachina — Foundations of Private Smart Contracts*](https://eprint.iacr.org/2020/543). +21. [Midnight docs: Smart contracts on Midnight](https://docs.midnight.network/concepts/how-midnight-works/smart-contracts) — the `increment` vs. read-add-write concurrency guidance. +22. [`compact compiler/midnight-ledger.ss`](https://github.com/LFDT-Minokawa/compact/blob/main/compiler/midnight-ledger.ss) — the ADT-method → VM-op table (ground truth for what pins): `MerkleTree.insert` bumps `first_free` with a relative `addi`; `HistoricMerkleTree.checkRoot` is `member` over the history map + `popeq`; `Counter.increment` is a relative `addi`; cell reads end in `popeq`. Verified against the repo at 2026-07-24; line refs drift with main. +23. [`midnightntwrk/midnight-ledger`](https://github.com/midnightntwrk/midnight-ledger) — transcript re-execution and rejection: `popeq` mismatch (`onchain-vm/src/result_mode.rs`, `ReadMismatch`), declared-vs-recomputed effects equality and guaranteed/fallible fee semantics (`ledger/src/semantics.rs`), Zswap root-history time-pruning (`zswap/src/ledger.rs`). diff --git a/contracts/src/token/extensions/ConfidentialNoteFungibleTokenAllowlist.compact b/contracts/src/token/extensions/ConfidentialNoteFungibleTokenAllowlist.compact index 082f9c19e..f998d2d68 100644 --- a/contracts/src/token/extensions/ConfidentialNoteFungibleTokenAllowlist.compact +++ b/contracts/src/token/extensions/ConfidentialNoteFungibleTokenAllowlist.compact @@ -32,6 +32,12 @@ pragma language_version >= 0.23.0; * @dev `wit_AllowlistPath` supplies the prover's own membership path; the * path stays witness, so which leaf proved membership is never revealed. * + * @dev Privacy caveat: leaves are UNSALTED (`leafOf(pk)` is deterministic), + * so anyone holding a candidate pk can test list membership and removal + * against the public inserts. Spend-time anonymity within the list is + * unaffected; the list's CONTENTS are dictionary-testable. Salted leaves are + * a known follow-up. + * * @dev NOT audited, NOT production. */ module ConfidentialNoteFungibleTokenAllowlist { @@ -64,6 +70,8 @@ module ConfidentialNoteFungibleTokenAllowlist { /** * @description UNGATED building block: adds `pk` to the allowlist. The * composer gates who may administer the list. + * + * @circuitInfo k=13, rows=6237 */ export circuit _addAllowed(pk: Field): [] { _allowed.insert(disclose(leafOf(pk))); @@ -75,6 +83,8 @@ module ConfidentialNoteFungibleTokenAllowlist { * Every outstanding path proof is invalidated by the root change. The * composer gates who may administer the list; see the module doc for the * index-bookkeeping caveat. + * + * @circuitInfo k=13, rows=2086 */ export circuit _removeAllowed(index: Uint<64>): [] { _allowed.insertIndexDefault(disclose(index)); @@ -88,6 +98,8 @@ module ConfidentialNoteFungibleTokenAllowlist { * Requirements: * * - `leafOf(pk)` is a leaf of the CURRENT tree (stale paths fail). + * + * @circuitInfo k=13, rows=7001 */ export circuit _assertAllowed(pk: Field): [] { const leaf = leafOf(pk); diff --git a/contracts/src/token/extensions/ConfidentialNoteFungibleTokenAudit.compact b/contracts/src/token/extensions/ConfidentialNoteFungibleTokenAudit.compact index 7c8a18435..ed6b89b6c 100644 --- a/contracts/src/token/extensions/ConfidentialNoteFungibleTokenAudit.compact +++ b/contracts/src/token/extensions/ConfidentialNoteFungibleTokenAudit.compact @@ -77,7 +77,9 @@ module ConfidentialNoteFungibleTokenAudit { // invocation (see module doc). witness wit_AuditRandomness(): Bytes<32>; // The audit secret scalar (`_auditKey = g^auditSk`); only `_rotateAuditKey` - // consumes it. + // consumes it. MUST be a canonical Jubjub scalar (below the subgroup + // order): an out-of-range value faults `ecMulGenerator` at proving time + // (same range assumption as `recoverAuditRecord`'s `auditSk`). witness wit_AuditKeySecret(): Field; /** @@ -147,6 +149,8 @@ module ConfidentialNoteFungibleTokenAudit { * - Extension is initialized. * - `newKey` is not the identity point. * - The caller proves the CURRENT audit secret (`g^secret == _auditKey`). + * + * @circuitInfo k=11, rows=1175 */ export circuit _rotateAuditKey(newKey: JubjubPoint): [] { assert(_isInitialized, "ConfidentialNoteFungibleTokenAudit: extension not initialized"); diff --git a/contracts/src/token/extensions/ConfidentialNoteFungibleTokenAuthority.compact b/contracts/src/token/extensions/ConfidentialNoteFungibleTokenAuthority.compact new file mode 100644 index 000000000..0888b5627 --- /dev/null +++ b/contracts/src/token/extensions/ConfidentialNoteFungibleTokenAuthority.compact @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts (token/extensions/ConfidentialNoteFungibleTokenAuthority.compact) + +pragma language_version >= 0.23.0; + +/** + * @module ConfidentialNoteFungibleTokenAuthority + * @description Optional standalone extension binding a single AUTHORITY role + * for a `ConfidentialNoteFungibleToken` pool: the authority is whoever proves + * the secret behind `_authorityPk = Hf(authoritySecret)` in-circuit. It + * imports no token module. + * + * The role is a gate, not a mechanism: this extension knows nothing about + * seizing or freezing. The composing contract places `_assertAuthority` in + * front of whichever administrative blocks its deployment reserves for the + * authority (seizure, freeze/unfreeze, ...): + * + * export circuit seize(...): [] { + * Authority__assertAuthority(); + * // ...consume the target note and re-mint to a recovery owner... + * } + * + * A deployment with different powers (freeze-only, no seizure at all) reuses + * the same gate; in production the authority key is governance-gated + * (multisig). + * + * @dev Identity derivation matches the token core's `derivePk` + * (`pk = Hf(sk)`, field-typed), so one keypair works across both modules and + * off-chain code can derive the authority identity with the core's exported + * circuit. + * + * @dev NOT audited, NOT production. + */ +module ConfidentialNoteFungibleTokenAuthority { + import CompactStandardLibrary; + + export ledger _isInitialized: Boolean; + // Authority authorization: `Hf(authoritySecret)`. + export ledger _authorityPk: Field; + + // The authority's secret (authorityPk = Hf(authoritySecret)). + witness wit_AuthoritySecret(): Bytes<32>; + + /** + * @description One-shot initialization binding the authority. + * + * Requirements: + * + * - Extension is not already initialized. + * - `authorityPk` is nonzero (zero has no known preimage, which would brick + * every authority-gated action permanently). + */ + export circuit initialize(authorityPk: Field): [] { + assert(!_isInitialized, "ConfidentialNoteFungibleTokenAuthority: already initialized"); + assert(authorityPk != 0 as Field, + "ConfidentialNoteFungibleTokenAuthority: zero authority key"); + _authorityPk = disclose(authorityPk); + _isInitialized = true; + } + + /** + * @description Building block: asserts the caller proves the authority + * secret. Place it before any administrative block the deployment reserves + * for the authority. + * + * Requirements: + * + * - Extension is initialized. + * - `Hf(wit_AuthoritySecret()) == _authorityPk`. + */ + export circuit _assertAuthority(): [] { + assert(_isInitialized, "ConfidentialNoteFungibleTokenAuthority: extension not initialized"); + assert(derivePk(wit_AuthoritySecret()) == _authorityPk, + "ConfidentialNoteFungibleTokenAuthority: not the authority"); + } + + /** + * @description Building block: self-rotation — the current authority proves + * their secret and binds a new authority key. Same semantics as an + * `Ownable`-style ownership transfer: a compromised key can rotate itself + * away, so production deployments gate this behind governance too. + * + * Requirements: + * + * - Extension is initialized. + * - The caller proves the CURRENT authority secret. + * - `newAuthorityPk` is nonzero (renounce-by-zero is deliberately NOT + * supported: it would brick authority actions silently; a renounce design + * is a separate decision). + */ + export circuit _rotateAuthority(newAuthorityPk: Field): [] { + _assertAuthority(); + assert(newAuthorityPk != 0 as Field, + "ConfidentialNoteFungibleTokenAuthority: zero authority key"); + _authorityPk = disclose(newAuthorityPk); + } + + // Same construction as the token core's `derivePk`, duplicated so this + // extension stays free of token imports (a module import would share the + // token's ledger state into any consumer of this extension). + circuit derivePk(sk: Bytes<32>): Field { + return degradeToTransient(persistentHash>(sk)); + } +} diff --git a/contracts/src/token/extensions/ConfidentialNoteFungibleTokenConcurrentSupply.compact b/contracts/src/token/extensions/ConfidentialNoteFungibleTokenConcurrentSupply.compact new file mode 100644 index 000000000..ddc6f4fd3 --- /dev/null +++ b/contracts/src/token/extensions/ConfidentialNoteFungibleTokenConcurrentSupply.compact @@ -0,0 +1,185 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts (token/extensions/ConfidentialNoteFungibleTokenConcurrentSupply.compact) + +pragma language_version >= 0.23.0; + +/** + * @module ConfidentialNoteFungibleTokenConcurrentSupply + * @description Concurrency-hardened sibling of + * `ConfidentialNoteFungibleTokenPrivateSupply`: the same confidential, + * attestable supply, but mint/burn accounting goes through an + * `ElGamalDeltaInbox` instead of updating `_encSupply` in-circuit. The direct + * update pins the old ciphertext into every mint/burn transcript, so + * concurrent mints/burns reject each other (`concurrency.md` §8.1); here they + * only APPEND encrypted deltas (commutes with everything), and a separate + * fold absorbs the backlog: + * + * - `_addMinted` / `_addBurned` — hot path, conflict-free: encrypt the + * delta (negated for burns) and credit the inbox. + * - `_foldSupply` — cold path: drain up to 8 pending deltas into + * `_encSupply`. Conflicts only with itself; safely permissionless. + * - `attestSupply` — checkpoint: proves the inbox is EMPTY in-circuit + * (`_assertEmpty`), so the attested total provably includes every mint + * and burn ever credited. The emptiness read is a deliberate barrier + * (see the inbox module doc). + * + * Conflict profile: mint ∥ mint, burn ∥ burn, mint ∥ burn all commute; + * fold ∥ fold and attest ∥ credit conflict (folder/checkpoint semantics). + * Compare PrivateSupply, where every pair above conflicts. + * + * @dev Same 1:1 wiring warning as PrivateSupply: pair every token mint/burn + * with exactly one `_addMinted`/`_addBurned` in the SAME circuit, or the + * encrypted supply silently diverges from the pool. + * + * @dev Plaintext range: the folded supply lives in the exponent, so burns + * exceeding mints (possible only when mis-wired) land at `g^(-v)` and make + * every future attestation unsatisfiable. Correct pairing rules this out. + * + * @dev `wit_ConcurrentSupplyRandomness` MUST return a fresh, secret seed per + * invocation: it derives both the delta's encryption randomness and the + * inbox entry id (a repeated seed leaks plaintext relations AND collides the + * id, rejecting the credit). + * + * @dev This extension shares the `ElGamalDeltaInbox` module instance with + * any other inbox user in the composing contract (module state is a per-file + * singleton); its entries live under the `OZ:cnt:csupply` domain. + * + * @dev NOT audited, NOT production. + */ +module ConfidentialNoteFungibleTokenConcurrentSupply { + import CompactStandardLibrary; + import "../../crypto/ElGamal" prefix ElGamal_; + import "../../utils/concurrency/ElGamalDeltaInbox" prefix Inbox_; + + export ledger _isInitialized: Boolean; + // ElGamal public key the supply is encrypted under (attester holds the secret). + export ledger _supplyKey: JubjubPoint; + // FOLDED outstanding supply. The live total is this plus the pending inbox + // deltas; `attestSupply` proves the inbox empty so the two coincide. + export ledger _encSupply: ElGamal_Ciphertext; + // Last publicly attested total and an attestation counter for indexers + // (a `Counter`, so the bump never pins — see concurrency.md §9.1). + export ledger _attestedSupply: Uint<128>; + export ledger _attestationCount: Counter; + + // Randomness seed for delta encryption + inbox id. MUST be fresh + secret + // per invocation (see module doc). + witness wit_ConcurrentSupplyRandomness(): Bytes<32>; + // The supply-key secret (only `attestSupply` consumes it). + witness wit_ConcurrentSupplyKeySecret(): Bytes<32>; + + /** + * @description One-shot initialization: binds the supply key and starts the + * folded supply at the canonical `Enc(0)`. + * + * Requirements: + * + * - Extension is not already initialized. + * - `supplyKey` is not the identity point. + */ + export circuit initialize(supplyKey: JubjubPoint): [] { + assert(!_isInitialized, "ConfidentialNoteFungibleTokenConcurrentSupply: already initialized"); + assert(supplyKey != ecMulGenerator(0 as Field), + "ConfidentialNoteFungibleTokenConcurrentSupply: identity supply key"); + _supplyKey = disclose(supplyKey); + _encSupply = ElGamal_encryptZero(); + _isInitialized = true; + } + + /** + * @description Records a minted `value` as a pending encrypted delta. Call + * once, with the exact minted amount, alongside every token mint. Touches + * no shared cell, so concurrent mints/burns never conflict. + * + * Requirements: + * + * - Extension is initialized. + */ + export circuit _addMinted(value: Uint<128>): [] { + assertInitialized(); + const seed = wit_ConcurrentSupplyRandomness(); + const r = ElGamal_expandRandomness(seed, pad(32, "OZ:cnt:csupply:add")); + Inbox__credit(supplyDomain(), creditIdOf(seed, pad(32, "OZ:cnt:csupply:id:add")), + ElGamal_encrypt(_supplyKey, value, r)); + } + + /** + * @description Records a burned `value` as a pending encrypted delta — a + * NEGATED encryption, so one homomorphic fold nets mints and burns alike + * (and a pending entry does not reveal its direction). Call once, with the + * exact burned amount, alongside every token burn. + * + * Requirements: + * + * - Extension is initialized. + */ + export circuit _addBurned(value: Uint<128>): [] { + assertInitialized(); + const seed = wit_ConcurrentSupplyRandomness(); + const r = ElGamal_expandRandomness(seed, pad(32, "OZ:cnt:csupply:sub")); + Inbox__credit(supplyDomain(), creditIdOf(seed, pad(32, "OZ:cnt:csupply:id:sub")), + ElGamal_negate(ElGamal_encrypt(_supplyKey, value, r))); + } + + /** + * @description Absorbs up to 8 pending deltas into the folded supply. The + * only circuit here that pins `_encSupply`, so folds conflict only with + * each other — and folding cannot corrupt value (amounts come from the + * ledger), so the composer MAY expose it permissionlessly. Call repeatedly + * to drain a deep backlog. + * + * Requirements: + * + * - Extension is initialized. + */ + export circuit _foldSupply(): [] { + assertInitialized(); + const net = Inbox__consume(supplyDomain()); + _encSupply = disclose(ElGamal_add(_encSupply, net)); + } + + /** + * @description Publishes a proof-backed public supply total. Beyond + * PrivateSupply's guarantees, this variant FIRST proves in-circuit that no + * delta is still pending (`_assertEmpty`), so the attested total provably + * reflects every mint and burn — a skipping fold witness cannot fake a + * complete picture. The emptiness read makes attestation a serialization + * point against concurrent mints/burns; fold-to-empty, then attest. + * + * Requirements: + * + * - Extension is initialized. + * - The supply inbox domain has no pending entries. + * - The caller proves the supply-key secret (`derivePk(secret) == _supplyKey`). + * - `_encSupply` decrypts to `total`. + */ + export circuit attestSupply(total: Uint<128>): [] { + assertInitialized(); + Inbox__assertEmpty(supplyDomain()); + ElGamal_assertDecryptsTo(_encSupply, _supplyKey, wit_ConcurrentSupplyKeySecret(), total); + // Only the attested total crosses to public state; the per-transaction + // deltas behind it stay encrypted. + _attestedSupply = disclose(total); + _attestationCount.increment(1); + } + + /** + * @description The inbox domain this extension's deltas live under. + * Exported so indexers and folders enumerate/derive entries the way the + * circuits do. + */ + export pure circuit supplyDomain(): Bytes<32> { + return pad(32, "OZ:cnt:csupply"); + } + + // Fresh inbox entry id from the caller's randomness seed, domain-separated + // from the encryption randomness expanded off the same seed. + circuit creditIdOf(seed: Bytes<32>, tag: Bytes<32>): Bytes<32> { + return persistentHash>>([seed, tag]); + } + + circuit assertInitialized(): [] { + assert(_isInitialized, + "ConfidentialNoteFungibleTokenConcurrentSupply: extension not initialized"); + } +} diff --git a/contracts/src/token/extensions/ConfidentialNoteFungibleTokenFreeze.compact b/contracts/src/token/extensions/ConfidentialNoteFungibleTokenFreeze.compact index c9fb52a91..aa3c94cb9 100644 --- a/contracts/src/token/extensions/ConfidentialNoteFungibleTokenFreeze.compact +++ b/contracts/src/token/extensions/ConfidentialNoteFungibleTokenFreeze.compact @@ -47,6 +47,8 @@ module ConfidentialNoteFungibleTokenFreeze { * Requirements: * * - `nf` is not already frozen. + * + * @circuitInfo k=9, rows=308 */ export circuit _freeze(nf: Bytes<32>): [] { assert(!_frozen.member(disclose(nf)), @@ -61,6 +63,8 @@ module ConfidentialNoteFungibleTokenFreeze { * Requirements: * * - `nf` is frozen. + * + * @circuitInfo k=9, rows=305 */ export circuit _unfreeze(nf: Bytes<32>): [] { assert(_frozen.member(disclose(nf)), @@ -71,6 +75,8 @@ module ConfidentialNoteFungibleTokenFreeze { /** * @description Building block: asserts the note behind `nf` is not frozen. * Place it at every owner-spend chokepoint (and nowhere near `seize`). + * + * @circuitInfo k=9, rows=308 */ export circuit _assertNotFrozen(nf: Bytes<32>): [] { assert(!_frozen.member(disclose(nf)), diff --git a/contracts/src/token/extensions/ConfidentialNoteFungibleTokenIssuer.compact b/contracts/src/token/extensions/ConfidentialNoteFungibleTokenIssuer.compact index e862a16ec..f414450bc 100644 --- a/contracts/src/token/extensions/ConfidentialNoteFungibleTokenIssuer.compact +++ b/contracts/src/token/extensions/ConfidentialNoteFungibleTokenIssuer.compact @@ -47,11 +47,15 @@ module ConfidentialNoteFungibleTokenIssuer { * Requirements: * * - Extension is not already initialized. + * - `issuerPk` is nonzero (zero has no known preimage, which would brick + * minting permanently). * - * @circuitInfo k=6, rows=31 + * @circuitInfo k=6, rows=37 */ export circuit initialize(issuerPk: Field): [] { assert(!_isInitialized, "ConfidentialNoteFungibleTokenIssuer: already initialized"); + assert(issuerPk != 0 as Field, + "ConfidentialNoteFungibleTokenIssuer: zero issuer key"); _issuerPk = disclose(issuerPk); _isInitialized = true; } @@ -84,9 +88,16 @@ module ConfidentialNoteFungibleTokenIssuer { * * - Extension is initialized. * - The caller proves the CURRENT issuer secret. + * - `newIssuerPk` is nonzero (renounce-by-zero is deliberately NOT + * supported: it would brick minting silently; a renounce design is a + * separate decision). + * + * @circuitInfo k=13, rows=2285 */ export circuit _rotateIssuer(newIssuerPk: Field): [] { _assertIssuer(); + assert(newIssuerPk != 0 as Field, + "ConfidentialNoteFungibleTokenIssuer: zero issuer key"); _issuerPk = disclose(newIssuerPk); } diff --git a/contracts/src/token/extensions/ConfidentialNoteFungibleTokenPrivateSupply.compact b/contracts/src/token/extensions/ConfidentialNoteFungibleTokenPrivateSupply.compact index e1fa11de1..7fc95af92 100644 --- a/contracts/src/token/extensions/ConfidentialNoteFungibleTokenPrivateSupply.compact +++ b/contracts/src/token/extensions/ConfidentialNoteFungibleTokenPrivateSupply.compact @@ -164,12 +164,19 @@ module ConfidentialNoteFungibleTokenPrivateSupply { * into the new ciphertext. Production deployments gate this behind * governance too. * + * WARNING: rotating to a key whose secret is lost or unheld makes the + * encrypted supply unattestable and unrotatable FOREVER — the identity + * point is rejected below, but no circuit can check that someone holds the + * scalar behind `newKey`. Verify key custody before rotating. + * * Requirements: * * - Extension is initialized. * - `newKey` is not the identity point. * - The caller proves the CURRENT supply-key secret and `_encSupply` * decrypts to `total`. + * + * @circuitInfo k=14, rows=11624 */ export circuit _rotateSupplyKey(newKey: JubjubPoint, total: Uint<128>): [] { assertInitialized(); diff --git a/contracts/src/token/extensions/ConfidentialNoteFungibleTokenReview.compact b/contracts/src/token/extensions/ConfidentialNoteFungibleTokenReview.compact index 4317245ca..5b897ec33 100644 --- a/contracts/src/token/extensions/ConfidentialNoteFungibleTokenReview.compact +++ b/contracts/src/token/extensions/ConfidentialNoteFungibleTokenReview.compact @@ -26,6 +26,18 @@ pragma language_version >= 0.23.0; * commitment and nullifier — full visibility over exactly the outputs * addressed to it. * + * @warning Record correctness is a property of the CONSUMER's wiring: this + * circuit cannot verify that `(ownerPk, value, nonce)` match a committed + * note. Emit the record in the SAME circuit that commits the note, from the + * same values, or the published record silently diverges from the pool — an + * undetectable-on-chain error, same class as the PrivateSupply mis-wiring + * warning. + * + * @dev Reviewer selection is the SENDER's: with hidden identities the + * circuit can enforce "an approved reviewer", never "the right custodian for + * this user". Per-user reviewer policy is deployment/Phase-2 design, out of + * scope of this draft. + * * @dev Disclosure caveat (draft): each record publishes WHICH approved * reviewer can open it (`reviewerKeyHash`), so custodian affiliation is * public per output. Hiding the reviewer behind a Merkle membership proof is @@ -87,6 +99,8 @@ module ConfidentialNoteFungibleTokenReview { * * - `reviewerKey` is not the identity point. * - `reviewerKey` is not already approved. + * + * @circuitInfo k=13, rows=5159 */ export circuit _addReviewer(reviewerKey: JubjubPoint): [] { assert(reviewerKey != ecMulGenerator(0 as Field), @@ -106,6 +120,8 @@ module ConfidentialNoteFungibleTokenReview { * Requirements: * * - `reviewerKey` is an approved reviewer. + * + * @circuitInfo k=13, rows=4593 */ export circuit _removeReviewer(reviewerKey: JubjubPoint): [] { const keyHash = reviewerKeyHashOf(reviewerKey); @@ -122,6 +138,8 @@ module ConfidentialNoteFungibleTokenReview { * Requirements: * * - `reviewerKey` is an approved reviewer. + * + * @circuitInfo k=16, rows=36165 */ export circuit _emitReviewRecord( reviewerKey: JubjubPoint, diff --git a/contracts/src/token/presets/ConcurrentConfidentialNoteFungibleToken.compact b/contracts/src/token/presets/ConcurrentConfidentialNoteFungibleToken.compact new file mode 100644 index 000000000..34fcd33db --- /dev/null +++ b/contracts/src/token/presets/ConcurrentConfidentialNoteFungibleToken.compact @@ -0,0 +1,174 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts (token/presets/ConcurrentConfidentialNoteFungibleToken.compact) + +pragma language_version >= 0.23.0; + +/** + * @module ConcurrentConfidentialNoteFungibleToken + * @description DRAFT minimal preset demonstrating the concurrency-hardened + * supply path: the note-token core + single-issuer gate + a + * `ConcurrentSupply` extension whose mint/burn accounting goes through an + * `ElGamalDeltaInbox` (see `concurrency.md` §9.2). It is deliberately NOT the + * regulated composition — no audit, delivery, freeze, or seizure — so the + * concurrency shape stands alone: created notes return to the caller and move + * out of band (the future Basic preset's style). + * + * import "./presets/ConcurrentConfidentialNoteFungibleToken" prefix Token_; + * + * constructor(issuerPk: Field, supplyKey: JubjubPoint) { + * Token_initialize(issuerPk, supplyKey); + * } + * + * What the delta inbox buys, per racing pair (contrast the Regulated preset, + * where every mint/burn pair conflicts on `_encSupply`): + * + * - mint ∥ mint, mint ∥ burn, burn ∥ burn — ✅ commute: tree appends, + * per-key nullifier inserts, and inbox credits pin nothing shared. + * - transfer ∥ anything (different notes) — ✅ commute, as in the core. + * - foldSupply ∥ foldSupply — ❌ folder self-serializes (safely + * permissionless: folding cannot corrupt value). + * - attestSupply ∥ mint/burn — ❌ deliberate checkpoint: attestation proves + * the inbox EMPTY in-circuit, so the published total provably includes + * every mint and burn. Fold-to-empty, then attest. + * + * @dev The module re-exports the composed modules' observable ledger state + * and types under bare names, including the inbox's `_pending` / + * `_pendingCounts` — folders and indexers enumerate pending deltas and watch + * backlog depth through the consumer's generated ledger reader. + * + * @dev `wit_NonceRandomness` (core) and `wit_ConcurrentSupplyRandomness` + * (supply) MUST each return a fresh, secret seed per invocation. + * + * @dev NOT audited, NOT production. + */ +module ConcurrentConfidentialNoteFungibleToken { + import CompactStandardLibrary; + import "../ConfidentialNoteFungibleToken" prefix Core_; + import "../extensions/ConfidentialNoteFungibleTokenIssuer" prefix Issuer_; + import "../extensions/ConfidentialNoteFungibleTokenConcurrentSupply" prefix Supply_; + + // Surface the composed modules' observable state and types under stable + // bare names (a prefix-only import would keep them out of a consumer's + // generated ledger reader). + import { Note, _commitments, _nullifiers } from "../ConfidentialNoteFungibleToken"; + import { _issuerPk } from "../extensions/ConfidentialNoteFungibleTokenIssuer"; + import { + _supplyKey, + _encSupply, + _attestedSupply, + _attestationCount + } from "../extensions/ConfidentialNoteFungibleTokenConcurrentSupply"; + import { _pending, _pendingCounts } from "../../utils/concurrency/ElGamalDeltaInbox"; + export { Note }; + export { + _issuerPk, + _commitments, + _nullifiers, + _supplyKey, + _encSupply, + _attestedSupply, + _attestationCount, + _pending, + _pendingCounts + }; + + export ledger _isInitialized: Boolean; + + /** + * @description One-shot initialization binding the issuer (may mint) and + * the supply key (attests totals). A consuming contract typically calls + * this from its constructor. + * + * Requirements: + * + * - Module is not already initialized. + * - Each sub-module initializer guards its own key. + */ + export circuit initialize(issuerPk: Field, supplyKey: JubjubPoint): [] { + assert(!_isInitialized, "ConcurrentConfidentialNoteFungibleToken: already initialized"); + Issuer_initialize(issuerPk); + Supply_initialize(supplyKey); + _isInitialized = true; + } + + /** + * @description Mints a note of `value` to `recipientPk` and returns it (a + * local, private result the caller hands over out of band). The supply + * delta is credited to the inbox, so concurrent mints/burns do NOT + * conflict. + * + * Requirements: + * + * - The caller proves the issuer secret. + * + * @circuitInfo k=15, rows=27464 + */ + export circuit mint(recipientPk: Field, value: Uint<128>): Note { + Issuer__assertIssuer(); + const note = Core__mint(recipientPk, value); + Supply__addMinted(value); + return note; + } + + /** + * @description Fully-private transfer, straight from the core: consumes the + * caller's input note, returns `[outNote, changeNote]`. Touches no supply + * state — transfers commute with everything but a spend of the same note. + * + * Requirements: + * + * - The input note is committed in the tree and unspent. + * - `value <= input.value`. + * + * @circuitInfo k=16, rows=36394 + */ + export circuit transfer(recipientPk: Field, value: Uint<128>): [Note, Note] { + return Core_transfer(recipientPk, value); + } + + /** + * @description Burns `value` from the caller's input note and returns the + * change note. The supply decrease is credited to the inbox as a negated + * encryption, so burns commute with mints and with each other. + * + * Requirements: + * + * - The input note is committed in the tree and unspent. + * - `value <= input.value`. + * + * @circuitInfo k=16, rows=40945 + */ + export circuit burn(value: Uint<128>): Note { + const changeNote = Core_burn(value); + Supply__addBurned(value); + return changeNote; + } + + /** + * @description Absorbs up to 8 pending supply deltas into `_encSupply`. + * Deliberately UNGATED — folding cannot corrupt value, so any party may + * drain the backlog (a censoring indexer is routed around, not trusted). + * Call repeatedly to drain a deep inbox. + * + * @circuitInfo k=16, rows=39462 + */ + export circuit foldSupply(): [] { + Supply__foldSupply(); + } + + /** + * @description Publishes a proof-backed public supply total. Proves the + * inbox empty in-circuit first, so the total provably includes every mint + * and burn (see the ConcurrentSupply extension). + * + * Requirements: + * + * - The supply inbox has no pending entries (fold-to-empty first). + * - The caller proves the supply-key secret and the exact total. + * + * @circuitInfo k=13, rows=4694 + */ + export circuit attestSupply(total: Uint<128>): [] { + Supply_attestSupply(total); + } +} diff --git a/contracts/src/token/presets/RegulatedConfidentialNoteFungibleToken.compact b/contracts/src/token/presets/RegulatedConfidentialNoteFungibleToken.compact index 999038dbb..6b9a45327 100644 --- a/contracts/src/token/presets/RegulatedConfidentialNoteFungibleToken.compact +++ b/contracts/src/token/presets/RegulatedConfidentialNoteFungibleToken.compact @@ -33,25 +33,32 @@ pragma language_version >= 0.23.0; * come from the audit channel instead of the core default, * - `extensions/ConfidentialNoteFungibleTokenIssuer` — the single-issuer gate * in front of value creation, + * - `extensions/ConfidentialNoteFungibleTokenAuthority` — the authority gate in + * front of seizure and freeze/unfreeze (the seize flow itself is this + * preset's wiring), * - `extensions/ConfidentialNoteFungibleTokenAudit` — mandatory auditor viewing; each * output nonce derives from the audit ECDH, so every note this contract * creates is auditor-recoverable BY CONSTRUCTION, * - `extensions/ConfidentialNoteFungibleTokenDelivery` — on-chain note delivery, so * recipients discover funds from chain data alone (no out-of-band channel), * - `extensions/ConfidentialNoteFungibleTokenPrivateSupply` — homomorphic encrypted supply - * with proof-backed public attestation. + * with proof-backed public attestation, + * - `extensions/ConfidentialNoteFungibleTokenFreeze` — freeze-before-seize: + * owner-spend paths check a frozen-nullifier set; `seize` deliberately does + * not, so a frozen note remains seizable. * - * Roles: the ISSUER may mint, the AUTHORITY may seize, the AUDIT key reads - * everything (never spends), the SUPPLY key attests totals. All four bind in + * Roles: the ISSUER may mint, the AUTHORITY may seize and freeze/unfreeze, + * the AUDIT key reads everything (never spends), the SUPPLY key attests + * totals. All four bind in * one `initialize` call, and each can later SELF-ROTATE by proving its * current secret (`rotateIssuer` / `rotateAuthority` / `rotateAuditKey` / * `rotateSupplyKey`); production deployments gate rotation behind governance * too. * * No state-changing circuit is usable before `initialize`: mint asserts the - * issuer extension's initialization, every output emission asserts the audit - * extension's, supply updates assert the supply extension's, and the zero - * authority key has no known preimage. + * issuer extension's initialization, authority actions assert the authority + * extension's, every output emission asserts the audit extension's, and + * supply updates assert the supply extension's. * * Seizure needs no key escrow: the core nullifier depends only on the nonce, * so the owner and the authority derive the SAME nullifier, making owner-spend @@ -60,8 +67,8 @@ pragma language_version >= 0.23.0; * owner, itself audited and delivered. * * What the public sees: commitment inserts, nullifiers, ciphertexts, the - * seizure counter, and attested supply totals. Amounts, senders, and - * recipients stay hidden. + * frozen-nullifier set, the seizure counter, and attested supply totals. + * Amounts, senders, and recipients stay hidden. * * See `token/docs/confidential-note-token.md` for the full design and the * auditor/compliance rationale. @@ -81,9 +88,11 @@ module RegulatedConfidentialNoteFungibleToken { import CompactStandardLibrary; import "../ConfidentialNoteFungibleToken" prefix Core_; import "../extensions/ConfidentialNoteFungibleTokenIssuer" prefix Issuer_; + import "../extensions/ConfidentialNoteFungibleTokenAuthority" prefix Authority_; import "../extensions/ConfidentialNoteFungibleTokenAudit" prefix Audit_; import "../extensions/ConfidentialNoteFungibleTokenDelivery" prefix Delivery_; import "../extensions/ConfidentialNoteFungibleTokenPrivateSupply" prefix Supply_; + import "../extensions/ConfidentialNoteFungibleTokenFreeze" prefix Freeze_; import "../../crypto/NoteDelivery" prefix NoteDelivery_; // Surface the composed modules' observable state and artifact types under @@ -92,6 +101,7 @@ module RegulatedConfidentialNoteFungibleToken { // would keep them out of a consumer's generated ledger reader. import { Note, _commitments, _nullifiers } from "../ConfidentialNoteFungibleToken"; import { _issuerPk } from "../extensions/ConfidentialNoteFungibleTokenIssuer"; + import { _authorityPk } from "../extensions/ConfidentialNoteFungibleTokenAuthority"; import { AuditRecord, AuditView, @@ -105,12 +115,15 @@ module RegulatedConfidentialNoteFungibleToken { _attestedSupply, _attestationCount } from "../extensions/ConfidentialNoteFungibleTokenPrivateSupply"; + import { _frozen } from "../extensions/ConfidentialNoteFungibleTokenFreeze"; import { FullDelivery } from "../../crypto/NoteDelivery"; export { Note, AuditRecord, AuditView, FullDelivery }; export { _issuerPk, + _authorityPk, _commitments, _nullifiers, + _frozen, _auditKey, _auditTrail, _deliveries, @@ -121,14 +134,10 @@ module RegulatedConfidentialNoteFungibleToken { }; export ledger _isInitialized: Boolean; - // Global seizure authority (`Hf(authoritySecret)`; governance-gated in a real - // deployment) and an auditable count of seizures performed. - export ledger _authorityPk: Field; + // Auditable count of seizures performed. The authority role itself (key, + // secret witness, gate, rotation) lives in the Authority extension. export ledger _seizureCount: Uint<64>; - // The seizure authority's secret (authorityPk = Hf(authoritySecret)). - witness wit_AuthoritySecret(): Bytes<32>; - /** * @description One-shot initialization binding all four roles: the issuer * (may mint), the seizure authority (may claw back), the audit key @@ -139,6 +148,8 @@ module RegulatedConfidentialNoteFungibleToken { * Requirements: * * - Module is not already initialized. + * - Each sub-module initializer guards its own key (nonzero issuer and + * authority, non-weak audit and supply keys). */ export circuit initialize( issuerPk: Field, @@ -148,9 +159,9 @@ module RegulatedConfidentialNoteFungibleToken { ): [] { assert(!_isInitialized, "RegulatedConfidentialNoteFungibleToken: already initialized"); Issuer_initialize(issuerPk); + Authority_initialize(authorityPk); Audit_initialize(auditKey); Supply_initialize(supplyKey); - _authorityPk = disclose(authorityPk); _isInitialized = true; } @@ -184,9 +195,10 @@ module RegulatedConfidentialNoteFungibleToken { * Requirements: * * - The input note is committed in the tree and unspent. + * - The input note is not frozen. * - `value <= input.value`. * - * @circuitInfo k=18, rows=135775 + * @circuitInfo k=18, rows=139995 */ export circuit transfer( recipientPk: Field, @@ -199,6 +211,9 @@ module RegulatedConfidentialNoteFungibleToken { // Peek at the input to size the change; the core re-reads the same witness // and enforces conservation against it. const input = Core__inputNote(); + // Freeze check binds to that same witness, so the checked note IS the + // note the core consumes. + Freeze__assertNotFrozen(Core_nullifierOf(input)); assert(input.value >= value, "RegulatedConfidentialNoteFungibleToken: insufficient note value"); const changeValue = (input.value - value) as Uint<128>; @@ -217,14 +232,16 @@ module RegulatedConfidentialNoteFungibleToken { * Requirements: * * - The input note is committed in the tree and unspent. + * - The input note is not frozen. * - `value <= input.value`. * - * @circuitInfo k=17, rows=82803 + * @circuitInfo k=17, rows=87023 */ export circuit burn(senderEncPk: JubjubPoint, value: Uint<128>): [] { const pk = Core__spenderPk(); const input = Core__inputNote(); + Freeze__assertNotFrozen(Core_nullifierOf(input)); assert(input.value >= value, "RegulatedConfidentialNoteFungibleToken: insufficient note value"); const changeValue = (input.value - value) as Uint<128>; @@ -255,9 +272,10 @@ module RegulatedConfidentialNoteFungibleToken { * @circuitInfo k=17, rows=75043 */ export circuit seize(targetOwnerPk: Field, recoveryPk: Field, recoveryEncPk: JubjubPoint): [] { - assert(Core_derivePk(wit_AuthoritySecret()) == _authorityPk, - "RegulatedConfidentialNoteFungibleToken: not the authority"); + Authority__assertAuthority(); + // Deliberately NO freeze check: seizing a frozen note is the + // freeze-then-seize flow. const target = Core__consumeNote(targetOwnerPk); const recoveryNote = emitOutput(recoveryPk, recoveryEncPk, target.value, pad(32, "OZ:cnt:out")); Core__mintNote(recoveryNote, recoveryPk); @@ -283,6 +301,8 @@ module RegulatedConfidentialNoteFungibleToken { /** * @description Rotates the issuer key: the current issuer proves their * secret and binds `newIssuerPk` (see the Issuer extension). + * + * @circuitInfo k=13, rows=2285 */ export circuit rotateIssuer(newIssuerPk: Field): [] { Issuer__rotateIssuer(newIssuerPk); @@ -290,16 +310,14 @@ module RegulatedConfidentialNoteFungibleToken { /** * @description Rotates the seizure authority: the current authority proves - * their secret and binds `newAuthorityPk`. + * their secret and binds `newAuthorityPk` (see the Authority extension). * * Requirements: * * - The caller proves the CURRENT authority secret. */ export circuit rotateAuthority(newAuthorityPk: Field): [] { - assert(Core_derivePk(wit_AuthoritySecret()) == _authorityPk, - "RegulatedConfidentialNoteFungibleToken: not the authority"); - _authorityPk = disclose(newAuthorityPk); + Authority__rotateAuthority(newAuthorityPk); } /** @@ -307,6 +325,8 @@ module RegulatedConfidentialNoteFungibleToken { * secret scalar and binds `newKey`. Prior records stay readable by the old * key; later outputs derive nonces from the new one (see the Audit * extension). + * + * @circuitInfo k=11, rows=1175 */ export circuit rotateAuditKey(newKey: JubjubPoint): [] { Audit__rotateAuditKey(newKey); @@ -317,11 +337,46 @@ module RegulatedConfidentialNoteFungibleToken { * and the exact (undisclosed) running total, and `_encSupply` is * re-encrypted under `newKey` in the same proof (see the PrivateSupply * extension). + * + * @circuitInfo k=14, rows=11624 */ export circuit rotateSupplyKey(newKey: JubjubPoint, total: Uint<128>): [] { Supply__rotateSupplyKey(newKey, total); } + /** + * @description Freezes the note behind `nf` (freeze-before-seize): the + * authority derives the target's nullifier from the audit trail and blocks + * owner-spend WITHOUT consuming the note. Reversible via `unfreeze`; + * `seize` still works on a frozen note. + * + * Requirements: + * + * - The caller proves the authority secret. + * - `nf` is not already frozen. + * + * @circuitInfo k=13, rows=2559 + */ + export circuit freeze(nf: Bytes<32>): [] { + Authority__assertAuthority(); + Freeze__freeze(nf); + } + + /** + * @description Lifts the freeze on `nf`. + * + * Requirements: + * + * - The caller proves the authority secret. + * - `nf` is frozen. + * + * @circuitInfo k=13, rows=2556 + */ + export circuit unfreeze(nf: Bytes<32>): [] { + Authority__assertAuthority(); + Freeze__unfreeze(nf); + } + // Emission policy for one output note: the audit record derives the nonce, // the delivery makes the note discoverable. Returns the note for the core to // commit. diff --git a/contracts/src/token/test/mocks/MockRegulatedConfidentialNoteFungibleToken.compact b/contracts/src/token/test/mocks/MockRegulatedConfidentialNoteFungibleToken.compact index b4d8651ea..afe2f76f7 100644 --- a/contracts/src/token/test/mocks/MockRegulatedConfidentialNoteFungibleToken.compact +++ b/contracts/src/token/test/mocks/MockRegulatedConfidentialNoteFungibleToken.compact @@ -20,6 +20,7 @@ import { _seizureCount, _commitments, _nullifiers, + _frozen, _auditKey, _auditTrail, _deliveries, @@ -38,6 +39,7 @@ export { _seizureCount, _commitments, _nullifiers, + _frozen, _auditKey, _auditTrail, _deliveries, @@ -96,3 +98,11 @@ export circuit rotateAuditKey(newKey: JubjubPoint): [] { export circuit rotateSupplyKey(newKey: JubjubPoint, total: Uint<128>): [] { return Token_rotateSupplyKey(newKey, total); } + +export circuit freeze(nf: Bytes<32>): [] { + return Token_freeze(nf); +} + +export circuit unfreeze(nf: Bytes<32>): [] { + return Token_unfreeze(nf); +} diff --git a/contracts/src/utils/concurrency/ElGamalDeltaInbox.compact b/contracts/src/utils/concurrency/ElGamalDeltaInbox.compact new file mode 100644 index 000000000..16e54a205 --- /dev/null +++ b/contracts/src/utils/concurrency/ElGamalDeltaInbox.compact @@ -0,0 +1,178 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts (utils/concurrency/ElGamalDeltaInbox.compact) + +pragma language_version >= 0.23.0; + +/** + * @module ElGamalDeltaInbox + * @description Concurrency building block: a pending-delta inbox for + * exponential-ElGamal accumulators (encrypted supplies, encrypted balances). + * Same credit/absorb split as `UintDeltaInbox` — writers blind-insert + * ciphertext deltas keyed by their own fresh randomness (commutes with + * everything), a fold step drains up to 8 entries and returns their + * homomorphic sum for the CONSUMER to absorb into its accumulator ciphertext. + * Only the fold pins the accumulator, so the per-writer conflict of an + * in-circuit `ElGamal_addEncrypted` on a shared cell disappears. See the + * repo-root `concurrency.md` (§9.2). + * + * // hot path (e.g. per mint): commutes with everything + * Inbox__credit(domain, wit_FreshId(), ElGamal_encrypt(supplyKey, v, r)); + * // per burn: a NEGATED encryption nets as a subtraction in the exponent + * Inbox__credit(domain, wit_FreshId(), ElGamal_negate(ElGamal_encrypt(supplyKey, v, r))); + * + * // cold path (folder role, composer-gated): serializes with itself only + * _encSupply = disclose(ElGamal_add(_encSupply, Inbox__consume(domain))); + * + * Signed semantics live in the exponent group, so a single `add` folds mints + * and burns alike — and a pending entry does not reveal its direction + * (ciphertexts are uniform either way). + * + * @dev Same-key discipline: every delta for one domain MUST be encrypted + * under that accumulator's public key, or the folded ciphertext decrypts + * under no key (`crypto/ElGamal.add` note). The inbox cannot check this; the + * consumer's emission chokepoint owns it. + * + * @dev Discrete-log range: the lifted plaintext is recovered by bounded + * search, and a net below zero lands at `g^(-v)` — outside the search range. + * The consumer owns ordering/underflow policy exactly as it does for + * `ElGamal_subEncrypted`. + * + * @dev `id` MUST be fresh per credit and derived from the writer's own + * randomness witness — NEVER from a ledger read (that would pin the read and + * reintroduce the conflict this module removes). + * + * @dev Trust model of the fold: the witness picks only WHICH entries to + * drain; ciphertexts come from the ledger, existence is asserted, drained + * entries are removed. Skipping is the only misbehavior, and it is harmless + * to safety (entries stay public and drainable), measurable + * (`_pendingCounts`), and provable-absent in-circuit: a checkpoint fold + * follows `_consume` with `_assertEmpty` — the required prelude to an exact + * supply attestation (fold-to-empty, then attest, in one serialization + * point). Folding cannot corrupt value, so composers MAY leave it + * permissionless. + * + * @dev NOT audited, NOT production. + */ +module ElGamalDeltaInbox { + import CompactStandardLibrary; + import "../../crypto/ElGamal" prefix ElGamal_; + + // Pending ciphertext deltas, keyed by `entryKeyOf(domain, id)`. + export ledger _pending: Map, ElGamal_Ciphertext>; + // Per-domain backlog counts. `Counter` increments/decrements are relative + // VM ops, so maintaining the count does NOT reintroduce pinning; reading it + // (only `_assertEmpty` does) is the deliberate completeness barrier. + export ledger _pendingCounts: Map, Counter>; + + // Up to 8 pending ids to drain this call; `default>` marks an + // empty slot. Supplied by the folder's indexer. + witness wit_ElGamalInboxPendingIds(domain: Bytes<32>): Vector<8, Bytes<32>>; + + /** + * @description Registry key for one pending entry: `H(tag, domain, id)`. + * Exported so indexers and folders derive keys the way the circuits do. + */ + export pure circuit entryKeyOf(domain: Bytes<32>, id: Bytes<32>): Bytes<32> { + return persistentHash>>( + [pad(32, "OZ:concurrency:eg-inbox"), domain, id]); + } + + /** + * @description UNGATED building block: records a pending ciphertext delta. + * Reads no ledger value, so concurrent credits commute with each other and + * with a concurrent `_consume` (which only touches ALREADY-EXISTING keys). + * + * Requirements: + * + * - `id` is nonzero (the zero id is the empty-slot sentinel). + * - `(domain, id)` is not already pending. + * + * @param domain - Which accumulator this delta belongs to. + * @param id - Fresh writer-chosen entry id (from the writer's randomness). + * @param delta - The ciphertext delta (already encrypted to the + * accumulator's key; negate for subtractions). + * + * @circuitInfo k=13, rows=4598 + */ + export circuit _credit(domain: Bytes<32>, id: Bytes<32>, delta: ElGamal_Ciphertext): [] { + assert(id != default>, "ElGamalDeltaInbox: zero id"); + const entryKey = entryKeyOf(domain, id); + assert(!_pending.member(disclose(entryKey)), "ElGamalDeltaInbox: id already pending"); + _pending.insert(disclose(entryKey), disclose(delta)); + // Bootstrap the domain's counter once (the member pin is FALSE only for a + // domain's first-ever credit, then TRUE and stable forever); the increment + // itself is relative and commutes. + if (disclose(!_pendingCounts.member(disclose(domain)))) { + _pendingCounts.insertDefault(disclose(domain)); + } + _pendingCounts.lookup(disclose(domain)).increment(1); + } + + /** + * @description UNGATED building block: drains up to 8 pending entries of + * `domain` (chosen by the folder's witness) and returns their homomorphic + * sum for the consumer to absorb. Pins exactly the drained entries plus + * nothing else, so it conflicts only with another concurrent `_consume` — + * never with credits. Call repeatedly to drain a deep inbox. + * + * @param domain - Which accumulator to fold. + * @return The homomorphic sum of the drained deltas (`Enc(0)` if all slots + * are empty). + * + * @circuitInfo k=16, rows=39757 + */ + export circuit _consume(domain: Bytes<32>): ElGamal_Ciphertext { + const ids = wit_ElGamalInboxPendingIds(domain); + const d0 = consumeSlot(domain, ids[0]); + const d1 = consumeSlot(domain, ids[1]); + const d2 = consumeSlot(domain, ids[2]); + const d3 = consumeSlot(domain, ids[3]); + const d4 = consumeSlot(domain, ids[4]); + const d5 = consumeSlot(domain, ids[5]); + const d6 = consumeSlot(domain, ids[6]); + const d7 = consumeSlot(domain, ids[7]); + return ElGamal_add( + ElGamal_add(ElGamal_add(d0, d1), ElGamal_add(d2, d3)), + ElGamal_add(ElGamal_add(d4, d5), ElGamal_add(d6, d7))); + } + + // Drains one slot; the zero id is an empty slot contributing `Enc(0)` (the + // additive identity) and touching no state. The branch condition derives + // from the folder's witness, so it is disclosed (it gates ledger + // operations). + circuit consumeSlot(domain: Bytes<32>, id: Bytes<32>): ElGamal_Ciphertext { + if (disclose(id == default>)) { + return ElGamal_encryptZero(); + } + const entryKey = entryKeyOf(domain, id); + assert(_pending.member(disclose(entryKey)), "ElGamalDeltaInbox: unknown pending id"); + const delta = _pending.lookup(disclose(entryKey)); + _pending.remove(disclose(entryKey)); + _pendingCounts.lookup(disclose(domain)).decrement(1); + return delta; + } + + /** + * @description Building block: proves in-circuit that `domain` has NO + * pending entries — the completeness checkpoint. A fold that runs + * `_consume` then `_assertEmpty` in one circuit proves its witness skipped + * nothing; run it before an exact supply attestation so the attested total + * provably includes every mint and burn. + * + * @dev This read PINS the domain's count, so a checkpoint fold conflicts + * with concurrent credits — necessarily: "nothing is outstanding" is only + * meaningful at a serialization point. Keep routine folds on plain + * `_consume` (commutes with credits) and pay this barrier only where + * completeness is the claim. + * + * @circuitInfo k=9, rows=343 + */ + export circuit _assertEmpty(domain: Bytes<32>): [] { + // A domain with no counter never received a credit: trivially empty. + if (disclose(!_pendingCounts.member(disclose(domain)))) { + return; + } + assert(_pendingCounts.lookup(disclose(domain)).read() == 0, + "ElGamalDeltaInbox: entries still pending"); + } +} diff --git a/contracts/src/utils/concurrency/RevocableMembershipTree.compact b/contracts/src/utils/concurrency/RevocableMembershipTree.compact new file mode 100644 index 000000000..e6a9698cd --- /dev/null +++ b/contracts/src/utils/concurrency/RevocableMembershipTree.compact @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts (utils/concurrency/RevocableMembershipTree.compact) + +pragma language_version >= 0.23.0; + +/** + * @module RevocableMembershipTree + * @description Concurrency building block: a ZK membership set whose ADDITIONS + * do not invalidate in-flight proofs, while REMOVALS still revoke instantly. + * A plain `MerkleTree` chokepoint couples admin tempo to user liveness (its + * `checkRoot` pins the CURRENT root, so every admin write aborts every + * in-flight proof); a bare `HistoricMerkleTree` tolerates concurrent inserts + * but keeps removed members provable against old roots. This module composes + * the two semantics deliberately: membership is proven against the historic + * root set, `_add` appends (old roots stay valid — onboarding stops hurting + * users), and `_removeAt` tombstones the leaf THEN calls `resetHistory()`, so + * only the post-removal root remains valid and every stale proof dies at + * once. See the repo-root `concurrency.md` (§9.4). + * + * // admin (composer-gated) + * Members__add(leafOf(pk)); // in-flight proofs keep verifying + * Members__removeAt(index); // instant revocation, on purpose + * + * // prover chokepoint + * Members__assertMember(leafOf(pk)); // which leaf proved it stays hidden + * + * @dev Leaf derivation stays consumer-side (identity commitments are domain + * business); salt leaves where dictionary-testing the set contents matters. + * + * @dev Removal is by leaf index, trusting the composer's off-chain index + * bookkeeping (fail-closed hardening — proving the leaf at `index` matches + * the member being removed — is a known follow-up, same as the token + * allowlist's). + * + * @dev History grows with every `_add` until the next removal resets it; + * `_resetHistory` is exposed for explicit pruning windows — calling it + * invalidates every in-flight proof, so treat it as an announced operational + * action, never routine hygiene. + * + * @dev Tree depth is fixed at 16 (65k members): Compact module state is a + * per-file singleton, so the depth cannot be a consumer choice without a + * generic-module instantiation this library deliberately avoids. + * + * @dev NOT audited, NOT production. + */ +module RevocableMembershipTree { + import CompactStandardLibrary; + + // Membership leaves. Historic on purpose: proofs verify against any root + // recorded since the last removal/reset. + export ledger _members: HistoricMerkleTree<16, Bytes<32>>; + + // The prover's own membership path (fetched from the public tree). + witness wit_RevocableMembershipPath(leaf: Bytes<32>): MerkleTreePath<16, Bytes<32>>; + + /** + * @description UNGATED building block: adds a member leaf. Appends to the + * root history, so proofs built before this add KEEP verifying — concurrent + * with every in-flight `_assertMember`. The composer gates who may + * administer the set. + * + * @circuitInfo k=13, rows=2299 + */ + export circuit _add(leaf: Bytes<32>): [] { + _members.insert(disclose(leaf)); + } + + /** + * @description UNGATED building block: removes the leaf at `index` by + * overwriting it with the default leaf, then RESETS the root history so + * only the post-removal root verifies — every outstanding membership proof + * (including the removed member's) is invalidated immediately. That + * in-flight abort is the point: removal is revocation. The composer gates + * who may administer the set; see the module doc for the index-bookkeeping + * caveat. + * + * @circuitInfo k=13, rows=2086 + */ + export circuit _removeAt(index: Uint<64>): [] { + _members.insertIndexDefault(disclose(index)); + _members.resetHistory(); + } + + /** + * @description Building block: proves `leaf` is in the tree without + * revealing which position. Verifies against the historic root set, so it + * survives concurrent `_add`s and dies on any removal/reset since the + * prover fetched their path. + * + * Requirements: + * + * - `leaf` is a member at some root recorded since the last removal/reset. + * + * @circuitInfo k=13, rows=3063 + */ + export circuit _assertMember(leaf: Bytes<32>): [] { + const path = wit_RevocableMembershipPath(leaf); + assert(_members.checkRoot(disclose(merkleTreePathRoot<16, Bytes<32>>(path))), + "RevocableMembershipTree: not a member"); + assert(leaf == path.leaf, + "RevocableMembershipTree: path does not match leaf"); + } + + /** + * @description UNGATED building block: prunes the root history to the + * current root only. Maintenance valve for history growth — invalidates + * every in-flight membership proof, so gate it and schedule it. + * + * @circuitInfo k=5, rows=24 + */ + export circuit _resetHistory(): [] { + _members.resetHistory(); + } +} diff --git a/contracts/src/utils/concurrency/ShardedCounter.compact b/contracts/src/utils/concurrency/ShardedCounter.compact new file mode 100644 index 000000000..faa6b5131 --- /dev/null +++ b/contracts/src/utils/concurrency/ShardedCounter.compact @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts (utils/concurrency/ShardedCounter.compact) + +pragma language_version >= 0.23.0; + +/** + * @module ShardedCounter + * @description Concurrency building block: an add-only `Uint<128>` total + * split across shards, for high-write counters that the kernel `Counter` + * cannot carry (its increment is capped at `Uint<16>` per call). Each `_add` + * is a read-modify-write of ONE shard cell, so two concurrent writers + * conflict only when they pick the same shard — a 1/N collision instead of + * certainty. Probabilistic, not eliminative: prefer a delta inbox + * (`UintDeltaInbox`) when a folder role is acceptable; sharding is for + * folderless deployments. See the repo-root `concurrency.md` (§9.3). + * + * // writer: shard chosen from the writer's OWN randomness + * const shard = (wit_Randomness() ...) % 8; + * Sharded__add(domain, shard as Uint<8>, amount); + * + * The total is the sum over shards: indexers read the map off-chain; a + * circuit needing the exact total must look up every shard it trusts to be + * populated, pinning them all (such a reader conflicts with every concurrent + * writer — same as before sharding, which is why exact-total reads should be + * rare or move to an inbox design). + * + * @dev The shard index MUST derive from the writer's own randomness witness — + * NEVER from a ledger read (that would pin the read and defeat the shape). + * The consumer fixes the shard-space size by bounding the index it passes + * (e.g. `% 8`); smaller spaces mean fewer cells to sum but more collisions. + * + * @dev Add-only on purpose: a subtraction can underflow one shard even when + * the cross-shard total is sufficient, so decrements need global knowledge + * that sharding is designed to avoid. Track decreasing quantities as a second + * domain (`minted` / `burned`) and net off-chain or at a fold point. + * + * @dev NOT audited, NOT production. + */ +module ShardedCounter { + import CompactStandardLibrary; + + // Shard-key hash preimage (domain-separated). + struct ShardKeyPreimage { + tag: Bytes<32>; + domain: Bytes<32>; + shard: Uint<8>; + } + + // Shard cells, keyed by `shardKeyOf(domain, shard)`. + export ledger _shards: Map, Uint<128>>; + + /** + * @description Registry key for one shard: `H(tag, domain, shard)`. + * Exported so indexers sum totals the way the circuits write them. + */ + export pure circuit shardKeyOf(domain: Bytes<32>, shard: Uint<8>): Bytes<32> { + return persistentHash(ShardKeyPreimage { + tag: pad(32, "OZ:concurrency:shard"), + domain: domain, + shard: shard + }); + } + + /** + * @description UNGATED building block: adds `amount` to one shard of + * `domain`'s total. Pins only the chosen shard cell. + * + * Requirements: + * + * - The shard's running total does not overflow `Uint<128>`. + * + * @param domain - Which total to add to. + * @param shard - Writer-chosen shard index (from the writer's randomness). + * @param amount - The amount to add. + * + * @circuitInfo k=13, rows=4634 + */ + export circuit _add(domain: Bytes<32>, shard: Uint<8>, amount: Uint<128>): [] { + const shardKey = shardKeyOf(domain, shard); + const current = shardValue(shardKey); + const MAX_UINT128 = 340282366920938463463374607431768211455; + assert(MAX_UINT128 - current >= amount, "ShardedCounter: arithmetic overflow"); + _shards.insert(disclose(shardKey), disclose((current + amount) as Uint<128>)); + } + + /** + * @description Reads one shard's running total (0 if never written). A + * circuit summing shards for an exact total pins every shard it reads. + * + * @circuitInfo k=13, rows=4261 + */ + export circuit _shardTotal(domain: Bytes<32>, shard: Uint<8>): Uint<128> { + return shardValue(shardKeyOf(domain, shard)); + } + + // Missing shard reads as zero. + circuit shardValue(shardKey: Bytes<32>): Uint<128> { + if (!_shards.member(disclose(shardKey))) { + return 0; + } + return _shards.lookup(disclose(shardKey)); + } +} diff --git a/contracts/src/utils/concurrency/UintDeltaInbox.compact b/contracts/src/utils/concurrency/UintDeltaInbox.compact new file mode 100644 index 000000000..2dc1f2a6e --- /dev/null +++ b/contracts/src/utils/concurrency/UintDeltaInbox.compact @@ -0,0 +1,200 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts (utils/concurrency/UintDeltaInbox.compact) + +pragma language_version >= 0.23.0; + +/** + * @module UintDeltaInbox + * @description Concurrency building block: a pending-delta inbox for `Uint` + * accumulators. Splits a hot read-modify-write cell into *credit* (hot, must + * commute) and *absorb* (cold, may serialize): writers blind-insert deltas + * into a map keyed by their own fresh randomness — no ledger value is read, + * so concurrent credits NEVER conflict with each other or with a concurrent + * consume — and a fold step drains up to `8` entries per call, returning the + * netted delta for the CONSUMER to absorb into its own accumulator cell. + * Only the fold conflicts, and only with itself. See the repo-root + * `concurrency.md` (§9.2) for the conflict model and why this shape commutes. + * + * // hot path (e.g. per mint): commutes with everything + * Inbox__credit(domain, wit_FreshId(), UintInbox_Delta { add: v, sub: 0 }); + * + * // cold path (folder role, composer-gated): serializes with itself only + * const net = Inbox__consume(domain); + * assert(_total + net.add >= net.sub, "underflow"); + * _total = disclose(((_total + net.add) - net.sub) as Uint<128>); + * + * One module instance serves many accumulators in one contract: entries are + * keyed by `(domain, id)`, so consumers separate concerns with domain tags + * (module ledger state is a singleton per module file, so double-importing + * does NOT create a second inbox). + * + * @dev `id` MUST be fresh per credit and derived from the writer's own + * randomness witness — NEVER from a ledger read (that would pin the read and + * reintroduce the conflict this module removes). A reused `(domain, id)` is + * rejected rather than silently overwritten. + * + * @dev Trust model of the fold: the witness picks only WHICH entries to + * drain (ids); amounts come from the ledger, existence is asserted, and + * drained entries are removed — a lying witness can never invent, alter, or + * double-count value. The one thing it could do is SKIP entries (delay their + * absorption), and that is (a) harmless to safety — skipped deltas stay in + * `_pending`, public and forever drainable, (b) measurable — `_pendingCounts` + * tracks each domain's backlog on-chain, and (c) PROVABLE-ABSENT in-circuit — + * a checkpoint fold follows `_consume` with `_assertEmpty`, which fails + * unless every pending entry of the domain was drained: + * + * const net = Inbox__consume(domain); + * Inbox__assertEmpty(domain); // in-circuit proof: nothing was skipped + * + * Because the fold cannot corrupt value, composers MAY leave it + * permissionless: then a skipping indexer is routed around by any honest + * party, and censorship requires all folders to collude. + * + * @dev Inbox growth is bounded by folding cadence; entries and backlog + * counts are visible to indexers via the generated ledger reader. + * + * @dev NOT audited, NOT production. + */ +module UintDeltaInbox { + import CompactStandardLibrary; + + // One pending delta. Both directions are carried so writers need no + // in-circuit branch: a credit sets one side and zeroes the other. + export struct Delta { + add: Uint<128>; + sub: Uint<128>; + } + + // The netted result of one consume batch. The consumer applies + // `acc + add - sub` with its own bounds checks. + export struct Net { + add: Uint<128>; + sub: Uint<128>; + } + + // Pending deltas, keyed by `entryKeyOf(domain, id)`. + export ledger _pending: Map, Delta>; + // Per-domain backlog counts. `Counter` increments/decrements are relative + // VM ops, so maintaining the count does NOT reintroduce pinning; reading it + // (only `_assertEmpty` does) is the deliberate completeness barrier. + export ledger _pendingCounts: Map, Counter>; + + // Up to 8 pending ids to drain this call; `default>` marks an + // empty slot. Supplied by the folder's indexer. + witness wit_UintInboxPendingIds(domain: Bytes<32>): Vector<8, Bytes<32>>; + + /** + * @description Registry key for one pending entry: `H(tag, domain, id)`. + * Exported so indexers and folders derive keys the way the circuits do. + */ + export pure circuit entryKeyOf(domain: Bytes<32>, id: Bytes<32>): Bytes<32> { + return persistentHash>>( + [pad(32, "OZ:concurrency:uint-inbox"), domain, id]); + } + + /** + * @description UNGATED building block: records a pending delta. Reads no + * ledger value, so concurrent credits commute with each other and with a + * concurrent `_consume` (which only touches ALREADY-EXISTING keys). + * + * Requirements: + * + * - `id` is nonzero (the zero id is the empty-slot sentinel). + * - `(domain, id)` is not already pending. + * + * @param domain - Which accumulator this delta belongs to. + * @param id - Fresh writer-chosen entry id (from the writer's randomness). + * @param delta - The delta to record. + * + * @circuitInfo k=13, rows=4829 + */ + export circuit _credit(domain: Bytes<32>, id: Bytes<32>, delta: Delta): [] { + assert(id != default>, "UintDeltaInbox: zero id"); + const entryKey = entryKeyOf(domain, id); + assert(!_pending.member(disclose(entryKey)), "UintDeltaInbox: id already pending"); + _pending.insert(disclose(entryKey), disclose(delta)); + // Bootstrap the domain's counter once (the member pin is FALSE only for a + // domain's first-ever credit, then TRUE and stable forever); the increment + // itself is relative and commutes. + if (disclose(!_pendingCounts.member(disclose(domain)))) { + _pendingCounts.insertDefault(disclose(domain)); + } + _pendingCounts.lookup(disclose(domain)).increment(1); + } + + /** + * @description UNGATED building block: drains up to 8 pending entries of + * `domain` (chosen by the folder's witness) and returns their netted sums + * for the consumer to absorb. Pins exactly the drained entries plus nothing + * else, so it conflicts only with another concurrent `_consume` — never + * with credits. Call repeatedly to drain a deep inbox. + * + * @param domain - Which accumulator to fold. + * @return The summed `add` and `sub` across the drained entries. + * + * @circuitInfo k=16, rows=38139 + */ + export circuit _consume(domain: Bytes<32>): Net { + const ids = wit_UintInboxPendingIds(domain); + const d0 = consumeSlot(domain, ids[0]); + const d1 = consumeSlot(domain, ids[1]); + const d2 = consumeSlot(domain, ids[2]); + const d3 = consumeSlot(domain, ids[3]); + const d4 = consumeSlot(domain, ids[4]); + const d5 = consumeSlot(domain, ids[5]); + const d6 = consumeSlot(domain, ids[6]); + const d7 = consumeSlot(domain, ids[7]); + return Net { + add: addCapped(addCapped(addCapped(d0.add, d1.add), addCapped(d2.add, d3.add)), + addCapped(addCapped(d4.add, d5.add), addCapped(d6.add, d7.add))), + sub: addCapped(addCapped(addCapped(d0.sub, d1.sub), addCapped(d2.sub, d3.sub)), + addCapped(addCapped(d4.sub, d5.sub), addCapped(d6.sub, d7.sub))) + }; + } + + // Drains one slot; the zero id is an empty slot contributing nothing and + // touching no state. The branch condition derives from the folder's witness, + // so it is disclosed (it gates ledger operations). + circuit consumeSlot(domain: Bytes<32>, id: Bytes<32>): Delta { + if (disclose(id == default>)) { + return Delta { add: 0, sub: 0 }; + } + const entryKey = entryKeyOf(domain, id); + assert(_pending.member(disclose(entryKey)), "UintDeltaInbox: unknown pending id"); + const delta = _pending.lookup(disclose(entryKey)); + _pending.remove(disclose(entryKey)); + _pendingCounts.lookup(disclose(domain)).decrement(1); + return delta; + } + + /** + * @description Building block: proves in-circuit that `domain` has NO + * pending entries — the completeness checkpoint. A fold that runs + * `_consume` then `_assertEmpty` in one circuit proves its witness skipped + * nothing; an exact-total consumer (attestation, cap check) composes it the + * same way before trusting the folded value. + * + * @dev This read PINS the domain's count, so a checkpoint fold conflicts + * with concurrent credits — necessarily: "nothing is outstanding" is only + * meaningful at a serialization point. Keep routine folds on plain + * `_consume` (commutes with credits) and pay this barrier only where + * completeness is the claim. + * + * @circuitInfo k=9, rows=340 + */ + export circuit _assertEmpty(domain: Bytes<32>): [] { + // A domain with no counter never received a credit: trivially empty. + if (disclose(!_pendingCounts.member(disclose(domain)))) { + return; + } + assert(_pendingCounts.lookup(disclose(domain)).read() == 0, + "UintDeltaInbox: entries still pending"); + } + + // Overflow-guarded Uint<128> addition. + circuit addCapped(a: Uint<128>, b: Uint<128>): Uint<128> { + const MAX_UINT128 = 340282366920938463463374607431768211455; + assert(MAX_UINT128 - a >= b, "UintDeltaInbox: arithmetic overflow"); + return (a + b) as Uint<128>; + } +} diff --git a/contracts/src/utils/concurrency/docs/elgamal-delta-inbox.md b/contracts/src/utils/concurrency/docs/elgamal-delta-inbox.md new file mode 100644 index 000000000..39b332a30 --- /dev/null +++ b/contracts/src/utils/concurrency/docs/elgamal-delta-inbox.md @@ -0,0 +1,124 @@ +# ElGamalDeltaInbox — design doc + +> **Status:** draft (2026-07-24), destination Notion. Module: [`utils/concurrency/ElGamalDeltaInbox.compact`](../ElGamalDeltaInbox.compact). Conflict-model background: repo-root [`concurrency.md`](../../../../../concurrency.md) (§8.1 the hotspot, §9.2 the pattern). Sibling: [`uint-delta-inbox.md`](./uint-delta-inbox.md) (same architecture, plaintext payload). + +## 1. Summary + +A **pending-delta inbox for exponential-ElGamal accumulators** — encrypted supplies and encrypted balances. Writers blind-insert *ciphertext* deltas keyed by their own randomness (commutes with everything); a fold drains up to 8 entries and returns their homomorphic sum for the consumer to absorb into its accumulator ciphertext. Burns push a **negated encryption**, so signed semantics live in the exponent: one `ElGamal_add` folds mints and burns alike, and a pending entry does not reveal its direction. A per-domain `Counter` backlog plus `_assertEmpty` make a checkpoint fold provably complete — the required prelude to an exact supply attestation. + +## 2. Motivation + +An encrypted accumulator is the *worst* pinning case: the homomorphic update is elliptic-curve math, which cannot run in the Impact VM, so the old ciphertext must be read into the circuit — pinned — on **every** update. In the note token's `PrivateSupply`, every mint/burn is `_encSupply = add/sub(_encSupply, …)`: all mints and burns mutually conflict, one landing per retry cycle. The account-model `ConfidentialFungibleToken` has the same shape per recipient balance cell (its design doc names the pull-inbox as the v2 direction — this module is that mechanism). The inbox removes the ciphertext read from the hot path; only the fold touches the accumulator. + +## 3. Specification + +```typescript +/** Pending ciphertext deltas, keyed by entryKeyOf(domain, id). */ +export ledger _pending: Map, ElGamal_Ciphertext>; +/** Per-domain backlog counts (relative Counter ops — pin nothing). */ +export ledger _pendingCounts: Map, Counter>; + +/** Up to 8 pending ids to drain; zero = empty slot. */ +witness wit_ElGamalInboxPendingIds(domain: Bytes<32>): Vector<8, Bytes<32>>; + +export pure circuit entryKeyOf(domain: Bytes<32>, id: Bytes<32>): Bytes<32>; + +/** + * @description Records a pending ciphertext delta (already encrypted to the + * accumulator's key; negate for subtractions). Commutes with everything. + */ +export circuit _credit(domain: Bytes<32>, id: Bytes<32>, delta: ElGamal_Ciphertext): []; + +/** + * @description Drains up to 8 witness-chosen entries; returns their + * homomorphic sum (Enc(0) if all slots empty). Conflicts only with another + * consume. + */ +export circuit _consume(domain: Bytes<32>): ElGamal_Ciphertext; + +/** @description Completeness checkpoint: proves the domain's inbox is empty. */ +export circuit _assertEmpty(domain: Bytes<32>): []; +``` + +## 4. Example flow — the shipped consumer + +`token/extensions/ConfidentialNoteFungibleTokenConcurrentSupply` (with the `ConcurrentConfidentialNoteFungibleToken` demo preset) wires it end to end: + +```typescript +// hot path: per mint / per burn — commutes with everything +export circuit _addMinted(value: Uint<128>): [] { + const seed = wit_ConcurrentSupplyRandomness(); + const r = ElGamal_expandRandomness(seed, pad(32, "OZ:cnt:csupply:add")); + Inbox__credit(supplyDomain(), creditIdOf(seed, ...), ElGamal_encrypt(_supplyKey, value, r)); +} +export circuit _addBurned(value: Uint<128>): [] { + // NEGATED encryption: nets as a subtraction in the exponent + Inbox__credit(supplyDomain(), ..., ElGamal_negate(ElGamal_encrypt(_supplyKey, value, r))); +} + +// cold path: permissionless folder +export circuit _foldSupply(): [] { + _encSupply = disclose(ElGamal_add(_encSupply, Inbox__consume(supplyDomain()))); +} + +// checkpoint: the attested total PROVABLY includes every mint and burn +export circuit attestSupply(total: Uint<128>): [] { + Inbox__assertEmpty(supplyDomain()); + ElGamal_assertDecryptsTo(_encSupply, _supplyKey, wit_ConcurrentSupplyKeySecret(), total); + _attestedSupply = disclose(total); +} +``` + +Worked example (burn-heavy batch): folded supply `Enc(500)`; eight entries land concurrently — mints +50, +30 and burns −100, −40, −60, −20, −80, −10. All commute. One fold: `Enc(500 + 80 − 310) = Enc(270)`; attest → 270. Draining a burn *before* its offsetting mint leaves a transient `Enc(negative)` — a valid group element, order-insensitive, and **unattestable** until the mint folds (`_assertEmpty` blocks while it is pending), so the transient state is unobservable through any proof-backed output. + +## 5. When to use it + +| Use case | Why it fits | +| --- | --- | +| Confidential supply under concurrent mint/burn (note token) | shipped: mint 27.5k / burn 41k rows, mutually commuting | +| Encrypted per-account credit inboxes (CFT v2 "pull-inbox" direction) | recipient folds own inbox on next spend; kills the inbound-payment hotspot | +| Any Enc-under-one-key running total with many writers | deltas are order-independent in the exponent group | + +## 6. When NOT to use it + +| Anti-case | Why it fails | +| --- | --- | +| **AMM reserves / swaps** | swap output = f(reserves): order sets price, swaps don't commute semantically; the fix is a batch auction, not an inbox (`concurrency.md` §8.5) | +| Deltas computed from the accumulator's current value | reading it re-pins the hot path | +| Aggregating deltas under different keys | homomorphic sum only decrypts if every delta shares the accumulator's key — the inbox cannot check this; the consumer's emission chokepoint owns it | +| Public totals | use `UintDeltaInbox` — same guarantees, cheaper, no discrete-log bound | +| Debit-side balance checks | a spend needs the current plaintext balance in-circuit; inboxes defer credits, never debits | +| Values outside the discrete-log recovery range | lifted ElGamal is only readable for bounded totals; a mis-wired net below zero is permanently unattestable | + +## 7. Trust model & security considerations + +- Fold witness: chooses WHICH entries; ciphertexts come from the ledger; can't invent/alter/double-count. Skipping = liveness only — public, measurable (`_pendingCounts`), and provable-absent at checkpoints (`_assertEmpty`). Folding is safely permissionless. +- **Same-key discipline** is the load-bearing consumer obligation (see `crypto/ElGamal.add`): one foreign-key delta makes the folded ciphertext undecryptable under any key. +- **Randomness**: `id` and encryption randomness derive from one fresh witness seed per credit; a repeated seed leaks plaintext relations AND collides the id (credit rejected). +- Checkpoint barrier is inherent: `_assertEmpty` pins the count, so exact attestations serialize against credits — fold-to-empty in a quiet moment, then attest. +- Mis-wiring (a credit without the matching token op) permanently corrupts the accumulator — same class as PrivateSupply's warning; pair 1:1 in one circuit. + +## 8. Costs (measured, compiler 0.31.1) + +| Circuit | k | rows | +| --- | --- | --- | +| `_credit` (bare; caller's `encrypt` ≈ +2.4k, `negate` ≈ +1.1k) | 13 | 4 598 | +| `_consume` (8 slots) | 16 | 39 757 | +| `_assertEmpty` | 9 | 343 | + +Consumer-level (Concurrent preset): mint 27 464, burn 40 945, `foldSupply` 39 462, `attestSupply` 4 694 — vs. the Regulated preset where every mint∥mint is a certain rejection. + +## 9. Risks & open questions + +- **P2 — attestation freshness**: an attest only speaks for its serialization point; between checkpoints the public total is stale by the (visible) backlog. +- **P2 — fixed batch size (8)** / **P3 — id discipline**: as in the Uint sibling. +- **P3 — direction privacy**: entries hide mint-vs-burn, but transaction *shape* (which circuit ran) may still distinguish them at the consumer level. + +## 10. Implementation status + +| Component | Status | +| --- | --- | +| Module | implemented; skip-zk + full keygen verified | +| First consumer | `…ConcurrentSupply` extension + `ConcurrentConfidentialNoteFungibleToken` demo preset (PrivateSupply untouched) | +| Tests / simulator | not yet (design phase) | +| Audit | not started; DRAFT, not production | diff --git a/contracts/src/utils/concurrency/docs/revocable-membership-tree.md b/contracts/src/utils/concurrency/docs/revocable-membership-tree.md new file mode 100644 index 000000000..9906f8d5c --- /dev/null +++ b/contracts/src/utils/concurrency/docs/revocable-membership-tree.md @@ -0,0 +1,124 @@ +# RevocableMembershipTree — design doc + +> **Status:** draft (2026-07-24), destination Notion. Module: [`utils/concurrency/RevocableMembershipTree.compact`](../RevocableMembershipTree.compact). Conflict-model background: repo-root [`concurrency.md`](../../../../../concurrency.md) (§5.3 the two `checkRoot`s, §9.4 the pattern). + +## 1. Summary + +A **ZK membership set whose additions never invalidate in-flight proofs, while removals still revoke instantly**. Membership is proven against a `HistoricMerkleTree`'s root *history*; `_add` appends (old roots stay valid), and `_removeAt` tombstones the leaf **then calls `resetHistory()`**, so only the post-removal root verifies and every stale proof dies at once. It composes the two tree semantics deliberately, where each is wanted. + +## 2. Motivation + +A membership check a hidden party proves in-circuit (a KYC allowlist at a spend chokepoint) pins a boolean derived from the tree root. The two stock trees each get one half right: + +- **Plain `MerkleTree`**: `checkRoot` pins `currentRoot == r` — ANY write changes the root, so **every admin add aborts every in-flight member proof**. Onboarding one user rejects every KYC-proven spend in flight. Revocation, however, is instant. +- **Bare `HistoricMerkleTree`**: `checkRoot` pins `r ∈ history`, and inserts only append — in-flight proofs survive adds. But a *removed* member's old proofs also survive: history keeps every root they were valid under. No revocation. + +The note token's Allowlist extension currently uses the plain tree and inherits the liveness coupling (its doc flags it). This module is the replacement shape. + +## 3. Specification + +```typescript +/** Membership leaves. Historic on purpose: proofs verify against any root + * recorded since the last removal/reset. */ +export ledger _members: HistoricMerkleTree<16, Bytes<32>>; + +/** The prover's own membership path (fetched from the public tree). */ +witness wit_RevocableMembershipPath(leaf: Bytes<32>): MerkleTreePath<16, Bytes<32>>; + +/** + * @description Adds a member leaf. Appends to root history: proofs built + * before this add KEEP verifying — concurrent with every in-flight + * _assertMember. Composer gates admin. + */ +export circuit _add(leaf: Bytes<32>): []; + +/** + * @description Removes the leaf at `index` (default-leaf tombstone), then + * RESETS the root history — every outstanding proof, including the removed + * member's, is invalidated immediately. That abort IS the feature. + */ +export circuit _removeAt(index: Uint<64>): []; + +/** + * @description Proves `leaf` is in the tree without revealing which + * position. Survives concurrent _add; dies on any removal/reset since the + * prover fetched their path. + */ +export circuit _assertMember(leaf: Bytes<32>): []; + +/** + * @description Maintenance valve: prunes history to the current root only. + * Invalidates every in-flight proof — gate it, announce it, schedule it. + */ +export circuit _resetHistory(): []; +``` + +Leaf derivation stays consumer-side (identity commitments are domain business); tree depth is fixed at 16 (~65k members) because module state is a per-file singleton and cannot be depth-parameterized per consumer. + +## 4. Example flow + +```typescript +// consumer's leaf policy (salt if dictionary-testing the set matters) +export pure circuit leafOf(pk: Field): Bytes<32> { return persistentHash<...>(...); } + +// admin (composer-gated) +export circuit addAllowed(pk: Field): [] { Members__add(leafOf(pk)); } +export circuit removeAllowed(index: Uint<64>): [] { Members__removeAt(index); } + +// spend chokepoint: hidden spender proves membership +export circuit transfer(recipientPk: Field, ...): [] { + Members__assertMember(leafOf(Core__spenderPk())); + Members__assertMember(leafOf(recipientPk)); + // ... +} +``` + +Timeline: ten users' transfers are in flight when the admin onboards an eleventh — all ten land (the old root is still in history). Later the admin removes a sanctioned member — history resets, the removed member's (and everyone's) outstanding proofs abort, users re-fetch paths once, and the removed member can never prove again. One-time churn exactly when churn is the point. + +## 5. When to use it + +| Use case | Why it fits | +| --- | --- | +| KYC allowlists proven at spend time by hidden parties | adds stop hurting user liveness; removals keep instant-revocation semantics | +| Role registries proven in ZK ("some approved reviewer/relayer", never which) | same add-heavy, remove-rare churn profile | +| Any in-circuit membership where additions vastly outnumber removals | the reset cost is paid only on the rare event that *should* be disruptive | + +## 6. When NOT to use it + +| Anti-case | Why it fails | +| --- | --- | +| Membership checked on **disclosed** identities | a plain `Set.member` is cheaper and per-key concurrent; ZK trees are for HIDDEN provers | +| **Deny-lists / exclusion sets** | this proves membership, not non-membership; exclusion is a different structure (per-key `Set` pins like the Freeze extension, or a sparse-tree non-inclusion design) | +| Remove-heavy sets | every removal is a global proof-invalidation event plus a wallet path-refetch storm; at high removal rates the set is effectively a plain tree | +| Sets larger than ~65k | depth is fixed at 16 (module-state singleton; no per-consumer generic) | +| **AMM-style order-dependent state** | not a membership problem at all; see `concurrency.md` §8.5 | + +## 7. Trust model & security considerations + +- **Index-bookkeeping trust**: `_removeAt` trusts the composer's off-chain index for which leaf to tombstone. Fail-closed hardening — proving in-circuit that the leaf at `index` matches the member being removed — is a known follow-up (shared with the token allowlist). +- **Unsalted leaves are dictionary-testable**: anyone holding a candidate identity can test membership against public inserts. Spend-time anonymity within the set is unaffected; salting is the consumer's call in `leafOf`. +- **History growth between removals** is unbounded (contract trees get no protocol pruning); `_resetHistory` is the valve, and it is deliberately disruptive — an announced operational window, never routine hygiene. +- Removal takes effect at *landing* order: an in-flight spend racing the removal is decided by the sequencer; the pin guarantees the loser fails rather than sneaking through. + +## 8. Costs (measured, compiler 0.31.1) + +| Circuit | k | rows | +| --- | --- | --- | +| `_add` | 13 | 2 299 | +| `_removeAt` | 13 | 2 086 | +| `_assertMember` | 13 | 3 063 | +| `_resetHistory` | 5 | 24 | + +## 9. Risks & open questions + +- **P2 — path-refetch UX after removals**: every wallet must re-fetch its path; indexer support and a "your proof went stale" error path are wallet-side work. +- **P2 — re-wiring the token Allowlist**: the note token's Allowlist extension should migrate to this module (its plain tree has the add-vs-spend liveness coupling); queued in the token doc's TODO. +- **P3 — depth parameterization**: a generic depth needs per-instantiation module state, which Compact does not offer; revisit if the platform adds it. + +## 10. Implementation status + +| Component | Status | +| --- | --- | +| Module | implemented; skip-zk + full keygen verified | +| Tests / consumer migration (token Allowlist) | not yet (design phase) | +| Audit | not started; DRAFT, not production | diff --git a/contracts/src/utils/concurrency/docs/sharded-counter.md b/contracts/src/utils/concurrency/docs/sharded-counter.md new file mode 100644 index 000000000..7fc7e754a --- /dev/null +++ b/contracts/src/utils/concurrency/docs/sharded-counter.md @@ -0,0 +1,103 @@ +# ShardedCounter — design doc + +> **Status:** draft (2026-07-24), destination Notion. Module: [`utils/concurrency/ShardedCounter.compact`](../ShardedCounter.compact). Conflict-model background: repo-root [`concurrency.md`](../../../../../concurrency.md) (§9.3). Siblings: the delta inboxes (preferred when a folder role is acceptable). + +## 1. Summary + +An **add-only `Uint<128>` total split across shards**. Each `_add` is a read-modify-write of ONE shard cell chosen from the writer's own randomness, so two concurrent writers conflict only on a shard collision (~1/N per pair) instead of with certainty. No folder role, no witness, no barrier — the trade is that contention is *reduced probabilistically*, not eliminated, and the exact total is a sum over shards. + +## 2. Motivation + +Two gaps this fills: + +- The kernel `Counter` commutes perfectly (relative `addi`, nothing pinned) but is capped: `Uint<64>` value, `Uint<16>` per increment. Token-scale amounts don't fit. +- The delta inboxes eliminate contention but require a fold role and cadence. Some deployments want *no moving parts*: write-and-forget totals whose exact value is only ever read off-chain. + +`ShardedCounter` sits between: `Uint<128>` amounts, zero operational duties, contention reduced by the shard fan-out. + +## 3. Specification + +```typescript +/** Shard cells, keyed by shardKeyOf(domain, shard). */ +export ledger _shards: Map, Uint<128>>; + +/** Registry key for one shard: H(tag, domain, shard). Exported so indexers + * sum totals the way the circuits write them. */ +export pure circuit shardKeyOf(domain: Bytes<32>, shard: Uint<8>): Bytes<32>; + +/** + * @description Adds `amount` to one shard of `domain`'s total. Pins only the + * chosen shard cell. `shard` MUST derive from the writer's own randomness. + */ +export circuit _add(domain: Bytes<32>, shard: Uint<8>, amount: Uint<128>): []; + +/** @description Reads one shard (0 if never written). Pins that shard. */ +export circuit _shardTotal(domain: Bytes<32>, shard: Uint<8>): Uint<128>; +``` + +The consumer fixes the shard-space size by bounding the index it passes (e.g. derive `shard = randomness mod 8`): fewer shards = fewer cells to sum, more collisions; more shards = the reverse. + +## 4. Example flow + +```typescript +witness wit_StatsRandomness(): Bytes<32>; + +circuit volumeDomain(): Bytes<32> { return pad(32, "MyApp:volume"); } + +export circuit recordTrade(amount: Uint<128>): [] { + // shard from the writer's OWN randomness — never from a ledger read + const shard = (degradeToTransient(persistentHash>(wit_StatsRandomness())) + as Uint<8>); // consumer bounds the space, e.g. via % 8 + Sharded__add(volumeDomain(), shard, amount); +} +// total volume = Σ shards, summed off-chain by the indexer +``` + +Eight writers land in one block: with 8 shards, most pick distinct shards and all land; the occasional pair that collides retries once. With a single cell, seven of eight would have failed. + +## 5. When to use it + +| Use case | Why it fits | +| --- | --- | +| Monotone volume metrics (total minted per color, cumulative trade volume, fee totals) | add-only, exact value read off-chain/rarely | +| High-write gauges in folderless deployments | no crank, no witness, no ops duty | +| Per-pair AMM *statistics* (cumulative volumes) once swaps are batched | stats writes stop being per-swap hot; sharding absorbs residual bursts | + +## 6. When NOT to use it + +| Anti-case | Why it fails | +| --- | --- | +| **Anything needing decrements** | a subtraction can underflow ONE shard even when the cross-shard total suffices; decrements need global knowledge sharding is designed to avoid. Track decreasing quantities as a second domain (`minted`/`burned`) and net at read time, or use a delta inbox | +| **AMM reserves / swaps** | order-dependent state AND every swap needs the exact value — both disqualifiers (`concurrency.md` §8.5) | +| Exact totals read in-circuit frequently | the reader pins every shard it sums — it conflicts with all writers, same as before sharding | +| Supply that must attest exactly | use `ElGamalDeltaInbox`/`UintDeltaInbox` + `_assertEmpty`: sharding has no completeness checkpoint | +| Small counters | the kernel `Counter` fully commutes with zero machinery — always prefer it when `Uint<64>`/`Uint<16>` bounds fit | +| Low-write cells | contention was never the problem; a plain cell is simpler | + +## 7. Trust model & security considerations + +- No witness, no folder: nothing to trust beyond the writers themselves. +- The one discipline: **shard choice must come from writer randomness**, never a ledger read (that read would pin and reintroduce the conflict) and never a fixed constant (all writers would share one shard — a hotspot with extra steps). +- Overflow is guarded per shard; the off-chain sum of ≤256 `Uint<128>` shards is the indexer's to widen. +- Probabilistic by construction: adversarial writers *can* deliberately collide shards to grief each other's transactions — but they only delay (retry), never corrupt, and they pay proving cost to do it. + +## 8. Costs (measured, compiler 0.31.1) + +| Circuit | k | rows | +| --- | --- | --- | +| `_add` | 13 | 4 634 | +| `_shardTotal` | 13 | 4 261 | + +## 9. Risks & open questions + +- **P2 — silent hotspotting**: a consumer that derives the shard from anything low-entropy (timestamp bucket, user id) recreates the hotspot; the circuit cannot detect it. +- **P3 — shard-count tuning**: 1/N collision math assumes uniform choice; the right N is workload-dependent and baked into the consumer, not the module. +- **P3 — read-side ergonomics**: no in-circuit "sum all shards" is provided on purpose (it would pin everything); if a bounded-staleness in-circuit total is ever needed, that is an inbox-with-fold design, not this module. + +## 10. Implementation status + +| Component | Status | +| --- | --- | +| Module | implemented; skip-zk + full keygen verified | +| Tests / consumer | not yet (design phase) | +| Audit | not started; DRAFT, not production | diff --git a/contracts/src/utils/concurrency/docs/uint-delta-inbox.md b/contracts/src/utils/concurrency/docs/uint-delta-inbox.md new file mode 100644 index 000000000..776c7adac --- /dev/null +++ b/contracts/src/utils/concurrency/docs/uint-delta-inbox.md @@ -0,0 +1,138 @@ +# UintDeltaInbox — design doc + +> **Status:** draft (2026-07-24), destination Notion. Module: [`utils/concurrency/UintDeltaInbox.compact`](../UintDeltaInbox.compact). Conflict-model background: repo-root [`concurrency.md`](../../../../../concurrency.md) (§3–§4 mechanics, §9.2 pattern). + +## 1. Summary + +A **pending-delta inbox** for plain `Uint<128>` accumulators. It splits a hot read-modify-write cell into *credit* (hot, commutes with everything) and *absorb* (cold, serializes only with itself): writers blind-insert `{add, sub}` deltas into a map keyed by their own fresh randomness; a fold drains up to 8 entries per call and returns the netted sums for the consumer to absorb into its own total. A per-domain `Counter` backlog plus an `_assertEmpty` circuit make the fold **provably complete** — a checkpoint fold demonstrates in-circuit that no entry was skipped. + +## 2. Motivation + +On Midnight, a transaction ships a fixed transcript; every ledger value the circuit consumed is pinned (`popeq`) and re-checked at replay. `x = x + v` on a shared cell therefore serializes all its writers: two transactions built at `x = 100` both bake "read must equal 100", and the second to land is rejected with `ReadMismatch`. Concrete hotspots in this repo: + +- `FungibleToken`: `_totalSupply = _totalSupply + value` and the recipient-balance credit `_balances.insert(to, toBal + value)` — **all inbound payments to one account conflict with each other**. +- `NativeShieldedTokenSupplyCore`: `_totalMinted.insert(domain, current + amount)` — concurrent mints of one color serialize. + +The loser pays no fees (guaranteed-segment failure) but must re-prove and wait finality again — under load, a hot cell degrades to one landed write per retry cycle. The inbox removes the shared read from the hot path entirely. + +## 3. Specification + +```typescript +/** One pending delta. Both directions carried so writers never branch. */ +export struct Delta { add: Uint<128>; sub: Uint<128>; } + +/** Netted result of one consume batch; consumer applies `acc + add - sub`. */ +export struct Net { add: Uint<128>; sub: Uint<128>; } + +/** Pending deltas, keyed by entryKeyOf(domain, id). */ +export ledger _pending: Map, Delta>; +/** Per-domain backlog counts (relative Counter ops — maintaining them pins nothing). */ +export ledger _pendingCounts: Map, Counter>; + +/** Up to 8 pending ids to drain; zero = empty slot. Supplied by the folder's indexer. */ +witness wit_UintInboxPendingIds(domain: Bytes<32>): Vector<8, Bytes<32>>; + +/** Registry key for one entry: H(tag, domain, id). */ +export pure circuit entryKeyOf(domain: Bytes<32>, id: Bytes<32>): Bytes<32>; + +/** + * @description Records a pending delta. Reads nothing shared: commutes with + * all concurrent credits and consumes. `id` MUST come from the writer's own + * randomness witness (never a ledger read); reuse is rejected. + */ +export circuit _credit(domain: Bytes<32>, id: Bytes<32>, delta: Delta): []; + +/** + * @description Drains up to 8 witness-chosen entries; returns netted sums. + * Pins only the drained entries → conflicts only with another consume. + */ +export circuit _consume(domain: Bytes<32>): Net; + +/** + * @description Completeness checkpoint: proves in-circuit the domain has NO + * pending entries. Pins the count (a deliberate barrier against credits). + */ +export circuit _assertEmpty(domain: Bytes<32>): []; +``` + +One module instance serves many accumulators (entries are `(domain, id)`-keyed); module ledger state is a per-file singleton, so double-importing does not create a second inbox. + +## 4. Example flow + +```typescript +export ledger _totalSupply: Uint<128>; // folded — only foldSupply touches it +witness wit_CreditId(): Bytes<32>; // fresh randomness per credit + +export circuit mint(to: Field, value: Uint<128>): [] { + Issuer__assertIssuer(); + // ...create the recipient's value... + Inbox__credit(supplyDomain(), wit_CreditId(), Inbox_Delta { add: value, sub: 0 }); +} + +export circuit burn(value: Uint<128>): [] { + // ...consume the caller's value... + Inbox__credit(supplyDomain(), wit_CreditId(), Inbox_Delta { add: 0, sub: value }); +} + +// permissionless: folding cannot corrupt value +export circuit foldSupply(): [] { + const net = Inbox__consume(supplyDomain()); + assert(_totalSupply + net.add >= net.sub, "underflow"); + _totalSupply = disclose(((_totalSupply + net.add) - net.sub) as Uint<128>); +} +``` + +Worked example: three mints (50, 30, 20) and a burn (40) land in ONE block — four inserts under four random keys, zero conflicts. One later fold returns `{add: 100, sub: 40}`; the consumer applies `+60` once. Under direct RMW, that block lands one operation and rejects three. + +## 5. When to use it + +| Use case | Why it fits | +| --- | --- | +| Public supply totals with many writers (mints/burns from issuer + holders) | deltas are order-independent; exact total needed only at checkpoints | +| Per-color minted/burned totals (native shielded token supply) | replaces the pinned `current + amount` map update | +| Recipient-credit inboxes for account tokens (one domain per account) | un-serializes the merchant/exchange inbound hotspot; recipient folds own inbox on next spend | +| Fee/reward accrual pots | many contributors, rare settlement | + +## 6. When NOT to use it + +| Anti-case | Why it fails | +| --- | --- | +| **AMM reserves / swaps** | a swap's output is a *function of* the reserves — order sets the price, so swaps don't commute semantically; no inbox fixes that (batch auctions do — `concurrency.md` §8.5) | +| Any delta computed FROM the accumulator's current value | same reason: reading the value re-pins it, defeating the split | +| Balances that must gate a debit atomically | the spender needs the exact current balance in the same circuit; inboxes defer *credits*, never debits | +| Values every transaction must read exactly | the fold barrier would dominate; the accumulator is then inherently serial | +| Single-writer cells | the writer's own wallet already orders its transactions; direct RMW is simpler | +| Small counters (`Uint<64>` value, `Uint<16>` steps) | the kernel `Counter` already commutes with zero machinery | + +## 7. Trust model & security considerations + +- The fold witness picks only WHICH entries to drain; amounts come from the ledger, existence is asserted, entries are removed. It can never invent, alter, or double-count value. +- Skipping is the only misbehavior: harmless to safety (entries stay public and drainable), measurable (`_pendingCounts` on-chain), and provable-absent at checkpoints (`_assertEmpty`). +- Folding cannot corrupt value → it may be **permissionless**; censorship then requires every folder to collude. +- The completeness barrier is fundamental, not incidental: "nothing outstanding" is a statement about a serialization point, so `_assertEmpty` necessarily conflicts with concurrent credits. Routine folds stay barrier-free. +- Transient ordering: draining burns before their offsetting mints can trip the consumer's underflow guard — the fold reverts harmlessly; drain roughly in arrival order or retry. + +## 8. Costs (measured, compiler 0.31.1) + +| Circuit | k | rows | +| --- | --- | --- | +| `_credit` | 13 | 4 829 | +| `_consume` (8 slots) | 16 | 38 139 | +| `_assertEmpty` | 9 | 340 | + +The hot path costs roughly what the pinned RMW it replaces cost; one fold absorbs 8 writers' contention for one mid-size proof. + +## 9. Risks & open questions + +- **P2 — inbox growth**: bounded by fold cadence; credits are gated by real economic actions, so spam equals real usage. Monitor `_pendingCounts`. +- **P2 — fixed batch size (8)**: compile-time constant (circuits cannot loop dynamically); deep backlogs need repeated folds. A native Queue ADT with stack-side append would obviate the witness entirely — a Compact feature request. +- **P3 — id discipline**: an id derived from a ledger read would re-pin the hot path; enforced by convention + docs, not by the circuit. + +## 10. Implementation status + +| Component | Status | +| --- | --- | +| Module | implemented; skip-zk + full keygen verified (scratchpad driver) | +| Tests / simulator | not yet (design phase) | +| First consumer | none yet (the ElGamal sibling has one; see its doc) | +| Audit | not started; DRAFT, not production | diff --git a/cross-contract-calls.md b/cross-contract-calls.md new file mode 100644 index 000000000..09e097e3d --- /dev/null +++ b/cross-contract-calls.md @@ -0,0 +1,532 @@ +# Cross-Contract Calls on Midnight + +> **Status:** living draft (2026-07-24), destination Notion. A general-purpose reference and discussion doc for cross-contract calls: the opening sections describe the feature as it exists today, and *Open questions* collects the design questions worth working through. Add new questions there rather than forking new docs. +> +> **Verification.** Every mechanics claim is tagged with a clickable marker like [\[1\]](#ref-1) that jumps to *References*, which deep-links to the exact lines on GitHub at the pinned commits: [`LFDT-Minokawa/compact`](https://github.com/LFDT-Minokawa/compact) @ `c06961e` and [`midnightntwrk/midnight-ledger`](https://github.com/midnightntwrk/midnight-ledger) @ `e1edad2`. Code marked *(verbatim)* is copied from examples the toolchain's own e2e suite compiles; code marked *(sketch)* is ours and not yet compiled. The *Prior art: zk stacks* comparison was verified against each platform's official documentation on 2026-07-23 (refs [\[62\]](#ref-62)–[\[65\]](#ref-65)). + +# Summary + +A **cross-contract call (C2C)** is one deployed contract's circuit invoking a circuit on another deployed contract, inside a single transaction. The feature is merged in the Compact toolchain under [CoIP-0002 "Contract Types, Values, and Calls"](https://github.com/LFDT-Minokawa/compact/blob/main/coips/coip-0002.md), introduced at toolchain 0.32.105 (language 0.24.102), and ships in the current RC matrix: compactc [0.33.0-rc.2](https://github.com/LFDT-Minokawa/compact/releases/tag/compactc-v0.33.0-rc.2), runtime 0.18.0-rc.1, ledger 9.1.0.0-rc.3 (JS package numbering; the Rust `midnight-ledger` repo is at 8.2.0-rc.1), node [2.0.0-rc.4](https://github.com/midnightntwrk/midnight-node/releases/tag/node-2.0.0-rc.4), midnight-js 5.0.0-beta.4. + +Be precise about what shipped: this is **stage one of multi-contract systems**. Interfaces are compile-time types, a DApp binds one implementation per contract type, callees must have no private state, and the call graph must form a forest. Dynamic implementation discovery is explicitly deferred to a future CoIP [\[1\]](#ref-1). What is dynamic today is the **instance**: a circuit can receive a contract reference as a parameter and call whatever deployment the transaction supplies. + +That dynamism is where the main design question lands: whether a DApp should be able to **restrict the callable contracts to a vetted set** rather than dispatch to arbitrary addresses. Q1 (in *Open questions*) works through what exists today, how EVM and other zk stacks handle the same question, and the trade-offs involved. + +Primary design overview from the Midnight team: [Cross-contract calls on Midnight — how they work](https://docs.google.com/document/d/1oJlQ3izG7GqZ9gOOZSpFNjf20oGKsx8YPldtxKkYQ-Q/edit?usp=sharing) ([announcement thread](https://openzeppelin.slack.com/archives/C0A94G0PS64/p1784042986745349)). + +# Concepts, from the sources + +The terms this doc relies on, defined by the primary sources rather than restated. Quoted text is verbatim. (C2C, CCC, and "cross-contract call" name the same thing: the Midnight team's docs write CCC, this doc writes C2C.) + +- **Cross-contract call (C2C / CCC).** "A cross-contract call (CCC) is one deployed contract's circuit calling a circuit on **another** deployed contract, inside a single transaction. The result is a **call tree**: the root circuit and every callee it invokes, proven and applied atomically." — Midnight team design doc [\[67\]](#ref-67). In language terms: "Cross-contract calls: `reference.circuit(args...)` invokes a circuit named in the reference's type." — Compact changelog [\[1\]](#ref-1). +- **Contract type.** "Compact programs can specify a collection of circuit signatures (that is, their names, parameter types, and return types) to describe other contracts on which they depend." — CoIP-0002 [\[66\]](#ref-66). "A contract type is a regular program-defined Compact type, just like a structure type or enumeration type." — language reference [\[2\]](#ref-2). +- **Contract reference (contract value).** "A reference is introduced from application code by passing a deployed contract's address where a value of the contract type is expected." — Compact changelog [\[1\]](#ref-1). And the inverse does not exist in-language: "No mechanism is provided within the Compact language to *create* values with contract types." — language reference [\[11\]](#ref-11). +- **`contract implements C;`.** "A contract implements a contract type whenever it exports a matching circuit for each one the contract type declares — but when the assertion is present the compiler verifies it and rejects the contract at compile time if any required circuit is missing or has a non-matching signature." — Compact changelog [\[1\]](#ref-1). +- **Static vs dynamic C2C.** "Dynamic C2C" is **not a term defined in Midnight documentation**; it is the team's working name (Slack, July 2026) for calls whose callee is chosen at runtime — in today's language, exactly the contract-typed circuit parameter (see *Holding a callee reference*). The clearest published definition of the distinction is Leo's: "Static calls require the callee program to be known at compile time … Dynamic calls allow the callee to be determined at runtime." [\[62\]](#ref-62). The sourced anchor for Midnight's future work in this direction: "Later proposals will address dynamic discovery of contract implementation code and management of private state across contracts." — CoIP-0002 [\[66\]](#ref-66). +- **Call tree / call forest.** "The result is a **call tree**: the root circuit and every callee it invokes" [\[67\]](#ref-67); the ledger requires a transaction's call graph to be "a forest (no cycles, no multiple parents)" — ledger source comment [\[68\]](#ref-68). +- **Communication commitment.** "A communication commitment, which commits to the inputs and outputs of the circuit being called." — ledger spec [\[42\]](#ref-42). +- **Entry point.** The name under which a callee circuit's verifier key is stored and looked up on-chain; the ledger resolves a call via the callee's `operations` map keyed by entry-point name — ledger spec [\[39\]](#ref-39), [\[47\]](#ref-47). +- **Maintenance authority / maintenance update.** The per-contract committee-plus-threshold that authorizes replacing the contract's verifier keys (`VerifierKeyRemove` / `VerifierKeyInsert`) or the authority itself — ledger source [\[52\]](#ref-52), [\[54\]](#ref-54). +- **Guaranteed / fallible sections.** "First the guaranteed transcript is applied, then the fallible transcript, with any failure during the fallible transcript application reverting to the state after the guaranteed transcript was applied." — ledger spec [\[44\]](#ref-44). +- **`expectedVk` (implementation binding).** The compiler-emitted SHA-256 fingerprint of a callee circuit's verifier key, compared by the runtime against the deployed key on every call — Compact changelog [\[26\]](#ref-26). + +# How cross-contract calls work + +## The callee interface is a type + +A caller declares the circuits it will call with a `contract` block. This is a compile-time type, not a deployment [\[2\]](#ref-2), [\[3\]](#ref-3); the interface block itself [\[7\]](#ref-7): + +```compact +// examples/composable/direct/Main.compact (verbatim) +contract Calculator { + circuit get_square(x: Field): Field; + circuit get_cube(x: Field): Field; +} +``` + +Contract typing is **structural, not nominal** [\[4\]](#ref-4): any contract exporting matching circuits satisfies the type. The type system therefore cannot express *identity*; anything identity-shaped (vetting, allowlists) must key on the address or the verifier key (see Q1). A callee can opt into a static conformance check with `contract implements Calculator;` [\[5\]](#ref-5). + +Since Issue 201, most interface-conformance checking moved from compile time to **runtime guards** (see *Runtime guards*). The compiler still rejects a call to a circuit the interface never declared: `"contract C has no circuit declaration named f"` [\[6\]](#ref-6). + +## Holding a callee reference + +A contract-typed value is a reference to a specific deployed instance. There are two ways to hold one, and they have different trust shapes. + +**As a ledger field** [\[8\]](#ref-8), fixed at deploy time: + +```compact +// examples/composable/direct/Main-constructor.compact (verbatim) +ledger calc: Calculator; + +constructor (c: Calculator) { + calc = disclose(c); +} +``` + +**As a circuit parameter** [\[9\]](#ref-9), chosen per call. This is the dynamic-dispatch form: + +```compact +// examples/composable/direct/Main-circuit-parameter.compact (verbatim) +circuit calculate_square(calc: Calculator, i: Field): Field { + return calc.get_square(disclose(i)); +} +``` + +Contract types are ordinary types, so references also live in ledger collections: `Map`, `List`, `MerkleTree<2, C>` all work [\[13\]](#ref-13). This enables the vetted-registry idiom in Q1. + +Two hard limits shape what architectures are possible: + +- **References cannot be created from addresses in-language.** `Calculator(address)` is rejected with `"invalid context for reference to contract type name Calculator"` [\[10\]](#ref-10). References enter a contract only from application code, via constructor or circuit arguments, or from a witness return [\[11\]](#ref-11). +- **Constructors cannot make cross-contract calls.** A dedicated compiler pass rejects them: `"constructor cannot call external contracts"` [\[12\]](#ref-12). Storing a reference in the constructor is fine; calling through it is not. + +## Disclosure + +The reference itself must always be disclosed, **"because a cross-contract call reveals the address of the called contract"** [\[14\]](#ref-14). A ledger-held reference was disclosed when stored; a parameter-held reference needs `disclose()` on the path to each call. Call *arguments* additionally need disclosure unless the callee circuit is declared `pure` in the interface, because an impure callee might publish them. + +Omitting it is a compile error (wording verified end-to-end [\[15\]](#ref-15)): + +```text +potential witness-value disclosure must be declared but is not: + witness value potentially disclosed: the value of parameter calc of exported circuit calculate_square ... + nature of the disclosure: contract call contract reference might disclose the witness value +``` + +## A contract's own address + +`kernel.self()` returns the executing contract's `ContractAddress` [\[16\]](#ref-16), used when a contract must name itself, for example as the sender in token operations it drives [\[17\]](#ref-17): + +```compact +// doc/compact-reference.mdx:3352-3356 (verbatim) +import CompactStandardLibrary; +circuit f(): ContractAddress { + return kernel.self(); +} +``` + +`ContractAddress` is `struct ContractAddress { bytes: Bytes<32>; }` [\[18\]](#ref-18). At runtime a contract value flattens to its 32-byte address, but the struct and the contract-typed value are **not interchangeable in-language** (see *Holding a callee reference*). + +## Return values + +A callee's return value flows back into the caller like any local value; the static type is the declared return type [\[19\]](#ref-19). Callees can even return contract references the caller reuses [\[20\]](#ref-20): + +```compact +// test-center/composable/Basic/Outer.compact (verbatim, abridged) +contract Inner { + circuit add(value: Field): Field; +} +export ledger inner: Inner; +constructor(i: Inner) { inner = disclose(i); } + +export circuit add(value: Field): Field { + return inner.add(disclose(value)); +} +export circuit setInner(i: Inner): Inner { + const lastInner = inner; + inner = disclose(i); + return lastInner; +} +``` + +## What a callee may be + +- **Witness-free on the called path.** The compiler eliminates circuits that call witnesses from cross-contract consideration [\[21\]](#ref-21), and the runtime enforces it: a callee that invokes a witness throws ``Cross-contract callee '' invoked witness ''`` [\[22\]](#ref-22). In effect, **called contracts must have no private state** on the paths you call. +- **No shielded (Zswap) coin operations inside a callee** [\[23\]](#ref-23), [\[55\]](#ref-55) — the main limit on moving token value through a call tree; see *Shielded coins and callees* below. +- **No generics across the boundary**: circuit declarations in contract types cannot be generic [\[24\]](#ref-24). +- Contract-typed *values* may still pass through witnesses as parameters or returns [\[25\]](#ref-25). + +## Shielded coins and callees + +The shielded-token stdlib circuits — `mintShieldedToken`, `receiveShielded`, `sendShielded`, `sendImmediateShielded`, `mergeCoin` [\[56\]](#ref-56) — all call the runtime primitives `createZswapInput`, `createZswapOutput`, or `ownPublicKey`. Each asserts a **Zswap local state** and throws `CompactError: "Zswap local state is undefined for contract ''"` when it is absent [\[55\]](#ref-55). The current runtime wires a Zswap local state into the **root** call only; a cross-contract callee runs in a sub-context that has none. So any shielded mint/send/receive/merge **inside a callee throws** — shielded value cannot move below the root of a call tree. + +Two clarifications from the code: + +- **Reads are fine.** `hasCoinCommitment` only reads the per-call query context, not the Zswap local state, so it does not throw in a callee. +- **Unshielded tokens use a different path.** `mintUnshieldedToken` / `sendUnshielded` / `receiveUnshielded` go through `kernel.*` effects, not Zswap local state [\[57\]](#ref-57), so they are not gated by this guard. (Whether they compose end-to-end through a tree is untested here; they are simply not blocked by the shielded-coin assertion.) + +This reads as a **client-runtime limitation, not a ledger rule**: the ledger's per-call effects already carry a contract's shielded nullifiers, receives, spends, and mints [\[58\]](#ref-58), so the structure anticipates contract calls that touch shielded coins. Treat "no shielded ops in a callee" as a stage-one gap to confirm on the roadmap, not a proven permanent bar. + +## Runtime guards + +Every cross-contract call passes four dynamic guards in the Compact runtime [\[26\]](#ref-26): + +| Guard | What it checks | On violation | +| --- | --- | --- | +| Re-entrancy | callee not already on the client call stack [\[27\]](#ref-27) | `Contract re-entrancy detected: '' is already executing on the call stack; re-entrant cross-contract calls are not yet supported` | +| Implementation binding | SHA-256 of the deployed verifier key matches the compiler-emitted `expectedVk` fingerprint | `ContractInterfaceMismatchError` | +| Purity | the callee's real purity (its own `pureCircuits` map) matches the `pure` annotation in the caller's interface — checked both directions [\[59\]](#ref-59) | `Expected pure circuit '' for callee '' to be defined` (interface said `pure`, callee is not) or `… to be undefined` (interface omitted `pure`, callee is pure) | +| Witness | the callee does not *invoke* a witness on the called path — declaring witnesses is fine, invoking one is trapped [\[60\]](#ref-60) | `Cross-contract callee '' invoked witness ''; calls to witnesses in non-root contracts are not yet supported` | + +Sequential calls to the same callee are fine [\[28\]](#ref-28); A→B→A and self-recursion are rejected [\[29\]](#ref-29), [\[30\]](#ref-30). Note the implementation-binding guard: **code-identity checking by verifier key already exists in the pipeline.** Q1 builds on this. + +**Purity, in detail.** The caller's `contract` interface annotates each circuit `pure` or not; the deployed callee's generated bindings expose the set of circuits its own compiler proved pure (`pureCircuits`). The guard cross-checks the two and rejects a mismatch **either way** — a `pure` interface over an impure implementation, or a non-`pure` interface over a pure one [\[59\]](#ref-59). This matters because purity drives disclosure: arguments to a `pure` callee need no `disclose()` (see *Disclosure*), so a wrong `pure` annotation would let a caller under-model a call's state effects. The message text comes from the generic `assertDefined` / `assertUndefined` helpers [\[61\]](#ref-61). + +**Witness, in detail.** Witnesses (private-state functions) exist only for the root contract. A callee is constructed with a proxy that supplies a stub for every witness name, so its generated `Contract` constructor still validates and witness-free circuits run unchanged — but the stub *throws the moment a called circuit actually invokes a witness* [\[60\]](#ref-60). The rule is therefore about invocation, not declaration: a callee may *declare* witnesses; it just cannot *call* one on a cross-contract path, which is the same as saying its called paths touch no private state. + +## The ledger execution model: a declarative call forest + +On the EVM, calls happen **on-chain**: contract A runs, hits a call, the chain executes B, control returns to A. There is a live call stack. Midnight works differently: **all execution and proving happens off-chain, before submission.** What the ledger receives is a *list of already-proven calls*, where each call *declares* which sub-calls it requires [\[34\]](#ref-34). The ledger never runs anything across contracts — it only checks that the declarations and the calls **match up**, one-to-one, in the same segment [\[35\]](#ref-35): + +``` +What the transaction contains: The linked ("declared") view: + + Call 1: Router.swap Router.swap + declares: "I need call 2" └─► Pair.swap + Call 2: Pair.swap └─► Token.transfer + declares: "I need call 3" + Call 3: Token.transfer + declares: nothing +``` + +**The matched-up graph must be a *forest*: one or more separate trees.** Concretely, two rules — no cycles, and every call has at most one parent: + +``` +ALLOWED — a forest (two trees): A D + ├─► B └─► E + └─► C + +REJECTED — a cycle (re-entrancy): A ─► B ─► A ✗ NonForest + +REJECTED — one call, two parents: A ─► (X) ◄─ C ✗ NonForest + (the SAME call claimed by two callers) + +ALLOWED — same contract, two calls: A ─► Helper (call 1) ✓ two separate + C ─► Helper (call 2) calls, one + parent each +``` + +Cycles are rejected at transaction construction with `NonForest` [\[36\]](#ref-36), and so is a call claimed by more than one caller [\[37\]](#ref-37). At verification a caller's position must be strictly smaller than its callee's, so a cycle cannot even be written down [\[38\]](#ref-38). Note the last example: each call is its own node (keyed by its communication commitment), so two contracts each making *their own* call to the same helper is fine — only *sharing one call* is not, and ordinary code never produces that. + +Remaining ledger facts: + +- **Verifier keys resolve by (contract address, entry-point name)** from the callee's on-chain operations map [\[39\]](#ref-39), [\[40\]](#ref-40). The vk-hash binding of *Runtime guards* is a client-side guard, not a ledger rule. +- **The callee learns its caller.** `CallContext.caller` is a user key hash or a contract address [\[41\]](#ref-41), so callee-side "who may call me" policies are app-expressible today. +- **No call-depth limit** — depth is bounded by budgets and transaction size, not a cap. +- **The "link" between caller and callee is the communication commitment**: a value committing to the sub-call's inputs and outputs, so the caller's declaration and the callee's actual execution are cryptographically tied together [\[42\]](#ref-42), [\[43\]](#ref-43). + +## Atomicity: guaranteed and fallible + +Transactions split into a **guaranteed** section (segment 0, runs before fees) and **fallible** segments. A failure in a fallible segment reverts to the state after the guaranteed section applied; fees are charged regardless [\[44\]](#ref-44). Causality is a theorem of the system: if A calls B, "A succeeding must imply B succeeding", both must be in the same intent, and the sectioning is consistent across the tree [\[45\]](#ref-45). The net effect is that a call tree applies all-or-nothing within its section. Design callees to fail loudly; a failed callee aborts its whole tree. + +## Missing-key lookups abort + +`Map.lookup` on an absent key yields `Null`, and the next cell operation aborts with `"expected a cell, received null"` [\[46\]](#ref-46). This bites hardest in C2C, where a callee routinely receives keys it has never seen (a pool paying a brand-new recipient). Default first: + +```compact +// pattern from the design doc +circuit balance_or_zero(owner: ContractAddress): Uint<64> { + return balances.member(disclose(owner)) + ? balances.lookup(disclose(owner)) + : 0 as Uint<64>; +} +``` + +## Artifacts and proving + +Each contract compiles to a bundle: generated bindings (`contract/index.js`), per-circuit proving and verifier keys, ZKIR, and `contract-info.json` [\[31\]](#ref-31). The client runtime models the **whole call tree** [\[32\]](#ref-32), so every contract that can appear in a tree must be compiled and present locally when proving. Call sites hard-code the callee implementation at `../T/contract/index.js`, which "effectively limits any DApp to a single implementation of each contract type" [\[33\]](#ref-33). + +# Privacy model (stated precisely) + +What a cross-contract call reveals, from the ledger structures themselves: + +| | | +| --- | --- | +| **Hidden** | circuit arguments and return values (behind the communication commitment [\[42\]](#ref-42)); contract private state (callees have none on called paths) | +| **Public** | callee contract address and entry-point name (`ContractCall` fields [\[47\]](#ref-47)); the declared call graph (sequence, callee address, entry-point hash per claim [\[34\]](#ref-34)); transcripts of public-state effects; caller identity, exposed to the callee [\[41\]](#ref-41) | + +Two consequences worth stating plainly: + +- **The choice of callee is an observable behavior channel.** When the callee is user-influenced, an observer learns which venue, token module, or counterparty a user routed through, even while amounts stay hidden. The language documents this: the reference must be disclosed "because a cross-contract call reveals the address of the called contract" (see *Disclosure*). +- **Shielded outputs to contracts name the contract in cleartext.** `ZswapOutput.contract: Option` is a public field, and contract-destined outputs carry no ciphertext: `assert!(self.contract.is_none() || self.ciphertext.is_none())` [\[48\]](#ref-48). The same holds for contract-owned spends [\[49\]](#ref-49). This was confirmed independently on preprod during the forwarder review (finding CRIT-1): a shielded send to a contract publishes the contract address, while a coin-public-key recipient stays hidden. + +This is the axis an EVM comparison cannot supply: on a transparent chain, callee restriction is purely a safety feature. On Midnight it is also a **privacy bound**, currently the only one available for dispatch (see Q1). + +# Constraints at a glance + +| Constraint | Layer | Source | +| --- | --- | --- | +| Call graph must be an acyclic, single-parent forest | ledger (structural) | [\[50\]](#ref-50) | +| No re-entrancy ("not yet supported") | client runtime guard | [\[27\]](#ref-27) | +| Callee must be witness-free on called path (no private state) | compiler + runtime guard | [\[21\]](#ref-21) | +| No shielded (Zswap) coin ops in a callee | client runtime | [\[55\]](#ref-55) | +| Constructors cannot make C2C calls | compiler | [\[12\]](#ref-12) | +| No reference creation from an address in-language | compiler | [\[10\]](#ref-10) | +| No generics across the call boundary | compiler | [\[24\]](#ref-24) | +| One implementation per contract type per DApp | tooling | [\[33\]](#ref-33) | +| Every callee's artifacts present locally at prove time | tooling | [\[32\]](#ref-32) | +| Callee address + entry point public per call | ledger | [\[47\]](#ref-47) | + +# Open questions + +This is the living part of the doc. Each question gets a subsection: the question, what exists today, relevant prior art, and a recommended direction. Add new questions here rather than forking new docs. + +## Q1 — Restricting callable contracts to a vetted set + +**The question:** should a DApp be able to restrict the callable contracts to a specific known set that it has vetted, rather than dispatch to arbitrary addresses? This is the central design question around dynamic C2C. + +**The answer is yes — as an opt-in primitive, not a gate.** Almost every DApp wants *some* bound on dispatch once the failure modes are on the table, but several important use cases need genuine openness: the callee is chosen by a user or a vote at runtime, so no developer can write the allowlist in advance (a smart-account wallet calling whatever dApp its user picks next; a DAO executor performing whatever call the members voted for — there, the vote *is* the vetting). The productive discussion is about granularity, mutability, defaults, and the privacy dimension. + +### What exists today + +**Compact is already restrictive by default — the opposite of the EVM.** The language has no way to turn a raw address into something callable (see *Holding a callee reference*): a contract can only call a `contract`-typed reference that application code handed to it. EVM's "call any address" is simply not expressible. Within that rule, a caller contract can take exactly three shapes today: + +**A — Fixed callees (closed).** Every reference is set once, in the constructor, and no circuit accepts a contract-typed parameter. The set of contracts this contract can ever call is frozen at deploy time; nothing a user sends can extend it. A vetted set for free — no allowlist code needed. + +```compact +// (sketch) closed: can only ever call the token wired in at deploy +ledger tokenA: Token; +constructor(a: Token) { tokenA = disclose(a); } +export circuit pay(to: ContractAddress, amount: Uint<64>): [] { + tokenA.transfer(disclose(to), disclose(amount)); +} +``` + +**B — Caller's choice (open).** A circuit accepts a contract-typed parameter, so whoever builds the transaction picks the callee. This is the *only* door to open dispatch in today's language — and therefore the only place the vetting question bites. (A stored reference can also be *reassigned* later, but only through such a parameter — the same door.) + +```compact +// (sketch) open: the transaction picks ANY deployed contract matching Token +export circuit pay(token: Token, to: ContractAddress, amount: Uint<64>): [] { + disclose(token).transfer(disclose(to), disclose(amount)); +} +``` + +**C — Guest list (open, but bounded).** The circuit takes a small id and looks the callee up in an admin-controlled ledger Map. A missing key aborts the whole call tree (see *Missing-key lookups abort*), so users can only pick from the approved entries — unvetted callees are unrepresentable: + +```compact +// (sketch — untested) vetted-registry idiom in current syntax +contract Token { + circuit transfer(to: ContractAddress, amount: Uint<64>): []; +} + +export ledger vettedTokens: Map, Token>; // populated at deploy / by admin circuit + +export circuit pay(tokenId: Uint<8>, to: ContractAddress, amount: Uint<64>): [] { + // aborts the whole call tree if tokenId is not vetted + const token = vettedTokens.lookup(disclose(tokenId)); + token.transfer(disclose(to), disclose(amount)); +} +``` + +All three shapes are expressible **today**. So the question for the platform is not "make restriction possible" — it is whether to bless shape C as a first-class primitive, so teams stop re-implementing it (and its admin/mutation path) inconsistently, and what its default posture and granularity should be. + +### Prior art: EVM + +The EVM imposes nothing: `call` accepts any address, and all restriction is app-level convention. The exploit record for protocols that forward user-controlled `(target, calldata)` is the strongest argument for a first-class primitive: Multichain/AnySwap router (2022), Dexible (2023), Socket/Bungee (2024), Li.Fi (2024). Same root cause each time, and the post-mortem fix each time was a target allowlist. The ecosystem also built the pattern proactively where stakes were high: Safe modules/guards, Uniswap v4 hook-address permission bits, ERC-3643 identity registries, timelock-managed target sets, and `EXTCODEHASH` code-identity checks. Solidity converged on exactly what dynamic C2C is proposing, but only after losses. + +### Prior art: zk stacks + +Verified against each platform's official docs on 2026-07-23; sources per row. In the *Restriction* column: **always-on** = the platform enforces it on every (dynamic) call; **DIY** = no platform primitive, apps hand-roll their own checks. + +| Platform | Dynamic C2C | Restriction | Callee visibility | Source | +| --- | --- | --- | --- | --- | +| Aleo / Leo | **Yes** — since Leo 4.0 (2026): `Interface@(target)::method` dynamic dispatch (itself an opt-in construct; static calls remain the default) | **Yes, always-on** — every dynamic call must satisfy a declared interface (compiler-enforced); identity vetting (which specific programs) is DIY | Public | [\[62\]](#ref-62) | +| Mina / o1js | **Partial** — callee chosen at proving time (a runtime argument), not on-chain dispatch; calls proven client-side and committed as account updates | **No — DIY only**; the platform only checks the proof against the verification key stored on the callee's account | Public | [\[63\]](#ref-63) | +| Starknet / Cairo | **Yes** — `call_contract` syscall (by address), `library_call` (by class hash) | **No — DIY only** ("any previously declared class") | Public (transparent chain) | [\[64\]](#ref-64) | +| Aztec (Noir / Aztec.nr) | **Yes** — private calls proven client-side under a recursive private kernel; public calls are enqueued and executed by the AVM | **No — DIY only** for vetting (which contracts *may* be called is the app's own code); kernel circuits do verify every call is *genuine* — correct function, valid proofs — but that is correctness, not trust | **"The addresses of all private calls are hidden from observers"** (doc verbatim); enqueued *public* calls are visible | [\[65\]](#ref-65) | +| Compact / Midnight today | **Yes** — via a contract-typed parameter (itself an opt-in shape; ledger-held references stay closed) | **No — DIY only** (registry idiom); the vk-binding guard is a toolchain integrity check, not a policy option | Public (see *Privacy model*) | [\[47\]](#ref-47) | + +Two lessons from the neighbors: + +- **On safety, Aleo just made the same transition Compact is making.** Static-only until Leo 4.0 added dynamic dispatch; their answer was to keep static calls the default and make dynamic calls **opt-in and interface-constrained** (the compiler checks the runtime target implements a declared interface). That is the same shape as Compact's contract-typed parameters — an interface bound, not identity vetting — so the vetting question is still open there too. Starknet's address-vs-class-hash split is the closest existing analogue for *what* a vetted set should key on (see *Vetting granularity* above). +- **On privacy, Aztec is the structurally different answer.** Instead of restricting the callee set, it hides the callee at the proof-system level: a private call's target never reaches the public ledger (though any public calls it enqueues are visible). If Midnight ever adopts this, the allowlist loses its privacy job and keeps only its safety job (see *The privacy dimension*). + +### Why open dispatch matters + +Open dynamic dispatch buys **permissionless composability**: calling contracts that did not exist at deploy time, with no gatekeeper approving integrations. The canonical users: + +- **Smart accounts** — exist to call whatever dApp the user picks next; unenumerable by definition. +- **Aggregators / routers** — the best route crosses venues launched after the router deployed. +- **Factory protocols and marketplaces** — must call pairs/collections created continuously by third parties. +- **Plugin systems and governance executors** — third-party modules over time; a DAO's `execute(target, args)` is open by design. + +### Three tiers of dispatch + +Most "open" demands are not *fully* open. A restriction primitive should express all three tiers; most real use cases land in tier 2 or 3 voluntarily: + +| Tier | Meaning | Who needs it | +| --- | --- | --- | +| 1 — Fully open | any deployed contract | smart accounts, governance executors | +| 2 — Code-vetted, instance-open | any instance of an audited implementation (**verifier-key predicate**) | factory pairs, standard-interface marketplaces | +| 3 — Registry-vetted | governed, mutable set of specific instances | routers, plugin systems | + +### Vetting granularity: address vs verifier key + +A vetted set has to name *what* it trusts. Two options: + +- **By address** — "allow the contract at address X." Trusts a specific deployment. +- **By verifier key** — "allow any contract running this exact audited code." The verifier key is the fingerprint of a circuit's code, and the client runtime already checks it on every call (`expectedVk`, see *Runtime guards*) — so this is a policy layer over existing machinery, not new cryptography. Starknet class hashes and EVM `EXTCODEHASH` are the precedents. + +The trap: **an address does not pin the code.** `ContractAddress` commits only to the contract's *initial* state [\[51\]](#ref-51), and a **maintenance update** can replace the verifier keys at that same address later (`VerifierKeyRemove` / `VerifierKeyInsert`, authorized by a committee threshold) [\[52\]](#ref-52), [\[53\]](#ref-53). The two options then fail differently: + +- Vet **by address**, and a later code swap silently keeps passing your check — you are now calling code nobody audited. +- Vet **by code**, and a swap makes your check fail loudly (safer), but a *legitimate* upgrade of the callee also breaks your DApp until you re-vet. + +Either way, you must also look at **who can change the callee's code** — its maintenance authority. A contract deployed with the default authority (empty committee, threshold 1, unsatisfiable) can never be updated, so it is effectively immutable and even address-vetting is sound there [\[54\]](#ref-54). A contract with a real committee can rotate its code at any time. **Sound vetting = code identity + a check on the callee's maintenance authority.** + +(The reverse direction — a callee restricting who may call *it* — is already expressible today via `CallContext.caller`, see *The ledger execution model*. The open gap is only the caller-side restriction of callees.) + +### The privacy dimension + +Every cross-contract call **publishes which contract was called**: the callee address and entry-point name are public transaction fields (see *Privacy model*). Arguments and amounts stay hidden; the *choice of callee* does not. On a privacy chain, that choice tells a story by itself — which venue, token, or counterparty a user interacted with is exactly the kind of metadata the chain exists to hide. + +This gives the allowlist two jobs: + +- On a transparent chain (EVM), restricting callees is purely a **safety** feature — everything is public anyway. +- On Midnight it is **also a privacy bound**: if a contract can only call k vetted targets, an observer learns "one of these k" instead of an arbitrary, revealing address. Today it is the *only* available bound on the callee leak. + +There is a deeper long-term fix: **hide the callee itself**, as Aztec does at the proof-system level. Whether that is even compatible with Midnight's declarative call-forest model — where call claims name the callee address — is an open architecture question, and the answer decides what an allowlist is *for*: the permanent design, or a mitigation until callee hiding exists. Worth resolving directly with the platform team. + +### Recommended direction + +1. **Open by default.** Dynamic C2C ships open (tier 1 stays plain dispatch); restriction is something a DApp opts into, not a gate the platform imposes. +2. **But ship the restriction primitive first-class** (platform or stdlib), covering tiers 2 and 3: **verifier-key predicates** ("any instance of this audited code") and **registry predicates** (specific instances). Ship it **together with** the dynamic-discovery CoIP, not after — EVM's lesson is not that allowlists are needed, but that safety arrived only after the losses. +3. **Pair vk-vetting with a maintenance-authority check**, or a key rotation can silently invalidate it. +4. **Audited mutation patterns** (immutable / role-gated / timelocked) rather than a bare setter. +5. **Tooling nudge instead of a language gate:** a suppressible compiler/linter warning when a circuit takes a contract-typed parameter with no policy attached — openness stays cheap, unrestricted dispatch stays greppable for auditors. +6. Resolve whether callee hiding is on any roadmap (it reframes the privacy argument); and once the API stabilizes, a vetted-registry / `AllowedCallees` module in `compact-contracts` is a natural library candidate. + +### Open sub-questions + +- Is the restriction envisioned per call site, per contract, or per deployment? Address-based, vk-based, or both? +- How should a vetted set be mutated, and what default posture should the platform want (immutable / role-gated / timelocked)? +- Is hiding the callee of a private call compatible with the call-forest model, and is it on any roadmap? +- The client guard says re-entrancy is "not yet supported" while the ledger's forest rule is structural. Which layer is authoritative for the roadmap? +- Does the single-parent (no-diamond) rule stay? What composition patterns is it expected to forbid? + +## Backlog: further open questions + +Smaller or newer questions (Q2–Q6), to be promoted to full subsections as they develop. Dated on entry. + +- **Q2 — Callee upgrades vs vetting (2026-07-23).** Maintenance updates can swap verifier keys under a fixed address. Should a vetting primitive pin the maintenance-authority state too? What happens to deployed callers when a vetted callee rotates keys? +- **Q3 — Callees with private state (2026-07-23, expanded 2026-07-24).** Called paths must be witness-free and cannot do Zswap ops (see *What a callee may be*). Is lifting this planned? It currently rules out C2C into any contract whose API touches its own private state. Concrete case (2026-07-24): the confidential note token (`ConfidentialNoteFungibleToken`, [compact-contracts PR #679](https://github.com/OpenZeppelin/compact-contracts/pull/679)) is *categorically* uncallable — every value-moving circuit invokes witnesses (spend secret, input note, Merkle path, randomness seeds, or role secrets), and the design has no witness-free getters at all (its "events" are ledger lists read off-chain). So no router, DEX, or custody contract can drive a note-model confidential token through C2C under stage-one rules; the entire confidential-token class sits behind this question, not just individual circuits. +- **Q4 — Artifact distribution (2026-07-23).** Every provable callee needs its compiled bundle locally, and a DApp binds one implementation per contract type (see *Artifacts and proving*). Does dynamic dispatch need a callee-bundle discovery/distribution story (the deferred "future CoIP")? +- **Q5 — State reads (2026-07-23).** A→B getter calls work but are full circuit calls. Is a cheaper read-only cross-contract state access planned? +- **Q6 — Fee asymmetry (2026-07-23).** Fallible-section failures still pay fees [\[44\]](#ref-44). Does a failed callee deep in a tree create griefing economics for the caller? + +# Worked example: can we build Uniswap V2? + +A DEX is a good stress test because a single swap exercises multi-level calls, shared token contracts, value movement, and cross-contract reads all at once. This section walks a V2-style design through the constraints described above. **Verdict up front:** the call shape is legal and a basic swap over a fixed set of public-balance tokens is buildable today; a faithful, permissionless V2 is not yet. + +## The V2 architecture, mapped to Midnight + +| V2 component | Its role | Midnight mapping | +| --- | --- | --- | +| Router | Stateless multi-hop helper; slippage, deadline, path routing | Caller contract holding `Pair` references | +| Pair (per token pair) | Reserves, swap math, LP token | Callee with **public-ledger** reserves | +| Token ×2 | The two underlying ERC-20s | Callee tokens with **public-ledger** balances | +| Factory | Deploys pairs; deterministic (`CREATE2`) address | App-code deploy + a ledger registry — **no on-chain address derivation** (see *Holding a callee reference*) | + +## The call tree for one swap + +``` +Router.swapExactIn (root) + ├─► TokenIn.transfer(user → Pair) move input into the pair first + └─► Pair.swap + ├─► TokenOut.transfer(Pair → user) pay the output + ├─► TokenIn.balanceOf(Pair) read reserves to verify the k invariant + └─► TokenOut.balanceOf(Pair) +``` + +Every edge flows away from the root; no callee reaches back. Valid forest. Note the inversion: the input token is moved into the `Pair` *before* the `Pair` pays out, so the chain runs one direction only and never re-enters (see *Runtime guards*). Multi-hop is two sibling subtrees under the Router (`Router → Pair1`, `Router → Pair2`), also a forest. + +## What works, what breaks + +| V2 feature | Midnight | Blocking constraint | Related question | +| --- | --- | --- | --- | +| `Router → Pair → Token` chain | ✅ works | forest, no depth limit | — | +| Multi-hop (sibling pair calls) | ✅ works | sibling subtrees, still a forest | — | +| Public-balance underlying tokens | ✅ works | callee is witness-free | — | +| Reserve reads (`balanceOf` for the k-check) | ⚠️ works, but costed | getter is a full circuit call, not a storage peek; a missing key aborts | Q5 (state reads) | +| Confidential-token pairs (our CFT or note token) | ❌ | callee must have no private state | Q3 (private state) | +| Flash swaps (`uniswapV2Call` callback) | ❌ | re-entrancy ban — the callback re-enters the caller | Q1 (re-entrancy) | +| `pairFor` / `CREATE2` address derivation | ❌ redesign | no reference-from-address in-contract | Q1 (references) | +| Generic pairs over heterogeneous token *code* | ❌ | one implementation per contract type | Q4 (artifacts) | +| Arbitrary user-supplied tokens | ❌ | every callee's artifacts must be compiled locally to prove | Q4 (artifacts) | + +(LP-token minting is a further callee/module the `Pair` would drive; it does not change the picture.) + +## Verdict + +- **Buildable today:** a swap DEX with a fixed, known set of public-balance tokens, in exactly the `Router → Pair → Token` shape. +- **Not yet:** a permissionless, generic V2 — arbitrary third-party tokens, on-chain pair derivation, flash swaps, or confidential assets. +- **Why it is a useful discussion case:** one concrete design touches open questions Q1, Q3, Q4 and Q5 at once — reference creation and the re-entrancy layer (Q1), private-state callees (Q3), artifact distribution (Q4), and cheaper state reads (Q5). It gives a tangible design to react to rather than an abstract list. + +# Technical links + +- CoIP-0002: [Contract Types, Values, and Calls](https://github.com/LFDT-Minokawa/compact/blob/main/coips/coip-0002.md) +- Midnight team design doc: [Cross-contract calls on Midnight — how they work](https://docs.google.com/document/d/1oJlQ3izG7GqZ9gOOZSpFNjf20oGKsx8YPldtxKkYQ-Q/edit?usp=sharing) +- Announcement + RC matrix: [Slack thread](https://openzeppelin.slack.com/archives/C0A94G0PS64/p1784042986745349) +- Local verified clones: `../compact-main` @ `c06961e`, `../midnight-ledger-main` @ `e1edad2` + +# References + +Pinned commits: Compact [`c06961e`](https://github.com/LFDT-Minokawa/compact/tree/c06961eb661942f7689c6509d0913326f264e848) · Ledger [`e1edad2`](https://github.com/midnightntwrk/midnight-ledger/tree/e1edad2d7019e1520d173f3e22e9991903225cef). Each link deep-links to the cited lines at that commit. + +1. [`compact CHANGELOG.md:307-330`](https://github.com/LFDT-Minokawa/compact/blob/c06961eb661942f7689c6509d0913326f264e848/CHANGELOG.md#L307-L330) — C2C is stage one; dynamic discovery deferred to a future CoIP. +2. [`doc/compact-reference.mdx:669-712`](https://github.com/LFDT-Minokawa/compact/blob/c06961eb661942f7689c6509d0913326f264e848/doc/compact-reference.mdx#L669-L712) — contract type declarations ("Contract types"). +3. [`doc/compact-grammar.mdx:271-286`](https://github.com/LFDT-Minokawa/compact/blob/c06961eb661942f7689c6509d0913326f264e848/doc/compact-grammar.mdx#L271-L286) — grammar: external-contract declaration. +4. [`doc/compact-reference.mdx:764-766`](https://github.com/LFDT-Minokawa/compact/blob/c06961eb661942f7689c6509d0913326f264e848/doc/compact-reference.mdx#L764-L766) — contract typing is structural, not nominal. +5. [`doc/compact-reference.mdx:774-784`](https://github.com/LFDT-Minokawa/compact/blob/c06961eb661942f7689c6509d0913326f264e848/doc/compact-reference.mdx#L774-L784) — `contract implements C;` assertion. +6. [`compiler/analysis-passes/infer-types.ss:829`](https://github.com/LFDT-Minokawa/compact/blob/c06961eb661942f7689c6509d0913326f264e848/compiler/analysis-passes/infer-types.ss#L829) — error: "no circuit declaration named". +7. [`examples/composable/direct/Main.compact:16-29`](https://github.com/LFDT-Minokawa/compact/blob/c06961eb661942f7689c6509d0913326f264e848/examples/composable/direct/Main.compact#L16-L29) — verbatim: `Calculator` interface + call. +8. [`examples/composable/direct/Main-constructor.compact:21-24`](https://github.com/LFDT-Minokawa/compact/blob/c06961eb661942f7689c6509d0913326f264e848/examples/composable/direct/Main-constructor.compact#L21-L24) — verbatim: ledger-field reference set in constructor. +9. [`examples/composable/direct/Main-circuit-parameter.compact:21-23`](https://github.com/LFDT-Minokawa/compact/blob/c06961eb661942f7689c6509d0913326f264e848/examples/composable/direct/Main-circuit-parameter.compact#L21-L23) — verbatim: parameter-held reference. +10. [`compiler/analysis-passes/expand-modules-and-types.ss:613`](https://github.com/LFDT-Minokawa/compact/blob/c06961eb661942f7689c6509d0913326f264e848/compiler/analysis-passes/expand-modules-and-types.ss#L613) — error: "invalid context for reference to contract type name". +11. [`doc/compact-reference.mdx:793-803`](https://github.com/LFDT-Minokawa/compact/blob/c06961eb661942f7689c6509d0913326f264e848/doc/compact-reference.mdx#L793-L803) — references enter only from application code / witness returns. +12. [`compiler/analysis-passes/reject-constructor-cc-calls.ss:19`](https://github.com/LFDT-Minokawa/compact/blob/c06961eb661942f7689c6509d0913326f264e848/compiler/analysis-passes/reject-constructor-cc-calls.ss#L19) — constructors cannot make cross-contract calls. +13. [`test-center/composable/Storage/Outer.compact`](https://github.com/LFDT-Minokawa/compact/blob/c06961eb661942f7689c6509d0913326f264e848/test-center/composable/Storage/Outer.compact) — contract references in `Map` / `List` / `MerkleTree`. +14. [`doc/compact-reference.mdx:3558-3565`](https://github.com/LFDT-Minokawa/compact/blob/c06961eb661942f7689c6509d0913326f264e848/doc/compact-reference.mdx#L3558-L3565) — disclosure of the reference; args unless callee is `pure`. +15. [`tests-e2e/src/tests/compiler/compiler.composable.direct.e2e.test.ts:265-269`](https://github.com/LFDT-Minokawa/compact/blob/c06961eb661942f7689c6509d0913326f264e848/tests-e2e/src/tests/compiler/compiler.composable.direct.e2e.test.ts#L265-L269) — verbatim disclosure-error text. +16. [`doc/ledger-adt.mdx:140-143`](https://github.com/LFDT-Minokawa/compact/blob/c06961eb661942f7689c6509d0913326f264e848/doc/ledger-adt.mdx#L140-L143) — `kernel.self(): ContractAddress`. +17. [`doc/compact-reference.mdx:3352-3356`](https://github.com/LFDT-Minokawa/compact/blob/c06961eb661942f7689c6509d0913326f264e848/doc/compact-reference.mdx#L3352-L3356) — verbatim: `kernel.self()` example. +18. [`compiler/standard-library.compact:94`](https://github.com/LFDT-Minokawa/compact/blob/c06961eb661942f7689c6509d0913326f264e848/compiler/standard-library.compact#L94) — `struct ContractAddress { bytes: Bytes<32>; }`. +19. [`doc/compact-reference.mdx:3106-3107`](https://github.com/LFDT-Minokawa/compact/blob/c06961eb661942f7689c6509d0913326f264e848/doc/compact-reference.mdx#L3106-L3107) — call's static type = declared return type. +20. [`test-center/composable/Basic/Outer.compact:16-34`](https://github.com/LFDT-Minokawa/compact/blob/c06961eb661942f7689c6509d0913326f264e848/test-center/composable/Basic/Outer.compact#L16-L34) — verbatim: `Inner` interface, `add` / `setInner`. +21. [`doc/compact-reference.mdx:3109-3118`](https://github.com/LFDT-Minokawa/compact/blob/c06961eb661942f7689c6509d0913326f264e848/doc/compact-reference.mdx#L3109-L3118) — witness-calling circuits excluded from cross-contract calls. +22. [`compact CHANGELOG.md:379-380`](https://github.com/LFDT-Minokawa/compact/blob/c06961eb661942f7689c6509d0913326f264e848/CHANGELOG.md#L379-L380) — runtime: a callee invoking a witness throws. +23. [`compact CHANGELOG.md:389-394`](https://github.com/LFDT-Minokawa/compact/blob/c06961eb661942f7689c6509d0913326f264e848/CHANGELOG.md#L389-L394) — no Zswap operations inside a callee. +24. [`doc/compact-reference.mdx:3093-3094`](https://github.com/LFDT-Minokawa/compact/blob/c06961eb661942f7689c6509d0913326f264e848/doc/compact-reference.mdx#L3093-L3094) — no generics across the call boundary. +25. [`test-center/composable/Witness/Outer.compact`](https://github.com/LFDT-Minokawa/compact/blob/c06961eb661942f7689c6509d0913326f264e848/test-center/composable/Witness/Outer.compact) — contract values passing through witnesses. +26. [`compact CHANGELOG.md:367-381`](https://github.com/LFDT-Minokawa/compact/blob/c06961eb661942f7689c6509d0913326f264e848/CHANGELOG.md#L367-L381) — the four dynamic runtime guards. +27. [`runtime/src/contract.ts:404-420`](https://github.com/LFDT-Minokawa/compact/blob/c06961eb661942f7689c6509d0913326f264e848/runtime/src/contract.ts#L404-L420) — re-entrancy guard + error text. +28. [`test-center/ts/composable/basic.ts:59`](https://github.com/LFDT-Minokawa/compact/blob/c06961eb661942f7689c6509d0913326f264e848/test-center/ts/composable/basic.ts#L59) — sequential calls to the same callee compose. +29. [`test-center/ts/composable/mutual-recursion.ts:202-218`](https://github.com/LFDT-Minokawa/compact/blob/c06961eb661942f7689c6509d0913326f264e848/test-center/ts/composable/mutual-recursion.ts#L202-L218) — A→B→A rejected. +30. [`test-center/ts/composable/self-recursion.ts:50`](https://github.com/LFDT-Minokawa/compact/blob/c06961eb661942f7689c6509d0913326f264e848/test-center/ts/composable/self-recursion.ts#L50) — self-recursion rejected. +31. [`doc/compact-reference.mdx:3585-3616`](https://github.com/LFDT-Minokawa/compact/blob/c06961eb661942f7689c6509d0913326f264e848/doc/compact-reference.mdx#L3585-L3616) — per-contract artifact bundle. +32. [`compact CHANGELOG.md:343-366`](https://github.com/LFDT-Minokawa/compact/blob/c06961eb661942f7689c6509d0913326f264e848/CHANGELOG.md#L343-L366) — `CircuitContext` models the whole call tree. +33. [`doc/compact-reference.mdx:3120-3133`](https://github.com/LFDT-Minokawa/compact/blob/c06961eb661942f7689c6509d0913326f264e848/doc/compact-reference.mdx#L3120-L3133) — one implementation per contract type per DApp. +34. [`ledger spec/contracts.md:170`](https://github.com/midnightntwrk/midnight-ledger/blob/e1edad2d7019e1520d173f3e22e9991903225cef/spec/contracts.md#L170) — `claimed_contract_calls` tuple. +35. [`ledger spec/intents-transactions.md:159-161`](https://github.com/midnightntwrk/midnight-ledger/blob/e1edad2d7019e1520d173f3e22e9991903225cef/spec/intents-transactions.md#L159-L161) — bidirectional call/claim matching per segment. +36. [`ledger/src/construct.rs:1046-1061`](https://github.com/midnightntwrk/midnight-ledger/blob/e1edad2d7019e1520d173f3e22e9991903225cef/ledger/src/construct.rs#L1046-L1061) — cycle detection → `NonForest`. +37. [`ledger/src/construct.rs:1069-1070`](https://github.com/midnightntwrk/midnight-ledger/blob/e1edad2d7019e1520d173f3e22e9991903225cef/ledger/src/construct.rs#L1069-L1070) — single-parent check → `NonForest`. +38. [`ledger/src/verify.rs:936-945`](https://github.com/midnightntwrk/midnight-ledger/blob/e1edad2d7019e1520d173f3e22e9991903225cef/ledger/src/verify.rs#L936-L945) — caller position strictly less than callee position. +39. [`ledger spec/contracts.md:301`](https://github.com/midnightntwrk/midnight-ledger/blob/e1edad2d7019e1520d173f3e22e9991903225cef/spec/contracts.md#L301) — verifier-key lookup by address + entry point. +40. [`onchain-state/src/state.rs:728-733`](https://github.com/midnightntwrk/midnight-ledger/blob/e1edad2d7019e1520d173f3e22e9991903225cef/onchain-state/src/state.rs#L728-L733) — `operations` map keyed by entry-point name. +41. [`ledger spec/contracts.md:178-211`](https://github.com/midnightntwrk/midnight-ledger/blob/e1edad2d7019e1520d173f3e22e9991903225cef/spec/contracts.md#L178-L211) — `CallContext.caller` / `PublicAddress` (178-182, 197, 205-211). +42. [`ledger spec/contracts.md:90-91`](https://github.com/midnightntwrk/midnight-ledger/blob/e1edad2d7019e1520d173f3e22e9991903225cef/spec/contracts.md#L90-L91) — communication commitment commits to inputs/outputs. +43. [`doc/ledger-adt.mdx:65-71`](https://github.com/LFDT-Minokawa/compact/blob/c06961eb661942f7689c6509d0913326f264e848/doc/ledger-adt.mdx#L65-L71) — `kernel.claimContractCall`. +44. [`ledger spec/contracts.md:105-114`](https://github.com/midnightntwrk/midnight-ledger/blob/e1edad2d7019e1520d173f3e22e9991903225cef/spec/contracts.md#L105-L114) — guaranteed/fallible sections; fees charged regardless. +45. [`ledger spec/properties.md:139-146`](https://github.com/midnightntwrk/midnight-ledger/blob/e1edad2d7019e1520d173f3e22e9991903225cef/spec/properties.md#L139-L146) — Theorem 4 (Causality). +46. [`onchain-vm/src/error.rs:68-81`](https://github.com/midnightntwrk/midnight-ledger/blob/e1edad2d7019e1520d173f3e22e9991903225cef/onchain-vm/src/error.rs#L68-L81) — "expected a cell, received null". +47. [`ledger spec/contracts.md:95-102`](https://github.com/midnightntwrk/midnight-ledger/blob/e1edad2d7019e1520d173f3e22e9991903225cef/spec/contracts.md#L95-L102) — `ContractCall` fields: `address`, `entry_point` public. +48. [`ledger spec/zswap.md:87-93`](https://github.com/midnightntwrk/midnight-ledger/blob/e1edad2d7019e1520d173f3e22e9991903225cef/spec/zswap.md#L87-L93) — `ZswapOutput.contract` public; no ciphertext for contracts (assert at 185-186). +49. [`ledger spec/zswap.md:82`](https://github.com/midnightntwrk/midnight-ledger/blob/e1edad2d7019e1520d173f3e22e9991903225cef/spec/zswap.md#L82) — `ZswapInput.contract` (contract-owned spends). +50. [`ledger/src/construct.rs:1046-1077`](https://github.com/midnightntwrk/midnight-ledger/blob/e1edad2d7019e1520d173f3e22e9991903225cef/ledger/src/construct.rs#L1046-L1077) — full forest-partition logic (cycles + single-parent). +51. [`ledger spec/contracts.md:58`](https://github.com/midnightntwrk/midnight-ledger/blob/e1edad2d7019e1520d173f3e22e9991903225cef/spec/contracts.md#L58) — `ContractAddress = Hash` (initial state only). +52. [`ledger/src/structure.rs:2687-2722`](https://github.com/midnightntwrk/midnight-ledger/blob/e1edad2d7019e1520d173f3e22e9991903225cef/ledger/src/structure.rs#L2687-L2722) — `SingleUpdate` / `MaintenanceUpdate` (VK replace). +53. [`ledger/src/verify.rs:1738-1788`](https://github.com/midnightntwrk/midnight-ledger/blob/e1edad2d7019e1520d173f3e22e9991903225cef/ledger/src/verify.rs#L1738-L1788) — maintenance-update authorization (committee threshold). +54. [`onchain-state/src/state.rs:708-713`](https://github.com/midnightntwrk/midnight-ledger/blob/e1edad2d7019e1520d173f3e22e9991903225cef/onchain-state/src/state.rs#L708-L713) — default maintenance authority is unsatisfiable (immutable). +55. [`runtime/src/zswap.ts:202-204`](https://github.com/LFDT-Minokawa/compact/blob/c06961eb661942f7689c6509d0913326f264e848/runtime/src/zswap.ts#L202-L204) — `assertHasCurrentZswapLocalState`; throws `"Zswap local state is undefined for contract '…'"`. +56. [`compiler/standard-library.compact:125-227`](https://github.com/LFDT-Minokawa/compact/blob/c06961eb661942f7689c6509d0913326f264e848/compiler/standard-library.compact#L125-L227) — shielded circuits (`mintShieldedToken`, `receiveShielded`, `sendShielded`, `sendImmediateShielded`, `mergeCoin`) call `createZswapInput` / `createZswapOutput`. +57. [`compiler/standard-library.compact:299-325`](https://github.com/LFDT-Minokawa/compact/blob/c06961eb661942f7689c6509d0913326f264e848/compiler/standard-library.compact#L299-L325) — unshielded ops (`mintUnshieldedToken` / `sendUnshielded` / `receiveUnshielded`) use `kernel.*` effects, not Zswap local state. +58. [`ledger spec/contracts.md:147-169`](https://github.com/midnightntwrk/midnight-ledger/blob/e1edad2d7019e1520d173f3e22e9991903225cef/spec/contracts.md#L147-L169) — per-call effects carry a contract's shielded nullifiers, receives, spends, and mints. +59. [`runtime/src/contract.ts:388-401`](https://github.com/LFDT-Minokawa/compact/blob/c06961eb661942f7689c6509d0913326f264e848/runtime/src/contract.ts#L388-L401) — `assertPurityMatches`: interface `pure` annotation vs the callee's `pureCircuits`, both directions. +60. [`runtime/src/contract.ts:427-449`](https://github.com/LFDT-Minokawa/compact/blob/c06961eb661942f7689c6509d0913326f264e848/runtime/src/contract.ts#L427-L449) — `forbiddenCalleeWitnesses`: witness stubs + the "invoked witness" throw. +61. [`runtime/src/error.ts:63-93`](https://github.com/LFDT-Minokawa/compact/blob/c06961eb661942f7689c6509d0913326f264e848/runtime/src/error.ts#L63-L93) — `assertDefined` / `assertUndefined`: "Expected … to be defined / undefined". +62. [Leo docs: Interfaces](https://docs.leo-lang.org/language/programs_in_practice/interfaces) — "Dynamic calls allow the callee to be determined at runtime. The caller still knows *what* it can call — expressed as an interface — but not *which* program it is calling." Shipped in Leo 4.0 ([announcement](https://provable.com/blog/interfaces-and-dynamic-dispatch-in-leo); [network-level audit](https://reports.zksecurity.xyz/reports/aleo-dynamic-dispatch/)). +63. [o1js docs: smart contracts](https://docs.o1labs.org/o1js/zkapps/smart-contracts) — off-chain execution, composing zkApp calls; [permissions](https://docs.o1labs.org/o1js/zkapps/permissions): "Every smart contract has a verification key stored on-chain." +64. [Cairo Book: system calls](https://book.cairo-lang.org/appendix-08-system-calls.html) — `call_contract_syscall(address, selector, calldata)`; `library_call_syscall(class_hash, …)`: "Calls the requested function in any previously declared class." +65. [Aztec docs: transactions](https://docs.aztec.network/developers/docs/foundational-topics/transactions) — "The addresses of all private calls are hidden from observers. The only information leaked … : 1. The number of private state updates triggered 2. The set of public calls generated." Public execution now runs in the AVM ([circuits/public_execution](https://docs.aztec.network/developers/nightly/docs/foundational-topics/advanced/circuits/public_execution)). +66. [`coips/coip-0002.md:38-47`](https://github.com/LFDT-Minokawa/compact/blob/c06961eb661942f7689c6509d0913326f264e848/coips/coip-0002.md#L38-L47) — CoIP-0002 abstract: the three new features (contract types, contract references, cross-contract calls); "Later proposals will address dynamic discovery of contract implementation code and management of private state across contracts." +67. [Midnight team design doc: Cross-contract calls on Midnight — how they work](https://docs.google.com/document/d/1oJlQ3izG7GqZ9gOOZSpFNjf20oGKsx8YPldtxKkYQ-Q/edit?usp=sharing) — CCC definition and call-tree framing. +68. [`ledger/src/construct.rs:1011-1012`](https://github.com/midnightntwrk/midnight-ledger/blob/e1edad2d7019e1520d173f3e22e9991903225cef/ledger/src/construct.rs#L1011-L1012) — "Generate a call graph between `calls`. Assert that this is a forest (no cycles, no multiple parents)." +