diff --git a/contracts/src/multisig/EcdsaSignerManager.compact b/contracts/src/multisig/EcdsaSignerManager.compact new file mode 100644 index 00000000..bcd10863 --- /dev/null +++ b/contracts/src/multisig/EcdsaSignerManager.compact @@ -0,0 +1,220 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.2.0 (multisig/EcdsaSignerManager.compact) + +pragma language_version >= 0.23.0; + +/** + * @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 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 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 + * 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 EcdsaSignerManager { + 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, "EcdsaSignerManager: duplicate signer"); + + Signer_assertSigner(commitment); + + // TODO: Replace with ecdsaVerify when the Compact ECDSA primitive is available + assert(stubVerifySignature(pubkey, state.msgHash, signature), "EcdsaSignerManager: 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.compact b/contracts/src/multisig/Signer.compact deleted file mode 100644 index 7a8207ec..00000000 --- a/contracts/src/multisig/Signer.compact +++ /dev/null @@ -1,312 +0,0 @@ -// SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.2.0 (multisig/Signer.compact) - -pragma language_version >= 0.23.0; - -/** - * @module Signer - * @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: - * - * - `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 - * caller from the contract layer and checks it against the registry. - * This separation allows the identity mechanism to change without - * modifying the 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. - * - * 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. - * - * Underscore-prefixed circuits (_addSigner, _removeSigner, - * _changeThreshold) have no access control enforcement. The consuming - * contract must gate these 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. - */ -module Signer { - import CompactStandardLibrary; - - // ─── State ────────────────────────────────────────────────────────────────── - - export ledger _signers: Set; - export ledger _signerCount: Uint<8>; - export ledger _threshold: Uint<8>; - - /** - * @description Initialization flag, tracked per-module to avoid the compiler's - * shared transitive-dependency state bug (LFDT-Minokawa/compact#270). See the - * Initializable module for rationale. - */ - export ledger _isInitialized: Boolean; - - // ─── Initialization ───────────────────────────────────────────────────────── - - /** - * @description Initializes the signer module with the given threshold - * and an initial set of signers. - * If used, it should only be called in the contract's constructor. - * - * @circuitInfo k=11, rows=1815 - * - * Requirements: - * - * - If used, can only be called once (in the constructor). - * - `thresh` must not be zero. - * - `thresh` must not exceed the number of `signers`. - * - `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> - ): [] { - assertNotInitialized(); - _isInitialized = true; - - for (const signer of signers) { - _addSigner(signer); - } - - _changeThreshold(thresh); - } - - // ─── Guards ───────────────────────────────────────────────────────────── - - /** - * @description Asserts that the given caller is an active signer. - * - * @circuitInfo k=10, rows=585 - * - * Requirements: - * - * - Contract must be initialized. - * - `caller` must be a member of the signers registry. - * - * @param {T} caller - The identity to validate. - * @returns {[]} Empty tuple. - */ - export circuit assertSigner(caller: T): [] { - assertInitialized(); - assert(isSigner(caller), "Signer: not a signer"); - } - - /** - * @description Asserts that the given approval count meets the threshold. - * - * @circuitInfo k=9, rows=54 - * - * Requirements: - * - * - Contract must be initialized. - * - Ledger threshold must be set (not be zero). - * - `approvalCount` must be >= threshold. - * - * @param {Uint<8>} approvalCount - The current number of approvals. - * @returns {[]} Empty tuple. - */ - export circuit assertThresholdMet(approvalCount: Uint<8>): [] { - assertInitialized(); - assert(_threshold != 0, "Signer: threshold not set"); - assert(approvalCount >= _threshold, "Signer: threshold not met"); - } - - // ─── View ────────────────────────────────────────────────────────── - - /** - * @description Returns the current signer count. - * - * @circuitInfo k=6, rows=26 - * - * Requirements: - * - * - Contract must be initialized. - * - * @returns {Uint<8>} The number of active signers. - */ - export circuit getSignerCount(): Uint<8> { - assertInitialized(); - return _signerCount; - } - - /** - * @description Returns the approval threshold. - * - * @circuitInfo k=6, rows=26 - * - * Requirements: - * - * - Contract must be initialized. - * - * @returns {Uint<8>} The threshold. - */ - export circuit getThreshold(): Uint<8> { - assertInitialized(); - return _threshold; - } - - /** - * @description Returns whether the given account is an active signer. - * - * @circuitInfo k=10, rows=605 - * - * @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. - * - * @circuitInfo k=10, rows=598 - * - * 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), - "Signer: 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. - * - * @circuitInfo k=10, rows=612 - * - * 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), "Signer: not a signer"); - - const newCount = _signerCount - 1 as Uint<8>; - assert(newCount >= _threshold, "Signer: 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. - * - * @circuitInfo k=9, rows=53 - * - * Requirements: - * - * - `newThreshold` must not be zero. - * - `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 <= _signerCount, "Signer: threshold exceeds signer count"); - _setThreshold(newThreshold); - } - - /** - * @description Sets the approval threshold without checking - * against the current signer count. - * - * @warning This is intended for use during contract construction - * or custom setup flows where signers may not yet be registered. - * - * @notice Access control is NOT enforced here. - * The consuming contract must gate this behind its own - * authorization policy. Use `_changeThreshold` for - * operational threshold changes with signer count validation. - * - * @circuitInfo k=6, rows=40 - * - * Requirements: - * - * - `newThreshold` must not be zero. - * - * @param {Uint<8>} newThreshold - The minimum number of approvals required. - * @returns {[]} Empty tuple. - */ - export circuit _setThreshold(newThreshold: Uint<8>): [] { - assert(newThreshold != 0, "Signer: threshold must not be zero"); - _threshold = disclose(newThreshold); - } - - // ─── Init guards ───────────────────────────────────────────────────────── - - /** - * @description Asserts that the contract has been initialized, throwing an error if not. - * - * Requirements: - * - * - Contract must be initialized. - * - * @return {[]} - Empty tuple. - */ - circuit assertInitialized(): [] { - assert(_isInitialized, "Signer: contract not initialized"); - } - - /** - * @description Asserts that the contract has not been initialized, throwing an error if it has. - * - * Requirements: - * - * - Contract must not be initialized. - * - * @return {[]} - Empty tuple. - */ - circuit assertNotInitialized(): [] { - assert(!_isInitialized, "Signer: contract already initialized"); - } -} diff --git a/contracts/src/multisig/SignerManager.compact b/contracts/src/multisig/SignerManager.compact index 9eb2d4f6..3a1f057b 100644 --- a/contracts/src/multisig/SignerManager.compact +++ b/contracts/src/multisig/SignerManager.compact @@ -5,25 +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: * - * - `Either` for ownPublicKey()-based identity - * - `Bytes<32>` for commitment-based identity (e.g., hash of ECDSA public key) - * - `NativePoint` 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`). * - * 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. + * 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. * - * Underscore-prefixed circuits (_addSigner, _removeSigner, - * _changeThreshold) have no access control enforcement. The consuming - * contract must gate these behind its own authorization policy. + * 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 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. The consuming contract must gate them behind its own + * authorization policy. + * + * 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; @@ -34,35 +50,45 @@ module SignerManager { export ledger _signerCount: Uint<8>; export ledger _threshold: Uint<8>; + /** + * @description Initialization flag, tracked per-module to avoid the compiler's + * shared transitive-dependency state bug (LFDT-Minokawa/compact#270). See the + * Initializable module for rationale. + */ + export ledger _isInitialized: Boolean; + // ─── Initialization ───────────────────────────────────────────────────────── /** - * @description Initializes the signer manager with the given threshold + * @description Initializes the signer module with the given threshold * and an initial set of signers. - * Must be called in the contract's constructor. + * If used, it should only be called in the contract's constructor. + * + * @circuitInfo k=11, rows=1815 * * Requirements: * - * - `thresh` must be greater than 0. + * - If used, can only be called once (in the constructor). + * - `thresh` must not be zero. + * - `thresh` must not exceed the number of `signers`. * - `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); + assertNotInitialized(); + _isInitialized = true; for (const signer of signers) { _addSigner(signer); } - assert(_signerCount >= thresh, "SignerManager: threshold exceeds signer count"); + _changeThreshold(thresh); } // ─── Guards ───────────────────────────────────────────────────────────── @@ -70,30 +96,38 @@ module SignerManager { /** * @description Asserts that the given caller is an active signer. * + * @circuitInfo k=10, rows=585 + * * Requirements: * + * - Contract must be initialized. * - `caller` must be a member of the signers registry. * * @param {T} caller - The identity to validate. - * * @returns {[]} Empty tuple. */ export circuit assertSigner(caller: T): [] { + assertInitialized(); assert(isSigner(caller), "SignerManager: not a signer"); } /** * @description Asserts that the given approval count meets the threshold. * + * @circuitInfo k=9, rows=54 + * * Requirements: * + * - Contract must be initialized. + * - Ledger threshold must be set (not be zero). * - `approvalCount` must be >= threshold. * * @param {Uint<8>} approvalCount - The current number of approvals. - * * @returns {[]} Empty tuple. */ export circuit assertThresholdMet(approvalCount: Uint<8>): [] { + assertInitialized(); + assert(_threshold != 0, "SignerManager: threshold not set"); assert(approvalCount >= _threshold, "SignerManager: threshold not met"); } @@ -102,26 +136,41 @@ module SignerManager { /** * @description Returns the current signer count. * + * @circuitInfo k=6, rows=26 + * + * Requirements: + * + * - Contract must be initialized. + * * @returns {Uint<8>} The number of active signers. */ export circuit getSignerCount(): Uint<8> { + assertInitialized(); return _signerCount; } /** * @description Returns the approval threshold. * + * @circuitInfo k=6, rows=26 + * + * Requirements: + * + * - Contract must be initialized. + * * @returns {Uint<8>} The threshold. */ export circuit getThreshold(): Uint<8> { + assertInitialized(); return _threshold; } /** * @description Returns whether the given account is an active signer. * - * @param {T} account - The account to check. + * @circuitInfo k=10, rows=605 * + * @param {T} account - The account to check. * @returns {Boolean} True if the account is an active signer. */ export circuit isSigner(account: T): Boolean { @@ -137,12 +186,13 @@ module SignerManager { * The consuming contract must gate this behind its own * authorization policy. * + * @circuitInfo k=10, rows=598 + * * Requirements: * * - `signer` must not already be an active signer. * * @param {T} signer - The signer to add. - * * @returns {[]} Empty tuple. */ export circuit _addSigner(signer: T): [] { @@ -162,13 +212,14 @@ module SignerManager { * The consuming contract must gate this behind its own * authorization policy. * + * @circuitInfo k=10, rows=612 + * * 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): [] { @@ -188,18 +239,72 @@ module SignerManager { * The consuming contract must gate this behind its own * authorization policy. * + * @circuitInfo k=9, rows=53 + * * Requirements: * - * - `newThreshold` must be greater than 0. + * - `newThreshold` must not be zero. * - `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"); + _setThreshold(newThreshold); + } + + /** + * @description Sets the approval threshold without checking + * against the current signer count. + * + * @warning This is intended for use during contract construction + * or custom setup flows where signers may not yet be registered. + * + * @notice Access control is NOT enforced here. + * The consuming contract must gate this behind its own + * authorization policy. Use `_changeThreshold` for + * operational threshold changes with signer count validation. + * + * @circuitInfo k=6, rows=40 + * + * Requirements: + * + * - `newThreshold` must not be zero. + * + * @param {Uint<8>} newThreshold - The minimum number of approvals required. + * @returns {[]} Empty tuple. + */ + export circuit _setThreshold(newThreshold: Uint<8>): [] { + assert(newThreshold != 0, "SignerManager: threshold must not be zero"); _threshold = disclose(newThreshold); } + + // ─── Init guards ───────────────────────────────────────────────────────── + + /** + * @description Asserts that the contract has been initialized, throwing an error if not. + * + * Requirements: + * + * - Contract must be initialized. + * + * @return {[]} - Empty tuple. + */ + circuit assertInitialized(): [] { + assert(_isInitialized, "SignerManager: contract not initialized"); + } + + /** + * @description Asserts that the contract has not been initialized, throwing an error if it has. + * + * Requirements: + * + * - Contract must not be initialized. + * + * @return {[]} - Empty tuple. + */ + circuit assertNotInitialized(): [] { + assert(!_isInitialized, "SignerManager: contract already initialized"); + } } diff --git a/contracts/src/multisig/ForwarderShielded.compact b/contracts/src/multisig/forwarder/NativeShieldedForwarder.compact similarity index 92% rename from contracts/src/multisig/ForwarderShielded.compact rename to contracts/src/multisig/forwarder/NativeShieldedForwarder.compact index 2dd473a3..b0bb0ed3 100644 --- a/contracts/src/multisig/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,9 +42,9 @@ 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_; + import "../../utils/Utils" prefix Utils_; // ─── State ────────────────────────────────────────────────────── @@ -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/ForwarderUnshielded.compact b/contracts/src/multisig/forwarder/NativeUnshieldedForwarder.compact similarity index 93% rename from contracts/src/multisig/ForwarderUnshielded.compact rename to contracts/src/multisig/forwarder/NativeUnshieldedForwarder.compact index 667f938c..0fa59f90 100644 --- a/contracts/src/multisig/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/ForwarderPrivate.compact b/contracts/src/multisig/forwarder/PrivateNativeShieldedForwarder.compact similarity index 87% rename from contracts/src/multisig/ForwarderPrivate.compact rename to contracts/src/multisig/forwarder/PrivateNativeShieldedForwarder.compact index 1d35f92a..9bc2d846 100644 --- a/contracts/src/multisig/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,9 +27,9 @@ 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_; + import "../../utils/Utils" prefix Utils_; // ─── State ────────────────────────────────────────────────────── @@ -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,16 @@ 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"), 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, "ForwarderPrivate:commitment"), parentAddr, opSecret] + [pad(32, "PrivateNativeShieldedForwarder"), parentAddr, opSecret] ); } @@ -211,7 +213,7 @@ module ForwarderPrivate { * @return {[]} - Empty tuple. */ circuit assertInitialized(): [] { - assert(_isInitialized, "ForwarderPrivate: contract not initialized"); + assert(_isInitialized, "PrivateNativeShieldedForwarder: contract not initialized"); } /** @@ -224,6 +226,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/ShieldedMultiSigV2.compact b/contracts/src/multisig/presets/ShieldedMultiSigV2.compact deleted file mode 100644 index 0c380dbe..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 "../ProposalManager" prefix Proposal_; -import "../ShieldedTreasuryStateless" prefix Treasury_; -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 - * 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 23d6ba81..00000000 --- a/contracts/src/multisig/presets/ShieldedMultiSigV3.compact +++ /dev/null @@ -1,368 +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"> 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); -} - -// ─── 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/presets/forwarder/ForwarderPrivate.compact b/contracts/src/multisig/presets/forwarder/ForwarderPrivate.compact deleted file mode 100644 index fb7fbd2e..00000000 --- a/contracts/src/multisig/presets/forwarder/ForwarderPrivate.compact +++ /dev/null @@ -1,125 +0,0 @@ -// SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.2.0 (multisig/presets/forwarder/ForwarderPrivate.compact) - -pragma language_version >= 0.23.0; - -/** - * @title ForwarderPrivate - * @description Private-parent forwarder. The parent address is hidden - * behind a `persistentHash` commitment on the ledger. Coins dwell at - * the contract address after deposit; the operator drains them later - * by presenting the `(parentAddr, opSecret)` preimage at drain time. - * - * Knowledge of the preimage is the sole authorization gate. The - * operational secret is held off-chain by the deployer; losing it is - * equivalent to loss of a hot-wallet key. Two forwarders bound to the - * same parent with different operational secrets produce different - * commitments and are unlinkable on-chain. - * - * @notice Each forwarder is bound to a single parent at deploy. To - * change the parent, deploy a new forwarder; the old one remains - * functional for outstanding coins until drained. - */ - -import CompactStandardLibrary; -import "../../ForwarderPrivate" prefix ForwarderPrivate_; - -export { ShieldedCoinInfo, QualifiedShieldedCoinInfo, ShieldedSendResult, 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>) { - ForwarderPrivate_initialize(parentCommitment); -} - -/** - * @description Receives a shielded coin into the forwarder's custody. - * No ledger write — the coin sits at the contract address until drained. - * - * @param {ShieldedCoinInfo} coin - The incoming shielded coin. - * - * @returns {[]} Empty tuple. - */ -export circuit deposit(coin: ShieldedCoinInfo): [] { - ForwarderPrivate__deposit(coin); -} - -/** - * @description Spends a previously-deposited shielded coin to 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 ForwarderPrivate__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 ForwarderPrivate__parentCommitment; -} - -/** - * @description Computes the parent commitment from a `(parentAddr, opSecret)` - * pair. Pure circuit — used off-chain by the deployer to compute the - * constructor argument, and inside `drain` for the preimage check. - * - * The commitment is domain-tagged - * (`pad(32, "ForwarderPrivate:commitment")`) to prevent preimage - * collisions with other `persistentHash` users in the system. - * - * @param {Bytes<32>} parentAddr - The parent address. - * @param {Bytes<32>} opSecret - The operational secret. - * - * @returns {Bytes<32>} The commitment `persistentHash([pad(32, "ForwarderPrivate:commitment"), parentAddr, opSecret])`. - */ -export pure circuit calculateParentCommitment( - parentAddr: Bytes<32>, - opSecret: Bytes<32> -): Bytes<32> { - return ForwarderPrivate__calculateParentCommitment(parentAddr, opSecret); -} diff --git a/contracts/src/multisig/presets/forwarder/ForwarderShielded.compact b/contracts/src/multisig/presets/forwarder/ForwarderShielded.compact deleted file mode 100644 index 1c84247a..00000000 --- a/contracts/src/multisig/presets/forwarder/ForwarderShielded.compact +++ /dev/null @@ -1,62 +0,0 @@ -// SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.2.0 (multisig/presets/forwarder/ForwarderShielded.compact) - -pragma language_version >= 0.23.0; - -/** - * @title 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 "../../ForwarderShielded" 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/presets/forwarder/ForwarderUnshielded.compact b/contracts/src/multisig/presets/forwarder/ForwarderUnshielded.compact deleted file mode 100644 index 4ec699f4..00000000 --- a/contracts/src/multisig/presets/forwarder/ForwarderUnshielded.compact +++ /dev/null @@ -1,64 +0,0 @@ -// SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.2.0 (multisig/presets/forwarder/ForwarderUnshielded.compact) - -pragma language_version >= 0.23.0; - -/** - * @title ForwarderUnshielded - * @description Public-parent forwarder for unshielded coins. Receives - * an unshielded amount of a given color and atomically forwards it to - * the configured parent 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. - * - * 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 "../../ForwarderUnshielded" 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/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/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/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'); }); }); 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/ShieldedMultiSig.test.ts b/contracts/src/multisig/test/ShieldedMultiSig.test.ts deleted file mode 100644 index d1ee95b8..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('SignerManager: 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'); - }); - }); - - 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('SignerManager: 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('SignerManager: 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('SignerManager: 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( - 'SignerManager: 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 3dea44c7..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('SignerManager: 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('SignerManager: 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 dcf3900b..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('Signer: 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('Signer: 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('Signer: 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/Signer.test.ts b/contracts/src/multisig/test/Signer.test.ts deleted file mode 100644 index da5af332..00000000 --- a/contracts/src/multisig/test/Signer.test.ts +++ /dev/null @@ -1,386 +0,0 @@ -import { beforeEach, describe, expect, it } from 'vitest'; -import { SignerSimulator } from './simulators/SignerSimulator.js'; - -const THRESHOLD = 2n; -const IS_INIT = true; - -// Simple `Bytes<32>` ids -const SIGNER = new Uint8Array(32).fill(1); -const SIGNER2 = new Uint8Array(32).fill(2); -const SIGNER3 = new Uint8Array(32).fill(3); -const SIGNERS = [SIGNER, SIGNER2, SIGNER3]; -const OTHER = new Uint8Array(32).fill(4); -const OTHER2 = new Uint8Array(32).fill(5); - -let contract: SignerSimulator; - -describe('Signer', () => { - describe('when not initialized', () => { - beforeEach(async () => { - const isNotInit = false; - contract = await SignerSimulator.create(SIGNERS, 0n, isNotInit); - }); - - const circuitsRequiringInit: [string, unknown[]][] = [ - ['assertSigner', [SIGNER]], - ['assertThresholdMet', [0n]], - ['getSignerCount', []], - ['getThreshold', []], - ]; - - it.each( - circuitsRequiringInit, - )('%s should fail', async (circuitName, args) => { - await expect( - ( - contract[circuitName as keyof SignerSimulator] as ( - ...a: unknown[] - ) => Promise - )(...args), - ).rejects.toThrow('Signer: contract not initialized'); - }); - - it('isSigner should succeed (no init guard)', async () => { - expect(await contract.isSigner(SIGNER)).toEqual(false); - }); - }); - - 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'); - }); - - 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'); - }); - - 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'); - }); - - it('should initialize with threshold equal to signer count', async () => { - const contract = await SignerSimulator.create( - SIGNERS, - BigInt(SIGNERS.length), - IS_INIT, - ); - expect(await contract.getThreshold()).toEqual(BigInt(SIGNERS.length)); - }); - - it('should initialize', async () => { - contract = await SignerSimulator.create(SIGNERS, THRESHOLD, IS_INIT); - - expect(await contract.getThreshold()).toEqual(THRESHOLD); - expect(await contract.getSignerCount()).toEqual(BigInt(SIGNERS.length)); - for (let i = 0; i < SIGNERS.length; i++) { - await contract.assertSigner(SIGNERS[i]); - } - }); - - it('should fail when initialized twice', async () => { - contract = await SignerSimulator.create(SIGNERS, THRESHOLD, IS_INIT); - await expect(contract.initialize(SIGNERS, THRESHOLD)).rejects.toThrow( - 'Signer: contract already initialized', - ); - }); - }); - - beforeEach(async () => { - contract = await SignerSimulator.create(SIGNERS, THRESHOLD, IS_INIT); - }); - - describe('assertSigner', () => { - it('should pass with good signer', async () => { - await contract.assertSigner(SIGNER); - }); - - it('should fail with bad signer', async () => { - await expect(contract.assertSigner(OTHER)).rejects.toThrow( - 'Signer: 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( - 'Signer: threshold not met', - ); - }); - - it('should fail with zero approvals', async () => { - await expect(contract.assertThresholdMet(0n)).rejects.toThrow( - 'Signer: threshold not met', - ); - }); - }); - - describe('getSignerCount', () => { - it('should return the initial signer count', async () => { - expect(await contract.getSignerCount()).toEqual(BigInt(SIGNERS.length)); - }); - - it('should reflect additions', async () => { - await contract._addSigner(OTHER); - expect(await contract.getSignerCount()).toEqual( - BigInt(SIGNERS.length) + 1n, - ); - }); - - it('should reflect removals', async () => { - await contract._removeSigner(SIGNER3); - expect(await contract.getSignerCount()).toEqual( - BigInt(SIGNERS.length) - 1n, - ); - }); - }); - - describe('getThreshold', () => { - it('should return the initial threshold', async () => { - expect(await contract.getThreshold()).toEqual(THRESHOLD); - }); - - it('should reflect _changeThreshold', async () => { - await contract._changeThreshold(3n); - expect(await contract.getThreshold()).toEqual(3n); - }); - - it('should reflect _setThreshold', async () => { - await contract._setThreshold(1n); - expect(await contract.getThreshold()).toEqual(1n); - }); - }); - - describe('isSigner', () => { - it('should return true for an active signer', async () => { - expect(await contract.isSigner(SIGNER)).toEqual(true); - }); - - it('should return false for a non-signer', async () => { - expect(await contract.isSigner(OTHER)).toEqual(false); - }); - }); - - describe('_addSigner', () => { - it('should add a new signer', async () => { - await contract._addSigner(OTHER); - - expect(await contract.isSigner(OTHER)).toEqual(true); - expect(await contract.getSignerCount()).toEqual( - BigInt(SIGNERS.length) + 1n, - ); - }); - - it('should fail when adding an existing signer', async () => { - await contract._addSigner(OTHER); - - await expect(contract._addSigner(OTHER)).rejects.toThrow( - 'Signer: signer already active', - ); - }); - - it('should add multiple new signers', async () => { - await contract._addSigner(OTHER); - await contract._addSigner(OTHER2); - - expect(await contract.isSigner(OTHER)).toEqual(true); - expect(await contract.isSigner(OTHER2)).toEqual(true); - expect(await contract.getSignerCount()).toEqual( - BigInt(SIGNERS.length) + 2n, - ); - }); - - it('should allow re-adding a previously removed signer', async () => { - expect(await contract.isSigner(SIGNER)).toEqual(true); - - await contract._removeSigner(SIGNER); - expect(await contract.isSigner(SIGNER)).toEqual(false); - - await contract._addSigner(SIGNER); - expect(await contract.isSigner(SIGNER)).toEqual(true); - }); - }); - - describe('_removeSigner', () => { - it('should remove an existing signer', async () => { - await contract._removeSigner(SIGNER3); - - expect(await contract.isSigner(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(OTHER)).rejects.toThrow( - 'Signer: not a signer', - ); - }); - - it('should fail when removal would breach threshold', async () => { - await contract._removeSigner(SIGNER3); - - await expect(contract._removeSigner(SIGNER2)).rejects.toThrow( - 'Signer: removal would breach threshold', - ); - }); - - it('should allow removal after threshold is lowered', async () => { - await contract._changeThreshold(1n); - await contract._removeSigner(SIGNER3); - await contract._removeSigner(SIGNER2); - - expect(await contract.getSignerCount()).toEqual(1n); - expect(await contract.isSigner(SIGNER)).toEqual(true); - expect(await contract.isSigner(SIGNER2)).toEqual(false); - expect(await contract.isSigner(SIGNER3)).toEqual(false); - }); - - it('should keep signer count in sync after multiple add/remove operations', async () => { - await contract._addSigner(OTHER); - await contract._addSigner(OTHER2); - await contract._removeSigner(SIGNER3); - await contract._removeSigner(OTHER); - - expect(await contract.getSignerCount()).toEqual(3n); - expect(await contract.isSigner(SIGNER)).toEqual(true); - expect(await contract.isSigner(SIGNER2)).toEqual(true); - expect(await contract.isSigner(SIGNER3)).toEqual(false); - expect(await contract.isSigner(OTHER)).toEqual(false); - expect(await contract.isSigner(OTHER2)).toEqual(true); - }); - }); - - 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( - 'Signer: 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'); - }); - - 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( - 'Signer: threshold not met', - ); - - await contract.assertThresholdMet(3n); - }); - }); - - describe('_setThreshold', () => { - beforeEach(async () => { - const isNotInit = false; - contract = await SignerSimulator.create(SIGNERS, 0n, isNotInit); - }); - - it('should have an empty state', async () => { - expect((await contract.getPublicState())._threshold).toEqual(0n); - expect((await contract.getPublicState())._signerCount).toEqual(0n); - expect((await contract.getPublicState())._signers.isEmpty()).toEqual( - true, - ); - }); - - it('should set threshold without signers', async () => { - expect((await contract.getPublicState())._threshold).toEqual(0n); - - await contract._setThreshold(2n); - expect((await contract.getPublicState())._threshold).toEqual(2n); - }); - - it('should set threshold multiple times', async () => { - await contract._setThreshold(2n); - await contract._setThreshold(3n); - expect((await contract.getPublicState())._threshold).toEqual(3n); - }); - - it('should fail with zero threshold', async () => { - await expect(contract._setThreshold(0n)).rejects.toThrow( - 'Signer: threshold must not be zero', - ); - }); - }); - - describe('custom setup flow when not initialized', () => { - beforeEach(async () => { - const isNotInit = false; - contract = await SignerSimulator.create(SIGNERS, 0n, isNotInit); - }); - - it('should have no signers by default', async () => { - expect((await contract.getPublicState())._signerCount).toEqual(0n); - expect(await contract.isSigner(SIGNER)).toEqual(false); - }); - - it('should have zero threshold by default', async () => { - expect((await contract.getPublicState())._threshold).toEqual(0n); - }); - - it('should allow adding signers then setting threshold', async () => { - await contract._addSigner(SIGNER); - await contract._addSigner(SIGNER2); - await contract._addSigner(SIGNER3); - await contract._changeThreshold(2n); - - expect((await contract.getPublicState())._signerCount).toEqual(3n); - expect((await contract.getPublicState())._threshold).toEqual(2n); - expect(await contract.isSigner(SIGNER)).toEqual(true); - }); - - it('should allow setting threshold then adding signers to meet it', async () => { - await contract._setThreshold(2n); - await contract._addSigner(SIGNER); - await contract._addSigner(SIGNER2); - - expect((await contract.getPublicState())._signerCount).toEqual(2n); - expect((await contract.getPublicState())._threshold).toEqual(2n); - }); - - it('should fail _changeThreshold before signers are added', async () => { - await expect(contract._changeThreshold(2n)).rejects.toThrow( - 'Signer: threshold exceeds signer count', - ); - }); - }); -}); diff --git a/contracts/src/multisig/test/SignerManager.test.ts b/contracts/src/multisig/test/SignerManager.test.ts index 9ecd2468..fd696773 100644 --- a/contracts/src/multisig/test/SignerManager.test.ts +++ b/contracts/src/multisig/test/SignerManager.test.ts @@ -1,61 +1,108 @@ import { beforeEach, describe, expect, it } from 'vitest'; -import * as utils from '#test-utils/address.js'; -import { - SignerManagerSimulator, - type SignerSet, -} from './simulators/SignerManagerSimulator.js'; +import { SignerManagerSimulator } from './simulators/SignerManagerSimulator.js'; const THRESHOLD = 2n; +const IS_INIT = true; -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'); +// Simple `Bytes<32>` ids +const SIGNER = new Uint8Array(32).fill(1); +const SIGNER2 = new Uint8Array(32).fill(2); +const SIGNER3 = new Uint8Array(32).fill(3); +const SIGNERS = [SIGNER, SIGNER2, SIGNER3]; +const OTHER = new Uint8Array(32).fill(4); +const OTHER2 = new Uint8Array(32).fill(5); let contract: SignerManagerSimulator; -describe('SigningManager', () => { +describe('SignerManager', () => { + describe('when not initialized', () => { + beforeEach(async () => { + const isNotInit = false; + contract = await SignerManagerSimulator.create(SIGNERS, 0n, isNotInit); + }); + + const circuitsRequiringInit: [string, unknown[]][] = [ + ['assertSigner', [SIGNER]], + ['assertThresholdMet', [0n]], + ['getSignerCount', []], + ['getThreshold', []], + ]; + + it.each( + circuitsRequiringInit, + )('%s should fail', async (circuitName, args) => { + await expect( + ( + contract[circuitName as keyof SignerManagerSimulator] as ( + ...a: unknown[] + ) => Promise + )(...args), + ).rejects.toThrow('SignerManager: contract not initialized'); + }); + + it('isSigner should succeed (no init guard)', async () => { + expect(await contract.isSigner(SIGNER)).toEqual(false); + }); + }); + describe('initialization', () => { it('should fail with a threshold of zero', async () => { - await expect(SignerManagerSimulator.create(SIGNERS, 0n)).rejects.toThrow( - 'SignerManager: threshold must be > 0', - ); + await expect( + 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( + 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: SignerSet = [Z_SIGNER, Z_SIGNER, Z_SIGNER2]; + const duplicateSigners = [SIGNER, SIGNER, SIGNER2]; await expect( - SignerManagerSimulator.create(duplicateSigners, THRESHOLD), + 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 SignerManagerSimulator.create( + SIGNERS, + BigInt(SIGNERS.length), + IS_INIT, + ); + expect(await contract.getThreshold()).toEqual(BigInt(SIGNERS.length)); + }); + it('should initialize', async () => { - contract = await SignerManagerSimulator.create(SIGNERS, THRESHOLD); + contract = await SignerManagerSimulator.create(SIGNERS, THRESHOLD, IS_INIT); - // 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]); } }); + + it('should fail when initialized twice', async () => { + contract = await SignerManagerSimulator.create(SIGNERS, THRESHOLD, IS_INIT); + await expect(contract.initialize(SIGNERS, THRESHOLD)).rejects.toThrow( + 'SignerManager: contract already initialized', + ); + }); }); beforeEach(async () => { - contract = await SignerManagerSimulator.create(SIGNERS, THRESHOLD); + contract = await SignerManagerSimulator.create(SIGNERS, THRESHOLD, IS_INIT); }); describe('assertSigner', () => { it('should pass with good signer', async () => { - await contract.assertSigner(Z_SIGNER); + await contract.assertSigner(SIGNER); }); it('should fail with bad signer', async () => { - await expect(contract.assertSigner(Z_OTHER)).rejects.toThrow( + await expect(contract.assertSigner(OTHER)).rejects.toThrow( 'SignerManager: not a signer', ); }); @@ -83,79 +130,139 @@ describe('SigningManager', () => { }); }); + describe('getSignerCount', () => { + it('should return the initial signer count', async () => { + expect(await contract.getSignerCount()).toEqual(BigInt(SIGNERS.length)); + }); + + it('should reflect additions', async () => { + await contract._addSigner(OTHER); + expect(await contract.getSignerCount()).toEqual( + BigInt(SIGNERS.length) + 1n, + ); + }); + + it('should reflect removals', async () => { + await contract._removeSigner(SIGNER3); + expect(await contract.getSignerCount()).toEqual( + BigInt(SIGNERS.length) - 1n, + ); + }); + }); + + describe('getThreshold', () => { + it('should return the initial threshold', async () => { + expect(await contract.getThreshold()).toEqual(THRESHOLD); + }); + + it('should reflect _changeThreshold', async () => { + await contract._changeThreshold(3n); + expect(await contract.getThreshold()).toEqual(3n); + }); + + it('should reflect _setThreshold', async () => { + await contract._setThreshold(1n); + expect(await contract.getThreshold()).toEqual(1n); + }); + }); + describe('isSigner', () => { it('should return true for an active signer', async () => { - expect(await contract.isSigner(Z_SIGNER)).toEqual(true); + expect(await contract.isSigner(SIGNER)).toEqual(true); }); it('should return false for a non-signer', async () => { - expect(await contract.isSigner(Z_OTHER)).toEqual(false); + expect(await contract.isSigner(OTHER)).toEqual(false); }); }); describe('_addSigner', () => { it('should add a new signer', async () => { - await contract._addSigner(Z_OTHER); + await contract._addSigner(OTHER); - expect(await contract.isSigner(Z_OTHER)).toEqual(true); + expect(await contract.isSigner(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( + await contract._addSigner(OTHER); + + await expect(contract._addSigner(OTHER)).rejects.toThrow( 'SignerManager: signer already active', ); }); it('should add multiple new signers', async () => { - await contract._addSigner(Z_OTHER); - await contract._addSigner(Z_OTHER2); + await contract._addSigner(OTHER); + await contract._addSigner(OTHER2); - expect(await contract.isSigner(Z_OTHER)).toEqual(true); - expect(await contract.isSigner(Z_OTHER2)).toEqual(true); + expect(await contract.isSigner(OTHER)).toEqual(true); + expect(await contract.isSigner(OTHER2)).toEqual(true); expect(await contract.getSignerCount()).toEqual( BigInt(SIGNERS.length) + 2n, ); }); + + it('should allow re-adding a previously removed signer', async () => { + expect(await contract.isSigner(SIGNER)).toEqual(true); + + await contract._removeSigner(SIGNER); + expect(await contract.isSigner(SIGNER)).toEqual(false); + + await contract._addSigner(SIGNER); + expect(await contract.isSigner(SIGNER)).toEqual(true); + }); }); describe('_removeSigner', () => { it('should remove an existing signer', async () => { - await contract._removeSigner(Z_SIGNER3); + await contract._removeSigner(SIGNER3); - expect(await contract.isSigner(Z_SIGNER3)).toEqual(false); + expect(await contract.isSigner(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( + await expect(contract._removeSigner(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); + await contract._removeSigner(SIGNER3); - // Remove another: count would go from 2 to 1, threshold is 2 — breach - await expect(contract._removeSigner(Z_SIGNER2)).rejects.toThrow( + await expect(contract._removeSigner(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); + await contract._removeSigner(SIGNER3); + await contract._removeSigner(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); + expect(await contract.isSigner(SIGNER)).toEqual(true); + expect(await contract.isSigner(SIGNER2)).toEqual(false); + expect(await contract.isSigner(SIGNER3)).toEqual(false); + }); + + it('should keep signer count in sync after multiple add/remove operations', async () => { + await contract._addSigner(OTHER); + await contract._addSigner(OTHER2); + await contract._removeSigner(SIGNER3); + await contract._removeSigner(OTHER); + + expect(await contract.getSignerCount()).toEqual(3n); + expect(await contract.isSigner(SIGNER)).toEqual(true); + expect(await contract.isSigner(SIGNER2)).toEqual(true); + expect(await contract.isSigner(SIGNER3)).toEqual(false); + expect(await contract.isSigner(OTHER)).toEqual(false); + expect(await contract.isSigner(OTHER2)).toEqual(true); }); }); @@ -174,7 +281,7 @@ describe('SigningManager', () => { it('should fail with a threshold of zero', async () => { await expect(contract._changeThreshold(0n)).rejects.toThrow( - 'SignerManager: threshold must be > 0', + 'SignerManager: threshold must not be zero', ); }); @@ -200,4 +307,80 @@ describe('SigningManager', () => { await contract.assertThresholdMet(3n); }); }); + + describe('_setThreshold', () => { + beforeEach(async () => { + const isNotInit = false; + contract = await SignerManagerSimulator.create(SIGNERS, 0n, isNotInit); + }); + + it('should have an empty state', async () => { + expect((await contract.getPublicState())._threshold).toEqual(0n); + expect((await contract.getPublicState())._signerCount).toEqual(0n); + expect((await contract.getPublicState())._signers.isEmpty()).toEqual( + true, + ); + }); + + it('should set threshold without signers', async () => { + expect((await contract.getPublicState())._threshold).toEqual(0n); + + await contract._setThreshold(2n); + expect((await contract.getPublicState())._threshold).toEqual(2n); + }); + + it('should set threshold multiple times', async () => { + await contract._setThreshold(2n); + await contract._setThreshold(3n); + expect((await contract.getPublicState())._threshold).toEqual(3n); + }); + + it('should fail with zero threshold', async () => { + await expect(contract._setThreshold(0n)).rejects.toThrow( + 'SignerManager: threshold must not be zero', + ); + }); + }); + + describe('custom setup flow when not initialized', () => { + beforeEach(async () => { + const isNotInit = false; + contract = await SignerManagerSimulator.create(SIGNERS, 0n, isNotInit); + }); + + it('should have no signers by default', async () => { + expect((await contract.getPublicState())._signerCount).toEqual(0n); + expect(await contract.isSigner(SIGNER)).toEqual(false); + }); + + it('should have zero threshold by default', async () => { + expect((await contract.getPublicState())._threshold).toEqual(0n); + }); + + it('should allow adding signers then setting threshold', async () => { + await contract._addSigner(SIGNER); + await contract._addSigner(SIGNER2); + await contract._addSigner(SIGNER3); + await contract._changeThreshold(2n); + + expect((await contract.getPublicState())._signerCount).toEqual(3n); + expect((await contract.getPublicState())._threshold).toEqual(2n); + expect(await contract.isSigner(SIGNER)).toEqual(true); + }); + + it('should allow setting threshold then adding signers to meet it', async () => { + await contract._setThreshold(2n); + await contract._addSigner(SIGNER); + await contract._addSigner(SIGNER2); + + expect((await contract.getPublicState())._signerCount).toEqual(2n); + expect((await contract.getPublicState())._threshold).toEqual(2n); + }); + + it('should fail _changeThreshold before signers are added', async () => { + await expect(contract._changeThreshold(2n)).rejects.toThrow( + 'SignerManager: threshold exceeds signer count', + ); + }); + }); }); diff --git a/contracts/src/multisig/test/mocks/MockEcdsaSignerManager.compact b/contracts/src/multisig/test/mocks/MockEcdsaSignerManager.compact new file mode 100644 index 00000000..e41b2ab5 --- /dev/null +++ b/contracts/src/multisig/test/mocks/MockEcdsaSignerManager.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 "../../EcdsaSignerManager" 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/MockForwarderPrivate.compact b/contracts/src/multisig/test/mocks/MockForwarderPrivate.compact index c6a62ff9..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 "../../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 2f87973a..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 "../../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 268383b6..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 "../../ForwarderUnshielded" prefix Forwarder_; +import "../../forwarder/NativeUnshieldedForwarder" 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..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 "../../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 403ca14e..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 "../../ShieldedTreasuryStateless" prefix Treasury_; +import "../../treasury/NativeShieldedTreasuryStateless" 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 deleted file mode 100644 index 30265778..00000000 --- a/contracts/src/multisig/test/mocks/MockSigner.compact +++ /dev/null @@ -1,73 +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"> prefix Signer_; -import "../../Signer">; - -export { ZswapCoinPublicKey, ContractAddress, Either, Maybe }; -export { _signers, _signerCount, _threshold }; - -/** - * @description `isInit` is a param for testing. - * - * If `isInit` is false, the constructor will not initialize the contract. - * This behavior is to test that _setThreshold will work for custom deployments - * where contracts don't `initialize` the signer module and instead have some sort - * of gated access control prior to setting up the signers -*/ -constructor(signers: Vector<3, Bytes<32>>, thresh: Uint<8>, isInit: Boolean) { - if (disclose(isInit)) { - Signer_initialize<3>(signers, thresh); - } -} - -// Exposed in order to test that contracts cannot be reinitialized. -// DO NOT EXPOSE in production -export circuit initialize(signers: Vector<3, Bytes<32>>, thresh: Uint<8>): [] { - return Signer_initialize<3>(signers, thresh); -} - -export circuit assertSigner(caller: Bytes<32>): [] { - 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: Bytes<32>): Boolean { - return Signer_isSigner(account); -} - -export circuit _addSigner(signer: Bytes<32>): [] { - return Signer__addSigner(signer); -} - -export circuit _removeSigner(signer: Bytes<32>): [] { - return Signer__removeSigner(signer); -} - -export circuit _changeThreshold(newThreshold: Uint<8>): [] { - return Signer__changeThreshold(newThreshold); -} - -export circuit _setThreshold(newThreshold: Uint<8>): [] { - return Signer__setThreshold(newThreshold); -} - diff --git a/contracts/src/multisig/test/mocks/MockSignerManager.compact b/contracts/src/multisig/test/mocks/MockSignerManager.compact index 870aed0c..4e2e5660 100644 --- a/contracts/src/multisig/test/mocks/MockSignerManager.compact +++ b/contracts/src/multisig/test/mocks/MockSignerManager.compact @@ -9,15 +9,33 @@ pragma language_version >= 0.23.0; import CompactStandardLibrary; -import "../../SignerManager"> prefix Signer_; +import "../../SignerManager"> prefix Signer_; +import "../../SignerManager">; export { ZswapCoinPublicKey, ContractAddress, Either, Maybe }; +export { _signers, _signerCount, _threshold }; -constructor(signers: Vector<3, Either>, thresh: Uint<8>) { - Signer_initialize<3>(signers, thresh); +/** + * @description `isInit` is a param for testing. + * + * If `isInit` is false, the constructor will not initialize the contract. + * This behavior is to test that _setThreshold will work for custom deployments + * where contracts don't `initialize` the signer module and instead have some sort + * of gated access control prior to setting up the signers +*/ +constructor(signers: Vector<3, Bytes<32>>, thresh: Uint<8>, isInit: Boolean) { + if (disclose(isInit)) { + Signer_initialize<3>(signers, thresh); + } } -export circuit assertSigner(caller: Either): [] { +// Exposed in order to test that contracts cannot be reinitialized. +// DO NOT EXPOSE in production +export circuit initialize(signers: Vector<3, Bytes<32>>, thresh: Uint<8>): [] { + return Signer_initialize<3>(signers, thresh); +} + +export circuit assertSigner(caller: Bytes<32>): [] { return Signer_assertSigner(caller); } @@ -33,18 +51,23 @@ export circuit getThreshold(): Uint<8> { return Signer_getThreshold(); } -export circuit isSigner(account: Either): Boolean { +export circuit isSigner(account: Bytes<32>): Boolean { return Signer_isSigner(account); } -export circuit _addSigner(signer: Either): [] { +export circuit _addSigner(signer: Bytes<32>): [] { return Signer__addSigner(signer); } -export circuit _removeSigner(signer: Either): [] { +export circuit _removeSigner(signer: Bytes<32>): [] { return Signer__removeSigner(signer); } export circuit _changeThreshold(newThreshold: Uint<8>): [] { return Signer__changeThreshold(newThreshold); } + +export circuit _setThreshold(newThreshold: Uint<8>): [] { + return Signer__setThreshold(newThreshold); +} + diff --git a/contracts/src/multisig/test/mocks/MockUnshieldedTreasury.compact b/contracts/src/multisig/test/mocks/MockUnshieldedTreasury.compact index f2c1b732..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 "../../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/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/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/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); + } +} 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/ShieldedMultiSigSimulator.ts b/contracts/src/multisig/test/simulators/ShieldedMultiSigSimulator.ts deleted file mode 100644 index a58035f4..00000000 --- a/contracts/src/multisig/test/simulators/ShieldedMultiSigSimulator.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { - 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'; - -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 ShieldedMultiSigArgs = readonly [ - signers: EitherPKAddress[], - thresh: bigint, -]; - -const ShieldedMultiSigSimulatorBase = createSimulator< - ShieldedMultiSigPrivateState, - ReturnType, - ReturnType, - ShieldedMultiSig, - ShieldedMultiSigArgs ->({ - contractFactory: (witnesses) => - new ShieldedMultiSig(witnesses), - defaultPrivateState: () => ShieldedMultiSigPrivateState, - contractArgs: (signers, thresh) => [signers, thresh], - ledgerExtractor: (state) => ledger(state), - witnessesFactory: () => ShieldedMultiSigWitnesses(), - artifactName: 'ShieldedMultiSig', -}); - -export class ShieldedMultiSigSimulator extends ShieldedMultiSigSimulatorBase { - static async create( - signers: EitherPKAddress[], - thresh: bigint, - options: SimulatorOptions< - ShieldedMultiSigPrivateState, - ReturnType - > = {}, - ): Promise { - // biome-ignore lint/complexity/noThisInStatic: super.create must keep the subclass `this` - return super.create( - [signers, thresh], - options, - ) as Promise; - } - - // Deposit - public deposit(coin: ShieldedCoinInfo): Promise<[]> { - return this.circuits.impure.deposit(coin); - } - - // Proposals - public createShieldedProposal( - to: Recipient, - color: Uint8Array, - amount: bigint, - ): Promise { - return this.circuits.impure.createShieldedProposal(to, color, amount); - } - - public approveProposal(id: bigint): Promise<[]> { - return this.circuits.impure.approveProposal(id); - } - - public revokeApproval(id: bigint): Promise<[]> { - return this.circuits.impure.revokeApproval(id); - } - - public executeShieldedProposal(id: bigint): Promise { - return this.circuits.impure.executeShieldedProposal(id); - } - - // View - Approvals - public isProposalApprovedBySigner( - id: bigint, - signer: EitherPKAddress, - ): Promise { - return this.circuits.impure.isProposalApprovedBySigner(id, signer); - } - - public getApprovalCount(id: bigint): Promise { - return this.circuits.impure.getApprovalCount(id); - } - - // View - Proposals - public getProposal(id: bigint): Promise { - return this.circuits.impure.getProposal(id); - } - - public getProposalRecipient(id: bigint): Promise { - return this.circuits.impure.getProposalRecipient(id); - } - - public getProposalAmount(id: bigint): Promise { - return this.circuits.impure.getProposalAmount(id); - } - - public getProposalColor(id: bigint): Promise { - return this.circuits.impure.getProposalColor(id); - } - - public getProposalStatus(id: bigint): Promise { - return this.circuits.impure.getProposalStatus(id); - } - - // View - Treasury - public getTokenBalance(color: Uint8Array): Promise { - return this.circuits.impure.getTokenBalance(color); - } - - public getReceivedTotal(color: Uint8Array): Promise { - return this.circuits.impure.getReceivedTotal(color); - } - - public getSentTotal(color: Uint8Array): Promise { - return this.circuits.impure.getSentTotal(color); - } - - public getReceivedMinusSent(color: Uint8Array): Promise { - return this.circuits.impure.getReceivedMinusSent(color); - } - - // View - Signers - public getSignerCount(): Promise { - return this.circuits.impure.getSignerCount(); - } - - public getThreshold(): Promise { - return this.circuits.impure.getThreshold(); - } - - public isSigner(account: EitherPKAddress): Promise { - return this.circuits.impure.isSigner(account); - } - - // Ledger access - public getLedger(): Promise { - 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/ShieldedMultiSigV3Simulator.ts deleted file mode 100644 index afba649f..00000000 --- a/contracts/src/multisig/test/simulators/ShieldedMultiSigV3Simulator.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { - createSimulator, - type SimulatorOptions, -} from '@openzeppelin/compact-simulator'; -import { - type ContractAddress, - type Either, - ledger, - pureCircuits, - Contract as ShieldedMultiSigV3Contract, - type ZswapCoinPublicKey, -} from '../../../../artifacts/ShieldedMultiSigV3/contract/index.js'; -import { - ShieldedMultiSigV3PrivateState, - ShieldedMultiSigV3Witnesses, -} from '../witnesses/ShieldedMultiSigV3Witnesses.js'; - -type ShieldedMultiSigV3Args = readonly [ - instanceSalt: Uint8Array, - initCoinNonce: Uint8Array, - tokenDomain: Uint8Array, - signerCommitments: Uint8Array[], -]; - -const ShieldedMultiSigV3SimulatorBase = createSimulator< - ShieldedMultiSigV3PrivateState, - ReturnType, - ReturnType, - ShieldedMultiSigV3Contract, - ShieldedMultiSigV3Args ->({ - contractFactory: (witnesses) => - new ShieldedMultiSigV3Contract(witnesses), - defaultPrivateState: () => ShieldedMultiSigV3PrivateState, - contractArgs: ( - instanceSalt, - initCoinNonce, - tokenDomain, - signerCommitments, - ) => [instanceSalt, initCoinNonce, tokenDomain, signerCommitments], - ledgerExtractor: (state) => ledger(state), - witnessesFactory: () => ShieldedMultiSigV3Witnesses(), - artifactName: 'ShieldedMultiSigV3', -}); - -export class ShieldedMultiSigV3Simulator extends ShieldedMultiSigV3SimulatorBase { - static async create( - instanceSalt: Uint8Array, - initCoinNonce: Uint8Array, - tokenDomain: Uint8Array, - signerCommitments: Uint8Array[], - options: SimulatorOptions< - ShieldedMultiSigV3PrivateState, - ReturnType - > = {}, - ): Promise { - // biome-ignore lint/complexity/noThisInStatic: super.create must keep the subclass `this` - return super.create( - [instanceSalt, initCoinNonce, tokenDomain, signerCommitments], - options, - ) as Promise; - } - - public _calculateSignerId( - pk: Uint8Array, - salt: Uint8Array, - ): Promise { - return this.circuits.pure._calculateSignerId(pk, salt); - } - - public mint( - amount: bigint, - recipient: Either, - pubkeys: Uint8Array[], - signatures: Uint8Array[], - ): Promise<[]> { - 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[], - ): Promise<[]> { - return this.circuits.impure.burn(coin, amount, pubkeys, signatures); - } - - public getNonce(): Promise { - return this.circuits.impure.getNonce(); - } - - public getTokenDomain(): Promise { - return this.circuits.impure.getTokenDomain(); - } - - public getTokenType(): Promise { - return this.circuits.impure.getTokenType(); - } - - 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); - } -} - -// 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/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 be5ee9aa..4e87dc4b 100644 --- a/contracts/src/multisig/test/simulators/SignerManagerSimulator.ts +++ b/contracts/src/multisig/test/simulators/SignerManagerSimulator.ts @@ -3,46 +3,32 @@ import { 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, -]; +import { EmptyPrivateState, emptyWitnesses } from '../EmptyWitnesses.js'; /** * Type constructor args */ -type SignerManagerArgs = readonly [signers: SignerSet, thresh: bigint]; +type SignerManagerArgs = readonly [ + signers: Uint8Array[], + thresh: bigint, + isInit: boolean, +]; const SignerManagerSimulatorBase = createSimulator< - SignerManagerPrivateState, + EmptyPrivateState, ReturnType, - ReturnType, - MockSignerManager, + ReturnType, + MockSignerManager, SignerManagerArgs >({ - contractFactory: (witnesses) => - new MockSignerManager(witnesses), - defaultPrivateState: () => SignerManagerPrivateState, - contractArgs: (signers, thresh) => [signers, thresh], + contractFactory: (witnesses) => new MockSignerManager(witnesses), + defaultPrivateState: () => EmptyPrivateState, + contractArgs: (signers, thresh, isInit) => [signers, thresh, isInit], ledgerExtractor: (state) => ledger(state), - witnessesFactory: () => SignerManagerWitnesses(), + witnessesFactory: () => emptyWitnesses(), artifactName: 'MockSignerManager', }); @@ -51,23 +37,26 @@ const SignerManagerSimulatorBase = createSimulator< */ export class SignerManagerSimulator extends SignerManagerSimulatorBase { static async create( - signers: SignerSet, + signers: Uint8Array[], thresh: bigint, + isInit: boolean, options: SimulatorOptions< - SignerManagerPrivateState, - ReturnType + EmptyPrivateState, + ReturnType > = {}, ): Promise { // biome-ignore lint/complexity/noThisInStatic: super.create must keep the subclass `this` return super.create( - [signers, thresh], + [signers, thresh, isInit], options, ) as Promise; } - public assertSigner( - caller: Either, - ): Promise<[]> { + public initialize(signers: Uint8Array[], thresh: bigint): Promise<[]> { + return this.circuits.impure.initialize(signers, thresh); + } + + public assertSigner(caller: Uint8Array): Promise<[]> { return this.circuits.impure.assertSigner(caller); } @@ -83,25 +72,23 @@ export class SignerManagerSimulator extends SignerManagerSimulatorBase { return this.circuits.impure.getThreshold(); } - public isSigner( - account: Either, - ): Promise { + public isSigner(account: Uint8Array): Promise { return this.circuits.impure.isSigner(account); } - public _addSigner( - signer: Either, - ): Promise<[]> { + public _addSigner(signer: Uint8Array): Promise<[]> { return this.circuits.impure._addSigner(signer); } - public _removeSigner( - signer: Either, - ): Promise<[]> { + public _removeSigner(signer: Uint8Array): Promise<[]> { return this.circuits.impure._removeSigner(signer); } public _changeThreshold(newThreshold: bigint): Promise<[]> { return this.circuits.impure._changeThreshold(newThreshold); } + + public _setThreshold(newThreshold: bigint): Promise<[]> { + return this.circuits.impure._setThreshold(newThreshold); + } } diff --git a/contracts/src/multisig/test/simulators/SignerSimulator.ts b/contracts/src/multisig/test/simulators/SignerSimulator.ts deleted file mode 100644 index ef3f3948..00000000 --- a/contracts/src/multisig/test/simulators/SignerSimulator.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { - createSimulator, - type SimulatorOptions, -} from '@openzeppelin/compact-simulator'; -import { - ledger, - Contract as MockSigner, -} from '../../../../artifacts/MockSigner/contract/index.js'; -import { - SignerPrivateState, - SignerWitnesses, -} from '../witnesses/SignerWitnesses.js'; - -/** - * Type constructor args - */ -type SignerArgs = readonly [ - signers: Uint8Array[], - thresh: bigint, - isInit: boolean, -]; - -const SignerSimulatorBase = createSimulator< - SignerPrivateState, - ReturnType, - ReturnType, - MockSigner, - SignerArgs ->({ - contractFactory: (witnesses) => new MockSigner(witnesses), - defaultPrivateState: () => SignerPrivateState, - contractArgs: (signers, thresh, isInit) => [signers, thresh, isInit], - ledgerExtractor: (state) => ledger(state), - witnessesFactory: () => SignerWitnesses(), - artifactName: 'MockSigner', -}); - -/** - * Signer Simulator - */ -export class SignerSimulator extends SignerSimulatorBase { - static async create( - signers: Uint8Array[], - thresh: bigint, - isInit: boolean, - options: SimulatorOptions< - SignerPrivateState, - ReturnType - > = {}, - ): Promise { - // biome-ignore lint/complexity/noThisInStatic: super.create must keep the subclass `this` - return super.create( - [signers, thresh, isInit], - options, - ) as Promise; - } - - public initialize(signers: Uint8Array[], thresh: bigint): Promise<[]> { - return this.circuits.impure.initialize(signers, thresh); - } - - public assertSigner(caller: Uint8Array): 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: Uint8Array): Promise { - return this.circuits.impure.isSigner(account); - } - - public _addSigner(signer: Uint8Array): Promise<[]> { - return this.circuits.impure._addSigner(signer); - } - - public _removeSigner(signer: Uint8Array): Promise<[]> { - return this.circuits.impure._removeSigner(signer); - } - - public _changeThreshold(newThreshold: bigint): Promise<[]> { - return this.circuits.impure._changeThreshold(newThreshold); - } - - public _setThreshold(newThreshold: bigint): Promise<[]> { - return this.circuits.impure._setThreshold(newThreshold); - } -} 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(); - } -} 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/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 = () => ({}); 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 = () => ({}); diff --git a/contracts/src/multisig/ShieldedTreasury.compact b/contracts/src/multisig/treasury/NativeShieldedTreasury.compact similarity index 92% rename from contracts/src/multisig/ShieldedTreasury.compact rename to contracts/src/multisig/treasury/NativeShieldedTreasury.compact index 4a0130ea..099cf6f3 100644 --- a/contracts/src/multisig/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,9 +22,9 @@ 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_; + import { selfAsRecipient, UINT128_MAX } from "../../utils/Utils" prefix Utils_; // ─── State ────────────────────────────────────────────────────── @@ -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/ShieldedTreasuryStateless.compact b/contracts/src/multisig/treasury/NativeShieldedTreasuryStateless.compact similarity index 91% rename from contracts/src/multisig/ShieldedTreasuryStateless.compact rename to contracts/src/multisig/treasury/NativeShieldedTreasuryStateless.compact index 698536e3..f9a5bbea 100644 --- a/contracts/src/multisig/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,9 +18,9 @@ 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_; + import { selfAsRecipient } from "../../utils/Utils" prefix Utils_; // ─── Deposit ──────────────────────────────────────────────────── diff --git a/contracts/src/multisig/UnshieldedTreasury.compact b/contracts/src/multisig/treasury/NativeUnshieldedTreasury.compact similarity index 92% rename from contracts/src/multisig/UnshieldedTreasury.compact rename to contracts/src/multisig/treasury/NativeUnshieldedTreasury.compact index 222a2df7..3f9e4d9e 100644 --- a/contracts/src/multisig/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,9 +17,9 @@ 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_; + import { UINT128_MAX } from "../../utils/Utils" prefix Utils_; // ─── State ────────────────────────────────────────────────────── @@ -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); diff --git a/contracts/src/multisig/presets/ShieldedMultiSig.compact b/contracts/test/integration/_mocks/MultisigProposalTreasury.compact similarity index 64% rename from contracts/src/multisig/presets/ShieldedMultiSig.compact rename to contracts/test/integration/_mocks/MultisigProposalTreasury.compact index 8a2f8b5f..7b2060a5 100644 --- a/contracts/src/multisig/presets/ShieldedMultiSig.compact +++ b/contracts/test/integration/_mocks/MultisigProposalTreasury.compact @@ -1,43 +1,50 @@ // SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.2.0 (multisig/presets/ShieldedMultiSig.compact) +// OpenZeppelin Compact Contracts v0.2.0 (test/integration/_mocks/MultisigProposalTreasury.compact) pragma language_version >= 0.23.0; +import CompactStandardLibrary; + /** - * @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. + * @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. * - * @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. + * This example is fixed at a 3-signer registry. * - * @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. + * @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 CompactStandardLibrary; - -import "../ProposalManager" prefix Proposal_; -import "../ShieldedTreasury" prefix Treasury_; -import "../SignerManager"> prefix Signer_; +import "../../../src/multisig/proposal/ProposalManager" prefix Proposal_; +import "../../../src/multisig/treasury/NativeShieldedTreasury" prefix Treasury_; +import "../../../src/multisig/SignerManager"> prefix Signer_; -// ─── State ─────────────────────────────────────────────────────────────── +// ─── State ────────────────────────────────────────────────────── export ledger _proposalApprovals: Map, Map, Boolean>>; export ledger _approvalCount: Map, Uint<8>>; -// ─── Constructor ───────────────────────────────────────────────────────── +// ─── 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> @@ -45,13 +52,13 @@ constructor( Signer_initialize<3>(signers, thresh); } -// ─── Deposit ───────────────────────────────────────────────────────────── +// ─── Deposit ──────────────────────────────────────────────────── export circuit deposit(coin: ShieldedCoinInfo): [] { Treasury__deposit(coin); } -// ─── Proposals ─────────────────────────────────────────────────────────── +// ─── Proposals ────────────────────────────────────────────────── export circuit createShieldedProposal( to: Proposal_Recipient, @@ -64,53 +71,40 @@ export circuit createShieldedProposal( assert( to.kind == Proposal_RecipientKind.ShieldedUser || to.kind == Proposal_RecipientKind.Contract, - "ShieldedMultiSig: recipient must be a shielded user or contract" + "ProposalTreasury: 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"); + assert(!isProposalApprovedBySigner(id, callerPK), "ProposalTreasury: 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"); + assert(isProposalApprovedBySigner(id, callerPK), "ProposalTreasury: not approved"); - // Revoke _revokeApproval(id, callerPK); } -export circuit executeShieldedProposal( - id: Uint<64>, -): ShieldedSendResult { - // Check if active +export circuit executeShieldedProposal(id: Uint<64>): ShieldedSendResult { 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), @@ -118,12 +112,11 @@ export circuit executeShieldedProposal( amount, ); - // Finish lifecycle Proposal__markExecuted(id); return result; } -// ─── Internal ─────────────────────────────────────────────────────────── +// ─── Internal ─────────────────────────────────────────────────── circuit _approveProposal(id: Uint<64>, signer: Either): [] { if (!_proposalApprovals.member(disclose(id))) { @@ -146,21 +139,16 @@ circuit _revokeApproval(id: Uint<64>, signer: 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. + * @warning Resolves callers via `ownPublicKey()` only, so a `right(ContractAddress)` + * signer cannot authenticate today. * - * @returns {Either} The caller wrapped as a left-variant. + * @returns {Either} The caller as a left-variant. */ circuit getCaller(): Either { return left(ownPublicKey()); } -// ─── View ─────────────────────────────────────────────────────────────── +// ─── View ─────────────────────────────────────────────────────── export circuit isProposalApprovedBySigner( id: Uint<64>, @@ -181,8 +169,6 @@ export circuit getApprovalCount(id: Uint<64>): Uint<8> { return _approvalCount.lookup(disclose(id)); } -// IProposalManager - export circuit getProposal(id: Uint<64>): Proposal_Proposal { return Proposal_getProposal(id); } @@ -203,8 +189,6 @@ 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); } @@ -221,8 +205,6 @@ export circuit getReceivedMinusSent(color: Bytes<32>): Uint<128> { return Treasury_getReceivedMinusSent(color); } -// ISignerManager - export circuit getSignerCount(): Uint<8> { return Signer_getSignerCount(); } 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); +}