diff --git a/contracts/package.json b/contracts/package.json index 1a99509a..f1f9553b 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", diff --git a/contracts/src/multisig/ProposalManager.compact b/contracts/src/multisig/ProposalManager.compact new file mode 100644 index 00000000..871cf877 --- /dev/null +++ b/contracts/src/multisig/ProposalManager.compact @@ -0,0 +1,310 @@ +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 }; + } + + /** + * @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 ───────────────────────────────────────────────────── + + /** + * @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/ShieldedTreasuryStateless.compact b/contracts/src/multisig/ShieldedTreasuryStateless.compact new file mode 100644 index 00000000..2bc1d1d7 --- /dev/null +++ b/contracts/src/multisig/ShieldedTreasuryStateless.compact @@ -0,0 +1,82 @@ +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. + */ +module ShieldedTreasuryStateless { + 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/SignerManager.compact b/contracts/src/multisig/SignerManager.compact new file mode 100644 index 00000000..5b80eb75 --- /dev/null +++ b/contracts/src/multisig/SignerManager.compact @@ -0,0 +1,195 @@ +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); + } + + assert(_signerCount >= thresh, "SignerManager: threshold exceeds signer count"); + } + + // ─── 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/presets/ShieldedMultiSig.compact b/contracts/src/multisig/presets/ShieldedMultiSig.compact new file mode 100644 index 00000000..59d54e5b --- /dev/null +++ b/contracts/src/multisig/presets/ShieldedMultiSig.compact @@ -0,0 +1,192 @@ +pragma language_version >= 0.21.0; + +import CompactStandardLibrary; + +import "../ProposalManager" prefix Proposal_; +import "../ShieldedTreasury" prefix Treasury_; +import "../SignerManager"> prefix Signer_; + +// ─── State ─────────────────────────────────────────────────────────────── + +export ledger _proposalApprovals: Map, Map, Boolean>>; +export ledger _approvalCount: Map, Uint<8>>; + +// ─── Constructor ───────────────────────────────────────────────────────── + +constructor( + signers: Vector<3, Either>, + thresh: Uint<8> +) { + Signer_initialize<3>(signers, thresh); +} + +// ─── Deposit ───────────────────────────────────────────────────────────── + +export circuit deposit(coin: ShieldedCoinInfo): [] { + Treasury__deposit(coin); +} + +// ─── Proposals ─────────────────────────────────────────────────────────── + +export circuit createShieldedProposal( + to: Proposal_Recipient, + color: Bytes<32>, + amount: Uint<128> +): Uint<64> { + const callerPK = getCaller(); + Signer_assertSigner(callerPK); + + return Proposal__createProposal(to, color, amount); +} + +export circuit approveProposal(id: Uint<64>): [] { + // Check if active + Proposal_assertProposalActive(id); + + // Check signer + const callerPK = getCaller(); + Signer_assertSigner(callerPK); + + // Check if already approved + assert(!isProposalApprovedBySigner(id, callerPK), "Multisig: already approved"); + + // Approve + _approveProposal(id, callerPK); +} + +export circuit revokeApproval(id: Uint<64>): [] { + // Check if active + Proposal_assertProposalActive(id); + + // Check signer + const callerPK = getCaller(); + Signer_assertSigner(callerPK); + + // Check has approved + assert(isProposalApprovedBySigner(id, callerPK), "Multisig: not approved"); + + // Revoke + _revokeApproval(id, callerPK); +} + +export circuit executeShieldedProposal( + id: Uint<64>, +): ShieldedSendResult { + // 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 _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>; + _approvalCount.insert(disclose(id), disclose(newCount)); +} + +circuit _revokeApproval(id: Uint<64>, 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()); +} + +// ─── View ─────────────────────────────────────────────────────────────── + +export circuit isProposalApprovedBySigner( + id: Uint<64>, + signer: Either +): Boolean { + if (!_proposalApprovals.member(disclose(id)) || !_proposalApprovals.lookup(disclose(id)).member(disclose(signer))) { + 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)); +} + +// 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); +} diff --git a/contracts/src/multisig/presets/ShieldedMultiSigV2.compact b/contracts/src/multisig/presets/ShieldedMultiSigV2.compact new file mode 100644 index 00000000..8d442d0d --- /dev/null +++ b/contracts/src/multisig/presets/ShieldedMultiSigV2.compact @@ -0,0 +1,270 @@ +pragma language_version >= 0.21.0; + +/** + * @title ShieldedMultisigV2 + * @description Privacy-preserving 2-of-3 multisig contract. + * + * Signer identities are stored as commitments: hashes of ECDSA public + * keys combined with an instance salt and domain separator. Signature + * verification happens in a single transaction with no multi-step + * proposal lifecycle. The contract enforces threshold authorization + * and replay protection. All other coordination (signature collection, + * coin selection) happens off-chain. + * + * Treasury is fully stateless meaning coin data is not stored on the public ledger. + * Deposits call receiveShielded only. The operator discovers coin indices + * through ZswapOutput events from the indexer, constructs QualifiedShieldedCoinInfo + * off-chain, and provides it as a circuit parameter for spending. + */ + +import CompactStandardLibrary; + +import "../ProposalManager" prefix Proposal_; +import "../ShieldedTreasuryStateless" prefix Treasury_; +import "../SignerManager"> prefix Signer_; + +// ─── Types ────────────────────────────────────────────────────── + +/** + * @description Accumulator for fold-based signature verification. + * Threads the valid count, previous commitment (for duplicate + * detection), and message hash through each iteration. + */ +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:signer:") + }); +} + +/** + * @description Stub for ECDSA signature verification. + * Always returns true. MUST be replaced before any non-test deployment. + */ +circuit stubVerifySignature( + pubkey: Bytes<64>, + msgHash: Bytes<32>, + signature: Bytes<64> +): Boolean { + return true; +} + +// ─── View ─────────────────────────────────────────────────────── + +export circuit getNonce(): Uint<64> { + return _nonce; +} + +export circuit getSignerCount(): Uint<8> { + return Signer_getSignerCount(); +} + +export circuit getThreshold(): Uint<8> { + return Signer_getThreshold(); +} + +export circuit isSigner(commitment: Bytes<32>): Boolean { + return Signer_isSigner(commitment); +} diff --git a/contracts/src/multisig/test/ProposalManager.test.ts b/contracts/src/multisig/test/ProposalManager.test.ts new file mode 100644 index 00000000..42b35da8 --- /dev/null +++ b/contracts/src/multisig/test/ProposalManager.test.ts @@ -0,0 +1,355 @@ +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/SignerManager.test.ts b/contracts/src/multisig/test/SignerManager.test.ts new file mode 100644 index 00000000..0cfd9d25 --- /dev/null +++ b/contracts/src/multisig/test/SignerManager.test.ts @@ -0,0 +1,198 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import * as utils from '#test-utils/address.js'; +import { SignerManagerSimulator } from './simulators/SignerManagerSimulator.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(); + }); + }); +}); diff --git a/contracts/src/multisig/test/mocks/MockProposalManager.compact b/contracts/src/multisig/test/mocks/MockProposalManager.compact new file mode 100644 index 00000000..d1e217d9 --- /dev/null +++ b/contracts/src/multisig/test/mocks/MockProposalManager.compact @@ -0,0 +1,70 @@ +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); +} + +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/mocks/MockShieldedTreasury.compact b/contracts/src/multisig/test/mocks/MockShieldedTreasury.compact new file mode 100644 index 00000000..7329442c --- /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, + color: Bytes<32>, + amount: Uint<128>, + ): 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/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); +} + diff --git a/contracts/src/multisig/test/mocks/MockSignerManager.compact b/contracts/src/multisig/test/mocks/MockSignerManager.compact new file mode 100644 index 00000000..021ee08c --- /dev/null +++ b/contracts/src/multisig/test/mocks/MockSignerManager.compact @@ -0,0 +1,43 @@ +pragma language_version >= 0.21.0; + +import CompactStandardLibrary; + +import "../../SignerManager"> prefix Signer_; + +export { ZswapCoinPublicKey, ContractAddress, Either, Maybe }; + +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); +} diff --git a/contracts/src/multisig/test/simulators/ProposalManagerSimulator.ts b/contracts/src/multisig/test/simulators/ProposalManagerSimulator.ts new file mode 100644 index 00000000..d8d3c937 --- /dev/null +++ b/contracts/src/multisig/test/simulators/ProposalManagerSimulator.ts @@ -0,0 +1,125 @@ +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/test/simulators/SignerManagerSimulator.ts b/contracts/src/multisig/test/simulators/SignerManagerSimulator.ts new file mode 100644 index 00000000..042d5aa0 --- /dev/null +++ b/contracts/src/multisig/test/simulators/SignerManagerSimulator.ts @@ -0,0 +1,88 @@ +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/SignerManagerWitnesses.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); + } +} diff --git a/contracts/src/multisig/witnesses/ProposalManagerWitnesses.ts b/contracts/src/multisig/witnesses/ProposalManagerWitnesses.ts new file mode 100644 index 00000000..f44381ed --- /dev/null +++ b/contracts/src/multisig/witnesses/ProposalManagerWitnesses.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/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 = () => ({}); diff --git a/contracts/src/multisig/witnesses/ShieldedTreasuryWitnesses.ts b/contracts/src/multisig/witnesses/ShieldedTreasuryWitnesses.ts new file mode 100644 index 00000000..a29b1014 --- /dev/null +++ b/contracts/src/multisig/witnesses/ShieldedTreasuryWitnesses.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/SignerManagerWitnesses.ts b/contracts/src/multisig/witnesses/SignerManagerWitnesses.ts new file mode 100644 index 00000000..6dcd48f8 --- /dev/null +++ b/contracts/src/multisig/witnesses/SignerManagerWitnesses.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/UnshieldedTreasuryWitnesses.ts b/contracts/src/multisig/witnesses/UnshieldedTreasuryWitnesses.ts new file mode 100644 index 00000000..dd08724e --- /dev/null +++ b/contracts/src/multisig/witnesses/UnshieldedTreasuryWitnesses.ts @@ -0,0 +1,7 @@ +// 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 = () => ({}); 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"],