From 09a26e383b68db59160451110c32dcea1fd26bf7 Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Mon, 22 Jun 2026 16:01:50 +0200 Subject: [PATCH 01/13] refactor(multisig): group modules into subdirs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the flat multisig modules into concern-based subdirectories so the package reads as composable building blocks rather than a flat pile: * signer/ — Signer, SignerManager * proposal/ — ProposalManager * treasury/ — ShieldedTreasury, ShieldedTreasuryStateless, UnshieldedTreasury * forwarder/ — ForwarderPrivate, ForwarderShielded, ForwarderUnshielded Presets and mocks keep their locations; only their import paths change. Mocks stay flat, so compiled artifact names (flat by basename) and every TS test import are untouched. The five moved modules that reach into utils/ gain an extra ../ in their import path. Also mark V3's inlined mint/burn with a TODO pointing at the future ShieldedToken module. Pure move plus import-path change, no logic change. compact:multisig compiles 24/24; the multisig suite passes 273/273. Refs: OpenZeppelin/compact-contracts#619 --- .../src/multisig/{ => forwarder}/ForwarderPrivate.compact | 2 +- .../src/multisig/{ => forwarder}/ForwarderShielded.compact | 2 +- .../multisig/{ => forwarder}/ForwarderUnshielded.compact | 0 contracts/src/multisig/presets/ShieldedMultiSig.compact | 6 +++--- contracts/src/multisig/presets/ShieldedMultiSigV2.compact | 6 +++--- contracts/src/multisig/presets/ShieldedMultiSigV3.compact | 7 ++++++- .../multisig/presets/forwarder/ForwarderPrivate.compact | 2 +- .../multisig/presets/forwarder/ForwarderShielded.compact | 2 +- .../multisig/presets/forwarder/ForwarderUnshielded.compact | 2 +- .../src/multisig/{ => proposal}/ProposalManager.compact | 0 contracts/src/multisig/{ => signer}/Signer.compact | 0 contracts/src/multisig/{ => signer}/SignerManager.compact | 0 .../src/multisig/test/mocks/MockForwarderPrivate.compact | 2 +- .../src/multisig/test/mocks/MockForwarderShielded.compact | 2 +- .../multisig/test/mocks/MockForwarderUnshielded.compact | 2 +- .../src/multisig/test/mocks/MockProposalManager.compact | 2 +- .../src/multisig/test/mocks/MockShieldedTreasury.compact | 2 +- .../test/mocks/MockShieldedTreasuryStateless.compact | 2 +- contracts/src/multisig/test/mocks/MockSigner.compact | 4 ++-- .../src/multisig/test/mocks/MockSignerManager.compact | 2 +- .../src/multisig/test/mocks/MockUnshieldedTreasury.compact | 2 +- .../src/multisig/{ => treasury}/ShieldedTreasury.compact | 2 +- .../{ => treasury}/ShieldedTreasuryStateless.compact | 2 +- .../src/multisig/{ => treasury}/UnshieldedTreasury.compact | 2 +- 24 files changed, 30 insertions(+), 25 deletions(-) rename contracts/src/multisig/{ => forwarder}/ForwarderPrivate.compact (99%) rename contracts/src/multisig/{ => forwarder}/ForwarderShielded.compact (99%) rename contracts/src/multisig/{ => forwarder}/ForwarderUnshielded.compact (100%) rename contracts/src/multisig/{ => proposal}/ProposalManager.compact (100%) rename contracts/src/multisig/{ => signer}/Signer.compact (100%) rename contracts/src/multisig/{ => signer}/SignerManager.compact (100%) rename contracts/src/multisig/{ => treasury}/ShieldedTreasury.compact (98%) rename contracts/src/multisig/{ => treasury}/ShieldedTreasuryStateless.compact (97%) rename contracts/src/multisig/{ => treasury}/UnshieldedTreasury.compact (98%) diff --git a/contracts/src/multisig/ForwarderPrivate.compact b/contracts/src/multisig/forwarder/ForwarderPrivate.compact similarity index 99% rename from contracts/src/multisig/ForwarderPrivate.compact rename to contracts/src/multisig/forwarder/ForwarderPrivate.compact index 1d35f92a..f2f08618 100644 --- a/contracts/src/multisig/ForwarderPrivate.compact +++ b/contracts/src/multisig/forwarder/ForwarderPrivate.compact @@ -29,7 +29,7 @@ pragma language_version >= 0.23.0; */ module ForwarderPrivate { import CompactStandardLibrary; - import "../utils/Utils" prefix Utils_; + import "../../utils/Utils" prefix Utils_; // ─── State ────────────────────────────────────────────────────── diff --git a/contracts/src/multisig/ForwarderShielded.compact b/contracts/src/multisig/forwarder/ForwarderShielded.compact similarity index 99% rename from contracts/src/multisig/ForwarderShielded.compact rename to contracts/src/multisig/forwarder/ForwarderShielded.compact index 2dd473a3..f4c82449 100644 --- a/contracts/src/multisig/ForwarderShielded.compact +++ b/contracts/src/multisig/forwarder/ForwarderShielded.compact @@ -44,7 +44,7 @@ pragma language_version >= 0.23.0; */ module ForwarderShielded { import CompactStandardLibrary; - import "../utils/Utils" prefix Utils_; + import "../../utils/Utils" prefix Utils_; // ─── State ────────────────────────────────────────────────────── diff --git a/contracts/src/multisig/ForwarderUnshielded.compact b/contracts/src/multisig/forwarder/ForwarderUnshielded.compact similarity index 100% rename from contracts/src/multisig/ForwarderUnshielded.compact rename to contracts/src/multisig/forwarder/ForwarderUnshielded.compact diff --git a/contracts/src/multisig/presets/ShieldedMultiSig.compact b/contracts/src/multisig/presets/ShieldedMultiSig.compact index 8a2f8b5f..740833a1 100644 --- a/contracts/src/multisig/presets/ShieldedMultiSig.compact +++ b/contracts/src/multisig/presets/ShieldedMultiSig.compact @@ -27,9 +27,9 @@ pragma language_version >= 0.23.0; import CompactStandardLibrary; -import "../ProposalManager" prefix Proposal_; -import "../ShieldedTreasury" prefix Treasury_; -import "../SignerManager"> prefix Signer_; +import "../proposal/ProposalManager" prefix Proposal_; +import "../treasury/ShieldedTreasury" prefix Treasury_; +import "../signer/SignerManager"> prefix Signer_; // ─── State ─────────────────────────────────────────────────────────────── diff --git a/contracts/src/multisig/presets/ShieldedMultiSigV2.compact b/contracts/src/multisig/presets/ShieldedMultiSigV2.compact index 0c380dbe..a37c5d7f 100644 --- a/contracts/src/multisig/presets/ShieldedMultiSigV2.compact +++ b/contracts/src/multisig/presets/ShieldedMultiSigV2.compact @@ -22,9 +22,9 @@ pragma language_version >= 0.23.0; import CompactStandardLibrary; -import "../ProposalManager" prefix Proposal_; -import "../ShieldedTreasuryStateless" prefix Treasury_; -import "../SignerManager"> prefix Signer_; +import "../proposal/ProposalManager" prefix Proposal_; +import "../treasury/ShieldedTreasuryStateless" prefix Treasury_; +import "../signer/SignerManager"> prefix Signer_; // ─── Types ────────────────────────────────────────────────────── diff --git a/contracts/src/multisig/presets/ShieldedMultiSigV3.compact b/contracts/src/multisig/presets/ShieldedMultiSigV3.compact index 23d6ba81..fb6719c7 100644 --- a/contracts/src/multisig/presets/ShieldedMultiSigV3.compact +++ b/contracts/src/multisig/presets/ShieldedMultiSigV3.compact @@ -40,7 +40,7 @@ pragma language_version >= 0.23.0; import CompactStandardLibrary; -import "../Signer"> prefix Signer_; +import "../signer/Signer"> prefix Signer_; import "../../utils/Utils" prefix Utils_; // For testing export { ZswapCoinPublicKey }; @@ -115,6 +115,11 @@ constructor( Signer_initialize<3>(signerCommitments, 2); } +// TODO: the mint/burn token-issuance logic below is slated to move into a +// reusable `ShieldedToken` module so it can be +// composed independently, mirroring how `SignerManager` / `SignatureVerifier` are +// factored. Kept inlined here for now. + // ─── Mint ─────────────────────────────────────────────────────── /** diff --git a/contracts/src/multisig/presets/forwarder/ForwarderPrivate.compact b/contracts/src/multisig/presets/forwarder/ForwarderPrivate.compact index fb7fbd2e..235120bd 100644 --- a/contracts/src/multisig/presets/forwarder/ForwarderPrivate.compact +++ b/contracts/src/multisig/presets/forwarder/ForwarderPrivate.compact @@ -22,7 +22,7 @@ pragma language_version >= 0.23.0; */ import CompactStandardLibrary; -import "../../ForwarderPrivate" prefix ForwarderPrivate_; +import "../../forwarder/ForwarderPrivate" prefix ForwarderPrivate_; export { ShieldedCoinInfo, QualifiedShieldedCoinInfo, ShieldedSendResult, ZswapCoinPublicKey }; diff --git a/contracts/src/multisig/presets/forwarder/ForwarderShielded.compact b/contracts/src/multisig/presets/forwarder/ForwarderShielded.compact index 1c84247a..0193f0a8 100644 --- a/contracts/src/multisig/presets/forwarder/ForwarderShielded.compact +++ b/contracts/src/multisig/presets/forwarder/ForwarderShielded.compact @@ -22,7 +22,7 @@ pragma language_version >= 0.23.0; */ import CompactStandardLibrary; -import "../../ForwarderShielded" prefix Forwarder_; +import "../../forwarder/ForwarderShielded" prefix Forwarder_; export { ZswapCoinPublicKey, ContractAddress, ShieldedCoinInfo, Either }; diff --git a/contracts/src/multisig/presets/forwarder/ForwarderUnshielded.compact b/contracts/src/multisig/presets/forwarder/ForwarderUnshielded.compact index 4ec699f4..68ca2291 100644 --- a/contracts/src/multisig/presets/forwarder/ForwarderUnshielded.compact +++ b/contracts/src/multisig/presets/forwarder/ForwarderUnshielded.compact @@ -24,7 +24,7 @@ pragma language_version >= 0.23.0; */ import CompactStandardLibrary; -import "../../ForwarderUnshielded" prefix Forwarder_; +import "../../forwarder/ForwarderUnshielded" prefix Forwarder_; export { ContractAddress, UserAddress, Either }; diff --git a/contracts/src/multisig/ProposalManager.compact b/contracts/src/multisig/proposal/ProposalManager.compact similarity index 100% rename from contracts/src/multisig/ProposalManager.compact rename to contracts/src/multisig/proposal/ProposalManager.compact diff --git a/contracts/src/multisig/Signer.compact b/contracts/src/multisig/signer/Signer.compact similarity index 100% rename from contracts/src/multisig/Signer.compact rename to contracts/src/multisig/signer/Signer.compact diff --git a/contracts/src/multisig/SignerManager.compact b/contracts/src/multisig/signer/SignerManager.compact similarity index 100% rename from contracts/src/multisig/SignerManager.compact rename to contracts/src/multisig/signer/SignerManager.compact diff --git a/contracts/src/multisig/test/mocks/MockForwarderPrivate.compact b/contracts/src/multisig/test/mocks/MockForwarderPrivate.compact index c6a62ff9..01888277 100644 --- a/contracts/src/multisig/test/mocks/MockForwarderPrivate.compact +++ b/contracts/src/multisig/test/mocks/MockForwarderPrivate.compact @@ -8,7 +8,7 @@ pragma language_version >= 0.23.0; import CompactStandardLibrary; -import "../../ForwarderPrivate" prefix ForwarderPrivate_; +import "../../forwarder/ForwarderPrivate" prefix ForwarderPrivate_; export { ShieldedCoinInfo, QualifiedShieldedCoinInfo, ShieldedSendResult, ZswapCoinPublicKey }; diff --git a/contracts/src/multisig/test/mocks/MockForwarderShielded.compact b/contracts/src/multisig/test/mocks/MockForwarderShielded.compact index 2f87973a..568a73bf 100644 --- a/contracts/src/multisig/test/mocks/MockForwarderShielded.compact +++ b/contracts/src/multisig/test/mocks/MockForwarderShielded.compact @@ -8,7 +8,7 @@ pragma language_version >= 0.23.0; import CompactStandardLibrary; -import "../../ForwarderShielded" prefix Forwarder_; +import "../../forwarder/ForwarderShielded" prefix Forwarder_; export { ZswapCoinPublicKey, ContractAddress, ShieldedCoinInfo, Either }; diff --git a/contracts/src/multisig/test/mocks/MockForwarderUnshielded.compact b/contracts/src/multisig/test/mocks/MockForwarderUnshielded.compact index 268383b6..653a3614 100644 --- a/contracts/src/multisig/test/mocks/MockForwarderUnshielded.compact +++ b/contracts/src/multisig/test/mocks/MockForwarderUnshielded.compact @@ -8,7 +8,7 @@ pragma language_version >= 0.23.0; import CompactStandardLibrary; -import "../../ForwarderUnshielded" prefix Forwarder_; +import "../../forwarder/ForwarderUnshielded" prefix Forwarder_; export { ContractAddress, UserAddress, Either }; diff --git a/contracts/src/multisig/test/mocks/MockProposalManager.compact b/contracts/src/multisig/test/mocks/MockProposalManager.compact index caad8c4b..9fe92d6c 100644 --- a/contracts/src/multisig/test/mocks/MockProposalManager.compact +++ b/contracts/src/multisig/test/mocks/MockProposalManager.compact @@ -9,7 +9,7 @@ pragma language_version >= 0.23.0; import CompactStandardLibrary; -import "../../ProposalManager" prefix Proposal_; +import "../../proposal/ProposalManager" prefix Proposal_; export circuit shieldedUserRecipient(key: ZswapCoinPublicKey): Proposal_Recipient { return Proposal_shieldedUserRecipient(key); diff --git a/contracts/src/multisig/test/mocks/MockShieldedTreasury.compact b/contracts/src/multisig/test/mocks/MockShieldedTreasury.compact index f1dea07d..9b181553 100644 --- a/contracts/src/multisig/test/mocks/MockShieldedTreasury.compact +++ b/contracts/src/multisig/test/mocks/MockShieldedTreasury.compact @@ -9,7 +9,7 @@ pragma language_version >= 0.23.0; import CompactStandardLibrary; -import "../../ShieldedTreasury" prefix Treasury_; +import "../../treasury/ShieldedTreasury" prefix Treasury_; export circuit _deposit(coin: ShieldedCoinInfo): [] { return Treasury__deposit(coin); diff --git a/contracts/src/multisig/test/mocks/MockShieldedTreasuryStateless.compact b/contracts/src/multisig/test/mocks/MockShieldedTreasuryStateless.compact index 403ca14e..a2b1e742 100644 --- a/contracts/src/multisig/test/mocks/MockShieldedTreasuryStateless.compact +++ b/contracts/src/multisig/test/mocks/MockShieldedTreasuryStateless.compact @@ -8,7 +8,7 @@ pragma language_version >= 0.23.0; import CompactStandardLibrary; -import "../../ShieldedTreasuryStateless" prefix Treasury_; +import "../../treasury/ShieldedTreasuryStateless" prefix Treasury_; export circuit _deposit(coin: ShieldedCoinInfo): [] { Treasury__deposit(coin); diff --git a/contracts/src/multisig/test/mocks/MockSigner.compact b/contracts/src/multisig/test/mocks/MockSigner.compact index 30265778..355393d0 100644 --- a/contracts/src/multisig/test/mocks/MockSigner.compact +++ b/contracts/src/multisig/test/mocks/MockSigner.compact @@ -9,8 +9,8 @@ pragma language_version >= 0.23.0; import CompactStandardLibrary; -import "../../Signer"> prefix Signer_; -import "../../Signer">; +import "../../signer/Signer"> prefix Signer_; +import "../../signer/Signer">; export { ZswapCoinPublicKey, ContractAddress, Either, Maybe }; export { _signers, _signerCount, _threshold }; diff --git a/contracts/src/multisig/test/mocks/MockSignerManager.compact b/contracts/src/multisig/test/mocks/MockSignerManager.compact index 870aed0c..ba59e423 100644 --- a/contracts/src/multisig/test/mocks/MockSignerManager.compact +++ b/contracts/src/multisig/test/mocks/MockSignerManager.compact @@ -9,7 +9,7 @@ pragma language_version >= 0.23.0; import CompactStandardLibrary; -import "../../SignerManager"> prefix Signer_; +import "../../signer/SignerManager"> prefix Signer_; export { ZswapCoinPublicKey, ContractAddress, Either, Maybe }; diff --git a/contracts/src/multisig/test/mocks/MockUnshieldedTreasury.compact b/contracts/src/multisig/test/mocks/MockUnshieldedTreasury.compact index f2c1b732..6b8a6b21 100644 --- a/contracts/src/multisig/test/mocks/MockUnshieldedTreasury.compact +++ b/contracts/src/multisig/test/mocks/MockUnshieldedTreasury.compact @@ -9,7 +9,7 @@ pragma language_version >= 0.23.0; import CompactStandardLibrary; -import "../../UnshieldedTreasury" prefix Treasury_; +import "../../treasury/UnshieldedTreasury" prefix Treasury_; export circuit _deposit(color: Bytes<32>, amount: Uint<128>): [] { return Treasury__deposit(color, amount); diff --git a/contracts/src/multisig/ShieldedTreasury.compact b/contracts/src/multisig/treasury/ShieldedTreasury.compact similarity index 98% rename from contracts/src/multisig/ShieldedTreasury.compact rename to contracts/src/multisig/treasury/ShieldedTreasury.compact index 4a0130ea..4a29beb0 100644 --- a/contracts/src/multisig/ShieldedTreasury.compact +++ b/contracts/src/multisig/treasury/ShieldedTreasury.compact @@ -24,7 +24,7 @@ pragma language_version >= 0.23.0; */ module ShieldedTreasury { import CompactStandardLibrary; - import { selfAsRecipient, UINT128_MAX } from "../utils/Utils" prefix Utils_; + import { selfAsRecipient, UINT128_MAX } from "../../utils/Utils" prefix Utils_; // ─── State ────────────────────────────────────────────────────── diff --git a/contracts/src/multisig/ShieldedTreasuryStateless.compact b/contracts/src/multisig/treasury/ShieldedTreasuryStateless.compact similarity index 97% rename from contracts/src/multisig/ShieldedTreasuryStateless.compact rename to contracts/src/multisig/treasury/ShieldedTreasuryStateless.compact index 698536e3..96529afa 100644 --- a/contracts/src/multisig/ShieldedTreasuryStateless.compact +++ b/contracts/src/multisig/treasury/ShieldedTreasuryStateless.compact @@ -20,7 +20,7 @@ pragma language_version >= 0.23.0; */ module ShieldedTreasuryStateless { import CompactStandardLibrary; - import { selfAsRecipient } from "../utils/Utils" prefix Utils_; + import { selfAsRecipient } from "../../utils/Utils" prefix Utils_; // ─── Deposit ──────────────────────────────────────────────────── diff --git a/contracts/src/multisig/UnshieldedTreasury.compact b/contracts/src/multisig/treasury/UnshieldedTreasury.compact similarity index 98% rename from contracts/src/multisig/UnshieldedTreasury.compact rename to contracts/src/multisig/treasury/UnshieldedTreasury.compact index 222a2df7..18fbd5fa 100644 --- a/contracts/src/multisig/UnshieldedTreasury.compact +++ b/contracts/src/multisig/treasury/UnshieldedTreasury.compact @@ -19,7 +19,7 @@ pragma language_version >= 0.23.0; */ module UnshieldedTreasury { import CompactStandardLibrary; - import { UINT128_MAX } from "../utils/Utils" prefix Utils_; + import { UINT128_MAX } from "../../utils/Utils" prefix Utils_; // ─── State ────────────────────────────────────────────────────── From dc1d65837f2f784779a4b9025ca007cb6d03837d Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Mon, 22 Jun 2026 16:13:57 +0200 Subject: [PATCH 02/13] refactor(multisig): remove legacy SignerManager module SignerManager is an older generation of the same module as Signer: identical signer-set/threshold state, but without the init-safety guards (assertInitialized / re-init protection) and the custom-setup _setThreshold path that Signer adds. Remove the legacy module together with its mock, simulator, witnesses, and test. The next commit renames Signer into its place; splitting the removal out first lets git record that as a rename (preserving Signer's history) rather than a rewrite of this file. Refs: OpenZeppelin/compact-contracts#619 --- .../src/multisig/signer/SignerManager.compact | 205 ------------------ .../src/multisig/test/SignerManager.test.ts | 203 ----------------- .../test/mocks/MockSignerManager.compact | 50 ----- .../test/simulators/SignerManagerSimulator.ts | 107 --------- .../test/witnesses/SignerManagerWitnesses.ts | 6 - 5 files changed, 571 deletions(-) delete mode 100644 contracts/src/multisig/signer/SignerManager.compact delete mode 100644 contracts/src/multisig/test/SignerManager.test.ts delete mode 100644 contracts/src/multisig/test/mocks/MockSignerManager.compact delete mode 100644 contracts/src/multisig/test/simulators/SignerManagerSimulator.ts delete mode 100644 contracts/src/multisig/test/witnesses/SignerManagerWitnesses.ts diff --git a/contracts/src/multisig/signer/SignerManager.compact b/contracts/src/multisig/signer/SignerManager.compact deleted file mode 100644 index 9eb2d4f6..00000000 --- a/contracts/src/multisig/signer/SignerManager.compact +++ /dev/null @@ -1,205 +0,0 @@ -// SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.2.0 (multisig/SignerManager.compact) - -pragma language_version >= 0.23.0; - -/** - * @module SignerManager - * @description Manages signer registry, threshold enforcement, and signer - * validation for multisig governance contracts. - * - * Parameterized over the signer identity type `T`, allowing the consuming - * contract to choose the identity mechanism at import time. Common - * instantiations include: - * - * - `Either` for ownPublicKey()-based identity - * - `Bytes<32>` for commitment-based identity (e.g., hash of ECDSA public key) - * - `NativePoint` for Schnorr/MuSig aggregated key - * - * SignerManager does not resolve caller identity. It receives a validated - * caller from the contract layer and checks it against the registry. - * This separation allows the identity mechanism to change without - * modifying the module. - * - * Underscore-prefixed circuits (_addSigner, _removeSigner, - * _changeThreshold) have no access control enforcement. The consuming - * contract must gate these behind its own authorization policy. - */ -module SignerManager { - import CompactStandardLibrary; - - // ─── State ────────────────────────────────────────────────────────────────── - - export ledger _signers: Set; - export ledger _signerCount: Uint<8>; - export ledger _threshold: Uint<8>; - - // ─── Initialization ───────────────────────────────────────────────────────── - - /** - * @description Initializes the signer manager with the given threshold - * and an initial set of signers. - * Must be called in the contract's constructor. - * - * Requirements: - * - * - `thresh` must be greater than 0. - * - `signers` must not contain duplicates. - * - * @param {Vector} signers - The initial signer set. - * @param {Uint<8>} thresh - The minimum number of approvals required. - * - * @returns {[]} Empty tuple. - */ - export circuit initialize<#n>( - signers: Vector, - thresh: Uint<8> - ): [] { - assert(thresh > 0, "SignerManager: threshold must be > 0"); - _threshold = disclose(thresh); - - for (const signer of signers) { - _addSigner(signer); - } - - assert(_signerCount >= thresh, "SignerManager: threshold exceeds signer count"); - } - - // ─── Guards ───────────────────────────────────────────────────────────── - - /** - * @description Asserts that the given caller is an active signer. - * - * Requirements: - * - * - `caller` must be a member of the signers registry. - * - * @param {T} caller - The identity to validate. - * - * @returns {[]} Empty tuple. - */ - export circuit assertSigner(caller: T): [] { - assert(isSigner(caller), "SignerManager: not a signer"); - } - - /** - * @description Asserts that the given approval count meets the threshold. - * - * Requirements: - * - * - `approvalCount` must be >= threshold. - * - * @param {Uint<8>} approvalCount - The current number of approvals. - * - * @returns {[]} Empty tuple. - */ - export circuit assertThresholdMet(approvalCount: Uint<8>): [] { - assert(approvalCount >= _threshold, "SignerManager: threshold not met"); - } - - // ─── View ────────────────────────────────────────────────────────── - - /** - * @description Returns the current signer count. - * - * @returns {Uint<8>} The number of active signers. - */ - export circuit getSignerCount(): Uint<8> { - return _signerCount; - } - - /** - * @description Returns the approval threshold. - * - * @returns {Uint<8>} The threshold. - */ - export circuit getThreshold(): Uint<8> { - return _threshold; - } - - /** - * @description Returns whether the given account is an active signer. - * - * @param {T} account - The account to check. - * - * @returns {Boolean} True if the account is an active signer. - */ - export circuit isSigner(account: T): Boolean { - return _signers.member(disclose(account)); - } - - // ─── Signer Management ───────────────────────────────────────────────────── - - /** - * @description Adds a new signer to the registry. - * - * @notice Access control is NOT enforced here. - * The consuming contract must gate this behind its own - * authorization policy. - * - * Requirements: - * - * - `signer` must not already be an active signer. - * - * @param {T} signer - The signer to add. - * - * @returns {[]} Empty tuple. - */ - export circuit _addSigner(signer: T): [] { - assert( - !isSigner(signer), - "SignerManager: signer already active" - ); - - _signers.insert(disclose(signer)); - _signerCount = _signerCount + 1 as Uint<8>; - } - - /** - * @description Removes a signer from the registry. - * - * @notice Access control is NOT enforced here. - * The consuming contract must gate this behind its own - * authorization policy. - * - * Requirements: - * - * - `signer` must be an active signer. - * - Removal must not drop signer count below threshold. - * - * @param {T} signer - The signer to remove. - * - * @returns {[]} Empty tuple. - */ - export circuit _removeSigner(signer: T): [] { - assert(isSigner(signer), "SignerManager: not a signer"); - - const newCount = _signerCount - 1 as Uint<8>; - assert(newCount >= _threshold, "SignerManager: removal would breach threshold"); - - _signers.remove(disclose(signer)); - _signerCount = newCount; - } - - /** - * @description Updates the approval threshold. - * - * @notice Access control is NOT enforced here. - * The consuming contract must gate this behind its own - * authorization policy. - * - * Requirements: - * - * - `newThreshold` must be greater than 0. - * - `newThreshold` must not exceed the current signer count. - * - * @param {Uint<8>} newThreshold - The new minimum number of approvals required. - * - * @returns {[]} Empty tuple. - */ - export circuit _changeThreshold(newThreshold: Uint<8>): [] { - assert(newThreshold > 0, "SignerManager: threshold must be > 0"); - assert(newThreshold <= _signerCount, "SignerManager: threshold exceeds signer count"); - _threshold = disclose(newThreshold); - } -} diff --git a/contracts/src/multisig/test/SignerManager.test.ts b/contracts/src/multisig/test/SignerManager.test.ts deleted file mode 100644 index 9ecd2468..00000000 --- a/contracts/src/multisig/test/SignerManager.test.ts +++ /dev/null @@ -1,203 +0,0 @@ -import { beforeEach, describe, expect, it } from 'vitest'; -import * as utils from '#test-utils/address.js'; -import { - SignerManagerSimulator, - type SignerSet, -} from './simulators/SignerManagerSimulator.js'; - -const THRESHOLD = 2n; - -const [_SIGNER, Z_SIGNER] = utils.generateEitherPubKeyPair('SIGNER'); -const [_SIGNER2, Z_SIGNER2] = utils.generateEitherPubKeyPair('SIGNER2'); -const [_SIGNER3, Z_SIGNER3] = utils.generateEitherPubKeyPair('SIGNER3'); -const SIGNERS: SignerSet = [Z_SIGNER, Z_SIGNER2, Z_SIGNER3]; -const [_OTHER, Z_OTHER] = utils.generateEitherPubKeyPair('OTHER'); -const [_OTHER2, Z_OTHER2] = utils.generateEitherPubKeyPair('OTHER2'); - -let contract: SignerManagerSimulator; - -describe('SigningManager', () => { - describe('initialization', () => { - it('should fail with a threshold of zero', async () => { - await expect(SignerManagerSimulator.create(SIGNERS, 0n)).rejects.toThrow( - 'SignerManager: threshold must be > 0', - ); - }); - - it('should fail with duplicate signers', async () => { - const duplicateSigners: SignerSet = [Z_SIGNER, Z_SIGNER, Z_SIGNER2]; - await expect( - SignerManagerSimulator.create(duplicateSigners, THRESHOLD), - ).rejects.toThrow('SignerManager: signer already active'); - }); - - it('should initialize', async () => { - contract = await SignerManagerSimulator.create(SIGNERS, THRESHOLD); - - // Check thresh - expect(await contract.getThreshold()).toEqual(THRESHOLD); - - // Check signers - expect(await contract.getSignerCount()).toEqual(BigInt(SIGNERS.length)); - for (let i = 0; i < SIGNERS.length; i++) { - await contract.assertSigner(SIGNERS[i]); - } - }); - }); - - beforeEach(async () => { - contract = await SignerManagerSimulator.create(SIGNERS, THRESHOLD); - }); - - describe('assertSigner', () => { - it('should pass with good signer', async () => { - await contract.assertSigner(Z_SIGNER); - }); - - it('should fail with bad signer', async () => { - await expect(contract.assertSigner(Z_OTHER)).rejects.toThrow( - 'SignerManager: not a signer', - ); - }); - }); - - describe('assertThresholdMet', () => { - it('should pass when approvals equal threshold', async () => { - await contract.assertThresholdMet(THRESHOLD); - }); - - it('should pass when approvals exceed threshold', async () => { - await contract.assertThresholdMet(THRESHOLD + 1n); - }); - - it('should fail when approvals are below threshold', async () => { - await expect(contract.assertThresholdMet(THRESHOLD - 1n)).rejects.toThrow( - 'SignerManager: threshold not met', - ); - }); - - it('should fail with zero approvals', async () => { - await expect(contract.assertThresholdMet(0n)).rejects.toThrow( - 'SignerManager: threshold not met', - ); - }); - }); - - describe('isSigner', () => { - it('should return true for an active signer', async () => { - expect(await contract.isSigner(Z_SIGNER)).toEqual(true); - }); - - it('should return false for a non-signer', async () => { - expect(await contract.isSigner(Z_OTHER)).toEqual(false); - }); - }); - - describe('_addSigner', () => { - it('should add a new signer', async () => { - await contract._addSigner(Z_OTHER); - - expect(await contract.isSigner(Z_OTHER)).toEqual(true); - expect(await contract.getSignerCount()).toEqual( - BigInt(SIGNERS.length) + 1n, - ); - }); - - it('should fail when adding an existing signer', async () => { - await expect(contract._addSigner(Z_SIGNER)).rejects.toThrow( - 'SignerManager: signer already active', - ); - }); - - it('should add multiple new signers', async () => { - await contract._addSigner(Z_OTHER); - await contract._addSigner(Z_OTHER2); - - expect(await contract.isSigner(Z_OTHER)).toEqual(true); - expect(await contract.isSigner(Z_OTHER2)).toEqual(true); - expect(await contract.getSignerCount()).toEqual( - BigInt(SIGNERS.length) + 2n, - ); - }); - }); - - describe('_removeSigner', () => { - it('should remove an existing signer', async () => { - await contract._removeSigner(Z_SIGNER3); - - expect(await contract.isSigner(Z_SIGNER3)).toEqual(false); - expect(await contract.getSignerCount()).toEqual( - BigInt(SIGNERS.length) - 1n, - ); - }); - - it('should fail when removing a non-signer', async () => { - await expect(contract._removeSigner(Z_OTHER)).rejects.toThrow( - 'SignerManager: not a signer', - ); - }); - - it('should fail when removal would breach threshold', async () => { - // Remove one signer: count goes from 3 to 2, threshold is 2 — ok - await contract._removeSigner(Z_SIGNER3); - - // Remove another: count would go from 2 to 1, threshold is 2 — breach - await expect(contract._removeSigner(Z_SIGNER2)).rejects.toThrow( - 'SignerManager: removal would breach threshold', - ); - }); - - it('should allow removal after threshold is lowered', async () => { - await contract._changeThreshold(1n); - await contract._removeSigner(Z_SIGNER3); - await contract._removeSigner(Z_SIGNER2); - - expect(await contract.getSignerCount()).toEqual(1n); - expect(await contract.isSigner(Z_SIGNER)).toEqual(true); - expect(await contract.isSigner(Z_SIGNER2)).toEqual(false); - expect(await contract.isSigner(Z_SIGNER3)).toEqual(false); - }); - }); - - describe('_changeThreshold', () => { - it('should update the threshold', async () => { - await contract._changeThreshold(3n); - - expect(await contract.getThreshold()).toEqual(3n); - }); - - it('should allow lowering the threshold', async () => { - await contract._changeThreshold(1n); - - expect(await contract.getThreshold()).toEqual(1n); - }); - - it('should fail with a threshold of zero', async () => { - await expect(contract._changeThreshold(0n)).rejects.toThrow( - 'SignerManager: threshold must be > 0', - ); - }); - - it('should fail when threshold exceeds signer count', async () => { - await expect( - contract._changeThreshold(BigInt(SIGNERS.length) + 1n), - ).rejects.toThrow('SignerManager: threshold exceeds signer count'); - }); - - it('should allow threshold equal to signer count', async () => { - await contract._changeThreshold(BigInt(SIGNERS.length)); - - expect(await contract.getThreshold()).toEqual(BigInt(SIGNERS.length)); - }); - - it('should reflect new threshold in assertThresholdMet', async () => { - await contract._changeThreshold(3n); - - await expect(contract.assertThresholdMet(2n)).rejects.toThrow( - 'SignerManager: threshold not met', - ); - - await contract.assertThresholdMet(3n); - }); - }); -}); diff --git a/contracts/src/multisig/test/mocks/MockSignerManager.compact b/contracts/src/multisig/test/mocks/MockSignerManager.compact deleted file mode 100644 index ba59e423..00000000 --- a/contracts/src/multisig/test/mocks/MockSignerManager.compact +++ /dev/null @@ -1,50 +0,0 @@ -// SPDX-License-Identifier: MIT - -// WARNING: FOR TESTING PURPOSES ONLY. -// This contract exposes internal circuits and bypasses safety checks that the -// corresponding production contract relies on. DO NOT deploy or use this -// contract in any production application. - -pragma language_version >= 0.23.0; - -import CompactStandardLibrary; - -import "../../signer/SignerManager"> prefix Signer_; - -export { ZswapCoinPublicKey, ContractAddress, Either, Maybe }; - -constructor(signers: Vector<3, Either>, thresh: Uint<8>) { - Signer_initialize<3>(signers, thresh); -} - -export circuit assertSigner(caller: Either): [] { - return Signer_assertSigner(caller); -} - -export circuit assertThresholdMet(approvalCount: Uint<8>): [] { - return Signer_assertThresholdMet(approvalCount); -} - -export circuit getSignerCount(): Uint<8> { - return Signer_getSignerCount(); -} - -export circuit getThreshold(): Uint<8> { - return Signer_getThreshold(); -} - -export circuit isSigner(account: Either): Boolean { - return Signer_isSigner(account); -} - -export circuit _addSigner(signer: Either): [] { - return Signer__addSigner(signer); -} - -export circuit _removeSigner(signer: Either): [] { - return Signer__removeSigner(signer); -} - -export circuit _changeThreshold(newThreshold: Uint<8>): [] { - return Signer__changeThreshold(newThreshold); -} diff --git a/contracts/src/multisig/test/simulators/SignerManagerSimulator.ts b/contracts/src/multisig/test/simulators/SignerManagerSimulator.ts deleted file mode 100644 index be5ee9aa..00000000 --- a/contracts/src/multisig/test/simulators/SignerManagerSimulator.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { - createSimulator, - type SimulatorOptions, -} from '@openzeppelin/compact-simulator'; -import { - type ContractAddress, - type Either, - ledger, - Contract as MockSignerManager, - type ZswapCoinPublicKey, -} from '../../../../artifacts/MockSignerManager/contract/index.js'; -import { - SignerManagerPrivateState, - SignerManagerWitnesses, -} from '../witnesses/SignerManagerWitnesses.js'; - -/** - * A fixed set of exactly three signers, matching the - * `Vector<3, Either>` the underlying - * `MockSignerManager` constructor expects. - */ -export type SignerSet = readonly [ - Either, - Either, - Either, -]; - -/** - * Type constructor args - */ -type SignerManagerArgs = readonly [signers: SignerSet, thresh: bigint]; - -const SignerManagerSimulatorBase = createSimulator< - SignerManagerPrivateState, - ReturnType, - ReturnType, - MockSignerManager, - SignerManagerArgs ->({ - contractFactory: (witnesses) => - new MockSignerManager(witnesses), - defaultPrivateState: () => SignerManagerPrivateState, - contractArgs: (signers, thresh) => [signers, thresh], - ledgerExtractor: (state) => ledger(state), - witnessesFactory: () => SignerManagerWitnesses(), - artifactName: 'MockSignerManager', -}); - -/** - * SignerManager Simulator - */ -export class SignerManagerSimulator extends SignerManagerSimulatorBase { - static async create( - signers: SignerSet, - thresh: bigint, - options: SimulatorOptions< - SignerManagerPrivateState, - ReturnType - > = {}, - ): Promise { - // biome-ignore lint/complexity/noThisInStatic: super.create must keep the subclass `this` - return super.create( - [signers, thresh], - options, - ) as Promise; - } - - public assertSigner( - caller: Either, - ): Promise<[]> { - return this.circuits.impure.assertSigner(caller); - } - - public assertThresholdMet(approvalCount: bigint): Promise<[]> { - return this.circuits.impure.assertThresholdMet(approvalCount); - } - - public getSignerCount(): Promise { - return this.circuits.impure.getSignerCount(); - } - - public getThreshold(): Promise { - return this.circuits.impure.getThreshold(); - } - - public isSigner( - account: Either, - ): Promise { - return this.circuits.impure.isSigner(account); - } - - public _addSigner( - signer: Either, - ): Promise<[]> { - return this.circuits.impure._addSigner(signer); - } - - public _removeSigner( - signer: Either, - ): Promise<[]> { - return this.circuits.impure._removeSigner(signer); - } - - public _changeThreshold(newThreshold: bigint): Promise<[]> { - return this.circuits.impure._changeThreshold(newThreshold); - } -} diff --git a/contracts/src/multisig/test/witnesses/SignerManagerWitnesses.ts b/contracts/src/multisig/test/witnesses/SignerManagerWitnesses.ts deleted file mode 100644 index 7bf6a25a..00000000 --- a/contracts/src/multisig/test/witnesses/SignerManagerWitnesses.ts +++ /dev/null @@ -1,6 +0,0 @@ -// SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.2.0 (multisig/witnesses/SignerManagerWitnesses.ts) - -export type SignerManagerPrivateState = Record; -export const SignerManagerPrivateState: SignerManagerPrivateState = {}; -export const SignerManagerWitnesses = () => ({}); From 8c791da54f612126755d9ede05e16fd9913c5c01 Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Mon, 22 Jun 2026 16:15:45 +0200 Subject: [PATCH 03/13] refactor(multisig): rename Signer module to SignerManager Rename the hardened Signer into the SignerManager name freed by the previous commit, keeping the more descriptive name on the init-safe implementation. Git records this as a rename, so the module's history, blame, and log --follow carry over. * signer/Signer.compact -> signer/SignerManager.compact (module decl + assert prefix Signer: -> SignerManager:) * mock, simulator, witnesses, and test renamed to match * repoint the ShieldedMultiSigV3 import and migrate the preset tests to the new module name and messages (the init-safe module reports "threshold must not be zero" rather than the legacy "threshold must be > 0") compact:multisig compiles 22/22; multisig suite passes 249/249. Refs: OpenZeppelin/compact-contracts#619 --- .../presets/ShieldedMultiSigV3.compact | 2 +- .../{Signer.compact => SignerManager.compact} | 28 ++++----- .../multisig/test/ShieldedMultiSig.test.ts | 12 ++-- .../multisig/test/ShieldedMultiSigV2.test.ts | 4 +- .../multisig/test/ShieldedMultiSigV3.test.ts | 6 +- .../{Signer.test.ts => SignerManager.test.ts} | 60 +++++++++---------- ...gner.compact => MockSignerManager.compact} | 4 +- ...Simulator.ts => SignerManagerSimulator.ts} | 42 ++++++------- .../test/witnesses/SignerManagerWitnesses.ts | 6 ++ .../test/witnesses/SignerWitnesses.ts | 6 -- 10 files changed, 85 insertions(+), 85 deletions(-) rename contracts/src/multisig/signer/{Signer.compact => SignerManager.compact} (91%) rename contracts/src/multisig/test/{Signer.test.ts => SignerManager.test.ts} (85%) rename contracts/src/multisig/test/mocks/{MockSigner.compact => MockSignerManager.compact} (94%) rename contracts/src/multisig/test/simulators/{SignerSimulator.ts => SignerManagerSimulator.ts} (66%) create mode 100644 contracts/src/multisig/test/witnesses/SignerManagerWitnesses.ts delete mode 100644 contracts/src/multisig/test/witnesses/SignerWitnesses.ts diff --git a/contracts/src/multisig/presets/ShieldedMultiSigV3.compact b/contracts/src/multisig/presets/ShieldedMultiSigV3.compact index fb6719c7..c5f9ba62 100644 --- a/contracts/src/multisig/presets/ShieldedMultiSigV3.compact +++ b/contracts/src/multisig/presets/ShieldedMultiSigV3.compact @@ -40,7 +40,7 @@ pragma language_version >= 0.23.0; import CompactStandardLibrary; -import "../signer/Signer"> prefix Signer_; +import "../signer/SignerManager"> prefix Signer_; import "../../utils/Utils" prefix Utils_; // For testing export { ZswapCoinPublicKey }; diff --git a/contracts/src/multisig/signer/Signer.compact b/contracts/src/multisig/signer/SignerManager.compact similarity index 91% rename from contracts/src/multisig/signer/Signer.compact rename to contracts/src/multisig/signer/SignerManager.compact index 7a8207ec..cd98b3c8 100644 --- a/contracts/src/multisig/signer/Signer.compact +++ b/contracts/src/multisig/signer/SignerManager.compact @@ -1,10 +1,10 @@ // SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.2.0 (multisig/Signer.compact) +// OpenZeppelin Compact Contracts v0.2.0 (multisig/signer/SignerManager.compact) pragma language_version >= 0.23.0; /** - * @module Signer + * @module SignerManager * @description Manages signer registry, threshold enforcement, and signer * validation for multisig governance contracts. * @@ -15,7 +15,7 @@ pragma language_version >= 0.23.0; * - `Bytes<32>` for commitment-based identity (e.g., hash of ECDSA public key) * - `JubjubPoint` for Schnorr/MuSig aggregated key * - * The Signer module does not resolve caller identity. It receives a validated + * The SignerManager module does not resolve caller identity. It receives a validated * caller from the contract layer and checks it against the registry. * This separation allows the identity mechanism to change without * modifying the module. @@ -43,7 +43,7 @@ pragma language_version >= 0.23.0; * call `initialize` outside of the constructor context because * this could corrupt the signer set and threshold configuration. */ -module Signer { +module SignerManager { import CompactStandardLibrary; // ─── State ────────────────────────────────────────────────────────────────── @@ -110,7 +110,7 @@ module Signer { */ export circuit assertSigner(caller: T): [] { assertInitialized(); - assert(isSigner(caller), "Signer: not a signer"); + assert(isSigner(caller), "SignerManager: not a signer"); } /** @@ -129,8 +129,8 @@ module Signer { */ export circuit assertThresholdMet(approvalCount: Uint<8>): [] { assertInitialized(); - assert(_threshold != 0, "Signer: threshold not set"); - assert(approvalCount >= _threshold, "Signer: threshold not met"); + assert(_threshold != 0, "SignerManager: threshold not set"); + assert(approvalCount >= _threshold, "SignerManager: threshold not met"); } // ─── View ────────────────────────────────────────────────────────── @@ -200,7 +200,7 @@ module Signer { export circuit _addSigner(signer: T): [] { assert( !isSigner(signer), - "Signer: signer already active" + "SignerManager: signer already active" ); _signers.insert(disclose(signer)); @@ -225,10 +225,10 @@ module Signer { * @returns {[]} Empty tuple. */ export circuit _removeSigner(signer: T): [] { - assert(isSigner(signer), "Signer: not a signer"); + assert(isSigner(signer), "SignerManager: not a signer"); const newCount = _signerCount - 1 as Uint<8>; - assert(newCount >= _threshold, "Signer: removal would breach threshold"); + assert(newCount >= _threshold, "SignerManager: removal would breach threshold"); _signers.remove(disclose(signer)); _signerCount = newCount; @@ -252,7 +252,7 @@ module Signer { * @returns {[]} Empty tuple. */ export circuit _changeThreshold(newThreshold: Uint<8>): [] { - assert(newThreshold <= _signerCount, "Signer: threshold exceeds signer count"); + assert(newThreshold <= _signerCount, "SignerManager: threshold exceeds signer count"); _setThreshold(newThreshold); } @@ -278,7 +278,7 @@ module Signer { * @returns {[]} Empty tuple. */ export circuit _setThreshold(newThreshold: Uint<8>): [] { - assert(newThreshold != 0, "Signer: threshold must not be zero"); + assert(newThreshold != 0, "SignerManager: threshold must not be zero"); _threshold = disclose(newThreshold); } @@ -294,7 +294,7 @@ module Signer { * @return {[]} - Empty tuple. */ circuit assertInitialized(): [] { - assert(_isInitialized, "Signer: contract not initialized"); + assert(_isInitialized, "SignerManager: contract not initialized"); } /** @@ -307,6 +307,6 @@ module Signer { * @return {[]} - Empty tuple. */ circuit assertNotInitialized(): [] { - assert(!_isInitialized, "Signer: contract already initialized"); + assert(!_isInitialized, "SignerManager: contract already initialized"); } } diff --git a/contracts/src/multisig/test/ShieldedMultiSig.test.ts b/contracts/src/multisig/test/ShieldedMultiSig.test.ts index d1ee95b8..56a4243b 100644 --- a/contracts/src/multisig/test/ShieldedMultiSig.test.ts +++ b/contracts/src/multisig/test/ShieldedMultiSig.test.ts @@ -62,13 +62,13 @@ describe('ShieldedMultiSig', () => { it('should fail with zero threshold', async () => { await expect( ShieldedMultiSigSimulator.create(SIGNERS, 0n), - ).rejects.toThrow('SignerManager: threshold must be > 0'); + ).rejects.toThrow('SignerManagerManager: threshold must be > 0'); }); it('should fail with threshold exceeding signer count', async () => { await expect( ShieldedMultiSigSimulator.create(SIGNERS, 4n), - ).rejects.toThrow('SignerManager: threshold exceeds signer count'); + ).rejects.toThrow('SignerManagerManager: threshold exceeds signer count'); }); }); @@ -126,7 +126,7 @@ describe('ShieldedMultiSig', () => { multisig .as('OTHER') .createShieldedProposal(to, COLOR, PROPOSAL_AMOUNT), - ).rejects.toThrow('SignerManager: not a signer'); + ).rejects.toThrow('SignerManagerManager: not a signer'); }); it('should fail with zero amount', async () => { @@ -192,7 +192,7 @@ describe('ShieldedMultiSig', () => { it('should fail for non-signer', async () => { await expect( multisig.as('OTHER').approveProposal(proposalId), - ).rejects.toThrow('SignerManager: not a signer'); + ).rejects.toThrow('SignerManagerManager: not a signer'); }); it('should fail for double approval', async () => { @@ -242,7 +242,7 @@ describe('ShieldedMultiSig', () => { it('should fail for non-signer', async () => { await expect( multisig.as('OTHER').revokeApproval(proposalId), - ).rejects.toThrow('SignerManager: not a signer'); + ).rejects.toThrow('SignerManagerManager: not a signer'); }); it('should fail if not yet approved', async () => { @@ -338,7 +338,7 @@ describe('ShieldedMultiSig', () => { await multisig.as('SIGNER1').approveProposal(id2); await expect(multisig.executeShieldedProposal(id2)).rejects.toThrow( - 'SignerManager: threshold not met', + 'SignerManagerManager: threshold not met', ); }); diff --git a/contracts/src/multisig/test/ShieldedMultiSigV2.test.ts b/contracts/src/multisig/test/ShieldedMultiSigV2.test.ts index 3dea44c7..ca3bce8d 100644 --- a/contracts/src/multisig/test/ShieldedMultiSigV2.test.ts +++ b/contracts/src/multisig/test/ShieldedMultiSigV2.test.ts @@ -96,7 +96,7 @@ describe('ShieldedMultiSigV2', () => { SIGNER_COMMITMENTS, 0n, ), - ).rejects.toThrow('SignerManager: threshold must be > 0'); + ).rejects.toThrow('SignerManagerManager: threshold must be > 0'); }); it('should fail with threshold greater than 2', async () => { @@ -185,7 +185,7 @@ describe('ShieldedMultiSigV2', () => { [PK1, NON_SIGNER_PK], [DUMMY_SIG, DUMMY_SIG], ), - ).rejects.toThrow('SignerManager: not a signer'); + ).rejects.toThrow('SignerManagerManager: not a signer'); }); }); }); diff --git a/contracts/src/multisig/test/ShieldedMultiSigV3.test.ts b/contracts/src/multisig/test/ShieldedMultiSigV3.test.ts index dcf3900b..fe36c539 100644 --- a/contracts/src/multisig/test/ShieldedMultiSigV3.test.ts +++ b/contracts/src/multisig/test/ShieldedMultiSigV3.test.ts @@ -95,7 +95,7 @@ describe('ShieldedMultiSigV3', () => { TOKEN_DOMAIN, [COMMITMENT1, COMMITMENT1, COMMITMENT2], ), - ).rejects.toThrow('Signer: signer already active'); + ).rejects.toThrow('SignerManager: signer already active'); }); it('should store token domain', async () => { @@ -232,7 +232,7 @@ describe('ShieldedMultiSigV3', () => { [PK1, NON_SIGNER_PK], [DUMMY_SIG, DUMMY_SIG], ), - ).rejects.toThrow('Signer: not a signer'); + ).rejects.toThrow('SignerManager: not a signer'); }); it('should increment nonce after mint', async () => { @@ -338,7 +338,7 @@ describe('ShieldedMultiSigV3', () => { [PK1, NON_SIGNER_PK], [DUMMY_SIG, DUMMY_SIG], ), - ).rejects.toThrow('Signer: not a signer'); + ).rejects.toThrow('SignerManager: not a signer'); }); it('should reject wrong token color', async () => { diff --git a/contracts/src/multisig/test/Signer.test.ts b/contracts/src/multisig/test/SignerManager.test.ts similarity index 85% rename from contracts/src/multisig/test/Signer.test.ts rename to contracts/src/multisig/test/SignerManager.test.ts index da5af332..fd696773 100644 --- a/contracts/src/multisig/test/Signer.test.ts +++ b/contracts/src/multisig/test/SignerManager.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it } from 'vitest'; -import { SignerSimulator } from './simulators/SignerSimulator.js'; +import { SignerManagerSimulator } from './simulators/SignerManagerSimulator.js'; const THRESHOLD = 2n; const IS_INIT = true; @@ -12,13 +12,13 @@ const SIGNERS = [SIGNER, SIGNER2, SIGNER3]; const OTHER = new Uint8Array(32).fill(4); const OTHER2 = new Uint8Array(32).fill(5); -let contract: SignerSimulator; +let contract: SignerManagerSimulator; -describe('Signer', () => { +describe('SignerManager', () => { describe('when not initialized', () => { beforeEach(async () => { const isNotInit = false; - contract = await SignerSimulator.create(SIGNERS, 0n, isNotInit); + contract = await SignerManagerSimulator.create(SIGNERS, 0n, isNotInit); }); const circuitsRequiringInit: [string, unknown[]][] = [ @@ -33,11 +33,11 @@ describe('Signer', () => { )('%s should fail', async (circuitName, args) => { await expect( ( - contract[circuitName as keyof SignerSimulator] as ( + contract[circuitName as keyof SignerManagerSimulator] as ( ...a: unknown[] ) => Promise )(...args), - ).rejects.toThrow('Signer: contract not initialized'); + ).rejects.toThrow('SignerManager: contract not initialized'); }); it('isSigner should succeed (no init guard)', async () => { @@ -48,25 +48,25 @@ describe('Signer', () => { describe('initialization', () => { it('should fail with a threshold of zero', async () => { await expect( - SignerSimulator.create(SIGNERS, 0n, IS_INIT), - ).rejects.toThrow('Signer: threshold must not be zero'); + SignerManagerSimulator.create(SIGNERS, 0n, IS_INIT), + ).rejects.toThrow('SignerManager: threshold must not be zero'); }); it('should fail when threshold exceeds signer count', async () => { await expect( - SignerSimulator.create(SIGNERS, BigInt(SIGNERS.length) + 1n, IS_INIT), - ).rejects.toThrow('Signer: threshold exceeds signer count'); + SignerManagerSimulator.create(SIGNERS, BigInt(SIGNERS.length) + 1n, IS_INIT), + ).rejects.toThrow('SignerManager: threshold exceeds signer count'); }); it('should fail with duplicate signers', async () => { const duplicateSigners = [SIGNER, SIGNER, SIGNER2]; await expect( - SignerSimulator.create(duplicateSigners, THRESHOLD, IS_INIT), - ).rejects.toThrow('Signer: signer already active'); + SignerManagerSimulator.create(duplicateSigners, THRESHOLD, IS_INIT), + ).rejects.toThrow('SignerManager: signer already active'); }); it('should initialize with threshold equal to signer count', async () => { - const contract = await SignerSimulator.create( + const contract = await SignerManagerSimulator.create( SIGNERS, BigInt(SIGNERS.length), IS_INIT, @@ -75,7 +75,7 @@ describe('Signer', () => { }); it('should initialize', async () => { - contract = await SignerSimulator.create(SIGNERS, THRESHOLD, IS_INIT); + contract = await SignerManagerSimulator.create(SIGNERS, THRESHOLD, IS_INIT); expect(await contract.getThreshold()).toEqual(THRESHOLD); expect(await contract.getSignerCount()).toEqual(BigInt(SIGNERS.length)); @@ -85,15 +85,15 @@ describe('Signer', () => { }); it('should fail when initialized twice', async () => { - contract = await SignerSimulator.create(SIGNERS, THRESHOLD, IS_INIT); + contract = await SignerManagerSimulator.create(SIGNERS, THRESHOLD, IS_INIT); await expect(contract.initialize(SIGNERS, THRESHOLD)).rejects.toThrow( - 'Signer: contract already initialized', + 'SignerManager: contract already initialized', ); }); }); beforeEach(async () => { - contract = await SignerSimulator.create(SIGNERS, THRESHOLD, IS_INIT); + contract = await SignerManagerSimulator.create(SIGNERS, THRESHOLD, IS_INIT); }); describe('assertSigner', () => { @@ -103,7 +103,7 @@ describe('Signer', () => { it('should fail with bad signer', async () => { await expect(contract.assertSigner(OTHER)).rejects.toThrow( - 'Signer: not a signer', + 'SignerManager: not a signer', ); }); }); @@ -119,13 +119,13 @@ describe('Signer', () => { it('should fail when approvals are below threshold', async () => { await expect(contract.assertThresholdMet(THRESHOLD - 1n)).rejects.toThrow( - 'Signer: threshold not met', + 'SignerManager: threshold not met', ); }); it('should fail with zero approvals', async () => { await expect(contract.assertThresholdMet(0n)).rejects.toThrow( - 'Signer: threshold not met', + 'SignerManager: threshold not met', ); }); }); @@ -190,7 +190,7 @@ describe('Signer', () => { await contract._addSigner(OTHER); await expect(contract._addSigner(OTHER)).rejects.toThrow( - 'Signer: signer already active', + 'SignerManager: signer already active', ); }); @@ -228,7 +228,7 @@ describe('Signer', () => { it('should fail when removing a non-signer', async () => { await expect(contract._removeSigner(OTHER)).rejects.toThrow( - 'Signer: not a signer', + 'SignerManager: not a signer', ); }); @@ -236,7 +236,7 @@ describe('Signer', () => { await contract._removeSigner(SIGNER3); await expect(contract._removeSigner(SIGNER2)).rejects.toThrow( - 'Signer: removal would breach threshold', + 'SignerManager: removal would breach threshold', ); }); @@ -281,14 +281,14 @@ describe('Signer', () => { it('should fail with a threshold of zero', async () => { await expect(contract._changeThreshold(0n)).rejects.toThrow( - 'Signer: threshold must not be zero', + 'SignerManager: threshold must not be zero', ); }); it('should fail when threshold exceeds signer count', async () => { await expect( contract._changeThreshold(BigInt(SIGNERS.length) + 1n), - ).rejects.toThrow('Signer: threshold exceeds signer count'); + ).rejects.toThrow('SignerManager: threshold exceeds signer count'); }); it('should allow threshold equal to signer count', async () => { @@ -301,7 +301,7 @@ describe('Signer', () => { await contract._changeThreshold(3n); await expect(contract.assertThresholdMet(2n)).rejects.toThrow( - 'Signer: threshold not met', + 'SignerManager: threshold not met', ); await contract.assertThresholdMet(3n); @@ -311,7 +311,7 @@ describe('Signer', () => { describe('_setThreshold', () => { beforeEach(async () => { const isNotInit = false; - contract = await SignerSimulator.create(SIGNERS, 0n, isNotInit); + contract = await SignerManagerSimulator.create(SIGNERS, 0n, isNotInit); }); it('should have an empty state', async () => { @@ -337,7 +337,7 @@ describe('Signer', () => { it('should fail with zero threshold', async () => { await expect(contract._setThreshold(0n)).rejects.toThrow( - 'Signer: threshold must not be zero', + 'SignerManager: threshold must not be zero', ); }); }); @@ -345,7 +345,7 @@ describe('Signer', () => { describe('custom setup flow when not initialized', () => { beforeEach(async () => { const isNotInit = false; - contract = await SignerSimulator.create(SIGNERS, 0n, isNotInit); + contract = await SignerManagerSimulator.create(SIGNERS, 0n, isNotInit); }); it('should have no signers by default', async () => { @@ -379,7 +379,7 @@ describe('Signer', () => { it('should fail _changeThreshold before signers are added', async () => { await expect(contract._changeThreshold(2n)).rejects.toThrow( - 'Signer: threshold exceeds signer count', + 'SignerManager: threshold exceeds signer count', ); }); }); diff --git a/contracts/src/multisig/test/mocks/MockSigner.compact b/contracts/src/multisig/test/mocks/MockSignerManager.compact similarity index 94% rename from contracts/src/multisig/test/mocks/MockSigner.compact rename to contracts/src/multisig/test/mocks/MockSignerManager.compact index 355393d0..9bce8484 100644 --- a/contracts/src/multisig/test/mocks/MockSigner.compact +++ b/contracts/src/multisig/test/mocks/MockSignerManager.compact @@ -9,8 +9,8 @@ pragma language_version >= 0.23.0; import CompactStandardLibrary; -import "../../signer/Signer"> prefix Signer_; -import "../../signer/Signer">; +import "../../signer/SignerManager"> prefix Signer_; +import "../../signer/SignerManager">; export { ZswapCoinPublicKey, ContractAddress, Either, Maybe }; export { _signers, _signerCount, _threshold }; diff --git a/contracts/src/multisig/test/simulators/SignerSimulator.ts b/contracts/src/multisig/test/simulators/SignerManagerSimulator.ts similarity index 66% rename from contracts/src/multisig/test/simulators/SignerSimulator.ts rename to contracts/src/multisig/test/simulators/SignerManagerSimulator.ts index ef3f3948..73e5bd66 100644 --- a/contracts/src/multisig/test/simulators/SignerSimulator.ts +++ b/contracts/src/multisig/test/simulators/SignerManagerSimulator.ts @@ -4,55 +4,55 @@ import { } from '@openzeppelin/compact-simulator'; import { ledger, - Contract as MockSigner, -} from '../../../../artifacts/MockSigner/contract/index.js'; + Contract as MockSignerManager, +} from '../../../../artifacts/MockSignerManager/contract/index.js'; import { - SignerPrivateState, - SignerWitnesses, -} from '../witnesses/SignerWitnesses.js'; + SignerManagerPrivateState, + SignerManagerWitnesses, +} from '../witnesses/SignerManagerWitnesses.js'; /** * Type constructor args */ -type SignerArgs = readonly [ +type SignerManagerArgs = readonly [ signers: Uint8Array[], thresh: bigint, isInit: boolean, ]; -const SignerSimulatorBase = createSimulator< - SignerPrivateState, +const SignerManagerSimulatorBase = createSimulator< + SignerManagerPrivateState, ReturnType, - ReturnType, - MockSigner, - SignerArgs + ReturnType, + MockSignerManager, + SignerManagerArgs >({ - contractFactory: (witnesses) => new MockSigner(witnesses), - defaultPrivateState: () => SignerPrivateState, + contractFactory: (witnesses) => new MockSignerManager(witnesses), + defaultPrivateState: () => SignerManagerPrivateState, contractArgs: (signers, thresh, isInit) => [signers, thresh, isInit], ledgerExtractor: (state) => ledger(state), - witnessesFactory: () => SignerWitnesses(), - artifactName: 'MockSigner', + witnessesFactory: () => SignerManagerWitnesses(), + artifactName: 'MockSignerManager', }); /** - * Signer Simulator + * SignerManager Simulator */ -export class SignerSimulator extends SignerSimulatorBase { +export class SignerManagerSimulator extends SignerManagerSimulatorBase { static async create( signers: Uint8Array[], thresh: bigint, isInit: boolean, options: SimulatorOptions< - SignerPrivateState, - ReturnType + SignerManagerPrivateState, + ReturnType > = {}, - ): Promise { + ): Promise { // biome-ignore lint/complexity/noThisInStatic: super.create must keep the subclass `this` return super.create( [signers, thresh, isInit], options, - ) as Promise; + ) as Promise; } public initialize(signers: Uint8Array[], thresh: bigint): Promise<[]> { diff --git a/contracts/src/multisig/test/witnesses/SignerManagerWitnesses.ts b/contracts/src/multisig/test/witnesses/SignerManagerWitnesses.ts new file mode 100644 index 00000000..7bf6a25a --- /dev/null +++ b/contracts/src/multisig/test/witnesses/SignerManagerWitnesses.ts @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.2.0 (multisig/witnesses/SignerManagerWitnesses.ts) + +export type SignerManagerPrivateState = Record; +export const SignerManagerPrivateState: SignerManagerPrivateState = {}; +export const SignerManagerWitnesses = () => ({}); diff --git a/contracts/src/multisig/test/witnesses/SignerWitnesses.ts b/contracts/src/multisig/test/witnesses/SignerWitnesses.ts deleted file mode 100644 index 1decffc9..00000000 --- a/contracts/src/multisig/test/witnesses/SignerWitnesses.ts +++ /dev/null @@ -1,6 +0,0 @@ -// SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.2.0 (multisig/witnesses/SignerWitnesses.ts) - -export type SignerPrivateState = Record; -export const SignerPrivateState: SignerPrivateState = {}; -export const SignerWitnesses = () => ({}); From 528077b4eb9fa07c42eaa19888dea727dc934cb1 Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Wed, 24 Jun 2026 17:51:11 +0200 Subject: [PATCH 04/13] refactor(multisig): regroup into examples and root primitives Reshape the multisig package per the team layout review: reusable primitives sit at the package root, the composable behaviour modules become example contracts, and presets are named for what they do instead of a version number. * Root primitives: move SignerManager to the package root and add the extracted SignatureVerifier (commitment signer registry + threshold ECDSA-commitment verification), the single owner of the registry the signature presets share. * examples/: house the behaviour modules (SignatureTreasury, SignatureMintBurn, ProposalTreasury) and the three forwarder example contracts. * Presets named for behaviour: ShieldedMultiSigV2 becomes NativeShieldedStatelessTreasury, ShieldedMultiSigV3 becomes NativeShieldedMintBurn, ShieldedMultiSig becomes NativeShieldedProposal; add NativeShieldedTokenVault (mint/burn plus treasury under one signer set, the no-C2C composition). * Rename treasury and forwarder modules to NativeShielded / Private naming and repoint every import path, including the test mocks. Compiles green under SKIP_ZK (28/28 multisig contracts). The .ts test stacks (specs, simulators, witnesses) are renamed on disk but their rewiring to the new names is deferred to a follow-up. --- .../src/multisig/SignatureVerifier.compact | 216 ++++++++++ .../{signer => }/SignerManager.compact | 2 +- .../NativeShieldedForwarder.compact} | 6 +- .../NativeUnshieldedForwarder.compact} | 8 +- .../PrivateNativeShieldedForwarder.compact} | 20 +- .../examples/ProposalTreasury.compact | 219 ++++++++++ .../examples/SignatureMintBurn.compact | 186 +++++++++ .../examples/SignatureTreasury.compact | 127 ++++++ ...ompact => NativeShieldedForwarder.compact} | 12 +- ...pact => NativeUnshieldedForwarder.compact} | 12 +- ...=> PrivateNativeShieldedForwarder.compact} | 24 +- .../presets/NativeShieldedMintBurn.compact | 99 +++++ .../presets/NativeShieldedProposal.compact | 123 ++++++ .../NativeShieldedStatelessTreasury.compact | 88 +++++ .../presets/NativeShieldedTokenVault.compact | 137 +++++++ .../multisig/presets/ShieldedMultiSig.compact | 236 ----------- .../presets/ShieldedMultiSigV2.compact | 280 ------------- .../presets/ShieldedMultiSigV3.compact | 373 ------------------ .../test/mocks/MockForwarderPrivate.compact | 2 +- .../test/mocks/MockForwarderShielded.compact | 2 +- .../mocks/MockForwarderUnshielded.compact | 2 +- .../test/mocks/MockShieldedTreasury.compact | 2 +- .../MockShieldedTreasuryStateless.compact | 2 +- .../test/mocks/MockSignatureVerifier.compact | 40 ++ .../test/mocks/MockSignerManager.compact | 4 +- .../test/mocks/MockUnshieldedTreasury.compact | 2 +- ...compact => NativeShieldedTreasury.compact} | 14 +- ...> NativeShieldedTreasuryStateless.compact} | 6 +- ...mpact => NativeUnshieldedTreasury.compact} | 10 +- 29 files changed, 1300 insertions(+), 954 deletions(-) create mode 100644 contracts/src/multisig/SignatureVerifier.compact rename contracts/src/multisig/{signer => }/SignerManager.compact (99%) rename contracts/src/multisig/{presets/forwarder/ForwarderShielded.compact => examples/NativeShieldedForwarder.compact} (89%) rename contracts/src/multisig/{presets/forwarder/ForwarderUnshielded.compact => examples/NativeUnshieldedForwarder.compact} (86%) rename contracts/src/multisig/{presets/forwarder/ForwarderPrivate.compact => examples/PrivateNativeShieldedForwarder.compact} (87%) create mode 100644 contracts/src/multisig/examples/ProposalTreasury.compact create mode 100644 contracts/src/multisig/examples/SignatureMintBurn.compact create mode 100644 contracts/src/multisig/examples/SignatureTreasury.compact rename contracts/src/multisig/forwarder/{ForwarderShielded.compact => NativeShieldedForwarder.compact} (93%) rename contracts/src/multisig/forwarder/{ForwarderUnshielded.compact => NativeUnshieldedForwarder.compact} (93%) rename contracts/src/multisig/forwarder/{ForwarderPrivate.compact => PrivateNativeShieldedForwarder.compact} (89%) create mode 100644 contracts/src/multisig/presets/NativeShieldedMintBurn.compact create mode 100644 contracts/src/multisig/presets/NativeShieldedProposal.compact create mode 100644 contracts/src/multisig/presets/NativeShieldedStatelessTreasury.compact create mode 100644 contracts/src/multisig/presets/NativeShieldedTokenVault.compact delete mode 100644 contracts/src/multisig/presets/ShieldedMultiSig.compact delete mode 100644 contracts/src/multisig/presets/ShieldedMultiSigV2.compact delete mode 100644 contracts/src/multisig/presets/ShieldedMultiSigV3.compact create mode 100644 contracts/src/multisig/test/mocks/MockSignatureVerifier.compact rename contracts/src/multisig/treasury/{ShieldedTreasury.compact => NativeShieldedTreasury.compact} (93%) rename contracts/src/multisig/treasury/{ShieldedTreasuryStateless.compact => NativeShieldedTreasuryStateless.compact} (93%) rename contracts/src/multisig/treasury/{UnshieldedTreasury.compact => NativeUnshieldedTreasury.compact} (93%) diff --git a/contracts/src/multisig/SignatureVerifier.compact b/contracts/src/multisig/SignatureVerifier.compact new file mode 100644 index 00000000..b6586472 --- /dev/null +++ b/contracts/src/multisig/SignatureVerifier.compact @@ -0,0 +1,216 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.2.0 (multisig/SignatureVerifier.compact) + +pragma language_version >= 0.23.0; + +/** + * @module SignatureVerifier + * @description Threshold ECDSA-commitment signature verification for multisig + * contracts that collect approvals off-chain. + * + * Signers are identified on-chain by commitments — `persistentHash` of an ECDSA + * public key with an instance salt and a domain separator — held in the + * `SignerManager>` registry. `verify` checks a parallel vector of + * public keys and signatures in a single transaction: each key is hashed into a + * commitment, checked for membership and duplicates, and its signature + * validated, with the valid count folded against the threshold. + * + * This module owns the commitment signer registry: it is the sole importer of + * `SignerManager>` for the signature flow and re-exposes the signer + * surface (`initialize`, `getSignerCount`, `getThreshold`, `isSigner`). + * Consuming contracts import only this module — not `SignerManager` directly — + * so there is a single registry. (Compact only shares a module's ledger state + * across imports that use the same import-path string, so a co-import from a + * different directory would create a second, empty registry.) + * + * @notice ECDSA verification is stubbed (`stubVerifySignature` always returns + * true). Replace it with `ecdsaVerify` once the Compact ECDSA primitive is + * available. + * + * @notice Duplicate detection compares each commitment against the previous one + * only, which is sufficient for at most 2 signers. Larger signer sets need a + * different uniqueness mechanism (sorted commitments or a bitmap). + */ +module SignatureVerifier { + import CompactStandardLibrary; + import "./SignerManager"> prefix Signer_; + + // ─── Types ────────────────────────────────────────────────────── + + /** + * @description Accumulator for fold-based signature verification. Threads the + * valid count, previous commitment (for duplicate detection), and message hash + * through each iteration. + */ + export struct VerificationState { + validCount: Uint<8>, + prevCommitment: Bytes<32>, + msgHash: Bytes<32> + } + + /** + * @description Input to `persistentHash` for computing signer commitments. + * Combines the ECDSA public key with an instance-specific salt and a domain + * separator to produce a unique, unlinkable commitment. + */ + export struct SignerCommitmentInput { + pk: Bytes<64>, + salt: Bytes<32>, + domain: Bytes<32> + } + + // ─── State ────────────────────────────────────────────────────── + + export ledger _instanceSalt: Bytes<32>; + + // ─── Setup ────────────────────────────────────────────────────── + + /** + * @description Initializes the commitment signer registry and the instance + * salt. Should be called once from the consuming contract's constructor. + * + * @param {Bytes<32>} salt - Cryptographically random instance salt. + * @param {Vector>} signers - The signer commitments. + * @param {Uint<8>} thresh - The minimum number of approvals required. + * @returns {[]} Empty tuple. + */ + export circuit initialize<#n>( + salt: Bytes<32>, + signers: Vector>, + thresh: Uint<8> + ): [] { + _instanceSalt = disclose(salt); + Signer_initialize(signers, thresh); + } + + // ─── Verification ─────────────────────────────────────────────── + + /** + * @description Verifies a parallel vector of public keys and signatures and + * asserts the threshold is met. Each key is hashed into a commitment, checked + * for duplicates and registry membership, and its signature validated against + * `msgHash`; the valid count is then checked against the threshold. + * + * @notice Duplicate detection is correct for at most 2 signers (see module + * notice). + * + * Requirements: + * + * - Every public key must hash to a registered signer commitment. + * - Every signature must be valid over `msgHash`. + * - Signers must not be duplicates. + * - Valid count must meet the threshold. + * + * @param {Bytes<32>} msgHash - The message hash signers signed off-chain. + * @param {Vector>} pubkeys - ECDSA public keys of approving signers. + * @param {Vector>} signatures - Signatures over `msgHash`. + * @returns {[]} Empty tuple. + */ + export circuit verify<#n>( + msgHash: Bytes<32>, + pubkeys: Vector>, + signatures: Vector> + ): [] { + const initialState = VerificationState { + validCount: 0 as Uint<8>, + prevCommitment: pad(32, ""), + msgHash: msgHash + }; + + const finalState = fold(verifySignature, initialState, pubkeys, signatures); + Signer_assertThresholdMet(finalState.validCount); + } + + /** + * @description Computes a signer commitment from an ECDSA public key. Pure — + * callable off-chain by the deployer to compute the constructor commitments. + * + * The commitment is `persistentHash(pk, salt, "multisig:signer:")`, where the + * salt is instance-specific (prevents cross-contract correlation) and the + * domain provides separation. + * + * @param {Bytes<64>} pk - The ECDSA public key. + * @param {Bytes<32>} salt - The instance salt. + * @returns {Bytes<32>} The signer commitment. + */ + export pure circuit _calculateSignerId(pk: Bytes<64>, salt: Bytes<32>): Bytes<32> { + return persistentHash(SignerCommitmentInput { + pk: pk, + salt: salt, + domain: pad(32, "multisig:signer:") + }); + } + + // ─── View ─────────────────────────────────────────────────────── + + /** + * @description Returns the number of registered signers. + * @returns {Uint<8>} The signer count. + */ + export circuit getSignerCount(): Uint<8> { + return Signer_getSignerCount(); + } + + /** + * @description Returns the approval threshold. + * @returns {Uint<8>} The threshold. + */ + export circuit getThreshold(): Uint<8> { + return Signer_getThreshold(); + } + + /** + * @description Returns whether the given commitment is a registered signer. + * @param {Bytes<32>} account - The commitment to check. + * @returns {Boolean} True if registered. + */ + export circuit isSigner(account: Bytes<32>): Boolean { + return Signer_isSigner(account); + } + + // ─── Internal ─────────────────────────────────────────────────── + + /** + * @description Fold callback. Verifies one signer's approval: derives the + * commitment, rejects duplicates against the previous commitment, checks + * registry membership, and validates the signature. + * + * @param {VerificationState} state - Accumulator threaded through fold. + * @param {Bytes<64>} pubkey - The signer's ECDSA public key. + * @param {Bytes<64>} signature - The signer's signature over `msgHash`. + * @returns {VerificationState} Updated accumulator. + */ + circuit verifySignature( + state: VerificationState, + pubkey: Bytes<64>, + signature: Bytes<64> + ): VerificationState { + const commitment = _calculateSignerId(pubkey, _instanceSalt); + + // Duplicate detection — sufficient for 2 signers only + assert(commitment != state.prevCommitment, "SignatureVerifier: duplicate signer"); + + Signer_assertSigner(commitment); + + // TODO: Replace with ecdsaVerify when the Compact ECDSA primitive is available + assert(stubVerifySignature(pubkey, state.msgHash, signature), "SignatureVerifier: invalid signature"); + + return VerificationState { + validCount: state.validCount + 1 as Uint<8>, + prevCommitment: commitment, + msgHash: state.msgHash + }; + } + + /** + * @description Stub for ECDSA signature verification. Always returns true. + * MUST be replaced before any non-test deployment. + */ + circuit stubVerifySignature( + pubkey: Bytes<64>, + msgHash: Bytes<32>, + signature: Bytes<64> + ): Boolean { + return true; + } +} diff --git a/contracts/src/multisig/signer/SignerManager.compact b/contracts/src/multisig/SignerManager.compact similarity index 99% rename from contracts/src/multisig/signer/SignerManager.compact rename to contracts/src/multisig/SignerManager.compact index cd98b3c8..bd47b094 100644 --- a/contracts/src/multisig/signer/SignerManager.compact +++ b/contracts/src/multisig/SignerManager.compact @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.2.0 (multisig/signer/SignerManager.compact) +// OpenZeppelin Compact Contracts v0.2.0 (multisig/SignerManager.compact) pragma language_version >= 0.23.0; diff --git a/contracts/src/multisig/presets/forwarder/ForwarderShielded.compact b/contracts/src/multisig/examples/NativeShieldedForwarder.compact similarity index 89% rename from contracts/src/multisig/presets/forwarder/ForwarderShielded.compact rename to contracts/src/multisig/examples/NativeShieldedForwarder.compact index 0193f0a8..f0081e80 100644 --- a/contracts/src/multisig/presets/forwarder/ForwarderShielded.compact +++ b/contracts/src/multisig/examples/NativeShieldedForwarder.compact @@ -1,10 +1,10 @@ // SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.2.0 (multisig/presets/forwarder/ForwarderShielded.compact) +// OpenZeppelin Compact Contracts v0.2.0 (multisig/examples/NativeShieldedForwarder.compact) pragma language_version >= 0.23.0; /** - * @title ForwarderShielded + * @title NativeShieldedForwarder (formerly ForwarderShielded) * @description Public-parent forwarder for shielded coins. Receives a * shielded coin and atomically forwards it to the configured parent * recipient, a coin public key. @@ -22,7 +22,7 @@ pragma language_version >= 0.23.0; */ import CompactStandardLibrary; -import "../../forwarder/ForwarderShielded" prefix Forwarder_; +import "../forwarder/NativeShieldedForwarder" prefix Forwarder_; export { ZswapCoinPublicKey, ContractAddress, ShieldedCoinInfo, Either }; diff --git a/contracts/src/multisig/presets/forwarder/ForwarderUnshielded.compact b/contracts/src/multisig/examples/NativeUnshieldedForwarder.compact similarity index 86% rename from contracts/src/multisig/presets/forwarder/ForwarderUnshielded.compact rename to contracts/src/multisig/examples/NativeUnshieldedForwarder.compact index 68ca2291..dea6572f 100644 --- a/contracts/src/multisig/presets/forwarder/ForwarderUnshielded.compact +++ b/contracts/src/multisig/examples/NativeUnshieldedForwarder.compact @@ -1,17 +1,17 @@ // SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.2.0 (multisig/presets/forwarder/ForwarderUnshielded.compact) +// OpenZeppelin Compact Contracts v0.2.0 (multisig/examples/NativeUnshieldedForwarder.compact) pragma language_version >= 0.23.0; /** - * @title ForwarderUnshielded + * @title NativeUnshieldedForwarder (formerly ForwarderUnshielded) * @description Public-parent forwarder for unshielded coins. Receives * an unshielded amount of a given color and atomically forwards it to * the configured parent recipient, a user address. * * Unshielded transfers are publicly visible on the chain: depositor, * recipient, color, and amount all appear on the public transcript. - * Use `ForwarderShielded` instead when the deposit kind is shielded. + * Use `NativeShieldedForwarder` instead when the deposit kind is shielded. * * The constructor accepts a `UserAddress`: an atomic forward can only * deliver to a recipient that needs no in-tx claim, and an unshielded @@ -24,7 +24,7 @@ pragma language_version >= 0.23.0; */ import CompactStandardLibrary; -import "../../forwarder/ForwarderUnshielded" prefix Forwarder_; +import "../forwarder/NativeUnshieldedForwarder" prefix Forwarder_; export { ContractAddress, UserAddress, Either }; diff --git a/contracts/src/multisig/presets/forwarder/ForwarderPrivate.compact b/contracts/src/multisig/examples/PrivateNativeShieldedForwarder.compact similarity index 87% rename from contracts/src/multisig/presets/forwarder/ForwarderPrivate.compact rename to contracts/src/multisig/examples/PrivateNativeShieldedForwarder.compact index 235120bd..adaecc90 100644 --- a/contracts/src/multisig/presets/forwarder/ForwarderPrivate.compact +++ b/contracts/src/multisig/examples/PrivateNativeShieldedForwarder.compact @@ -1,10 +1,10 @@ // SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.2.0 (multisig/presets/forwarder/ForwarderPrivate.compact) +// OpenZeppelin Compact Contracts v0.2.0 (multisig/examples/PrivateNativeShieldedForwarder.compact) pragma language_version >= 0.23.0; /** - * @title ForwarderPrivate + * @title PrivateNativeShieldedForwarder (formerly 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 @@ -22,7 +22,7 @@ pragma language_version >= 0.23.0; */ import CompactStandardLibrary; -import "../../forwarder/ForwarderPrivate" prefix ForwarderPrivate_; +import "../forwarder/PrivateNativeShieldedForwarder" prefix Forwarder_; export { ShieldedCoinInfo, QualifiedShieldedCoinInfo, ShieldedSendResult, ZswapCoinPublicKey }; @@ -35,7 +35,7 @@ export { ShieldedCoinInfo, QualifiedShieldedCoinInfo, ShieldedSendResult, ZswapC * `(parentAddr, opSecret)` pair that the operator will present at drain. */ constructor(parentCommitment: Bytes<32>) { - ForwarderPrivate_initialize(parentCommitment); + Forwarder_initialize(parentCommitment); } /** @@ -47,7 +47,7 @@ constructor(parentCommitment: Bytes<32>) { * @returns {[]} Empty tuple. */ export circuit deposit(coin: ShieldedCoinInfo): [] { - ForwarderPrivate__deposit(coin); + Forwarder__deposit(coin); } /** @@ -91,7 +91,7 @@ export circuit drain( opSecret: Bytes<32>, value: Uint<128> ): ShieldedSendResult { - return ForwarderPrivate__drain(coin, parent, opSecret, value); + return Forwarder__drain(coin, parent, opSecret, value); } /** @@ -100,7 +100,7 @@ export circuit drain( * @returns {Bytes<32>} The commitment set at deploy. */ export circuit getParentCommitment(): Bytes<32> { - return ForwarderPrivate__parentCommitment; + return Forwarder__parentCommitment; } /** @@ -109,17 +109,17 @@ export circuit getParentCommitment(): Bytes<32> { * constructor argument, and inside `drain` for the preimage check. * * The commitment is domain-tagged - * (`pad(32, "ForwarderPrivate:commitment")`) to prevent preimage + * (`pad(32, "PrivateNativeShieldedForwarder:commitment")`) to prevent preimage * collisions with other `persistentHash` users in the system. * * @param {Bytes<32>} parentAddr - The parent address. * @param {Bytes<32>} opSecret - The operational secret. * - * @returns {Bytes<32>} The commitment `persistentHash([pad(32, "ForwarderPrivate:commitment"), parentAddr, opSecret])`. + * @returns {Bytes<32>} The commitment `persistentHash([pad(32, "PrivateNativeShieldedForwarder:commitment"), parentAddr, opSecret])`. */ export pure circuit calculateParentCommitment( parentAddr: Bytes<32>, opSecret: Bytes<32> ): Bytes<32> { - return ForwarderPrivate__calculateParentCommitment(parentAddr, opSecret); + return Forwarder__calculateParentCommitment(parentAddr, opSecret); } diff --git a/contracts/src/multisig/examples/ProposalTreasury.compact b/contracts/src/multisig/examples/ProposalTreasury.compact new file mode 100644 index 00000000..3ac89c57 --- /dev/null +++ b/contracts/src/multisig/examples/ProposalTreasury.compact @@ -0,0 +1,219 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.2.0 (multisig/examples/ProposalTreasury.compact) + +pragma language_version >= 0.23.0; + +/** + * @module ProposalTreasury + * @description Composable multisig behavior: on-chain proposal governance over a + * shielded treasury, authorized by caller identity. Formerly the body of + * `ShieldedMultiSig`. + * + * Composes `SignerManager>`, + * `ProposalManager`, and `NativeShieldedTreasury`. Signers create, approve, and revoke + * proposals; once the threshold is met, `executeShieldedProposal` transfers from + * the treasury. Unlike the signature-based modules, authorization is by the + * on-chain caller (`getCaller`), not off-chain signatures — so it does NOT use + * `SignatureVerifier` and cannot share a registry with those modules. + * + * @notice Signer identity uses `Either` for + * forward compatibility. Today only `left(ZswapCoinPublicKey)` callers can + * authenticate — `getCaller()` resolves via `ownPublicKey()` and cannot produce + * a right-variant. Contract-address signers may be registered but cannot exercise + * governance until contract-to-contract calls exist. The broad state shape lets + * `getCaller()` be swapped via a CMA circuit upgrade later without a state migration. + */ +module ProposalTreasury { + import CompactStandardLibrary; + import "../proposal/ProposalManager" prefix Proposal_; + import "../treasury/NativeShieldedTreasury" prefix Treasury_; + import "../SignerManager"> prefix Signer_; + + // ─── State ────────────────────────────────────────────────────── + + export ledger _proposalApprovals: Map, Map, Boolean>>; + export ledger _approvalCount: Map, Uint<8>>; + + // ─── Setup ────────────────────────────────────────────────────── + + /** + * @description Initializes the signer registry. Call once from the consuming + * contract's constructor. + * + * @param {Vector>} signers - Signer set. + * @param {Uint<8>} thresh - Minimum approvals required. + * @returns {[]} Empty tuple. + */ + export circuit initialize<#n>( + signers: Vector>, + thresh: Uint<8> + ): [] { + Signer_initialize(signers, thresh); + } + + // ─── Deposit ──────────────────────────────────────────────────── + + export circuit deposit(coin: ShieldedCoinInfo): [] { + Treasury__deposit(coin); + } + + // ─── Proposals ────────────────────────────────────────────────── + + export circuit createShieldedProposal( + to: Proposal_Recipient, + color: Bytes<32>, + amount: Uint<128> + ): Uint<64> { + const callerPK = getCaller(); + Signer_assertSigner(callerPK); + + assert( + to.kind == Proposal_RecipientKind.ShieldedUser + || to.kind == Proposal_RecipientKind.Contract, + "ProposalTreasury: recipient must be a shielded user or contract" + ); + + return Proposal__createProposal(to, color, amount); + } + + export circuit approveProposal(id: Uint<64>): [] { + Proposal_assertProposalActive(id); + + const callerPK = getCaller(); + Signer_assertSigner(callerPK); + + assert(!isProposalApprovedBySigner(id, callerPK), "ProposalTreasury: already approved"); + + _approveProposal(id, callerPK); + } + + export circuit revokeApproval(id: Uint<64>): [] { + Proposal_assertProposalActive(id); + + const callerPK = getCaller(); + Signer_assertSigner(callerPK); + + assert(isProposalApprovedBySigner(id, callerPK), "ProposalTreasury: not approved"); + + _revokeApproval(id, callerPK); + } + + export circuit executeShieldedProposal(id: Uint<64>): ShieldedSendResult { + Proposal_assertProposalActive(id); + + const approvalCount = getApprovalCount(id); + Signer_assertThresholdMet(approvalCount); + + const { to, color, amount } = Proposal_getProposal(id); + const result = Treasury__send( + Proposal_toShieldedRecipient(to), + color, + amount, + ); + + Proposal__markExecuted(id); + return result; + } + + // ─── Internal ─────────────────────────────────────────────────── + + circuit _approveProposal(id: Uint<64>, signer: Either): [] { + if (!_proposalApprovals.member(disclose(id))) { + _proposalApprovals.insert(disclose(id), default, Boolean>>); + } + + _proposalApprovals.lookup(disclose(id)).insert(disclose(signer), disclose(true)); + + const newCount = getApprovalCount(id) + 1 as Uint<8>; + _approvalCount.insert(disclose(id), disclose(newCount)); + } + + circuit _revokeApproval(id: Uint<64>, signer: Either): [] { + _proposalApprovals.lookup(disclose(id)).remove(disclose(signer)); + + const newCount = getApprovalCount(id) - 1 as Uint<8>; + _approvalCount.insert(disclose(id), disclose(newCount)); + } + + /** + * @description Returns the caller identity used for signer authentication. + * + * @warning Resolves callers via `ownPublicKey()` only, so a `right(ContractAddress)` + * signer cannot authenticate today. The `Either` shape is kept so `getCaller()` + * can be swapped via a CMA circuit upgrade once contract-to-contract calls exist. + * + * @returns {Either} The caller as a left-variant. + */ + circuit getCaller(): Either { + return left(ownPublicKey()); + } + + // ─── View ─────────────────────────────────────────────────────── + + export circuit isProposalApprovedBySigner( + id: Uint<64>, + signer: Either + ): Boolean { + if (!_proposalApprovals.member(disclose(id)) || !_proposalApprovals.lookup(disclose(id)).member(disclose(signer))) { + return false; + } + + return _proposalApprovals.lookup(disclose(id)).lookup(disclose(signer)); + } + + export circuit getApprovalCount(id: Uint<64>): Uint<8> { + if (!_approvalCount.member(disclose(id))) { + return 0; + } + + return _approvalCount.lookup(disclose(id)); + } + + export circuit getProposal(id: Uint<64>): Proposal_Proposal { + return Proposal_getProposal(id); + } + + export circuit getProposalRecipient(id: Uint<64>): Proposal_Recipient { + return Proposal_getProposalRecipient(id); + } + + export circuit getProposalAmount(id: Uint<64>): Uint<128> { + return Proposal_getProposalAmount(id); + } + + export circuit getProposalColor(id: Uint<64>): Bytes<32> { + return Proposal_getProposalColor(id); + } + + export circuit getProposalStatus(id: Uint<64>): Proposal_ProposalStatus { + return Proposal_getProposalStatus(id); + } + + export circuit getTokenBalance(color: Bytes<32>): Uint<128> { + return Treasury_getTokenBalance(color); + } + + export circuit getReceivedTotal(color: Bytes<32>): Uint<128> { + return Treasury_getReceivedTotal(color); + } + + export circuit getSentTotal(color: Bytes<32>): Uint<128> { + return Treasury_getSentTotal(color); + } + + export circuit getReceivedMinusSent(color: Bytes<32>): Uint<128> { + return Treasury_getReceivedMinusSent(color); + } + + export circuit getSignerCount(): Uint<8> { + return Signer_getSignerCount(); + } + + export circuit getThreshold(): Uint<8> { + return Signer_getThreshold(); + } + + export circuit isSigner(account: Either): Boolean { + return Signer_isSigner(account); + } +} diff --git a/contracts/src/multisig/examples/SignatureMintBurn.compact b/contracts/src/multisig/examples/SignatureMintBurn.compact new file mode 100644 index 00000000..798c9300 --- /dev/null +++ b/contracts/src/multisig/examples/SignatureMintBurn.compact @@ -0,0 +1,186 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.0.1-alpha.1 (multisig/examples/SignatureMintBurn.compact) + +pragma language_version >= 0.23.0; + +/** + * @module SignatureMintBurn + * @description Composable multisig behavior: signature-authorized mint/burn of a + * native shielded token issued by the consuming contract. Formerly the body of + * `ShieldedMultiSigV3`. + * + * `mint` creates a UTXO of this contract's token type via `mintShieldedToken`; + * `burn` consumes one via `sendShielded` to `shieldedBurnAddress()`. Both require + * threshold ECDSA approval verified against the shared `SignatureVerifier` + * registry. A counter provides replay protection and feeds `evolveNonce` for + * unique coin nonces. Operation-domain prefixes (`multisig:mint:` / + * `multisig:burn:`) stop a signature for one op being replayed as the other. + * + * Initialization is split so this module can be composed: `initialize` seeds the + * shared signer registry, while `initializeToken` seeds this module's own token + * state. A combined contract calls another module's `initialize` once for the + * shared registry, then this module's `initializeToken` (see + * `presets/NativeShieldedTokenVault`). + * + * @notice DEPRECATION: this is a stopgap. From `0.3.0-alpha` it is superseded by + * the reusable Shielded Native Token standard (with a pluggable multisig access + * layer), OpenZeppelin/compact-contracts#544. Prefer that standard once available. + * + * @notice ECDSA verification is stubbed in `SignatureVerifier`. Replace it (and + * `persistentHash` with `keccak256`) once the Compact primitives are available. + */ +module SignatureMintBurn { + import CompactStandardLibrary; + import "../SignatureVerifier" prefix Signature_; + import "../../utils/Utils" prefix Utils_; + + // ─── State ────────────────────────────────────────────────────── + + export ledger _counter: Counter; + export ledger _coinNonce: Bytes<32>; + export sealed ledger _tokenDomain: Bytes<32>; + + // ─── Setup ────────────────────────────────────────────────────── + + /** + * @description Initializes the shared signer registry and instance salt. Call + * once per contract. In a combined contract, call this on exactly one module. + * + * @param {Bytes<32>} salt - Random salt for commitment derivation. + * @param {Vector>} signers - Signer commitments. + * @param {Uint<8>} thresh - Minimum approvals required. + * @returns {[]} Empty tuple. + */ + export circuit initialize<#n>( + salt: Bytes<32>, + signers: Vector>, + thresh: Uint<8> + ): [] { + Signature_initialize(salt, signers, thresh); + } + + /** + * @description Seeds this module's token state, independent of the signer + * registry. Call once from the consuming contract's constructor. + * + * @param {Bytes<32>} tokenDomain - Domain used with `kernel.self()` to derive + * this contract's token color. + * @param {Bytes<32>} initCoinNonce - Initial coin-nonce seed (random). + * @returns {[]} Empty tuple. + */ + export circuit initializeToken(tokenDomain: Bytes<32>, initCoinNonce: Bytes<32>): [] { + _tokenDomain = disclose(tokenDomain); + _coinNonce = disclose(initCoinNonce); + } + + // ─── Mint ─────────────────────────────────────────────────────── + + /** + * @description Mints a new shielded coin of this contract's token type to the + * recipient, authorized by threshold signatures. The message hash commits to + * the `multisig:mint:` domain, contract address, recipient, counter, and amount. + * + * @param {Uint<64>} amount - The token amount to mint. + * @param {Either} recipient - Recipient. + * @param {Vector<2, Bytes<64>>} pubkeys - ECDSA public keys of approving signers. + * @param {Vector<2, Bytes<64>>} signatures - Signatures over the mint hash. + * @returns {[]} Empty tuple. + */ + export circuit mint( + amount: Uint<64>, + recipient: Either, + pubkeys: Vector<2, Bytes<64>>, + signatures: Vector<2, Bytes<64>> + ): [] { + const opNonce = _counter; + _counter.increment(1); + + const canonRecipient = Utils_canonicalize(recipient); + const recipientHash = persistentHash>(canonRecipient); + + const msgHash = persistentHash>>([ + pad(32, "multisig:mint:"), + kernel.self().bytes, + recipientHash, + opNonce as Bytes<32>, + amount as Bytes<32> + ]); + + Signature_verify<2>(msgHash, pubkeys, signatures); + + _coinNonce = evolveNonce(_counter, _coinNonce); + mintShieldedToken(_tokenDomain, disclose(amount), _coinNonce, disclose(canonRecipient)); + } + + // ─── Burn ─────────────────────────────────────────────────────── + + /** + * @description Burns a coin of this contract's token type to + * `shieldedBurnAddress()`, authorized by threshold signatures. Change from a + * partial burn is handled by the transaction layer. The `multisig:burn:` domain + * prefix prevents replay as a mint. + * + * @param {QualifiedShieldedCoinInfo} coin - The coin to burn (operator pool). + * @param {Uint<64>} amount - The token amount to burn. + * @param {Vector<2, Bytes<64>>} pubkeys - ECDSA public keys of approving signers. + * @param {Vector<2, Bytes<64>>} signatures - Signatures over the burn hash. + * @returns {[]} Empty tuple. + */ + export circuit burn( + coin: QualifiedShieldedCoinInfo, + amount: Uint<64>, + pubkeys: Vector<2, Bytes<64>>, + signatures: Vector<2, Bytes<64>> + ): [] { + const opNonce = _counter; + _counter.increment(1); + + const msgHash = persistentHash>>([ + pad(32, "multisig:burn:"), + kernel.self().bytes, + opNonce as Bytes<32>, + amount as Bytes<32> + ]); + + Signature_verify<2>(msgHash, pubkeys, signatures); + + assert(coin.color == tokenType(_tokenDomain, kernel.self()), "SignatureMintBurn: coin not from this contract"); + assert(coin.value >= amount, "SignatureMintBurn: insufficient coin value"); + + sendShielded(disclose(coin), shieldedBurnAddress(), disclose(amount)); + } + + // ─── View ─────────────────────────────────────────────────────── + + /** + * @description Computes a signer commitment from an ECDSA public key. Pure — + * callable off-chain by the deployer. + */ + export pure circuit _calculateSignerId(pk: Bytes<64>, salt: Bytes<32>): Bytes<32> { + return Signature__calculateSignerId(pk, salt); + } + + export circuit getNonce(): Uint<64> { + return _counter; + } + + export circuit getTokenDomain(): Bytes<32> { + return _tokenDomain; + } + + export circuit getTokenType(): Bytes<32> { + return tokenType(_tokenDomain, kernel.self()); + } + + export circuit getSignerCount(): Uint<8> { + return Signature_getSignerCount(); + } + + export circuit getThreshold(): Uint<8> { + return Signature_getThreshold(); + } + + export circuit isSigner(commitment: Bytes<32>): Boolean { + return Signature_isSigner(commitment); + } +} diff --git a/contracts/src/multisig/examples/SignatureTreasury.compact b/contracts/src/multisig/examples/SignatureTreasury.compact new file mode 100644 index 00000000..e117ece0 --- /dev/null +++ b/contracts/src/multisig/examples/SignatureTreasury.compact @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.2.0 (multisig/examples/SignatureTreasury.compact) + +pragma language_version >= 0.23.0; + +/** + * @module SignatureTreasury + * @description Composable multisig behavior: signature-authorized, single-tx + * spend from a stateless shielded treasury. Formerly the body of + * `ShieldedMultiSigV2`. + * + * Combines `SignatureVerifier` (commitment signer registry + threshold ECDSA + * verification) with `NativeShieldedTreasuryStateless` (custody + send of native + * shielded tokens). Approvals are collected off-chain; `execute` verifies them + * and sends in a single transaction. A monotonic `_nonce` binds each spend to a + * unique message hash for replay protection. + * + * Import this module at the contract root and wrap it in a thin preset (see + * `presets/NativeShieldedStatelessTreasury`), or compose it with other + * root modules that import the same `../SignatureVerifier` to share one + * signer registry (see `presets/NativeShieldedTokenVault`). + */ +module SignatureTreasury { + import CompactStandardLibrary; + import "../SignatureVerifier" prefix Signature_; + import "../treasury/NativeShieldedTreasuryStateless" prefix Treasury_; + import "../proposal/ProposalManager" prefix Proposal_; + + // ─── State ────────────────────────────────────────────────────── + + export ledger _nonce: Counter; + + // ─── Setup ────────────────────────────────────────────────────── + + /** + * @description Initializes the shared signer registry and instance salt. + * Call once from the consuming contract's constructor. + * + * @param {Bytes<32>} salt - Random salt for commitment derivation. + * @param {Vector>} signers - Signer commitments. + * @param {Uint<8>} thresh - Minimum approvals required. + * @returns {[]} Empty tuple. + */ + export circuit initialize<#n>( + salt: Bytes<32>, + signers: Vector>, + thresh: Uint<8> + ): [] { + Signature_initialize(salt, signers, thresh); + } + + // ─── Deposit ──────────────────────────────────────────────────── + + /** + * @description Receives a shielded coin into the treasury. No access control; + * anyone may deposit. No coin data is stored on the public ledger. + * + * @param {ShieldedCoinInfo} coin - The incoming shielded coin. + * @returns {[]} Empty tuple. + */ + export circuit deposit(coin: ShieldedCoinInfo): [] { + Treasury__deposit(coin); + } + + // ─── Execute ──────────────────────────────────────────────────── + + /** + * @description Executes a shielded send authorized by threshold signatures. + * Reads and increments the nonce, reconstructs the off-chain message hash + * `persistentHash(nonce, recipient address, coin color, amount)`, verifies the + * signatures against the shared registry, then sends from the treasury. + * + * @param {Proposal_Recipient} to - The recipient. + * @param {Uint<128>} amount - The amount to send. + * @param {QualifiedShieldedCoinInfo} coin - The coin to spend (operator pool). + * @param {Vector<2, Bytes<64>>} pubkeys - ECDSA public keys of approving signers. + * @param {Vector<2, Bytes<64>>} signatures - Signatures over the operation. + * @returns {ShieldedSendResult} The send result including any change. + */ + export circuit execute( + to: Proposal_Recipient, + amount: Uint<128>, + coin: QualifiedShieldedCoinInfo, + pubkeys: Vector<2, Bytes<64>>, + signatures: Vector<2, Bytes<64>> + ): ShieldedSendResult { + const currentNonce = _nonce; + _nonce.increment(1); + + const msgHash = persistentHash>>([ + currentNonce as Bytes<32>, + to.address, + coin.color, + amount as Bytes<32> + ]); + + Signature_verify<2>(msgHash, pubkeys, signatures); + + return Treasury__send(coin, Proposal_toShieldedRecipient(to), amount); + } + + // ─── View ─────────────────────────────────────────────────────── + + /** + * @description Computes a signer commitment from an ECDSA public key. Pure — + * callable off-chain by the deployer. + */ + export pure circuit _calculateSignerId(pk: Bytes<64>, salt: Bytes<32>): Bytes<32> { + return Signature__calculateSignerId(pk, salt); + } + + export circuit getNonce(): Uint<64> { + return _nonce; + } + + export circuit getSignerCount(): Uint<8> { + return Signature_getSignerCount(); + } + + export circuit getThreshold(): Uint<8> { + return Signature_getThreshold(); + } + + export circuit isSigner(commitment: Bytes<32>): Boolean { + return Signature_isSigner(commitment); + } +} diff --git a/contracts/src/multisig/forwarder/ForwarderShielded.compact b/contracts/src/multisig/forwarder/NativeShieldedForwarder.compact similarity index 93% rename from contracts/src/multisig/forwarder/ForwarderShielded.compact rename to contracts/src/multisig/forwarder/NativeShieldedForwarder.compact index f4c82449..b0bb0ed3 100644 --- a/contracts/src/multisig/forwarder/ForwarderShielded.compact +++ b/contracts/src/multisig/forwarder/NativeShieldedForwarder.compact @@ -1,10 +1,10 @@ // SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.2.0 (multisig/ForwarderShielded.compact) +// OpenZeppelin Compact Contracts v0.2.0 (multisig/forwarder/NativeShieldedForwarder.compact) pragma language_version >= 0.23.0; /** - * @module ForwarderShielded + * @module NativeShieldedForwarder (formerly ForwarderShielded) * @description Public-parent forwarder for shielded coins. Provides an * atomic forward pattern: receive a shielded coin and immediately send * it to the configured parent recipient. @@ -42,7 +42,7 @@ pragma language_version >= 0.23.0; * permanently. The zero-key guard rejects only the all-zero key, not an * otherwise-unspendable one. */ -module ForwarderShielded { +module NativeShieldedForwarder { import CompactStandardLibrary; import "../../utils/Utils" prefix Utils_; @@ -88,7 +88,7 @@ module ForwarderShielded { * @returns {[]} Empty tuple. */ export circuit initialize(parent: ZswapCoinPublicKey): [] { - assert(!Utils_isKeyZero(parent), "ForwarderShielded: zero parent"); + assert(!Utils_isKeyZero(parent), "NativeShieldedForwarder: zero parent"); assertNotInitialized(); _isInitialized = true; _parent = left(disclose(parent)); @@ -131,7 +131,7 @@ module ForwarderShielded { * @returns {[]} Empty tuple. */ circuit assertInitialized(): [] { - assert(_isInitialized, "ForwarderShielded: contract not initialized"); + assert(_isInitialized, "NativeShieldedForwarder: contract not initialized"); } /** @@ -144,6 +144,6 @@ module ForwarderShielded { * @returns {[]} Empty tuple. */ circuit assertNotInitialized(): [] { - assert(!_isInitialized, "ForwarderShielded: contract already initialized"); + assert(!_isInitialized, "NativeShieldedForwarder: contract already initialized"); } } diff --git a/contracts/src/multisig/forwarder/ForwarderUnshielded.compact b/contracts/src/multisig/forwarder/NativeUnshieldedForwarder.compact similarity index 93% rename from contracts/src/multisig/forwarder/ForwarderUnshielded.compact rename to contracts/src/multisig/forwarder/NativeUnshieldedForwarder.compact index 667f938c..0fa59f90 100644 --- a/contracts/src/multisig/forwarder/ForwarderUnshielded.compact +++ b/contracts/src/multisig/forwarder/NativeUnshieldedForwarder.compact @@ -1,10 +1,10 @@ // SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.2.0 (multisig/ForwarderUnshielded.compact) +// OpenZeppelin Compact Contracts v0.2.0 (multisig/forwarder/NativeUnshieldedForwarder.compact) pragma language_version >= 0.23.0; /** - * @module ForwarderUnshielded + * @module NativeUnshieldedForwarder (formerly ForwarderUnshielded) * @description Public-parent forwarder for unshielded coins. Provides an * atomic forward pattern: receive an unshielded amount of a given color * and immediately send it to the configured parent recipient. @@ -46,7 +46,7 @@ pragma language_version >= 0.23.0; * permanently. The zero-address guard rejects only the all-zero address, * not an otherwise-unspendable one. */ -module ForwarderUnshielded { +module NativeUnshieldedForwarder { import CompactStandardLibrary; // ─── State ────────────────────────────────────────────────────── @@ -92,7 +92,7 @@ module ForwarderUnshielded { */ export circuit initialize(parent: UserAddress): [] { const isZero = default == parent; - assert(!isZero, "ForwarderUnshielded: zero parent"); + assert(!isZero, "NativeUnshieldedForwarder: zero parent"); assertNotInitialized(); _isInitialized = true; _parent = right(disclose(parent)); @@ -134,7 +134,7 @@ module ForwarderUnshielded { * @returns {[]} Empty tuple. */ circuit assertInitialized(): [] { - assert(_isInitialized, "ForwarderUnshielded: contract not initialized"); + assert(_isInitialized, "NativeUnshieldedForwarder: contract not initialized"); } /** @@ -147,6 +147,6 @@ module ForwarderUnshielded { * @returns {[]} Empty tuple. */ circuit assertNotInitialized(): [] { - assert(!_isInitialized, "ForwarderUnshielded: contract already initialized"); + assert(!_isInitialized, "NativeUnshieldedForwarder: contract already initialized"); } } diff --git a/contracts/src/multisig/forwarder/ForwarderPrivate.compact b/contracts/src/multisig/forwarder/PrivateNativeShieldedForwarder.compact similarity index 89% rename from contracts/src/multisig/forwarder/ForwarderPrivate.compact rename to contracts/src/multisig/forwarder/PrivateNativeShieldedForwarder.compact index f2f08618..aec8e3bb 100644 --- a/contracts/src/multisig/forwarder/ForwarderPrivate.compact +++ b/contracts/src/multisig/forwarder/PrivateNativeShieldedForwarder.compact @@ -1,10 +1,10 @@ // SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.2.0 (multisig/ForwarderPrivate.compact) +// OpenZeppelin Compact Contracts v0.2.0 (multisig/forwarder/PrivateNativeShieldedForwarder.compact) pragma language_version >= 0.23.0; /** - * @module ForwarderPrivate + * @module PrivateNativeShieldedForwarder (formerly ForwarderPrivate) * @description Private-parent forwarder primitives. The parent is a coin * public key, hidden behind a `persistentHash` commitment on the ledger. * Deposits accumulate at the contract (no atomic forward); the operator @@ -27,7 +27,7 @@ pragma language_version >= 0.23.0; * `_calculateParentCommitment` helper does not access state and is * intentionally callable without initialization. */ -module ForwarderPrivate { +module PrivateNativeShieldedForwarder { import CompactStandardLibrary; import "../../utils/Utils" prefix Utils_; @@ -58,13 +58,13 @@ module ForwarderPrivate { * under the domain-tagged hash). * * @param {Bytes<32>} parentCommitment - Domain-tagged - * `persistentHash([pad(32, "ForwarderPrivate:commitment"), parentAddr, opSecret])` + * `persistentHash([pad(32, "PrivateNativeShieldedForwarder:commitment"), parentAddr, opSecret])` * computed off-chain by the deployer (see `_calculateParentCommitment`). * * @returns {[]} Empty tuple. */ export circuit initialize(parentCommitment: Bytes<32>): [] { - assert(parentCommitment != default>, "ForwarderPrivate: zero commitment"); + assert(parentCommitment != default>, "PrivateNativeShieldedForwarder: zero commitment"); assertNotInitialized(); _isInitialized = true; _parentCommitment = disclose(parentCommitment); @@ -142,12 +142,12 @@ module ForwarderPrivate { assertInitialized(); // Reject a zero parent before the commitment gate. - assert(!Utils_isKeyZero(parent), "ForwarderPrivate: zero parent"); + assert(!Utils_isKeyZero(parent), "PrivateNativeShieldedForwarder: zero parent"); // Commitment gate — the preimage is the parent key's 32 bytes. assert( _calculateParentCommitment(parent.bytes, opSecret) == _parentCommitment, - "ForwarderPrivate: invalid parent" + "PrivateNativeShieldedForwarder: invalid parent" ); // Send to the parent coin public key (the `left` arm). `disclose` reveals @@ -180,7 +180,7 @@ module ForwarderPrivate { * Callable without initialization. * * The first hash input is a fixed domain tag - * (`pad(32, "ForwarderPrivate:commitment")`). The tag prevents + * (`pad(32, "PrivateNativeShieldedForwarder: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. @@ -188,14 +188,14 @@ module ForwarderPrivate { * @param {Bytes<32>} parentAddr - The parent address. * @param {Bytes<32>} opSecret - The operational secret. * - * @returns {Bytes<32>} `persistentHash([pad(32, "ForwarderPrivate:commitment"), parentAddr, opSecret])`. + * @returns {Bytes<32>} `persistentHash([pad(32, "PrivateNativeShieldedForwarder:commitment"), parentAddr, opSecret])`. */ export pure circuit _calculateParentCommitment( parentAddr: Bytes<32>, opSecret: Bytes<32> ): Bytes<32> { return persistentHash>>( - [pad(32, "ForwarderPrivate:commitment"), parentAddr, opSecret] + [pad(32, "PrivateNativeShieldedForwarder:commitment"), parentAddr, opSecret] ); } @@ -211,7 +211,7 @@ module ForwarderPrivate { * @return {[]} - Empty tuple. */ circuit assertInitialized(): [] { - assert(_isInitialized, "ForwarderPrivate: contract not initialized"); + assert(_isInitialized, "PrivateNativeShieldedForwarder: contract not initialized"); } /** @@ -224,6 +224,6 @@ module ForwarderPrivate { * @return {[]} - Empty tuple. */ circuit assertNotInitialized(): [] { - assert(!_isInitialized, "ForwarderPrivate: contract already initialized"); + assert(!_isInitialized, "PrivateNativeShieldedForwarder: contract already initialized"); } } diff --git a/contracts/src/multisig/presets/NativeShieldedMintBurn.compact b/contracts/src/multisig/presets/NativeShieldedMintBurn.compact new file mode 100644 index 00000000..9ca50d48 --- /dev/null +++ b/contracts/src/multisig/presets/NativeShieldedMintBurn.compact @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.0.1-alpha.1 (multisig/presets/NativeShieldedMintBurn.compact) + +pragma language_version >= 0.23.0; + +/** + * @title NativeShieldedMintBurn (formerly ShieldedMultiSigV3) + * @description Example preset: a deployable multisig token contract. Both mint + * and burn require threshold ECDSA authorization; no single party can create or + * destroy supply. Non-transferable (no transfer/execute surface). + * + * Thin wrapper that composes a single root module, `SignatureMintBurn`. All + * behavior lives there; this contract only supplies a constructor and delegates. + * For the combined mint/burn + treasury variant, see `NativeShieldedTokenVault`. + * + * @notice DEPRECATION: the underlying mint/burn is a stopgap. From `0.3.0-alpha` + * it is superseded by the reusable Shielded Native Token standard (with a + * pluggable multisig access layer), OpenZeppelin/compact-contracts#544. Prefer + * that standard once available; do not build new dependents on this. + */ + +import CompactStandardLibrary; + +import "../examples/SignatureMintBurn" prefix Token_; +// For testing +export { ZswapCoinPublicKey }; + +// ─── Constructor ──────────────────────────────────────────────── + +/** + * @description Deploys with 3 signer commitments and a threshold of 2. + * `tokenDomain` derives this contract's token color via + * `tokenType(tokenDomain, kernel.self())`; `initCoinNonce` seeds the mint + * coin-nonce chain. Both `instanceSalt` and `initCoinNonce` must be random. + * + * @param {Bytes<32>} instanceSalt - Random salt for signer commitment derivation. + * @param {Bytes<32>} initCoinNonce - Initial coin nonce seed. + * @param {Bytes<32>} tokenDomain - Domain used to derive this contract's token color. + * @param {Vector<3, Bytes<32>>} signerCommitments - Hashed signer identities. + */ +constructor( + instanceSalt: Bytes<32>, + initCoinNonce: Bytes<32>, + tokenDomain: Bytes<32>, + signerCommitments: Vector<3, Bytes<32>>, +) { + Token_initialize<3>(instanceSalt, signerCommitments, 2); + Token_initializeToken(tokenDomain, initCoinNonce); +} + +// ─── Circuits (delegated to SignatureMintBurn) ────────────────── + +export circuit mint( + amount: Uint<64>, + recipient: Either, + pubkeys: Vector<2, Bytes<64>>, + signatures: Vector<2, Bytes<64>> +): [] { + Token_mint(amount, recipient, pubkeys, signatures); +} + +export circuit burn( + coin: QualifiedShieldedCoinInfo, + amount: Uint<64>, + pubkeys: Vector<2, Bytes<64>>, + signatures: Vector<2, Bytes<64>> +): [] { + Token_burn(coin, amount, pubkeys, signatures); +} + +export pure circuit _calculateSignerId(pk: Bytes<64>, salt: Bytes<32>): Bytes<32> { + return Token__calculateSignerId(pk, salt); +} + +// ─── View ─────────────────────────────────────────────────────── + +export circuit getNonce(): Uint<64> { + return Token_getNonce(); +} + +export circuit getTokenDomain(): Bytes<32> { + return Token_getTokenDomain(); +} + +export circuit getTokenType(): Bytes<32> { + return Token_getTokenType(); +} + +export circuit getSignerCount(): Uint<8> { + return Token_getSignerCount(); +} + +export circuit getThreshold(): Uint<8> { + return Token_getThreshold(); +} + +export circuit isSigner(commitment: Bytes<32>): Boolean { + return Token_isSigner(commitment); +} diff --git a/contracts/src/multisig/presets/NativeShieldedProposal.compact b/contracts/src/multisig/presets/NativeShieldedProposal.compact new file mode 100644 index 00000000..442e9a60 --- /dev/null +++ b/contracts/src/multisig/presets/NativeShieldedProposal.compact @@ -0,0 +1,123 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.2.0 (multisig/presets/NativeShieldedProposal.compact) + +pragma language_version >= 0.23.0; + +/** + * @title NativeShieldedProposal (formerly ShieldedMultiSig) + * @description Example preset: a deployable multisig that governs a shielded + * treasury through an on-chain proposal lifecycle (create / approve / revoke / + * execute), authorized by caller identity. + * + * Thin wrapper that composes a single root module, `ProposalTreasury`. All + * behavior lives there; this contract only supplies a constructor and delegates. + * Authorization is by the on-chain caller (not off-chain signatures), so this + * preset is independent of the signature-based modules. + */ + +import CompactStandardLibrary; + +import "../examples/ProposalTreasury" prefix Proposal_; +import "../proposal/ProposalManager" prefix ProposalManager_; + +// ─── Constructor ──────────────────────────────────────────────── + +/** + * @description Deploys the multisig with 3 signers and a threshold. + * + * @param {Vector<3, Either>} signers - Signer set. + * @param {Uint<8>} thresh - Minimum approvals required. + */ +constructor( + signers: Vector<3, Either>, + thresh: Uint<8> +) { + Proposal_initialize<3>(signers, thresh); +} + +// ─── Circuits (delegated to ProposalTreasury) ─────────────────── + +export circuit deposit(coin: ShieldedCoinInfo): [] { + Proposal_deposit(coin); +} + +export circuit createShieldedProposal( + to: ProposalManager_Recipient, + color: Bytes<32>, + amount: Uint<128> +): Uint<64> { + return Proposal_createShieldedProposal(to, color, amount); +} + +export circuit approveProposal(id: Uint<64>): [] { + Proposal_approveProposal(id); +} + +export circuit revokeApproval(id: Uint<64>): [] { + Proposal_revokeApproval(id); +} + +export circuit executeShieldedProposal(id: Uint<64>): ShieldedSendResult { + return Proposal_executeShieldedProposal(id); +} + +// ─── View ─────────────────────────────────────────────────────── + +export circuit isProposalApprovedBySigner( + id: Uint<64>, + signer: Either +): Boolean { + return Proposal_isProposalApprovedBySigner(id, signer); +} + +export circuit getApprovalCount(id: Uint<64>): Uint<8> { + return Proposal_getApprovalCount(id); +} + +export circuit getProposal(id: Uint<64>): ProposalManager_Proposal { + return Proposal_getProposal(id); +} + +export circuit getProposalRecipient(id: Uint<64>): ProposalManager_Recipient { + return Proposal_getProposalRecipient(id); +} + +export circuit getProposalAmount(id: Uint<64>): Uint<128> { + return Proposal_getProposalAmount(id); +} + +export circuit getProposalColor(id: Uint<64>): Bytes<32> { + return Proposal_getProposalColor(id); +} + +export circuit getProposalStatus(id: Uint<64>): ProposalManager_ProposalStatus { + return Proposal_getProposalStatus(id); +} + +export circuit getTokenBalance(color: Bytes<32>): Uint<128> { + return Proposal_getTokenBalance(color); +} + +export circuit getReceivedTotal(color: Bytes<32>): Uint<128> { + return Proposal_getReceivedTotal(color); +} + +export circuit getSentTotal(color: Bytes<32>): Uint<128> { + return Proposal_getSentTotal(color); +} + +export circuit getReceivedMinusSent(color: Bytes<32>): Uint<128> { + return Proposal_getReceivedMinusSent(color); +} + +export circuit getSignerCount(): Uint<8> { + return Proposal_getSignerCount(); +} + +export circuit getThreshold(): Uint<8> { + return Proposal_getThreshold(); +} + +export circuit isSigner(account: Either): Boolean { + return Proposal_isSigner(account); +} diff --git a/contracts/src/multisig/presets/NativeShieldedStatelessTreasury.compact b/contracts/src/multisig/presets/NativeShieldedStatelessTreasury.compact new file mode 100644 index 00000000..25df5c6c --- /dev/null +++ b/contracts/src/multisig/presets/NativeShieldedStatelessTreasury.compact @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.2.0 (multisig/presets/NativeShieldedStatelessTreasury.compact) + +pragma language_version >= 0.23.0; + +/** + * @title NativeShieldedStatelessTreasury (formerly ShieldedMultiSigV2) + * @description Example preset: a deployable 2-of-3 signature multisig over a + * stateless shielded treasury. + * + * Thin wrapper that composes a single root module, `SignatureTreasury` + * (signature-authorized spend over `NativeShieldedTreasuryStateless`). All behavior + * lives in the module; this contract only supplies a constructor and delegates, + * demonstrating how to deploy that module on its own. For the combined + * mint/burn + treasury variant, see `NativeShieldedTokenVault`. + */ + +import CompactStandardLibrary; + +import "../examples/SignatureTreasury" prefix Treasury_; +import "../proposal/ProposalManager" prefix Proposal_; + +// ─── Constructor ──────────────────────────────────────────────── + +/** + * @description Deploys the multisig with 3 signer commitments and a threshold. + * Each commitment is `persistentHash(pk, instanceSalt, "multisig:signer:")`, + * computed off-chain via `_calculateSignerId`. + * + * Requirements: + * + * - `thresh` must be > 0 and <= 2 (matches the 2-signature `execute` surface). + * - `signerCommitments` must not contain duplicates. + * - `instanceSalt` should be cryptographically random. + * + * @param {Bytes<32>} instanceSalt - Random salt for commitment derivation. + * @param {Vector<3, Bytes<32>>} signerCommitments - Hashed signer identities. + * @param {Uint<8>} thresh - Minimum approvals required. + */ +constructor( + instanceSalt: Bytes<32>, + signerCommitments: Vector<3, Bytes<32>>, + thresh: Uint<8>, +) { + assert( + thresh <= 2, + "NativeShieldedStatelessTreasury: threshold cannot exceed 2 (execute verifies at most 2 signatures)" + ); + Treasury_initialize<3>(instanceSalt, signerCommitments, thresh); +} + +// ─── Circuits (delegated to SignatureTreasury) ────────────────── + +export circuit deposit(coin: ShieldedCoinInfo): [] { + Treasury_deposit(coin); +} + +export circuit execute( + to: Proposal_Recipient, + amount: Uint<128>, + coin: QualifiedShieldedCoinInfo, + pubkeys: Vector<2, Bytes<64>>, + signatures: Vector<2, Bytes<64>> +): ShieldedSendResult { + return Treasury_execute(to, amount, coin, pubkeys, signatures); +} + +export pure circuit _calculateSignerId(pk: Bytes<64>, salt: Bytes<32>): Bytes<32> { + return Treasury__calculateSignerId(pk, salt); +} + +// ─── View ─────────────────────────────────────────────────────── + +export circuit getNonce(): Uint<64> { + return Treasury_getNonce(); +} + +export circuit getSignerCount(): Uint<8> { + return Treasury_getSignerCount(); +} + +export circuit getThreshold(): Uint<8> { + return Treasury_getThreshold(); +} + +export circuit isSigner(commitment: Bytes<32>): Boolean { + return Treasury_isSigner(commitment); +} diff --git a/contracts/src/multisig/presets/NativeShieldedTokenVault.compact b/contracts/src/multisig/presets/NativeShieldedTokenVault.compact new file mode 100644 index 00000000..846bfc88 --- /dev/null +++ b/contracts/src/multisig/presets/NativeShieldedTokenVault.compact @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.0.1-alpha.1 (multisig/presets/NativeShieldedTokenVault.compact) + +pragma language_version >= 0.23.0; + +/** + * @title NativeShieldedTokenVault + * @description Example preset combining TWO root modules in one contract: + * `SignatureMintBurn` (issue/destroy this contract's own native shielded token) + * and `SignatureTreasury` (custody + signature-authorized spend). This is the + * composition the no-C2C protocol forces: a contract that both mints its own + * token and manages a treasury of it, atomically, under one signer set. + * + * Both modules import the same `../SignatureVerifier`, so the compiler + * deduplicates that state into a single signer registry shared by `mint`, `burn`, + * and `execute`. The constructor initializes that shared registry once (via the + * treasury module) and seeds the token state separately. + * + * @notice DEPRECATION: the mint/burn half is a stopgap superseded by the Shielded + * Native Token standard (OpenZeppelin/compact-contracts#544) from `0.3.0-alpha`. + */ + +import CompactStandardLibrary; + +import "../examples/SignatureTreasury" prefix Treasury_; +import "../examples/SignatureMintBurn" prefix Token_; +import "../proposal/ProposalManager" prefix Proposal_; +// For testing +export { ZswapCoinPublicKey }; + +// ─── Constructor ──────────────────────────────────────────────── + +/** + * @description Deploys with 3 signer commitments and a threshold. Initializes the + * shared signer registry once (through the treasury module), then seeds the token + * module's own state. + * + * Requirements: + * + * - `thresh` must be > 0 and <= 2 (mint/burn/execute each verify 2 signatures). + * - `signerCommitments` must not contain duplicates. + * - `instanceSalt` and `initCoinNonce` should be cryptographically random. + * + * @param {Bytes<32>} instanceSalt - Random salt for commitment derivation. + * @param {Bytes<32>} initCoinNonce - Initial coin nonce seed. + * @param {Bytes<32>} tokenDomain - Domain used to derive this contract's token color. + * @param {Vector<3, Bytes<32>>} signerCommitments - Hashed signer identities. + * @param {Uint<8>} thresh - Minimum approvals required. + */ +constructor( + instanceSalt: Bytes<32>, + initCoinNonce: Bytes<32>, + tokenDomain: Bytes<32>, + signerCommitments: Vector<3, Bytes<32>>, + thresh: Uint<8>, +) { + assert( + thresh <= 2, + "NativeShieldedTokenVault: threshold cannot exceed 2 (each op verifies at most 2 signatures)" + ); + // Initialize the shared signer registry once, through the treasury module. + Treasury_initialize<3>(instanceSalt, signerCommitments, thresh); + // Seed the token module's own state (no second registry init). + Token_initializeToken(tokenDomain, initCoinNonce); +} + +// ─── Treasury (SignatureTreasury) ─────────────────────────────── + +export circuit deposit(coin: ShieldedCoinInfo): [] { + Treasury_deposit(coin); +} + +export circuit execute( + to: Proposal_Recipient, + amount: Uint<128>, + coin: QualifiedShieldedCoinInfo, + pubkeys: Vector<2, Bytes<64>>, + signatures: Vector<2, Bytes<64>> +): ShieldedSendResult { + return Treasury_execute(to, amount, coin, pubkeys, signatures); +} + +// ─── Token (SignatureMintBurn) ────────────────────────────────── + +export circuit mint( + amount: Uint<64>, + recipient: Either, + pubkeys: Vector<2, Bytes<64>>, + signatures: Vector<2, Bytes<64>> +): [] { + Token_mint(amount, recipient, pubkeys, signatures); +} + +export circuit burn( + coin: QualifiedShieldedCoinInfo, + amount: Uint<64>, + pubkeys: Vector<2, Bytes<64>>, + signatures: Vector<2, Bytes<64>> +): [] { + Token_burn(coin, amount, pubkeys, signatures); +} + +// ─── Signature Verification ───────────────────────────────────── + +export pure circuit _calculateSignerId(pk: Bytes<64>, salt: Bytes<32>): Bytes<32> { + return Treasury__calculateSignerId(pk, salt); +} + +// ─── View ─────────────────────────────────────────────────────── + +export circuit getExecuteNonce(): Uint<64> { + return Treasury_getNonce(); +} + +export circuit getTokenNonce(): Uint<64> { + return Token_getNonce(); +} + +export circuit getTokenDomain(): Bytes<32> { + return Token_getTokenDomain(); +} + +export circuit getTokenType(): Bytes<32> { + return Token_getTokenType(); +} + +export circuit getSignerCount(): Uint<8> { + return Treasury_getSignerCount(); +} + +export circuit getThreshold(): Uint<8> { + return Treasury_getThreshold(); +} + +export circuit isSigner(commitment: Bytes<32>): Boolean { + return Treasury_isSigner(commitment); +} diff --git a/contracts/src/multisig/presets/ShieldedMultiSig.compact b/contracts/src/multisig/presets/ShieldedMultiSig.compact deleted file mode 100644 index 740833a1..00000000 --- a/contracts/src/multisig/presets/ShieldedMultiSig.compact +++ /dev/null @@ -1,236 +0,0 @@ -// SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.2.0 (multisig/presets/ShieldedMultiSig.compact) - -pragma language_version >= 0.23.0; - -/** - * @module ShieldedMultiSig - * @description A shielded multisig preset composing `SignerManager`, - * `ProposalManager`, and `ShieldedTreasury`. Signers approve proposals that - * transfer shielded tokens out of the treasury once the configured threshold - * is met. - * - * @notice Signer identity uses `Either` - * in state for forward compatibility. In the current protocol, only - * `left(ZswapCoinPublicKey)` callers can authenticate — `getCaller()` resolves - * via `ownPublicKey()` and has no way to produce a right-variant today. - * Registering contract-address signers is permitted but those signers cannot - * exercise governance (create/approve/revoke) until contract-to-contract calls - * are supported. Choose your signer set accordingly. - * - * @notice The state shape is deliberately kept broad so that, once - * contract-to-contract calls are supported, `getCaller()` can be swapped via - * a CMA (Contract Maintenance Authorities) circuit upgrade without a state - * migration. Existing deployments would then gain working contract-signer - * authentication. - */ - -import CompactStandardLibrary; - -import "../proposal/ProposalManager" prefix Proposal_; -import "../treasury/ShieldedTreasury" prefix Treasury_; -import "../signer/SignerManager"> prefix Signer_; - -// ─── State ─────────────────────────────────────────────────────────────── - -export ledger _proposalApprovals: Map, Map, Boolean>>; -export ledger _approvalCount: Map, Uint<8>>; - -// ─── Constructor ───────────────────────────────────────────────────────── - -constructor( - signers: Vector<3, Either>, - thresh: Uint<8> -) { - Signer_initialize<3>(signers, thresh); -} - -// ─── Deposit ───────────────────────────────────────────────────────────── - -export circuit deposit(coin: ShieldedCoinInfo): [] { - Treasury__deposit(coin); -} - -// ─── Proposals ─────────────────────────────────────────────────────────── - -export circuit createShieldedProposal( - to: Proposal_Recipient, - color: Bytes<32>, - amount: Uint<128> -): Uint<64> { - const callerPK = getCaller(); - Signer_assertSigner(callerPK); - - assert( - to.kind == Proposal_RecipientKind.ShieldedUser - || to.kind == Proposal_RecipientKind.Contract, - "ShieldedMultiSig: recipient must be a shielded user or contract" - ); - - return Proposal__createProposal(to, color, amount); -} - -export circuit approveProposal(id: Uint<64>): [] { - // Check if active - Proposal_assertProposalActive(id); - - // Check signer - const callerPK = getCaller(); - Signer_assertSigner(callerPK); - - // Check if already approved - assert(!isProposalApprovedBySigner(id, callerPK), "Multisig: already approved"); - - // Approve - _approveProposal(id, callerPK); -} - -export circuit revokeApproval(id: Uint<64>): [] { - // Check if active - Proposal_assertProposalActive(id); - - // Check signer - const callerPK = getCaller(); - Signer_assertSigner(callerPK); - - // Check has approved - assert(isProposalApprovedBySigner(id, callerPK), "Multisig: not approved"); - - // Revoke - _revokeApproval(id, callerPK); -} - -export circuit executeShieldedProposal( - id: Uint<64>, -): ShieldedSendResult { - // Check if active - Proposal_assertProposalActive(id); - - // Check threshold - const approvalCount = getApprovalCount(id); - Signer_assertThresholdMet(approvalCount); - - // Transfer - const { to, color, amount } = Proposal_getProposal(id); - const result = Treasury__send( - Proposal_toShieldedRecipient(to), - color, - amount, - ); - - // Finish lifecycle - Proposal__markExecuted(id); - return result; -} - -// ─── Internal ─────────────────────────────────────────────────────────── - -circuit _approveProposal(id: Uint<64>, signer: Either): [] { - if (!_proposalApprovals.member(disclose(id))) { - _proposalApprovals.insert(disclose(id), default, Boolean>>); - } - - _proposalApprovals.lookup(disclose(id)).insert(disclose(signer), disclose(true)); - - const newCount = getApprovalCount(id) + 1 as Uint<8>; - _approvalCount.insert(disclose(id), disclose(newCount)); -} - -circuit _revokeApproval(id: Uint<64>, signer: Either): [] { - _proposalApprovals.lookup(disclose(id)).remove(disclose(signer)); - - const newCount = getApprovalCount(id) - 1 as Uint<8>; - _approvalCount.insert(disclose(id), disclose(newCount)); -} - -/** - * @description Returns the caller identity used for signer authentication. - * - * @warning Currently resolves callers via `ownPublicKey()` only, so any signer - * registered as a `right(ContractAddress)` variant cannot authenticate through - * this circuit today. Ledger fields keep the `Either` - * shape so that, once contract-to-contract calls are supported, `getCaller()` - * can be replaced via a CMA (Contract Maintenance Authorities) circuit upgrade - * to detect the contract-call context and return the appropriate variant — - * without a state migration. - * - * @returns {Either} The caller wrapped as a left-variant. - */ -circuit getCaller(): Either { - return left(ownPublicKey()); -} - -// ─── View ─────────────────────────────────────────────────────────────── - -export circuit isProposalApprovedBySigner( - id: Uint<64>, - signer: Either -): Boolean { - if (!_proposalApprovals.member(disclose(id)) || !_proposalApprovals.lookup(disclose(id)).member(disclose(signer))) { - return false; - } - - return _proposalApprovals.lookup(disclose(id)).lookup(disclose(signer)); -} - -export circuit getApprovalCount(id: Uint<64>): Uint<8> { - if (!_approvalCount.member(disclose(id))) { - return 0; - } - - return _approvalCount.lookup(disclose(id)); -} - -// IProposalManager - -export circuit getProposal(id: Uint<64>): Proposal_Proposal { - return Proposal_getProposal(id); -} - -export circuit getProposalRecipient(id: Uint<64>): Proposal_Recipient { - return Proposal_getProposalRecipient(id); -} - -export circuit getProposalAmount(id: Uint<64>): Uint<128> { - return Proposal_getProposalAmount(id); -} - -export circuit getProposalColor(id: Uint<64>): Bytes<32> { - return Proposal_getProposalColor(id); -} - -export circuit getProposalStatus(id: Uint<64>): Proposal_ProposalStatus { - return Proposal_getProposalStatus(id); -} - -// IShieldedTreasury - -export circuit getTokenBalance(color: Bytes<32>): Uint<128> { - return Treasury_getTokenBalance(color); -} - -export circuit getReceivedTotal(color: Bytes<32>): Uint<128> { - return Treasury_getReceivedTotal(color); -} - -export circuit getSentTotal(color: Bytes<32>): Uint<128> { - return Treasury_getSentTotal(color); -} - -export circuit getReceivedMinusSent(color: Bytes<32>): Uint<128> { - return Treasury_getReceivedMinusSent(color); -} - -// ISignerManager - -export circuit getSignerCount(): Uint<8> { - return Signer_getSignerCount(); -} - -export circuit getThreshold(): Uint<8> { - return Signer_getThreshold(); -} - -export circuit isSigner(account: Either): Boolean { - return Signer_isSigner(account); -} diff --git a/contracts/src/multisig/presets/ShieldedMultiSigV2.compact b/contracts/src/multisig/presets/ShieldedMultiSigV2.compact deleted file mode 100644 index a37c5d7f..00000000 --- a/contracts/src/multisig/presets/ShieldedMultiSigV2.compact +++ /dev/null @@ -1,280 +0,0 @@ -// SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.2.0 (multisig/presets/ShieldedMultiSigV2.compact) - -pragma language_version >= 0.23.0; - -/** - * @title ShieldedMultisigV2 - * @description Privacy-preserving 2-of-3 multisig contract. - * - * Signer identities are stored as commitments: hashes of ECDSA public - * keys combined with an instance salt and domain separator. Signature - * verification happens in a single transaction with no multi-step - * proposal lifecycle. The contract enforces threshold authorization - * and replay protection. All other coordination (signature collection, - * coin selection) happens off-chain. - * - * Treasury is fully stateless meaning coin data is not stored on the public ledger. - * Deposits call receiveShielded only. The operator discovers coin indices - * through ZswapOutput events from the indexer, constructs QualifiedShieldedCoinInfo - * off-chain, and provides it as a circuit parameter for spending. - */ - -import CompactStandardLibrary; - -import "../proposal/ProposalManager" prefix Proposal_; -import "../treasury/ShieldedTreasuryStateless" prefix Treasury_; -import "../signer/SignerManager"> prefix Signer_; - -// ─── Types ────────────────────────────────────────────────────── - -/** - * @description Accumulator for fold-based signature verification. - * Threads the valid count, previous commitment (for duplicate - * detection), and message hash through each iteration. - */ -export struct VerificationState { - validCount: Uint<8>, - prevCommitment: Bytes<32>, - msgHash: Bytes<32> -} - -/** - * @description Input to persistentHash for computing signer commitments. - * Combines the ECDSA public key with an instance-specific salt and - * domain separator to produce a unique, unlinkable commitment. - */ -export struct SignerCommitmentInput { - pk: Bytes<64>, - salt: Bytes<32>, - domain: Bytes<32> -} - -// ─── State ────────────────────────────────────────────────────── - -ledger _nonce: Counter; -ledger _instanceSalt: Bytes<32>; - -// ─── Constructor ──────────────────────────────────────────────── - -/** - * @description Deploys the multisig with 3 signer commitments and - * a threshold. - * - * Each commitment is computed off-chain as: - * persistentHash(SignerCommitmentInput { pk, instanceSalt, domain }) - * where domain is pad(32, "MultiSig:signer:"). - * - * The instanceSalt should be a random value to prevent the same public - * key from producing the same commitment across different multisig - * deployments, breaking cross-contract signer correlation. - * - * Requirements: - * - * - `thresh` must be > 0 and <= 2 (matches the 2-signature `execute` surface). - * - `signerCommitments` must not contain duplicates. - * - `instanceSalt` should be cryptographically random. - * - * @param {Bytes<32>} instanceSalt - Random salt for commitment derivation. - * @param {Vector<3, Bytes<32>>} signerCommitments - Hashed signer identities. - * @param {Uint<8>} thresh - Minimum approvals required. - */ -constructor( - instanceSalt: Bytes<32>, - signerCommitments: Vector<3, Bytes<32>>, - thresh: Uint<8>, -) { - assert( - thresh <= 2, - "ShieldedMultiSigV2: threshold cannot exceed 2 (execute verifies at most 2 signatures)" - ); - _instanceSalt = disclose(instanceSalt); - Signer_initialize<3>(signerCommitments, thresh); -} - -// ─── Deposit ──────────────────────────────────────────────────── - -/** - * @description Receives a shielded coin into the multisig treasury. - * - * No access control which allows anyone to deposit. The coin is claimed at the - * protocol level through receiveShielded. No coin data is stored on the - * public ledger, preserving full balance privacy. - * - * The operator discovers the coin's Merkle tree index by subscribing - * to ZswapOutput events via the indexer, filtering by contract address, - * and extracting mt_index. Combined with the known ShieldedCoinInfo, - * this produces the QualifiedShieldedCoinInfo needed for spending. - * - * @param {ShieldedCoinInfo} coin - The incoming shielded coin. - */ -export circuit deposit(coin: ShieldedCoinInfo): [] { - receiveShielded(disclose(coin)); -} - -// ─── Execute ──────────────────────────────────────────────────── - -/** - * @description Executes a shielded send authorized by threshold signatures. - * - * The circuit reads the current nonce from the ledger, increments it, - * then reconstructs the message hash that signers must have signed - * off-chain: `persistentHash(nonce, recipient address, coin color, amount)`. - * - * Signatures are verified via fold over parallel pubkey and signature - * vectors. Each public key is hashed with the instance salt to produce - * a commitment, checked against the signer registry, and the signature - * is verified against the message hash. Duplicate signers are rejected - * via inequality check on adjacent commitments. - * - * @notice ECDSA verification is stubbed. Replace stubVerifySignature - * with ecdsaVerify when Compact ECDSA primitives are available. - * - * @notice Duplicate detection via != only works for exactly 2 signers. - * Production contracts with larger signer sets need a different - * uniqueness enforcement mechanism. - * - * Requirements: - * - * - Both public keys must hash to registered signer commitments. - * - Both signatures must be valid over the message hash. - * - Signers must not be duplicates. - * - Coin value must be >= amount. - * - * @param {Proposal_Recipient} to - The recipient. - * @param {Uint<128>} amount - The amount to send. - * @param {QualifiedShieldedCoinInfo} coin - The coin to spend (from operator's pool). - * @param {Vector<2, Bytes<64>>} pubkeys - ECDSA public keys of approving signers. - * @param {Vector<2, Bytes<64>>} signatures - ECDSA signatures over the operation. - * - * @returns {ShieldedSendResult} The send result including any change. - */ -export circuit execute( - to: Proposal_Recipient, - amount: Uint<128>, - coin: QualifiedShieldedCoinInfo, - pubkeys: Vector<2, Bytes<64>>, - signatures: Vector<2, Bytes<64>> -): ShieldedSendResult { - // Increment nonce - const currentNonce = _nonce; - _nonce.increment(1); - - // Construct message hash - const msgHash = persistentHash>>([ - currentNonce as Bytes<32>, - to.address, - coin.color, - amount as Bytes<32> - ]); - - // Verify signatures via fold over parallel vectors - const initialState = VerificationState { - validCount: 0 as Uint<8>, - prevCommitment: pad(32, ""), - msgHash: msgHash - }; - - const finalState = fold(verifySignature, initialState, pubkeys, signatures); - Signer_assertThresholdMet(finalState.validCount); - - // Execute transfer - const normalizedRecipient = Proposal_toShieldedRecipient(to); - return Treasury__send(coin, normalizedRecipient, amount); -} - -// ─── Signature Verification ───────────────────────────────────── - -/** - * @description Fold callback. Verifies one signer's approval. - * - * Computes the signer's commitment from their public key and the - * instance salt, checks for duplicates against the previous commitment, - * verifies registry membership, and validates the ECDSA signature. - * - * @param {VerificationState} state - Accumulator threaded through fold. - * @param {Bytes<64>} pubkey - The signer's ECDSA public key. - * @param {Bytes<64>} signature - The signer's signature over msgHash. - * - * @returns {VerificationState} Updated accumulator. - */ -circuit verifySignature( - state: VerificationState, - pubkey: Bytes<64>, - signature: Bytes<64> -): VerificationState { - const commitment = _calculateSignerId(pubkey, _instanceSalt); - - // Duplicate detection — sufficient for 2 signers only - assert(commitment != state.prevCommitment, "Multisig: duplicate signer"); - - // Verify this commitment is a registered signer - Signer_assertSigner(commitment); - - // TODO: Replace with actual ECDSA primitive when available - // assert(ecdsaVerify(pubkey, state.msgHash, signature), "Multisig: invalid signature"); - assert(stubVerifySignature(pubkey, state.msgHash, signature), "Multisig: invalid signature"); - - return VerificationState { - validCount: state.validCount + 1 as Uint<8>, - prevCommitment: commitment, - msgHash: state.msgHash - }; -} - -/** - * @description Computes a signer commitment from an ECDSA public key. - * - * The commitment is persistentHash(pk, salt, domain) where: - * - pk: the signer's ECDSA public key (64 bytes) - * - salt: instance-specific random value (prevents cross-contract correlation) - * - domain: "MultiSig:signer:" (domain separation) - * - * This is a pure circuit. It can be called off-chain by the deployer - * to compute commitments for the constructor. - * - * @param {Bytes<64>} pk - The ECDSA public key. - * @param {Bytes<32>} salt - The instance salt. - * - * @returns {Bytes<32>} The signer commitment. - */ -export pure circuit _calculateSignerId( - pk: Bytes<64>, - salt: Bytes<32> -): Bytes<32> { - return persistentHash(SignerCommitmentInput { - pk: pk, - salt: salt, - domain: pad(32, "MultiSig:signer:") - }); -} - -/** - * @description Stub for ECDSA signature verification. - * Always returns true. MUST be replaced before any non-test deployment. - */ -circuit stubVerifySignature( - pubkey: Bytes<64>, - msgHash: Bytes<32>, - signature: Bytes<64> -): Boolean { - return true; -} - -// ─── View ─────────────────────────────────────────────────────── - -export circuit getNonce(): Uint<64> { - return _nonce; -} - -export circuit getSignerCount(): Uint<8> { - return Signer_getSignerCount(); -} - -export circuit getThreshold(): Uint<8> { - return Signer_getThreshold(); -} - -export circuit isSigner(commitment: Bytes<32>): Boolean { - return Signer_isSigner(commitment); -} diff --git a/contracts/src/multisig/presets/ShieldedMultiSigV3.compact b/contracts/src/multisig/presets/ShieldedMultiSigV3.compact deleted file mode 100644 index c5f9ba62..00000000 --- a/contracts/src/multisig/presets/ShieldedMultiSigV3.compact +++ /dev/null @@ -1,373 +0,0 @@ -// SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.0.1-alpha.1 (multisig/presets/ShieldedMultiSigV3.compact) - -pragma language_version >= 0.23.0; - -/** - * @title ShieldedMultiSigV3 - * @description Privacy-preserving multisig token contract for tokenised - * deposits. Both minting and burning require threshold ECDSA - * authorization. No single party can unilaterally create or destroy tokens. - * - * Designed with the following features: - * - Supply only enters through authorized mint (no open deposit) - * - Supply only exits through authorized burn (no arbitrary transfers) - * - Non-transferability enforced by absence of transfer/execute circuits - * - Change coins from partial burns handled by the transaction layer - * - Operator discovers coins via ZswapOutput events from the indexer - * - * Uses Midnight's native shielded token primitives: - * - * - `mint` creates a new UTXO via `mintShieldedToken`, addressed to the - * contract. No external coin input; supply is produced on-chain. - * - `burn` consumes a UTXO via `sendShielded` to `burnAddress()`. Only - * coins of this contract's token type can be burned. Change is handled - * automatically by the transaction layer. - * - * Signer identities are stored as commitments: hashes of ECDSA public keys - * combined with an instance salt and domain separator. A counter - * provides replay protection, and also feeds `evolveNonce` to produce - * unique coin nonces on each mint. - * - * Operation domain prefixes ("multisig:mint:" / "multisig:burn:") in the message - * hash prevent signatures for one operation type from being replayed as - * the other. - * - * @notice ECDSA verification is stubbed. Replace `stubVerifySignature` with - * `ecdsaVerify`, and `persistentHash` with `keccak256`, once the Compact - * ECDSA and Keccak primitives are available. - */ - -import CompactStandardLibrary; - -import "../signer/SignerManager"> prefix Signer_; -import "../../utils/Utils" prefix Utils_; -// For testing -export { ZswapCoinPublicKey }; - -// ─── Types ────────────────────────────────────────────────────── - -/** - * @description Accumulator for fold-based signature verification. - * Threads the valid count, previous commitment (for duplicate - * detection), and message hash through each iteration. - */ -struct VerificationState { - validCount: Uint<8>, - prevCommitment: Bytes<32>, - msgHash: Bytes<32> -} - -/** - * @description Input to persistentHash for computing signer commitments. - * Combines the ECDSA public key with an instance-specific salt and - * domain separator to produce a unique, unlinkable commitment. - */ -struct SignerCommitmentInput { - pk: Bytes<64>, - salt: Bytes<32>, - domain: Bytes<32> -} - -// ─── State ────────────────────────────────────────────────────── - -export ledger _counter: Counter; -export ledger _coinNonce: Bytes<32>; -export ledger _instanceSalt: Bytes<32>; -export sealed ledger _tokenDomain: Bytes<32>; - -// ─── Constructor ──────────────────────────────────────────────── - -/** - * @description Deploys the contract with 3 signer commitments and - * a threshold of 2. - * - * Each commitment is computed off-chain as: - * `persistentHash(SignerCommitmentInput { pk, instanceSalt, domain })` - * where domain is `pad(32, "multisig:signer:")`. - * - * `tokenDomain` is used with `kernel.self()` to derive the token color - * via `tokenType(_tokenDomain, kernel.self())`. Only coins of this color - * can be burned through this contract. - * - * `initCoinNonce` seeds the `evolveNonce` chain used to produce unique - * mint nonces. It must be cryptographically random. - * - * Requirements: - * - * - `signerCommitments` must not contain duplicates. - * - `instanceSalt` and `initCoinNonce` should be cryptographically random. - * - * @param {Bytes<32>} instanceSalt - Random salt for signer commitment derivation. - * @param {Bytes<32>} initCoinNonce - Initial coin nonce seed. - * @param {Bytes<32>} tokenDomain - Domain string used to derive this contract's token color. - * @param {Vector<3, Bytes<32>>} signerCommitments - Hashed signer identities. - */ -constructor( - instanceSalt: Bytes<32>, - initCoinNonce: Bytes<32>, - tokenDomain: Bytes<32>, - signerCommitments: Vector<3, Bytes<32>>, -) { - _instanceSalt = disclose(instanceSalt); - _coinNonce = disclose(initCoinNonce); - _tokenDomain = disclose(tokenDomain); - Signer_initialize<3>(signerCommitments, 2); -} - -// TODO: the mint/burn token-issuance logic below is slated to move into a -// reusable `ShieldedToken` module so it can be -// composed independently, mirroring how `SignerManager` / `SignatureVerifier` are -// factored. Kept inlined here for now. - -// ─── Mint ─────────────────────────────────────────────────────── - -/** - * @description Mints a new shielded coin addressed to the specified - * recipient, authorized by threshold signatures. - * - * Creates a new UTXO of this contract's token type via `mintShieldedToken`, - * addressed to the provided recipient. No external coin input is required; - * supply is produced on-chain. - * - * The message hash commits to: - * - operation domain ("multisig:mint:" for burn op replay protection) - * - contract address (cross-instance replay protection) - * - recipient (redirection protection) - * - counter value (replay protection) - * - amount - * - * Coin nonce uniqueness is guaranteed by `evolveNonce(_counter, _coinNonce)` - * after the counter has been incremented, binding each mint's nonce to a - * distinct counter value. - * - * @notice Replace `persistentHash` with `keccak256` and `stubVerifySignature` - * with `ecdsaVerify` once the Compact ECDSA and Keccak primitives are - * available, to match the custodian's HSM signing format. - * - * Requirements: - * - * - Both public keys must hash to registered signer commitments. - * - Both signatures must be valid over the mint message hash. - * - Signers must not be duplicates. - * - Threshold must be met. - * - * @param {Uint<64>} amount - The token amount to mint. - * @param {Either} recipient - The address to receive the minted tokens. - * @param {Vector<2, Bytes<64>>} pubkeys - ECDSA public keys of approving signers. - * @param {Vector<2, Bytes<64>>} signatures - ECDSA signatures over the mint hash. - */ -export circuit mint( - amount: Uint<64>, - recipient: Either, - pubkeys: Vector<2, Bytes<64>>, - signatures: Vector<2, Bytes<64>> -): [] { - const opNonce = _counter; - _counter.increment(1); - - // Canonicalize recipient to ensure garbage values don't sully the hash - const canonRecipient = Utils_canonicalize(recipient); - const recipientHash = persistentHash>(canonRecipient); - - const msgHash = persistentHash>>([ - pad(32, "multisig:mint:"), - kernel.self().bytes, - recipientHash, - opNonce as Bytes<32>, - amount as Bytes<32> - ]); - - const initialState = VerificationState { - validCount: 0 as Uint<8>, - prevCommitment: pad(32, ""), - msgHash: msgHash - }; - - const finalState = fold(verifySignature, initialState, pubkeys, signatures); - // Thresh check not needed as it will always be 2 - // Leaving it as defense-in-depth - Signer_assertThresholdMet(finalState.validCount); - - _coinNonce = evolveNonce(_counter, _coinNonce); - mintShieldedToken(_tokenDomain, disclose(amount), _coinNonce, disclose(canonRecipient)); -} - -// ─── Burn ─────────────────────────────────────────────────────── - -/** - * @description Burns a shielded coin of this contract's token type, - * authorized by threshold signatures. - * - * Sends the specified amount of the supplied coin to `burnAddress()` via - * `sendShielded`. The nullifier is submitted on-chain, permanently marking - * the UTXO as spent. - * - * Change handling is automatic: if `amount < coin.value`, the transaction - * layer creates a change output addressed back to the contract. The operator - * discovers this change coin via `nextZswapLocalState.outputs` in the - * transaction's private result, then discovers its mt_index from the - * indexer events using the standard flow. No contract-level change logic - * is required. - * - * Only coins of this contract's token type can be burned. The operator - * supplies the `QualifiedShieldedCoinInfo` from the off-chain UTXO pool. - * - * The "multisig:burn:" domain prefix ensures burn signatures cannot be replayed - * as mint operations for the same parameters. - * - * @notice Replace `persistentHash` with `keccak256` and `stubVerifySignature` - * with `ecdsaVerify` once the Compact ECDSA and Keccak primitives are - * available, to match the custodian's HSM signing format. - * - * Requirements: - * - * - Both public keys must hash to registered signer commitments. - * - Both signatures must be valid over the burn message hash. - * - Signers must not be duplicates. - * - Threshold must be met. - * - coin.color must equal tokenType(_tokenDomain, kernel.self()). - * - coin.value must be >= amount. - * - * @param {QualifiedShieldedCoinInfo} coin - The coin to burn (from operator's UTXO pool). - * @param {Uint<64>} amount - The token amount to burn. - * @param {Vector<2, Bytes<64>>} pubkeys - ECDSA public keys of approving signers. - * @param {Vector<2, Bytes<64>>} signatures - ECDSA signatures over the burn hash. - */ -export circuit burn( - coin: QualifiedShieldedCoinInfo, - amount: Uint<64>, - pubkeys: Vector<2, Bytes<64>>, - signatures: Vector<2, Bytes<64>> -): [] { - const opNonce = _counter; - _counter.increment(1); - - const msgHash = persistentHash>>([ - pad(32, "multisig:burn:"), - kernel.self().bytes, - opNonce as Bytes<32>, - amount as Bytes<32> - ]); - - const initialState = VerificationState { - validCount: 0 as Uint<8>, - prevCommitment: pad(32, ""), - msgHash: msgHash - }; - - const finalState = fold(verifySignature, initialState, pubkeys, signatures); - // Thresh check not needed as it will always be 2 - // Leaving it as defense-in-depth - Signer_assertThresholdMet(finalState.validCount); - - assert(coin.color == tokenType(_tokenDomain, kernel.self()), "Multisig: coin not from this contract"); - assert(coin.value >= amount, "Multisig: insufficient coin value"); - - sendShielded(disclose(coin), shieldedBurnAddress(), disclose(amount)); -} - -// ─── Signature Verification ───────────────────────────────────── - -/** - * @description Fold callback. Verifies one signer's approval. - * - * Computes the signer's commitment from their public key and the instance - * salt, checks for duplicates against the previous commitment, verifies - * registry membership, and validates the ECDSA signature. - * - * @notice Circuit signatures are fixed at Vector<2, ...>, so this contract - * supports only a fixed 2-of-3 configuration. Supporting more signatures - * (e.g. 3-of-N) requires a separate variant with a larger vector and a - * different duplicate-detection mechanism (sorted commitments or bitmap). - * - * @param {VerificationState} state - Accumulator threaded through fold. - * @param {Bytes<64>} pubkey - The signer's ECDSA public key. - * @param {Bytes<64>} signature - The signer's signature over msgHash. - * @returns {VerificationState} Updated accumulator. - */ -circuit verifySignature( - state: VerificationState, - pubkey: Bytes<64>, - signature: Bytes<64> -): VerificationState { - const commitment = _calculateSignerId(pubkey, _instanceSalt); - - // Duplicate detection — sufficient for 2 signers only - assert(commitment != state.prevCommitment, "Multisig: duplicate signer"); - - Signer_assertSigner(commitment); - - // TODO: Replace with ecdsaVerify + keccak256 when primitives are available - assert(stubVerifySignature(pubkey, state.msgHash, signature), "Multisig: invalid signature"); - - return VerificationState { - validCount: state.validCount + 1 as Uint<8>, - prevCommitment: commitment, - msgHash: state.msgHash - }; -} - -/** - * @description Computes a signer commitment from an ECDSA public key. - * - * The commitment is persistentHash(pk, salt, domain) where: - * - pk: the signer's ECDSA public key (64 bytes) - * - salt: instance-specific random value (prevents cross-contract correlation) - * - domain: "multisig:signer:" (domain separation) - * - * Pure circuit — callable off-chain by the deployer to compute - * commitments for the constructor. - * - * @param {Bytes<64>} pk - The ECDSA public key. - * @param {Bytes<32>} salt - The instance salt. - * @returns {Bytes<32>} The signer commitment. - */ -export pure circuit _calculateSignerId( - pk: Bytes<64>, - salt: Bytes<32> -): Bytes<32> { - return persistentHash(SignerCommitmentInput { - pk: pk, - salt: salt, - domain: pad(32, "multisig:signer:") - }); -} - -/** - * @description Stub for ECDSA signature verification. - * Always returns true. MUST be replaced before any non-test deployment. - */ -circuit stubVerifySignature( - pubkey: Bytes<64>, - msgHash: Bytes<32>, - signature: Bytes<64> -): Boolean { - return true; -} - -// ─── View ─────────────────────────────────────────────────────── - -export circuit getNonce(): Uint<64> { - return _counter; -} - -export circuit getTokenDomain(): Bytes<32> { - return _tokenDomain; -} - -export circuit getTokenType(): Bytes<32> { - return tokenType(_tokenDomain, kernel.self()); -} - -export circuit getSignerCount(): Uint<8> { - return Signer_getSignerCount(); -} - -export circuit getThreshold(): Uint<8> { - return Signer_getThreshold(); -} - -export circuit isSigner(commitment: Bytes<32>): Boolean { - return Signer_isSigner(commitment); -} diff --git a/contracts/src/multisig/test/mocks/MockForwarderPrivate.compact b/contracts/src/multisig/test/mocks/MockForwarderPrivate.compact index 01888277..28f6637c 100644 --- a/contracts/src/multisig/test/mocks/MockForwarderPrivate.compact +++ b/contracts/src/multisig/test/mocks/MockForwarderPrivate.compact @@ -8,7 +8,7 @@ pragma language_version >= 0.23.0; import CompactStandardLibrary; -import "../../forwarder/ForwarderPrivate" prefix ForwarderPrivate_; +import "../../forwarder/PrivateNativeShieldedForwarder" prefix ForwarderPrivate_; export { ShieldedCoinInfo, QualifiedShieldedCoinInfo, ShieldedSendResult, ZswapCoinPublicKey }; diff --git a/contracts/src/multisig/test/mocks/MockForwarderShielded.compact b/contracts/src/multisig/test/mocks/MockForwarderShielded.compact index 568a73bf..e5ffffa2 100644 --- a/contracts/src/multisig/test/mocks/MockForwarderShielded.compact +++ b/contracts/src/multisig/test/mocks/MockForwarderShielded.compact @@ -8,7 +8,7 @@ pragma language_version >= 0.23.0; import CompactStandardLibrary; -import "../../forwarder/ForwarderShielded" prefix Forwarder_; +import "../../forwarder/NativeShieldedForwarder" prefix Forwarder_; export { ZswapCoinPublicKey, ContractAddress, ShieldedCoinInfo, Either }; diff --git a/contracts/src/multisig/test/mocks/MockForwarderUnshielded.compact b/contracts/src/multisig/test/mocks/MockForwarderUnshielded.compact index 653a3614..3817f614 100644 --- a/contracts/src/multisig/test/mocks/MockForwarderUnshielded.compact +++ b/contracts/src/multisig/test/mocks/MockForwarderUnshielded.compact @@ -8,7 +8,7 @@ pragma language_version >= 0.23.0; import CompactStandardLibrary; -import "../../forwarder/ForwarderUnshielded" prefix Forwarder_; +import "../../forwarder/NativeUnshieldedForwarder" prefix Forwarder_; export { ContractAddress, UserAddress, Either }; diff --git a/contracts/src/multisig/test/mocks/MockShieldedTreasury.compact b/contracts/src/multisig/test/mocks/MockShieldedTreasury.compact index 9b181553..9e2e5799 100644 --- a/contracts/src/multisig/test/mocks/MockShieldedTreasury.compact +++ b/contracts/src/multisig/test/mocks/MockShieldedTreasury.compact @@ -9,7 +9,7 @@ pragma language_version >= 0.23.0; import CompactStandardLibrary; -import "../../treasury/ShieldedTreasury" prefix Treasury_; +import "../../treasury/NativeShieldedTreasury" prefix Treasury_; export circuit _deposit(coin: ShieldedCoinInfo): [] { return Treasury__deposit(coin); diff --git a/contracts/src/multisig/test/mocks/MockShieldedTreasuryStateless.compact b/contracts/src/multisig/test/mocks/MockShieldedTreasuryStateless.compact index a2b1e742..d31cf19e 100644 --- a/contracts/src/multisig/test/mocks/MockShieldedTreasuryStateless.compact +++ b/contracts/src/multisig/test/mocks/MockShieldedTreasuryStateless.compact @@ -8,7 +8,7 @@ pragma language_version >= 0.23.0; import CompactStandardLibrary; -import "../../treasury/ShieldedTreasuryStateless" prefix Treasury_; +import "../../treasury/NativeShieldedTreasuryStateless" prefix Treasury_; export circuit _deposit(coin: ShieldedCoinInfo): [] { Treasury__deposit(coin); diff --git a/contracts/src/multisig/test/mocks/MockSignatureVerifier.compact b/contracts/src/multisig/test/mocks/MockSignatureVerifier.compact new file mode 100644 index 00000000..06939a21 --- /dev/null +++ b/contracts/src/multisig/test/mocks/MockSignatureVerifier.compact @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MIT + +// WARNING: FOR TESTING PURPOSES ONLY. +// This contract exposes internal circuits and bypasses safety checks that the +// corresponding production contract relies on. DO NOT deploy or use this +// contract in any production application. + +pragma language_version >= 0.23.0; + +import CompactStandardLibrary; + +import "../../SignatureVerifier" prefix Signature_; + +constructor(salt: Bytes<32>, signers: Vector<3, Bytes<32>>, thresh: Uint<8>) { + Signature_initialize<3>(salt, signers, thresh); +} + +export circuit verify( + msgHash: Bytes<32>, + pubkeys: Vector<2, Bytes<64>>, + signatures: Vector<2, Bytes<64>> +): [] { + return Signature_verify<2>(msgHash, pubkeys, signatures); +} + +export pure circuit _calculateSignerId(pk: Bytes<64>, salt: Bytes<32>): Bytes<32> { + return Signature__calculateSignerId(pk, salt); +} + +export circuit getSignerCount(): Uint<8> { + return Signature_getSignerCount(); +} + +export circuit getThreshold(): Uint<8> { + return Signature_getThreshold(); +} + +export circuit isSigner(account: Bytes<32>): Boolean { + return Signature_isSigner(account); +} diff --git a/contracts/src/multisig/test/mocks/MockSignerManager.compact b/contracts/src/multisig/test/mocks/MockSignerManager.compact index 9bce8484..4e2e5660 100644 --- a/contracts/src/multisig/test/mocks/MockSignerManager.compact +++ b/contracts/src/multisig/test/mocks/MockSignerManager.compact @@ -9,8 +9,8 @@ pragma language_version >= 0.23.0; import CompactStandardLibrary; -import "../../signer/SignerManager"> prefix Signer_; -import "../../signer/SignerManager">; +import "../../SignerManager"> prefix Signer_; +import "../../SignerManager">; export { ZswapCoinPublicKey, ContractAddress, Either, Maybe }; export { _signers, _signerCount, _threshold }; diff --git a/contracts/src/multisig/test/mocks/MockUnshieldedTreasury.compact b/contracts/src/multisig/test/mocks/MockUnshieldedTreasury.compact index 6b8a6b21..71b7d9d5 100644 --- a/contracts/src/multisig/test/mocks/MockUnshieldedTreasury.compact +++ b/contracts/src/multisig/test/mocks/MockUnshieldedTreasury.compact @@ -9,7 +9,7 @@ pragma language_version >= 0.23.0; import CompactStandardLibrary; -import "../../treasury/UnshieldedTreasury" prefix Treasury_; +import "../../treasury/NativeUnshieldedTreasury" prefix Treasury_; export circuit _deposit(color: Bytes<32>, amount: Uint<128>): [] { return Treasury__deposit(color, amount); diff --git a/contracts/src/multisig/treasury/ShieldedTreasury.compact b/contracts/src/multisig/treasury/NativeShieldedTreasury.compact similarity index 93% rename from contracts/src/multisig/treasury/ShieldedTreasury.compact rename to contracts/src/multisig/treasury/NativeShieldedTreasury.compact index 4a29beb0..099cf6f3 100644 --- a/contracts/src/multisig/treasury/ShieldedTreasury.compact +++ b/contracts/src/multisig/treasury/NativeShieldedTreasury.compact @@ -1,10 +1,10 @@ // SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.2.0 (multisig/ShieldedTreasury.compact) +// OpenZeppelin Compact Contracts v0.2.0 (multisig/treasury/NativeShieldedTreasury.compact) pragma language_version >= 0.23.0; /** - * @module ShieldedTreasury + * @module NativeShieldedTreasury * @description Manages shielded (private) token deposits, accounting, * and transfers for multisig governance contracts. * @@ -22,7 +22,7 @@ pragma language_version >= 0.23.0; * enforcement. The consuming contract must gate these behind its own * authorization policy. */ -module ShieldedTreasury { +module NativeShieldedTreasury { import CompactStandardLibrary; import { selfAsRecipient, UINT128_MAX } from "../../utils/Utils" prefix Utils_; @@ -60,7 +60,7 @@ module ShieldedTreasury { */ export circuit _deposit(coin: ShieldedCoinInfo): [] { const currentReceived = getReceivedTotal(coin.color); - assert(currentReceived <= Utils_UINT128_MAX() - coin.value, "ShieldedTreasury: overflow"); + assert(currentReceived <= Utils_UINT128_MAX() - coin.value, "NativeShieldedTreasury: overflow"); receiveShielded(disclose(coin)); @@ -109,13 +109,13 @@ module ShieldedTreasury { color: Bytes<32>, amount: Uint<128> ): ShieldedSendResult { - assert(_coins.member(disclose(color)), "ShieldedTreasury: no balance"); + assert(_coins.member(disclose(color)), "NativeShieldedTreasury: no balance"); const coin = _coins.lookup(disclose(color)); - assert(coin.value >= amount, "ShieldedTreasury: coin value insufficient"); + assert(coin.value >= amount, "NativeShieldedTreasury: coin value insufficient"); const currentSent = getSentTotal(color); - assert(currentSent <= Utils_UINT128_MAX() - amount, "ShieldedTreasury: overflow"); + assert(currentSent <= Utils_UINT128_MAX() - amount, "NativeShieldedTreasury: overflow"); const result = sendShielded(coin, disclose(recipient), disclose(amount)); diff --git a/contracts/src/multisig/treasury/ShieldedTreasuryStateless.compact b/contracts/src/multisig/treasury/NativeShieldedTreasuryStateless.compact similarity index 93% rename from contracts/src/multisig/treasury/ShieldedTreasuryStateless.compact rename to contracts/src/multisig/treasury/NativeShieldedTreasuryStateless.compact index 96529afa..f9a5bbea 100644 --- a/contracts/src/multisig/treasury/ShieldedTreasuryStateless.compact +++ b/contracts/src/multisig/treasury/NativeShieldedTreasuryStateless.compact @@ -1,10 +1,10 @@ // SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.2.0 (multisig/ShieldedTreasuryStateless.compact) +// OpenZeppelin Compact Contracts v0.2.0 (multisig/treasury/NativeShieldedTreasuryStateless.compact) pragma language_version >= 0.23.0; /** - * @module ShieldedTreasury + * @module NativeShieldedTreasuryStateless * @description Manages shielded (private) token deposits, accounting, * and transfers for multisig governance contracts. * @@ -18,7 +18,7 @@ pragma language_version >= 0.23.0; * purposes. The canonical balance query is `getTokenBalance`, which * reads the actual coin value from the UTXO map. */ -module ShieldedTreasuryStateless { +module NativeShieldedTreasuryStateless { import CompactStandardLibrary; import { selfAsRecipient } from "../../utils/Utils" prefix Utils_; diff --git a/contracts/src/multisig/treasury/UnshieldedTreasury.compact b/contracts/src/multisig/treasury/NativeUnshieldedTreasury.compact similarity index 93% rename from contracts/src/multisig/treasury/UnshieldedTreasury.compact rename to contracts/src/multisig/treasury/NativeUnshieldedTreasury.compact index 18fbd5fa..3f9e4d9e 100644 --- a/contracts/src/multisig/treasury/UnshieldedTreasury.compact +++ b/contracts/src/multisig/treasury/NativeUnshieldedTreasury.compact @@ -1,10 +1,10 @@ // SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.2.0 (multisig/UnshieldedTreasury.compact) +// OpenZeppelin Compact Contracts v0.2.0 (multisig/treasury/NativeUnshieldedTreasury.compact) pragma language_version >= 0.23.0; /** - * @module UnshieldedTreasury + * @module NativeUnshieldedTreasury * @description Manages unshielded (transparent) token deposits and * transfers for multisig governance contracts. * @@ -17,7 +17,7 @@ pragma language_version >= 0.23.0; * enforcement. The consuming contract must gate these behind its own * authorization policy. */ -module UnshieldedTreasury { +module NativeUnshieldedTreasury { import CompactStandardLibrary; import { UINT128_MAX } from "../../utils/Utils" prefix Utils_; @@ -53,7 +53,7 @@ module UnshieldedTreasury { export circuit _deposit(color: Bytes<32>, amount: Uint<128>): [] { assert( unshieldedBalanceLte(disclose(color), Utils_UINT128_MAX() - disclose(amount)), - "UnshieldedTreasury: overflow" + "NativeUnshieldedTreasury: overflow" ); receiveUnshielded(disclose(color), disclose(amount)); @@ -88,7 +88,7 @@ module UnshieldedTreasury { ): [] { assert( unshieldedBalanceGte(disclose(color), disclose(amount)), - "UnshieldedTreasury: insufficient balance" + "NativeUnshieldedTreasury: insufficient balance" ); const bal = getTokenBalance(color); From d81117a98897134950f47bcb0a2cc7a608021888 Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Wed, 24 Jun 2026 19:22:06 +0200 Subject: [PATCH 05/13] refactor(multisig): split signer manager by scheme Make signature verification a swappable per-scheme module, since Compact cannot make `verify` generic over a signature scheme. * SignerManager stays the general signer registry (signer set, threshold, membership, add/remove), generic over the identity type: > commitments for the signature path, > for caller-authorized governance. * EcdsaSignerManager (formerly SignatureVerifier) wraps SignerManager> and adds threshold ECDSA-commitment verification (instance salt, commitment derivation, verify). The cryptographic check is stubbed until the Compact ECDSA primitive lands. It is the single entrance the signature examples import; a future SchnorrSignerManager is a sibling of the same shape. * SignatureTreasury, SignatureMintBurn, and the NativeShieldedTokenVault preset import EcdsaSignerManager. ProposalTreasury (caller-auth V1) keeps using SignerManager directly. The signer count stays a single source of truth: one SignerManager registry under EcdsaSignerManager, shared across the Vault's mint/burn/execute via the same import path. Compiles green under SKIP_ZK (28/28 multisig contracts). The .ts test stacks remain renamed-on-disk and deferred to a follow-up. --- ...ier.compact => EcdsaSignerManager.compact} | 42 ++++++++------ contracts/src/multisig/SignerManager.compact | 58 +++++++++---------- .../examples/ProposalTreasury.compact | 2 +- .../examples/SignatureMintBurn.compact | 20 +++---- .../examples/SignatureTreasury.compact | 18 +++--- .../presets/NativeShieldedTokenVault.compact | 2 +- .../test/mocks/MockSignatureVerifier.compact | 2 +- 7 files changed, 73 insertions(+), 71 deletions(-) rename contracts/src/multisig/{SignatureVerifier.compact => EcdsaSignerManager.compact} (82%) diff --git a/contracts/src/multisig/SignatureVerifier.compact b/contracts/src/multisig/EcdsaSignerManager.compact similarity index 82% rename from contracts/src/multisig/SignatureVerifier.compact rename to contracts/src/multisig/EcdsaSignerManager.compact index b6586472..bcd10863 100644 --- a/contracts/src/multisig/SignatureVerifier.compact +++ b/contracts/src/multisig/EcdsaSignerManager.compact @@ -1,27 +1,31 @@ // SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.2.0 (multisig/SignatureVerifier.compact) +// OpenZeppelin Compact Contracts v0.2.0 (multisig/EcdsaSignerManager.compact) pragma language_version >= 0.23.0; /** - * @module SignatureVerifier - * @description Threshold ECDSA-commitment signature verification for multisig - * contracts that collect approvals off-chain. + * @module EcdsaSignerManager + * @description ECDSA signature scheme manager — the signer entrance examples + * import for ECDSA-authorized multisig. Wraps the general `SignerManager>` + * registry and adds threshold ECDSA-commitment verification on top of it. * * Signers are identified on-chain by commitments — `persistentHash` of an ECDSA - * public key with an instance salt and a domain separator — held in the - * `SignerManager>` registry. `verify` checks a parallel vector of - * public keys and signatures in a single transaction: each key is hashed into a - * commitment, checked for membership and duplicates, and its signature - * validated, with the valid count folded against the threshold. + * public key with an instance salt and a domain separator — held in one + * `SignerManager>`. `verify` checks a parallel vector of public keys + * and signatures in a single transaction: each key is hashed into a commitment, + * checked for membership and duplicates, and its signature validated, with the + * valid count folded against the threshold. * - * This module owns the commitment signer registry: it is the sole importer of - * `SignerManager>` for the signature flow and re-exposes the signer - * surface (`initialize`, `getSignerCount`, `getThreshold`, `isSigner`). - * Consuming contracts import only this module — not `SignerManager` directly — - * so there is a single registry. (Compact only shares a module's ledger state - * across imports that use the same import-path string, so a co-import from a - * different directory would create a second, empty registry.) + * This is the ECDSA member of the per-scheme manager family. Compact cannot make + * `verify` generic over a signature scheme, so each scheme is its own manager (a + * future `SchnorrSignerManager` is a sibling) that wraps the same general + * `SignerManager` registry. Caller-authorized contracts that need no signature + * verification use `SignerManager` directly (see `examples/ProposalTreasury`). + * + * The registry state (signer set, count, threshold) lives in one place — the + * underlying `SignerManager>` — and every `<#n>` circuit reads it, so + * the signer count is a single source of truth. Composing modules import the + * same `../EcdsaSignerManager` path so they share that one registry. * * @notice ECDSA verification is stubbed (`stubVerifySignature` always returns * true). Replace it with `ecdsaVerify` once the Compact ECDSA primitive is @@ -31,7 +35,7 @@ pragma language_version >= 0.23.0; * only, which is sufficient for at most 2 signers. Larger signer sets need a * different uniqueness mechanism (sorted commitments or a bitmap). */ -module SignatureVerifier { +module EcdsaSignerManager { import CompactStandardLibrary; import "./SignerManager"> prefix Signer_; @@ -188,12 +192,12 @@ module SignatureVerifier { const commitment = _calculateSignerId(pubkey, _instanceSalt); // Duplicate detection — sufficient for 2 signers only - assert(commitment != state.prevCommitment, "SignatureVerifier: duplicate signer"); + assert(commitment != state.prevCommitment, "EcdsaSignerManager: duplicate signer"); Signer_assertSigner(commitment); // TODO: Replace with ecdsaVerify when the Compact ECDSA primitive is available - assert(stubVerifySignature(pubkey, state.msgHash, signature), "SignatureVerifier: invalid signature"); + assert(stubVerifySignature(pubkey, state.msgHash, signature), "EcdsaSignerManager: invalid signature"); return VerificationState { validCount: state.validCount + 1 as Uint<8>, diff --git a/contracts/src/multisig/SignerManager.compact b/contracts/src/multisig/SignerManager.compact index bd47b094..3a1f057b 100644 --- a/contracts/src/multisig/SignerManager.compact +++ b/contracts/src/multisig/SignerManager.compact @@ -5,43 +5,41 @@ pragma language_version >= 0.23.0; /** * @module SignerManager - * @description Manages signer registry, threshold enforcement, and signer - * validation for multisig governance contracts. + * @description The general signer registry for multisig: signer set, threshold + * enforcement, and signer validation. Every multisig builds on this; a signature + * scheme manager (e.g. `EcdsaSignerManager`) wraps it and adds the verification. * - * Parameterized over the signer identity type `T`, allowing the consuming - * contract to choose the identity mechanism at import time. Common - * instantiations include: + * Parameterized over the signer identity type `T`, so the consuming contract + * picks the identity mechanism at import time. Common instantiations: * - * - `Bytes<32>` for commitment-based identity (e.g., hash of ECDSA public key) - * - `JubjubPoint` for Schnorr/MuSig aggregated key + * - `Bytes<32>` for commitment-based identity (hash of a public key) — used by + * the signature scheme managers. + * - `Either` for on-chain caller identity — + * used by caller-authorized governance (see `examples/ProposalTreasury`). * - * The SignerManager module does not resolve caller identity. It receives a validated - * caller from the contract layer and checks it against the registry. - * This separation allows the identity mechanism to change without - * modifying the module. + * This module does not resolve caller identity or verify signatures. It receives + * a validated identity from the layer above and checks it against the registry. + * Keeping it scheme-agnostic is what lets it serve both the `Bytes<32>` signature + * path and the `Either` caller path from one generic module. * - * The signer count and threshold are of type `Uint<8>`, limiting the - * maximum number of signers and threshold to 255. This is sufficient - * for any practical multisig use case. For large-scale governance - * requiring more signers, consider a Merkle tree-based variant. + * The signer count and threshold are `Uint<8>`, capping signers/threshold at 255 + * — sufficient for any practical multisig. For large-scale governance, consider + * a Merkle tree-based variant. * - * Multi-step signer reconfigurations (e.g., removing a signer and - * lowering the threshold) may produce intermediate states where the - * module's invariants temporarily hold but the contract's intended - * configuration is incomplete. This is a contract-layer concern. - * Contracts should either perform reconfigurations atomically in a - * single circuit or use a configuration nonce to invalidate proposals - * created under a stale signer set. + * Multi-step signer reconfigurations (e.g., removing a signer and lowering the + * threshold) may produce intermediate states where the module's invariants hold + * but the contract's intended configuration is incomplete. This is a + * contract-layer concern: reconfigure atomically in one circuit, or use a + * configuration nonce to invalidate proposals created under a stale signer set. * - * Underscore-prefixed circuits (_addSigner, _removeSigner, - * _changeThreshold) have no access control enforcement. The consuming - * contract must gate these behind its own authorization policy. + * Underscore-prefixed circuits (_addSigner, _removeSigner, _changeThreshold) + * have no access control. The consuming contract must gate them behind its own + * authorization policy. * - * Contracts may handle their own initialization and this module - * supports custom flows. Thus, contracts may choose to not - * call `initialize` in the contract's constructor. Contracts MUST NOT - * call `initialize` outside of the constructor context because - * this could corrupt the signer set and threshold configuration. + * Contracts may handle their own initialization and this module supports custom + * flows, so a contract may choose not to call `initialize` in its constructor. + * Contracts MUST NOT call `initialize` outside the constructor context, as this + * could corrupt the signer set and threshold configuration. */ module SignerManager { import CompactStandardLibrary; diff --git a/contracts/src/multisig/examples/ProposalTreasury.compact b/contracts/src/multisig/examples/ProposalTreasury.compact index 3ac89c57..a62b6c66 100644 --- a/contracts/src/multisig/examples/ProposalTreasury.compact +++ b/contracts/src/multisig/examples/ProposalTreasury.compact @@ -14,7 +14,7 @@ pragma language_version >= 0.23.0; * proposals; once the threshold is met, `executeShieldedProposal` transfers from * the treasury. Unlike the signature-based modules, authorization is by the * on-chain caller (`getCaller`), not off-chain signatures — so it does NOT use - * `SignatureVerifier` and cannot share a registry with those modules. + * `EcdsaSignerManager` (the signature entrance) and cannot share a registry with it. * * @notice Signer identity uses `Either` for * forward compatibility. Today only `left(ZswapCoinPublicKey)` callers can diff --git a/contracts/src/multisig/examples/SignatureMintBurn.compact b/contracts/src/multisig/examples/SignatureMintBurn.compact index 798c9300..8814359a 100644 --- a/contracts/src/multisig/examples/SignatureMintBurn.compact +++ b/contracts/src/multisig/examples/SignatureMintBurn.compact @@ -11,7 +11,7 @@ pragma language_version >= 0.23.0; * * `mint` creates a UTXO of this contract's token type via `mintShieldedToken`; * `burn` consumes one via `sendShielded` to `shieldedBurnAddress()`. Both require - * threshold ECDSA approval verified against the shared `SignatureVerifier` + * threshold ECDSA approval verified against the shared `EcdsaSignerManager` * registry. A counter provides replay protection and feeds `evolveNonce` for * unique coin nonces. Operation-domain prefixes (`multisig:mint:` / * `multisig:burn:`) stop a signature for one op being replayed as the other. @@ -26,12 +26,12 @@ pragma language_version >= 0.23.0; * the reusable Shielded Native Token standard (with a pluggable multisig access * layer), OpenZeppelin/compact-contracts#544. Prefer that standard once available. * - * @notice ECDSA verification is stubbed in `SignatureVerifier`. Replace it (and + * @notice ECDSA verification is stubbed in `EcdsaSignerManager`. Replace it (and * `persistentHash` with `keccak256`) once the Compact primitives are available. */ module SignatureMintBurn { import CompactStandardLibrary; - import "../SignatureVerifier" prefix Signature_; + import "../EcdsaSignerManager" prefix Signer_; import "../../utils/Utils" prefix Utils_; // ─── State ────────────────────────────────────────────────────── @@ -56,7 +56,7 @@ module SignatureMintBurn { signers: Vector>, thresh: Uint<8> ): [] { - Signature_initialize(salt, signers, thresh); + Signer_initialize(salt, signers, thresh); } /** @@ -106,7 +106,7 @@ module SignatureMintBurn { amount as Bytes<32> ]); - Signature_verify<2>(msgHash, pubkeys, signatures); + Signer_verify<2>(msgHash, pubkeys, signatures); _coinNonce = evolveNonce(_counter, _coinNonce); mintShieldedToken(_tokenDomain, disclose(amount), _coinNonce, disclose(canonRecipient)); @@ -142,7 +142,7 @@ module SignatureMintBurn { amount as Bytes<32> ]); - Signature_verify<2>(msgHash, pubkeys, signatures); + Signer_verify<2>(msgHash, pubkeys, signatures); assert(coin.color == tokenType(_tokenDomain, kernel.self()), "SignatureMintBurn: coin not from this contract"); assert(coin.value >= amount, "SignatureMintBurn: insufficient coin value"); @@ -157,7 +157,7 @@ module SignatureMintBurn { * callable off-chain by the deployer. */ export pure circuit _calculateSignerId(pk: Bytes<64>, salt: Bytes<32>): Bytes<32> { - return Signature__calculateSignerId(pk, salt); + return Signer__calculateSignerId(pk, salt); } export circuit getNonce(): Uint<64> { @@ -173,14 +173,14 @@ module SignatureMintBurn { } export circuit getSignerCount(): Uint<8> { - return Signature_getSignerCount(); + return Signer_getSignerCount(); } export circuit getThreshold(): Uint<8> { - return Signature_getThreshold(); + return Signer_getThreshold(); } export circuit isSigner(commitment: Bytes<32>): Boolean { - return Signature_isSigner(commitment); + return Signer_isSigner(commitment); } } diff --git a/contracts/src/multisig/examples/SignatureTreasury.compact b/contracts/src/multisig/examples/SignatureTreasury.compact index e117ece0..3b9a97de 100644 --- a/contracts/src/multisig/examples/SignatureTreasury.compact +++ b/contracts/src/multisig/examples/SignatureTreasury.compact @@ -9,7 +9,7 @@ pragma language_version >= 0.23.0; * spend from a stateless shielded treasury. Formerly the body of * `ShieldedMultiSigV2`. * - * Combines `SignatureVerifier` (commitment signer registry + threshold ECDSA + * Combines `EcdsaSignerManager` (commitment signer registry + threshold ECDSA * verification) with `NativeShieldedTreasuryStateless` (custody + send of native * shielded tokens). Approvals are collected off-chain; `execute` verifies them * and sends in a single transaction. A monotonic `_nonce` binds each spend to a @@ -17,12 +17,12 @@ pragma language_version >= 0.23.0; * * Import this module at the contract root and wrap it in a thin preset (see * `presets/NativeShieldedStatelessTreasury`), or compose it with other - * root modules that import the same `../SignatureVerifier` to share one + * root modules that import the same `../EcdsaSignerManager` to share one * signer registry (see `presets/NativeShieldedTokenVault`). */ module SignatureTreasury { import CompactStandardLibrary; - import "../SignatureVerifier" prefix Signature_; + import "../EcdsaSignerManager" prefix Signer_; import "../treasury/NativeShieldedTreasuryStateless" prefix Treasury_; import "../proposal/ProposalManager" prefix Proposal_; @@ -46,7 +46,7 @@ module SignatureTreasury { signers: Vector>, thresh: Uint<8> ): [] { - Signature_initialize(salt, signers, thresh); + Signer_initialize(salt, signers, thresh); } // ─── Deposit ──────────────────────────────────────────────────── @@ -94,7 +94,7 @@ module SignatureTreasury { amount as Bytes<32> ]); - Signature_verify<2>(msgHash, pubkeys, signatures); + Signer_verify<2>(msgHash, pubkeys, signatures); return Treasury__send(coin, Proposal_toShieldedRecipient(to), amount); } @@ -106,7 +106,7 @@ module SignatureTreasury { * callable off-chain by the deployer. */ export pure circuit _calculateSignerId(pk: Bytes<64>, salt: Bytes<32>): Bytes<32> { - return Signature__calculateSignerId(pk, salt); + return Signer__calculateSignerId(pk, salt); } export circuit getNonce(): Uint<64> { @@ -114,14 +114,14 @@ module SignatureTreasury { } export circuit getSignerCount(): Uint<8> { - return Signature_getSignerCount(); + return Signer_getSignerCount(); } export circuit getThreshold(): Uint<8> { - return Signature_getThreshold(); + return Signer_getThreshold(); } export circuit isSigner(commitment: Bytes<32>): Boolean { - return Signature_isSigner(commitment); + return Signer_isSigner(commitment); } } diff --git a/contracts/src/multisig/presets/NativeShieldedTokenVault.compact b/contracts/src/multisig/presets/NativeShieldedTokenVault.compact index 846bfc88..a7a33d95 100644 --- a/contracts/src/multisig/presets/NativeShieldedTokenVault.compact +++ b/contracts/src/multisig/presets/NativeShieldedTokenVault.compact @@ -11,7 +11,7 @@ pragma language_version >= 0.23.0; * composition the no-C2C protocol forces: a contract that both mints its own * token and manages a treasury of it, atomically, under one signer set. * - * Both modules import the same `../SignatureVerifier`, so the compiler + * Both modules import the same `../EcdsaSignerManager`, so the compiler * deduplicates that state into a single signer registry shared by `mint`, `burn`, * and `execute`. The constructor initializes that shared registry once (via the * treasury module) and seeds the token state separately. diff --git a/contracts/src/multisig/test/mocks/MockSignatureVerifier.compact b/contracts/src/multisig/test/mocks/MockSignatureVerifier.compact index 06939a21..e41b2ab5 100644 --- a/contracts/src/multisig/test/mocks/MockSignatureVerifier.compact +++ b/contracts/src/multisig/test/mocks/MockSignatureVerifier.compact @@ -9,7 +9,7 @@ pragma language_version >= 0.23.0; import CompactStandardLibrary; -import "../../SignatureVerifier" prefix Signature_; +import "../../EcdsaSignerManager" prefix Signature_; constructor(salt: Bytes<32>, signers: Vector<3, Bytes<32>>, thresh: Uint<8>) { Signature_initialize<3>(salt, signers, thresh); From 850519a39a5bce1f65b28b33980f7d75360bbcd8 Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Thu, 25 Jun 2026 12:10:30 +0200 Subject: [PATCH 06/13] fix(multisig): keep private forwarder commitment domain within 32 bytes The commitment domain separator padded the full descriptive string "PrivateNativeShieldedForwarder:commitment" (41 bytes) to 32, which exceeds the pad width and fails to compile. Use the module name alone (30 bytes) as the unique domain tag. This unblocks MockForwarderPrivate and the private-forwarder suite, which the compiler wrapper had been masking as a false success. --- .../forwarder/PrivateNativeShieldedForwarder.compact | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/contracts/src/multisig/forwarder/PrivateNativeShieldedForwarder.compact b/contracts/src/multisig/forwarder/PrivateNativeShieldedForwarder.compact index aec8e3bb..9bc2d846 100644 --- a/contracts/src/multisig/forwarder/PrivateNativeShieldedForwarder.compact +++ b/contracts/src/multisig/forwarder/PrivateNativeShieldedForwarder.compact @@ -188,14 +188,16 @@ module PrivateNativeShieldedForwarder { * @param {Bytes<32>} parentAddr - The parent address. * @param {Bytes<32>} opSecret - The operational secret. * - * @returns {Bytes<32>} `persistentHash([pad(32, "PrivateNativeShieldedForwarder:commitment"), parentAddr, opSecret])`. + * @returns {Bytes<32>} `persistentHash([pad(32, "PrivateNativeShieldedForwarder"), parentAddr, opSecret])`. */ export pure circuit _calculateParentCommitment( parentAddr: Bytes<32>, opSecret: Bytes<32> ): Bytes<32> { + // Domain separator. Must be <= 32 bytes; the module name (30 bytes) is used + // verbatim as a unique tag for this commitment. return persistentHash>>( - [pad(32, "PrivateNativeShieldedForwarder:commitment"), parentAddr, opSecret] + [pad(32, "PrivateNativeShieldedForwarder"), parentAddr, opSecret] ); } From 70a49c51eebe840f9a3c11834ea3a8e82e54a083 Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Thu, 25 Jun 2026 12:11:24 +0200 Subject: [PATCH 07/13] refactor(multisig): defer forwarder example wrappers to preset branch The deployable forwarder example contracts (examples/*Forwarder) are thin top-level wrappers in the same category as the presets, and their basenames collide with the forwarder modules in the shared flat artifact namespace. Remove them (and their now-stale preset tests and simulators) from this branch; they return with the presets in a follow-up branch. The forwarder modules and their Mock-based tests stay and remain the coverage for forwarder behavior. --- .../examples/NativeShieldedForwarder.compact | 62 --------- .../NativeUnshieldedForwarder.compact | 64 --------- .../PrivateNativeShieldedForwarder.compact | 125 ------------------ .../test/presets/ForwarderPrivate.test.ts | 74 ----------- .../test/presets/ForwarderShielded.test.ts | 41 ------ .../test/presets/ForwarderUnshielded.test.ts | 37 ------ .../presets/ForwarderPrivateSimulator.ts | 72 ---------- .../presets/ForwarderShieldedSimulator.ts | 55 -------- .../presets/ForwarderUnshieldedSimulator.ts | 54 -------- 9 files changed, 584 deletions(-) delete mode 100644 contracts/src/multisig/examples/NativeShieldedForwarder.compact delete mode 100644 contracts/src/multisig/examples/NativeUnshieldedForwarder.compact delete mode 100644 contracts/src/multisig/examples/PrivateNativeShieldedForwarder.compact delete mode 100644 contracts/src/multisig/test/presets/ForwarderPrivate.test.ts delete mode 100644 contracts/src/multisig/test/presets/ForwarderShielded.test.ts delete mode 100644 contracts/src/multisig/test/presets/ForwarderUnshielded.test.ts delete mode 100644 contracts/src/multisig/test/simulators/presets/ForwarderPrivateSimulator.ts delete mode 100644 contracts/src/multisig/test/simulators/presets/ForwarderShieldedSimulator.ts delete mode 100644 contracts/src/multisig/test/simulators/presets/ForwarderUnshieldedSimulator.ts diff --git a/contracts/src/multisig/examples/NativeShieldedForwarder.compact b/contracts/src/multisig/examples/NativeShieldedForwarder.compact deleted file mode 100644 index f0081e80..00000000 --- a/contracts/src/multisig/examples/NativeShieldedForwarder.compact +++ /dev/null @@ -1,62 +0,0 @@ -// SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.2.0 (multisig/examples/NativeShieldedForwarder.compact) - -pragma language_version >= 0.23.0; - -/** - * @title NativeShieldedForwarder (formerly ForwarderShielded) - * @description Public-parent forwarder for shielded coins. Receives a - * shielded coin and atomically forwards it to the configured parent - * recipient, a coin public key. - * - * The parent recipient is set at deploy time. This preset exposes no - * setter, so the parent is fixed for the life of the contract. Anyone - * may call `deposit`; the recipient is fixed, so there is no need for - * access control. - * - * The constructor accepts a `ZswapCoinPublicKey`: an atomic forward can - * only deliver to a recipient that needs no in-tx claim, and a shielded - * send to a non-participating contract is rejected. The parent is stored - * generically (`Either`) on the ledger so a future circuit upgrade can - * add contract-address support without a state migration. - */ - -import CompactStandardLibrary; -import "../forwarder/NativeShieldedForwarder" prefix Forwarder_; - -export { ZswapCoinPublicKey, ContractAddress, ShieldedCoinInfo, Either }; - -/** - * @description Deploys the forwarder bound to a specific parent - * recipient. - * - * @param {ZswapCoinPublicKey} parent - The coin public key that receives - * every forwarded coin. - */ -constructor(parent: ZswapCoinPublicKey) { - Forwarder_initialize(parent); -} - -/** - * @description Receives a shielded coin and atomically forwards it to - * the configured parent. The coin is claimed via `receiveShielded` and - * immediately re-sent via `sendImmediateShielded`. - * - * @param {ShieldedCoinInfo} coin - The incoming shielded coin. - * - * @returns {[]} Empty tuple. - */ -export circuit deposit(coin: ShieldedCoinInfo): [] { - Forwarder__deposit(coin); -} - -/** - * @description Returns the configured parent recipient as stored on the - * ledger (the generic `Either`; the `left` arm holds the coin public key). - * - * @returns {Either} The parent - * recipient set at deploy. - */ -export circuit getParent(): Either { - return Forwarder__parent; -} diff --git a/contracts/src/multisig/examples/NativeUnshieldedForwarder.compact b/contracts/src/multisig/examples/NativeUnshieldedForwarder.compact deleted file mode 100644 index dea6572f..00000000 --- a/contracts/src/multisig/examples/NativeUnshieldedForwarder.compact +++ /dev/null @@ -1,64 +0,0 @@ -// SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.2.0 (multisig/examples/NativeUnshieldedForwarder.compact) - -pragma language_version >= 0.23.0; - -/** - * @title NativeUnshieldedForwarder (formerly ForwarderUnshielded) - * @description Public-parent forwarder for unshielded coins. Receives - * an unshielded amount of a given color and atomically forwards it to - * the configured parent recipient, a user address. - * - * Unshielded transfers are publicly visible on the chain: depositor, - * recipient, color, and amount all appear on the public transcript. - * Use `NativeShieldedForwarder` instead when the deposit kind is shielded. - * - * The constructor accepts a `UserAddress`: an atomic forward can only - * deliver to a recipient that needs no in-tx claim, and an unshielded - * send to a non-participating contract is rejected. The parent is stored - * generically (`Either`) on the ledger so a future circuit upgrade can - * add contract-address support without a state migration. - * - * The parent recipient is set at deploy time. This preset exposes no - * setter, so the parent is fixed for the life of the contract. - */ - -import CompactStandardLibrary; -import "../forwarder/NativeUnshieldedForwarder" prefix Forwarder_; - -export { ContractAddress, UserAddress, Either }; - -/** - * @description Deploys the forwarder bound to a specific parent - * recipient. - * - * @param {UserAddress} parent - The user address that receives every - * forwarded amount. - */ -constructor(parent: UserAddress) { - Forwarder_initialize(parent); -} - -/** - * @description Receives an unshielded amount of `color` and atomically - * forwards it to the configured parent. - * - * @param {Bytes<32>} color - The token color. - * @param {Uint<128>} amount - The amount to deposit. - * - * @returns {[]} Empty tuple. - */ -export circuit deposit(color: Bytes<32>, amount: Uint<128>): [] { - Forwarder__deposit(color, amount); -} - -/** - * @description Returns the configured parent recipient as stored on the - * ledger (the generic `Either`; the `right` arm holds the user address). - * - * @returns {Either} The parent recipient - * set at deploy. - */ -export circuit getParent(): Either { - return Forwarder__parent; -} diff --git a/contracts/src/multisig/examples/PrivateNativeShieldedForwarder.compact b/contracts/src/multisig/examples/PrivateNativeShieldedForwarder.compact deleted file mode 100644 index adaecc90..00000000 --- a/contracts/src/multisig/examples/PrivateNativeShieldedForwarder.compact +++ /dev/null @@ -1,125 +0,0 @@ -// SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.2.0 (multisig/examples/PrivateNativeShieldedForwarder.compact) - -pragma language_version >= 0.23.0; - -/** - * @title PrivateNativeShieldedForwarder (formerly ForwarderPrivate) - * @description Private-parent forwarder. The parent address is hidden - * behind a `persistentHash` commitment on the ledger. Coins dwell at - * the contract address after deposit; the operator drains them later - * by presenting the `(parentAddr, opSecret)` preimage at drain time. - * - * Knowledge of the preimage is the sole authorization gate. The - * operational secret is held off-chain by the deployer; losing it is - * equivalent to loss of a hot-wallet key. Two forwarders bound to the - * same parent with different operational secrets produce different - * commitments and are unlinkable on-chain. - * - * @notice Each forwarder is bound to a single parent at deploy. To - * change the parent, deploy a new forwarder; the old one remains - * functional for outstanding coins until drained. - */ - -import CompactStandardLibrary; -import "../forwarder/PrivateNativeShieldedForwarder" prefix Forwarder_; - -export { ShieldedCoinInfo, QualifiedShieldedCoinInfo, ShieldedSendResult, ZswapCoinPublicKey }; - -/** - * @description Deploys the forwarder bound to a specific parent - * commitment. The deployer computes the commitment off-chain as - * `calculateParentCommitment(parentAddr, opSecret)` and passes it here. - * - * @param {Bytes<32>} parentCommitment - The commitment to the - * `(parentAddr, opSecret)` pair that the operator will present at drain. - */ -constructor(parentCommitment: Bytes<32>) { - Forwarder_initialize(parentCommitment); -} - -/** - * @description Receives a shielded coin into the forwarder's custody. - * No ledger write — the coin sits at the contract address until drained. - * - * @param {ShieldedCoinInfo} coin - The incoming shielded coin. - * - * @returns {[]} Empty tuple. - */ -export circuit deposit(coin: ShieldedCoinInfo): [] { - Forwarder__deposit(coin); -} - -/** - * @description Spends a previously-deposited shielded coin to the parent, - * a coin public key. The caller proves knowledge of `(parent, opSecret)` - * matching the stored commitment; the coin is sent as a shielded note, so the - * parent stays hidden on-chain. If the input coin's value exceeds `value`, - * change is re-emitted back to the contract for future drains. - * - * The parent is a `ZswapCoinPublicKey`, never a `ContractAddress`: a shielded - * send to a contract publishes that contract's address in cleartext, which - * would defeat the private-parent guarantee. - * - * Requirements: - * - * - `parent` must not be the zero key. - * - `calculateParentCommitment(parent.bytes, opSecret)` must equal the stored - * `_parentCommitment`. - * - `coin.value` must be >= `value` (enforced by `sendShielded`). - * - * @param {QualifiedShieldedCoinInfo} coin - The coin to spend. - * @param {ZswapCoinPublicKey} parent - The parent coin public key. Its 32 - * bytes are the preimage to the stored commitment. - * @param {Bytes<32>} opSecret - The operational secret. Never appears - * on the public transcript. - * - * @warning **Losing the operational secret is permanent fund loss.** It - * is the sole drain authorization. No rotation, revocation, or recovery - * path exists. If the operator loses it, every shielded coin accumulated - * at this contract becomes inaccessible. Back it up offline before the - * first deposit. - * - * @param {Uint<128>} value - The amount to send. - * - * @returns {ShieldedSendResult} The result containing the sent coin and - * any change. - */ -export circuit drain( - coin: QualifiedShieldedCoinInfo, - parent: ZswapCoinPublicKey, - opSecret: Bytes<32>, - value: Uint<128> -): ShieldedSendResult { - return Forwarder__drain(coin, parent, opSecret, value); -} - -/** - * @description Returns the stored parent commitment. - * - * @returns {Bytes<32>} The commitment set at deploy. - */ -export circuit getParentCommitment(): Bytes<32> { - return Forwarder__parentCommitment; -} - -/** - * @description Computes the parent commitment from a `(parentAddr, opSecret)` - * pair. Pure circuit — used off-chain by the deployer to compute the - * constructor argument, and inside `drain` for the preimage check. - * - * The commitment is domain-tagged - * (`pad(32, "PrivateNativeShieldedForwarder:commitment")`) to prevent preimage - * collisions with other `persistentHash` users in the system. - * - * @param {Bytes<32>} parentAddr - The parent address. - * @param {Bytes<32>} opSecret - The operational secret. - * - * @returns {Bytes<32>} The commitment `persistentHash([pad(32, "PrivateNativeShieldedForwarder:commitment"), parentAddr, opSecret])`. - */ -export pure circuit calculateParentCommitment( - parentAddr: Bytes<32>, - opSecret: Bytes<32> -): Bytes<32> { - return Forwarder__calculateParentCommitment(parentAddr, opSecret); -} diff --git a/contracts/src/multisig/test/presets/ForwarderPrivate.test.ts b/contracts/src/multisig/test/presets/ForwarderPrivate.test.ts deleted file mode 100644 index ba87b3fd..00000000 --- a/contracts/src/multisig/test/presets/ForwarderPrivate.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import * as utils from '#test-utils/address.js'; -import { ForwarderPrivateSimulator } from '../simulators/presets/ForwarderPrivateSimulator.js'; - -const PARENT_BYTES = utils.createEitherTestUser('PARENT').left.bytes; -const OP_SECRET = new Uint8Array(32).fill(0xaa); -const COLOR = new Uint8Array(32).fill(1); -const AMOUNT = 1000n; - -// The drain parent is a `ZswapCoinPublicKey` (`{ bytes }`); the commitment is -// over its raw 32 bytes (`calculateParentCommitment(parent.bytes, opSecret)`). -function key(bytes: Uint8Array) { - return { bytes }; -} - -function makeCoin(color: Uint8Array, value: bigint) { - return { nonce: new Uint8Array(32), color, value }; -} - -function makeQualifiedCoin(color: Uint8Array, value: bigint, mtIndex: bigint) { - return { nonce: new Uint8Array(32), color, value, mt_index: mtIndex }; -} - -function commitment(parent: Uint8Array, opSecret: Uint8Array): Uint8Array { - return ForwarderPrivateSimulator.calculateParentCommitment(parent, opSecret); -} - -describe('ForwarderPrivate preset', () => { - it('should store the parentCommitment passed to the constructor', async () => { - const c = commitment(PARENT_BYTES, OP_SECRET); - const fwd = await ForwarderPrivateSimulator.create(c); - expect(await fwd.getParentCommitment()).toEqual(c); - }); - - it('should expose deposit and forward to _deposit', async () => { - const fwd = await ForwarderPrivateSimulator.create( - commitment(PARENT_BYTES, OP_SECRET), - ); - await fwd.deposit(makeCoin(COLOR, AMOUNT)); - }); - - it('should expose drain and forward to _drain', async () => { - const fwd = await ForwarderPrivateSimulator.create( - commitment(PARENT_BYTES, OP_SECRET), - ); - await fwd.deposit(makeCoin(COLOR, AMOUNT)); - const result = await fwd.drain( - makeQualifiedCoin(COLOR, AMOUNT, 0n), - key(PARENT_BYTES), - OP_SECRET, - AMOUNT, - ); - expect(result.sent.value).toEqual(AMOUNT); - }); - - it('should expose calculateParentCommitment as a static pure helper', () => { - const c1 = commitment(PARENT_BYTES, OP_SECRET); - const c2 = commitment(PARENT_BYTES, OP_SECRET); - expect(c1).toEqual(c2); - }); - - it('should propagate the zero-commitment guard from the module', async () => { - await expect( - ForwarderPrivateSimulator.create(new Uint8Array(32)), - ).rejects.toThrow('ForwarderPrivate: zero commitment'); - }); - - it('should expose the public ledger state', async () => { - const fwd = await ForwarderPrivateSimulator.create( - commitment(PARENT_BYTES, OP_SECRET), - ); - expect(await fwd.getPublicState()).toBeDefined(); - }); -}); diff --git a/contracts/src/multisig/test/presets/ForwarderShielded.test.ts b/contracts/src/multisig/test/presets/ForwarderShielded.test.ts deleted file mode 100644 index 14445072..00000000 --- a/contracts/src/multisig/test/presets/ForwarderShielded.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import * as utils from '#test-utils/address.js'; -import { ForwarderShieldedSimulator } from '../simulators/presets/ForwarderShieldedSimulator.js'; - -// The constructor takes a `ZswapCoinPublicKey` (the supported arm). The -// `_parent` ledger field stays a generic `Either`; `initialize` stores the key -// in the `left` arm, which is what `getParent` reads back. A contract-address -// parent is not expressible today (see the module header). -const PARENT = utils.createEitherTestUser('PARENT').left; -const ZERO_KEY = utils.ZERO_KEY.left; -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 in the left arm', async () => { - const fwd = await ForwarderShieldedSimulator.create(PARENT); - const parent = await fwd.getParent(); - expect(parent.is_left).toBe(true); - expect(parent.left).toEqual(PARENT); - }); - - it('should expose deposit and forward to _deposit', async () => { - const fwd = await ForwarderShieldedSimulator.create(PARENT); - await fwd.deposit(makeCoin(COLOR, AMOUNT)); - }); - - it('should propagate the zero-parent guard from the module', async () => { - await expect(ForwarderShieldedSimulator.create(ZERO_KEY)).rejects.toThrow( - 'ForwarderShielded: zero parent', - ); - }); - - it('should expose the public ledger state', async () => { - const fwd = await ForwarderShieldedSimulator.create(PARENT); - expect(await fwd.getPublicState()).toBeDefined(); - }); -}); diff --git a/contracts/src/multisig/test/presets/ForwarderUnshielded.test.ts b/contracts/src/multisig/test/presets/ForwarderUnshielded.test.ts deleted file mode 100644 index 0f3ade64..00000000 --- a/contracts/src/multisig/test/presets/ForwarderUnshielded.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import * as utils from '#test-utils/address.js'; -import { ForwarderUnshieldedSimulator } from '../simulators/presets/ForwarderUnshieldedSimulator.js'; - -// The constructor takes a `UserAddress` (the supported arm). The `_parent` -// ledger field stays a generic `Either`; `initialize` stores the address in the -// `right` arm, which is what `getParent` reads back. A contract-address parent -// is not expressible today (see the module header). -const PARENT = utils.createEitherTestUserAddress('PARENT').right; -const ZERO_ADDR = utils.ZERO_USER_ADDRESS.right; -const COLOR = new Uint8Array(32).fill(1); -const AMOUNT = 1000n; - -describe('ForwarderUnshielded preset', () => { - it('should store the parent passed to the constructor in the right arm', async () => { - const fwd = await ForwarderUnshieldedSimulator.create(PARENT); - const parent = await fwd.getParent(); - expect(parent.is_left).toBe(false); - expect(parent.right).toEqual(PARENT); - }); - - it('should expose deposit and forward to _deposit', async () => { - const fwd = await ForwarderUnshieldedSimulator.create(PARENT); - await fwd.deposit(COLOR, AMOUNT); - }); - - it('should propagate the zero-parent guard from the module', async () => { - await expect( - ForwarderUnshieldedSimulator.create(ZERO_ADDR), - ).rejects.toThrow('ForwarderUnshielded: zero parent'); - }); - - it('should expose the public ledger state', async () => { - const fwd = await ForwarderUnshieldedSimulator.create(PARENT); - expect(await fwd.getPublicState()).toBeDefined(); - }); -}); diff --git a/contracts/src/multisig/test/simulators/presets/ForwarderPrivateSimulator.ts b/contracts/src/multisig/test/simulators/presets/ForwarderPrivateSimulator.ts deleted file mode 100644 index 8c06eb45..00000000 --- a/contracts/src/multisig/test/simulators/presets/ForwarderPrivateSimulator.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { - createSimulator, - type SimulatorOptions, -} from '@openzeppelin/compact-simulator'; -import { - Contract as ForwarderPrivate, - ledger, - pureCircuits, - type QualifiedShieldedCoinInfo, - type ShieldedCoinInfo, - type ShieldedSendResult, - type ZswapCoinPublicKey, -} from '../../../../../artifacts/ForwarderPrivate/contract/index.js'; -import { EmptyPrivateState, emptyWitnesses } from '../../EmptyWitnesses.js'; - -type ForwarderPrivateArgs = readonly [parentCommitment: Uint8Array]; - -const ForwarderPrivateSimulatorBase = createSimulator< - EmptyPrivateState, - ReturnType, - ReturnType, - ForwarderPrivate, - ForwarderPrivateArgs ->({ - contractFactory: (witnesses) => - new ForwarderPrivate(witnesses), - defaultPrivateState: () => EmptyPrivateState, - contractArgs: (parentCommitment) => [parentCommitment], - ledgerExtractor: (state) => ledger(state), - witnessesFactory: () => emptyWitnesses(), - artifactName: 'ForwarderPrivate', -}); - -export class ForwarderPrivateSimulator extends ForwarderPrivateSimulatorBase { - static async create( - parentCommitment: Uint8Array, - options: SimulatorOptions< - EmptyPrivateState, - ReturnType - > = {}, - ): Promise { - // biome-ignore lint/complexity/noThisInStatic: super.create must keep the subclass `this` - return super.create( - [parentCommitment], - options, - ) as Promise; - } - - public static calculateParentCommitment( - parentAddr: Uint8Array, - opSecret: Uint8Array, - ): Uint8Array { - return pureCircuits.calculateParentCommitment(parentAddr, opSecret); - } - - public deposit(coin: ShieldedCoinInfo): Promise<[]> { - return this.circuits.impure.deposit(coin); - } - - public drain( - coin: QualifiedShieldedCoinInfo, - parent: ZswapCoinPublicKey, - opSecret: Uint8Array, - value: bigint, - ): Promise { - return this.circuits.impure.drain(coin, parent, opSecret, value); - } - - public getParentCommitment(): Promise { - return this.circuits.impure.getParentCommitment(); - } -} diff --git a/contracts/src/multisig/test/simulators/presets/ForwarderShieldedSimulator.ts b/contracts/src/multisig/test/simulators/presets/ForwarderShieldedSimulator.ts deleted file mode 100644 index e34b9717..00000000 --- a/contracts/src/multisig/test/simulators/presets/ForwarderShieldedSimulator.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { - createSimulator, - type SimulatorOptions, -} from '@openzeppelin/compact-simulator'; -import { - type ContractAddress, - type Either, - Contract as ForwarderShielded, - ledger, - type ShieldedCoinInfo, - type ZswapCoinPublicKey, -} from '../../../../../artifacts/ForwarderShielded/contract/index.js'; -import { EmptyPrivateState, emptyWitnesses } from '../../EmptyWitnesses.js'; - -type ForwarderShieldedArgs = readonly [parent: ZswapCoinPublicKey]; - -const ForwarderShieldedSimulatorBase = createSimulator< - EmptyPrivateState, - ReturnType, - ReturnType, - ForwarderShielded, - ForwarderShieldedArgs ->({ - contractFactory: (witnesses) => - new ForwarderShielded(witnesses), - defaultPrivateState: () => EmptyPrivateState, - contractArgs: (parent) => [parent], - ledgerExtractor: (state) => ledger(state), - witnessesFactory: () => emptyWitnesses(), - artifactName: 'ForwarderShielded', -}); - -export class ForwarderShieldedSimulator extends ForwarderShieldedSimulatorBase { - static async create( - parent: ZswapCoinPublicKey, - options: SimulatorOptions< - EmptyPrivateState, - ReturnType - > = {}, - ): Promise { - // biome-ignore lint/complexity/noThisInStatic: super.create must keep the subclass `this` - return super.create( - [parent], - options, - ) as Promise; - } - - public deposit(coin: ShieldedCoinInfo): Promise<[]> { - return this.circuits.impure.deposit(coin); - } - - public getParent(): Promise> { - return this.circuits.impure.getParent(); - } -} diff --git a/contracts/src/multisig/test/simulators/presets/ForwarderUnshieldedSimulator.ts b/contracts/src/multisig/test/simulators/presets/ForwarderUnshieldedSimulator.ts deleted file mode 100644 index 0119fe24..00000000 --- a/contracts/src/multisig/test/simulators/presets/ForwarderUnshieldedSimulator.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { - createSimulator, - type SimulatorOptions, -} from '@openzeppelin/compact-simulator'; -import { - type ContractAddress, - type Either, - Contract as ForwarderUnshielded, - ledger, - type UserAddress, -} from '../../../../../artifacts/ForwarderUnshielded/contract/index.js'; -import { EmptyPrivateState, emptyWitnesses } from '../../EmptyWitnesses.js'; - -type ForwarderUnshieldedArgs = readonly [parent: UserAddress]; - -const ForwarderUnshieldedSimulatorBase = createSimulator< - EmptyPrivateState, - ReturnType, - ReturnType, - ForwarderUnshielded, - ForwarderUnshieldedArgs ->({ - contractFactory: (witnesses) => - new ForwarderUnshielded(witnesses), - defaultPrivateState: () => EmptyPrivateState, - contractArgs: (parent) => [parent], - ledgerExtractor: (state) => ledger(state), - witnessesFactory: () => emptyWitnesses(), - artifactName: 'ForwarderUnshielded', -}); - -export class ForwarderUnshieldedSimulator extends ForwarderUnshieldedSimulatorBase { - static async create( - parent: UserAddress, - options: SimulatorOptions< - EmptyPrivateState, - ReturnType - > = {}, - ): Promise { - // biome-ignore lint/complexity/noThisInStatic: super.create must keep the subclass `this` - return super.create( - [parent], - options, - ) as Promise; - } - - public deposit(color: Uint8Array, amount: bigint): Promise<[]> { - return this.circuits.impure.deposit(color, amount); - } - - public getParent(): Promise> { - return this.circuits.impure.getParent(); - } -} From 5692c0379103a26929651a6a68862a334dcd1e1c Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Thu, 25 Jun 2026 12:11:53 +0200 Subject: [PATCH 08/13] test(multisig): collapse per-contract witnesses into shared EmptyWitnesses Every multisig contract has empty private state and declares no witnesses, so each per-contract *Witnesses.ts was byte-identical to the shared EmptyWitnesses.ts (the forwarder simulators already used it). Delete them all and repoint the remaining simulators (SignerManager, ProposalManager, ShieldedTreasury) at EmptyWitnesses, matching where main is heading and shrinking the rebase surface. --- .../simulators/ProposalManagerSimulator.ts | 21 ++++++++----------- .../simulators/ShieldedTreasurySimulator.ts | 21 ++++++++----------- .../test/simulators/SignerManagerSimulator.ts | 21 ++++++++----------- .../witnesses/ProposalManagerWitnesses.ts | 6 ------ .../witnesses/ShieldedMultiSigV2Witnesses.ts | 7 ------- .../witnesses/ShieldedMultiSigV3Witnesses.ts | 7 ------- .../witnesses/ShieldedMultiSigWitnesses.ts | 6 ------ .../witnesses/ShieldedTreasuryWitnesses.ts | 6 ------ .../test/witnesses/SignerManagerWitnesses.ts | 6 ------ .../witnesses/UnshieldedTreasuryWitnesses.ts | 7 ------- 10 files changed, 27 insertions(+), 81 deletions(-) delete mode 100644 contracts/src/multisig/test/witnesses/ProposalManagerWitnesses.ts delete mode 100644 contracts/src/multisig/test/witnesses/ShieldedMultiSigV2Witnesses.ts delete mode 100644 contracts/src/multisig/test/witnesses/ShieldedMultiSigV3Witnesses.ts delete mode 100644 contracts/src/multisig/test/witnesses/ShieldedMultiSigWitnesses.ts delete mode 100644 contracts/src/multisig/test/witnesses/ShieldedTreasuryWitnesses.ts delete mode 100644 contracts/src/multisig/test/witnesses/SignerManagerWitnesses.ts delete mode 100644 contracts/src/multisig/test/witnesses/UnshieldedTreasuryWitnesses.ts diff --git a/contracts/src/multisig/test/simulators/ProposalManagerSimulator.ts b/contracts/src/multisig/test/simulators/ProposalManagerSimulator.ts index 98c97436..8f57d22f 100644 --- a/contracts/src/multisig/test/simulators/ProposalManagerSimulator.ts +++ b/contracts/src/multisig/test/simulators/ProposalManagerSimulator.ts @@ -7,10 +7,7 @@ import { Contract as MockProposalManager, pureCircuits, } from '../../../../artifacts/MockProposalManager/contract/index.js'; -import { - ProposalManagerPrivateState, - ProposalManagerWitnesses, -} from '../witnesses/ProposalManagerWitnesses.js'; +import { EmptyPrivateState, emptyWitnesses } from '../EmptyWitnesses.js'; type Recipient = { kind: number; address: Uint8Array }; type Proposal = { @@ -23,26 +20,26 @@ type Proposal = { type ProposalManagerArgs = readonly []; const ProposalManagerSimulatorBase = createSimulator< - ProposalManagerPrivateState, + EmptyPrivateState, ReturnType, - ReturnType, - MockProposalManager, + ReturnType, + MockProposalManager, ProposalManagerArgs >({ contractFactory: (witnesses) => - new MockProposalManager(witnesses), - defaultPrivateState: () => ProposalManagerPrivateState, + new MockProposalManager(witnesses), + defaultPrivateState: () => EmptyPrivateState, contractArgs: () => [], ledgerExtractor: (state) => ledger(state), - witnessesFactory: () => ProposalManagerWitnesses(), + witnessesFactory: () => emptyWitnesses(), artifactName: 'MockProposalManager', }); export class ProposalManagerSimulator extends ProposalManagerSimulatorBase { static async create( options: SimulatorOptions< - ProposalManagerPrivateState, - ReturnType + EmptyPrivateState, + ReturnType > = {}, ): Promise { // biome-ignore lint/complexity/noThisInStatic: super.create must keep the subclass `this` diff --git a/contracts/src/multisig/test/simulators/ShieldedTreasurySimulator.ts b/contracts/src/multisig/test/simulators/ShieldedTreasurySimulator.ts index 210972d2..001c94e9 100644 --- a/contracts/src/multisig/test/simulators/ShieldedTreasurySimulator.ts +++ b/contracts/src/multisig/test/simulators/ShieldedTreasurySimulator.ts @@ -6,10 +6,7 @@ import { ledger, Contract as MockShieldedTreasury, } from '../../../../artifacts/MockShieldedTreasury/contract/index.js'; -import { - ShieldedTreasuryPrivateState, - ShieldedTreasuryWitnesses, -} from '../witnesses/ShieldedTreasuryWitnesses.js'; +import { EmptyPrivateState, emptyWitnesses } from '../EmptyWitnesses.js'; type ShieldedCoinInfo = { nonce: Uint8Array; color: Uint8Array; value: bigint }; type ShieldedSendResult = { @@ -20,26 +17,26 @@ type ShieldedSendResult = { type ShieldedTreasuryArgs = readonly []; const ShieldedTreasurySimulatorBase = createSimulator< - ShieldedTreasuryPrivateState, + EmptyPrivateState, ReturnType, - ReturnType, - MockShieldedTreasury, + ReturnType, + MockShieldedTreasury, ShieldedTreasuryArgs >({ contractFactory: (witnesses) => - new MockShieldedTreasury(witnesses), - defaultPrivateState: () => ShieldedTreasuryPrivateState, + new MockShieldedTreasury(witnesses), + defaultPrivateState: () => EmptyPrivateState, contractArgs: () => [], ledgerExtractor: (state) => ledger(state), - witnessesFactory: () => ShieldedTreasuryWitnesses(), + witnessesFactory: () => emptyWitnesses(), artifactName: 'MockShieldedTreasury', }); export class ShieldedTreasurySimulator extends ShieldedTreasurySimulatorBase { static async create( options: SimulatorOptions< - ShieldedTreasuryPrivateState, - ReturnType + EmptyPrivateState, + ReturnType > = {}, ): Promise { // biome-ignore lint/complexity/noThisInStatic: super.create must keep the subclass `this` diff --git a/contracts/src/multisig/test/simulators/SignerManagerSimulator.ts b/contracts/src/multisig/test/simulators/SignerManagerSimulator.ts index 73e5bd66..4e87dc4b 100644 --- a/contracts/src/multisig/test/simulators/SignerManagerSimulator.ts +++ b/contracts/src/multisig/test/simulators/SignerManagerSimulator.ts @@ -6,10 +6,7 @@ import { ledger, Contract as MockSignerManager, } from '../../../../artifacts/MockSignerManager/contract/index.js'; -import { - SignerManagerPrivateState, - SignerManagerWitnesses, -} from '../witnesses/SignerManagerWitnesses.js'; +import { EmptyPrivateState, emptyWitnesses } from '../EmptyWitnesses.js'; /** * Type constructor args @@ -21,17 +18,17 @@ type SignerManagerArgs = readonly [ ]; const SignerManagerSimulatorBase = createSimulator< - SignerManagerPrivateState, + EmptyPrivateState, ReturnType, - ReturnType, - MockSignerManager, + ReturnType, + MockSignerManager, SignerManagerArgs >({ - contractFactory: (witnesses) => new MockSignerManager(witnesses), - defaultPrivateState: () => SignerManagerPrivateState, + contractFactory: (witnesses) => new MockSignerManager(witnesses), + defaultPrivateState: () => EmptyPrivateState, contractArgs: (signers, thresh, isInit) => [signers, thresh, isInit], ledgerExtractor: (state) => ledger(state), - witnessesFactory: () => SignerManagerWitnesses(), + witnessesFactory: () => emptyWitnesses(), artifactName: 'MockSignerManager', }); @@ -44,8 +41,8 @@ export class SignerManagerSimulator extends SignerManagerSimulatorBase { thresh: bigint, isInit: boolean, options: SimulatorOptions< - SignerManagerPrivateState, - ReturnType + EmptyPrivateState, + ReturnType > = {}, ): Promise { // biome-ignore lint/complexity/noThisInStatic: super.create must keep the subclass `this` diff --git a/contracts/src/multisig/test/witnesses/ProposalManagerWitnesses.ts b/contracts/src/multisig/test/witnesses/ProposalManagerWitnesses.ts deleted file mode 100644 index 0d9fd801..00000000 --- a/contracts/src/multisig/test/witnesses/ProposalManagerWitnesses.ts +++ /dev/null @@ -1,6 +0,0 @@ -// SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.2.0 (multisig/witnesses/ProposalManagerWitnesses.ts) - -export type ProposalManagerPrivateState = Record; -export const ProposalManagerPrivateState: ProposalManagerPrivateState = {}; -export const ProposalManagerWitnesses = () => ({}); diff --git a/contracts/src/multisig/test/witnesses/ShieldedMultiSigV2Witnesses.ts b/contracts/src/multisig/test/witnesses/ShieldedMultiSigV2Witnesses.ts deleted file mode 100644 index c580fd17..00000000 --- a/contracts/src/multisig/test/witnesses/ShieldedMultiSigV2Witnesses.ts +++ /dev/null @@ -1,7 +0,0 @@ -// SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.2.0 (multisig/witnesses/ShieldedMultiSigV2Witnesses.ts) - -export type ShieldedMultiSigV2PrivateState = Record; -export const ShieldedMultiSigV2PrivateState: ShieldedMultiSigV2PrivateState = - {}; -export const ShieldedMultiSigV2Witnesses = () => ({}); diff --git a/contracts/src/multisig/test/witnesses/ShieldedMultiSigV3Witnesses.ts b/contracts/src/multisig/test/witnesses/ShieldedMultiSigV3Witnesses.ts deleted file mode 100644 index f726fb96..00000000 --- a/contracts/src/multisig/test/witnesses/ShieldedMultiSigV3Witnesses.ts +++ /dev/null @@ -1,7 +0,0 @@ -// SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.0.1-alpha.1 (multisig/witnesses/ShieldedMultiSigV3Witnesses.ts) - -export type ShieldedMultiSigV3PrivateState = Record; -export const ShieldedMultiSigV3PrivateState: ShieldedMultiSigV3PrivateState = - {}; -export const ShieldedMultiSigV3Witnesses = () => ({}); diff --git a/contracts/src/multisig/test/witnesses/ShieldedMultiSigWitnesses.ts b/contracts/src/multisig/test/witnesses/ShieldedMultiSigWitnesses.ts deleted file mode 100644 index 9f36b434..00000000 --- a/contracts/src/multisig/test/witnesses/ShieldedMultiSigWitnesses.ts +++ /dev/null @@ -1,6 +0,0 @@ -// SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.2.0 (multisig/witnesses/ShieldedMultiSigWitnesses.ts) - -export type ShieldedMultiSigPrivateState = Record; -export const ShieldedMultiSigPrivateState: ShieldedMultiSigPrivateState = {}; -export const ShieldedMultiSigWitnesses = () => ({}); diff --git a/contracts/src/multisig/test/witnesses/ShieldedTreasuryWitnesses.ts b/contracts/src/multisig/test/witnesses/ShieldedTreasuryWitnesses.ts deleted file mode 100644 index 06b9ba49..00000000 --- a/contracts/src/multisig/test/witnesses/ShieldedTreasuryWitnesses.ts +++ /dev/null @@ -1,6 +0,0 @@ -// SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.2.0 (multisig/witnesses/ShieldedTreasuryWitnesses.ts) - -export type ShieldedTreasuryPrivateState = Record; -export const ShieldedTreasuryPrivateState: ShieldedTreasuryPrivateState = {}; -export const ShieldedTreasuryWitnesses = () => ({}); diff --git a/contracts/src/multisig/test/witnesses/SignerManagerWitnesses.ts b/contracts/src/multisig/test/witnesses/SignerManagerWitnesses.ts deleted file mode 100644 index 7bf6a25a..00000000 --- a/contracts/src/multisig/test/witnesses/SignerManagerWitnesses.ts +++ /dev/null @@ -1,6 +0,0 @@ -// SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.2.0 (multisig/witnesses/SignerManagerWitnesses.ts) - -export type SignerManagerPrivateState = Record; -export const SignerManagerPrivateState: SignerManagerPrivateState = {}; -export const SignerManagerWitnesses = () => ({}); diff --git a/contracts/src/multisig/test/witnesses/UnshieldedTreasuryWitnesses.ts b/contracts/src/multisig/test/witnesses/UnshieldedTreasuryWitnesses.ts deleted file mode 100644 index 8e06df02..00000000 --- a/contracts/src/multisig/test/witnesses/UnshieldedTreasuryWitnesses.ts +++ /dev/null @@ -1,7 +0,0 @@ -// SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.2.0 (multisig/witnesses/UnshieldedTreasuryWitnesses.ts) - -export type UnshieldedTreasuryPrivateState = Record; -export const UnshieldedTreasuryPrivateState: UnshieldedTreasuryPrivateState = - {}; -export const UnshieldedTreasuryWitnesses = () => ({}); From a0a94ff6fd04c2907a2af0c4c0e536d8dbfd1058 Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Thu, 25 Jun 2026 12:12:40 +0200 Subject: [PATCH 09/13] test(multisig): cover example modules and rename verifier to EcdsaSignerManager The example modules (SignatureTreasury, SignatureMintBurn, ProposalTreasury) are composable behaviors with no constructor, so each gets a Mock top-level wrapper plus a simulator and spec, matching how every other module in the package is tested. The specs carry over from the old ShieldedMultiSigV2/V3/(V1) suites (rename-preserved) and target the modules directly via EmptyWitnesses. Also rename the signature primitive's test stack to match the renamed module: MockSignatureVerifier -> MockEcdsaSignerManager, with a new EcdsaSignerManager spec/simulator and the corrected assert prefix. The example modules' duplicate-signer assertion now reads "EcdsaSignerManager: duplicate signer". --- .../multisig/test/EcdsaSignerManager.test.ts | 88 +++ .../multisig/test/ProposalTreasury.test.ts | 528 +++++++++++++++++ .../multisig/test/ShieldedMultiSig.test.ts | 540 ------------------ .../multisig/test/ShieldedMultiSigV2.test.ts | 192 ------- .../multisig/test/ShieldedMultiSigV3.test.ts | 454 --------------- .../multisig/test/SignatureMintBurn.test.ts | 429 ++++++++++++++ .../multisig/test/SignatureTreasury.test.ts | 226 ++++++++ ...compact => MockEcdsaSignerManager.compact} | 0 .../test/mocks/MockProposalTreasury.compact | 109 ++++ .../test/mocks/MockSignatureMintBurn.compact | 76 +++ .../test/mocks/MockSignatureTreasury.compact | 61 ++ .../simulators/EcdsaSignerManagerSimulator.ts | 83 +++ ...ulator.ts => ProposalTreasurySimulator.ts} | 88 ++- .../simulators/ShieldedMultiSigV2Simulator.ts | 112 ---- ...lator.ts => SignatureMintBurnSimulator.ts} | 74 ++- .../simulators/SignatureTreasurySimulator.ts | 99 ++++ 16 files changed, 1771 insertions(+), 1388 deletions(-) create mode 100644 contracts/src/multisig/test/EcdsaSignerManager.test.ts create mode 100644 contracts/src/multisig/test/ProposalTreasury.test.ts delete mode 100644 contracts/src/multisig/test/ShieldedMultiSig.test.ts delete mode 100644 contracts/src/multisig/test/ShieldedMultiSigV2.test.ts delete mode 100644 contracts/src/multisig/test/ShieldedMultiSigV3.test.ts create mode 100644 contracts/src/multisig/test/SignatureMintBurn.test.ts create mode 100644 contracts/src/multisig/test/SignatureTreasury.test.ts rename contracts/src/multisig/test/mocks/{MockSignatureVerifier.compact => MockEcdsaSignerManager.compact} (100%) create mode 100644 contracts/src/multisig/test/mocks/MockProposalTreasury.compact create mode 100644 contracts/src/multisig/test/mocks/MockSignatureMintBurn.compact create mode 100644 contracts/src/multisig/test/mocks/MockSignatureTreasury.compact create mode 100644 contracts/src/multisig/test/simulators/EcdsaSignerManagerSimulator.ts rename contracts/src/multisig/test/simulators/{ShieldedMultiSigSimulator.ts => ProposalTreasurySimulator.ts} (51%) delete mode 100644 contracts/src/multisig/test/simulators/ShieldedMultiSigV2Simulator.ts rename contracts/src/multisig/test/simulators/{ShieldedMultiSigV3Simulator.ts => SignatureMintBurnSimulator.ts} (52%) create mode 100644 contracts/src/multisig/test/simulators/SignatureTreasurySimulator.ts diff --git a/contracts/src/multisig/test/EcdsaSignerManager.test.ts b/contracts/src/multisig/test/EcdsaSignerManager.test.ts new file mode 100644 index 00000000..79a38cdf --- /dev/null +++ b/contracts/src/multisig/test/EcdsaSignerManager.test.ts @@ -0,0 +1,88 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { EcdsaSignerManagerSimulator } from './simulators/EcdsaSignerManagerSimulator.js'; + +const THRESHOLD = 2n; + +// Instance salt and ECDSA public keys (Bytes<64>) used to derive commitments. +const SALT = new Uint8Array(32).fill(7); +const PK1 = new Uint8Array(64).fill(1); +const PK2 = new Uint8Array(64).fill(2); +const PK3 = new Uint8Array(64).fill(3); +const PK_UNKNOWN = new Uint8Array(64).fill(9); + +// A dummy signature and message hash. The ECDSA check is stubbed, so the +// signature bytes are irrelevant to verification today. +const SIG = new Uint8Array(64).fill(0); +const MSG = new Uint8Array(32).fill(5); + +const COMMITMENT1 = EcdsaSignerManagerSimulator.calculateSignerId(PK1, SALT); +const COMMITMENT2 = EcdsaSignerManagerSimulator.calculateSignerId(PK2, SALT); +const COMMITMENT3 = EcdsaSignerManagerSimulator.calculateSignerId(PK3, SALT); +const SIGNERS = [COMMITMENT1, COMMITMENT2, COMMITMENT3]; + +let verifier: EcdsaSignerManagerSimulator; + +describe('EcdsaSignerManager', () => { + beforeEach(async () => { + verifier = await EcdsaSignerManagerSimulator.create( + SALT, + SIGNERS, + THRESHOLD, + ); + }); + + describe('initialization', () => { + it('should register all signer commitments', async () => { + expect(await verifier.getSignerCount()).toEqual(3n); + expect(await verifier.getThreshold()).toEqual(2n); + expect(await verifier.isSigner(COMMITMENT1)).toEqual(true); + expect(await verifier.isSigner(COMMITMENT2)).toEqual(true); + expect(await verifier.isSigner(COMMITMENT3)).toEqual(true); + }); + + it('should not recognize an unregistered commitment', async () => { + const unknown = EcdsaSignerManagerSimulator.calculateSignerId( + PK_UNKNOWN, + SALT, + ); + expect(await verifier.isSigner(unknown)).toEqual(false); + }); + }); + + describe('_calculateSignerId', () => { + it('should be deterministic for the same key and salt', () => { + expect( + EcdsaSignerManagerSimulator.calculateSignerId(PK1, SALT), + ).toStrictEqual(COMMITMENT1); + }); + + it('should produce a different commitment for a different salt', () => { + const otherSalt = new Uint8Array(32).fill(8); + expect( + EcdsaSignerManagerSimulator.calculateSignerId(PK1, otherSalt), + ).not.toStrictEqual(COMMITMENT1); + }); + + it('should produce a different commitment for a different key', () => { + expect(COMMITMENT1).not.toStrictEqual(COMMITMENT2); + }); + }); + + describe('verify', () => { + it('should pass with two distinct registered signers', async () => { + await verifier.verify(MSG, [PK1, PK2], [SIG, SIG]); + }); + + it('should reject a duplicate signer', async () => { + await expect( + verifier.verify(MSG, [PK1, PK1], [SIG, SIG]), + ).rejects.toThrow('EcdsaSignerManager: duplicate signer'); + }); + + it('should reject an unregistered signer', async () => { + await expect( + verifier.verify(MSG, [PK_UNKNOWN, PK2], [SIG, SIG]), + ).rejects.toThrow('SignerManager: not a signer'); + }); + }); +}); diff --git a/contracts/src/multisig/test/ProposalTreasury.test.ts b/contracts/src/multisig/test/ProposalTreasury.test.ts new file mode 100644 index 00000000..7782f729 --- /dev/null +++ b/contracts/src/multisig/test/ProposalTreasury.test.ts @@ -0,0 +1,528 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import * as utils from '#test-utils/address.js'; +import { ProposalTreasurySimulator } from './simulators/ProposalTreasurySimulator.js'; + +const ProposalStatus = { Inactive: 0, Active: 1, Executed: 2, Cancelled: 3 }; +const RecipientKind = { ShieldedUser: 0, UnshieldedUser: 1, Contract: 2 }; + +const THRESHOLD = 2n; +const COLOR = new Uint8Array(32).fill(1); +const AMOUNT = 1000n; +const PROPOSAL_AMOUNT = 400n; + +const [SIGNER1, Z_SIGNER1] = utils.generateEitherPubKeyPair('SIGNER1'); +const [SIGNER2, Z_SIGNER2] = utils.generateEitherPubKeyPair('SIGNER2'); +const [SIGNER3, Z_SIGNER3] = utils.generateEitherPubKeyPair('SIGNER3'); +const SIGNERS = [Z_SIGNER1, Z_SIGNER2, Z_SIGNER3]; + +const [_NON_SIGNER, Z_NON_SIGNER] = utils.generateEitherPubKeyPair('OTHER'); +const [, Z_RECIPIENT_PK] = utils.generatePubKeyPair('RECIPIENT'); + +function makeRecipient(pk: { bytes: Uint8Array }): { + kind: number; + address: Uint8Array; +} { + return { kind: RecipientKind.ShieldedUser, address: pk.bytes }; +} + +function makeCoin( + color: Uint8Array, + value: bigint, + nonce?: Uint8Array, +): { nonce: Uint8Array; color: Uint8Array; value: bigint } { + return { + nonce: nonce ?? new Uint8Array(32).fill(0), + color, + value, + }; +} + +let multisig: ProposalTreasurySimulator; + +describe('ProposalTreasury', () => { + describe('constructor', () => { + it('should initialize with signers and threshold', () => { + multisig = new ProposalTreasurySimulator(SIGNERS, THRESHOLD); + expect(multisig.getSignerCount()).toEqual(BigInt(SIGNERS.length)); + expect(multisig.getThreshold()).toEqual(THRESHOLD); + }); + + it('should register all signers', () => { + multisig = new ProposalTreasurySimulator(SIGNERS, THRESHOLD); + for (const signer of SIGNERS) { + expect(multisig.isSigner(signer)).toEqual(true); + } + }); + + it('should reject non-signers', () => { + multisig = new ProposalTreasurySimulator(SIGNERS, THRESHOLD); + expect(multisig.isSigner(Z_NON_SIGNER)).toEqual(false); + }); + + it('should fail with zero threshold', () => { + expect(() => { + new ProposalTreasurySimulator(SIGNERS, 0n); + }).toThrow('SignerManager: threshold must not be zero'); + }); + + it('should fail with threshold exceeding signer count', () => { + expect(() => { + new ProposalTreasurySimulator(SIGNERS, 4n); + }).toThrow('SignerManager: threshold exceeds signer count'); + }); + }); + + describe('when initialized', () => { + beforeEach(() => { + multisig = new ProposalTreasurySimulator(SIGNERS, THRESHOLD); + }); + + describe('deposit', () => { + it('should accept deposits', () => { + multisig.deposit(makeCoin(COLOR, AMOUNT)); + expect(multisig.getTokenBalance(COLOR)).toEqual(AMOUNT); + }); + + it('should accumulate deposits', () => { + multisig.deposit(makeCoin(COLOR, AMOUNT, new Uint8Array(32).fill(1))); + multisig.deposit(makeCoin(COLOR, AMOUNT, new Uint8Array(32).fill(2))); + expect(multisig.getTokenBalance(COLOR)).toEqual(AMOUNT * 2n); + }); + + it('should track received total', () => { + multisig.deposit(makeCoin(COLOR, AMOUNT)); + expect(multisig.getReceivedTotal(COLOR)).toEqual(AMOUNT); + }); + }); + + describe('createShieldedProposal', () => { + it('should allow signer to create proposal', () => { + const to = makeRecipient(Z_RECIPIENT_PK); + const id = multisig + .as(SIGNER1) + .createShieldedProposal(to, COLOR, PROPOSAL_AMOUNT); + expect(id).toEqual(1n); + }); + + it('should store proposal data correctly', () => { + const to = makeRecipient(Z_RECIPIENT_PK); + const id = multisig + .as(SIGNER1) + .createShieldedProposal(to, COLOR, PROPOSAL_AMOUNT); + + const proposal = multisig.getProposal(id); + expect(proposal.status).toEqual(ProposalStatus.Active); + expect(proposal.amount).toEqual(PROPOSAL_AMOUNT); + expect(proposal.color).toEqual(COLOR); + }); + + it('should fail for non-signer', () => { + const to = makeRecipient(Z_RECIPIENT_PK); + expect(() => { + multisig + .as(_NON_SIGNER) + .createShieldedProposal(to, COLOR, PROPOSAL_AMOUNT); + }).toThrow('SignerManager: not a signer'); + }); + + it('should fail with zero amount', () => { + const to = makeRecipient(Z_RECIPIENT_PK); + expect(() => { + multisig.as(SIGNER1).createShieldedProposal(to, COLOR, 0n); + }).toThrow('ProposalManager: zero amount'); + }); + + it('should reject UnshieldedUser recipient kind', () => { + const to = { + kind: RecipientKind.UnshieldedUser, + address: Z_RECIPIENT_PK.bytes, + }; + expect(() => { + multisig + .as(SIGNER1) + .createShieldedProposal(to, COLOR, PROPOSAL_AMOUNT); + }).toThrow( + 'ProposalTreasury: recipient must be a shielded user or contract', + ); + }); + + it('should accept Contract recipient kind', () => { + const to = { + kind: RecipientKind.Contract, + address: new Uint8Array(32).fill(7), + }; + const id = multisig + .as(SIGNER1) + .createShieldedProposal(to, COLOR, PROPOSAL_AMOUNT); + expect(id).toEqual(1n); + expect(multisig.getProposalRecipient(id).kind).toEqual( + RecipientKind.Contract, + ); + }); + }); + + describe('approveProposal', () => { + let proposalId: bigint; + + beforeEach(() => { + const to = makeRecipient(Z_RECIPIENT_PK); + proposalId = multisig + .as(SIGNER1) + .createShieldedProposal(to, COLOR, PROPOSAL_AMOUNT); + }); + + it('should allow signer to approve', () => { + multisig.as(SIGNER1).approveProposal(proposalId); + expect( + multisig.isProposalApprovedBySigner(proposalId, Z_SIGNER1), + ).toEqual(true); + expect(multisig.getApprovalCount(proposalId)).toEqual(1n); + }); + + it('should allow multiple signers to approve', () => { + multisig.as(SIGNER1).approveProposal(proposalId); + multisig.as(SIGNER2).approveProposal(proposalId); + expect(multisig.getApprovalCount(proposalId)).toEqual(2n); + }); + + it('should fail for non-signer', () => { + expect(() => { + multisig.as(_NON_SIGNER).approveProposal(proposalId); + }).toThrow('SignerManager: not a signer'); + }); + + it('should fail for double approval', () => { + multisig.as(SIGNER1).approveProposal(proposalId); + expect(() => { + multisig.as(SIGNER1).approveProposal(proposalId); + }).toThrow('ProposalTreasury: already approved'); + }); + + it('should fail for non-existing proposal', () => { + expect(() => { + multisig.as(SIGNER1).approveProposal(999n); + }).toThrow('ProposalManager: proposal not found'); + }); + + it('should fail for executed proposal', () => { + multisig.deposit(makeCoin(COLOR, AMOUNT)); + multisig.as(SIGNER1).approveProposal(proposalId); + multisig.as(SIGNER2).approveProposal(proposalId); + multisig.executeShieldedProposal(proposalId); + + expect(() => { + multisig.as(SIGNER3).approveProposal(proposalId); + }).toThrow('ProposalManager: proposal not active'); + }); + }); + + describe('revokeApproval', () => { + let proposalId: bigint; + + beforeEach(() => { + const to = makeRecipient(Z_RECIPIENT_PK); + proposalId = multisig + .as(SIGNER1) + .createShieldedProposal(to, COLOR, PROPOSAL_AMOUNT); + multisig.as(SIGNER1).approveProposal(proposalId); + }); + + it('should allow signer to revoke their approval', () => { + multisig.as(SIGNER1).revokeApproval(proposalId); + expect( + multisig.isProposalApprovedBySigner(proposalId, Z_SIGNER1), + ).toEqual(false); + expect(multisig.getApprovalCount(proposalId)).toEqual(0n); + }); + + it('should fail for non-signer', () => { + expect(() => { + multisig.as(_NON_SIGNER).revokeApproval(proposalId); + }).toThrow('SignerManager: not a signer'); + }); + + it('should fail if not yet approved', () => { + expect(() => { + multisig.as(SIGNER2).revokeApproval(proposalId); + }).toThrow('ProposalTreasury: not approved'); + }); + + it('should allow re-approval after revoke', () => { + multisig.as(SIGNER1).revokeApproval(proposalId); + multisig.as(SIGNER1).approveProposal(proposalId); + expect( + multisig.isProposalApprovedBySigner(proposalId, Z_SIGNER1), + ).toEqual(true); + expect(multisig.getApprovalCount(proposalId)).toEqual(1n); + }); + + it('should fail for executed proposal', () => { + multisig.deposit(makeCoin(COLOR, AMOUNT)); + multisig.as(SIGNER2).approveProposal(proposalId); + multisig.executeShieldedProposal(proposalId); + + expect(() => { + multisig.as(SIGNER1).revokeApproval(proposalId); + }).toThrow('ProposalManager: proposal not active'); + }); + }); + + describe('executeShieldedProposal', () => { + let proposalId: bigint; + + beforeEach(() => { + // Fund the treasury + multisig.deposit(makeCoin(COLOR, AMOUNT)); + + // Create and approve proposal to threshold + const to = makeRecipient(Z_RECIPIENT_PK); + proposalId = multisig + .as(SIGNER1) + .createShieldedProposal(to, COLOR, PROPOSAL_AMOUNT); + multisig.as(SIGNER1).approveProposal(proposalId); + multisig.as(SIGNER2).approveProposal(proposalId); + }); + + it('should execute when threshold is met', () => { + multisig.executeShieldedProposal(proposalId); + expect(multisig.getProposalStatus(proposalId)).toEqual( + ProposalStatus.Executed, + ); + }); + + it('should return sent coin and change in result', () => { + const result = multisig.executeShieldedProposal(proposalId); + expect(result.sent.value).toEqual(PROPOSAL_AMOUNT); + expect(result.sent.color).toEqual(COLOR); + expect(result.change.is_some).toEqual(true); + expect(result.change.value.value).toEqual(AMOUNT - PROPOSAL_AMOUNT); + expect(result.change.value.color).toEqual(COLOR); + }); + + it('should return no change when sending full balance', () => { + // Create proposal for the full amount + const to = makeRecipient(Z_RECIPIENT_PK); + const fullId = multisig + .as(SIGNER1) + .createShieldedProposal(to, COLOR, AMOUNT); + multisig.as(SIGNER1).approveProposal(fullId); + multisig.as(SIGNER2).approveProposal(fullId); + + const result = multisig.executeShieldedProposal(fullId); + expect(result.sent.value).toEqual(AMOUNT); + expect(result.change.is_some).toEqual(false); + }); + + it('should deduct from treasury balance', () => { + multisig.executeShieldedProposal(proposalId); + expect(multisig.getTokenBalance(COLOR)).toEqual( + AMOUNT - PROPOSAL_AMOUNT, + ); + }); + + it('should track sent total', () => { + multisig.executeShieldedProposal(proposalId); + expect(multisig.getSentTotal(COLOR)).toEqual(PROPOSAL_AMOUNT); + }); + + it('should fail when threshold is not met', () => { + // Create a new proposal with only 1 approval + const to = makeRecipient(Z_RECIPIENT_PK); + const id2 = multisig + .as(SIGNER1) + .createShieldedProposal(to, COLOR, 100n); + multisig.as(SIGNER1).approveProposal(id2); + + expect(() => { + multisig.executeShieldedProposal(id2); + }).toThrow('SignerManager: threshold not met'); + }); + + it('should fail for non-existing proposal', () => { + expect(() => { + multisig.executeShieldedProposal(999n); + }).toThrow('ProposalManager: proposal not found'); + }); + + it('should fail when executed twice', () => { + multisig.executeShieldedProposal(proposalId); + expect(() => { + multisig.executeShieldedProposal(proposalId); + }).toThrow('ProposalManager: proposal not active'); + }); + + it('should fail with insufficient treasury balance', () => { + // Create proposal for more than treasury holds + const to = makeRecipient(Z_RECIPIENT_PK); + const bigId = multisig + .as(SIGNER1) + .createShieldedProposal(to, COLOR, AMOUNT + 1n); + multisig.as(SIGNER1).approveProposal(bigId); + multisig.as(SIGNER2).approveProposal(bigId); + + expect(() => { + multisig.executeShieldedProposal(bigId); + }).toThrow('ShieldedTreasury: coin value insufficient'); + }); + }); + + describe('view - approvals', () => { + it('should return false for unapproved signer', () => { + const to = makeRecipient(Z_RECIPIENT_PK); + const id = multisig + .as(SIGNER1) + .createShieldedProposal(to, COLOR, PROPOSAL_AMOUNT); + expect(multisig.isProposalApprovedBySigner(id, Z_SIGNER1)).toEqual( + false, + ); + }); + + it('should return 0 approval count for new proposal', () => { + const to = makeRecipient(Z_RECIPIENT_PK); + const id = multisig + .as(SIGNER1) + .createShieldedProposal(to, COLOR, PROPOSAL_AMOUNT); + expect(multisig.getApprovalCount(id)).toEqual(0n); + }); + }); + + describe('view - proposal delegation', () => { + let proposalId: bigint; + + beforeEach(() => { + const to = makeRecipient(Z_RECIPIENT_PK); + proposalId = multisig + .as(SIGNER1) + .createShieldedProposal(to, COLOR, PROPOSAL_AMOUNT); + }); + + it('getProposalRecipient should return recipient', () => { + const recipient = multisig.getProposalRecipient(proposalId); + expect(recipient.kind).toEqual(RecipientKind.ShieldedUser); + expect(recipient.address).toEqual(Z_RECIPIENT_PK.bytes); + }); + + it('getProposalAmount should return amount', () => { + expect(multisig.getProposalAmount(proposalId)).toEqual(PROPOSAL_AMOUNT); + }); + + it('getProposalColor should return color', () => { + expect(multisig.getProposalColor(proposalId)).toEqual(COLOR); + }); + }); + + describe('view - signer manager delegation', () => { + it('getSignerCount should match initial count', () => { + expect(multisig.getSignerCount()).toEqual(BigInt(SIGNERS.length)); + }); + + it('getThreshold should match initial threshold', () => { + expect(multisig.getThreshold()).toEqual(THRESHOLD); + }); + + it('isSigner should return true for signer', () => { + expect(multisig.isSigner(Z_SIGNER1)).toEqual(true); + }); + + it('isSigner should return false for non-signer', () => { + expect(multisig.isSigner(Z_NON_SIGNER)).toEqual(false); + }); + }); + + describe('view - treasury delegation', () => { + beforeEach(() => { + multisig.deposit(makeCoin(COLOR, AMOUNT)); + }); + + it('getTokenBalance should reflect deposits', () => { + expect(multisig.getTokenBalance(COLOR)).toEqual(AMOUNT); + }); + + it('getReceivedTotal should reflect deposits', () => { + expect(multisig.getReceivedTotal(COLOR)).toEqual(AMOUNT); + }); + + it('getSentTotal should be 0 before any sends', () => { + expect(multisig.getSentTotal(COLOR)).toEqual(0n); + }); + + it('getReceivedMinusSent should equal balance', () => { + expect(multisig.getReceivedMinusSent(COLOR)).toEqual(AMOUNT); + }); + }); + + describe('full lifecycle', () => { + it('should handle deposit -> propose -> approve -> execute', () => { + // Deposit + multisig.deposit(makeCoin(COLOR, AMOUNT)); + expect(multisig.getTokenBalance(COLOR)).toEqual(AMOUNT); + + // Propose + const to = makeRecipient(Z_RECIPIENT_PK); + const id = multisig + .as(SIGNER1) + .createShieldedProposal(to, COLOR, PROPOSAL_AMOUNT); + + // Approve to threshold + multisig.as(SIGNER1).approveProposal(id); + multisig.as(SIGNER2).approveProposal(id); + expect(multisig.getApprovalCount(id)).toEqual(THRESHOLD); + + // Execute + multisig.executeShieldedProposal(id); + expect(multisig.getProposalStatus(id)).toEqual(ProposalStatus.Executed); + expect(multisig.getTokenBalance(COLOR)).toEqual( + AMOUNT - PROPOSAL_AMOUNT, + ); + expect(multisig.getReceivedMinusSent(COLOR)).toEqual( + AMOUNT - PROPOSAL_AMOUNT, + ); + }); + + it('should handle multiple proposals concurrently', () => { + multisig.deposit(makeCoin(COLOR, AMOUNT)); + + const to = makeRecipient(Z_RECIPIENT_PK); + const id1 = multisig + .as(SIGNER1) + .createShieldedProposal(to, COLOR, 200n); + const id2 = multisig + .as(SIGNER2) + .createShieldedProposal(to, COLOR, 300n); + + // Approve and execute first + multisig.as(SIGNER1).approveProposal(id1); + multisig.as(SIGNER2).approveProposal(id1); + multisig.executeShieldedProposal(id1); + + // Approve and execute second + multisig.as(SIGNER1).approveProposal(id2); + multisig.as(SIGNER3).approveProposal(id2); + multisig.executeShieldedProposal(id2); + + expect(multisig.getTokenBalance(COLOR)).toEqual(AMOUNT - 200n - 300n); + }); + + it('should handle approve -> revoke -> re-approve -> execute', () => { + multisig.deposit(makeCoin(COLOR, AMOUNT)); + const to = makeRecipient(Z_RECIPIENT_PK); + const id = multisig + .as(SIGNER1) + .createShieldedProposal(to, COLOR, PROPOSAL_AMOUNT); + + // Approve then revoke + multisig.as(SIGNER1).approveProposal(id); + multisig.as(SIGNER1).revokeApproval(id); + expect(multisig.getApprovalCount(id)).toEqual(0n); + + // Re-approve with enough signers + multisig.as(SIGNER2).approveProposal(id); + multisig.as(SIGNER3).approveProposal(id); + expect(multisig.getApprovalCount(id)).toEqual(2n); + + multisig.executeShieldedProposal(id); + expect(multisig.getProposalStatus(id)).toEqual(ProposalStatus.Executed); + }); + }); + }); +}); diff --git a/contracts/src/multisig/test/ShieldedMultiSig.test.ts b/contracts/src/multisig/test/ShieldedMultiSig.test.ts deleted file mode 100644 index 56a4243b..00000000 --- a/contracts/src/multisig/test/ShieldedMultiSig.test.ts +++ /dev/null @@ -1,540 +0,0 @@ -import { beforeEach, describe, expect, it } from 'vitest'; -import * as utils from '#test-utils/address.js'; -import { ShieldedMultiSigSimulator } from './simulators/ShieldedMultiSigSimulator.js'; - -const ProposalStatus = { Inactive: 0, Active: 1, Executed: 2, Cancelled: 3 }; -const RecipientKind = { ShieldedUser: 0, UnshieldedUser: 1, Contract: 2 }; - -const THRESHOLD = 2n; -const COLOR = new Uint8Array(32).fill(1); -const AMOUNT = 1000n; -const PROPOSAL_AMOUNT = 400n; - -const [, Z_SIGNER1] = utils.generateEitherPubKeyPair('SIGNER1'); -const [, Z_SIGNER2] = utils.generateEitherPubKeyPair('SIGNER2'); -const [, Z_SIGNER3] = utils.generateEitherPubKeyPair('SIGNER3'); -const SIGNERS = [Z_SIGNER1, Z_SIGNER2, Z_SIGNER3]; - -const [, Z_NON_SIGNER] = utils.generateEitherPubKeyPair('OTHER'); -const [, Z_RECIPIENT_PK] = utils.generatePubKeyPair('RECIPIENT'); - -function makeRecipient(pk: { bytes: Uint8Array }): { - kind: number; - address: Uint8Array; -} { - return { kind: RecipientKind.ShieldedUser, address: pk.bytes }; -} - -function makeCoin( - color: Uint8Array, - value: bigint, - nonce?: Uint8Array, -): { nonce: Uint8Array; color: Uint8Array; value: bigint } { - return { - nonce: nonce ?? new Uint8Array(32).fill(0), - color, - value, - }; -} - -let multisig: ShieldedMultiSigSimulator; - -describe('ShieldedMultiSig', () => { - describe('constructor', () => { - it('should initialize with signers and threshold', async () => { - multisig = await ShieldedMultiSigSimulator.create(SIGNERS, THRESHOLD); - expect(await multisig.getSignerCount()).toEqual(BigInt(SIGNERS.length)); - expect(await multisig.getThreshold()).toEqual(THRESHOLD); - }); - - it('should register all signers', async () => { - multisig = await ShieldedMultiSigSimulator.create(SIGNERS, THRESHOLD); - for (const signer of SIGNERS) { - expect(await multisig.isSigner(signer)).toEqual(true); - } - }); - - it('should reject non-signers', async () => { - multisig = await ShieldedMultiSigSimulator.create(SIGNERS, THRESHOLD); - expect(await multisig.isSigner(Z_NON_SIGNER)).toEqual(false); - }); - - it('should fail with zero threshold', async () => { - await expect( - ShieldedMultiSigSimulator.create(SIGNERS, 0n), - ).rejects.toThrow('SignerManagerManager: threshold must be > 0'); - }); - - it('should fail with threshold exceeding signer count', async () => { - await expect( - ShieldedMultiSigSimulator.create(SIGNERS, 4n), - ).rejects.toThrow('SignerManagerManager: threshold exceeds signer count'); - }); - }); - - describe('when initialized', () => { - beforeEach(async () => { - multisig = await ShieldedMultiSigSimulator.create(SIGNERS, THRESHOLD); - }); - - describe('deposit', () => { - it('should accept deposits', async () => { - await multisig.deposit(makeCoin(COLOR, AMOUNT)); - expect(await multisig.getTokenBalance(COLOR)).toEqual(AMOUNT); - }); - - it('should accumulate deposits', async () => { - await multisig.deposit( - makeCoin(COLOR, AMOUNT, new Uint8Array(32).fill(1)), - ); - await multisig.deposit( - makeCoin(COLOR, AMOUNT, new Uint8Array(32).fill(2)), - ); - expect(await multisig.getTokenBalance(COLOR)).toEqual(AMOUNT * 2n); - }); - - it('should track received total', async () => { - await multisig.deposit(makeCoin(COLOR, AMOUNT)); - expect(await multisig.getReceivedTotal(COLOR)).toEqual(AMOUNT); - }); - }); - - describe('createShieldedProposal', () => { - it('should allow signer to create proposal', async () => { - const to = makeRecipient(Z_RECIPIENT_PK); - const id = await multisig - .as('SIGNER1') - .createShieldedProposal(to, COLOR, PROPOSAL_AMOUNT); - expect(id).toEqual(1n); - }); - - it('should store proposal data correctly', async () => { - const to = makeRecipient(Z_RECIPIENT_PK); - const id = await multisig - .as('SIGNER1') - .createShieldedProposal(to, COLOR, PROPOSAL_AMOUNT); - - const proposal = await multisig.getProposal(id); - expect(proposal.status).toEqual(ProposalStatus.Active); - expect(proposal.amount).toEqual(PROPOSAL_AMOUNT); - expect(proposal.color).toEqual(COLOR); - }); - - it('should fail for non-signer', async () => { - const to = makeRecipient(Z_RECIPIENT_PK); - await expect( - multisig - .as('OTHER') - .createShieldedProposal(to, COLOR, PROPOSAL_AMOUNT), - ).rejects.toThrow('SignerManagerManager: not a signer'); - }); - - it('should fail with zero amount', async () => { - const to = makeRecipient(Z_RECIPIENT_PK); - await expect( - multisig.as('SIGNER1').createShieldedProposal(to, COLOR, 0n), - ).rejects.toThrow('ProposalManager: zero amount'); - }); - - it('should reject UnshieldedUser recipient kind', async () => { - const to = { - kind: RecipientKind.UnshieldedUser, - address: Z_RECIPIENT_PK.bytes, - }; - await expect( - multisig - .as('SIGNER1') - .createShieldedProposal(to, COLOR, PROPOSAL_AMOUNT), - ).rejects.toThrow( - 'ShieldedMultiSig: recipient must be a shielded user or contract', - ); - }); - - it('should accept Contract recipient kind', async () => { - const to = { - kind: RecipientKind.Contract, - address: new Uint8Array(32).fill(7), - }; - const id = await multisig - .as('SIGNER1') - .createShieldedProposal(to, COLOR, PROPOSAL_AMOUNT); - expect(id).toEqual(1n); - expect((await multisig.getProposalRecipient(id)).kind).toEqual( - RecipientKind.Contract, - ); - }); - }); - - describe('approveProposal', () => { - let proposalId: bigint; - - beforeEach(async () => { - const to = makeRecipient(Z_RECIPIENT_PK); - proposalId = await multisig - .as('SIGNER1') - .createShieldedProposal(to, COLOR, PROPOSAL_AMOUNT); - }); - - it('should allow signer to approve', async () => { - await multisig.as('SIGNER1').approveProposal(proposalId); - expect( - await multisig.isProposalApprovedBySigner(proposalId, Z_SIGNER1), - ).toEqual(true); - expect(await multisig.getApprovalCount(proposalId)).toEqual(1n); - }); - - it('should allow multiple signers to approve', async () => { - await multisig.as('SIGNER1').approveProposal(proposalId); - await multisig.as('SIGNER2').approveProposal(proposalId); - expect(await multisig.getApprovalCount(proposalId)).toEqual(2n); - }); - - it('should fail for non-signer', async () => { - await expect( - multisig.as('OTHER').approveProposal(proposalId), - ).rejects.toThrow('SignerManagerManager: not a signer'); - }); - - it('should fail for double approval', async () => { - await multisig.as('SIGNER1').approveProposal(proposalId); - await expect( - multisig.as('SIGNER1').approveProposal(proposalId), - ).rejects.toThrow('Multisig: already approved'); - }); - - it('should fail for non-existing proposal', async () => { - await expect( - multisig.as('SIGNER1').approveProposal(999n), - ).rejects.toThrow('ProposalManager: proposal not found'); - }); - - it('should fail for executed proposal', async () => { - await multisig.deposit(makeCoin(COLOR, AMOUNT)); - await multisig.as('SIGNER1').approveProposal(proposalId); - await multisig.as('SIGNER2').approveProposal(proposalId); - await multisig.executeShieldedProposal(proposalId); - - await expect( - multisig.as('SIGNER3').approveProposal(proposalId), - ).rejects.toThrow('ProposalManager: proposal not active'); - }); - }); - - describe('revokeApproval', () => { - let proposalId: bigint; - - beforeEach(async () => { - const to = makeRecipient(Z_RECIPIENT_PK); - proposalId = await multisig - .as('SIGNER1') - .createShieldedProposal(to, COLOR, PROPOSAL_AMOUNT); - await multisig.as('SIGNER1').approveProposal(proposalId); - }); - - it('should allow signer to revoke their approval', async () => { - await multisig.as('SIGNER1').revokeApproval(proposalId); - expect( - await multisig.isProposalApprovedBySigner(proposalId, Z_SIGNER1), - ).toEqual(false); - expect(await multisig.getApprovalCount(proposalId)).toEqual(0n); - }); - - it('should fail for non-signer', async () => { - await expect( - multisig.as('OTHER').revokeApproval(proposalId), - ).rejects.toThrow('SignerManagerManager: not a signer'); - }); - - it('should fail if not yet approved', async () => { - await expect( - multisig.as('SIGNER2').revokeApproval(proposalId), - ).rejects.toThrow('Multisig: not approved'); - }); - - it('should allow re-approval after revoke', async () => { - await multisig.as('SIGNER1').revokeApproval(proposalId); - await multisig.as('SIGNER1').approveProposal(proposalId); - expect( - await multisig.isProposalApprovedBySigner(proposalId, Z_SIGNER1), - ).toEqual(true); - expect(await multisig.getApprovalCount(proposalId)).toEqual(1n); - }); - - it('should fail for executed proposal', async () => { - await multisig.deposit(makeCoin(COLOR, AMOUNT)); - await multisig.as('SIGNER2').approveProposal(proposalId); - await multisig.executeShieldedProposal(proposalId); - - await expect( - multisig.as('SIGNER1').revokeApproval(proposalId), - ).rejects.toThrow('ProposalManager: proposal not active'); - }); - }); - - describe('executeShieldedProposal', () => { - let proposalId: bigint; - - beforeEach(async () => { - // Fund the treasury - await multisig.deposit(makeCoin(COLOR, AMOUNT)); - - // Create and approve proposal to threshold - const to = makeRecipient(Z_RECIPIENT_PK); - proposalId = await multisig - .as('SIGNER1') - .createShieldedProposal(to, COLOR, PROPOSAL_AMOUNT); - await multisig.as('SIGNER1').approveProposal(proposalId); - await multisig.as('SIGNER2').approveProposal(proposalId); - }); - - it('should execute when threshold is met', async () => { - await multisig.executeShieldedProposal(proposalId); - expect(await multisig.getProposalStatus(proposalId)).toEqual( - ProposalStatus.Executed, - ); - }); - - it('should return sent coin and change in result', async () => { - const result = await multisig.executeShieldedProposal(proposalId); - expect(result.sent.value).toEqual(PROPOSAL_AMOUNT); - expect(result.sent.color).toEqual(COLOR); - expect(result.change.is_some).toEqual(true); - expect(result.change.value.value).toEqual(AMOUNT - PROPOSAL_AMOUNT); - expect(result.change.value.color).toEqual(COLOR); - }); - - it('should return no change when sending full balance', async () => { - // Create proposal for the full amount - const to = makeRecipient(Z_RECIPIENT_PK); - const fullId = await multisig - .as('SIGNER1') - .createShieldedProposal(to, COLOR, AMOUNT); - await multisig.as('SIGNER1').approveProposal(fullId); - await multisig.as('SIGNER2').approveProposal(fullId); - - const result = await multisig.executeShieldedProposal(fullId); - expect(result.sent.value).toEqual(AMOUNT); - expect(result.change.is_some).toEqual(false); - }); - - it('should deduct from treasury balance', async () => { - await multisig.executeShieldedProposal(proposalId); - expect(await multisig.getTokenBalance(COLOR)).toEqual( - AMOUNT - PROPOSAL_AMOUNT, - ); - }); - - it('should track sent total', async () => { - await multisig.executeShieldedProposal(proposalId); - expect(await multisig.getSentTotal(COLOR)).toEqual(PROPOSAL_AMOUNT); - }); - - it('should fail when threshold is not met', async () => { - // Create a new proposal with only 1 approval - const to = makeRecipient(Z_RECIPIENT_PK); - const id2 = await multisig - .as('SIGNER1') - .createShieldedProposal(to, COLOR, 100n); - await multisig.as('SIGNER1').approveProposal(id2); - - await expect(multisig.executeShieldedProposal(id2)).rejects.toThrow( - 'SignerManagerManager: threshold not met', - ); - }); - - it('should fail for non-existing proposal', async () => { - await expect(multisig.executeShieldedProposal(999n)).rejects.toThrow( - 'ProposalManager: proposal not found', - ); - }); - - it('should fail when executed twice', async () => { - await multisig.executeShieldedProposal(proposalId); - await expect( - multisig.executeShieldedProposal(proposalId), - ).rejects.toThrow('ProposalManager: proposal not active'); - }); - - it('should fail with insufficient treasury balance', async () => { - // Create proposal for more than treasury holds - const to = makeRecipient(Z_RECIPIENT_PK); - const bigId = await multisig - .as('SIGNER1') - .createShieldedProposal(to, COLOR, AMOUNT + 1n); - await multisig.as('SIGNER1').approveProposal(bigId); - await multisig.as('SIGNER2').approveProposal(bigId); - - await expect(multisig.executeShieldedProposal(bigId)).rejects.toThrow( - 'ShieldedTreasury: coin value insufficient', - ); - }); - }); - - describe('view - approvals', () => { - it('should return false for unapproved signer', async () => { - const to = makeRecipient(Z_RECIPIENT_PK); - const id = await multisig - .as('SIGNER1') - .createShieldedProposal(to, COLOR, PROPOSAL_AMOUNT); - expect( - await multisig.isProposalApprovedBySigner(id, Z_SIGNER1), - ).toEqual(false); - }); - - it('should return 0 approval count for new proposal', async () => { - const to = makeRecipient(Z_RECIPIENT_PK); - const id = await multisig - .as('SIGNER1') - .createShieldedProposal(to, COLOR, PROPOSAL_AMOUNT); - expect(await multisig.getApprovalCount(id)).toEqual(0n); - }); - }); - - describe('view - proposal delegation', () => { - let proposalId: bigint; - - beforeEach(async () => { - const to = makeRecipient(Z_RECIPIENT_PK); - proposalId = await multisig - .as('SIGNER1') - .createShieldedProposal(to, COLOR, PROPOSAL_AMOUNT); - }); - - it('getProposalRecipient should return recipient', async () => { - const recipient = await multisig.getProposalRecipient(proposalId); - expect(recipient.kind).toEqual(RecipientKind.ShieldedUser); - expect(recipient.address).toEqual(Z_RECIPIENT_PK.bytes); - }); - - it('getProposalAmount should return amount', async () => { - expect(await multisig.getProposalAmount(proposalId)).toEqual( - PROPOSAL_AMOUNT, - ); - }); - - it('getProposalColor should return color', async () => { - expect(await multisig.getProposalColor(proposalId)).toEqual(COLOR); - }); - }); - - describe('view - signer manager delegation', () => { - it('getSignerCount should match initial count', async () => { - expect(await multisig.getSignerCount()).toEqual(BigInt(SIGNERS.length)); - }); - - it('getThreshold should match initial threshold', async () => { - expect(await multisig.getThreshold()).toEqual(THRESHOLD); - }); - - it('isSigner should return true for signer', async () => { - expect(await multisig.isSigner(Z_SIGNER1)).toEqual(true); - }); - - it('isSigner should return false for non-signer', async () => { - expect(await multisig.isSigner(Z_NON_SIGNER)).toEqual(false); - }); - }); - - describe('view - treasury delegation', () => { - beforeEach(async () => { - await multisig.deposit(makeCoin(COLOR, AMOUNT)); - }); - - it('getTokenBalance should reflect deposits', async () => { - expect(await multisig.getTokenBalance(COLOR)).toEqual(AMOUNT); - }); - - it('getReceivedTotal should reflect deposits', async () => { - expect(await multisig.getReceivedTotal(COLOR)).toEqual(AMOUNT); - }); - - it('getSentTotal should be 0 before any sends', async () => { - expect(await multisig.getSentTotal(COLOR)).toEqual(0n); - }); - - it('getReceivedMinusSent should equal balance', async () => { - expect(await multisig.getReceivedMinusSent(COLOR)).toEqual(AMOUNT); - }); - }); - - describe('full lifecycle', () => { - it('should handle deposit -> propose -> approve -> execute', async () => { - // Deposit - await multisig.deposit(makeCoin(COLOR, AMOUNT)); - expect(await multisig.getTokenBalance(COLOR)).toEqual(AMOUNT); - - // Propose - const to = makeRecipient(Z_RECIPIENT_PK); - const id = await multisig - .as('SIGNER1') - .createShieldedProposal(to, COLOR, PROPOSAL_AMOUNT); - - // Approve to threshold - await multisig.as('SIGNER1').approveProposal(id); - await multisig.as('SIGNER2').approveProposal(id); - expect(await multisig.getApprovalCount(id)).toEqual(THRESHOLD); - - // Execute - await multisig.executeShieldedProposal(id); - expect(await multisig.getProposalStatus(id)).toEqual( - ProposalStatus.Executed, - ); - expect(await multisig.getTokenBalance(COLOR)).toEqual( - AMOUNT - PROPOSAL_AMOUNT, - ); - expect(await multisig.getReceivedMinusSent(COLOR)).toEqual( - AMOUNT - PROPOSAL_AMOUNT, - ); - }); - - it('should handle multiple proposals concurrently', async () => { - await multisig.deposit(makeCoin(COLOR, AMOUNT)); - - const to = makeRecipient(Z_RECIPIENT_PK); - const id1 = await multisig - .as('SIGNER1') - .createShieldedProposal(to, COLOR, 200n); - const id2 = await multisig - .as('SIGNER2') - .createShieldedProposal(to, COLOR, 300n); - - // Approve and execute first - await multisig.as('SIGNER1').approveProposal(id1); - await multisig.as('SIGNER2').approveProposal(id1); - await multisig.executeShieldedProposal(id1); - - // Approve and execute second - await multisig.as('SIGNER1').approveProposal(id2); - await multisig.as('SIGNER3').approveProposal(id2); - await multisig.executeShieldedProposal(id2); - - expect(await multisig.getTokenBalance(COLOR)).toEqual( - AMOUNT - 200n - 300n, - ); - }); - - it('should handle approve -> revoke -> re-approve -> execute', async () => { - await multisig.deposit(makeCoin(COLOR, AMOUNT)); - const to = makeRecipient(Z_RECIPIENT_PK); - const id = await multisig - .as('SIGNER1') - .createShieldedProposal(to, COLOR, PROPOSAL_AMOUNT); - - // Approve then revoke - await multisig.as('SIGNER1').approveProposal(id); - await multisig.as('SIGNER1').revokeApproval(id); - expect(await multisig.getApprovalCount(id)).toEqual(0n); - - // Re-approve with enough signers - await multisig.as('SIGNER2').approveProposal(id); - await multisig.as('SIGNER3').approveProposal(id); - expect(await multisig.getApprovalCount(id)).toEqual(2n); - - await multisig.executeShieldedProposal(id); - expect(await multisig.getProposalStatus(id)).toEqual( - ProposalStatus.Executed, - ); - }); - }); - }); -}); diff --git a/contracts/src/multisig/test/ShieldedMultiSigV2.test.ts b/contracts/src/multisig/test/ShieldedMultiSigV2.test.ts deleted file mode 100644 index ca3bce8d..00000000 --- a/contracts/src/multisig/test/ShieldedMultiSigV2.test.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { beforeEach, describe, expect, it } from 'vitest'; -import { ShieldedMultiSigV2Simulator } from './simulators/ShieldedMultiSigV2Simulator.js'; - -const RecipientKind = { ShieldedUser: 0, UnshieldedUser: 1, Contract: 2 }; - -const INSTANCE_SALT = new Uint8Array(32).fill(0xaa); -const COLOR = new Uint8Array(32).fill(1); -const AMOUNT = 1000n; - -const PK1 = new Uint8Array(64).fill(0x11); -const PK2 = new Uint8Array(64).fill(0x22); -const PK3 = new Uint8Array(64).fill(0x33); -const NON_SIGNER_PK = new Uint8Array(64).fill(0x99); - -const COMMITMENT1 = ShieldedMultiSigV2Simulator.calculateSignerId( - PK1, - INSTANCE_SALT, -); -const COMMITMENT2 = ShieldedMultiSigV2Simulator.calculateSignerId( - PK2, - INSTANCE_SALT, -); -const COMMITMENT3 = ShieldedMultiSigV2Simulator.calculateSignerId( - PK3, - INSTANCE_SALT, -); -const SIGNER_COMMITMENTS = [COMMITMENT1, COMMITMENT2, COMMITMENT3]; - -const DUMMY_SIG = new Uint8Array(64).fill(0xff); - -function makeRecipient(address: Uint8Array): { - kind: number; - address: Uint8Array; -} { - return { kind: RecipientKind.ShieldedUser, address }; -} - -function makeCoin( - color: Uint8Array, - value: bigint, - nonce?: Uint8Array, -): { nonce: Uint8Array; color: Uint8Array; value: bigint } { - return { - nonce: nonce ?? new Uint8Array(32).fill(0), - color, - value, - }; -} - -function makeQualifiedCoin( - color: Uint8Array, - value: bigint, - mtIndex: bigint, - nonce?: Uint8Array, -): { - nonce: Uint8Array; - color: Uint8Array; - value: bigint; - mt_index: bigint; -} { - return { - nonce: nonce ?? new Uint8Array(32).fill(0), - color, - value, - mt_index: mtIndex, - }; -} - -let multisig: ShieldedMultiSigV2Simulator; - -describe('ShieldedMultiSigV2', () => { - describe('constructor', () => { - it('should initialize with 2-of-3 threshold', async () => { - multisig = await ShieldedMultiSigV2Simulator.create( - INSTANCE_SALT, - SIGNER_COMMITMENTS, - 2n, - ); - expect(await multisig.getSignerCount()).toEqual(3n); - expect(await multisig.getThreshold()).toEqual(2n); - }); - - it('should initialize with 1-of-3 threshold', async () => { - multisig = await ShieldedMultiSigV2Simulator.create( - INSTANCE_SALT, - SIGNER_COMMITMENTS, - 1n, - ); - expect(await multisig.getThreshold()).toEqual(1n); - }); - - it('should fail with zero threshold', async () => { - await expect( - ShieldedMultiSigV2Simulator.create( - INSTANCE_SALT, - SIGNER_COMMITMENTS, - 0n, - ), - ).rejects.toThrow('SignerManagerManager: threshold must be > 0'); - }); - - it('should fail with threshold greater than 2', async () => { - await expect( - ShieldedMultiSigV2Simulator.create( - INSTANCE_SALT, - SIGNER_COMMITMENTS, - 3n, - ), - ).rejects.toThrow( - 'ShieldedMultiSigV2: threshold cannot exceed 2 (execute verifies at most 2 signatures)', - ); - }); - - it('should register all signer commitments', async () => { - multisig = await ShieldedMultiSigV2Simulator.create( - INSTANCE_SALT, - SIGNER_COMMITMENTS, - 2n, - ); - for (const commitment of SIGNER_COMMITMENTS) { - expect(await multisig.isSigner(commitment)).toEqual(true); - } - }); - - it('should reject a non-signer commitment', async () => { - multisig = await ShieldedMultiSigV2Simulator.create( - INSTANCE_SALT, - SIGNER_COMMITMENTS, - 2n, - ); - const unknown = ShieldedMultiSigV2Simulator.calculateSignerId( - NON_SIGNER_PK, - INSTANCE_SALT, - ); - expect(await multisig.isSigner(unknown)).toEqual(false); - }); - }); - - describe('when initialized', () => { - beforeEach(async () => { - multisig = await ShieldedMultiSigV2Simulator.create( - INSTANCE_SALT, - SIGNER_COMMITMENTS, - 2n, - ); - }); - - describe('view', () => { - it('getNonce should start at 0', async () => { - expect(await multisig.getNonce()).toEqual(0n); - }); - - it('getSignerCount should return 3', async () => { - expect(await multisig.getSignerCount()).toEqual(3n); - }); - - it('getThreshold should match constructor arg', async () => { - expect(await multisig.getThreshold()).toEqual(2n); - }); - }); - - describe('deposit', () => { - it('should accept deposits without reverting', async () => { - await multisig.deposit(makeCoin(COLOR, AMOUNT)); - }); - }); - - describe('execute', () => { - it('should reject duplicate signer', async () => { - const to = makeRecipient(new Uint8Array(32).fill(7)); - const coin = makeQualifiedCoin(COLOR, AMOUNT, 0n); - await expect( - multisig.execute(to, 100n, coin, [PK1, PK1], [DUMMY_SIG, DUMMY_SIG]), - ).rejects.toThrow('Multisig: duplicate signer'); - }); - - it('should reject a non-signer pubkey', async () => { - const to = makeRecipient(new Uint8Array(32).fill(7)); - const coin = makeQualifiedCoin(COLOR, AMOUNT, 0n); - await expect( - multisig.execute( - to, - 100n, - coin, - [PK1, NON_SIGNER_PK], - [DUMMY_SIG, DUMMY_SIG], - ), - ).rejects.toThrow('SignerManagerManager: not a signer'); - }); - }); - }); -}); diff --git a/contracts/src/multisig/test/ShieldedMultiSigV3.test.ts b/contracts/src/multisig/test/ShieldedMultiSigV3.test.ts deleted file mode 100644 index fe36c539..00000000 --- a/contracts/src/multisig/test/ShieldedMultiSigV3.test.ts +++ /dev/null @@ -1,454 +0,0 @@ -import { beforeEach, describe, expect, it } from 'vitest'; -import * as utils from '#test-utils/address.js'; -import { - calculateSignerId, - ShieldedMultiSigV3Simulator, -} from './simulators/ShieldedMultiSigV3Simulator.js'; - -// ─── Fixtures ───────────────────────────────────────────────────── - -const INSTANCE_SALT = new Uint8Array(32).fill(0xaa); -const INIT_COIN_NONCE = new Uint8Array(32).fill(0xbb); -const TOKEN_DOMAIN = new Uint8Array(32); -Buffer.from('smt:token:').copy(TOKEN_DOMAIN); - -const PK1 = new Uint8Array(64).fill(0x11); -const PK2 = new Uint8Array(64).fill(0x22); -const PK3 = new Uint8Array(64).fill(0x33); -const NON_SIGNER_PK = new Uint8Array(64).fill(0x99); - -const COMMITMENT1 = calculateSignerId(PK1, INSTANCE_SALT); -const COMMITMENT2 = calculateSignerId(PK2, INSTANCE_SALT); -const COMMITMENT3 = calculateSignerId(PK3, INSTANCE_SALT); -const SIGNER_COMMITMENTS = [COMMITMENT1, COMMITMENT2, COMMITMENT3]; - -const DUMMY_SIG = new Uint8Array(64).fill(0xff); - -const USER_RECIPIENT = utils.createEitherTestUser('ALICE'); -const CONTRACT_RECIPIENT = utils.createEitherTestContractAddress('TARGET'); - -function makeQualifiedCoin( - color: Uint8Array, - value: bigint, - mtIndex = 0n, - nonce?: Uint8Array, -): { - nonce: Uint8Array; - color: Uint8Array; - value: bigint; - mt_index: bigint; -} { - return { - nonce: nonce ?? new Uint8Array(32).fill(0), - color, - value, - mt_index: mtIndex, - }; -} - -let multisig: ShieldedMultiSigV3Simulator; - -describe('ShieldedMultiSigV3', () => { - describe('constructor', () => { - it('should initialize', async () => { - multisig = await ShieldedMultiSigV3Simulator.create( - INSTANCE_SALT, - INIT_COIN_NONCE, - TOKEN_DOMAIN, - SIGNER_COMMITMENTS, - ); - expect(await multisig.getSignerCount()).toEqual(3n); - expect(await multisig.getThreshold()).toEqual(2n); - }); - - it('should register all signer commitments', async () => { - multisig = await ShieldedMultiSigV3Simulator.create( - INSTANCE_SALT, - INIT_COIN_NONCE, - TOKEN_DOMAIN, - SIGNER_COMMITMENTS, - ); - for (const commitment of SIGNER_COMMITMENTS) { - expect(await multisig.isSigner(commitment)).toEqual(true); - } - }); - - it('should reject a non-signer commitment', async () => { - multisig = await ShieldedMultiSigV3Simulator.create( - INSTANCE_SALT, - INIT_COIN_NONCE, - TOKEN_DOMAIN, - SIGNER_COMMITMENTS, - ); - const unknown = await multisig._calculateSignerId( - NON_SIGNER_PK, - INSTANCE_SALT, - ); - expect(await multisig.isSigner(unknown)).toEqual(false); - }); - - it('should fail with duplicate signer commitments', async () => { - await expect( - ShieldedMultiSigV3Simulator.create( - INSTANCE_SALT, - INIT_COIN_NONCE, - TOKEN_DOMAIN, - [COMMITMENT1, COMMITMENT1, COMMITMENT2], - ), - ).rejects.toThrow('SignerManager: signer already active'); - }); - - it('should store token domain', async () => { - multisig = await ShieldedMultiSigV3Simulator.create( - INSTANCE_SALT, - INIT_COIN_NONCE, - TOKEN_DOMAIN, - SIGNER_COMMITMENTS, - ); - expect(await multisig.getTokenDomain()).toEqual(TOKEN_DOMAIN); - }); - }); - - describe('when initialized', () => { - beforeEach(async () => { - multisig = await ShieldedMultiSigV3Simulator.create( - INSTANCE_SALT, - INIT_COIN_NONCE, - TOKEN_DOMAIN, - SIGNER_COMMITMENTS, - ); - }); - - describe('view', () => { - it('getNonce should start at 0', async () => { - expect(await multisig.getNonce()).toEqual(0n); - }); - - it('getSignerCount should return 3', async () => { - expect(await multisig.getSignerCount()).toEqual(3n); - }); - - it('getThreshold should match constructor arg', async () => { - expect(await multisig.getThreshold()).toEqual(2n); - }); - - it('getTokenType should return non-zero', async () => { - expect(await multisig.getTokenType()).not.toEqual(new Uint8Array(32)); - }); - - it('getTokenType should be deterministic', async () => { - expect(await multisig.getTokenType()).toEqual( - await multisig.getTokenType(), - ); - }); - }); - - describe('_calculateSignerId', () => { - it('should produce deterministic commitments', async () => { - const c1 = await multisig._calculateSignerId(PK1, INSTANCE_SALT); - const c2 = await multisig._calculateSignerId(PK1, INSTANCE_SALT); - expect(c1).toEqual(c2); - }); - - it('should produce different commitments for different keys', async () => { - const c1 = await multisig._calculateSignerId(PK1, INSTANCE_SALT); - const c2 = await multisig._calculateSignerId(PK2, INSTANCE_SALT); - expect(c1).not.toEqual(c2); - }); - - it('should produce different commitments for different salts', async () => { - const salt2 = new Uint8Array(32).fill(0xcc); - const c1 = await multisig._calculateSignerId(PK1, INSTANCE_SALT); - const c2 = await multisig._calculateSignerId(PK1, salt2); - expect(c1).not.toEqual(c2); - }); - - it('should match registered commitments', async () => { - expect(await multisig._calculateSignerId(PK1, INSTANCE_SALT)).toEqual( - COMMITMENT1, - ); - expect(await multisig._calculateSignerId(PK2, INSTANCE_SALT)).toEqual( - COMMITMENT2, - ); - expect(await multisig._calculateSignerId(PK3, INSTANCE_SALT)).toEqual( - COMMITMENT3, - ); - }); - }); - - describe('mint', () => { - it('should mint to a user recipient with signers 0 and 1', async () => { - await multisig.mint( - 100n, - USER_RECIPIENT, - [PK1, PK2], - [DUMMY_SIG, DUMMY_SIG], - ); - }); - - it('should mint to a user recipient with signers 0 and 2', async () => { - await multisig.mint( - 100n, - USER_RECIPIENT, - [PK1, PK3], - [DUMMY_SIG, DUMMY_SIG], - ); - }); - - it('should mint to a user recipient with signers 1 and 2', async () => { - await multisig.mint( - 100n, - USER_RECIPIENT, - [PK2, PK3], - [DUMMY_SIG, DUMMY_SIG], - ); - }); - - it('should mint to a contract recipient', async () => { - await multisig.mint( - 100n, - CONTRACT_RECIPIENT, - [PK1, PK2], - [DUMMY_SIG, DUMMY_SIG], - ); - }); - - it('should reject duplicate signer', async () => { - await expect( - multisig.mint( - 100n, - USER_RECIPIENT, - [PK1, PK1], - [DUMMY_SIG, DUMMY_SIG], - ), - ).rejects.toThrow('Multisig: duplicate signer'); - }); - - it('should reject a non-signer pubkey', async () => { - await expect( - multisig.mint( - 100n, - USER_RECIPIENT, - [PK1, NON_SIGNER_PK], - [DUMMY_SIG, DUMMY_SIG], - ), - ).rejects.toThrow('SignerManager: not a signer'); - }); - - it('should increment nonce after mint', async () => { - expect(await multisig.getNonce()).toEqual(0n); - await multisig.mint( - 100n, - USER_RECIPIENT, - [PK1, PK2], - [DUMMY_SIG, DUMMY_SIG], - ); - expect(await multisig.getNonce()).toEqual(1n); - }); - - it('should increment nonce on each mint', async () => { - await multisig.mint( - 100n, - USER_RECIPIENT, - [PK1, PK2], - [DUMMY_SIG, DUMMY_SIG], - ); - await multisig.mint( - 200n, - USER_RECIPIENT, - [PK1, PK3], - [DUMMY_SIG, DUMMY_SIG], - ); - await multisig.mint( - 300n, - CONTRACT_RECIPIENT, - [PK2, PK3], - [DUMMY_SIG, DUMMY_SIG], - ); - expect(await multisig.getNonce()).toEqual(3n); - }); - - it('should accept zero amount', async () => { - await multisig.mint( - 0n, - USER_RECIPIENT, - [PK1, PK2], - [DUMMY_SIG, DUMMY_SIG], - ); - }); - - it('should prevent replay by incrementing nonce', async () => { - await multisig.mint( - 100n, - USER_RECIPIENT, - [PK1, PK2], - [DUMMY_SIG, DUMMY_SIG], - ); - // Second mint with same params succeeds because nonce is different - // (stub ver doesn't actually check signatures) - await multisig.mint( - 100n, - USER_RECIPIENT, - [PK1, PK2], - [DUMMY_SIG, DUMMY_SIG], - ); - expect(await multisig.getNonce()).toEqual(2n); - }); - }); - - describe('burn', () => { - it('should burn with valid coin and signers 0 and 1', async () => { - const coin = makeQualifiedCoin(await multisig.getTokenType(), 100n); - await multisig.burn(coin, 100n, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]); - }); - - it('should burn with signers 0 and 2', async () => { - const coin = makeQualifiedCoin(await multisig.getTokenType(), 100n); - await multisig.burn(coin, 100n, [PK1, PK3], [DUMMY_SIG, DUMMY_SIG]); - }); - - it('should burn with signers 1 and 2', async () => { - const coin = makeQualifiedCoin(await multisig.getTokenType(), 100n); - await multisig.burn(coin, 100n, [PK2, PK3], [DUMMY_SIG, DUMMY_SIG]); - }); - - it('should burn partial amount', async () => { - const coin = makeQualifiedCoin(await multisig.getTokenType(), 100n); - await multisig.burn(coin, 50n, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]); - }); - - it('should handle zero burn amount', async () => { - const coin = makeQualifiedCoin(await multisig.getTokenType(), 100n); - await multisig.burn(coin, 0n, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]); - }); - - it('should reject duplicate signer', async () => { - const coin = makeQualifiedCoin(await multisig.getTokenType(), 100n); - await expect( - multisig.burn(coin, 100n, [PK1, PK1], [DUMMY_SIG, DUMMY_SIG]), - ).rejects.toThrow('Multisig: duplicate signer'); - }); - - it('should reject a non-signer pubkey', async () => { - const coin = makeQualifiedCoin(await multisig.getTokenType(), 100n); - await expect( - multisig.burn( - coin, - 100n, - [PK1, NON_SIGNER_PK], - [DUMMY_SIG, DUMMY_SIG], - ), - ).rejects.toThrow('SignerManager: not a signer'); - }); - - it('should reject wrong token color', async () => { - const wrongColor = new Uint8Array(32).fill(0xde); - const coin = makeQualifiedCoin(wrongColor, 100n); - await expect( - multisig.burn(coin, 100n, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]), - ).rejects.toThrow('Multisig: coin not from this contract'); - }); - - it('should reject insufficient coin value', async () => { - const coin = makeQualifiedCoin(await multisig.getTokenType(), 10n); - await expect( - multisig.burn(coin, 100n, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]), - ).rejects.toThrow('Multisig: insufficient coin value'); - }); - - it('should reject when amount exceeds value by 1', async () => { - const coin = makeQualifiedCoin(await multisig.getTokenType(), 99n); - await expect( - multisig.burn(coin, 100n, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]), - ).rejects.toThrow('Multisig: insufficient coin value'); - }); - - it('should share nonce across mint and burn', async () => { - await multisig.mint( - 100n, - USER_RECIPIENT, - [PK1, PK2], - [DUMMY_SIG, DUMMY_SIG], - ); - expect(await multisig.getNonce()).toEqual(1n); - - const coin = makeQualifiedCoin(await multisig.getTokenType(), 100n); - await multisig.burn(coin, 50n, [PK1, PK3], [DUMMY_SIG, DUMMY_SIG]); - expect(await multisig.getNonce()).toEqual(2n); - }); - }); - - describe('domain separation', () => { - it('should isolate signers across instances with different salts', async () => { - const salt2 = new Uint8Array(32).fill(0xcc); - const c1 = await multisig._calculateSignerId(PK1, INSTANCE_SALT); - const c2 = await multisig._calculateSignerId(PK1, salt2); - expect(c1).not.toEqual(c2); - }); - - it('should derive different token types with different domains', async () => { - const altDomain = new Uint8Array(32); - Buffer.from('alt:token:').copy(altDomain); - - const alt = await ShieldedMultiSigV3Simulator.create( - INSTANCE_SALT, - INIT_COIN_NONCE, - altDomain, - SIGNER_COMMITMENTS, - ); - - expect(await multisig.getTokenType()).not.toEqual( - await alt.getTokenType(), - ); - }); - }); - - describe('nonce', () => { - it('should start at 0', async () => { - expect(await multisig.getNonce()).toEqual(0n); - }); - - it('should increment monotonically', async () => { - for (let i = 0; i < 5; i++) { - await multisig.mint( - 1n, - USER_RECIPIENT, - [PK1, PK2], - [DUMMY_SIG, DUMMY_SIG], - ); - expect(await multisig.getNonce()).toEqual(BigInt(i + 1)); - } - }); - }); - - describe('cross-instance replay', () => { - it('should derive different message hashes for different instances', async () => { - const instance2 = await ShieldedMultiSigV3Simulator.create( - INSTANCE_SALT, - INIT_COIN_NONCE, - TOKEN_DOMAIN, - SIGNER_COMMITMENTS, - ); - - // With stub verification, both succeed independently. - // Once real ECDSA is available, a signature produced for one - // instance's message hash must not validate against the other's. - await multisig.mint( - 100n, - USER_RECIPIENT, - [PK1, PK2], - [DUMMY_SIG, DUMMY_SIG], - ); - await instance2.mint( - 100n, - USER_RECIPIENT, - [PK1, PK2], - [DUMMY_SIG, DUMMY_SIG], - ); - - expect(await multisig.getNonce()).toEqual(1n); - expect(await instance2.getNonce()).toEqual(1n); - }); - }); - }); -}); diff --git a/contracts/src/multisig/test/SignatureMintBurn.test.ts b/contracts/src/multisig/test/SignatureMintBurn.test.ts new file mode 100644 index 00000000..f6f63d0f --- /dev/null +++ b/contracts/src/multisig/test/SignatureMintBurn.test.ts @@ -0,0 +1,429 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import * as utils from '#test-utils/address.js'; +import { + calculateSignerId, + SignatureMintBurnSimulator, +} from './simulators/SignatureMintBurnSimulator.js'; + +// ─── Fixtures ───────────────────────────────────────────────────── + +const INSTANCE_SALT = new Uint8Array(32).fill(0xaa); +const INIT_COIN_NONCE = new Uint8Array(32).fill(0xbb); +const TOKEN_DOMAIN = new Uint8Array(32); +Buffer.from('smt:token:').copy(TOKEN_DOMAIN); + +const PK1 = new Uint8Array(64).fill(0x11); +const PK2 = new Uint8Array(64).fill(0x22); +const PK3 = new Uint8Array(64).fill(0x33); +const NON_SIGNER_PK = new Uint8Array(64).fill(0x99); + +const COMMITMENT1 = calculateSignerId(PK1, INSTANCE_SALT); +const COMMITMENT2 = calculateSignerId(PK2, INSTANCE_SALT); +const COMMITMENT3 = calculateSignerId(PK3, INSTANCE_SALT); +const SIGNER_COMMITMENTS = [COMMITMENT1, COMMITMENT2, COMMITMENT3]; + +const DUMMY_SIG = new Uint8Array(64).fill(0xff); + +const USER_RECIPIENT = utils.createEitherTestUser('ALICE'); +const CONTRACT_RECIPIENT = utils.createEitherTestContractAddress('TARGET'); + +function makeQualifiedCoin( + color: Uint8Array, + value: bigint, + mtIndex = 0n, + nonce?: Uint8Array, +): { + nonce: Uint8Array; + color: Uint8Array; + value: bigint; + mt_index: bigint; +} { + return { + nonce: nonce ?? new Uint8Array(32).fill(0), + color, + value, + mt_index: mtIndex, + }; +} + +let multisig: SignatureMintBurnSimulator; + +describe('SignatureMintBurn', () => { + describe('constructor', () => { + it('should initialize', () => { + multisig = new SignatureMintBurnSimulator( + INSTANCE_SALT, + INIT_COIN_NONCE, + TOKEN_DOMAIN, + SIGNER_COMMITMENTS, + ); + expect(multisig.getSignerCount()).toEqual(3n); + expect(multisig.getThreshold()).toEqual(2n); + }); + + it('should register all signer commitments', () => { + multisig = new SignatureMintBurnSimulator( + INSTANCE_SALT, + INIT_COIN_NONCE, + TOKEN_DOMAIN, + SIGNER_COMMITMENTS, + ); + for (const commitment of SIGNER_COMMITMENTS) { + expect(multisig.isSigner(commitment)).toEqual(true); + } + }); + + it('should reject a non-signer commitment', () => { + multisig = new SignatureMintBurnSimulator( + INSTANCE_SALT, + INIT_COIN_NONCE, + TOKEN_DOMAIN, + SIGNER_COMMITMENTS, + ); + const unknown = multisig._calculateSignerId(NON_SIGNER_PK, INSTANCE_SALT); + expect(multisig.isSigner(unknown)).toEqual(false); + }); + + it('should fail with duplicate signer commitments', () => { + expect(() => { + new SignatureMintBurnSimulator( + INSTANCE_SALT, + INIT_COIN_NONCE, + TOKEN_DOMAIN, + [COMMITMENT1, COMMITMENT1, COMMITMENT2], + ); + }).toThrow('SignerManager: signer already active'); + }); + + it('should store token domain', () => { + multisig = new SignatureMintBurnSimulator( + INSTANCE_SALT, + INIT_COIN_NONCE, + TOKEN_DOMAIN, + SIGNER_COMMITMENTS, + ); + expect(multisig.getTokenDomain()).toEqual(TOKEN_DOMAIN); + }); + }); + + describe('when initialized', () => { + beforeEach(() => { + multisig = new SignatureMintBurnSimulator( + INSTANCE_SALT, + INIT_COIN_NONCE, + TOKEN_DOMAIN, + SIGNER_COMMITMENTS, + ); + }); + + describe('view', () => { + it('getNonce should start at 0', () => { + expect(multisig.getNonce()).toEqual(0n); + }); + + it('getSignerCount should return 3', () => { + expect(multisig.getSignerCount()).toEqual(3n); + }); + + it('getThreshold should match constructor arg', () => { + expect(multisig.getThreshold()).toEqual(2n); + }); + + it('getTokenType should return non-zero', () => { + expect(multisig.getTokenType()).not.toEqual(new Uint8Array(32)); + }); + + it('getTokenType should be deterministic', () => { + expect(multisig.getTokenType()).toEqual(multisig.getTokenType()); + }); + }); + + describe('_calculateSignerId', () => { + it('should produce deterministic commitments', () => { + const c1 = multisig._calculateSignerId(PK1, INSTANCE_SALT); + const c2 = multisig._calculateSignerId(PK1, INSTANCE_SALT); + expect(c1).toEqual(c2); + }); + + it('should produce different commitments for different keys', () => { + const c1 = multisig._calculateSignerId(PK1, INSTANCE_SALT); + const c2 = multisig._calculateSignerId(PK2, INSTANCE_SALT); + expect(c1).not.toEqual(c2); + }); + + it('should produce different commitments for different salts', () => { + const salt2 = new Uint8Array(32).fill(0xcc); + const c1 = multisig._calculateSignerId(PK1, INSTANCE_SALT); + const c2 = multisig._calculateSignerId(PK1, salt2); + expect(c1).not.toEqual(c2); + }); + + it('should match registered commitments', () => { + expect(multisig._calculateSignerId(PK1, INSTANCE_SALT)).toEqual( + COMMITMENT1, + ); + expect(multisig._calculateSignerId(PK2, INSTANCE_SALT)).toEqual( + COMMITMENT2, + ); + expect(multisig._calculateSignerId(PK3, INSTANCE_SALT)).toEqual( + COMMITMENT3, + ); + }); + }); + + describe('mint', () => { + it('should mint to a user recipient with signers 0 and 1', () => { + expect(() => { + multisig.mint( + 100n, + USER_RECIPIENT, + [PK1, PK2], + [DUMMY_SIG, DUMMY_SIG], + ); + }).not.toThrow(); + }); + + it('should mint to a user recipient with signers 0 and 2', () => { + expect(() => { + multisig.mint( + 100n, + USER_RECIPIENT, + [PK1, PK3], + [DUMMY_SIG, DUMMY_SIG], + ); + }).not.toThrow(); + }); + + it('should mint to a user recipient with signers 1 and 2', () => { + expect(() => { + multisig.mint( + 100n, + USER_RECIPIENT, + [PK2, PK3], + [DUMMY_SIG, DUMMY_SIG], + ); + }).not.toThrow(); + }); + + it('should mint to a contract recipient', () => { + expect(() => { + multisig.mint( + 100n, + CONTRACT_RECIPIENT, + [PK1, PK2], + [DUMMY_SIG, DUMMY_SIG], + ); + }).not.toThrow(); + }); + + it('should reject duplicate signer', () => { + expect(() => { + multisig.mint( + 100n, + USER_RECIPIENT, + [PK1, PK1], + [DUMMY_SIG, DUMMY_SIG], + ); + }).toThrow('EcdsaSignerManager: duplicate signer'); + }); + + it('should reject a non-signer pubkey', () => { + expect(() => { + multisig.mint( + 100n, + USER_RECIPIENT, + [PK1, NON_SIGNER_PK], + [DUMMY_SIG, DUMMY_SIG], + ); + }).toThrow('SignerManager: not a signer'); + }); + + it('should increment nonce after mint', () => { + expect(multisig.getNonce()).toEqual(0n); + multisig.mint(100n, USER_RECIPIENT, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]); + expect(multisig.getNonce()).toEqual(1n); + }); + + it('should increment nonce on each mint', () => { + multisig.mint(100n, USER_RECIPIENT, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]); + multisig.mint(200n, USER_RECIPIENT, [PK1, PK3], [DUMMY_SIG, DUMMY_SIG]); + multisig.mint( + 300n, + CONTRACT_RECIPIENT, + [PK2, PK3], + [DUMMY_SIG, DUMMY_SIG], + ); + expect(multisig.getNonce()).toEqual(3n); + }); + + it('should accept zero amount', () => { + expect(() => { + multisig.mint(0n, USER_RECIPIENT, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]); + }).not.toThrow(); + }); + + it('should prevent replay by incrementing nonce', () => { + multisig.mint(100n, USER_RECIPIENT, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]); + // Second mint with same params succeeds because nonce is different + // (stub ver doesn't actually check signatures) + expect(() => { + multisig.mint( + 100n, + USER_RECIPIENT, + [PK1, PK2], + [DUMMY_SIG, DUMMY_SIG], + ); + }).not.toThrow(); + expect(multisig.getNonce()).toEqual(2n); + }); + }); + + describe('burn', () => { + it('should burn with valid coin and signers 0 and 1', () => { + const coin = makeQualifiedCoin(multisig.getTokenType(), 100n); + expect(() => { + multisig.burn(coin, 100n, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]); + }).not.toThrow(); + }); + + it('should burn with signers 0 and 2', () => { + const coin = makeQualifiedCoin(multisig.getTokenType(), 100n); + expect(() => { + multisig.burn(coin, 100n, [PK1, PK3], [DUMMY_SIG, DUMMY_SIG]); + }).not.toThrow(); + }); + + it('should burn with signers 1 and 2', () => { + const coin = makeQualifiedCoin(multisig.getTokenType(), 100n); + expect(() => { + multisig.burn(coin, 100n, [PK2, PK3], [DUMMY_SIG, DUMMY_SIG]); + }).not.toThrow(); + }); + + it('should burn partial amount', () => { + const coin = makeQualifiedCoin(multisig.getTokenType(), 100n); + expect(() => { + multisig.burn(coin, 50n, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]); + }).not.toThrow(); + }); + + it('should handle zero burn amount', () => { + const coin = makeQualifiedCoin(multisig.getTokenType(), 100n); + expect(() => { + multisig.burn(coin, 0n, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]); + }).not.toThrow(); + }); + + it('should reject duplicate signer', () => { + const coin = makeQualifiedCoin(multisig.getTokenType(), 100n); + expect(() => { + multisig.burn(coin, 100n, [PK1, PK1], [DUMMY_SIG, DUMMY_SIG]); + }).toThrow('EcdsaSignerManager: duplicate signer'); + }); + + it('should reject a non-signer pubkey', () => { + const coin = makeQualifiedCoin(multisig.getTokenType(), 100n); + expect(() => { + multisig.burn( + coin, + 100n, + [PK1, NON_SIGNER_PK], + [DUMMY_SIG, DUMMY_SIG], + ); + }).toThrow('SignerManager: not a signer'); + }); + + it('should reject wrong token color', () => { + const wrongColor = new Uint8Array(32).fill(0xde); + const coin = makeQualifiedCoin(wrongColor, 100n); + expect(() => { + multisig.burn(coin, 100n, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]); + }).toThrow('SignatureMintBurn: coin not from this contract'); + }); + + it('should reject insufficient coin value', () => { + const coin = makeQualifiedCoin(multisig.getTokenType(), 10n); + expect(() => { + multisig.burn(coin, 100n, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]); + }).toThrow('SignatureMintBurn: insufficient coin value'); + }); + + it('should reject when amount exceeds value by 1', () => { + const coin = makeQualifiedCoin(multisig.getTokenType(), 99n); + expect(() => { + multisig.burn(coin, 100n, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]); + }).toThrow('SignatureMintBurn: insufficient coin value'); + }); + + it('should share nonce across mint and burn', () => { + multisig.mint(100n, USER_RECIPIENT, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]); + expect(multisig.getNonce()).toEqual(1n); + + const coin = makeQualifiedCoin(multisig.getTokenType(), 100n); + multisig.burn(coin, 50n, [PK1, PK3], [DUMMY_SIG, DUMMY_SIG]); + expect(multisig.getNonce()).toEqual(2n); + }); + }); + + describe('domain separation', () => { + it('should isolate signers across instances with different salts', () => { + const salt2 = new Uint8Array(32).fill(0xcc); + const c1 = multisig._calculateSignerId(PK1, INSTANCE_SALT); + const c2 = multisig._calculateSignerId(PK1, salt2); + expect(c1).not.toEqual(c2); + }); + + it('should derive different token types with different domains', () => { + const altDomain = new Uint8Array(32); + Buffer.from('alt:token:').copy(altDomain); + + const alt = new SignatureMintBurnSimulator( + INSTANCE_SALT, + INIT_COIN_NONCE, + altDomain, + SIGNER_COMMITMENTS, + ); + + expect(multisig.getTokenType()).not.toEqual(alt.getTokenType()); + }); + }); + + describe('nonce', () => { + it('should start at 0', () => { + expect(multisig.getNonce()).toEqual(0n); + }); + + it('should increment monotonically', () => { + for (let i = 0; i < 5; i++) { + multisig.mint(1n, USER_RECIPIENT, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]); + expect(multisig.getNonce()).toEqual(BigInt(i + 1)); + } + }); + }); + + describe('cross-instance replay', () => { + it('should derive different message hashes for different instances', () => { + const instance2 = new SignatureMintBurnSimulator( + INSTANCE_SALT, + INIT_COIN_NONCE, + TOKEN_DOMAIN, + SIGNER_COMMITMENTS, + ); + + // With stub verification, both succeed independently. + // Once real ECDSA is available, a signature produced for one + // instance's message hash must not validate against the other's. + multisig.mint(100n, USER_RECIPIENT, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]); + instance2.mint( + 100n, + USER_RECIPIENT, + [PK1, PK2], + [DUMMY_SIG, DUMMY_SIG], + ); + + expect(multisig.getNonce()).toEqual(1n); + expect(instance2.getNonce()).toEqual(1n); + }); + }); + }); +}); diff --git a/contracts/src/multisig/test/SignatureTreasury.test.ts b/contracts/src/multisig/test/SignatureTreasury.test.ts new file mode 100644 index 00000000..592ee639 --- /dev/null +++ b/contracts/src/multisig/test/SignatureTreasury.test.ts @@ -0,0 +1,226 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { SignatureTreasurySimulator } from './simulators/SignatureTreasurySimulator.js'; + +const RecipientKind = { ShieldedUser: 0, UnshieldedUser: 1, Contract: 2 }; + +const INSTANCE_SALT = new Uint8Array(32).fill(0xaa); +const COLOR = new Uint8Array(32).fill(1); +const AMOUNT = 1000n; + +const PK1 = new Uint8Array(64).fill(0x11); +const PK2 = new Uint8Array(64).fill(0x22); +const PK3 = new Uint8Array(64).fill(0x33); +const NON_SIGNER_PK = new Uint8Array(64).fill(0x99); + +const COMMITMENT1 = SignatureTreasurySimulator.calculateSignerId( + PK1, + INSTANCE_SALT, +); +const COMMITMENT2 = SignatureTreasurySimulator.calculateSignerId( + PK2, + INSTANCE_SALT, +); +const COMMITMENT3 = SignatureTreasurySimulator.calculateSignerId( + PK3, + INSTANCE_SALT, +); +const SIGNER_COMMITMENTS = [COMMITMENT1, COMMITMENT2, COMMITMENT3]; + +const DUMMY_SIG = new Uint8Array(64).fill(0xff); + +function makeRecipient(address: Uint8Array): { + kind: number; + address: Uint8Array; +} { + return { kind: RecipientKind.ShieldedUser, address }; +} + +function makeCoin( + color: Uint8Array, + value: bigint, + nonce?: Uint8Array, +): { nonce: Uint8Array; color: Uint8Array; value: bigint } { + return { + nonce: nonce ?? new Uint8Array(32).fill(0), + color, + value, + }; +} + +function makeQualifiedCoin( + color: Uint8Array, + value: bigint, + mtIndex: bigint, + nonce?: Uint8Array, +): { + nonce: Uint8Array; + color: Uint8Array; + value: bigint; + mt_index: bigint; +} { + return { + nonce: nonce ?? new Uint8Array(32).fill(0), + color, + value, + mt_index: mtIndex, + }; +} + +let multisig: SignatureTreasurySimulator; + +describe('SignatureTreasury', () => { + describe('constructor', () => { + it('should initialize with 2-of-3 threshold', () => { + multisig = new SignatureTreasurySimulator( + INSTANCE_SALT, + SIGNER_COMMITMENTS, + 2n, + ); + expect(multisig.getSignerCount()).toEqual(3n); + expect(multisig.getThreshold()).toEqual(2n); + }); + + it('should initialize with 1-of-3 threshold', () => { + multisig = new SignatureTreasurySimulator( + INSTANCE_SALT, + SIGNER_COMMITMENTS, + 1n, + ); + expect(multisig.getThreshold()).toEqual(1n); + }); + + it('should fail with zero threshold', () => { + expect(() => { + new SignatureTreasurySimulator(INSTANCE_SALT, SIGNER_COMMITMENTS, 0n); + }).toThrow('SignerManager: threshold must not be zero'); + }); + + it('should fail with threshold exceeding signer count', () => { + expect(() => { + new SignatureTreasurySimulator(INSTANCE_SALT, SIGNER_COMMITMENTS, 4n); + }).toThrow('SignerManager: threshold exceeds signer count'); + }); + + it('should register all signer commitments', () => { + multisig = new SignatureTreasurySimulator( + INSTANCE_SALT, + SIGNER_COMMITMENTS, + 2n, + ); + for (const commitment of SIGNER_COMMITMENTS) { + expect(multisig.isSigner(commitment)).toEqual(true); + } + }); + + it('should reject a non-signer commitment', () => { + multisig = new SignatureTreasurySimulator( + INSTANCE_SALT, + SIGNER_COMMITMENTS, + 2n, + ); + const unknown = SignatureTreasurySimulator.calculateSignerId( + NON_SIGNER_PK, + INSTANCE_SALT, + ); + expect(multisig.isSigner(unknown)).toEqual(false); + }); + + it('should fail with duplicate signer commitments', () => { + expect(() => { + new SignatureTreasurySimulator( + INSTANCE_SALT, + [COMMITMENT1, COMMITMENT1, COMMITMENT2], + 2n, + ); + }).toThrow('SignerManager: signer already active'); + }); + }); + + describe('when initialized', () => { + beforeEach(() => { + multisig = new SignatureTreasurySimulator( + INSTANCE_SALT, + SIGNER_COMMITMENTS, + 2n, + ); + }); + + describe('view', () => { + it('getNonce should start at 0', () => { + expect(multisig.getNonce()).toEqual(0n); + }); + + it('getSignerCount should return 3', () => { + expect(multisig.getSignerCount()).toEqual(3n); + }); + + it('getThreshold should match constructor arg', () => { + expect(multisig.getThreshold()).toEqual(2n); + }); + }); + + describe('_calculateSignerId', () => { + it('should be deterministic for the same key and salt', () => { + expect( + SignatureTreasurySimulator.calculateSignerId(PK1, INSTANCE_SALT), + ).toStrictEqual(COMMITMENT1); + }); + + it('should produce a different commitment for a different salt', () => { + const otherSalt = new Uint8Array(32).fill(0xcc); + expect( + SignatureTreasurySimulator.calculateSignerId(PK1, otherSalt), + ).not.toStrictEqual(COMMITMENT1); + }); + }); + + describe('deposit', () => { + it('should accept deposits without reverting', () => { + expect(() => { + multisig.deposit(makeCoin(COLOR, AMOUNT)); + }).not.toThrow(); + }); + }); + + describe('execute', () => { + it('should reject duplicate signer', () => { + const to = makeRecipient(new Uint8Array(32).fill(7)); + const coin = makeQualifiedCoin(COLOR, AMOUNT, 0n); + expect(() => { + multisig.execute(to, 100n, coin, [PK1, PK1], [DUMMY_SIG, DUMMY_SIG]); + }).toThrow('EcdsaSignerManager: duplicate signer'); + }); + + it('should reject a non-signer pubkey', () => { + const to = makeRecipient(new Uint8Array(32).fill(7)); + const coin = makeQualifiedCoin(COLOR, AMOUNT, 0n); + expect(() => { + multisig.execute( + to, + 100n, + coin, + [PK1, NON_SIGNER_PK], + [DUMMY_SIG, DUMMY_SIG], + ); + }).toThrow('SignerManager: not a signer'); + }); + }); + + describe('execute — threshold above the 2-signature surface', () => { + it('should reject when threshold exceeds verifiable signatures', () => { + // A 3-of-3 instance can never satisfy `execute`, which verifies at most + // two signatures. Two valid distinct signers still fall short. + const strict = new SignatureTreasurySimulator( + INSTANCE_SALT, + SIGNER_COMMITMENTS, + 3n, + ); + const to = makeRecipient(new Uint8Array(32).fill(7)); + const coin = makeQualifiedCoin(COLOR, AMOUNT, 0n); + expect(() => { + strict.execute(to, 100n, coin, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]); + }).toThrow('SignerManager: threshold not met'); + }); + }); + }); +}); diff --git a/contracts/src/multisig/test/mocks/MockSignatureVerifier.compact b/contracts/src/multisig/test/mocks/MockEcdsaSignerManager.compact similarity index 100% rename from contracts/src/multisig/test/mocks/MockSignatureVerifier.compact rename to contracts/src/multisig/test/mocks/MockEcdsaSignerManager.compact diff --git a/contracts/src/multisig/test/mocks/MockProposalTreasury.compact b/contracts/src/multisig/test/mocks/MockProposalTreasury.compact new file mode 100644 index 00000000..d10c6a29 --- /dev/null +++ b/contracts/src/multisig/test/mocks/MockProposalTreasury.compact @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: MIT + +// WARNING: FOR TESTING PURPOSES ONLY. +// This contract exposes the `ProposalTreasury` example module as a deployable +// contract so the simulator can exercise it. DO NOT deploy or use this contract +// in any production application. + +pragma language_version >= 0.23.0; + +import CompactStandardLibrary; + +import "../../examples/ProposalTreasury" prefix Proposal_; +import "../../proposal/ProposalManager" prefix ProposalManager_; + +// ─── Constructor ──────────────────────────────────────────────── + +constructor( + signers: Vector<3, Either>, + thresh: Uint<8> +) { + Proposal_initialize<3>(signers, thresh); +} + +// ─── Circuits (delegated to ProposalTreasury) ─────────────────── + +export circuit deposit(coin: ShieldedCoinInfo): [] { + Proposal_deposit(coin); +} + +export circuit createShieldedProposal( + to: ProposalManager_Recipient, + color: Bytes<32>, + amount: Uint<128> +): Uint<64> { + return Proposal_createShieldedProposal(to, color, amount); +} + +export circuit approveProposal(id: Uint<64>): [] { + Proposal_approveProposal(id); +} + +export circuit revokeApproval(id: Uint<64>): [] { + Proposal_revokeApproval(id); +} + +export circuit executeShieldedProposal(id: Uint<64>): ShieldedSendResult { + return Proposal_executeShieldedProposal(id); +} + +// ─── View ─────────────────────────────────────────────────────── + +export circuit isProposalApprovedBySigner( + id: Uint<64>, + signer: Either +): Boolean { + return Proposal_isProposalApprovedBySigner(id, signer); +} + +export circuit getApprovalCount(id: Uint<64>): Uint<8> { + return Proposal_getApprovalCount(id); +} + +export circuit getProposal(id: Uint<64>): ProposalManager_Proposal { + return Proposal_getProposal(id); +} + +export circuit getProposalRecipient(id: Uint<64>): ProposalManager_Recipient { + return Proposal_getProposalRecipient(id); +} + +export circuit getProposalAmount(id: Uint<64>): Uint<128> { + return Proposal_getProposalAmount(id); +} + +export circuit getProposalColor(id: Uint<64>): Bytes<32> { + return Proposal_getProposalColor(id); +} + +export circuit getProposalStatus(id: Uint<64>): ProposalManager_ProposalStatus { + return Proposal_getProposalStatus(id); +} + +export circuit getTokenBalance(color: Bytes<32>): Uint<128> { + return Proposal_getTokenBalance(color); +} + +export circuit getReceivedTotal(color: Bytes<32>): Uint<128> { + return Proposal_getReceivedTotal(color); +} + +export circuit getSentTotal(color: Bytes<32>): Uint<128> { + return Proposal_getSentTotal(color); +} + +export circuit getReceivedMinusSent(color: Bytes<32>): Uint<128> { + return Proposal_getReceivedMinusSent(color); +} + +export circuit getSignerCount(): Uint<8> { + return Proposal_getSignerCount(); +} + +export circuit getThreshold(): Uint<8> { + return Proposal_getThreshold(); +} + +export circuit isSigner(account: Either): Boolean { + return Proposal_isSigner(account); +} diff --git a/contracts/src/multisig/test/mocks/MockSignatureMintBurn.compact b/contracts/src/multisig/test/mocks/MockSignatureMintBurn.compact new file mode 100644 index 00000000..41d44b85 --- /dev/null +++ b/contracts/src/multisig/test/mocks/MockSignatureMintBurn.compact @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: MIT + +// WARNING: FOR TESTING PURPOSES ONLY. +// This contract exposes the `SignatureMintBurn` example module as a deployable +// contract so the simulator can exercise it. DO NOT deploy or use this contract +// in any production application. + +pragma language_version >= 0.23.0; + +import CompactStandardLibrary; + +import "../../examples/SignatureMintBurn" prefix Token_; +// For testing +export { ZswapCoinPublicKey }; + +// ─── Constructor ──────────────────────────────────────────────── + +constructor( + instanceSalt: Bytes<32>, + initCoinNonce: Bytes<32>, + tokenDomain: Bytes<32>, + signerCommitments: Vector<3, Bytes<32>>, +) { + Token_initialize<3>(instanceSalt, signerCommitments, 2); + Token_initializeToken(tokenDomain, initCoinNonce); +} + +// ─── Circuits (delegated to SignatureMintBurn) ────────────────── + +export circuit mint( + amount: Uint<64>, + recipient: Either, + pubkeys: Vector<2, Bytes<64>>, + signatures: Vector<2, Bytes<64>> +): [] { + Token_mint(amount, recipient, pubkeys, signatures); +} + +export circuit burn( + coin: QualifiedShieldedCoinInfo, + amount: Uint<64>, + pubkeys: Vector<2, Bytes<64>>, + signatures: Vector<2, Bytes<64>> +): [] { + Token_burn(coin, amount, pubkeys, signatures); +} + +export pure circuit _calculateSignerId(pk: Bytes<64>, salt: Bytes<32>): Bytes<32> { + return Token__calculateSignerId(pk, salt); +} + +// ─── View ─────────────────────────────────────────────────────── + +export circuit getNonce(): Uint<64> { + return Token_getNonce(); +} + +export circuit getTokenDomain(): Bytes<32> { + return Token_getTokenDomain(); +} + +export circuit getTokenType(): Bytes<32> { + return Token_getTokenType(); +} + +export circuit getSignerCount(): Uint<8> { + return Token_getSignerCount(); +} + +export circuit getThreshold(): Uint<8> { + return Token_getThreshold(); +} + +export circuit isSigner(commitment: Bytes<32>): Boolean { + return Token_isSigner(commitment); +} diff --git a/contracts/src/multisig/test/mocks/MockSignatureTreasury.compact b/contracts/src/multisig/test/mocks/MockSignatureTreasury.compact new file mode 100644 index 00000000..8cef871d --- /dev/null +++ b/contracts/src/multisig/test/mocks/MockSignatureTreasury.compact @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: MIT + +// WARNING: FOR TESTING PURPOSES ONLY. +// This contract exposes the `SignatureTreasury` example module as a deployable +// contract so the simulator can exercise it. DO NOT deploy or use this contract +// in any production application. + +pragma language_version >= 0.23.0; + +import CompactStandardLibrary; + +import "../../examples/SignatureTreasury" prefix Treasury_; +import "../../proposal/ProposalManager" prefix Proposal_; + +// ─── Constructor ──────────────────────────────────────────────── + +constructor( + instanceSalt: Bytes<32>, + signerCommitments: Vector<3, Bytes<32>>, + thresh: Uint<8>, +) { + Treasury_initialize<3>(instanceSalt, signerCommitments, thresh); +} + +// ─── Circuits (delegated to SignatureTreasury) ────────────────── + +export circuit deposit(coin: ShieldedCoinInfo): [] { + Treasury_deposit(coin); +} + +export circuit execute( + to: Proposal_Recipient, + amount: Uint<128>, + coin: QualifiedShieldedCoinInfo, + pubkeys: Vector<2, Bytes<64>>, + signatures: Vector<2, Bytes<64>> +): ShieldedSendResult { + return Treasury_execute(to, amount, coin, pubkeys, signatures); +} + +export pure circuit _calculateSignerId(pk: Bytes<64>, salt: Bytes<32>): Bytes<32> { + return Treasury__calculateSignerId(pk, salt); +} + +// ─── View ─────────────────────────────────────────────────────── + +export circuit getNonce(): Uint<64> { + return Treasury_getNonce(); +} + +export circuit getSignerCount(): Uint<8> { + return Treasury_getSignerCount(); +} + +export circuit getThreshold(): Uint<8> { + return Treasury_getThreshold(); +} + +export circuit isSigner(commitment: Bytes<32>): Boolean { + return Treasury_isSigner(commitment); +} diff --git a/contracts/src/multisig/test/simulators/EcdsaSignerManagerSimulator.ts b/contracts/src/multisig/test/simulators/EcdsaSignerManagerSimulator.ts new file mode 100644 index 00000000..54534836 --- /dev/null +++ b/contracts/src/multisig/test/simulators/EcdsaSignerManagerSimulator.ts @@ -0,0 +1,83 @@ +import { + createSimulator, + type SimulatorOptions, +} from '@openzeppelin/compact-simulator'; +import { + ledger, + Contract as MockEcdsaSignerManager, + pureCircuits, +} from '../../../../artifacts/MockEcdsaSignerManager/contract/index.js'; +import { EmptyPrivateState, emptyWitnesses } from '../EmptyWitnesses.js'; + +/** + * Type constructor args + */ +type EcdsaSignerManagerArgs = readonly [ + salt: Uint8Array, + signers: Uint8Array[], + thresh: bigint, +]; + +const EcdsaSignerManagerSimulatorBase = createSimulator< + EmptyPrivateState, + ReturnType, + ReturnType, + MockEcdsaSignerManager, + EcdsaSignerManagerArgs +>({ + contractFactory: (witnesses) => + new MockEcdsaSignerManager(witnesses), + defaultPrivateState: () => EmptyPrivateState, + contractArgs: (salt, signers, thresh) => [salt, signers, thresh], + ledgerExtractor: (state) => ledger(state), + witnessesFactory: () => emptyWitnesses(), + artifactName: 'MockEcdsaSignerManager', +}); + +/** + * EcdsaSignerManager Simulator + */ +export class EcdsaSignerManagerSimulator extends EcdsaSignerManagerSimulatorBase { + static async create( + salt: Uint8Array, + signers: Uint8Array[], + thresh: bigint, + options: SimulatorOptions< + EmptyPrivateState, + ReturnType + > = {}, + ): Promise { + // biome-ignore lint/complexity/noThisInStatic: super.create must keep the subclass `this` + return super.create( + [salt, signers, thresh], + options, + ) as Promise; + } + + /** + * Pure commitment derivation — callable without a deployed instance. + */ + public static calculateSignerId(pk: Uint8Array, salt: Uint8Array): Uint8Array { + return pureCircuits._calculateSignerId(pk, salt); + } + + public verify( + msgHash: Uint8Array, + pubkeys: [Uint8Array, Uint8Array], + signatures: [Uint8Array, Uint8Array], + ) { + return this.circuits.impure.verify(msgHash, pubkeys, signatures); + } + + public getSignerCount(): Promise { + return this.circuits.impure.getSignerCount(); + } + + public getThreshold(): Promise { + return this.circuits.impure.getThreshold(); + } + + public isSigner(account: Uint8Array): Promise { + return this.circuits.impure.isSigner(account); + } +} diff --git a/contracts/src/multisig/test/simulators/ShieldedMultiSigSimulator.ts b/contracts/src/multisig/test/simulators/ProposalTreasurySimulator.ts similarity index 51% rename from contracts/src/multisig/test/simulators/ShieldedMultiSigSimulator.ts rename to contracts/src/multisig/test/simulators/ProposalTreasurySimulator.ts index a58035f4..1b45041e 100644 --- a/contracts/src/multisig/test/simulators/ShieldedMultiSigSimulator.ts +++ b/contracts/src/multisig/test/simulators/ProposalTreasurySimulator.ts @@ -1,16 +1,13 @@ import { + type BaseSimulatorOptions, createSimulator, - type SimulatorOptions, } from '@openzeppelin/compact-simulator'; import { type Ledger, ledger, - Contract as ShieldedMultiSig, -} from '../../../../artifacts/ShieldedMultiSig/contract/index.js'; -import { - ShieldedMultiSigPrivateState, - ShieldedMultiSigWitnesses, -} from '../witnesses/ShieldedMultiSigWitnesses.js'; + Contract as MockProposalTreasury, +} from '../../../../artifacts/MockProposalTreasury/contract/index.js'; +import { EmptyPrivateState, emptyWitnesses } from '../EmptyWitnesses.js'; type EitherPKAddress = { is_left: boolean; @@ -30,45 +27,40 @@ type Proposal = { status: number; }; -type ShieldedMultiSigArgs = readonly [ +type ProposalTreasuryArgs = readonly [ signers: EitherPKAddress[], thresh: bigint, ]; -const ShieldedMultiSigSimulatorBase = createSimulator< - ShieldedMultiSigPrivateState, +const ProposalTreasurySimulatorBase = createSimulator< + EmptyPrivateState, ReturnType, - ReturnType, - ShieldedMultiSig, - ShieldedMultiSigArgs + ReturnType, + MockProposalTreasury, + ProposalTreasuryArgs >({ contractFactory: (witnesses) => - new ShieldedMultiSig(witnesses), - defaultPrivateState: () => ShieldedMultiSigPrivateState, + new MockProposalTreasury(witnesses), + defaultPrivateState: () => EmptyPrivateState, contractArgs: (signers, thresh) => [signers, thresh], ledgerExtractor: (state) => ledger(state), - witnessesFactory: () => ShieldedMultiSigWitnesses(), - artifactName: 'ShieldedMultiSig', + witnessesFactory: () => emptyWitnesses(), }); -export class ShieldedMultiSigSimulator extends ShieldedMultiSigSimulatorBase { - static async create( +export class ProposalTreasurySimulator extends ProposalTreasurySimulatorBase { + constructor( signers: EitherPKAddress[], thresh: bigint, - options: SimulatorOptions< - ShieldedMultiSigPrivateState, - ReturnType + options: BaseSimulatorOptions< + EmptyPrivateState, + ReturnType > = {}, - ): Promise { - // biome-ignore lint/complexity/noThisInStatic: super.create must keep the subclass `this` - return super.create( - [signers, thresh], - options, - ) as Promise; + ) { + super([signers, thresh], options); } // Deposit - public deposit(coin: ShieldedCoinInfo): Promise<[]> { + public deposit(coin: ShieldedCoinInfo) { return this.circuits.impure.deposit(coin); } @@ -77,19 +69,19 @@ export class ShieldedMultiSigSimulator extends ShieldedMultiSigSimulatorBase { to: Recipient, color: Uint8Array, amount: bigint, - ): Promise { + ): bigint { return this.circuits.impure.createShieldedProposal(to, color, amount); } - public approveProposal(id: bigint): Promise<[]> { + public approveProposal(id: bigint) { return this.circuits.impure.approveProposal(id); } - public revokeApproval(id: bigint): Promise<[]> { + public revokeApproval(id: bigint) { return this.circuits.impure.revokeApproval(id); } - public executeShieldedProposal(id: bigint): Promise { + public executeShieldedProposal(id: bigint): ShieldedSendResult { return this.circuits.impure.executeShieldedProposal(id); } @@ -97,67 +89,67 @@ export class ShieldedMultiSigSimulator extends ShieldedMultiSigSimulatorBase { public isProposalApprovedBySigner( id: bigint, signer: EitherPKAddress, - ): Promise { + ): boolean { return this.circuits.impure.isProposalApprovedBySigner(id, signer); } - public getApprovalCount(id: bigint): Promise { + public getApprovalCount(id: bigint): bigint { return this.circuits.impure.getApprovalCount(id); } // View - Proposals - public getProposal(id: bigint): Promise { + public getProposal(id: bigint): Proposal { return this.circuits.impure.getProposal(id); } - public getProposalRecipient(id: bigint): Promise { + public getProposalRecipient(id: bigint): Recipient { return this.circuits.impure.getProposalRecipient(id); } - public getProposalAmount(id: bigint): Promise { + public getProposalAmount(id: bigint): bigint { return this.circuits.impure.getProposalAmount(id); } - public getProposalColor(id: bigint): Promise { + public getProposalColor(id: bigint): Uint8Array { return this.circuits.impure.getProposalColor(id); } - public getProposalStatus(id: bigint): Promise { + public getProposalStatus(id: bigint): number { return this.circuits.impure.getProposalStatus(id); } // View - Treasury - public getTokenBalance(color: Uint8Array): Promise { + public getTokenBalance(color: Uint8Array): bigint { return this.circuits.impure.getTokenBalance(color); } - public getReceivedTotal(color: Uint8Array): Promise { + public getReceivedTotal(color: Uint8Array): bigint { return this.circuits.impure.getReceivedTotal(color); } - public getSentTotal(color: Uint8Array): Promise { + public getSentTotal(color: Uint8Array): bigint { return this.circuits.impure.getSentTotal(color); } - public getReceivedMinusSent(color: Uint8Array): Promise { + public getReceivedMinusSent(color: Uint8Array): bigint { return this.circuits.impure.getReceivedMinusSent(color); } // View - Signers - public getSignerCount(): Promise { + public getSignerCount(): bigint { return this.circuits.impure.getSignerCount(); } - public getThreshold(): Promise { + public getThreshold(): bigint { return this.circuits.impure.getThreshold(); } - public isSigner(account: EitherPKAddress): Promise { + public isSigner(account: EitherPKAddress): boolean { return this.circuits.impure.isSigner(account); } // Ledger access - public getLedger(): Promise { + public getLedger(): Ledger { return this.getPublicState(); } } diff --git a/contracts/src/multisig/test/simulators/ShieldedMultiSigV2Simulator.ts b/contracts/src/multisig/test/simulators/ShieldedMultiSigV2Simulator.ts deleted file mode 100644 index c03078bd..00000000 --- a/contracts/src/multisig/test/simulators/ShieldedMultiSigV2Simulator.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { - createSimulator, - type SimulatorOptions, -} from '@openzeppelin/compact-simulator'; -import { - type Ledger, - ledger, - pureCircuits, - Contract as ShieldedMultiSigV2, -} from '../../../../artifacts/ShieldedMultiSigV2/contract/index.js'; -import { - ShieldedMultiSigV2PrivateState, - ShieldedMultiSigV2Witnesses, -} from '../witnesses/ShieldedMultiSigV2Witnesses.js'; - -type Recipient = { kind: number; address: Uint8Array }; -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 ShieldedMultiSigV2Args = readonly [ - instanceSalt: Uint8Array, - signerCommitments: Uint8Array[], - thresh: bigint, -]; - -const ShieldedMultiSigV2SimulatorBase = createSimulator< - ShieldedMultiSigV2PrivateState, - ReturnType, - ReturnType, - ShieldedMultiSigV2, - ShieldedMultiSigV2Args ->({ - contractFactory: (witnesses) => - new ShieldedMultiSigV2(witnesses), - defaultPrivateState: () => ShieldedMultiSigV2PrivateState, - contractArgs: (instanceSalt, signerCommitments, thresh) => [ - instanceSalt, - signerCommitments, - thresh, - ], - ledgerExtractor: (state) => ledger(state), - witnessesFactory: () => ShieldedMultiSigV2Witnesses(), - artifactName: 'ShieldedMultiSigV2', -}); - -export class ShieldedMultiSigV2Simulator extends ShieldedMultiSigV2SimulatorBase { - static async create( - instanceSalt: Uint8Array, - signerCommitments: Uint8Array[], - thresh: bigint, - options: SimulatorOptions< - ShieldedMultiSigV2PrivateState, - ReturnType - > = {}, - ): Promise { - // biome-ignore lint/complexity/noThisInStatic: super.create must keep the subclass `this` - return super.create( - [instanceSalt, signerCommitments, thresh], - options, - ) as Promise; - } - - public static calculateSignerId( - pk: Uint8Array, - salt: Uint8Array, - ): Uint8Array { - return pureCircuits._calculateSignerId(pk, salt); - } - - public deposit(coin: ShieldedCoinInfo): Promise<[]> { - return this.circuits.impure.deposit(coin); - } - - public execute( - to: Recipient, - amount: bigint, - coin: QualifiedShieldedCoinInfo, - pubkeys: Uint8Array[], - signatures: Uint8Array[], - ): Promise { - return this.circuits.impure.execute(to, amount, coin, pubkeys, signatures); - } - - public getNonce(): Promise { - return this.circuits.impure.getNonce(); - } - - public getSignerCount(): Promise { - return this.circuits.impure.getSignerCount(); - } - - public getThreshold(): Promise { - return this.circuits.impure.getThreshold(); - } - - public isSigner(commitment: Uint8Array): Promise { - return this.circuits.impure.isSigner(commitment); - } - - public getLedger(): Promise { - return this.getPublicState(); - } -} diff --git a/contracts/src/multisig/test/simulators/ShieldedMultiSigV3Simulator.ts b/contracts/src/multisig/test/simulators/SignatureMintBurnSimulator.ts similarity index 52% rename from contracts/src/multisig/test/simulators/ShieldedMultiSigV3Simulator.ts rename to contracts/src/multisig/test/simulators/SignatureMintBurnSimulator.ts index afba649f..24bd06b1 100644 --- a/contracts/src/multisig/test/simulators/ShieldedMultiSigV3Simulator.ts +++ b/contracts/src/multisig/test/simulators/SignatureMintBurnSimulator.ts @@ -1,70 +1,60 @@ import { + type BaseSimulatorOptions, createSimulator, - type SimulatorOptions, } from '@openzeppelin/compact-simulator'; import { - type ContractAddress, - type Either, ledger, pureCircuits, - Contract as ShieldedMultiSigV3Contract, + Contract as MockSignatureMintBurn, type ZswapCoinPublicKey, -} from '../../../../artifacts/ShieldedMultiSigV3/contract/index.js'; -import { - ShieldedMultiSigV3PrivateState, - ShieldedMultiSigV3Witnesses, -} from '../witnesses/ShieldedMultiSigV3Witnesses.js'; +} from '../../../../artifacts/MockSignatureMintBurn/contract/index.js'; +import { EmptyPrivateState, emptyWitnesses } from '../EmptyWitnesses.js'; -type ShieldedMultiSigV3Args = readonly [ +type SignatureMintBurnArgs = readonly [ instanceSalt: Uint8Array, initCoinNonce: Uint8Array, tokenDomain: Uint8Array, signerCommitments: Uint8Array[], ]; -const ShieldedMultiSigV3SimulatorBase = createSimulator< - ShieldedMultiSigV3PrivateState, +const SignatureMintBurnSimulatorBase = createSimulator< + EmptyPrivateState, ReturnType, - ReturnType, - ShieldedMultiSigV3Contract, - ShieldedMultiSigV3Args + ReturnType, + MockSignatureMintBurn, + SignatureMintBurnArgs >({ contractFactory: (witnesses) => - new ShieldedMultiSigV3Contract(witnesses), - defaultPrivateState: () => ShieldedMultiSigV3PrivateState, - contractArgs: ( + new MockSignatureMintBurn(witnesses), + defaultPrivateState: () => EmptyPrivateState, + contractArgs: (instanceSalt, initCoinNonce, tokenDomain, signerCommitments) => [ instanceSalt, initCoinNonce, tokenDomain, signerCommitments, - ) => [instanceSalt, initCoinNonce, tokenDomain, signerCommitments], + ], ledgerExtractor: (state) => ledger(state), - witnessesFactory: () => ShieldedMultiSigV3Witnesses(), - artifactName: 'ShieldedMultiSigV3', + witnessesFactory: () => emptyWitnesses(), }); -export class ShieldedMultiSigV3Simulator extends ShieldedMultiSigV3SimulatorBase { - static async create( +export class SignatureMintBurnSimulator extends SignatureMintBurnSimulatorBase { + constructor( instanceSalt: Uint8Array, initCoinNonce: Uint8Array, tokenDomain: Uint8Array, signerCommitments: Uint8Array[], - options: SimulatorOptions< - ShieldedMultiSigV3PrivateState, - ReturnType + options: BaseSimulatorOptions< + EmptyPrivateState, + ReturnType > = {}, - ): Promise { - // biome-ignore lint/complexity/noThisInStatic: super.create must keep the subclass `this` - return super.create( + ) { + super( [instanceSalt, initCoinNonce, tokenDomain, signerCommitments], options, - ) as Promise; + ); } - public _calculateSignerId( - pk: Uint8Array, - salt: Uint8Array, - ): Promise { + public _calculateSignerId(pk: Uint8Array, salt: Uint8Array): Uint8Array { return this.circuits.pure._calculateSignerId(pk, salt); } @@ -73,7 +63,7 @@ export class ShieldedMultiSigV3Simulator extends ShieldedMultiSigV3SimulatorBase recipient: Either, pubkeys: Uint8Array[], signatures: Uint8Array[], - ): Promise<[]> { + ) { return this.circuits.impure.mint(amount, recipient, pubkeys, signatures); } @@ -87,31 +77,31 @@ export class ShieldedMultiSigV3Simulator extends ShieldedMultiSigV3SimulatorBase amount: bigint, pubkeys: Uint8Array[], signatures: Uint8Array[], - ): Promise<[]> { + ) { return this.circuits.impure.burn(coin, amount, pubkeys, signatures); } - public getNonce(): Promise { + public getNonce(): bigint { return this.circuits.impure.getNonce(); } - public getTokenDomain(): Promise { + public getTokenDomain(): Uint8Array { return this.circuits.impure.getTokenDomain(); } - public getTokenType(): Promise { + public getTokenType(): Uint8Array { return this.circuits.impure.getTokenType(); } - public getSignerCount(): Promise { + public getSignerCount(): bigint { return this.circuits.impure.getSignerCount(); } - public getThreshold(): Promise { + public getThreshold(): bigint { return this.circuits.impure.getThreshold(); } - public isSigner(commitment: Uint8Array): Promise { + public isSigner(commitment: Uint8Array): boolean { return this.circuits.impure.isSigner(commitment); } } diff --git a/contracts/src/multisig/test/simulators/SignatureTreasurySimulator.ts b/contracts/src/multisig/test/simulators/SignatureTreasurySimulator.ts new file mode 100644 index 00000000..21786212 --- /dev/null +++ b/contracts/src/multisig/test/simulators/SignatureTreasurySimulator.ts @@ -0,0 +1,99 @@ +import { + type BaseSimulatorOptions, + createSimulator, +} from '@openzeppelin/compact-simulator'; +import { + ledger, + pureCircuits, + Contract as MockSignatureTreasury, +} from '../../../../artifacts/MockSignatureTreasury/contract/index.js'; +import { EmptyPrivateState, emptyWitnesses } from '../EmptyWitnesses.js'; + +type Recipient = { kind: number; address: Uint8Array }; +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 SignatureTreasuryArgs = readonly [ + instanceSalt: Uint8Array, + signerCommitments: Uint8Array[], + thresh: bigint, +]; + +const SignatureTreasurySimulatorBase = createSimulator< + EmptyPrivateState, + ReturnType, + ReturnType, + MockSignatureTreasury, + SignatureTreasuryArgs +>({ + contractFactory: (witnesses) => + new MockSignatureTreasury(witnesses), + defaultPrivateState: () => EmptyPrivateState, + contractArgs: (instanceSalt, signerCommitments, thresh) => [ + instanceSalt, + signerCommitments, + thresh, + ], + ledgerExtractor: (state) => ledger(state), + witnessesFactory: () => emptyWitnesses(), +}); + +export class SignatureTreasurySimulator extends SignatureTreasurySimulatorBase { + constructor( + instanceSalt: Uint8Array, + signerCommitments: Uint8Array[], + thresh: bigint, + options: BaseSimulatorOptions< + EmptyPrivateState, + ReturnType + > = {}, + ) { + super([instanceSalt, signerCommitments, thresh], options); + } + + public static calculateSignerId( + pk: Uint8Array, + salt: Uint8Array, + ): Uint8Array { + return pureCircuits._calculateSignerId(pk, salt); + } + + public deposit(coin: ShieldedCoinInfo) { + return this.circuits.impure.deposit(coin); + } + + public execute( + to: Recipient, + amount: bigint, + coin: QualifiedShieldedCoinInfo, + pubkeys: Uint8Array[], + signatures: Uint8Array[], + ): ShieldedSendResult { + return this.circuits.impure.execute(to, amount, coin, pubkeys, signatures); + } + + public getNonce(): bigint { + return this.circuits.impure.getNonce(); + } + + public getSignerCount(): bigint { + return this.circuits.impure.getSignerCount(); + } + + public getThreshold(): bigint { + return this.circuits.impure.getThreshold(); + } + + public isSigner(commitment: Uint8Array): boolean { + return this.circuits.impure.isSigner(commitment); + } +} From 077c5897022f33d20cc223604ee29b1a26c382d9 Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Thu, 25 Jun 2026 12:12:49 +0200 Subject: [PATCH 10/13] test(multisig): realign forwarder module tests with renamed asserts The forwarder modules were renamed (ForwarderShielded -> NativeShieldedForwarder, etc.) and their assert messages updated, but the kept module tests still expected the old prefixes. Update the toThrow expectations to the current "Native*Forwarder:" / "PrivateNativeShieldedForwarder:" messages. --- contracts/src/multisig/test/Forwarder.test.ts | 8 ++++---- .../src/multisig/test/ForwarderPrivate.test.ts | 14 +++++++------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/contracts/src/multisig/test/Forwarder.test.ts b/contracts/src/multisig/test/Forwarder.test.ts index e9fa63eb..4f1214a1 100644 --- a/contracts/src/multisig/test/Forwarder.test.ts +++ b/contracts/src/multisig/test/Forwarder.test.ts @@ -35,7 +35,7 @@ describe('ForwarderShielded module', () => { it('should fail initialization with a zero parent', async () => { await expect( MockForwarderShieldedSimulator.create(SHIELDED_ZERO, true), - ).rejects.toThrow('ForwarderShielded: zero parent'); + ).rejects.toThrow('NativeShieldedForwarder: zero parent'); }); it('should store the coin-public-key parent in the left arm', async () => { @@ -56,7 +56,7 @@ describe('ForwarderShielded module', () => { false, ); await expect(mock.deposit(makeCoin(COLOR, AMOUNT))).rejects.toThrow( - 'ForwarderShielded: contract not initialized', + 'NativeShieldedForwarder: contract not initialized', ); }); }); @@ -81,7 +81,7 @@ describe('ForwarderUnshielded module', () => { it('should fail initialization with a zero parent', async () => { await expect( MockForwarderUnshieldedSimulator.create(UNSHIELDED_ZERO, true), - ).rejects.toThrow('ForwarderUnshielded: zero parent'); + ).rejects.toThrow('NativeUnshieldedForwarder: zero parent'); }); it('should store the user-address parent in the right arm', async () => { @@ -102,7 +102,7 @@ describe('ForwarderUnshielded module', () => { false, ); await expect(mock.deposit(COLOR, AMOUNT)).rejects.toThrow( - 'ForwarderUnshielded: contract not initialized', + 'NativeUnshieldedForwarder: contract not initialized', ); }); }); diff --git a/contracts/src/multisig/test/ForwarderPrivate.test.ts b/contracts/src/multisig/test/ForwarderPrivate.test.ts index 92ffc2e6..7712e321 100644 --- a/contracts/src/multisig/test/ForwarderPrivate.test.ts +++ b/contracts/src/multisig/test/ForwarderPrivate.test.ts @@ -75,7 +75,7 @@ describe('ForwarderPrivate module', () => { it('should fail initialization with zero commitment', async () => { await expect( MockForwarderPrivateSimulator.create(ZERO, true), - ).rejects.toThrow('ForwarderPrivate: zero commitment'); + ).rejects.toThrow('PrivateNativeShieldedForwarder: zero commitment'); }); it('should store the parent commitment after initialization', async () => { @@ -99,7 +99,7 @@ describe('ForwarderPrivate module', () => { it('should fail deposit when not initialized', async () => { await expect(mock.deposit(makeCoin(COLOR, AMOUNT))).rejects.toThrow( - 'ForwarderPrivate: contract not initialized', + 'PrivateNativeShieldedForwarder: contract not initialized', ); }); @@ -111,7 +111,7 @@ describe('ForwarderPrivate module', () => { OP_SECRET, AMOUNT, ), - ).rejects.toThrow('ForwarderPrivate: contract not initialized'); + ).rejects.toThrow('PrivateNativeShieldedForwarder: contract not initialized'); }); }); @@ -167,7 +167,7 @@ describe('ForwarderPrivate module', () => { OP_SECRET, AMOUNT, ), - ).rejects.toThrow('ForwarderPrivate: invalid parent'); + ).rejects.toThrow('PrivateNativeShieldedForwarder: invalid parent'); }); it('should fail drain with wrong opSecret', async () => { @@ -178,7 +178,7 @@ describe('ForwarderPrivate module', () => { WRONG_OP_SECRET, AMOUNT, ), - ).rejects.toThrow('ForwarderPrivate: invalid parent'); + ).rejects.toThrow('PrivateNativeShieldedForwarder: invalid parent'); }); it('should fail drain with both wrong', async () => { @@ -189,7 +189,7 @@ describe('ForwarderPrivate module', () => { WRONG_OP_SECRET, AMOUNT, ), - ).rejects.toThrow('ForwarderPrivate: invalid parent'); + ).rejects.toThrow('PrivateNativeShieldedForwarder: invalid parent'); }); it('should fail drain with value > coin.value', async () => { @@ -248,7 +248,7 @@ describe('ForwarderPrivate module', () => { OP_SECRET, AMOUNT, ), - ).rejects.toThrow('ForwarderPrivate: zero parent'); + ).rejects.toThrow('PrivateNativeShieldedForwarder: zero parent'); }); }); From 8cb57c356bcd4903e3a940a58f221ebc4fe9b7b3 Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Thu, 25 Jun 2026 12:17:17 +0200 Subject: [PATCH 11/13] refactor(multisig): defer presets to a follow-up branch The deployable preset contracts (NativeShieldedMintBurn, NativeShieldedProposal, NativeShieldedStatelessTreasury, NativeShieldedTokenVault) need rework and move to a separate branch. Remove them here and drop the now-dangling `presets/...` references from the SignatureTreasury and SignatureMintBurn module docs, describing the thin-wrapper composition pattern in prose instead. --- .../examples/SignatureMintBurn.compact | 3 +- .../examples/SignatureTreasury.compact | 6 +- .../presets/NativeShieldedMintBurn.compact | 99 ------------- .../presets/NativeShieldedProposal.compact | 123 ---------------- .../NativeShieldedStatelessTreasury.compact | 88 ----------- .../presets/NativeShieldedTokenVault.compact | 137 ------------------ 6 files changed, 4 insertions(+), 452 deletions(-) delete mode 100644 contracts/src/multisig/presets/NativeShieldedMintBurn.compact delete mode 100644 contracts/src/multisig/presets/NativeShieldedProposal.compact delete mode 100644 contracts/src/multisig/presets/NativeShieldedStatelessTreasury.compact delete mode 100644 contracts/src/multisig/presets/NativeShieldedTokenVault.compact diff --git a/contracts/src/multisig/examples/SignatureMintBurn.compact b/contracts/src/multisig/examples/SignatureMintBurn.compact index 8814359a..d0b9dfb1 100644 --- a/contracts/src/multisig/examples/SignatureMintBurn.compact +++ b/contracts/src/multisig/examples/SignatureMintBurn.compact @@ -19,8 +19,7 @@ pragma language_version >= 0.23.0; * Initialization is split so this module can be composed: `initialize` seeds the * shared signer registry, while `initializeToken` seeds this module's own token * state. A combined contract calls another module's `initialize` once for the - * shared registry, then this module's `initializeToken` (see - * `presets/NativeShieldedTokenVault`). + * shared registry, then this module's `initializeToken`. * * @notice DEPRECATION: this is a stopgap. From `0.3.0-alpha` it is superseded by * the reusable Shielded Native Token standard (with a pluggable multisig access diff --git a/contracts/src/multisig/examples/SignatureTreasury.compact b/contracts/src/multisig/examples/SignatureTreasury.compact index 3b9a97de..88a9dc68 100644 --- a/contracts/src/multisig/examples/SignatureTreasury.compact +++ b/contracts/src/multisig/examples/SignatureTreasury.compact @@ -15,10 +15,10 @@ pragma language_version >= 0.23.0; * and sends in a single transaction. A monotonic `_nonce` binds each spend to a * unique message hash for replay protection. * - * Import this module at the contract root and wrap it in a thin preset (see - * `presets/NativeShieldedStatelessTreasury`), or compose it with other + * Import this module at the contract root and wrap it in a thin top-level + * contract that supplies a constructor and delegates, or compose it with other * root modules that import the same `../EcdsaSignerManager` to share one - * signer registry (see `presets/NativeShieldedTokenVault`). + * signer registry. */ module SignatureTreasury { import CompactStandardLibrary; diff --git a/contracts/src/multisig/presets/NativeShieldedMintBurn.compact b/contracts/src/multisig/presets/NativeShieldedMintBurn.compact deleted file mode 100644 index 9ca50d48..00000000 --- a/contracts/src/multisig/presets/NativeShieldedMintBurn.compact +++ /dev/null @@ -1,99 +0,0 @@ -// SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.0.1-alpha.1 (multisig/presets/NativeShieldedMintBurn.compact) - -pragma language_version >= 0.23.0; - -/** - * @title NativeShieldedMintBurn (formerly ShieldedMultiSigV3) - * @description Example preset: a deployable multisig token contract. Both mint - * and burn require threshold ECDSA authorization; no single party can create or - * destroy supply. Non-transferable (no transfer/execute surface). - * - * Thin wrapper that composes a single root module, `SignatureMintBurn`. All - * behavior lives there; this contract only supplies a constructor and delegates. - * For the combined mint/burn + treasury variant, see `NativeShieldedTokenVault`. - * - * @notice DEPRECATION: the underlying mint/burn is a stopgap. From `0.3.0-alpha` - * it is superseded by the reusable Shielded Native Token standard (with a - * pluggable multisig access layer), OpenZeppelin/compact-contracts#544. Prefer - * that standard once available; do not build new dependents on this. - */ - -import CompactStandardLibrary; - -import "../examples/SignatureMintBurn" prefix Token_; -// For testing -export { ZswapCoinPublicKey }; - -// ─── Constructor ──────────────────────────────────────────────── - -/** - * @description Deploys with 3 signer commitments and a threshold of 2. - * `tokenDomain` derives this contract's token color via - * `tokenType(tokenDomain, kernel.self())`; `initCoinNonce` seeds the mint - * coin-nonce chain. Both `instanceSalt` and `initCoinNonce` must be random. - * - * @param {Bytes<32>} instanceSalt - Random salt for signer commitment derivation. - * @param {Bytes<32>} initCoinNonce - Initial coin nonce seed. - * @param {Bytes<32>} tokenDomain - Domain used to derive this contract's token color. - * @param {Vector<3, Bytes<32>>} signerCommitments - Hashed signer identities. - */ -constructor( - instanceSalt: Bytes<32>, - initCoinNonce: Bytes<32>, - tokenDomain: Bytes<32>, - signerCommitments: Vector<3, Bytes<32>>, -) { - Token_initialize<3>(instanceSalt, signerCommitments, 2); - Token_initializeToken(tokenDomain, initCoinNonce); -} - -// ─── Circuits (delegated to SignatureMintBurn) ────────────────── - -export circuit mint( - amount: Uint<64>, - recipient: Either, - pubkeys: Vector<2, Bytes<64>>, - signatures: Vector<2, Bytes<64>> -): [] { - Token_mint(amount, recipient, pubkeys, signatures); -} - -export circuit burn( - coin: QualifiedShieldedCoinInfo, - amount: Uint<64>, - pubkeys: Vector<2, Bytes<64>>, - signatures: Vector<2, Bytes<64>> -): [] { - Token_burn(coin, amount, pubkeys, signatures); -} - -export pure circuit _calculateSignerId(pk: Bytes<64>, salt: Bytes<32>): Bytes<32> { - return Token__calculateSignerId(pk, salt); -} - -// ─── View ─────────────────────────────────────────────────────── - -export circuit getNonce(): Uint<64> { - return Token_getNonce(); -} - -export circuit getTokenDomain(): Bytes<32> { - return Token_getTokenDomain(); -} - -export circuit getTokenType(): Bytes<32> { - return Token_getTokenType(); -} - -export circuit getSignerCount(): Uint<8> { - return Token_getSignerCount(); -} - -export circuit getThreshold(): Uint<8> { - return Token_getThreshold(); -} - -export circuit isSigner(commitment: Bytes<32>): Boolean { - return Token_isSigner(commitment); -} diff --git a/contracts/src/multisig/presets/NativeShieldedProposal.compact b/contracts/src/multisig/presets/NativeShieldedProposal.compact deleted file mode 100644 index 442e9a60..00000000 --- a/contracts/src/multisig/presets/NativeShieldedProposal.compact +++ /dev/null @@ -1,123 +0,0 @@ -// SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.2.0 (multisig/presets/NativeShieldedProposal.compact) - -pragma language_version >= 0.23.0; - -/** - * @title NativeShieldedProposal (formerly ShieldedMultiSig) - * @description Example preset: a deployable multisig that governs a shielded - * treasury through an on-chain proposal lifecycle (create / approve / revoke / - * execute), authorized by caller identity. - * - * Thin wrapper that composes a single root module, `ProposalTreasury`. All - * behavior lives there; this contract only supplies a constructor and delegates. - * Authorization is by the on-chain caller (not off-chain signatures), so this - * preset is independent of the signature-based modules. - */ - -import CompactStandardLibrary; - -import "../examples/ProposalTreasury" prefix Proposal_; -import "../proposal/ProposalManager" prefix ProposalManager_; - -// ─── Constructor ──────────────────────────────────────────────── - -/** - * @description Deploys the multisig with 3 signers and a threshold. - * - * @param {Vector<3, Either>} signers - Signer set. - * @param {Uint<8>} thresh - Minimum approvals required. - */ -constructor( - signers: Vector<3, Either>, - thresh: Uint<8> -) { - Proposal_initialize<3>(signers, thresh); -} - -// ─── Circuits (delegated to ProposalTreasury) ─────────────────── - -export circuit deposit(coin: ShieldedCoinInfo): [] { - Proposal_deposit(coin); -} - -export circuit createShieldedProposal( - to: ProposalManager_Recipient, - color: Bytes<32>, - amount: Uint<128> -): Uint<64> { - return Proposal_createShieldedProposal(to, color, amount); -} - -export circuit approveProposal(id: Uint<64>): [] { - Proposal_approveProposal(id); -} - -export circuit revokeApproval(id: Uint<64>): [] { - Proposal_revokeApproval(id); -} - -export circuit executeShieldedProposal(id: Uint<64>): ShieldedSendResult { - return Proposal_executeShieldedProposal(id); -} - -// ─── View ─────────────────────────────────────────────────────── - -export circuit isProposalApprovedBySigner( - id: Uint<64>, - signer: Either -): Boolean { - return Proposal_isProposalApprovedBySigner(id, signer); -} - -export circuit getApprovalCount(id: Uint<64>): Uint<8> { - return Proposal_getApprovalCount(id); -} - -export circuit getProposal(id: Uint<64>): ProposalManager_Proposal { - return Proposal_getProposal(id); -} - -export circuit getProposalRecipient(id: Uint<64>): ProposalManager_Recipient { - return Proposal_getProposalRecipient(id); -} - -export circuit getProposalAmount(id: Uint<64>): Uint<128> { - return Proposal_getProposalAmount(id); -} - -export circuit getProposalColor(id: Uint<64>): Bytes<32> { - return Proposal_getProposalColor(id); -} - -export circuit getProposalStatus(id: Uint<64>): ProposalManager_ProposalStatus { - return Proposal_getProposalStatus(id); -} - -export circuit getTokenBalance(color: Bytes<32>): Uint<128> { - return Proposal_getTokenBalance(color); -} - -export circuit getReceivedTotal(color: Bytes<32>): Uint<128> { - return Proposal_getReceivedTotal(color); -} - -export circuit getSentTotal(color: Bytes<32>): Uint<128> { - return Proposal_getSentTotal(color); -} - -export circuit getReceivedMinusSent(color: Bytes<32>): Uint<128> { - return Proposal_getReceivedMinusSent(color); -} - -export circuit getSignerCount(): Uint<8> { - return Proposal_getSignerCount(); -} - -export circuit getThreshold(): Uint<8> { - return Proposal_getThreshold(); -} - -export circuit isSigner(account: Either): Boolean { - return Proposal_isSigner(account); -} diff --git a/contracts/src/multisig/presets/NativeShieldedStatelessTreasury.compact b/contracts/src/multisig/presets/NativeShieldedStatelessTreasury.compact deleted file mode 100644 index 25df5c6c..00000000 --- a/contracts/src/multisig/presets/NativeShieldedStatelessTreasury.compact +++ /dev/null @@ -1,88 +0,0 @@ -// SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.2.0 (multisig/presets/NativeShieldedStatelessTreasury.compact) - -pragma language_version >= 0.23.0; - -/** - * @title NativeShieldedStatelessTreasury (formerly ShieldedMultiSigV2) - * @description Example preset: a deployable 2-of-3 signature multisig over a - * stateless shielded treasury. - * - * Thin wrapper that composes a single root module, `SignatureTreasury` - * (signature-authorized spend over `NativeShieldedTreasuryStateless`). All behavior - * lives in the module; this contract only supplies a constructor and delegates, - * demonstrating how to deploy that module on its own. For the combined - * mint/burn + treasury variant, see `NativeShieldedTokenVault`. - */ - -import CompactStandardLibrary; - -import "../examples/SignatureTreasury" prefix Treasury_; -import "../proposal/ProposalManager" prefix Proposal_; - -// ─── Constructor ──────────────────────────────────────────────── - -/** - * @description Deploys the multisig with 3 signer commitments and a threshold. - * Each commitment is `persistentHash(pk, instanceSalt, "multisig:signer:")`, - * computed off-chain via `_calculateSignerId`. - * - * Requirements: - * - * - `thresh` must be > 0 and <= 2 (matches the 2-signature `execute` surface). - * - `signerCommitments` must not contain duplicates. - * - `instanceSalt` should be cryptographically random. - * - * @param {Bytes<32>} instanceSalt - Random salt for commitment derivation. - * @param {Vector<3, Bytes<32>>} signerCommitments - Hashed signer identities. - * @param {Uint<8>} thresh - Minimum approvals required. - */ -constructor( - instanceSalt: Bytes<32>, - signerCommitments: Vector<3, Bytes<32>>, - thresh: Uint<8>, -) { - assert( - thresh <= 2, - "NativeShieldedStatelessTreasury: threshold cannot exceed 2 (execute verifies at most 2 signatures)" - ); - Treasury_initialize<3>(instanceSalt, signerCommitments, thresh); -} - -// ─── Circuits (delegated to SignatureTreasury) ────────────────── - -export circuit deposit(coin: ShieldedCoinInfo): [] { - Treasury_deposit(coin); -} - -export circuit execute( - to: Proposal_Recipient, - amount: Uint<128>, - coin: QualifiedShieldedCoinInfo, - pubkeys: Vector<2, Bytes<64>>, - signatures: Vector<2, Bytes<64>> -): ShieldedSendResult { - return Treasury_execute(to, amount, coin, pubkeys, signatures); -} - -export pure circuit _calculateSignerId(pk: Bytes<64>, salt: Bytes<32>): Bytes<32> { - return Treasury__calculateSignerId(pk, salt); -} - -// ─── View ─────────────────────────────────────────────────────── - -export circuit getNonce(): Uint<64> { - return Treasury_getNonce(); -} - -export circuit getSignerCount(): Uint<8> { - return Treasury_getSignerCount(); -} - -export circuit getThreshold(): Uint<8> { - return Treasury_getThreshold(); -} - -export circuit isSigner(commitment: Bytes<32>): Boolean { - return Treasury_isSigner(commitment); -} diff --git a/contracts/src/multisig/presets/NativeShieldedTokenVault.compact b/contracts/src/multisig/presets/NativeShieldedTokenVault.compact deleted file mode 100644 index a7a33d95..00000000 --- a/contracts/src/multisig/presets/NativeShieldedTokenVault.compact +++ /dev/null @@ -1,137 +0,0 @@ -// SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.0.1-alpha.1 (multisig/presets/NativeShieldedTokenVault.compact) - -pragma language_version >= 0.23.0; - -/** - * @title NativeShieldedTokenVault - * @description Example preset combining TWO root modules in one contract: - * `SignatureMintBurn` (issue/destroy this contract's own native shielded token) - * and `SignatureTreasury` (custody + signature-authorized spend). This is the - * composition the no-C2C protocol forces: a contract that both mints its own - * token and manages a treasury of it, atomically, under one signer set. - * - * Both modules import the same `../EcdsaSignerManager`, so the compiler - * deduplicates that state into a single signer registry shared by `mint`, `burn`, - * and `execute`. The constructor initializes that shared registry once (via the - * treasury module) and seeds the token state separately. - * - * @notice DEPRECATION: the mint/burn half is a stopgap superseded by the Shielded - * Native Token standard (OpenZeppelin/compact-contracts#544) from `0.3.0-alpha`. - */ - -import CompactStandardLibrary; - -import "../examples/SignatureTreasury" prefix Treasury_; -import "../examples/SignatureMintBurn" prefix Token_; -import "../proposal/ProposalManager" prefix Proposal_; -// For testing -export { ZswapCoinPublicKey }; - -// ─── Constructor ──────────────────────────────────────────────── - -/** - * @description Deploys with 3 signer commitments and a threshold. Initializes the - * shared signer registry once (through the treasury module), then seeds the token - * module's own state. - * - * Requirements: - * - * - `thresh` must be > 0 and <= 2 (mint/burn/execute each verify 2 signatures). - * - `signerCommitments` must not contain duplicates. - * - `instanceSalt` and `initCoinNonce` should be cryptographically random. - * - * @param {Bytes<32>} instanceSalt - Random salt for commitment derivation. - * @param {Bytes<32>} initCoinNonce - Initial coin nonce seed. - * @param {Bytes<32>} tokenDomain - Domain used to derive this contract's token color. - * @param {Vector<3, Bytes<32>>} signerCommitments - Hashed signer identities. - * @param {Uint<8>} thresh - Minimum approvals required. - */ -constructor( - instanceSalt: Bytes<32>, - initCoinNonce: Bytes<32>, - tokenDomain: Bytes<32>, - signerCommitments: Vector<3, Bytes<32>>, - thresh: Uint<8>, -) { - assert( - thresh <= 2, - "NativeShieldedTokenVault: threshold cannot exceed 2 (each op verifies at most 2 signatures)" - ); - // Initialize the shared signer registry once, through the treasury module. - Treasury_initialize<3>(instanceSalt, signerCommitments, thresh); - // Seed the token module's own state (no second registry init). - Token_initializeToken(tokenDomain, initCoinNonce); -} - -// ─── Treasury (SignatureTreasury) ─────────────────────────────── - -export circuit deposit(coin: ShieldedCoinInfo): [] { - Treasury_deposit(coin); -} - -export circuit execute( - to: Proposal_Recipient, - amount: Uint<128>, - coin: QualifiedShieldedCoinInfo, - pubkeys: Vector<2, Bytes<64>>, - signatures: Vector<2, Bytes<64>> -): ShieldedSendResult { - return Treasury_execute(to, amount, coin, pubkeys, signatures); -} - -// ─── Token (SignatureMintBurn) ────────────────────────────────── - -export circuit mint( - amount: Uint<64>, - recipient: Either, - pubkeys: Vector<2, Bytes<64>>, - signatures: Vector<2, Bytes<64>> -): [] { - Token_mint(amount, recipient, pubkeys, signatures); -} - -export circuit burn( - coin: QualifiedShieldedCoinInfo, - amount: Uint<64>, - pubkeys: Vector<2, Bytes<64>>, - signatures: Vector<2, Bytes<64>> -): [] { - Token_burn(coin, amount, pubkeys, signatures); -} - -// ─── Signature Verification ───────────────────────────────────── - -export pure circuit _calculateSignerId(pk: Bytes<64>, salt: Bytes<32>): Bytes<32> { - return Treasury__calculateSignerId(pk, salt); -} - -// ─── View ─────────────────────────────────────────────────────── - -export circuit getExecuteNonce(): Uint<64> { - return Treasury_getNonce(); -} - -export circuit getTokenNonce(): Uint<64> { - return Token_getNonce(); -} - -export circuit getTokenDomain(): Bytes<32> { - return Token_getTokenDomain(); -} - -export circuit getTokenType(): Bytes<32> { - return Token_getTokenType(); -} - -export circuit getSignerCount(): Uint<8> { - return Treasury_getSignerCount(); -} - -export circuit getThreshold(): Uint<8> { - return Treasury_getThreshold(); -} - -export circuit isSigner(commitment: Bytes<32>): Boolean { - return Treasury_isSigner(commitment); -} From 3a351c32e40bfa30cdbb2af78e7f8c782764bbfd Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Thu, 25 Jun 2026 12:24:23 +0200 Subject: [PATCH 12/13] test(multisig): add NativeShieldedTreasuryStateless coverage Wire the previously-orphan MockShieldedTreasuryStateless to a simulator and spec covering deposit, full/partial send (with change accounting), and the over-send rejection. Uses the shared EmptyWitnesses. --- .../NativeShieldedTreasuryStateless.test.ts | 97 +++++++++++++++++++ ...ativeShieldedTreasuryStatelessSimulator.ts | 71 ++++++++++++++ 2 files changed, 168 insertions(+) create mode 100644 contracts/src/multisig/test/NativeShieldedTreasuryStateless.test.ts create mode 100644 contracts/src/multisig/test/simulators/NativeShieldedTreasuryStatelessSimulator.ts diff --git a/contracts/src/multisig/test/NativeShieldedTreasuryStateless.test.ts b/contracts/src/multisig/test/NativeShieldedTreasuryStateless.test.ts new file mode 100644 index 00000000..8852b9c7 --- /dev/null +++ b/contracts/src/multisig/test/NativeShieldedTreasuryStateless.test.ts @@ -0,0 +1,97 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import * as utils from '#test-utils/address.js'; +import { NativeShieldedTreasuryStatelessSimulator } from './simulators/NativeShieldedTreasuryStatelessSimulator.js'; + +const COLOR = new Uint8Array(32).fill(1); +const AMOUNT = 1000n; + +const Z_RECIPIENT = utils.createEitherTestUser('RECIPIENT'); + +function makeCoin( + color: Uint8Array, + value: bigint, + nonce?: Uint8Array, +): { nonce: Uint8Array; color: Uint8Array; value: bigint } { + return { + nonce: nonce ?? new Uint8Array(32).fill(0), + color, + value, + }; +} + +function makeQualifiedCoin( + color: Uint8Array, + value: bigint, + mtIndex = 0n, + nonce?: Uint8Array, +): { + nonce: Uint8Array; + color: Uint8Array; + value: bigint; + mt_index: bigint; +} { + return { + nonce: nonce ?? new Uint8Array(32).fill(0), + color, + value, + mt_index: mtIndex, + }; +} + +let treasury: NativeShieldedTreasuryStatelessSimulator; + +describe('NativeShieldedTreasuryStateless', () => { + beforeEach(async () => { + treasury = await NativeShieldedTreasuryStatelessSimulator.create(); + }); + + describe('_deposit', () => { + it('should accept a deposit without reverting', async () => { + await treasury._deposit(makeCoin(COLOR, AMOUNT)); + }); + + it('should accept a zero-value deposit', async () => { + await treasury._deposit(makeCoin(COLOR, 0n)); + }); + }); + + describe('_send', () => { + beforeEach(async () => { + await treasury._deposit(makeCoin(COLOR, AMOUNT)); + }); + + it('should send the full coin with no change', async () => { + const result = await treasury._send( + makeQualifiedCoin(COLOR, AMOUNT), + Z_RECIPIENT, + AMOUNT, + ); + expect(result.sent.value).toEqual(AMOUNT); + expect(result.sent.color).toEqual(COLOR); + expect(result.change.is_some).toEqual(false); + }); + + it('should send a partial amount and return change', async () => { + const result = await treasury._send( + makeQualifiedCoin(COLOR, AMOUNT), + Z_RECIPIENT, + 400n, + ); + expect(result.sent.value).toEqual(400n); + expect(result.sent.color).toEqual(COLOR); + expect(result.change.is_some).toEqual(true); + expect(result.change.value.value).toEqual(AMOUNT - 400n); + expect(result.change.value.color).toEqual(COLOR); + }); + + it('should reject sending more than the coin holds', async () => { + await expect( + treasury._send( + makeQualifiedCoin(COLOR, AMOUNT), + Z_RECIPIENT, + AMOUNT + 1n, + ), + ).rejects.toThrow(); + }); + }); +}); diff --git a/contracts/src/multisig/test/simulators/NativeShieldedTreasuryStatelessSimulator.ts b/contracts/src/multisig/test/simulators/NativeShieldedTreasuryStatelessSimulator.ts new file mode 100644 index 00000000..12c69c19 --- /dev/null +++ b/contracts/src/multisig/test/simulators/NativeShieldedTreasuryStatelessSimulator.ts @@ -0,0 +1,71 @@ +import { + createSimulator, + type SimulatorOptions, +} from '@openzeppelin/compact-simulator'; +import { + ledger, + Contract as MockShieldedTreasuryStateless, +} from '../../../../artifacts/MockShieldedTreasuryStateless/contract/index.js'; +import { EmptyPrivateState, emptyWitnesses } from '../EmptyWitnesses.js'; + +type EitherRecipient = { + is_left: boolean; + left: { bytes: Uint8Array }; + right: { bytes: Uint8Array }; +}; +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 NativeShieldedTreasuryStatelessArgs = readonly []; + +const NativeShieldedTreasuryStatelessSimulatorBase = createSimulator< + EmptyPrivateState, + ReturnType, + ReturnType, + MockShieldedTreasuryStateless, + NativeShieldedTreasuryStatelessArgs +>({ + contractFactory: (witnesses) => + new MockShieldedTreasuryStateless(witnesses), + defaultPrivateState: () => EmptyPrivateState, + contractArgs: () => [], + ledgerExtractor: (state) => ledger(state), + witnessesFactory: () => emptyWitnesses(), + artifactName: 'MockShieldedTreasuryStateless', +}); + +export class NativeShieldedTreasuryStatelessSimulator extends NativeShieldedTreasuryStatelessSimulatorBase { + static async create( + options: SimulatorOptions< + EmptyPrivateState, + ReturnType + > = {}, + ): Promise { + // biome-ignore lint/complexity/noThisInStatic: super.create must keep the subclass `this` + return super.create( + [], + options, + ) as Promise; + } + + public _deposit(coin: ShieldedCoinInfo): Promise<[]> { + return this.circuits.impure._deposit(coin); + } + + public _send( + coin: QualifiedShieldedCoinInfo, + recipient: EitherRecipient, + amount: bigint, + ): Promise { + return this.circuits.impure._send(coin, recipient, amount); + } +} From bab2ca5ef32207290b5909aa1c819996579d53ce Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Thu, 25 Jun 2026 20:17:01 +0200 Subject: [PATCH 13/13] refactor(multisig): move examples to integration Move the multisig examples (SignatureMintBurn, SignatureTreasury, ProposalTreasury) out of src/multisig/examples/ into test/integration/_mocks/, converting each from a module (+ test mock) into a self-contained top-level contract prefixed with Multisig (MultisigSignatureMintBurn, MultisigSignatureTreasury, MultisigProposalTreasury). They stay as usage examples and now sit where they can double as integration-test fixtures. Remove the now-redundant unit tests, simulators, and mocks for the three. Integration tests are not added here; they are tracked in #630. Refs: #630 --- .../examples/ProposalTreasury.compact | 219 -------- .../examples/SignatureMintBurn.compact | 185 ------ .../examples/SignatureTreasury.compact | 127 ----- .../multisig/test/ProposalTreasury.test.ts | 528 ------------------ .../multisig/test/SignatureMintBurn.test.ts | 429 -------------- .../multisig/test/SignatureTreasury.test.ts | 226 -------- .../test/mocks/MockProposalTreasury.compact | 109 ---- .../test/mocks/MockSignatureMintBurn.compact | 76 --- .../test/mocks/MockSignatureTreasury.compact | 61 -- .../simulators/ProposalTreasurySimulator.ts | 155 ----- .../simulators/SignatureMintBurnSimulator.ts | 117 ---- .../simulators/SignatureTreasurySimulator.ts | 99 ---- .../_mocks/MultisigProposalTreasury.compact | 218 ++++++++ .../_mocks/MultisigSignatureMintBurn.compact | 174 ++++++ .../_mocks/MultisigSignatureTreasury.compact | 128 +++++ 15 files changed, 520 insertions(+), 2331 deletions(-) delete mode 100644 contracts/src/multisig/examples/ProposalTreasury.compact delete mode 100644 contracts/src/multisig/examples/SignatureMintBurn.compact delete mode 100644 contracts/src/multisig/examples/SignatureTreasury.compact delete mode 100644 contracts/src/multisig/test/ProposalTreasury.test.ts delete mode 100644 contracts/src/multisig/test/SignatureMintBurn.test.ts delete mode 100644 contracts/src/multisig/test/SignatureTreasury.test.ts delete mode 100644 contracts/src/multisig/test/mocks/MockProposalTreasury.compact delete mode 100644 contracts/src/multisig/test/mocks/MockSignatureMintBurn.compact delete mode 100644 contracts/src/multisig/test/mocks/MockSignatureTreasury.compact delete mode 100644 contracts/src/multisig/test/simulators/ProposalTreasurySimulator.ts delete mode 100644 contracts/src/multisig/test/simulators/SignatureMintBurnSimulator.ts delete mode 100644 contracts/src/multisig/test/simulators/SignatureTreasurySimulator.ts create mode 100644 contracts/test/integration/_mocks/MultisigProposalTreasury.compact create mode 100644 contracts/test/integration/_mocks/MultisigSignatureMintBurn.compact create mode 100644 contracts/test/integration/_mocks/MultisigSignatureTreasury.compact diff --git a/contracts/src/multisig/examples/ProposalTreasury.compact b/contracts/src/multisig/examples/ProposalTreasury.compact deleted file mode 100644 index a62b6c66..00000000 --- a/contracts/src/multisig/examples/ProposalTreasury.compact +++ /dev/null @@ -1,219 +0,0 @@ -// SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.2.0 (multisig/examples/ProposalTreasury.compact) - -pragma language_version >= 0.23.0; - -/** - * @module ProposalTreasury - * @description Composable multisig behavior: on-chain proposal governance over a - * shielded treasury, authorized by caller identity. Formerly the body of - * `ShieldedMultiSig`. - * - * Composes `SignerManager>`, - * `ProposalManager`, and `NativeShieldedTreasury`. Signers create, approve, and revoke - * proposals; once the threshold is met, `executeShieldedProposal` transfers from - * the treasury. Unlike the signature-based modules, authorization is by the - * on-chain caller (`getCaller`), not off-chain signatures — so it does NOT use - * `EcdsaSignerManager` (the signature entrance) and cannot share a registry with it. - * - * @notice Signer identity uses `Either` for - * forward compatibility. Today only `left(ZswapCoinPublicKey)` callers can - * authenticate — `getCaller()` resolves via `ownPublicKey()` and cannot produce - * a right-variant. Contract-address signers may be registered but cannot exercise - * governance until contract-to-contract calls exist. The broad state shape lets - * `getCaller()` be swapped via a CMA circuit upgrade later without a state migration. - */ -module ProposalTreasury { - import CompactStandardLibrary; - import "../proposal/ProposalManager" prefix Proposal_; - import "../treasury/NativeShieldedTreasury" prefix Treasury_; - import "../SignerManager"> prefix Signer_; - - // ─── State ────────────────────────────────────────────────────── - - export ledger _proposalApprovals: Map, Map, Boolean>>; - export ledger _approvalCount: Map, Uint<8>>; - - // ─── Setup ────────────────────────────────────────────────────── - - /** - * @description Initializes the signer registry. Call once from the consuming - * contract's constructor. - * - * @param {Vector>} signers - Signer set. - * @param {Uint<8>} thresh - Minimum approvals required. - * @returns {[]} Empty tuple. - */ - export circuit initialize<#n>( - signers: Vector>, - thresh: Uint<8> - ): [] { - Signer_initialize(signers, thresh); - } - - // ─── Deposit ──────────────────────────────────────────────────── - - export circuit deposit(coin: ShieldedCoinInfo): [] { - Treasury__deposit(coin); - } - - // ─── Proposals ────────────────────────────────────────────────── - - export circuit createShieldedProposal( - to: Proposal_Recipient, - color: Bytes<32>, - amount: Uint<128> - ): Uint<64> { - const callerPK = getCaller(); - Signer_assertSigner(callerPK); - - assert( - to.kind == Proposal_RecipientKind.ShieldedUser - || to.kind == Proposal_RecipientKind.Contract, - "ProposalTreasury: recipient must be a shielded user or contract" - ); - - return Proposal__createProposal(to, color, amount); - } - - export circuit approveProposal(id: Uint<64>): [] { - Proposal_assertProposalActive(id); - - const callerPK = getCaller(); - Signer_assertSigner(callerPK); - - assert(!isProposalApprovedBySigner(id, callerPK), "ProposalTreasury: already approved"); - - _approveProposal(id, callerPK); - } - - export circuit revokeApproval(id: Uint<64>): [] { - Proposal_assertProposalActive(id); - - const callerPK = getCaller(); - Signer_assertSigner(callerPK); - - assert(isProposalApprovedBySigner(id, callerPK), "ProposalTreasury: not approved"); - - _revokeApproval(id, callerPK); - } - - export circuit executeShieldedProposal(id: Uint<64>): ShieldedSendResult { - Proposal_assertProposalActive(id); - - const approvalCount = getApprovalCount(id); - Signer_assertThresholdMet(approvalCount); - - const { to, color, amount } = Proposal_getProposal(id); - const result = Treasury__send( - Proposal_toShieldedRecipient(to), - color, - amount, - ); - - Proposal__markExecuted(id); - return result; - } - - // ─── Internal ─────────────────────────────────────────────────── - - circuit _approveProposal(id: Uint<64>, signer: Either): [] { - if (!_proposalApprovals.member(disclose(id))) { - _proposalApprovals.insert(disclose(id), default, Boolean>>); - } - - _proposalApprovals.lookup(disclose(id)).insert(disclose(signer), disclose(true)); - - const newCount = getApprovalCount(id) + 1 as Uint<8>; - _approvalCount.insert(disclose(id), disclose(newCount)); - } - - circuit _revokeApproval(id: Uint<64>, signer: Either): [] { - _proposalApprovals.lookup(disclose(id)).remove(disclose(signer)); - - const newCount = getApprovalCount(id) - 1 as Uint<8>; - _approvalCount.insert(disclose(id), disclose(newCount)); - } - - /** - * @description Returns the caller identity used for signer authentication. - * - * @warning Resolves callers via `ownPublicKey()` only, so a `right(ContractAddress)` - * signer cannot authenticate today. The `Either` shape is kept so `getCaller()` - * can be swapped via a CMA circuit upgrade once contract-to-contract calls exist. - * - * @returns {Either} The caller as a left-variant. - */ - circuit getCaller(): Either { - return left(ownPublicKey()); - } - - // ─── View ─────────────────────────────────────────────────────── - - export circuit isProposalApprovedBySigner( - id: Uint<64>, - signer: Either - ): Boolean { - if (!_proposalApprovals.member(disclose(id)) || !_proposalApprovals.lookup(disclose(id)).member(disclose(signer))) { - return false; - } - - return _proposalApprovals.lookup(disclose(id)).lookup(disclose(signer)); - } - - export circuit getApprovalCount(id: Uint<64>): Uint<8> { - if (!_approvalCount.member(disclose(id))) { - return 0; - } - - return _approvalCount.lookup(disclose(id)); - } - - export circuit getProposal(id: Uint<64>): Proposal_Proposal { - return Proposal_getProposal(id); - } - - export circuit getProposalRecipient(id: Uint<64>): Proposal_Recipient { - return Proposal_getProposalRecipient(id); - } - - export circuit getProposalAmount(id: Uint<64>): Uint<128> { - return Proposal_getProposalAmount(id); - } - - export circuit getProposalColor(id: Uint<64>): Bytes<32> { - return Proposal_getProposalColor(id); - } - - export circuit getProposalStatus(id: Uint<64>): Proposal_ProposalStatus { - return Proposal_getProposalStatus(id); - } - - export circuit getTokenBalance(color: Bytes<32>): Uint<128> { - return Treasury_getTokenBalance(color); - } - - export circuit getReceivedTotal(color: Bytes<32>): Uint<128> { - return Treasury_getReceivedTotal(color); - } - - export circuit getSentTotal(color: Bytes<32>): Uint<128> { - return Treasury_getSentTotal(color); - } - - export circuit getReceivedMinusSent(color: Bytes<32>): Uint<128> { - return Treasury_getReceivedMinusSent(color); - } - - export circuit getSignerCount(): Uint<8> { - return Signer_getSignerCount(); - } - - export circuit getThreshold(): Uint<8> { - return Signer_getThreshold(); - } - - export circuit isSigner(account: Either): Boolean { - return Signer_isSigner(account); - } -} diff --git a/contracts/src/multisig/examples/SignatureMintBurn.compact b/contracts/src/multisig/examples/SignatureMintBurn.compact deleted file mode 100644 index d0b9dfb1..00000000 --- a/contracts/src/multisig/examples/SignatureMintBurn.compact +++ /dev/null @@ -1,185 +0,0 @@ -// SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.0.1-alpha.1 (multisig/examples/SignatureMintBurn.compact) - -pragma language_version >= 0.23.0; - -/** - * @module SignatureMintBurn - * @description Composable multisig behavior: signature-authorized mint/burn of a - * native shielded token issued by the consuming contract. Formerly the body of - * `ShieldedMultiSigV3`. - * - * `mint` creates a UTXO of this contract's token type via `mintShieldedToken`; - * `burn` consumes one via `sendShielded` to `shieldedBurnAddress()`. Both require - * threshold ECDSA approval verified against the shared `EcdsaSignerManager` - * registry. A counter provides replay protection and feeds `evolveNonce` for - * unique coin nonces. Operation-domain prefixes (`multisig:mint:` / - * `multisig:burn:`) stop a signature for one op being replayed as the other. - * - * Initialization is split so this module can be composed: `initialize` seeds the - * shared signer registry, while `initializeToken` seeds this module's own token - * state. A combined contract calls another module's `initialize` once for the - * shared registry, then this module's `initializeToken`. - * - * @notice DEPRECATION: this is a stopgap. From `0.3.0-alpha` it is superseded by - * the reusable Shielded Native Token standard (with a pluggable multisig access - * layer), OpenZeppelin/compact-contracts#544. Prefer that standard once available. - * - * @notice ECDSA verification is stubbed in `EcdsaSignerManager`. Replace it (and - * `persistentHash` with `keccak256`) once the Compact primitives are available. - */ -module SignatureMintBurn { - import CompactStandardLibrary; - import "../EcdsaSignerManager" prefix Signer_; - import "../../utils/Utils" prefix Utils_; - - // ─── State ────────────────────────────────────────────────────── - - export ledger _counter: Counter; - export ledger _coinNonce: Bytes<32>; - export sealed ledger _tokenDomain: Bytes<32>; - - // ─── Setup ────────────────────────────────────────────────────── - - /** - * @description Initializes the shared signer registry and instance salt. Call - * once per contract. In a combined contract, call this on exactly one module. - * - * @param {Bytes<32>} salt - Random salt for commitment derivation. - * @param {Vector>} signers - Signer commitments. - * @param {Uint<8>} thresh - Minimum approvals required. - * @returns {[]} Empty tuple. - */ - export circuit initialize<#n>( - salt: Bytes<32>, - signers: Vector>, - thresh: Uint<8> - ): [] { - Signer_initialize(salt, signers, thresh); - } - - /** - * @description Seeds this module's token state, independent of the signer - * registry. Call once from the consuming contract's constructor. - * - * @param {Bytes<32>} tokenDomain - Domain used with `kernel.self()` to derive - * this contract's token color. - * @param {Bytes<32>} initCoinNonce - Initial coin-nonce seed (random). - * @returns {[]} Empty tuple. - */ - export circuit initializeToken(tokenDomain: Bytes<32>, initCoinNonce: Bytes<32>): [] { - _tokenDomain = disclose(tokenDomain); - _coinNonce = disclose(initCoinNonce); - } - - // ─── Mint ─────────────────────────────────────────────────────── - - /** - * @description Mints a new shielded coin of this contract's token type to the - * recipient, authorized by threshold signatures. The message hash commits to - * the `multisig:mint:` domain, contract address, recipient, counter, and amount. - * - * @param {Uint<64>} amount - The token amount to mint. - * @param {Either} recipient - Recipient. - * @param {Vector<2, Bytes<64>>} pubkeys - ECDSA public keys of approving signers. - * @param {Vector<2, Bytes<64>>} signatures - Signatures over the mint hash. - * @returns {[]} Empty tuple. - */ - export circuit mint( - amount: Uint<64>, - recipient: Either, - pubkeys: Vector<2, Bytes<64>>, - signatures: Vector<2, Bytes<64>> - ): [] { - const opNonce = _counter; - _counter.increment(1); - - const canonRecipient = Utils_canonicalize(recipient); - const recipientHash = persistentHash>(canonRecipient); - - const msgHash = persistentHash>>([ - pad(32, "multisig:mint:"), - kernel.self().bytes, - recipientHash, - opNonce as Bytes<32>, - amount as Bytes<32> - ]); - - Signer_verify<2>(msgHash, pubkeys, signatures); - - _coinNonce = evolveNonce(_counter, _coinNonce); - mintShieldedToken(_tokenDomain, disclose(amount), _coinNonce, disclose(canonRecipient)); - } - - // ─── Burn ─────────────────────────────────────────────────────── - - /** - * @description Burns a coin of this contract's token type to - * `shieldedBurnAddress()`, authorized by threshold signatures. Change from a - * partial burn is handled by the transaction layer. The `multisig:burn:` domain - * prefix prevents replay as a mint. - * - * @param {QualifiedShieldedCoinInfo} coin - The coin to burn (operator pool). - * @param {Uint<64>} amount - The token amount to burn. - * @param {Vector<2, Bytes<64>>} pubkeys - ECDSA public keys of approving signers. - * @param {Vector<2, Bytes<64>>} signatures - Signatures over the burn hash. - * @returns {[]} Empty tuple. - */ - export circuit burn( - coin: QualifiedShieldedCoinInfo, - amount: Uint<64>, - pubkeys: Vector<2, Bytes<64>>, - signatures: Vector<2, Bytes<64>> - ): [] { - const opNonce = _counter; - _counter.increment(1); - - const msgHash = persistentHash>>([ - pad(32, "multisig:burn:"), - kernel.self().bytes, - opNonce as Bytes<32>, - amount as Bytes<32> - ]); - - Signer_verify<2>(msgHash, pubkeys, signatures); - - assert(coin.color == tokenType(_tokenDomain, kernel.self()), "SignatureMintBurn: coin not from this contract"); - assert(coin.value >= amount, "SignatureMintBurn: insufficient coin value"); - - sendShielded(disclose(coin), shieldedBurnAddress(), disclose(amount)); - } - - // ─── View ─────────────────────────────────────────────────────── - - /** - * @description Computes a signer commitment from an ECDSA public key. Pure — - * callable off-chain by the deployer. - */ - export pure circuit _calculateSignerId(pk: Bytes<64>, salt: Bytes<32>): Bytes<32> { - return Signer__calculateSignerId(pk, salt); - } - - export circuit getNonce(): Uint<64> { - return _counter; - } - - export circuit getTokenDomain(): Bytes<32> { - return _tokenDomain; - } - - export circuit getTokenType(): Bytes<32> { - return tokenType(_tokenDomain, kernel.self()); - } - - export circuit getSignerCount(): Uint<8> { - return Signer_getSignerCount(); - } - - export circuit getThreshold(): Uint<8> { - return Signer_getThreshold(); - } - - export circuit isSigner(commitment: Bytes<32>): Boolean { - return Signer_isSigner(commitment); - } -} diff --git a/contracts/src/multisig/examples/SignatureTreasury.compact b/contracts/src/multisig/examples/SignatureTreasury.compact deleted file mode 100644 index 88a9dc68..00000000 --- a/contracts/src/multisig/examples/SignatureTreasury.compact +++ /dev/null @@ -1,127 +0,0 @@ -// SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.2.0 (multisig/examples/SignatureTreasury.compact) - -pragma language_version >= 0.23.0; - -/** - * @module SignatureTreasury - * @description Composable multisig behavior: signature-authorized, single-tx - * spend from a stateless shielded treasury. Formerly the body of - * `ShieldedMultiSigV2`. - * - * Combines `EcdsaSignerManager` (commitment signer registry + threshold ECDSA - * verification) with `NativeShieldedTreasuryStateless` (custody + send of native - * shielded tokens). Approvals are collected off-chain; `execute` verifies them - * and sends in a single transaction. A monotonic `_nonce` binds each spend to a - * unique message hash for replay protection. - * - * Import this module at the contract root and wrap it in a thin top-level - * contract that supplies a constructor and delegates, or compose it with other - * root modules that import the same `../EcdsaSignerManager` to share one - * signer registry. - */ -module SignatureTreasury { - import CompactStandardLibrary; - import "../EcdsaSignerManager" prefix Signer_; - import "../treasury/NativeShieldedTreasuryStateless" prefix Treasury_; - import "../proposal/ProposalManager" prefix Proposal_; - - // ─── State ────────────────────────────────────────────────────── - - export ledger _nonce: Counter; - - // ─── Setup ────────────────────────────────────────────────────── - - /** - * @description Initializes the shared signer registry and instance salt. - * Call once from the consuming contract's constructor. - * - * @param {Bytes<32>} salt - Random salt for commitment derivation. - * @param {Vector>} signers - Signer commitments. - * @param {Uint<8>} thresh - Minimum approvals required. - * @returns {[]} Empty tuple. - */ - export circuit initialize<#n>( - salt: Bytes<32>, - signers: Vector>, - thresh: Uint<8> - ): [] { - Signer_initialize(salt, signers, thresh); - } - - // ─── Deposit ──────────────────────────────────────────────────── - - /** - * @description Receives a shielded coin into the treasury. No access control; - * anyone may deposit. No coin data is stored on the public ledger. - * - * @param {ShieldedCoinInfo} coin - The incoming shielded coin. - * @returns {[]} Empty tuple. - */ - export circuit deposit(coin: ShieldedCoinInfo): [] { - Treasury__deposit(coin); - } - - // ─── Execute ──────────────────────────────────────────────────── - - /** - * @description Executes a shielded send authorized by threshold signatures. - * Reads and increments the nonce, reconstructs the off-chain message hash - * `persistentHash(nonce, recipient address, coin color, amount)`, verifies the - * signatures against the shared registry, then sends from the treasury. - * - * @param {Proposal_Recipient} to - The recipient. - * @param {Uint<128>} amount - The amount to send. - * @param {QualifiedShieldedCoinInfo} coin - The coin to spend (operator pool). - * @param {Vector<2, Bytes<64>>} pubkeys - ECDSA public keys of approving signers. - * @param {Vector<2, Bytes<64>>} signatures - Signatures over the operation. - * @returns {ShieldedSendResult} The send result including any change. - */ - export circuit execute( - to: Proposal_Recipient, - amount: Uint<128>, - coin: QualifiedShieldedCoinInfo, - pubkeys: Vector<2, Bytes<64>>, - signatures: Vector<2, Bytes<64>> - ): ShieldedSendResult { - const currentNonce = _nonce; - _nonce.increment(1); - - const msgHash = persistentHash>>([ - currentNonce as Bytes<32>, - to.address, - coin.color, - amount as Bytes<32> - ]); - - Signer_verify<2>(msgHash, pubkeys, signatures); - - return Treasury__send(coin, Proposal_toShieldedRecipient(to), amount); - } - - // ─── View ─────────────────────────────────────────────────────── - - /** - * @description Computes a signer commitment from an ECDSA public key. Pure — - * callable off-chain by the deployer. - */ - export pure circuit _calculateSignerId(pk: Bytes<64>, salt: Bytes<32>): Bytes<32> { - return Signer__calculateSignerId(pk, salt); - } - - export circuit getNonce(): Uint<64> { - return _nonce; - } - - export circuit getSignerCount(): Uint<8> { - return Signer_getSignerCount(); - } - - export circuit getThreshold(): Uint<8> { - return Signer_getThreshold(); - } - - export circuit isSigner(commitment: Bytes<32>): Boolean { - return Signer_isSigner(commitment); - } -} diff --git a/contracts/src/multisig/test/ProposalTreasury.test.ts b/contracts/src/multisig/test/ProposalTreasury.test.ts deleted file mode 100644 index 7782f729..00000000 --- a/contracts/src/multisig/test/ProposalTreasury.test.ts +++ /dev/null @@ -1,528 +0,0 @@ -import { beforeEach, describe, expect, it } from 'vitest'; -import * as utils from '#test-utils/address.js'; -import { ProposalTreasurySimulator } from './simulators/ProposalTreasurySimulator.js'; - -const ProposalStatus = { Inactive: 0, Active: 1, Executed: 2, Cancelled: 3 }; -const RecipientKind = { ShieldedUser: 0, UnshieldedUser: 1, Contract: 2 }; - -const THRESHOLD = 2n; -const COLOR = new Uint8Array(32).fill(1); -const AMOUNT = 1000n; -const PROPOSAL_AMOUNT = 400n; - -const [SIGNER1, Z_SIGNER1] = utils.generateEitherPubKeyPair('SIGNER1'); -const [SIGNER2, Z_SIGNER2] = utils.generateEitherPubKeyPair('SIGNER2'); -const [SIGNER3, Z_SIGNER3] = utils.generateEitherPubKeyPair('SIGNER3'); -const SIGNERS = [Z_SIGNER1, Z_SIGNER2, Z_SIGNER3]; - -const [_NON_SIGNER, Z_NON_SIGNER] = utils.generateEitherPubKeyPair('OTHER'); -const [, Z_RECIPIENT_PK] = utils.generatePubKeyPair('RECIPIENT'); - -function makeRecipient(pk: { bytes: Uint8Array }): { - kind: number; - address: Uint8Array; -} { - return { kind: RecipientKind.ShieldedUser, address: pk.bytes }; -} - -function makeCoin( - color: Uint8Array, - value: bigint, - nonce?: Uint8Array, -): { nonce: Uint8Array; color: Uint8Array; value: bigint } { - return { - nonce: nonce ?? new Uint8Array(32).fill(0), - color, - value, - }; -} - -let multisig: ProposalTreasurySimulator; - -describe('ProposalTreasury', () => { - describe('constructor', () => { - it('should initialize with signers and threshold', () => { - multisig = new ProposalTreasurySimulator(SIGNERS, THRESHOLD); - expect(multisig.getSignerCount()).toEqual(BigInt(SIGNERS.length)); - expect(multisig.getThreshold()).toEqual(THRESHOLD); - }); - - it('should register all signers', () => { - multisig = new ProposalTreasurySimulator(SIGNERS, THRESHOLD); - for (const signer of SIGNERS) { - expect(multisig.isSigner(signer)).toEqual(true); - } - }); - - it('should reject non-signers', () => { - multisig = new ProposalTreasurySimulator(SIGNERS, THRESHOLD); - expect(multisig.isSigner(Z_NON_SIGNER)).toEqual(false); - }); - - it('should fail with zero threshold', () => { - expect(() => { - new ProposalTreasurySimulator(SIGNERS, 0n); - }).toThrow('SignerManager: threshold must not be zero'); - }); - - it('should fail with threshold exceeding signer count', () => { - expect(() => { - new ProposalTreasurySimulator(SIGNERS, 4n); - }).toThrow('SignerManager: threshold exceeds signer count'); - }); - }); - - describe('when initialized', () => { - beforeEach(() => { - multisig = new ProposalTreasurySimulator(SIGNERS, THRESHOLD); - }); - - describe('deposit', () => { - it('should accept deposits', () => { - multisig.deposit(makeCoin(COLOR, AMOUNT)); - expect(multisig.getTokenBalance(COLOR)).toEqual(AMOUNT); - }); - - it('should accumulate deposits', () => { - multisig.deposit(makeCoin(COLOR, AMOUNT, new Uint8Array(32).fill(1))); - multisig.deposit(makeCoin(COLOR, AMOUNT, new Uint8Array(32).fill(2))); - expect(multisig.getTokenBalance(COLOR)).toEqual(AMOUNT * 2n); - }); - - it('should track received total', () => { - multisig.deposit(makeCoin(COLOR, AMOUNT)); - expect(multisig.getReceivedTotal(COLOR)).toEqual(AMOUNT); - }); - }); - - describe('createShieldedProposal', () => { - it('should allow signer to create proposal', () => { - const to = makeRecipient(Z_RECIPIENT_PK); - const id = multisig - .as(SIGNER1) - .createShieldedProposal(to, COLOR, PROPOSAL_AMOUNT); - expect(id).toEqual(1n); - }); - - it('should store proposal data correctly', () => { - const to = makeRecipient(Z_RECIPIENT_PK); - const id = multisig - .as(SIGNER1) - .createShieldedProposal(to, COLOR, PROPOSAL_AMOUNT); - - const proposal = multisig.getProposal(id); - expect(proposal.status).toEqual(ProposalStatus.Active); - expect(proposal.amount).toEqual(PROPOSAL_AMOUNT); - expect(proposal.color).toEqual(COLOR); - }); - - it('should fail for non-signer', () => { - const to = makeRecipient(Z_RECIPIENT_PK); - expect(() => { - multisig - .as(_NON_SIGNER) - .createShieldedProposal(to, COLOR, PROPOSAL_AMOUNT); - }).toThrow('SignerManager: not a signer'); - }); - - it('should fail with zero amount', () => { - const to = makeRecipient(Z_RECIPIENT_PK); - expect(() => { - multisig.as(SIGNER1).createShieldedProposal(to, COLOR, 0n); - }).toThrow('ProposalManager: zero amount'); - }); - - it('should reject UnshieldedUser recipient kind', () => { - const to = { - kind: RecipientKind.UnshieldedUser, - address: Z_RECIPIENT_PK.bytes, - }; - expect(() => { - multisig - .as(SIGNER1) - .createShieldedProposal(to, COLOR, PROPOSAL_AMOUNT); - }).toThrow( - 'ProposalTreasury: recipient must be a shielded user or contract', - ); - }); - - it('should accept Contract recipient kind', () => { - const to = { - kind: RecipientKind.Contract, - address: new Uint8Array(32).fill(7), - }; - const id = multisig - .as(SIGNER1) - .createShieldedProposal(to, COLOR, PROPOSAL_AMOUNT); - expect(id).toEqual(1n); - expect(multisig.getProposalRecipient(id).kind).toEqual( - RecipientKind.Contract, - ); - }); - }); - - describe('approveProposal', () => { - let proposalId: bigint; - - beforeEach(() => { - const to = makeRecipient(Z_RECIPIENT_PK); - proposalId = multisig - .as(SIGNER1) - .createShieldedProposal(to, COLOR, PROPOSAL_AMOUNT); - }); - - it('should allow signer to approve', () => { - multisig.as(SIGNER1).approveProposal(proposalId); - expect( - multisig.isProposalApprovedBySigner(proposalId, Z_SIGNER1), - ).toEqual(true); - expect(multisig.getApprovalCount(proposalId)).toEqual(1n); - }); - - it('should allow multiple signers to approve', () => { - multisig.as(SIGNER1).approveProposal(proposalId); - multisig.as(SIGNER2).approveProposal(proposalId); - expect(multisig.getApprovalCount(proposalId)).toEqual(2n); - }); - - it('should fail for non-signer', () => { - expect(() => { - multisig.as(_NON_SIGNER).approveProposal(proposalId); - }).toThrow('SignerManager: not a signer'); - }); - - it('should fail for double approval', () => { - multisig.as(SIGNER1).approveProposal(proposalId); - expect(() => { - multisig.as(SIGNER1).approveProposal(proposalId); - }).toThrow('ProposalTreasury: already approved'); - }); - - it('should fail for non-existing proposal', () => { - expect(() => { - multisig.as(SIGNER1).approveProposal(999n); - }).toThrow('ProposalManager: proposal not found'); - }); - - it('should fail for executed proposal', () => { - multisig.deposit(makeCoin(COLOR, AMOUNT)); - multisig.as(SIGNER1).approveProposal(proposalId); - multisig.as(SIGNER2).approveProposal(proposalId); - multisig.executeShieldedProposal(proposalId); - - expect(() => { - multisig.as(SIGNER3).approveProposal(proposalId); - }).toThrow('ProposalManager: proposal not active'); - }); - }); - - describe('revokeApproval', () => { - let proposalId: bigint; - - beforeEach(() => { - const to = makeRecipient(Z_RECIPIENT_PK); - proposalId = multisig - .as(SIGNER1) - .createShieldedProposal(to, COLOR, PROPOSAL_AMOUNT); - multisig.as(SIGNER1).approveProposal(proposalId); - }); - - it('should allow signer to revoke their approval', () => { - multisig.as(SIGNER1).revokeApproval(proposalId); - expect( - multisig.isProposalApprovedBySigner(proposalId, Z_SIGNER1), - ).toEqual(false); - expect(multisig.getApprovalCount(proposalId)).toEqual(0n); - }); - - it('should fail for non-signer', () => { - expect(() => { - multisig.as(_NON_SIGNER).revokeApproval(proposalId); - }).toThrow('SignerManager: not a signer'); - }); - - it('should fail if not yet approved', () => { - expect(() => { - multisig.as(SIGNER2).revokeApproval(proposalId); - }).toThrow('ProposalTreasury: not approved'); - }); - - it('should allow re-approval after revoke', () => { - multisig.as(SIGNER1).revokeApproval(proposalId); - multisig.as(SIGNER1).approveProposal(proposalId); - expect( - multisig.isProposalApprovedBySigner(proposalId, Z_SIGNER1), - ).toEqual(true); - expect(multisig.getApprovalCount(proposalId)).toEqual(1n); - }); - - it('should fail for executed proposal', () => { - multisig.deposit(makeCoin(COLOR, AMOUNT)); - multisig.as(SIGNER2).approveProposal(proposalId); - multisig.executeShieldedProposal(proposalId); - - expect(() => { - multisig.as(SIGNER1).revokeApproval(proposalId); - }).toThrow('ProposalManager: proposal not active'); - }); - }); - - describe('executeShieldedProposal', () => { - let proposalId: bigint; - - beforeEach(() => { - // Fund the treasury - multisig.deposit(makeCoin(COLOR, AMOUNT)); - - // Create and approve proposal to threshold - const to = makeRecipient(Z_RECIPIENT_PK); - proposalId = multisig - .as(SIGNER1) - .createShieldedProposal(to, COLOR, PROPOSAL_AMOUNT); - multisig.as(SIGNER1).approveProposal(proposalId); - multisig.as(SIGNER2).approveProposal(proposalId); - }); - - it('should execute when threshold is met', () => { - multisig.executeShieldedProposal(proposalId); - expect(multisig.getProposalStatus(proposalId)).toEqual( - ProposalStatus.Executed, - ); - }); - - it('should return sent coin and change in result', () => { - const result = multisig.executeShieldedProposal(proposalId); - expect(result.sent.value).toEqual(PROPOSAL_AMOUNT); - expect(result.sent.color).toEqual(COLOR); - expect(result.change.is_some).toEqual(true); - expect(result.change.value.value).toEqual(AMOUNT - PROPOSAL_AMOUNT); - expect(result.change.value.color).toEqual(COLOR); - }); - - it('should return no change when sending full balance', () => { - // Create proposal for the full amount - const to = makeRecipient(Z_RECIPIENT_PK); - const fullId = multisig - .as(SIGNER1) - .createShieldedProposal(to, COLOR, AMOUNT); - multisig.as(SIGNER1).approveProposal(fullId); - multisig.as(SIGNER2).approveProposal(fullId); - - const result = multisig.executeShieldedProposal(fullId); - expect(result.sent.value).toEqual(AMOUNT); - expect(result.change.is_some).toEqual(false); - }); - - it('should deduct from treasury balance', () => { - multisig.executeShieldedProposal(proposalId); - expect(multisig.getTokenBalance(COLOR)).toEqual( - AMOUNT - PROPOSAL_AMOUNT, - ); - }); - - it('should track sent total', () => { - multisig.executeShieldedProposal(proposalId); - expect(multisig.getSentTotal(COLOR)).toEqual(PROPOSAL_AMOUNT); - }); - - it('should fail when threshold is not met', () => { - // Create a new proposal with only 1 approval - const to = makeRecipient(Z_RECIPIENT_PK); - const id2 = multisig - .as(SIGNER1) - .createShieldedProposal(to, COLOR, 100n); - multisig.as(SIGNER1).approveProposal(id2); - - expect(() => { - multisig.executeShieldedProposal(id2); - }).toThrow('SignerManager: threshold not met'); - }); - - it('should fail for non-existing proposal', () => { - expect(() => { - multisig.executeShieldedProposal(999n); - }).toThrow('ProposalManager: proposal not found'); - }); - - it('should fail when executed twice', () => { - multisig.executeShieldedProposal(proposalId); - expect(() => { - multisig.executeShieldedProposal(proposalId); - }).toThrow('ProposalManager: proposal not active'); - }); - - it('should fail with insufficient treasury balance', () => { - // Create proposal for more than treasury holds - const to = makeRecipient(Z_RECIPIENT_PK); - const bigId = multisig - .as(SIGNER1) - .createShieldedProposal(to, COLOR, AMOUNT + 1n); - multisig.as(SIGNER1).approveProposal(bigId); - multisig.as(SIGNER2).approveProposal(bigId); - - expect(() => { - multisig.executeShieldedProposal(bigId); - }).toThrow('ShieldedTreasury: coin value insufficient'); - }); - }); - - describe('view - approvals', () => { - it('should return false for unapproved signer', () => { - const to = makeRecipient(Z_RECIPIENT_PK); - const id = multisig - .as(SIGNER1) - .createShieldedProposal(to, COLOR, PROPOSAL_AMOUNT); - expect(multisig.isProposalApprovedBySigner(id, Z_SIGNER1)).toEqual( - false, - ); - }); - - it('should return 0 approval count for new proposal', () => { - const to = makeRecipient(Z_RECIPIENT_PK); - const id = multisig - .as(SIGNER1) - .createShieldedProposal(to, COLOR, PROPOSAL_AMOUNT); - expect(multisig.getApprovalCount(id)).toEqual(0n); - }); - }); - - describe('view - proposal delegation', () => { - let proposalId: bigint; - - beforeEach(() => { - const to = makeRecipient(Z_RECIPIENT_PK); - proposalId = multisig - .as(SIGNER1) - .createShieldedProposal(to, COLOR, PROPOSAL_AMOUNT); - }); - - it('getProposalRecipient should return recipient', () => { - const recipient = multisig.getProposalRecipient(proposalId); - expect(recipient.kind).toEqual(RecipientKind.ShieldedUser); - expect(recipient.address).toEqual(Z_RECIPIENT_PK.bytes); - }); - - it('getProposalAmount should return amount', () => { - expect(multisig.getProposalAmount(proposalId)).toEqual(PROPOSAL_AMOUNT); - }); - - it('getProposalColor should return color', () => { - expect(multisig.getProposalColor(proposalId)).toEqual(COLOR); - }); - }); - - describe('view - signer manager delegation', () => { - it('getSignerCount should match initial count', () => { - expect(multisig.getSignerCount()).toEqual(BigInt(SIGNERS.length)); - }); - - it('getThreshold should match initial threshold', () => { - expect(multisig.getThreshold()).toEqual(THRESHOLD); - }); - - it('isSigner should return true for signer', () => { - expect(multisig.isSigner(Z_SIGNER1)).toEqual(true); - }); - - it('isSigner should return false for non-signer', () => { - expect(multisig.isSigner(Z_NON_SIGNER)).toEqual(false); - }); - }); - - describe('view - treasury delegation', () => { - beforeEach(() => { - multisig.deposit(makeCoin(COLOR, AMOUNT)); - }); - - it('getTokenBalance should reflect deposits', () => { - expect(multisig.getTokenBalance(COLOR)).toEqual(AMOUNT); - }); - - it('getReceivedTotal should reflect deposits', () => { - expect(multisig.getReceivedTotal(COLOR)).toEqual(AMOUNT); - }); - - it('getSentTotal should be 0 before any sends', () => { - expect(multisig.getSentTotal(COLOR)).toEqual(0n); - }); - - it('getReceivedMinusSent should equal balance', () => { - expect(multisig.getReceivedMinusSent(COLOR)).toEqual(AMOUNT); - }); - }); - - describe('full lifecycle', () => { - it('should handle deposit -> propose -> approve -> execute', () => { - // Deposit - multisig.deposit(makeCoin(COLOR, AMOUNT)); - expect(multisig.getTokenBalance(COLOR)).toEqual(AMOUNT); - - // Propose - const to = makeRecipient(Z_RECIPIENT_PK); - const id = multisig - .as(SIGNER1) - .createShieldedProposal(to, COLOR, PROPOSAL_AMOUNT); - - // Approve to threshold - multisig.as(SIGNER1).approveProposal(id); - multisig.as(SIGNER2).approveProposal(id); - expect(multisig.getApprovalCount(id)).toEqual(THRESHOLD); - - // Execute - multisig.executeShieldedProposal(id); - expect(multisig.getProposalStatus(id)).toEqual(ProposalStatus.Executed); - expect(multisig.getTokenBalance(COLOR)).toEqual( - AMOUNT - PROPOSAL_AMOUNT, - ); - expect(multisig.getReceivedMinusSent(COLOR)).toEqual( - AMOUNT - PROPOSAL_AMOUNT, - ); - }); - - it('should handle multiple proposals concurrently', () => { - multisig.deposit(makeCoin(COLOR, AMOUNT)); - - const to = makeRecipient(Z_RECIPIENT_PK); - const id1 = multisig - .as(SIGNER1) - .createShieldedProposal(to, COLOR, 200n); - const id2 = multisig - .as(SIGNER2) - .createShieldedProposal(to, COLOR, 300n); - - // Approve and execute first - multisig.as(SIGNER1).approveProposal(id1); - multisig.as(SIGNER2).approveProposal(id1); - multisig.executeShieldedProposal(id1); - - // Approve and execute second - multisig.as(SIGNER1).approveProposal(id2); - multisig.as(SIGNER3).approveProposal(id2); - multisig.executeShieldedProposal(id2); - - expect(multisig.getTokenBalance(COLOR)).toEqual(AMOUNT - 200n - 300n); - }); - - it('should handle approve -> revoke -> re-approve -> execute', () => { - multisig.deposit(makeCoin(COLOR, AMOUNT)); - const to = makeRecipient(Z_RECIPIENT_PK); - const id = multisig - .as(SIGNER1) - .createShieldedProposal(to, COLOR, PROPOSAL_AMOUNT); - - // Approve then revoke - multisig.as(SIGNER1).approveProposal(id); - multisig.as(SIGNER1).revokeApproval(id); - expect(multisig.getApprovalCount(id)).toEqual(0n); - - // Re-approve with enough signers - multisig.as(SIGNER2).approveProposal(id); - multisig.as(SIGNER3).approveProposal(id); - expect(multisig.getApprovalCount(id)).toEqual(2n); - - multisig.executeShieldedProposal(id); - expect(multisig.getProposalStatus(id)).toEqual(ProposalStatus.Executed); - }); - }); - }); -}); diff --git a/contracts/src/multisig/test/SignatureMintBurn.test.ts b/contracts/src/multisig/test/SignatureMintBurn.test.ts deleted file mode 100644 index f6f63d0f..00000000 --- a/contracts/src/multisig/test/SignatureMintBurn.test.ts +++ /dev/null @@ -1,429 +0,0 @@ -import { beforeEach, describe, expect, it } from 'vitest'; -import * as utils from '#test-utils/address.js'; -import { - calculateSignerId, - SignatureMintBurnSimulator, -} from './simulators/SignatureMintBurnSimulator.js'; - -// ─── Fixtures ───────────────────────────────────────────────────── - -const INSTANCE_SALT = new Uint8Array(32).fill(0xaa); -const INIT_COIN_NONCE = new Uint8Array(32).fill(0xbb); -const TOKEN_DOMAIN = new Uint8Array(32); -Buffer.from('smt:token:').copy(TOKEN_DOMAIN); - -const PK1 = new Uint8Array(64).fill(0x11); -const PK2 = new Uint8Array(64).fill(0x22); -const PK3 = new Uint8Array(64).fill(0x33); -const NON_SIGNER_PK = new Uint8Array(64).fill(0x99); - -const COMMITMENT1 = calculateSignerId(PK1, INSTANCE_SALT); -const COMMITMENT2 = calculateSignerId(PK2, INSTANCE_SALT); -const COMMITMENT3 = calculateSignerId(PK3, INSTANCE_SALT); -const SIGNER_COMMITMENTS = [COMMITMENT1, COMMITMENT2, COMMITMENT3]; - -const DUMMY_SIG = new Uint8Array(64).fill(0xff); - -const USER_RECIPIENT = utils.createEitherTestUser('ALICE'); -const CONTRACT_RECIPIENT = utils.createEitherTestContractAddress('TARGET'); - -function makeQualifiedCoin( - color: Uint8Array, - value: bigint, - mtIndex = 0n, - nonce?: Uint8Array, -): { - nonce: Uint8Array; - color: Uint8Array; - value: bigint; - mt_index: bigint; -} { - return { - nonce: nonce ?? new Uint8Array(32).fill(0), - color, - value, - mt_index: mtIndex, - }; -} - -let multisig: SignatureMintBurnSimulator; - -describe('SignatureMintBurn', () => { - describe('constructor', () => { - it('should initialize', () => { - multisig = new SignatureMintBurnSimulator( - INSTANCE_SALT, - INIT_COIN_NONCE, - TOKEN_DOMAIN, - SIGNER_COMMITMENTS, - ); - expect(multisig.getSignerCount()).toEqual(3n); - expect(multisig.getThreshold()).toEqual(2n); - }); - - it('should register all signer commitments', () => { - multisig = new SignatureMintBurnSimulator( - INSTANCE_SALT, - INIT_COIN_NONCE, - TOKEN_DOMAIN, - SIGNER_COMMITMENTS, - ); - for (const commitment of SIGNER_COMMITMENTS) { - expect(multisig.isSigner(commitment)).toEqual(true); - } - }); - - it('should reject a non-signer commitment', () => { - multisig = new SignatureMintBurnSimulator( - INSTANCE_SALT, - INIT_COIN_NONCE, - TOKEN_DOMAIN, - SIGNER_COMMITMENTS, - ); - const unknown = multisig._calculateSignerId(NON_SIGNER_PK, INSTANCE_SALT); - expect(multisig.isSigner(unknown)).toEqual(false); - }); - - it('should fail with duplicate signer commitments', () => { - expect(() => { - new SignatureMintBurnSimulator( - INSTANCE_SALT, - INIT_COIN_NONCE, - TOKEN_DOMAIN, - [COMMITMENT1, COMMITMENT1, COMMITMENT2], - ); - }).toThrow('SignerManager: signer already active'); - }); - - it('should store token domain', () => { - multisig = new SignatureMintBurnSimulator( - INSTANCE_SALT, - INIT_COIN_NONCE, - TOKEN_DOMAIN, - SIGNER_COMMITMENTS, - ); - expect(multisig.getTokenDomain()).toEqual(TOKEN_DOMAIN); - }); - }); - - describe('when initialized', () => { - beforeEach(() => { - multisig = new SignatureMintBurnSimulator( - INSTANCE_SALT, - INIT_COIN_NONCE, - TOKEN_DOMAIN, - SIGNER_COMMITMENTS, - ); - }); - - describe('view', () => { - it('getNonce should start at 0', () => { - expect(multisig.getNonce()).toEqual(0n); - }); - - it('getSignerCount should return 3', () => { - expect(multisig.getSignerCount()).toEqual(3n); - }); - - it('getThreshold should match constructor arg', () => { - expect(multisig.getThreshold()).toEqual(2n); - }); - - it('getTokenType should return non-zero', () => { - expect(multisig.getTokenType()).not.toEqual(new Uint8Array(32)); - }); - - it('getTokenType should be deterministic', () => { - expect(multisig.getTokenType()).toEqual(multisig.getTokenType()); - }); - }); - - describe('_calculateSignerId', () => { - it('should produce deterministic commitments', () => { - const c1 = multisig._calculateSignerId(PK1, INSTANCE_SALT); - const c2 = multisig._calculateSignerId(PK1, INSTANCE_SALT); - expect(c1).toEqual(c2); - }); - - it('should produce different commitments for different keys', () => { - const c1 = multisig._calculateSignerId(PK1, INSTANCE_SALT); - const c2 = multisig._calculateSignerId(PK2, INSTANCE_SALT); - expect(c1).not.toEqual(c2); - }); - - it('should produce different commitments for different salts', () => { - const salt2 = new Uint8Array(32).fill(0xcc); - const c1 = multisig._calculateSignerId(PK1, INSTANCE_SALT); - const c2 = multisig._calculateSignerId(PK1, salt2); - expect(c1).not.toEqual(c2); - }); - - it('should match registered commitments', () => { - expect(multisig._calculateSignerId(PK1, INSTANCE_SALT)).toEqual( - COMMITMENT1, - ); - expect(multisig._calculateSignerId(PK2, INSTANCE_SALT)).toEqual( - COMMITMENT2, - ); - expect(multisig._calculateSignerId(PK3, INSTANCE_SALT)).toEqual( - COMMITMENT3, - ); - }); - }); - - describe('mint', () => { - it('should mint to a user recipient with signers 0 and 1', () => { - expect(() => { - multisig.mint( - 100n, - USER_RECIPIENT, - [PK1, PK2], - [DUMMY_SIG, DUMMY_SIG], - ); - }).not.toThrow(); - }); - - it('should mint to a user recipient with signers 0 and 2', () => { - expect(() => { - multisig.mint( - 100n, - USER_RECIPIENT, - [PK1, PK3], - [DUMMY_SIG, DUMMY_SIG], - ); - }).not.toThrow(); - }); - - it('should mint to a user recipient with signers 1 and 2', () => { - expect(() => { - multisig.mint( - 100n, - USER_RECIPIENT, - [PK2, PK3], - [DUMMY_SIG, DUMMY_SIG], - ); - }).not.toThrow(); - }); - - it('should mint to a contract recipient', () => { - expect(() => { - multisig.mint( - 100n, - CONTRACT_RECIPIENT, - [PK1, PK2], - [DUMMY_SIG, DUMMY_SIG], - ); - }).not.toThrow(); - }); - - it('should reject duplicate signer', () => { - expect(() => { - multisig.mint( - 100n, - USER_RECIPIENT, - [PK1, PK1], - [DUMMY_SIG, DUMMY_SIG], - ); - }).toThrow('EcdsaSignerManager: duplicate signer'); - }); - - it('should reject a non-signer pubkey', () => { - expect(() => { - multisig.mint( - 100n, - USER_RECIPIENT, - [PK1, NON_SIGNER_PK], - [DUMMY_SIG, DUMMY_SIG], - ); - }).toThrow('SignerManager: not a signer'); - }); - - it('should increment nonce after mint', () => { - expect(multisig.getNonce()).toEqual(0n); - multisig.mint(100n, USER_RECIPIENT, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]); - expect(multisig.getNonce()).toEqual(1n); - }); - - it('should increment nonce on each mint', () => { - multisig.mint(100n, USER_RECIPIENT, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]); - multisig.mint(200n, USER_RECIPIENT, [PK1, PK3], [DUMMY_SIG, DUMMY_SIG]); - multisig.mint( - 300n, - CONTRACT_RECIPIENT, - [PK2, PK3], - [DUMMY_SIG, DUMMY_SIG], - ); - expect(multisig.getNonce()).toEqual(3n); - }); - - it('should accept zero amount', () => { - expect(() => { - multisig.mint(0n, USER_RECIPIENT, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]); - }).not.toThrow(); - }); - - it('should prevent replay by incrementing nonce', () => { - multisig.mint(100n, USER_RECIPIENT, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]); - // Second mint with same params succeeds because nonce is different - // (stub ver doesn't actually check signatures) - expect(() => { - multisig.mint( - 100n, - USER_RECIPIENT, - [PK1, PK2], - [DUMMY_SIG, DUMMY_SIG], - ); - }).not.toThrow(); - expect(multisig.getNonce()).toEqual(2n); - }); - }); - - describe('burn', () => { - it('should burn with valid coin and signers 0 and 1', () => { - const coin = makeQualifiedCoin(multisig.getTokenType(), 100n); - expect(() => { - multisig.burn(coin, 100n, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]); - }).not.toThrow(); - }); - - it('should burn with signers 0 and 2', () => { - const coin = makeQualifiedCoin(multisig.getTokenType(), 100n); - expect(() => { - multisig.burn(coin, 100n, [PK1, PK3], [DUMMY_SIG, DUMMY_SIG]); - }).not.toThrow(); - }); - - it('should burn with signers 1 and 2', () => { - const coin = makeQualifiedCoin(multisig.getTokenType(), 100n); - expect(() => { - multisig.burn(coin, 100n, [PK2, PK3], [DUMMY_SIG, DUMMY_SIG]); - }).not.toThrow(); - }); - - it('should burn partial amount', () => { - const coin = makeQualifiedCoin(multisig.getTokenType(), 100n); - expect(() => { - multisig.burn(coin, 50n, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]); - }).not.toThrow(); - }); - - it('should handle zero burn amount', () => { - const coin = makeQualifiedCoin(multisig.getTokenType(), 100n); - expect(() => { - multisig.burn(coin, 0n, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]); - }).not.toThrow(); - }); - - it('should reject duplicate signer', () => { - const coin = makeQualifiedCoin(multisig.getTokenType(), 100n); - expect(() => { - multisig.burn(coin, 100n, [PK1, PK1], [DUMMY_SIG, DUMMY_SIG]); - }).toThrow('EcdsaSignerManager: duplicate signer'); - }); - - it('should reject a non-signer pubkey', () => { - const coin = makeQualifiedCoin(multisig.getTokenType(), 100n); - expect(() => { - multisig.burn( - coin, - 100n, - [PK1, NON_SIGNER_PK], - [DUMMY_SIG, DUMMY_SIG], - ); - }).toThrow('SignerManager: not a signer'); - }); - - it('should reject wrong token color', () => { - const wrongColor = new Uint8Array(32).fill(0xde); - const coin = makeQualifiedCoin(wrongColor, 100n); - expect(() => { - multisig.burn(coin, 100n, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]); - }).toThrow('SignatureMintBurn: coin not from this contract'); - }); - - it('should reject insufficient coin value', () => { - const coin = makeQualifiedCoin(multisig.getTokenType(), 10n); - expect(() => { - multisig.burn(coin, 100n, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]); - }).toThrow('SignatureMintBurn: insufficient coin value'); - }); - - it('should reject when amount exceeds value by 1', () => { - const coin = makeQualifiedCoin(multisig.getTokenType(), 99n); - expect(() => { - multisig.burn(coin, 100n, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]); - }).toThrow('SignatureMintBurn: insufficient coin value'); - }); - - it('should share nonce across mint and burn', () => { - multisig.mint(100n, USER_RECIPIENT, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]); - expect(multisig.getNonce()).toEqual(1n); - - const coin = makeQualifiedCoin(multisig.getTokenType(), 100n); - multisig.burn(coin, 50n, [PK1, PK3], [DUMMY_SIG, DUMMY_SIG]); - expect(multisig.getNonce()).toEqual(2n); - }); - }); - - describe('domain separation', () => { - it('should isolate signers across instances with different salts', () => { - const salt2 = new Uint8Array(32).fill(0xcc); - const c1 = multisig._calculateSignerId(PK1, INSTANCE_SALT); - const c2 = multisig._calculateSignerId(PK1, salt2); - expect(c1).not.toEqual(c2); - }); - - it('should derive different token types with different domains', () => { - const altDomain = new Uint8Array(32); - Buffer.from('alt:token:').copy(altDomain); - - const alt = new SignatureMintBurnSimulator( - INSTANCE_SALT, - INIT_COIN_NONCE, - altDomain, - SIGNER_COMMITMENTS, - ); - - expect(multisig.getTokenType()).not.toEqual(alt.getTokenType()); - }); - }); - - describe('nonce', () => { - it('should start at 0', () => { - expect(multisig.getNonce()).toEqual(0n); - }); - - it('should increment monotonically', () => { - for (let i = 0; i < 5; i++) { - multisig.mint(1n, USER_RECIPIENT, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]); - expect(multisig.getNonce()).toEqual(BigInt(i + 1)); - } - }); - }); - - describe('cross-instance replay', () => { - it('should derive different message hashes for different instances', () => { - const instance2 = new SignatureMintBurnSimulator( - INSTANCE_SALT, - INIT_COIN_NONCE, - TOKEN_DOMAIN, - SIGNER_COMMITMENTS, - ); - - // With stub verification, both succeed independently. - // Once real ECDSA is available, a signature produced for one - // instance's message hash must not validate against the other's. - multisig.mint(100n, USER_RECIPIENT, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]); - instance2.mint( - 100n, - USER_RECIPIENT, - [PK1, PK2], - [DUMMY_SIG, DUMMY_SIG], - ); - - expect(multisig.getNonce()).toEqual(1n); - expect(instance2.getNonce()).toEqual(1n); - }); - }); - }); -}); diff --git a/contracts/src/multisig/test/SignatureTreasury.test.ts b/contracts/src/multisig/test/SignatureTreasury.test.ts deleted file mode 100644 index 592ee639..00000000 --- a/contracts/src/multisig/test/SignatureTreasury.test.ts +++ /dev/null @@ -1,226 +0,0 @@ -import { beforeEach, describe, expect, it } from 'vitest'; -import { SignatureTreasurySimulator } from './simulators/SignatureTreasurySimulator.js'; - -const RecipientKind = { ShieldedUser: 0, UnshieldedUser: 1, Contract: 2 }; - -const INSTANCE_SALT = new Uint8Array(32).fill(0xaa); -const COLOR = new Uint8Array(32).fill(1); -const AMOUNT = 1000n; - -const PK1 = new Uint8Array(64).fill(0x11); -const PK2 = new Uint8Array(64).fill(0x22); -const PK3 = new Uint8Array(64).fill(0x33); -const NON_SIGNER_PK = new Uint8Array(64).fill(0x99); - -const COMMITMENT1 = SignatureTreasurySimulator.calculateSignerId( - PK1, - INSTANCE_SALT, -); -const COMMITMENT2 = SignatureTreasurySimulator.calculateSignerId( - PK2, - INSTANCE_SALT, -); -const COMMITMENT3 = SignatureTreasurySimulator.calculateSignerId( - PK3, - INSTANCE_SALT, -); -const SIGNER_COMMITMENTS = [COMMITMENT1, COMMITMENT2, COMMITMENT3]; - -const DUMMY_SIG = new Uint8Array(64).fill(0xff); - -function makeRecipient(address: Uint8Array): { - kind: number; - address: Uint8Array; -} { - return { kind: RecipientKind.ShieldedUser, address }; -} - -function makeCoin( - color: Uint8Array, - value: bigint, - nonce?: Uint8Array, -): { nonce: Uint8Array; color: Uint8Array; value: bigint } { - return { - nonce: nonce ?? new Uint8Array(32).fill(0), - color, - value, - }; -} - -function makeQualifiedCoin( - color: Uint8Array, - value: bigint, - mtIndex: bigint, - nonce?: Uint8Array, -): { - nonce: Uint8Array; - color: Uint8Array; - value: bigint; - mt_index: bigint; -} { - return { - nonce: nonce ?? new Uint8Array(32).fill(0), - color, - value, - mt_index: mtIndex, - }; -} - -let multisig: SignatureTreasurySimulator; - -describe('SignatureTreasury', () => { - describe('constructor', () => { - it('should initialize with 2-of-3 threshold', () => { - multisig = new SignatureTreasurySimulator( - INSTANCE_SALT, - SIGNER_COMMITMENTS, - 2n, - ); - expect(multisig.getSignerCount()).toEqual(3n); - expect(multisig.getThreshold()).toEqual(2n); - }); - - it('should initialize with 1-of-3 threshold', () => { - multisig = new SignatureTreasurySimulator( - INSTANCE_SALT, - SIGNER_COMMITMENTS, - 1n, - ); - expect(multisig.getThreshold()).toEqual(1n); - }); - - it('should fail with zero threshold', () => { - expect(() => { - new SignatureTreasurySimulator(INSTANCE_SALT, SIGNER_COMMITMENTS, 0n); - }).toThrow('SignerManager: threshold must not be zero'); - }); - - it('should fail with threshold exceeding signer count', () => { - expect(() => { - new SignatureTreasurySimulator(INSTANCE_SALT, SIGNER_COMMITMENTS, 4n); - }).toThrow('SignerManager: threshold exceeds signer count'); - }); - - it('should register all signer commitments', () => { - multisig = new SignatureTreasurySimulator( - INSTANCE_SALT, - SIGNER_COMMITMENTS, - 2n, - ); - for (const commitment of SIGNER_COMMITMENTS) { - expect(multisig.isSigner(commitment)).toEqual(true); - } - }); - - it('should reject a non-signer commitment', () => { - multisig = new SignatureTreasurySimulator( - INSTANCE_SALT, - SIGNER_COMMITMENTS, - 2n, - ); - const unknown = SignatureTreasurySimulator.calculateSignerId( - NON_SIGNER_PK, - INSTANCE_SALT, - ); - expect(multisig.isSigner(unknown)).toEqual(false); - }); - - it('should fail with duplicate signer commitments', () => { - expect(() => { - new SignatureTreasurySimulator( - INSTANCE_SALT, - [COMMITMENT1, COMMITMENT1, COMMITMENT2], - 2n, - ); - }).toThrow('SignerManager: signer already active'); - }); - }); - - describe('when initialized', () => { - beforeEach(() => { - multisig = new SignatureTreasurySimulator( - INSTANCE_SALT, - SIGNER_COMMITMENTS, - 2n, - ); - }); - - describe('view', () => { - it('getNonce should start at 0', () => { - expect(multisig.getNonce()).toEqual(0n); - }); - - it('getSignerCount should return 3', () => { - expect(multisig.getSignerCount()).toEqual(3n); - }); - - it('getThreshold should match constructor arg', () => { - expect(multisig.getThreshold()).toEqual(2n); - }); - }); - - describe('_calculateSignerId', () => { - it('should be deterministic for the same key and salt', () => { - expect( - SignatureTreasurySimulator.calculateSignerId(PK1, INSTANCE_SALT), - ).toStrictEqual(COMMITMENT1); - }); - - it('should produce a different commitment for a different salt', () => { - const otherSalt = new Uint8Array(32).fill(0xcc); - expect( - SignatureTreasurySimulator.calculateSignerId(PK1, otherSalt), - ).not.toStrictEqual(COMMITMENT1); - }); - }); - - describe('deposit', () => { - it('should accept deposits without reverting', () => { - expect(() => { - multisig.deposit(makeCoin(COLOR, AMOUNT)); - }).not.toThrow(); - }); - }); - - describe('execute', () => { - it('should reject duplicate signer', () => { - const to = makeRecipient(new Uint8Array(32).fill(7)); - const coin = makeQualifiedCoin(COLOR, AMOUNT, 0n); - expect(() => { - multisig.execute(to, 100n, coin, [PK1, PK1], [DUMMY_SIG, DUMMY_SIG]); - }).toThrow('EcdsaSignerManager: duplicate signer'); - }); - - it('should reject a non-signer pubkey', () => { - const to = makeRecipient(new Uint8Array(32).fill(7)); - const coin = makeQualifiedCoin(COLOR, AMOUNT, 0n); - expect(() => { - multisig.execute( - to, - 100n, - coin, - [PK1, NON_SIGNER_PK], - [DUMMY_SIG, DUMMY_SIG], - ); - }).toThrow('SignerManager: not a signer'); - }); - }); - - describe('execute — threshold above the 2-signature surface', () => { - it('should reject when threshold exceeds verifiable signatures', () => { - // A 3-of-3 instance can never satisfy `execute`, which verifies at most - // two signatures. Two valid distinct signers still fall short. - const strict = new SignatureTreasurySimulator( - INSTANCE_SALT, - SIGNER_COMMITMENTS, - 3n, - ); - const to = makeRecipient(new Uint8Array(32).fill(7)); - const coin = makeQualifiedCoin(COLOR, AMOUNT, 0n); - expect(() => { - strict.execute(to, 100n, coin, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]); - }).toThrow('SignerManager: threshold not met'); - }); - }); - }); -}); diff --git a/contracts/src/multisig/test/mocks/MockProposalTreasury.compact b/contracts/src/multisig/test/mocks/MockProposalTreasury.compact deleted file mode 100644 index d10c6a29..00000000 --- a/contracts/src/multisig/test/mocks/MockProposalTreasury.compact +++ /dev/null @@ -1,109 +0,0 @@ -// SPDX-License-Identifier: MIT - -// WARNING: FOR TESTING PURPOSES ONLY. -// This contract exposes the `ProposalTreasury` example module as a deployable -// contract so the simulator can exercise it. DO NOT deploy or use this contract -// in any production application. - -pragma language_version >= 0.23.0; - -import CompactStandardLibrary; - -import "../../examples/ProposalTreasury" prefix Proposal_; -import "../../proposal/ProposalManager" prefix ProposalManager_; - -// ─── Constructor ──────────────────────────────────────────────── - -constructor( - signers: Vector<3, Either>, - thresh: Uint<8> -) { - Proposal_initialize<3>(signers, thresh); -} - -// ─── Circuits (delegated to ProposalTreasury) ─────────────────── - -export circuit deposit(coin: ShieldedCoinInfo): [] { - Proposal_deposit(coin); -} - -export circuit createShieldedProposal( - to: ProposalManager_Recipient, - color: Bytes<32>, - amount: Uint<128> -): Uint<64> { - return Proposal_createShieldedProposal(to, color, amount); -} - -export circuit approveProposal(id: Uint<64>): [] { - Proposal_approveProposal(id); -} - -export circuit revokeApproval(id: Uint<64>): [] { - Proposal_revokeApproval(id); -} - -export circuit executeShieldedProposal(id: Uint<64>): ShieldedSendResult { - return Proposal_executeShieldedProposal(id); -} - -// ─── View ─────────────────────────────────────────────────────── - -export circuit isProposalApprovedBySigner( - id: Uint<64>, - signer: Either -): Boolean { - return Proposal_isProposalApprovedBySigner(id, signer); -} - -export circuit getApprovalCount(id: Uint<64>): Uint<8> { - return Proposal_getApprovalCount(id); -} - -export circuit getProposal(id: Uint<64>): ProposalManager_Proposal { - return Proposal_getProposal(id); -} - -export circuit getProposalRecipient(id: Uint<64>): ProposalManager_Recipient { - return Proposal_getProposalRecipient(id); -} - -export circuit getProposalAmount(id: Uint<64>): Uint<128> { - return Proposal_getProposalAmount(id); -} - -export circuit getProposalColor(id: Uint<64>): Bytes<32> { - return Proposal_getProposalColor(id); -} - -export circuit getProposalStatus(id: Uint<64>): ProposalManager_ProposalStatus { - return Proposal_getProposalStatus(id); -} - -export circuit getTokenBalance(color: Bytes<32>): Uint<128> { - return Proposal_getTokenBalance(color); -} - -export circuit getReceivedTotal(color: Bytes<32>): Uint<128> { - return Proposal_getReceivedTotal(color); -} - -export circuit getSentTotal(color: Bytes<32>): Uint<128> { - return Proposal_getSentTotal(color); -} - -export circuit getReceivedMinusSent(color: Bytes<32>): Uint<128> { - return Proposal_getReceivedMinusSent(color); -} - -export circuit getSignerCount(): Uint<8> { - return Proposal_getSignerCount(); -} - -export circuit getThreshold(): Uint<8> { - return Proposal_getThreshold(); -} - -export circuit isSigner(account: Either): Boolean { - return Proposal_isSigner(account); -} diff --git a/contracts/src/multisig/test/mocks/MockSignatureMintBurn.compact b/contracts/src/multisig/test/mocks/MockSignatureMintBurn.compact deleted file mode 100644 index 41d44b85..00000000 --- a/contracts/src/multisig/test/mocks/MockSignatureMintBurn.compact +++ /dev/null @@ -1,76 +0,0 @@ -// SPDX-License-Identifier: MIT - -// WARNING: FOR TESTING PURPOSES ONLY. -// This contract exposes the `SignatureMintBurn` example module as a deployable -// contract so the simulator can exercise it. DO NOT deploy or use this contract -// in any production application. - -pragma language_version >= 0.23.0; - -import CompactStandardLibrary; - -import "../../examples/SignatureMintBurn" prefix Token_; -// For testing -export { ZswapCoinPublicKey }; - -// ─── Constructor ──────────────────────────────────────────────── - -constructor( - instanceSalt: Bytes<32>, - initCoinNonce: Bytes<32>, - tokenDomain: Bytes<32>, - signerCommitments: Vector<3, Bytes<32>>, -) { - Token_initialize<3>(instanceSalt, signerCommitments, 2); - Token_initializeToken(tokenDomain, initCoinNonce); -} - -// ─── Circuits (delegated to SignatureMintBurn) ────────────────── - -export circuit mint( - amount: Uint<64>, - recipient: Either, - pubkeys: Vector<2, Bytes<64>>, - signatures: Vector<2, Bytes<64>> -): [] { - Token_mint(amount, recipient, pubkeys, signatures); -} - -export circuit burn( - coin: QualifiedShieldedCoinInfo, - amount: Uint<64>, - pubkeys: Vector<2, Bytes<64>>, - signatures: Vector<2, Bytes<64>> -): [] { - Token_burn(coin, amount, pubkeys, signatures); -} - -export pure circuit _calculateSignerId(pk: Bytes<64>, salt: Bytes<32>): Bytes<32> { - return Token__calculateSignerId(pk, salt); -} - -// ─── View ─────────────────────────────────────────────────────── - -export circuit getNonce(): Uint<64> { - return Token_getNonce(); -} - -export circuit getTokenDomain(): Bytes<32> { - return Token_getTokenDomain(); -} - -export circuit getTokenType(): Bytes<32> { - return Token_getTokenType(); -} - -export circuit getSignerCount(): Uint<8> { - return Token_getSignerCount(); -} - -export circuit getThreshold(): Uint<8> { - return Token_getThreshold(); -} - -export circuit isSigner(commitment: Bytes<32>): Boolean { - return Token_isSigner(commitment); -} diff --git a/contracts/src/multisig/test/mocks/MockSignatureTreasury.compact b/contracts/src/multisig/test/mocks/MockSignatureTreasury.compact deleted file mode 100644 index 8cef871d..00000000 --- a/contracts/src/multisig/test/mocks/MockSignatureTreasury.compact +++ /dev/null @@ -1,61 +0,0 @@ -// SPDX-License-Identifier: MIT - -// WARNING: FOR TESTING PURPOSES ONLY. -// This contract exposes the `SignatureTreasury` example module as a deployable -// contract so the simulator can exercise it. DO NOT deploy or use this contract -// in any production application. - -pragma language_version >= 0.23.0; - -import CompactStandardLibrary; - -import "../../examples/SignatureTreasury" prefix Treasury_; -import "../../proposal/ProposalManager" prefix Proposal_; - -// ─── Constructor ──────────────────────────────────────────────── - -constructor( - instanceSalt: Bytes<32>, - signerCommitments: Vector<3, Bytes<32>>, - thresh: Uint<8>, -) { - Treasury_initialize<3>(instanceSalt, signerCommitments, thresh); -} - -// ─── Circuits (delegated to SignatureTreasury) ────────────────── - -export circuit deposit(coin: ShieldedCoinInfo): [] { - Treasury_deposit(coin); -} - -export circuit execute( - to: Proposal_Recipient, - amount: Uint<128>, - coin: QualifiedShieldedCoinInfo, - pubkeys: Vector<2, Bytes<64>>, - signatures: Vector<2, Bytes<64>> -): ShieldedSendResult { - return Treasury_execute(to, amount, coin, pubkeys, signatures); -} - -export pure circuit _calculateSignerId(pk: Bytes<64>, salt: Bytes<32>): Bytes<32> { - return Treasury__calculateSignerId(pk, salt); -} - -// ─── View ─────────────────────────────────────────────────────── - -export circuit getNonce(): Uint<64> { - return Treasury_getNonce(); -} - -export circuit getSignerCount(): Uint<8> { - return Treasury_getSignerCount(); -} - -export circuit getThreshold(): Uint<8> { - return Treasury_getThreshold(); -} - -export circuit isSigner(commitment: Bytes<32>): Boolean { - return Treasury_isSigner(commitment); -} diff --git a/contracts/src/multisig/test/simulators/ProposalTreasurySimulator.ts b/contracts/src/multisig/test/simulators/ProposalTreasurySimulator.ts deleted file mode 100644 index 1b45041e..00000000 --- a/contracts/src/multisig/test/simulators/ProposalTreasurySimulator.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { - type BaseSimulatorOptions, - createSimulator, -} from '@openzeppelin/compact-simulator'; -import { - type Ledger, - ledger, - Contract as MockProposalTreasury, -} from '../../../../artifacts/MockProposalTreasury/contract/index.js'; -import { EmptyPrivateState, emptyWitnesses } from '../EmptyWitnesses.js'; - -type EitherPKAddress = { - is_left: boolean; - left: { bytes: Uint8Array }; - right: { bytes: Uint8Array }; -}; -type Recipient = { kind: number; address: Uint8Array }; -type ShieldedCoinInfo = { nonce: Uint8Array; color: Uint8Array; value: bigint }; -type ShieldedSendResult = { - change: { is_some: boolean; value: ShieldedCoinInfo }; - sent: ShieldedCoinInfo; -}; -type Proposal = { - to: Recipient; - color: Uint8Array; - amount: bigint; - status: number; -}; - -type ProposalTreasuryArgs = readonly [ - signers: EitherPKAddress[], - thresh: bigint, -]; - -const ProposalTreasurySimulatorBase = createSimulator< - EmptyPrivateState, - ReturnType, - ReturnType, - MockProposalTreasury, - ProposalTreasuryArgs ->({ - contractFactory: (witnesses) => - new MockProposalTreasury(witnesses), - defaultPrivateState: () => EmptyPrivateState, - contractArgs: (signers, thresh) => [signers, thresh], - ledgerExtractor: (state) => ledger(state), - witnessesFactory: () => emptyWitnesses(), -}); - -export class ProposalTreasurySimulator extends ProposalTreasurySimulatorBase { - constructor( - signers: EitherPKAddress[], - thresh: bigint, - options: BaseSimulatorOptions< - EmptyPrivateState, - ReturnType - > = {}, - ) { - super([signers, thresh], options); - } - - // Deposit - public deposit(coin: ShieldedCoinInfo) { - return this.circuits.impure.deposit(coin); - } - - // Proposals - public createShieldedProposal( - to: Recipient, - color: Uint8Array, - amount: bigint, - ): bigint { - return this.circuits.impure.createShieldedProposal(to, color, amount); - } - - public approveProposal(id: bigint) { - return this.circuits.impure.approveProposal(id); - } - - public revokeApproval(id: bigint) { - return this.circuits.impure.revokeApproval(id); - } - - public executeShieldedProposal(id: bigint): ShieldedSendResult { - return this.circuits.impure.executeShieldedProposal(id); - } - - // View - Approvals - public isProposalApprovedBySigner( - id: bigint, - signer: EitherPKAddress, - ): boolean { - return this.circuits.impure.isProposalApprovedBySigner(id, signer); - } - - public getApprovalCount(id: bigint): bigint { - return this.circuits.impure.getApprovalCount(id); - } - - // View - Proposals - public getProposal(id: bigint): Proposal { - return this.circuits.impure.getProposal(id); - } - - public getProposalRecipient(id: bigint): Recipient { - return this.circuits.impure.getProposalRecipient(id); - } - - public getProposalAmount(id: bigint): bigint { - return this.circuits.impure.getProposalAmount(id); - } - - public getProposalColor(id: bigint): Uint8Array { - return this.circuits.impure.getProposalColor(id); - } - - public getProposalStatus(id: bigint): number { - return this.circuits.impure.getProposalStatus(id); - } - - // View - Treasury - public getTokenBalance(color: Uint8Array): bigint { - return this.circuits.impure.getTokenBalance(color); - } - - public getReceivedTotal(color: Uint8Array): bigint { - return this.circuits.impure.getReceivedTotal(color); - } - - public getSentTotal(color: Uint8Array): bigint { - return this.circuits.impure.getSentTotal(color); - } - - public getReceivedMinusSent(color: Uint8Array): bigint { - return this.circuits.impure.getReceivedMinusSent(color); - } - - // View - Signers - public getSignerCount(): bigint { - return this.circuits.impure.getSignerCount(); - } - - public getThreshold(): bigint { - return this.circuits.impure.getThreshold(); - } - - public isSigner(account: EitherPKAddress): boolean { - return this.circuits.impure.isSigner(account); - } - - // Ledger access - public getLedger(): Ledger { - return this.getPublicState(); - } -} diff --git a/contracts/src/multisig/test/simulators/SignatureMintBurnSimulator.ts b/contracts/src/multisig/test/simulators/SignatureMintBurnSimulator.ts deleted file mode 100644 index 24bd06b1..00000000 --- a/contracts/src/multisig/test/simulators/SignatureMintBurnSimulator.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { - type BaseSimulatorOptions, - createSimulator, -} from '@openzeppelin/compact-simulator'; -import { - ledger, - pureCircuits, - Contract as MockSignatureMintBurn, - type ZswapCoinPublicKey, -} from '../../../../artifacts/MockSignatureMintBurn/contract/index.js'; -import { EmptyPrivateState, emptyWitnesses } from '../EmptyWitnesses.js'; - -type SignatureMintBurnArgs = readonly [ - instanceSalt: Uint8Array, - initCoinNonce: Uint8Array, - tokenDomain: Uint8Array, - signerCommitments: Uint8Array[], -]; - -const SignatureMintBurnSimulatorBase = createSimulator< - EmptyPrivateState, - ReturnType, - ReturnType, - MockSignatureMintBurn, - SignatureMintBurnArgs ->({ - contractFactory: (witnesses) => - new MockSignatureMintBurn(witnesses), - defaultPrivateState: () => EmptyPrivateState, - contractArgs: (instanceSalt, initCoinNonce, tokenDomain, signerCommitments) => [ - instanceSalt, - initCoinNonce, - tokenDomain, - signerCommitments, - ], - ledgerExtractor: (state) => ledger(state), - witnessesFactory: () => emptyWitnesses(), -}); - -export class SignatureMintBurnSimulator extends SignatureMintBurnSimulatorBase { - constructor( - instanceSalt: Uint8Array, - initCoinNonce: Uint8Array, - tokenDomain: Uint8Array, - signerCommitments: Uint8Array[], - options: BaseSimulatorOptions< - EmptyPrivateState, - ReturnType - > = {}, - ) { - super( - [instanceSalt, initCoinNonce, tokenDomain, signerCommitments], - options, - ); - } - - public _calculateSignerId(pk: Uint8Array, salt: Uint8Array): Uint8Array { - return this.circuits.pure._calculateSignerId(pk, salt); - } - - public mint( - amount: bigint, - recipient: Either, - pubkeys: Uint8Array[], - signatures: Uint8Array[], - ) { - return this.circuits.impure.mint(amount, recipient, pubkeys, signatures); - } - - public burn( - coin: { - nonce: Uint8Array; - color: Uint8Array; - value: bigint; - mt_index: bigint; - }, - amount: bigint, - pubkeys: Uint8Array[], - signatures: Uint8Array[], - ) { - return this.circuits.impure.burn(coin, amount, pubkeys, signatures); - } - - public getNonce(): bigint { - return this.circuits.impure.getNonce(); - } - - public getTokenDomain(): Uint8Array { - return this.circuits.impure.getTokenDomain(); - } - - public getTokenType(): Uint8Array { - return this.circuits.impure.getTokenType(); - } - - public getSignerCount(): bigint { - return this.circuits.impure.getSignerCount(); - } - - public getThreshold(): bigint { - return this.circuits.impure.getThreshold(); - } - - public isSigner(commitment: Uint8Array): boolean { - return this.circuits.impure.isSigner(commitment); - } -} - -// Computes signer commitment from `pk`, `salt`, and -// domain ("multisig:signer:"). Pure standalone circuit so commitments can be -// calculated before contract instantiation. -export function calculateSignerId( - pk: Uint8Array, - salt: Uint8Array, -): Uint8Array { - return pureCircuits._calculateSignerId(pk, salt); -} diff --git a/contracts/src/multisig/test/simulators/SignatureTreasurySimulator.ts b/contracts/src/multisig/test/simulators/SignatureTreasurySimulator.ts deleted file mode 100644 index 21786212..00000000 --- a/contracts/src/multisig/test/simulators/SignatureTreasurySimulator.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { - type BaseSimulatorOptions, - createSimulator, -} from '@openzeppelin/compact-simulator'; -import { - ledger, - pureCircuits, - Contract as MockSignatureTreasury, -} from '../../../../artifacts/MockSignatureTreasury/contract/index.js'; -import { EmptyPrivateState, emptyWitnesses } from '../EmptyWitnesses.js'; - -type Recipient = { kind: number; address: Uint8Array }; -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 SignatureTreasuryArgs = readonly [ - instanceSalt: Uint8Array, - signerCommitments: Uint8Array[], - thresh: bigint, -]; - -const SignatureTreasurySimulatorBase = createSimulator< - EmptyPrivateState, - ReturnType, - ReturnType, - MockSignatureTreasury, - SignatureTreasuryArgs ->({ - contractFactory: (witnesses) => - new MockSignatureTreasury(witnesses), - defaultPrivateState: () => EmptyPrivateState, - contractArgs: (instanceSalt, signerCommitments, thresh) => [ - instanceSalt, - signerCommitments, - thresh, - ], - ledgerExtractor: (state) => ledger(state), - witnessesFactory: () => emptyWitnesses(), -}); - -export class SignatureTreasurySimulator extends SignatureTreasurySimulatorBase { - constructor( - instanceSalt: Uint8Array, - signerCommitments: Uint8Array[], - thresh: bigint, - options: BaseSimulatorOptions< - EmptyPrivateState, - ReturnType - > = {}, - ) { - super([instanceSalt, signerCommitments, thresh], options); - } - - public static calculateSignerId( - pk: Uint8Array, - salt: Uint8Array, - ): Uint8Array { - return pureCircuits._calculateSignerId(pk, salt); - } - - public deposit(coin: ShieldedCoinInfo) { - return this.circuits.impure.deposit(coin); - } - - public execute( - to: Recipient, - amount: bigint, - coin: QualifiedShieldedCoinInfo, - pubkeys: Uint8Array[], - signatures: Uint8Array[], - ): ShieldedSendResult { - return this.circuits.impure.execute(to, amount, coin, pubkeys, signatures); - } - - public getNonce(): bigint { - return this.circuits.impure.getNonce(); - } - - public getSignerCount(): bigint { - return this.circuits.impure.getSignerCount(); - } - - public getThreshold(): bigint { - return this.circuits.impure.getThreshold(); - } - - public isSigner(commitment: Uint8Array): boolean { - return this.circuits.impure.isSigner(commitment); - } -} diff --git a/contracts/test/integration/_mocks/MultisigProposalTreasury.compact b/contracts/test/integration/_mocks/MultisigProposalTreasury.compact new file mode 100644 index 00000000..7b2060a5 --- /dev/null +++ b/contracts/test/integration/_mocks/MultisigProposalTreasury.compact @@ -0,0 +1,218 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.2.0 (test/integration/_mocks/MultisigProposalTreasury.compact) + +pragma language_version >= 0.23.0; + +import CompactStandardLibrary; + +/** + * @title MultisigProposalTreasury (example) + * @description Example top-level contract: on-chain proposal governance over a + * shielded treasury, authorized by caller identity. Formerly the body of + * `ShieldedMultiSig`. Lives under `test/integration/_mocks/` so it serves as + * both a usage example and an integration-test fixture (composes production + * modules into one deployable contract). + * + * Composes `SignerManager>`, + * `ProposalManager`, and `NativeShieldedTreasury`. Signers create, approve, and + * revoke proposals; once the threshold is met, `executeShieldedProposal` + * transfers from the treasury. Unlike the signature-based examples, authorization + * is by the on-chain caller (`getCaller`), not off-chain signatures. + * + * This example is fixed at a 3-signer registry. + * + * @notice Signer identity uses `Either` for + * forward compatibility. Today only `left(ZswapCoinPublicKey)` callers can + * authenticate — `getCaller()` resolves via `ownPublicKey()` and cannot produce + * a right-variant. Contract-address signers may be registered but cannot exercise + * governance until contract-to-contract calls exist. + */ + +import "../../../src/multisig/proposal/ProposalManager" prefix Proposal_; +import "../../../src/multisig/treasury/NativeShieldedTreasury" prefix Treasury_; +import "../../../src/multisig/SignerManager"> prefix Signer_; + +// ─── State ────────────────────────────────────────────────────── + +export ledger _proposalApprovals: Map, Map, Boolean>>; +export ledger _approvalCount: Map, Uint<8>>; + +// ─── Constructor ──────────────────────────────────────────────── + +/** + * @description Initializes the signer registry (3 signers). + * + * @param {Vector<3, Either>} signers - Signer set. + * @param {Uint<8>} thresh - Minimum approvals required. + */ +constructor( + signers: Vector<3, Either>, + thresh: Uint<8> +) { + Signer_initialize<3>(signers, thresh); +} + +// ─── Deposit ──────────────────────────────────────────────────── + +export circuit deposit(coin: ShieldedCoinInfo): [] { + Treasury__deposit(coin); +} + +// ─── Proposals ────────────────────────────────────────────────── + +export circuit createShieldedProposal( + to: Proposal_Recipient, + color: Bytes<32>, + amount: Uint<128> +): Uint<64> { + const callerPK = getCaller(); + Signer_assertSigner(callerPK); + + assert( + to.kind == Proposal_RecipientKind.ShieldedUser + || to.kind == Proposal_RecipientKind.Contract, + "ProposalTreasury: recipient must be a shielded user or contract" + ); + + return Proposal__createProposal(to, color, amount); +} + +export circuit approveProposal(id: Uint<64>): [] { + Proposal_assertProposalActive(id); + + const callerPK = getCaller(); + Signer_assertSigner(callerPK); + + assert(!isProposalApprovedBySigner(id, callerPK), "ProposalTreasury: already approved"); + + _approveProposal(id, callerPK); +} + +export circuit revokeApproval(id: Uint<64>): [] { + Proposal_assertProposalActive(id); + + const callerPK = getCaller(); + Signer_assertSigner(callerPK); + + assert(isProposalApprovedBySigner(id, callerPK), "ProposalTreasury: not approved"); + + _revokeApproval(id, callerPK); +} + +export circuit executeShieldedProposal(id: Uint<64>): ShieldedSendResult { + Proposal_assertProposalActive(id); + + const approvalCount = getApprovalCount(id); + Signer_assertThresholdMet(approvalCount); + + const { to, color, amount } = Proposal_getProposal(id); + const result = Treasury__send( + Proposal_toShieldedRecipient(to), + color, + amount, + ); + + Proposal__markExecuted(id); + return result; +} + +// ─── Internal ─────────────────────────────────────────────────── + +circuit _approveProposal(id: Uint<64>, signer: Either): [] { + if (!_proposalApprovals.member(disclose(id))) { + _proposalApprovals.insert(disclose(id), default, Boolean>>); + } + + _proposalApprovals.lookup(disclose(id)).insert(disclose(signer), disclose(true)); + + const newCount = getApprovalCount(id) + 1 as Uint<8>; + _approvalCount.insert(disclose(id), disclose(newCount)); +} + +circuit _revokeApproval(id: Uint<64>, signer: Either): [] { + _proposalApprovals.lookup(disclose(id)).remove(disclose(signer)); + + const newCount = getApprovalCount(id) - 1 as Uint<8>; + _approvalCount.insert(disclose(id), disclose(newCount)); +} + +/** + * @description Returns the caller identity used for signer authentication. + * + * @warning Resolves callers via `ownPublicKey()` only, so a `right(ContractAddress)` + * signer cannot authenticate today. + * + * @returns {Either} The caller as a left-variant. + */ +circuit getCaller(): Either { + return left(ownPublicKey()); +} + +// ─── View ─────────────────────────────────────────────────────── + +export circuit isProposalApprovedBySigner( + id: Uint<64>, + signer: Either +): Boolean { + if (!_proposalApprovals.member(disclose(id)) || !_proposalApprovals.lookup(disclose(id)).member(disclose(signer))) { + return false; + } + + return _proposalApprovals.lookup(disclose(id)).lookup(disclose(signer)); +} + +export circuit getApprovalCount(id: Uint<64>): Uint<8> { + if (!_approvalCount.member(disclose(id))) { + return 0; + } + + return _approvalCount.lookup(disclose(id)); +} + +export circuit getProposal(id: Uint<64>): Proposal_Proposal { + return Proposal_getProposal(id); +} + +export circuit getProposalRecipient(id: Uint<64>): Proposal_Recipient { + return Proposal_getProposalRecipient(id); +} + +export circuit getProposalAmount(id: Uint<64>): Uint<128> { + return Proposal_getProposalAmount(id); +} + +export circuit getProposalColor(id: Uint<64>): Bytes<32> { + return Proposal_getProposalColor(id); +} + +export circuit getProposalStatus(id: Uint<64>): Proposal_ProposalStatus { + return Proposal_getProposalStatus(id); +} + +export circuit getTokenBalance(color: Bytes<32>): Uint<128> { + return Treasury_getTokenBalance(color); +} + +export circuit getReceivedTotal(color: Bytes<32>): Uint<128> { + return Treasury_getReceivedTotal(color); +} + +export circuit getSentTotal(color: Bytes<32>): Uint<128> { + return Treasury_getSentTotal(color); +} + +export circuit getReceivedMinusSent(color: Bytes<32>): Uint<128> { + return Treasury_getReceivedMinusSent(color); +} + +export circuit getSignerCount(): Uint<8> { + return Signer_getSignerCount(); +} + +export circuit getThreshold(): Uint<8> { + return Signer_getThreshold(); +} + +export circuit isSigner(account: Either): Boolean { + return Signer_isSigner(account); +} diff --git a/contracts/test/integration/_mocks/MultisigSignatureMintBurn.compact b/contracts/test/integration/_mocks/MultisigSignatureMintBurn.compact new file mode 100644 index 00000000..ebef2e51 --- /dev/null +++ b/contracts/test/integration/_mocks/MultisigSignatureMintBurn.compact @@ -0,0 +1,174 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.2.0 (test/integration/_mocks/MultisigSignatureMintBurn.compact) + +pragma language_version >= 0.23.0; + +import CompactStandardLibrary; + +/** + * @title MultisigSignatureMintBurn (example) + * @description Example top-level contract: signature-authorized mint/burn of a + * native shielded token issued by this contract. Formerly the body of + * `ShieldedMultiSigV3`. Lives under `test/integration/_mocks/` so it serves + * as both a usage example and an integration-test fixture (composes production + * modules into one deployable contract). + * + * `mint` creates a UTXO of this contract's token type via `mintShieldedToken`; + * `burn` consumes one via `sendShielded` to `shieldedBurnAddress()`. Both require + * threshold ECDSA approval verified against the shared `EcdsaSignerManager` + * registry. A counter provides replay protection and feeds `evolveNonce` for + * unique coin nonces. Operation-domain prefixes (`multisig:mint:` / + * `multisig:burn:`) stop a signature for one op being replayed as the other. + * + * This example is fixed at a 3-signer registry with a threshold of 2 and 2 + * presented approvals per operation. + * + * @notice ECDSA verification is stubbed in `EcdsaSignerManager`; replace it (and + * `persistentHash` with `keccak256`) once the Compact primitives are available. + * Not for production deployment. + */ + +import "../../../src/multisig/EcdsaSignerManager" prefix Signer_; +import "../../../src/utils/Utils" prefix Utils_; + +export { ZswapCoinPublicKey }; + +// ─── State ────────────────────────────────────────────────────── + +export ledger _counter: Counter; +export ledger _coinNonce: Bytes<32>; +export sealed ledger _tokenDomain: Bytes<32>; + +// ─── Constructor ──────────────────────────────────────────────── + +/** + * @description Initializes the shared signer registry (3 signers, threshold 2) + * and this contract's token state. + * + * @param {Bytes<32>} instanceSalt - Random salt for commitment derivation. + * @param {Bytes<32>} initCoinNonce - Initial coin-nonce seed (random). + * @param {Bytes<32>} tokenDomain - Domain used with `kernel.self()` to derive + * this contract's token color. + * @param {Vector<3, Bytes<32>>} signerCommitments - Signer commitments. + */ +constructor( + instanceSalt: Bytes<32>, + initCoinNonce: Bytes<32>, + tokenDomain: Bytes<32>, + signerCommitments: Vector<3, Bytes<32>> +) { + Signer_initialize<3>(instanceSalt, signerCommitments, 2); + _tokenDomain = disclose(tokenDomain); + _coinNonce = disclose(initCoinNonce); +} + +// ─── Mint ─────────────────────────────────────────────────────── + +/** + * @description Mints a new shielded coin of this contract's token type to the + * recipient, authorized by threshold signatures. The message hash commits to + * the `multisig:mint:` domain, contract address, recipient, counter, and amount. + * + * @param {Uint<64>} amount - The token amount to mint. + * @param {Either} recipient - Recipient. + * @param {Vector<2, Bytes<64>>} pubkeys - ECDSA public keys of approving signers. + * @param {Vector<2, Bytes<64>>} signatures - Signatures over the mint hash. + * @returns {[]} Empty tuple. + */ +export circuit mint( + amount: Uint<64>, + recipient: Either, + pubkeys: Vector<2, Bytes<64>>, + signatures: Vector<2, Bytes<64>> +): [] { + const opNonce = _counter; + _counter.increment(1); + + const canonRecipient = Utils_canonicalize(recipient); + const recipientHash = persistentHash>(canonRecipient); + + const msgHash = persistentHash>>([ + pad(32, "multisig:mint:"), + kernel.self().bytes, + recipientHash, + opNonce as Bytes<32>, + amount as Bytes<32> + ]); + + Signer_verify<2>(msgHash, pubkeys, signatures); + + _coinNonce = evolveNonce(_counter, _coinNonce); + mintShieldedToken(_tokenDomain, disclose(amount), _coinNonce, disclose(canonRecipient)); +} + +// ─── Burn ─────────────────────────────────────────────────────── + +/** + * @description Burns a coin of this contract's token type to + * `shieldedBurnAddress()`, authorized by threshold signatures. Change from a + * partial burn is handled by the transaction layer. The `multisig:burn:` domain + * prefix prevents replay as a mint. + * + * @param {QualifiedShieldedCoinInfo} coin - The coin to burn (operator pool). + * @param {Uint<64>} amount - The token amount to burn. + * @param {Vector<2, Bytes<64>>} pubkeys - ECDSA public keys of approving signers. + * @param {Vector<2, Bytes<64>>} signatures - Signatures over the burn hash. + * @returns {[]} Empty tuple. + */ +export circuit burn( + coin: QualifiedShieldedCoinInfo, + amount: Uint<64>, + pubkeys: Vector<2, Bytes<64>>, + signatures: Vector<2, Bytes<64>> +): [] { + const opNonce = _counter; + _counter.increment(1); + + const msgHash = persistentHash>>([ + pad(32, "multisig:burn:"), + kernel.self().bytes, + opNonce as Bytes<32>, + amount as Bytes<32> + ]); + + Signer_verify<2>(msgHash, pubkeys, signatures); + + assert(coin.color == tokenType(_tokenDomain, kernel.self()), "SignatureMintBurn: coin not from this contract"); + assert(coin.value >= amount, "SignatureMintBurn: insufficient coin value"); + + sendShielded(disclose(coin), shieldedBurnAddress(), disclose(amount)); +} + +// ─── View ─────────────────────────────────────────────────────── + +/** + * @description Computes a signer commitment from an ECDSA public key. Pure — + * callable off-chain by the deployer. + */ +export pure circuit _calculateSignerId(pk: Bytes<64>, salt: Bytes<32>): Bytes<32> { + return Signer__calculateSignerId(pk, salt); +} + +export circuit getNonce(): Uint<64> { + return _counter; +} + +export circuit getTokenDomain(): Bytes<32> { + return _tokenDomain; +} + +export circuit getTokenType(): Bytes<32> { + return tokenType(_tokenDomain, kernel.self()); +} + +export circuit getSignerCount(): Uint<8> { + return Signer_getSignerCount(); +} + +export circuit getThreshold(): Uint<8> { + return Signer_getThreshold(); +} + +export circuit isSigner(commitment: Bytes<32>): Boolean { + return Signer_isSigner(commitment); +} diff --git a/contracts/test/integration/_mocks/MultisigSignatureTreasury.compact b/contracts/test/integration/_mocks/MultisigSignatureTreasury.compact new file mode 100644 index 00000000..24021fd4 --- /dev/null +++ b/contracts/test/integration/_mocks/MultisigSignatureTreasury.compact @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.2.0 (test/integration/_mocks/MultisigSignatureTreasury.compact) + +pragma language_version >= 0.23.0; + +import CompactStandardLibrary; + +/** + * @title MultisigSignatureTreasury (example) + * @description Example top-level contract: signature-authorized, single-tx spend + * from a stateless shielded treasury. Formerly the body of `ShieldedMultiSigV2`. + * Lives under `test/integration/_mocks/` so it serves as both a usage example + * and an integration-test fixture (composes production modules into one + * deployable contract). + * + * Combines `EcdsaSignerManager` (commitment signer registry + threshold ECDSA + * verification) with `NativeShieldedTreasuryStateless` (custody + send of native + * shielded tokens). Approvals are collected off-chain; `execute` verifies them + * and sends in a single transaction. A monotonic `_nonce` binds each spend to a + * unique message hash for replay protection. + * + * This example is fixed at a 3-signer registry and 2 presented approvals per + * operation. + * + * @notice ECDSA verification is stubbed in `EcdsaSignerManager`; replace it once + * the Compact primitive is available. Not for production deployment. + */ + +import "../../../src/multisig/EcdsaSignerManager" prefix Signer_; +import "../../../src/multisig/treasury/NativeShieldedTreasuryStateless" prefix Treasury_; +import "../../../src/multisig/proposal/ProposalManager" prefix Proposal_; + +// ─── State ────────────────────────────────────────────────────── + +export ledger _nonce: Counter; + +// ─── Constructor ──────────────────────────────────────────────── + +/** + * @description Initializes the shared signer registry and instance salt. + * + * @param {Bytes<32>} instanceSalt - Random salt for commitment derivation. + * @param {Vector<3, Bytes<32>>} signerCommitments - Signer commitments. + * @param {Uint<8>} thresh - Minimum approvals required. + */ +constructor( + instanceSalt: Bytes<32>, + signerCommitments: Vector<3, Bytes<32>>, + thresh: Uint<8> +) { + Signer_initialize<3>(instanceSalt, signerCommitments, thresh); +} + +// ─── Deposit ──────────────────────────────────────────────────── + +/** + * @description Receives a shielded coin into the treasury. No access control; + * anyone may deposit. No coin data is stored on the public ledger. + * + * @param {ShieldedCoinInfo} coin - The incoming shielded coin. + * @returns {[]} Empty tuple. + */ +export circuit deposit(coin: ShieldedCoinInfo): [] { + Treasury__deposit(coin); +} + +// ─── Execute ──────────────────────────────────────────────────── + +/** + * @description Executes a shielded send authorized by threshold signatures. + * Reads and increments the nonce, reconstructs the off-chain message hash + * `persistentHash(nonce, recipient address, coin color, amount)`, verifies the + * signatures against the shared registry, then sends from the treasury. + * + * @param {Proposal_Recipient} to - The recipient. + * @param {Uint<128>} amount - The amount to send. + * @param {QualifiedShieldedCoinInfo} coin - The coin to spend (operator pool). + * @param {Vector<2, Bytes<64>>} pubkeys - ECDSA public keys of approving signers. + * @param {Vector<2, Bytes<64>>} signatures - Signatures over the operation. + * @returns {ShieldedSendResult} The send result including any change. + */ +export circuit execute( + to: Proposal_Recipient, + amount: Uint<128>, + coin: QualifiedShieldedCoinInfo, + pubkeys: Vector<2, Bytes<64>>, + signatures: Vector<2, Bytes<64>> +): ShieldedSendResult { + const currentNonce = _nonce; + _nonce.increment(1); + + const msgHash = persistentHash>>([ + currentNonce as Bytes<32>, + to.address, + coin.color, + amount as Bytes<32> + ]); + + Signer_verify<2>(msgHash, pubkeys, signatures); + + return Treasury__send(coin, Proposal_toShieldedRecipient(to), amount); +} + +// ─── View ─────────────────────────────────────────────────────── + +/** + * @description Computes a signer commitment from an ECDSA public key. Pure — + * callable off-chain by the deployer. + */ +export pure circuit _calculateSignerId(pk: Bytes<64>, salt: Bytes<32>): Bytes<32> { + return Signer__calculateSignerId(pk, salt); +} + +export circuit getNonce(): Uint<64> { + return _nonce; +} + +export circuit getSignerCount(): Uint<8> { + return Signer_getSignerCount(); +} + +export circuit getThreshold(): Uint<8> { + return Signer_getThreshold(); +} + +export circuit isSigner(commitment: Bytes<32>): Boolean { + return Signer_isSigner(commitment); +}