diff --git a/.gitignore b/.gitignore index 2a32ed65..02d03dcd 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,7 @@ coverage *~ *temp + +.claude/ + +.states diff --git a/contracts/package.json b/contracts/package.json index 438c4d7e..ffe9670e 100644 --- a/contracts/package.json +++ b/contracts/package.json @@ -33,6 +33,7 @@ "compact:utils": "compact-compiler --dir utils", "build": "compact-builder", "test": "compact-compiler --skip-zk && vitest run", + "test:coverage": "compact-compiler --skip-zk && vitest run --coverage", "types": "tsc -p tsconfig.json --noEmit", "clean": "git clean -fXd" }, @@ -46,6 +47,8 @@ "@openzeppelin-compact/contracts-simulator": "workspace:^", "@tsconfig/node24": "^24.0.4", "@types/node": "24.10.0", + "@vitest/coverage-v8": "^4.1.6", + "fast-check": "^3.23.2", "ts-node": "^10.9.2", "typescript": "^5.9.3", "vitest": "^4.1.2" diff --git a/contracts/src/multisig/Forwarder.compact b/contracts/src/multisig/Forwarder.compact new file mode 100644 index 00000000..9c29aabd --- /dev/null +++ b/contracts/src/multisig/Forwarder.compact @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.0.1-alpha.1 (multisig/Forwarder.compact) + +pragma language_version >= 0.21.0; + +/** + * @module Forwarder + * @description Public-parent forwarder primitives. Provides an atomic + * forward pattern: receive a coin (shielded or unshielded) and + * immediately send it to a hard-coded parent address. + * + * The module is generic over the parent's address type `T`. Each preset + * specializes it to the address kind it forwards to: `ForwarderShielded` + * uses `ZswapCoinPublicKey`, `ForwarderUnshielded` uses `UserAddress`. + * Storing the parent as a typed value rather than raw `Bytes<32>` forces + * every deployer to supply the correct address kind for the coin type. + * + * Underscore-prefixed circuits have no access control. The forwarder is + * intentionally permissionless — the recipient is hard-coded at deploy, + * so any caller may deposit without compromising the parent. State + * access is gated by the `Initializable` module: every circuit asserts + * the module has been initialized via `initialize`. + */ +module Forwarder { + import CompactStandardLibrary; + import "../security/Initializable" prefix Initializable_; + + // ─── State ────────────────────────────────────────────────────── + + export sealed ledger _parent: T; + + // ─── Init ─────────────────────────────────────────────────────── + + /** + * @description Initializes the forwarder with a parent address. + * Called once from the preset constructor. The parent is the + * recipient of every forwarded coin and is immutable after init. + * + * Requirements: + * + * - Contract must not be initialized. + * - `parent` must not be the zero address. A zero parent would + * forward every deposit to an unspendable address with no recovery + * path. + * + * @param {T} parent - The parent address. Typed per the specializing + * preset: `ZswapCoinPublicKey` for shielded, `UserAddress` for + * unshielded. + * + * @returns {[]} Empty tuple. + */ + export circuit initialize(parent: T): [] { + assert(parent != default, "Forwarder: zero parent"); + Initializable_initialize(); + _parent = disclose(parent); + } + + // ─── Deposit ──────────────────────────────────────────────────── + + /** + * @description Receives a shielded coin and atomically forwards it + * to `_parent`. The coin is claimed at the protocol level via + * `receiveShielded`, then immediately re-sent via + * `sendImmediateShielded`. The parent's bytes are wrapped as a + * `ZswapCoinPublicKey` recipient at the send site. + * + * @circuitInfo k=15, rows=18573 + * + * Requirements: + * + * - Contract must be initialized. + * + * @param {ShieldedCoinInfo} coin - The incoming shielded coin. + * + * @returns {[]} Empty tuple. + */ + export circuit _depositShielded(coin: ShieldedCoinInfo): [] { + Initializable_assertInitialized(); + receiveShielded(disclose(coin)); + sendImmediateShielded( + disclose(coin), + left(ZswapCoinPublicKey { bytes: _parent.bytes }), + disclose(coin.value) + ); + } + + /** + * @description Receives an unshielded amount of `color` and + * atomically forwards it to `_parent`. The parent's bytes are + * wrapped as a `UserAddress` recipient at the send site. + * + * @circuitInfo k=9, rows=436 + * + * Requirements: + * + * - Contract must be initialized. + * + * @param {Bytes<32>} color - The token color. + * @param {Uint<128>} amount - The amount to deposit. + * + * @returns {[]} Empty tuple. + */ + export circuit _depositUnshielded(color: Bytes<32>, amount: Uint<128>): [] { + Initializable_assertInitialized(); + receiveUnshielded(disclose(color), disclose(amount)); + sendUnshielded( + disclose(color), + disclose(amount), + right(UserAddress { bytes: _parent.bytes }) + ); + } +} diff --git a/contracts/src/multisig/ForwarderPrivate.compact b/contracts/src/multisig/ForwarderPrivate.compact new file mode 100644 index 00000000..b4159a26 --- /dev/null +++ b/contracts/src/multisig/ForwarderPrivate.compact @@ -0,0 +1,174 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.0.1-alpha.1 (multisig/ForwarderPrivate.compact) + +pragma language_version >= 0.21.0; + +/** + * @module ForwarderPrivate + * @description Private-parent forwarder primitives. The parent address + * is hidden behind a `persistentHash` commitment on the ledger. + * Deposits accumulate at the contract (no atomic forward); the operator + * drains coins by presenting the `(parentAddr, opSecret)` preimage at drain + * time. + * + * Knowledge of the preimage is the sole authorization gate — there is + * no signer set and no nullifier scheme. The operational secret is + * held off-chain; the contract never sees it except during a drain. + * Two forwarders bound to the same parent with different operational + * secrets produce different commitments and are unlinkable on-chain. + * + * Underscore-prefixed circuits have no access control beyond the + * preimage check inside `_drain`. State access is gated by the + * `Initializable` module: every state-touching circuit asserts the + * module has been initialized via `initialize`. The pure + * `_calculateParentCommitment` helper does not access state and is + * intentionally callable without initialization. + */ +module ForwarderPrivate { + import CompactStandardLibrary; + import "../security/Initializable" prefix Initializable_; + import "../utils/Utils" prefix Utils_; + + // ─── State ────────────────────────────────────────────────────── + + export sealed ledger _parentCommitment: Bytes<32>; + + // ─── Init ─────────────────────────────────────────────────────── + + /** + * @description Initializes the forwarder with a parent commitment. + * Called once from the preset constructor. The commitment is + * immutable after init. + * + * Requirements: + * + * - Contract must not be initialized. + * - `parentCommitment` must not be the zero bytes. A zero commitment + * is the sole drain gate and would leave every deposited coin + * permanently unrecoverable (no preimage exists for `default>` + * under the domain-tagged hash). + * + * @param {Bytes<32>} parentCommitment - Domain-tagged + * `persistentHash([pad(32, "ForwarderPrivate:commitment"), parentAddr, opSecret])` + * computed off-chain by the deployer (see `_calculateParentCommitment`). + * + * @returns {[]} Empty tuple. + */ + export circuit initialize(parentCommitment: Bytes<32>): [] { + assert(parentCommitment != default>, "ForwarderPrivate: zero commitment"); + Initializable_initialize(); + _parentCommitment = disclose(parentCommitment); + } + + // ─── Deposit ──────────────────────────────────────────────────── + + /** + * @description Receives a shielded coin into the forwarder's custody. + * No ledger write — coins dwell at the contract address until drained. + * + * @circuitInfo k=13, rows=6538 + * + * Requirements: + * + * - Contract must be initialized. + * + * @param {ShieldedCoinInfo} coin - The incoming shielded coin. + * + * @returns {[]} Empty tuple. + */ + export circuit _deposit(coin: ShieldedCoinInfo): [] { + Initializable_assertInitialized(); + receiveShielded(disclose(coin)); + } + + // ─── Drain ────────────────────────────────────────────────────── + + /** + * @description Spends a previously-deposited shielded coin to + * `parentAddr`. The caller proves knowledge of `(parentAddr, opSecret)` + * matching the stored commitment. If `coin.value` exceeds `value`, + * the change is re-emitted back to the contract for future drains. + * + * @circuitInfo k=16, rows=47811 + * + * Requirements: + * + * - Contract must be initialized. + * - `_calculateParentCommitment(parentAddr, opSecret) == _parentCommitment`. + * - `coin.value` must be >= `value` (enforced by `sendShielded`). + * + * @param {QualifiedShieldedCoinInfo} coin - The coin to spend. + * @param {Bytes<32>} parentAddr - The parent address. Preimage to the + * stored commitment. + * @param {Bytes<32>} opSecret - The operational secret. Never appears + * on the public transcript. + * + * @warning **Losing the operational secret is permanent fund loss.** It + * is the sole drain authorization, with no rotation, revocation, or + * recovery path. If the operator misplaces it, every shielded coin + * accumulated at this contract is forever inaccessible, equivalent to + * losing a hot-wallet private key. Back it up offline before the first + * deposit and treat it with the same hygiene as a signing key. + * + * @param {Uint<128>} value - The amount to send. + * + * @returns {ShieldedSendResult} The result containing the sent coin + * and any change. + */ + export circuit _drain( + coin: QualifiedShieldedCoinInfo, + parentAddr: Bytes<32>, + opSecret: Bytes<32>, + value: Uint<128> + ): ShieldedSendResult { + Initializable_assertInitialized(); + assert( + _calculateParentCommitment(parentAddr, opSecret) == _parentCommitment, + "ForwarderPrivate: invalid parent" + ); + + const result = sendShielded( + disclose(coin), + right(ContractAddress { bytes: disclose(parentAddr) }), + disclose(value) + ); + + if (disclose(result.change.is_some)) { + sendImmediateShielded( + disclose(result.change.value), + Utils_selfAsRecipient(), + disclose(result.change.value.value) + ); + } + + return result; + } + + // ─── Pure helpers ─────────────────────────────────────────────── + + /** + * @description Computes the parent commitment from `(parentAddr, opSecret)`. + * Pure circuit — used off-chain by the deployer to compute the + * constructor argument, and inside `_drain` for the preimage check. + * Callable without initialization. + * + * The first hash input is a fixed domain tag + * (`pad(32, "ForwarderPrivate:commitment")`). The tag prevents + * preimage collisions with other `persistentHash` users in the + * system that hash two `Bytes<32>` values — a colliding preimage + * crafted under a different domain cannot satisfy this commitment. + * + * @param {Bytes<32>} parentAddr - The parent address. + * @param {Bytes<32>} opSecret - The operational secret. + * + * @returns {Bytes<32>} `persistentHash([pad(32, "ForwarderPrivate:commitment"), parentAddr, opSecret])`. + */ + export pure circuit _calculateParentCommitment( + parentAddr: Bytes<32>, + opSecret: Bytes<32> + ): Bytes<32> { + return persistentHash>>( + [pad(32, "ForwarderPrivate:commitment"), parentAddr, opSecret] + ); + } +} diff --git a/contracts/src/multisig/presets/forwarder/ForwarderPrivate.compact b/contracts/src/multisig/presets/forwarder/ForwarderPrivate.compact new file mode 100644 index 00000000..d1af5096 --- /dev/null +++ b/contracts/src/multisig/presets/forwarder/ForwarderPrivate.compact @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.0.1-alpha.1 (multisig/presets/forwarder/ForwarderPrivate.compact) + +pragma language_version >= 0.21.0; + +/** + * @title ForwarderPrivate + * @description Private-parent forwarder. The parent address is hidden + * behind a `persistentHash` commitment on the ledger. Coins dwell at + * the contract address after deposit; the operator drains them later + * by presenting the `(parentAddr, opSecret)` preimage at drain time. + * + * Knowledge of the preimage is the sole authorization gate. The + * operational secret is held off-chain by the deployer; losing it is + * equivalent to loss of a hot-wallet key. Two forwarders bound to the + * same parent with different operational secrets produce different + * commitments and are unlinkable on-chain. + * + * @notice Each forwarder is bound to a single parent at deploy. To + * change the parent, deploy a new forwarder; the old one remains + * functional for outstanding coins until drained. + */ + +import CompactStandardLibrary; +import "../../ForwarderPrivate" prefix ForwarderPrivate_; + +export { ShieldedCoinInfo, QualifiedShieldedCoinInfo, ShieldedSendResult }; + +/** + * @description Deploys the forwarder bound to a specific parent + * commitment. The deployer computes the commitment off-chain as + * `calculateParentCommitment(parentAddr, opSecret)` and passes it here. + * + * @param {Bytes<32>} parentCommitment - The commitment to the + * `(parentAddr, opSecret)` pair that the operator will present at drain. + */ +constructor(parentCommitment: Bytes<32>) { + ForwarderPrivate_initialize(parentCommitment); +} + +/** + * @description Receives a shielded coin into the forwarder's custody. + * No ledger write — the coin sits at the contract address until drained. + * + * @param {ShieldedCoinInfo} coin - The incoming shielded coin. + * + * @returns {[]} Empty tuple. + */ +export circuit deposit(coin: ShieldedCoinInfo): [] { + ForwarderPrivate__deposit(coin); +} + +/** + * @description Spends a previously-deposited shielded coin to + * `parentAddr`. The caller proves knowledge of `(parentAddr, opSecret)` + * matching the stored commitment. If the input coin's value exceeds + * `value`, change is re-emitted back to the contract for future drains. + * + * Requirements: + * + * - `calculateParentCommitment(parentAddr, opSecret)` must equal the stored + * `_parentCommitment`. + * - `coin.value` must be >= `value` (enforced by `sendShielded`). + * + * @param {QualifiedShieldedCoinInfo} coin - The coin to spend. + * @param {Bytes<32>} parentAddr - The parent address. Preimage to the + * stored commitment. + * @param {Bytes<32>} opSecret - The operational secret. Never appears + * on the public transcript. + * + * @warning **Losing the operational secret is permanent fund loss.** It + * is the sole drain authorization. No rotation, revocation, or recovery + * path exists. If the operator loses it, every shielded coin accumulated + * at this contract becomes inaccessible. Back it up offline before the + * first deposit. + * + * @param {Uint<128>} value - The amount to send. + * + * @returns {ShieldedSendResult} The result containing the sent coin and + * any change. + */ +export circuit drain( + coin: QualifiedShieldedCoinInfo, + parentAddr: Bytes<32>, + opSecret: Bytes<32>, + value: Uint<128> +): ShieldedSendResult { + return ForwarderPrivate__drain(coin, parentAddr, opSecret, value); +} + +/** + * @description Returns the stored parent commitment. + * + * @returns {Bytes<32>} The commitment set at deploy. + */ +export circuit getParentCommitment(): Bytes<32> { + return ForwarderPrivate__parentCommitment; +} + +/** + * @description Computes the parent commitment from a `(parentAddr, opSecret)` + * pair. Pure circuit — used off-chain by the deployer to compute the + * constructor argument, and inside `drain` for the preimage check. + * + * The commitment is domain-tagged + * (`pad(32, "ForwarderPrivate:commitment")`) to prevent preimage + * collisions with other `persistentHash` users in the system. + * + * @param {Bytes<32>} parentAddr - The parent address. + * @param {Bytes<32>} opSecret - The operational secret. + * + * @returns {Bytes<32>} The commitment `persistentHash([pad(32, "ForwarderPrivate:commitment"), parentAddr, opSecret])`. + */ +export pure circuit calculateParentCommitment( + parentAddr: Bytes<32>, + opSecret: Bytes<32> +): Bytes<32> { + return ForwarderPrivate__calculateParentCommitment(parentAddr, opSecret); +} diff --git a/contracts/src/multisig/presets/forwarder/ForwarderShielded.compact b/contracts/src/multisig/presets/forwarder/ForwarderShielded.compact new file mode 100644 index 00000000..d0889bfc --- /dev/null +++ b/contracts/src/multisig/presets/forwarder/ForwarderShielded.compact @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.0.1-alpha.1 (multisig/presets/forwarder/ForwarderShielded.compact) + +pragma language_version >= 0.21.0; + +/** + * @title ForwarderShielded + * @description Public-parent forwarder for shielded coins. Receives a + * shielded coin and atomically forwards it to the configured parent + * address. + * + * The parent address is hard-coded at deploy time and immutable. Anyone + * may call `deposit`; the recipient is fixed, so there is no need for + * access control. + */ + +import CompactStandardLibrary; +import "../../Forwarder" prefix Forwarder_; + +export { ZswapCoinPublicKey, ShieldedCoinInfo }; + +/** + * @description Deploys the forwarder bound to a specific parent address. + * + * @param {ZswapCoinPublicKey} parent - The parent address that receives + * every forwarded coin. + */ +constructor(parent: ZswapCoinPublicKey) { + Forwarder_initialize(parent); +} + +/** + * @description Receives a shielded coin and atomically forwards it to + * the configured parent. The coin is claimed via `receiveShielded` and + * immediately re-sent via `sendImmediateShielded`. + * + * @param {ShieldedCoinInfo} coin - The incoming shielded coin. + * + * @returns {[]} Empty tuple. + */ +export circuit deposit(coin: ShieldedCoinInfo): [] { + Forwarder__depositShielded(coin); +} + +/** + * @description Returns the configured parent address. + * + * @returns {ZswapCoinPublicKey} The parent address set at deploy. + */ +export circuit getParent(): ZswapCoinPublicKey { + return Forwarder__parent; +} diff --git a/contracts/src/multisig/presets/forwarder/ForwarderUnshielded.compact b/contracts/src/multisig/presets/forwarder/ForwarderUnshielded.compact new file mode 100644 index 00000000..94da78c6 --- /dev/null +++ b/contracts/src/multisig/presets/forwarder/ForwarderUnshielded.compact @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.0.1-alpha.1 (multisig/presets/forwarder/ForwarderUnshielded.compact) + +pragma language_version >= 0.21.0; + +/** + * @title ForwarderUnshielded + * @description Public-parent forwarder for unshielded coins. Receives + * an unshielded amount of a given color and atomically forwards it to + * the configured parent address. + * + * Unshielded transfers are publicly visible on the chain: depositor, + * recipient, color, and amount all appear on the public transcript. + * Use `ForwarderShielded` instead when the deposit kind is shielded. + */ + +import CompactStandardLibrary; +import "../../Forwarder" prefix Forwarder_; + +export { UserAddress }; + +/** + * @description Deploys the forwarder bound to a specific parent address. + * + * @param {UserAddress} parent - The parent address that receives every + * forwarded amount. + */ +constructor(parent: UserAddress) { + Forwarder_initialize(parent); +} + +/** + * @description Receives an unshielded amount of `color` and atomically + * forwards it to the configured parent. + * + * @param {Bytes<32>} color - The token color. + * @param {Uint<128>} amount - The amount to deposit. + * + * @returns {[]} Empty tuple. + */ +export circuit depositUnshielded(color: Bytes<32>, amount: Uint<128>): [] { + Forwarder__depositUnshielded(color, amount); +} + +/** + * @description Returns the configured parent address. + * + * @returns {UserAddress} The parent address set at deploy. + */ +export circuit getParent(): UserAddress { + return Forwarder__parent; +} diff --git a/contracts/src/multisig/test/Forwarder.test.ts b/contracts/src/multisig/test/Forwarder.test.ts new file mode 100644 index 00000000..617ffe81 --- /dev/null +++ b/contracts/src/multisig/test/Forwarder.test.ts @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import * as utils from '#test-utils/address.js'; +import { MockForwarderSimulator } from './simulators/MockForwarderSimulator.js'; + +const PARENT = utils.createEitherTestUser('PARENT').left.bytes; +const ZERO = new Uint8Array(32); +const COLOR = new Uint8Array(32).fill(1); +const AMOUNT = 1000n; + +function makeCoin(color: Uint8Array, value: bigint, nonce?: Uint8Array) { + return { + nonce: nonce ?? new Uint8Array(32).fill(0), + color, + value, + }; +} + +describe('Forwarder module', () => { + describe('initialization', () => { + it('should initialize on construction when isInit is true', () => { + expect(() => new MockForwarderSimulator(PARENT, true)).not.toThrow(); + }); + + it('should fail initialization with zero parent', () => { + expect(() => new MockForwarderSimulator(ZERO, true)).toThrow( + 'Forwarder: zero parent', + ); + }); + + it('should expose the public ledger state after initialization', () => { + const mock = new MockForwarderSimulator(PARENT, true); + expect(mock.getPublicState()).toBeDefined(); + }); + }); + + describe('init guard', () => { + let mock: MockForwarderSimulator; + + beforeEach(() => { + mock = new MockForwarderSimulator(PARENT, false); + }); + + it('should fail depositShielded when not initialized', () => { + expect(() => mock.depositShielded(makeCoin(COLOR, AMOUNT))).toThrow( + 'Initializable: contract not initialized', + ); + }); + + it('should fail depositUnshielded when not initialized', () => { + expect(() => mock.depositUnshielded(COLOR, AMOUNT)).toThrow( + 'Initializable: contract not initialized', + ); + }); + }); + + describe('deposit', () => { + let mock: MockForwarderSimulator; + + beforeEach(() => { + mock = new MockForwarderSimulator(PARENT, true); + }); + + it('should accept a shielded deposit and forward it', () => { + expect(() => + mock.depositShielded(makeCoin(COLOR, AMOUNT)), + ).not.toThrow(); + }); + + it('should accept an unshielded deposit and forward it', () => { + expect(() => mock.depositUnshielded(COLOR, AMOUNT)).not.toThrow(); + }); + }); +}); diff --git a/contracts/src/multisig/test/ForwarderPrivate.test.ts b/contracts/src/multisig/test/ForwarderPrivate.test.ts new file mode 100644 index 00000000..1f9ed739 --- /dev/null +++ b/contracts/src/multisig/test/ForwarderPrivate.test.ts @@ -0,0 +1,235 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import fc from 'fast-check'; +import * as utils from '#test-utils/address.js'; +import { MockForwarderPrivateSimulator } from './simulators/MockForwarderPrivateSimulator.js'; + +const PARENT = utils.createEitherTestUser('PARENT').left.bytes; +const WRONG_PARENT = utils.createEitherTestUser('WRONG').left.bytes; +const OP_SECRET = new Uint8Array(32).fill(0xaa); +const WRONG_OP_SECRET = new Uint8Array(32).fill(0xbb); +const ZERO = new Uint8Array(32); +const COLOR = new Uint8Array(32).fill(1); +const AMOUNT = 1000n; +const MAX_U64 = (1n << 64n) - 1n; + +function makeCoin(color: Uint8Array, value: bigint, nonce?: Uint8Array) { + return { + nonce: nonce ?? new Uint8Array(32).fill(0), + color, + value, + }; +} + +function makeQualifiedCoin( + color: Uint8Array, + value: bigint, + mtIndex: bigint, + nonce?: Uint8Array, +) { + return { + nonce: nonce ?? new Uint8Array(32).fill(0), + color, + value, + mt_index: mtIndex, + }; +} + +function commitment(parent: Uint8Array, opSecret: Uint8Array): Uint8Array { + return MockForwarderPrivateSimulator.calculateParentCommitment(parent, opSecret); +} + +describe('ForwarderPrivate module', () => { + describe('initialization', () => { + it('should initialize on construction when isInit is true', () => { + const c = commitment(PARENT, OP_SECRET); + const mock = new MockForwarderPrivateSimulator(c, true); + expect(() => mock.deposit(makeCoin(COLOR, AMOUNT))).not.toThrow(); + }); + + it('should fail initialization with zero commitment', () => { + expect(() => new MockForwarderPrivateSimulator(ZERO, true)).toThrow( + 'ForwarderPrivate: zero commitment', + ); + }); + + it('should expose the public ledger state after initialization', () => { + const c = commitment(PARENT, OP_SECRET); + const mock = new MockForwarderPrivateSimulator(c, true); + expect(mock.getPublicState()).toBeDefined(); + }); + }); + + describe('init guard', () => { + let mock: MockForwarderPrivateSimulator; + + beforeEach(() => { + mock = new MockForwarderPrivateSimulator(commitment(PARENT, OP_SECRET), false); + }); + + it('should fail deposit when not initialized', () => { + expect(() => mock.deposit(makeCoin(COLOR, AMOUNT))).toThrow( + 'Initializable: contract not initialized', + ); + }); + + it('should fail drain when not initialized', () => { + expect(() => + mock.drain(makeQualifiedCoin(COLOR, AMOUNT, 0n), PARENT, OP_SECRET, AMOUNT), + ).toThrow('Initializable: contract not initialized'); + }); + }); + + describe('calculateParentCommitment', () => { + it('should produce the same commitment for the same (parent, opSecret)', () => { + const c1 = commitment(PARENT, OP_SECRET); + const c2 = commitment(PARENT, OP_SECRET); + expect(c1).toEqual(c2); + }); + + it('should produce different commitments for different opSecrets', () => { + fc.assert( + fc.property( + fc.uint8Array({ minLength: 32, maxLength: 32 }), + fc.uint8Array({ minLength: 32, maxLength: 32 }), + fc.uint8Array({ minLength: 32, maxLength: 32 }), + (parent, s1, s2) => { + fc.pre(s1.some((b, i) => b !== s2[i])); + const c1 = commitment( + Uint8Array.from(parent), + Uint8Array.from(s1), + ); + const c2 = commitment( + Uint8Array.from(parent), + Uint8Array.from(s2), + ); + expect(c1).not.toEqual(c2); + }, + ), + { numRuns: 50 }, + ); + }); + }); + + describe('drain', () => { + let mock: MockForwarderPrivateSimulator; + + beforeEach(() => { + mock = new MockForwarderPrivateSimulator(commitment(PARENT, OP_SECRET), true); + mock.deposit(makeCoin(COLOR, AMOUNT)); + }); + + it('should succeed drain with correct (parentAddr, opSecret)', () => { + const result = mock.drain( + makeQualifiedCoin(COLOR, AMOUNT, 0n), + PARENT, + OP_SECRET, + AMOUNT, + ); + expect(result.sent.value).toEqual(AMOUNT); + }); + + it('should fail drain with wrong parentAddr', () => { + expect(() => + mock.drain( + makeQualifiedCoin(COLOR, AMOUNT, 0n), + WRONG_PARENT, + OP_SECRET, + AMOUNT, + ), + ).toThrow('ForwarderPrivate: invalid parent'); + }); + + it('should fail drain with wrong opSecret', () => { + expect(() => + mock.drain( + makeQualifiedCoin(COLOR, AMOUNT, 0n), + PARENT, + WRONG_OP_SECRET, + AMOUNT, + ), + ).toThrow('ForwarderPrivate: invalid parent'); + }); + + it('should fail drain with both wrong', () => { + expect(() => + mock.drain( + makeQualifiedCoin(COLOR, AMOUNT, 0n), + WRONG_PARENT, + WRONG_OP_SECRET, + AMOUNT, + ), + ).toThrow('ForwarderPrivate: invalid parent'); + }); + + it('should fail drain with value > coin.value', () => { + expect(() => + mock.drain( + makeQualifiedCoin(COLOR, AMOUNT, 0n), + PARENT, + OP_SECRET, + AMOUNT + 1n, + ), + ).toThrow(); + }); + + it('should produce no change when drain value equals coin value', () => { + const result = mock.drain( + makeQualifiedCoin(COLOR, AMOUNT, 0n), + PARENT, + OP_SECRET, + AMOUNT, + ); + expect(result.change.is_some).toBe(false); + }); + + it('should produce a change coin when drain value is less than coin value', () => { + const result = mock.drain( + makeQualifiedCoin(COLOR, AMOUNT, 0n), + PARENT, + OP_SECRET, + 400n, + ); + expect(result.change.is_some).toBe(true); + expect(result.change.value.value).toEqual(AMOUNT - 400n); + expect(result.change.value.color).toEqual(COLOR); + }); + + it('should produce a sent coin of exactly value on partial drain', () => { + const result = mock.drain( + makeQualifiedCoin(COLOR, AMOUNT, 0n), + PARENT, + OP_SECRET, + 400n, + ); + expect(result.sent.value).toEqual(400n); + expect(result.sent.color).toEqual(COLOR); + }); + }); + + describe('property: change arithmetic', () => { + it('should preserve change.value == coin.value - drain.value on partial drain', () => { + fc.assert( + fc.property( + fc.bigInt({ min: 2n, max: MAX_U64 - 1n }), + fc.bigInt({ min: 1n, max: MAX_U64 - 1n }), + (coinVal, drainVal) => { + fc.pre(drainVal < coinVal); + const mock = new MockForwarderPrivateSimulator( + commitment(PARENT, OP_SECRET), + true, + ); + mock.deposit(makeCoin(COLOR, coinVal)); + const result = mock.drain( + makeQualifiedCoin(COLOR, coinVal, 0n), + PARENT, + OP_SECRET, + drainVal, + ); + expect(result.change.value.value).toEqual(coinVal - drainVal); + }, + ), + { numRuns: 25 }, + ); + }); + }); +}); diff --git a/contracts/src/multisig/test/mocks/MockForwarder.compact b/contracts/src/multisig/test/mocks/MockForwarder.compact new file mode 100644 index 00000000..bfca8bd8 --- /dev/null +++ b/contracts/src/multisig/test/mocks/MockForwarder.compact @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.0.1-alpha.1 (multisig/test/mocks/MockForwarder.compact) + +pragma language_version >= 0.21.0; + +import CompactStandardLibrary; +import "../../Forwarder" prefix Forwarder_; + +export { ZswapCoinPublicKey, ShieldedCoinInfo }; + +/** + * @description Test fixture exposing the public Forwarder module's + * underscore-prefixed circuits directly. `isInit` controls whether the + * constructor initializes. Set it to `false` to leave the contract + * uninitialized and exercise the not-initialized guard. The parent is a + * sealed ledger field, so it can only be set in the constructor. + * + * DO NOT USE IN PRODUCTION. + */ +constructor(parent: ZswapCoinPublicKey, isInit: Boolean) { + if (disclose(isInit)) { + Forwarder_initialize(parent); + } +} + +export circuit depositShielded(coin: ShieldedCoinInfo): [] { + return Forwarder__depositShielded(coin); +} + +export circuit depositUnshielded(color: Bytes<32>, amount: Uint<128>): [] { + return Forwarder__depositUnshielded(color, amount); +} diff --git a/contracts/src/multisig/test/mocks/MockForwarderPrivate.compact b/contracts/src/multisig/test/mocks/MockForwarderPrivate.compact new file mode 100644 index 00000000..1bb47501 --- /dev/null +++ b/contracts/src/multisig/test/mocks/MockForwarderPrivate.compact @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.0.1-alpha.1 (multisig/test/mocks/MockForwarderPrivate.compact) + +pragma language_version >= 0.21.0; + +import CompactStandardLibrary; +import "../../ForwarderPrivate" prefix ForwarderPrivate_; + +export { ShieldedCoinInfo, QualifiedShieldedCoinInfo, ShieldedSendResult }; + +/** + * @description Test fixture exposing the private ForwarderPrivate + * module's underscore-prefixed circuits directly. `isInit` controls + * whether the constructor initializes. Set it to `false` to leave the + * contract uninitialized and exercise the not-initialized guard. The + * parent commitment is a sealed ledger field, so it can only be set in + * the constructor. + * + * DO NOT USE IN PRODUCTION. + */ +constructor(parentCommitment: Bytes<32>, isInit: Boolean) { + if (disclose(isInit)) { + ForwarderPrivate_initialize(parentCommitment); + } +} + +export circuit deposit(coin: ShieldedCoinInfo): [] { + return ForwarderPrivate__deposit(coin); +} + +export circuit drain( + coin: QualifiedShieldedCoinInfo, + parentAddr: Bytes<32>, + opSecret: Bytes<32>, + value: Uint<128> +): ShieldedSendResult { + return ForwarderPrivate__drain(coin, parentAddr, opSecret, value); +} + +export pure circuit calculateParentCommitment( + parentAddr: Bytes<32>, + opSecret: Bytes<32> +): Bytes<32> { + return ForwarderPrivate__calculateParentCommitment(parentAddr, opSecret); +} diff --git a/contracts/src/multisig/test/presets/ForwarderPrivate.test.ts b/contracts/src/multisig/test/presets/ForwarderPrivate.test.ts new file mode 100644 index 00000000..39956670 --- /dev/null +++ b/contracts/src/multisig/test/presets/ForwarderPrivate.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; +import * as utils from '#test-utils/address.js'; +import { ForwarderPrivateSimulator } from '../simulators/presets/ForwarderPrivateSimulator.js'; + +const PARENT = utils.createEitherTestUser('PARENT').left.bytes; +const OP_SECRET = new Uint8Array(32).fill(0xaa); +const COLOR = new Uint8Array(32).fill(1); +const AMOUNT = 1000n; + +function makeCoin(color: Uint8Array, value: bigint) { + return { nonce: new Uint8Array(32), color, value }; +} + +function makeQualifiedCoin(color: Uint8Array, value: bigint, mtIndex: bigint) { + return { nonce: new Uint8Array(32), color, value, mt_index: mtIndex }; +} + +function commitment(parent: Uint8Array, opSecret: Uint8Array): Uint8Array { + return ForwarderPrivateSimulator.calculateParentCommitment(parent, opSecret); +} + +describe('ForwarderPrivate preset', () => { + it('should store the parentCommitment passed to the constructor', () => { + const c = commitment(PARENT, OP_SECRET); + const fwd = new ForwarderPrivateSimulator(c); + expect(fwd.getParentCommitment()).toEqual(c); + }); + + it('should expose deposit and forward to _deposit', () => { + const fwd = new ForwarderPrivateSimulator(commitment(PARENT, OP_SECRET)); + expect(() => fwd.deposit(makeCoin(COLOR, AMOUNT))).not.toThrow(); + }); + + it('should expose drain and forward to _drain', () => { + const fwd = new ForwarderPrivateSimulator(commitment(PARENT, OP_SECRET)); + fwd.deposit(makeCoin(COLOR, AMOUNT)); + const result = fwd.drain( + makeQualifiedCoin(COLOR, AMOUNT, 0n), + PARENT, + OP_SECRET, + AMOUNT, + ); + expect(result.sent.value).toEqual(AMOUNT); + }); + + it('should expose calculateParentCommitment as a static pure helper', () => { + const c1 = commitment(PARENT, OP_SECRET); + const c2 = commitment(PARENT, OP_SECRET); + expect(c1).toEqual(c2); + }); + + it('should propagate the zero-commitment guard from the module', () => { + expect(() => new ForwarderPrivateSimulator(new Uint8Array(32))).toThrow( + 'ForwarderPrivate: zero commitment', + ); + }); + + it('should expose the public ledger state', () => { + const fwd = new ForwarderPrivateSimulator(commitment(PARENT, OP_SECRET)); + expect(fwd.getPublicState()).toBeDefined(); + }); +}); diff --git a/contracts/src/multisig/test/presets/ForwarderShielded.test.ts b/contracts/src/multisig/test/presets/ForwarderShielded.test.ts new file mode 100644 index 00000000..2464bfa9 --- /dev/null +++ b/contracts/src/multisig/test/presets/ForwarderShielded.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import * as utils from '#test-utils/address.js'; +import { ForwarderShieldedSimulator } from '../simulators/presets/ForwarderShieldedSimulator.js'; + +const PARENT = utils.createEitherTestUser('PARENT').left.bytes; +const COLOR = new Uint8Array(32).fill(1); +const AMOUNT = 1000n; + +function makeCoin(color: Uint8Array, value: bigint) { + return { nonce: new Uint8Array(32), color, value }; +} + +describe('ForwarderShielded preset', () => { + it('should store the parent passed to the constructor', () => { + const fwd = new ForwarderShieldedSimulator(PARENT); + expect(fwd.getParent()).toEqual(PARENT); + }); + + it('should expose deposit and forward to _depositShielded', () => { + const fwd = new ForwarderShieldedSimulator(PARENT); + expect(() => fwd.deposit(makeCoin(COLOR, AMOUNT))).not.toThrow(); + }); + + it('should propagate the zero-parent guard from the module', () => { + expect(() => new ForwarderShieldedSimulator(new Uint8Array(32))).toThrow( + 'Forwarder: zero parent', + ); + }); + + it('should expose the public ledger state', () => { + const fwd = new ForwarderShieldedSimulator(PARENT); + expect(fwd.getPublicState()).toBeDefined(); + }); +}); diff --git a/contracts/src/multisig/test/presets/ForwarderUnshielded.test.ts b/contracts/src/multisig/test/presets/ForwarderUnshielded.test.ts new file mode 100644 index 00000000..3349b58c --- /dev/null +++ b/contracts/src/multisig/test/presets/ForwarderUnshielded.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import * as utils from '#test-utils/address.js'; +import { ForwarderUnshieldedSimulator } from '../simulators/presets/ForwarderUnshieldedSimulator.js'; + +const PARENT = utils.createEitherTestUser('PARENT').left.bytes; +const COLOR = new Uint8Array(32).fill(1); +const AMOUNT = 1000n; + +describe('ForwarderUnshielded preset', () => { + it('should store the parent passed to the constructor', () => { + const fwd = new ForwarderUnshieldedSimulator(PARENT); + expect(fwd.getParent()).toEqual(PARENT); + }); + + it('should expose depositUnshielded and forward to _depositUnshielded', () => { + const fwd = new ForwarderUnshieldedSimulator(PARENT); + expect(() => fwd.depositUnshielded(COLOR, AMOUNT)).not.toThrow(); + }); + + it('should propagate the zero-parent guard from the module', () => { + expect(() => new ForwarderUnshieldedSimulator(new Uint8Array(32))).toThrow( + 'Forwarder: zero parent', + ); + }); + + it('should expose the public ledger state', () => { + const fwd = new ForwarderUnshieldedSimulator(PARENT); + expect(fwd.getPublicState()).toBeDefined(); + }); +}); diff --git a/contracts/src/multisig/test/simulators/MockForwarderPrivateSimulator.ts b/contracts/src/multisig/test/simulators/MockForwarderPrivateSimulator.ts new file mode 100644 index 00000000..02e2b9f9 --- /dev/null +++ b/contracts/src/multisig/test/simulators/MockForwarderPrivateSimulator.ts @@ -0,0 +1,69 @@ +import { + type BaseSimulatorOptions, + createSimulator, +} from '@openzeppelin-compact/contracts-simulator'; +import { + ledger, + pureCircuits, + type QualifiedShieldedCoinInfo, + type ShieldedCoinInfo, + type ShieldedSendResult, + Contract as MockForwarderPrivate, +} from '../../../../artifacts/MockForwarderPrivate/contract/index.js'; +import { + MockForwarderPrivatePrivateState, + MockForwarderPrivateWitnesses, +} from '../../witnesses/MockForwarderPrivateWitnesses.js'; + +type MockForwarderPrivateArgs = readonly [ + parentCommitment: Uint8Array, + isInit: boolean, +]; + +const MockForwarderPrivateSimulatorBase = createSimulator< + MockForwarderPrivatePrivateState, + ReturnType, + ReturnType, + MockForwarderPrivate, + MockForwarderPrivateArgs +>({ + contractFactory: (witnesses) => + new MockForwarderPrivate(witnesses), + defaultPrivateState: () => MockForwarderPrivatePrivateState, + contractArgs: (parentCommitment, isInit) => [parentCommitment, isInit], + ledgerExtractor: (state) => ledger(state), + witnessesFactory: () => MockForwarderPrivateWitnesses(), +}); + +export class MockForwarderPrivateSimulator extends MockForwarderPrivateSimulatorBase { + constructor( + parentCommitment: Uint8Array, + isInit: boolean, + options: BaseSimulatorOptions< + MockForwarderPrivatePrivateState, + ReturnType + > = {}, + ) { + super([parentCommitment, isInit], options); + } + + public static calculateParentCommitment( + parentAddr: Uint8Array, + opSecret: Uint8Array, + ): Uint8Array { + return pureCircuits.calculateParentCommitment(parentAddr, opSecret); + } + + public deposit(coin: ShieldedCoinInfo) { + return this.circuits.impure.deposit(coin); + } + + public drain( + coin: QualifiedShieldedCoinInfo, + parentAddr: Uint8Array, + opSecret: Uint8Array, + value: bigint, + ): ShieldedSendResult { + return this.circuits.impure.drain(coin, parentAddr, opSecret, value); + } +} diff --git a/contracts/src/multisig/test/simulators/MockForwarderSimulator.ts b/contracts/src/multisig/test/simulators/MockForwarderSimulator.ts new file mode 100644 index 00000000..67e51370 --- /dev/null +++ b/contracts/src/multisig/test/simulators/MockForwarderSimulator.ts @@ -0,0 +1,52 @@ +import { + type BaseSimulatorOptions, + createSimulator, +} from '@openzeppelin-compact/contracts-simulator'; +import { + ledger, + Contract as MockForwarder, + type ShieldedCoinInfo, + type ZswapCoinPublicKey, +} from '../../../../artifacts/MockForwarder/contract/index.js'; +import { + MockForwarderPrivateState, + MockForwarderWitnesses, +} from '../../witnesses/MockForwarderWitnesses.js'; + +type MockForwarderArgs = readonly [parent: ZswapCoinPublicKey, isInit: boolean]; + +const MockForwarderSimulatorBase = createSimulator< + MockForwarderPrivateState, + ReturnType, + ReturnType, + MockForwarder, + MockForwarderArgs +>({ + contractFactory: (witnesses) => + new MockForwarder(witnesses), + defaultPrivateState: () => MockForwarderPrivateState, + contractArgs: (parent, isInit) => [parent, isInit], + ledgerExtractor: (state) => ledger(state), + witnessesFactory: () => MockForwarderWitnesses(), +}); + +export class MockForwarderSimulator extends MockForwarderSimulatorBase { + constructor( + parent: Uint8Array, + isInit: boolean, + options: BaseSimulatorOptions< + MockForwarderPrivateState, + ReturnType + > = {}, + ) { + super([{ bytes: parent }, isInit], options); + } + + public depositShielded(coin: ShieldedCoinInfo) { + return this.circuits.impure.depositShielded(coin); + } + + public depositUnshielded(color: Uint8Array, amount: bigint) { + return this.circuits.impure.depositUnshielded(color, amount); + } +} diff --git a/contracts/src/multisig/test/simulators/presets/ForwarderPrivateSimulator.ts b/contracts/src/multisig/test/simulators/presets/ForwarderPrivateSimulator.ts new file mode 100644 index 00000000..ac054627 --- /dev/null +++ b/contracts/src/multisig/test/simulators/presets/ForwarderPrivateSimulator.ts @@ -0,0 +1,69 @@ +import { + type BaseSimulatorOptions, + createSimulator, +} from '@openzeppelin-compact/contracts-simulator'; +import { + ledger, + pureCircuits, + type QualifiedShieldedCoinInfo, + type ShieldedCoinInfo, + type ShieldedSendResult, + Contract as ForwarderPrivate, +} from '../../../../../artifacts/ForwarderPrivate/contract/index.js'; +import { + ForwarderPrivatePrivateState, + ForwarderPrivateWitnesses, +} from '../../../witnesses/presets/ForwarderPrivateWitnesses.js'; + +type ForwarderPrivateArgs = readonly [parentCommitment: Uint8Array]; + +const ForwarderPrivateSimulatorBase = createSimulator< + ForwarderPrivatePrivateState, + ReturnType, + ReturnType, + ForwarderPrivate, + ForwarderPrivateArgs +>({ + contractFactory: (witnesses) => + new ForwarderPrivate(witnesses), + defaultPrivateState: () => ForwarderPrivatePrivateState, + contractArgs: (parentCommitment) => [parentCommitment], + ledgerExtractor: (state) => ledger(state), + witnessesFactory: () => ForwarderPrivateWitnesses(), +}); + +export class ForwarderPrivateSimulator extends ForwarderPrivateSimulatorBase { + constructor( + parentCommitment: Uint8Array, + options: BaseSimulatorOptions< + ForwarderPrivatePrivateState, + ReturnType + > = {}, + ) { + super([parentCommitment], options); + } + + public static calculateParentCommitment( + parentAddr: Uint8Array, + opSecret: Uint8Array, + ): Uint8Array { + return pureCircuits.calculateParentCommitment(parentAddr, opSecret); + } + + public deposit(coin: ShieldedCoinInfo) { + return this.circuits.impure.deposit(coin); + } + + public drain( + coin: QualifiedShieldedCoinInfo, + parentAddr: Uint8Array, + opSecret: Uint8Array, + value: bigint, + ): ShieldedSendResult { + return this.circuits.impure.drain(coin, parentAddr, opSecret, value); + } + + public getParentCommitment(): Uint8Array { + return this.circuits.impure.getParentCommitment(); + } +} diff --git a/contracts/src/multisig/test/simulators/presets/ForwarderShieldedSimulator.ts b/contracts/src/multisig/test/simulators/presets/ForwarderShieldedSimulator.ts new file mode 100644 index 00000000..f136ec84 --- /dev/null +++ b/contracts/src/multisig/test/simulators/presets/ForwarderShieldedSimulator.ts @@ -0,0 +1,51 @@ +import { + type BaseSimulatorOptions, + createSimulator, +} from '@openzeppelin-compact/contracts-simulator'; +import { + ledger, + Contract as ForwarderShielded, + type ShieldedCoinInfo, + type ZswapCoinPublicKey, +} from '../../../../../artifacts/ForwarderShielded/contract/index.js'; +import { + ForwarderShieldedPrivateState, + ForwarderShieldedWitnesses, +} from '../../../witnesses/presets/ForwarderShieldedWitnesses.js'; + +type ForwarderShieldedArgs = readonly [parent: ZswapCoinPublicKey]; + +const ForwarderShieldedSimulatorBase = createSimulator< + ForwarderShieldedPrivateState, + ReturnType, + ReturnType, + ForwarderShielded, + ForwarderShieldedArgs +>({ + contractFactory: (witnesses) => + new ForwarderShielded(witnesses), + defaultPrivateState: () => ForwarderShieldedPrivateState, + contractArgs: (parent) => [parent], + ledgerExtractor: (state) => ledger(state), + witnessesFactory: () => ForwarderShieldedWitnesses(), +}); + +export class ForwarderShieldedSimulator extends ForwarderShieldedSimulatorBase { + constructor( + parent: Uint8Array, + options: BaseSimulatorOptions< + ForwarderShieldedPrivateState, + ReturnType + > = {}, + ) { + super([{ bytes: parent }], options); + } + + public deposit(coin: ShieldedCoinInfo) { + return this.circuits.impure.deposit(coin); + } + + public getParent(): Uint8Array { + return this.circuits.impure.getParent().bytes; + } +} diff --git a/contracts/src/multisig/test/simulators/presets/ForwarderUnshieldedSimulator.ts b/contracts/src/multisig/test/simulators/presets/ForwarderUnshieldedSimulator.ts new file mode 100644 index 00000000..09e5cbc7 --- /dev/null +++ b/contracts/src/multisig/test/simulators/presets/ForwarderUnshieldedSimulator.ts @@ -0,0 +1,50 @@ +import { + type BaseSimulatorOptions, + createSimulator, +} from '@openzeppelin-compact/contracts-simulator'; +import { + ledger, + Contract as ForwarderUnshielded, + type UserAddress, +} from '../../../../../artifacts/ForwarderUnshielded/contract/index.js'; +import { + ForwarderUnshieldedPrivateState, + ForwarderUnshieldedWitnesses, +} from '../../../witnesses/presets/ForwarderUnshieldedWitnesses.js'; + +type ForwarderUnshieldedArgs = readonly [parent: UserAddress]; + +const ForwarderUnshieldedSimulatorBase = createSimulator< + ForwarderUnshieldedPrivateState, + ReturnType, + ReturnType, + ForwarderUnshielded, + ForwarderUnshieldedArgs +>({ + contractFactory: (witnesses) => + new ForwarderUnshielded(witnesses), + defaultPrivateState: () => ForwarderUnshieldedPrivateState, + contractArgs: (parent) => [parent], + ledgerExtractor: (state) => ledger(state), + witnessesFactory: () => ForwarderUnshieldedWitnesses(), +}); + +export class ForwarderUnshieldedSimulator extends ForwarderUnshieldedSimulatorBase { + constructor( + parent: Uint8Array, + options: BaseSimulatorOptions< + ForwarderUnshieldedPrivateState, + ReturnType + > = {}, + ) { + super([{ bytes: parent }], options); + } + + public depositUnshielded(color: Uint8Array, amount: bigint) { + return this.circuits.impure.depositUnshielded(color, amount); + } + + public getParent(): Uint8Array { + return this.circuits.impure.getParent().bytes; + } +} diff --git a/contracts/src/multisig/witnesses/MockForwarderPrivateWitnesses.ts b/contracts/src/multisig/witnesses/MockForwarderPrivateWitnesses.ts new file mode 100644 index 00000000..53a7e453 --- /dev/null +++ b/contracts/src/multisig/witnesses/MockForwarderPrivateWitnesses.ts @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.0.1-alpha.1 (multisig/witnesses/MockForwarderPrivateWitnesses.ts) + +export type MockForwarderPrivatePrivateState = Record; +export const MockForwarderPrivatePrivateState: MockForwarderPrivatePrivateState = {}; +export const MockForwarderPrivateWitnesses = () => ({}); diff --git a/contracts/src/multisig/witnesses/MockForwarderWitnesses.ts b/contracts/src/multisig/witnesses/MockForwarderWitnesses.ts new file mode 100644 index 00000000..4cd09d2b --- /dev/null +++ b/contracts/src/multisig/witnesses/MockForwarderWitnesses.ts @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.0.1-alpha.1 (multisig/witnesses/MockForwarderWitnesses.ts) + +export type MockForwarderPrivateState = Record; +export const MockForwarderPrivateState: MockForwarderPrivateState = {}; +export const MockForwarderWitnesses = () => ({}); diff --git a/contracts/src/multisig/witnesses/presets/ForwarderPrivateWitnesses.ts b/contracts/src/multisig/witnesses/presets/ForwarderPrivateWitnesses.ts new file mode 100644 index 00000000..6bc2ab8a --- /dev/null +++ b/contracts/src/multisig/witnesses/presets/ForwarderPrivateWitnesses.ts @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.0.1-alpha.1 (multisig/witnesses/presets/ForwarderPrivateWitnesses.ts) + +export type ForwarderPrivatePrivateState = Record; +export const ForwarderPrivatePrivateState: ForwarderPrivatePrivateState = {}; +export const ForwarderPrivateWitnesses = () => ({}); diff --git a/contracts/src/multisig/witnesses/presets/ForwarderShieldedWitnesses.ts b/contracts/src/multisig/witnesses/presets/ForwarderShieldedWitnesses.ts new file mode 100644 index 00000000..0b65d0be --- /dev/null +++ b/contracts/src/multisig/witnesses/presets/ForwarderShieldedWitnesses.ts @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.0.1-alpha.1 (multisig/witnesses/presets/ForwarderShieldedWitnesses.ts) + +export type ForwarderShieldedPrivateState = Record; +export const ForwarderShieldedPrivateState: ForwarderShieldedPrivateState = {}; +export const ForwarderShieldedWitnesses = () => ({}); diff --git a/contracts/src/multisig/witnesses/presets/ForwarderUnshieldedWitnesses.ts b/contracts/src/multisig/witnesses/presets/ForwarderUnshieldedWitnesses.ts new file mode 100644 index 00000000..c0860d2c --- /dev/null +++ b/contracts/src/multisig/witnesses/presets/ForwarderUnshieldedWitnesses.ts @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.0.1-alpha.1 (multisig/witnesses/presets/ForwarderUnshieldedWitnesses.ts) + +export type ForwarderUnshieldedPrivateState = Record; +export const ForwarderUnshieldedPrivateState: ForwarderUnshieldedPrivateState = {}; +export const ForwarderUnshieldedWitnesses = () => ({}); diff --git a/contracts/vitest.config.ts b/contracts/vitest.config.ts index 3e71d7a7..aa4afd81 100644 --- a/contracts/vitest.config.ts +++ b/contracts/vitest.config.ts @@ -7,5 +7,40 @@ export default defineConfig({ include: ['src/**/*.test.ts'], exclude: [...configDefaults.exclude, 'src/archive/**'], reporters: 'verbose', + coverage: { + provider: 'v8', + reporter: ['text', 'html'], + include: [ + 'src/**/witnesses/**/*.ts', + 'src/**/test/simulators/**/*.ts', + // compactc-generated JS for every compiled contract. + 'artifacts/*/contract/index.js', + ], + exclude: [ + ...(configDefaults.coverage?.exclude ?? []), + 'src/archive/**', + 'src/**/test/**/*.test.ts', + // Drop `.compact` after source-map remap: compactc emits + // function-entry-granularity maps, so branch / line attribution + // on `.compact` lines is unreliable even when both legs of an + // `if` are exercised. Tracking upstream: + // https://github.com/LFDT-Minokawa/compact/issues/465 + 'src/**/*.compact', + ], + excludeAfterRemap: true, + // 95 % per-file is the closing gate of the test stage. Leaves + // room for unavoidable TS-plumbing gaps (simulator factory + // callbacks, witness stub bodies) without contorting tests + // around test infrastructure. Subset runs (e.g. + // `vitest run `) fail this gate — pass + // `--coverage.thresholds.lines=0` etc. when iterating on one file. + thresholds: { + perFile: true, + lines: 95, + branches: 95, + functions: 95, + statements: 95, + }, + }, }, }); diff --git a/yarn.lock b/yarn.lock index fcb3e772..8d704e7e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5,6 +5,48 @@ __metadata: version: 8 cacheKey: 10 +"@babel/helper-string-parser@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-string-parser@npm:7.29.7" + checksum: 10/4d8ef0ef7105f3d9fe4361137c8f42e5b4c7a52b5380b962762f2a528a1ba89064e2c6236090716ce34b63707b886ae0ebf36b2c2fcc2851f27e652febfc3648 + languageName: node + linkType: hard + +"@babel/helper-validator-identifier@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-validator-identifier@npm:7.29.7" + checksum: 10/2efa42701eb05babf26dff3332109c9e5e1a3400a71fb9e68ee27af28235036a2a72c2494c04bdab3f909075f42a58b2e8271074372bc7f8e79ec02bd364d7a7 + languageName: node + linkType: hard + +"@babel/parser@npm:^7.29.3": + version: 7.29.7 + resolution: "@babel/parser@npm:7.29.7" + dependencies: + "@babel/types": "npm:^7.29.7" + bin: + parser: ./bin/babel-parser.js + checksum: 10/da40c5928c95997b01aabe84fc3440881b8f20b866714fefa142961d37e82ffc03fbb9afed706f15f8a688278f95286ca0cea0d87ad6c77600f8c6c45d9824ee + languageName: node + linkType: hard + +"@babel/types@npm:^7.29.0, @babel/types@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/types@npm:7.29.7" + dependencies: + "@babel/helper-string-parser": "npm:^7.29.7" + "@babel/helper-validator-identifier": "npm:^7.29.7" + checksum: 10/bd4f5635db1057bd0abeebf93eb3ae38399e152271cea8dce8288350f0afa13ed3e2db2e16e22bd3303068890eec18965a83420539afbe0dde31432b4cf9636d + languageName: node + linkType: hard + +"@bcoe/v8-coverage@npm:^1.0.2": + version: 1.0.2 + resolution: "@bcoe/v8-coverage@npm:1.0.2" + checksum: 10/46600b2dde460269b07a8e4f12b72e418eae1337b85c979f43af3336c9a1c65b04e42508ab6b245f1e0e3c64328e1c38d8cd733e4a7cebc4fbf9cf65c6e59937 + languageName: node + linkType: hard + "@biomejs/biome@npm:^2.4.7": version: 2.4.7 resolution: "@biomejs/biome@npm:2.4.7" @@ -156,14 +198,14 @@ __metadata: languageName: node linkType: hard -"@jridgewell/resolve-uri@npm:^3.0.3": +"@jridgewell/resolve-uri@npm:^3.0.3, @jridgewell/resolve-uri@npm:^3.1.0": version: 3.1.2 resolution: "@jridgewell/resolve-uri@npm:3.1.2" checksum: 10/97106439d750a409c22c8bff822d648f6a71f3aa9bc8e5129efdc36343cd3096ddc4eeb1c62d2fe48e9bdd4db37b05d4646a17114ecebd3bbcacfa2de51c3c1d languageName: node linkType: hard -"@jridgewell/sourcemap-codec@npm:^1.4.10, @jridgewell/sourcemap-codec@npm:^1.5.5": +"@jridgewell/sourcemap-codec@npm:^1.4.10, @jridgewell/sourcemap-codec@npm:^1.4.14, @jridgewell/sourcemap-codec@npm:^1.5.5": version: 1.5.5 resolution: "@jridgewell/sourcemap-codec@npm:1.5.5" checksum: 10/5d9d207b462c11e322d71911e55e21a4e2772f71ffe8d6f1221b8eb5ae6774458c1d242f897fb0814e8714ca9a6b498abfa74dfe4f434493342902b1a48b33a5 @@ -180,6 +222,16 @@ __metadata: languageName: node linkType: hard +"@jridgewell/trace-mapping@npm:^0.3.31": + version: 0.3.31 + resolution: "@jridgewell/trace-mapping@npm:0.3.31" + dependencies: + "@jridgewell/resolve-uri": "npm:^3.1.0" + "@jridgewell/sourcemap-codec": "npm:^1.4.14" + checksum: 10/da0283270e691bdb5543806077548532791608e52386cfbbf3b9e8fb00457859d1bd01d512851161c886eb3a2f3ce6fd9bcf25db8edf3bddedd275bd4a88d606 + languageName: node + linkType: hard + "@midnight-ntwrk/compact-runtime@npm:0.14.0": version: 0.14.0 resolution: "@midnight-ntwrk/compact-runtime@npm:0.14.0" @@ -285,6 +337,8 @@ __metadata: "@openzeppelin-compact/contracts-simulator": "workspace:^" "@tsconfig/node24": "npm:^24.0.4" "@types/node": "npm:24.10.0" + "@vitest/coverage-v8": "npm:^4.1.6" + fast-check: "npm:^3.23.2" ts-node: "npm:^10.9.2" typescript: "npm:^5.9.3" vitest: "npm:^4.1.2" @@ -560,6 +614,30 @@ __metadata: languageName: node linkType: hard +"@vitest/coverage-v8@npm:^4.1.6": + version: 4.1.7 + resolution: "@vitest/coverage-v8@npm:4.1.7" + dependencies: + "@bcoe/v8-coverage": "npm:^1.0.2" + "@vitest/utils": "npm:4.1.7" + ast-v8-to-istanbul: "npm:^1.0.0" + istanbul-lib-coverage: "npm:^3.2.2" + istanbul-lib-report: "npm:^3.0.1" + istanbul-reports: "npm:^3.2.0" + magicast: "npm:^0.5.2" + obug: "npm:^2.1.1" + std-env: "npm:^4.0.0-rc.1" + tinyrainbow: "npm:^3.1.0" + peerDependencies: + "@vitest/browser": 4.1.7 + vitest: 4.1.7 + peerDependenciesMeta: + "@vitest/browser": + optional: true + checksum: 10/ebfe69453f635946449303356fd7b41d6db5ef2449c7e50fe4789930d4b386685c5d8e3587c0fb8ce4010463371dad195471dda2efad673ee26b58d6ff5b7fbe + languageName: node + linkType: hard + "@vitest/expect@npm:4.1.2": version: 4.1.2 resolution: "@vitest/expect@npm:4.1.2" @@ -602,6 +680,15 @@ __metadata: languageName: node linkType: hard +"@vitest/pretty-format@npm:4.1.7": + version: 4.1.7 + resolution: "@vitest/pretty-format@npm:4.1.7" + dependencies: + tinyrainbow: "npm:^3.1.0" + checksum: 10/79c86c39173577250955744c3444d8c0c9304c95c7d351b91a916229252c3733a0e969741a8f3441a5c4777b5a4371707ecb747ea4bfd2c07e72ddf1ef621293 + languageName: node + linkType: hard + "@vitest/runner@npm:4.1.2": version: 4.1.2 resolution: "@vitest/runner@npm:4.1.2" @@ -642,6 +729,17 @@ __metadata: languageName: node linkType: hard +"@vitest/utils@npm:4.1.7": + version: 4.1.7 + resolution: "@vitest/utils@npm:4.1.7" + dependencies: + "@vitest/pretty-format": "npm:4.1.7" + convert-source-map: "npm:^2.0.0" + tinyrainbow: "npm:^3.1.0" + checksum: 10/9cc729618dade24de3ad6862c288c22e9daac3fda5cae0abc9b6ce87035cc8e7efa2b66c3c124ae08beef462b36761b062e792bbc619798b832a7ea9382ed12a + languageName: node + linkType: hard + "abbrev@npm:^3.0.0": version: 3.0.1 resolution: "abbrev@npm:3.0.1" @@ -711,6 +809,17 @@ __metadata: languageName: node linkType: hard +"ast-v8-to-istanbul@npm:^1.0.0": + version: 1.0.2 + resolution: "ast-v8-to-istanbul@npm:1.0.2" + dependencies: + "@jridgewell/trace-mapping": "npm:^0.3.31" + estree-walker: "npm:^3.0.3" + js-tokens: "npm:^10.0.0" + checksum: 10/640494e7170d3b36079da24c35f132bbac51c7e63289d418d3054a085dc84e3e5f7c3e56136647f302a3d48c64acf8f4230d7d13acb45ff9ac5f50d398512f8c + languageName: node + linkType: hard + "balanced-match@npm:^1.0.0": version: 1.0.2 resolution: "balanced-match@npm:1.0.2" @@ -925,6 +1034,15 @@ __metadata: languageName: node linkType: hard +"fast-check@npm:^3.23.2": + version: 3.23.2 + resolution: "fast-check@npm:3.23.2" + dependencies: + pure-rand: "npm:^6.1.0" + checksum: 10/dab344146b778e8bc2973366ea55528d1b58d3e3037270262b877c54241e800c4d744957722c24705c787020d702aece11e57c9e3dbd5ea19c3e10926bf1f3fe + languageName: node + linkType: hard + "fast-check@npm:^4.6.0": version: 4.6.0 resolution: "fast-check@npm:4.6.0" @@ -1014,6 +1132,20 @@ __metadata: languageName: node linkType: hard +"has-flag@npm:^4.0.0": + version: 4.0.0 + resolution: "has-flag@npm:4.0.0" + checksum: 10/261a1357037ead75e338156b1f9452c016a37dcd3283a972a30d9e4a87441ba372c8b81f818cd0fbcd9c0354b4ae7e18b9e1afa1971164aef6d18c2b6095a8ad + languageName: node + linkType: hard + +"html-escaper@npm:^2.0.0": + version: 2.0.2 + resolution: "html-escaper@npm:2.0.2" + checksum: 10/034d74029dcca544a34fb6135e98d427acd73019796ffc17383eaa3ec2fe1c0471dcbbc8f8ed39e46e86d43ccd753a160631615e4048285e313569609b66d5b7 + languageName: node + linkType: hard + "http-cache-semantics@npm:^4.1.1": version: 4.2.0 resolution: "http-cache-semantics@npm:4.2.0" @@ -1099,6 +1231,34 @@ __metadata: languageName: node linkType: hard +"istanbul-lib-coverage@npm:^3.0.0, istanbul-lib-coverage@npm:^3.2.2": + version: 3.2.2 + resolution: "istanbul-lib-coverage@npm:3.2.2" + checksum: 10/40bbdd1e937dfd8c830fa286d0f665e81b7a78bdabcd4565f6d5667c99828bda3db7fb7ac6b96a3e2e8a2461ddbc5452d9f8bc7d00cb00075fa6a3e99f5b6a81 + languageName: node + linkType: hard + +"istanbul-lib-report@npm:^3.0.0, istanbul-lib-report@npm:^3.0.1": + version: 3.0.1 + resolution: "istanbul-lib-report@npm:3.0.1" + dependencies: + istanbul-lib-coverage: "npm:^3.0.0" + make-dir: "npm:^4.0.0" + supports-color: "npm:^7.1.0" + checksum: 10/86a83421ca1cf2109a9f6d193c06c31ef04a45e72a74579b11060b1e7bb9b6337a4e6f04abfb8857e2d569c271273c65e855ee429376a0d7c91ad91db42accd1 + languageName: node + linkType: hard + +"istanbul-reports@npm:^3.2.0": + version: 3.2.0 + resolution: "istanbul-reports@npm:3.2.0" + dependencies: + html-escaper: "npm:^2.0.0" + istanbul-lib-report: "npm:^3.0.0" + checksum: 10/6773a1d5c7d47eeec75b317144fe2a3b1da84a44b6282bebdc856e09667865e58c9b025b75b3d87f5bc62939126cbba4c871ee84254537d934ba5da5d4c4ec4e + languageName: node + linkType: hard + "jackspeak@npm:^3.1.2": version: 3.4.3 resolution: "jackspeak@npm:3.4.3" @@ -1112,6 +1272,13 @@ __metadata: languageName: node linkType: hard +"js-tokens@npm:^10.0.0": + version: 10.0.0 + resolution: "js-tokens@npm:10.0.0" + checksum: 10/88f536ec89f076fc230d29df255b3c55531237669d746d1868fca716b1e3f5f2e4abf8e5b8701903216e3f00d2dc3918d078b35da87772d433ab6a513c3bf76d + languageName: node + linkType: hard + "lightningcss-android-arm64@npm:1.32.0": version: 1.32.0 resolution: "lightningcss-android-arm64@npm:1.32.0" @@ -1258,6 +1425,26 @@ __metadata: languageName: node linkType: hard +"magicast@npm:^0.5.2": + version: 0.5.3 + resolution: "magicast@npm:0.5.3" + dependencies: + "@babel/parser": "npm:^7.29.3" + "@babel/types": "npm:^7.29.0" + source-map-js: "npm:^1.2.1" + checksum: 10/436ad518726b691cf9ac1a14ab14705784f28075892a092b06e8b17ac7303fe57e8a2789989c68b560653a909a8df49d1582bb73f9bdad4bcbab892201251049 + languageName: node + linkType: hard + +"make-dir@npm:^4.0.0": + version: 4.0.0 + resolution: "make-dir@npm:4.0.0" + dependencies: + semver: "npm:^7.5.3" + checksum: 10/bf0731a2dd3aab4db6f3de1585cea0b746bb73eb5a02e3d8d72757e376e64e6ada190b1eddcde5b2f24a81b688a9897efd5018737d05e02e2a671dda9cff8a8a + languageName: node + linkType: hard + "make-error@npm:^1.1.1": version: 1.3.6 resolution: "make-error@npm:1.3.6" @@ -1572,6 +1759,13 @@ __metadata: languageName: node linkType: hard +"pure-rand@npm:^6.1.0": + version: 6.1.0 + resolution: "pure-rand@npm:6.1.0" + checksum: 10/256aa4bcaf9297256f552914e03cbdb0039c8fe1db11fa1e6d3f80790e16e563eb0a859a1e61082a95e224fc0c608661839439f8ecc6a3db4e48d46d99216ee4 + languageName: node + linkType: hard + "pure-rand@npm:^8.0.0": version: 8.1.0 resolution: "pure-rand@npm:8.1.0" @@ -1670,6 +1864,15 @@ __metadata: languageName: node linkType: hard +"semver@npm:^7.5.3": + version: 7.8.1 + resolution: "semver@npm:7.8.1" + bin: + semver: bin/semver.js + checksum: 10/3244f6c4cb3f8126fea0426d353829ed4967e41e1f4696337c6fdcad87426466fe2badaf49d7dc85849acfc496ea0599432a4aecc33802d2d774e723acfa30e6 + languageName: node + linkType: hard + "shebang-command@npm:^2.0.0": version: 2.0.0 resolution: "shebang-command@npm:2.0.0" @@ -1815,6 +2018,15 @@ __metadata: languageName: node linkType: hard +"supports-color@npm:^7.1.0": + version: 7.2.0 + resolution: "supports-color@npm:7.2.0" + dependencies: + has-flag: "npm:^4.0.0" + checksum: 10/c8bb7afd564e3b26b50ca6ee47572c217526a1389fe018d00345856d4a9b08ffbd61fadaf283a87368d94c3dcdb8f5ffe2650a5a65863e21ad2730ca0f05210a + languageName: node + linkType: hard + "tar@npm:~7.5.7": version: 7.5.7 resolution: "tar@npm:7.5.7"