diff --git a/CHANGELOG.md b/CHANGELOG.md index 38ce9834..8cc9f1f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add Confidential Fungible Token (#653) - Add EcdhMask (#655) +- Add Confidential Note Fungible Token core (#723) ### Changed diff --git a/contracts/src/token/ConfidentialNoteFungibleToken.compact b/contracts/src/token/ConfidentialNoteFungibleToken.compact new file mode 100644 index 00000000..8002ad58 --- /dev/null +++ b/contracts/src/token/ConfidentialNoteFungibleToken.compact @@ -0,0 +1,460 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.3.0-alpha.1 (token/ConfidentialNoteFungibleToken.compact) + +pragma language_version >= 0.23.0; + +/** + * @module ConfidentialNoteFungibleToken + * @description A note-based confidential token: amounts, sender, and recipient + * are all hidden from the public ledger. + * + * Vocabulary: the TOKEN is the asset; a NOTE is its internal value record (a + * UTXO). A note is `(value, nonce)` owned by `pk = Hf(sk)`, a field-typed hash + * of the owner's secret; a balance is the sum of one's unspent notes. The + * commitment `cm = H(domain, value, nonce, pk)` goes in the tree, and the + * nullifier `nf = H(domain, nonce)` marks the note spent. Spending proves that + * the note's commitment is in the tree without revealing which leaf. + * + * The public ledger therefore holds only the commitment tree and the nullifier + * set: no balances, no accounts, no supply. A transfer publishes one nullifier + * and two commitments, and nothing else. + * + * The module is deliberately barebones — the note machinery and nothing else. + * It holds no roles and needs no initialization. `transfer` and `burn` work out + * of the box because they are self-gated: spending requires the owner's secret + * via `wit_SecretKey`, and output nonces come from the caller's own randomness + * witness. Created notes are returned to the caller as a local, private result + * (nothing extra goes on-chain), who hands them to recipients out of band. + * + * Value creation carries no default gate: `_mint` / `_mintNote` are ungated + * building blocks, and the composing contract decides who may create value. + * The `_`-prefixed blocks (`_mintNote`, `_transfer`, `_burn`, `_consumeNote`) + * accept caller-built notes, so a composing contract can source nonces from its + * own emission policy; `_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, + * with no owner secret, so any party that knows a nonce derives the SAME + * nullifier. Output nonces MUST therefore be unique and unpredictable, and + * `wit_NonceRandomness` MUST return a fresh, secret seed per invocation. That + * same property is what lets a composing contract offer escrow-free clawback, + * where an authorized spend and the owner's own spend race for one nullifier + * and the first to land wins. + */ +module ConfidentialNoteFungibleToken { + import CompactStandardLibrary; + + /** + * @description A unit of value. Ownership is not a field: a note belongs to + * whoever's `pk` was bound into its commitment (see `commitOf`). + * @type {Uint<128>} value - The amount the note carries. + * @type {Field} nonce - The note's unique, secret nonce. It provides the + * commitment's hiding entropy and is the sole nullifier preimage, so it is + * spend-critical (see the module's `@dev` note). + */ + export struct Note { + value: Uint<128>; + nonce: Field; + } + + /** + * @description Domain-separated preimage of a note commitment. + * @type {Bytes<32>} domain - The commitment domain tag. + * @type {Uint<128>} value - The note's value. + * @type {Field} nonce - The note's nonce. + * @type {Field} pk - The owner's spend identity. + */ + struct CommitPreimage { + domain: Bytes<32>; + value: Uint<128>; + nonce: Field; + pk: Field; + } + + /** + * @description Domain-separated preimage of a nullifier. It deliberately + * omits the owner, so the nullifier depends only on the note's nonce. + * @type {Bytes<32>} domain - The nullifier domain tag. + * @type {Field} nonce - The note's nonce. + */ + struct NullifierPreimage { + domain: Bytes<32>; + nonce: Field; + } + + /** + * @description Append-only tree of note commitments: what value exists. It + * never says whose or how much, and it does not prevent double-spends — that + * is `_nullifiers`' job. + * @dev History matters. A proof built against a slightly stale root still + * verifies after later inserts, so concurrent spenders do not invalidate each + * other's in-flight proofs. + * @type {HistoricMerkleTree<32, Bytes<32>>} _commitments - The commitment tree. + */ + export ledger _commitments: HistoricMerkleTree<32, Bytes<32>>; + + /** + * @description Published nullifiers: what has been spent. A nullifier is + * unlinkable to its note's commitment, owner, or value, so the set reveals + * only how many spends have happened. + * @type {Set>} _nullifiers - The spent-note nullifier set. + */ + export ledger _nullifiers: Set>; + + /** + * @witness wit_SecretKey + * @description Returns the caller's spend secret, from which the circuits + * derive the identity `pk = Hf(sk)` that owns notes. + * + * @returns {Bytes<32>} secretKey - A 32-byte cryptographically secure random + * value. + */ + witness wit_SecretKey(): Bytes<32>; + + /** + * @witness wit_InputNote + * @description Returns the note the next spend consumes. The wallet chooses + * it; the circuit proves it exists and is unspent. + * + * @returns {Note} inputNote - The note to consume. + */ + witness wit_InputNote(): Note; + + /** + * @witness wit_Path + * @description Returns the Merkle path proving `cm` is a leaf of the + * commitment tree. The path stays private, so the proof reveals membership + * without revealing which leaf. + * + * @param {Bytes<32>} cm - The input note's commitment. + * @returns {MerkleTreePath<32, Bytes<32>>} path - The path from `cm` to a + * root the tree recognizes, current or historical. + */ + witness wit_Path(cm: Bytes<32>): MerkleTreePath<32, Bytes<32>>; + + /** + * @witness wit_NonceRandomness + * @description Returns the seed the core derives its default output nonces + * from. + * + * @dev MUST be fresh and secret per invocation. A reused seed re-derives an + * identical note, and because the nullifier depends only on the nonce, the + * two notes share one nullifier: spending either burns both. + * + * @returns {Bytes<32>} seed - A 32-byte cryptographically secure random + * value. + */ + witness wit_NonceRandomness(): Bytes<32>; + + /** + * @description UNGATED building block: mints a note of `value` to + * `recipientPk` with a core-derived fresh nonce and returns it, so the caller + * can hand it to the recipient out of band. + * + * @circuitInfo k=14, rows=10964 + * + * @notice The amount is NOT written to public state — issuance stays hidden + * and only the hiding commitment appears on-chain. This is the property an + * account-based token cannot offer. + * + * @warning No authorization. The composer gates who may create value. + * + * @param {Field} recipientPk - The identity receiving the note. + * @param {Uint<128>} value - The amount to create. + * @return {Note} - The new note, returned as a local private result. + */ + export circuit _mint(recipientPk: Field, value: Uint<128>): Note { + const note = Note { value: value, nonce: freshNonce(pad(32, "OZ:note: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 Derives one output nonce from the caller's randomness witness. + * + * @dev The `slot` tag separates the nonces of several outputs created in one + * invocation, which share a single witness call: without it, a transfer's + * output and change would collide on one nonce, hence one nullifier. + * + * @param {Bytes<32>} slot - The per-output domain tag. + * @return {Field} - The output note's nonce. + */ + circuit freshNonce(slot: Bytes<32>): Field { + return degradeToTransient(persistentHash>>( + [wit_NonceRandomness(), pad(32, "OZ:note:nonce:core"), slot])); + } + + /** + * @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 + * + * @warning No authorization and no value accounting. Re-exporting this from a + * deployed contract is a permissionless mint. + * + * @param {Note} note - The note to commit. + * @param {Field} ownerPk - The identity that will own it. + * @return {[]} - Empty tuple. + */ + export circuit _mintNote(note: Note, ownerPk: Field): [] { + // Only the hiding commitment crosses to public state. + _commitments.insert(disclose(commitOf(note, ownerPk))); + } + + /** + * @description `cm = H(domain, value, nonce, pk)`. Exported so off-chain + * viewers can recompute commitments for notes they recover. + * + * @notice The commitment binds all three of value, nonce, and owner, which is + * what makes ownership unforgeable: a non-owner's `pk` hashes to a leaf that + * is not in the tree, so no membership proof exists for it. + * + * @param {Note} note - The note to commit to. + * @param {Field} pk - The identity that owns the note. + * @return {Bytes<32>} - The hiding commitment stored in `_commitments`. + */ + export pure circuit commitOf(note: Note, pk: Field): Bytes<32> { + return persistentHash(CommitPreimage { + domain: pad(32, "OZ:note:commit"), + value: note.value, + nonce: note.nonce, + pk: pk + }); + } + + /** + * @description Burns `value` from the caller's input note: spends the note + * and re-issues only the change, so `value` leaves circulation. + * + * @circuitInfo k=15, rows=25584 + * + * @notice Both the burned amount and the burner stay hidden; publicly a burn + * is indistinguishable from any other spend. The core writes no supply, so + * nothing on the ledger records that value was destroyed. + * + * Requirements: + * + * - The input note is committed in the tree and unspent. + * - The caller owns the input note. + * - `value <= input.value`. + * + * @param {Uint<128>} value - The amount to burn. + * @return {Note} - The caller's change note, returned as a local private + * result. + */ + export circuit burn(value: Uint<128>): Note { + const pk = _spenderPk(); + const input = _inputNote(); + assert(input.value >= value, "ConfidentialNoteFungibleToken: insufficient note value"); + + const changeNote = Note { + value: (input.value - value) as Uint<128>, + nonce: freshNonce(pad(32, "OZ:note: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. + * + * @return {Field} - The caller's spend identity. + */ + export circuit _spenderPk(): Field { + return derivePk(wit_SecretKey()); + } + + /** + * @description Field-typed identity hash: `pk = Hf(sk)`. Exported so + * wallets, auditors, and tests derive identities the way the circuits do. + * + * @param {Bytes<32>} sk - The owner's spend secret. + * @return {Field} - The spend identity that owns notes. + */ + export pure circuit derivePk(sk: Bytes<32>): Field { + return degradeToTransient(persistentHash>(sk)); + } + + /** + * @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. + * + * @dev The peek is not trusted. Any mismatch with what the composer then + * passes to `_transfer` / `_burn` is caught by their conservation asserts, + * which re-read this same witness. + * + * @return {Note} - The note the next spend will consume. + */ + export circuit _inputNote(): Note { + return wit_InputNote(); + } + + /** + * @description UNGATED building block: consumes the input note owned by + * `spenderPk` and re-issues only `changeNote`, so `value` leaves circulation. + * + * @circuitInfo k=15, rows=19119 + * + * @warning No authorization. The composer decides who may call this. + * + * Requirements: + * + * - The input note is committed in the tree and unspent. + * - The input note's commitment binds `spenderPk`. + * - `input.value == value + changeNote.value`. + * + * @param {Field} spenderPk - The identity that owns the input note. + * @param {Uint<128>} value - The amount destroyed. + * @param {Note} changeNote - The spender's change note. + * @return {[]} - Empty tuple. + */ + export circuit _burn(spenderPk: Field, value: Uint<128>, changeNote: Note): [] { + const input = _consumeNote(spenderPk); + assert(input.value == value + changeNote.value, + "ConfidentialNoteFungibleToken: burn does not conserve value"); + _mintNote(changeNote, spenderPk); + } + + /** + * @description UNGATED building block and the heart of the module: consumes + * the witness-supplied input note owned by `ownerPk`. It proves the note's + * commitment is in the tree at some historical root without revealing which + * leaf, checks the note is unspent, and publishes its nullifier. + * + * @circuitInfo k=14, rows=12248 + * + * @notice Used by `_transfer` / `_burn`, and directly by custom spend paths + * such as clawback, where `ownerPk` is the target's identity and the + * authorization is the caller's own. + * + * @warning No authorization: the caller need not own the note. Anyone holding + * a note's nonce and its owner's `pk` can nullify it, which is deliberate + * (see the module's `@dev` note) and is why nonces must stay secret. + * + * Requirements: + * + * - The input note is committed in the tree, under `ownerPk`, and unspent. + * - The witnessed path's leaf is the input note's commitment. + * - The path's root is one the tree recognizes, current or historical. + * + * @param {Field} ownerPk - The identity the input note is committed to. + * @return {Note} - The consumed note, for the caller's value accounting. + */ + 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), + "ConfidentialNoteFungibleToken: input root not recognized"); + assert(cm == path.leaf, + "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)), + "ConfidentialNoteFungibleToken: note already spent"); + _nullifiers.insert(disclose(nf)); + + return input; + } + + /** + * @description `nf = H(domain, nonce)` — derivable by anyone who knows the + * nonce. Exported so off-chain viewers can watch a note's consumption. + * + * @dev The preimage omits any owner secret by design. Every party that learns + * a nonce derives the same nullifier and races the owner for the single + * spend, which is what makes clawback escrow-free in a composing contract. + * + * @param {Note} note - The note to nullify. + * @return {Bytes<32>} - The nullifier published in `_nullifiers` when the + * note is spent. + */ + export pure circuit nullifierOf(note: Note): Bytes<32> { + return persistentHash(NullifierPreimage { + domain: pad(32, "OZ:note:null"), + nonce: note.nonce + }); + } + + /** + * @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. Sender and recipient are both hidden; the public + * ledger gains one nullifier and two commitments. + * + * @circuitInfo k=16, rows=36394 + * + * @notice Both notes return to the caller as a local private result, so the + * wallet can hand the recipient note over out of band and track its own + * change. Nothing beyond the two commitments reaches the ledger. + * + * Requirements: + * + * - The input note is committed in the tree and unspent. + * - The caller owns the input note (its commitment binds `Hf(wit_SecretKey())`). + * - `value <= input.value`. + * + * @param {Field} recipientPk - The identity receiving the output note. + * @param {Uint<128>} value - The amount to send. + * @return {[Note, Note]} - The recipient's note and the caller's change note. + */ + export circuit transfer(recipientPk: Field, value: Uint<128>): [Note, Note] { + const pk = _spenderPk(); + const input = _inputNote(); + assert(input.value >= value, "ConfidentialNoteFungibleToken: insufficient note value"); + + const outNote = Note { value: value, nonce: freshNonce(pad(32, "OZ:note:out")) }; + const changeNote = Note { + value: (input.value - value) as Uint<128>, + nonce: freshNonce(pad(32, "OZ:note:chg")) + }; + _transfer(pk, recipientPk, outNote, changeNote); + return [disclose(outNote), disclose(changeNote)]; + } + + /** + * @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. + * + * @circuitInfo k=15, rows=25732 + * + * @notice Taking both output notes as parameters is what lets a composing + * contract own its emission policy (audit-derived nonces, delivery records, + * supply accounting) while the core still enforces conservation. + * + * @warning No authorization. Whoever supplies a valid input note and its path + * can spend it; the composer decides who may call this. + * + * Requirements: + * + * - The input note is committed in the tree and unspent. + * - The input note's commitment binds `spenderPk`. + * - `input.value == outNote.value + changeNote.value`. + * + * @param {Field} spenderPk - The identity that owns the input note. + * @param {Field} recipientPk - The identity receiving `outNote`. + * @param {Note} outNote - The recipient's output note. + * @param {Note} changeNote - The spender's change note. + * @return {[]} - Empty tuple. + */ + export circuit _transfer(spenderPk: Field, recipientPk: Field, outNote: Note, changeNote: Note): [] { + const input = _consumeNote(spenderPk); + assert(input.value == outNote.value + changeNote.value, + "ConfidentialNoteFungibleToken: transfer does not conserve value"); + _mintNote(outNote, recipientPk); + _mintNote(changeNote, spenderPk); + } +} diff --git a/contracts/src/token/test/ConfidentialNoteFungibleToken.compatibility.test.ts b/contracts/src/token/test/ConfidentialNoteFungibleToken.compatibility.test.ts new file mode 100644 index 00000000..6f9f49b4 --- /dev/null +++ b/contracts/src/token/test/ConfidentialNoteFungibleToken.compatibility.test.ts @@ -0,0 +1,271 @@ +/** + * Compatibility claims for the ConfidentialNoteFungibleToken core. + * + * Every other suite compares the module against itself, so all of them stay green + * when the WIRE FORMAT moves: rename a domain tag or reorder a hash preimage and + * every digest moves together, keeping relative assertions consistent. + * + * This suite pins absolute values and the shape of the published state, the two + * things an outside party depends on. A holder rebuilds a commitment and derives a + * nullifier to spend; move either and their note is unspendable. A client reads + * the ledger by slot and calls circuits by name; move a slot index or unexport a + * field and it reads the wrong thing. + * + * SO A FAILURE HERE IS NOT A TEST TO FIX, in order of likelihood: + * + * 1. Revert. Most failures are accidental. + * 2. Accept deliberately. Pre-release nothing is deployed to break, as with the + * `OZ:cnt:` to `OZ:note:` rename. Regenerate in the same commit and say so. + * 3. Post-release, it is a breaking change needing a migration. + * + * Never regenerate a value without deciding which of the three it is. + * + * PROVENANCE. Every value below is byte-identical under compiler 0.31.0 (CI) and + * 0.31.1 (current local), on language 0.23.0 and runtime 0.16.0. Digests, layout, + * and circuit surface alike. + * + * Recorded, not asserted. Those compilers differ in name and agree on every byte, + * so a pinned `compiler-version` would fail with nothing broken. The enforceable + * pin is `.github/actions/setup/action.yml`. On a toolchain bump, rebuild under + * both compilers and diff instead of assuming. + * + * Circuit complexity (k, rows) is not pinned here; it needs a non-`SKIP_ZK` + * build. See OpenZeppelin/compact-contracts#750. + */ + +import { beforeEach, describe, expect, it } from 'vitest'; +import { + type CircuitSurface, + circuitSurface, + type Exhaustive, + ledgerSlots, + type NameOf, + readContractInfo, +} from '#test-utils/compiler/contractInfo.js'; +import type { + Circuits, + Ledger, + ProvableCircuits, +} from '../../../artifacts/MockConfidentialNoteFungibleToken/contract/index.js'; +import { pureCircuits as core } from '../../../artifacts/MockConfidentialNoteFungibleToken/contract/index.js'; +import { ConfidentialNoteFungibleTokenSimulator } from './simulators/ConfidentialNoteFungibleTokenSimulator.js'; +import type { Note } from './witnesses/ConfidentialNoteFungibleTokenWitnesses.js'; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +/** A deterministic 32-byte secret key from a label. */ +const secretKey = (label: string): Uint8Array => { + const sk = new Uint8Array(32); + sk.set(new TextEncoder().encode(label)); + return sk; +}; + +const ALICE_SK = secretKey('ALICE'); +const ALICE = core.derivePk(ALICE_SK); +const BOB = core.derivePk(secretKey('BOB')); + +/** Stands in for wallet randomness, the only non-deterministic mint input. */ +const FIXED_SEED = secretKey('FIXED-NONCE-SEED'); + +/** Lowercase `0x…` rendering, so a failed vector prints readably. */ +const hex = (bytes: Uint8Array): string => + `0x${Buffer.from(bytes).toString('hex')}`; + +/** The one note every digest below is taken over. */ +const NOTE: Note = { value: 100n, nonce: 7n }; + +// --------------------------------------------------------------------------- +// Digests +// --------------------------------------------------------------------------- + +/** + * Domain-separated hashes. The tags are permanent parts of the format: + * `OZ:note:commit`, `OZ:note:null`, `OZ:note:nonce:core`, `OZ:note:out`, + * `OZ:note:chg`. + * + * `derivePk` has no tag of its own, and is pinned because every commitment is + * taken over its output. + */ +describe('ConfidentialNoteFungibleToken compatibility: digests', () => { + // Pure circuits: no deployment, so these run on either backend. + + it('should derive the pinned pk from a known secret', () => { + expect(core.derivePk(ALICE_SK)).toBe( + 327106606165982063573363806696144765309444401206486966729313816924943346449n, + ); + }); + + it('should commit a known note to the pinned digest', () => { + expect(hex(core.commitOf(NOTE, ALICE))).toBe( + '0x7ef9ff74353b2baa237f53d015dde72177fa05beb954226d2d290dbffebdc772', + ); + }); + + it('should nullify a known note to the pinned digest', () => { + expect(hex(core.nullifierOf(NOTE))).toBe( + '0xeea890f67c3c07ea1850ba30f4059f45313d9294dcd7fbffce5a393dd69ceff9', + ); + }); +}); + +// --------------------------------------------------------------------------- +// Nonce derivation +// --------------------------------------------------------------------------- + +/** + * These deploy, since nonce derivation happens inside a circuit. Worth the cost on + * live too: it proves the deployed bytecode derives the same nonces as the local + * artifact, which nothing else here checks. + */ +describe('ConfidentialNoteFungibleToken compatibility: nonce derivation', () => { + let token: ConfidentialNoteFungibleTokenSimulator; + + beforeEach(async () => { + token = await ConfidentialNoteFungibleTokenSimulator.create(); + token.wallet.nonceSeed = FIXED_SEED; + }); + + it('should derive the pinned nonce for a minted note', async () => { + const minted = await token._mint(ALICE, 100n); + + expect(minted.nonce).toBe( + 141839877545769226285799287554416334503102257183126676284284147282857489951n, + ); + }); + + it('should derive the pinned nonce for a change note', async () => { + const minted = await token._mint(ALICE, 100n); + token.wallet.secretKey = ALICE_SK; + token.wallet.inputNote = minted; + token.wallet.pathOverride = undefined; + token.wallet.nonceSeed = FIXED_SEED; + + const [, change] = await token.transfer(BOB, 30n); + + // A different slot tag from the output note, which is why one reused seed + // still yields two distinct nonces. + expect(change.nonce).toBe( + 55310597546632184040479428936926702560855239758036834627964489285919135708n, + ); + }); +}); + +// --------------------------------------------------------------------------- +// Published surface +// --------------------------------------------------------------------------- + +/** Emitted with or without keys, so this section runs under `SKIP_ZK`. */ +const contractInfo = () => + readContractInfo('MockConfidentialNoteFungibleToken'); + +describe('ConfidentialNoteFungibleToken compatibility: published surface', () => { + /** + * Every field costs something if it moves. `index` is the slot a client reads. + * `storage` decides semantics: `HistoricMerkleTree` accepts a recently-current + * root where `MerkleTree` accepts only the current one, which is what lets + * concurrent spends coexist with mints. `depth` fixes capacity and is part of + * the serialized form. `exported` decides whether clients see the slot. + * + * Asserted whole, so an ADDED or REMOVED slot fails too. + */ + it('should keep the pinned ledger layout', () => { + expect(ledgerSlots(contractInfo())).toStrictEqual([ + { + name: '_commitments', + index: 0, + exported: true, + storage: 'HistoricMerkleTree', + depth: 32, + type: { 'type-name': 'Bytes', length: 32 }, + }, + { + name: '_nullifiers', + index: 1, + exported: true, + storage: 'Set', + type: { 'type-name': 'Bytes', length: 32 }, + }, + ]); + }); + + /** + * `proof` is the load-bearing flag: a circuit touching no ledger state has an + * empty public transcript, gets no verifier key, and cannot be called on a + * deployed instance. `_spenderPk` and `_inputNote` are in that class, which is + * why the functional suite skips them on live. Flipping one changes what a + * client may do without changing any behaviour a test would notice. + * + * Keyed on `Circuits`, the generated type, so TS rejects this table if a circuit + * is added, removed, or renamed. Sorted by name because dispatch is by name. + */ + const SURFACE: Exhaustive< + NameOf>, + Pick + > = { + _burn: { pure: false, proof: true }, + _consumeNote: { pure: false, proof: true }, + _inputNote: { pure: false, proof: false }, + _mint: { pure: false, proof: true }, + _mintNote: { pure: false, proof: true }, + _spenderPk: { pure: false, proof: false }, + _transfer: { pure: false, proof: true }, + burn: { pure: false, proof: true }, + commitOf: { pure: true, proof: false }, + derivePk: { pure: true, proof: false }, + nullifierOf: { pure: true, proof: false }, + transfer: { pure: false, proof: true }, + }; + + it('should keep the pinned circuit surface', () => { + const expected = Object.entries(SURFACE) + .map(([name, flags]) => ({ name, ...flags })) + .sort((left, right) => left.name.localeCompare(right.name)); + + expect(circuitSurface(contractInfo())).toStrictEqual(expected); + }); + + /** + * The JSON and the generated `.d.ts` describe the same contract independently, + * and a client trusts both, so they have to agree. `ProvableCircuits` is the + * compiler's own answer to what a deployed instance accepts. + */ + it('should agree with the generated circuit types on what is callable', () => { + const provable = circuitSurface(contractInfo()) + .filter(({ proof }) => proof) + .map(({ name }) => name); + + const declared: Exhaustive>> = { + _burn: true, + _consumeNote: true, + _mint: true, + _mintNote: true, + _transfer: true, + burn: true, + transfer: true, + }; + + expect(provable).toStrictEqual(Object.keys(declared).sort()); + }); + + /** + * `Ledger` is the generated reader and holds only EXPORTED slots, so the exported + * subset must be exactly its keys. Unexporting a slot removes it from every + * client's reader while leaving it in the state, a silent break. + * + * The `Core__` prefix comes from the mock importing the module prefixed. + */ + it('should export exactly the slots the generated Ledger type exposes', () => { + const exported = ledgerSlots(contractInfo()) + .filter(({ exported }) => exported) + .map(({ name }) => `Core_${name}`); + + const declared: Exhaustive> = { + Core__commitments: true, + Core__nullifiers: true, + }; + + expect(exported.sort()).toStrictEqual(Object.keys(declared).sort()); + }); +}); diff --git a/contracts/src/token/test/ConfidentialNoteFungibleToken.concurrency.test.ts b/contracts/src/token/test/ConfidentialNoteFungibleToken.concurrency.test.ts new file mode 100644 index 00000000..a1573ec1 --- /dev/null +++ b/contracts/src/token/test/ConfidentialNoteFungibleToken.concurrency.test.ts @@ -0,0 +1,268 @@ +/** + * Concurrency claims for the ConfidentialNoteFungibleToken core. + * + * Whether a call still lands once someone else's call moved the ledger first. + * + * Every case builds two calls against one snapshot, lands the first, then applies + * the second. See `#test-utils/concurrency/types.ts` for why that reproduces a + * real conflict deterministically, with no race to lose. + * + * EXHAUSTIVE, NOT SAMPLED. Five callable operations give 25 ordered pairs, doubled + * for spend-vs-spend which splits on whether both spend the same note. Enumerating + * a space that small beats sampling it. Each case asserts against {@link predict} + * rather than a memorised answer, so a new circuit with unexpected pinning fails + * here instead of silently widening the gap between model and module. + * + * What each operation pins: + * + * `_mint` / `_mintNote` insert into `_commitments` only. Inserts append at the + * LIVE first-free index, so they commute. + * `transfer` / `burn` / read `_nullifiers.member(nf)`, pinning that ONE key, + * `_consumeNote` and `checkRoot(root)`, which on a HistoricMerkleTree + * pins "in history" and survives concurrent inserts. + * + * `_mint` x `transfer` is the load-bearing row: swap `HistoricMerkleTree` for + * `MerkleTree` and it fails, since plain `checkRoot` pins the CURRENT root. Nothing + * else notices that one-word change, so keep that row if this matrix is trimmed. + */ + +import { isLiveBackend } from '@openzeppelin/compact-simulator'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { createConcurrencyHarness } from '#test-utils/concurrency/backend.js'; +import { + createParties, + labelledSecret, + type Party, +} from '#test-utils/concurrency/parties.js'; +import { race } from '#test-utils/concurrency/race.js'; +import type { + Call, + ConcurrencyHarness, + Outcome, +} from '#test-utils/concurrency/types.js'; +import { + pureCircuits as core, + Contract as MockCore, +} from '../../../artifacts/MockConfidentialNoteFungibleToken/contract/index.js'; +import { + ConfidentialNoteFungibleTokenWitnesses, + createNoteWallet, + type Note, + type NoteWallet, +} from './witnesses/ConfidentialNoteFungibleTokenWitnesses.js'; + +// --------------------------------------------------------------------------- +// Two parties, one ledger +// --------------------------------------------------------------------------- + +type PrivateState = Record; +type NoteParty = Party>; + +/** + * A party's spend secret comes from its name, so the derived public keys below + * are stable across every `beforeEach` and can be computed once. + */ +const noteParties = () => + createParties>(['alice', 'bob'], { + wallet: (label) => { + const wallet = createNoteWallet(); + wallet.secretKey = labelledSecret(label); + return wallet; + }, + contract: (wallet) => + new MockCore(ConfidentialNoteFungibleTokenWitnesses(wallet)), + }); + +const ALICE = core.derivePk(labelledSecret('alice')); +const BOB = core.derivePk(labelledSecret('bob')); + +const NOTE_VALUE = 100n; +const SPEND_VALUE = 30n; + +/** A caller-built note, for the one operation that takes one as an argument. */ +const CALLER_BUILT: Note = { value: 5n, nonce: 42n }; + +// --------------------------------------------------------------------------- +// The operation space, and the conflict model it is measured against +// --------------------------------------------------------------------------- + +/** Every circuit the mock exposes that writes to the ledger. */ +const OPERATIONS = [ + '_mint', + '_mintNote', + 'transfer', + 'burn', + '_consumeNote', +] as const; + +type Operation = (typeof OPERATIONS)[number]; + +/** Whether an operation consumes the caller's input note. */ +const SPENDS: Readonly> = { + _mint: false, + _mintNote: false, + transfer: true, + burn: true, + _consumeNote: true, +}; + +/** + * The conflict model in one line: two calls built on one snapshot collide only + * where they pin the same key, and the only pinned key here is a nullifier. + * + * @param first - The operation that lands. + * @param second - The operation applied against the moved state. + * @param sameNote - Whether both calls spend the one note. + */ +const predict = ( + first: Operation, + second: Operation, + sameNote: boolean, +): Outcome => + SPENDS[first] && SPENDS[second] && sameNote + ? 'second-rejected' + : 'both-landed'; + +// --------------------------------------------------------------------------- +// The matrix +// --------------------------------------------------------------------------- + +interface MatrixCase { + readonly first: Operation; + readonly second: Operation; + /** Only meaningful when both operations spend. */ + readonly sameNote: boolean; + readonly expected: Outcome; + readonly name: string; +} + +const describeCase = ( + first: Operation, + second: Operation, + sameNote: boolean, + expected: Outcome, +): string => { + const bothSpend = SPENDS[first] && SPENDS[second]; + const notes = bothSpend + ? sameNote + ? ' on one note' + : ' on separate notes' + : ''; + return expected === 'both-landed' + ? `should let ${first} and ${second} both land${notes}` + : `should not let ${first} and ${second} both land${notes}`; +}; + +/** Every ordered pair, split on note sharing wherever that can matter. */ +const MATRIX: readonly MatrixCase[] = OPERATIONS.flatMap((first) => + OPERATIONS.flatMap((second) => { + const sharings = SPENDS[first] && SPENDS[second] ? [true, false] : [false]; + return sharings.map((sameNote) => ({ + first, + second, + sameNote, + expected: predict(first, second, sameNote), + name: describeCase( + first, + second, + sameNote, + predict(first, second, sameNote), + ), + })); + }), +); + +// TODO: Support live concurrency https://github.com/OpenZeppelin/compact-contracts/issues/749 +describe.skipIf(isLiveBackend())( + 'ConfidentialNoteFungibleToken: concurrency', + () => { + // These cases are backend-neutral: they assert a verdict from `race`, not a + // transport. The skip above comes off once the live harness lands. + let harness: ConcurrencyHarness; + let alice: NoteParty; + let bob: NoteParty; + + beforeEach(async () => { + const { parties, contracts } = noteParties(); + alice = parties.alice; + bob = parties.bob; + harness = await createConcurrencyHarness({ + contracts, + privateState: {}, + }); + }); + + /** Gives `holder` a spendable note, committed on the shared ledger. */ + const arm = async (holder: NoteParty, ownerPk: bigint): Promise => { + holder.wallet.inputNote = await harness.apply({ + actor: holder.name, + circuitId: '_mint', + args: [ownerPk, NOTE_VALUE], + }); + }; + + /** The call `actor` makes for `operation`, spending its own note. */ + const callFor = (actor: NoteParty, operation: Operation): Call => { + const self = actor === alice ? ALICE : BOB; + const other = actor === alice ? BOB : ALICE; + switch (operation) { + case '_mint': + return { + actor: actor.name, + circuitId: '_mint', + args: [self, NOTE_VALUE], + }; + case '_mintNote': + return { + actor: actor.name, + circuitId: '_mintNote', + args: [CALLER_BUILT, self], + }; + case 'transfer': + return { + actor: actor.name, + circuitId: 'transfer', + args: [other, SPEND_VALUE], + }; + case 'burn': + return { actor: actor.name, circuitId: 'burn', args: [SPEND_VALUE] }; + case '_consumeNote': + return { + actor: actor.name, + circuitId: '_consumeNote', + args: [self], + }; + } + }; + + for (const testCase of MATRIX) { + it(testCase.name, async () => { + // Same note means one party issuing both calls; separate notes means two + // parties, each armed with its own. + const secondParty = testCase.sameNote ? alice : bob; + + if (SPENDS[testCase.first]) { + await arm(alice, ALICE); + } + if (SPENDS[testCase.second] && secondParty !== alice) { + await arm(secondParty, BOB); + } + if ( + SPENDS[testCase.second] && + secondParty === alice && + !SPENDS[testCase.first] + ) { + await arm(alice, ALICE); + } + + const verdict = await race( + harness, + callFor(alice, testCase.first), + callFor(secondParty, testCase.second), + ); + + expect(verdict).toBe(testCase.expected); + }); + } + }, +); diff --git a/contracts/src/token/test/ConfidentialNoteFungibleToken.invariant.test.ts b/contracts/src/token/test/ConfidentialNoteFungibleToken.invariant.test.ts new file mode 100644 index 00000000..6c357355 --- /dev/null +++ b/contracts/src/token/test/ConfidentialNoteFungibleToken.invariant.test.ts @@ -0,0 +1,275 @@ +/** + * Stateful invariants of the note core, over generated operation sequences. + * + * Neither the functional nor the property suite says anything about a SEQUENCE: + * that after any interleaving of mint, transfer, and burn, the ledger still agrees + * with what a wallet believes. So this carries a shadow model of an honest wallet + * and re-checks it after every step. + * + * Conservation is checked per operation, not globally, since the core keeps no + * supply figure: `transfer` splits the input exactly, `burn` destroys exactly what + * it was asked to. + * + * DRY ONLY. Each operation is a transaction on live, so 20 runs of 6 would be some + * 120 txs and 90 minutes. Fuzzing is a dry technique here; live covers the same + * circuits by example. + */ + +import { isLiveBackend } from '@openzeppelin/compact-simulator'; +import fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { expectRejection } from '#test-utils/assertions/rejection.js'; +import { pureCircuits as core } from '../../../artifacts/MockConfidentialNoteFungibleToken/contract/index.js'; +import { ConfidentialNoteFungibleTokenSimulator } from './simulators/ConfidentialNoteFungibleTokenSimulator.js'; +import type { Note } from './witnesses/ConfidentialNoteFungibleTokenWitnesses.js'; + +// --------------------------------------------------------------------------- +// Identities +// --------------------------------------------------------------------------- + +const secretKey = (label: string): Uint8Array => { + const sk = new Uint8Array(32); + sk.set(new TextEncoder().encode(label)); + return sk; +}; + +const ALICE_SK = secretKey('ALICE'); +const ALICE = core.derivePk(ALICE_SK); +const BOB = core.derivePk(secretKey('BOB')); + +// --------------------------------------------------------------------------- +// The operations a sequence is built from +// --------------------------------------------------------------------------- + +type Op = + | { readonly kind: 'mint'; readonly value: bigint } + | { readonly kind: 'transfer'; readonly pct: bigint } + | { readonly kind: 'burn'; readonly pct: bigint }; + +const amount = () => fc.bigInt({ min: 1n, max: 200n }); + +/** + * A spend names a PERCENTAGE of the held note, so it scales with the state. + * Absolutes collapse onto the insufficient-value guard as the change note shrinks + * (measured: 106 of 223 steps). The range runs past 100 to keep exercising that + * guard, just not exclusively. + */ +const percentage = () => fc.bigInt({ min: 0n, max: 120n }); + +/** + * Spends outweigh mints and every sequence opens with a mint, so the budget goes on + * real state transitions. Without both, runs were dominated by spends against an + * empty wallet (measured: 154 of 306 steps), which has its own case in the + * functional suite. + */ +const opArb: fc.Arbitrary = fc.oneof( + { + arbitrary: fc.record({ + kind: fc.constant('mint' as const), + value: amount(), + }), + weight: 1, + }, + { + arbitrary: fc.record({ + kind: fc.constant('transfer' as const), + pct: percentage(), + }), + weight: 2, + }, + { + arbitrary: fc.record({ + kind: fc.constant('burn' as const), + pct: percentage(), + }), + weight: 2, + }, +); + +/** A sequence that always has something to spend from its second step on. */ +const sequence = (maxOps: number): fc.Arbitrary => + fc + .tuple( + fc.bigInt({ min: 1n, max: 200n }), + fc.array(opArb, { minLength: 1, maxLength: maxOps }), + ) + .map(([opening, rest]) => [ + { kind: 'mint' as const, value: opening }, + ...rest, + ]); + +/** The wallet's belief about the world, maintained independently of the ledger. */ +interface Model { + commitments: bigint; + spends: bigint; + /** Every note spent so far, kept whole so a replay can be re-presented. */ + spentNotes: Note[]; + /** What ALICE can spend next; undefined until the first mint. */ + held: Note | undefined; + /** Notes transferred out to BOB, which ALICE can no longer touch. */ + bobNotes: Note[]; +} + +describe.skipIf(isLiveBackend())( + 'ConfidentialNoteFungibleToken: invariants under generated op sequences', + () => { + let token: ConfidentialNoteFungibleTokenSimulator; + + const publicState = () => token.getPublicState(); + + const commitmentCount = async (): Promise => + (await publicState()).Core__commitments.firstFree(); + + const nullifierCount = async (): Promise => + (await publicState()).Core__nullifiers.size(); + + const isSpent = async (note: Note): Promise => + (await publicState()).Core__nullifiers.member(core.nullifierOf(note)); + + const isCommitted = async (note: Note, ownerPk: bigint): Promise => + (await publicState()).Core__commitments.findPathForLeaf( + core.commitOf(note, ownerPk), + ) !== undefined; + + /** Points the next spend at `note`, as the owner of `sk`. */ + const spendAs = (sk: Uint8Array, note: Note): void => { + token.wallet.secretKey = sk; + token.wallet.inputNote = note; + token.wallet.pathOverride = undefined; + }; + + /** Re-checks every invariant that must hold after each operation. */ + const checkInvariants = async (model: Model): Promise => { + expect(await commitmentCount()).toBe(model.commitments); + expect(await nullifierCount()).toBe(model.spends); + + // A nullifier set only ever grows: nothing un-spends. + for (const spent of model.spentNotes) { + expect(await isSpent(spent)).toBe(true); + } + // The note the wallet believes it holds really is committed to it. + if (model.held !== undefined) { + expect(await isCommitted(model.held, ALICE)).toBe(true); + expect(await isSpent(model.held)).toBe(false); + } + }; + + /** Applies one operation to both the contract and the model. */ + const step = async (op: Op, model: Model): Promise => { + if (op.kind === 'mint') { + const minted = await token._mint(ALICE, op.value); + + expect(minted.value).toBe(op.value); + model.commitments += 1n; + model.held = minted; + return; + } + + // Unreachable: every sequence opens with a mint. Spending an empty wallet + // has its own named case in the functional suite. + const input = model.held; + if (input === undefined) { + throw new Error('invariants: sequence generator failed to seed a note'); + } + + spendAs(ALICE_SK, input); + // Resolve the percentage against what is actually held. Integer division, + // so a small note plus a small percentage legitimately yields a zero-value + // spend, which the module treats as spendable padding. + const value = (input.value * op.pct) / 100n; + + if (value > input.value) { + // The guard, reached by generated amounts rather than a chosen one. + await expectRejection( + op.kind === 'transfer' + ? token.transfer(BOB, value) + : token.burn(value), + 'ConfidentialNoteFungibleToken: insufficient note value', + ); + return; + } + + if (op.kind === 'transfer') { + const [out, change] = await token.transfer(BOB, value); + + // Conservation: the input is split exactly, nothing created or lost. + expect(out.value + change.value).toBe(input.value); + expect(out.value).toBe(value); + model.commitments += 2n; + model.bobNotes.push(out); + model.held = change; + } else { + const change = await token.burn(value); + + // Conservation: exactly `op.value` destroyed, the rest re-issued. + expect(change.value).toBe(input.value - value); + model.commitments += 1n; + model.held = change; + } + + model.spends += 1n; + model.spentNotes.push(input); + }; + + it('should keep the ledger consistent with the model after every step', async () => { + await fc.assert( + fc.asyncProperty(sequence(6), async (ops) => { + token = await ConfidentialNoteFungibleTokenSimulator.create(); + const model: Model = { + commitments: 0n, + spends: 0n, + spentNotes: [], + held: undefined, + bobNotes: [], + }; + + for (const op of ops) { + await step(op, model); + await checkInvariants(model); + } + + // Recipient notes are committed to BOB and remain unspent: ALICE + // never had the means to spend them. + for (const note of model.bobNotes) { + expect(await isCommitted(note, BOB)).toBe(true); + expect(await isSpent(note)).toBe(false); + } + }), + { numRuns: 20 }, + ); + }); + + it('should never let a spent note be spent again', async () => { + await fc.assert( + fc.asyncProperty(sequence(5), async (ops) => { + token = await ConfidentialNoteFungibleTokenSimulator.create(); + const model: Model = { + commitments: 0n, + spends: 0n, + spentNotes: [], + held: undefined, + bobNotes: [], + }; + + for (const op of ops) { + await step(op, model); + } + fc.pre(model.spends > 0n); + + // Re-present the FIRST note the sequence spent, verbatim. Using the + // exact note matters: its commitment is still in the tree, so the + // call reaches the nullifier check instead of failing earlier on a + // missing path, which would prove nothing about double spending. + const replayed = model.spentNotes[0] as Note; + spendAs(ALICE_SK, replayed); + + await expectRejection( + token.burn(0n), + 'ConfidentialNoteFungibleToken: note already spent', + ); + }), + { numRuns: 15 }, + ); + }); + }, +); diff --git a/contracts/src/token/test/ConfidentialNoteFungibleToken.privacy.test.ts b/contracts/src/token/test/ConfidentialNoteFungibleToken.privacy.test.ts new file mode 100644 index 00000000..4db4629c --- /dev/null +++ b/contracts/src/token/test/ConfidentialNoteFungibleToken.privacy.test.ts @@ -0,0 +1,786 @@ +/** + * Privacy claims for the ConfidentialNoteFungibleToken core, as executable + * assertions rather than prose. + * + * The functional suite asks what a circuit did. This one asks what the chain + * gets to see: it drives the contract directly so it can read `proofData`, the + * per-call record a real transaction carries. + * + * publicTranscript the ledger operations the transaction publishes + * privateTranscriptOutputs the witness answers, which stay on the prover + * + * Three layers, weakest to strongest: + * + * 1. no secret's byte encoding appears in the public transcript, + * 2. the transcript's SHAPE does not vary with the secrets, + * 3. two runs differing only in a secret differ only in hash digests. + * + * Layer 3 is the one that catches a value-dependent branch, the classic leak in + * a Compact circuit: the branch bit shows up as a different operation sequence + * even when no value is ever disclosed. + * + * Layers 1-3 run dry, because `proofData` is produced by the in-memory path. A + * fourth layer runs LIVE and is the ground truth the others stand in for: the + * serialized transaction as the indexer stored it, scanned for the same + * secrets. Run it with `MIDNIGHT_BACKEND=live`; see the live describe below for + * why the scan uses high-entropy secrets. + */ + +import { randomBytes } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import type { + AlignedValue, + CircuitResults, + Op, +} from '@midnight-ntwrk/compact-runtime'; +import { + bigIntToValue, + dummyContractAddress, +} from '@midnight-ntwrk/compact-runtime'; +import { + CircuitContextManager, + isLiveBackend, +} from '@openzeppelin/compact-simulator'; +import fc from 'fast-check'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { + awaitPublishedTxs, + indexerHead, +} from '#test-utils/harness/publishedTx.js'; +import { + pureCircuits as core, + ledger, + Contract as MockCore, +} from '../../../artifacts/MockConfidentialNoteFungibleToken/contract/index.js'; +import { ConfidentialNoteFungibleTokenSimulator } from './simulators/ConfidentialNoteFungibleTokenSimulator.js'; +import { + ConfidentialNoteFungibleTokenWitnesses, + createNoteWallet, + type Note, + type NoteWallet, +} from './witnesses/ConfidentialNoteFungibleTokenWitnesses.js'; + +// --------------------------------------------------------------------------- +// Probe: the contract driven directly, so `proofData` survives the call +// --------------------------------------------------------------------------- + +const secretKey = (label: string): Uint8Array => { + const sk = new Uint8Array(32); + sk.set(new TextEncoder().encode(label)); + return sk; +}; + +const ALICE_SK = secretKey('ALICE'); +const BOB_SK = secretKey('BOB'); +const ALICE = core.derivePk(ALICE_SK); +const BOB = core.derivePk(BOB_SK); + +// Planted so two probes derive byte-identical notes; the differential tests +// need every input equal except the one secret under study. +const SEED = secretKey('DIFFERENTIAL-SEED'); + +/** The core declares no private state; the wallet carries the secrets. */ +type PrivateState = Record; + +type Trace = { + transcript: Op[]; + privateOutputs: AlignedValue[]; + input: AlignedValue; + output: AlignedValue; +}; + +class Probe { + readonly wallet: NoteWallet; + private readonly contract; + private readonly manager; + + constructor() { + this.wallet = createNoteWallet(); + this.wallet.nonceSeed = SEED; + this.contract = new MockCore( + ConfidentialNoteFungibleTokenWitnesses(this.wallet), + ); + this.manager = new CircuitContextManager( + this.contract, + {}, + '0'.repeat(64), + dummyContractAddress(), + ); + } + + private run(call: () => CircuitResults): [T, Trace] { + const { result, context, proofData } = call(); + this.manager.setContext(context); + return [ + result, + { + transcript: proofData.publicTranscript, + privateOutputs: proofData.privateTranscriptOutputs, + input: proofData.input, + output: proofData.output, + }, + ]; + } + + mint(recipientPk: bigint, value: bigint): [Note, Trace] { + return this.run(() => + this.contract.impureCircuits._mint( + this.manager.getContext(), + recipientPk, + value, + ), + ); + } + + transfer(recipientPk: bigint, value: bigint): [[Note, Note], Trace] { + return this.run(() => + this.contract.impureCircuits.transfer( + this.manager.getContext(), + recipientPk, + value, + ), + ); + } + + burn(value: bigint): [Note, Trace] { + return this.run(() => + this.contract.impureCircuits.burn(this.manager.getContext(), value), + ); + } + + consumeNote(ownerPk: bigint): [Note, Trace] { + return this.run(() => + this.contract.impureCircuits._consumeNote( + this.manager.getContext(), + ownerPk, + ), + ); + } + + spend(sk: Uint8Array, note: Note): void { + this.wallet.secretKey = sk; + this.wallet.inputNote = note; + } + + get state() { + return ledger(this.manager.getContext().currentQueryContext.state.state); + } +} + +// --------------------------------------------------------------------------- +// Reading a transcript +// --------------------------------------------------------------------------- + +const hex = (bytes: Uint8Array): string => Buffer.from(bytes).toString('hex'); + +/** + * The runtime stores byte values with trailing zeros stripped, so a 32-byte + * secret whose tail is padding appears in a transcript under its trimmed form. + * Searching for the padded form would pass vacuously. + */ +const encoded = (bytes: Uint8Array): string => { + let end = bytes.length; + while (end > 0 && bytes[end - 1] === 0) end--; + return hex(bytes.subarray(0, end)); +}; + +/** Every byte string appearing anywhere in a transcript or aligned value. */ +const bytesIn = (node: unknown, found: string[] = []): string[] => { + if (node instanceof Uint8Array) { + found.push(hex(node)); + } else if (Array.isArray(node)) { + for (const child of node) bytesIn(child, found); + } else if (node !== null && typeof node === 'object') { + for (const child of Object.values(node)) bytesIn(child, found); + } + return found; +}; + +/** The operation sequence with every operand stripped: the public shape. */ +const shapeOf = (transcript: Op[]): string[] => + transcript.map((op) => + typeof op === 'string' ? op : (Object.keys(op as object)[0] ?? '?'), + ); + +/** Byte encodings a field-typed secret could plausibly appear as. */ +const encodingsOf = (value: bigint): string[] => bigIntToValue(value).map(hex); + +/** + * The digest-width values a transcript publishes: hashes, roots, nullifiers. + * + * NOT an exact 32 bytes. The runtime zero-trims leading zero bytes, so a digest + * beginning `0x00` arrives 31 bytes wide, roughly one time in 256. Filtering on + * exactly 64 hex characters therefore drops real digests at random, which is how + * this layer became flaky once its inputs were generated rather than chosen. + * + * The bound below keeps every plausibly-trimmed digest while still excluding the + * small tags and indices a transcript also carries: misclassifying a digest now + * needs five leading zero bytes, about one in a trillion. + */ +const DIGEST_MIN_HEX = 56; + +const digestsIn = (trace: Trace): string[] => + bytesIn(trace.transcript).filter((b) => b.length >= DIGEST_MIN_HEX); + +const CORE_SOURCE = readFileSync( + new URL('../ConfidentialNoteFungibleToken.compact', import.meta.url), + 'utf8', +); + +// --------------------------------------------------------------------------- +// What reaches the public transcript +// --------------------------------------------------------------------------- + +describe.skipIf(isLiveBackend())( + 'ConfidentialNoteFungibleToken privacy: the public transcript', + () => { + // The tree stores the hash of the leaf, so even the commitment stays off + // the wire: the transaction carries one opaque digest, and only a holder + // who can rebuild the note can recognise it. + it('should publish one opaque digest per mint, not the commitment itself', () => { + const probe = new Probe(); + const [note, trace] = probe.mint(ALICE, 1000n); + const commitment = core.commitOf(note, ALICE); + + expect(bytesIn(trace.transcript)).not.toContain(hex(commitment)); + expect(digestsIn(trace)).toHaveLength(1); + // The note really was committed, so the assertions above are not vacuous. + expect( + probe.state.Core__commitments.findPathForLeaf(commitment) !== undefined, + ).toBe(true); + }); + + it('should not carry the minted amount', () => { + const probe = new Probe(); + const [, trace] = probe.mint(ALICE, 1000n); + const published = bytesIn(trace.transcript); + + for (const encoding of encodingsOf(1000n)) { + expect(published).not.toContain(encoding); + } + }); + + it('should not carry the note nonce or the owner identity', () => { + const probe = new Probe(); + const [note, trace] = probe.mint(ALICE, 1000n); + const published = bytesIn(trace.transcript); + + for (const encoding of encodingsOf(note.nonce)) { + expect(published).not.toContain(encoding); + } + for (const encoding of encodingsOf(ALICE)) { + expect(published).not.toContain(encoding); + } + }); + + it('should not carry the spend secret of a burn', () => { + const probe = new Probe(); + const [note] = probe.mint(ALICE, 1000n); + probe.spend(ALICE_SK, note); + const [, trace] = probe.burn(400n); + const published = bytesIn(trace.transcript); + + expect(published).not.toContain(encoded(ALICE_SK)); + for (const encoding of encodingsOf(400n)) { + expect(published).not.toContain(encoding); + } + }); + + // The mirror image of the checks above: the secrets do exist, on the side + // that never leaves the prover. Without this, a probe that simply failed to + // read anything would satisfy every `not.toContain` above. + it('should carry the spend secret on the private side only', () => { + const probe = new Probe(); + const [note] = probe.mint(ALICE, 1000n); + probe.spend(ALICE_SK, note); + const [, trace] = probe.burn(400n); + + expect(bytesIn(trace.privateOutputs)).toContain(encoded(ALICE_SK)); + expect(bytesIn(trace.transcript)).not.toContain(encoded(ALICE_SK)); + }); + + it('should publish the nullifier of a spent note', () => { + const probe = new Probe(); + const [note] = probe.mint(ALICE, 1000n); + probe.spend(ALICE_SK, note); + const [, trace] = probe.burn(400n); + + expect(bytesIn(trace.transcript)).toContain(hex(core.nullifierOf(note))); + }); + + it('should publish exactly two commitments and one nullifier per transfer', () => { + const probe = new Probe(); + const [note] = probe.mint(ALICE, 1000n); + const before = probe.state; + probe.spend(ALICE_SK, note); + probe.transfer(BOB, 300n); + const after = probe.state; + + expect(after.Core__commitments.firstFree()).toBe( + before.Core__commitments.firstFree() + 2n, + ); + expect(after.Core__nullifiers.size()).toBe( + before.Core__nullifiers.size() + 1n, + ); + }); + }, +); + +// --------------------------------------------------------------------------- +// Indistinguishability: the shape does not depend on the secrets +// --------------------------------------------------------------------------- + +describe.skipIf(isLiveBackend())( + 'ConfidentialNoteFungibleToken privacy: indistinguishability', + () => { + /** A transfer of `value` to `recipientPk`, from an identical starting note. */ + const transferTrace = (recipientPk: bigint, value: bigint): Trace => { + const probe = new Probe(); + const [note] = probe.mint(ALICE, 1000n); + probe.spend(ALICE_SK, note); + return probe.transfer(recipientPk, value)[1]; + }; + + const mintTrace = (recipientPk: bigint, value: bigint): Trace => { + const probe = new Probe(); + return probe.mint(recipientPk, value)[1]; + }; + + /** + * Inputs are GENERATED here rather than chosen. + * + * These claims are the ones a hand-picked pair is weakest at: a planted + * `disclose(value)` survived this layer once because both probes happened to + * mint the same amount. Generating both sides of every comparison removes + * that whole class of coincidence. + * + * Run counts are small on purpose. Each case drives two full circuit + * executions, so a default 100 runs would add tens of seconds to a suite + * that is otherwise instant. + */ + const UINT128_MAX = (1n << 128n) - 1n; + + /** + * Any `Uint<128>`. Spanning the full width matters: an amount that leaked + * would most likely surface as a CHANGE IN BYTE LENGTH, which only shows up + * when the generated values straddle encoding boundaries. + */ + const anyAmount = () => fc.bigInt({ min: 0n, max: UINT128_MAX }); + + /** An amount the 1000-value input note below can actually pay. */ + const payableAmount = () => fc.bigInt({ min: 0n, max: 1000n }); + + /** A recipient, derived from a generated secret so it is a valid `Field`. */ + const anyRecipientPk = () => + fc + .uint8Array({ minLength: 32, maxLength: 32 }) + .map((sk) => core.derivePk(sk)); + + it('should mint with the same transcript shape for any amount', () => { + fc.assert( + fc.property(anyAmount(), anyAmount(), (a, b) => { + expect(shapeOf(mintTrace(ALICE, a).transcript)).toStrictEqual( + shapeOf(mintTrace(ALICE, b).transcript), + ); + }), + { numRuns: 15 }, + ); + }); + + it('should transfer with the same transcript shape for any amount', () => { + fc.assert( + fc.property(payableAmount(), payableAmount(), (a, b) => { + expect(shapeOf(transferTrace(BOB, a).transcript)).toStrictEqual( + shapeOf(transferTrace(BOB, b).transcript), + ); + }), + { numRuns: 10 }, + ); + }); + + it('should transfer with the same transcript shape for any recipient', () => { + fc.assert( + fc.property(anyRecipientPk(), anyRecipientPk(), (first, second) => { + expect(shapeOf(transferTrace(first, 300n).transcript)).toStrictEqual( + shapeOf(transferTrace(second, 300n).transcript), + ); + }), + { numRuns: 10 }, + ); + }); + + it('should transfer with the same transcript length for any amount', () => { + fc.assert( + fc.property(payableAmount(), payableAmount(), (a, b) => { + const left = bytesIn(transferTrace(BOB, a).transcript); + const right = bytesIn(transferTrace(BOB, b).transcript); + + expect(left.length).toBe(right.length); + expect(left.map((bytes) => bytes.length)).toStrictEqual( + right.map((bytes) => bytes.length), + ); + }), + { numRuns: 10 }, + ); + }); + + /** The byte strings that move between two otherwise identical runs. */ + const drift = (a: Trace, b: Trace): string[] => { + const left = bytesIn(a.transcript); + const right = bytesIn(b.transcript); + expect(left).toHaveLength(right.length); + return left.filter((value, i) => value !== right[i]); + }; + + /** + * Nothing that moved is anything but an opaque digest. + * + * The substantive claim is the non-containment: whatever moved is not the + * encoding of any secret the two runs differed in. Width is only a + * structural sanity bound, and deliberately not an equality: a digest whose + * leading byte is zero is published one byte shorter, so requiring exactly + * 64 hex characters would fail on roughly one draw in 256. + */ + const expectOpaque = (moved: string[], secrets: bigint[]): void => { + for (const value of moved) { + expect(value.length).toBeLessThanOrEqual(64); + for (const secret of secrets) { + expect(encodingsOf(secret)).not.toContain(value); + } + } + }; + + it('should move only one digest when the minted amount differs', () => { + fc.assert( + fc.property(anyAmount(), anyAmount(), (a, b) => { + fc.pre(a !== b); + + const moved = drift(mintTrace(ALICE, a), mintTrace(ALICE, b)); + + expect(moved).toHaveLength(1); + expectOpaque(moved, [a, b, ALICE]); + }), + { numRuns: 15 }, + ); + }); + + it('should move only one digest when the mint recipient differs', () => { + fc.assert( + fc.property( + payableAmount(), + anyRecipientPk(), + anyRecipientPk(), + (value, first, second) => { + fc.pre(first !== second); + + const moved = drift( + mintTrace(first, value), + mintTrace(second, value), + ); + + expect(moved).toHaveLength(1); + expectOpaque(moved, [value, first, second]); + }, + ), + { numRuns: 15 }, + ); + }); + + // The strongest claim in the file. Two transfers of wildly different + // amounts publish byte-identical transactions except for the two output + // leaf digests, and those are hashes. + it('should move only the two output digests when the amount differs', () => { + fc.assert( + fc.property(payableAmount(), payableAmount(), (a, b) => { + fc.pre(a !== b); + + const probeA = new Probe(); + const [inputA] = probeA.mint(ALICE, 1000n); + probeA.spend(ALICE_SK, inputA); + const [notesA, traceA] = probeA.transfer(BOB, a); + + const probeB = new Probe(); + const [inputB] = probeB.mint(ALICE, 1000n); + probeB.spend(ALICE_SK, inputB); + const [notesB, traceB] = probeB.transfer(BOB, b); + + // Same starting note, so the spend half of the transaction is + // identical. + expect(inputA).toStrictEqual(inputB); + // Sanity: the two runs really did carry different amounts. + expect(notesA[0].value).not.toBe(notesB[0].value); + + const moved = drift(traceA, traceB); + expect(moved).toHaveLength(2); // the output note and the change note + expectOpaque(moved, [ + a, + b, + notesA[0].nonce, + notesA[1].nonce, + ALICE, + BOB, + ]); + }), + { numRuns: 10 }, + ); + }); + + // Only the recipient's own digest moves. The change note's digest does not, + // so a watcher cannot even tell that the recipient changed. + it('should move only one digest when the recipient differs', () => { + fc.assert( + fc.property( + payableAmount(), + anyRecipientPk(), + anyRecipientPk(), + (value, first, second) => { + fc.pre(first !== second); + + const probeA = new Probe(); + const [inputA] = probeA.mint(ALICE, 1000n); + probeA.spend(ALICE_SK, inputA); + const [, traceA] = probeA.transfer(first, value); + + const probeB = new Probe(); + const [inputB] = probeB.mint(ALICE, 1000n); + probeB.spend(ALICE_SK, inputB); + const [, traceB] = probeB.transfer(second, value); + + const moved = drift(traceA, traceB); + expect(moved).toHaveLength(1); + expectOpaque(moved, [value, ALICE, first, second]); + }, + ), + { numRuns: 10 }, + ); + }); + + // The nullifier depends on the nonce alone, so a caller who never held the + // owner's secret publishes the same one. That is what makes an owner spend + // and a clawback mutually exclusive, and why nonces are spend-critical. + it('should publish the same nullifier whoever consumes the note', () => { + const probeA = new Probe(); + const [note] = probeA.mint(ALICE, 1000n); + probeA.spend(ALICE_SK, note); + const [, traceA] = probeA.consumeNote(ALICE); + + // A second deployment, same note, consumed by a caller holding Bob's + // secret and naming Alice as the owner: the ungated clawback path. + const probeB = new Probe(); + probeB.mint(ALICE, 1000n); + probeB.spend(BOB_SK, note); + const [, traceB] = probeB.consumeNote(ALICE); + + const nullifier = hex(core.nullifierOf(note)); + expect(bytesIn(traceA.transcript)).toContain(nullifier); + expect(bytesIn(traceB.transcript)).toContain(nullifier); + expect(drift(traceA, traceB)).toStrictEqual([]); + }); + }, +); + +// --------------------------------------------------------------------------- +// The disclose surface +// --------------------------------------------------------------------------- + +describe.skipIf(isLiveBackend())( + 'ConfidentialNoteFungibleToken privacy: the disclose surface', + () => { + // Every crossing of the privacy boundary is one `disclose()`. Pinning the + // exact set means a new one fails a test instead of relying on review to + // catch it. Update this list only with a reviewed justification. + const EXPECTED_DISCLOSURES = [ + // to public state + '_commitments.insert(disclose(commitOf(note, ownerPk)));', + 'const root = disclose(merkleTreePathRoot<32, Bytes<32>>(path));', + 'assert(!_nullifiers.member(disclose(nf)),', + '_nullifiers.insert(disclose(nf));', + // to the local caller only, across the exported-circuit boundary + 'return disclose(note);', + 'return disclose(changeNote);', + 'return [disclose(outNote), disclose(changeNote)];', + ]; + + it('should disclose only at the reviewed sites', () => { + const sites = CORE_SOURCE.split('\n') + .map((line) => line.trim()) + .filter((line) => line.includes('disclose(') && !line.startsWith('*')); + + expect(new Set(sites)).toStrictEqual(new Set(EXPECTED_DISCLOSURES)); + }); + + it('should not disclose a witness value directly', () => { + // `disclose(wit_...)` would publish a secret verbatim. Every legitimate + // disclosure above publishes a hash, a root, or a locally-returned note. + expect(CORE_SOURCE).not.toMatch(/disclose\(\s*wit_/); + }); + + it('should write no public state outside the tree and the nullifier set', () => { + const ledgerFields = CORE_SOURCE.split('\n') + .filter((line) => line.trim().startsWith('export ledger')) + .map((line) => line.trim()); + + expect(ledgerFields).toStrictEqual([ + 'export ledger _commitments: HistoricMerkleTree<32, Bytes<32>>;', + 'export ledger _nullifiers: Set>;', + ]); + }); + }, +); + +// --------------------------------------------------------------------------- +// Ground truth: the transaction as the chain stored it +// --------------------------------------------------------------------------- + +/** + * The layers above read `proofData`, a faithful preimage of the transaction. + * This one reads the transaction itself, fetched back from the indexer, and + * asks the same question of the bytes a real observer receives. + * + * Two things shape how these tests are written: + * + * - SECRETS MUST BE HIGH-ENTROPY. A serialized transaction is a large blob of + * proof bytes. Searching it for a short encoding (a `1000n` amount is two + * bytes) finds a match by coincidence, so a naive scan fails on a contract + * that leaks nothing. Every secret below is long enough that an accidental + * hit is negligible: 32-byte keys, a 15-byte amount. + * - NO DIFFERENTIAL LAYER. Two real transactions differ in their proofs, fees, + * and wallet nonces no matter what the circuit does, so the byte-identical + * comparison that makes layer 3 strong cannot work here. Layer 3 stays dry; + * this layer is presence-scanning plus published state. + * + * Run single-worker (`MIDNIGHT_LIVE_WORKERS=1`): the scan reads every + * transaction in the block window, and a concurrent spec's transactions would + * be swept in with them. + */ +describe.runIf(isLiveBackend())( + 'ConfidentialNoteFungibleToken privacy: the published transaction', + () => { + // 32 random bytes: no padding to trim, nothing to collide with. + const liveSecret = (): Uint8Array => new Uint8Array(randomBytes(32)); + + // ~15 bytes of entropy, comfortably inside Uint<128> and far too wide to + // turn up in a proof blob by chance. + const liveAmount = (): bigint => + BigInt(`0x${Buffer.from(randomBytes(15)).toString('hex')}`); + + let token: ConfidentialNoteFungibleTokenSimulator; + + beforeEach(async () => { + token = await ConfidentialNoteFungibleTokenSimulator.create(); + }); + + it('should not publish the amount, the nonce, or the owner in the transaction', async () => { + const ownerSk = liveSecret(); + const ownerPk = core.derivePk(ownerSk); + const amount = liveAmount(); + + const from = await indexerHead(); + const note = await token._mint(ownerPk, amount); + const published = await awaitPublishedTxs(from); + + expect(published.length).toBeGreaterThan(0); + const wire = published.map((tx) => tx.raw.toLowerCase()).join(''); + + for (const encoding of encodingsOf(amount)) { + expect(wire).not.toContain(encoding); + } + for (const encoding of encodingsOf(note.nonce)) { + expect(wire).not.toContain(encoding); + } + for (const encoding of encodingsOf(ownerPk)) { + expect(wire).not.toContain(encoding); + } + expect(wire).not.toContain(encoded(ownerSk)); + // The commitment is hashed into the tree, so not even that reaches the + // wire in recognisable form. + expect(wire).not.toContain(hex(core.commitOf(note, ownerPk))); + }); + + it('should not publish the spend secret of a transfer', async () => { + const senderSk = liveSecret(); + const senderPk = core.derivePk(senderSk); + const recipientPk = core.derivePk(liveSecret()); + const amount = liveAmount(); + + const note = await token._mint(senderPk, amount); + token.wallet.secretKey = senderSk; + token.wallet.inputNote = note; + + const from = await indexerHead(); + const [out] = await token.transfer(recipientPk, amount); + const published = await awaitPublishedTxs(from); + const wire = published.map((tx) => tx.raw.toLowerCase()).join(''); + + expect(wire).not.toContain(encoded(senderSk)); + for (const encoding of encodingsOf(recipientPk)) { + expect(wire).not.toContain(encoding); + } + for (const encoding of encodingsOf(out.nonce)) { + expect(wire).not.toContain(encoding); + } + for (const encoding of encodingsOf(amount)) { + expect(wire).not.toContain(encoding); + } + }); + + it('should publish the nullifier of the spent note', async () => { + const ownerSk = liveSecret(); + const ownerPk = core.derivePk(ownerSk); + const amount = liveAmount(); + + const note = await token._mint(ownerPk, amount); + token.wallet.secretKey = ownerSk; + token.wallet.inputNote = note; + + const from = await indexerHead(); + await token.burn(amount); + const published = await awaitPublishedTxs(from); + const wire = published.map((tx) => tx.raw.toLowerCase()).join(''); + + // The positive control: the scan above is only meaningful if this scan + // can find something. A nullifier is public by design. + expect(wire).toContain(hex(core.nullifierOf(note))); + }); + + it('should leave only the tree and the nullifier set in the published state', async () => { + const ownerSk = liveSecret(); + const ownerPk = core.derivePk(ownerSk); + + const note = await token._mint(ownerPk, liveAmount()); + token.wallet.secretKey = ownerSk; + token.wallet.inputNote = note; + await token.transfer(core.derivePk(liveSecret()), 1n); + + const state = await token.getPublicState(); + expect(Object.keys(state).sort()).toStrictEqual([ + 'Core__commitments', + 'Core__nullifiers', + ]); + expect(state.Core__commitments.firstFree()).toBe(3n); + expect(state.Core__nullifiers.size()).toBe(1n); + }); + + // A KNOWN, ACCEPTED LEAK, asserted so it stays a decision rather than a + // surprise: the ledger records which entry point a transaction called, so + // an observer learns a transfer happened, just not its amount or parties. + it('should publish the entry point, making the operation type public', async () => { + const ownerSk = liveSecret(); + const ownerPk = core.derivePk(ownerSk); + + const note = await token._mint(ownerPk, liveAmount()); + token.wallet.secretKey = ownerSk; + token.wallet.inputNote = note; + + const from = await indexerHead(); + await token.burn(1n); + const published = await awaitPublishedTxs(from); + + const entryPoints = published.flatMap((tx) => + tx.calls.map((call) => call.entryPoint), + ); + expect(entryPoints.length).toBeGreaterThan(0); + expect(entryPoints.every((point) => point.length > 0)).toBe(true); + }); + }, +); diff --git a/contracts/src/token/test/ConfidentialNoteFungibleToken.property.test.ts b/contracts/src/token/test/ConfidentialNoteFungibleToken.property.test.ts new file mode 100644 index 00000000..36992867 --- /dev/null +++ b/contracts/src/token/test/ConfidentialNoteFungibleToken.property.test.ts @@ -0,0 +1,144 @@ +/** + * Stateless properties of the note core's pure circuits. + * + * The functional suite states each claim at hand-picked points. This states the + * same claims over generated inputs, so they hold where nobody chose, and a + * failure shrinks to the smallest counterexample. + * + * Single calls only, no ledger, no deployment. Sequence claims live in + * `ConfidentialNoteFungibleToken.invariant.test.ts`. + * + * Runs on either backend: pure circuits evaluate locally even on live, so nothing + * here costs a block. + */ + +import fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { pureCircuits as core } from '../../../artifacts/MockConfidentialNoteFungibleToken/contract/index.js'; + +// --------------------------------------------------------------------------- +// Generators, each pinned to the circuit's own type +// --------------------------------------------------------------------------- + +/** The inclusive top of an unsigned 128-bit range. */ +const UINT128_MAX = (1n << 128n) - 1n; + +/** `Uint<128>`, the declared width of a note's value. */ +const value = () => fc.bigInt({ min: 0n, max: UINT128_MAX }); + +/** + * A note nonce is a `Field`. Generated at 128 bits, comfortably inside the + * scalar field, so the runtime never rejects an out-of-range element and every + * failure is about the circuit rather than the encoding. + */ +const nonce = () => fc.bigInt({ min: 0n, max: UINT128_MAX }); + +/** `Bytes<32>`, the declared width of a spend secret. */ +const secret = () => fc.uint8Array({ minLength: 32, maxLength: 32 }); + +const note = () => fc.record({ value: value(), nonce: nonce() }); + +/** An owner pk, derived rather than generated, so it is a valid `Field`. */ +const ownerPk = () => secret().map((sk) => core.derivePk(sk)); + +const hex = (bytes: Uint8Array): string => + `0x${Buffer.from(bytes).toString('hex')}`; + +// --------------------------------------------------------------------------- +// nullifierOf +// --------------------------------------------------------------------------- + +describe('ConfidentialNoteFungibleToken property: nullifierOf', () => { + // The design decision behind escrow-free clawback, generalized: the preimage + // is the nonce alone, so anyone who learns a nonce derives the nullifier. + it('should ignore the value, for any two values sharing a nonce', () => { + fc.assert( + fc.property(nonce(), value(), value(), (n, a, b) => { + expect(hex(core.nullifierOf({ value: a, nonce: n }))).toBe( + hex(core.nullifierOf({ value: b, nonce: n })), + ); + }), + ); + }); + + it('should differ whenever the nonce differs', () => { + fc.assert( + fc.property(value(), nonce(), nonce(), (v, n1, n2) => { + fc.pre(n1 !== n2); + expect(hex(core.nullifierOf({ value: v, nonce: n1 }))).not.toBe( + hex(core.nullifierOf({ value: v, nonce: n2 })), + ); + }), + ); + }); +}); + +// --------------------------------------------------------------------------- +// derivePk +// --------------------------------------------------------------------------- + +describe('ConfidentialNoteFungibleToken property: derivePk', () => { + it('should be deterministic for any secret', () => { + fc.assert( + fc.property(secret(), (sk) => { + expect(core.derivePk(sk)).toBe(core.derivePk(sk)); + }), + ); + }); + + it('should be injective across any two distinct secrets', () => { + fc.assert( + fc.property(secret(), secret(), (a, b) => { + fc.pre(hex(a) !== hex(b)); + expect(core.derivePk(a)).not.toBe(core.derivePk(b)); + }), + ); + }); +}); + +// --------------------------------------------------------------------------- +// commitOf +// --------------------------------------------------------------------------- + +describe('ConfidentialNoteFungibleToken property: commitOf', () => { + it('should bind the value: any two values commit differently', () => { + fc.assert( + fc.property(ownerPk(), nonce(), value(), value(), (pk, n, a, b) => { + fc.pre(a !== b); + expect(hex(core.commitOf({ value: a, nonce: n }, pk))).not.toBe( + hex(core.commitOf({ value: b, nonce: n }, pk)), + ); + }), + ); + }); + + it('should bind the nonce: any two nonces commit differently', () => { + fc.assert( + fc.property(ownerPk(), value(), nonce(), nonce(), (pk, v, n1, n2) => { + fc.pre(n1 !== n2); + expect(hex(core.commitOf({ value: v, nonce: n1 }, pk))).not.toBe( + hex(core.commitOf({ value: v, nonce: n2 }, pk)), + ); + }), + ); + }); + + // This is what makes ownership enforceable: a non-owner's pk yields a leaf + // that is not in the tree, so no membership proof exists for it. + it('should bind the owner: any two owners commit differently', () => { + fc.assert( + fc.property(note(), ownerPk(), ownerPk(), (n, pk1, pk2) => { + fc.pre(pk1 !== pk2); + expect(hex(core.commitOf(n, pk1))).not.toBe(hex(core.commitOf(n, pk2))); + }), + ); + }); + + it('should never collide with the nullifier of the same note', () => { + fc.assert( + fc.property(note(), ownerPk(), (n, pk) => { + expect(hex(core.commitOf(n, pk))).not.toBe(hex(core.nullifierOf(n))); + }), + ); + }); +}); diff --git a/contracts/src/token/test/ConfidentialNoteFungibleToken.test.ts b/contracts/src/token/test/ConfidentialNoteFungibleToken.test.ts new file mode 100644 index 00000000..391a4c19 --- /dev/null +++ b/contracts/src/token/test/ConfidentialNoteFungibleToken.test.ts @@ -0,0 +1,744 @@ +import { isLiveBackend } from '@openzeppelin/compact-simulator'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { expectRejection } from '#test-utils/assertions/rejection.js'; +import { pureCircuits as core } from '../../../artifacts/MockConfidentialNoteFungibleToken/contract/index.js'; +import { ConfidentialNoteFungibleTokenSimulator } from './simulators/ConfidentialNoteFungibleTokenSimulator.js'; +import type { Note } from './witnesses/ConfidentialNoteFungibleTokenWitnesses.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** A deterministic 32-byte secret key from a label. */ +const secretKey = (label: string): Uint8Array => { + const sk = new Uint8Array(32); + sk.set(new TextEncoder().encode(label)); + return sk; +}; + +const ALICE_SK = secretKey('ALICE'); +const BOB_SK = secretKey('BOB'); +const CAROL_SK = secretKey('CAROL'); + +// `pk = Hf(sk)`, computed off-circuit through the module's own pure circuit — +// the same derivation a wallet or auditor would run. +const ALICE = core.derivePk(ALICE_SK); +const BOB = core.derivePk(BOB_SK); +const CAROL = core.derivePk(CAROL_SK); + +const FIXED_SEED = secretKey('FIXED-NONCE-SEED'); + +let token: ConfidentialNoteFungibleTokenSimulator; + +/** Points the next spend at `note`, spending as the owner of `sk`. */ +const spendAs = (sk: Uint8Array, note: Note): void => { + token.wallet.secretKey = sk; + token.wallet.inputNote = note; + token.wallet.pathOverride = undefined; +}; + +const publicState = () => token.getPublicState(); + +/** Is `note` committed to `ownerPk` in the tree? */ +const isCommitted = async (note: Note, ownerPk: bigint): Promise => + (await publicState()).Core__commitments.findPathForLeaf( + core.commitOf(note, ownerPk), + ) !== undefined; + +/** Has `note` been spent (is its nullifier published)? */ +const isSpent = async (note: Note): Promise => + (await publicState()).Core__nullifiers.member(core.nullifierOf(note)); + +/** Number of leaves inserted so far. */ +const commitmentCount = async (): Promise => + (await publicState()).Core__commitments.firstFree(); + +/** Number of notes spent so far. */ +const nullifierCount = async (): Promise => + (await publicState()).Core__nullifiers.size(); + +const pathFor = async (note: Note, ownerPk: bigint) => { + const path = (await publicState()).Core__commitments.findPathForLeaf( + core.commitOf(note, ownerPk), + ); + if (path === undefined) throw new Error('test setup: note not committed'); + return path; +}; + +// --------------------------------------------------------------------------- +// Deployment baseline +// --------------------------------------------------------------------------- + +describe('ConfidentialNoteFungibleToken: initial state', () => { + beforeEach(async () => { + token = await ConfidentialNoteFungibleTokenSimulator.create(); + }); + + it('should start with an empty commitment tree', async () => { + const ledger = await publicState(); + expect(ledger.Core__commitments.firstFree()).toBe(0n); + expect(ledger.Core__commitments.isFull()).toBe(false); + }); + + it('should start with an empty nullifier set', async () => { + const ledger = await publicState(); + expect(ledger.Core__nullifiers.isEmpty()).toBe(true); + expect(ledger.Core__nullifiers.size()).toBe(0n); + }); + + // The core holds no roles and no init flag: value creation is available on a + // fresh deployment, and the composing contract is what gates it. + it('should mint on a fresh deployment with no initialization', async () => { + const note = await token._mint(ALICE, 100n); + expect(await isCommitted(note, ALICE)).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// _mint +// --------------------------------------------------------------------------- + +describe('ConfidentialNoteFungibleToken: _mint', () => { + beforeEach(async () => { + token = await ConfidentialNoteFungibleTokenSimulator.create(); + }); + + it('should publish exactly one commitment and no nullifier', async () => { + const note = await token._mint(ALICE, 100n); + + expect(await commitmentCount()).toBe(1n); + expect(await nullifierCount()).toBe(0n); + expect(await isCommitted(note, ALICE)).toBe(true); + expect(await isSpent(note)).toBe(false); + }); + + it('should return the requested value with a non-zero nonce', async () => { + const note = await token._mint(ALICE, 100n); + + expect(note.value).toBe(100n); + expect(note.nonce).not.toBe(0n); + }); + + it('should not commit the note to any other owner', async () => { + const note = await token._mint(ALICE, 100n); + expect(await isCommitted(note, BOB)).toBe(false); + }); + + it('should derive a distinct nonce per mint', async () => { + const first = await token._mint(ALICE, 100n); + const second = await token._mint(ALICE, 100n); + + expect(second.nonce).not.toBe(first.nonce); + expect(await commitmentCount()).toBe(2n); + }); + + // Ungated by design: no secret is read, so any caller mints to any pk. The + // composing contract is responsible for the issuer gate. + it('should mint without reading the caller secret', async () => { + token.wallet.secretKey = BOB_SK; + const note = await token._mint(ALICE, 100n); + + expect(await isCommitted(note, ALICE)).toBe(true); + expect(await isCommitted(note, BOB)).toBe(false); + }); + + it('should mint a zero-value note that is spendable padding', async () => { + const note = await token._mint(ALICE, 0n); + expect(note.value).toBe(0n); + expect(await isCommitted(note, ALICE)).toBe(true); + + spendAs(ALICE_SK, note); + await token.burn(0n); + expect(await isSpent(note)).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// freshNonce (nonce hygiene) +// --------------------------------------------------------------------------- + +describe('ConfidentialNoteFungibleToken: freshNonce', () => { + beforeEach(async () => { + token = await ConfidentialNoteFungibleTokenSimulator.create(); + }); + + it('should derive unpredictable nonces from the default fresh randomness', async () => { + const first = await token._mint(ALICE, 100n); + const second = await token._mint(ALICE, 100n); + + expect(second.nonce).not.toBe(first.nonce); + expect(core.nullifierOf(second)).not.toEqual(core.nullifierOf(first)); + }); + + // Why `wit_NonceRandomness` must return a fresh secret seed per call: a reused + // seed re-derives the same note, and the two share one nullifier, so spending + // either one burns both. + it('should collapse two mints into one spendable note when the seed is reused', async () => { + token.wallet.nonceSeed = FIXED_SEED; + const first = await token._mint(ALICE, 100n); + const second = await token._mint(ALICE, 100n); + + expect(second).toStrictEqual(first); + expect(await commitmentCount()).toBe(2n); + + spendAs(ALICE_SK, first); + await token.burn(100n); + + expect(await isSpent(second)).toBe(true); + token.wallet.inputNote = second; + await expect(token.burn(100n)).rejects.toThrow( + 'ConfidentialNoteFungibleToken: note already spent', + ); + }); +}); + +// --------------------------------------------------------------------------- +// _mintNote +// --------------------------------------------------------------------------- + +describe('ConfidentialNoteFungibleToken: _mintNote', () => { + beforeEach(async () => { + token = await ConfidentialNoteFungibleTokenSimulator.create(); + }); + + it('should commit a caller-built note with a caller-chosen nonce', async () => { + const note = { value: 42n, nonce: 12345n }; + await token._mintNote(note, ALICE); + + expect(await isCommitted(note, ALICE)).toBe(true); + expect(await commitmentCount()).toBe(1n); + }); + + it('should commit distinct leaves for equal notes to distinct owners', async () => { + const forAlice = await token._mint(ALICE, 100n); + const sameValueForBob = { value: 100n, nonce: forAlice.nonce }; + await token._mintNote(sameValueForBob, BOB); + + expect(core.commitOf(forAlice, ALICE)).not.toEqual( + core.commitOf(sameValueForBob, BOB), + ); + expect(await isCommitted(forAlice, ALICE)).toBe(true); + expect(await isCommitted(sameValueForBob, BOB)).toBe(true); + }); + + // The tree is append-only and does not deduplicate; single-spend is the + // nullifier set's job, not the tree's. + it('should append a duplicate leaf when the same note is minted twice', async () => { + const note = { value: 42n, nonce: 12345n }; + await token._mintNote(note, ALICE); + await token._mintNote(note, ALICE); + + expect(await commitmentCount()).toBe(2n); + }); +}); + +// --------------------------------------------------------------------------- +// commitOf +// --------------------------------------------------------------------------- + +describe('ConfidentialNoteFungibleToken: commitOf', () => { + it('should commit to value, nonce, and owner together', () => { + const note = { value: 100n, nonce: 7n }; + const commitment = core.commitOf(note, ALICE); + + expect(core.commitOf({ value: 101n, nonce: 7n }, ALICE)).not.toEqual( + commitment, + ); + expect(core.commitOf({ value: 100n, nonce: 8n }, ALICE)).not.toEqual( + commitment, + ); + expect(core.commitOf(note, BOB)).not.toEqual(commitment); + expect(core.commitOf({ value: 100n, nonce: 7n }, ALICE)).toEqual( + commitment, + ); + }); +}); + +// --------------------------------------------------------------------------- +// burn +// --------------------------------------------------------------------------- + +describe('ConfidentialNoteFungibleToken: burn', () => { + let input: Note; + + beforeEach(async () => { + token = await ConfidentialNoteFungibleTokenSimulator.create(); + input = await token._mint(ALICE, 100n); + spendAs(ALICE_SK, input); + }); + + it('should spend the note and re-issue only the change', async () => { + const change = await token.burn(30n); + + expect(change.value).toBe(70n); + expect(await isCommitted(change, ALICE)).toBe(true); + expect(await isSpent(input)).toBe(true); + expect(await commitmentCount()).toBe(2n); // the mint plus the change + expect(await nullifierCount()).toBe(1n); + }); + + it('should leave a zero-value change note when the whole note is burned', async () => { + const change = await token.burn(100n); + + expect(change.value).toBe(0n); + expect(await isCommitted(change, ALICE)).toBe(true); + }); + + it('should let the owner spend the change', async () => { + const change = await token.burn(30n); + + spendAs(ALICE_SK, change); + const [out] = await token.transfer(BOB, 70n); + + expect(out.value).toBe(70n); + expect(await isCommitted(out, BOB)).toBe(true); + }); + + it('should not burn more than the note holds', async () => { + await expect(token.burn(101n)).rejects.toThrow( + 'ConfidentialNoteFungibleToken: insufficient note value', + ); + }); + + it('should not burn the same note twice', async () => { + await token.burn(30n); + spendAs(ALICE_SK, input); + + await expect(token.burn(30n)).rejects.toThrow( + 'ConfidentialNoteFungibleToken: note already spent', + ); + }); + + it('should not let anyone other than the owner burn', async () => { + spendAs(BOB_SK, input); + + await expectRejection( + token.burn(30n), + 'wit_Path: commitment not found in tree', + ); + }); +}); + +// --------------------------------------------------------------------------- +// _spenderPk +// --------------------------------------------------------------------------- + +// Impure but NOT provable: each reads a witness yet touches no ledger state, so +// its public transcript is empty, compactc registers no on-chain operation and +// emits no verifier key (`ProvableCircuits` in the generated artifact lists 7 of +// the 9 impure circuits). Callable in-circuit only, which is how `burn` and +// `transfer` use them, so the live backend has no transaction to submit. +describe.skipIf(isLiveBackend())( + 'ConfidentialNoteFungibleToken: _spenderPk', + () => { + let input: Note; + + beforeEach(async () => { + token = await ConfidentialNoteFungibleTokenSimulator.create(); + input = await token._mint(ALICE, 100n); + spendAs(ALICE_SK, input); + }); + + it('should derive the caller pk in-circuit exactly as derivePk does', async () => { + token.wallet.secretKey = ALICE_SK; + expect(await token._spenderPk()).toEqual(ALICE); + }); + }, +); + +// --------------------------------------------------------------------------- +// derivePk +// --------------------------------------------------------------------------- + +describe('ConfidentialNoteFungibleToken: derivePk', () => { + it('should derive the same pk for the same secret', () => { + expect(core.derivePk(ALICE_SK)).toEqual(ALICE); + }); + + it('should derive distinct pks for distinct secrets', () => { + expect(new Set([ALICE, BOB, CAROL]).size).toBe(3); + }); +}); + +// --------------------------------------------------------------------------- +// _inputNote +// --------------------------------------------------------------------------- + +// Impure but NOT provable: each reads a witness yet touches no ledger state, so +// its public transcript is empty, compactc registers no on-chain operation and +// emits no verifier key (`ProvableCircuits` in the generated artifact lists 7 of +// the 9 impure circuits). Callable in-circuit only, which is how `burn` and +// `transfer` use them, so the live backend has no transaction to submit. +describe.skipIf(isLiveBackend())( + 'ConfidentialNoteFungibleToken: _inputNote', + () => { + let input: Note; + + beforeEach(async () => { + token = await ConfidentialNoteFungibleTokenSimulator.create(); + input = await token._mint(ALICE, 100n); + spendAs(ALICE_SK, input); + }); + + it('should read the input note the next spend will consume', async () => { + expect(await token._inputNote()).toStrictEqual(input); + }); + }, +); + +// --------------------------------------------------------------------------- +// _burn: the conserving building block +// --------------------------------------------------------------------------- + +describe('ConfidentialNoteFungibleToken: _burn', () => { + let input: Note; + + beforeEach(async () => { + token = await ConfidentialNoteFungibleTokenSimulator.create(); + input = await token._mint(ALICE, 100n); + spendAs(ALICE_SK, input); + }); + + it('should accept a burn that conserves value', async () => { + const change = { value: 70n, nonce: 222n }; + await token._burn(ALICE, 30n, change); + + expect(await isCommitted(change, ALICE)).toBe(true); + expect(await isSpent(input)).toBe(true); + expect(await commitmentCount()).toBe(2n); + }); + + it('should not accept a burn whose change does not conserve value', async () => { + await expect( + token._burn(ALICE, 30n, { value: 71n, nonce: 222n }), + ).rejects.toThrow( + 'ConfidentialNoteFungibleToken: burn does not conserve value', + ); + }); +}); + +// --------------------------------------------------------------------------- +// _consumeNote +// --------------------------------------------------------------------------- + +describe('ConfidentialNoteFungibleToken: _consumeNote', () => { + let input: Note; + + beforeEach(async () => { + token = await ConfidentialNoteFungibleTokenSimulator.create(); + input = await token._mint(ALICE, 100n); + spendAs(ALICE_SK, input); + }); + + it('should publish the nullifier and return the consumed note', async () => { + expect(await token._consumeNote(ALICE)).toStrictEqual(input); + + expect(await isSpent(input)).toBe(true); + expect(await nullifierCount()).toBe(1n); + expect(await commitmentCount()).toBe(1n); // nothing re-issued + }); + + it('should not consume the same note twice', async () => { + await token._consumeNote(ALICE); + spendAs(ALICE_SK, input); + + await expect(token._consumeNote(ALICE)).rejects.toThrow( + 'ConfidentialNoteFungibleToken: note already spent', + ); + }); + + it('should not consume a note whose commitment is not in the tree', async () => { + spendAs(ALICE_SK, { value: 100n, nonce: 999n }); + + await expectRejection( + token._consumeNote(ALICE), + 'wit_Path: commitment not found in tree', + ); + }); + + it('should not consume a note under an owner pk it was not committed to', async () => { + await expectRejection( + token._consumeNote(BOB), + 'wit_Path: commitment not found in tree', + ); + }); + + // No authorization: whoever knows a note and its owner pk can nullify it. + // This is the primitive an extension turns into escrow-free clawback, and the + // reason nonces must stay secret. + it('should consume a note for a caller who holds no owner secret', async () => { + token.wallet.secretKey = BOB_SK; + token.wallet.inputNote = input; + + expect(await token._consumeNote(ALICE)).toStrictEqual(input); + expect(await isSpent(input)).toBe(true); + }); + + it('should consume a zero-value note', async () => { + const padding = await token._mint(ALICE, 0n); + spendAs(ALICE_SK, padding); + + expect(await token._consumeNote(ALICE)).toStrictEqual(padding); + expect(await isSpent(padding)).toBe(true); + }); + + // A proof is built against the tree the wallet last saw. Later inserts move + // the root, and the historical root set is what keeps such a proof valid. + it('should accept a proof against a stale root', async () => { + const stalePath = await pathFor(input, ALICE); + const staleRoot = (await publicState()).Core__commitments.root(); + + await token._mint(CAROL, 5n); // moves the tree on + expect((await publicState()).Core__commitments.root()).not.toStrictEqual( + staleRoot, + ); + + token.wallet.pathOverride = stalePath; + expect(await token._consumeNote(ALICE)).toStrictEqual(input); + expect(await isSpent(input)).toBe(true); + }); + + it('should not accept a path whose leaf is not the input commitment', async () => { + const other = await token._mint(ALICE, 7n); + const otherPath = await pathFor(other, ALICE); + + token.wallet.inputNote = input; + token.wallet.pathOverride = otherPath; + + await expect(token._consumeNote(ALICE)).rejects.toThrow( + 'ConfidentialNoteFungibleToken: path does not match input commitment', + ); + }); + + it('should not accept a path rooted in a tree this contract never had', async () => { + const foreign = await ConfidentialNoteFungibleTokenSimulator.create(); + const foreignNote = await foreign._mint(ALICE, 100n); + const foreignPath = ( + await foreign.getPublicState() + ).Core__commitments.findPathForLeaf(core.commitOf(foreignNote, ALICE)); + + token.wallet.inputNote = foreignNote; + token.wallet.pathOverride = foreignPath; + + await expect(token._consumeNote(ALICE)).rejects.toThrow( + 'ConfidentialNoteFungibleToken: input root not recognized', + ); + }); +}); + +// --------------------------------------------------------------------------- +// nullifierOf +// --------------------------------------------------------------------------- + +describe('ConfidentialNoteFungibleToken: nullifierOf', () => { + // The design decision behind escrow-free clawback: the nullifier preimage is + // the nonce alone, so every party that learns a nonce derives the same + // nullifier and races the owner for the single spend. + it('should derive the nullifier from the nonce alone, ignoring value and owner', () => { + const nullifier = core.nullifierOf({ value: 100n, nonce: 7n }); + + expect(core.nullifierOf({ value: 999n, nonce: 7n })).toEqual(nullifier); + expect(core.nullifierOf({ value: 100n, nonce: 8n })).not.toEqual(nullifier); + }); + + it('should not equate a commitment with a nullifier for the same note', () => { + const note = { value: 100n, nonce: 7n }; + expect(core.commitOf(note, ALICE)).not.toEqual(core.nullifierOf(note)); + }); +}); + +// --------------------------------------------------------------------------- +// transfer +// --------------------------------------------------------------------------- + +describe('ConfidentialNoteFungibleToken: transfer', () => { + let input: Note; + + beforeEach(async () => { + token = await ConfidentialNoteFungibleTokenSimulator.create(); + input = await token._mint(ALICE, 100n); + spendAs(ALICE_SK, input); + }); + + it('should split the input into a recipient note and change, conserving value', async () => { + const [out, change] = await token.transfer(BOB, 30n); + + expect(out.value).toBe(30n); + expect(change.value).toBe(70n); + expect(out.value + change.value).toBe(input.value); + }); + + it('should commit the output to the recipient and the change to the sender', async () => { + const [out, change] = await token.transfer(BOB, 30n); + + expect(await isCommitted(out, BOB)).toBe(true); + expect(await isCommitted(change, ALICE)).toBe(true); + expect(await isCommitted(out, ALICE)).toBe(false); + expect(await isCommitted(change, BOB)).toBe(false); + }); + + it('should publish one nullifier and two commitments', async () => { + await token.transfer(BOB, 30n); + + expect(await commitmentCount()).toBe(3n); // the mint plus two outputs + expect(await nullifierCount()).toBe(1n); + expect(await isSpent(input)).toBe(true); + }); + + it('should give the output and the change distinct nonces', async () => { + const [out, change] = await token.transfer(BOB, 30n); + expect(out.nonce).not.toBe(change.nonce); + }); + + // Both nonces come from one witness call, so they must be separated by their + // slot tag rather than by the randomness itself. + it('should keep the output and change nonces distinct under a reused seed', async () => { + token.wallet.nonceSeed = FIXED_SEED; + const [out, change] = await token.transfer(BOB, 30n); + + expect(out.nonce).not.toBe(change.nonce); + }); + + it('should let the recipient spend what it received', async () => { + const [out] = await token.transfer(BOB, 30n); + + spendAs(BOB_SK, out); + const [onward, bobChange] = await token.transfer(CAROL, 10n); + + expect(onward.value).toBe(10n); + expect(bobChange.value).toBe(20n); + expect(await isSpent(out)).toBe(true); + expect(await isCommitted(onward, CAROL)).toBe(true); + }); + + it('should leave a zero-value change note when the whole note is sent', async () => { + const [out, change] = await token.transfer(BOB, 100n); + + expect(out.value).toBe(100n); + expect(change.value).toBe(0n); + expect(await isCommitted(change, ALICE)).toBe(true); + }); + + it('should send to the sender itself', async () => { + const [out, change] = await token.transfer(ALICE, 30n); + + expect(await isCommitted(out, ALICE)).toBe(true); + expect(await isCommitted(change, ALICE)).toBe(true); + expect(await nullifierCount()).toBe(1n); + }); + + it('should not send more than the note holds', async () => { + await expect(token.transfer(BOB, 101n)).rejects.toThrow( + 'ConfidentialNoteFungibleToken: insufficient note value', + ); + }); + + it('should not spend the same note twice', async () => { + await token.transfer(BOB, 30n); + spendAs(ALICE_SK, input); + + await expect(token.transfer(BOB, 30n)).rejects.toThrow( + 'ConfidentialNoteFungibleToken: note already spent', + ); + }); + + it('should not spend a note that was never committed', async () => { + spendAs(ALICE_SK, { value: 100n, nonce: 999n }); + + await expectRejection( + token.transfer(BOB, 30n), + 'wit_Path: commitment not found in tree', + ); + }); + + // Ownership is enforced by the commitment: a non-owner's pk hashes to a leaf + // that is not in the tree, so no membership proof exists. + it('should not let anyone other than the owner spend', async () => { + spendAs(BOB_SK, input); + + await expectRejection( + token.transfer(CAROL, 30n), + 'wit_Path: commitment not found in tree', + ); + expect(await isSpent(input)).toBe(false); + }); + + it('should leave the ledger untouched when a transfer reverts', async () => { + const before = await commitmentCount(); + + await expect(token.transfer(BOB, 101n)).rejects.toThrow(); + + expect(await commitmentCount()).toBe(before); + expect(await nullifierCount()).toBe(0n); + }); +}); + +// --------------------------------------------------------------------------- +// _transfer: the conserving building block +// --------------------------------------------------------------------------- + +describe('ConfidentialNoteFungibleToken: _transfer', () => { + let input: Note; + + beforeEach(async () => { + token = await ConfidentialNoteFungibleTokenSimulator.create(); + input = await token._mint(ALICE, 100n); + spendAs(ALICE_SK, input); + }); + + // How a composing contract spends: it builds both output notes itself (its + // emission policy owns the nonces) and the core checks conservation. + it('should accept caller-built notes that conserve value', async () => { + const out = { value: 30n, nonce: 111n }; + const change = { value: 70n, nonce: 222n }; + + await token._transfer(ALICE, BOB, out, change); + + expect(await isCommitted(out, BOB)).toBe(true); + expect(await isCommitted(change, ALICE)).toBe(true); + expect(await isSpent(input)).toBe(true); + }); + + it('should not accept outputs that destroy value', async () => { + await expect( + token._transfer( + ALICE, + BOB, + { value: 30n, nonce: 111n }, + { value: 69n, nonce: 222n }, + ), + ).rejects.toThrow( + 'ConfidentialNoteFungibleToken: transfer does not conserve value', + ); + }); + + it('should not accept outputs that inflate value', async () => { + await expect( + token._transfer( + ALICE, + BOB, + { value: 30n, nonce: 111n }, + { value: 71n, nonce: 222n }, + ), + ).rejects.toThrow( + 'ConfidentialNoteFungibleToken: transfer does not conserve value', + ); + }); + + it('should leave the ledger untouched when conservation fails', async () => { + const before = await commitmentCount(); + + await expect( + token._transfer( + ALICE, + BOB, + { value: 30n, nonce: 111n }, + { value: 71n, nonce: 222n }, + ), + ).rejects.toThrow(); + + expect(await commitmentCount()).toBe(before); + expect(await nullifierCount()).toBe(0n); + expect(await isSpent(input)).toBe(false); + }); +}); diff --git a/contracts/src/token/test/mocks/MockConfidentialNoteFungibleToken.compact b/contracts/src/token/test/mocks/MockConfidentialNoteFungibleToken.compact new file mode 100644 index 00000000..710bd115 --- /dev/null +++ b/contracts/src/token/test/mocks/MockConfidentialNoteFungibleToken.compact @@ -0,0 +1,68 @@ +// TEST-ONLY. Mock for the ConfidentialNoteFungibleToken core. Exposes the +// self-gated `transfer` / `burn` surface plus every `_`-prefixed building block, +// so the suite can drive the core the way a composing contract would. Circuits +// appear in the core's own order. +// +// The building blocks are UNGATED by design: re-exporting `_mint` / `_mintNote` +// is a permissionless mint, and re-exporting `_consumeNote` / `_transfer` / +// `_burn` lets any caller spend a note whose nonce they know. A PRODUCTION +// contract must gate them (see the core's module doc). DO NOT deploy this mock. +pragma language_version >= 0.23.0; +import CompactStandardLibrary; +import "../../ConfidentialNoteFungibleToken" prefix Core_; + +export { Core_Note } + +export { Core__commitments, Core__nullifiers }; + +export circuit _mint(recipientPk: Field, value: Uint<128>): Core_Note { + return Core__mint(recipientPk, value); +} + +export circuit _mintNote(note: Core_Note, ownerPk: Field): [] { + return Core__mintNote(note, ownerPk); +} + +export pure circuit commitOf(note: Core_Note, pk: Field): Bytes<32> { + return Core_commitOf(note, pk); +} + +export circuit burn(value: Uint<128>): Core_Note { + return Core_burn(value); +} + +export circuit _spenderPk(): Field { + // Exported-boundary marker: returns only to the local caller. + return disclose(Core__spenderPk()); +} + +export pure circuit derivePk(sk: Bytes<32>): Field { + return Core_derivePk(sk); +} + +export circuit _inputNote(): Core_Note { + // Exported-boundary marker: returns only to the local caller. + return disclose(Core__inputNote()); +} + +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 nullifierOf(note: Core_Note): Bytes<32> { + return Core_nullifierOf(note); +} + +export circuit transfer(recipientPk: Field, value: Uint<128>): [Core_Note, Core_Note] { + return Core_transfer(recipientPk, value); +} + +export circuit _transfer(spenderPk: Field, recipientPk: Field, outNote: Core_Note, changeNote: Core_Note): [] { + return Core__transfer(spenderPk, recipientPk, outNote, changeNote); +} diff --git a/contracts/src/token/test/simulators/ConfidentialNoteFungibleTokenSimulator.ts b/contracts/src/token/test/simulators/ConfidentialNoteFungibleTokenSimulator.ts new file mode 100644 index 00000000..ab1ea709 --- /dev/null +++ b/contracts/src/token/test/simulators/ConfidentialNoteFungibleTokenSimulator.ts @@ -0,0 +1,134 @@ +import { + createSimulator, + type SimulatorOptions, +} from '@openzeppelin/compact-simulator'; +import { + ledger, + Contract as MockCore, +} from '../../../../artifacts/MockConfidentialNoteFungibleToken/contract/index.js'; +import { + type ConfidentialNoteFungibleTokenPrivateState, + ConfidentialNoteFungibleTokenWitnesses, + createNoteWallet, + type Note, + type NoteWallet, + ConfidentialNoteFungibleTokenPrivateState as PrivateState, +} from '../witnesses/ConfidentialNoteFungibleTokenWitnesses.js'; + +type Options = SimulatorOptions< + ConfidentialNoteFungibleTokenPrivateState, + ReturnType +>; + +/** + * The wallet the next construction binds its witnesses to. `create` sets it and + * the factory below reads it, so every instance gets its own wallet while the + * config stays a module-level constant. + */ +let pendingWallet: NoteWallet = createNoteWallet(); + +const ConfidentialNoteFungibleTokenSimulatorBase = createSimulator< + ConfidentialNoteFungibleTokenPrivateState, + ReturnType, + ReturnType, + MockCore, + readonly [] +>({ + contractFactory: (witnesses) => + new MockCore(witnesses), + defaultPrivateState: () => PrivateState.generate(), + contractArgs: () => [], + ledgerExtractor: (state) => ledger(state), + witnessesFactory: () => ConfidentialNoteFungibleTokenWitnesses(pendingWallet), + artifactName: 'MockConfidentialNoteFungibleToken', +}); + +/** + * ConfidentialNoteFungibleToken (core) simulator. + * + * Methods mirror the mock's circuits one for one, `_` prefixes included, so a + * spec reads as the circuit it drives. The caller's identity and the note being + * spent are witness inputs, not arguments: set them on {@link wallet}. + */ +export class ConfidentialNoteFungibleTokenSimulator extends ConfidentialNoteFungibleTokenSimulatorBase { + /** The private inputs the witnesses answer with. Mutate between calls. */ + public wallet!: NoteWallet; + + /** + * @param options Standard simulator options, plus a `wallet` to reuse across + * deployments. Passing `options.witnesses` takes over witness wiring entirely + * and leaves {@link wallet} disconnected. + */ + static async create( + options: Options & { wallet?: NoteWallet } = {}, + ): Promise { + const wallet = options.wallet ?? createNoteWallet(); + pendingWallet = wallet; + // biome-ignore lint/complexity/noThisInStatic: super.create keeps subclass `this` + const simulator = (await super.create( + [], + options, + )) as ConfidentialNoteFungibleTokenSimulator; + simulator.wallet = wallet; + return simulator; + } + + /** Mints `value` to `recipientPk` with a core-derived nonce. Ungated. */ + public _mint(recipientPk: bigint, value: bigint): Promise { + return this.circuits.impure._mint(recipientPk, value); + } + + /** Commits a caller-built note to `ownerPk`. Ungated. */ + public _mintNote(note: Note, ownerPk: bigint): Promise<[]> { + return this.circuits.impure._mintNote(note, ownerPk); + } + + /** Spends the caller's input note, re-issuing only the change. */ + public burn(value: bigint): Promise { + return this.circuits.impure.burn(value); + } + + /** The caller's spend identity, `Hf(wit_SecretKey())`. */ + public _spenderPk(): Promise { + return this.circuits.impure._spenderPk(); + } + + /** The input note the next spend will consume. */ + public _inputNote(): Promise { + return this.circuits.impure._inputNote(); + } + + /** Consumes the input note, re-issuing only `changeNote`. Ungated. */ + public _burn( + spenderPk: bigint, + value: bigint, + changeNote: Note, + ): Promise<[]> { + return this.circuits.impure._burn(spenderPk, value, changeNote); + } + + /** Nullifies the input note owned by `ownerPk`. Ungated. */ + public _consumeNote(ownerPk: bigint): Promise { + return this.circuits.impure._consumeNote(ownerPk); + } + + /** Spends the caller's input note into a recipient note plus change. */ + public transfer(recipientPk: bigint, value: bigint): Promise<[Note, Note]> { + return this.circuits.impure.transfer(recipientPk, value); + } + + /** Consumes the input note and commits `outNote` + `changeNote`. Ungated. */ + public _transfer( + spenderPk: bigint, + recipientPk: bigint, + outNote: Note, + changeNote: Note, + ): Promise<[]> { + return this.circuits.impure._transfer( + spenderPk, + recipientPk, + outNote, + changeNote, + ); + } +} diff --git a/contracts/src/token/test/witnesses/ConfidentialNoteFungibleTokenWitnesses.ts b/contracts/src/token/test/witnesses/ConfidentialNoteFungibleTokenWitnesses.ts new file mode 100644 index 00000000..43fcbbf9 --- /dev/null +++ b/contracts/src/token/test/witnesses/ConfidentialNoteFungibleTokenWitnesses.ts @@ -0,0 +1,93 @@ +// TEST-ONLY WITNESS. NOT FOR PRODUCTION USE. +// Drives the 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/MockConfidentialNoteFungibleToken/contract/index.js'; + +/** A note as the circuits see it: value plus a field-typed nonce. */ +export type Note = { value: bigint; nonce: bigint }; + +/** + * The private inputs the circuits ask for, held in a plain object the spec + * mutates between calls. + * + * A real wallet would keep these in simulator private state, but + * `setPrivateState` throws on the live backend, and this module's every spend + * has to name a different input note. Closing the witnesses over a mutable + * wallet instead keeps one spec file runnable on both backends. + */ +export type NoteWallet = { + /** Owner spend secret; `pk = Hf(sk)`. */ + secretKey: Uint8Array; + /** The input note consumed by the next transfer / burn / consume. */ + inputNote: Note; + /** + * Fixed nonce-randomness seed. Leave undefined for the production-correct + * behavior (a fresh secret seed per witness call); set one only to test what + * a reused seed does. + */ + nonceSeed?: Uint8Array; + /** + * Merkle path returned verbatim instead of one looked up in the live tree. + * Plants a stale (historical) or mismatched path, which a well-behaved + * wallet never does. + */ + pathOverride?: MerkleTreePath; +}; + +export const createNoteWallet = (): NoteWallet => ({ + secretKey: new Uint8Array(getRandomValues(Buffer.alloc(32))), + inputNote: { value: 0n, nonce: 0n }, +}); + +/** The core declares no private state; the wallet above carries everything. */ +export type ConfidentialNoteFungibleTokenPrivateState = Record; +export const ConfidentialNoteFungibleTokenPrivateState = { + generate: (): ConfidentialNoteFungibleTokenPrivateState => ({}), +}; + +export interface IConfidentialNoteFungibleTokenWitnesses

{ + wit_SecretKey(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 ConfidentialNoteFungibleTokenWitnesses = ( + wallet: NoteWallet, +): IConfidentialNoteFungibleTokenWitnesses => ({ + wit_SecretKey(context) { + return [context.privateState, wallet.secretKey]; + }, + wit_InputNote(context) { + return [context.privateState, wallet.inputNote]; + }, + // The circuit passes the input commitment; the wallet answers with its + // Merkle path, read here from the live commitment tree. + wit_Path(context, cm) { + const planted = wallet.pathOverride; + if (planted !== undefined) { + return [context.privateState, planted]; + } + const path = context.ledger.Core__commitments.findPathForLeaf(cm); + if (path === undefined) { + throw new Error('wit_Path: commitment not found in tree'); + } + return [context.privateState, path]; + }, + // Fresh and secret per call, as the module requires; a fixed seed is only + // honored when a spec explicitly plants one. + wit_NonceRandomness(context) { + return [ + context.privateState, + wallet.nonceSeed ?? new Uint8Array(getRandomValues(Buffer.alloc(32))), + ]; + }, +}); diff --git a/contracts/test-utils/assertions/rejection.ts b/contracts/test-utils/assertions/rejection.ts new file mode 100644 index 00000000..ba655cba --- /dev/null +++ b/contracts/test-utils/assertions/rejection.ts @@ -0,0 +1,101 @@ +/** + * Asserting *why* a call was rejected, on either backend. + * + * `expect(...).rejects.toThrow(msg)` reads `error.message` and nothing else. + * That is enough when the failure comes from an in-circuit `assert`, whose text + * the compiler emits into the circuit. It is not enough when the failure comes + * from a witness throwing: on the live backend that arrives wrapped twice, + * + * Error "Unexpected error executing scoped transaction '…': …" + * cause: ContractRuntimeError "Error executing circuit 'transfer'" + * cause: the witness's own error + * + * so the real reason sits below the surface and a message assertion that passes + * dry fails live for no behavioural reason. Both wrappers do preserve `cause` + * (`midnight-js-contracts` uses `new Error(msg, { cause: err })`, and + * `compact-js`'s `ContractRuntimeError.make(message, cause)` keeps it), so the + * reason is still there — just not where vitest looks. + * + * {@link expectRejection} walks the whole chain, so one assertion string holds + * in both backends: dry matches at depth 0, live further down. + * + * Effect hides a failure's cause behind a `FiberFailure` that only renders via + * `toString()`, and an `AggregateError` keeps its children on `errors`, so the + * walk covers both alongside `cause`. + */ + +/** + * Rendered text for every error reachable from `error`, outermost first. + * + * Breadth-first so the printed order matches how deeply each entry was nested, + * which is what makes a failed match readable. Cycle-safe. + */ +export function causeChain(error: unknown): string[] { + const seen = new Set(); + const queue: unknown[] = [error]; + const rendered: string[] = []; + + while (queue.length > 0) { + const current = queue.shift(); + if (current == null || seen.has(current)) { + continue; + } + seen.add(current); + + // `String(err)` rather than `.message`: it picks up a custom `toString`, + // which is the only way a FiberFailure surfaces what it wraps. + rendered.push(String(current)); + + if (current instanceof Error) { + queue.push(current.cause); + const { errors } = current as { errors?: unknown }; + if (Array.isArray(errors)) { + queue.push(...errors); + } + } + } + return rendered; +} + +/** Whether `reason` appears anywhere in `error`'s cause chain. */ +export function rejectionIncludes(error: unknown, reason: string): boolean { + return causeChain(error).some((text) => text.includes(reason)); +} + +/** + * Asserts `call` rejects, and that `reason` appears somewhere in the rejection's + * cause chain. + * + * On a miss it prints the entire chain, so a run on a backend that wraps errors + * differently reports what it actually got instead of just "no match". + * + * @param call - The pending call, e.g. `token.burn(30n)`. + * @param reason - Substring to find, at any depth. + */ +export async function expectRejection( + call: Promise, + reason: string, +): Promise { + let thrown: unknown; + let rejected = false; + try { + await call; + } catch (error) { + rejected = true; + thrown = error; + } + + if (!rejected) { + throw new Error( + `expected a rejection including "${reason}", but the call resolved`, + ); + } + if (!rejectionIncludes(thrown, reason)) { + const chain = causeChain(thrown) + .map((text, depth) => ` [${depth}] ${text}`) + .join('\n'); + throw new Error( + `expected a rejection including\n ${reason}\nbut the cause chain was:\n${chain}`, + ); + } +} diff --git a/contracts/test-utils/assertions/test/rejection.test.ts b/contracts/test-utils/assertions/test/rejection.test.ts new file mode 100644 index 00000000..8d8d9b2b --- /dev/null +++ b/contracts/test-utils/assertions/test/rejection.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest'; +import { + causeChain, + expectRejection, + rejectionIncludes, +} from '../rejection.js'; + +const WITNESS_REASON = 'wit_Path: commitment not found in tree'; + +/** The shape the live backend produces: the reason two `cause` levels down. */ +const liveWrapped = () => { + const witness = new Error(WITNESS_REASON); + const runtime = new Error("Error executing circuit 'transfer'", { + cause: witness, + }); + return new Error( + "Unexpected error executing scoped transaction '': ContractRuntimeError: Error executing circuit 'transfer'", + { cause: runtime }, + ); +}; + +describe('rejection: causeChain', () => { + it('should render the outermost error first', () => { + const chain = causeChain(liveWrapped()); + + expect(chain[0]).toContain('Unexpected error executing scoped transaction'); + expect(chain[chain.length - 1]).toContain(WITNESS_REASON); + }); + + it('should reach a reason nested two cause levels down', () => { + expect(causeChain(liveWrapped())).toHaveLength(3); + }); + + it('should follow an AggregateError children', () => { + const aggregate = new AggregateError( + [new Error('first'), new Error(WITNESS_REASON)], + 'several failed', + ); + + expect(causeChain(aggregate).join('\n')).toContain(WITNESS_REASON); + }); + + it('should use toString, so a custom renderer is not missed', () => { + // A FiberFailure keeps its cause behind a Symbol and only renders via + // toString; `.message` alone would report nothing. + const opaque = { + toString: () => `FiberFailure: ${WITNESS_REASON}`, + }; + + expect(causeChain(opaque)).toStrictEqual([ + `FiberFailure: ${WITNESS_REASON}`, + ]); + }); + + it('should terminate on a cycle', () => { + const a = new Error('a'); + const b = new Error('b', { cause: a }); + (a as { cause?: unknown }).cause = b; + + expect(causeChain(a)).toHaveLength(2); + }); + + it('should render nothing for no error', () => { + expect(causeChain(undefined)).toStrictEqual([]); + expect(causeChain(null)).toStrictEqual([]); + }); +}); + +describe('rejection: rejectionIncludes', () => { + it('should find a reason at the surface', () => { + expect(rejectionIncludes(new Error(WITNESS_REASON), WITNESS_REASON)).toBe( + true, + ); + }); + + it('should find a reason the wrappers buried', () => { + expect(rejectionIncludes(liveWrapped(), WITNESS_REASON)).toBe(true); + }); + + it('should not report a reason that is absent', () => { + expect(rejectionIncludes(liveWrapped(), 'note already spent')).toBe(false); + }); +}); + +describe('rejection: expectRejection', () => { + it('should accept a rejection carrying the reason at the surface', async () => { + await expectRejection( + Promise.reject(new Error(WITNESS_REASON)), + WITNESS_REASON, + ); + }); + + it('should accept the same reason when the backend wrapped it', async () => { + await expectRejection(Promise.reject(liveWrapped()), WITNESS_REASON); + }); + + it('should reject a call that resolved instead', async () => { + await expect( + expectRejection(Promise.resolve('fine'), WITNESS_REASON), + ).rejects.toThrow( + `expected a rejection including "${WITNESS_REASON}", but the call resolved`, + ); + }); + + it('should print the whole chain when the reason is absent', async () => { + // The diagnostic IS the feature: a backend that wraps differently has to + // report what it produced, not just "no match". + await expect( + expectRejection(Promise.reject(liveWrapped()), 'some other reason'), + ).rejects.toThrow(/\[0\].*\n.*\[1\].*\n.*\[2\]/s); + }); +}); diff --git a/contracts/test-utils/compiler/contractInfo.ts b/contracts/test-utils/compiler/contractInfo.ts new file mode 100644 index 00000000..94eed152 --- /dev/null +++ b/contracts/test-utils/compiler/contractInfo.ts @@ -0,0 +1,368 @@ +/** + * Reads and types `contract-info.json`, the compiler's description of a + * contract's published surface: ledger slots and their indices, and which + * circuits a client can call. Emitted on every build, `--skip-zk` included. + * + * Declared rather than imported because no package describes this file. The near + * misses: `CompactType` (`compact-runtime`) is a runtime codec, not a static + * shape; `SparseCompactADT` (same package) is tagged `'cell' | 'set' | 'list' | + * 'map'`, a partial vocabulary for finding contract references. Every variant + * below is derived from the 472 compiled artifacts in this monorepo. + * + * Circuit complexity (k, rows) is not here; see + * OpenZeppelin/compact-contracts#750. + */ + +import { readFileSync } from 'node:fs'; + +// --------------------------------------------------------------------------- +// Type descriptors +// --------------------------------------------------------------------------- + +/** + * Every `type-name` the compiler emits. + * + * `List` and `Map` appear here as well as in {@link LedgerStorage} because a + * `Map` slot's value may itself be a collection, written as a type descriptor. + * + * On an unrecognized name, add the variant. Widening to `string` defeats the + * point. + */ +export type CompactTypeName = + | 'Alias' + | 'Boolean' + | 'Bytes' + | 'Enum' + | 'Field' + | 'List' + | 'Map' + | 'Opaque' + | 'Struct' + | 'Tuple' + | 'Uint' + | 'Vector'; + +/** A struct field, or a circuit or witness parameter. */ +export interface NamedCompactType { + readonly name: string; + readonly type: CompactTypeInfo; +} + +/** `Field`, the native scalar. A `bigint` at runtime. */ +export interface FieldTypeInfo { + readonly 'type-name': 'Field'; +} + +/** `Boolean`. A `boolean` at runtime. */ +export interface BooleanTypeInfo { + readonly 'type-name': 'Boolean'; +} + +/** `Bytes`. A `Uint8Array` at runtime. */ +export interface BytesTypeInfo { + readonly 'type-name': 'Bytes'; + readonly length: number; +} + +/** + * `Uint`, given as an inclusive maximum. + * + * CAUTION: a JSON number, so anything above 2^53 is already imprecise. `Uint<128>` + * reads back as `3.402823669209385e+38` and fails a `BigInt` round trip. Fine to + * compare against another parse of the same file; never an exact bound. + */ +export interface UintTypeInfo { + readonly 'type-name': 'Uint'; + readonly maxval: number; +} + +/** + * `Opaque<"...">`, passed through uninterpreted. + * + * `tsType` stays `string` because it is author-supplied, unlike the rest of this + * union. Seen here: `'string'`, and `'JubjubPoint'` (the `compact-runtime` + * interface of that name). + */ +export interface OpaqueTypeInfo { + readonly 'type-name': 'Opaque'; + readonly tsType: string; +} + +/** `Vector`, fixed-length and homogeneous. */ +export interface VectorTypeInfo { + readonly 'type-name': 'Vector'; + readonly length: number; + readonly type: CompactTypeInfo; +} + +/** A tuple: fixed-length, heterogeneous. */ +export interface TupleTypeInfo { + readonly 'type-name': 'Tuple'; + readonly types: readonly CompactTypeInfo[]; +} + +/** A named struct. `elements` order is the encoding. */ +export interface StructTypeInfo { + readonly 'type-name': 'Struct'; + readonly name: string; + readonly elements: readonly NamedCompactType[]; +} + +/** A named enum. `elements` holds the ordered variant names. */ +export interface EnumTypeInfo { + readonly 'type-name': 'Enum'; + readonly name: string; + readonly elements: readonly string[]; +} + +/** A named alias. Transparent to the encoding. */ +export interface AliasTypeInfo { + readonly 'type-name': 'Alias'; + readonly name: string; + readonly type: CompactTypeInfo; +} + +/** A `List` in type position, i.e. as a `Map` slot's value. */ +export interface ListTypeInfo { + readonly 'type-name': 'List'; + readonly type: CompactTypeInfo; +} + +/** A `Map` in type position, i.e. as an outer `Map` slot's value. */ +export interface MapTypeInfo { + readonly 'type-name': 'Map'; + readonly key: CompactTypeInfo; + readonly value: CompactTypeInfo; +} + +/** + * Any Compact type, discriminated on `type-name`, so `length` is reachable only + * on `Bytes` and `Vector`, `maxval` only on `Uint`, and so on. + */ +export type CompactTypeInfo = + | AliasTypeInfo + | BooleanTypeInfo + | BytesTypeInfo + | EnumTypeInfo + | FieldTypeInfo + | ListTypeInfo + | MapTypeInfo + | OpaqueTypeInfo + | StructTypeInfo + | TupleTypeInfo + | UintTypeInfo + | VectorTypeInfo; + +// --------------------------------------------------------------------------- +// Ledger slots +// --------------------------------------------------------------------------- + +/** Every ledger ADT the compiler emits as a slot's `storage`. */ +export type LedgerStorage = + | 'Cell' + | 'Counter' + | 'HistoricMerkleTree' + | 'List' + | 'Map' + | 'MerkleTree' + | 'Set'; + +/** What every ledger slot carries, whatever its storage kind. */ +interface LedgerSlotBase { + readonly name: string; + /** The storage slot, fixed by declaration order. Reordering repoints readers. */ + readonly index: number; + /** Whether the slot appears in the generated `ledger()` reader. */ + readonly exported: boolean; +} + +/** A single value. */ +export interface CellSlot extends LedgerSlotBase { + readonly storage: 'Cell'; + readonly type: CompactTypeInfo; +} + +/** A monotonic counter. Carries NO `type`: the element type is implied. */ +export interface CounterSlot extends LedgerSlotBase { + readonly storage: 'Counter'; +} + +/** A set of values. */ +export interface SetSlot extends LedgerSlotBase { + readonly storage: 'Set'; + readonly type: CompactTypeInfo; +} + +/** An ordered list of values. */ +export interface ListSlot extends LedgerSlotBase { + readonly storage: 'List'; + readonly type: CompactTypeInfo; +} + +/** + * A key-value map. Carries `key` and `value` INSTEAD OF `type`; reading `.type` + * on a map slot is the mistake this union makes impossible. + */ +export interface MapSlot extends LedgerSlotBase { + readonly storage: 'Map'; + readonly key: CompactTypeInfo; + readonly value: CompactTypeInfo; +} + +/** + * A Merkle tree accepting only the CURRENT root, so any insert invalidates every + * in-flight proof. Contrast {@link HistoricMerkleTreeSlot}. + */ +export interface MerkleTreeSlot extends LedgerSlotBase { + readonly storage: 'MerkleTree'; + /** Capacity is `2^depth` leaves, and is part of the serialized form. */ + readonly depth: number; + readonly type: CompactTypeInfo; +} + +/** + * A Merkle tree accepting any recently-current root, which is what lets a spend + * land while others insert. + */ +export interface HistoricMerkleTreeSlot extends LedgerSlotBase { + readonly storage: 'HistoricMerkleTree'; + /** Capacity is `2^depth` leaves, and is part of the serialized form. */ + readonly depth: number; + readonly type: CompactTypeInfo; +} + +/** + * One ledger field, discriminated on `storage`. + * + * A union because the variants genuinely differ: `Counter` has no element type, + * `Map` splits it into `key`/`value`, and only trees carry `depth`. + */ +export type LedgerSlot = + | CellSlot + | CounterSlot + | HistoricMerkleTreeSlot + | ListSlot + | MapSlot + | MerkleTreeSlot + | SetSlot; + +// --------------------------------------------------------------------------- +// Circuits, witnesses, and the file as a whole +// --------------------------------------------------------------------------- + +/** One circuit the contract exposes. */ +export interface CircuitInfo { + readonly name: string; + /** A pure circuit reads no state and is callable off-chain for free. */ + readonly pure: boolean; + /** + * Whether the circuit gets a verifier key, and so whether a deployed instance + * can be called at all. False for an empty public transcript: reading witnesses + * without touching the ledger leaves nothing to verify. Impure does not imply + * provable. + */ + readonly proof: boolean; + readonly arguments: readonly NamedCompactType[]; + readonly 'result-type': CompactTypeInfo; +} + +/** + * One witness the caller must supply. Note `'result type'` with a SPACE, where + * {@link CircuitInfo} uses a hyphen. That is the compiler's inconsistency. + */ +export interface WitnessInfo { + readonly name: string; + readonly arguments: readonly NamedCompactType[]; + readonly 'result type': CompactTypeInfo; +} + +/** The whole of `contract-info.json`. */ +export interface ContractInfo { + readonly 'compiler-version': string; + readonly 'language-version': string; + readonly 'runtime-version': string; + readonly circuits: readonly CircuitInfo[]; + readonly witnesses: readonly WitnessInfo[]; + /** Child contracts. `unknown` because it is empty in all 472 artifacts here. */ + readonly contracts: readonly unknown[]; + /** + * The public ledger. ABSENT, not empty, when a contract declares no ledger + * state (64 of 472 artifacts). Prefer {@link ledgerSlots}. + */ + readonly ledger?: readonly LedgerSlot[]; +} + +/** A circuit reduced to the three facts that decide how a client may call it. */ +export type CircuitSurface = Pick; + +// --------------------------------------------------------------------------- +// Binding a pin to the compiler's other output +// --------------------------------------------------------------------------- + +/** + * Names from the generated `contract/index.d.ts`, the compiler's other output. + * + * It exports `PureCircuits`, `ProvableCircuits` (callable on a deployed + * instance), `ImpureCircuits`, `Circuits`, `Ledger` (exported slots only), + * and `Witnesses`, all keyed by name. Keying a pin off those makes a rename a + * compile error rather than a runtime surprise. + */ +export type NameOf = keyof T & string; + +/** + * A record that must mention every name in `Names` and no others, so a missing + * key, a stray key, and a typo are all compile errors. + * + * `Exclude>, NameOf>>` gives the + * impure-but-not-callable set without restating it. + */ +export type Exhaustive = Record< + Names, + Value +>; + +// --------------------------------------------------------------------------- +// Reading +// --------------------------------------------------------------------------- + +/** + * Loads the compiler metadata for a built artifact. Read at call time, so + * importing this module never requires a compiled artifact. + * + * @param artifactName - The directory under `contracts/artifacts`, usually a + * mock, e.g. `MockConfidentialNoteFungibleToken`. + * @throws If the artifact has not been built. + */ +export function readContractInfo(artifactName: string): ContractInfo { + const path = new URL( + `../../artifacts/${artifactName}/compiler/contract-info.json`, + import.meta.url, + ); + + try { + return JSON.parse(readFileSync(path, 'utf8')) as ContractInfo; + } catch (cause) { + throw new Error( + `readContractInfo: no compiler metadata for '${artifactName}'. ` + + 'Compile the contract first, then re-run.', + { cause }, + ); + } +} + +/** Ledger slots in declaration order, `[]` where the compiler omitted the key. */ +export function ledgerSlots(info: ContractInfo): readonly LedgerSlot[] { + return info.ledger ?? []; +} + +/** + * The circuit surface, sorted by name. + * + * Sorted because the compiler emits declaration order while dispatch is by name, + * so reordering a `.compact` source is not a compatibility change. + */ +export function circuitSurface(info: ContractInfo): CircuitSurface[] { + return info.circuits + .map(({ name, pure, proof }) => ({ name, pure, proof })) + .sort((left, right) => left.name.localeCompare(right.name)); +} diff --git a/contracts/test-utils/compiler/test/contractInfo.test.ts b/contracts/test-utils/compiler/test/contractInfo.test.ts new file mode 100644 index 00000000..898c141e --- /dev/null +++ b/contracts/test-utils/compiler/test/contractInfo.test.ts @@ -0,0 +1,149 @@ +/** + * NO COMPILED ARTIFACT REQUIRED: `test:harness` has no `compile` dependency, so + * nothing here may read a real build. The happy path writes its own throwaway + * artifact instead, which still exercises the likeliest breakage, the relative path + * resolved against this module's location. `contracts/artifacts` is gitignored. + */ + +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { + type CircuitInfo, + type ContractInfo, + circuitSurface, + readContractInfo, +} from '../contractInfo.js'; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +/** A circuit entry carrying the noise `circuitSurface` is meant to drop. */ +const circuit = (name: string, pure: boolean, proof: boolean): CircuitInfo => ({ + name, + pure, + proof, + arguments: [{ name: 'value', type: { 'type-name': 'Field' } }], + 'result-type': { 'type-name': 'Field' }, +}); + +const contractInfo = (circuits: CircuitInfo[]): ContractInfo => ({ + 'compiler-version': '0.31.1', + 'language-version': '0.23.0', + 'runtime-version': '0.16.0', + circuits, + witnesses: [], + contracts: [], + ledger: [ + { + name: '_things', + index: 0, + exported: true, + storage: 'Set', + type: { 'type-name': 'Bytes', length: 32 }, + }, + ], +}); + +// --------------------------------------------------------------------------- +// circuitSurface +// --------------------------------------------------------------------------- + +describe('circuitSurface', () => { + it('should keep only the three fields that decide callability', () => { + const surface = circuitSurface( + contractInfo([circuit('only', false, true)]), + ); + + // Asserted whole, so an added field fails rather than passing unnoticed. + expect(surface).toStrictEqual([{ name: 'only', pure: false, proof: true }]); + }); + + it('should sort by name, so source reordering is not a change', () => { + const declarationOrder = contractInfo([ + circuit('transfer', false, true), + circuit('_burn', false, true), + circuit('commitOf', true, false), + ]); + + expect(circuitSurface(declarationOrder).map(({ name }) => name)).toEqual([ + '_burn', + 'commitOf', + 'transfer', + ]); + }); + + it('should preserve each circuit own pure and proof flags', () => { + const mixed = contractInfo([ + circuit('a_pure', true, false), + circuit('b_provable', false, true), + // Impure but unprovable: reads witnesses, touches no ledger state. + circuit('c_local', false, false), + ]); + + expect(circuitSurface(mixed)).toStrictEqual([ + { name: 'a_pure', pure: true, proof: false }, + { name: 'b_provable', pure: false, proof: true }, + { name: 'c_local', pure: false, proof: false }, + ]); + }); + + it('should return an empty surface for a contract with no circuits', () => { + expect(circuitSurface(contractInfo([]))).toStrictEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// readContractInfo +// --------------------------------------------------------------------------- + +describe('readContractInfo', () => { + const FIXTURE = '__ContractInfoReaderFixture__'; + const fixtureRoot = new URL( + `../../../artifacts/${FIXTURE}/`, + import.meta.url, + ); + const written = contractInfo([circuit('roundTripped', false, true)]); + + beforeAll(() => { + mkdirSync(new URL('compiler/', fixtureRoot), { recursive: true }); + writeFileSync( + new URL('compiler/contract-info.json', fixtureRoot), + JSON.stringify(written), + 'utf8', + ); + }); + + afterAll(() => { + rmSync(fixtureRoot, { recursive: true, force: true }); + }); + + it('should resolve an artifact by name and parse its metadata', () => { + // The point of the round trip: the module resolves this path against its own + // location, which no unit test of a pure function would catch breaking. + expect(readContractInfo(FIXTURE)).toStrictEqual(written); + }); + + it('should compose with circuitSurface on what it read', () => { + expect(circuitSurface(readContractInfo(FIXTURE))).toStrictEqual([ + { name: 'roundTripped', pure: false, proof: true }, + ]); + }); + + it('should explain what to do when the artifact is not built', () => { + // The likely cause of this failure is a missing compile, not a typo, so the + // message has to say so rather than surface a bare ENOENT. + expect(() => readContractInfo('NoSuchArtifactAnywhere')).toThrowError( + /no compiler metadata for 'NoSuchArtifactAnywhere'.*Compile the contract first/s, + ); + }); + + it('should keep the underlying filesystem error as the cause', () => { + try { + readContractInfo('NoSuchArtifactAnywhere'); + expect.unreachable('expected a throw'); + } catch (error) { + expect((error as Error).cause).toMatchObject({ code: 'ENOENT' }); + } + }); +}); diff --git a/contracts/test-utils/concurrency/DryReplayHarness.ts b/contracts/test-utils/concurrency/DryReplayHarness.ts new file mode 100644 index 00000000..7abf44ca --- /dev/null +++ b/contracts/test-utils/concurrency/DryReplayHarness.ts @@ -0,0 +1,215 @@ +/** + * A {@link ConcurrencyHarness} that replays transcripts in memory. + * + * Not an approximation of the node: `QueryContext.runTranscript` is the same + * verifying-mode entry point in the same WASM onchain runtime the node runs, so + * a rejection here is the rejection the node would issue for the same reason. + * + * SCOPE. `run_transcript` forwards the program and the gas budget to `query` and + * ignores `transcript.effects` (onchain-runtime/src/context.rs:990), so this + * covers exactly ONE of the two couplings between a transcript and its + * snapshot: pinned reads. The declared-vs-recomputed effects check runs in the + * ledger after the transcript does (ledger/src/semantics.rs:1400), as do mempool + * ordering, block inclusion, and fee treatment on a failed segment. All of those + * are live-backend properties. + */ + +import { + type AlignedValue, + type ChargedState, + type ContractAddress, + CostModel, + createCircuitContext, + dummyContractAddress, + type Effects, + type Op, + QueryContext, + type RunningCost, + type StateValue, +} from '@midnight-ntwrk/compact-runtime'; +import { CircuitContextManager } from '@openzeppelin/compact-simulator'; +import type { + Attempt, + Call, + ConcurrencyHarness, + HarnessOptions, + Pending, + ReplayableContract, +} from './types.js'; + +/** + * A transcript's gas field is a budget the program must not exceed, not a + * measurement. Passing the cost the build actually reported fails with + * `OutOfGas`, because verifying mode re-charges the whole program against it. + * Nothing here is testing fees, so the budget is simply large. + */ +const BUDGET: RunningCost = { + readTime: 2n ** 60n, + computeTime: 2n ** 60n, + bytesWritten: 2n ** 60n, + bytesDeleted: 2n ** 60n, +}; + +const DEFAULT_COIN_PUBLIC_KEY = '0'.repeat(64); + +const messageOf = (error: unknown): string => + error instanceof Error ? error.message : String(error); + +/** A built call, plus the snapshot it was built against. */ +interface DryPending extends Pending { + readonly transcript: Op[]; + /** + * Declared effects. `run_transcript` ignores them today, but a transcript is + * the pair, so they are carried and passed rather than stubbed. + */ + readonly effects: Effects; + readonly builtOn: ChargedState; +} + +export class DryReplayHarness

implements ConcurrencyHarness { + private current: ChargedState; + private readonly contracts: Readonly>>; + private readonly privateState: P; + private readonly address: ContractAddress; + private readonly coinPublicKey: string; + private readonly costModel = CostModel.initialCostModel(); + + constructor(options: HarnessOptions

) { + this.contracts = options.contracts; + this.privateState = options.privateState; + this.address = options.contractAddress ?? dummyContractAddress(); + this.coinPublicKey = options.coinPublicKey ?? DEFAULT_COIN_PUBLIC_KEY; + this.current = this.deploy(options.constructorArgs ?? []); + } + + /** The ledger as it stands, for a spec that wants to read it. */ + get state(): StateValue { + return this.current.state; + } + + async snapshot(): Promise { + return this.current; + } + + async build(call: Call, at: ChargedState): Promise> { + const circuit = this.circuitFor(call); + const context = createCircuitContext( + this.address, + this.coinPublicKey, + at, + this.privateState, + ); + const results = circuit(context, ...(call.args as never[])); + + const pending: DryPending = { + call, + result: results.result as R, + transcript: results.proofData.publicTranscript, + effects: results.context.currentQueryContext.effects, + builtOn: at, + }; + return pending; + } + + async land(pending: Pending): Promise { + this.current = this.replay(pending as DryPending, this.current); + } + + async attempt(pending: Pending): Promise { + const built = pending as DryPending; + try { + this.current = this.replay(built, this.current); + return { outcome: 'landed' }; + } catch (error) { + return this.classify(built, error); + } + } + + async apply(call: Call): Promise { + const pending = await this.build(call, this.current); + await this.land(pending); + return pending.result; + } + + /** + * Decides whether a failed replay is a state divergence or a bug, by + * definition rather than by inspecting the message. + * + * A conflict IS "valid against the state it was built on, invalid against the + * state it is applied to". So replay the same transcript against its own build + * snapshot: if that succeeds, the only thing that changed is the state, which + * is a conflict. If it fails there too, the transcript was never valid and the + * fault is in the spec or the harness (a mis-set gas budget, a bad program), + * so the original error is rethrown rather than scored. + * + * Matching on upstream error text would be the alternative, and a worse one: + * `runTranscript` surfaces `OnchainProgramError` through wasm-bindgen as a + * plain `Error` carrying only its `Display` string, so there is no error type + * to import and any pattern here would be a copy of a string we do not own. + */ + private classify(pending: DryPending, error: unknown): Attempt { + try { + this.replay(pending, pending.builtOn); + } catch { + throw error; + } + return { outcome: 'rejected', reason: messageOf(error) }; + } + + /** + * Applies a built transcript the way the node does: re-execute it in + * verifying mode against `against`. Deliberately NOT the state the build + * itself produced, which would skip the pinned-read checks entirely and score + * every case as landed. + */ + private replay( + pending: DryPending, + against: ChargedState, + ): ChargedState { + return new QueryContext(against, this.address).runTranscript( + { + gas: BUDGET, + effects: pending.effects, + program: pending.transcript, + }, + this.costModel, + ).state; + } + + /** Every actor shares one deployed ledger; it lives here, not on them. */ + private deploy(constructorArgs: readonly unknown[]): ChargedState { + const [deployer] = Object.values(this.contracts); + if (deployer === undefined) { + throw new Error('concurrency harness: no contracts given'); + } + const manager = new CircuitContextManager( + deployer, + this.privateState, + this.coinPublicKey, + this.address, + ...constructorArgs, + ); + return manager.getContext().currentQueryContext.state; + } + + private circuitFor(call: Call) { + const contract = this.contracts[call.actor]; + if (contract === undefined) { + throw new Error(`concurrency harness: unknown actor '${call.actor}'`); + } + const circuit = contract.impureCircuits[call.circuitId]; + if (circuit === undefined) { + throw new Error( + `concurrency harness: '${call.actor}' has no circuit '${call.circuitId}'`, + ); + } + return circuit; + } +} + +/** A harness that replays transcripts in memory. */ +export function createDryHarness

( + options: HarnessOptions

, +): DryReplayHarness

{ + return new DryReplayHarness(options); +} diff --git a/contracts/test-utils/concurrency/backend.ts b/contracts/test-utils/concurrency/backend.ts new file mode 100644 index 00000000..251f229a --- /dev/null +++ b/contracts/test-utils/concurrency/backend.ts @@ -0,0 +1,48 @@ +/** + * Backend seam: a spec asks for a harness, not for a particular transport. + * + * Dry today. The live implementation is the same four operations over + * `midnight-js-contracts`: `build` becomes + * `createUnprovenCallTxFromInitialStates` with `initialContractState` pinned to + * the snapshot, and `land` / `attempt` become `submitCallTxAsync` plus a + * finality wait. It needs providers on {@link HarnessOptions}, so that field + * arrives with it. + * + * The live backend classifies a rejection from TYPED signals, never from error + * text, so nothing there needs the dry backend's differential replay: + * + * `CallTxFailedError` / `TxFailedError` @midnight-ntwrk/midnight-js-contracts + * `SucceedEntirely` / `FailEntirely` / `FailFallible`, `SegmentSuccess` / + * `SegmentFail`, `TxStatus` @midnight-ntwrk/midnight-js-types + * `TransactionResult` @midnight-ntwrk/ledger-v8 + * + * `TransactionResult` carries `type: 'success' | 'partialSuccess' | 'failure'` + * and `error?: string`, so the ledger's own message is readable at runtime. + * Read it; do not hardcode it. The Display strings behind it are static data in + * the wasm binary, exported nowhere. + * + * Known open question, settled only by running it: this module's conflicts are + * GUARANTEED-segment failures (no `Kernel.checkpoint` anywhere in the note + * core), and a guaranteed-phase failure keeps the transaction out of any block. + * So there may be no `FinalizedTxData` and no `TransactionResult` to read, and + * the signal is a submit-time rejection or a timeout instead. `FailEntirely` and + * `TransactionResult.error` are what a FALLIBLE-segment failure produces. The + * rejected path needs a bounded wait either way. + */ + +import { isLiveBackend } from '@openzeppelin/compact-simulator'; +import { createDryHarness } from './DryReplayHarness.js'; +import type { ConcurrencyHarness, HarnessOptions } from './types.js'; + +/** The harness for the current backend. */ +export async function createConcurrencyHarness

( + options: HarnessOptions

, +): Promise { + if (isLiveBackend()) { + throw new Error( + 'concurrency harness: live backend not implemented yet — see the seam ' + + 'note in test-utils/concurrency/backend.ts', + ); + } + return createDryHarness(options); +} diff --git a/contracts/test-utils/concurrency/parties.ts b/contracts/test-utils/concurrency/parties.ts new file mode 100644 index 00000000..00faac75 --- /dev/null +++ b/contracts/test-utils/concurrency/parties.ts @@ -0,0 +1,75 @@ +/** + * The parties a concurrency spec races against each other. + * + * Two things every such spec needs, whatever the contract: each party holds its + * own private inputs, and each party calls through its own contract instance so + * the witnesses answer with those inputs. The ledger they share lives in the + * harness, not on the instances, which is what makes them concurrent rather than + * independent. + * + * Secrets are derived from the party's name, not sampled, so a failing case + * reproduces and a spec can compute the derived identities once at module level. + */ + +/** + * Deterministic bytes from a label, zero-padded. + * + * @param label - Party name, or any tag the spec wants to be reproducible. + * @param size - Byte length; 32 suits a Compact `Bytes<32>` secret. + */ +export function labelledSecret(label: string, size = 32): Uint8Array { + const bytes = new Uint8Array(size); + const encoded = new TextEncoder().encode(label); + if (encoded.length > size) { + throw new Error( + `labelledSecret: '${label}' needs ${encoded.length} bytes, limit is ${size}`, + ); + } + bytes.set(encoded); + return bytes; +} + +/** One party: its private inputs, and the contract instance bound to them. */ +export interface Party { + readonly name: string; + /** The private-input holder the witnesses read, mutable between calls. */ + readonly wallet: W; + readonly contract: C; +} + +/** How to build one party, for a given contract's witnesses. */ +export interface PartyFactory { + /** Fresh private-input holder, seeded from `label` so it is reproducible. */ + wallet: (label: string) => W; + /** Contract instance whose witnesses answer from `wallet`. */ + contract: (wallet: W) => C; +} + +export interface PartySet { + readonly parties: Readonly>>; + /** Ready to hand straight to `createConcurrencyHarness({ contracts })`. */ + readonly contracts: Readonly>; +} + +/** + * Builds one party per name, plus the `contracts` record the harness wants. + * + * @param names - Actor names, used verbatim as `Call.actor`. + * @param factory - Contract-specific wallet and contract construction. + */ +export function createParties( + names: readonly string[], + factory: PartyFactory, +): PartySet { + const parties: Record> = {}; + const contracts: Record = {}; + + for (const name of names) { + const wallet = factory.wallet(name); + const contract = factory.contract(wallet); + parties[name] = { name, wallet, contract }; + contracts[name] = contract; + } + + return { parties, contracts }; +} diff --git a/contracts/test-utils/concurrency/race.ts b/contracts/test-utils/concurrency/race.ts new file mode 100644 index 00000000..6a792c39 --- /dev/null +++ b/contracts/test-utils/concurrency/race.ts @@ -0,0 +1,35 @@ +/** + * The one scenario every concurrency claim is made of. + * + * Deliberately not a wall-clock race: a conflict is a divergence between the + * state a transcript was built on and the state it is applied to, so building + * both calls against one snapshot and then applying them in order reproduces it + * exactly, every time. No same-block trickery on either backend. + */ + +import type { Call, ConcurrencyHarness, Outcome } from './types.js'; + +/** + * Builds both calls against one snapshot, lands the first, then applies the + * second. The second is the one under test: it was built against a state that + * no longer exists. + * + * @param harness - Backend to run against. + * @param first - The call that wins the race and lands. + * @param second - The call built on the now-stale snapshot. + * @returns Whether both landed, or the second was rejected. + */ +export async function race( + harness: ConcurrencyHarness, + first: Call, + second: Call, +): Promise { + const snapshot = await harness.snapshot(); + const pendingFirst = await harness.build(first, snapshot); + const pendingSecond = await harness.build(second, snapshot); + + await harness.land(pendingFirst); + const attempt = await harness.attempt(pendingSecond); + + return attempt.outcome === 'landed' ? 'both-landed' : 'second-rejected'; +} diff --git a/contracts/test-utils/concurrency/test/DryReplayHarness.test.ts b/contracts/test-utils/concurrency/test/DryReplayHarness.test.ts new file mode 100644 index 00000000..7b315c36 --- /dev/null +++ b/contracts/test-utils/concurrency/test/DryReplayHarness.test.ts @@ -0,0 +1,96 @@ +/** + * The harness's own guard rails, driven by a stub contract. + * + * Deliberately no compiled artifact here: `test:harness` does not depend on the + * `compile` task, so importing one would break a clean checkout. The replay + * behaviour that genuinely needs real ledger state is pinned instead by the + * `harness invariants` describe in a contract's own concurrency spec. + */ + +import { ContractState } from '@midnight-ntwrk/compact-runtime'; +import { describe, expect, it } from 'vitest'; +import { createConcurrencyHarness } from '../backend.js'; +import { createDryHarness, DryReplayHarness } from '../DryReplayHarness.js'; +import type { ReplayableContract } from '../types.js'; + +/** A contract that deploys to a blank ledger and exposes one circuit name. */ +const stubContract = (): ReplayableContract> => + ({ + initialState: () => ({ + currentPrivateState: {}, + currentContractState: new ContractState(), + currentZswapLocalState: {}, + }), + impureCircuits: { + doSomething: () => { + throw new Error('stub circuit: not meant to run'); + }, + }, + }) as unknown as ReplayableContract>; + +const options = () => ({ + contracts: { alice: stubContract() }, + privateState: {}, +}); + +describe('DryReplayHarness', () => { + it('should refuse to deploy with no contracts', () => { + expect(() => createDryHarness({ contracts: {}, privateState: {} })).toThrow( + 'concurrency harness: no contracts given', + ); + }); + + it('should reject a call by an actor it does not know', async () => { + const harness = createDryHarness(options()); + const snapshot = await harness.snapshot(); + + // A lookup failure is a spec bug, and `build` is where it surfaces: only + // `attempt` ever scores an outcome, and only for a replay that failed. + await expect( + harness.build( + { actor: 'carol', circuitId: 'doSomething', args: [] }, + snapshot, + ), + ).rejects.toThrow("concurrency harness: unknown actor 'carol'"); + }); + + it('should reject a circuit the actor does not have', async () => { + const harness = createDryHarness(options()); + const snapshot = await harness.snapshot(); + + await expect( + harness.build( + { actor: 'alice', circuitId: 'notACircuit', args: [] }, + snapshot, + ), + ).rejects.toThrow( + "concurrency harness: 'alice' has no circuit 'notACircuit'", + ); + }); + + it('should surface a throwing circuit rather than scoring it', async () => { + const harness = createDryHarness(options()); + const snapshot = await harness.snapshot(); + + await expect( + harness.build( + { actor: 'alice', circuitId: 'doSomething', args: [] }, + snapshot, + ), + ).rejects.toThrow('stub circuit: not meant to run'); + }); + + it('should start from the deployed state', async () => { + const harness = createDryHarness(options()); + + expect(await harness.snapshot()).toBe(await harness.snapshot()); + }); +}); + +describe('createConcurrencyHarness', () => { + it('should give a replay harness on the dry backend', async () => { + expect(await createConcurrencyHarness(options())).toBeInstanceOf( + DryReplayHarness, + ); + }); +}); diff --git a/contracts/test-utils/concurrency/test/parties.test.ts b/contracts/test-utils/concurrency/test/parties.test.ts new file mode 100644 index 00000000..2858aaa5 --- /dev/null +++ b/contracts/test-utils/concurrency/test/parties.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; +import { createParties, labelledSecret } from '../parties.js'; + +describe('concurrency parties: labelledSecret', () => { + it('should derive the same bytes for the same label', () => { + expect(labelledSecret('alice')).toStrictEqual(labelledSecret('alice')); + }); + + it('should derive different bytes for different labels', () => { + expect(labelledSecret('alice')).not.toStrictEqual(labelledSecret('bob')); + }); + + it('should zero-pad to the requested width', () => { + const secret = labelledSecret('ab', 4); + + expect(secret).toStrictEqual(new Uint8Array([0x61, 0x62, 0x00, 0x00])); + }); + + it('should not silently truncate a label that does not fit', () => { + expect(() => labelledSecret('alice', 4)).toThrow( + "labelledSecret: 'alice' needs 5 bytes, limit is 4", + ); + }); +}); + +describe('concurrency parties: createParties', () => { + // Stands in for a contract instance; only identity matters here. + const factory = { + wallet: (label: string) => ({ secret: labelledSecret(label) }), + contract: (wallet: { secret: Uint8Array }) => ({ boundTo: wallet }), + }; + + it('should give every party its own wallet and contract', () => { + const { parties } = createParties(['alice', 'bob'], factory); + + expect(Object.keys(parties)).toStrictEqual(['alice', 'bob']); + expect(parties.alice.name).toBe('alice'); + expect(parties.alice.wallet).not.toBe(parties.bob.wallet); + expect(parties.alice.contract).not.toBe(parties.bob.contract); + }); + + it('should bind each contract to that party wallet', () => { + const { parties } = createParties(['alice'], factory); + + // The witnesses have to read the party's own inputs, or two "parties" would + // race with one identity. + expect(parties.alice.contract.boundTo).toBe(parties.alice.wallet); + }); + + it('should seed each wallet from the party name', () => { + const { parties } = createParties(['alice'], factory); + + expect(parties.alice.wallet.secret).toStrictEqual(labelledSecret('alice')); + }); + + it('should expose contracts keyed the way Call.actor names them', () => { + const { parties, contracts } = createParties(['alice', 'bob'], factory); + + expect(contracts.alice).toBe(parties.alice.contract); + expect(contracts.bob).toBe(parties.bob.contract); + }); + + it('should return nothing for no names', () => { + const { parties, contracts } = createParties([], factory); + + expect(parties).toStrictEqual({}); + expect(contracts).toStrictEqual({}); + }); +}); diff --git a/contracts/test-utils/concurrency/types.ts b/contracts/test-utils/concurrency/types.ts new file mode 100644 index 00000000..ade27389 --- /dev/null +++ b/contracts/test-utils/concurrency/types.ts @@ -0,0 +1,106 @@ +/** + * The vocabulary a concurrency spec writes against, shared by every backend. + * + * A Compact transaction does not carry "call this circuit with these + * arguments". It carries a fixed public transcript, an Impact program built + * against the state the wallet saw, which the node re-executes against whatever + * state is current when the transaction lands. Two things tie that transcript + * to the state it was built on: + * + * 1. every ledger read the circuit consumed is a `popeq` with the expected + * value baked in, so a divergent read is a hard rejection, + * 2. the declared effects must equal the ones the replay recomputes. + * + * So a conflict is not a race in wall-clock time. It is a divergence between + * the state a transcript was built on and the state it is applied to, which is + * why {@link ConcurrencyHarness} separates `build` from `land`: a spec pins two + * builds to one snapshot, then applies them in order. No same-block trickery on + * either backend. + * + * Which of the two couplings a backend can see differs, so a claim that depends + * on (2) belongs in a live spec. See the SCOPE note on `DryReplayHarness`. + */ + +import type { + CircuitContext, + CircuitResults, + ContractAddress, + ContractState, +} from '@midnight-ntwrk/compact-runtime'; + +/** How a pair of calls built on one snapshot turned out. */ +export type Outcome = 'both-landed' | 'second-rejected'; + +/** One circuit invocation, named the same way on either backend. */ +export interface Call { + /** Which party's keys and notes answer the witnesses. */ + readonly actor: string; + /** Circuit name, exactly as the contract exports it. */ + readonly circuitId: string; + readonly args: readonly unknown[]; +} + +/** A call already built against some snapshot, not yet applied. */ +export interface Pending { + readonly call: Call; + readonly result: R; +} + +/** What applying a pending call did. */ +export type Attempt = + | { readonly outcome: 'landed' } + | { readonly outcome: 'rejected'; readonly reason: string }; + +/** + * The four operations a concurrency claim needs, so a spec reads the same + * whether it is replaying transcripts in memory or submitting transactions to a + * node. + * + * @typeParam S - The backend's snapshot handle, opaque to specs. + */ +export interface ConcurrencyHarness { + /** The current state, to pin subsequent builds to. */ + snapshot(): Promise; + /** Builds and applies `call` at the current state. Must succeed. */ + apply(call: Call): Promise; + /** Builds `call` against `at` without applying it. */ + build(call: Call, at: S): Promise>; + /** Applies a pending call that is required to succeed. */ + land(pending: Pending): Promise; + /** Applies a pending call that is allowed to be rejected. */ + attempt(pending: Pending): Promise; +} + +/** A circuit as the generated contract exposes it. */ +export type ImpureCircuit

= ( + context: CircuitContext

, + ...args: never[] +) => CircuitResults; + +/** The slice of a generated contract a harness drives. */ +export interface ReplayableContract

{ + initialState: ( + context: never, + ...args: never[] + ) => { + currentPrivateState: P; + currentContractState: ContractState; + currentZswapLocalState: unknown; + }; + impureCircuits: Record>; +} + +export interface HarnessOptions

{ + /** + * One contract instance per actor. Each instance closes over that actor's + * witnesses, which is how the harness gives two parties different secrets + * while they share one ledger. + */ + readonly contracts: Readonly>>; + /** Private state handed to every circuit context. */ + readonly privateState: P; + /** Constructor arguments, if the contract takes any. */ + readonly constructorArgs?: readonly unknown[]; + readonly contractAddress?: ContractAddress; + readonly coinPublicKey?: string; +} diff --git a/contracts/test-utils/harness/publishedTx.ts b/contracts/test-utils/harness/publishedTx.ts new file mode 100644 index 00000000..b9e52eaf --- /dev/null +++ b/contracts/test-utils/harness/publishedTx.ts @@ -0,0 +1,240 @@ +/** + * Live transport for the transactions a spec's calls actually published. + * + * The dry backend hands a spec `proofData.publicTranscript`, a faithful preimage + * of what a transaction will carry. This is the other end of that claim: the + * serialized transaction as the indexer stored it, which is what a real + * observer sees. A privacy spec asserts against this rather than a proxy. + * + * Deliberately isolated the way `ledgerEvents.ts` is: one query, `fetch` only, + * so an indexer schema change is a one-file fix. + */ + +import { PORTS } from './network.js'; + +/** A transaction as published, plus the contract calls it carried. */ +export interface PublishedTx { + readonly hash: string; + /** The whole serialized transaction, hex. Everything an observer receives. */ + readonly raw: string; + /** Per contract call: the entry point invoked and the resulting state. */ + readonly calls: ReadonlyArray<{ + readonly address: string; + readonly entryPoint: string; + readonly state: string; + }>; +} + +interface GqlHead { + block: { height: number } | null; +} + +interface GqlBlock { + block: { + height: number; + transactions: ReadonlyArray<{ + hash: string; + raw: string; + contractActions: ReadonlyArray<{ + address?: string; + entryPoint?: string; + state?: string; + }>; + }>; + } | null; +} + +const HEAD_QUERY = 'query Head { block { height } }'; + +// `entryPoint` lives on the ContractCall variant of the ContractAction +// interface, so it needs an inline fragment; a deploy in the same block +// contributes no entry point. +const BLOCK_TXS_QUERY = `query BlockTxs($offset: BlockOffset) { + block(offset: $offset) { + height + transactions { + hash + raw + contractActions { + address + state + ... on ContractCall { entryPoint } + } + } + } +}`; + +const url = (): string => `http://127.0.0.1:${PORTS.indexer}/api/v4/graphql`; + +/** Ceiling for a single request. The indexer is local; 10s means it is stuck. */ +const REQUEST_TIMEOUT_MS = 10_000; + +/** How long to wait between polls. */ +const POLL_INTERVAL_MS = 1_000; + +/** + * Ran out of time, either on one request or on the caller's whole deadline. + * + * Kept distinct from a protocol failure so {@link awaitPublishedTxs} can poll + * through transient slowness while a real indexer error still surfaces at once. + */ +class IndexerTimeout extends Error {} + +/** + * How long one request may take: its own ceiling, or whatever is left of the + * caller's deadline, whichever is smaller. + * + * Bounding by the remainder is the point. A fixed per-request ceiling would still + * let `publishedTxsSince` overrun, since it issues one request per block. + */ +function requestBudget(deadline: number | undefined): number { + if (deadline === undefined) { + return REQUEST_TIMEOUT_MS; + } + const remaining = deadline - Date.now(); + if (remaining <= 0) { + throw new IndexerTimeout( + 'indexer: deadline passed before the next request', + ); + } + return Math.min(remaining, REQUEST_TIMEOUT_MS); +} + +async function gql( + query: string, + variables: Record, + deadline?: number, +): Promise { + const budget = requestBudget(deadline); + let res: Response; + try { + res = await fetch(url(), { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ query, variables }), + // Without this a hung socket outlives any caller deadline: `fetch` has no + // total-response timeout, and undici's body timeout is far longer than the + // poll budget callers ask for. + signal: AbortSignal.timeout(budget), + }); + } catch (cause) { + if (cause instanceof Error && cause.name === 'TimeoutError') { + throw new IndexerTimeout(`indexer ${url()}: no response in ${budget}ms`, { + cause, + }); + } + throw cause; + } + if (!res.ok) { + throw new Error(`indexer ${url()}: HTTP ${res.status}`); + } + const body = (await res.json()) as { data?: T; errors?: unknown }; + if (body.errors) { + throw new Error(`indexer gql errors: ${JSON.stringify(body.errors)}`); + } + if (!body.data) { + throw new Error('indexer gql: empty data'); + } + return body.data; +} + +/** + * The chain head height as the indexer sees it (0 before the first block). + * + * @param deadline - Absolute epoch-ms bound. Omit to use the request ceiling. + */ +export async function indexerHead(deadline?: number): Promise { + const data = await gql(HEAD_QUERY, {}, deadline); + return data.block?.height ?? 0; +} + +/** + * Every transaction the indexer has in blocks after `height`, oldest first. + * + * @param height - Exclusive lower bound, normally the head captured before the + * call under test. + * @param contractAddress - When given, keeps only transactions carrying a + * contract action at that address. + * @param deadline - Absolute epoch-ms bound covering EVERY request this makes, + * one per block. Omit to bound each request individually instead. + */ +export async function publishedTxsSince( + height: number, + contractAddress?: string, + deadline?: number, +): Promise { + const head = await indexerHead(deadline); + const found: PublishedTx[] = []; + + for (let h = height + 1; h <= head; h++) { + // Throws once the deadline passes, which is what bounds this loop. Better + // than returning a truncated window the caller would read as complete. + const data = await gql( + BLOCK_TXS_QUERY, + { offset: { height: h } }, + deadline, + ); + for (const tx of data.block?.transactions ?? []) { + const calls = tx.contractActions + .filter((action) => action.entryPoint !== undefined) + .map((action) => ({ + address: action.address ?? '', + entryPoint: action.entryPoint ?? '', + state: action.state ?? '', + })); + if (contractAddress !== undefined) { + const wanted = contractAddress.replace(/^0x/, ''); + if (!calls.some((call) => call.address.replace(/^0x/, '') === wanted)) { + continue; + } + } + found.push({ hash: tx.hash, raw: tx.raw, calls }); + } + } + return found; +} + +/** + * Polls until at least `min` transactions have been indexed after `height`. + * + * A call resolves once the node finalizes it, which can be a beat ahead of the + * indexer having the block; without this a spec reads an empty window and + * asserts nothing. + * + * @param height - Exclusive lower bound, the head captured before the call. + * @param min - How many transactions to wait for. + * @param timeoutMs - Give up after this long. Enforced across requests, not only + * between polls, so a stuck indexer cannot outlive it. + */ +export async function awaitPublishedTxs( + height: number, + min = 1, + timeoutMs = 120_000, +): Promise { + const deadline = Date.now() + timeoutMs; + let seen: PublishedTx[] = []; + + while (Date.now() < deadline) { + try { + seen = await publishedTxsSince(height, undefined, deadline); + } catch (cause) { + // Slowness is what this function exists to absorb, so keep polling while + // time remains. A protocol failure is a real defect: surface it at once. + if (!(cause instanceof IndexerTimeout)) { + throw cause; + } + } + if (seen.length >= min) { + return seen; + } + const pause = Math.min(POLL_INTERVAL_MS, deadline - Date.now()); + if (pause <= 0) { + break; + } + await new Promise((resolve) => setTimeout(resolve, pause)); + } + + throw new Error( + `indexer: expected ${min} transaction(s) after block ${height}, saw ${seen.length}`, + ); +} diff --git a/contracts/test-utils/harness/test/publishedTx.test.ts b/contracts/test-utils/harness/test/publishedTx.test.ts new file mode 100644 index 00000000..bafaa1dc --- /dev/null +++ b/contracts/test-utils/harness/test/publishedTx.test.ts @@ -0,0 +1,209 @@ +/** + * Tests for the published-transaction transport, `fetch` stubbed. + * + * The point of interest is the timeout contract. `awaitPublishedTxs` documents + * "give up after this long", and enforcing that needs a bound on each request as + * well as between polls, since `publishedTxsSince` issues one request per block. + * A hung socket used to outlive the deadline entirely. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + awaitPublishedTxs, + indexerHead, + publishedTxsSince, +} from '../publishedTx.js'; + +// --------------------------------------------------------------------------- +// Stubbing the indexer +// --------------------------------------------------------------------------- + +const realFetch = globalThis.fetch; + +/** A GraphQL 200 carrying `data`. */ +const ok = (data: unknown): Response => + ({ + ok: true, + status: 200, + json: async () => ({ data }), + }) as unknown as Response; + +const head = (height: number | null) => ({ + block: height === null ? null : { height }, +}); + +const blockWith = ( + height: number, + actions: readonly Record[], +) => ({ + block: { + height, + transactions: [ + { + hash: `0xtx${height}`, + raw: `0xraw${height}`, + contractActions: actions, + }, + ], + }, +}); + +/** Never settles until aborted, which is what a stuck indexer looks like. */ +const hang = (init?: { signal?: AbortSignal }): Promise => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => { + // What undici raises when an `AbortSignal.timeout` fires. + const error = new Error('The operation was aborted due to timeout'); + error.name = 'TimeoutError'; + reject(error); + }); + }); + +let fetchMock: ReturnType; + +beforeEach(() => { + fetchMock = vi.fn(); + globalThis.fetch = fetchMock as unknown as typeof fetch; +}); + +afterEach(() => { + globalThis.fetch = realFetch; + vi.useRealTimers(); +}); + +// --------------------------------------------------------------------------- +// The timeout contract +// --------------------------------------------------------------------------- + +describe('awaitPublishedTxs timeout', () => { + it('should give up on a stuck indexer instead of hanging', async () => { + fetchMock.mockImplementation( + (_url: string, init?: { signal?: AbortSignal }) => hang(init), + ); + + const started = Date.now(); + await expect(awaitPublishedTxs(0, 1, 300)).rejects.toThrow( + /expected 1 transaction\(s\) after block 0, saw 0/, + ); + + // The assertion that matters: bounded by the caller's deadline, not by + // undici's far longer body timeout. + expect(Date.now() - started).toBeLessThan(3_000); + }); + + it('should pass an abort signal on every request', async () => { + fetchMock.mockResolvedValue(ok(head(0))); + + await indexerHead(); + + const init = fetchMock.mock.calls[0]?.[1] as { signal?: AbortSignal }; + expect(init.signal).toBeInstanceOf(AbortSignal); + }); + + it('should stop issuing per-block requests once the deadline passes', async () => { + // A head far ahead of the lower bound: unbounded, this would be 5000 requests. + fetchMock.mockImplementation( + (_url: string, init?: { signal?: AbortSignal }) => { + const body = JSON.parse(String((init as { body?: string })?.body)); + return body.query.includes('Head') + ? Promise.resolve(ok(head(5_000))) + : hang(init); + }, + ); + + await expect(awaitPublishedTxs(0, 1, 300)).rejects.toThrow(/expected 1/); + + // One head plus a bounded handful of block reads, nowhere near 5000. + expect(fetchMock.mock.calls.length).toBeLessThan(20); + }); +}); + +// --------------------------------------------------------------------------- +// Protocol failures still surface at once +// --------------------------------------------------------------------------- + +describe('protocol failures', () => { + it('should surface an HTTP error rather than polling through it', async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 503, + } as unknown as Response); + + await expect(awaitPublishedTxs(0, 1, 30_000)).rejects.toThrow(/HTTP 503/); + // Not retried: one call, and the deadline was never consulted. + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('should surface GraphQL errors', async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ errors: [{ message: 'unknown field' }] }), + } as unknown as Response); + + await expect(indexerHead()).rejects.toThrow(/unknown field/); + }); + + it('should surface an empty data payload', async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({}), + } as unknown as Response); + + await expect(indexerHead()).rejects.toThrow(/empty data/); + }); +}); + +// --------------------------------------------------------------------------- +// Reading a window, unchanged by the fix +// --------------------------------------------------------------------------- + +describe('publishedTxsSince', () => { + it('should report 0 for a head the indexer has no block for', async () => { + fetchMock.mockResolvedValue(ok(head(null))); + + expect(await indexerHead()).toBe(0); + }); + + it('should keep only actions carrying an entry point', async () => { + fetchMock.mockImplementation((_url: string, init?: unknown) => { + const body = JSON.parse(String((init as { body?: string })?.body)); + return Promise.resolve( + body.query.includes('Head') + ? ok(head(1)) + : ok( + blockWith(1, [ + // A deploy in the same block contributes no entry point. + { address: '0xdeployed', state: '0xs' }, + { address: '0xcalled', entryPoint: 'transfer', state: '0xs' }, + ]), + ), + ); + }); + + const [tx] = await publishedTxsSince(0); + + expect(tx?.calls).toStrictEqual([ + { address: '0xcalled', entryPoint: 'transfer', state: '0xs' }, + ]); + }); + + it('should filter by contract address, ignoring a 0x prefix', async () => { + fetchMock.mockImplementation((_url: string, init?: unknown) => { + const body = JSON.parse(String((init as { body?: string })?.body)); + return Promise.resolve( + body.query.includes('Head') + ? ok(head(1)) + : ok( + blockWith(1, [ + { address: 'abc123', entryPoint: 'transfer', state: '0xs' }, + ]), + ), + ); + }); + + expect(await publishedTxsSince(0, '0xabc123')).toHaveLength(1); + expect(await publishedTxsSince(0, '0xdeadbeef')).toHaveLength(0); + }); +});