From 40bb0b9df9ad84d7aeaccb8d13f9173b6c443726 Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Wed, 27 May 2026 16:43:07 +0200 Subject: [PATCH 01/16] chore: ignore .states/ in git Local node-state directory holding wallet seeds and chain snapshots during development. Should not be tracked. --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 2a32ed65..02d03dcd 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,7 @@ coverage *~ *temp + +.claude/ + +.states From 6e9a9e9448c41a08a418e2ce1766d76b14c4ee1f Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Wed, 27 May 2026 16:43:26 +0200 Subject: [PATCH 02/16] feat(multisig): add Forwarder + ForwarderPrivate modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new modules providing forwarder primitives for inbound coin routing to a designated parent address. Forwarder (public): - _depositShielded: atomic receive + forward via Zswap, accumulates per-color total - _depositUnshielded: same flow for unshielded transfers - _recordReceived: shared overflow-guarded accounting helper - _parent + _received ledger fields, immutable parent after init ForwarderPrivate (private): - _deposit: receiveShielded only; coins dwell at the contract - _drain: preimage-gated send with change re-emission to self - _calculateParentCommitment: persistentHash([parentAddr, salt]) — pure - _parentCommitment ledger field hides parent under operational salt Both modules gated by Initializable. No witnesses in v1; all sensitive data flows through circuit parameters. --- contracts/src/multisig/Forwarder.compact | 156 ++++++++++++++++++ .../src/multisig/ForwarderPrivate.compact | 152 +++++++++++++++++ 2 files changed, 308 insertions(+) create mode 100644 contracts/src/multisig/Forwarder.compact create mode 100644 contracts/src/multisig/ForwarderPrivate.compact diff --git a/contracts/src/multisig/Forwarder.compact b/contracts/src/multisig/Forwarder.compact new file mode 100644 index 00000000..4a721e54 --- /dev/null +++ b/contracts/src/multisig/Forwarder.compact @@ -0,0 +1,156 @@ +// 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, while + * accumulating per-color cumulative receipt totals for audit. + * + * Presets (`ForwarderShielded`, `ForwarderUnshielded`) wrap individual + * deposit paths so each deployable contract is single-purpose. The + * overflow guard and `_received.insert` are factored into a shared + * internal `_recordReceived` helper. + * + * 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 `_init`. + */ +module Forwarder { + import CompactStandardLibrary; + import { initialize, assertInitialized } from "../security/Initializable" prefix Initializable_; + import { UINT128_MAX } from "../utils/Utils" prefix Utils_; + + // ─── State ────────────────────────────────────────────────────── + + export ledger _parent: Bytes<32>; + export ledger _received: Map, Uint<128>>; + + // ─── 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. + * + * @param {Bytes<32>} parent - The parent address. + * + * @returns {[]} Empty tuple. + */ + export circuit _init(parent: Bytes<32>): [] { + 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 per-color cumulative total in + * `_received` is incremented by `coin.value`. + * + * @circuitInfo k=15, rows=24718 + * + * Requirements: + * + * - Contract must be initialized. + * - `_received[coin.color] + coin.value` must not overflow `Uint<128>` + * (enforced by `_recordReceived`). + * + * @param {ShieldedCoinInfo} coin - The incoming shielded coin. + * + * @returns {[]} Empty tuple. + */ + export circuit _depositShielded(coin: ShieldedCoinInfo): [] { + Initializable_assertInitialized(); + _recordReceived(coin.color, coin.value); + receiveShielded(disclose(coin)); + sendImmediateShielded( + disclose(coin), + right(ContractAddress { bytes: _parent }), + disclose(coin.value) + ); + } + + /** + * @description Receives an unshielded amount of `color` and + * atomically forwards it to `_parent`. The per-color cumulative + * total in `_received` is incremented by `amount`. + * + * @circuitInfo k=10, rows=813 + * + * Requirements: + * + * - Contract must be initialized. + * - `_received[color] + amount` must not overflow `Uint<128>` + * (enforced by `_recordReceived`). + * + * @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(); + _recordReceived(color, amount); + receiveUnshielded(disclose(color), disclose(amount)); + sendUnshielded( + disclose(color), + disclose(amount), + left(ContractAddress { bytes: _parent }) + ); + } + + // ─── View ─────────────────────────────────────────────────────── + + /** + * @description Returns the cumulative received total for `color`, + * or zero if no deposit of that color has been recorded. + * + * @circuitInfo k=9, rows=343 + * + * Requirements: + * + * - Contract must be initialized. + * + * @param {Bytes<32>} color - The token color. + * + * @returns {Uint<128>} Cumulative received total for `color`. + */ + export circuit _getReceived(color: Bytes<32>): Uint<128> { + Initializable_assertInitialized(); + if (!_received.member(disclose(color))) { + return 0; + } + return _received.lookup(disclose(color)); + } + + // ─── Internal ─────────────────────────────────────────────────── + + /** + * @description Overflow-guarded accumulator. Factored from both + * deposit paths so the overflow assert and `_received.insert` happen + * exactly once per deposit regardless of coin kind. + * + * @param {Bytes<32>} color - The token color. + * @param {Uint<128>} value - The value to add to `_received[color]`. + * + * @returns {[]} Empty tuple. + */ + circuit _recordReceived(color: Bytes<32>, value: Uint<128>): [] { + const current = _getReceived(color); + assert(current <= Utils_UINT128_MAX() - value, "Forwarder: received overflow"); + _received.insert(disclose(color), disclose(current + value as Uint<128>)); + } +} diff --git a/contracts/src/multisig/ForwarderPrivate.compact b/contracts/src/multisig/ForwarderPrivate.compact new file mode 100644 index 00000000..2c0e9f04 --- /dev/null +++ b/contracts/src/multisig/ForwarderPrivate.compact @@ -0,0 +1,152 @@ +// 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, salt)` preimage at drain + * time. + * + * Knowledge of the preimage is the sole authorization gate — there is + * no signer set and no nullifier scheme. Salt is held off-chain as an + * operational secret; the contract never sees it except during a drain. + * Two forwarders bound to the same parent with different salts 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 `_init`. The pure + * `_calculateParentCommitment` helper does not access state and is + * intentionally callable without initialization. + */ +module ForwarderPrivate { + import CompactStandardLibrary; + import { initialize, assertInitialized } from "../security/Initializable" prefix Initializable_; + import { selfAsRecipient } from "../utils/Utils" prefix Utils_; + + // ─── State ────────────────────────────────────────────────────── + + export 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. + * + * @param {Bytes<32>} parentCommitment - `persistentHash([parentAddr, salt])` + * computed off-chain by the deployer (see `_calculateParentCommitment`). + * + * @returns {[]} Empty tuple. + */ + export circuit _init(parentCommitment: Bytes<32>): [] { + 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, salt)` + * 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=47778 + * + * Requirements: + * + * - Contract must be initialized. + * - `_calculateParentCommitment(parentAddr, salt) == _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>} salt - The salt. Operational secret; never + * appears on the public transcript. + * @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>, + salt: Bytes<32>, + value: Uint<128> + ): ShieldedSendResult { + Initializable_assertInitialized(); + assert( + _calculateParentCommitment(parentAddr, salt) == _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, salt)`. + * Pure circuit — used off-chain by the deployer to compute the + * constructor argument, and inside `_drain` for the preimage check. + * Callable without initialization. + * + * @param {Bytes<32>} parentAddr - The parent address. + * @param {Bytes<32>} salt - The salt. + * + * @returns {Bytes<32>} `persistentHash([parentAddr, salt])`. + */ + export pure circuit _calculateParentCommitment( + parentAddr: Bytes<32>, + salt: Bytes<32> + ): Bytes<32> { + return persistentHash>>([parentAddr, salt]); + } +} From dc7605e999fa434625542ccb0b1a91467c449e2c Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Wed, 27 May 2026 16:43:46 +0200 Subject: [PATCH 03/16] feat(multisig): add Forwarder preset variants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three single-purpose presets composed from the Forwarder / ForwarderPrivate modules via named imports. Each preset is its own deployable contract with its own verifier key; a bank picks the right preset at deploy time based on the coin kind it accepts. - ForwarderShielded: exposes deposit(coin) for shielded receipts - ForwarderUnshielded: exposes depositUnshielded(color, amount) - ForwarderPrivate: exposes deposit + drain + _calculateParentCommitment for the private-parent flow No combined preset — banks that need both shielded and unshielded deploy two contracts. No ForwarderPrivateUnshielded — unshielded sends publish the parent on-chain, which defeats the private variant. --- .../forwarder/ForwarderPrivate.compact | 112 ++++++++++++++++++ .../forwarder/ForwarderShielded.compact | 73 ++++++++++++ .../forwarder/ForwarderUnshielded.compact | 73 ++++++++++++ 3 files changed, 258 insertions(+) create mode 100644 contracts/src/multisig/presets/forwarder/ForwarderPrivate.compact create mode 100644 contracts/src/multisig/presets/forwarder/ForwarderShielded.compact create mode 100644 contracts/src/multisig/presets/forwarder/ForwarderUnshielded.compact diff --git a/contracts/src/multisig/presets/forwarder/ForwarderPrivate.compact b/contracts/src/multisig/presets/forwarder/ForwarderPrivate.compact new file mode 100644 index 00000000..c6658702 --- /dev/null +++ b/contracts/src/multisig/presets/forwarder/ForwarderPrivate.compact @@ -0,0 +1,112 @@ +// 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, salt)` preimage at drain time. + * + * Knowledge of the preimage is the sole authorization gate. Salt is an + * operational secret held off-chain by the deployer; loss of the salt + * is equivalent to loss of a hot-wallet key. Two forwarders bound to + * the same parent with different salts 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 { + _init, + _deposit, + _drain, + _calculateParentCommitment, + _parentCommitment +} from "../../ForwarderPrivate" prefix ForwarderPrivate_; + +/** + * @description Deploys the forwarder bound to a specific parent + * commitment. The deployer computes the commitment off-chain as + * `_calculateParentCommitment(parentAddr, salt)` and passes it here. + * + * @param {Bytes<32>} parentCommitment - The commitment to the + * `(parentAddr, salt)` pair that the operator will present at drain. + */ +constructor(parentCommitment: Bytes<32>) { + ForwarderPrivate__init(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, salt)` + * 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, salt)` 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>} salt - The salt. Operational secret; never appears + * on the public transcript. + * @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>, + salt: Bytes<32>, + value: Uint<128> +): ShieldedSendResult { + return ForwarderPrivate__drain(coin, parentAddr, salt, 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, salt)` + * pair. Pure circuit — used off-chain by the deployer to compute the + * constructor argument, and inside `drain` for the preimage check. + * + * @param {Bytes<32>} parentAddr - The parent address. + * @param {Bytes<32>} salt - The salt. + * + * @returns {Bytes<32>} The commitment `persistentHash([parentAddr, salt])`. + */ +export pure circuit _calculateParentCommitment( + parentAddr: Bytes<32>, + salt: Bytes<32> +): Bytes<32> { + return ForwarderPrivate__calculateParentCommitment(parentAddr, salt); +} diff --git a/contracts/src/multisig/presets/forwarder/ForwarderShielded.compact b/contracts/src/multisig/presets/forwarder/ForwarderShielded.compact new file mode 100644 index 00000000..c22751de --- /dev/null +++ b/contracts/src/multisig/presets/forwarder/ForwarderShielded.compact @@ -0,0 +1,73 @@ +// 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, accumulating cumulative receipt totals per color. + * + * 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. Per-color totals are observable from the indexer for + * audit / reconciliation. + */ + +import CompactStandardLibrary; +import { + _init, + _depositShielded, + _getReceived, + _parent +} from "../../Forwarder" prefix Forwarder_; + +/** + * @description Deploys the forwarder bound to a specific parent address. + * + * @param {Bytes<32>} parent - The parent address that receives every + * forwarded coin. Wrapped as a `ContractAddress` at the send site. + */ +constructor(parent: Bytes<32>) { + Forwarder__init(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`. The per-color + * cumulative total in `_received` is incremented by `coin.value`. + * + * Requirements: + * + * - `_received[coin.color] + coin.value` must not overflow `Uint<128>`. + * + * @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 {Bytes<32>} The parent address set at deploy. + */ +export circuit getParent(): Bytes<32> { + return Forwarder__parent; +} + +/** + * @description Returns the cumulative received total for a given color, + * or zero if no coin of that color has been deposited. + * + * @param {Bytes<32>} color - The token color. + * + * @returns {Uint<128>} Cumulative received total for `color`. + */ +export circuit getReceived(color: Bytes<32>): Uint<128> { + return Forwarder__getReceived(color); +} diff --git a/contracts/src/multisig/presets/forwarder/ForwarderUnshielded.compact b/contracts/src/multisig/presets/forwarder/ForwarderUnshielded.compact new file mode 100644 index 00000000..f91fadb7 --- /dev/null +++ b/contracts/src/multisig/presets/forwarder/ForwarderUnshielded.compact @@ -0,0 +1,73 @@ +// 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, accumulating cumulative receipt totals + * per color. + * + * 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 { + _init, + _depositUnshielded, + _getReceived, + _parent +} from "../../Forwarder" prefix Forwarder_; + +/** + * @description Deploys the forwarder bound to a specific parent address. + * + * @param {Bytes<32>} parent - The parent address that receives every + * forwarded amount. + */ +constructor(parent: Bytes<32>) { + Forwarder__init(parent); +} + +/** + * @description Receives an unshielded amount of `color` and atomically + * forwards it to the configured parent. The per-color cumulative total + * in `_received` is incremented by `amount`. + * + * Requirements: + * + * - `_received[color] + amount` must not overflow `Uint<128>`. + * + * @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 {Bytes<32>} The parent address set at deploy. + */ +export circuit getParent(): Bytes<32> { + return Forwarder__parent; +} + +/** + * @description Returns the cumulative received total for a given color, + * or zero if no amount of that color has been deposited. + * + * @param {Bytes<32>} color - The token color. + * + * @returns {Uint<128>} Cumulative received total for `color`. + */ +export circuit getReceived(color: Bytes<32>): Uint<128> { + return Forwarder__getReceived(color); +} From 1a8f1a694af7dc4b07f606ad0d9497b1244e6190 Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Wed, 27 May 2026 16:43:58 +0200 Subject: [PATCH 04/16] build(contracts): add fast-check + v8 coverage, wire 95% threshold - @vitest/coverage-v8 for native v8 coverage with source-map back-mapping. The coverage include glob covers TS witnesses / simulators and the compactc-generated artifacts/Forwarder*/contract/ index.js files; v8 follows the .js.map to render coverage pages under .compact filenames. - fast-check for property-based tests (unlinkability across salts, per-color accumulation, partial-drain change arithmetic). - test:coverage script: compactc --skip-zk && vitest run --coverage. - 95% per-file threshold (lines/branches/functions/statements) as the closing gate. Subset runs override with --coverage.thresholds.lines=0 etc. --- contracts/package.json | 3 + contracts/vitest.config.ts | 32 ++++++ yarn.lock | 216 ++++++++++++++++++++++++++++++++++++- 3 files changed, 249 insertions(+), 2 deletions(-) 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/vitest.config.ts b/contracts/vitest.config.ts index 3e71d7a7..d4d6d87e 100644 --- a/contracts/vitest.config.ts +++ b/contracts/vitest.config.ts @@ -7,5 +7,37 @@ 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', + // Include compactc-generated JS so v8 can map executed lines + // back to .compact source via the index.js.map files. The + // forwarder presets are the focus here; expand as needed. + 'artifacts/ForwarderShielded/contract/index.js', + 'artifacts/ForwarderUnshielded/contract/index.js', + 'artifacts/ForwarderPrivate/contract/index.js', + ], + exclude: [ + ...(configDefaults.coverage?.exclude ?? []), + 'src/archive/**', + 'src/**/test/*.test.ts', + ], + // 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" From 828fa2cbe9dfb32a392c0d15b59f1fd40a351bab Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Wed, 27 May 2026 16:45:52 +0200 Subject: [PATCH 05/16] test(multisig): add Forwarder simulators + witnesses + test suites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test infrastructure for the three forwarder presets. - Witness stubs (Record) — v1 has no witnesses. - Simulators wrap the compactc-emitted artifacts via createSimulator, expose the public preset surface, and (for ForwarderPrivate) a static calculateParentCommitment that delegates to pureCircuits. - Three test suites covering 31 cases total: * constructor + initial state * shielded / unshielded deposit accumulation + overflow guard * drain auth (correct, wrong parent, wrong salt, both wrong) * drain change-coin handling (full vs partial) * regression: no ledger mutation across drain failures * property tests (fast-check) for accumulation, unlinkability across salts, and partial-drain change arithmetic --- .../multisig/test/ForwarderPrivate.test.ts | 240 ++++++++++++++++++ .../multisig/test/ForwarderShielded.test.ts | 110 ++++++++ .../multisig/test/ForwarderUnshielded.test.ts | 55 ++++ .../simulators/ForwarderPrivateSimulator.ts | 78 ++++++ .../simulators/ForwarderShieldedSimulator.ts | 55 ++++ .../ForwarderUnshieldedSimulator.ts | 53 ++++ .../witnesses/ForwarderPrivateWitnesses.ts | 6 + .../witnesses/ForwarderShieldedWitnesses.ts | 6 + .../witnesses/ForwarderUnshieldedWitnesses.ts | 6 + 9 files changed, 609 insertions(+) create mode 100644 contracts/src/multisig/test/ForwarderPrivate.test.ts create mode 100644 contracts/src/multisig/test/ForwarderShielded.test.ts create mode 100644 contracts/src/multisig/test/ForwarderUnshielded.test.ts create mode 100644 contracts/src/multisig/test/simulators/ForwarderPrivateSimulator.ts create mode 100644 contracts/src/multisig/test/simulators/ForwarderShieldedSimulator.ts create mode 100644 contracts/src/multisig/test/simulators/ForwarderUnshieldedSimulator.ts create mode 100644 contracts/src/multisig/witnesses/ForwarderPrivateWitnesses.ts create mode 100644 contracts/src/multisig/witnesses/ForwarderShieldedWitnesses.ts create mode 100644 contracts/src/multisig/witnesses/ForwarderUnshieldedWitnesses.ts diff --git a/contracts/src/multisig/test/ForwarderPrivate.test.ts b/contracts/src/multisig/test/ForwarderPrivate.test.ts new file mode 100644 index 00000000..4f7dc166 --- /dev/null +++ b/contracts/src/multisig/test/ForwarderPrivate.test.ts @@ -0,0 +1,240 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import fc from 'fast-check'; +import * as utils from '#test-utils/address.js'; +import { ForwarderPrivateSimulator } from './simulators/ForwarderPrivateSimulator.js'; + +const PARENT = utils.createEitherTestUser('PARENT').left.bytes; +const WRONG_PARENT = utils.createEitherTestUser('WRONG').left.bytes; +const SALT = new Uint8Array(32).fill(0xaa); +const WRONG_SALT = new Uint8Array(32).fill(0xbb); +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, salt: Uint8Array): Uint8Array { + return ForwarderPrivateSimulator.calculateParentCommitment(parent, salt); +} + +let fwd: ForwarderPrivateSimulator; + +describe('ForwarderPrivate', () => { + describe('constructor', () => { + it('should store and expose the parentCommitment at deploy', () => { + const c = commitment(PARENT, SALT); + fwd = new ForwarderPrivateSimulator(c); + expect(fwd.getParentCommitment()).toEqual(c); + }); + + it('should produce the same commitment for the same (parent, salt)', () => { + const c1 = commitment(PARENT, SALT); + const c2 = commitment(PARENT, SALT); + expect(c1).toEqual(c2); + }); + }); + + describe('deposit', () => { + beforeEach(() => { + fwd = new ForwarderPrivateSimulator(commitment(PARENT, SALT)); + }); + + it('should not modify _parentCommitment on deposit', () => { + const before = fwd.getParentCommitment(); + fwd.deposit(makeCoin(COLOR, AMOUNT)); + expect(fwd.getParentCommitment()).toEqual(before); + }); + }); + + describe('drain', () => { + beforeEach(() => { + fwd = new ForwarderPrivateSimulator(commitment(PARENT, SALT)); + fwd.deposit(makeCoin(COLOR, AMOUNT)); + }); + + it('should succeed drain with correct (parentAddr, salt)', () => { + const result = fwd.drain( + makeQualifiedCoin(COLOR, AMOUNT, 0n), + PARENT, + SALT, + AMOUNT, + ); + expect(result.sent.value).toEqual(AMOUNT); + }); + + it('should fail drain with wrong parentAddr', () => { + expect(() => + fwd.drain( + makeQualifiedCoin(COLOR, AMOUNT, 0n), + WRONG_PARENT, + SALT, + AMOUNT, + ), + ).toThrow('ForwarderPrivate: invalid parent'); + }); + + it('should fail drain with wrong salt', () => { + expect(() => + fwd.drain( + makeQualifiedCoin(COLOR, AMOUNT, 0n), + PARENT, + WRONG_SALT, + AMOUNT, + ), + ).toThrow('ForwarderPrivate: invalid parent'); + }); + + it('should fail drain with both wrong', () => { + expect(() => + fwd.drain( + makeQualifiedCoin(COLOR, AMOUNT, 0n), + WRONG_PARENT, + WRONG_SALT, + AMOUNT, + ), + ).toThrow('ForwarderPrivate: invalid parent'); + }); + + it('should fail drain with value > coin.value', () => { + expect(() => + fwd.drain( + makeQualifiedCoin(COLOR, AMOUNT, 0n), + PARENT, + SALT, + AMOUNT + 1n, + ), + ).toThrow(); + }); + + it('should produce no change when drain value equals coin value', () => { + const result = fwd.drain( + makeQualifiedCoin(COLOR, AMOUNT, 0n), + PARENT, + SALT, + AMOUNT, + ); + expect(result.change.is_some).toBe(false); + }); + + it('should produce a change coin when drain value is less than coin value', () => { + const result = fwd.drain( + makeQualifiedCoin(COLOR, AMOUNT, 0n), + PARENT, + SALT, + 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 = fwd.drain( + makeQualifiedCoin(COLOR, AMOUNT, 0n), + PARENT, + SALT, + 400n, + ); + expect(result.sent.value).toEqual(400n); + expect(result.sent.color).toEqual(COLOR); + }); + + it('should not mutate _parentCommitment on drain', () => { + const before = fwd.getParentCommitment(); + fwd.drain(makeQualifiedCoin(COLOR, AMOUNT, 0n), PARENT, SALT, 100n); + expect(fwd.getParentCommitment()).toEqual(before); + }); + + it('should not mutate ledger when drain fails authorization', () => { + const before = fwd.getParentCommitment(); + expect(() => + fwd.drain( + makeQualifiedCoin(COLOR, AMOUNT, 0n), + WRONG_PARENT, + SALT, + AMOUNT, + ), + ).toThrow(); + expect(fwd.getParentCommitment()).toEqual(before); + }); + }); + + describe('regression', () => { + it('should not modify _parentCommitment across deposit/drain sequence', () => { + fwd = new ForwarderPrivateSimulator(commitment(PARENT, SALT)); + const before = fwd.getParentCommitment(); + fwd.deposit(makeCoin(COLOR, AMOUNT)); + fwd.drain(makeQualifiedCoin(COLOR, AMOUNT, 0n), PARENT, SALT, 500n); + expect(fwd.getParentCommitment()).toEqual(before); + }); + }); + + describe('property: unlinkability', () => { + it('should produce different commitments for different salts', () => { + 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('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 sim = new ForwarderPrivateSimulator(commitment(PARENT, SALT)); + sim.deposit(makeCoin(COLOR, coinVal)); + const result = sim.drain( + makeQualifiedCoin(COLOR, coinVal, 0n), + PARENT, + SALT, + drainVal, + ); + expect(result.change.value.value).toEqual(coinVal - drainVal); + }, + ), + { numRuns: 25 }, + ); + }); + }); +}); diff --git a/contracts/src/multisig/test/ForwarderShielded.test.ts b/contracts/src/multisig/test/ForwarderShielded.test.ts new file mode 100644 index 00000000..f89a263a --- /dev/null +++ b/contracts/src/multisig/test/ForwarderShielded.test.ts @@ -0,0 +1,110 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import fc from 'fast-check'; +import * as utils from '#test-utils/address.js'; +import { ForwarderShieldedSimulator } from './simulators/ForwarderShieldedSimulator.js'; + +const PARENT = utils.createEitherTestUser('PARENT').left.bytes; +const ALT_PARENT = utils.createEitherTestUser('ALT').left.bytes; +const COLOR = new Uint8Array(32).fill(1); +const COLOR2 = new Uint8Array(32).fill(2); +const AMOUNT = 1000n; +const MAX_U32 = (1n << 32n) - 1n; + +function makeCoin(color: Uint8Array, value: bigint, nonce?: Uint8Array) { + return { + nonce: nonce ?? new Uint8Array(32).fill(0), + color, + value, + }; +} + +let fwd: ForwarderShieldedSimulator; + +describe('ForwarderShielded', () => { + describe('constructor', () => { + it('should store and expose the parent at deploy', () => { + fwd = new ForwarderShieldedSimulator(PARENT); + expect(fwd.getParent()).toEqual(PARENT); + }); + + it('should return 0 received for unknown color', () => { + fwd = new ForwarderShieldedSimulator(PARENT); + expect(fwd.getReceived(COLOR)).toEqual(0n); + }); + }); + + describe('deposit', () => { + beforeEach(() => { + fwd = new ForwarderShieldedSimulator(PARENT); + }); + + it('should accumulate _received[color] on deposit', () => { + fwd.deposit(makeCoin(COLOR, AMOUNT)); + expect(fwd.getReceived(COLOR)).toEqual(AMOUNT); + }); + + it('should track received per color independently', () => { + fwd.deposit(makeCoin(COLOR, AMOUNT)); + fwd.deposit(makeCoin(COLOR2, AMOUNT * 2n)); + expect(fwd.getReceived(COLOR)).toEqual(AMOUNT); + expect(fwd.getReceived(COLOR2)).toEqual(AMOUNT * 2n); + }); + + it('should accumulate sequential deposits to the same color', () => { + fwd.deposit(makeCoin(COLOR, AMOUNT, new Uint8Array(32).fill(1))); + fwd.deposit(makeCoin(COLOR, AMOUNT, new Uint8Array(32).fill(2))); + fwd.deposit(makeCoin(COLOR, AMOUNT, new Uint8Array(32).fill(3))); + expect(fwd.getReceived(COLOR)).toEqual(AMOUNT * 3n); + }); + + it('should forward the deposited color without substitution', () => { + fwd.deposit(makeCoin(COLOR, AMOUNT)); + expect(fwd.getReceived(COLOR)).toEqual(AMOUNT); + expect(fwd.getReceived(COLOR2)).toEqual(0n); + }); + + it('should not modify _parent after multiple deposits', () => { + for (let i = 0; i < 5; i++) { + fwd.deposit(makeCoin(COLOR, AMOUNT, new Uint8Array(32).fill(i))); + } + expect(fwd.getParent()).toEqual(PARENT); + }); + }); + + describe('privacy / disclosure', () => { + it('should expose distinct parents for distinct deployments', () => { + const fwd1 = new ForwarderShieldedSimulator(PARENT); + const fwd2 = new ForwarderShieldedSimulator(ALT_PARENT); + expect(fwd1.getParent()).toEqual(PARENT); + expect(fwd2.getParent()).toEqual(ALT_PARENT); + expect(fwd1.getParent()).not.toEqual(fwd2.getParent()); + }); + }); + + describe('property: accumulation', () => { + it( + 'should accumulate _received as the sum of deposited values', + { timeout: 30_000 }, + () => { + fc.assert( + fc.property( + fc.array( + fc.bigInt({ min: 0n, max: MAX_U32 }), + { minLength: 1, maxLength: 4 }, + ), + (values) => { + const sim = new ForwarderShieldedSimulator(PARENT); + let i = 0; + for (const v of values) { + sim.deposit(makeCoin(COLOR, v, new Uint8Array(32).fill(i++))); + } + const expected = values.reduce((acc, v) => acc + v, 0n); + expect(sim.getReceived(COLOR)).toEqual(expected); + }, + ), + { numRuns: 20 }, + ); + }, + ); + }); +}); diff --git a/contracts/src/multisig/test/ForwarderUnshielded.test.ts b/contracts/src/multisig/test/ForwarderUnshielded.test.ts new file mode 100644 index 00000000..cfae222b --- /dev/null +++ b/contracts/src/multisig/test/ForwarderUnshielded.test.ts @@ -0,0 +1,55 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import * as utils from '#test-utils/address.js'; +import { ForwarderUnshieldedSimulator } from './simulators/ForwarderUnshieldedSimulator.js'; + +const PARENT = utils.createEitherTestUser('PARENT').left.bytes; +const COLOR = new Uint8Array(32).fill(1); +const COLOR2 = new Uint8Array(32).fill(2); +const AMOUNT = 1000n; +const MAX_U128 = (1n << 128n) - 1n; + +let fwd: ForwarderUnshieldedSimulator; + +describe('ForwarderUnshielded', () => { + describe('constructor', () => { + it('should store and expose the parent at deploy', () => { + fwd = new ForwarderUnshieldedSimulator(PARENT); + expect(fwd.getParent()).toEqual(PARENT); + }); + + it('should return 0 received for unknown color', () => { + fwd = new ForwarderUnshieldedSimulator(PARENT); + expect(fwd.getReceived(COLOR)).toEqual(0n); + }); + }); + + describe('depositUnshielded', () => { + beforeEach(() => { + fwd = new ForwarderUnshieldedSimulator(PARENT); + }); + + it('should accumulate _received[color] on depositUnshielded', () => { + fwd.depositUnshielded(COLOR, AMOUNT); + expect(fwd.getReceived(COLOR)).toEqual(AMOUNT); + }); + + it('should fail depositUnshielded with "Forwarder: received overflow" at MAX', () => { + fwd.depositUnshielded(COLOR, MAX_U128); + expect(() => fwd.depositUnshielded(COLOR, 1n)).toThrow( + 'Forwarder: received overflow', + ); + }); + + it('should not modify _parent after depositUnshielded', () => { + fwd.depositUnshielded(COLOR, AMOUNT); + expect(fwd.getParent()).toEqual(PARENT); + }); + + it('should track received per color independently on unshielded deposits', () => { + fwd.depositUnshielded(COLOR, AMOUNT); + fwd.depositUnshielded(COLOR2, AMOUNT * 2n); + expect(fwd.getReceived(COLOR)).toEqual(AMOUNT); + expect(fwd.getReceived(COLOR2)).toEqual(AMOUNT * 2n); + }); + }); +}); diff --git a/contracts/src/multisig/test/simulators/ForwarderPrivateSimulator.ts b/contracts/src/multisig/test/simulators/ForwarderPrivateSimulator.ts new file mode 100644 index 00000000..2934d84a --- /dev/null +++ b/contracts/src/multisig/test/simulators/ForwarderPrivateSimulator.ts @@ -0,0 +1,78 @@ +import { + type BaseSimulatorOptions, + createSimulator, +} from '@openzeppelin-compact/contracts-simulator'; +import { + ledger, + pureCircuits, + Contract as ForwarderPrivate, +} from '../../../../artifacts/ForwarderPrivate/contract/index.js'; +import { + ForwarderPrivatePrivateState, + ForwarderPrivateWitnesses, +} from '../../witnesses/ForwarderPrivateWitnesses.js'; + +type ShieldedCoinInfo = { nonce: Uint8Array; color: Uint8Array; value: bigint }; +type QualifiedShieldedCoinInfo = { + nonce: Uint8Array; + color: Uint8Array; + value: bigint; + mt_index: bigint; +}; +type ShieldedSendResult = { + change: { is_some: boolean; value: ShieldedCoinInfo }; + sent: ShieldedCoinInfo; +}; + +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, + salt: Uint8Array, + ): Uint8Array { + return pureCircuits._calculateParentCommitment(parentAddr, salt); + } + + public deposit(coin: ShieldedCoinInfo) { + return this.circuits.impure.deposit(coin); + } + + public drain( + coin: QualifiedShieldedCoinInfo, + parentAddr: Uint8Array, + salt: Uint8Array, + value: bigint, + ): ShieldedSendResult { + return this.circuits.impure.drain(coin, parentAddr, salt, value); + } + + public getParentCommitment(): Uint8Array { + return this.circuits.impure.getParentCommitment(); + } +} diff --git a/contracts/src/multisig/test/simulators/ForwarderShieldedSimulator.ts b/contracts/src/multisig/test/simulators/ForwarderShieldedSimulator.ts new file mode 100644 index 00000000..57e11a42 --- /dev/null +++ b/contracts/src/multisig/test/simulators/ForwarderShieldedSimulator.ts @@ -0,0 +1,55 @@ +import { + type BaseSimulatorOptions, + createSimulator, +} from '@openzeppelin-compact/contracts-simulator'; +import { + ledger, + Contract as ForwarderShielded, +} from '../../../../artifacts/ForwarderShielded/contract/index.js'; +import { + ForwarderShieldedPrivateState, + ForwarderShieldedWitnesses, +} from '../../witnesses/ForwarderShieldedWitnesses.js'; + +type ShieldedCoinInfo = { nonce: Uint8Array; color: Uint8Array; value: bigint }; + +type ForwarderShieldedArgs = readonly [parent: Uint8Array]; + +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([parent], options); + } + + public deposit(coin: ShieldedCoinInfo) { + return this.circuits.impure.deposit(coin); + } + + public getParent(): Uint8Array { + return this.circuits.impure.getParent(); + } + + public getReceived(color: Uint8Array): bigint { + return this.circuits.impure.getReceived(color); + } +} diff --git a/contracts/src/multisig/test/simulators/ForwarderUnshieldedSimulator.ts b/contracts/src/multisig/test/simulators/ForwarderUnshieldedSimulator.ts new file mode 100644 index 00000000..c77d5b9b --- /dev/null +++ b/contracts/src/multisig/test/simulators/ForwarderUnshieldedSimulator.ts @@ -0,0 +1,53 @@ +import { + type BaseSimulatorOptions, + createSimulator, +} from '@openzeppelin-compact/contracts-simulator'; +import { + ledger, + Contract as ForwarderUnshielded, +} from '../../../../artifacts/ForwarderUnshielded/contract/index.js'; +import { + ForwarderUnshieldedPrivateState, + ForwarderUnshieldedWitnesses, +} from '../../witnesses/ForwarderUnshieldedWitnesses.js'; + +type ForwarderUnshieldedArgs = readonly [parent: Uint8Array]; + +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([parent], options); + } + + public depositUnshielded(color: Uint8Array, amount: bigint) { + return this.circuits.impure.depositUnshielded(color, amount); + } + + public getParent(): Uint8Array { + return this.circuits.impure.getParent(); + } + + public getReceived(color: Uint8Array): bigint { + return this.circuits.impure.getReceived(color); + } +} diff --git a/contracts/src/multisig/witnesses/ForwarderPrivateWitnesses.ts b/contracts/src/multisig/witnesses/ForwarderPrivateWitnesses.ts new file mode 100644 index 00000000..a8f3bfdf --- /dev/null +++ b/contracts/src/multisig/witnesses/ForwarderPrivateWitnesses.ts @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.0.1-alpha.1 (multisig/witnesses/ForwarderPrivateWitnesses.ts) + +export type ForwarderPrivatePrivateState = Record; +export const ForwarderPrivatePrivateState: ForwarderPrivatePrivateState = {}; +export const ForwarderPrivateWitnesses = () => ({}); diff --git a/contracts/src/multisig/witnesses/ForwarderShieldedWitnesses.ts b/contracts/src/multisig/witnesses/ForwarderShieldedWitnesses.ts new file mode 100644 index 00000000..620f5c81 --- /dev/null +++ b/contracts/src/multisig/witnesses/ForwarderShieldedWitnesses.ts @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.0.1-alpha.1 (multisig/witnesses/ForwarderShieldedWitnesses.ts) + +export type ForwarderShieldedPrivateState = Record; +export const ForwarderShieldedPrivateState: ForwarderShieldedPrivateState = {}; +export const ForwarderShieldedWitnesses = () => ({}); diff --git a/contracts/src/multisig/witnesses/ForwarderUnshieldedWitnesses.ts b/contracts/src/multisig/witnesses/ForwarderUnshieldedWitnesses.ts new file mode 100644 index 00000000..b536c87d --- /dev/null +++ b/contracts/src/multisig/witnesses/ForwarderUnshieldedWitnesses.ts @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.0.1-alpha.1 (multisig/witnesses/ForwarderUnshieldedWitnesses.ts) + +export type ForwarderUnshieldedPrivateState = Record; +export const ForwarderUnshieldedPrivateState: ForwarderUnshieldedPrivateState = {}; +export const ForwarderUnshieldedWitnesses = () => ({}); From 9dd9d77ca39f6782b3cfb305bd658cc1fbb905e7 Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Thu, 28 May 2026 12:12:05 +0200 Subject: [PATCH 06/16] refactor(multisig): nest forwarder presets under presets/ subdirs Move the three forwarder preset test files, simulators, and witnesses into matching `presets/` subdirectories so module-level fixtures stay flat alongside the rest of multisig/ while preset wiring lives in a clearly-scoped folder. The preset test files are slimmed to wiring-only checks (constructor arg storage, exposed circuit forwarding, zero-guard propagation, public-state accessor). Behavioural coverage moves out to the new module-level test files in the follow-up commit. Preset simulator import paths are bumped one level up to reach witnesses/ and artifacts/ from their new depth. --- .../multisig/test/ForwarderPrivate.test.ts | 240 ------------------ .../multisig/test/ForwarderShielded.test.ts | 110 -------- .../multisig/test/ForwarderUnshielded.test.ts | 55 ---- .../test/presets/ForwarderPrivate.test.ts | 62 +++++ .../test/presets/ForwarderShielded.test.ts | 40 +++ .../test/presets/ForwarderUnshielded.test.ts | 36 +++ .../ForwarderPrivateSimulator.ts | 6 +- .../ForwarderShieldedSimulator.ts | 4 +- .../ForwarderUnshieldedSimulator.ts | 4 +- .../ForwarderPrivateWitnesses.ts | 0 .../ForwarderShieldedWitnesses.ts | 0 .../ForwarderUnshieldedWitnesses.ts | 0 12 files changed, 145 insertions(+), 412 deletions(-) delete mode 100644 contracts/src/multisig/test/ForwarderPrivate.test.ts delete mode 100644 contracts/src/multisig/test/ForwarderShielded.test.ts delete mode 100644 contracts/src/multisig/test/ForwarderUnshielded.test.ts create mode 100644 contracts/src/multisig/test/presets/ForwarderPrivate.test.ts create mode 100644 contracts/src/multisig/test/presets/ForwarderShielded.test.ts create mode 100644 contracts/src/multisig/test/presets/ForwarderUnshielded.test.ts rename contracts/src/multisig/test/simulators/{ => presets}/ForwarderPrivateSimulator.ts (90%) rename contracts/src/multisig/test/simulators/{ => presets}/ForwarderShieldedSimulator.ts (91%) rename contracts/src/multisig/test/simulators/{ => presets}/ForwarderUnshieldedSimulator.ts (91%) rename contracts/src/multisig/witnesses/{ => presets}/ForwarderPrivateWitnesses.ts (100%) rename contracts/src/multisig/witnesses/{ => presets}/ForwarderShieldedWitnesses.ts (100%) rename contracts/src/multisig/witnesses/{ => presets}/ForwarderUnshieldedWitnesses.ts (100%) diff --git a/contracts/src/multisig/test/ForwarderPrivate.test.ts b/contracts/src/multisig/test/ForwarderPrivate.test.ts deleted file mode 100644 index 4f7dc166..00000000 --- a/contracts/src/multisig/test/ForwarderPrivate.test.ts +++ /dev/null @@ -1,240 +0,0 @@ -import { beforeEach, describe, expect, it } from 'vitest'; -import fc from 'fast-check'; -import * as utils from '#test-utils/address.js'; -import { ForwarderPrivateSimulator } from './simulators/ForwarderPrivateSimulator.js'; - -const PARENT = utils.createEitherTestUser('PARENT').left.bytes; -const WRONG_PARENT = utils.createEitherTestUser('WRONG').left.bytes; -const SALT = new Uint8Array(32).fill(0xaa); -const WRONG_SALT = new Uint8Array(32).fill(0xbb); -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, salt: Uint8Array): Uint8Array { - return ForwarderPrivateSimulator.calculateParentCommitment(parent, salt); -} - -let fwd: ForwarderPrivateSimulator; - -describe('ForwarderPrivate', () => { - describe('constructor', () => { - it('should store and expose the parentCommitment at deploy', () => { - const c = commitment(PARENT, SALT); - fwd = new ForwarderPrivateSimulator(c); - expect(fwd.getParentCommitment()).toEqual(c); - }); - - it('should produce the same commitment for the same (parent, salt)', () => { - const c1 = commitment(PARENT, SALT); - const c2 = commitment(PARENT, SALT); - expect(c1).toEqual(c2); - }); - }); - - describe('deposit', () => { - beforeEach(() => { - fwd = new ForwarderPrivateSimulator(commitment(PARENT, SALT)); - }); - - it('should not modify _parentCommitment on deposit', () => { - const before = fwd.getParentCommitment(); - fwd.deposit(makeCoin(COLOR, AMOUNT)); - expect(fwd.getParentCommitment()).toEqual(before); - }); - }); - - describe('drain', () => { - beforeEach(() => { - fwd = new ForwarderPrivateSimulator(commitment(PARENT, SALT)); - fwd.deposit(makeCoin(COLOR, AMOUNT)); - }); - - it('should succeed drain with correct (parentAddr, salt)', () => { - const result = fwd.drain( - makeQualifiedCoin(COLOR, AMOUNT, 0n), - PARENT, - SALT, - AMOUNT, - ); - expect(result.sent.value).toEqual(AMOUNT); - }); - - it('should fail drain with wrong parentAddr', () => { - expect(() => - fwd.drain( - makeQualifiedCoin(COLOR, AMOUNT, 0n), - WRONG_PARENT, - SALT, - AMOUNT, - ), - ).toThrow('ForwarderPrivate: invalid parent'); - }); - - it('should fail drain with wrong salt', () => { - expect(() => - fwd.drain( - makeQualifiedCoin(COLOR, AMOUNT, 0n), - PARENT, - WRONG_SALT, - AMOUNT, - ), - ).toThrow('ForwarderPrivate: invalid parent'); - }); - - it('should fail drain with both wrong', () => { - expect(() => - fwd.drain( - makeQualifiedCoin(COLOR, AMOUNT, 0n), - WRONG_PARENT, - WRONG_SALT, - AMOUNT, - ), - ).toThrow('ForwarderPrivate: invalid parent'); - }); - - it('should fail drain with value > coin.value', () => { - expect(() => - fwd.drain( - makeQualifiedCoin(COLOR, AMOUNT, 0n), - PARENT, - SALT, - AMOUNT + 1n, - ), - ).toThrow(); - }); - - it('should produce no change when drain value equals coin value', () => { - const result = fwd.drain( - makeQualifiedCoin(COLOR, AMOUNT, 0n), - PARENT, - SALT, - AMOUNT, - ); - expect(result.change.is_some).toBe(false); - }); - - it('should produce a change coin when drain value is less than coin value', () => { - const result = fwd.drain( - makeQualifiedCoin(COLOR, AMOUNT, 0n), - PARENT, - SALT, - 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 = fwd.drain( - makeQualifiedCoin(COLOR, AMOUNT, 0n), - PARENT, - SALT, - 400n, - ); - expect(result.sent.value).toEqual(400n); - expect(result.sent.color).toEqual(COLOR); - }); - - it('should not mutate _parentCommitment on drain', () => { - const before = fwd.getParentCommitment(); - fwd.drain(makeQualifiedCoin(COLOR, AMOUNT, 0n), PARENT, SALT, 100n); - expect(fwd.getParentCommitment()).toEqual(before); - }); - - it('should not mutate ledger when drain fails authorization', () => { - const before = fwd.getParentCommitment(); - expect(() => - fwd.drain( - makeQualifiedCoin(COLOR, AMOUNT, 0n), - WRONG_PARENT, - SALT, - AMOUNT, - ), - ).toThrow(); - expect(fwd.getParentCommitment()).toEqual(before); - }); - }); - - describe('regression', () => { - it('should not modify _parentCommitment across deposit/drain sequence', () => { - fwd = new ForwarderPrivateSimulator(commitment(PARENT, SALT)); - const before = fwd.getParentCommitment(); - fwd.deposit(makeCoin(COLOR, AMOUNT)); - fwd.drain(makeQualifiedCoin(COLOR, AMOUNT, 0n), PARENT, SALT, 500n); - expect(fwd.getParentCommitment()).toEqual(before); - }); - }); - - describe('property: unlinkability', () => { - it('should produce different commitments for different salts', () => { - 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('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 sim = new ForwarderPrivateSimulator(commitment(PARENT, SALT)); - sim.deposit(makeCoin(COLOR, coinVal)); - const result = sim.drain( - makeQualifiedCoin(COLOR, coinVal, 0n), - PARENT, - SALT, - drainVal, - ); - expect(result.change.value.value).toEqual(coinVal - drainVal); - }, - ), - { numRuns: 25 }, - ); - }); - }); -}); diff --git a/contracts/src/multisig/test/ForwarderShielded.test.ts b/contracts/src/multisig/test/ForwarderShielded.test.ts deleted file mode 100644 index f89a263a..00000000 --- a/contracts/src/multisig/test/ForwarderShielded.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { beforeEach, describe, expect, it } from 'vitest'; -import fc from 'fast-check'; -import * as utils from '#test-utils/address.js'; -import { ForwarderShieldedSimulator } from './simulators/ForwarderShieldedSimulator.js'; - -const PARENT = utils.createEitherTestUser('PARENT').left.bytes; -const ALT_PARENT = utils.createEitherTestUser('ALT').left.bytes; -const COLOR = new Uint8Array(32).fill(1); -const COLOR2 = new Uint8Array(32).fill(2); -const AMOUNT = 1000n; -const MAX_U32 = (1n << 32n) - 1n; - -function makeCoin(color: Uint8Array, value: bigint, nonce?: Uint8Array) { - return { - nonce: nonce ?? new Uint8Array(32).fill(0), - color, - value, - }; -} - -let fwd: ForwarderShieldedSimulator; - -describe('ForwarderShielded', () => { - describe('constructor', () => { - it('should store and expose the parent at deploy', () => { - fwd = new ForwarderShieldedSimulator(PARENT); - expect(fwd.getParent()).toEqual(PARENT); - }); - - it('should return 0 received for unknown color', () => { - fwd = new ForwarderShieldedSimulator(PARENT); - expect(fwd.getReceived(COLOR)).toEqual(0n); - }); - }); - - describe('deposit', () => { - beforeEach(() => { - fwd = new ForwarderShieldedSimulator(PARENT); - }); - - it('should accumulate _received[color] on deposit', () => { - fwd.deposit(makeCoin(COLOR, AMOUNT)); - expect(fwd.getReceived(COLOR)).toEqual(AMOUNT); - }); - - it('should track received per color independently', () => { - fwd.deposit(makeCoin(COLOR, AMOUNT)); - fwd.deposit(makeCoin(COLOR2, AMOUNT * 2n)); - expect(fwd.getReceived(COLOR)).toEqual(AMOUNT); - expect(fwd.getReceived(COLOR2)).toEqual(AMOUNT * 2n); - }); - - it('should accumulate sequential deposits to the same color', () => { - fwd.deposit(makeCoin(COLOR, AMOUNT, new Uint8Array(32).fill(1))); - fwd.deposit(makeCoin(COLOR, AMOUNT, new Uint8Array(32).fill(2))); - fwd.deposit(makeCoin(COLOR, AMOUNT, new Uint8Array(32).fill(3))); - expect(fwd.getReceived(COLOR)).toEqual(AMOUNT * 3n); - }); - - it('should forward the deposited color without substitution', () => { - fwd.deposit(makeCoin(COLOR, AMOUNT)); - expect(fwd.getReceived(COLOR)).toEqual(AMOUNT); - expect(fwd.getReceived(COLOR2)).toEqual(0n); - }); - - it('should not modify _parent after multiple deposits', () => { - for (let i = 0; i < 5; i++) { - fwd.deposit(makeCoin(COLOR, AMOUNT, new Uint8Array(32).fill(i))); - } - expect(fwd.getParent()).toEqual(PARENT); - }); - }); - - describe('privacy / disclosure', () => { - it('should expose distinct parents for distinct deployments', () => { - const fwd1 = new ForwarderShieldedSimulator(PARENT); - const fwd2 = new ForwarderShieldedSimulator(ALT_PARENT); - expect(fwd1.getParent()).toEqual(PARENT); - expect(fwd2.getParent()).toEqual(ALT_PARENT); - expect(fwd1.getParent()).not.toEqual(fwd2.getParent()); - }); - }); - - describe('property: accumulation', () => { - it( - 'should accumulate _received as the sum of deposited values', - { timeout: 30_000 }, - () => { - fc.assert( - fc.property( - fc.array( - fc.bigInt({ min: 0n, max: MAX_U32 }), - { minLength: 1, maxLength: 4 }, - ), - (values) => { - const sim = new ForwarderShieldedSimulator(PARENT); - let i = 0; - for (const v of values) { - sim.deposit(makeCoin(COLOR, v, new Uint8Array(32).fill(i++))); - } - const expected = values.reduce((acc, v) => acc + v, 0n); - expect(sim.getReceived(COLOR)).toEqual(expected); - }, - ), - { numRuns: 20 }, - ); - }, - ); - }); -}); diff --git a/contracts/src/multisig/test/ForwarderUnshielded.test.ts b/contracts/src/multisig/test/ForwarderUnshielded.test.ts deleted file mode 100644 index cfae222b..00000000 --- a/contracts/src/multisig/test/ForwarderUnshielded.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { beforeEach, describe, expect, it } from 'vitest'; -import * as utils from '#test-utils/address.js'; -import { ForwarderUnshieldedSimulator } from './simulators/ForwarderUnshieldedSimulator.js'; - -const PARENT = utils.createEitherTestUser('PARENT').left.bytes; -const COLOR = new Uint8Array(32).fill(1); -const COLOR2 = new Uint8Array(32).fill(2); -const AMOUNT = 1000n; -const MAX_U128 = (1n << 128n) - 1n; - -let fwd: ForwarderUnshieldedSimulator; - -describe('ForwarderUnshielded', () => { - describe('constructor', () => { - it('should store and expose the parent at deploy', () => { - fwd = new ForwarderUnshieldedSimulator(PARENT); - expect(fwd.getParent()).toEqual(PARENT); - }); - - it('should return 0 received for unknown color', () => { - fwd = new ForwarderUnshieldedSimulator(PARENT); - expect(fwd.getReceived(COLOR)).toEqual(0n); - }); - }); - - describe('depositUnshielded', () => { - beforeEach(() => { - fwd = new ForwarderUnshieldedSimulator(PARENT); - }); - - it('should accumulate _received[color] on depositUnshielded', () => { - fwd.depositUnshielded(COLOR, AMOUNT); - expect(fwd.getReceived(COLOR)).toEqual(AMOUNT); - }); - - it('should fail depositUnshielded with "Forwarder: received overflow" at MAX', () => { - fwd.depositUnshielded(COLOR, MAX_U128); - expect(() => fwd.depositUnshielded(COLOR, 1n)).toThrow( - 'Forwarder: received overflow', - ); - }); - - it('should not modify _parent after depositUnshielded', () => { - fwd.depositUnshielded(COLOR, AMOUNT); - expect(fwd.getParent()).toEqual(PARENT); - }); - - it('should track received per color independently on unshielded deposits', () => { - fwd.depositUnshielded(COLOR, AMOUNT); - fwd.depositUnshielded(COLOR2, AMOUNT * 2n); - expect(fwd.getReceived(COLOR)).toEqual(AMOUNT); - expect(fwd.getReceived(COLOR2)).toEqual(AMOUNT * 2n); - }); - }); -}); 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..d7081904 --- /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 SALT = 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, salt: Uint8Array): Uint8Array { + return ForwarderPrivateSimulator.calculateParentCommitment(parent, salt); +} + +describe('ForwarderPrivate preset', () => { + it('should store the parentCommitment passed to the constructor', () => { + const c = commitment(PARENT, SALT); + const fwd = new ForwarderPrivateSimulator(c); + expect(fwd.getParentCommitment()).toEqual(c); + }); + + it('should expose deposit and forward to _deposit', () => { + const fwd = new ForwarderPrivateSimulator(commitment(PARENT, SALT)); + expect(() => fwd.deposit(makeCoin(COLOR, AMOUNT))).not.toThrow(); + }); + + it('should expose drain and forward to _drain', () => { + const fwd = new ForwarderPrivateSimulator(commitment(PARENT, SALT)); + fwd.deposit(makeCoin(COLOR, AMOUNT)); + const result = fwd.drain( + makeQualifiedCoin(COLOR, AMOUNT, 0n), + PARENT, + SALT, + AMOUNT, + ); + expect(result.sent.value).toEqual(AMOUNT); + }); + + it('should expose calculateParentCommitment as a static pure helper', () => { + const c1 = commitment(PARENT, SALT); + const c2 = commitment(PARENT, SALT); + 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, SALT)); + 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..6c028dca --- /dev/null +++ b/contracts/src/multisig/test/presets/ForwarderShielded.test.ts @@ -0,0 +1,40 @@ +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); + fwd.deposit(makeCoin(COLOR, AMOUNT)); + expect(fwd.getReceived(COLOR)).toEqual(AMOUNT); + }); + + it('should expose getReceived and return 0 for unknown color', () => { + const fwd = new ForwarderShieldedSimulator(PARENT); + expect(fwd.getReceived(COLOR)).toEqual(0n); + }); + + 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..13d53917 --- /dev/null +++ b/contracts/src/multisig/test/presets/ForwarderUnshielded.test.ts @@ -0,0 +1,36 @@ +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); + fwd.depositUnshielded(COLOR, AMOUNT); + expect(fwd.getReceived(COLOR)).toEqual(AMOUNT); + }); + + it('should expose getReceived and return 0 for unknown color', () => { + const fwd = new ForwarderUnshieldedSimulator(PARENT); + expect(fwd.getReceived(COLOR)).toEqual(0n); + }); + + 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/ForwarderPrivateSimulator.ts b/contracts/src/multisig/test/simulators/presets/ForwarderPrivateSimulator.ts similarity index 90% rename from contracts/src/multisig/test/simulators/ForwarderPrivateSimulator.ts rename to contracts/src/multisig/test/simulators/presets/ForwarderPrivateSimulator.ts index 2934d84a..3f72ddea 100644 --- a/contracts/src/multisig/test/simulators/ForwarderPrivateSimulator.ts +++ b/contracts/src/multisig/test/simulators/presets/ForwarderPrivateSimulator.ts @@ -6,11 +6,11 @@ import { ledger, pureCircuits, Contract as ForwarderPrivate, -} from '../../../../artifacts/ForwarderPrivate/contract/index.js'; +} from '../../../../../artifacts/ForwarderPrivate/contract/index.js'; import { ForwarderPrivatePrivateState, ForwarderPrivateWitnesses, -} from '../../witnesses/ForwarderPrivateWitnesses.js'; +} from '../../../witnesses/presets/ForwarderPrivateWitnesses.js'; type ShieldedCoinInfo = { nonce: Uint8Array; color: Uint8Array; value: bigint }; type QualifiedShieldedCoinInfo = { @@ -56,7 +56,7 @@ export class ForwarderPrivateSimulator extends ForwarderPrivateSimulatorBase { parentAddr: Uint8Array, salt: Uint8Array, ): Uint8Array { - return pureCircuits._calculateParentCommitment(parentAddr, salt); + return pureCircuits.calculateParentCommitment(parentAddr, salt); } public deposit(coin: ShieldedCoinInfo) { diff --git a/contracts/src/multisig/test/simulators/ForwarderShieldedSimulator.ts b/contracts/src/multisig/test/simulators/presets/ForwarderShieldedSimulator.ts similarity index 91% rename from contracts/src/multisig/test/simulators/ForwarderShieldedSimulator.ts rename to contracts/src/multisig/test/simulators/presets/ForwarderShieldedSimulator.ts index 57e11a42..b0a9f9a8 100644 --- a/contracts/src/multisig/test/simulators/ForwarderShieldedSimulator.ts +++ b/contracts/src/multisig/test/simulators/presets/ForwarderShieldedSimulator.ts @@ -5,11 +5,11 @@ import { import { ledger, Contract as ForwarderShielded, -} from '../../../../artifacts/ForwarderShielded/contract/index.js'; +} from '../../../../../artifacts/ForwarderShielded/contract/index.js'; import { ForwarderShieldedPrivateState, ForwarderShieldedWitnesses, -} from '../../witnesses/ForwarderShieldedWitnesses.js'; +} from '../../../witnesses/presets/ForwarderShieldedWitnesses.js'; type ShieldedCoinInfo = { nonce: Uint8Array; color: Uint8Array; value: bigint }; diff --git a/contracts/src/multisig/test/simulators/ForwarderUnshieldedSimulator.ts b/contracts/src/multisig/test/simulators/presets/ForwarderUnshieldedSimulator.ts similarity index 91% rename from contracts/src/multisig/test/simulators/ForwarderUnshieldedSimulator.ts rename to contracts/src/multisig/test/simulators/presets/ForwarderUnshieldedSimulator.ts index c77d5b9b..2075c4e8 100644 --- a/contracts/src/multisig/test/simulators/ForwarderUnshieldedSimulator.ts +++ b/contracts/src/multisig/test/simulators/presets/ForwarderUnshieldedSimulator.ts @@ -5,11 +5,11 @@ import { import { ledger, Contract as ForwarderUnshielded, -} from '../../../../artifacts/ForwarderUnshielded/contract/index.js'; +} from '../../../../../artifacts/ForwarderUnshielded/contract/index.js'; import { ForwarderUnshieldedPrivateState, ForwarderUnshieldedWitnesses, -} from '../../witnesses/ForwarderUnshieldedWitnesses.js'; +} from '../../../witnesses/presets/ForwarderUnshieldedWitnesses.js'; type ForwarderUnshieldedArgs = readonly [parent: Uint8Array]; diff --git a/contracts/src/multisig/witnesses/ForwarderPrivateWitnesses.ts b/contracts/src/multisig/witnesses/presets/ForwarderPrivateWitnesses.ts similarity index 100% rename from contracts/src/multisig/witnesses/ForwarderPrivateWitnesses.ts rename to contracts/src/multisig/witnesses/presets/ForwarderPrivateWitnesses.ts diff --git a/contracts/src/multisig/witnesses/ForwarderShieldedWitnesses.ts b/contracts/src/multisig/witnesses/presets/ForwarderShieldedWitnesses.ts similarity index 100% rename from contracts/src/multisig/witnesses/ForwarderShieldedWitnesses.ts rename to contracts/src/multisig/witnesses/presets/ForwarderShieldedWitnesses.ts diff --git a/contracts/src/multisig/witnesses/ForwarderUnshieldedWitnesses.ts b/contracts/src/multisig/witnesses/presets/ForwarderUnshieldedWitnesses.ts similarity index 100% rename from contracts/src/multisig/witnesses/ForwarderUnshieldedWitnesses.ts rename to contracts/src/multisig/witnesses/presets/ForwarderUnshieldedWitnesses.ts From 96c1b9c6135a5a55957ccd88cb608ba05be02d1e Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Thu, 28 May 2026 12:12:24 +0200 Subject: [PATCH 07/16] feat(multisig): add zero-address + domain-separation guards in Forwarder modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR #526 review feedback: - `Forwarder._init` rejects `parent == default>`; a zero recipient would forward every deposit to an unspendable address with no recovery path. - `ForwarderPrivate._init` rejects a zero parentCommitment; since the commitment is the sole drain gate, a zero value would lock every accumulated coin permanently. - `ForwarderPrivate._calculateParentCommitment` now hashes `[pad(32, "ForwarderPrivate:commitment"), parentAddr, salt]` instead of `[parentAddr, salt]`. Domain tag prevents preimage collisions with other `persistentHash` users in the system. - `_drain` `@param salt` carries a prominent `@warning` block: salt loss is permanent fund loss (no rotation, revocation, or recovery path). Same warning mirrored on the preset's `drain` wrapper. - Preset `_calculateParentCommitment` re-export renamed to `calculateParentCommitment` (no leading underscore); the `_` prefix is reserved for module-internal helpers in this codebase. - `@circuitInfo` for `_drain` bumped 47778 → 47811 rows after the Vector<2> → Vector<3> commitment-input shape change. --- contracts/src/multisig/Forwarder.compact | 4 +++ .../src/multisig/ForwarderPrivate.compact | 31 ++++++++++++++++--- .../forwarder/ForwarderPrivate.compact | 19 +++++++++--- 3 files changed, 46 insertions(+), 8 deletions(-) diff --git a/contracts/src/multisig/Forwarder.compact b/contracts/src/multisig/Forwarder.compact index 4a721e54..cbd9d0b4 100644 --- a/contracts/src/multisig/Forwarder.compact +++ b/contracts/src/multisig/Forwarder.compact @@ -41,12 +41,16 @@ module Forwarder { * 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 {Bytes<32>} parent - The parent address. * * @returns {[]} Empty tuple. */ export circuit _init(parent: Bytes<32>): [] { + assert(parent != default>, "Forwarder: zero parent"); Initializable_initialize(); _parent = disclose(parent); } diff --git a/contracts/src/multisig/ForwarderPrivate.compact b/contracts/src/multisig/ForwarderPrivate.compact index 2c0e9f04..4890fedf 100644 --- a/contracts/src/multisig/ForwarderPrivate.compact +++ b/contracts/src/multisig/ForwarderPrivate.compact @@ -43,13 +43,19 @@ module ForwarderPrivate { * 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 - `persistentHash([parentAddr, salt])` + * @param {Bytes<32>} parentCommitment - Domain-tagged + * `persistentHash([pad(32, "ForwarderPrivate:commitment"), parentAddr, salt])` * computed off-chain by the deployer (see `_calculateParentCommitment`). * * @returns {[]} Empty tuple. */ export circuit _init(parentCommitment: Bytes<32>): [] { + assert(parentCommitment != default>, "ForwarderPrivate: zero commitment"); Initializable_initialize(); _parentCommitment = disclose(parentCommitment); } @@ -83,7 +89,7 @@ module ForwarderPrivate { * 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=47778 + * @circuitInfo k=16, rows=47811 * * Requirements: * @@ -96,6 +102,15 @@ module ForwarderPrivate { * stored commitment. * @param {Bytes<32>} salt - The salt. Operational secret; never * appears on the public transcript. + * + * @warning **Salt loss is permanent fund loss.** The salt is the sole + * drain authorization. There is no rotation, revocation, or recovery + * path. If the operator misplaces the salt, every shielded coin + * accumulated at this contract is forever inaccessible — equivalent + * to losing a hot-wallet private key. Back the salt 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 @@ -138,15 +153,23 @@ module ForwarderPrivate { * 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>} salt - The salt. * - * @returns {Bytes<32>} `persistentHash([parentAddr, salt])`. + * @returns {Bytes<32>} `persistentHash([pad(32, "ForwarderPrivate:commitment"), parentAddr, salt])`. */ export pure circuit _calculateParentCommitment( parentAddr: Bytes<32>, salt: Bytes<32> ): Bytes<32> { - return persistentHash>>([parentAddr, salt]); + return persistentHash>>( + [pad(32, "ForwarderPrivate:commitment"), parentAddr, salt] + ); } } diff --git a/contracts/src/multisig/presets/forwarder/ForwarderPrivate.compact b/contracts/src/multisig/presets/forwarder/ForwarderPrivate.compact index c6658702..773a7795 100644 --- a/contracts/src/multisig/presets/forwarder/ForwarderPrivate.compact +++ b/contracts/src/multisig/presets/forwarder/ForwarderPrivate.compact @@ -33,7 +33,7 @@ import { /** * @description Deploys the forwarder bound to a specific parent * commitment. The deployer computes the commitment off-chain as - * `_calculateParentCommitment(parentAddr, salt)` and passes it here. + * `calculateParentCommitment(parentAddr, salt)` and passes it here. * * @param {Bytes<32>} parentCommitment - The commitment to the * `(parentAddr, salt)` pair that the operator will present at drain. @@ -62,7 +62,7 @@ export circuit deposit(coin: ShieldedCoinInfo): [] { * * Requirements: * - * - `_calculateParentCommitment(parentAddr, salt)` must equal the stored + * - `calculateParentCommitment(parentAddr, salt)` must equal the stored * `_parentCommitment`. * - `coin.value` must be >= `value` (enforced by `sendShielded`). * @@ -71,6 +71,13 @@ export circuit deposit(coin: ShieldedCoinInfo): [] { * stored commitment. * @param {Bytes<32>} salt - The salt. Operational secret; never appears * on the public transcript. + * + * @warning **Salt loss is permanent fund loss.** Salt is the sole drain + * authorization. No rotation, revocation, or recovery path exists. If + * the operator loses the salt, every shielded coin accumulated at this + * contract becomes inaccessible. Back the salt up offline before the + * first deposit. + * * @param {Uint<128>} value - The amount to send. * * @returns {ShieldedSendResult} The result containing the sent coin and @@ -99,12 +106,16 @@ export circuit getParentCommitment(): Bytes<32> { * 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>} salt - The salt. * - * @returns {Bytes<32>} The commitment `persistentHash([parentAddr, salt])`. + * @returns {Bytes<32>} The commitment `persistentHash([pad(32, "ForwarderPrivate:commitment"), parentAddr, salt])`. */ -export pure circuit _calculateParentCommitment( +export pure circuit calculateParentCommitment( parentAddr: Bytes<32>, salt: Bytes<32> ): Bytes<32> { From 81e119ee155c72b75adffaef9312b44576b7ffc0 Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Thu, 28 May 2026 12:13:32 +0200 Subject: [PATCH 08/16] test(multisig): add module-level Forwarder tests via Mock fixtures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR #526 review feedback on missing double-init / init-guard coverage. The preset constructors are the only init entry on the shipping API, so this layer cannot drive a second `_init` call without re-exposing the underscore-prefixed circuit. Following the Signer/MockSigner convention, two mock contracts wrap the modules directly and expose `initialize`, `deposit*`, `drain`, etc. as test-only circuits: - MockForwarder.compact exposes the public Forwarder module - MockForwarderPrivate.compact exposes the private one The mocks take an `isInit` constructor flag (false to test the not-initialized path; true then `initialize(...)` for double-init). Empty witness stubs and `createSimulator`-based wrappers are added alongside. New module-level test files exercise behaviour previously covered in the preset tests: - test/Forwarder.test.ts — init guards (zero-parent, double-init, late-init), assertInitialized firing on every state-touching circuit, `_recordReceived` accumulation via both deposit paths, Uint<128> overflow on the unshielded path, Uint<64> Zswap cap on the shielded path, property-based accumulation. - test/ForwarderPrivate.test.ts — init guards (zero-commitment, double-init), assertInitialized, `calculateParentCommitment` purity + unlinkability, drain happy/failure paths, change arithmetic on partial drains, property-based change arithmetic. 971 / 971 tests pass. --- contracts/src/multisig/test/Forwarder.test.ts | 179 +++++++++++++ .../multisig/test/ForwarderPrivate.test.ts | 249 ++++++++++++++++++ .../multisig/test/mocks/MockForwarder.compact | 43 +++ .../test/mocks/MockForwarderPrivate.compact | 51 ++++ .../MockForwarderPrivateSimulator.ts | 82 ++++++ .../test/simulators/MockForwarderSimulator.ts | 60 +++++ .../MockForwarderPrivateWitnesses.ts | 6 + .../witnesses/MockForwarderWitnesses.ts | 6 + 8 files changed, 676 insertions(+) create mode 100644 contracts/src/multisig/test/Forwarder.test.ts create mode 100644 contracts/src/multisig/test/ForwarderPrivate.test.ts create mode 100644 contracts/src/multisig/test/mocks/MockForwarder.compact create mode 100644 contracts/src/multisig/test/mocks/MockForwarderPrivate.compact create mode 100644 contracts/src/multisig/test/simulators/MockForwarderPrivateSimulator.ts create mode 100644 contracts/src/multisig/test/simulators/MockForwarderSimulator.ts create mode 100644 contracts/src/multisig/witnesses/MockForwarderPrivateWitnesses.ts create mode 100644 contracts/src/multisig/witnesses/MockForwarderWitnesses.ts diff --git a/contracts/src/multisig/test/Forwarder.test.ts b/contracts/src/multisig/test/Forwarder.test.ts new file mode 100644 index 00000000..19addef0 --- /dev/null +++ b/contracts/src/multisig/test/Forwarder.test.ts @@ -0,0 +1,179 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import fc from 'fast-check'; +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 COLOR2 = new Uint8Array(32).fill(2); +const AMOUNT = 1000n; +const MAX_U32 = (1n << 32n) - 1n; +const MAX_U64 = (1n << 64n) - 1n; +const MAX_U128 = (1n << 128n) - 1n; + +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', () => { + const mock = new MockForwarderSimulator(PARENT, true); + expect(mock.getReceived(COLOR)).toEqual(0n); + }); + + it('should fail initialization with zero parent', () => { + expect(() => new MockForwarderSimulator(ZERO, true)).toThrow( + 'Forwarder: zero parent', + ); + }); + + it('should fail when initialized twice', () => { + const mock = new MockForwarderSimulator(PARENT, true); + expect(() => mock.initialize(PARENT)).toThrow( + 'Initializable: contract already initialized', + ); + }); + + it('should allow late initialize when isInit is false', () => { + const mock = new MockForwarderSimulator(PARENT, false); + expect(() => mock.initialize(PARENT)).not.toThrow(); + expect(mock.getReceived(COLOR)).toEqual(0n); + }); + + 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', + ); + }); + + it('should fail getReceived when not initialized', () => { + expect(() => mock.getReceived(COLOR)).toThrow( + 'Initializable: contract not initialized', + ); + }); + }); + + describe('_recordReceived via depositShielded', () => { + let mock: MockForwarderSimulator; + + beforeEach(() => { + mock = new MockForwarderSimulator(PARENT, true); + }); + + it('should accumulate _received[color] on a single shielded deposit', () => { + mock.depositShielded(makeCoin(COLOR, AMOUNT)); + expect(mock.getReceived(COLOR)).toEqual(AMOUNT); + }); + + it('should track received per color independently for shielded deposits', () => { + mock.depositShielded(makeCoin(COLOR, AMOUNT)); + mock.depositShielded(makeCoin(COLOR2, AMOUNT * 2n)); + expect(mock.getReceived(COLOR)).toEqual(AMOUNT); + expect(mock.getReceived(COLOR2)).toEqual(AMOUNT * 2n); + }); + + it('should accumulate sequential shielded deposits to the same color', () => { + mock.depositShielded(makeCoin(COLOR, AMOUNT, new Uint8Array(32).fill(1))); + mock.depositShielded(makeCoin(COLOR, AMOUNT, new Uint8Array(32).fill(2))); + mock.depositShielded(makeCoin(COLOR, AMOUNT, new Uint8Array(32).fill(3))); + expect(mock.getReceived(COLOR)).toEqual(AMOUNT * 3n); + }); + + // The `_recordReceived` overflow assert targets `Uint<128>`. Zswap + // bounds `ShieldedCoinInfo.value` at `Uint<64>`, so the `Uint<128>` + // ceiling cannot be reached by shielded deposits in finite test + // runs. The unshielded path (Uint<128> amounts) exercises the assert + // directly below. + it('should reject shielded deposit when coin.value exceeds Uint<64>', () => { + expect(() => + mock.depositShielded(makeCoin(COLOR, MAX_U128)), + ).toThrow(); + }); + + it('should accept shielded deposit at the Uint<64> ceiling', () => { + mock.depositShielded(makeCoin(COLOR, MAX_U64)); + expect(mock.getReceived(COLOR)).toEqual(MAX_U64); + }); + }); + + describe('_recordReceived via depositUnshielded', () => { + let mock: MockForwarderSimulator; + + beforeEach(() => { + mock = new MockForwarderSimulator(PARENT, true); + }); + + it('should accumulate _received[color] on a single unshielded deposit', () => { + mock.depositUnshielded(COLOR, AMOUNT); + expect(mock.getReceived(COLOR)).toEqual(AMOUNT); + }); + + it('should track received per color independently for unshielded deposits', () => { + mock.depositUnshielded(COLOR, AMOUNT); + mock.depositUnshielded(COLOR2, AMOUNT * 2n); + expect(mock.getReceived(COLOR)).toEqual(AMOUNT); + expect(mock.getReceived(COLOR2)).toEqual(AMOUNT * 2n); + }); + + it('should fail depositUnshielded with "Forwarder: received overflow" at MAX', () => { + mock.depositUnshielded(COLOR, MAX_U128); + expect(() => mock.depositUnshielded(COLOR, 1n)).toThrow( + 'Forwarder: received overflow', + ); + }); + }); + + describe('property: accumulation', () => { + it( + 'should accumulate _received as the sum of shielded deposits', + { timeout: 30_000 }, + () => { + fc.assert( + fc.property( + fc.array(fc.bigInt({ min: 0n, max: MAX_U32 }), { + minLength: 1, + maxLength: 4, + }), + (values) => { + const mock = new MockForwarderSimulator(PARENT, true); + let i = 0; + for (const v of values) { + mock.depositShielded( + makeCoin(COLOR, v, new Uint8Array(32).fill(i++)), + ); + } + const expected = values.reduce((acc, v) => acc + v, 0n); + expect(mock.getReceived(COLOR)).toEqual(expected); + }, + ), + { numRuns: 20 }, + ); + }, + ); + }); +}); diff --git a/contracts/src/multisig/test/ForwarderPrivate.test.ts b/contracts/src/multisig/test/ForwarderPrivate.test.ts new file mode 100644 index 00000000..6938cf56 --- /dev/null +++ b/contracts/src/multisig/test/ForwarderPrivate.test.ts @@ -0,0 +1,249 @@ +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 SALT = new Uint8Array(32).fill(0xaa); +const WRONG_SALT = 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, salt: Uint8Array): Uint8Array { + return MockForwarderPrivateSimulator.calculateParentCommitment(parent, salt); +} + +describe('ForwarderPrivate module', () => { + describe('initialization', () => { + it('should initialize on construction when isInit is true', () => { + const c = commitment(PARENT, SALT); + 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 fail when initialized twice', () => { + const c = commitment(PARENT, SALT); + const mock = new MockForwarderPrivateSimulator(c, true); + expect(() => mock.initialize(c)).toThrow( + 'Initializable: contract already initialized', + ); + }); + + it('should allow late initialize when isInit is false', () => { + const c = commitment(PARENT, SALT); + const mock = new MockForwarderPrivateSimulator(c, false); + expect(() => mock.initialize(c)).not.toThrow(); + }); + + it('should expose the public ledger state after initialization', () => { + const c = commitment(PARENT, SALT); + const mock = new MockForwarderPrivateSimulator(c, true); + expect(mock.getPublicState()).toBeDefined(); + }); + }); + + describe('init guard', () => { + let mock: MockForwarderPrivateSimulator; + + beforeEach(() => { + mock = new MockForwarderPrivateSimulator(commitment(PARENT, SALT), 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, SALT, AMOUNT), + ).toThrow('Initializable: contract not initialized'); + }); + }); + + describe('calculateParentCommitment', () => { + it('should produce the same commitment for the same (parent, salt)', () => { + const c1 = commitment(PARENT, SALT); + const c2 = commitment(PARENT, SALT); + expect(c1).toEqual(c2); + }); + + it('should produce different commitments for different salts', () => { + 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, SALT), true); + mock.deposit(makeCoin(COLOR, AMOUNT)); + }); + + it('should succeed drain with correct (parentAddr, salt)', () => { + const result = mock.drain( + makeQualifiedCoin(COLOR, AMOUNT, 0n), + PARENT, + SALT, + AMOUNT, + ); + expect(result.sent.value).toEqual(AMOUNT); + }); + + it('should fail drain with wrong parentAddr', () => { + expect(() => + mock.drain( + makeQualifiedCoin(COLOR, AMOUNT, 0n), + WRONG_PARENT, + SALT, + AMOUNT, + ), + ).toThrow('ForwarderPrivate: invalid parent'); + }); + + it('should fail drain with wrong salt', () => { + expect(() => + mock.drain( + makeQualifiedCoin(COLOR, AMOUNT, 0n), + PARENT, + WRONG_SALT, + AMOUNT, + ), + ).toThrow('ForwarderPrivate: invalid parent'); + }); + + it('should fail drain with both wrong', () => { + expect(() => + mock.drain( + makeQualifiedCoin(COLOR, AMOUNT, 0n), + WRONG_PARENT, + WRONG_SALT, + AMOUNT, + ), + ).toThrow('ForwarderPrivate: invalid parent'); + }); + + it('should fail drain with value > coin.value', () => { + expect(() => + mock.drain( + makeQualifiedCoin(COLOR, AMOUNT, 0n), + PARENT, + SALT, + AMOUNT + 1n, + ), + ).toThrow(); + }); + + it('should produce no change when drain value equals coin value', () => { + const result = mock.drain( + makeQualifiedCoin(COLOR, AMOUNT, 0n), + PARENT, + SALT, + 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, + SALT, + 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, + SALT, + 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, SALT), + true, + ); + mock.deposit(makeCoin(COLOR, coinVal)); + const result = mock.drain( + makeQualifiedCoin(COLOR, coinVal, 0n), + PARENT, + SALT, + 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..b93b2720 --- /dev/null +++ b/contracts/src/multisig/test/mocks/MockForwarder.compact @@ -0,0 +1,43 @@ +// 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 { + _init, + _depositShielded, + _depositUnshielded, + _getReceived +} from "../../Forwarder" prefix Forwarder_; + +/** + * @description Test fixture exposing the public Forwarder module's + * underscore-prefixed circuits directly. `isInit` controls whether the + * constructor auto-initializes — set to `false` to test the + * not-initialized path; set to `true` and re-call `initialize` to test + * the double-initialization revert. + * + * DO NOT USE IN PRODUCTION. + */ +constructor(parent: Bytes<32>, isInit: Boolean) { + if (disclose(isInit)) { + Forwarder__init(parent); + } +} + +export circuit initialize(parent: Bytes<32>): [] { + return Forwarder__init(parent); +} + +export circuit depositShielded(coin: ShieldedCoinInfo): [] { + return Forwarder__depositShielded(coin); +} + +export circuit depositUnshielded(color: Bytes<32>, amount: Uint<128>): [] { + return Forwarder__depositUnshielded(color, amount); +} + +export circuit getReceived(color: Bytes<32>): Uint<128> { + return Forwarder__getReceived(color); +} diff --git a/contracts/src/multisig/test/mocks/MockForwarderPrivate.compact b/contracts/src/multisig/test/mocks/MockForwarderPrivate.compact new file mode 100644 index 00000000..c2491e79 --- /dev/null +++ b/contracts/src/multisig/test/mocks/MockForwarderPrivate.compact @@ -0,0 +1,51 @@ +// 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 { + _init, + _deposit, + _drain, + _calculateParentCommitment +} from "../../ForwarderPrivate" prefix ForwarderPrivate_; + +/** + * @description Test fixture exposing the private ForwarderPrivate + * module's underscore-prefixed circuits directly. `isInit` controls + * whether the constructor auto-initializes — set to `false` to test the + * not-initialized path; set to `true` and re-call `initialize` to test + * the double-initialization revert. + * + * DO NOT USE IN PRODUCTION. + */ +constructor(parentCommitment: Bytes<32>, isInit: Boolean) { + if (disclose(isInit)) { + ForwarderPrivate__init(parentCommitment); + } +} + +export circuit initialize(parentCommitment: Bytes<32>): [] { + return ForwarderPrivate__init(parentCommitment); +} + +export circuit deposit(coin: ShieldedCoinInfo): [] { + return ForwarderPrivate__deposit(coin); +} + +export circuit drain( + coin: QualifiedShieldedCoinInfo, + parentAddr: Bytes<32>, + salt: Bytes<32>, + value: Uint<128> +): ShieldedSendResult { + return ForwarderPrivate__drain(coin, parentAddr, salt, value); +} + +export pure circuit calculateParentCommitment( + parentAddr: Bytes<32>, + salt: Bytes<32> +): Bytes<32> { + return ForwarderPrivate__calculateParentCommitment(parentAddr, salt); +} diff --git a/contracts/src/multisig/test/simulators/MockForwarderPrivateSimulator.ts b/contracts/src/multisig/test/simulators/MockForwarderPrivateSimulator.ts new file mode 100644 index 00000000..53704884 --- /dev/null +++ b/contracts/src/multisig/test/simulators/MockForwarderPrivateSimulator.ts @@ -0,0 +1,82 @@ +import { + type BaseSimulatorOptions, + createSimulator, +} from '@openzeppelin-compact/contracts-simulator'; +import { + ledger, + pureCircuits, + Contract as MockForwarderPrivate, +} from '../../../../artifacts/MockForwarderPrivate/contract/index.js'; +import { + MockForwarderPrivatePrivateState, + MockForwarderPrivateWitnesses, +} from '../../witnesses/MockForwarderPrivateWitnesses.js'; + +type ShieldedCoinInfo = { nonce: Uint8Array; color: Uint8Array; value: bigint }; +type QualifiedShieldedCoinInfo = { + nonce: Uint8Array; + color: Uint8Array; + value: bigint; + mt_index: bigint; +}; +type ShieldedSendResult = { + change: { is_some: boolean; value: ShieldedCoinInfo }; + sent: ShieldedCoinInfo; +}; + +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, + salt: Uint8Array, + ): Uint8Array { + return pureCircuits.calculateParentCommitment(parentAddr, salt); + } + + public initialize(parentCommitment: Uint8Array) { + return this.circuits.impure.initialize(parentCommitment); + } + + public deposit(coin: ShieldedCoinInfo) { + return this.circuits.impure.deposit(coin); + } + + public drain( + coin: QualifiedShieldedCoinInfo, + parentAddr: Uint8Array, + salt: Uint8Array, + value: bigint, + ): ShieldedSendResult { + return this.circuits.impure.drain(coin, parentAddr, salt, 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..d589c545 --- /dev/null +++ b/contracts/src/multisig/test/simulators/MockForwarderSimulator.ts @@ -0,0 +1,60 @@ +import { + type BaseSimulatorOptions, + createSimulator, +} from '@openzeppelin-compact/contracts-simulator'; +import { + ledger, + Contract as MockForwarder, +} from '../../../../artifacts/MockForwarder/contract/index.js'; +import { + MockForwarderPrivateState, + MockForwarderWitnesses, +} from '../../witnesses/MockForwarderWitnesses.js'; + +type ShieldedCoinInfo = { nonce: Uint8Array; color: Uint8Array; value: bigint }; + +type MockForwarderArgs = readonly [parent: Uint8Array, 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([parent, isInit], options); + } + + public initialize(parent: Uint8Array) { + return this.circuits.impure.initialize(parent); + } + + public depositShielded(coin: ShieldedCoinInfo) { + return this.circuits.impure.depositShielded(coin); + } + + public depositUnshielded(color: Uint8Array, amount: bigint) { + return this.circuits.impure.depositUnshielded(color, amount); + } + + public getReceived(color: Uint8Array): bigint { + return this.circuits.impure.getReceived(color); + } +} 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 = () => ({}); From 0b25b72316449c297f0759eb148c4c466f99ce29 Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Thu, 28 May 2026 12:14:16 +0200 Subject: [PATCH 09/16] build(contracts): broaden coverage globs and drop .compact from report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Simplify the coverage `include` to glob patterns that cover every compiled artifact instead of listing each Forwarder file by hand, and collapse the duplicated `**/` patterns in `witnesses/` and `simulators/`. Drop `.compact` files from the report after source-map remap (`excludeAfterRemap: true`). The compactc-emitted source map is function-entry granularity only (every statement inside a circuit collapses onto the circuit header line), so back-projecting v8 branch/line coverage to `.compact` produces misleading partial coverage on circuits that are fully exercised in tests — e.g. `ForwarderPrivate._drain`'s `if (disclose(result.change.is_some))` reports 50 % branches even though both legs run via the partial-drain and full-drain test cases plus the property suite. Coverage now tracks: every TS witness, every test simulator, every artifacts/*/contract/index.js shim. With this scope all Forwarder TS files hit 100 % lines / branches / functions / statements; the JS shim retains real v8 instrumentation for circuit execution. Upstream tracker for the source-map fidelity work: https://github.com/LFDT-Minokawa/compact/issues/465 --- contracts/vitest.config.ts | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/contracts/vitest.config.ts b/contracts/vitest.config.ts index d4d6d87e..aa4afd81 100644 --- a/contracts/vitest.config.ts +++ b/contracts/vitest.config.ts @@ -11,20 +11,23 @@ export default defineConfig({ provider: 'v8', reporter: ['text', 'html'], include: [ - 'src/**/witnesses/*.ts', - 'src/**/test/simulators/*.ts', - // Include compactc-generated JS so v8 can map executed lines - // back to .compact source via the index.js.map files. The - // forwarder presets are the focus here; expand as needed. - 'artifacts/ForwarderShielded/contract/index.js', - 'artifacts/ForwarderUnshielded/contract/index.js', - 'artifacts/ForwarderPrivate/contract/index.js', + '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', + '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 From fa3c31650b5cfad9e04c7e41a790fb9f5f0506a9 Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Thu, 28 May 2026 16:30:57 +0200 Subject: [PATCH 10/16] docs(multisig): fix path comment in preset witness files Header comment on the preset witness files still referenced the pre-rename location (multisig/witnesses/...) after the move to presets/. Add the missing presets/ segment so the comment matches the actual path. Raised by CodeRabbit on #526. --- .../src/multisig/witnesses/presets/ForwarderPrivateWitnesses.ts | 2 +- .../multisig/witnesses/presets/ForwarderShieldedWitnesses.ts | 2 +- .../multisig/witnesses/presets/ForwarderUnshieldedWitnesses.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/contracts/src/multisig/witnesses/presets/ForwarderPrivateWitnesses.ts b/contracts/src/multisig/witnesses/presets/ForwarderPrivateWitnesses.ts index a8f3bfdf..6bc2ab8a 100644 --- a/contracts/src/multisig/witnesses/presets/ForwarderPrivateWitnesses.ts +++ b/contracts/src/multisig/witnesses/presets/ForwarderPrivateWitnesses.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.0.1-alpha.1 (multisig/witnesses/ForwarderPrivateWitnesses.ts) +// OpenZeppelin Compact Contracts v0.0.1-alpha.1 (multisig/witnesses/presets/ForwarderPrivateWitnesses.ts) export type ForwarderPrivatePrivateState = Record; export const ForwarderPrivatePrivateState: ForwarderPrivatePrivateState = {}; diff --git a/contracts/src/multisig/witnesses/presets/ForwarderShieldedWitnesses.ts b/contracts/src/multisig/witnesses/presets/ForwarderShieldedWitnesses.ts index 620f5c81..0b65d0be 100644 --- a/contracts/src/multisig/witnesses/presets/ForwarderShieldedWitnesses.ts +++ b/contracts/src/multisig/witnesses/presets/ForwarderShieldedWitnesses.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.0.1-alpha.1 (multisig/witnesses/ForwarderShieldedWitnesses.ts) +// OpenZeppelin Compact Contracts v0.0.1-alpha.1 (multisig/witnesses/presets/ForwarderShieldedWitnesses.ts) export type ForwarderShieldedPrivateState = Record; export const ForwarderShieldedPrivateState: ForwarderShieldedPrivateState = {}; diff --git a/contracts/src/multisig/witnesses/presets/ForwarderUnshieldedWitnesses.ts b/contracts/src/multisig/witnesses/presets/ForwarderUnshieldedWitnesses.ts index b536c87d..c0860d2c 100644 --- a/contracts/src/multisig/witnesses/presets/ForwarderUnshieldedWitnesses.ts +++ b/contracts/src/multisig/witnesses/presets/ForwarderUnshieldedWitnesses.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.0.1-alpha.1 (multisig/witnesses/ForwarderUnshieldedWitnesses.ts) +// OpenZeppelin Compact Contracts v0.0.1-alpha.1 (multisig/witnesses/presets/ForwarderUnshieldedWitnesses.ts) export type ForwarderUnshieldedPrivateState = Record; export const ForwarderUnshieldedPrivateState: ForwarderUnshieldedPrivateState = {}; From 6641069193599a9bea9d5df988d9ab794dd8f2d6 Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Wed, 3 Jun 2026 11:24:59 +0200 Subject: [PATCH 11/16] refactor(multisig): apply forwarder review fixes Address three Forwarder review comments from #526: * Rename the module init circuit from `_init` to `initialize`, to match the rest of the library (FungibleToken, SignerManager). Touches both module circuits, the three preset constructors, and the mocks. * Mark `_parent` and `_parentCommitment` as `sealed` so the immutable parent is enforced by the compiler, not only the Initializable guard. Sealed forbids writes reachable from an exported circuit, so the mocks drop their re-exported `initialize` wrapper and the now-impossible late-init and double-init tests are removed. * Replace the brace-list imports in the forwarder presets and mocks with the bare `prefix` form used by the other multisig presets; the prefix already namespaces every binding. Refs: OpenZeppelin/compact-contracts#526 --- contracts/src/multisig/Forwarder.compact | 6 +++--- .../src/multisig/ForwarderPrivate.compact | 6 +++--- .../forwarder/ForwarderPrivate.compact | 10 ++-------- .../forwarder/ForwarderShielded.compact | 9 ++------- .../forwarder/ForwarderUnshielded.compact | 9 ++------- contracts/src/multisig/test/Forwarder.test.ts | 13 ------------ .../multisig/test/ForwarderPrivate.test.ts | 14 ------------- .../multisig/test/mocks/MockForwarder.compact | 19 +++++------------- .../test/mocks/MockForwarderPrivate.compact | 20 ++++++------------- .../MockForwarderPrivateSimulator.ts | 4 ---- .../test/simulators/MockForwarderSimulator.ts | 4 ---- 11 files changed, 23 insertions(+), 91 deletions(-) diff --git a/contracts/src/multisig/Forwarder.compact b/contracts/src/multisig/Forwarder.compact index cbd9d0b4..53875b7d 100644 --- a/contracts/src/multisig/Forwarder.compact +++ b/contracts/src/multisig/Forwarder.compact @@ -19,7 +19,7 @@ pragma language_version >= 0.21.0; * 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 `_init`. + * the module has been initialized via `initialize`. */ module Forwarder { import CompactStandardLibrary; @@ -28,7 +28,7 @@ module Forwarder { // ─── State ────────────────────────────────────────────────────── - export ledger _parent: Bytes<32>; + export sealed ledger _parent: Bytes<32>; export ledger _received: Map, Uint<128>>; // ─── Init ─────────────────────────────────────────────────────── @@ -49,7 +49,7 @@ module Forwarder { * * @returns {[]} Empty tuple. */ - export circuit _init(parent: Bytes<32>): [] { + export circuit initialize(parent: Bytes<32>): [] { assert(parent != default>, "Forwarder: zero parent"); Initializable_initialize(); _parent = disclose(parent); diff --git a/contracts/src/multisig/ForwarderPrivate.compact b/contracts/src/multisig/ForwarderPrivate.compact index 4890fedf..9a87fcbd 100644 --- a/contracts/src/multisig/ForwarderPrivate.compact +++ b/contracts/src/multisig/ForwarderPrivate.compact @@ -20,7 +20,7 @@ pragma language_version >= 0.21.0; * 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 `_init`. The pure + * module has been initialized via `initialize`. The pure * `_calculateParentCommitment` helper does not access state and is * intentionally callable without initialization. */ @@ -31,7 +31,7 @@ module ForwarderPrivate { // ─── State ────────────────────────────────────────────────────── - export ledger _parentCommitment: Bytes<32>; + export sealed ledger _parentCommitment: Bytes<32>; // ─── Init ─────────────────────────────────────────────────────── @@ -54,7 +54,7 @@ module ForwarderPrivate { * * @returns {[]} Empty tuple. */ - export circuit _init(parentCommitment: Bytes<32>): [] { + export circuit initialize(parentCommitment: Bytes<32>): [] { assert(parentCommitment != default>, "ForwarderPrivate: zero commitment"); Initializable_initialize(); _parentCommitment = disclose(parentCommitment); diff --git a/contracts/src/multisig/presets/forwarder/ForwarderPrivate.compact b/contracts/src/multisig/presets/forwarder/ForwarderPrivate.compact index 773a7795..3e19a4a2 100644 --- a/contracts/src/multisig/presets/forwarder/ForwarderPrivate.compact +++ b/contracts/src/multisig/presets/forwarder/ForwarderPrivate.compact @@ -22,13 +22,7 @@ pragma language_version >= 0.21.0; */ import CompactStandardLibrary; -import { - _init, - _deposit, - _drain, - _calculateParentCommitment, - _parentCommitment -} from "../../ForwarderPrivate" prefix ForwarderPrivate_; +import "../../ForwarderPrivate" prefix ForwarderPrivate_; /** * @description Deploys the forwarder bound to a specific parent @@ -39,7 +33,7 @@ import { * `(parentAddr, salt)` pair that the operator will present at drain. */ constructor(parentCommitment: Bytes<32>) { - ForwarderPrivate__init(parentCommitment); + ForwarderPrivate_initialize(parentCommitment); } /** diff --git a/contracts/src/multisig/presets/forwarder/ForwarderShielded.compact b/contracts/src/multisig/presets/forwarder/ForwarderShielded.compact index c22751de..bfca9cf0 100644 --- a/contracts/src/multisig/presets/forwarder/ForwarderShielded.compact +++ b/contracts/src/multisig/presets/forwarder/ForwarderShielded.compact @@ -16,12 +16,7 @@ pragma language_version >= 0.21.0; */ import CompactStandardLibrary; -import { - _init, - _depositShielded, - _getReceived, - _parent -} from "../../Forwarder" prefix Forwarder_; +import "../../Forwarder" prefix Forwarder_; /** * @description Deploys the forwarder bound to a specific parent address. @@ -30,7 +25,7 @@ import { * forwarded coin. Wrapped as a `ContractAddress` at the send site. */ constructor(parent: Bytes<32>) { - Forwarder__init(parent); + Forwarder_initialize(parent); } /** diff --git a/contracts/src/multisig/presets/forwarder/ForwarderUnshielded.compact b/contracts/src/multisig/presets/forwarder/ForwarderUnshielded.compact index f91fadb7..c0a9a94b 100644 --- a/contracts/src/multisig/presets/forwarder/ForwarderUnshielded.compact +++ b/contracts/src/multisig/presets/forwarder/ForwarderUnshielded.compact @@ -16,12 +16,7 @@ pragma language_version >= 0.21.0; */ import CompactStandardLibrary; -import { - _init, - _depositUnshielded, - _getReceived, - _parent -} from "../../Forwarder" prefix Forwarder_; +import "../../Forwarder" prefix Forwarder_; /** * @description Deploys the forwarder bound to a specific parent address. @@ -30,7 +25,7 @@ import { * forwarded amount. */ constructor(parent: Bytes<32>) { - Forwarder__init(parent); + Forwarder_initialize(parent); } /** diff --git a/contracts/src/multisig/test/Forwarder.test.ts b/contracts/src/multisig/test/Forwarder.test.ts index 19addef0..e8af2cef 100644 --- a/contracts/src/multisig/test/Forwarder.test.ts +++ b/contracts/src/multisig/test/Forwarder.test.ts @@ -33,19 +33,6 @@ describe('Forwarder module', () => { ); }); - it('should fail when initialized twice', () => { - const mock = new MockForwarderSimulator(PARENT, true); - expect(() => mock.initialize(PARENT)).toThrow( - 'Initializable: contract already initialized', - ); - }); - - it('should allow late initialize when isInit is false', () => { - const mock = new MockForwarderSimulator(PARENT, false); - expect(() => mock.initialize(PARENT)).not.toThrow(); - expect(mock.getReceived(COLOR)).toEqual(0n); - }); - it('should expose the public ledger state after initialization', () => { const mock = new MockForwarderSimulator(PARENT, true); expect(mock.getPublicState()).toBeDefined(); diff --git a/contracts/src/multisig/test/ForwarderPrivate.test.ts b/contracts/src/multisig/test/ForwarderPrivate.test.ts index 6938cf56..094ffa53 100644 --- a/contracts/src/multisig/test/ForwarderPrivate.test.ts +++ b/contracts/src/multisig/test/ForwarderPrivate.test.ts @@ -52,20 +52,6 @@ describe('ForwarderPrivate module', () => { ); }); - it('should fail when initialized twice', () => { - const c = commitment(PARENT, SALT); - const mock = new MockForwarderPrivateSimulator(c, true); - expect(() => mock.initialize(c)).toThrow( - 'Initializable: contract already initialized', - ); - }); - - it('should allow late initialize when isInit is false', () => { - const c = commitment(PARENT, SALT); - const mock = new MockForwarderPrivateSimulator(c, false); - expect(() => mock.initialize(c)).not.toThrow(); - }); - it('should expose the public ledger state after initialization', () => { const c = commitment(PARENT, SALT); const mock = new MockForwarderPrivateSimulator(c, true); diff --git a/contracts/src/multisig/test/mocks/MockForwarder.compact b/contracts/src/multisig/test/mocks/MockForwarder.compact index b93b2720..17f41b66 100644 --- a/contracts/src/multisig/test/mocks/MockForwarder.compact +++ b/contracts/src/multisig/test/mocks/MockForwarder.compact @@ -4,32 +4,23 @@ pragma language_version >= 0.21.0; import CompactStandardLibrary; -import { - _init, - _depositShielded, - _depositUnshielded, - _getReceived -} from "../../Forwarder" prefix Forwarder_; +import "../../Forwarder" prefix Forwarder_; /** * @description Test fixture exposing the public Forwarder module's * underscore-prefixed circuits directly. `isInit` controls whether the - * constructor auto-initializes — set to `false` to test the - * not-initialized path; set to `true` and re-call `initialize` to test - * the double-initialization revert. + * 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: Bytes<32>, isInit: Boolean) { if (disclose(isInit)) { - Forwarder__init(parent); + Forwarder_initialize(parent); } } -export circuit initialize(parent: Bytes<32>): [] { - return Forwarder__init(parent); -} - export circuit depositShielded(coin: ShieldedCoinInfo): [] { return Forwarder__depositShielded(coin); } diff --git a/contracts/src/multisig/test/mocks/MockForwarderPrivate.compact b/contracts/src/multisig/test/mocks/MockForwarderPrivate.compact index c2491e79..85fec9ba 100644 --- a/contracts/src/multisig/test/mocks/MockForwarderPrivate.compact +++ b/contracts/src/multisig/test/mocks/MockForwarderPrivate.compact @@ -4,32 +4,24 @@ pragma language_version >= 0.21.0; import CompactStandardLibrary; -import { - _init, - _deposit, - _drain, - _calculateParentCommitment -} from "../../ForwarderPrivate" prefix ForwarderPrivate_; +import "../../ForwarderPrivate" prefix ForwarderPrivate_; /** * @description Test fixture exposing the private ForwarderPrivate * module's underscore-prefixed circuits directly. `isInit` controls - * whether the constructor auto-initializes — set to `false` to test the - * not-initialized path; set to `true` and re-call `initialize` to test - * the double-initialization revert. + * 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__init(parentCommitment); + ForwarderPrivate_initialize(parentCommitment); } } -export circuit initialize(parentCommitment: Bytes<32>): [] { - return ForwarderPrivate__init(parentCommitment); -} - export circuit deposit(coin: ShieldedCoinInfo): [] { return ForwarderPrivate__deposit(coin); } diff --git a/contracts/src/multisig/test/simulators/MockForwarderPrivateSimulator.ts b/contracts/src/multisig/test/simulators/MockForwarderPrivateSimulator.ts index 53704884..19b2b9a9 100644 --- a/contracts/src/multisig/test/simulators/MockForwarderPrivateSimulator.ts +++ b/contracts/src/multisig/test/simulators/MockForwarderPrivateSimulator.ts @@ -63,10 +63,6 @@ export class MockForwarderPrivateSimulator extends MockForwarderPrivateSimulator return pureCircuits.calculateParentCommitment(parentAddr, salt); } - public initialize(parentCommitment: Uint8Array) { - return this.circuits.impure.initialize(parentCommitment); - } - public deposit(coin: ShieldedCoinInfo) { return this.circuits.impure.deposit(coin); } diff --git a/contracts/src/multisig/test/simulators/MockForwarderSimulator.ts b/contracts/src/multisig/test/simulators/MockForwarderSimulator.ts index d589c545..14582d07 100644 --- a/contracts/src/multisig/test/simulators/MockForwarderSimulator.ts +++ b/contracts/src/multisig/test/simulators/MockForwarderSimulator.ts @@ -42,10 +42,6 @@ export class MockForwarderSimulator extends MockForwarderSimulatorBase { super([parent, isInit], options); } - public initialize(parent: Uint8Array) { - return this.circuits.impure.initialize(parent); - } - public depositShielded(coin: ShieldedCoinInfo) { return this.circuits.impure.depositShielded(coin); } From 9bce9d5ea47816dd4328517a88bce8cf17d5d760 Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Wed, 3 Jun 2026 11:47:36 +0200 Subject: [PATCH 12/16] refactor(multisig): remove forwarder _received counter The public Forwarder kept a per-color `_received` cumulative total on the public ledger. It is redundant (an indexer can sum deposits off-chain), does not aggregate across child forwarders, and on the shielded path it leaked aggregate volume per color. BitGo forwarders likewise keep no on-chain counter. Remove `_received` and its machinery: the `_recordReceived` overflow guard, `_getReceived`, the `UINT128_MAX` import, and the preset/mock `getReceived` wrappers. Deposit circuits now only receive and forward. Refresh the deposit `@circuitInfo` rows (k unchanged; shielded 24718->24434, unshielded 813->529). Refs: OpenZeppelin/compact-contracts#526 --- contracts/src/multisig/Forwarder.compact | 67 +---------- .../forwarder/ForwarderShielded.compact | 26 +---- .../forwarder/ForwarderUnshielded.compact | 24 +--- contracts/src/multisig/test/Forwarder.test.ts | 107 ++---------------- .../multisig/test/mocks/MockForwarder.compact | 4 - .../test/presets/ForwarderShielded.test.ts | 8 +- .../test/presets/ForwarderUnshielded.test.ts | 8 +- .../test/simulators/MockForwarderSimulator.ts | 4 - .../presets/ForwarderShieldedSimulator.ts | 4 - .../presets/ForwarderUnshieldedSimulator.ts | 4 - 10 files changed, 22 insertions(+), 234 deletions(-) diff --git a/contracts/src/multisig/Forwarder.compact b/contracts/src/multisig/Forwarder.compact index 53875b7d..a91f14e6 100644 --- a/contracts/src/multisig/Forwarder.compact +++ b/contracts/src/multisig/Forwarder.compact @@ -7,13 +7,10 @@ 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, while - * accumulating per-color cumulative receipt totals for audit. + * immediately send it to a hard-coded parent address. * * Presets (`ForwarderShielded`, `ForwarderUnshielded`) wrap individual - * deposit paths so each deployable contract is single-purpose. The - * overflow guard and `_received.insert` are factored into a shared - * internal `_recordReceived` helper. + * deposit paths so each deployable contract is single-purpose. * * Underscore-prefixed circuits have no access control. The forwarder is * intentionally permissionless — the recipient is hard-coded at deploy, @@ -24,12 +21,10 @@ pragma language_version >= 0.21.0; module Forwarder { import CompactStandardLibrary; import { initialize, assertInitialized } from "../security/Initializable" prefix Initializable_; - import { UINT128_MAX } from "../utils/Utils" prefix Utils_; // ─── State ────────────────────────────────────────────────────── export sealed ledger _parent: Bytes<32>; - export ledger _received: Map, Uint<128>>; // ─── Init ─────────────────────────────────────────────────────── @@ -61,16 +56,13 @@ module Forwarder { * @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 per-color cumulative total in - * `_received` is incremented by `coin.value`. + * `sendImmediateShielded`. * - * @circuitInfo k=15, rows=24718 + * @circuitInfo k=15, rows=24434 * * Requirements: * * - Contract must be initialized. - * - `_received[coin.color] + coin.value` must not overflow `Uint<128>` - * (enforced by `_recordReceived`). * * @param {ShieldedCoinInfo} coin - The incoming shielded coin. * @@ -78,7 +70,6 @@ module Forwarder { */ export circuit _depositShielded(coin: ShieldedCoinInfo): [] { Initializable_assertInitialized(); - _recordReceived(coin.color, coin.value); receiveShielded(disclose(coin)); sendImmediateShielded( disclose(coin), @@ -89,16 +80,13 @@ module Forwarder { /** * @description Receives an unshielded amount of `color` and - * atomically forwards it to `_parent`. The per-color cumulative - * total in `_received` is incremented by `amount`. + * atomically forwards it to `_parent`. * - * @circuitInfo k=10, rows=813 + * @circuitInfo k=10, rows=529 * * Requirements: * * - Contract must be initialized. - * - `_received[color] + amount` must not overflow `Uint<128>` - * (enforced by `_recordReceived`). * * @param {Bytes<32>} color - The token color. * @param {Uint<128>} amount - The amount to deposit. @@ -107,7 +95,6 @@ module Forwarder { */ export circuit _depositUnshielded(color: Bytes<32>, amount: Uint<128>): [] { Initializable_assertInitialized(); - _recordReceived(color, amount); receiveUnshielded(disclose(color), disclose(amount)); sendUnshielded( disclose(color), @@ -115,46 +102,4 @@ module Forwarder { left(ContractAddress { bytes: _parent }) ); } - - // ─── View ─────────────────────────────────────────────────────── - - /** - * @description Returns the cumulative received total for `color`, - * or zero if no deposit of that color has been recorded. - * - * @circuitInfo k=9, rows=343 - * - * Requirements: - * - * - Contract must be initialized. - * - * @param {Bytes<32>} color - The token color. - * - * @returns {Uint<128>} Cumulative received total for `color`. - */ - export circuit _getReceived(color: Bytes<32>): Uint<128> { - Initializable_assertInitialized(); - if (!_received.member(disclose(color))) { - return 0; - } - return _received.lookup(disclose(color)); - } - - // ─── Internal ─────────────────────────────────────────────────── - - /** - * @description Overflow-guarded accumulator. Factored from both - * deposit paths so the overflow assert and `_received.insert` happen - * exactly once per deposit regardless of coin kind. - * - * @param {Bytes<32>} color - The token color. - * @param {Uint<128>} value - The value to add to `_received[color]`. - * - * @returns {[]} Empty tuple. - */ - circuit _recordReceived(color: Bytes<32>, value: Uint<128>): [] { - const current = _getReceived(color); - assert(current <= Utils_UINT128_MAX() - value, "Forwarder: received overflow"); - _received.insert(disclose(color), disclose(current + value as Uint<128>)); - } } diff --git a/contracts/src/multisig/presets/forwarder/ForwarderShielded.compact b/contracts/src/multisig/presets/forwarder/ForwarderShielded.compact index bfca9cf0..90d5f43e 100644 --- a/contracts/src/multisig/presets/forwarder/ForwarderShielded.compact +++ b/contracts/src/multisig/presets/forwarder/ForwarderShielded.compact @@ -7,12 +7,11 @@ 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, accumulating cumulative receipt totals per color. + * 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. Per-color totals are observable from the indexer for - * audit / reconciliation. + * may call `deposit`; the recipient is fixed, so there is no need for + * access control. */ import CompactStandardLibrary; @@ -31,12 +30,7 @@ constructor(parent: Bytes<32>) { /** * @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`. The per-color - * cumulative total in `_received` is incremented by `coin.value`. - * - * Requirements: - * - * - `_received[coin.color] + coin.value` must not overflow `Uint<128>`. + * immediately re-sent via `sendImmediateShielded`. * * @param {ShieldedCoinInfo} coin - The incoming shielded coin. * @@ -54,15 +48,3 @@ export circuit deposit(coin: ShieldedCoinInfo): [] { export circuit getParent(): Bytes<32> { return Forwarder__parent; } - -/** - * @description Returns the cumulative received total for a given color, - * or zero if no coin of that color has been deposited. - * - * @param {Bytes<32>} color - The token color. - * - * @returns {Uint<128>} Cumulative received total for `color`. - */ -export circuit getReceived(color: Bytes<32>): Uint<128> { - return Forwarder__getReceived(color); -} diff --git a/contracts/src/multisig/presets/forwarder/ForwarderUnshielded.compact b/contracts/src/multisig/presets/forwarder/ForwarderUnshielded.compact index c0a9a94b..2a9b99e7 100644 --- a/contracts/src/multisig/presets/forwarder/ForwarderUnshielded.compact +++ b/contracts/src/multisig/presets/forwarder/ForwarderUnshielded.compact @@ -7,10 +7,9 @@ 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, accumulating cumulative receipt totals - * per color. + * the configured parent address. * - * Unshielded transfers are publicly visible on the chain — depositor, + * 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. */ @@ -30,12 +29,7 @@ constructor(parent: Bytes<32>) { /** * @description Receives an unshielded amount of `color` and atomically - * forwards it to the configured parent. The per-color cumulative total - * in `_received` is incremented by `amount`. - * - * Requirements: - * - * - `_received[color] + amount` must not overflow `Uint<128>`. + * forwards it to the configured parent. * * @param {Bytes<32>} color - The token color. * @param {Uint<128>} amount - The amount to deposit. @@ -54,15 +48,3 @@ export circuit depositUnshielded(color: Bytes<32>, amount: Uint<128>): [] { export circuit getParent(): Bytes<32> { return Forwarder__parent; } - -/** - * @description Returns the cumulative received total for a given color, - * or zero if no amount of that color has been deposited. - * - * @param {Bytes<32>} color - The token color. - * - * @returns {Uint<128>} Cumulative received total for `color`. - */ -export circuit getReceived(color: Bytes<32>): Uint<128> { - return Forwarder__getReceived(color); -} diff --git a/contracts/src/multisig/test/Forwarder.test.ts b/contracts/src/multisig/test/Forwarder.test.ts index e8af2cef..617ffe81 100644 --- a/contracts/src/multisig/test/Forwarder.test.ts +++ b/contracts/src/multisig/test/Forwarder.test.ts @@ -1,16 +1,11 @@ import { beforeEach, describe, expect, it } from 'vitest'; -import fc from 'fast-check'; 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 COLOR2 = new Uint8Array(32).fill(2); const AMOUNT = 1000n; -const MAX_U32 = (1n << 32n) - 1n; -const MAX_U64 = (1n << 64n) - 1n; -const MAX_U128 = (1n << 128n) - 1n; function makeCoin(color: Uint8Array, value: bigint, nonce?: Uint8Array) { return { @@ -23,8 +18,7 @@ function makeCoin(color: Uint8Array, value: bigint, nonce?: Uint8Array) { describe('Forwarder module', () => { describe('initialization', () => { it('should initialize on construction when isInit is true', () => { - const mock = new MockForwarderSimulator(PARENT, true); - expect(mock.getReceived(COLOR)).toEqual(0n); + expect(() => new MockForwarderSimulator(PARENT, true)).not.toThrow(); }); it('should fail initialization with zero parent', () => { @@ -57,110 +51,23 @@ describe('Forwarder module', () => { 'Initializable: contract not initialized', ); }); - - it('should fail getReceived when not initialized', () => { - expect(() => mock.getReceived(COLOR)).toThrow( - 'Initializable: contract not initialized', - ); - }); }); - describe('_recordReceived via depositShielded', () => { + describe('deposit', () => { let mock: MockForwarderSimulator; beforeEach(() => { mock = new MockForwarderSimulator(PARENT, true); }); - it('should accumulate _received[color] on a single shielded deposit', () => { - mock.depositShielded(makeCoin(COLOR, AMOUNT)); - expect(mock.getReceived(COLOR)).toEqual(AMOUNT); - }); - - it('should track received per color independently for shielded deposits', () => { - mock.depositShielded(makeCoin(COLOR, AMOUNT)); - mock.depositShielded(makeCoin(COLOR2, AMOUNT * 2n)); - expect(mock.getReceived(COLOR)).toEqual(AMOUNT); - expect(mock.getReceived(COLOR2)).toEqual(AMOUNT * 2n); - }); - - it('should accumulate sequential shielded deposits to the same color', () => { - mock.depositShielded(makeCoin(COLOR, AMOUNT, new Uint8Array(32).fill(1))); - mock.depositShielded(makeCoin(COLOR, AMOUNT, new Uint8Array(32).fill(2))); - mock.depositShielded(makeCoin(COLOR, AMOUNT, new Uint8Array(32).fill(3))); - expect(mock.getReceived(COLOR)).toEqual(AMOUNT * 3n); - }); - - // The `_recordReceived` overflow assert targets `Uint<128>`. Zswap - // bounds `ShieldedCoinInfo.value` at `Uint<64>`, so the `Uint<128>` - // ceiling cannot be reached by shielded deposits in finite test - // runs. The unshielded path (Uint<128> amounts) exercises the assert - // directly below. - it('should reject shielded deposit when coin.value exceeds Uint<64>', () => { + it('should accept a shielded deposit and forward it', () => { expect(() => - mock.depositShielded(makeCoin(COLOR, MAX_U128)), - ).toThrow(); + mock.depositShielded(makeCoin(COLOR, AMOUNT)), + ).not.toThrow(); }); - it('should accept shielded deposit at the Uint<64> ceiling', () => { - mock.depositShielded(makeCoin(COLOR, MAX_U64)); - expect(mock.getReceived(COLOR)).toEqual(MAX_U64); + it('should accept an unshielded deposit and forward it', () => { + expect(() => mock.depositUnshielded(COLOR, AMOUNT)).not.toThrow(); }); }); - - describe('_recordReceived via depositUnshielded', () => { - let mock: MockForwarderSimulator; - - beforeEach(() => { - mock = new MockForwarderSimulator(PARENT, true); - }); - - it('should accumulate _received[color] on a single unshielded deposit', () => { - mock.depositUnshielded(COLOR, AMOUNT); - expect(mock.getReceived(COLOR)).toEqual(AMOUNT); - }); - - it('should track received per color independently for unshielded deposits', () => { - mock.depositUnshielded(COLOR, AMOUNT); - mock.depositUnshielded(COLOR2, AMOUNT * 2n); - expect(mock.getReceived(COLOR)).toEqual(AMOUNT); - expect(mock.getReceived(COLOR2)).toEqual(AMOUNT * 2n); - }); - - it('should fail depositUnshielded with "Forwarder: received overflow" at MAX', () => { - mock.depositUnshielded(COLOR, MAX_U128); - expect(() => mock.depositUnshielded(COLOR, 1n)).toThrow( - 'Forwarder: received overflow', - ); - }); - }); - - describe('property: accumulation', () => { - it( - 'should accumulate _received as the sum of shielded deposits', - { timeout: 30_000 }, - () => { - fc.assert( - fc.property( - fc.array(fc.bigInt({ min: 0n, max: MAX_U32 }), { - minLength: 1, - maxLength: 4, - }), - (values) => { - const mock = new MockForwarderSimulator(PARENT, true); - let i = 0; - for (const v of values) { - mock.depositShielded( - makeCoin(COLOR, v, new Uint8Array(32).fill(i++)), - ); - } - const expected = values.reduce((acc, v) => acc + v, 0n); - expect(mock.getReceived(COLOR)).toEqual(expected); - }, - ), - { numRuns: 20 }, - ); - }, - ); - }); }); diff --git a/contracts/src/multisig/test/mocks/MockForwarder.compact b/contracts/src/multisig/test/mocks/MockForwarder.compact index 17f41b66..0049a521 100644 --- a/contracts/src/multisig/test/mocks/MockForwarder.compact +++ b/contracts/src/multisig/test/mocks/MockForwarder.compact @@ -28,7 +28,3 @@ export circuit depositShielded(coin: ShieldedCoinInfo): [] { export circuit depositUnshielded(color: Bytes<32>, amount: Uint<128>): [] { return Forwarder__depositUnshielded(color, amount); } - -export circuit getReceived(color: Bytes<32>): Uint<128> { - return Forwarder__getReceived(color); -} diff --git a/contracts/src/multisig/test/presets/ForwarderShielded.test.ts b/contracts/src/multisig/test/presets/ForwarderShielded.test.ts index 6c028dca..2464bfa9 100644 --- a/contracts/src/multisig/test/presets/ForwarderShielded.test.ts +++ b/contracts/src/multisig/test/presets/ForwarderShielded.test.ts @@ -18,13 +18,7 @@ describe('ForwarderShielded preset', () => { it('should expose deposit and forward to _depositShielded', () => { const fwd = new ForwarderShieldedSimulator(PARENT); - fwd.deposit(makeCoin(COLOR, AMOUNT)); - expect(fwd.getReceived(COLOR)).toEqual(AMOUNT); - }); - - it('should expose getReceived and return 0 for unknown color', () => { - const fwd = new ForwarderShieldedSimulator(PARENT); - expect(fwd.getReceived(COLOR)).toEqual(0n); + expect(() => fwd.deposit(makeCoin(COLOR, AMOUNT))).not.toThrow(); }); it('should propagate the zero-parent guard from the module', () => { diff --git a/contracts/src/multisig/test/presets/ForwarderUnshielded.test.ts b/contracts/src/multisig/test/presets/ForwarderUnshielded.test.ts index 13d53917..3349b58c 100644 --- a/contracts/src/multisig/test/presets/ForwarderUnshielded.test.ts +++ b/contracts/src/multisig/test/presets/ForwarderUnshielded.test.ts @@ -14,13 +14,7 @@ describe('ForwarderUnshielded preset', () => { it('should expose depositUnshielded and forward to _depositUnshielded', () => { const fwd = new ForwarderUnshieldedSimulator(PARENT); - fwd.depositUnshielded(COLOR, AMOUNT); - expect(fwd.getReceived(COLOR)).toEqual(AMOUNT); - }); - - it('should expose getReceived and return 0 for unknown color', () => { - const fwd = new ForwarderUnshieldedSimulator(PARENT); - expect(fwd.getReceived(COLOR)).toEqual(0n); + expect(() => fwd.depositUnshielded(COLOR, AMOUNT)).not.toThrow(); }); it('should propagate the zero-parent guard from the module', () => { diff --git a/contracts/src/multisig/test/simulators/MockForwarderSimulator.ts b/contracts/src/multisig/test/simulators/MockForwarderSimulator.ts index 14582d07..bd6d9e6d 100644 --- a/contracts/src/multisig/test/simulators/MockForwarderSimulator.ts +++ b/contracts/src/multisig/test/simulators/MockForwarderSimulator.ts @@ -49,8 +49,4 @@ export class MockForwarderSimulator extends MockForwarderSimulatorBase { public depositUnshielded(color: Uint8Array, amount: bigint) { return this.circuits.impure.depositUnshielded(color, amount); } - - public getReceived(color: Uint8Array): bigint { - return this.circuits.impure.getReceived(color); - } } diff --git a/contracts/src/multisig/test/simulators/presets/ForwarderShieldedSimulator.ts b/contracts/src/multisig/test/simulators/presets/ForwarderShieldedSimulator.ts index b0a9f9a8..c134d60e 100644 --- a/contracts/src/multisig/test/simulators/presets/ForwarderShieldedSimulator.ts +++ b/contracts/src/multisig/test/simulators/presets/ForwarderShieldedSimulator.ts @@ -48,8 +48,4 @@ export class ForwarderShieldedSimulator extends ForwarderShieldedSimulatorBase { public getParent(): Uint8Array { return this.circuits.impure.getParent(); } - - public getReceived(color: Uint8Array): bigint { - return this.circuits.impure.getReceived(color); - } } diff --git a/contracts/src/multisig/test/simulators/presets/ForwarderUnshieldedSimulator.ts b/contracts/src/multisig/test/simulators/presets/ForwarderUnshieldedSimulator.ts index 2075c4e8..fdad2a06 100644 --- a/contracts/src/multisig/test/simulators/presets/ForwarderUnshieldedSimulator.ts +++ b/contracts/src/multisig/test/simulators/presets/ForwarderUnshieldedSimulator.ts @@ -46,8 +46,4 @@ export class ForwarderUnshieldedSimulator extends ForwarderUnshieldedSimulatorBa public getParent(): Uint8Array { return this.circuits.impure.getParent(); } - - public getReceived(color: Uint8Array): bigint { - return this.circuits.impure.getReceived(color); - } } From 0a7d41be8747d92eab2707896ff589f7a8a36357 Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Wed, 3 Jun 2026 14:22:18 +0200 Subject: [PATCH 13/16] feat(multisig): make Forwarder parent type-safe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Andrew's review (comment 5) flagged that the deposit recipients hard-coded address types from a raw `Bytes<32>` parent, which lets a deployer pass the wrong address kind for the coin type. * Make `Forwarder` generic over the parent type `T` and store `_parent: T` (sealed). Presets specialize it — `ForwarderShielded` to `ZswapCoinPublicKey`, `ForwarderUnshielded` to `UserAddress` — so the deploy API enforces the correct kind, and each forwards to the user side of its `Either` rather than a contract address. * Keep both `_depositShielded` / `_depositUnshielded` as core internals that forward to `_parent` only; each rebuilds its typed recipient from `_parent.bytes`, so both coexist under one `T` (struct `as` casts are rejected, so the rebuild is the workaround). * Export the STD types each contract uses (`ZswapCoinPublicKey`, `ShieldedCoinInfo` / `UserAddress`) so the generated TS carries named types; simulators import them instead of inlining `{ bytes }`. * Refresh `@circuitInfo`: `_depositShielded` k=15 rows=18573, `_depositUnshielded` k=9 rows=436. Refs: OpenZeppelin/compact-contracts#526 --- contracts/src/multisig/Forwarder.compact | 33 +++++++++++-------- .../forwarder/ForwarderShielded.compact | 14 ++++---- .../forwarder/ForwarderUnshielded.compact | 12 ++++--- .../multisig/test/mocks/MockForwarder.compact | 6 ++-- .../test/simulators/MockForwarderSimulator.ts | 8 ++--- .../presets/ForwarderShieldedSimulator.ts | 10 +++--- .../presets/ForwarderUnshieldedSimulator.ts | 7 ++-- 7 files changed, 52 insertions(+), 38 deletions(-) diff --git a/contracts/src/multisig/Forwarder.compact b/contracts/src/multisig/Forwarder.compact index a91f14e6..306de656 100644 --- a/contracts/src/multisig/Forwarder.compact +++ b/contracts/src/multisig/Forwarder.compact @@ -9,8 +9,11 @@ pragma language_version >= 0.21.0; * forward pattern: receive a coin (shielded or unshielded) and * immediately send it to a hard-coded parent address. * - * Presets (`ForwarderShielded`, `ForwarderUnshielded`) wrap individual - * deposit paths so each deployable contract is single-purpose. + * 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, @@ -18,13 +21,13 @@ pragma language_version >= 0.21.0; * access is gated by the `Initializable` module: every circuit asserts * the module has been initialized via `initialize`. */ -module Forwarder { +module Forwarder { import CompactStandardLibrary; import { initialize, assertInitialized } from "../security/Initializable" prefix Initializable_; // ─── State ────────────────────────────────────────────────────── - export sealed ledger _parent: Bytes<32>; + export sealed ledger _parent: T; // ─── Init ─────────────────────────────────────────────────────── @@ -40,12 +43,14 @@ module Forwarder { * forward every deposit to an unspendable address with no recovery * path. * - * @param {Bytes<32>} parent - The parent address. + * @param {T} parent - The parent address. Typed per the specializing + * preset: `ZswapCoinPublicKey` for shielded, `UserAddress` for + * unshielded. * * @returns {[]} Empty tuple. */ - export circuit initialize(parent: Bytes<32>): [] { - assert(parent != default>, "Forwarder: zero parent"); + export circuit initialize(parent: T): [] { + assert(parent != default, "Forwarder: zero parent"); Initializable_initialize(); _parent = disclose(parent); } @@ -56,9 +61,10 @@ module Forwarder { * @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`. + * `sendImmediateShielded`. The parent's bytes are wrapped as a + * `ZswapCoinPublicKey` recipient at the send site. * - * @circuitInfo k=15, rows=24434 + * @circuitInfo k=15, rows=18573 * * Requirements: * @@ -73,16 +79,17 @@ module Forwarder { receiveShielded(disclose(coin)); sendImmediateShielded( disclose(coin), - right(ContractAddress { bytes: _parent }), + left(ZswapCoinPublicKey { bytes: _parent.bytes }), disclose(coin.value) ); } /** * @description Receives an unshielded amount of `color` and - * atomically forwards it to `_parent`. + * atomically forwards it to `_parent`. The parent's bytes are + * wrapped as a `UserAddress` recipient at the send site. * - * @circuitInfo k=10, rows=529 + * @circuitInfo k=9, rows=436 * * Requirements: * @@ -99,7 +106,7 @@ module Forwarder { sendUnshielded( disclose(color), disclose(amount), - left(ContractAddress { bytes: _parent }) + right(UserAddress { bytes: _parent.bytes }) ); } } diff --git a/contracts/src/multisig/presets/forwarder/ForwarderShielded.compact b/contracts/src/multisig/presets/forwarder/ForwarderShielded.compact index 90d5f43e..d0889bfc 100644 --- a/contracts/src/multisig/presets/forwarder/ForwarderShielded.compact +++ b/contracts/src/multisig/presets/forwarder/ForwarderShielded.compact @@ -15,15 +15,17 @@ pragma language_version >= 0.21.0; */ import CompactStandardLibrary; -import "../../Forwarder" prefix Forwarder_; +import "../../Forwarder" prefix Forwarder_; + +export { ZswapCoinPublicKey, ShieldedCoinInfo }; /** * @description Deploys the forwarder bound to a specific parent address. * - * @param {Bytes<32>} parent - The parent address that receives every - * forwarded coin. Wrapped as a `ContractAddress` at the send site. + * @param {ZswapCoinPublicKey} parent - The parent address that receives + * every forwarded coin. */ -constructor(parent: Bytes<32>) { +constructor(parent: ZswapCoinPublicKey) { Forwarder_initialize(parent); } @@ -43,8 +45,8 @@ export circuit deposit(coin: ShieldedCoinInfo): [] { /** * @description Returns the configured parent address. * - * @returns {Bytes<32>} The parent address set at deploy. + * @returns {ZswapCoinPublicKey} The parent address set at deploy. */ -export circuit getParent(): Bytes<32> { +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 index 2a9b99e7..94da78c6 100644 --- a/contracts/src/multisig/presets/forwarder/ForwarderUnshielded.compact +++ b/contracts/src/multisig/presets/forwarder/ForwarderUnshielded.compact @@ -15,15 +15,17 @@ pragma language_version >= 0.21.0; */ import CompactStandardLibrary; -import "../../Forwarder" prefix Forwarder_; +import "../../Forwarder" prefix Forwarder_; + +export { UserAddress }; /** * @description Deploys the forwarder bound to a specific parent address. * - * @param {Bytes<32>} parent - The parent address that receives every + * @param {UserAddress} parent - The parent address that receives every * forwarded amount. */ -constructor(parent: Bytes<32>) { +constructor(parent: UserAddress) { Forwarder_initialize(parent); } @@ -43,8 +45,8 @@ export circuit depositUnshielded(color: Bytes<32>, amount: Uint<128>): [] { /** * @description Returns the configured parent address. * - * @returns {Bytes<32>} The parent address set at deploy. + * @returns {UserAddress} The parent address set at deploy. */ -export circuit getParent(): Bytes<32> { +export circuit getParent(): UserAddress { return Forwarder__parent; } diff --git a/contracts/src/multisig/test/mocks/MockForwarder.compact b/contracts/src/multisig/test/mocks/MockForwarder.compact index 0049a521..bfca8bd8 100644 --- a/contracts/src/multisig/test/mocks/MockForwarder.compact +++ b/contracts/src/multisig/test/mocks/MockForwarder.compact @@ -4,7 +4,9 @@ pragma language_version >= 0.21.0; import CompactStandardLibrary; -import "../../Forwarder" prefix Forwarder_; +import "../../Forwarder" prefix Forwarder_; + +export { ZswapCoinPublicKey, ShieldedCoinInfo }; /** * @description Test fixture exposing the public Forwarder module's @@ -15,7 +17,7 @@ import "../../Forwarder" prefix Forwarder_; * * DO NOT USE IN PRODUCTION. */ -constructor(parent: Bytes<32>, isInit: Boolean) { +constructor(parent: ZswapCoinPublicKey, isInit: Boolean) { if (disclose(isInit)) { Forwarder_initialize(parent); } diff --git a/contracts/src/multisig/test/simulators/MockForwarderSimulator.ts b/contracts/src/multisig/test/simulators/MockForwarderSimulator.ts index bd6d9e6d..67e51370 100644 --- a/contracts/src/multisig/test/simulators/MockForwarderSimulator.ts +++ b/contracts/src/multisig/test/simulators/MockForwarderSimulator.ts @@ -5,15 +5,15 @@ import { import { ledger, Contract as MockForwarder, + type ShieldedCoinInfo, + type ZswapCoinPublicKey, } from '../../../../artifacts/MockForwarder/contract/index.js'; import { MockForwarderPrivateState, MockForwarderWitnesses, } from '../../witnesses/MockForwarderWitnesses.js'; -type ShieldedCoinInfo = { nonce: Uint8Array; color: Uint8Array; value: bigint }; - -type MockForwarderArgs = readonly [parent: Uint8Array, isInit: boolean]; +type MockForwarderArgs = readonly [parent: ZswapCoinPublicKey, isInit: boolean]; const MockForwarderSimulatorBase = createSimulator< MockForwarderPrivateState, @@ -39,7 +39,7 @@ export class MockForwarderSimulator extends MockForwarderSimulatorBase { ReturnType > = {}, ) { - super([parent, isInit], options); + super([{ bytes: parent }, isInit], options); } public depositShielded(coin: ShieldedCoinInfo) { diff --git a/contracts/src/multisig/test/simulators/presets/ForwarderShieldedSimulator.ts b/contracts/src/multisig/test/simulators/presets/ForwarderShieldedSimulator.ts index c134d60e..f136ec84 100644 --- a/contracts/src/multisig/test/simulators/presets/ForwarderShieldedSimulator.ts +++ b/contracts/src/multisig/test/simulators/presets/ForwarderShieldedSimulator.ts @@ -5,15 +5,15 @@ import { import { ledger, Contract as ForwarderShielded, + type ShieldedCoinInfo, + type ZswapCoinPublicKey, } from '../../../../../artifacts/ForwarderShielded/contract/index.js'; import { ForwarderShieldedPrivateState, ForwarderShieldedWitnesses, } from '../../../witnesses/presets/ForwarderShieldedWitnesses.js'; -type ShieldedCoinInfo = { nonce: Uint8Array; color: Uint8Array; value: bigint }; - -type ForwarderShieldedArgs = readonly [parent: Uint8Array]; +type ForwarderShieldedArgs = readonly [parent: ZswapCoinPublicKey]; const ForwarderShieldedSimulatorBase = createSimulator< ForwarderShieldedPrivateState, @@ -38,7 +38,7 @@ export class ForwarderShieldedSimulator extends ForwarderShieldedSimulatorBase { ReturnType > = {}, ) { - super([parent], options); + super([{ bytes: parent }], options); } public deposit(coin: ShieldedCoinInfo) { @@ -46,6 +46,6 @@ export class ForwarderShieldedSimulator extends ForwarderShieldedSimulatorBase { } public getParent(): Uint8Array { - return this.circuits.impure.getParent(); + 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 index fdad2a06..09e5cbc7 100644 --- a/contracts/src/multisig/test/simulators/presets/ForwarderUnshieldedSimulator.ts +++ b/contracts/src/multisig/test/simulators/presets/ForwarderUnshieldedSimulator.ts @@ -5,13 +5,14 @@ import { 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: Uint8Array]; +type ForwarderUnshieldedArgs = readonly [parent: UserAddress]; const ForwarderUnshieldedSimulatorBase = createSimulator< ForwarderUnshieldedPrivateState, @@ -36,7 +37,7 @@ export class ForwarderUnshieldedSimulator extends ForwarderUnshieldedSimulatorBa ReturnType > = {}, ) { - super([parent], options); + super([{ bytes: parent }], options); } public depositUnshielded(color: Uint8Array, amount: bigint) { @@ -44,6 +45,6 @@ export class ForwarderUnshieldedSimulator extends ForwarderUnshieldedSimulatorBa } public getParent(): Uint8Array { - return this.circuits.impure.getParent(); + return this.circuits.impure.getParent().bytes; } } From 5ad2cc4bded4d7a8045c45a75be98c098ebccbf1 Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Wed, 3 Jun 2026 14:29:22 +0200 Subject: [PATCH 14/16] refactor(multisig): name ForwarderPrivate TS types The ForwarderPrivate simulators hand-inlined `ShieldedCoinInfo`, `QualifiedShieldedCoinInfo`, and `ShieldedSendResult` as structural types, which drift from the generated artifact. * Re-export the three STD types from the ForwarderPrivate preset and the MockForwarderPrivate fixture so compactc emits named aliases in the generated `index.d.ts`. * Import the named types in both simulators instead of inlining them. Mirrors the parent-typing change in the preceding commit. No behavioral change; the 37 forwarder tests still pass. Refs: OpenZeppelin/compact-contracts#526 --- .../presets/forwarder/ForwarderPrivate.compact | 2 ++ .../test/mocks/MockForwarderPrivate.compact | 2 ++ .../simulators/MockForwarderPrivateSimulator.ts | 15 +++------------ .../presets/ForwarderPrivateSimulator.ts | 15 +++------------ 4 files changed, 10 insertions(+), 24 deletions(-) diff --git a/contracts/src/multisig/presets/forwarder/ForwarderPrivate.compact b/contracts/src/multisig/presets/forwarder/ForwarderPrivate.compact index 3e19a4a2..58cc6a7e 100644 --- a/contracts/src/multisig/presets/forwarder/ForwarderPrivate.compact +++ b/contracts/src/multisig/presets/forwarder/ForwarderPrivate.compact @@ -24,6 +24,8 @@ pragma language_version >= 0.21.0; 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 diff --git a/contracts/src/multisig/test/mocks/MockForwarderPrivate.compact b/contracts/src/multisig/test/mocks/MockForwarderPrivate.compact index 85fec9ba..52cb0a91 100644 --- a/contracts/src/multisig/test/mocks/MockForwarderPrivate.compact +++ b/contracts/src/multisig/test/mocks/MockForwarderPrivate.compact @@ -6,6 +6,8 @@ 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 diff --git a/contracts/src/multisig/test/simulators/MockForwarderPrivateSimulator.ts b/contracts/src/multisig/test/simulators/MockForwarderPrivateSimulator.ts index 19b2b9a9..def0e4ec 100644 --- a/contracts/src/multisig/test/simulators/MockForwarderPrivateSimulator.ts +++ b/contracts/src/multisig/test/simulators/MockForwarderPrivateSimulator.ts @@ -5,6 +5,9 @@ import { import { ledger, pureCircuits, + type QualifiedShieldedCoinInfo, + type ShieldedCoinInfo, + type ShieldedSendResult, Contract as MockForwarderPrivate, } from '../../../../artifacts/MockForwarderPrivate/contract/index.js'; import { @@ -12,18 +15,6 @@ import { MockForwarderPrivateWitnesses, } from '../../witnesses/MockForwarderPrivateWitnesses.js'; -type ShieldedCoinInfo = { nonce: Uint8Array; color: Uint8Array; value: bigint }; -type QualifiedShieldedCoinInfo = { - nonce: Uint8Array; - color: Uint8Array; - value: bigint; - mt_index: bigint; -}; -type ShieldedSendResult = { - change: { is_some: boolean; value: ShieldedCoinInfo }; - sent: ShieldedCoinInfo; -}; - type MockForwarderPrivateArgs = readonly [ parentCommitment: Uint8Array, isInit: boolean, diff --git a/contracts/src/multisig/test/simulators/presets/ForwarderPrivateSimulator.ts b/contracts/src/multisig/test/simulators/presets/ForwarderPrivateSimulator.ts index 3f72ddea..29955256 100644 --- a/contracts/src/multisig/test/simulators/presets/ForwarderPrivateSimulator.ts +++ b/contracts/src/multisig/test/simulators/presets/ForwarderPrivateSimulator.ts @@ -5,6 +5,9 @@ import { import { ledger, pureCircuits, + type QualifiedShieldedCoinInfo, + type ShieldedCoinInfo, + type ShieldedSendResult, Contract as ForwarderPrivate, } from '../../../../../artifacts/ForwarderPrivate/contract/index.js'; import { @@ -12,18 +15,6 @@ import { ForwarderPrivateWitnesses, } from '../../../witnesses/presets/ForwarderPrivateWitnesses.js'; -type ShieldedCoinInfo = { nonce: Uint8Array; color: Uint8Array; value: bigint }; -type QualifiedShieldedCoinInfo = { - nonce: Uint8Array; - color: Uint8Array; - value: bigint; - mt_index: bigint; -}; -type ShieldedSendResult = { - change: { is_some: boolean; value: ShieldedCoinInfo }; - sent: ShieldedCoinInfo; -}; - type ForwarderPrivateArgs = readonly [parentCommitment: Uint8Array]; const ForwarderPrivateSimulatorBase = createSimulator< From 70742e738faac6f9af0deebf36f779682f51dfe6 Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Wed, 3 Jun 2026 14:37:14 +0200 Subject: [PATCH 15/16] refactor(multisig): drop brace-list module imports Comment 3 (6641069) replaced the brace-list imports in the forwarder presets and mocks with the bare `prefix` form, but the two module files were missed and still imported named symbols. * `Forwarder.compact` now uses `import "../security/Initializable" prefix Initializable_;`. * `ForwarderPrivate.compact` does the same, plus `import "../utils/Utils" prefix Utils_;`. The bare form imports every exported member under the prefix, so the call sites (`Initializable_initialize`, `Initializable_assertInitialized`, `Utils_selfAsRecipient`) are unchanged. Matches the other multisig presets; with no go-to-def in the compact-lsp the brace list bought nothing. 37 forwarder tests still pass. Refs: OpenZeppelin/compact-contracts#526 --- contracts/src/multisig/Forwarder.compact | 2 +- contracts/src/multisig/ForwarderPrivate.compact | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/contracts/src/multisig/Forwarder.compact b/contracts/src/multisig/Forwarder.compact index 306de656..9c29aabd 100644 --- a/contracts/src/multisig/Forwarder.compact +++ b/contracts/src/multisig/Forwarder.compact @@ -23,7 +23,7 @@ pragma language_version >= 0.21.0; */ module Forwarder { import CompactStandardLibrary; - import { initialize, assertInitialized } from "../security/Initializable" prefix Initializable_; + import "../security/Initializable" prefix Initializable_; // ─── State ────────────────────────────────────────────────────── diff --git a/contracts/src/multisig/ForwarderPrivate.compact b/contracts/src/multisig/ForwarderPrivate.compact index 9a87fcbd..f1fc0c57 100644 --- a/contracts/src/multisig/ForwarderPrivate.compact +++ b/contracts/src/multisig/ForwarderPrivate.compact @@ -26,8 +26,8 @@ pragma language_version >= 0.21.0; */ module ForwarderPrivate { import CompactStandardLibrary; - import { initialize, assertInitialized } from "../security/Initializable" prefix Initializable_; - import { selfAsRecipient } from "../utils/Utils" prefix Utils_; + import "../security/Initializable" prefix Initializable_; + import "../utils/Utils" prefix Utils_; // ─── State ────────────────────────────────────────────────────── From 189ca650af80e23daba77701a789d7ba2c8fb973 Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Wed, 3 Jun 2026 14:50:56 +0200 Subject: [PATCH 16/16] refactor(multisig): rename salt to opSecret Comment 6: `salt` reads like a throwaway randomizer, but in ForwarderPrivate it is the sole drain authorization. Losing it loses every accumulated coin. Rename it to `opSecret` (operational secret) so the name signals that criticality at every call site. * Rename the `salt` parameter to `opSecret` in `_drain` and `_calculateParentCommitment`, plus the preset and mock wrappers. * Rename the test constants `SALT` / `WRONG_SALT` to `OP_SECRET` / `WRONG_OP_SECRET`, and the simulator and helper params. * Reword the doc comments to drop the "salt" term and describe the value as the operational secret. Pure rename; no behavioral change. 37 forwarder tests pass. Refs: OpenZeppelin/compact-contracts#526 --- .../src/multisig/ForwarderPrivate.compact | 47 +++++++++-------- .../forwarder/ForwarderPrivate.compact | 44 ++++++++-------- .../multisig/test/ForwarderPrivate.test.ts | 50 +++++++++---------- .../test/mocks/MockForwarderPrivate.compact | 8 +-- .../test/presets/ForwarderPrivate.test.ts | 20 ++++---- .../MockForwarderPrivateSimulator.ts | 8 +-- .../presets/ForwarderPrivateSimulator.ts | 8 +-- 7 files changed, 92 insertions(+), 93 deletions(-) diff --git a/contracts/src/multisig/ForwarderPrivate.compact b/contracts/src/multisig/ForwarderPrivate.compact index f1fc0c57..b4159a26 100644 --- a/contracts/src/multisig/ForwarderPrivate.compact +++ b/contracts/src/multisig/ForwarderPrivate.compact @@ -8,14 +8,14 @@ pragma language_version >= 0.21.0; * @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, salt)` preimage at drain + * 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. Salt is held off-chain as an - * operational secret; the contract never sees it except during a drain. - * Two forwarders bound to the same parent with different salts produce - * different commitments and are unlinkable on-chain. + * 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 @@ -49,7 +49,7 @@ module ForwarderPrivate { * under the domain-tagged hash). * * @param {Bytes<32>} parentCommitment - Domain-tagged - * `persistentHash([pad(32, "ForwarderPrivate:commitment"), parentAddr, salt])` + * `persistentHash([pad(32, "ForwarderPrivate:commitment"), parentAddr, opSecret])` * computed off-chain by the deployer (see `_calculateParentCommitment`). * * @returns {[]} Empty tuple. @@ -85,7 +85,7 @@ module ForwarderPrivate { /** * @description Spends a previously-deposited shielded coin to - * `parentAddr`. The caller proves knowledge of `(parentAddr, salt)` + * `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. * @@ -94,22 +94,21 @@ module ForwarderPrivate { * Requirements: * * - Contract must be initialized. - * - `_calculateParentCommitment(parentAddr, salt) == _parentCommitment`. + * - `_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>} salt - The salt. Operational secret; never - * appears on the public transcript. + * @param {Bytes<32>} opSecret - The operational secret. Never appears + * on the public transcript. * - * @warning **Salt loss is permanent fund loss.** The salt is the sole - * drain authorization. There is no rotation, revocation, or recovery - * path. If the operator misplaces the salt, every shielded coin - * accumulated at this contract is forever inaccessible — equivalent - * to losing a hot-wallet private key. Back the salt up offline before - * the first deposit and treat it with the same hygiene as a signing - * key. + * @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. * @@ -119,12 +118,12 @@ module ForwarderPrivate { export circuit _drain( coin: QualifiedShieldedCoinInfo, parentAddr: Bytes<32>, - salt: Bytes<32>, + opSecret: Bytes<32>, value: Uint<128> ): ShieldedSendResult { Initializable_assertInitialized(); assert( - _calculateParentCommitment(parentAddr, salt) == _parentCommitment, + _calculateParentCommitment(parentAddr, opSecret) == _parentCommitment, "ForwarderPrivate: invalid parent" ); @@ -148,7 +147,7 @@ module ForwarderPrivate { // ─── Pure helpers ─────────────────────────────────────────────── /** - * @description Computes the parent commitment from `(parentAddr, salt)`. + * @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. @@ -160,16 +159,16 @@ module ForwarderPrivate { * crafted under a different domain cannot satisfy this commitment. * * @param {Bytes<32>} parentAddr - The parent address. - * @param {Bytes<32>} salt - The salt. + * @param {Bytes<32>} opSecret - The operational secret. * - * @returns {Bytes<32>} `persistentHash([pad(32, "ForwarderPrivate:commitment"), parentAddr, salt])`. + * @returns {Bytes<32>} `persistentHash([pad(32, "ForwarderPrivate:commitment"), parentAddr, opSecret])`. */ export pure circuit _calculateParentCommitment( parentAddr: Bytes<32>, - salt: Bytes<32> + opSecret: Bytes<32> ): Bytes<32> { return persistentHash>>( - [pad(32, "ForwarderPrivate:commitment"), parentAddr, salt] + [pad(32, "ForwarderPrivate:commitment"), parentAddr, opSecret] ); } } diff --git a/contracts/src/multisig/presets/forwarder/ForwarderPrivate.compact b/contracts/src/multisig/presets/forwarder/ForwarderPrivate.compact index 58cc6a7e..d1af5096 100644 --- a/contracts/src/multisig/presets/forwarder/ForwarderPrivate.compact +++ b/contracts/src/multisig/presets/forwarder/ForwarderPrivate.compact @@ -8,13 +8,13 @@ pragma language_version >= 0.21.0; * @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, salt)` preimage at drain time. + * by presenting the `(parentAddr, opSecret)` preimage at drain time. * - * Knowledge of the preimage is the sole authorization gate. Salt is an - * operational secret held off-chain by the deployer; loss of the salt - * is equivalent to loss of a hot-wallet key. Two forwarders bound to - * the same parent with different salts produce different commitments - * and are unlinkable on-chain. + * 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 @@ -29,10 +29,10 @@ export { ShieldedCoinInfo, QualifiedShieldedCoinInfo, ShieldedSendResult }; /** * @description Deploys the forwarder bound to a specific parent * commitment. The deployer computes the commitment off-chain as - * `calculateParentCommitment(parentAddr, salt)` and passes it here. + * `calculateParentCommitment(parentAddr, opSecret)` and passes it here. * * @param {Bytes<32>} parentCommitment - The commitment to the - * `(parentAddr, salt)` pair that the operator will present at drain. + * `(parentAddr, opSecret)` pair that the operator will present at drain. */ constructor(parentCommitment: Bytes<32>) { ForwarderPrivate_initialize(parentCommitment); @@ -52,26 +52,26 @@ export circuit deposit(coin: ShieldedCoinInfo): [] { /** * @description Spends a previously-deposited shielded coin to - * `parentAddr`. The caller proves knowledge of `(parentAddr, salt)` + * `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, salt)` must equal the stored + * - `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>} salt - The salt. Operational secret; never appears + * @param {Bytes<32>} opSecret - The operational secret. Never appears * on the public transcript. * - * @warning **Salt loss is permanent fund loss.** Salt is the sole drain - * authorization. No rotation, revocation, or recovery path exists. If - * the operator loses the salt, every shielded coin accumulated at this - * contract becomes inaccessible. Back the salt up offline before the + * @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. @@ -82,10 +82,10 @@ export circuit deposit(coin: ShieldedCoinInfo): [] { export circuit drain( coin: QualifiedShieldedCoinInfo, parentAddr: Bytes<32>, - salt: Bytes<32>, + opSecret: Bytes<32>, value: Uint<128> ): ShieldedSendResult { - return ForwarderPrivate__drain(coin, parentAddr, salt, value); + return ForwarderPrivate__drain(coin, parentAddr, opSecret, value); } /** @@ -98,7 +98,7 @@ export circuit getParentCommitment(): Bytes<32> { } /** - * @description Computes the parent commitment from a `(parentAddr, salt)` + * @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. * @@ -107,13 +107,13 @@ export circuit getParentCommitment(): Bytes<32> { * collisions with other `persistentHash` users in the system. * * @param {Bytes<32>} parentAddr - The parent address. - * @param {Bytes<32>} salt - The salt. + * @param {Bytes<32>} opSecret - The operational secret. * - * @returns {Bytes<32>} The commitment `persistentHash([pad(32, "ForwarderPrivate:commitment"), parentAddr, salt])`. + * @returns {Bytes<32>} The commitment `persistentHash([pad(32, "ForwarderPrivate:commitment"), parentAddr, opSecret])`. */ export pure circuit calculateParentCommitment( parentAddr: Bytes<32>, - salt: Bytes<32> + opSecret: Bytes<32> ): Bytes<32> { - return ForwarderPrivate__calculateParentCommitment(parentAddr, salt); + return ForwarderPrivate__calculateParentCommitment(parentAddr, opSecret); } diff --git a/contracts/src/multisig/test/ForwarderPrivate.test.ts b/contracts/src/multisig/test/ForwarderPrivate.test.ts index 094ffa53..1f9ed739 100644 --- a/contracts/src/multisig/test/ForwarderPrivate.test.ts +++ b/contracts/src/multisig/test/ForwarderPrivate.test.ts @@ -5,8 +5,8 @@ import { MockForwarderPrivateSimulator } from './simulators/MockForwarderPrivate const PARENT = utils.createEitherTestUser('PARENT').left.bytes; const WRONG_PARENT = utils.createEitherTestUser('WRONG').left.bytes; -const SALT = new Uint8Array(32).fill(0xaa); -const WRONG_SALT = new Uint8Array(32).fill(0xbb); +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; @@ -34,14 +34,14 @@ function makeQualifiedCoin( }; } -function commitment(parent: Uint8Array, salt: Uint8Array): Uint8Array { - return MockForwarderPrivateSimulator.calculateParentCommitment(parent, salt); +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, SALT); + const c = commitment(PARENT, OP_SECRET); const mock = new MockForwarderPrivateSimulator(c, true); expect(() => mock.deposit(makeCoin(COLOR, AMOUNT))).not.toThrow(); }); @@ -53,7 +53,7 @@ describe('ForwarderPrivate module', () => { }); it('should expose the public ledger state after initialization', () => { - const c = commitment(PARENT, SALT); + const c = commitment(PARENT, OP_SECRET); const mock = new MockForwarderPrivateSimulator(c, true); expect(mock.getPublicState()).toBeDefined(); }); @@ -63,7 +63,7 @@ describe('ForwarderPrivate module', () => { let mock: MockForwarderPrivateSimulator; beforeEach(() => { - mock = new MockForwarderPrivateSimulator(commitment(PARENT, SALT), false); + mock = new MockForwarderPrivateSimulator(commitment(PARENT, OP_SECRET), false); }); it('should fail deposit when not initialized', () => { @@ -74,19 +74,19 @@ describe('ForwarderPrivate module', () => { it('should fail drain when not initialized', () => { expect(() => - mock.drain(makeQualifiedCoin(COLOR, AMOUNT, 0n), PARENT, SALT, AMOUNT), + 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, salt)', () => { - const c1 = commitment(PARENT, SALT); - const c2 = commitment(PARENT, SALT); + 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 salts', () => { + it('should produce different commitments for different opSecrets', () => { fc.assert( fc.property( fc.uint8Array({ minLength: 32, maxLength: 32 }), @@ -114,15 +114,15 @@ describe('ForwarderPrivate module', () => { let mock: MockForwarderPrivateSimulator; beforeEach(() => { - mock = new MockForwarderPrivateSimulator(commitment(PARENT, SALT), true); + mock = new MockForwarderPrivateSimulator(commitment(PARENT, OP_SECRET), true); mock.deposit(makeCoin(COLOR, AMOUNT)); }); - it('should succeed drain with correct (parentAddr, salt)', () => { + it('should succeed drain with correct (parentAddr, opSecret)', () => { const result = mock.drain( makeQualifiedCoin(COLOR, AMOUNT, 0n), PARENT, - SALT, + OP_SECRET, AMOUNT, ); expect(result.sent.value).toEqual(AMOUNT); @@ -133,18 +133,18 @@ describe('ForwarderPrivate module', () => { mock.drain( makeQualifiedCoin(COLOR, AMOUNT, 0n), WRONG_PARENT, - SALT, + OP_SECRET, AMOUNT, ), ).toThrow('ForwarderPrivate: invalid parent'); }); - it('should fail drain with wrong salt', () => { + it('should fail drain with wrong opSecret', () => { expect(() => mock.drain( makeQualifiedCoin(COLOR, AMOUNT, 0n), PARENT, - WRONG_SALT, + WRONG_OP_SECRET, AMOUNT, ), ).toThrow('ForwarderPrivate: invalid parent'); @@ -155,7 +155,7 @@ describe('ForwarderPrivate module', () => { mock.drain( makeQualifiedCoin(COLOR, AMOUNT, 0n), WRONG_PARENT, - WRONG_SALT, + WRONG_OP_SECRET, AMOUNT, ), ).toThrow('ForwarderPrivate: invalid parent'); @@ -166,7 +166,7 @@ describe('ForwarderPrivate module', () => { mock.drain( makeQualifiedCoin(COLOR, AMOUNT, 0n), PARENT, - SALT, + OP_SECRET, AMOUNT + 1n, ), ).toThrow(); @@ -176,7 +176,7 @@ describe('ForwarderPrivate module', () => { const result = mock.drain( makeQualifiedCoin(COLOR, AMOUNT, 0n), PARENT, - SALT, + OP_SECRET, AMOUNT, ); expect(result.change.is_some).toBe(false); @@ -186,7 +186,7 @@ describe('ForwarderPrivate module', () => { const result = mock.drain( makeQualifiedCoin(COLOR, AMOUNT, 0n), PARENT, - SALT, + OP_SECRET, 400n, ); expect(result.change.is_some).toBe(true); @@ -198,7 +198,7 @@ describe('ForwarderPrivate module', () => { const result = mock.drain( makeQualifiedCoin(COLOR, AMOUNT, 0n), PARENT, - SALT, + OP_SECRET, 400n, ); expect(result.sent.value).toEqual(400n); @@ -215,14 +215,14 @@ describe('ForwarderPrivate module', () => { (coinVal, drainVal) => { fc.pre(drainVal < coinVal); const mock = new MockForwarderPrivateSimulator( - commitment(PARENT, SALT), + commitment(PARENT, OP_SECRET), true, ); mock.deposit(makeCoin(COLOR, coinVal)); const result = mock.drain( makeQualifiedCoin(COLOR, coinVal, 0n), PARENT, - SALT, + OP_SECRET, drainVal, ); expect(result.change.value.value).toEqual(coinVal - drainVal); diff --git a/contracts/src/multisig/test/mocks/MockForwarderPrivate.compact b/contracts/src/multisig/test/mocks/MockForwarderPrivate.compact index 52cb0a91..1bb47501 100644 --- a/contracts/src/multisig/test/mocks/MockForwarderPrivate.compact +++ b/contracts/src/multisig/test/mocks/MockForwarderPrivate.compact @@ -31,15 +31,15 @@ export circuit deposit(coin: ShieldedCoinInfo): [] { export circuit drain( coin: QualifiedShieldedCoinInfo, parentAddr: Bytes<32>, - salt: Bytes<32>, + opSecret: Bytes<32>, value: Uint<128> ): ShieldedSendResult { - return ForwarderPrivate__drain(coin, parentAddr, salt, value); + return ForwarderPrivate__drain(coin, parentAddr, opSecret, value); } export pure circuit calculateParentCommitment( parentAddr: Bytes<32>, - salt: Bytes<32> + opSecret: Bytes<32> ): Bytes<32> { - return ForwarderPrivate__calculateParentCommitment(parentAddr, salt); + 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 index d7081904..39956670 100644 --- a/contracts/src/multisig/test/presets/ForwarderPrivate.test.ts +++ b/contracts/src/multisig/test/presets/ForwarderPrivate.test.ts @@ -3,7 +3,7 @@ import * as utils from '#test-utils/address.js'; import { ForwarderPrivateSimulator } from '../simulators/presets/ForwarderPrivateSimulator.js'; const PARENT = utils.createEitherTestUser('PARENT').left.bytes; -const SALT = new Uint8Array(32).fill(0xaa); +const OP_SECRET = new Uint8Array(32).fill(0xaa); const COLOR = new Uint8Array(32).fill(1); const AMOUNT = 1000n; @@ -15,37 +15,37 @@ function makeQualifiedCoin(color: Uint8Array, value: bigint, mtIndex: bigint) { return { nonce: new Uint8Array(32), color, value, mt_index: mtIndex }; } -function commitment(parent: Uint8Array, salt: Uint8Array): Uint8Array { - return ForwarderPrivateSimulator.calculateParentCommitment(parent, salt); +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, SALT); + 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, SALT)); + 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, SALT)); + const fwd = new ForwarderPrivateSimulator(commitment(PARENT, OP_SECRET)); fwd.deposit(makeCoin(COLOR, AMOUNT)); const result = fwd.drain( makeQualifiedCoin(COLOR, AMOUNT, 0n), PARENT, - SALT, + OP_SECRET, AMOUNT, ); expect(result.sent.value).toEqual(AMOUNT); }); it('should expose calculateParentCommitment as a static pure helper', () => { - const c1 = commitment(PARENT, SALT); - const c2 = commitment(PARENT, SALT); + const c1 = commitment(PARENT, OP_SECRET); + const c2 = commitment(PARENT, OP_SECRET); expect(c1).toEqual(c2); }); @@ -56,7 +56,7 @@ describe('ForwarderPrivate preset', () => { }); it('should expose the public ledger state', () => { - const fwd = new ForwarderPrivateSimulator(commitment(PARENT, SALT)); + const fwd = new ForwarderPrivateSimulator(commitment(PARENT, OP_SECRET)); expect(fwd.getPublicState()).toBeDefined(); }); }); diff --git a/contracts/src/multisig/test/simulators/MockForwarderPrivateSimulator.ts b/contracts/src/multisig/test/simulators/MockForwarderPrivateSimulator.ts index def0e4ec..02e2b9f9 100644 --- a/contracts/src/multisig/test/simulators/MockForwarderPrivateSimulator.ts +++ b/contracts/src/multisig/test/simulators/MockForwarderPrivateSimulator.ts @@ -49,9 +49,9 @@ export class MockForwarderPrivateSimulator extends MockForwarderPrivateSimulator public static calculateParentCommitment( parentAddr: Uint8Array, - salt: Uint8Array, + opSecret: Uint8Array, ): Uint8Array { - return pureCircuits.calculateParentCommitment(parentAddr, salt); + return pureCircuits.calculateParentCommitment(parentAddr, opSecret); } public deposit(coin: ShieldedCoinInfo) { @@ -61,9 +61,9 @@ export class MockForwarderPrivateSimulator extends MockForwarderPrivateSimulator public drain( coin: QualifiedShieldedCoinInfo, parentAddr: Uint8Array, - salt: Uint8Array, + opSecret: Uint8Array, value: bigint, ): ShieldedSendResult { - return this.circuits.impure.drain(coin, parentAddr, salt, value); + return this.circuits.impure.drain(coin, parentAddr, opSecret, value); } } diff --git a/contracts/src/multisig/test/simulators/presets/ForwarderPrivateSimulator.ts b/contracts/src/multisig/test/simulators/presets/ForwarderPrivateSimulator.ts index 29955256..ac054627 100644 --- a/contracts/src/multisig/test/simulators/presets/ForwarderPrivateSimulator.ts +++ b/contracts/src/multisig/test/simulators/presets/ForwarderPrivateSimulator.ts @@ -45,9 +45,9 @@ export class ForwarderPrivateSimulator extends ForwarderPrivateSimulatorBase { public static calculateParentCommitment( parentAddr: Uint8Array, - salt: Uint8Array, + opSecret: Uint8Array, ): Uint8Array { - return pureCircuits.calculateParentCommitment(parentAddr, salt); + return pureCircuits.calculateParentCommitment(parentAddr, opSecret); } public deposit(coin: ShieldedCoinInfo) { @@ -57,10 +57,10 @@ export class ForwarderPrivateSimulator extends ForwarderPrivateSimulatorBase { public drain( coin: QualifiedShieldedCoinInfo, parentAddr: Uint8Array, - salt: Uint8Array, + opSecret: Uint8Array, value: bigint, ): ShieldedSendResult { - return this.circuits.impure.drain(coin, parentAddr, salt, value); + return this.circuits.impure.drain(coin, parentAddr, opSecret, value); } public getParentCommitment(): Uint8Array {