From f2dbffa05799f61b41d9fdb852c18f2631aab5c0 Mon Sep 17 00:00:00 2001 From: andrew Date: Mon, 11 May 2026 12:39:51 -0300 Subject: [PATCH 1/4] add multisig v3 preset --- .../presets/ShieldedMultiSigV3.compact | 367 ++++++++++++++++++ .../multisig/test/ShieldedMultiSigV3.test.ts | 330 ++++++++++++++++ .../simulators/ShieldedMultiSigV3Simulator.ts | 117 ++++++ .../witnesses/ShieldedMultiSigV3Witnesses.ts | 7 + 4 files changed, 821 insertions(+) create mode 100644 contracts/src/multisig/presets/ShieldedMultiSigV3.compact create mode 100644 contracts/src/multisig/test/ShieldedMultiSigV3.test.ts create mode 100644 contracts/src/multisig/test/simulators/ShieldedMultiSigV3Simulator.ts create mode 100644 contracts/src/multisig/witnesses/ShieldedMultiSigV3Witnesses.ts diff --git a/contracts/src/multisig/presets/ShieldedMultiSigV3.compact b/contracts/src/multisig/presets/ShieldedMultiSigV3.compact new file mode 100644 index 00000000..3c5348c3 --- /dev/null +++ b/contracts/src/multisig/presets/ShieldedMultiSigV3.compact @@ -0,0 +1,367 @@ +// 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 "../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. + */ +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 ────────────────────────────────────────────────────── + +ledger _counter: Counter; +ledger _coinNonce: Bytes<32>; +ledger _instanceSalt: Bytes<32>; +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>>([ + pad(32, "multisig:mint:"), + 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(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>>([ + pad(32, "multisig:burn:"), + 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 { + pk: pk, + salt: salt, + domain: pad(32, "Multisig:signer:") + }); +} + +/** + * @description Stub for ECDSA signature verification. + * Always returns true. MUST be replaced before any non-test deployment. + */ +circuit stubVerifySignature( + pubkey: Bytes<64>, + msgHash: Bytes<32>, + signature: Bytes<64> +): Boolean { + return true; +} + +// ─── View ─────────────────────────────────────────────────────── + +export circuit getNonce(): Uint<64> { + return _counter; +} + +export circuit getTokenDomain(): Bytes<32> { + return _tokenDomain; +} + +export circuit getTokenType(): Bytes<32> { + return tokenType(_tokenDomain, kernel.self()); +} + +export circuit getSignerCount(): Uint<8> { + return Signer_getSignerCount(); +} + +export circuit getThreshold(): Uint<8> { + return Signer_getThreshold(); +} + +export circuit isSigner(commitment: Bytes<32>): Boolean { + return Signer_isSigner(commitment); +} diff --git a/contracts/src/multisig/test/ShieldedMultiSigV3.test.ts b/contracts/src/multisig/test/ShieldedMultiSigV3.test.ts new file mode 100644 index 00000000..cc2ea715 --- /dev/null +++ b/contracts/src/multisig/test/ShieldedMultiSigV3.test.ts @@ -0,0 +1,330 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +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); + +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 with 2-of-3 threshold', () => { + multisig = new ShieldedMultiSigV3Simulator( + INSTANCE_SALT, + INIT_COIN_NONCE, + TOKEN_DOMAIN, + SIGNER_COMMITMENTS, + 2n, + ); + expect(multisig.getSignerCount()).toEqual(3n); + expect(multisig.getThreshold()).toEqual(2n); + }); + + it('should initialize with 1-of-3 threshold', () => { + multisig = new ShieldedMultiSigV3Simulator( + INSTANCE_SALT, + INIT_COIN_NONCE, + TOKEN_DOMAIN, + SIGNER_COMMITMENTS, + 1n, + ); + expect(multisig.getThreshold()).toEqual(1n); + }); + + it('should fail with zero threshold', () => { + expect(() => { + new ShieldedMultiSigV3Simulator( + INSTANCE_SALT, + INIT_COIN_NONCE, + TOKEN_DOMAIN, + SIGNER_COMMITMENTS, + 0n, + ); + }).toThrow('SignerManager: threshold must be > 0'); + }); + + it('should fail with threshold greater than 2', () => { + expect(() => { + new ShieldedMultiSigV3Simulator( + INSTANCE_SALT, + INIT_COIN_NONCE, + TOKEN_DOMAIN, + SIGNER_COMMITMENTS, + 3n, + ); + }).toThrow('Multisig: threshold cannot exceed 2'); + }); + + it('should register all signer commitments', () => { + multisig = new ShieldedMultiSigV3Simulator( + INSTANCE_SALT, + INIT_COIN_NONCE, + TOKEN_DOMAIN, + SIGNER_COMMITMENTS, + 2n, + ); + for (const commitment of SIGNER_COMMITMENTS) { + expect(multisig.isSigner(commitment)).toEqual(true); + } + }); + + it('should reject a non-signer commitment', () => { + multisig = new ShieldedMultiSigV3Simulator( + INSTANCE_SALT, + INIT_COIN_NONCE, + TOKEN_DOMAIN, + SIGNER_COMMITMENTS, + 2n, + ); + const unknown = multisig._calculateSignerId(NON_SIGNER_PK, INSTANCE_SALT); + expect(multisig.isSigner(unknown)).toEqual(false); + }); + + it('should store token domain', () => { + multisig = new ShieldedMultiSigV3Simulator( + INSTANCE_SALT, + INIT_COIN_NONCE, + TOKEN_DOMAIN, + SIGNER_COMMITMENTS, + 2n, + ); + expect(multisig.getTokenDomain()).toEqual(TOKEN_DOMAIN); + }); + }); + + describe('when initialized', () => { + beforeEach(() => { + multisig = new ShieldedMultiSigV3Simulator( + INSTANCE_SALT, + INIT_COIN_NONCE, + TOKEN_DOMAIN, + SIGNER_COMMITMENTS, + 2n, + ); + }); + + describe('view', () => { + it('getNonce should start at 0', () => { + expect(multisig.getNonce()).toEqual(0n); + }); + + it('getSignerCount should return 3', () => { + expect(multisig.getSignerCount()).toEqual(3n); + }); + + it('getThreshold should match constructor arg', () => { + expect(multisig.getThreshold()).toEqual(2n); + }); + + it('getTokenType should return non-zero', () => { + expect(multisig.getTokenType()).not.toEqual(new Uint8Array(32)); + }); + + it('getTokenType should be deterministic', () => { + expect(multisig.getTokenType()).toEqual(multisig.getTokenType()); + }); + }); + + describe('_calculateSignerId', () => { + it('should produce deterministic commitments', () => { + const c1 = multisig._calculateSignerId(PK1, INSTANCE_SALT); + const c2 = multisig._calculateSignerId(PK1, INSTANCE_SALT); + expect(c1).toEqual(c2); + }); + + it('should produce different commitments for different keys', () => { + const c1 = multisig._calculateSignerId(PK1, INSTANCE_SALT); + const c2 = multisig._calculateSignerId(PK2, INSTANCE_SALT); + expect(c1).not.toEqual(c2); + }); + + it('should produce different commitments for different salts', () => { + const salt2 = new Uint8Array(32).fill(0xcc); + const c1 = multisig._calculateSignerId(PK1, INSTANCE_SALT); + const c2 = multisig._calculateSignerId(PK1, salt2); + expect(c1).not.toEqual(c2); + }); + + it('should match registered commitments', () => { + expect(multisig._calculateSignerId(PK1, INSTANCE_SALT)).toEqual( + COMMITMENT1, + ); + expect(multisig._calculateSignerId(PK2, INSTANCE_SALT)).toEqual( + COMMITMENT2, + ); + expect(multisig._calculateSignerId(PK3, INSTANCE_SALT)).toEqual( + COMMITMENT3, + ); + }); + }); + + describe('mint', () => { + it('should mint with signers 0 and 1', () => { + expect(() => { + multisig.mint(100n, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]); + }).not.toThrow(); + }); + + it('should mint with signers 0 and 2', () => { + expect(() => { + multisig.mint(100n, [PK1, PK3], [DUMMY_SIG, DUMMY_SIG]); + }).not.toThrow(); + }); + + it('should mint with signers 1 and 2', () => { + expect(() => { + multisig.mint(100n, [PK2, PK3], [DUMMY_SIG, DUMMY_SIG]); + }).not.toThrow(); + }); + + it('should reject duplicate signer', () => { + expect(() => { + multisig.mint(100n, [PK1, PK1], [DUMMY_SIG, DUMMY_SIG]); + }).toThrow('Multisig: duplicate signer'); + }); + + it('should reject a non-signer pubkey', () => { + expect(() => { + multisig.mint(100n, [PK1, NON_SIGNER_PK], [DUMMY_SIG, DUMMY_SIG]); + }).toThrow('SignerManager: not a signer'); + }); + + it('should increment nonce after mint', () => { + expect(multisig.getNonce()).toEqual(0n); + multisig.mint(100n, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]); + expect(multisig.getNonce()).toEqual(1n); + }); + + it('should increment nonce on each mint', () => { + multisig.mint(100n, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]); + multisig.mint(200n, [PK1, PK3], [DUMMY_SIG, DUMMY_SIG]); + multisig.mint(300n, [PK2, PK3], [DUMMY_SIG, DUMMY_SIG]); + expect(multisig.getNonce()).toEqual(3n); + }); + + it('should accept zero amount', () => { + expect(() => { + multisig.mint(0n, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]); + }).not.toThrow(); + }); + }); + + describe('burn', () => { + it('should reject duplicate signer', () => { + const coin = makeQualifiedCoin(multisig.getTokenType(), 100n); + expect(() => { + multisig.burn(coin, 100n, [PK1, PK1], [DUMMY_SIG, DUMMY_SIG]); + }).toThrow('Multisig: duplicate signer'); + }); + + it('should reject a non-signer pubkey', () => { + const coin = makeQualifiedCoin(multisig.getTokenType(), 100n); + expect(() => { + multisig.burn( + coin, + 100n, + [PK1, NON_SIGNER_PK], + [DUMMY_SIG, DUMMY_SIG], + ); + }).toThrow('SignerManager: not a signer'); + }); + + it('should reject wrong token color', () => { + const wrongColor = new Uint8Array(32).fill(0xde); + const coin = makeQualifiedCoin(wrongColor, 100n); + expect(() => { + multisig.burn(coin, 100n, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]); + }).toThrow('Multisig: coin not from this contract'); + }); + + it('should reject insufficient coin value', () => { + const coin = makeQualifiedCoin(multisig.getTokenType(), 10n); + expect(() => { + multisig.burn(coin, 100n, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]); + }).toThrow('Multisig: insufficient coin value'); + }); + + it('should reject when amount exceeds value by 1', () => { + const coin = makeQualifiedCoin(multisig.getTokenType(), 99n); + expect(() => { + multisig.burn(coin, 100n, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]); + }).toThrow('Multisig: insufficient coin value'); + }); + }); + + describe('domain separation', () => { + it('should isolate signers across instances with different salts', () => { + const salt2 = new Uint8Array(32).fill(0xcc); + const c1 = multisig._calculateSignerId(PK1, INSTANCE_SALT); + const c2 = multisig._calculateSignerId(PK1, salt2); + expect(c1).not.toEqual(c2); + }); + + it('should derive different token types with different domains', () => { + const altDomain = new Uint8Array(32); + Buffer.from('alt:token:').copy(altDomain); + + const alt = new ShieldedMultiSigV3Simulator( + INSTANCE_SALT, + INIT_COIN_NONCE, + altDomain, + SIGNER_COMMITMENTS, + 2n, + ); + + expect(multisig.getTokenType()).not.toEqual(alt.getTokenType()); + }); + }); + + describe('nonce', () => { + it('should start at 0', () => { + expect(multisig.getNonce()).toEqual(0n); + }); + + it('should increment monotonically', () => { + for (let i = 0; i < 5; i++) { + multisig.mint(1n, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]); + expect(multisig.getNonce()).toEqual(BigInt(i + 1)); + } + }); + }); + }); +}); diff --git a/contracts/src/multisig/test/simulators/ShieldedMultiSigV3Simulator.ts b/contracts/src/multisig/test/simulators/ShieldedMultiSigV3Simulator.ts new file mode 100644 index 00000000..f2fe87c0 --- /dev/null +++ b/contracts/src/multisig/test/simulators/ShieldedMultiSigV3Simulator.ts @@ -0,0 +1,117 @@ +import { + type BaseSimulatorOptions, + createSimulator, +} from '@openzeppelin-compact/contracts-simulator'; +import { + ledger, + pureCircuits, + Contract as ShieldedMultisigV3Contract, +} from '../../../../artifacts/ShieldedMultisigV3/contract/index.js'; +import { + ShieldedMultiSigV3PrivateState, + ShieldedMultiSigV3Witnesses, +} from '../../witnesses/ShieldedMultiSigV3Witnesses.js'; + +type ShieldedMultisigV3Args = readonly [ + instanceSalt: Uint8Array, + initCoinNonce: Uint8Array, + tokenDomain: Uint8Array, + signerCommitments: Uint8Array[], + thresh: bigint, +]; + +const ShieldedMultiSigV3SimulatorBase = createSimulator< + ShieldedMultiSigV3PrivateState, + ReturnType, + ReturnType, + ShieldedMultisigV3Contract, + ShieldedMultisigV3Args +>({ + contractFactory: (witnesses) => + new ShieldedMultisigV3Contract(witnesses), + defaultPrivateState: () => ShieldedMultiSigV3PrivateState, + contractArgs: ( + instanceSalt, + initCoinNonce, + tokenDomain, + signerCommitments, + thresh, + ) => [instanceSalt, initCoinNonce, tokenDomain, signerCommitments, thresh], + ledgerExtractor: (state) => ledger(state), + witnessesFactory: () => ShieldedMultiSigV3Witnesses(), +}); + +export class ShieldedMultiSigV3Simulator extends ShieldedMultiSigV3SimulatorBase { + constructor( + instanceSalt: Uint8Array, + initCoinNonce: Uint8Array, + tokenDomain: Uint8Array, + signerCommitments: Uint8Array[], + thresh: bigint, + options: BaseSimulatorOptions< + ShieldedMultiSigV3PrivateState, + ReturnType + > = {}, + ) { + super( + [instanceSalt, initCoinNonce, tokenDomain, signerCommitments, thresh], + options, + ); + } + + public _calculateSignerId(pk: Uint8Array, salt: Uint8Array): Uint8Array { + return this.circuits.pure._calculateSignerId(pk, salt); + } + + public mint(amount: bigint, pubkeys: Uint8Array[], signatures: Uint8Array[]) { + return this.circuits.impure.mint(amount, pubkeys, signatures); + } + + public burn( + coin: { + nonce: Uint8Array; + color: Uint8Array; + value: bigint; + mt_index: bigint; + }, + amount: bigint, + pubkeys: Uint8Array[], + signatures: Uint8Array[], + ) { + return this.circuits.impure.burn(coin, amount, pubkeys, signatures); + } + + public getNonce(): bigint { + return this.circuits.impure.getNonce(); + } + + public getTokenDomain(): Uint8Array { + return this.circuits.impure.getTokenDomain(); + } + + public getTokenType(): Uint8Array { + return this.circuits.impure.getTokenType(); + } + + public getSignerCount(): bigint { + return this.circuits.impure.getSignerCount(); + } + + public getThreshold(): bigint { + return this.circuits.impure.getThreshold(); + } + + public isSigner(commitment: Uint8Array): boolean { + return this.circuits.impure.isSigner(commitment); + } +} + +// Computes signer commitment from `pk`, `salt`, and +// domain ("Multisig:signer:"). Pure standalone circuit so commitments can be +// calculated before contract instantiation. +export function calculateSignerId( + pk: Uint8Array, + salt: Uint8Array, +): Uint8Array { + return pureCircuits._calculateSignerId(pk, salt); +} diff --git a/contracts/src/multisig/witnesses/ShieldedMultiSigV3Witnesses.ts b/contracts/src/multisig/witnesses/ShieldedMultiSigV3Witnesses.ts new file mode 100644 index 00000000..f726fb96 --- /dev/null +++ b/contracts/src/multisig/witnesses/ShieldedMultiSigV3Witnesses.ts @@ -0,0 +1,7 @@ +// 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 = () => ({}); From c182f53329b7a0d7de1a3fdfeca72cfc72e72d2f Mon Sep 17 00:00:00 2001 From: andrew Date: Mon, 11 May 2026 15:32:41 -0300 Subject: [PATCH 2/4] fix casing --- .../src/multisig/test/ShieldedMultiSigV3.test.ts | 2 +- .../test/simulators/ShieldedMultiSigV3Simulator.ts | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/contracts/src/multisig/test/ShieldedMultiSigV3.test.ts b/contracts/src/multisig/test/ShieldedMultiSigV3.test.ts index cc2ea715..802f9a31 100644 --- a/contracts/src/multisig/test/ShieldedMultiSigV3.test.ts +++ b/contracts/src/multisig/test/ShieldedMultiSigV3.test.ts @@ -44,7 +44,7 @@ function makeQualifiedCoin( let multisig: ShieldedMultiSigV3Simulator; -describe('ShieldedMultisigV3', () => { +describe('ShieldedMultiSigV3', () => { describe('constructor', () => { it('should initialize with 2-of-3 threshold', () => { multisig = new ShieldedMultiSigV3Simulator( diff --git a/contracts/src/multisig/test/simulators/ShieldedMultiSigV3Simulator.ts b/contracts/src/multisig/test/simulators/ShieldedMultiSigV3Simulator.ts index f2fe87c0..6f3d48b3 100644 --- a/contracts/src/multisig/test/simulators/ShieldedMultiSigV3Simulator.ts +++ b/contracts/src/multisig/test/simulators/ShieldedMultiSigV3Simulator.ts @@ -5,14 +5,14 @@ import { import { ledger, pureCircuits, - Contract as ShieldedMultisigV3Contract, -} from '../../../../artifacts/ShieldedMultisigV3/contract/index.js'; + Contract as ShieldedMultiSigV3Contract, +} from '../../../../artifacts/ShieldedMultiSigV3/contract/index.js'; import { ShieldedMultiSigV3PrivateState, ShieldedMultiSigV3Witnesses, } from '../../witnesses/ShieldedMultiSigV3Witnesses.js'; -type ShieldedMultisigV3Args = readonly [ +type ShieldedMultiSigV3Args = readonly [ instanceSalt: Uint8Array, initCoinNonce: Uint8Array, tokenDomain: Uint8Array, @@ -24,11 +24,11 @@ const ShieldedMultiSigV3SimulatorBase = createSimulator< ShieldedMultiSigV3PrivateState, ReturnType, ReturnType, - ShieldedMultisigV3Contract, - ShieldedMultisigV3Args + ShieldedMultiSigV3Contract, + ShieldedMultiSigV3Args >({ contractFactory: (witnesses) => - new ShieldedMultisigV3Contract(witnesses), + new ShieldedMultiSigV3Contract(witnesses), defaultPrivateState: () => ShieldedMultiSigV3PrivateState, contractArgs: ( instanceSalt, From 762cdcb1e90e3e2e5d646626fe7804d49a3961f2 Mon Sep 17 00:00:00 2001 From: andrew Date: Tue, 12 May 2026 03:22:42 -0300 Subject: [PATCH 3/4] add address to msg hash --- .../multisig/presets/ShieldedMultiSigV3.compact | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/contracts/src/multisig/presets/ShieldedMultiSigV3.compact b/contracts/src/multisig/presets/ShieldedMultiSigV3.compact index 3c5348c3..d32e8c6a 100644 --- a/contracts/src/multisig/presets/ShieldedMultiSigV3.compact +++ b/contracts/src/multisig/presets/ShieldedMultiSigV3.compact @@ -40,7 +40,7 @@ pragma language_version >= 0.21.0; import CompactStandardLibrary; -import "../SignerManager"> prefix Signer_; +import "../Signer"> prefix Signer_; // ─── Types ────────────────────────────────────────────────────── @@ -68,10 +68,10 @@ struct SignerCommitmentInput { // ─── State ────────────────────────────────────────────────────── -ledger _counter: Counter; -ledger _coinNonce: Bytes<32>; -ledger _instanceSalt: Bytes<32>; -sealed ledger _tokenDomain: Bytes<32>; +export ledger _counter: Counter; +export ledger _coinNonce: Bytes<32>; +export ledger _instanceSalt: Bytes<32>; +export sealed ledger _tokenDomain: Bytes<32>; // ─── Constructor ──────────────────────────────────────────────── @@ -163,8 +163,9 @@ export circuit mint( const opNonce = _counter; _counter.increment(1); - const msgHash = persistentHash>>([ + const msgHash = persistentHash>>([ pad(32, "multisig:mint:"), + kernel.self().bytes, opNonce as Bytes<32>, amount as Bytes<32> ]); @@ -175,7 +176,6 @@ export circuit mint( msgHash: msgHash }; - const finalState = fold(verifySignature, initialState, pubkeys, signatures); Signer_assertThresholdMet(finalState.validCount); @@ -234,8 +234,9 @@ export circuit burn( const opNonce = _counter; _counter.increment(1); - const msgHash = persistentHash>>([ + const msgHash = persistentHash>>([ pad(32, "multisig:burn:"), + kernel.self().bytes, opNonce as Bytes<32>, amount as Bytes<32> ]); From 43fa9f24b32ee8c1f4527aff533d9f46fbf74f2d Mon Sep 17 00:00:00 2001 From: andrew Date: Tue, 12 May 2026 03:22:54 -0300 Subject: [PATCH 4/4] add missing tests --- .../multisig/test/ShieldedMultiSigV3.test.ts | 80 ++++++++++++++++++- 1 file changed, 77 insertions(+), 3 deletions(-) diff --git a/contracts/src/multisig/test/ShieldedMultiSigV3.test.ts b/contracts/src/multisig/test/ShieldedMultiSigV3.test.ts index 802f9a31..806ee4f7 100644 --- a/contracts/src/multisig/test/ShieldedMultiSigV3.test.ts +++ b/contracts/src/multisig/test/ShieldedMultiSigV3.test.ts @@ -78,7 +78,7 @@ describe('ShieldedMultiSigV3', () => { SIGNER_COMMITMENTS, 0n, ); - }).toThrow('SignerManager: threshold must be > 0'); + }).toThrow('Signer: threshold must not be zero'); }); it('should fail with threshold greater than 2', () => { @@ -118,6 +118,18 @@ describe('ShieldedMultiSigV3', () => { expect(multisig.isSigner(unknown)).toEqual(false); }); + it('should fail with duplicate signer commitments', () => { + expect(() => { + new ShieldedMultiSigV3Simulator( + INSTANCE_SALT, + INIT_COIN_NONCE, + TOKEN_DOMAIN, + [COMMITMENT1, COMMITMENT1, COMMITMENT2], + 2n, + ); + }).toThrow('Signer: signer already active'); + }); + it('should store token domain', () => { multisig = new ShieldedMultiSigV3Simulator( INSTANCE_SALT, @@ -224,7 +236,7 @@ describe('ShieldedMultiSigV3', () => { it('should reject a non-signer pubkey', () => { expect(() => { multisig.mint(100n, [PK1, NON_SIGNER_PK], [DUMMY_SIG, DUMMY_SIG]); - }).toThrow('SignerManager: not a signer'); + }).toThrow('Signer: not a signer'); }); it('should increment nonce after mint', () => { @@ -245,9 +257,41 @@ describe('ShieldedMultiSigV3', () => { multisig.mint(0n, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]); }).not.toThrow(); }); + + it('should prevent replay by incrementing nonce', () => { + multisig.mint(100n, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]); + // Second mint with same params succeeds because nonce is different + // (stub ver doesn't actually check signatures) + expect(() => { + multisig.mint(100n, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]); + }).not.toThrow(); + expect(multisig.getNonce()).toEqual(2n); + }); }); describe('burn', () => { + it('should burn with valid coin and signers', () => { + const coin = makeQualifiedCoin(multisig.getTokenType(), 100n); + expect(() => { + multisig.burn(coin, 100n, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]); + }).not.toThrow(); + }); + + it('should burn partial amount', () => { + const coin = makeQualifiedCoin(multisig.getTokenType(), 100n); + expect(() => { + multisig.burn(coin, 50n, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]); + }).not.toThrow(); + }); + + it('should handle zero burn amount', () => { + const coin = makeQualifiedCoin(multisig.getTokenType(), 100n); + + expect(() => { + multisig.burn(coin, 0n, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]); + }).not.toThrow(); + }); + it('should reject duplicate signer', () => { const coin = makeQualifiedCoin(multisig.getTokenType(), 100n); expect(() => { @@ -264,7 +308,7 @@ describe('ShieldedMultiSigV3', () => { [PK1, NON_SIGNER_PK], [DUMMY_SIG, DUMMY_SIG], ); - }).toThrow('SignerManager: not a signer'); + }).toThrow('Signer: not a signer'); }); it('should reject wrong token color', () => { @@ -288,6 +332,15 @@ describe('ShieldedMultiSigV3', () => { multisig.burn(coin, 100n, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]); }).toThrow('Multisig: insufficient coin value'); }); + + it('should share nonce across mint and burn', () => { + multisig.mint(100n, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]); + expect(multisig.getNonce()).toEqual(1n); + + const coin = makeQualifiedCoin(multisig.getTokenType(), 100n); + multisig.burn(coin, 50n, [PK1, PK3], [DUMMY_SIG, DUMMY_SIG]); + expect(multisig.getNonce()).toEqual(2n); + }); }); describe('domain separation', () => { @@ -326,5 +379,26 @@ describe('ShieldedMultiSigV3', () => { } }); }); + + describe('cross-instance replay', () => { + it('should derive different message hashes for different instances', () => { + const instance2 = new ShieldedMultiSigV3Simulator( + INSTANCE_SALT, + INIT_COIN_NONCE, + TOKEN_DOMAIN, + SIGNER_COMMITMENTS, + 2n, + ); + + // With stub verification, both succeed independently. + // Once real ECDSA is available, a signature produced for one + // instance's message hash must not validate against the other's. + multisig.mint(100n, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]); + instance2.mint(100n, [PK1, PK2], [DUMMY_SIG, DUMMY_SIG]); + + expect(multisig.getNonce()).toEqual(1n); + expect(instance2.getNonce()).toEqual(1n); + }); + }); }); });