From 78196e0030774c3e2ecd63b4236f21ec638a9f8a Mon Sep 17 00:00:00 2001 From: andrew Date: Mon, 9 Mar 2026 00:00:30 -0300 Subject: [PATCH 01/33] add multisig compile as turbo task --- turbo.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/turbo.json b/turbo.json index f1bb2905..711e82e7 100644 --- a/turbo.json +++ b/turbo.json @@ -29,6 +29,13 @@ "outputLogs": "new-only", "outputs": ["artifacts/**/"] }, + "compact:multisig": { + "dependsOn": ["^build", "compact:security", "compact:utils"], + "env": ["COMPACT_HOME", "SKIP_ZK"], + "inputs": ["src/access/**/*.compact"], + "outputLogs": "new-only", + "outputs": ["artifacts/**/"] + }, "compact:token": { "dependsOn": ["^build", "compact:security", "compact:utils"], "env": ["COMPACT_HOME", "SKIP_ZK"], @@ -41,6 +48,7 @@ "compact:security", "compact:utils", "compact:access", + "compact:multisig", "compact:token" ], "env": ["COMPACT_HOME", "SKIP_ZK"], From 3f135822f073bca120b28dbb769067bbfaf288d8 Mon Sep 17 00:00:00 2001 From: andrew Date: Mon, 9 Mar 2026 00:01:12 -0300 Subject: [PATCH 02/33] add init multisig modules --- .../src/multisig/ProposalManager.compact | 264 ++++++++++++++++++ .../src/multisig/ShieldedTreasury.compact | 207 ++++++++++++++ contracts/src/multisig/SignerManager.compact | 193 +++++++++++++ .../src/multisig/UnshieldedTreasury.compact | 113 ++++++++ .../test/mocks/MockProposalManager.compact | 62 ++++ .../test/mocks/MockShieldedTreasury.compact | 37 +++ .../test/mocks/MockSignerManager.compact | 41 +++ .../test/mocks/MockUnshieldedTreasury.compact | 25 ++ 8 files changed, 942 insertions(+) create mode 100644 contracts/src/multisig/ProposalManager.compact create mode 100644 contracts/src/multisig/ShieldedTreasury.compact create mode 100644 contracts/src/multisig/SignerManager.compact create mode 100644 contracts/src/multisig/UnshieldedTreasury.compact create mode 100644 contracts/src/multisig/test/mocks/MockProposalManager.compact create mode 100644 contracts/src/multisig/test/mocks/MockShieldedTreasury.compact create mode 100644 contracts/src/multisig/test/mocks/MockSignerManager.compact create mode 100644 contracts/src/multisig/test/mocks/MockUnshieldedTreasury.compact diff --git a/contracts/src/multisig/ProposalManager.compact b/contracts/src/multisig/ProposalManager.compact new file mode 100644 index 00000000..f8a23d94 --- /dev/null +++ b/contracts/src/multisig/ProposalManager.compact @@ -0,0 +1,264 @@ +pragma language_version >= 0.21.0; + +/** + * @module ProposalManager + * @description Token-agnostic proposal lifecycle management for multisig + * governance contracts. + * + * Supports shielded and unshielded proposals through a unified + * Recipient type with a RecipientKind tag. Typed helper circuits + * provide safe construction of recipients without exposing the + * internal Bytes<32> representation to consumers. + */ +module ProposalManager { + import CompactStandardLibrary; + + // ─── Types ────────────────────────────────────────────────────── + + export enum ProposalStatus { + Inactive, + Active, + Executed, + Cancelled + } + + export enum RecipientKind { + ShieldedUser, + UnshieldedUser, + Contract + } + + export struct Recipient { + kind: RecipientKind, + address: Bytes<32> + } + + export struct Proposal { + to: Recipient, + color: Bytes<32>, + amount: Uint<128>, + status: ProposalStatus + } + + // ─── State ────────────────────────────────────────────────────── + + ledger _nextProposalId: Counter; + ledger _proposals: Map, Proposal>; + + // ─── Recipient Helpers ────────────────────────────────────────── + + /** + * @description Constructs a shielded user recipient. + * + * @param {ZswapCoinPublicKey} key - The shielded recipient's public key. + * @returns {Recipient} The typed recipient. + */ + export circuit shieldedUserRecipient(key: ZswapCoinPublicKey): Recipient { + return Recipient { kind: RecipientKind.ShieldedUser, address: key.bytes }; + } + + /** + * @description Constructs an unshielded user recipient. + * + * @param {UserAddress} addr - The unshielded recipient's address. + * @returns {Recipient} The typed recipient. + */ + export circuit unshieldedUserRecipient(addr: UserAddress): Recipient { + return Recipient { kind: RecipientKind.UnshieldedUser, address: addr.bytes }; + } + + /** + * @description Constructs a contract recipient. + * + * @param {ContractAddress} addr - The contract address. + * @returns {Recipient} The typed recipient. + */ + export circuit contractRecipient(addr: ContractAddress): Recipient { + return Recipient { kind: RecipientKind.Contract, address: addr.bytes }; + } + + // ─── Guards ───────────────────────────────────────────────────── + + /** + * @description Asserts that a proposal exists. + * + * Requirements: + * + * - Proposal with `id` must have been created. + * + * @param {Uint<64>} id - The proposal ID. + * @returns {[]} Empty tuple. + */ + export circuit assertProposalExists(id: Uint<64>): [] { + assert( + _proposals.member(disclose(id)), + "ProposalManager: proposal not found" + ); + } + + /** + * @description Asserts that a proposal exists and is active. + * + * Requirements: + * + * - Proposal must exist. + * - Proposal status must be Active. + * + * @param {Uint<64>} id - The proposal ID. + * @returns {[]} Empty tuple. + */ + export circuit assertProposalActive(id: Uint<64>): [] { + assertProposalExists(id); + assert( + _proposals.lookup(disclose(id)).status == ProposalStatus.Active, + "ProposalManager: proposal not active" + ); + } + + // ─── Proposal Lifecycle ───────────────────────────────────────── + + /** + * @description Creates a new proposal. + * + * @notice Access control is NOT enforced here. + * The consuming contract must gate this behind its own + * authorization policy. + * + * Requirements: + * + * - `amount` must be greater than 0. + * + * @param {Recipient} to - The recipient (constructed via helper circuits). + * @param {Bytes<32>} color - The token color. + * @param {Uint<128>} amount - The amount to transfer. + * @returns {Uint<64>} The new proposal ID. + */ + export circuit _createProposal( + to: Recipient, + color: Bytes<32>, + amount: Uint<128> + ): Uint<64> { + assert(amount > 0, "ProposalManager: zero amount"); + + _nextProposalId.increment(1); + const id = _nextProposalId; + + _proposals.insert(disclose(id), disclose(Proposal { + to: to, + color: color, + amount: amount, + status: ProposalStatus.Active + })); + + return id; + } + + /** + * @description Cancels a proposal. + * + * @notice Access control is NOT enforced here. + * The consuming contract must gate this behind its own + * authorization policy. + * + * Requirements: + * + * - Proposal must be active. + * + * @param {Uint<64>} id - The proposal ID. + * @returns {[]} Empty tuple. + */ + export circuit _cancelProposal(id: Uint<64>): [] { + assertProposalActive(id); + + const proposal = _proposals.lookup(disclose(id)); + _proposals.insert(disclose(id), disclose(Proposal { + to: proposal.to, + color: proposal.color, + amount: proposal.amount, + status: ProposalStatus.Cancelled + })); + } + + /** + * @description Marks a proposal as executed. + * + * @notice Access control is NOT enforced here. + * The consuming contract must gate this behind its own + * authorization policy. + * + * Requirements: + * + * - Proposal must be active. + * + * @param {Uint<64>} id - The proposal ID. + * @returns {[]} Empty tuple. + */ + export circuit _markExecuted(id: Uint<64>): [] { + assertProposalActive(id); + + const proposal = _proposals.lookup(disclose(id)); + _proposals.insert(disclose(id), disclose(Proposal { + to: proposal.to, + color: proposal.color, + amount: proposal.amount, + status: ProposalStatus.Executed + })); + } + + // ─── View ─────────────────────────────────────────────────────── + + /** + * @description Returns the full proposal data. + * + * @param {Uint<64>} id - The proposal ID. + * @returns {Proposal} The proposal. + */ + export circuit getProposal(id: Uint<64>): Proposal { + assertProposalExists(id); + return _proposals.lookup(disclose(id)); + } + + /** + * @description Returns the recipient of a proposal. + * + * @param {Uint<64>} id - The proposal ID. + * @returns {Recipient} The recipient. + */ + export circuit getProposalRecipient(id: Uint<64>): Recipient { + assertProposalExists(id); + return _proposals.lookup(disclose(id)).to; + } + + /** + * @description Returns the amount of a proposal. + * + * @param {Uint<64>} id - The proposal ID. + * @returns {Uint<128>} The amount. + */ + export circuit getProposalAmount(id: Uint<64>): Uint<128> { + assertProposalExists(id); + return _proposals.lookup(disclose(id)).amount; + } + + /** + * @description Returns the token color of a proposal. + * + * @param {Uint<64>} id - The proposal ID. + * @returns {Bytes<32>} The token color. + */ + export circuit getProposalColor(id: Uint<64>): Bytes<32> { + assertProposalExists(id); + return _proposals.lookup(disclose(id)).color; + } + + /** + * @description Returns the status of a proposal. + * + * @param {Uint<64>} id - The proposal ID. + * @returns {ProposalStatus} The proposal status. + */ + export circuit getProposalStatus(id: Uint<64>): ProposalStatus { + assertProposalExists(id); + return _proposals.lookup(disclose(id)).status; + } +} diff --git a/contracts/src/multisig/ShieldedTreasury.compact b/contracts/src/multisig/ShieldedTreasury.compact new file mode 100644 index 00000000..f8f3f0d5 --- /dev/null +++ b/contracts/src/multisig/ShieldedTreasury.compact @@ -0,0 +1,207 @@ +pragma language_version >= 0.21.0; + +/** + * @module ShieldedTreasury + * @description Manages shielded (private) token deposits, accounting, + * and transfers for multisig governance contracts. + * + * Coins are stored on the contract ledger in a map keyed by token color, + * with one UTXO per color. Deposits are merged with existing coins of + * the same color via `mergeCoinImmediate`. This simplifies coin selection + * at spend time — the executor doesn't need to choose between multiple + * UTXOs of the same color. + * + * Cumulative received and sent totals are tracked per color for audit + * purposes. The canonical balance query is `getTokenBalance`, which + * reads the actual coin value from the UTXO map. + * + * Underscore-prefixed circuits (_deposit, _send) have no access control + * enforcement. The consuming contract must gate these behind its own + * authorization policy. + */ +module ShieldedTreasury { + import CompactStandardLibrary; + + // ─── State ────────────────────────────────────────────────────── + + ledger _coins: Map, QualifiedShieldedCoinInfo>; + ledger _shieldedReceived: Map, Uint<128>>; + ledger _shieldedSent: Map, Uint<128>>; + + // ─── Constant ─────────────────────────────────────────────────── + + export circuit UINT128_MAX(): Uint<128> { + return 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF; + } + + // ─── Deposit ──────────────────────────────────────────────────── + + /** + * @description Receives a shielded coin into the treasury. + * + * The coin is first claimed at the protocol level via `receiveShielded`, + * which allocates the Merkle tree index required by `insertCoin`. + * The coin is then merged with any existing coin of the same color, + * or inserted as a new entry if no coin of that color exists. + * + * Zero-value deposits are permitted. While currently a no-op + * economically, they may serve as signaling mechanisms when events + * are supported, or as decoy transactions for privacy. + * + * @notice Access control is NOT enforced here. + * The consuming contract must gate this behind its own + * authorization policy. + * + * Requirements: + * + * - Deposit must not cause received total overflow. + * + * @param {ShieldedCoinInfo} coin - The incoming shielded coin descriptor. + * @returns {[]} Empty tuple. + */ + export circuit _deposit(coin: ShieldedCoinInfo): [] { + const currentReceived = getReceivedTotal(coin.color); + assert(currentReceived <= UINT128_MAX() - coin.value, "ShieldedTreasury: overflow"); + + receiveShielded(disclose(coin)); + + const coinColor = disclose(coin.color); + + if (_coins.member(coinColor)) { + const merged = mergeCoinImmediate(_coins.lookup(coinColor), disclose(coin)); + _coins.insertCoin(coinColor, disclose(merged), selfAsRecipient()); + } else { + _coins.insertCoin(coinColor, disclose(coin), selfAsRecipient()); + } + + _shieldedReceived.insert( + coinColor, + disclose(currentReceived + coin.value as Uint<128>) + ); + } + + // ─── Send ─────────────────────────────────────────────────────── + + /** + * @description Sends shielded tokens from the treasury. + * + * Looks up the stored coin by color, verifies sufficient value, + * and executes the shielded send. If the send produces change, + * it is sent back to the contract via `sendImmediateShielded`. + * + * @notice Access control is NOT enforced here. + * The consuming contract must gate this behind its own + * authorization policy. + * + * Requirements: + * + * - A coin of the given `color` must exist in the treasury. + * - The coin's value must be >= `amount`. + * - Send must not cause sent total overflow. + * + * @param {Either} recipient - The recipient. + * @param {Bytes<32>} color - The token color. + * @param {Uint<128>} amount - The amount to send. + * @returns {ShieldedSendResult} The result containing sent coin and any change. + */ + export circuit _send( + recipient: Either, + color: Bytes<32>, + amount: Uint<128> + ): ShieldedSendResult { + assert(_coins.member(disclose(color)), "ShieldedTreasury: no balance"); + + const coin = _coins.lookup(disclose(color)); + assert(coin.value >= amount, "ShieldedTreasury: coin value insufficient"); + + const result = sendShielded(disclose(coin), disclose(recipient), disclose(amount)); + + if (disclose(result.change.is_some)) { + sendImmediateShielded( + disclose(result.change.value), + selfAsRecipient(), + disclose(result.change.value.value) + ); + _coins.insertCoin(disclose(color), result.change.value, selfAsRecipient()); + } else { + _coins.remove(disclose(color)); + } + + const currentSent = getSentTotal(color); + assert(currentSent <= UINT128_MAX() - amount, "ShieldedTreasury: overflow"); + + _shieldedSent.insert( + disclose(color), + disclose(currentSent + amount as Uint<128>) + ); + + return result; + } + + // ─── View ─────────────────────────────────────────────────────── + + /** + * @description Returns the current token balance for a color. + * Reads the actual coin value from the UTXO map. + * + * @param {Bytes<32>} color - The token color. + * @returns {Uint<128>} The current balance. + */ + export circuit getTokenBalance(color: Bytes<32>): Uint<128> { + if (!_coins.member(disclose(color))) { + return 0; + } + return _coins.lookup(disclose(color)).value; + } + + // ─── Accounting ───────────────────────────────────────────────── + + /** + * @description Returns the cumulative received total for a color. + * + * @param {Bytes<32>} color - The token color. + * @returns {Uint<128>} Total received. + */ + export circuit getReceivedTotal(color: Bytes<32>): Uint<128> { + if (!_shieldedReceived.member(disclose(color))) { + return 0; + } + return _shieldedReceived.lookup(disclose(color)); + } + + /** + * @description Returns the cumulative sent total for a color. + * + * @param {Bytes<32>} color - The token color. + * @returns {Uint<128>} Total sent. + */ + export circuit getSentTotal(color: Bytes<32>): Uint<128> { + if (!_shieldedSent.member(disclose(color))) { + return 0; + } + return _shieldedSent.lookup(disclose(color)); + } + + /** + * @description Returns the difference between cumulative received + * and cumulative sent totals for a color. Should equal + * `getTokenBalance` if accounting is consistent. + * + * @param {Bytes<32>} color - The token color. + * @returns {Uint<128>} Received minus sent. + */ + export circuit getReceivedMinusSent(color: Bytes<32>): Uint<128> { + return getReceivedTotal(color) - getSentTotal(color) as Uint<128>; + } + + /** + * @description Returns the current contract's address as an + * `Either` for use as a + * recipient in shielded send operations (deposits and receiving change). + * + * @returns {Either} The contract's address as a recipient. + */ + circuit selfAsRecipient(): Either { + return right(kernel.self()); + } +} diff --git a/contracts/src/multisig/SignerManager.compact b/contracts/src/multisig/SignerManager.compact new file mode 100644 index 00000000..3e6b507b --- /dev/null +++ b/contracts/src/multisig/SignerManager.compact @@ -0,0 +1,193 @@ +pragma language_version >= 0.21.0; + +/** + * @module SignerManager + * @description Manages signer registry, threshold enforcement, and signer + * validation for multisig governance contracts. + * + * Parameterized over the signer identity type `T`, allowing the consuming + * contract to choose the identity mechanism at import time. Common + * instantiations include: + * + * - `Either` for ownPublicKey()-based identity + * - `Bytes<32>` for commitment-based identity (e.g., hash of ECDSA public key) + * - `NativePoint` for Schnorr/MuSig aggregated key + * + * SignerManager does not resolve caller identity. It receives a validated + * caller from the contract layer and checks it against the registry. + * This separation allows the identity mechanism to change without + * modifying the module. + * + * Underscore-prefixed circuits (_addSigner, _removeSigner, + * _changeThreshold) have no access control enforcement. The consuming + * contract must gate these behind its own authorization policy. + */ +module SignerManager { + import CompactStandardLibrary; + + // ─── State ────────────────────────────────────────────────────────────────── + + ledger _signers: Map; + ledger _signerCount: Uint<8>; + ledger _threshold: Uint<8>; + + // ─── Initialization ───────────────────────────────────────────────────────── + + /** + * @description Initializes the signer manager with the given threshold + * and an initial set of signers. + * Must be called in the contract's constructor. + * + * Requirements: + * + * - `thresh` must be greater than 0. + * - `signers` must not contain duplicates. + * + * @param {Vector} signers - The initial signer set. + * @param {Uint<8>} thresh - The minimum number of approvals required. + * @returns {[]} Empty tuple. + */ + export circuit initialize<#n>( + signers: Vector, + thresh: Uint<8> + ): [] { + assert(thresh > 0, "SignerManager: threshold must be > 0"); + _threshold = disclose(thresh); + + for (const signer of signers) { + _addSigner(signer); + } + } + + // ─── Guards ───────────────────────────────────────────────────────────── + + /** + * @description Asserts that the given caller is an active signer. + * + * Requirements: + * + * - `caller` must be a member of the signers registry. + * + * @param {T} caller - The identity to validate. + * @returns {[]} Empty tuple. + */ + export circuit assertSigner(caller: T): [] { + assert(isSigner(caller), "SignerManager: not a signer"); + } + + /** + * @description Asserts that the given approval count meets the threshold. + * + * Requirements: + * + * - `approvalCount` must be >= threshold. + * + * @param {Uint<8>} approvalCount - The current number of approvals. + * @returns {[]} Empty tuple. + */ + export circuit assertThresholdMet(approvalCount: Uint<8>): [] { + assert(approvalCount >= _threshold, "SignerManager: threshold not met"); + } + + // ─── View ────────────────────────────────────────────────────────── + + /** + * @description Returns the current signer count. + * + * @returns {Uint<8>} The number of active signers. + */ + export circuit getSignerCount(): Uint<8> { + return _signerCount; + } + + /** + * @description Returns the approval threshold. + * + * @returns {Uint<8>} The threshold. + */ + export circuit getThreshold(): Uint<8> { + return _threshold; + } + + /** + * @description Returns whether the given account is an active signer. + * + * @param {T} account - The account to check. + * @returns {Boolean} True if the account is an active signer. + */ + export circuit isSigner(account: T): Boolean { + return _signers.member(disclose(account)); + } + + // ─── Signer Management ───────────────────────────────────────────────────── + + /** + * @description Adds a new signer to the registry. + * + * @notice Access control is NOT enforced here. + * The consuming contract must gate this behind its own + * authorization policy. + * + * Requirements: + * + * - `signer` must not already be an active signer. + * + * @param {T} signer - The signer to add. + * @returns {[]} Empty tuple. + */ + export circuit _addSigner(signer: T): [] { + assert( + !isSigner(signer), + "SignerManager: signer already active" + ); + + _signers.insert(disclose(signer), true); + _signerCount = _signerCount + 1 as Uint<8>; + } + + /** + * @description Removes a signer from the registry. + * + * @notice Access control is NOT enforced here. + * The consuming contract must gate this behind its own + * authorization policy. + * + * Requirements: + * + * - `signer` must be an active signer. + * - Removal must not drop signer count below threshold. + * + * @param {T} signer - The signer to remove. + * @returns {[]} Empty tuple. + */ + export circuit _removeSigner(signer: T): [] { + assert(isSigner(signer), "SignerManager: not a signer"); + + const newCount = _signerCount - 1 as Uint<8>; + assert(newCount >= _threshold, "SignerManager: removal would breach threshold"); + + _signers.remove(disclose(signer)); + _signerCount = newCount; + } + + /** + * @description Updates the approval threshold. + * + * @notice Access control is NOT enforced here. + * The consuming contract must gate this behind its own + * authorization policy. + * + * Requirements: + * + * - `newThreshold` must be greater than 0. + * - `newThreshold` must not exceed the current signer count. + * + * @param {Uint<8>} newThreshold - The new minimum number of approvals required. + * @returns {[]} Empty tuple. + */ + export circuit _changeThreshold(newThreshold: Uint<8>): [] { + assert(newThreshold > 0, "SignerManager: threshold must be > 0"); + assert(newThreshold <= _signerCount, "SignerManager: threshold exceeds signer count"); + _threshold = disclose(newThreshold); + } +} diff --git a/contracts/src/multisig/UnshieldedTreasury.compact b/contracts/src/multisig/UnshieldedTreasury.compact new file mode 100644 index 00000000..fa789f84 --- /dev/null +++ b/contracts/src/multisig/UnshieldedTreasury.compact @@ -0,0 +1,113 @@ +pragma language_version >= 0.21.0; + +/** + * @module UnshieldedTreasury + * @description Manages unshielded (transparent) token deposits and + * transfers for multisig governance contracts. + * + * Balances are tracked per token color in a single map. Protocol-level + * balance comparison circuits (`unshieldedBalanceLte`, + * `unshieldedBalanceGte`) are used for overflow and sufficiency checks, + * avoiding the exact-match problem of `unshieldedBalance`. + * + * Underscore-prefixed circuits (_deposit, _send) have no access control + * enforcement. The consuming contract must gate these behind its own + * authorization policy. + */ +module UnshieldedTreasury { + import CompactStandardLibrary; + + // ─── State ────────────────────────────────────────────────────── + + ledger _balances: Map, Uint<128>>; + + // ─── Constant ─────────────────────────────────────────────────── + + export circuit UINT128_MAX(): Uint<128> { + return 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF; + } + + // ─── Deposit ──────────────────────────────────────────────────── + + /** + * @description Receives unshielded tokens into the treasury. + * + * The token receive is executed at the protocol level first via + * `receiveUnshielded`. The balance map is then updated. + * + * Zero-value deposits are permitted. While currently a no-op + * economically, they may serve as signaling mechanisms when events + * are supported. + * + * @notice Access control is NOT enforced here. + * The consuming contract must gate this behind its own + * authorization policy. + * + * Requirements: + * + * - Deposit must not cause balance overflow. + * + * @param {Bytes<32>} color - The token type identifier. + * @param {Uint<128>} amount - The amount to deposit. + * @returns {[]} Empty tuple. + */ + export circuit _deposit(color: Bytes<32>, amount: Uint<128>): [] { + assert( + unshieldedBalanceLte(disclose(color), UINT128_MAX() - disclose(amount)), + "UnshieldedTreasury: overflow" + ); + + receiveUnshielded(disclose(color), disclose(amount)); + + const bal = getTokenBalance(color); + _balances.insert(disclose(color), disclose(bal + amount as Uint<128>)); + } + + // ─── Send ─────────────────────────────────────────────────────── + + /** + * @description Sends unshielded tokens from the treasury. + * + * @notice Access control is NOT enforced here. + * The consuming contract must gate this behind its own + * authorization policy. + * + * Requirements: + * + * - The treasury must hold sufficient balance for the given token color. + * + * @param {Either} recipient - The recipient address. + * @param {Bytes<32>} color - The token type identifier. + * @param {Uint<128>} amount - The amount to send. + * @returns {[]} Empty tuple. + */ + export circuit _send( + recipient: Either, + color: Bytes<32>, + amount: Uint<128> + ): [] { + assert( + unshieldedBalanceGte(disclose(color), disclose(amount)), + "UnshieldedTreasury: insufficient balance" + ); + + const bal = getTokenBalance(color); + _balances.insert(disclose(color), disclose(bal - amount as Uint<128>)); + sendUnshielded(disclose(color), disclose(amount), disclose(recipient)); + } + + // ─── View ─────────────────────────────────────────────────────── + + /** + * @description Returns the balance for a given token color. + * + * @param {Bytes<32>} color - The token type identifier. + * @returns {Uint<128>} The current balance. + */ + export circuit getTokenBalance(color: Bytes<32>): Uint<128> { + if (!_balances.member(disclose(color))) { + return 0; + } + return _balances.lookup(disclose(color)); + } +} diff --git a/contracts/src/multisig/test/mocks/MockProposalManager.compact b/contracts/src/multisig/test/mocks/MockProposalManager.compact new file mode 100644 index 00000000..351961a9 --- /dev/null +++ b/contracts/src/multisig/test/mocks/MockProposalManager.compact @@ -0,0 +1,62 @@ +pragma language_version >= 0.21.0; + +import CompactStandardLibrary; + +import "../../ProposalManager" prefix Proposal_; + +export circuit shieldedUserRecipient(key: ZswapCoinPublicKey): Proposal_Recipient { + return Proposal_shieldedUserRecipient(key); +} + +export circuit unshieldedUserRecipient(addr: UserAddress): Proposal_Recipient { + return Proposal_unshieldedUserRecipient(addr); +} + +export circuit contractRecipient(addr: ContractAddress): Proposal_Recipient { + return Proposal_contractRecipient(addr); +} + +export circuit assertProposalExists(id: Uint<64>): [] { + return Proposal_assertProposalExists(id); +} + +export circuit assertProposalActive(id: Uint<64>): [] { + return Proposal_assertProposalActive(id); +} + +export circuit _createProposal( + to: Proposal_Recipient, + color: Bytes<32>, + amount: Uint<128> +): Uint<64> { + return Proposal__createProposal(to, color, amount); +} + +export circuit _cancelProposal(id: Uint<64>): [] { + return Proposal__cancelProposal(id); +} + +export circuit _markExecuted(id: Uint<64>): [] { + return Proposal__markExecuted(id); +} + +export circuit getProposal(id: Uint<64>): Proposal_Proposal { + return Proposal_getProposal(id); +} + +export circuit getProposalRecipient(id: Uint<64>): Proposal_Recipient { + return Proposal_getProposalRecipient(id); +} + +export circuit getProposalAmount(id: Uint<64>): Uint<128> { + return Proposal_getProposalAmount(id); +} + +export circuit getProposalColor(id: Uint<64>): Bytes<32> { + return Proposal_getProposalColor(id); +} + +export circuit getProposalStatus(id: Uint<64>): Proposal_ProposalStatus { + return Proposal_getProposalStatus(id); +} + diff --git a/contracts/src/multisig/test/mocks/MockShieldedTreasury.compact b/contracts/src/multisig/test/mocks/MockShieldedTreasury.compact new file mode 100644 index 00000000..8f4fd1af --- /dev/null +++ b/contracts/src/multisig/test/mocks/MockShieldedTreasury.compact @@ -0,0 +1,37 @@ +pragma language_version >= 0.21.0; + +import CompactStandardLibrary; + +import "../../ShieldedTreasury" prefix Treasury_; + +export circuit UINT128_MAX(): Uint<128> { + return Treasury_UINT128_MAX(); +} + +export circuit _deposit(coin: ShieldedCoinInfo): [] { + return Treasury__deposit(coin); +} + +export circuit _send( + recipient: Either, + amount: Uint<128>, + color: Bytes<32> + ): ShieldedSendResult { + return Treasury__send(recipient, color, amount); +} + +export circuit getTokenBalance(color: Bytes<32>): Uint<128> { + return Treasury_getTokenBalance(color); +} + +export circuit getReceivedTotal(color: Bytes<32>): Uint<128> { + return Treasury_getReceivedTotal(color); +} + +export circuit getSentTotal(color: Bytes<32>): Uint<128> { + return Treasury_getSentTotal(color); +} + +export circuit getReceivedMinusSent(color: Bytes<32>): Uint<128> { + return Treasury_getReceivedMinusSent(color); +} diff --git a/contracts/src/multisig/test/mocks/MockSignerManager.compact b/contracts/src/multisig/test/mocks/MockSignerManager.compact new file mode 100644 index 00000000..9ec54aa3 --- /dev/null +++ b/contracts/src/multisig/test/mocks/MockSignerManager.compact @@ -0,0 +1,41 @@ +pragma language_version >= 0.21.0; + +import CompactStandardLibrary; + +import "../../SignerManager"> prefix Signer_; + +constructor(signers: Vector<3, Either>, thresh: Uint<8>) { + Signer_initialize<3>(signers, thresh); +} + +export circuit assertSigner(caller: Either): [] { + return Signer_assertSigner(caller); +} + +export circuit assertThresholdMet(approvalCount: Uint<8>): [] { + return Signer_assertThresholdMet(approvalCount); +} + +export circuit getSignerCount(): Uint<8> { + return Signer_getSignerCount(); +} + +export circuit getThreshold(): Uint<8> { + return Signer_getThreshold(); +} + +export circuit isSigner(account: Either): Boolean { + return Signer_isSigner(account); +} + +export circuit _addSigner(signer: Either): [] { + return Signer__addSigner(signer); +} + +export circuit _removeSigner(signer: Either): [] { + return Signer__removeSigner(signer); +} + +export circuit _changeThreshold(newThreshold: Uint<8>): [] { + return Signer__changeThreshold(newThreshold); +} diff --git a/contracts/src/multisig/test/mocks/MockUnshieldedTreasury.compact b/contracts/src/multisig/test/mocks/MockUnshieldedTreasury.compact new file mode 100644 index 00000000..b0cf7ea6 --- /dev/null +++ b/contracts/src/multisig/test/mocks/MockUnshieldedTreasury.compact @@ -0,0 +1,25 @@ +pragma language_version >= 0.21.0; + +import CompactStandardLibrary; + +import "../../UnshieldedTreasury" prefix Treasury_; + +export circuit UINT128_MAX(): Uint<128> { + return Treasury_UINT128_MAX(); +} + +export circuit _deposit(color: Bytes<32>, amount: Uint<128>): [] { + return Treasury__deposit(color, amount); +} + +export circuit _send( + recipient: Either, + color: Bytes<32>, + amount: Uint<128> + ): [] { + return Treasury__send(recipient, color, amount); +} + +export circuit getTokenBalance(color: Bytes<32>): Uint<128> { + return Treasury_getTokenBalance(color); +} From 48330f11633180f4f75020b998c0af40c9cd6271 Mon Sep 17 00:00:00 2001 From: andrew Date: Tue, 10 Mar 2026 03:55:08 -0300 Subject: [PATCH 03/33] add helpers --- .../src/multisig/ProposalManager.compact | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/contracts/src/multisig/ProposalManager.compact b/contracts/src/multisig/ProposalManager.compact index f8a23d94..871cf877 100644 --- a/contracts/src/multisig/ProposalManager.compact +++ b/contracts/src/multisig/ProposalManager.compact @@ -77,6 +77,52 @@ module ProposalManager { return Recipient { kind: RecipientKind.Contract, address: addr.bytes }; } + /** + * @description Converts a Recipient to a shielded send recipient. + * Handles both ShieldedUser and Contract kinds. + * + * Requirements: + * + * - Recipient kind must be ShieldedUser or Contract. + * + * @param {Recipient} r - The recipient. + * @returns {Either} The shielded recipient. + */ + export circuit toShieldedRecipient(r: Recipient): Either { + if (r.kind == RecipientKind.ShieldedUser) { + return left( + ZswapCoinPublicKey { bytes: r.address } + ); + } + assert(r.kind == RecipientKind.Contract, "ProposalManager: invalid shielded recipient"); + return right( + ContractAddress { bytes: r.address } + ); + } + + /** + * @description Converts a Recipient to an unshielded send recipient. + * Handles both UnshieldedUser and Contract kinds. + * + * Requirements: + * + * - Recipient kind must be UnshieldedUser or Contract. + * + * @param {Recipient} r - The recipient. + * @returns {Either} The unshielded recipient. + */ + export circuit toUnshieldedRecipient(r: Recipient): Either { + if (r.kind == RecipientKind.Contract) { + return left( + ContractAddress { bytes: r.address } + ); + } + assert(r.kind == RecipientKind.UnshieldedUser, "ProposalManager: invalid unshielded recipient"); + return right( + UserAddress { bytes: r.address } + ); + } + // ─── Guards ───────────────────────────────────────────────────── /** From 12528032dd1f1a51f1fda4b752fea64875a698bc Mon Sep 17 00:00:00 2001 From: andrew Date: Tue, 10 Mar 2026 03:55:32 -0300 Subject: [PATCH 04/33] add basic multisig contract --- .../multisig/presets/BasicMultisig.compact | 247 ++++++++++++++++++ 1 file changed, 247 insertions(+) create mode 100644 contracts/src/multisig/presets/BasicMultisig.compact diff --git a/contracts/src/multisig/presets/BasicMultisig.compact b/contracts/src/multisig/presets/BasicMultisig.compact new file mode 100644 index 00000000..fe05aed2 --- /dev/null +++ b/contracts/src/multisig/presets/BasicMultisig.compact @@ -0,0 +1,247 @@ +pragma language_version >= 0.21.0; + +import CompactStandardLibrary; + +import "../ProposalManager" prefix Proposal_; +import "../ShieldedTreasury" prefix Treasury_; +import "../SignerManager"> prefix Signer_; +import "./../../access/Ownable" prefix Ownable_; + +// ─── State ─────────────────────────────────────────────────────────────── + +export ledger _finalized: Boolean; +export ledger _proposalApprovals: Map, Map, Boolean>>; +export ledger _approvalCount: Map, Uint<8>>; + +// ─── Constructor ───────────────────────────────────────────────────────── + +constructor( + initialOwner: Either, + signers: Vector<3, Either>, + thresh: Uint<8> +) { + Ownable_initialize(initialOwner); + Signer_initialize<3>(signers, thresh); +} + +// ─── Setup phase ───────────────────────────────────────────────────────── + +export circuit addSigner( + signer: Either +): [] { + Ownable_assertOnlyOwner(); + assertNotFinalized(); + Signer__addSigner(signer); +} + +export circuit removeSigner( + signer: Either +): [] { + Ownable_assertOnlyOwner(); + assertNotFinalized(); + Signer__removeSigner(signer); +} + +export circuit finalize(): [] { + Ownable_assertOnlyOwner(); + assert( + Signer_getSignerCount() >= Signer_getThreshold(), + "Multisig: insufficient signers for threshold" + ); + _finalize(); +} + +// ─── Proposals ─────────────────────────────────────────────────────────── + +export circuit createShieldedProposal( + to: Proposal_Recipient, + color: Bytes<32>, + amount: Uint<128> +): Uint<64> { + assertFinalized(); + + const callerPK = getCaller(); + Signer_assertSigner(callerPK); + + return Proposal__createProposal(to, color, amount); +} + +export circuit approveProposal(id: Uint<64>): [] { + // Check if finalized + assertFinalized(); + + // Check if active + Proposal_assertProposalActive(id); + + // Check signer + const callerPK = getCaller(); + Signer_assertSigner(callerPK); + + // Check if already approved + assert(!isProposalApprovedBySigner(id, callerPK), "Multisig: already approved"); + + // Approve + _approveProposal(id, callerPK); +} + +export circuit cancelProposal(id: Uint<64>): [] { + // Check if finalized + assertFinalized(); + + // Check if active + Proposal_assertProposalActive(id); + + // Check signer + const callerPK = getCaller(); + Signer_assertSigner(callerPK); + + // Cancel + Proposal__cancelProposal(id); +} + +export circuit executeShieldedProposal( + id: Uint<64>, +): ShieldedSendResult { + // Check finalized + assertFinalized(); + + // Check if active + Proposal_assertProposalActive(id); + + // Check threshold + const approvalCount = getApprovalCount(id); + Signer_assertThresholdMet(approvalCount); + + // Transfer + const { to, color, amount } = Proposal_getProposal(id); + const result = Treasury__send( + Proposal_toShieldedRecipient(to), + color, + amount, + ); + + // Finish lifecycle + Proposal__markExecuted(id); + return result; +} + +// ─── Internal ─────────────────────────────────────────────────────────── + +circuit _finalize(): [] { + assertNotFinalized(); + _finalized = true; +} + +circuit _approveProposal(id: Uint<64>, signer: Either): [] { + _proposalApprovals.lookup(disclose(id)).insert(disclose(signer), disclose(true)); + + const newCount = _approvalCount.lookup(disclose(id)) + 1 as Uint<8>; + _approvalCount.insert(disclose(id), disclose(newCount)); +} + +circuit getCaller(): Either { + return left(ownPublicKey()); +} + +// ─── Guards ────────────────────────────────────────────────────────────── + +circuit assertFinalized(): [] { + assert(_finalized, "Multisig: not finalized"); +} + +circuit assertNotFinalized(): [] { + assert(!_finalized, "Multisig: already finalized"); +} + +// ─── View ─────────────────────────────────────────────────────────────── + +export circuit isFinalized(id: Uint<64>): Boolean { + return _finalized; +} + +export circuit isProposalApprovedBySigner( + id: Uint<64>, + signer: Either + ): Boolean { + if (_proposalApprovals.member(disclose(id))) { + return false; + } + + return _proposalApprovals.lookup(disclose(id)).lookup(disclose(signer)); +} + +export circuit getApprovalCount(id: Uint<64>): Uint<8> { + if (!_approvalCount.member(disclose(id))) { + return 0; + } + + return _approvalCount.lookup(disclose(id)); +} + +// IOwnable + +export circuit owner(): Either { + return Ownable_owner(); +} + +export circuit transferOwnership(newOwner: Either): [] { + return Ownable_transferOwnership(newOwner); +} + +export circuit renounceOwnership(): [] { + return Ownable_renounceOwnership(); +} + +// IProposalManager + +export circuit getProposal(id: Uint<64>): Proposal_Proposal { + return Proposal_getProposal(id); +} + +export circuit getProposalRecipient(id: Uint<64>): Proposal_Recipient { + return Proposal_getProposalRecipient(id); +} + +export circuit getProposalAmount(id: Uint<64>): Uint<128> { + return Proposal_getProposalAmount(id); +} + +export circuit getProposalColor(id: Uint<64>): Bytes<32> { + return Proposal_getProposalColor(id); +} + +export circuit getProposalStatus(id: Uint<64>): Proposal_ProposalStatus { + return Proposal_getProposalStatus(id); +} + +// IShieldedTreasury + +export circuit getTokenBalance(color: Bytes<32>): Uint<128> { + return Treasury_getTokenBalance(color); +} + +export circuit getReceivedTotal(color: Bytes<32>): Uint<128> { + return Treasury_getReceivedTotal(color); +} + +export circuit getSentTotal(color: Bytes<32>): Uint<128> { + return Treasury_getSentTotal(color); +} + +export circuit getReceivedMinusSent(color: Bytes<32>): Uint<128> { + return Treasury_getReceivedMinusSent(color); +} + +// ISignerManager + +export circuit getSignerCount(): Uint<8> { + return Signer_getSignerCount(); +} + +export circuit getThreshold(): Uint<8> { + return Signer_getThreshold(); +} + +export circuit isSigner(account: Either): Boolean { + return Signer_isSigner(account); +} From 7abe151e06a32f586489e50986477b5433c89fc4 Mon Sep 17 00:00:00 2001 From: andrew Date: Tue, 10 Mar 2026 11:28:31 -0300 Subject: [PATCH 05/33] remove unused param --- contracts/src/multisig/presets/BasicMultisig.compact | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/src/multisig/presets/BasicMultisig.compact b/contracts/src/multisig/presets/BasicMultisig.compact index fe05aed2..c6143e1c 100644 --- a/contracts/src/multisig/presets/BasicMultisig.compact +++ b/contracts/src/multisig/presets/BasicMultisig.compact @@ -155,7 +155,7 @@ circuit assertNotFinalized(): [] { // ─── View ─────────────────────────────────────────────────────────────── -export circuit isFinalized(id: Uint<64>): Boolean { +export circuit isFinalized(): Boolean { return _finalized; } From 5def030ea7b233a7621eb39e7cf6d306b1bd45b0 Mon Sep 17 00:00:00 2001 From: andrew Date: Tue, 10 Mar 2026 15:08:06 -0300 Subject: [PATCH 06/33] fix newCount var in _approveProposal --- contracts/src/multisig/presets/BasicMultisig.compact | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/src/multisig/presets/BasicMultisig.compact b/contracts/src/multisig/presets/BasicMultisig.compact index c6143e1c..7466ef53 100644 --- a/contracts/src/multisig/presets/BasicMultisig.compact +++ b/contracts/src/multisig/presets/BasicMultisig.compact @@ -135,7 +135,7 @@ circuit _finalize(): [] { circuit _approveProposal(id: Uint<64>, signer: Either): [] { _proposalApprovals.lookup(disclose(id)).insert(disclose(signer), disclose(true)); - const newCount = _approvalCount.lookup(disclose(id)) + 1 as Uint<8>; + const newCount = getApprovalCount() + 1 as Uint<8>; _approvalCount.insert(disclose(id), disclose(newCount)); } From e15782e94baaae2aab2ebfa4ceccf8a708596a1b Mon Sep 17 00:00:00 2001 From: andrew Date: Tue, 10 Mar 2026 15:26:54 -0300 Subject: [PATCH 07/33] remove setup, remove ownable --- .../multisig/presets/BasicMultisig.compact | 77 +------------------ 1 file changed, 1 insertion(+), 76 deletions(-) diff --git a/contracts/src/multisig/presets/BasicMultisig.compact b/contracts/src/multisig/presets/BasicMultisig.compact index 7466ef53..7c998426 100644 --- a/contracts/src/multisig/presets/BasicMultisig.compact +++ b/contracts/src/multisig/presets/BasicMultisig.compact @@ -5,52 +5,21 @@ import CompactStandardLibrary; import "../ProposalManager" prefix Proposal_; import "../ShieldedTreasury" prefix Treasury_; import "../SignerManager"> prefix Signer_; -import "./../../access/Ownable" prefix Ownable_; // ─── State ─────────────────────────────────────────────────────────────── -export ledger _finalized: Boolean; export ledger _proposalApprovals: Map, Map, Boolean>>; export ledger _approvalCount: Map, Uint<8>>; // ─── Constructor ───────────────────────────────────────────────────────── constructor( - initialOwner: Either, signers: Vector<3, Either>, thresh: Uint<8> ) { - Ownable_initialize(initialOwner); Signer_initialize<3>(signers, thresh); } -// ─── Setup phase ───────────────────────────────────────────────────────── - -export circuit addSigner( - signer: Either -): [] { - Ownable_assertOnlyOwner(); - assertNotFinalized(); - Signer__addSigner(signer); -} - -export circuit removeSigner( - signer: Either -): [] { - Ownable_assertOnlyOwner(); - assertNotFinalized(); - Signer__removeSigner(signer); -} - -export circuit finalize(): [] { - Ownable_assertOnlyOwner(); - assert( - Signer_getSignerCount() >= Signer_getThreshold(), - "Multisig: insufficient signers for threshold" - ); - _finalize(); -} - // ─── Proposals ─────────────────────────────────────────────────────────── export circuit createShieldedProposal( @@ -58,8 +27,6 @@ export circuit createShieldedProposal( color: Bytes<32>, amount: Uint<128> ): Uint<64> { - assertFinalized(); - const callerPK = getCaller(); Signer_assertSigner(callerPK); @@ -67,9 +34,6 @@ export circuit createShieldedProposal( } export circuit approveProposal(id: Uint<64>): [] { - // Check if finalized - assertFinalized(); - // Check if active Proposal_assertProposalActive(id); @@ -85,9 +49,6 @@ export circuit approveProposal(id: Uint<64>): [] { } export circuit cancelProposal(id: Uint<64>): [] { - // Check if finalized - assertFinalized(); - // Check if active Proposal_assertProposalActive(id); @@ -102,9 +63,6 @@ export circuit cancelProposal(id: Uint<64>): [] { export circuit executeShieldedProposal( id: Uint<64>, ): ShieldedSendResult { - // Check finalized - assertFinalized(); - // Check if active Proposal_assertProposalActive(id); @@ -127,15 +85,10 @@ export circuit executeShieldedProposal( // ─── Internal ─────────────────────────────────────────────────────────── -circuit _finalize(): [] { - assertNotFinalized(); - _finalized = true; -} - circuit _approveProposal(id: Uint<64>, signer: Either): [] { _proposalApprovals.lookup(disclose(id)).insert(disclose(signer), disclose(true)); - const newCount = getApprovalCount() + 1 as Uint<8>; + const newCount = getApprovalCount(id) + 1 as Uint<8>; _approvalCount.insert(disclose(id), disclose(newCount)); } @@ -143,22 +96,8 @@ circuit getCaller(): Either { return left(ownPublicKey()); } -// ─── Guards ────────────────────────────────────────────────────────────── - -circuit assertFinalized(): [] { - assert(_finalized, "Multisig: not finalized"); -} - -circuit assertNotFinalized(): [] { - assert(!_finalized, "Multisig: already finalized"); -} - // ─── View ─────────────────────────────────────────────────────────────── -export circuit isFinalized(): Boolean { - return _finalized; -} - export circuit isProposalApprovedBySigner( id: Uint<64>, signer: Either @@ -178,20 +117,6 @@ export circuit getApprovalCount(id: Uint<64>): Uint<8> { return _approvalCount.lookup(disclose(id)); } -// IOwnable - -export circuit owner(): Either { - return Ownable_owner(); -} - -export circuit transferOwnership(newOwner: Either): [] { - return Ownable_transferOwnership(newOwner); -} - -export circuit renounceOwnership(): [] { - return Ownable_renounceOwnership(); -} - // IProposalManager export circuit getProposal(id: Uint<64>): Proposal_Proposal { From 8348bc976b89c4745df25bebe8b2e24408e462bf Mon Sep 17 00:00:00 2001 From: andrew Date: Tue, 10 Mar 2026 16:28:45 -0300 Subject: [PATCH 08/33] fix isProposalApprovedBySigner --- contracts/src/multisig/presets/BasicMultisig.compact | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/src/multisig/presets/BasicMultisig.compact b/contracts/src/multisig/presets/BasicMultisig.compact index 7c998426..1baceaeb 100644 --- a/contracts/src/multisig/presets/BasicMultisig.compact +++ b/contracts/src/multisig/presets/BasicMultisig.compact @@ -102,7 +102,7 @@ export circuit isProposalApprovedBySigner( id: Uint<64>, signer: Either ): Boolean { - if (_proposalApprovals.member(disclose(id))) { + if (!_proposalApprovals.member(disclose(id))) { return false; } From 7db320bfd17daf62315bedfeff880aa63968e4e9 Mon Sep 17 00:00:00 2001 From: andrew Date: Tue, 10 Mar 2026 16:31:03 -0300 Subject: [PATCH 09/33] add member check to _approveProposal --- contracts/src/multisig/presets/BasicMultisig.compact | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/contracts/src/multisig/presets/BasicMultisig.compact b/contracts/src/multisig/presets/BasicMultisig.compact index 1baceaeb..12060cd5 100644 --- a/contracts/src/multisig/presets/BasicMultisig.compact +++ b/contracts/src/multisig/presets/BasicMultisig.compact @@ -86,6 +86,10 @@ export circuit executeShieldedProposal( // ─── Internal ─────────────────────────────────────────────────────────── circuit _approveProposal(id: Uint<64>, signer: Either): [] { + if (!_proposalApprovals.member(disclose(id))) { + _proposalApprovals.insert(disclose(id), default, Boolean>>); + } + _proposalApprovals.lookup(disclose(id)).insert(disclose(signer), disclose(true)); const newCount = getApprovalCount(id) + 1 as Uint<8>; From 41d9888aee5618e39b733e080d215e36bb08f788 Mon Sep 17 00:00:00 2001 From: andrew Date: Tue, 10 Mar 2026 18:08:29 -0300 Subject: [PATCH 10/33] replace cancelApproval with revokeApproval --- .../multisig/presets/BasicMultisig.compact | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/contracts/src/multisig/presets/BasicMultisig.compact b/contracts/src/multisig/presets/BasicMultisig.compact index 12060cd5..53b5644d 100644 --- a/contracts/src/multisig/presets/BasicMultisig.compact +++ b/contracts/src/multisig/presets/BasicMultisig.compact @@ -48,7 +48,7 @@ export circuit approveProposal(id: Uint<64>): [] { _approveProposal(id, callerPK); } -export circuit cancelProposal(id: Uint<64>): [] { +export circuit revokeApproval(id: Uint<64>): [] { // Check if active Proposal_assertProposalActive(id); @@ -56,8 +56,15 @@ export circuit cancelProposal(id: Uint<64>): [] { const callerPK = getCaller(); Signer_assertSigner(callerPK); - // Cancel - Proposal__cancelProposal(id); + // Check has approved + assert(isProposalApprovedBySigner(id, callerPK), "Multisig: not approved"); + + // Revoke + _revokeApproval(id, callerPK); +} + +export circuit deposit(coin: ShieldedCoinInfo): [] { + Treasury__deposit(coin); } export circuit executeShieldedProposal( @@ -96,6 +103,13 @@ circuit _approveProposal(id: Uint<64>, signer: Either, signer: Either): [] { + _proposalApprovals.lookup(disclose(id)).remove(disclose(signer)); + + const newCount = getApprovalCount(id) - 1 as Uint<8>; + _approvalCount.insert(disclose(id), disclose(newCount)); +} + circuit getCaller(): Either { return left(ownPublicKey()); } From f13f3a0977913573cb565f3748da15000a9c22e6 Mon Sep 17 00:00:00 2001 From: andrew Date: Tue, 10 Mar 2026 18:59:19 -0300 Subject: [PATCH 11/33] fix isProposalApprovedBySigner --- contracts/src/multisig/presets/BasicMultisig.compact | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/contracts/src/multisig/presets/BasicMultisig.compact b/contracts/src/multisig/presets/BasicMultisig.compact index 53b5644d..eaccee69 100644 --- a/contracts/src/multisig/presets/BasicMultisig.compact +++ b/contracts/src/multisig/presets/BasicMultisig.compact @@ -117,10 +117,10 @@ circuit getCaller(): Either { // ─── View ─────────────────────────────────────────────────────────────── export circuit isProposalApprovedBySigner( - id: Uint<64>, - signer: Either - ): Boolean { - if (!_proposalApprovals.member(disclose(id))) { + id: Uint<64>, + signer: Either +): Boolean { + if (!_proposalApprovals.member(disclose(id)) || !_proposalApprovals.lookup(disclose(id)).member(disclose(signer))) { return false; } From 66cd730088b21ceca134128a5171fb2ed11a5220 Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 11 Mar 2026 01:48:41 -0300 Subject: [PATCH 12/33] add empty witnesses --- contracts/src/multisig/witnesses/ProposalManagerWitness.ts | 6 ++++++ contracts/src/multisig/witnesses/ShieldedTreasuryWitness.ts | 6 ++++++ contracts/src/multisig/witnesses/SignerManagerWItness.ts | 6 ++++++ .../src/multisig/witnesses/UnshieldedTreasuryWitness.ts | 6 ++++++ 4 files changed, 24 insertions(+) create mode 100644 contracts/src/multisig/witnesses/ProposalManagerWitness.ts create mode 100644 contracts/src/multisig/witnesses/ShieldedTreasuryWitness.ts create mode 100644 contracts/src/multisig/witnesses/SignerManagerWItness.ts create mode 100644 contracts/src/multisig/witnesses/UnshieldedTreasuryWitness.ts diff --git a/contracts/src/multisig/witnesses/ProposalManagerWitness.ts b/contracts/src/multisig/witnesses/ProposalManagerWitness.ts new file mode 100644 index 00000000..f44381ed --- /dev/null +++ b/contracts/src/multisig/witnesses/ProposalManagerWitness.ts @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.0.1-alpha.1 (multisig/witnesses/ProposalManagerWitness.ts) + +export type ProposalManagerPrivateState = Record; +export const ProposalManagerPrivateState: ProposalManagerPrivateState = {}; +export const ProposalManagerWitnesses = () => ({}); diff --git a/contracts/src/multisig/witnesses/ShieldedTreasuryWitness.ts b/contracts/src/multisig/witnesses/ShieldedTreasuryWitness.ts new file mode 100644 index 00000000..a29b1014 --- /dev/null +++ b/contracts/src/multisig/witnesses/ShieldedTreasuryWitness.ts @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.0.1-alpha.1 (multisig/witnesses/ShieldedTreasuryWitness.ts) + +export type ShieldedTreasuryPrivateState = Record; +export const ShieldedTreasuryPrivateState: ShieldedTreasuryPrivateState = {}; +export const ShieldedTreasuryWitnesses = () => ({}); diff --git a/contracts/src/multisig/witnesses/SignerManagerWItness.ts b/contracts/src/multisig/witnesses/SignerManagerWItness.ts new file mode 100644 index 00000000..6dcd48f8 --- /dev/null +++ b/contracts/src/multisig/witnesses/SignerManagerWItness.ts @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.0.1-alpha.1 (multisig/witnesses/SignerManagerWitness.ts) + +export type SignerManagerPrivateState = Record; +export const SignerManagerPrivateState: SignerManagerPrivateState = {}; +export const SignerManagerWitnesses = () => ({}); diff --git a/contracts/src/multisig/witnesses/UnshieldedTreasuryWitness.ts b/contracts/src/multisig/witnesses/UnshieldedTreasuryWitness.ts new file mode 100644 index 00000000..077508d2 --- /dev/null +++ b/contracts/src/multisig/witnesses/UnshieldedTreasuryWitness.ts @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.0.1-alpha.1 (multisig/witnesses/UnshieldedTreasuryWitness.ts) + +export type UnshieldedTreasuryPrivateState = Record; +export const UnshieldedTreasuryPrivateState: UnshieldedTreasuryPrivateState = {}; +export const UnshieldedTreasuryWitnesses = () => ({}); From 22ca211977c34dc51f8c7c4a51f0bbbed7990b30 Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 11 Mar 2026 01:54:26 -0300 Subject: [PATCH 13/33] export compact types from mock --- contracts/src/multisig/test/mocks/MockSignerManager.compact | 2 ++ 1 file changed, 2 insertions(+) diff --git a/contracts/src/multisig/test/mocks/MockSignerManager.compact b/contracts/src/multisig/test/mocks/MockSignerManager.compact index 9ec54aa3..021ee08c 100644 --- a/contracts/src/multisig/test/mocks/MockSignerManager.compact +++ b/contracts/src/multisig/test/mocks/MockSignerManager.compact @@ -4,6 +4,8 @@ import CompactStandardLibrary; import "../../SignerManager"> prefix Signer_; +export { ZswapCoinPublicKey, ContractAddress, Either, Maybe }; + constructor(signers: Vector<3, Either>, thresh: Uint<8>) { Signer_initialize<3>(signers, thresh); } From b4e36f9cc3b8a189900356ec69dd4dee297d8555 Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 11 Mar 2026 01:54:53 -0300 Subject: [PATCH 14/33] add SignerManager sim --- .../test/simulators/SignerManagerSimulator.ts | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 contracts/src/multisig/test/simulators/SignerManagerSimulator.ts diff --git a/contracts/src/multisig/test/simulators/SignerManagerSimulator.ts b/contracts/src/multisig/test/simulators/SignerManagerSimulator.ts new file mode 100644 index 00000000..f170e7a4 --- /dev/null +++ b/contracts/src/multisig/test/simulators/SignerManagerSimulator.ts @@ -0,0 +1,86 @@ +import { + type BaseSimulatorOptions, + createSimulator, +} from '@openzeppelin-compact/contracts-simulator'; +import { + type ContractAddress, + type Either, + ledger, + Contract as MockSignerManager, + type ZswapCoinPublicKey, +} from '../../../../artifacts/MockSignerManager/contract/index.js'; +import { + SignerManagerPrivateState, + SignerManagerWitnesses, +} from '../../witnesses/SignerManagerWitness.js'; + +/** + * Type constructor args + */ +type SignerManagerArgs = readonly [ + signers: Either[], + thresh: bigint +]; + +const SignerManagerSimulatorBase = createSimulator< + SignerManagerPrivateState, + ReturnType, + ReturnType, + MockSignerManager, + SignerManagerArgs +>({ + contractFactory: (witnesses) => + new MockSignerManager(witnesses), + defaultPrivateState: () => SignerManagerPrivateState, + contractArgs: (signers, thresh) => [signers, thresh], + ledgerExtractor: (state) => ledger(state), + witnessesFactory: () => SignerManagerWitnesses(), +}); + +/** + * SignerManager Simulator + */ +export class SignerManagerSimulator extends SignerManagerSimulatorBase { + constructor( + signers: Either[], + thresh: bigint, + options: BaseSimulatorOptions< + SignerManagerPrivateState, + ReturnType + > = {}, + ) { + super([signers, thresh], options); + } + + public assertSigner(caller: Either) { + return this.circuits.impure.assertSigner(caller); + } + + public assertThresholdMet(approvalCount: bigint) { + return this.circuits.impure.assertThresholdMet(approvalCount); + } + + public getSignerCount(): bigint { + return this.circuits.impure.getSignerCount(); + } + + public getThreshold(): bigint { + return this.circuits.impure.getThreshold(); + } + + public isSigner(account: Either): Boolean { + return this.circuits.impure.isSigner(account); + } + + public _addSigner(signer: Either) { + return this.circuits.impure._addSigner(signer); + } + + public _removeSigner(signer: Either) { + return this.circuits.impure._removeSigner(signer); + } + + public _changeThreshold(newThreshold: bigint) { + return this.circuits.impure._changeThreshold(newThreshold); + } +} From 700bdb282de93aed818ff79d38a681c308d64d78 Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 11 Mar 2026 01:55:07 -0300 Subject: [PATCH 15/33] add SignerManager tests --- .../src/multisig/test/SignerManager.test.ts | 206 ++++++++++++++++++ 1 file changed, 206 insertions(+) create mode 100644 contracts/src/multisig/test/SignerManager.test.ts diff --git a/contracts/src/multisig/test/SignerManager.test.ts b/contracts/src/multisig/test/SignerManager.test.ts new file mode 100644 index 00000000..e2ac6de6 --- /dev/null +++ b/contracts/src/multisig/test/SignerManager.test.ts @@ -0,0 +1,206 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { SignerManagerSimulator } from './simulators/SignerManagerSimulator.js'; +import * as utils from '#test-utils/address.js'; + +const THRESHOLD = 2n; + +const [_SIGNER, Z_SIGNER] = utils.generateEitherPubKeyPair('SIGNER'); +const [_SIGNER2, Z_SIGNER2] = utils.generateEitherPubKeyPair('SIGNER2'); +const [_SIGNER3, Z_SIGNER3] = utils.generateEitherPubKeyPair('SIGNER3'); +const SIGNERS = [Z_SIGNER, Z_SIGNER2, Z_SIGNER3]; +const [_OTHER, Z_OTHER] = utils.generateEitherPubKeyPair('OTHER'); +const [_OTHER2, Z_OTHER2] = utils.generateEitherPubKeyPair('OTHER2'); + +let contract: SignerManagerSimulator; + +describe('SigningManager', () => { + describe('initialization', () => { + it('should fail with a threshold of zero', () => { + expect(() => { + new SignerManagerSimulator(SIGNERS, 0n) + }).toThrow('SignerManager: threshold must be > 0'); + }); + + it('should fail with duplicate signers', () => { + const duplicateSigners = [Z_SIGNER, Z_SIGNER, Z_SIGNER2]; + expect(() => { + new SignerManagerSimulator(duplicateSigners, THRESHOLD) + }).toThrow('SignerManager: signer already active'); + }); + + it('should initialize', () => { + expect(() => { + contract = new SignerManagerSimulator(SIGNERS, THRESHOLD); + }).to.be.ok; + + // Check thresh + expect(contract.getThreshold()).toEqual(THRESHOLD); + + // Check signers + expect(contract.getSignerCount()).toEqual(BigInt(SIGNERS.length)); + expect(() => { + for (let i = 0; i < SIGNERS.length; i++) { + contract.assertSigner(SIGNERS[i]); + }; + }).to.be.ok; + }) + }); + + beforeEach(() => { + contract = new SignerManagerSimulator(SIGNERS, THRESHOLD); + }) + + describe('assertSigner', () => { + it('should pass with good signer', () => { + expect(() => + contract.assertSigner(Z_SIGNER) + ).not.toThrow(); + }); + + it('should fail with bad signer', () => { + expect(() => { + contract.assertSigner(Z_OTHER) + }).toThrow('SignerManager: not a signer') + }); + }); + + describe('assertThresholdMet', () => { + it('should pass when approvals equal threshold', () => { + expect(() => + contract.assertThresholdMet(THRESHOLD) + ).not.toThrow(); + }); + + it('should pass when approvals exceed threshold', () => { + expect(() => + contract.assertThresholdMet(THRESHOLD + 1n) + ).not.toThrow(); + }); + + it('should fail when approvals are below threshold', () => { + expect(() => { + contract.assertThresholdMet(THRESHOLD - 1n) + }).toThrow('SignerManager: threshold not met'); + }); + + it('should fail with zero approvals', () => { + expect(() => { + contract.assertThresholdMet(0n) + }).toThrow('SignerManager: threshold not met'); + }); + }); + + describe('isSigner', () => { + it('should return true for an active signer', () => { + expect(contract.isSigner(Z_SIGNER)).toEqual(true); + }); + + it('should return false for a non-signer', () => { + expect(contract.isSigner(Z_OTHER)).toEqual(false); + }); + }); + + describe('_addSigner', () => { + it('should add a new signer', () => { + contract._addSigner(Z_OTHER); + + expect(contract.isSigner(Z_OTHER)).toEqual(true); + expect(contract.getSignerCount()).toEqual(BigInt(SIGNERS.length) + 1n); + }); + + it('should fail when adding an existing signer', () => { + expect(() => { + contract._addSigner(Z_SIGNER) + }).toThrow('SignerManager: signer already active'); + }); + + it('should add multiple new signers', () => { + contract._addSigner(Z_OTHER); + contract._addSigner(Z_OTHER2); + + expect(contract.isSigner(Z_OTHER)).toEqual(true); + expect(contract.isSigner(Z_OTHER2)).toEqual(true); + expect(contract.getSignerCount()).toEqual(BigInt(SIGNERS.length) + 2n); + }); + }); + + describe('_removeSigner', () => { + it('should remove an existing signer', () => { + contract._removeSigner(Z_SIGNER3); + + expect(contract.isSigner(Z_SIGNER3)).toEqual(false); + expect(contract.getSignerCount()).toEqual(BigInt(SIGNERS.length) - 1n); + }); + + it('should fail when removing a non-signer', () => { + expect(() => { + contract._removeSigner(Z_OTHER) + }).toThrow('SignerManager: not a signer'); + }); + + it('should fail when removal would breach threshold', () => { + // Remove one signer: count goes from 3 to 2, threshold is 2 — ok + contract._removeSigner(Z_SIGNER3); + + // Remove another: count would go from 2 to 1, threshold is 2 — breach + expect(() => { + contract._removeSigner(Z_SIGNER2) + }).toThrow('SignerManager: removal would breach threshold'); + }); + + it('should allow removal after threshold is lowered', () => { + contract._changeThreshold(1n); + contract._removeSigner(Z_SIGNER3); + contract._removeSigner(Z_SIGNER2); + + expect(contract.getSignerCount()).toEqual(1n); + expect(contract.isSigner(Z_SIGNER)).toEqual(true); + expect(contract.isSigner(Z_SIGNER2)).toEqual(false); + expect(contract.isSigner(Z_SIGNER3)).toEqual(false); + }); + }); + + describe('_changeThreshold', () => { + it('should update the threshold', () => { + contract._changeThreshold(3n); + + expect(contract.getThreshold()).toEqual(3n); + }); + + it('should allow lowering the threshold', () => { + contract._changeThreshold(1n); + + expect(contract.getThreshold()).toEqual(1n); + }); + + it('should fail with a threshold of zero', () => { + expect(() => { + contract._changeThreshold(0n) + }).toThrow('SignerManager: threshold must be > 0'); + }); + + it('should fail when threshold exceeds signer count', () => { + expect(() => { + contract._changeThreshold(BigInt(SIGNERS.length) + 1n) + }).toThrow('SignerManager: threshold exceeds signer count'); + }); + + it('should allow threshold equal to signer count', () => { + contract._changeThreshold(BigInt(SIGNERS.length)); + + expect(contract.getThreshold()).toEqual(BigInt(SIGNERS.length)); + }); + + it('should reflect new threshold in assertThresholdMet', () => { + contract._changeThreshold(3n); + + expect(() => { + contract.assertThresholdMet(2n) + }).toThrow('SignerManager: threshold not met'); + + expect(() => + contract.assertThresholdMet(3n) + ).not.toThrow(); + }); + }); +}); From 0fccc11cba0a51534c33638b5c8d0a635f9267a7 Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 11 Mar 2026 03:49:06 -0300 Subject: [PATCH 16/33] fix param order --- contracts/src/multisig/test/mocks/MockShieldedTreasury.compact | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/src/multisig/test/mocks/MockShieldedTreasury.compact b/contracts/src/multisig/test/mocks/MockShieldedTreasury.compact index 8f4fd1af..f6ca5cf6 100644 --- a/contracts/src/multisig/test/mocks/MockShieldedTreasury.compact +++ b/contracts/src/multisig/test/mocks/MockShieldedTreasury.compact @@ -14,8 +14,8 @@ export circuit _deposit(coin: ShieldedCoinInfo): [] { export circuit _send( recipient: Either, - amount: Uint<128>, color: Bytes<32> + amount: Uint<128>, ): ShieldedSendResult { return Treasury__send(recipient, color, amount); } From da3365de604432af74c45088e44cf4db349de644 Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 11 Mar 2026 03:49:51 -0300 Subject: [PATCH 17/33] move deposit --- contracts/src/multisig/presets/BasicMultisig.compact | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/contracts/src/multisig/presets/BasicMultisig.compact b/contracts/src/multisig/presets/BasicMultisig.compact index eaccee69..59d54e5b 100644 --- a/contracts/src/multisig/presets/BasicMultisig.compact +++ b/contracts/src/multisig/presets/BasicMultisig.compact @@ -20,6 +20,12 @@ constructor( Signer_initialize<3>(signers, thresh); } +// ─── Deposit ───────────────────────────────────────────────────────────── + +export circuit deposit(coin: ShieldedCoinInfo): [] { + Treasury__deposit(coin); +} + // ─── Proposals ─────────────────────────────────────────────────────────── export circuit createShieldedProposal( @@ -63,10 +69,6 @@ export circuit revokeApproval(id: Uint<64>): [] { _revokeApproval(id, callerPK); } -export circuit deposit(coin: ShieldedCoinInfo): [] { - Treasury__deposit(coin); -} - export circuit executeShieldedProposal( id: Uint<64>, ): ShieldedSendResult { From 1b979e6d0458915d0da82aabd46769bb67b820bb Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 11 Mar 2026 03:55:30 -0300 Subject: [PATCH 18/33] move file/improve contract name --- .../presets/{BasicMultisig.compact => ShieldedMultiSig.compact} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename contracts/src/multisig/presets/{BasicMultisig.compact => ShieldedMultiSig.compact} (100%) diff --git a/contracts/src/multisig/presets/BasicMultisig.compact b/contracts/src/multisig/presets/ShieldedMultiSig.compact similarity index 100% rename from contracts/src/multisig/presets/BasicMultisig.compact rename to contracts/src/multisig/presets/ShieldedMultiSig.compact From fdbb640788c865f4b02d694a8549e4b5f16afb41 Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 11 Mar 2026 04:15:00 -0300 Subject: [PATCH 19/33] add comma to param --- contracts/src/multisig/test/mocks/MockShieldedTreasury.compact | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/src/multisig/test/mocks/MockShieldedTreasury.compact b/contracts/src/multisig/test/mocks/MockShieldedTreasury.compact index f6ca5cf6..7329442c 100644 --- a/contracts/src/multisig/test/mocks/MockShieldedTreasury.compact +++ b/contracts/src/multisig/test/mocks/MockShieldedTreasury.compact @@ -14,7 +14,7 @@ export circuit _deposit(coin: ShieldedCoinInfo): [] { export circuit _send( recipient: Either, - color: Bytes<32> + color: Bytes<32>, amount: Uint<128>, ): ShieldedSendResult { return Treasury__send(recipient, color, amount); From e5b4e0657eefc8977d808132184530131e5600c3 Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 11 Mar 2026 04:30:04 -0300 Subject: [PATCH 20/33] fix fmt --- .../src/multisig/test/SignerManager.test.ts | 48 ++++++++----------- .../test/simulators/SignerManagerSimulator.ts | 6 ++- .../witnesses/UnshieldedTreasuryWitness.ts | 3 +- 3 files changed, 26 insertions(+), 31 deletions(-) diff --git a/contracts/src/multisig/test/SignerManager.test.ts b/contracts/src/multisig/test/SignerManager.test.ts index e2ac6de6..0cfd9d25 100644 --- a/contracts/src/multisig/test/SignerManager.test.ts +++ b/contracts/src/multisig/test/SignerManager.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it } from 'vitest'; -import { SignerManagerSimulator } from './simulators/SignerManagerSimulator.js'; import * as utils from '#test-utils/address.js'; +import { SignerManagerSimulator } from './simulators/SignerManagerSimulator.js'; const THRESHOLD = 2n; @@ -17,14 +17,14 @@ describe('SigningManager', () => { describe('initialization', () => { it('should fail with a threshold of zero', () => { expect(() => { - new SignerManagerSimulator(SIGNERS, 0n) + new SignerManagerSimulator(SIGNERS, 0n); }).toThrow('SignerManager: threshold must be > 0'); }); it('should fail with duplicate signers', () => { const duplicateSigners = [Z_SIGNER, Z_SIGNER, Z_SIGNER2]; expect(() => { - new SignerManagerSimulator(duplicateSigners, THRESHOLD) + new SignerManagerSimulator(duplicateSigners, THRESHOLD); }).toThrow('SignerManager: signer already active'); }); @@ -41,51 +41,45 @@ describe('SigningManager', () => { expect(() => { for (let i = 0; i < SIGNERS.length; i++) { contract.assertSigner(SIGNERS[i]); - }; + } }).to.be.ok; - }) + }); }); beforeEach(() => { contract = new SignerManagerSimulator(SIGNERS, THRESHOLD); - }) + }); describe('assertSigner', () => { it('should pass with good signer', () => { - expect(() => - contract.assertSigner(Z_SIGNER) - ).not.toThrow(); + expect(() => contract.assertSigner(Z_SIGNER)).not.toThrow(); }); it('should fail with bad signer', () => { expect(() => { - contract.assertSigner(Z_OTHER) - }).toThrow('SignerManager: not a signer') + contract.assertSigner(Z_OTHER); + }).toThrow('SignerManager: not a signer'); }); }); describe('assertThresholdMet', () => { it('should pass when approvals equal threshold', () => { - expect(() => - contract.assertThresholdMet(THRESHOLD) - ).not.toThrow(); + expect(() => contract.assertThresholdMet(THRESHOLD)).not.toThrow(); }); it('should pass when approvals exceed threshold', () => { - expect(() => - contract.assertThresholdMet(THRESHOLD + 1n) - ).not.toThrow(); + expect(() => contract.assertThresholdMet(THRESHOLD + 1n)).not.toThrow(); }); it('should fail when approvals are below threshold', () => { expect(() => { - contract.assertThresholdMet(THRESHOLD - 1n) + contract.assertThresholdMet(THRESHOLD - 1n); }).toThrow('SignerManager: threshold not met'); }); it('should fail with zero approvals', () => { expect(() => { - contract.assertThresholdMet(0n) + contract.assertThresholdMet(0n); }).toThrow('SignerManager: threshold not met'); }); }); @@ -110,7 +104,7 @@ describe('SigningManager', () => { it('should fail when adding an existing signer', () => { expect(() => { - contract._addSigner(Z_SIGNER) + contract._addSigner(Z_SIGNER); }).toThrow('SignerManager: signer already active'); }); @@ -134,7 +128,7 @@ describe('SigningManager', () => { it('should fail when removing a non-signer', () => { expect(() => { - contract._removeSigner(Z_OTHER) + contract._removeSigner(Z_OTHER); }).toThrow('SignerManager: not a signer'); }); @@ -144,7 +138,7 @@ describe('SigningManager', () => { // Remove another: count would go from 2 to 1, threshold is 2 — breach expect(() => { - contract._removeSigner(Z_SIGNER2) + contract._removeSigner(Z_SIGNER2); }).toThrow('SignerManager: removal would breach threshold'); }); @@ -175,13 +169,13 @@ describe('SigningManager', () => { it('should fail with a threshold of zero', () => { expect(() => { - contract._changeThreshold(0n) + contract._changeThreshold(0n); }).toThrow('SignerManager: threshold must be > 0'); }); it('should fail when threshold exceeds signer count', () => { expect(() => { - contract._changeThreshold(BigInt(SIGNERS.length) + 1n) + contract._changeThreshold(BigInt(SIGNERS.length) + 1n); }).toThrow('SignerManager: threshold exceeds signer count'); }); @@ -195,12 +189,10 @@ describe('SigningManager', () => { contract._changeThreshold(3n); expect(() => { - contract.assertThresholdMet(2n) + contract.assertThresholdMet(2n); }).toThrow('SignerManager: threshold not met'); - expect(() => - contract.assertThresholdMet(3n) - ).not.toThrow(); + expect(() => contract.assertThresholdMet(3n)).not.toThrow(); }); }); }); diff --git a/contracts/src/multisig/test/simulators/SignerManagerSimulator.ts b/contracts/src/multisig/test/simulators/SignerManagerSimulator.ts index f170e7a4..1003c925 100644 --- a/contracts/src/multisig/test/simulators/SignerManagerSimulator.ts +++ b/contracts/src/multisig/test/simulators/SignerManagerSimulator.ts @@ -19,7 +19,7 @@ import { */ type SignerManagerArgs = readonly [ signers: Either[], - thresh: bigint + thresh: bigint, ]; const SignerManagerSimulatorBase = createSimulator< @@ -68,7 +68,9 @@ export class SignerManagerSimulator extends SignerManagerSimulatorBase { return this.circuits.impure.getThreshold(); } - public isSigner(account: Either): Boolean { + public isSigner( + account: Either, + ): boolean { return this.circuits.impure.isSigner(account); } diff --git a/contracts/src/multisig/witnesses/UnshieldedTreasuryWitness.ts b/contracts/src/multisig/witnesses/UnshieldedTreasuryWitness.ts index 077508d2..dd08724e 100644 --- a/contracts/src/multisig/witnesses/UnshieldedTreasuryWitness.ts +++ b/contracts/src/multisig/witnesses/UnshieldedTreasuryWitness.ts @@ -2,5 +2,6 @@ // OpenZeppelin Compact Contracts v0.0.1-alpha.1 (multisig/witnesses/UnshieldedTreasuryWitness.ts) export type UnshieldedTreasuryPrivateState = Record; -export const UnshieldedTreasuryPrivateState: UnshieldedTreasuryPrivateState = {}; +export const UnshieldedTreasuryPrivateState: UnshieldedTreasuryPrivateState = + {}; export const UnshieldedTreasuryWitnesses = () => ({}); From f521b3d56f5e7d774de8898d9bfb66abe23b5c40 Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 11 Mar 2026 04:54:07 -0300 Subject: [PATCH 21/33] add multisig script to contracts manifest --- contracts/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/contracts/package.json b/contracts/package.json index d05b662f..dc9e126e 100644 --- a/contracts/package.json +++ b/contracts/package.json @@ -27,6 +27,7 @@ "compact": "compact-compiler", "compact:access": "compact-compiler --dir access", "compact:archive": "compact-compiler --dir archive", + "compact:multisig": "compact-compiler --dir multisig", "compact:security": "compact-compiler --dir security", "compact:token": "compact-compiler --dir token", "compact:utils": "compact-compiler --dir utils", From c780647a5305262327de189659c9f16606eff701 Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 11 Mar 2026 04:59:37 -0300 Subject: [PATCH 22/33] normalize file names --- .../{ProposalManagerWitness.ts => ProposalManagerWitnesses.ts} | 0 .../{ShieldedTreasuryWitness.ts => ShieldedTreasuryWitnesses.ts} | 0 .../{SignerManagerWItness.ts => SignerManagerWitnesses.ts} | 0 ...nshieldedTreasuryWitness.ts => UnshieldedTreasuryWitnesses.ts} | 0 4 files changed, 0 insertions(+), 0 deletions(-) rename contracts/src/multisig/witnesses/{ProposalManagerWitness.ts => ProposalManagerWitnesses.ts} (100%) rename contracts/src/multisig/witnesses/{ShieldedTreasuryWitness.ts => ShieldedTreasuryWitnesses.ts} (100%) rename contracts/src/multisig/witnesses/{SignerManagerWItness.ts => SignerManagerWitnesses.ts} (100%) rename contracts/src/multisig/witnesses/{UnshieldedTreasuryWitness.ts => UnshieldedTreasuryWitnesses.ts} (100%) diff --git a/contracts/src/multisig/witnesses/ProposalManagerWitness.ts b/contracts/src/multisig/witnesses/ProposalManagerWitnesses.ts similarity index 100% rename from contracts/src/multisig/witnesses/ProposalManagerWitness.ts rename to contracts/src/multisig/witnesses/ProposalManagerWitnesses.ts diff --git a/contracts/src/multisig/witnesses/ShieldedTreasuryWitness.ts b/contracts/src/multisig/witnesses/ShieldedTreasuryWitnesses.ts similarity index 100% rename from contracts/src/multisig/witnesses/ShieldedTreasuryWitness.ts rename to contracts/src/multisig/witnesses/ShieldedTreasuryWitnesses.ts diff --git a/contracts/src/multisig/witnesses/SignerManagerWItness.ts b/contracts/src/multisig/witnesses/SignerManagerWitnesses.ts similarity index 100% rename from contracts/src/multisig/witnesses/SignerManagerWItness.ts rename to contracts/src/multisig/witnesses/SignerManagerWitnesses.ts diff --git a/contracts/src/multisig/witnesses/UnshieldedTreasuryWitness.ts b/contracts/src/multisig/witnesses/UnshieldedTreasuryWitnesses.ts similarity index 100% rename from contracts/src/multisig/witnesses/UnshieldedTreasuryWitness.ts rename to contracts/src/multisig/witnesses/UnshieldedTreasuryWitnesses.ts From 2452867bf0e69070820beed9cc6fe146a2fe1143 Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 11 Mar 2026 05:00:00 -0300 Subject: [PATCH 23/33] update import path --- .../src/multisig/test/simulators/SignerManagerSimulator.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/src/multisig/test/simulators/SignerManagerSimulator.ts b/contracts/src/multisig/test/simulators/SignerManagerSimulator.ts index 1003c925..042d5aa0 100644 --- a/contracts/src/multisig/test/simulators/SignerManagerSimulator.ts +++ b/contracts/src/multisig/test/simulators/SignerManagerSimulator.ts @@ -12,7 +12,7 @@ import { import { SignerManagerPrivateState, SignerManagerWitnesses, -} from '../../witnesses/SignerManagerWitness.js'; +} from '../../witnesses/SignerManagerWitnesses.js'; /** * Type constructor args From 75d36eaec537d0e50bfe1a95372f078812d1010f Mon Sep 17 00:00:00 2001 From: Pepe Blasco Date: Wed, 11 Mar 2026 10:00:43 +0100 Subject: [PATCH 24/33] validate threshold does not exceed signer count after initialization --- contracts/src/multisig/SignerManager.compact | 2 ++ 1 file changed, 2 insertions(+) diff --git a/contracts/src/multisig/SignerManager.compact b/contracts/src/multisig/SignerManager.compact index 3e6b507b..5b80eb75 100644 --- a/contracts/src/multisig/SignerManager.compact +++ b/contracts/src/multisig/SignerManager.compact @@ -57,6 +57,8 @@ module SignerManager { for (const signer of signers) { _addSigner(signer); } + + assert(_signerCount >= thresh, "SignerManager: threshold exceeds signer count"); } // ─── Guards ───────────────────────────────────────────────────────────── From e26647edfb3ddc13c877aa150dde0a53299bc917 Mon Sep 17 00:00:00 2001 From: Pepe Blasco Date: Thu, 12 Mar 2026 10:34:32 +0100 Subject: [PATCH 25/33] Add multisig tests (#380) --- .../src/multisig/test/ProposalManager.test.ts | 361 +++++++++++++ .../multisig/test/ShieldedMultiSig.test.ts | 500 ++++++++++++++++++ .../multisig/test/ShieldedTreasury.test.ts | 149 ++++++ .../test/mocks/MockProposalManager.compact | 8 + .../simulators/ProposalManagerSimulator.ts | 129 +++++ .../simulators/ShieldedMultiSigSimulator.ts | 158 ++++++ .../simulators/ShieldedTreasurySimulator.ts | 83 +++ .../witnesses/ShieldedMultiSigWitnesses.ts | 6 + 8 files changed, 1394 insertions(+) create mode 100644 contracts/src/multisig/test/ProposalManager.test.ts create mode 100644 contracts/src/multisig/test/ShieldedMultiSig.test.ts create mode 100644 contracts/src/multisig/test/ShieldedTreasury.test.ts create mode 100644 contracts/src/multisig/test/simulators/ProposalManagerSimulator.ts create mode 100644 contracts/src/multisig/test/simulators/ShieldedMultiSigSimulator.ts create mode 100644 contracts/src/multisig/test/simulators/ShieldedTreasurySimulator.ts create mode 100644 contracts/src/multisig/witnesses/ShieldedMultiSigWitnesses.ts diff --git a/contracts/src/multisig/test/ProposalManager.test.ts b/contracts/src/multisig/test/ProposalManager.test.ts new file mode 100644 index 00000000..6a140ee0 --- /dev/null +++ b/contracts/src/multisig/test/ProposalManager.test.ts @@ -0,0 +1,361 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import * as utils from '#test-utils/address.js'; +import { ProposalManagerSimulator } from './simulators/ProposalManagerSimulator.js'; + +// Enum values matching ProposalStatus and RecipientKind +const ProposalStatus = { Inactive: 0, Active: 1, Executed: 2, Cancelled: 3 }; +const RecipientKind = { ShieldedUser: 0, UnshieldedUser: 1, Contract: 2 }; + +const COLOR = new Uint8Array(32).fill(1); +const COLOR2 = new Uint8Array(32).fill(2); +const AMOUNT = 1000n; +const AMOUNT2 = 2000n; + +const [_RECIPIENT, Z_RECIPIENT] = utils.generatePubKeyPair('RECIPIENT'); +const Z_CONTRACT_RECIPIENT = utils.encodeToAddress('CONTRACT_RECIPIENT'); + +let contract: ProposalManagerSimulator; + +describe('ProposalManager', () => { + beforeEach(() => { + contract = new ProposalManagerSimulator(); + }); + + describe('recipient helpers (pure)', () => { + it('should create shielded user recipient', () => { + const recipient = contract.shieldedUserRecipient(Z_RECIPIENT); + expect(recipient.kind).toEqual(RecipientKind.ShieldedUser); + expect(recipient.address).toEqual(Z_RECIPIENT.bytes); + }); + + it('should create unshielded user recipient', () => { + const addr = utils.encodeToPK('UNSHIELDED_USER'); + const recipient = contract.unshieldedUserRecipient(addr); + expect(recipient.kind).toEqual(RecipientKind.UnshieldedUser); + expect(recipient.address).toEqual(addr.bytes); + }); + + it('should create contract recipient', () => { + const recipient = contract.contractRecipient(Z_CONTRACT_RECIPIENT); + expect(recipient.kind).toEqual(RecipientKind.Contract); + expect(recipient.address).toEqual(Z_CONTRACT_RECIPIENT.bytes); + }); + + it('should convert shielded user recipient to shielded send format', () => { + const recipient = contract.shieldedUserRecipient(Z_RECIPIENT); + const shielded = contract.toShieldedRecipient(recipient); + expect(shielded.is_left).toEqual(true); + expect(shielded.left.bytes).toEqual(Z_RECIPIENT.bytes); + }); + + it('should convert contract recipient to shielded send format', () => { + const recipient = contract.contractRecipient(Z_CONTRACT_RECIPIENT); + const shielded = contract.toShieldedRecipient(recipient); + expect(shielded.is_left).toEqual(false); + expect(shielded.right.bytes).toEqual(Z_CONTRACT_RECIPIENT.bytes); + }); + + it('should reject unshielded user in toShieldedRecipient', () => { + const recipient = { + kind: RecipientKind.UnshieldedUser, + address: new Uint8Array(32), + }; + expect(() => { + contract.toShieldedRecipient(recipient); + }).toThrow('ProposalManager: invalid shielded recipient'); + }); + + it('should convert contract recipient to unshielded send format', () => { + const recipient = contract.contractRecipient(Z_CONTRACT_RECIPIENT); + const unshielded = contract.toUnshieldedRecipient(recipient); + expect(unshielded.is_left).toEqual(true); + expect(unshielded.left.bytes).toEqual(Z_CONTRACT_RECIPIENT.bytes); + }); + + it('should convert unshielded user recipient to unshielded send format', () => { + const addr = utils.encodeToPK('UNSHIELDED_USER'); + const recipient = contract.unshieldedUserRecipient(addr); + const unshielded = contract.toUnshieldedRecipient(recipient); + expect(unshielded.is_left).toEqual(false); + expect(unshielded.right.bytes).toEqual(addr.bytes); + }); + + it('should reject shielded user in toUnshieldedRecipient', () => { + const recipient = contract.shieldedUserRecipient(Z_RECIPIENT); + expect(() => { + contract.toUnshieldedRecipient(recipient); + }).toThrow('ProposalManager: invalid unshielded recipient'); + }); + }); + + describe('_createProposal', () => { + it('should create a proposal and return id', () => { + const recipient = contract.shieldedUserRecipient(Z_RECIPIENT); + const id = contract._createProposal(recipient, COLOR, AMOUNT); + expect(id).toEqual(1n); + }); + + it('should create sequential proposal ids', () => { + const recipient = contract.shieldedUserRecipient(Z_RECIPIENT); + const id1 = contract._createProposal(recipient, COLOR, AMOUNT); + const id2 = contract._createProposal(recipient, COLOR2, AMOUNT2); + expect(id1).toEqual(1n); + expect(id2).toEqual(2n); + }); + + it('should store proposal data correctly', () => { + const recipient = contract.shieldedUserRecipient(Z_RECIPIENT); + const id = contract._createProposal(recipient, COLOR, AMOUNT); + + const proposal = contract.getProposal(id); + expect(proposal.to.kind).toEqual(RecipientKind.ShieldedUser); + expect(proposal.to.address).toEqual(Z_RECIPIENT.bytes); + expect(proposal.color).toEqual(COLOR); + expect(proposal.amount).toEqual(AMOUNT); + expect(proposal.status).toEqual(ProposalStatus.Active); + }); + + it('should store contract recipient correctly', () => { + const recipient = contract.contractRecipient(Z_CONTRACT_RECIPIENT); + const id = contract._createProposal(recipient, COLOR2, AMOUNT2); + + const proposal = contract.getProposal(id); + expect(proposal.to.kind).toEqual(RecipientKind.Contract); + expect(proposal.to.address).toEqual(Z_CONTRACT_RECIPIENT.bytes); + expect(proposal.color).toEqual(COLOR2); + expect(proposal.amount).toEqual(AMOUNT2); + }); + + it('should fail with zero amount', () => { + const recipient = contract.shieldedUserRecipient(Z_RECIPIENT); + expect(() => { + contract._createProposal(recipient, COLOR, 0n); + }).toThrow('ProposalManager: zero amount'); + }); + }); + + describe('assertProposalExists', () => { + it('should pass for existing proposal', () => { + const recipient = contract.shieldedUserRecipient(Z_RECIPIENT); + const id = contract._createProposal(recipient, COLOR, AMOUNT); + expect(() => contract.assertProposalExists(id)).not.toThrow(); + }); + + it('should fail for non-existing proposal', () => { + expect(() => { + contract.assertProposalExists(999n); + }).toThrow('ProposalManager: proposal not found'); + }); + }); + + describe('assertProposalActive', () => { + it('should pass for active proposal', () => { + const recipient = contract.shieldedUserRecipient(Z_RECIPIENT); + const id = contract._createProposal(recipient, COLOR, AMOUNT); + expect(() => contract.assertProposalActive(id)).not.toThrow(); + }); + + it('should fail for non-existing proposal', () => { + expect(() => { + contract.assertProposalActive(999n); + }).toThrow('ProposalManager: proposal not found'); + }); + + it('should fail for cancelled proposal', () => { + const recipient = contract.shieldedUserRecipient(Z_RECIPIENT); + const id = contract._createProposal(recipient, COLOR, AMOUNT); + contract._cancelProposal(id); + expect(() => { + contract.assertProposalActive(id); + }).toThrow('ProposalManager: proposal not active'); + }); + + it('should fail for executed proposal', () => { + const recipient = contract.shieldedUserRecipient(Z_RECIPIENT); + const id = contract._createProposal(recipient, COLOR, AMOUNT); + contract._markExecuted(id); + expect(() => { + contract.assertProposalActive(id); + }).toThrow('ProposalManager: proposal not active'); + }); + }); + + describe('_cancelProposal', () => { + it('should cancel an active proposal', () => { + const recipient = contract.shieldedUserRecipient(Z_RECIPIENT); + const id = contract._createProposal(recipient, COLOR, AMOUNT); + + contract._cancelProposal(id); + expect(contract.getProposalStatus(id)).toEqual( + ProposalStatus.Cancelled, + ); + }); + + it('should preserve proposal data after cancellation', () => { + const recipient = contract.shieldedUserRecipient(Z_RECIPIENT); + const id = contract._createProposal(recipient, COLOR, AMOUNT); + + contract._cancelProposal(id); + const proposal = contract.getProposal(id); + expect(proposal.to.address).toEqual(Z_RECIPIENT.bytes); + expect(proposal.color).toEqual(COLOR); + expect(proposal.amount).toEqual(AMOUNT); + }); + + it('should fail for non-existing proposal', () => { + expect(() => { + contract._cancelProposal(999n); + }).toThrow('ProposalManager: proposal not found'); + }); + + it('should fail for already cancelled proposal', () => { + const recipient = contract.shieldedUserRecipient(Z_RECIPIENT); + const id = contract._createProposal(recipient, COLOR, AMOUNT); + contract._cancelProposal(id); + + expect(() => { + contract._cancelProposal(id); + }).toThrow('ProposalManager: proposal not active'); + }); + + it('should fail for executed proposal', () => { + const recipient = contract.shieldedUserRecipient(Z_RECIPIENT); + const id = contract._createProposal(recipient, COLOR, AMOUNT); + contract._markExecuted(id); + + expect(() => { + contract._cancelProposal(id); + }).toThrow('ProposalManager: proposal not active'); + }); + }); + + describe('_markExecuted', () => { + it('should mark an active proposal as executed', () => { + const recipient = contract.shieldedUserRecipient(Z_RECIPIENT); + const id = contract._createProposal(recipient, COLOR, AMOUNT); + + contract._markExecuted(id); + expect(contract.getProposalStatus(id)).toEqual(ProposalStatus.Executed); + }); + + it('should fail for non-existing proposal', () => { + expect(() => { + contract._markExecuted(999n); + }).toThrow('ProposalManager: proposal not found'); + }); + + it('should fail for already executed proposal', () => { + const recipient = contract.shieldedUserRecipient(Z_RECIPIENT); + const id = contract._createProposal(recipient, COLOR, AMOUNT); + contract._markExecuted(id); + + expect(() => { + contract._markExecuted(id); + }).toThrow('ProposalManager: proposal not active'); + }); + + it('should fail for cancelled proposal', () => { + const recipient = contract.shieldedUserRecipient(Z_RECIPIENT); + const id = contract._createProposal(recipient, COLOR, AMOUNT); + contract._cancelProposal(id); + + expect(() => { + contract._markExecuted(id); + }).toThrow('ProposalManager: proposal not active'); + }); + }); + + describe('view circuits', () => { + let proposalId: bigint; + + beforeEach(() => { + const recipient = contract.shieldedUserRecipient(Z_RECIPIENT); + proposalId = contract._createProposal(recipient, COLOR, AMOUNT); + }); + + it('getProposal should return full proposal', () => { + const proposal = contract.getProposal(proposalId); + expect(proposal.to.kind).toEqual(RecipientKind.ShieldedUser); + expect(proposal.color).toEqual(COLOR); + expect(proposal.amount).toEqual(AMOUNT); + expect(proposal.status).toEqual(ProposalStatus.Active); + }); + + it('getProposalRecipient should return recipient', () => { + const recipient = contract.getProposalRecipient(proposalId); + expect(recipient.kind).toEqual(RecipientKind.ShieldedUser); + expect(recipient.address).toEqual(Z_RECIPIENT.bytes); + }); + + it('getProposalAmount should return amount', () => { + expect(contract.getProposalAmount(proposalId)).toEqual(AMOUNT); + }); + + it('getProposalColor should return color', () => { + expect(contract.getProposalColor(proposalId)).toEqual(COLOR); + }); + + it('getProposalStatus should return status', () => { + expect(contract.getProposalStatus(proposalId)).toEqual( + ProposalStatus.Active, + ); + }); + + it('all view circuits should fail for non-existing proposal', () => { + const badId = 999n; + expect(() => contract.getProposal(badId)).toThrow( + 'ProposalManager: proposal not found', + ); + expect(() => contract.getProposalRecipient(badId)).toThrow( + 'ProposalManager: proposal not found', + ); + expect(() => contract.getProposalAmount(badId)).toThrow( + 'ProposalManager: proposal not found', + ); + expect(() => contract.getProposalColor(badId)).toThrow( + 'ProposalManager: proposal not found', + ); + expect(() => contract.getProposalStatus(badId)).toThrow( + 'ProposalManager: proposal not found', + ); + }); + }); + + describe('lifecycle transitions', () => { + it('should handle create -> cancel flow', () => { + const recipient = contract.shieldedUserRecipient(Z_RECIPIENT); + const id = contract._createProposal(recipient, COLOR, AMOUNT); + expect(contract.getProposalStatus(id)).toEqual(ProposalStatus.Active); + + contract._cancelProposal(id); + expect(contract.getProposalStatus(id)).toEqual( + ProposalStatus.Cancelled, + ); + }); + + it('should handle create -> execute flow', () => { + const recipient = contract.shieldedUserRecipient(Z_RECIPIENT); + const id = contract._createProposal(recipient, COLOR, AMOUNT); + expect(contract.getProposalStatus(id)).toEqual(ProposalStatus.Active); + + contract._markExecuted(id); + expect(contract.getProposalStatus(id)).toEqual(ProposalStatus.Executed); + }); + + it('should handle multiple proposals independently', () => { + const recipient = contract.shieldedUserRecipient(Z_RECIPIENT); + const id1 = contract._createProposal(recipient, COLOR, AMOUNT); + const id2 = contract._createProposal(recipient, COLOR2, AMOUNT2); + + contract._cancelProposal(id1); + + expect(contract.getProposalStatus(id1)).toEqual( + ProposalStatus.Cancelled, + ); + expect(contract.getProposalStatus(id2)).toEqual(ProposalStatus.Active); + + contract._markExecuted(id2); + expect(contract.getProposalStatus(id2)).toEqual(ProposalStatus.Executed); + }); + }); +}); diff --git a/contracts/src/multisig/test/ShieldedMultiSig.test.ts b/contracts/src/multisig/test/ShieldedMultiSig.test.ts new file mode 100644 index 00000000..760d1b56 --- /dev/null +++ b/contracts/src/multisig/test/ShieldedMultiSig.test.ts @@ -0,0 +1,500 @@ +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 [SIGNER1, Z_SIGNER1] = utils.generateEitherPubKeyPair('SIGNER1'); +const [SIGNER2, Z_SIGNER2] = utils.generateEitherPubKeyPair('SIGNER2'); +const [SIGNER3, Z_SIGNER3] = utils.generateEitherPubKeyPair('SIGNER3'); +const SIGNERS = [Z_SIGNER1, Z_SIGNER2, Z_SIGNER3]; + +const [_NON_SIGNER, Z_NON_SIGNER] = utils.generateEitherPubKeyPair('OTHER'); +const [, Z_RECIPIENT_PK] = utils.generatePubKeyPair('RECIPIENT'); + +function makeRecipient(pk: { bytes: Uint8Array }): { + kind: number; + address: Uint8Array; +} { + return { kind: RecipientKind.ShieldedUser, address: pk.bytes }; +} + +function makeCoin( + color: Uint8Array, + value: bigint, + nonce?: Uint8Array, +): { nonce: Uint8Array; color: Uint8Array; value: bigint } { + return { + nonce: nonce ?? new Uint8Array(32).fill(0), + color, + value, + }; +} + +let multisig: ShieldedMultiSigSimulator; + +describe('ShieldedMultiSig', () => { + describe('constructor', () => { + it('should initialize with signers and threshold', () => { + multisig = new ShieldedMultiSigSimulator(SIGNERS, THRESHOLD); + expect(multisig.getSignerCount()).toEqual(BigInt(SIGNERS.length)); + expect(multisig.getThreshold()).toEqual(THRESHOLD); + }); + + it('should register all signers', () => { + multisig = new ShieldedMultiSigSimulator(SIGNERS, THRESHOLD); + for (const signer of SIGNERS) { + expect(multisig.isSigner(signer)).toEqual(true); + } + }); + + it('should reject non-signers', () => { + multisig = new ShieldedMultiSigSimulator(SIGNERS, THRESHOLD); + expect(multisig.isSigner(Z_NON_SIGNER)).toEqual(false); + }); + + it('should fail with zero threshold', () => { + expect(() => { + new ShieldedMultiSigSimulator(SIGNERS, 0n); + }).toThrow('SignerManager: threshold must be > 0'); + }); + + it('should fail with threshold exceeding signer count', () => { + expect(() => { + new ShieldedMultiSigSimulator(SIGNERS, 4n); + }).toThrow('SignerManager: threshold exceeds signer count'); + }); + }); + + describe('when initialized', () => { + beforeEach(() => { + multisig = new ShieldedMultiSigSimulator(SIGNERS, THRESHOLD); + }); + + describe('deposit', () => { + it('should accept deposits', () => { + multisig.deposit(makeCoin(COLOR, AMOUNT)); + expect(multisig.getTokenBalance(COLOR)).toEqual(AMOUNT); + }); + + it('should accumulate deposits', () => { + multisig.deposit(makeCoin(COLOR, AMOUNT, new Uint8Array(32).fill(1))); + multisig.deposit(makeCoin(COLOR, AMOUNT, new Uint8Array(32).fill(2))); + expect(multisig.getTokenBalance(COLOR)).toEqual(AMOUNT * 2n); + }); + + it('should track received total', () => { + multisig.deposit(makeCoin(COLOR, AMOUNT)); + expect(multisig.getReceivedTotal(COLOR)).toEqual(AMOUNT); + }); + }); + + describe('createShieldedProposal', () => { + it('should allow signer to create proposal', () => { + const to = makeRecipient(Z_RECIPIENT_PK); + const id = multisig + .as(SIGNER1) + .createShieldedProposal(to, COLOR, PROPOSAL_AMOUNT); + expect(id).toEqual(1n); + }); + + it('should store proposal data correctly', () => { + const to = makeRecipient(Z_RECIPIENT_PK); + const id = multisig + .as(SIGNER1) + .createShieldedProposal(to, COLOR, PROPOSAL_AMOUNT); + + const proposal = multisig.getProposal(id); + expect(proposal.status).toEqual(ProposalStatus.Active); + expect(proposal.amount).toEqual(PROPOSAL_AMOUNT); + expect(proposal.color).toEqual(COLOR); + }); + + it('should fail for non-signer', () => { + const to = makeRecipient(Z_RECIPIENT_PK); + expect(() => { + multisig + .as(_NON_SIGNER) + .createShieldedProposal(to, COLOR, PROPOSAL_AMOUNT); + }).toThrow('SignerManager: not a signer'); + }); + + it('should fail with zero amount', () => { + const to = makeRecipient(Z_RECIPIENT_PK); + expect(() => { + multisig.as(SIGNER1).createShieldedProposal(to, COLOR, 0n); + }).toThrow('ProposalManager: zero amount'); + }); + }); + + describe('approveProposal', () => { + let proposalId: bigint; + + beforeEach(() => { + const to = makeRecipient(Z_RECIPIENT_PK); + proposalId = multisig + .as(SIGNER1) + .createShieldedProposal(to, COLOR, PROPOSAL_AMOUNT); + }); + + it('should allow signer to approve', () => { + multisig.as(SIGNER1).approveProposal(proposalId); + expect( + multisig.isProposalApprovedBySigner(proposalId, Z_SIGNER1), + ).toEqual(true); + expect(multisig.getApprovalCount(proposalId)).toEqual(1n); + }); + + it('should allow multiple signers to approve', () => { + multisig.as(SIGNER1).approveProposal(proposalId); + multisig.as(SIGNER2).approveProposal(proposalId); + expect(multisig.getApprovalCount(proposalId)).toEqual(2n); + }); + + it('should fail for non-signer', () => { + expect(() => { + multisig.as(_NON_SIGNER).approveProposal(proposalId); + }).toThrow('SignerManager: not a signer'); + }); + + it('should fail for double approval', () => { + multisig.as(SIGNER1).approveProposal(proposalId); + expect(() => { + multisig.as(SIGNER1).approveProposal(proposalId); + }).toThrow('Multisig: already approved'); + }); + + it('should fail for non-existing proposal', () => { + expect(() => { + multisig.as(SIGNER1).approveProposal(999n); + }).toThrow('ProposalManager: proposal not found'); + }); + + it('should fail for executed proposal', () => { + multisig.deposit(makeCoin(COLOR, AMOUNT)); + multisig.as(SIGNER1).approveProposal(proposalId); + multisig.as(SIGNER2).approveProposal(proposalId); + multisig.executeShieldedProposal(proposalId); + + expect(() => { + multisig.as(SIGNER3).approveProposal(proposalId); + }).toThrow('ProposalManager: proposal not active'); + }); + }); + + describe('revokeApproval', () => { + let proposalId: bigint; + + beforeEach(() => { + const to = makeRecipient(Z_RECIPIENT_PK); + proposalId = multisig + .as(SIGNER1) + .createShieldedProposal(to, COLOR, PROPOSAL_AMOUNT); + multisig.as(SIGNER1).approveProposal(proposalId); + }); + + it('should allow signer to revoke their approval', () => { + multisig.as(SIGNER1).revokeApproval(proposalId); + expect( + multisig.isProposalApprovedBySigner(proposalId, Z_SIGNER1), + ).toEqual(false); + expect(multisig.getApprovalCount(proposalId)).toEqual(0n); + }); + + it('should fail for non-signer', () => { + expect(() => { + multisig.as(_NON_SIGNER).revokeApproval(proposalId); + }).toThrow('SignerManager: not a signer'); + }); + + it('should fail if not yet approved', () => { + expect(() => { + multisig.as(SIGNER2).revokeApproval(proposalId); + }).toThrow('Multisig: not approved'); + }); + + it('should allow re-approval after revoke', () => { + multisig.as(SIGNER1).revokeApproval(proposalId); + multisig.as(SIGNER1).approveProposal(proposalId); + expect( + multisig.isProposalApprovedBySigner(proposalId, Z_SIGNER1), + ).toEqual(true); + expect(multisig.getApprovalCount(proposalId)).toEqual(1n); + }); + + it('should fail for executed proposal', () => { + multisig.deposit(makeCoin(COLOR, AMOUNT)); + multisig.as(SIGNER2).approveProposal(proposalId); + multisig.executeShieldedProposal(proposalId); + + expect(() => { + multisig.as(SIGNER1).revokeApproval(proposalId); + }).toThrow('ProposalManager: proposal not active'); + }); + }); + + describe('executeShieldedProposal', () => { + let proposalId: bigint; + + beforeEach(() => { + // Fund the treasury + multisig.deposit(makeCoin(COLOR, AMOUNT)); + + // Create and approve proposal to threshold + const to = makeRecipient(Z_RECIPIENT_PK); + proposalId = multisig + .as(SIGNER1) + .createShieldedProposal(to, COLOR, PROPOSAL_AMOUNT); + multisig.as(SIGNER1).approveProposal(proposalId); + multisig.as(SIGNER2).approveProposal(proposalId); + }); + + it('should execute when threshold is met', () => { + multisig.executeShieldedProposal(proposalId); + expect(multisig.getProposalStatus(proposalId)).toEqual( + ProposalStatus.Executed, + ); + }); + + it('should return sent coin and change in result', () => { + const result = multisig.executeShieldedProposal(proposalId); + expect(result.sent.value).toEqual(PROPOSAL_AMOUNT); + expect(result.sent.color).toEqual(COLOR); + expect(result.change.is_some).toEqual(true); + expect(result.change.value.value).toEqual(AMOUNT - PROPOSAL_AMOUNT); + expect(result.change.value.color).toEqual(COLOR); + }); + + it('should return no change when sending full balance', () => { + // Create proposal for the full amount + const to = makeRecipient(Z_RECIPIENT_PK); + const fullId = multisig + .as(SIGNER1) + .createShieldedProposal(to, COLOR, AMOUNT); + multisig.as(SIGNER1).approveProposal(fullId); + multisig.as(SIGNER2).approveProposal(fullId); + + const result = multisig.executeShieldedProposal(fullId); + expect(result.sent.value).toEqual(AMOUNT); + expect(result.change.is_some).toEqual(false); + }); + + it('should deduct from treasury balance', () => { + multisig.executeShieldedProposal(proposalId); + expect(multisig.getTokenBalance(COLOR)).toEqual( + AMOUNT - PROPOSAL_AMOUNT, + ); + }); + + it('should track sent total', () => { + multisig.executeShieldedProposal(proposalId); + expect(multisig.getSentTotal(COLOR)).toEqual(PROPOSAL_AMOUNT); + }); + + it('should fail when threshold is not met', () => { + // Create a new proposal with only 1 approval + const to = makeRecipient(Z_RECIPIENT_PK); + const id2 = multisig + .as(SIGNER1) + .createShieldedProposal(to, COLOR, 100n); + multisig.as(SIGNER1).approveProposal(id2); + + expect(() => { + multisig.executeShieldedProposal(id2); + }).toThrow('SignerManager: threshold not met'); + }); + + it('should fail for non-existing proposal', () => { + expect(() => { + multisig.executeShieldedProposal(999n); + }).toThrow('ProposalManager: proposal not found'); + }); + + it('should fail when executed twice', () => { + multisig.executeShieldedProposal(proposalId); + expect(() => { + multisig.executeShieldedProposal(proposalId); + }).toThrow('ProposalManager: proposal not active'); + }); + + it('should fail with insufficient treasury balance', () => { + // Create proposal for more than treasury holds + const to = makeRecipient(Z_RECIPIENT_PK); + const bigId = multisig + .as(SIGNER1) + .createShieldedProposal(to, COLOR, AMOUNT + 1n); + multisig.as(SIGNER1).approveProposal(bigId); + multisig.as(SIGNER2).approveProposal(bigId); + + expect(() => { + multisig.executeShieldedProposal(bigId); + }).toThrow('ShieldedTreasury: coin value insufficient'); + }); + }); + + describe('view - approvals', () => { + it('should return false for unapproved signer', () => { + const to = makeRecipient(Z_RECIPIENT_PK); + const id = multisig + .as(SIGNER1) + .createShieldedProposal(to, COLOR, PROPOSAL_AMOUNT); + expect(multisig.isProposalApprovedBySigner(id, Z_SIGNER1)).toEqual( + false, + ); + }); + + it('should return 0 approval count for new proposal', () => { + const to = makeRecipient(Z_RECIPIENT_PK); + const id = multisig + .as(SIGNER1) + .createShieldedProposal(to, COLOR, PROPOSAL_AMOUNT); + expect(multisig.getApprovalCount(id)).toEqual(0n); + }); + }); + + describe('view - proposal delegation', () => { + let proposalId: bigint; + + beforeEach(() => { + const to = makeRecipient(Z_RECIPIENT_PK); + proposalId = multisig + .as(SIGNER1) + .createShieldedProposal(to, COLOR, PROPOSAL_AMOUNT); + }); + + it('getProposalRecipient should return recipient', () => { + const recipient = multisig.getProposalRecipient(proposalId); + expect(recipient.kind).toEqual(RecipientKind.ShieldedUser); + expect(recipient.address).toEqual(Z_RECIPIENT_PK.bytes); + }); + + it('getProposalAmount should return amount', () => { + expect(multisig.getProposalAmount(proposalId)).toEqual(PROPOSAL_AMOUNT); + }); + + it('getProposalColor should return color', () => { + expect(multisig.getProposalColor(proposalId)).toEqual(COLOR); + }); + }); + + describe('view - signer manager delegation', () => { + it('getSignerCount should match initial count', () => { + expect(multisig.getSignerCount()).toEqual(BigInt(SIGNERS.length)); + }); + + it('getThreshold should match initial threshold', () => { + expect(multisig.getThreshold()).toEqual(THRESHOLD); + }); + + it('isSigner should return true for signer', () => { + expect(multisig.isSigner(Z_SIGNER1)).toEqual(true); + }); + + it('isSigner should return false for non-signer', () => { + expect(multisig.isSigner(Z_NON_SIGNER)).toEqual(false); + }); + }); + + describe('view - treasury delegation', () => { + beforeEach(() => { + multisig.deposit(makeCoin(COLOR, AMOUNT)); + }); + + it('getTokenBalance should reflect deposits', () => { + expect(multisig.getTokenBalance(COLOR)).toEqual(AMOUNT); + }); + + it('getReceivedTotal should reflect deposits', () => { + expect(multisig.getReceivedTotal(COLOR)).toEqual(AMOUNT); + }); + + it('getSentTotal should be 0 before any sends', () => { + expect(multisig.getSentTotal(COLOR)).toEqual(0n); + }); + + it('getReceivedMinusSent should equal balance', () => { + expect(multisig.getReceivedMinusSent(COLOR)).toEqual(AMOUNT); + }); + }); + + describe('full lifecycle', () => { + it('should handle deposit -> propose -> approve -> execute', () => { + // Deposit + multisig.deposit(makeCoin(COLOR, AMOUNT)); + expect(multisig.getTokenBalance(COLOR)).toEqual(AMOUNT); + + // Propose + const to = makeRecipient(Z_RECIPIENT_PK); + const id = multisig + .as(SIGNER1) + .createShieldedProposal(to, COLOR, PROPOSAL_AMOUNT); + + // Approve to threshold + multisig.as(SIGNER1).approveProposal(id); + multisig.as(SIGNER2).approveProposal(id); + expect(multisig.getApprovalCount(id)).toEqual(THRESHOLD); + + // Execute + multisig.executeShieldedProposal(id); + expect(multisig.getProposalStatus(id)).toEqual(ProposalStatus.Executed); + expect(multisig.getTokenBalance(COLOR)).toEqual( + AMOUNT - PROPOSAL_AMOUNT, + ); + expect(multisig.getReceivedMinusSent(COLOR)).toEqual( + AMOUNT - PROPOSAL_AMOUNT, + ); + }); + + it('should handle multiple proposals concurrently', () => { + multisig.deposit(makeCoin(COLOR, AMOUNT)); + + const to = makeRecipient(Z_RECIPIENT_PK); + const id1 = multisig + .as(SIGNER1) + .createShieldedProposal(to, COLOR, 200n); + const id2 = multisig + .as(SIGNER2) + .createShieldedProposal(to, COLOR, 300n); + + // Approve and execute first + multisig.as(SIGNER1).approveProposal(id1); + multisig.as(SIGNER2).approveProposal(id1); + multisig.executeShieldedProposal(id1); + + // Approve and execute second + multisig.as(SIGNER1).approveProposal(id2); + multisig.as(SIGNER3).approveProposal(id2); + multisig.executeShieldedProposal(id2); + + expect(multisig.getTokenBalance(COLOR)).toEqual(AMOUNT - 200n - 300n); + }); + + it('should handle approve -> revoke -> re-approve -> execute', () => { + multisig.deposit(makeCoin(COLOR, AMOUNT)); + const to = makeRecipient(Z_RECIPIENT_PK); + const id = multisig + .as(SIGNER1) + .createShieldedProposal(to, COLOR, PROPOSAL_AMOUNT); + + // Approve then revoke + multisig.as(SIGNER1).approveProposal(id); + multisig.as(SIGNER1).revokeApproval(id); + expect(multisig.getApprovalCount(id)).toEqual(0n); + + // Re-approve with enough signers + multisig.as(SIGNER2).approveProposal(id); + multisig.as(SIGNER3).approveProposal(id); + expect(multisig.getApprovalCount(id)).toEqual(2n); + + multisig.executeShieldedProposal(id); + expect(multisig.getProposalStatus(id)).toEqual(ProposalStatus.Executed); + }); + }); + }); +}); diff --git a/contracts/src/multisig/test/ShieldedTreasury.test.ts b/contracts/src/multisig/test/ShieldedTreasury.test.ts new file mode 100644 index 00000000..7cd6541b --- /dev/null +++ b/contracts/src/multisig/test/ShieldedTreasury.test.ts @@ -0,0 +1,149 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import * as utils from '#test-utils/address.js'; +import { ShieldedTreasurySimulator } from './simulators/ShieldedTreasurySimulator.js'; + +const COLOR = new Uint8Array(32).fill(1); +const COLOR2 = new Uint8Array(32).fill(2); +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, + }; +} + +let treasury: ShieldedTreasurySimulator; + +describe('ShieldedTreasury', () => { + beforeEach(() => { + treasury = new ShieldedTreasurySimulator(); + }); + + describe('UINT128_MAX', () => { + it('should return max uint128 value', () => { + const max = ShieldedTreasurySimulator.UINT128_MAX(); + expect(max).toEqual((1n << 128n) - 1n); + }); + }); + + describe('initial state', () => { + it('should return 0 balance for unknown color', () => { + expect(treasury.getTokenBalance(COLOR)).toEqual(0n); + }); + + it('should return 0 received total for unknown color', () => { + expect(treasury.getReceivedTotal(COLOR)).toEqual(0n); + }); + + it('should return 0 sent total for unknown color', () => { + expect(treasury.getSentTotal(COLOR)).toEqual(0n); + }); + + it('should return 0 receivedMinusSent for unknown color', () => { + expect(treasury.getReceivedMinusSent(COLOR)).toEqual(0n); + }); + }); + + describe('_deposit', () => { + it('should deposit and update balance', () => { + treasury._deposit(makeCoin(COLOR, AMOUNT)); + expect(treasury.getTokenBalance(COLOR)).toEqual(AMOUNT); + }); + + it('should track received total', () => { + treasury._deposit(makeCoin(COLOR, AMOUNT)); + expect(treasury.getReceivedTotal(COLOR)).toEqual(AMOUNT); + }); + + it('should accumulate multiple deposits', () => { + treasury._deposit(makeCoin(COLOR, AMOUNT, new Uint8Array(32).fill(1))); + treasury._deposit(makeCoin(COLOR, AMOUNT, new Uint8Array(32).fill(2))); + expect(treasury.getTokenBalance(COLOR)).toEqual(AMOUNT * 2n); + expect(treasury.getReceivedTotal(COLOR)).toEqual(AMOUNT * 2n); + }); + + it('should track balances per color independently', () => { + treasury._deposit(makeCoin(COLOR, AMOUNT)); + treasury._deposit(makeCoin(COLOR2, AMOUNT * 2n)); + expect(treasury.getTokenBalance(COLOR)).toEqual(AMOUNT); + expect(treasury.getTokenBalance(COLOR2)).toEqual(AMOUNT * 2n); + }); + + it('should allow zero value deposit', () => { + treasury._deposit(makeCoin(COLOR, 0n)); + expect(treasury.getTokenBalance(COLOR)).toEqual(0n); + expect(treasury.getReceivedTotal(COLOR)).toEqual(0n); + }); + + it('should maintain receivedMinusSent consistency', () => { + treasury._deposit(makeCoin(COLOR, AMOUNT)); + expect(treasury.getReceivedMinusSent(COLOR)).toEqual(AMOUNT); + }); + }); + + describe('_send', () => { + beforeEach(() => { + treasury._deposit(makeCoin(COLOR, AMOUNT)); + }); + + it('should send partial amount', () => { + treasury._send(Z_RECIPIENT, COLOR, 400n); + expect(treasury.getTokenBalance(COLOR)).toEqual(AMOUNT - 400n); + }); + + it('should send full balance', () => { + treasury._send(Z_RECIPIENT, COLOR, AMOUNT); + expect(treasury.getTokenBalance(COLOR)).toEqual(0n); + }); + + it('should track sent total', () => { + treasury._send(Z_RECIPIENT, COLOR, 400n); + expect(treasury.getSentTotal(COLOR)).toEqual(400n); + }); + + it('should maintain receivedMinusSent after send', () => { + treasury._send(Z_RECIPIENT, COLOR, 400n); + expect(treasury.getReceivedMinusSent(COLOR)).toEqual(AMOUNT - 400n); + }); + + it('should fail with insufficient balance', () => { + expect(() => { + treasury._send(Z_RECIPIENT, COLOR, AMOUNT + 1n); + }).toThrow('ShieldedTreasury: coin value insufficient'); + }); + + it('should fail for unknown color', () => { + expect(() => { + treasury._send(Z_RECIPIENT, COLOR2, 1n); + }).toThrow('ShieldedTreasury: no balance'); + }); + }); + + describe('accounting consistency', () => { + it('should keep receivedMinusSent equal to balance', () => { + treasury._deposit(makeCoin(COLOR, 500n)); + treasury._send(Z_RECIPIENT, COLOR, 200n); + treasury._deposit(makeCoin(COLOR, 300n, new Uint8Array(32).fill(3))); + + const balance = treasury.getTokenBalance(COLOR); + const rms = treasury.getReceivedMinusSent(COLOR); + expect(balance).toEqual(600n); + expect(rms).toEqual(600n); + }); + + it('should accumulate sent total across sends', () => { + treasury._deposit(makeCoin(COLOR, 1000n)); + treasury._send(Z_RECIPIENT, COLOR, 200n); + treasury._send(Z_RECIPIENT, COLOR, 300n); + expect(treasury.getSentTotal(COLOR)).toEqual(500n); + }); + }); +}); diff --git a/contracts/src/multisig/test/mocks/MockProposalManager.compact b/contracts/src/multisig/test/mocks/MockProposalManager.compact index 351961a9..d1e217d9 100644 --- a/contracts/src/multisig/test/mocks/MockProposalManager.compact +++ b/contracts/src/multisig/test/mocks/MockProposalManager.compact @@ -60,3 +60,11 @@ export circuit getProposalStatus(id: Uint<64>): Proposal_ProposalStatus { return Proposal_getProposalStatus(id); } +export circuit toShieldedRecipient(r: Proposal_Recipient): Either { + return Proposal_toShieldedRecipient(r); +} + +export circuit toUnshieldedRecipient(r: Proposal_Recipient): Either { + return Proposal_toUnshieldedRecipient(r); +} + diff --git a/contracts/src/multisig/test/simulators/ProposalManagerSimulator.ts b/contracts/src/multisig/test/simulators/ProposalManagerSimulator.ts new file mode 100644 index 00000000..d8ca7040 --- /dev/null +++ b/contracts/src/multisig/test/simulators/ProposalManagerSimulator.ts @@ -0,0 +1,129 @@ +import { + type BaseSimulatorOptions, + createSimulator, +} from '@openzeppelin-compact/contracts-simulator'; +import { + ledger, + Contract as MockProposalManager, + pureCircuits, +} from '../../../../artifacts/MockProposalManager/contract/index.js'; +import { + ProposalManagerPrivateState, + ProposalManagerWitnesses, +} from '../../witnesses/ProposalManagerWitnesses.js'; + +type Recipient = { kind: number; address: Uint8Array }; +type Proposal = { + to: Recipient; + color: Uint8Array; + amount: bigint; + status: number; +}; + +type ProposalManagerArgs = readonly []; + +const ProposalManagerSimulatorBase = createSimulator< + ProposalManagerPrivateState, + ReturnType, + ReturnType, + MockProposalManager, + ProposalManagerArgs +>({ + contractFactory: (witnesses) => + new MockProposalManager(witnesses), + defaultPrivateState: () => ProposalManagerPrivateState, + contractArgs: () => [], + ledgerExtractor: (state) => ledger(state), + witnessesFactory: () => ProposalManagerWitnesses(), +}); + +export class ProposalManagerSimulator extends ProposalManagerSimulatorBase { + constructor( + options: BaseSimulatorOptions< + ProposalManagerPrivateState, + ReturnType + > = {}, + ) { + super([], options); + } + + // Pure circuits (recipient helpers) + public shieldedUserRecipient(key: { bytes: Uint8Array }): Recipient { + return pureCircuits.shieldedUserRecipient(key); + } + + public unshieldedUserRecipient(addr: { bytes: Uint8Array }): Recipient { + return pureCircuits.unshieldedUserRecipient(addr); + } + + public contractRecipient(addr: { bytes: Uint8Array }): Recipient { + return pureCircuits.contractRecipient(addr); + } + + public toShieldedRecipient( + r: Recipient, + ): { + is_left: boolean; + left: { bytes: Uint8Array }; + right: { bytes: Uint8Array }; + } { + return pureCircuits.toShieldedRecipient(r); + } + + public toUnshieldedRecipient( + r: Recipient, + ): { + is_left: boolean; + left: { bytes: Uint8Array }; + right: { bytes: Uint8Array }; + } { + return pureCircuits.toUnshieldedRecipient(r); + } + + // Guards + public assertProposalExists(id: bigint) { + return this.circuits.impure.assertProposalExists(id); + } + + public assertProposalActive(id: bigint) { + return this.circuits.impure.assertProposalActive(id); + } + + // Lifecycle + public _createProposal( + to: Recipient, + color: Uint8Array, + amount: bigint, + ): bigint { + return this.circuits.impure._createProposal(to, color, amount); + } + + public _cancelProposal(id: bigint) { + return this.circuits.impure._cancelProposal(id); + } + + public _markExecuted(id: bigint) { + return this.circuits.impure._markExecuted(id); + } + + // View + public getProposal(id: bigint): Proposal { + return this.circuits.impure.getProposal(id); + } + + public getProposalRecipient(id: bigint): Recipient { + return this.circuits.impure.getProposalRecipient(id); + } + + public getProposalAmount(id: bigint): bigint { + return this.circuits.impure.getProposalAmount(id); + } + + public getProposalColor(id: bigint): Uint8Array { + return this.circuits.impure.getProposalColor(id); + } + + public getProposalStatus(id: bigint): number { + return this.circuits.impure.getProposalStatus(id); + } +} diff --git a/contracts/src/multisig/test/simulators/ShieldedMultiSigSimulator.ts b/contracts/src/multisig/test/simulators/ShieldedMultiSigSimulator.ts new file mode 100644 index 00000000..ccdabef5 --- /dev/null +++ b/contracts/src/multisig/test/simulators/ShieldedMultiSigSimulator.ts @@ -0,0 +1,158 @@ +import { + type BaseSimulatorOptions, + createSimulator, +} from '@openzeppelin-compact/contracts-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(), +}); + +export class ShieldedMultiSigSimulator extends ShieldedMultiSigSimulatorBase { + constructor( + signers: EitherPKAddress[], + thresh: bigint, + options: BaseSimulatorOptions< + ShieldedMultiSigPrivateState, + ReturnType + > = {}, + ) { + super([signers, thresh], options); + } + + // Deposit + public deposit(coin: ShieldedCoinInfo) { + return this.circuits.impure.deposit(coin); + } + + // Proposals + public createShieldedProposal( + to: Recipient, + color: Uint8Array, + amount: bigint, + ): bigint { + return this.circuits.impure.createShieldedProposal(to, color, amount); + } + + public approveProposal(id: bigint) { + return this.circuits.impure.approveProposal(id); + } + + public revokeApproval(id: bigint) { + return this.circuits.impure.revokeApproval(id); + } + + public executeShieldedProposal(id: bigint): ShieldedSendResult { + return this.circuits.impure.executeShieldedProposal(id); + } + + // View - Approvals + public isProposalApprovedBySigner( + id: bigint, + signer: EitherPKAddress, + ): boolean { + return this.circuits.impure.isProposalApprovedBySigner(id, signer); + } + + public getApprovalCount(id: bigint): bigint { + return this.circuits.impure.getApprovalCount(id); + } + + // View - Proposals + public getProposal(id: bigint): Proposal { + return this.circuits.impure.getProposal(id); + } + + public getProposalRecipient(id: bigint): Recipient { + return this.circuits.impure.getProposalRecipient(id); + } + + public getProposalAmount(id: bigint): bigint { + return this.circuits.impure.getProposalAmount(id); + } + + public getProposalColor(id: bigint): Uint8Array { + return this.circuits.impure.getProposalColor(id); + } + + public getProposalStatus(id: bigint): number { + return this.circuits.impure.getProposalStatus(id); + } + + // View - Treasury + public getTokenBalance(color: Uint8Array): bigint { + return this.circuits.impure.getTokenBalance(color); + } + + public getReceivedTotal(color: Uint8Array): bigint { + return this.circuits.impure.getReceivedTotal(color); + } + + public getSentTotal(color: Uint8Array): bigint { + return this.circuits.impure.getSentTotal(color); + } + + public getReceivedMinusSent(color: Uint8Array): bigint { + return this.circuits.impure.getReceivedMinusSent(color); + } + + // View - Signers + public getSignerCount(): bigint { + return this.circuits.impure.getSignerCount(); + } + + public getThreshold(): bigint { + return this.circuits.impure.getThreshold(); + } + + public isSigner(account: EitherPKAddress): boolean { + return this.circuits.impure.isSigner(account); + } + + // Ledger access + public getLedger(): Ledger { + return this.getPublicState(); + } +} diff --git a/contracts/src/multisig/test/simulators/ShieldedTreasurySimulator.ts b/contracts/src/multisig/test/simulators/ShieldedTreasurySimulator.ts new file mode 100644 index 00000000..19f622ad --- /dev/null +++ b/contracts/src/multisig/test/simulators/ShieldedTreasurySimulator.ts @@ -0,0 +1,83 @@ +import { + type BaseSimulatorOptions, + createSimulator, +} from '@openzeppelin-compact/contracts-simulator'; +import { + ledger, + Contract as MockShieldedTreasury, + pureCircuits, +} from '../../../../artifacts/MockShieldedTreasury/contract/index.js'; +import { + ShieldedTreasuryPrivateState, + ShieldedTreasuryWitnesses, +} from '../../witnesses/ShieldedTreasuryWitnesses.js'; + +type ShieldedCoinInfo = { nonce: Uint8Array; color: Uint8Array; value: bigint }; +type ShieldedSendResult = { + change: { is_some: boolean; value: ShieldedCoinInfo }; + sent: ShieldedCoinInfo; +}; + +type ShieldedTreasuryArgs = readonly []; + +const ShieldedTreasurySimulatorBase = createSimulator< + ShieldedTreasuryPrivateState, + ReturnType, + ReturnType, + MockShieldedTreasury, + ShieldedTreasuryArgs +>({ + contractFactory: (witnesses) => + new MockShieldedTreasury(witnesses), + defaultPrivateState: () => ShieldedTreasuryPrivateState, + contractArgs: () => [], + ledgerExtractor: (state) => ledger(state), + witnessesFactory: () => ShieldedTreasuryWitnesses(), +}); + +export class ShieldedTreasurySimulator extends ShieldedTreasurySimulatorBase { + constructor( + options: BaseSimulatorOptions< + ShieldedTreasuryPrivateState, + ReturnType + > = {}, + ) { + super([], options); + } + + public static UINT128_MAX(): bigint { + return pureCircuits.UINT128_MAX(); + } + + public _deposit(coin: ShieldedCoinInfo) { + return this.circuits.impure._deposit(coin); + } + + public _send( + recipient: { + is_left: boolean; + left: { bytes: Uint8Array }; + right: { bytes: Uint8Array }; + }, + color: Uint8Array, + amount: bigint, + ): ShieldedSendResult { + return this.circuits.impure._send(recipient, color, amount); + } + + public getTokenBalance(color: Uint8Array): bigint { + return this.circuits.impure.getTokenBalance(color); + } + + public getReceivedTotal(color: Uint8Array): bigint { + return this.circuits.impure.getReceivedTotal(color); + } + + public getSentTotal(color: Uint8Array): bigint { + return this.circuits.impure.getSentTotal(color); + } + + public getReceivedMinusSent(color: Uint8Array): bigint { + return this.circuits.impure.getReceivedMinusSent(color); + } +} diff --git a/contracts/src/multisig/witnesses/ShieldedMultiSigWitnesses.ts b/contracts/src/multisig/witnesses/ShieldedMultiSigWitnesses.ts new file mode 100644 index 00000000..cacf623b --- /dev/null +++ b/contracts/src/multisig/witnesses/ShieldedMultiSigWitnesses.ts @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.0.1-alpha.1 (multisig/witnesses/ShieldedMultiSigWitnesses.ts) + +export type ShieldedMultiSigPrivateState = Record; +export const ShieldedMultiSigPrivateState: ShieldedMultiSigPrivateState = {}; +export const ShieldedMultiSigWitnesses = () => ({}); From 01ed666ac0df9b148e3b24b3a2fc7cb4fd49cb9e Mon Sep 17 00:00:00 2001 From: andrew Date: Tue, 17 Mar 2026 10:51:50 -0300 Subject: [PATCH 26/33] fix fmt --- contracts/src/multisig/test/ProposalManager.test.ts | 12 +++--------- .../test/simulators/ProposalManagerSimulator.ts | 8 ++------ 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/contracts/src/multisig/test/ProposalManager.test.ts b/contracts/src/multisig/test/ProposalManager.test.ts index 6a140ee0..42b35da8 100644 --- a/contracts/src/multisig/test/ProposalManager.test.ts +++ b/contracts/src/multisig/test/ProposalManager.test.ts @@ -186,9 +186,7 @@ describe('ProposalManager', () => { const id = contract._createProposal(recipient, COLOR, AMOUNT); contract._cancelProposal(id); - expect(contract.getProposalStatus(id)).toEqual( - ProposalStatus.Cancelled, - ); + expect(contract.getProposalStatus(id)).toEqual(ProposalStatus.Cancelled); }); it('should preserve proposal data after cancellation', () => { @@ -328,9 +326,7 @@ describe('ProposalManager', () => { expect(contract.getProposalStatus(id)).toEqual(ProposalStatus.Active); contract._cancelProposal(id); - expect(contract.getProposalStatus(id)).toEqual( - ProposalStatus.Cancelled, - ); + expect(contract.getProposalStatus(id)).toEqual(ProposalStatus.Cancelled); }); it('should handle create -> execute flow', () => { @@ -349,9 +345,7 @@ describe('ProposalManager', () => { contract._cancelProposal(id1); - expect(contract.getProposalStatus(id1)).toEqual( - ProposalStatus.Cancelled, - ); + expect(contract.getProposalStatus(id1)).toEqual(ProposalStatus.Cancelled); expect(contract.getProposalStatus(id2)).toEqual(ProposalStatus.Active); contract._markExecuted(id2); diff --git a/contracts/src/multisig/test/simulators/ProposalManagerSimulator.ts b/contracts/src/multisig/test/simulators/ProposalManagerSimulator.ts index d8ca7040..d8d3c937 100644 --- a/contracts/src/multisig/test/simulators/ProposalManagerSimulator.ts +++ b/contracts/src/multisig/test/simulators/ProposalManagerSimulator.ts @@ -60,9 +60,7 @@ export class ProposalManagerSimulator extends ProposalManagerSimulatorBase { return pureCircuits.contractRecipient(addr); } - public toShieldedRecipient( - r: Recipient, - ): { + public toShieldedRecipient(r: Recipient): { is_left: boolean; left: { bytes: Uint8Array }; right: { bytes: Uint8Array }; @@ -70,9 +68,7 @@ export class ProposalManagerSimulator extends ProposalManagerSimulatorBase { return pureCircuits.toShieldedRecipient(r); } - public toUnshieldedRecipient( - r: Recipient, - ): { + public toUnshieldedRecipient(r: Recipient): { is_left: boolean; left: { bytes: Uint8Array }; right: { bytes: Uint8Array }; From 0d64f87861d7b6d41be945be7d83faf1019aedaa Mon Sep 17 00:00:00 2001 From: andrew Date: Tue, 24 Mar 2026 01:29:19 -0300 Subject: [PATCH 27/33] add stateless treasury and multisig v2 --- .../ShieldedTreasuryStateless.compact | 86 ++++++ .../presets/ShieldedMultiSigV2.compact | 270 ++++++++++++++++++ 2 files changed, 356 insertions(+) create mode 100644 contracts/src/multisig/ShieldedTreasuryStateless.compact create mode 100644 contracts/src/multisig/presets/ShieldedMultiSigV2.compact diff --git a/contracts/src/multisig/ShieldedTreasuryStateless.compact b/contracts/src/multisig/ShieldedTreasuryStateless.compact new file mode 100644 index 00000000..fef57fea --- /dev/null +++ b/contracts/src/multisig/ShieldedTreasuryStateless.compact @@ -0,0 +1,86 @@ +pragma language_version >= 0.21.0; + +/** + * @module ShieldedTreasury + * @description Manages shielded (private) token deposits, accounting, + * and transfers for multisig governance contracts. + * + * Coins are stored on the contract ledger in a map keyed by token color, + * with one UTXO per color. Deposits are merged with existing coins of + * the same color via `mergeCoinImmediate`. This simplifies coin selection + * at spend time — the executor doesn't need to choose between multiple + * UTXOs of the same color. + * + * Cumulative received and sent totals are tracked per color for audit + * purposes. The canonical balance query is `getTokenBalance`, which + * reads the actual coin value from the UTXO map. + * + * Underscore-prefixed circuits (_deposit, _send) have no access control + * enforcement. The consuming contract must gate these behind its own + * authorization policy. + */ +module ShieldedTreasury2 { + import CompactStandardLibrary; + + // ─── Deposit ──────────────────────────────────────────────────── + + /** + * @description Receives a shielded coin into the treasury. + */ + export circuit _deposit(coin: ShieldedCoinInfo): [] { + receiveShielded(disclose(coin)); + } + + // ─── Send ─────────────────────────────────────────────────────── + + /** + * @description Sends shielded tokens from the treasury. + * + * Looks up the stored coin by color, verifies sufficient value, + * and executes the shielded send. If the send produces change, + * it is sent back to the contract via `sendImmediateShielded`. + * + * @notice Access control is NOT enforced here. + * The consuming contract must gate this behind its own + * authorization policy. + * + * Requirements: + * + * - A coin of the given `color` must exist in the treasury. + * - The coin's value must be >= `amount`. + * - Send must not cause sent total overflow. + * + * @param {Either} recipient - The recipient. + * @param {Bytes<32>} color - The token color. + * @param {Uint<128>} amount - The amount to send. + * @returns {ShieldedSendResult} The result containing sent coin and any change. + */ + export circuit _send( + coin: QualifiedShieldedCoinInfo, + recipient: Either, + amount: Uint<128> + ): ShieldedSendResult { + const result = sendShielded(disclose(coin), disclose(recipient), disclose(amount)); + + if (disclose(result.change.is_some)) { + sendImmediateShielded( + disclose(result.change.value), + selfAsRecipient(), + disclose(result.change.value.value) + ); + } + + return result; + } + + /** + * @description Returns the current contract's address as an + * `Either` for use as a + * recipient in shielded send operations (deposits and receiving change). + * + * @returns {Either} The contract's address as a recipient. + */ + circuit selfAsRecipient(): Either { + return right(kernel.self()); + } +} diff --git a/contracts/src/multisig/presets/ShieldedMultiSigV2.compact b/contracts/src/multisig/presets/ShieldedMultiSigV2.compact new file mode 100644 index 00000000..28a7985e --- /dev/null +++ b/contracts/src/multisig/presets/ShieldedMultiSigV2.compact @@ -0,0 +1,270 @@ +pragma language_version >= 0.21.0; + +/** + * @title ShieldedMultisig + * @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. + */ +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 _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 <= 3. + * - `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>, +) { + _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:siger:") + }); +} + +/** + * @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); +} From 5aefaafd29ad222c06fa771335eb016616709772 Mon Sep 17 00:00:00 2001 From: andrew Date: Tue, 24 Mar 2026 01:34:27 -0300 Subject: [PATCH 28/33] fix mod name, add stateless mocj --- .../multisig/ShieldedTreasuryStateless.compact | 2 +- .../mocks/MockShieldedTreasuryStateless.compact | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 contracts/src/multisig/test/mocks/MockShieldedTreasuryStateless.compact diff --git a/contracts/src/multisig/ShieldedTreasuryStateless.compact b/contracts/src/multisig/ShieldedTreasuryStateless.compact index fef57fea..bdc7e16b 100644 --- a/contracts/src/multisig/ShieldedTreasuryStateless.compact +++ b/contracts/src/multisig/ShieldedTreasuryStateless.compact @@ -19,7 +19,7 @@ pragma language_version >= 0.21.0; * enforcement. The consuming contract must gate these behind its own * authorization policy. */ -module ShieldedTreasury2 { +module ShieldedTreasuryStateless { import CompactStandardLibrary; // ─── Deposit ──────────────────────────────────────────────────── diff --git a/contracts/src/multisig/test/mocks/MockShieldedTreasuryStateless.compact b/contracts/src/multisig/test/mocks/MockShieldedTreasuryStateless.compact new file mode 100644 index 00000000..5d39fad5 --- /dev/null +++ b/contracts/src/multisig/test/mocks/MockShieldedTreasuryStateless.compact @@ -0,0 +1,17 @@ +pragma language_version >= 0.21.0; + +import CompactStandardLibrary; +import "../../ShieldedTreasuryStateless" prefix Treasury_; + +export circuit _deposit(coin: ShieldedCoinInfo): [] { + Treasury__deposit(coin); +} + +export circuit _send( + coin: QualifiedShieldedCoinInfo, + recipient: Either, + amount: Uint<128> +): ShieldedSendResult { + return Treasury__send(coin, recipient, amount); +} + From 71b8a6ee96c7b0edbfe045257fc90667589bf547 Mon Sep 17 00:00:00 2001 From: andrew Date: Tue, 24 Mar 2026 01:35:03 -0300 Subject: [PATCH 29/33] export circuit --- contracts/src/multisig/ShieldedTreasuryStateless.compact | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/src/multisig/ShieldedTreasuryStateless.compact b/contracts/src/multisig/ShieldedTreasuryStateless.compact index bdc7e16b..4f36940d 100644 --- a/contracts/src/multisig/ShieldedTreasuryStateless.compact +++ b/contracts/src/multisig/ShieldedTreasuryStateless.compact @@ -80,7 +80,7 @@ module ShieldedTreasuryStateless { * * @returns {Either} The contract's address as a recipient. */ - circuit selfAsRecipient(): Either { + export circuit selfAsRecipient(): Either { return right(kernel.self()); } } From d1552a55af40322f0bc0436909353d811df547b2 Mon Sep 17 00:00:00 2001 From: andrew Date: Tue, 24 Mar 2026 01:35:21 -0300 Subject: [PATCH 30/33] remove export --- contracts/src/multisig/ShieldedTreasuryStateless.compact | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/src/multisig/ShieldedTreasuryStateless.compact b/contracts/src/multisig/ShieldedTreasuryStateless.compact index 4f36940d..bdc7e16b 100644 --- a/contracts/src/multisig/ShieldedTreasuryStateless.compact +++ b/contracts/src/multisig/ShieldedTreasuryStateless.compact @@ -80,7 +80,7 @@ module ShieldedTreasuryStateless { * * @returns {Either} The contract's address as a recipient. */ - export circuit selfAsRecipient(): Either { + circuit selfAsRecipient(): Either { return right(kernel.self()); } } From 75ec231657e19492ebe731276017c1de257cad63 Mon Sep 17 00:00:00 2001 From: andrew Date: Tue, 24 Mar 2026 01:53:23 -0300 Subject: [PATCH 31/33] clean up comment --- contracts/src/multisig/ShieldedTreasuryStateless.compact | 4 ---- 1 file changed, 4 deletions(-) diff --git a/contracts/src/multisig/ShieldedTreasuryStateless.compact b/contracts/src/multisig/ShieldedTreasuryStateless.compact index bdc7e16b..2bc1d1d7 100644 --- a/contracts/src/multisig/ShieldedTreasuryStateless.compact +++ b/contracts/src/multisig/ShieldedTreasuryStateless.compact @@ -14,10 +14,6 @@ pragma language_version >= 0.21.0; * Cumulative received and sent totals are tracked per color for audit * purposes. The canonical balance query is `getTokenBalance`, which * reads the actual coin value from the UTXO map. - * - * Underscore-prefixed circuits (_deposit, _send) have no access control - * enforcement. The consuming contract must gate these behind its own - * authorization policy. */ module ShieldedTreasuryStateless { import CompactStandardLibrary; From 0938974925722c493b84a25ac1d922cb6dbbfbde Mon Sep 17 00:00:00 2001 From: andrew Date: Tue, 24 Mar 2026 02:13:53 -0300 Subject: [PATCH 32/33] fix domain --- contracts/src/multisig/presets/ShieldedMultiSigV2.compact | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/src/multisig/presets/ShieldedMultiSigV2.compact b/contracts/src/multisig/presets/ShieldedMultiSigV2.compact index 28a7985e..3a04086b 100644 --- a/contracts/src/multisig/presets/ShieldedMultiSigV2.compact +++ b/contracts/src/multisig/presets/ShieldedMultiSigV2.compact @@ -235,7 +235,7 @@ export pure circuit _calculateSignerId( return persistentHash(SignerCommitmentInput { pk: pk, salt: salt, - domain: pad(32, "MultiSig:siger:") + domain: pad(32, "MultiSig:signer:") }); } From eafb62a1f474e591bb592dc9d5d164645addef2a Mon Sep 17 00:00:00 2001 From: andrew Date: Tue, 24 Mar 2026 02:14:25 -0300 Subject: [PATCH 33/33] fix title --- contracts/src/multisig/presets/ShieldedMultiSigV2.compact | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/src/multisig/presets/ShieldedMultiSigV2.compact b/contracts/src/multisig/presets/ShieldedMultiSigV2.compact index 3a04086b..8d442d0d 100644 --- a/contracts/src/multisig/presets/ShieldedMultiSigV2.compact +++ b/contracts/src/multisig/presets/ShieldedMultiSigV2.compact @@ -1,7 +1,7 @@ pragma language_version >= 0.21.0; /** - * @title ShieldedMultisig + * @title ShieldedMultisigV2 * @description Privacy-preserving 2-of-3 multisig contract. * * Signer identities are stored as commitments: hashes of ECDSA public