Skip to content

Add multisig preset v3 targeting main#604

Open
andrew-fleming wants to merge 24 commits into
OpenZeppelin:mainfrom
andrew-fleming:re-add-multisig-v3
Open

Add multisig preset v3 targeting main#604
andrew-fleming wants to merge 24 commits into
OpenZeppelin:mainfrom
andrew-fleming:re-add-multisig-v3

Conversation

@andrew-fleming

@andrew-fleming andrew-fleming commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Summary by CodeRabbit

  • New Features

    • Added a shielded multisig contract supporting threshold-based token minting and burning with cryptographic signature verification, nonce-based replay protection, and domain separation.
  • Tests

    • Comprehensive test suite covering contract initialization, mint and burn operations, signature validation, nonce management, and cross-instance behavior validation.

andrew-fleming and others added 11 commits May 5, 2026 14:25
Co-authored-by: 0xisk <iskander.andrews@openzeppelin.com>
Co-authored-by: 0xisk <0xisk@proton.me>
Signed-off-by: 0xisk <0xisk@proton.me>
Co-authored-by: andrew <fleming-andrew@protonmail.com>
Co-authored-by: 0xisk <iskander.andrews@openzeppelin.com>
@andrew-fleming andrew-fleming requested review from a team as code owners June 16, 2026 06:18
@coderabbitai

coderabbitai Bot commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 8df90272-7073-4bfe-bc27-f4bfe49e8a0b

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Walkthrough

Adds ShieldedMultiSigV3.compact, a threshold-authorized shielded token mint/burn contract with fold-based ECDSA signature verification, replay protection via a per-operation counter, and signer commitment derivation using (pk, salt, domain). Includes a TypeScript simulator, a comprehensive Vitest test suite, and eight placeholder witness scaffold modules for other multisig contract variants.

Changes

ShieldedMultiSigV3 Contract, Simulator, and Tests

Layer / File(s) Summary
Contract data types, ledger state, and constructor
contracts/src/multisig/presets/ShieldedMultiSigV3.compact
Defines imports, internal structs (VerificationState, SignerCommitmentInput), exported ledger variables (_counter, _coinNonce, _instanceSalt, _tokenDomain), and the constructor that initializes state, enforces threshold ≤ 2, and registers signer commitments.
mint and burn circuits
contracts/src/multisig/presets/ShieldedMultiSigV3.compact
mint increments the counter, hashes a multisig:mint:-prefixed message for replay protection, verifies 2 signatures via a fold-based callback, evolves the coin nonce, and calls mintShieldedToken. burn follows the same pattern, then asserts coin color and value before receiving the coin and sending to the burn address.
Signature verification and view circuits
contracts/src/multisig/presets/ShieldedMultiSigV3.compact
verifySignature fold callback derives commitments from (pubkey, instanceSalt), rejects duplicates, asserts registry membership, and invokes stubVerifySignature (always-true placeholder). _calculateSignerId uses a persistent hash over (pk, salt, domain). View circuits expose nonce, token domain/type, signer count/threshold, and membership.
TypeScript simulator wiring
contracts/src/multisig/test/simulators/ShieldedMultiSigV3Simulator.ts, contracts/src/multisig/witnesses/ShieldedMultiSigV3Witnesses.ts
Defines ShieldedMultiSigV3SimulatorBase via createSimulator, wires contract factory, ledger extractor, and witnesses. Exports ShieldedMultiSigV3Simulator with mint/burn/query methods delegating to circuits.impure/circuits.pure, and a standalone calculateSignerId helper.
Vitest test suite
contracts/src/multisig/test/ShieldedMultiSigV3.test.ts
Covers constructor validation, view getters, _calculateSignerId determinism, mint and burn acceptance/rejection paths, nonce monotonicity, domain separation, and cross-instance replay independence.

Placeholder Witness Module Scaffolding

