Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ Import the modules through `./node_modules/@openzeppelin/compact-contracts/...`.
```typescript
// MyContract.compact

pragma language_version >= 0.21.0;
pragma language_version >= 0.23.0;

import CompactStandardLibrary;
import "./node_modules/@openzeppelin/compact-contracts/access/Ownable"
Expand Down
2 changes: 1 addition & 1 deletion biome.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.3.8/schema.json",
"$schema": "https://biomejs.dev/schemas/2.4.15/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
Expand Down
2 changes: 1 addition & 1 deletion contracts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
"compact:security": "compact-compiler --dir security",
"compact:token": "compact-compiler --dir token",
"compact:utils": "compact-compiler --dir utils",
"build": "compact-builder --hierarchical --out dist --clean-dist --exclude '*/archive/*' --exclude 'Mock*' --exclude '*.mock.compact' --copy package.json --copy ../README.md && find dist -type d -empty -delete",
"build": "compact-builder --hierarchical --out dist --clean-dist --exclude '*/archive/*' --exclude '*/examples/*' --exclude 'Mock*' --exclude '*.mock.compact' --copy package.json --copy ../README.md && find dist -type d -empty -delete",
"test": "SKIP_ZK=true yarn run compact && vitest run",
"test:coverage": "SKIP_ZK=true yarn run compact && vitest run --coverage",
"compact:integration": "SKIP_ZK=true compact compile test/integration/_mocks/SharedInitCollision.compact artifacts/SharedInitCollision && SKIP_ZK=true compact compile test/integration/_mocks/ComposedTokens.compact artifacts/ComposedTokens",
Expand Down
16 changes: 9 additions & 7 deletions contracts/src/access/test/ShieldedAccessControl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import {
} from '@midnight-ntwrk/compact-runtime';
import { beforeEach, describe, expect, it } from 'vitest';
import type { Ledger } from '../../../artifacts/MockShieldedAccessControl/contract/index.js';
import { ShieldedAccessControlPrivateState } from './witnesses/ShieldedAccessControlWitnesses.js';
import { ShieldedAccessControlSimulator } from './simulators/ShieldedAccessControlSimulator.js';
import { ShieldedAccessControlPrivateState } from './witnesses/ShieldedAccessControlWitnesses.js';

const INSTANCE_SALT = new Uint8Array(32).fill(48473095);
const COMMITMENT_DOMAIN = 'ShieldedAccessControl:commitment';
Expand Down Expand Up @@ -1268,9 +1268,10 @@ describe('ShieldedAccessControl', () => {
it('should return a path when the commitment is in the tree', () => {
contract._grantRole(ROLE_OP1, OP1_ACCOUNT_ID);

const path = contract.privateState.getCommitmentPathWithFindForLeaf(
OP1_ROLE_COMMITMENT,
);
const path =
contract.privateState.getCommitmentPathWithFindForLeaf(
OP1_ROLE_COMMITMENT,
);

expect(path).toBeDefined();
expect(path?.leaf).toEqual(OP1_ROLE_COMMITMENT);
Expand Down Expand Up @@ -1299,9 +1300,10 @@ describe('ShieldedAccessControl', () => {
contract.privateState.getCommitmentPathWithWitnessImpl(
OP1_ROLE_COMMITMENT,
);
const findPath = contract.privateState.getCommitmentPathWithFindForLeaf(
OP1_ROLE_COMMITMENT,
);
const findPath =
contract.privateState.getCommitmentPathWithFindForLeaf(
OP1_ROLE_COMMITMENT,
);

expect(witnessPath.leaf).toEqual(findPath?.leaf);
});
Expand Down
2 changes: 1 addition & 1 deletion contracts/src/access/test/ZOwnablePK.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import {
import { beforeEach, describe, expect, it } from 'vitest';
import * as utils from '#test-utils/address.js';
import type { ZswapCoinPublicKey } from '../../../artifacts/MockOwnable/contract/index.js';
import { ZOwnablePKPrivateState } from './witnesses/ZOwnablePKWitnesses.js';
import { ZOwnablePKSimulator } from './simulators/ZOwnablePKSimulator.js';
import { ZOwnablePKPrivateState } from './witnesses/ZOwnablePKWitnesses.js';

// PKs
const [OWNER, Z_OWNER] = utils.generatePubKeyPair('OWNER');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,11 @@ import {
type SendResult,
type ZswapCoinPublicKey,
} from '../../../../artifacts/MockShieldedToken/contract/index.js'; // Combined imports
import type { IContractSimulator } from '../types/test.js';
import {
type ShieldedTokenPrivateState,
ShieldedTokenWitnesses,
} from '../witnesses/ShieldedTokenWitnesses.js';
import type { IContractSimulator } from '../types/test.js';

/**
* @description A simulator implementation of a shielded token contract for testing purposes.
Expand Down
236 changes: 236 additions & 0 deletions contracts/src/crypto/ElGamal.compact
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
// 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, exponential (lifted) variant.
*
* A value `v` is encrypted as a 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 this variant is only usable when `v` is bounded (the caller is
* responsible for that bound; see e.g. the per-transfer value cap in
* `ConfidentialFungibleToken`).
*
* @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 multiple confidential modules (single-receiver
* token 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.
*/
module ElGamal {
import CompactStandardLibrary;

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------

/**
* @description Single-receiver ElGamal ciphertext over Jubjub.
* c1 = g^r
* c2 = pk^r * 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<Vector<1, Bytes<32>>>([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<Vector<2, Bytes<32>>>([seed, tag]));
}

// ---------------------------------------------------------------------------
// Curve helpers
// ---------------------------------------------------------------------------

/**
* @description The order of the Jubjub prime-order subgroup, minus one. Used
* as the scalar for point negation: for any `P` in the subgroup,
* `(ORDER - 1) * P = ORDER*P - P = O - P = -P`.
*
* @notice This is the modulus of the Jubjub scalar field (`EmbeddedFr`) minus
* one. The value is verified against the runtime in the ElGamal tests:
* `ORDER - 1` is the largest valid scalar (`ORDER` itself faults `ecMul`),
* and `P + (ORDER-1)*P` equals the curve identity `(0, 1)`.
*
* Compact has no module-level constants, so it is exposed as a pure circuit
* returning the literal (same pattern as `MAX_TRANSFER_VALUE` in the token).
*/
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.
* Every point produced by this module derives from `ecMulGenerator`/`ecAdd`
* and is therefore in the prime-order subgroup, where this identity holds.
*/
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.
*/
export pure circuit encryptZero(): Ciphertext {
const id = ecMulGenerator(0 as Field);
return Ciphertext { c1: id, c2: id };
}

/**
* @description Produces a fresh ciphertext of `value` under `pk` with
* randomness `r`. 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.
*
* @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 {
const c1 = ecMulGenerator(r);
const mask = ecMul(pk, r);
const valuePoint = ecMulGenerator(value as Field);
const c2 = ecAdd(mask, valuePoint);
return Ciphertext { c1: c1, c2: c2 };
}

/**
* @description Homomorphically adds `value` to the plaintext encrypted by
* `old`, producing a fresh ciphertext under `pk` with new randomness `r`,
* componentwise summed with `old`.
*/
export pure circuit addEncrypted(
old: Ciphertext,
pk: JubjubPoint,
value: Uint<128>,
r: Field
): Ciphertext {
const fresh = encrypt(pk, value, r);
return Ciphertext {
c1: ecAdd(old.c1, fresh.c1),
c2: ecAdd(old.c2, fresh.c2)
};
}

/**
* @description Homomorphically subtracts `value` from the plaintext encrypted
* by `old`. Builds a fresh ciphertext of `value` under `pk` with randomness
* `r`, negates it componentwise via `ecNeg`, and adds to `old`.
*
* @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.
*/
export pure circuit subEncrypted(
old: Ciphertext,
pk: JubjubPoint,
value: Uint<128>,
r: Field
): Ciphertext {
const fresh = encrypt(pk, value, r);
return Ciphertext {
c1: ecAdd(old.c1, ecNeg(fresh.c1)),
c2: ecAdd(old.c2, ecNeg(fresh.c2))
};
}

// ---------------------------------------------------------------------------
// Verification
// ---------------------------------------------------------------------------

/**
* @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.
*
* @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 circuit assertDecryptsTo(
ct: Ciphertext,
pk: JubjubPoint,
ek: Bytes<32>,
claimedValue: Uint<128>
): [] {
const ekField = secretToScalar(ek);
assert(ecMulGenerator(ekField) == pk,
"ElGamal: ek/pk mismatch");

// m = c2 - ek*c1. ek*c1 is negated via ecNeg (coordinate negation) rather
// than ecMul by a negative scalar, which would exceed the Jubjub scalar
// field. ekField < r, so ecMul(ct.c1, ekField) is a valid scalar mul.
const ekC1 = ecMul(ct.c1, ekField);
const mPoint = ecAdd(ct.c2, ecNeg(ekC1));
const expected = ecMulGenerator(claimedValue as Field);
assert(mPoint == expected,
"ElGamal: plaintext mismatch");
}
}
Loading