-
Notifications
You must be signed in to change notification settings - Fork 26
add multisig v3 preset #501
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
andrew-fleming
wants to merge
4
commits into
OpenZeppelin:post-release
Choose a base branch
from
andrew-fleming:re-add-multisig-v3
base: post-release
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
368 changes: 368 additions & 0 deletions
368
contracts/src/multisig/presets/ShieldedMultiSigV3.compact
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,368 @@ | ||
| // SPDX-License-Identifier: MIT | ||
| // OpenZeppelin Compact Contracts v0.0.1-alpha.1 (multisig/presets/ShieldedMultiSigV3.compact) | ||
|
|
||
| pragma language_version >= 0.21.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 `receiveShielded` + `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"<Bytes<32>> 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. | ||
| */ | ||
| 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. | ||
| * | ||
| * 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: | ||
| * | ||
| * - `thresh` must be > 0 and <= 2. | ||
| * - `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. | ||
| * @param {Uint<8>} thresh - Minimum approvals required. | ||
| */ | ||
| constructor( | ||
| instanceSalt: Bytes<32>, | ||
| initCoinNonce: Bytes<32>, | ||
| tokenDomain: Bytes<32>, | ||
| signerCommitments: Vector<3, Bytes<32>>, | ||
| thresh: Uint<8>, | ||
| ) { | ||
| assert( | ||
| thresh <= 2, | ||
| "Multisig: threshold cannot exceed 2 (circuits verify at most 2 signatures)" | ||
| ); | ||
| _instanceSalt = disclose(instanceSalt); | ||
| _coinNonce = disclose(initCoinNonce); | ||
| _tokenDomain = disclose(tokenDomain); | ||
| Signer_initialize<3>(signerCommitments, thresh); | ||
| } | ||
|
|
||
| // ─── Mint ─────────────────────────────────────────────────────── | ||
|
|
||
| /** | ||
| * @description Mints a new shielded coin addressed to this contract, | ||
| * authorized by threshold signatures. | ||
| * | ||
| * Creates a new UTXO of this contract's token type via `mintShieldedToken`, | ||
| * addressed to the contract itself — (false, contract_address). No external | ||
| * coin input is required; supply is produced on-chain. | ||
| * | ||
| * The operator discovers the new UTXO via ZswapOutput events from the | ||
| * indexer and adds it to the off-chain UTXO pool for future burn operations. | ||
| * | ||
| * The message hash commits to: operation domain ("multisig:mint:"), current | ||
| * counter value (replay protection), and the amount. The domain prefix | ||
| * ensures mint signatures cannot be replayed as burn operations. | ||
| * | ||
| * 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 {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>, | ||
| pubkeys: Vector<2, Bytes<64>>, | ||
| signatures: Vector<2, Bytes<64>> | ||
| ): [] { | ||
| const opNonce = _counter; | ||
| _counter.increment(1); | ||
|
|
||
| const msgHash = persistentHash<Vector<4, Bytes<32>>>([ | ||
| pad(32, "multisig:mint:"), | ||
| 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); | ||
| Signer_assertThresholdMet(finalState.validCount); | ||
|
|
||
| _coinNonce = evolveNonce(_counter, _coinNonce); | ||
| const contractRecipient = right<ZswapCoinPublicKey, ContractAddress>(kernel.self()); | ||
| mintShieldedToken(_tokenDomain, disclose(amount), _coinNonce, disclose(contractRecipient)); | ||
| } | ||
|
|
||
| // ─── Burn ─────────────────────────────────────────────────────── | ||
|
|
||
| /** | ||
| * @description Burns a shielded coin of this contract's token type, | ||
| * authorized by threshold signatures. | ||
| * | ||
| * Receives the coin via `receiveShielded` then sends the specified amount | ||
| * 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<Vector<4, Bytes<32>>>([ | ||
| 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); | ||
| 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"); | ||
|
|
||
| const _coin = ShieldedCoinInfo { | ||
| nonce: coin.nonce, | ||
| color: coin.color, | ||
| value: coin.value | ||
| }; | ||
| receiveShielded(disclose(_coin)); | ||
| 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, ...>. | ||
| * This contract supports at most 2-of-N threshold configurations. | ||
| * 3-of-N or higher requires a separate contract 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>(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); | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should this be sent to a custom address? (TBD)