Layer / File(s) Summary
Empty witness modules for other multisig contract variants
contracts/src/multisig/witnesses/ProposalManagerWitnesses.ts, contracts/src/multisig/witnesses/ShieldedMultiSigV2Witnesses.ts, contracts/src/multisig/witnesses/ShieldedMultiSigWitnesses.ts, contracts/src/multisig/witnesses/ShieldedTreasuryWitnesses.ts, contracts/src/multisig/witnesses/SignerManagerWitnesses.ts, contracts/src/multisig/witnesses/SignerWitnesses.ts, contracts/src/multisig/witnesses/UnshieldedTreasuryWitnesses.ts
Seven stub modules each exporting an empty PrivateState type (Record<string, never>), an empty constant, and a witnesses factory returning {}.

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant ShieldedMultiSigV3
  participant SignerRegistry
  participant ZswapRuntime

  rect rgba(100, 149, 237, 0.5)
    Note over Caller, ZswapRuntime: mint flow
    Caller->>ShieldedMultiSigV3: mint(amount, recipient, pubkeys[2], signatures[2])
    ShieldedMultiSigV3->>ShieldedMultiSigV3: _counter++, build multisig:mint: message hash
    loop fold over 2 signers
      ShieldedMultiSigV3->>SignerRegistry: derive commitment(pubkey, instanceSalt), assert member, no duplicate
    end
    ShieldedMultiSigV3->>ShieldedMultiSigV3: evolve _coinNonce
    ShieldedMultiSigV3->>ZswapRuntime: mintShieldedToken(recipient, amount, nonce)
  end

  rect rgba(200, 100, 100, 0.5)
    Note over Caller, ZswapRuntime: burn flow
    Caller->>ShieldedMultiSigV3: burn(coin, amount, pubkeys[2], signatures[2])
    ShieldedMultiSigV3->>ShieldedMultiSigV3: _counter++, build multisig:burn: message hash
    loop fold over 2 signers
      ShieldedMultiSigV3->>SignerRegistry: derive commitment(pubkey, instanceSalt), assert member, no duplicate
    end
    ShieldedMultiSigV3->>ShieldedMultiSigV3: assert coin.color == tokenType, coin.value >= amount
    ShieldedMultiSigV3->>ZswapRuntime: receiveShieldedCoin(coin), send amount to burnAddress
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Suggested labels

enhancement

Suggested reviewers

  • pepebndc

🐇 A new shielded multisig hops along,
With mint and burn circuits singing their song!
Three signers commit, two must agree,
The nonce ticks forward — no replay for me!
stubVerifySignature winks from the code,
A placeholder warming the cryptographic road. 🌟

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: adding a multisig preset v3 implementation, which is the primary focus of the PR with comprehensive contract, test, and simulator additions.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
contracts/src/multisig/test/simulators/ShieldedMultiSigV3Simulator.ts (1)

16-22: ⚡ Quick win

Use tuple types for fixed-size multisig vectors.

The contract expects fixed lengths, but simulator APIs accept unbounded arrays. Type this as tuples to catch invalid call shapes at compile time.

Suggested typing refinement
 type ShieldedMultiSigV3Args = readonly [
   instanceSalt: Uint8Array,
   initCoinNonce: Uint8Array,
   tokenDomain: Uint8Array,
-  signerCommitments: Uint8Array[],
+  signerCommitments: [Uint8Array, Uint8Array, Uint8Array],
   thresh: bigint,
 ];
