diff --git a/CHANGELOG.md b/CHANGELOG.md index fb008f8a4..ccdf94523 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add blocklist (#626) +- Add allowList (#625) +- Add ElGamal module (#617) - Multisig contract suite under `contracts/src/multisig/`: configurable M-of-N `Signer` / `SignerManager` registry, `ProposalManager`, stateful `ShieldedTreasury` and `ShieldedTreasuryStateless`, `UnshieldedTreasury`, `Forwarder` + `ForwarderPrivate` modules with per-recipient presets, and the `ShieldedMultiSig` / `ShieldedMultiSigV2` presets. Signature verification is stubbed pending ECDSA + Keccak primitives (#475). (#378, #424, #526) ### Changed diff --git a/contracts/package.json b/contracts/package.json index 7796032d6..ea985379f 100644 --- a/contracts/package.json +++ b/contracts/package.json @@ -27,6 +27,7 @@ "compact": "compact-compiler --exclude '*/archive/*'", "compact:access": "compact-compiler --dir access", "compact:archive": "compact-compiler --dir archive", + "compact:crypto": "compact-compiler --dir crypto", "compact:multisig": "compact-compiler --dir multisig", "compact:security": "compact-compiler --dir security", "compact:token": "compact-compiler --dir token", diff --git a/contracts/src/crypto/ElGamal.compact b/contracts/src/crypto/ElGamal.compact new file mode 100644 index 000000000..fcbb1f502 --- /dev/null +++ b/contracts/src/crypto/ElGamal.compact @@ -0,0 +1,458 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.2.0 (crypto/ElGamal.compact) + +pragma language_version >= 0.23.0; + +/** + * @module ElGamal + * @description Stateless, single-receiver ElGamal primitives over the Jubjub + * curve. Provides general point encryption (`encryptPoint` / `assertDecryptsToPoint`) + * and the exponential (lifted) variant (`encrypt` / `assertDecryptsTo`) for + * bounded scalar values. + * + * In the lifted variant a value `v` is encrypted as the point `g^v`, so the + * scheme is additively homomorphic in the exponent: ciphertexts of `a` and `b` + * combine into a ciphertext of `a + b`. Recovering `v` from `g^v` requires a + * discrete-log search, so the lifted variant is only usable when `v` is bounded; + * the caller is responsible for enforcing that bound (e.g. capping each encrypted + * value to a range a discrete-log search can clear). The general point form + * carries an arbitrary message point and sidesteps the bound, at the cost of + * decryption recovering only a point (not a scalar). + * + * @notice This module holds NO ledger state and declares NO witnesses. Every + * circuit takes its keys, plaintexts, and randomness as explicit arguments so + * the same primitives can back many importing modules (single-receiver today; + * multi-receiver / auditor variants later) without each importer inheriting a + * witness interface it may not need. Secrets and cached plaintexts are supplied + * by the importing module's own witnesses and verified there. + * + * @dev Hash-to-scalar discipline. Both `secretToScalar` and `expandRandomness` + * map bytes to a Jubjub scalar via `degradeToTransient(persistentHash(...))`. + * `persistentHash` is used because the derived public key is stored on-chain + * and compared against on every later verification; `degradeToTransient` + * truncates the Field to 31 bytes so the result is ALWAYS a valid Jubjub + * scalar. Feeding a raw `persistentHash` output into `ecMulGenerator` would + * occasionally exceed the Jubjub scalar field order and fault at runtime. + * + * @dev Subgroup membership. Every `JubjubPoint` reaching a curve operation is + * in the Jubjub prime-order subgroup, including points supplied by a caller as + * a witness or argument (e.g. a recipient `pk`, or the message point in + * `encryptPoint`). The Midnight runtime enforces this at the ZK-constraint + * level: the embedded-curve gadget assigns points via cofactor clearing, so an + * off-curve / low-order / mixed-order point makes the circuit unsatisfiable. + * This module therefore performs NO in-circuit subgroup check (one is neither + * needed nor expressible: `ecMul` by the subgroup order faults, and + * `JubjubPoint` is opaque). The `ecNeg` identity and the homomorphic algebra + * rely on this guarantee. + * + * @dev Weak keys. Encrypting under the identity point yields `c2 = m` with no + * masking (the plaintext is exposed), and the identity is a valid subgroup + * point so the runtime subgroup guarantee does NOT catch it. `encryptPoint` + * therefore asserts `pk != identity`, and every encryption path routes through + * it, so confidentiality is protected regardless of the importer. An importing + * module MAY additionally reject an identity `pk` where keys enter the system + * (e.g. at registration) to fail fast with a clear error rather than at first + * encryption — a UX nicety, not a confidentiality requirement. + * + * @dev Randomness. `encryptPoint` asserts `r != 0` — like `pk = identity`, + * `r = 0` zeros the mask (`r*pk = O`), yielding the non-hiding `(O, m)`; it is + * the unique mask-zeroing randomness, so the two asserts together guarantee a + * non-trivial mask. The remaining obligations the circuit CANNOT enforce: + * every `r` MUST be a valid Jubjub scalar (`< ℓ`, the subgroup order; larger + * values fault `ecMul`), and MUST be fresh and secret. Reusing an `r` under the + * same `pk` leaks the difference of the two plaintexts (`c1` collides, so + * `c2 - c2' = m - m'`). Prefer `expandRandomness` with a distinct tag per + * encryption. + */ +module ElGamal { + import CompactStandardLibrary; + + // --------------------------------------------------------------------------- + // Types + // --------------------------------------------------------------------------- + + /** + * @description Single-receiver ElGamal ciphertext over Jubjub. + * c1 = g^r + * c2 = pk^r * m (lifted: m = g^v) + */ + export struct Ciphertext { + c1: JubjubPoint; + c2: JubjubPoint; + } + + // --------------------------------------------------------------------------- + // Key / scalar derivation + // --------------------------------------------------------------------------- + + /** + * @description Maps a 32-byte secret to a valid Jubjub scalar. See the + * module-level hash-to-scalar note for why `degradeToTransient` is required. + * + * @param secret - The 32-byte secret to map. + * @return A Field guaranteed to be a valid Jubjub scalar. + */ + export pure circuit secretToScalar(secret: Bytes<32>): Field { + return degradeToTransient(persistentHash>>([secret])); + } + + /** + * @description Derives the ElGamal public key `pk = g^secretToScalar(ek)` + * from a secret `ek`. + * + * @param ek - The 32-byte encryption secret. + * @return The corresponding public key point. + */ + export pure circuit derivePk(ek: Bytes<32>): JubjubPoint { + return ecMulGenerator(secretToScalar(ek)); + } + + /** + * @description Deterministically expands a 32-byte seed into a Jubjub scalar + * tagged by a distinguishing string, so a single witness seed can yield many + * distinct randomness values within one circuit invocation. Distinct tags + * always produce distinct outputs (collision resistance of the hash); the + * caller is responsible for using distinct tags. + * + * @param seed - The seed to expand. + * @param tag - A 32-byte tag distinguishing this expansion from its siblings. + * @return A Field suitable for use as encryption randomness. + */ + export pure circuit expandRandomness(seed: Bytes<32>, tag: Bytes<32>): Field { + return degradeToTransient(persistentHash>>([seed, tag])); + } + + // --------------------------------------------------------------------------- + // Curve helpers + // --------------------------------------------------------------------------- + + /** + * @description The order of the Jubjub prime-order subgroup (`EmbeddedFr` + * modulus `ℓ`), minus one. Used as the scalar for point negation: for any `P` + * in the subgroup, `(ℓ - 1) * P = ℓ*P - P = O - P = -P`. + * + * @notice The negation identity this enables (`P + (ℓ-1)*P == O`) is exercised + * by the `negate` round-trip tests. Compact has no module-level constants, so + * the value is exposed as a pure circuit returning the literal. + */ + pure circuit JUBJUB_SUBGROUP_ORDER_MINUS_ONE(): Field { + return 6554484396890773809930967563523245729705921265872317281365359162392183254198 as Field; + } + + /** + * @description Negates a Jubjub point. + * + * @notice Implemented as scalar multiplication by `ORDER - 1` rather than the + * obvious `ecMul(p, -1)`: the scalar `-1` computed in the `Field` type is + * `BLS_modulus - 1`, which exceeds the Jubjub scalar field order and faults + * `ecMul` ("failed to decode for built-in type EmbeddedFr"). `JubjubPoint` + * is opaque (no coordinate access), so coordinate negation is unavailable. + * The `(ORDER-1)*P = -P` identity holds only in the prime-order subgroup, but + * that always applies here: the runtime constrains every assigned `JubjubPoint` + * into the prime-order subgroup (the embedded-curve gadget assigns points via + * cofactor clearing), so no in-subgroup precondition needs to be checked or + * assumed. See the module-level subgroup note. + */ + pure circuit ecNeg(p: JubjubPoint): JubjubPoint { + return ecMul(p, JUBJUB_SUBGROUP_ORDER_MINUS_ONE()); + } + + // --------------------------------------------------------------------------- + // Encryption & homomorphic operations + // --------------------------------------------------------------------------- + + /** + * @description The identity ciphertext `Enc(0)` (both components are the + * curve identity). Useful for initializing a fresh balance. + * + * @notice This is a fixed, deterministic value `(O, O)` — NOT a hiding + * encryption. It is publicly recognizable, so a slot left at `encryptZero` is + * distinguishable as never-written. Rerandomize (or credit with fresh `r`) + * before treating it as private. + */ + export pure circuit encryptZero(): Ciphertext { + const id = ecMulGenerator(0 as Field); + return Ciphertext { c1: id, c2: id }; + } + + /** + * @description Encrypts an arbitrary message point `m` under `pk` with + * randomness `r`, producing the ciphertext `(g^r, pk^r * m)`. This is the + * general (non-lifted) ElGamal encryption; `encrypt` is the lifted special + * case `encryptPoint(pk, g^value, r)`. + * + * @notice `m` (like `pk`) is constrained into the prime-order subgroup by the + * runtime (see the module-level subgroup note), so no in-circuit membership + * check is performed. `pk` is asserted non-identity and `r` non-zero — the + * two inputs that would zero the mask and expose `m` (see the weak-keys and + * randomness notes). Decrypting `m` is only feasible when it is a known or + * small-exponent point; the caller owns that concern (the lifted `encrypt` + * bounds it via the discrete-log range). + * + * Requirements: + * + * - `pk` is not the identity point. + * - `r` is non-zero. + * + * @param pk - The recipient public key. + * @param m - The message point to encrypt. + * @param r - The encryption randomness. + * @return The ciphertext `(g^r, pk^r * m)`. + */ + export pure circuit encryptPoint(pk: JubjubPoint, m: JubjubPoint, r: Field): Ciphertext { + assert(pk != ecMulGenerator(0 as Field), "ElGamal: identity pk"); + assert(r != 0 as Field, "ElGamal: zero randomness"); + const c1 = ecMulGenerator(r); + const mask = ecMul(pk, r); + const c2 = ecAdd(mask, m); + return Ciphertext { c1: c1, c2: c2 }; + } + + /** + * @description Produces a fresh ciphertext of `value` under `pk` with + * randomness `r`, lifting `value` to the point `g^value`. Randomness is taken + * as a `Field` directly to avoid a redundant `Bytes<32> -> Field` conversion + * at every call site; `expandRandomness` produces `Field` for the same reason. + * + * Requirements: + * + * - `pk` is not the identity point. + * - `r` is non-zero. + * + * @param pk - The recipient public key. + * @param value - The plaintext value to encrypt. + * @param r - The encryption randomness. + * @return The ciphertext `(g^r, pk^r * g^value)`. + */ + export pure circuit encrypt(pk: JubjubPoint, value: Uint<128>, r: Field): Ciphertext { + return encryptPoint(pk, ecMulGenerator(value as Field), r); + } + + /** + * @description Negates a ciphertext componentwise: if `ct` encrypts `v` then + * the result encrypts `-v` under the same key (with negated randomness). + * This is the building block `sub` rests on. + * + * @param ct - The ciphertext to negate. + * @return A ciphertext of `-v`. + */ + export pure circuit negate(ct: Ciphertext): Ciphertext { + return Ciphertext { c1: ecNeg(ct.c1), c2: ecNeg(ct.c2) }; + } + + /** + * @description Homomorphically adds two ciphertexts: `Enc(a) + Enc(b)` + * decrypts to `a + b`. This is the defining additive-homomorphism primitive + * and combines two independently produced ciphertexts componentwise. + * + * @notice Both inputs MUST be encrypted under the same public key for the + * result to decrypt under that key's secret; combining ciphertexts under + * different keys yields a ciphertext no single key can open. The randomness + * of the result is the sum of the inputs' randomness, so the result is a + * valid fresh ciphertext under that key. + * + * @param a - The first ciphertext. + * @param b - The second ciphertext (same recipient key as `a`). + * @return A ciphertext of `a + b`. + */ + export pure circuit add(a: Ciphertext, b: Ciphertext): Ciphertext { + return Ciphertext { c1: ecAdd(a.c1, b.c1), c2: ecAdd(a.c2, b.c2) }; + } + + /** + * @description Homomorphically subtracts two ciphertexts: `Enc(a) - Enc(b)` + * decrypts to `a - b`. Implemented as `add(a, negate(b))`. + * + * @notice Same-key requirement as `add`. The caller MUST ensure the + * underlying plaintext subtraction does not underflow; this circuit cannot + * check it (see `subEncrypted`). + * + * @param a - The minuend ciphertext. + * @param b - The subtrahend ciphertext (same recipient key as `a`). + * @return A ciphertext of `a - b`. + */ + export pure circuit sub(a: Ciphertext, b: Ciphertext): Ciphertext { + return add(a, negate(b)); + } + + /** + * @description Homomorphically multiplies the plaintext of `ct` by a public + * scalar `k`: if `ct` encrypts `v` then the result encrypts `k * v` under the + * same key. Both components are scaled by `k`, so the randomness scales to + * `k * r` and the result is a valid fresh ciphertext under that key. + * + * This is the scalar-multiplication leg of the homomorphism, and the only way + * to express linear combinations: a weighted sum `Enc(3a + 5b)` is + * `add(scalarMul(ca, 3), scalarMul(cb, 5))`, which repeated `add` cannot do + * for large weights. + * + * @notice `k` MUST be a valid Jubjub scalar (less than the subgroup order); + * larger values fault `ecMul` (see the module-level hash-to-scalar note). The + * caller also remains responsible for the discrete-log bound — `k * v` must + * stay within the range a recovery search can clear. + * + * @param ct - The ciphertext whose plaintext is scaled. + * @param k - The public scalar multiplier (a valid Jubjub scalar). + * @return A ciphertext of `k * v`. + */ + export pure circuit scalarMul(ct: Ciphertext, k: Field): Ciphertext { + return Ciphertext { c1: ecMul(ct.c1, k), c2: ecMul(ct.c2, k) }; + } + + /** + * @description Homomorphically adds `value` to the plaintext encrypted by + * `old`, producing a fresh ciphertext under `pk` with new randomness `r`. + * Convenience over `add` for folding a known plaintext into a ciphertext: + * `add(old, encrypt(pk, value, r))`. + * + * Requirements: + * + * - `pk` is not the identity point. + * - `r` is non-zero. + */ + export pure circuit addEncrypted( + old: Ciphertext, + pk: JubjubPoint, + value: Uint<128>, + r: Field + ): Ciphertext { + return add(old, encrypt(pk, value, r)); + } + + /** + * @description Homomorphically subtracts `value` from the plaintext encrypted + * by `old`. Convenience over `sub`: `sub(old, encrypt(pk, value, r))`. + * + * @notice The caller MUST ensure the underlying plaintext subtraction does + * not underflow; this circuit cannot check it. Callers verify sufficiency by + * reading the plaintext via their own witness and asserting it with + * `assertDecryptsTo` before calling. + * + * Requirements: + * + * - `pk` is not the identity point. + * - `r` is non-zero. + */ + export pure circuit subEncrypted( + old: Ciphertext, + pk: JubjubPoint, + value: Uint<128>, + r: Field + ): Ciphertext { + return sub(old, encrypt(pk, value, r)); + } + + /** + * @description Rerandomizes `ct`: returns a fresh ciphertext of the same + * plaintext under the same key `pk`, using new randomness `r`. The output is + * unlinkable to the input (it is a uniformly fresh encryption of the same + * value) yet decrypts identically. Equivalent to homomorphically adding an + * encryption of zero. + * + * @notice `r = 0` is rejected (it would be a no-op via the `encryptPoint` + * zero-randomness assert). One silent failure mode remains that the circuit + * cannot catch: a `pk` that is not the key `ct` was encrypted under corrupts + * the plaintext (the result no longer decrypts to the original value). Pass + * fresh randomness and the matching `pk`. + * + * Requirements: + * + * - `pk` is not the identity point. + * - `r` is non-zero. + * + * @param ct - The ciphertext to rerandomize. + * @param pk - The recipient public key `ct` is encrypted under. + * @param r - Fresh, nonzero randomness for the rerandomization. + * @return A fresh ciphertext of the same plaintext. + */ + export pure circuit rerandomize(ct: Ciphertext, pk: JubjubPoint, r: Field): Ciphertext { + return add(ct, encrypt(pk, 0, r)); + } + + // --------------------------------------------------------------------------- + // Verification + // --------------------------------------------------------------------------- + + /** + * @description Asserts that `ek` is the secret for `pk`, i.e. + * `derivePk(ek) == pk`. Lets a caller prove ownership of a public key + * without touching any ciphertext or plaintext. + * + * Requirements: + * + * - `ek` is the secret for `pk` (`derivePk(ek) == pk`). + * + * @param pk - The public key to check. + * @param ek - The secret claimed to correspond to `pk`. + */ + export pure circuit assertKeyPair(pk: JubjubPoint, ek: Bytes<32>): [] { + assert(derivePk(ek) == pk, + "ElGamal: ek/pk mismatch"); + } + + /** + * @description Asserts that `ct` decrypts under `(pk, ek)` to the message + * point `m`, and that `ek` is the secret for `pk`. This is the general + * (non-lifted) verification; `assertDecryptsTo` is the lifted special case + * with `m = g^claimedValue`. + * + * Requirements: + * + * - `ek` is the secret for `pk`. + * - `ct` decrypts under `(pk, ek)` to `m`. + * + * @param ct - The ciphertext to verify. + * @param pk - The public key `ct` is expected to be encrypted under. + * @param ek - The secret claimed to correspond to `pk`. + * @param m - The message point `ct` is claimed to encrypt. + */ + export pure circuit assertDecryptsToPoint( + ct: Ciphertext, + pk: JubjubPoint, + ek: Bytes<32>, + m: JubjubPoint + ): [] { + // Inlines the same key check as `assertKeyPair` (rather than calling it) so + // `ekField` is computed once and reused for the decryption below, avoiding + // a second `secretToScalar` hash in this hot verification path. + const ekField = secretToScalar(ek); + assert(ecMulGenerator(ekField) == pk, + "ElGamal: ek/pk mismatch"); + + // m = c2 - ek*c1. ek*c1 is negated via ecNeg (scalar mul by ℓ-1) rather + // than ecMul by a negative scalar, which would exceed the Jubjub scalar + // field. ekField < ℓ (the subgroup order), so ecMul(ct.c1, ekField) is a + // valid scalar mul. + const ekC1 = ecMul(ct.c1, ekField); + const mPoint = ecAdd(ct.c2, ecNeg(ekC1)); + assert(mPoint == m, + "ElGamal: plaintext mismatch"); + } + + /** + * @description Asserts that `ct` decrypts under `(pk, ek)` to `claimedValue`, + * and that `ek` is the secret for `pk`. This is the in-circuit verification + * path an importing module uses to bind its witness-supplied `ek` and cached + * plaintext to publicly stored state: a wrong `ek` fails the key check, a + * wrong plaintext fails the decryption check. Lifted special case of + * `assertDecryptsToPoint` with the message point `g^claimedValue`. + * + * Requirements: + * + * - `ek` is the secret for `pk`. + * - `ct` decrypts under `(pk, ek)` to `claimedValue`. + * + * @param ct - The ciphertext to verify. + * @param pk - The public key `ct` is expected to be encrypted under. + * @param ek - The secret claimed to correspond to `pk`. + * @param claimedValue - The plaintext `ct` is claimed to encrypt. + */ + export pure circuit assertDecryptsTo( + ct: Ciphertext, + pk: JubjubPoint, + ek: Bytes<32>, + claimedValue: Uint<128> + ): [] { + assertDecryptsToPoint(ct, pk, ek, ecMulGenerator(claimedValue as Field)); + } +} diff --git a/contracts/src/crypto/test/ElGamal.test.ts b/contracts/src/crypto/test/ElGamal.test.ts new file mode 100644 index 000000000..d36b6563e --- /dev/null +++ b/contracts/src/crypto/test/ElGamal.test.ts @@ -0,0 +1,528 @@ +import type { JubjubPoint } from '@midnight-ntwrk/compact-runtime'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { + type Ciphertext, + ElGamalSimulator, +} from './simulators/ElGamalSimulator.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Deterministic 32-byte value seeded from a label, so every test input is + * reproducible. + */ +const b32 = (label: string): Uint8Array => { + const out = new Uint8Array(32); + out.set(new TextEncoder().encode(label).slice(0, 32)); + return out; +}; + +// Distinct secrets for two independent identities. +const EK_A = b32('elgamal-ek-A'); +const EK_B = b32('elgamal-ek-B'); + +// Explicit encryption randomness. Any value below the Jubjub scalar field +// order (~2^252) is a valid scalar; these small constants keep the tests +// deterministic and let us assert that distinct randomness yields distinct +// ciphertexts. `expandRandomness` is exercised separately. +const R1 = 111n; +const R2 = 222n; +const R3 = 333n; + +let contract: ElGamalSimulator; +let pkA: JubjubPoint; +let pkB: JubjubPoint; + +describe('ElGamal', () => { + beforeAll(async () => { + contract = await ElGamalSimulator.create(); + pkA = await contract.derivePk(EK_A); + pkB = await contract.derivePk(EK_B); + }); + + // ------------------------------------------------------------------------- + // secretToScalar + // ------------------------------------------------------------------------- + describe('secretToScalar', () => { + it('is deterministic for the same secret', async () => { + expect(await contract.secretToScalar(EK_A)).toBe( + await contract.secretToScalar(EK_A), + ); + }); + + it('maps distinct secrets to distinct scalars', async () => { + expect(await contract.secretToScalar(EK_A)).not.toBe( + await contract.secretToScalar(EK_B), + ); + }); + + it('returns a positive scalar', async () => { + expect(await contract.secretToScalar(EK_A)).toBeGreaterThan(0n); + }); + }); + + // ------------------------------------------------------------------------- + // derivePk + // ------------------------------------------------------------------------- + describe('derivePk', () => { + it('is deterministic for the same secret', async () => { + expect(await contract.derivePk(EK_A)).toEqual( + await contract.derivePk(EK_A), + ); + }); + + it('maps distinct secrets to distinct public keys', async () => { + expect(await contract.derivePk(EK_A)).not.toEqual( + await contract.derivePk(EK_B), + ); + }); + }); + + // ------------------------------------------------------------------------- + // expandRandomness + // + // The anti-reuse guarantee: a single witness seed must yield independent + // randomness per tag, and the wallet cannot collapse them. + // ------------------------------------------------------------------------- + describe('expandRandomness', () => { + const seed = b32('seed-0'); + const otherSeed = b32('seed-1'); + const tagX = b32('tag-x'); + const tagY = b32('tag-y'); + + it('is deterministic for the same (seed, tag)', async () => { + expect(await contract.expandRandomness(seed, tagX)).toBe( + await contract.expandRandomness(seed, tagX), + ); + }); + + it('produces distinct outputs for distinct tags under the same seed', async () => { + expect(await contract.expandRandomness(seed, tagX)).not.toBe( + await contract.expandRandomness(seed, tagY), + ); + }); + + it('produces distinct outputs for distinct seeds under the same tag', async () => { + expect(await contract.expandRandomness(seed, tagX)).not.toBe( + await contract.expandRandomness(otherSeed, tagX), + ); + }); + }); + + // ------------------------------------------------------------------------- + // encrypt + assertDecryptsTo (correctness of the core scheme) + // + // `assertDecryptsTo` is the decryption oracle for these tests: it succeeds + // if `ct` decrypts under `(pk, ek)` to the claimed value. + // ------------------------------------------------------------------------- + describe('encrypt / decryption round-trip', () => { + it('decrypts to the encrypted value', async () => { + const ct = await contract.encrypt(pkA, 100n, R1); + await contract.assertDecryptsTo(ct, pkA, EK_A, 100n); + }); + + it('round-trips the zero value', async () => { + const ct = await contract.encrypt(pkA, 0n, R1); + await contract.assertDecryptsTo(ct, pkA, EK_A, 0n); + }); + + it('rejects a wrong claimed plaintext', async () => { + const ct = await contract.encrypt(pkA, 100n, R1); + await expect( + contract.assertDecryptsTo(ct, pkA, EK_A, 101n), + ).rejects.toThrow('ElGamal: plaintext mismatch'); + }); + + it('rejects an ek that does not match the public key', async () => { + const ct = await contract.encrypt(pkA, 100n, R1); + // EK_B derives pkB, not pkA, so the key-binding check fails first. + await expect( + contract.assertDecryptsTo(ct, pkA, EK_B, 100n), + ).rejects.toThrow('ElGamal: ek/pk mismatch'); + }); + + it('rejects a public key the ek does not correspond to', async () => { + const ct = await contract.encrypt(pkA, 100n, R1); + await expect( + contract.assertDecryptsTo(ct, pkB, EK_A, 100n), + ).rejects.toThrow('ElGamal: ek/pk mismatch'); + }); + + it('is randomized: same plaintext under different randomness yields different ciphertexts', async () => { + const ct1 = await contract.encrypt(pkA, 100n, R1); + const ct2 = await contract.encrypt(pkA, 100n, R2); + expect(ct1).not.toEqual(ct2); + // ...yet both decrypt to the same value. + await contract.assertDecryptsTo(ct1, pkA, EK_A, 100n); + await contract.assertDecryptsTo(ct2, pkA, EK_A, 100n); + }); + + it('binds a ciphertext to its recipient key (no cross-key decryption)', async () => { + // A ciphertext for pkA must not decrypt to its plaintext under B's key, + // even though (pkB, EK_B) is internally consistent. + const ct = await contract.encrypt(pkA, 100n, R1); + await expect( + contract.assertDecryptsTo(ct, pkB, EK_B, 100n), + ).rejects.toThrow('ElGamal: plaintext mismatch'); + }); + }); + + // ------------------------------------------------------------------------- + // encryptPoint / assertDecryptsToPoint (general, non-lifted ElGamal) + // + // Message points are arbitrary prime-order-subgroup points; we reuse derived + // public keys as convenient subgroup points to encrypt. + // ------------------------------------------------------------------------- + describe('encryptPoint / assertDecryptsToPoint', () => { + let m1: JubjubPoint; // an arbitrary subgroup point + let m2: JubjubPoint; + + beforeAll(async () => { + m1 = pkB; + m2 = await contract.derivePk(b32('msg-point-2')); + }); + + it('round-trips an arbitrary message point', async () => { + const ct = await contract.encryptPoint(pkA, m1, R1); + await contract.assertDecryptsToPoint(ct, pkA, EK_A, m1); + }); + + it('rejects a wrong claimed message point', async () => { + const ct = await contract.encryptPoint(pkA, m1, R1); + await expect( + contract.assertDecryptsToPoint(ct, pkA, EK_A, m2), + ).rejects.toThrow('ElGamal: plaintext mismatch'); + }); + + it('rejects an ek that does not match the public key', async () => { + const ct = await contract.encryptPoint(pkA, m1, R1); + await expect( + contract.assertDecryptsToPoint(ct, pkA, EK_B, m1), + ).rejects.toThrow('ElGamal: ek/pk mismatch'); + }); + + it('rejects encryption under the identity public key (non-hiding weak key)', async () => { + // encryptZero().c1 is g^0 = the curve identity, a valid subgroup point. + const idPk = (await contract.encryptZero()).c1; + await expect(contract.encryptPoint(idPk, m1, R1)).rejects.toThrow( + 'ElGamal: identity pk', + ); + // The lifted path routes through encryptPoint, so it is guarded too. + await expect(contract.encrypt(idPk, 100n, R1)).rejects.toThrow( + 'ElGamal: identity pk', + ); + }); + + it('rejects zero randomness (mask vanishes, non-hiding)', async () => { + await expect(contract.encryptPoint(pkA, m1, 0n)).rejects.toThrow( + 'ElGamal: zero randomness', + ); + await expect(contract.encrypt(pkA, 100n, 0n)).rejects.toThrow( + 'ElGamal: zero randomness', + ); + // rerandomize routes through encryptPoint, so r=0 hard-fails (no longer a + // silent no-op). + const ct = await contract.encrypt(pkA, 40n, R1); + await expect(contract.rerandomize(ct, pkA, 0n)).rejects.toThrow( + 'ElGamal: zero randomness', + ); + }); + + it('lifted encrypt is the special case encryptPoint(pk, g^value, r)', async () => { + // g^0 is the curve identity, which encryptZero exposes as its c1. + const idPoint = (await contract.encryptZero()).c1; + const lifted = await contract.encrypt(pkA, 0n, R1); + await contract.assertDecryptsToPoint(lifted, pkA, EK_A, idPoint); + }); + }); + + // ------------------------------------------------------------------------- + // encryptZero + // ------------------------------------------------------------------------- + describe('encryptZero', () => { + it('decrypts to 0 under a valid key pair', async () => { + const ct = await contract.encryptZero(); + await contract.assertDecryptsTo(ct, pkA, EK_A, 0n); + }); + + it('does not decrypt to a nonzero value', async () => { + const ct = await contract.encryptZero(); + await expect( + contract.assertDecryptsTo(ct, pkA, EK_A, 1n), + ).rejects.toThrow('ElGamal: plaintext mismatch'); + }); + + it('is the canonical (non-randomized) identity ciphertext', async () => { + expect(await contract.encryptZero()).toEqual( + await contract.encryptZero(), + ); + }); + }); + + // ------------------------------------------------------------------------- + // addEncrypted (additive homomorphism) + // ------------------------------------------------------------------------- + describe('addEncrypted', () => { + it('adds to the encrypted plaintext: Enc(a) + b decrypts to a + b', async () => { + const ct = await contract.addEncrypted( + await contract.encrypt(pkA, 40n, R1), + pkA, + 2n, + R2, + ); + await contract.assertDecryptsTo(ct, pkA, EK_A, 42n); + }); + + it('adding to the identity yields the added value', async () => { + const ct = await contract.addEncrypted( + await contract.encryptZero(), + pkA, + 75n, + R1, + ); + await contract.assertDecryptsTo(ct, pkA, EK_A, 75n); + }); + + it('adding 0 preserves the plaintext but rerandomizes the ciphertext', async () => { + const base = await contract.encrypt(pkA, 40n, R1); + const added = await contract.addEncrypted(base, pkA, 0n, R2); + expect(added).not.toEqual(base); + await contract.assertDecryptsTo(added, pkA, EK_A, 40n); + }); + + it('rejects a wrong claimed sum', async () => { + const ct = await contract.addEncrypted( + await contract.encrypt(pkA, 40n, R1), + pkA, + 2n, + R2, + ); + await expect( + contract.assertDecryptsTo(ct, pkA, EK_A, 43n), + ).rejects.toThrow('ElGamal: plaintext mismatch'); + }); + }); + + // ------------------------------------------------------------------------- + // subEncrypted (additive homomorphism, subtraction) + // ------------------------------------------------------------------------- + describe('subEncrypted', () => { + it('subtracts from the encrypted plaintext: Enc(a) - b decrypts to a - b', async () => { + const ct = await contract.subEncrypted( + await contract.encrypt(pkA, 50n, R1), + pkA, + 8n, + R2, + ); + await contract.assertDecryptsTo(ct, pkA, EK_A, 42n); + }); + + it('subtracting the full balance decrypts to 0', async () => { + const ct = await contract.subEncrypted( + await contract.encrypt(pkA, 50n, R1), + pkA, + 50n, + R2, + ); + await contract.assertDecryptsTo(ct, pkA, EK_A, 0n); + }); + + it('rejects a wrong claimed difference', async () => { + const ct = await contract.subEncrypted( + await contract.encrypt(pkA, 50n, R1), + pkA, + 8n, + R2, + ); + await expect( + contract.assertDecryptsTo(ct, pkA, EK_A, 41n), + ).rejects.toThrow('ElGamal: plaintext mismatch'); + }); + + it('does NOT guard against underflow (caller must check the plaintext)', async () => { + // Subtracting more than the balance produces a ciphertext of a - b taken + // modulo the curve order — a huge value, not a clamped 0. This documents + // the contract: callers must assert sufficiency of the plaintext first. + const ct = await contract.subEncrypted( + await contract.encrypt(pkA, 5n, R1), + pkA, + 10n, + R2, + ); + await expect( + contract.assertDecryptsTo(ct, pkA, EK_A, 0n), + ).rejects.toThrow('ElGamal: plaintext mismatch'); + }); + }); + + // ------------------------------------------------------------------------- + // negate (componentwise ciphertext negation) + // ------------------------------------------------------------------------- + describe('negate', () => { + it('a ciphertext plus its negation decrypts to 0', async () => { + const ct = await contract.encrypt(pkA, 30n, R1); + const zero = await contract.add(ct, await contract.negate(ct)); + await contract.assertDecryptsTo(zero, pkA, EK_A, 0n); + }); + + it('negating twice round-trips to the original plaintext', async () => { + const ct = await contract.encrypt(pkA, 30n, R1); + const back = await contract.negate(await contract.negate(ct)); + await contract.assertDecryptsTo(back, pkA, EK_A, 30n); + }); + }); + + // ------------------------------------------------------------------------- + // add (homomorphic addition of two ciphertexts) + // ------------------------------------------------------------------------- + describe('add', () => { + it('Enc(a) + Enc(b) decrypts to a + b', async () => { + const sum = await contract.add( + await contract.encrypt(pkA, 40n, R1), + await contract.encrypt(pkA, 2n, R2), + ); + await contract.assertDecryptsTo(sum, pkA, EK_A, 42n); + }); + + it('adding the identity ciphertext preserves the plaintext', async () => { + const base = await contract.encrypt(pkA, 40n, R1); + const sum = await contract.add(base, await contract.encryptZero()); + await contract.assertDecryptsTo(sum, pkA, EK_A, 40n); + }); + + it('does not combine across recipient keys (result opens under neither)', async () => { + // Enc_A(10) + Enc_B(5) is not a valid ciphertext under either key. + const mixed = await contract.add( + await contract.encrypt(pkA, 10n, R1), + await contract.encrypt(pkB, 5n, R2), + ); + await expect( + contract.assertDecryptsTo(mixed, pkA, EK_A, 15n), + ).rejects.toThrow('ElGamal: plaintext mismatch'); + await expect( + contract.assertDecryptsTo(mixed, pkB, EK_B, 15n), + ).rejects.toThrow('ElGamal: plaintext mismatch'); + }); + }); + + // ------------------------------------------------------------------------- + // sub (homomorphic subtraction of two ciphertexts) + // ------------------------------------------------------------------------- + describe('sub', () => { + it('Enc(a) - Enc(b) decrypts to a - b', async () => { + const diff = await contract.sub( + await contract.encrypt(pkA, 50n, R1), + await contract.encrypt(pkA, 8n, R2), + ); + await contract.assertDecryptsTo(diff, pkA, EK_A, 42n); + }); + + it('subtracting an equal-value ciphertext decrypts to 0', async () => { + const diff = await contract.sub( + await contract.encrypt(pkA, 50n, R1), + await contract.encrypt(pkA, 50n, R2), + ); + await contract.assertDecryptsTo(diff, pkA, EK_A, 0n); + }); + }); + + // ------------------------------------------------------------------------- + // scalarMul (homomorphic multiplication by a public scalar) + // ------------------------------------------------------------------------- + describe('scalarMul', () => { + it('scales the plaintext: k * Enc(v) decrypts to k * v', async () => { + const scaled = await contract.scalarMul( + await contract.encrypt(pkA, 6n, R1), + 7n, + ); + await contract.assertDecryptsTo(scaled, pkA, EK_A, 42n); + }); + + it('scaling by 1 preserves the plaintext', async () => { + const scaled = await contract.scalarMul( + await contract.encrypt(pkA, 40n, R1), + 1n, + ); + await contract.assertDecryptsTo(scaled, pkA, EK_A, 40n); + }); + + it('scaling by 0 decrypts to 0', async () => { + const scaled = await contract.scalarMul( + await contract.encrypt(pkA, 40n, R1), + 0n, + ); + await contract.assertDecryptsTo(scaled, pkA, EK_A, 0n); + }); + + it('composes with add into a weighted sum: 3a + 5b', async () => { + // 3*4 + 5*6 = 42 + const weighted = await contract.add( + await contract.scalarMul(await contract.encrypt(pkA, 4n, R1), 3n), + await contract.scalarMul(await contract.encrypt(pkA, 6n, R2), 5n), + ); + await contract.assertDecryptsTo(weighted, pkA, EK_A, 42n); + }); + }); + + // ------------------------------------------------------------------------- + // rerandomize (same plaintext, fresh randomness, unlinkable ciphertext) + // ------------------------------------------------------------------------- + describe('rerandomize', () => { + it('produces a different ciphertext that decrypts to the same value', async () => { + const base = await contract.encrypt(pkA, 40n, R1); + const fresh = await contract.rerandomize(base, pkA, R2); + expect(fresh).not.toEqual(base); + await contract.assertDecryptsTo(fresh, pkA, EK_A, 40n); + }); + + it('distinct randomness yields distinct rerandomizations', async () => { + const base = await contract.encrypt(pkA, 40n, R1); + expect(await contract.rerandomize(base, pkA, R2)).not.toEqual( + await contract.rerandomize(base, pkA, R3), + ); + }); + }); + + // ------------------------------------------------------------------------- + // assertKeyPair (standalone key-ownership check) + // ------------------------------------------------------------------------- + describe('assertKeyPair', () => { + it('accepts a matching (pk, ek) pair', async () => { + await contract.assertKeyPair(pkA, EK_A); + }); + + it('rejects an ek that does not derive the public key', async () => { + await expect(contract.assertKeyPair(pkA, EK_B)).rejects.toThrow( + 'ElGamal: ek/pk mismatch', + ); + }); + }); + + // ------------------------------------------------------------------------- + // Composed flow + // + // Exercises a typical running-balance sequence: start from a fresh balance, + // add twice, subtract once, and confirm the running plaintext. + // ------------------------------------------------------------------------- + describe('composed balance flow', () => { + it('tracks a running balance through add/add/sub', async () => { + let bal: Ciphertext = await contract.encryptZero(); + bal = await contract.addEncrypted(bal, pkA, 100n, R1); // add 100 + bal = await contract.addEncrypted(bal, pkA, 30n, R2); // add 30 + bal = await contract.subEncrypted(bal, pkA, 45n, R3); // subtract 45 + await contract.assertDecryptsTo(bal, pkA, EK_A, 85n); + // And the intermediate-wrong value is rejected. + await expect( + contract.assertDecryptsTo(bal, pkA, EK_A, 130n), + ).rejects.toThrow('ElGamal: plaintext mismatch'); + }); + }); + + describe('simulator wiring', () => { + it('exposes an empty public ledger via getPublicState', async () => { + expect(await contract.getPublicState()).toStrictEqual({}); + }); + }); +}); diff --git a/contracts/src/crypto/test/mocks/MockElGamal.compact b/contracts/src/crypto/test/mocks/MockElGamal.compact new file mode 100644 index 000000000..73ab5e208 --- /dev/null +++ b/contracts/src/crypto/test/mocks/MockElGamal.compact @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: MIT + +// WARNING: FOR TESTING PURPOSES ONLY. +// This contract exposes internal circuits of the ElGamal module so they can be +// driven directly from off-chain tests. DO NOT deploy or use this contract in +// any production application. + +pragma language_version >= 0.23.0; + +import CompactStandardLibrary; + +import "../../ElGamal" prefix ElGamal_; + +export { ElGamal_Ciphertext } + +export pure circuit secretToScalar(secret: Bytes<32>): Field { + return ElGamal_secretToScalar(secret); +} + +export pure circuit derivePk(ek: Bytes<32>): JubjubPoint { + return ElGamal_derivePk(ek); +} + +export pure circuit expandRandomness(seed: Bytes<32>, tag: Bytes<32>): Field { + return ElGamal_expandRandomness(seed, tag); +} + +export pure circuit encryptZero(): ElGamal_Ciphertext { + return ElGamal_encryptZero(); +} + +export pure circuit encryptPoint( + pk: JubjubPoint, + m: JubjubPoint, + r: Field + ): ElGamal_Ciphertext { + return ElGamal_encryptPoint(pk, m, r); +} + +export pure circuit encrypt( + pk: JubjubPoint, + value: Uint<128>, + r: Field + ): ElGamal_Ciphertext { + return ElGamal_encrypt(pk, value, r); +} + +export pure circuit negate(ct: ElGamal_Ciphertext): ElGamal_Ciphertext { + return ElGamal_negate(ct); +} + +export pure circuit add( + a: ElGamal_Ciphertext, + b: ElGamal_Ciphertext + ): ElGamal_Ciphertext { + return ElGamal_add(a, b); +} + +export pure circuit sub( + a: ElGamal_Ciphertext, + b: ElGamal_Ciphertext + ): ElGamal_Ciphertext { + return ElGamal_sub(a, b); +} + +export pure circuit scalarMul( + ct: ElGamal_Ciphertext, + k: Field + ): ElGamal_Ciphertext { + return ElGamal_scalarMul(ct, k); +} + +export pure circuit addEncrypted( + old: ElGamal_Ciphertext, + pk: JubjubPoint, + value: Uint<128>, + r: Field + ): ElGamal_Ciphertext { + return ElGamal_addEncrypted(old, pk, value, r); +} + +export pure circuit subEncrypted( + old: ElGamal_Ciphertext, + pk: JubjubPoint, + value: Uint<128>, + r: Field + ): ElGamal_Ciphertext { + return ElGamal_subEncrypted(old, pk, value, r); +} + +export pure circuit rerandomize( + ct: ElGamal_Ciphertext, + pk: JubjubPoint, + r: Field + ): ElGamal_Ciphertext { + return ElGamal_rerandomize(ct, pk, r); +} + +export pure circuit assertKeyPair(pk: JubjubPoint, ek: Bytes<32>): [] { + ElGamal_assertKeyPair(pk, ek); +} + +export pure circuit assertDecryptsToPoint( + ct: ElGamal_Ciphertext, + pk: JubjubPoint, + ek: Bytes<32>, + m: JubjubPoint + ): [] { + ElGamal_assertDecryptsToPoint(ct, pk, ek, m); +} + +export pure circuit assertDecryptsTo( + ct: ElGamal_Ciphertext, + pk: JubjubPoint, + ek: Bytes<32>, + claimedValue: Uint<128> + ): [] { + ElGamal_assertDecryptsTo(ct, pk, ek, claimedValue); +} diff --git a/contracts/src/crypto/test/simulators/ElGamalSimulator.ts b/contracts/src/crypto/test/simulators/ElGamalSimulator.ts new file mode 100644 index 000000000..e545a28c2 --- /dev/null +++ b/contracts/src/crypto/test/simulators/ElGamalSimulator.ts @@ -0,0 +1,205 @@ +import type { JubjubPoint } from '@midnight-ntwrk/compact-runtime'; +import { + createSimulator, + type SimulatorOptions, +} from '@openzeppelin/compact-simulator'; +import { + type ElGamal_Ciphertext as Ciphertext, + ledger, + Contract as MockElGamal, +} from '../../../../artifacts/MockElGamal/contract/index.js'; +import { + ElGamalPrivateState, + ElGamalWitnesses, +} from '../witnesses/ElGamalWitnesses.js'; + +export type { Ciphertext }; + +/** + * Type constructor args + */ +type ElGamalArgs = readonly []; + +const ElGamalSimulatorBase = createSimulator< + ElGamalPrivateState, + ReturnType, + ReturnType, + MockElGamal, + ElGamalArgs +>({ + contractFactory: (witnesses) => + new MockElGamal(witnesses), + defaultPrivateState: () => ElGamalPrivateState, + contractArgs: () => [], + ledgerExtractor: (state) => ledger(state), + witnessesFactory: () => ElGamalWitnesses(), + artifactName: 'MockElGamal', +}); + +/** + * ElGamal Simulator + * + * Every ElGamal circuit is pure (no ledger, no witnesses), so each method is a + * thin pass-through to the compiled pure circuit. + */ +export class ElGamalSimulator extends ElGamalSimulatorBase { + static async create( + options: SimulatorOptions< + ElGamalPrivateState, + ReturnType + > = {}, + ): Promise { + // biome-ignore lint/complexity/noThisInStatic: super.create must keep the subclass `this` + return super.create([], options) as Promise; + } + + /** + * @description Maps a 32-byte secret to a valid Jubjub scalar. + */ + public secretToScalar(secret: Uint8Array): Promise { + return this.circuits.pure.secretToScalar(secret); + } + + /** + * @description Derives the ElGamal public key `pk = g^secretToScalar(ek)`. + */ + public derivePk(ek: Uint8Array): Promise { + return this.circuits.pure.derivePk(ek); + } + + /** + * @description Deterministically expands `seed` into a Jubjub scalar tagged + * by `tag`. + */ + public expandRandomness(seed: Uint8Array, tag: Uint8Array): Promise { + return this.circuits.pure.expandRandomness(seed, tag); + } + + /** + * @description The identity ciphertext `Enc(0)`. + */ + public encryptZero(): Promise { + return this.circuits.pure.encryptZero(); + } + + /** + * @description Encrypts an arbitrary message point `m` under `pk` with + * randomness `r`: `(g^r, pk^r * m)`. + */ + public encryptPoint( + pk: JubjubPoint, + m: JubjubPoint, + r: bigint, + ): Promise { + return this.circuits.pure.encryptPoint(pk, m, r); + } + + /** + * @description Encrypts `value` under `pk` with randomness `r`. + */ + public encrypt( + pk: JubjubPoint, + value: bigint, + r: bigint, + ): Promise { + return this.circuits.pure.encrypt(pk, value, r); + } + + /** + * @description Negates a ciphertext componentwise (encrypts `-v`). + */ + public negate(ct: Ciphertext): Promise { + return this.circuits.pure.negate(ct); + } + + /** + * @description Homomorphically adds two ciphertexts: `Enc(a) + Enc(b)`. + */ + public add(a: Ciphertext, b: Ciphertext): Promise { + return this.circuits.pure.add(a, b); + } + + /** + * @description Homomorphically subtracts two ciphertexts: `Enc(a) - Enc(b)`. + */ + public sub(a: Ciphertext, b: Ciphertext): Promise { + return this.circuits.pure.sub(a, b); + } + + /** + * @description Homomorphically scales the plaintext of `ct` by public scalar + * `k`: `Enc(v)` becomes `Enc(k * v)`. + */ + public scalarMul(ct: Ciphertext, k: bigint): Promise { + return this.circuits.pure.scalarMul(ct, k); + } + + /** + * @description Homomorphically adds `value` to the plaintext of `old`. + */ + public addEncrypted( + old: Ciphertext, + pk: JubjubPoint, + value: bigint, + r: bigint, + ): Promise { + return this.circuits.pure.addEncrypted(old, pk, value, r); + } + + /** + * @description Homomorphically subtracts `value` from the plaintext of `old`. + */ + public subEncrypted( + old: Ciphertext, + pk: JubjubPoint, + value: bigint, + r: bigint, + ): Promise { + return this.circuits.pure.subEncrypted(old, pk, value, r); + } + + /** + * @description Rerandomizes `ct` under `pk` with fresh randomness `r`, + * preserving the plaintext. + */ + public rerandomize( + ct: Ciphertext, + pk: JubjubPoint, + r: bigint, + ): Promise { + return this.circuits.pure.rerandomize(ct, pk, r); + } + + /** + * @description Asserts `ek` is the secret for `pk`. Throws on mismatch. + */ + public assertKeyPair(pk: JubjubPoint, ek: Uint8Array): Promise<[]> { + return this.circuits.pure.assertKeyPair(pk, ek); + } + + /** + * @description Asserts `ct` decrypts under `(pk, ek)` to the message point + * `m` and that `ek` is the secret for `pk`. Throws if either check fails. + */ + public assertDecryptsToPoint( + ct: Ciphertext, + pk: JubjubPoint, + ek: Uint8Array, + m: JubjubPoint, + ): Promise<[]> { + return this.circuits.pure.assertDecryptsToPoint(ct, pk, ek, m); + } + + /** + * @description Asserts `ct` decrypts under `(pk, ek)` to `claimedValue` and + * that `ek` is the secret for `pk`. Throws if either check fails. + */ + public assertDecryptsTo( + ct: Ciphertext, + pk: JubjubPoint, + ek: Uint8Array, + claimedValue: bigint, + ): Promise<[]> { + return this.circuits.pure.assertDecryptsTo(ct, pk, ek, claimedValue); + } +} diff --git a/contracts/src/crypto/test/witnesses/ElGamalWitnesses.ts b/contracts/src/crypto/test/witnesses/ElGamalWitnesses.ts new file mode 100644 index 000000000..479df1251 --- /dev/null +++ b/contracts/src/crypto/test/witnesses/ElGamalWitnesses.ts @@ -0,0 +1,11 @@ +// TEST-ONLY WITNESS. NOT FOR PRODUCTION USE. +// Unaudited reference material that drives Compact circuits in +// off-chain tests. Not shipped as a consumable artifact. Production +// consumers must author and audit their own witnesses. +// +// The ElGamal module is stateless and declares no witnesses, so the private +// state and witness set are both empty. + +export type ElGamalPrivateState = Record; +export const ElGamalPrivateState: ElGamalPrivateState = {}; +export const ElGamalWitnesses = () => ({}); diff --git a/contracts/src/multisig/EcdsaSignerManager.compact b/contracts/src/multisig/EcdsaSignerManager.compact index bcd108630..755e0286e 100644 --- a/contracts/src/multisig/EcdsaSignerManager.compact +++ b/contracts/src/multisig/EcdsaSignerManager.compact @@ -31,9 +31,17 @@ pragma language_version >= 0.23.0; * true). Replace it with `ecdsaVerify` once the Compact ECDSA primitive is * available. * - * @notice Duplicate detection compares each commitment against the previous one - * only, which is sufficient for at most 2 signers. Larger signer sets need a - * different uniqueness mechanism (sorted commitments or a bitmap). + * @notice Duplicate detection requires that presented signer commitments form a + * **strictly increasing** sequence under Compact's `Bytes`→integer order + * (first byte is the least-significant byte — the same embedding as + * `Bytes as Field`). Equal commitments therefore fail, including non-adjacent + * duplicates such as `[A, B, A]`. Callers that present `n >= 2` public + * keys/signatures must sort them by ascending commitment under that order + * before calling `verify`. + * + * Order is total on 32-byte values. Collision of distinct hash commitments is + * negligible. Comparison is implemented as two `Uint<128>` limbs (bytes 0–15 + * and 16–31) so no value overflows Compact's 248-bit `Uint` cap. */ module EcdsaSignerManager { import CompactStandardLibrary; @@ -43,8 +51,8 @@ module EcdsaSignerManager { /** * @description Accumulator for fold-based signature verification. Threads the - * valid count, previous commitment (for duplicate detection), and message hash - * through each iteration. + * valid count, previous commitment (strictly-increasing uniqueness for any + * `n`), and message hash through each iteration. */ export struct VerificationState { validCount: Uint<8>, @@ -95,19 +103,22 @@ module EcdsaSignerManager { * for duplicates and registry membership, and its signature validated against * `msgHash`; the valid count is then checked against the threshold. * - * @notice Duplicate detection is correct for at most 2 signers (see module - * notice). + * @notice Presented commitments must be **strictly increasing** under Compact + * `Bytes`→integer order (first byte LSB; see module notice). Sort + * `pubkeys`/`signatures` by ascending commitment before calling when `n >= 2`. * * Requirements: * * - Every public key must hash to a registered signer commitment. * - Every signature must be valid over `msgHash`. - * - Signers must not be duplicates. + * - Signers must be distinct: each commitment must be strictly greater than + * the previous one in presentation order (total order on `Bytes<32>`). * - Valid count must meet the threshold. * * @param {Bytes<32>} msgHash - The message hash signers signed off-chain. - * @param {Vector>} pubkeys - ECDSA public keys of approving signers. - * @param {Vector>} signatures - Signatures over `msgHash`. + * @param {Vector>} pubkeys - ECDSA public keys of approving signers + * (ordered by ascending commitment; first byte LSB). + * @param {Vector>} signatures - Signatures over `msgHash` (same order). * @returns {[]} Empty tuple. */ export circuit verify<#n>( @@ -117,6 +128,9 @@ module EcdsaSignerManager { ): [] { const initialState = VerificationState { validCount: 0 as Uint<8>, + // Zero sentinel so the first commitment need only be non-zero (hash + // commitments are effectively never zero) and later ones must strictly + // increase. prevCommitment: pad(32, ""), msgHash: msgHash }; @@ -176,8 +190,8 @@ module EcdsaSignerManager { /** * @description Fold callback. Verifies one signer's approval: derives the - * commitment, rejects duplicates against the previous commitment, checks - * registry membership, and validates the signature. + * commitment, enforces strictly-increasing commitments (distinct signers for + * any `n`), checks registry membership, and validates the signature. * * @param {VerificationState} state - Accumulator threaded through fold. * @param {Bytes<64>} pubkey - The signer's ECDSA public key. @@ -191,8 +205,13 @@ module EcdsaSignerManager { ): VerificationState { const commitment = _calculateSignerId(pubkey, _instanceSalt); - // Duplicate detection — sufficient for 2 signers only - assert(commitment != state.prevCommitment, "EcdsaSignerManager: duplicate signer"); + // Distinct signers for any n: require strictly increasing commitments + // under Bytes→integer order (first byte LSB). Equals (including + // non-adjacent repeats) fail. Callers must present pubkeys sorted. + assert( + isStrictlyGreaterCommitment(commitment, state.prevCommitment), + "EcdsaSignerManager: duplicate or unsorted signer" + ); Signer_assertSigner(commitment); @@ -217,4 +236,30 @@ module EcdsaSignerManager { ): Boolean { return true; } + + /** + * @description True iff `a` is strictly greater than `b` under Compact's + * `Bytes`→integer embedding (first byte is least-significant — same as + * `Bytes as Field`). + * + * Compact allows `>` only on `Uint`, not on `Field` or `Bytes`. A full + * 32-byte value can exceed `Uint<248>` (runtime cast overflow), so we split + * into two 16-byte little-endian limbs, each fitting in `Uint<128>`, and + * compare high then low. Equal high limbs fall through to the low limb so + * the order is total on 32-byte values. + * + * @param {Bytes<32>} a - Candidate commitment. + * @param {Bytes<32>} b - Previous commitment in presentation order. + * @returns {Boolean} True if a > b. + */ + pure circuit isStrictlyGreaterCommitment(a: Bytes<32>, b: Bytes<32>): Boolean { + // Bytes→Uint: first byte of the slice is the least-significant byte. + // 16 bytes always fit in Uint<128> (max 2^128-1). + const aLow = slice<16>(a, 0) as Uint<128>; + const aHigh = slice<16>(a, 16) as Uint<128>; + const bLow = slice<16>(b, 0) as Uint<128>; + const bHigh = slice<16>(b, 16) as Uint<128>; + return (aHigh > bHigh) || ((aHigh == bHigh) && (aLow > bLow)); + } } + diff --git a/contracts/src/multisig/test/EcdsaSignerManager.test.ts b/contracts/src/multisig/test/EcdsaSignerManager.test.ts index 79a38cdfc..f8817b555 100644 --- a/contracts/src/multisig/test/EcdsaSignerManager.test.ts +++ b/contracts/src/multisig/test/EcdsaSignerManager.test.ts @@ -1,7 +1,11 @@ import { beforeEach, describe, expect, it } from 'vitest'; -import { EcdsaSignerManagerSimulator } from './simulators/EcdsaSignerManagerSimulator.js'; +import { + EcdsaSignerManagerSimulator, + sortByCommitmentField, +} from './simulators/EcdsaSignerManagerSimulator.js'; const THRESHOLD = 2n; +const THRESHOLD_3 = 3n; // Instance salt and ECDSA public keys (Bytes<64>) used to derive commitments. const SALT = new Uint8Array(32).fill(7); @@ -21,6 +25,45 @@ const COMMITMENT3 = EcdsaSignerManagerSimulator.calculateSignerId(PK3, SALT); const SIGNERS = [COMMITMENT1, COMMITMENT2, COMMITMENT3]; let verifier: EcdsaSignerManagerSimulator; +let verifier3: EcdsaSignerManagerSimulator; + +/** Present keys sorted by ascending commitment (Field order), as verify requires. */ +function sortedPair( + a: Uint8Array, + b: Uint8Array, +): { + pubkeys: [Uint8Array, Uint8Array]; + signatures: [Uint8Array, Uint8Array]; +} { + const { pubkeys, signatures } = sortByCommitmentField( + SALT, + [a, b], + [SIG, SIG], + ); + return { + pubkeys: [pubkeys[0]!, pubkeys[1]!], + signatures: [signatures[0]!, signatures[1]!], + }; +} + +function sortedTriple( + a: Uint8Array, + b: Uint8Array, + c: Uint8Array, +): { + pubkeys: [Uint8Array, Uint8Array, Uint8Array]; + signatures: [Uint8Array, Uint8Array, Uint8Array]; +} { + const { pubkeys, signatures } = sortByCommitmentField( + SALT, + [a, b, c], + [SIG, SIG, SIG], + ); + return { + pubkeys: [pubkeys[0]!, pubkeys[1]!, pubkeys[2]!], + signatures: [signatures[0]!, signatures[1]!, signatures[2]!], + }; +} describe('EcdsaSignerManager', () => { beforeEach(async () => { @@ -29,6 +72,11 @@ describe('EcdsaSignerManager', () => { SIGNERS, THRESHOLD, ); + verifier3 = await EcdsaSignerManagerSimulator.create( + SALT, + SIGNERS, + THRESHOLD_3, + ); }); describe('initialization', () => { @@ -69,20 +117,42 @@ describe('EcdsaSignerManager', () => { }); describe('verify', () => { - it('should pass with two distinct registered signers', async () => { - await verifier.verify(MSG, [PK1, PK2], [SIG, SIG]); + it('should pass with two distinct registered signers (sorted)', async () => { + const { pubkeys, signatures } = sortedPair(PK1, PK2); + await verifier.verify(MSG, pubkeys, signatures); }); - it('should reject a duplicate signer', async () => { + it('should reject an adjacent duplicate signer', async () => { await expect( verifier.verify(MSG, [PK1, PK1], [SIG, SIG]), - ).rejects.toThrow('EcdsaSignerManager: duplicate signer'); + ).rejects.toThrow('EcdsaSignerManager: duplicate or unsorted signer'); }); it('should reject an unregistered signer', async () => { + const { pubkeys, signatures } = sortedPair(PK_UNKNOWN, PK2); + // Order may place unknown first or second; either fails membership or order + await expect(verifier.verify(MSG, pubkeys, signatures)).rejects.toThrow(); + }); + }); + + describe('verify3 (issue #629 — non-adjacent duplicates)', () => { + it('should pass with three distinct registered signers sorted by commitment', async () => { + const { pubkeys, signatures } = sortedTriple(PK1, PK2, PK3); + await verifier3.verify3(MSG, pubkeys, signatures); + }); + + it('should reject non-adjacent duplicate presentation [A, B, A]', async () => { + // Deliberately unsorted path that previously counted A twice under + // adjacent-only checks. Strictly-increasing Field order rejects this. + await expect( + verifier3.verify3(MSG, [PK1, PK2, PK1], [SIG, SIG, SIG]), + ).rejects.toThrow('EcdsaSignerManager: duplicate or unsorted signer'); + }); + + it('should reject adjacent duplicate in a 3-signer presentation', async () => { await expect( - verifier.verify(MSG, [PK_UNKNOWN, PK2], [SIG, SIG]), - ).rejects.toThrow('SignerManager: not a signer'); + verifier3.verify3(MSG, [PK1, PK1, PK2], [SIG, SIG, SIG]), + ).rejects.toThrow('EcdsaSignerManager: duplicate or unsorted signer'); }); }); }); diff --git a/contracts/src/multisig/test/mocks/MockEcdsaSignerManager.compact b/contracts/src/multisig/test/mocks/MockEcdsaSignerManager.compact index e41b2ab56..a2d0302ae 100644 --- a/contracts/src/multisig/test/mocks/MockEcdsaSignerManager.compact +++ b/contracts/src/multisig/test/mocks/MockEcdsaSignerManager.compact @@ -23,6 +23,17 @@ export circuit verify( return Signature_verify<2>(msgHash, pubkeys, signatures); } +/** + * 3-of-N presentation path used to test non-adjacent duplicate rejection (#629). + */ +export circuit verify3( + msgHash: Bytes<32>, + pubkeys: Vector<3, Bytes<64>>, + signatures: Vector<3, Bytes<64>> +): [] { + return Signature_verify<3>(msgHash, pubkeys, signatures); +} + export pure circuit _calculateSignerId(pk: Bytes<64>, salt: Bytes<32>): Bytes<32> { return Signature__calculateSignerId(pk, salt); } diff --git a/contracts/src/multisig/test/simulators/EcdsaSignerManagerSimulator.ts b/contracts/src/multisig/test/simulators/EcdsaSignerManagerSimulator.ts index 54534836b..af59c5bca 100644 --- a/contracts/src/multisig/test/simulators/EcdsaSignerManagerSimulator.ts +++ b/contracts/src/multisig/test/simulators/EcdsaSignerManagerSimulator.ts @@ -34,6 +34,41 @@ const EcdsaSignerManagerSimulatorBase = createSimulator< artifactName: 'MockEcdsaSignerManager', }); +/** + * Interpret a 32-byte commitment the way Compact's `Bytes as Field` cast does + * in the runtime (little-endian field element). Used to sort presentation order. + */ +export function commitmentFieldOrderKey(commitment: Uint8Array): bigint { + let x = 0n; + for (let i = commitment.length - 1; i >= 0; i--) { + x = (x << 8n) | BigInt(commitment[i]!); + } + return x; +} + +/** + * Sort pubkeys (and parallel signatures) by ascending commitment as Field. + */ +export function sortByCommitmentField( + salt: Uint8Array, + pubkeys: Uint8Array[], + signatures: Uint8Array[], +): { pubkeys: Uint8Array[]; signatures: Uint8Array[] } { + const decorated = pubkeys.map((pk, i) => { + const c = EcdsaSignerManagerSimulator.calculateSignerId(pk, salt); + return { + pk, + sig: signatures[i]!, + key: commitmentFieldOrderKey(c), + }; + }); + decorated.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0)); + return { + pubkeys: decorated.map((d) => d.pk), + signatures: decorated.map((d) => d.sig), + }; +} + /** * EcdsaSignerManager Simulator */ @@ -69,6 +104,14 @@ export class EcdsaSignerManagerSimulator extends EcdsaSignerManagerSimulatorBase return this.circuits.impure.verify(msgHash, pubkeys, signatures); } + public verify3( + msgHash: Uint8Array, + pubkeys: [Uint8Array, Uint8Array, Uint8Array], + signatures: [Uint8Array, Uint8Array, Uint8Array], + ) { + return this.circuits.impure.verify3(msgHash, pubkeys, signatures); + } + public getSignerCount(): Promise { return this.circuits.impure.getSignerCount(); } diff --git a/contracts/src/security/Allowlist.compact b/contracts/src/security/Allowlist.compact new file mode 100644 index 000000000..04e196d44 --- /dev/null +++ b/contracts/src/security/Allowlist.compact @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.2.0 (security/Allowlist.compact) + +pragma language_version >= 0.23.0; + +/** + * @module Allowlist + * @description A minimal, reusable permit-list keyed by an abstract `Bytes<32>` + * account identifier. It answers a single question: is this account a member? + * + * This module is a pure membership MECHANISM, not a policy. It holds no notion + * of "when" the list is enforced and no access control. The mutators (`_allow`, + * `_disallow`) are deliberately UNGATED composable primitives: a composing + * contract gates them behind its access-control module (the same way a token + * gates `_mint` / `_burn`), and decides when to call `assertAllowed` (for + * example, only while an "eligibility required" flag is set). Keeping the + * enforce-or-not decision in the composing layer leaves this module general + * enough to back KYC allowlists, permissioned mints, membership gates, and the + * like. + * + * @notice This is the PUBLIC (transparent) allowlist: membership status is + * publicly readable and the approved set is publicly enumerable. That is the + * right posture for transparent or public-graph assets. A graph-private asset + * should instead use a shielded allowlist (a public allowlist would re-expose + * the candidate set and bound the anonymity set). + */ +module Allowlist { + import CompactStandardLibrary; + + // --------------------------------------------------------------------------- + // State + // --------------------------------------------------------------------------- + + /** @description Approved accounts. Absent means not allowed. */ + export ledger _allowed: Set>; + + // --------------------------------------------------------------------------- + // Views + // --------------------------------------------------------------------------- + + /** + * @description Returns whether `account` is currently allowed. + * + * @circuitInfo k=9, rows=305 + * + * @param {Bytes<32>} account - The account to query. + * @return {Boolean} - True if `account` is a member of the allowlist. + */ + export circuit isAllowed(account: Bytes<32>): Boolean { + return _allowed.member(disclose(account)); + } + + // --------------------------------------------------------------------------- + // Check (the eligibility seam) + // --------------------------------------------------------------------------- + + /** + * @description Asserts that `account` is allowed. The composing contract + * decides when this gate applies. + * + * @circuitInfo k=9, rows=305 + * + * Requirements: + * + * - `account` is a member of the allowlist. + * + * @param {Bytes<32>} account - The account that must be allowed. + * @return {[]} - Empty tuple. + */ + export circuit assertAllowed(account: Bytes<32>): [] { + assert(isAllowed(account), "Allowlist: account not allowed"); + } + + // --------------------------------------------------------------------------- + // Mutators (UNGATED - the composing contract is responsible for gating these) + // --------------------------------------------------------------------------- + + /** + * @description Adds `account` to the allowlist. + * + * @circuitInfo k=9, rows=303 + * + * @param {Bytes<32>} account - The account to allow. + * @return {[]} - Empty tuple. + */ + export circuit _allow(account: Bytes<32>): [] { + _allowed.insert(disclose(account)); + } + + /** + * @description Removes `account` from the allowlist. + * + * @circuitInfo k=9, rows=303 + * + * @param {Bytes<32>} account - The account to disallow. + * @return {[]} - Empty tuple. + */ + export circuit _disallow(account: Bytes<32>): [] { + _allowed.remove(disclose(account)); + } +} diff --git a/contracts/src/security/Blocklist.compact b/contracts/src/security/Blocklist.compact new file mode 100644 index 000000000..465d1c017 --- /dev/null +++ b/contracts/src/security/Blocklist.compact @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.2.0 (security/Blocklist.compact) + +pragma language_version >= 0.23.0; + +/** + * @module Blocklist + * @description A minimal, reusable deny-list keyed by an abstract `Bytes<32>` + * account identifier. It answers a single question: is this account blocked? + * + * This module is a pure membership MECHANISM, not a policy. It holds no notion + * of "when" the list is enforced and no access control. The mutators (`_block`, + * `_unblock`) are deliberately UNGATED composable primitives: a composing + * contract gates them behind its access-control module (the same way a token + * gates `_mint` / `_burn`), and decides when to call `assertNotBlocked`. Keeping + * the enforce-or-not decision in the composing layer leaves this module general + * enough to back sanctions lists, account freezes, abuse denials, and the like. + * + * @notice This is the PUBLIC (transparent) blocklist: membership status is + * publicly readable and the blocked set is publicly enumerable. That is the + * right posture for transparent or public-graph assets. A graph-private asset + * should instead use a shielded blocklist (a public blocklist would re-expose + * the candidate set and bound the anonymity set). + */ +module Blocklist { + import CompactStandardLibrary; + + // --------------------------------------------------------------------------- + // State + // --------------------------------------------------------------------------- + + /** @description Blocked accounts. Absent means not blocked. */ + export ledger _blocked: Set>; + + // --------------------------------------------------------------------------- + // Views + // --------------------------------------------------------------------------- + + /** + * @description Returns whether `account` is currently blocked. + * + * @circuitInfo k=9, rows=305 + * + * @param {Bytes<32>} account - The account to query. + * @return {Boolean} - True if `account` is a member of the blocklist. + */ + export circuit isBlocked(account: Bytes<32>): Boolean { + return _blocked.member(disclose(account)); + } + + // --------------------------------------------------------------------------- + // Check (the eligibility seam) + // --------------------------------------------------------------------------- + + /** + * @description Asserts that `account` is not blocked. The composing contract + * decides when this gate applies. + * + * @circuitInfo k=9, rows=308 + * + * Requirements: + * + * - `account` is not a member of the blocklist. + * + * @param {Bytes<32>} account - The account that must not be blocked. + * @return {[]} - Empty tuple. + */ + export circuit assertNotBlocked(account: Bytes<32>): [] { + assert(!isBlocked(account), "Blocklist: account blocked"); + } + + // --------------------------------------------------------------------------- + // Mutators (UNGATED - the composing contract is responsible for gating these) + // --------------------------------------------------------------------------- + + /** + * @description Adds `account` to the blocklist. + * + * @circuitInfo k=9, rows=303 + * + * @param {Bytes<32>} account - The account to block. + * @return {[]} - Empty tuple. + */ + export circuit _block(account: Bytes<32>): [] { + _blocked.insert(disclose(account)); + } + + /** + * @description Removes `account` from the blocklist. + * + * @circuitInfo k=9, rows=303 + * + * @param {Bytes<32>} account - The account to unblock. + * @return {[]} - Empty tuple. + */ + export circuit _unblock(account: Bytes<32>): [] { + _blocked.remove(disclose(account)); + } +} diff --git a/contracts/src/security/test/Allowlist.test.ts b/contracts/src/security/test/Allowlist.test.ts new file mode 100644 index 000000000..dd7b31e1a --- /dev/null +++ b/contracts/src/security/test/Allowlist.test.ts @@ -0,0 +1,127 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { AllowlistSimulator } from './simulators/AllowlistSimulator.js'; + +// Deterministic 32-byte account identifier seeded from a label. +const account = (label: string): Uint8Array => { + const a = new Uint8Array(32); + a.set(new TextEncoder().encode(label).slice(0, 32)); + return a; +}; + +const ALICE = account('ALICE'); +const BOB = account('BOB'); + +let allowlist: AllowlistSimulator; + +describe('Allowlist', () => { + beforeEach(async () => { + allowlist = await AllowlistSimulator.create(); + }); + + describe('default state', () => { + it('is empty: no account is allowed', async () => { + expect(await allowlist.isAllowed(ALICE)).toBe(false); + expect(await allowlist.isAllowed(BOB)).toBe(false); + }); + + it('assertAllowed throws for a non-member', async () => { + await expect(allowlist.assertAllowed(ALICE)).rejects.toThrow( + 'Allowlist: account not allowed', + ); + }); + }); + + describe('allow', () => { + it('adds an account to the allowlist', async () => { + await allowlist.allow(ALICE); + expect(await allowlist.isAllowed(ALICE)).toBe(true); + }); + + it('does not affect other accounts', async () => { + await allowlist.allow(ALICE); + expect(await allowlist.isAllowed(BOB)).toBe(false); + }); + + it('assertAllowed passes for a member', async () => { + await allowlist.allow(ALICE); + await allowlist.assertAllowed(ALICE); + }); + + it('is idempotent', async () => { + await allowlist.allow(ALICE); + await allowlist.allow(ALICE); + expect(await allowlist.isAllowed(ALICE)).toBe(true); + }); + + it('clears with a single disallow after being allowed multiple times', async () => { + await allowlist.allow(ALICE); + await allowlist.allow(ALICE); + expect(await allowlist.isAllowed(ALICE)).toBe(true); + await allowlist.disallow(ALICE); + // Membership is binary, not a counter: one disallow clears it regardless + // of how many times it was allowed. + expect(await allowlist.isAllowed(ALICE)).toBe(false); + }); + }); + + describe('disallow', () => { + it('removes an account from the allowlist', async () => { + await allowlist.allow(ALICE); + await allowlist.disallow(ALICE); + expect(await allowlist.isAllowed(ALICE)).toBe(false); + }); + + it('assertAllowed throws again after disallow', async () => { + await allowlist.allow(ALICE); + await allowlist.disallow(ALICE); + await expect(allowlist.assertAllowed(ALICE)).rejects.toThrow( + 'Allowlist: account not allowed', + ); + }); + + it('is a no-op for a non-member', async () => { + await allowlist.disallow(BOB); + expect(await allowlist.isAllowed(BOB)).toBe(false); + }); + }); + + describe('multiple operations', () => { + it('handles allow -> disallow -> allow', async () => { + await allowlist.allow(ALICE); + expect(await allowlist.isAllowed(ALICE)).toBe(true); + + await allowlist.disallow(ALICE); + expect(await allowlist.isAllowed(ALICE)).toBe(false); + + await allowlist.allow(ALICE); + expect(await allowlist.isAllowed(ALICE)).toBe(true); + }); + + it('tracks several accounts independently', async () => { + await allowlist.allow(ALICE); + expect(await allowlist.isAllowed(ALICE)).toBe(true); + expect(await allowlist.isAllowed(BOB)).toBe(false); + + await allowlist.allow(BOB); + await allowlist.disallow(ALICE); + expect(await allowlist.isAllowed(ALICE)).toBe(false); + expect(await allowlist.isAllowed(BOB)).toBe(true); + }); + }); + + describe('simulator wiring', () => { + it('exposes the public ledger via getPublicState', async () => { + const sim = await AllowlistSimulator.create(); + + expect( + (await sim.getPublicState()).Allowlist__allowed.member(ALICE), + ).toBe(false); + + await sim.allow(ALICE); + + expect( + (await sim.getPublicState()).Allowlist__allowed.member(ALICE), + ).toBe(true); + }); + }); +}); diff --git a/contracts/src/security/test/Blocklist.test.ts b/contracts/src/security/test/Blocklist.test.ts new file mode 100644 index 000000000..ae7f0d7b5 --- /dev/null +++ b/contracts/src/security/test/Blocklist.test.ts @@ -0,0 +1,125 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { BlocklistSimulator } from './simulators/BlocklistSimulator.js'; + +// Deterministic 32-byte account identifier seeded from a label. +const account = (label: string): Uint8Array => { + const a = new Uint8Array(32); + a.set(new TextEncoder().encode(label).slice(0, 32)); + return a; +}; + +const ALICE = account('ALICE'); +const BOB = account('BOB'); + +let blocklist: BlocklistSimulator; + +describe('Blocklist', () => { + beforeEach(async () => { + blocklist = await BlocklistSimulator.create(); + }); + + describe('default state', () => { + it('is empty: no account is blocked', async () => { + expect(await blocklist.isBlocked(ALICE)).toBe(false); + expect(await blocklist.isBlocked(BOB)).toBe(false); + }); + + it('assertNotBlocked passes for a non-member', async () => { + await blocklist.assertNotBlocked(ALICE); + }); + }); + + describe('block', () => { + it('adds an account to the blocklist', async () => { + await blocklist.block(ALICE); + expect(await blocklist.isBlocked(ALICE)).toBe(true); + }); + + it('does not affect other accounts', async () => { + await blocklist.block(ALICE); + expect(await blocklist.isBlocked(BOB)).toBe(false); + }); + + it('assertNotBlocked throws for a member', async () => { + await blocklist.block(ALICE); + await expect(blocklist.assertNotBlocked(ALICE)).rejects.toThrow( + 'Blocklist: account blocked', + ); + }); + + it('is idempotent', async () => { + await blocklist.block(ALICE); + await blocklist.block(ALICE); + expect(await blocklist.isBlocked(ALICE)).toBe(true); + }); + + it('clears with a single unblock after being blocked multiple times', async () => { + await blocklist.block(ALICE); + await blocklist.block(ALICE); + expect(await blocklist.isBlocked(ALICE)).toBe(true); + await blocklist.unblock(ALICE); + // Membership is binary, not a counter: one unblock clears it regardless + // of how many times it was blocked. + expect(await blocklist.isBlocked(ALICE)).toBe(false); + }); + }); + + describe('unblock', () => { + it('removes an account from the blocklist', async () => { + await blocklist.block(ALICE); + await blocklist.unblock(ALICE); + expect(await blocklist.isBlocked(ALICE)).toBe(false); + }); + + it('assertNotBlocked passes again after unblock', async () => { + await blocklist.block(ALICE); + await blocklist.unblock(ALICE); + await blocklist.assertNotBlocked(ALICE); + }); + + it('is a no-op for a non-member', async () => { + await blocklist.unblock(BOB); + expect(await blocklist.isBlocked(BOB)).toBe(false); + }); + }); + + describe('multiple operations', () => { + it('handles block -> unblock -> block', async () => { + await blocklist.block(ALICE); + expect(await blocklist.isBlocked(ALICE)).toBe(true); + + await blocklist.unblock(ALICE); + expect(await blocklist.isBlocked(ALICE)).toBe(false); + + await blocklist.block(ALICE); + expect(await blocklist.isBlocked(ALICE)).toBe(true); + }); + + it('tracks several accounts independently', async () => { + await blocklist.block(ALICE); + expect(await blocklist.isBlocked(ALICE)).toBe(true); + expect(await blocklist.isBlocked(BOB)).toBe(false); + + await blocklist.block(BOB); + await blocklist.unblock(ALICE); + expect(await blocklist.isBlocked(ALICE)).toBe(false); + expect(await blocklist.isBlocked(BOB)).toBe(true); + }); + }); + + describe('simulator wiring', () => { + it('exposes the public ledger via getPublicState', async () => { + const sim = await BlocklistSimulator.create(); + + expect( + (await sim.getPublicState()).Blocklist__blocked.member(ALICE), + ).toBe(false); + + await sim.block(ALICE); + + expect( + (await sim.getPublicState()).Blocklist__blocked.member(ALICE), + ).toBe(true); + }); + }); +}); diff --git a/contracts/src/security/test/mocks/MockAllowlist.compact b/contracts/src/security/test/mocks/MockAllowlist.compact new file mode 100644 index 000000000..ebd2d05a6 --- /dev/null +++ b/contracts/src/security/test/mocks/MockAllowlist.compact @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MIT + +// WARNING: FOR TESTING PURPOSES ONLY. +// This contract exposes internal circuits and bypasses safety checks that the +// corresponding production contract relies on. DO NOT deploy or use this +// contract in any production application. + +pragma language_version >= 0.23.0; + +import CompactStandardLibrary; +import "../../Allowlist" prefix Allowlist_; + +export { Allowlist__allowed }; + +export circuit isAllowed(account: Bytes<32>): Boolean { + return Allowlist_isAllowed(account); +} + +export circuit assertAllowed(account: Bytes<32>): [] { + return Allowlist_assertAllowed(account); +} + +export circuit allow(account: Bytes<32>): [] { + return Allowlist__allow(account); +} + +export circuit disallow(account: Bytes<32>): [] { + return Allowlist__disallow(account); +} diff --git a/contracts/src/security/test/mocks/MockBlocklist.compact b/contracts/src/security/test/mocks/MockBlocklist.compact new file mode 100644 index 000000000..7edbf4e23 --- /dev/null +++ b/contracts/src/security/test/mocks/MockBlocklist.compact @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MIT + +// WARNING: FOR TESTING PURPOSES ONLY. +// This contract exposes internal circuits and bypasses safety checks that the +// corresponding production contract relies on. DO NOT deploy or use this +// contract in any production application. + +pragma language_version >= 0.23.0; + +import CompactStandardLibrary; +import "../../Blocklist" prefix Blocklist_; + +export { Blocklist__blocked }; + +export circuit isBlocked(account: Bytes<32>): Boolean { + return Blocklist_isBlocked(account); +} + +export circuit assertNotBlocked(account: Bytes<32>): [] { + return Blocklist_assertNotBlocked(account); +} + +export circuit block(account: Bytes<32>): [] { + return Blocklist__block(account); +} + +export circuit unblock(account: Bytes<32>): [] { + return Blocklist__unblock(account); +} diff --git a/contracts/src/security/test/simulators/AllowlistSimulator.ts b/contracts/src/security/test/simulators/AllowlistSimulator.ts new file mode 100644 index 000000000..7e93e6b2a --- /dev/null +++ b/contracts/src/security/test/simulators/AllowlistSimulator.ts @@ -0,0 +1,77 @@ +import { + createSimulator, + type SimulatorOptions, +} from '@openzeppelin/compact-simulator'; +import { + ledger, + Contract as MockAllowlist, +} from '../../../../artifacts/MockAllowlist/contract/index.js'; +import { + AllowlistPrivateState, + AllowlistWitnesses, +} from '../witnesses/AllowlistWitnesses.js'; + +/** + * Type constructor args + */ +type AllowlistArgs = readonly []; + +const AllowlistSimulatorBase = createSimulator< + AllowlistPrivateState, + ReturnType, + ReturnType, + MockAllowlist, + AllowlistArgs +>({ + contractFactory: (witnesses) => + new MockAllowlist(witnesses), + defaultPrivateState: () => AllowlistPrivateState, + contractArgs: () => [], + ledgerExtractor: (state) => ledger(state), + witnessesFactory: () => AllowlistWitnesses(), + artifactName: 'MockAllowlist', +}); + +/** + * Allowlist Simulator + */ +export class AllowlistSimulator extends AllowlistSimulatorBase { + static async create( + options: SimulatorOptions< + AllowlistPrivateState, + ReturnType + > = {}, + ): Promise { + // biome-ignore lint/complexity/noThisInStatic: super.create must keep the subclass `this` + return super.create([], options) as Promise; + } + + /** + * @description Returns whether `account` is currently allowed. + * @returns True if `account` is a member of the allowlist. + */ + public isAllowed(account: Uint8Array): Promise { + return this.circuits.impure.isAllowed(account); + } + + /** + * @description Asserts that `account` is allowed. + */ + public assertAllowed(account: Uint8Array): Promise<[]> { + return this.circuits.impure.assertAllowed(account); + } + + /** + * @description Adds `account` to the allowlist. + */ + public allow(account: Uint8Array): Promise<[]> { + return this.circuits.impure.allow(account); + } + + /** + * @description Removes `account` from the allowlist. + */ + public disallow(account: Uint8Array): Promise<[]> { + return this.circuits.impure.disallow(account); + } +} diff --git a/contracts/src/security/test/simulators/BlocklistSimulator.ts b/contracts/src/security/test/simulators/BlocklistSimulator.ts new file mode 100644 index 000000000..d33386181 --- /dev/null +++ b/contracts/src/security/test/simulators/BlocklistSimulator.ts @@ -0,0 +1,77 @@ +import { + createSimulator, + type SimulatorOptions, +} from '@openzeppelin/compact-simulator'; +import { + ledger, + Contract as MockBlocklist, +} from '../../../../artifacts/MockBlocklist/contract/index.js'; +import { + BlocklistPrivateState, + BlocklistWitnesses, +} from '../witnesses/BlocklistWitnesses.js'; + +/** + * Type constructor args + */ +type BlocklistArgs = readonly []; + +const BlocklistSimulatorBase = createSimulator< + BlocklistPrivateState, + ReturnType, + ReturnType, + MockBlocklist, + BlocklistArgs +>({ + contractFactory: (witnesses) => + new MockBlocklist(witnesses), + defaultPrivateState: () => BlocklistPrivateState, + contractArgs: () => [], + ledgerExtractor: (state) => ledger(state), + witnessesFactory: () => BlocklistWitnesses(), + artifactName: 'MockBlocklist', +}); + +/** + * Blocklist Simulator + */ +export class BlocklistSimulator extends BlocklistSimulatorBase { + static async create( + options: SimulatorOptions< + BlocklistPrivateState, + ReturnType + > = {}, + ): Promise { + // biome-ignore lint/complexity/noThisInStatic: super.create must keep the subclass `this` + return super.create([], options) as Promise; + } + + /** + * @description Returns whether `account` is currently blocked. + * @returns True if `account` is a member of the blocklist. + */ + public isBlocked(account: Uint8Array): Promise { + return this.circuits.impure.isBlocked(account); + } + + /** + * @description Asserts that `account` is not blocked. + */ + public assertNotBlocked(account: Uint8Array): Promise<[]> { + return this.circuits.impure.assertNotBlocked(account); + } + + /** + * @description Adds `account` to the blocklist. + */ + public block(account: Uint8Array): Promise<[]> { + return this.circuits.impure.block(account); + } + + /** + * @description Removes `account` from the blocklist. + */ + public unblock(account: Uint8Array): Promise<[]> { + return this.circuits.impure.unblock(account); + } +} diff --git a/contracts/src/security/test/witnesses/AllowlistWitnesses.ts b/contracts/src/security/test/witnesses/AllowlistWitnesses.ts new file mode 100644 index 000000000..910b719f0 --- /dev/null +++ b/contracts/src/security/test/witnesses/AllowlistWitnesses.ts @@ -0,0 +1,8 @@ +// TEST-ONLY WITNESS. NOT FOR PRODUCTION USE. +// Unaudited reference material that drives Compact circuits in +// off-chain tests. Not shipped as a consumable artifact. Production +// consumers must author and audit their own witnesses. + +export type AllowlistPrivateState = Record; +export const AllowlistPrivateState: AllowlistPrivateState = {}; +export const AllowlistWitnesses = () => ({}); diff --git a/contracts/src/security/test/witnesses/BlocklistWitnesses.ts b/contracts/src/security/test/witnesses/BlocklistWitnesses.ts new file mode 100644 index 000000000..c84b8f3b9 --- /dev/null +++ b/contracts/src/security/test/witnesses/BlocklistWitnesses.ts @@ -0,0 +1,8 @@ +// TEST-ONLY WITNESS. NOT FOR PRODUCTION USE. +// Unaudited reference material that drives Compact circuits in +// off-chain tests. Not shipped as a consumable artifact. Production +// consumers must author and audit their own witnesses. + +export type BlocklistPrivateState = Record; +export const BlocklistPrivateState: BlocklistPrivateState = {}; +export const BlocklistWitnesses = () => ({}); diff --git a/contracts/src/token/FungibleToken.compact b/contracts/src/token/FungibleToken.compact index 59eb7b770..345fdb8d4 100644 --- a/contracts/src/token/FungibleToken.compact +++ b/contracts/src/token/FungibleToken.compact @@ -688,16 +688,17 @@ module FungibleToken { const canonOwner = Utils_canonicalize, ContractAddress>(owner); const canonSpender = Utils_canonicalize, ContractAddress>(spender); - assert((_allowances.member(disclose(canonOwner)) && - _allowances.lookup(canonOwner).member(disclose(canonSpender))), - "FungibleToken: insufficient allowance" - ); - - const currentAllowance = _allowances.lookup(canonOwner).lookup(disclose(canonSpender)); - const MAX_UINT128 = 340282366920938463463374607431768211455; - if (currentAllowance < MAX_UINT128) { - assert(currentAllowance >= value, "FungibleToken: insufficient allowance"); - _approve(canonOwner, canonSpender, currentAllowance - value as Uint<128>); + // A missing allowance entry is treated as a zero allowance, mirroring + // `allowance`, and a zero-value spend is a no-op. Guarding on `value` + // avoids reverting (outside an assertion) on a missing entry and avoids + // writing a spurious zero entry for a zero-value spend. + if (disclose(value > 0)) { + const currentAllowance = allowance(canonOwner, canonSpender); + const MAX_UINT128 = 340282366920938463463374607431768211455; + if (currentAllowance < MAX_UINT128) { + assert(currentAllowance >= value, "FungibleToken: insufficient allowance"); + _approve(canonOwner, canonSpender, currentAllowance - value as Uint<128>); + } } } diff --git a/contracts/src/token/MultiToken.compact b/contracts/src/token/MultiToken.compact index 780ef3336..57c23a6c2 100644 --- a/contracts/src/token/MultiToken.compact +++ b/contracts/src/token/MultiToken.compact @@ -425,11 +425,17 @@ module MultiToken { if (!Utils_isTargetZero(disclose(canonFrom))) { const fromBalance = balanceOf(canonFrom, id); assert(fromBalance >= value, "MultiToken: insufficient balance"); - const newBalance = fromBalance - value; - _balances.lookup(id).insert(disclose(canonFrom), disclose(newBalance)); + // Skip the write when `value` is zero. A zero-value update leaves the + // balance unchanged, so there is nothing to write, and guarding here + // avoids `_balances.lookup(id)` reverting outside an assertion when `id` + // has never been initialized. + if (disclose(value > 0)) { + const newBalance = fromBalance - value; + _balances.lookup(id).insert(disclose(canonFrom), disclose(newBalance)); + } } - if (!Utils_isTargetZero(disclose(canonTo))) { + if (!Utils_isTargetZero(disclose(canonTo)) && disclose(value > 0)) { if (!_balances.member(disclose(id))) { _balances.insert( disclose(id), diff --git a/contracts/src/token/test/FungibleToken.test.ts b/contracts/src/token/test/FungibleToken.test.ts index 19a2c121c..108de8b3e 100644 --- a/contracts/src/token/test/FungibleToken.test.ts +++ b/contracts/src/token/test/FungibleToken.test.ts @@ -588,6 +588,27 @@ describe('FungibleToken', () => { ).rejects.toThrow('FungibleToken: insufficient allowance'); }); + it('should allow zero-value transferFrom without a pre-existing allowance', async () => { + // A missing allowance entry is treated as zero, and a zero-value spend + // is a no-op, so this must not revert. + await token.privateState.injectSecretKey(UNAUTHORIZED.secretKey); + + const txSuccess = await token.transferFrom( + OWNER.either, + RECIPIENT.either, + 0n, + ); + expect(txSuccess).toBe(true); + + // No allowance entry is created for the spender, matching `allowance`. + expect( + await token.allowance(OWNER.either, UNAUTHORIZED.either), + ).toEqual(0n); + // Balances are unchanged. + expect(await token.balanceOf(OWNER.either)).toEqual(AMOUNT); + expect(await token.balanceOf(RECIPIENT.either)).toEqual(0n); + }); + it('should fail to transferFrom to the zero address', async () => { await token.privateState.injectSecretKey(SPENDER.secretKey); @@ -1138,6 +1159,21 @@ describe('FungibleToken', () => { ).rejects.toThrow('FungibleToken: insufficient allowance'); }); + it('should allow a zero-value spend without a pre-existing allowance', async () => { + // A missing entry is treated as zero and a zero-value spend is a no-op, + // so this must not revert and must not create an entry. + await token._spendAllowance(OWNER.either, OTHER.either, 0n); + expect(await token.allowance(OWNER.either, OTHER.either)).toEqual(0n); + }); + + it('should leave an existing allowance unchanged on a zero-value spend', async () => { + await token._approve(OWNER.either, SPENDER.either, AMOUNT); + await token._spendAllowance(OWNER.either, SPENDER.either, 0n); + expect(await token.allowance(OWNER.either, SPENDER.either)).toEqual( + AMOUNT, + ); + }); + it('should canonicalize when spending allowance', async () => { await token._approve(OWNER.either, SPENDER.either, AMOUNT); diff --git a/contracts/src/token/test/MultiToken.test.ts b/contracts/src/token/test/MultiToken.test.ts index 814bd06dd..a7376e5ee 100644 --- a/contracts/src/token/test/MultiToken.test.ts +++ b/contracts/src/token/test/MultiToken.test.ts @@ -394,6 +394,23 @@ describe('MultiToken', () => { expect(await token.balanceOf(RECIPIENT.either, TOKEN_ID)).toEqual(0n); }); + it('should allow transfer of 0 tokens for an uninitialized id', async () => { + // A zero-value update must not revert on an uninitialized id. + await token.transferFrom( + OWNER.either, + RECIPIENT.either, + NONEXISTENT_ID, + 0n, + ); + + expect(await token.balanceOf(OWNER.either, NONEXISTENT_ID)).toEqual( + 0n, + ); + expect( + await token.balanceOf(RECIPIENT.either, NONEXISTENT_ID), + ).toEqual(0n); + }); + it('should handle self-transfer', async () => { await token.transferFrom( OWNER.either, @@ -1017,6 +1034,21 @@ describe('MultiToken', () => { expect(await token.balanceOf(RECIPIENT.either, TOKEN_ID)).toEqual(0n); }); + it('should allow transfer of 0 tokens for an uninitialized id', async () => { + // A zero-value update must not revert on an uninitialized id. + await token._transfer( + OWNER.either, + RECIPIENT.either, + NONEXISTENT_ID, + 0n, + ); + + expect(await token.balanceOf(OWNER.either, NONEXISTENT_ID)).toEqual(0n); + expect(await token.balanceOf(RECIPIENT.either, NONEXISTENT_ID)).toEqual( + 0n, + ); + }); + it('should fail with insufficient balance', async () => { await expect( token._transfer( @@ -1293,6 +1325,15 @@ describe('MultiToken', () => { ).rejects.toThrow('MultiToken: arithmetic overflow'); }); + it('should allow minting 0 tokens of an uninitialized id', async () => { + // A zero-value mint is a no-op: it must not revert and must not + // initialize the id. + await token._mint(RECIPIENT.either, NONEXISTENT_ID, 0n); + expect(await token.balanceOf(RECIPIENT.either, NONEXISTENT_ID)).toEqual( + 0n, + ); + }); + it('should fail when minting to zero address (id)', async () => { await expect( token._mint(ZERO_ACCOUNT, TOKEN_ID, AMOUNT), @@ -1426,6 +1467,12 @@ describe('MultiToken', () => { ).rejects.toThrow('MultiToken: insufficient balance'); }); + it('should allow burning 0 tokens from an uninitialized id', async () => { + // A zero-value burn must not revert on an uninitialized id. + await token._burn(OWNER.either, NONEXISTENT_ID, 0n); + expect(await token.balanceOf(OWNER.either, NONEXISTENT_ID)).toEqual(0n); + }); + it('should handle non-canonical fromAddress (id)', async () => { const nonCanonical = nonCanonicalLeft(OWNER.accountId); await token._burn(nonCanonical, TOKEN_ID, AMOUNT); diff --git a/package.json b/package.json index 2f0f98892..575678cf7 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "compact": "turbo run compact --filter=@openzeppelin/compact-contracts --log-prefix=none", "compact:access": "turbo run compact:access --filter=@openzeppelin/compact-contracts --log-prefix=none", "compact:archive": "turbo run compact:archive --filter=@openzeppelin/compact-contracts --log-prefix=none", + "compact:crypto": "turbo run compact:crypto --filter=@openzeppelin/compact-contracts --log-prefix=none", "compact:security": "turbo run compact:security --filter=@openzeppelin/compact-contracts --log-prefix=none", "compact:token": "turbo run compact:token --filter=@openzeppelin/compact-contracts --log-prefix=none", "compact:utils": "turbo run compact:utils --filter=@openzeppelin/compact-contracts --log-prefix=none", diff --git a/turbo.json b/turbo.json index 38dd9ef60..efac8ec02 100644 --- a/turbo.json +++ b/turbo.json @@ -1,6 +1,13 @@ { "$schema": "https://turbo.build/schema.json", "tasks": { + "compact:crypto": { + "dependsOn": ["^build"], + "env": ["COMPACT_HOME", "SKIP_ZK"], + "inputs": ["src/crypto/**/*.compact"], + "outputLogs": "new-only", + "outputs": ["artifacts/**/"] + }, "compact:security": { "dependsOn": ["^build"], "env": ["COMPACT_HOME", "SKIP_ZK"], @@ -45,6 +52,7 @@ }, "compact": { "dependsOn": [ + "compact:crypto", "compact:security", "compact:utils", "compact:access",