@@
   public mint(
     amount: bigint,
     recipient: Either<ZswapCoinPublicKey, ContractAddress>,
-    pubkeys: Uint8Array[],
-    signatures: Uint8Array[],
+    pubkeys: [Uint8Array, Uint8Array],
+    signatures: [Uint8Array, Uint8Array],
   ) {
@@
     amount: bigint,
-    pubkeys: Uint8Array[],
-    signatures: Uint8Array[],
+    pubkeys: [Uint8Array, Uint8Array],
+    signatures: [Uint8Array, Uint8Array],
   ) {

Also applies to: 67-72, 84-85

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@contracts/src/multisig/test/simulators/ShieldedMultiSigV3Simulator.ts` around
lines 16 - 22, The ShieldedMultiSigV3Args type definition at lines 16-22 (and
similar type definitions at lines 67-72 and 84-85) uses unbounded array types
like Uint8Array[] for signerCommitments, but the contract expects fixed-length
vectors. Replace the unbounded array types with fixed-length tuple types that
match the contract's requirements. For signerCommitments in
ShieldedMultiSigV3Args and any similar vector fields in the other affected type
definitions, define them as tuples with a specific length (for example, as
readonly [Uint8Array, Uint8Array, ...] or similar) instead of Uint8Array[], so
that TypeScript enforces the correct number of elements at compile time. Ensure
all three type definition sites (lines 16-22, 67-72, and 84-85) are updated
consistently with the same fixed-length tuple patterns that the contract
expects.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@contracts/src/multisig/presets/ShieldedMultiSigV3.compact`:
- Around line 86-87: The signer-domain literal used in documentation has
inconsistent casing compared to the actual implementation in _calculateSignerId.
Update the documentation comments at the affected locations to use
"Multisig:signer:" (with capital M) instead of "multisig:signer:" (lowercase m)
to match what the implementation actually hashes, ensuring developers following
the documentation produce correct commitments. This inconsistency exists at
multiple locations including lines 86-87 and line 337.
- Around line 115-118: The assertion at ShieldedMultiSigV3.compact line 115-118
allows thresh <= 2 (including thresh = 1), but the runtime execution path always
requires and validates exactly 2 signatures, making thresh = 1 unenforceable.
Change the assertion to enforce thresh = 2 (or thresh >= 2 if higher values are
possible) and update the error message accordingly. Apply the same fix to all
sibling locations at lines 162-167, 237-242, and 305-310 to ensure consistency
across all threshold validation blocks in the contract.
- Around line 305-307: The stubVerifySignature function currently always returns
true, completely bypassing signature verification in the multisig contract and
making the authorization policy unenforceable. Replace the stub implementation
to fail-closed by returning false or throwing an error instead, ensuring that
signature verification fails until real ECDSA verification with ecdsaVerify and
keccak256 primitives can be properly implemented. This prevents authentication
bypass in both the signature assertion at line 305-307 and any other locations
where stubVerifySignature is called within the 345-351 range.

---

Nitpick comments:
In `@contracts/src/multisig/test/simulators/ShieldedMultiSigV3Simulator.ts`:
- Around line 16-22: The ShieldedMultiSigV3Args type definition at lines 16-22
(and similar type definitions at lines 67-72 and 84-85) uses unbounded array
types like Uint8Array[] for signerCommitments, but the contract expects
fixed-length vectors. Replace the unbounded array types with fixed-length tuple
types that match the contract's requirements. For signerCommitments in
ShieldedMultiSigV3Args and any similar vector fields in the other affected type
definitions, define them as tuples with a specific length (for example, as
readonly [Uint8Array, Uint8Array, ...] or similar) instead of Uint8Array[], so
that TypeScript enforces the correct number of elements at compile time. Ensure
all three type definition sites (lines 16-22, 67-72, and 84-85) are updated
consistently with the same fixed-length tuple patterns that the contract
expects.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 52caec6e-96c8-4565-81a5-b47ca638f541

📥 Commits

Reviewing files that changed from the base of the PR and between 0c7e26f and 22fca12.

📒 Files selected for processing (11)
  • contracts/src/multisig/presets/ShieldedMultiSigV3.compact
  • contracts/src/multisig/test/ShieldedMultiSigV3.test.ts
  • contracts/src/multisig/test/simulators/ShieldedMultiSigV3Simulator.ts
  • contracts/src/multisig/witnesses/ProposalManagerWitnesses.ts
  • contracts/src/multisig/witnesses/ShieldedMultiSigV2Witnesses.ts
  • contracts/src/multisig/witnesses/ShieldedMultiSigV3Witnesses.ts
  • contracts/src/multisig/witnesses/ShieldedMultiSigWitnesses.ts
  • contracts/src/multisig/witnesses/ShieldedTreasuryWitnesses.ts
  • contracts/src/multisig/witnesses/SignerManagerWitnesses.ts
  • contracts/src/multisig/witnesses/SignerWitnesses.ts
  • contracts/src/multisig/witnesses/UnshieldedTreasuryWitnesses.ts

Comment on lines +86 to +87
* `persistentHash(SignerCommitmentInput { pk, instanceSalt, domain })`
* where domain is `pad(32, "multisig:signer:")`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Signer-domain literal is inconsistent between docs and implementation.

Docs use multisig:signer: while _calculateSignerId hashes Multisig:signer:. Following the constructor docs can produce wrong commitments.

Suggested doc alignment (non-breaking)
- * where domain is `pad(32, "multisig:signer:")`.
+ * where domain is `pad(32, "Multisig:signer:")`.

Also applies to: 337-337

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@contracts/src/multisig/presets/ShieldedMultiSigV3.compact` around lines 86 -
87, The signer-domain literal used in documentation has inconsistent casing
compared to the actual implementation in _calculateSignerId. Update the
documentation comments at the affected locations to use "Multisig:signer:" (with
capital M) instead of "multisig:signer:" (lowercase m) to match what the
implementation actually hashes, ensuring developers following the documentation
produce correct commitments. This inconsistency exists at multiple locations
including lines 86-87 and line 337.

Comment thread contracts/src/multisig/presets/ShieldedMultiSigV3.compact Outdated
Comment on lines +305 to +307
// TODO: Replace with ecdsaVerify + keccak256 when primitives are available
assert(stubVerifySignature(pubkey, state.msgHash, signature), "Multisig: invalid signature");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Authentication is effectively disabled by the stub verifier.

Line 350 always returns true, so signature ownership is never proven in mint/burn. This makes authorization policy unenforceable.

Suggested fail-closed mitigation until real ECDSA verification is available
 circuit stubVerifySignature(
   pubkey: Bytes<64>,
   msgHash: Bytes<32>,
   signature: Bytes<64>
 ): Boolean {
-  return true;
+  assert(false, "Multisig: signature verification unavailable in this preset");
+  return false;
 }

Also applies to: 345-351

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@contracts/src/multisig/presets/ShieldedMultiSigV3.compact` around lines 305 -
307, The stubVerifySignature function currently always returns true, completely
bypassing signature verification in the multisig contract and making the
authorization policy unenforceable. Replace the stub implementation to
fail-closed by returning false or throwing an error instead, ensuring that
signature verification fails until real ECDSA verification with ecdsaVerify and
keccak256 primitives can be properly implemented. This prevents authentication
bypass in both the signature assertion at line 305-307 and any other locations
where stubVerifySignature is called within the 345-351 range.

@0xisk 0xisk left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you @andrew-fleming! Looking good just some comments left.

Comment on lines +265 to +271
const _coin = ShieldedCoinInfo {
nonce: coin.nonce,
color: coin.color,
value: coin.value
};
receiveShielded(disclose(_coin));
sendShielded(disclose(coin), shieldedBurnAddress(), disclose(amount));

@0xisk 0xisk Jun 18, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const _coin = ShieldedCoinInfo {
nonce: coin.nonce,
color: coin.color,
value: coin.value
};
receiveShielded(disclose(_coin));
sendShielded(disclose(coin), shieldedBurnAddress(), disclose(amount));
sendShielded(disclose(coin), shieldedBurnAddress(), disclose(amount));

🔴 blocking: no need to receiveShielded as this is already a QualifiedCoinInfo.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 blocking: Needs to rebase to remove all those repeated witnesses.

// SPDX-License-Identifier: MIT
// OpenZeppelin Compact Contracts v0.0.1-alpha.1 (multisig/presets/ShieldedMultiSigV3.compact)

pragma language_version >= 0.21.0;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 blocking: upgrade to 0.23

0xisk added 3 commits June 18, 2026 10:38
The coin passed to ShieldedMultiSigV3.burn is already a
QualifiedShieldedCoinInfo, so sendShielded can spend it directly. The
receiveShielded call and the intermediate ShieldedCoinInfo
reconstruction were redundant; remove them and align the doc comments.

Refs: OpenZeppelin#604
Align ShieldedMultiSigV3 with the repo-wide pragma raised in OpenZeppelin#598 and
with its own deps (Signer, Utils), which already declare >= 0.23.0.

Refs: OpenZeppelin#604
The PR introduced a second witnesses dir at src/multisig/witnesses/
holding byte-identical copies of files already in test/witnesses/.
Only ShieldedMultiSigV3Witnesses.ts was actually consumed.

Drop the 7 duplicates, move the V3 witness under test/witnesses/ to
match the rest of the suite, and repoint the V3 simulator import to
../witnesses/ like the V2 simulator.

Refs: OpenZeppelin#604
Comment on lines +115 to +118
assert(
thresh <= 2,
"Multisig: threshold cannot exceed 2 (circuits verify at most 2 signatures)"
);

@0xisk 0xisk Jun 18, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❔︎ question: shouldn't we assert also > 0?

@andrew-fleming andrew-fleming Jun 19, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Signer disallows 0 already

  export circuit initialize<#n>(
    signers: Vector<n, T>,
    thresh: Uint<8>
  ): [] {
    (...)
    _changeThreshold(thresh);
  }

  export circuit _changeThreshold(newThreshold: Uint<8>): [] {
    assert(newThreshold <= _signerCount, "Signer: threshold exceeds signer count");
    _setThreshold(newThreshold);
  }

  export circuit _setThreshold(newThreshold: Uint<8>): [] {
    assert(newThreshold != 0, "Signer: threshold must not be zero");
    _threshold = disclose(newThreshold);
  }

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed the thresh param since it can only be 2

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants